Compare commits
93 Commits
80ff216e54
...
preset
| Author | SHA1 | Date | |
|---|---|---|---|
| ff92451a76 | |||
| 60485bc06a | |||
| f6f299c3e5 | |||
| 66485f5c59 | |||
| 5f9ff9bcc9 | |||
| 35730b36f0 | |||
| d516833cc3 | |||
| 220be64dec | |||
| b433477c64 | |||
| 43b7047c57 | |||
| 167417d1ec | |||
| fb8141b320 | |||
| 96712dda88 | |||
| f5a7b42e7c | |||
| 1b1e9d727e | |||
| 668d29b786 | |||
| e5f42e099e | |||
| a9edda38ef | |||
| edec5ff460 | |||
|
|
264eb7296f | ||
|
|
fbd4295302 | ||
|
|
7bdb324ebc | ||
|
|
28b19b5219 | ||
|
|
75ddd559c9 | ||
|
|
5a1067263a | ||
|
|
e67de6215a | ||
|
|
7179b6531e | ||
|
|
fd618d7714 | ||
|
|
d1ffb857c8 | ||
|
|
f8eba0ee7e | ||
|
|
e6b5bf2cf1 | ||
|
|
fbae75b957 | ||
|
|
93476655fc | ||
|
|
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 |
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`.
|
||||
45
.cursor/rules/led-driver.mdc
Normal file
45
.cursor/rules/led-driver.mdc
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: led-driver — MicroPython ESP32: mpremote, imports, layout, I/O, no pycache in src
|
||||
globs: led-driver/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# led-driver (MicroPython / ESP32)
|
||||
|
||||
## Device and tests
|
||||
|
||||
1. Validate **MicroPython behaviour** under **`led-driver/`** with **`mpremote connect <PORT> …`** on the chip. Host **`python3`** does **not** prove the firmware build.
|
||||
|
||||
2. **Execution target is fixed:** treat **`led-driver/`** code as firmware that runs **only on MicroPython ESP32 devices**. Do **not** run `led-driver/src/main.py` (or other firmware modules) with host CPython as a normal execution path.
|
||||
|
||||
3. **Flow:** `mpremote connect <PORT> cp <local> :<on-flash>` then `run <script>.py`. Inline commands only — no **`.sh`** wrappers unless the user asks. Default serial placeholder: **`/dev/ttyACM0`**.
|
||||
|
||||
4. Checks that **import and run** code from **`led-driver/src/`** belong in **`led-driver/tests/`** and run with **`mpremote run …`**. **Do not** add **`pytest`** under **`led-controller/tests/`** that **`sys.path`**-loads **`led-driver/src`** and runs those modules on CPython.
|
||||
|
||||
## Import layout
|
||||
|
||||
4. **No** **`sys.path.insert`**, **`__file__`** path stitching, or other import-path hacks under **`led-driver/`**. Use device flash search path, or host **`PYTHONPATH`** / layout you control.
|
||||
|
||||
5. **No** “import fixer” code — fix copy order, flash paths, or env instead.
|
||||
|
||||
## Imports (fail loudly)
|
||||
|
||||
6. If a dependency does not load, **crash** and fix deployment or filesystem. **Do not** catch **`ImportError`** / **`ModuleNotFoundError`** around **`import`** / **`from … import`** for app/firmware modules (`settings`, `utils`, `network`, `machine`, …).
|
||||
|
||||
7. **Allowed — stdlib name pairs only** (MicroPython vs CPython): one **`except ImportError`**, then **one** fallback import, **no** extra logic in **`except`**:
|
||||
- `uos` → `os`
|
||||
- `ubinascii` → `binascii`
|
||||
- `utime` → `time`
|
||||
Not for “maybe the file exists on flash” — only different **stdlib** names.
|
||||
|
||||
8. **No** large inline reimplementations after **`except ImportError`** — deploy the real module.
|
||||
|
||||
## I/O
|
||||
|
||||
9. Non-blocking **recv** / **accept**: use plain **`except OSError:`** (or **break** on empty). **No** errno / EAGAIN / EWOULDBLOCK tables or **`getattr(errno, …)`** unless fixing a **documented** target bug.
|
||||
|
||||
10. Minimal **`try` / `except OSError`** around optional socket options (e.g. **`SO_REUSEADDR`**) is fine.
|
||||
|
||||
## Host Python and `src/`
|
||||
|
||||
11. **Do not** leave **`__pycache__/`** or **`.pyc`** under **`led-driver/src/`** from host runs. Remove if created; **`.gitignore`** already ignores it. Prefer **`PYTHONDONTWRITEBYTECODE=1`** or **`-B`** when host Python must touch **`led-driver/src/`**.
|
||||
18
.cursor/rules/scoped-fixes.mdc
Normal file
18
.cursor/rules/scoped-fixes.mdc
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
description: Fix only the issue or task the user gave; no refactors unless requested
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Scoped fixes (no overscoping)
|
||||
|
||||
1. **Change only what is needed** to satisfy the user’s *current* request (bug, error, feature, or explicit follow-up). Prefer the smallest diff that fixes it.
|
||||
|
||||
2. **Refactors:** Do **not** refactor (restructure, rename, extract functions, change abstractions, or “make it nicer”) **unless the user explicitly asked for a refactor**. A bug fix may touch nearby lines only as much as required to correct the bug.
|
||||
|
||||
3. **Do not** rename, reformat, or “clean up” unrelated code; do not add extra error handling, logging, or features you were not asked for.
|
||||
|
||||
4. **Related issues:** If you spot other problems (missing functions, wrong types elsewhere, style), you may **mention them in prose** — do **not** fix them unless the user explicitly asks.
|
||||
|
||||
5. **Tests and docs:** Add or change tests or documentation **only** when the user asked for them or they are strictly required to verify the requested fix.
|
||||
|
||||
6. **Multiple distinct fixes:** If the user reported one error (e.g. a single `TypeError`), fix **that** cause first. Offer to tackle follow-ups separately rather than bundling.
|
||||
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.
|
||||
16
.cursor/rules/strict-user-scope.mdc
Normal file
16
.cursor/rules/strict-user-scope.mdc
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: enforce strict user-scoped changes only
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Strict User Scope
|
||||
|
||||
1. Only implement exactly what the user asked for in the current message.
|
||||
|
||||
2. Do not add extra refactors, cleanups, renames, architecture changes, or behavioural changes unless the user explicitly asked for them.
|
||||
|
||||
3. If a potential improvement is noticed, mention it briefly and ask before changing code.
|
||||
|
||||
4. For revert/undo requests, perform the narrowest possible revert and do not modify anything else.
|
||||
|
||||
5. Keep edits minimal and local to the requested area.
|
||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
# led-driver/src is MicroPython source — never keep host __pycache__ there (see .cursor/rules/led-driver.mdc)
|
||||
led-driver/src/__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
|
||||
.pytest_cache/
|
||||
.ropeproject/
|
||||
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
|
||||
10
Pipfile
10
Pipfile
@@ -9,8 +9,14 @@ pyserial = "*"
|
||||
esptool = "*"
|
||||
pyjwt = "*"
|
||||
watchfiles = "*"
|
||||
requests = "*"
|
||||
selenium = "*"
|
||||
adafruit-ampy = "*"
|
||||
microdot = "*"
|
||||
websockets = "*"
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.12"
|
||||
@@ -18,3 +24,7 @@ python_version = "3.12"
|
||||
[scripts]
|
||||
web = "python /home/pi/led-controller/tests/web.py"
|
||||
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"
|
||||
|
||||
818
Pipfile.lock
generated
818
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "24a0e63d49a769fb2bbc35d7d361aeb0c8563f2d65cbeb24acfae9e183d1c0ca"
|
||||
"sha256": "18691f772c7660e4a087c90560c87a9217a09e9b6db97825d21c092a06d64b89"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -16,130 +16,154 @@
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"adafruit-ampy": {
|
||||
"hashes": [
|
||||
"sha256:4a74812226e53c17d01eb828633424bc4f4fe76b9499a7b35eba6fc2532635b7",
|
||||
"sha256:f4cba36f564096f2aafd173f7fbabb845365cc3bb3f41c37541edf98b58d3976"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"anyio": {
|
||||
"hashes": [
|
||||
"sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703",
|
||||
"sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"
|
||||
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
|
||||
"sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.13.0"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
|
||||
"sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==4.12.1"
|
||||
"version": "==26.1.0"
|
||||
},
|
||||
"bitarray": {
|
||||
"hashes": [
|
||||
"sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199",
|
||||
"sha256:014df8a9430276862392ac5d471697de042367996c49f32d0008585d2c60755a",
|
||||
"sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e",
|
||||
"sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3",
|
||||
"sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e",
|
||||
"sha256:0df69d26f21a9d2f1b20266f6737fa43f08aa5015c99900fb69f255fbe4dabb4",
|
||||
"sha256:0f8069a807a3e6e3c361ce302ece4bf1c3b49962c1726d1d56587e8f48682861",
|
||||
"sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5",
|
||||
"sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521",
|
||||
"sha256:165052a0e61c880f7093808a0c524ce1b3555bfa114c0dfb5c809cd07918a60d",
|
||||
"sha256:178c5a4c7fdfb5cd79e372ae7f675390e670f3732e5bc68d327e01a5b3ff8d55",
|
||||
"sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9",
|
||||
"sha256:1a54d7e7999735faacdcbe8128e30207abc2caf9f9fd7102d180b32f1b78bfce",
|
||||
"sha256:1a926fa554870642607fd10e66ee25b75fdd9a7ca4bbffa93d424e4ae2bf734a",
|
||||
"sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9",
|
||||
"sha256:21ca6a47bf20db9e7ad74ca04b3d479e4d76109b68333eb23535553d2705339e",
|
||||
"sha256:22c540ed20167d3dbb1e2d868ca935180247d620c40eace90efa774504a40e3b",
|
||||
"sha256:239578587b9c29469ab61149dda40a2fe714a6a4eca0f8ff9ea9439ec4b7bc30",
|
||||
"sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6",
|
||||
"sha256:26714898eb0d847aac8af94c4441c9cb50387847d0fe6b9fc4217c086cd68b80",
|
||||
"sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11",
|
||||
"sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f",
|
||||
"sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25",
|
||||
"sha256:2fa23fdb3beab313950bbb49674e8a161e61449332d3997089fe3944953f1b77",
|
||||
"sha256:2fe8c54b15a9cd4f93bc2aaceab354ec65af93370aa1496ba2f9c537a4855ee0",
|
||||
"sha256:30989a2451b693c3f9359d91098a744992b5431a0be4858f1fdf0ec76b457125",
|
||||
"sha256:31a4ad2b730128e273f1c22300da3e3631f125703e4fee0ac44d385abfb15671",
|
||||
"sha256:337c8cd46a4c6568d367ed676cbf2d7de16f890bb31dbb54c44c1d6bb6d4a1de",
|
||||
"sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860",
|
||||
"sha256:3ea52df96566457735314794422274bd1962066bfb609e7eea9113d70cf04ffe",
|
||||
"sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d",
|
||||
"sha256:41b53711f89008ba2de62e4c2d2260a8b357072fd4f18e1351b28955db2719dc",
|
||||
"sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df",
|
||||
"sha256:46cf239856b87fe1c86dfbb3d459d840a8b1649e7922b1e0bfb6b6464692644a",
|
||||
"sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8",
|
||||
"sha256:4902f4ecd5fcb6a5f482d7b0ae1c16c21f26fc5279b3b6127363d13ad8e7a9d9",
|
||||
"sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe",
|
||||
"sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607",
|
||||
"sha256:4f298daaaea58d45e245a132d6d2bdfb6f856da50dc03d75ebb761439fb626cf",
|
||||
"sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee",
|
||||
"sha256:5338a313f998e1be7267191b7caaae82563b4a2b42b393561055412a34042caa",
|
||||
"sha256:5591daf81313096909d973fb2612fccd87528fdfdd39f6478bdce54543178954",
|
||||
"sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a",
|
||||
"sha256:58a01ea34057463f7a98a4d6ff40160f65f945e924fec08a5b39e327e372875d",
|
||||
"sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428",
|
||||
"sha256:5c5a8a83df95e51f7a7c2b083eaea134cbed39fc42c6aeb2e764ddb7ccccd43e",
|
||||
"sha256:5f2fb10518f6b365f5b720e43a529c3b2324ca02932f609631a44edb347d8d54",
|
||||
"sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5",
|
||||
"sha256:6d70fa9c6d2e955bde8cd327ffc11f2cc34bc21944e5571a46ca501e7eadef24",
|
||||
"sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f",
|
||||
"sha256:720963fee259291a88348ae9735d9deb5d334e84a016244f61c89f5a49aa400a",
|
||||
"sha256:75a3b6e9c695a6570ea488db75b84bb592ff70a944957efa1c655867c575018b",
|
||||
"sha256:792462abfeeca6cc8c6c1e6d27e14319682f0182f6b0ba37befe911af794db70",
|
||||
"sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7",
|
||||
"sha256:7f14d6b303e55bd7d19b28309ef8014370e84a3806c5e452e078e7df7344d97a",
|
||||
"sha256:7f65bd5d4cdb396295b6aa07f84ca659ac65c5c68b53956a6d95219e304b0ada",
|
||||
"sha256:81c6b4a6c1af800d52a6fa32389ef8f4281583f4f99dc1a40f2bb47667281541",
|
||||
"sha256:82a07de83dce09b4fa1bccbdc8bde8f188b131666af0dc9048ba0a0e448d8a3b",
|
||||
"sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4",
|
||||
"sha256:84b52b2cf77bb7f703d16c4007b021078dbbe6cf8ffb57abe81a7bacfc175ef2",
|
||||
"sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd",
|
||||
"sha256:8a9c962c64a4c08def58b9799333e33af94ec53038cf151d36edacdb41f81646",
|
||||
"sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89",
|
||||
"sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa",
|
||||
"sha256:94652da1a4ca7cfb69c15dd6986b205e0bd9c63a05029c3b48b4201085f527bd",
|
||||
"sha256:969fd67de8c42affdb47b38b80f1eaa79ac0ef17d65407cdd931db1675315af1",
|
||||
"sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb",
|
||||
"sha256:99d25aff3745c54e61ab340b98400c52ebec04290a62078155e0d7eb30380220",
|
||||
"sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c",
|
||||
"sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310",
|
||||
"sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2",
|
||||
"sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e",
|
||||
"sha256:a358277122456666a8b2a0b9aa04f1b89d34e8aa41d08a6557d693e6abb6667c",
|
||||
"sha256:a60da2f9efbed355edb35a1fb6829148676786c829fad708bb6bb47211b3593a",
|
||||
"sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a",
|
||||
"sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594",
|
||||
"sha256:b4f10d3f304be7183fac79bf2cd997f82e16aa9a9f37343d76c026c6e435a8a8",
|
||||
"sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52",
|
||||
"sha256:c394a3f055b49f92626f83c1a0b6d6cd2c628f1ccd72481c3e3c6aa4695f3b20",
|
||||
"sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8",
|
||||
"sha256:c5ba07e58fd98c9782201e79eb8dd4225733d212a5a3700f9a84d329bd0463a6",
|
||||
"sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9",
|
||||
"sha256:cbba763d99de0255a3e4938f25a8579930ac8aa089233cb2fb2ed7d04d4aff02",
|
||||
"sha256:cbd1660fb48827381ce3a621a4fdc237959e1cd4e98b098952a8f624a0726425",
|
||||
"sha256:cd761d158f67e288fd0ebe00c3b158095ce80a4bc7c32b60c7121224003ba70d",
|
||||
"sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2",
|
||||
"sha256:d2dbe8a3baf2d842e342e8acb06ae3844765d38df67687c144cdeb71f1bcb5d7",
|
||||
"sha256:d5c931ec1c03111718cabf85f6012bb2815fa0ce578175567fa8d6f2cc15d3b4",
|
||||
"sha256:df6d7bf3e15b7e6e202a16ff4948a51759354016026deb04ab9b5acbbe35e096",
|
||||
"sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d",
|
||||
"sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149",
|
||||
"sha256:e5aed4754895942ae15ffa48c52d181e1c1463236fda68d2dba29c03aa61786b",
|
||||
"sha256:e645b4c365d6f1f9e0799380ad6395268f3c3b898244a650aaeb8d9d27b74c35",
|
||||
"sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773",
|
||||
"sha256:f08342dc8d19214faa7ef99574dea6c37a2790d6d04a9793ef8fa76c188dc08d",
|
||||
"sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6",
|
||||
"sha256:f0ce9d9e07c75da8027c62b4c9f45771d1d8aae7dc9ad7fb606c6a5aedbe9741",
|
||||
"sha256:f1f723e260c35e1c7c57a09d3a6ebe681bd56c83e1208ae3ce1869b7c0d10d4f",
|
||||
"sha256:f2fcbe9b3a5996b417e030aa33a562e7e20dfc86271e53d7e841fc5df16268b8",
|
||||
"sha256:f3fd8df63c41ff6a676d031956aebf68ebbc687b47c507da25501eb22eec341f",
|
||||
"sha256:f8d3417db5e14a6789073b21ae44439a755289477901901bae378a57b905e148",
|
||||
"sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8",
|
||||
"sha256:fc98ff43abad61f00515ad9a06213b7716699146e46eabd256cdfe7cb522bd97",
|
||||
"sha256:ff1863f037dad765ef5963efc2e37d399ac023e192a6f2bb394e2377d023cefe"
|
||||
"sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80",
|
||||
"sha256:03fe327549f177040b32f7faa736dc152be936d8b264d8b84f94c75f1379bfa1",
|
||||
"sha256:07626f76a248fce5ebbb10fb0d4899d3c7f908ba21cb2fb4f5a7a9daf24c20cd",
|
||||
"sha256:0793c51d3b1c7410bde1f7254fff71fabff1bc0cdeba1fa51319ac4e7931df3d",
|
||||
"sha256:0a33f8931ac91ebc23ce4decb99ed8fdddba2bafd2af3bb2781bcfd9878d4822",
|
||||
"sha256:0a661f3492462e7adf8a054fb7414a22fc8251f1e18b9d8cbcf008d2dc85f012",
|
||||
"sha256:0c8c66f5d8055cb84ad0ea14af57b3579cb0b6db589f2086f5e33f0922cf2354",
|
||||
"sha256:0da5f17bed67ffe1d72f79fbf98403513a6e51a4f9b8293c1ff8a64e121242be",
|
||||
"sha256:0f099a4a77daf9bb99787070854894fe588c7d6988ea729f970ba2b3b82c7559",
|
||||
"sha256:10c0caabff00ab0631d1e4fd25f56c7a5cf0f068426e5860d28dbbb972b509bf",
|
||||
"sha256:133648c3405564e7fef9103f1768cb018de1b4976f3d8beff09cd4acea73bfe4",
|
||||
"sha256:154a19e1dcd430494fdad7d1a0fb36383baaa363e1cb9d5a7b744cd2418c44d2",
|
||||
"sha256:18f3a2c8908e63a66d3994808254397a5f989b1fb91087c33739f62bf1a1a064",
|
||||
"sha256:190a3482818d69faef176171c7cae10d55cb4dd0c686b5aced7f592b5e5591c1",
|
||||
"sha256:190b20cbffc9cd7f308f7a57d406119c3af3ae197613325fd2d92d99c8882ad6",
|
||||
"sha256:1d7b786a1ddd9b8dda17c445060a94a465cba2e113603ae7bdc5364efc1efd11",
|
||||
"sha256:20e412527ec1aac7e3a6542b32a9c34bb852c954676b05008f0e3d58c390a0ac",
|
||||
"sha256:21add0aa968496a2bd8341d85720d09808e22e0adc7dbefc1e0f8f67c4b83f36",
|
||||
"sha256:2762db8049b230520358ac742cbc57bceaacebe34e5d25c096f2b4bc3887a3a8",
|
||||
"sha256:29c8c10a49d6a9586f592116618b99c3dabcb24d881b7a649e0691ef87f314c4",
|
||||
"sha256:2b9916867fa1ed815739e3e37dda458f397dee25a0e293b808839cfc2a396ca0",
|
||||
"sha256:2da2ca9495668ab77132a911f6bd530d2bfe686d10467584894efc3b66e9ffb5",
|
||||
"sha256:2dc07dab252c63c4f6600e200b26fa05207db6b650d41ae88ab0cec4d6c59459",
|
||||
"sha256:300e3026d17ae3328320ba78d3165bdb1c43d0dfdbc461a69ebbdc005d9ce0b3",
|
||||
"sha256:30d42c34da2974a5e2e0b51c57ecf89892c1e83ed67e1084d1e27eefc27add91",
|
||||
"sha256:329b994944993c45c3845047476ef4f231fe1a53972f18f8d005fd12fac163e1",
|
||||
"sha256:3a5e594b4be2dbfe021cee8d6d7d96e9bb19dee7ed7be351f43bca7a0619b978",
|
||||
"sha256:3b9358f6437a5fa0c765ffae5810c9830547baf4bcf469438b82845c3f33f998",
|
||||
"sha256:3b9790ae107fc8648155f120e80a58ef8e94424efefff5b355de84061de6a18b",
|
||||
"sha256:40d1b57012bf9b4fefd25345aaa95aab3ca510cc693f33c2cb02a4b771d8e51a",
|
||||
"sha256:430fe5150816445c8294a36ce2612360037342d750cea179efe5de38c66670a8",
|
||||
"sha256:4494c599effa16064f2b600f6eb28115182d6826847d795a55691339788d8a4d",
|
||||
"sha256:478b9f0ea86f957624dd2b159066855716f78db94666e9b04babe85fc013e01b",
|
||||
"sha256:4b7d7d10a1c82050efbb9a83d7a43974f70cf8f021afb86463b42e4ac4e5a46b",
|
||||
"sha256:4c7ce072191ba23a4a4876452ccd5f2a67b926e66a248d052d39e9969cd3ab47",
|
||||
"sha256:4da256fc567a57ded2a4aa962fc9e9d430ab740e5c67be9e98a63ef4eb467f2f",
|
||||
"sha256:4e34f1cb6cdb036c5f4a839a2b74419f75fa36177a70c4bab2867f48973cbe44",
|
||||
"sha256:4fb869faf4b484cb213199ced1e2732091559107637d429fc25d0a9731f5f630",
|
||||
"sha256:539880ddf9a8cc54c9e6126e7d072c991563f0c90ef73b3519a783d53df00352",
|
||||
"sha256:55f4b105a1686eb486069a9e578d502d1998e890d8144012225de9e0450aeabd",
|
||||
"sha256:5743f532e408cfd716fa16776b5a6447b83ff2cf39021fb5f8d052aa0f331508",
|
||||
"sha256:5b67b869f860eb19055e2560844d8c7d0935245938935bdb764b3e683e2014e2",
|
||||
"sha256:5e30d8e399f38ae1ec86aa9be76d20ba15872dd0c41b4b46d1b78905857363b9",
|
||||
"sha256:660e11b9932f58f10151d0febd11f77d3b0d48d6fa4dd4686d8983f40187101e",
|
||||
"sha256:67125404d12547443d74113862a80c10310cf875aff8dbfc5548fee1d9737123",
|
||||
"sha256:6956ef0259a037f10da767741aca82925f6f9978bb6dceb5344e56ce0629ab07",
|
||||
"sha256:698c37fca3761af69a09a1d39cc0492f7e8cb9e263af39a288dce8f3b8a9e2bc",
|
||||
"sha256:69c8298e8197b113f765a2ea60f49ceb8e1ea9eb308140b3cdc611e0d1de70b8",
|
||||
"sha256:6ef49462a615de062dcac8281944d0b036fe1e9c96a6c690bf6cf5e4b5488f0e",
|
||||
"sha256:6f92d12a46b2a67d56194bb5d226dabf586b386d1f1a5e25be5b745a3080dbba",
|
||||
"sha256:70f70ea138e69ec3159e4a38fef52443cb8eb81388aeb241b273265ea16387c5",
|
||||
"sha256:72a0e87b2196120523fc6194ca6b580fcffa12d7daa4d57a16d7838e60f82d0e",
|
||||
"sha256:72b32d8c471930c95d49640ec99f7694f9b040ca1342ff03ed69d3aea90f9339",
|
||||
"sha256:746e25f17ba4203b5933773782cf2d30bca5cdb66a9ba5d48a53a6c795aedc57",
|
||||
"sha256:75e33c9187da271d1dbeb2582ab2df2e441346492098f67559b09173ea4edde4",
|
||||
"sha256:7875abfd90f2ae3aa22d50f3fa1c93bbae456458cc73d3179b838f07bed1fc10",
|
||||
"sha256:78ab0d4166cf35c73054d1e04f224af1edc3cb4d75da8b6f74f4cff7c300f358",
|
||||
"sha256:78cbda57a2808d994517b53571eaa2d9299359f63aa71cf4bc94210169aad8b1",
|
||||
"sha256:7c133052737c7c75bfa49f5ba71918166fe988995b26a0d2f263a79bf8fed58a",
|
||||
"sha256:7eae9e763fbd32f19f2a66dfc2e37906f8422e0c4ad4a6c9dcf9d3246740812e",
|
||||
"sha256:814bb54db2a016026efc055a3527461e5eb551c0d91b32eeade003829ff84311",
|
||||
"sha256:81ede1f094f26eeaff62e029ff1bc4e84e9d568f20d4669f64dcf7c7b18a28fc",
|
||||
"sha256:838fd67b3d00c5a64181073282a2c0bf8f76465da4844d5e79d2dbbc64c987dc",
|
||||
"sha256:89c7c125a0913d71ba9cc1fa8e14c7cfe1517b1c1f45416e1f9babcedd3b545d",
|
||||
"sha256:8a345b5dc8ab8cafdf338e08530d48fe3f73df27f4ff569be793c7a7e7bb6b6b",
|
||||
"sha256:8c3fe25871f1758519a3ad8dcafb1bd95c5d1aaeb122e6492ac739ab11fa5907",
|
||||
"sha256:8e12d50d4d65c74bd877e15c276992263b878456a7cfcf72521e7205a553557f",
|
||||
"sha256:9adacf6fdadeeb96e6c902aef08d02d2f45429fdbf0a75b80307e435156066f8",
|
||||
"sha256:9befda0dbd27ed95fba1c26be4bf98a49ba166b3c91beb5fc04364c130ce950c",
|
||||
"sha256:9fa5620f7f352f9706924c0e2071a212be36421f09ee064b0fd7e1128289fcdb",
|
||||
"sha256:a681bbf9f94027d66e15974cd207cec1a2993837b9c45acf5f6b22a67632b1c2",
|
||||
"sha256:ab363a5baae965fb3438f2137583853ad9c77d7e45f2a62ba63e609a34d792ea",
|
||||
"sha256:ac49519fcfeb4a7ecdf6b7d0ec6cac409e59f94c1bb54630db577a97893b6e38",
|
||||
"sha256:ad5a71c1ef4a2e404c2c888db09226c821d9d14eff8813e1da873572f5fbb89d",
|
||||
"sha256:af01133e78e5528ee282ceb1cf4bc54aecb937c2001913e751452ad7dffbbeb1",
|
||||
"sha256:b3118ec012a799456f7fca6cc002c078590578b7640fbaab52d8ecb9a651f1c1",
|
||||
"sha256:b46b7aec9272fd81c984e723e599957629a91204120b3e7f0933f138e0792fdf",
|
||||
"sha256:ba0339d6aa80615a17f47fabc5700485e9469121d658458f95cdd2003288c28b",
|
||||
"sha256:c08cd5b19c570e1e9e094a6ce70d35bb39d12360e0763474ed9374229f174fcc",
|
||||
"sha256:c0b367a00e8c88a714b2384c97dedcc85340547b3a54b6037a42fca5554d0576",
|
||||
"sha256:c263ed9922942353a954cfbcd5f81b7626c0e20dc7f3e53d4926e8bc560ab845",
|
||||
"sha256:c3387c314695f9790dce12fcf44357197ebf773651b6a4195f5e091cf500ae73",
|
||||
"sha256:c4fd3399eaf6f1c77ea3132611efbc3d7a8c0eb899793387b3266be221dc75fd",
|
||||
"sha256:cac0145491619287ff893853bf3ca4d98d5ef94b617271184a5af68a06ac301a",
|
||||
"sha256:cd9b848c17ef034f2ae31b2a1bd9276710c2baf03509f1f3fa4dc4382b0a1b53",
|
||||
"sha256:ced27af6aee28782260bfa5643797937e96a6489bca972202834017208cf74f5",
|
||||
"sha256:cf99e36c0f6ae5643ecef7ad7e1194aeb4a9798d9cff60b20ac041533fa6db0a",
|
||||
"sha256:d7d5f7f6f80388ce94849775da5f4082ab5e123e259972961970e190d60f5d2b",
|
||||
"sha256:dc2cab92c42991b711132bc52405680e075d1505d4356c4468bc6e9c93d49137",
|
||||
"sha256:ddcd25a1f72b2b545fb27e17882046a6c161f3f24514b2e028c00c58ed73a2dd",
|
||||
"sha256:defa3c12cb06b2fd2066a9e21bf00aab96465be84d9585c8c05195f080510506",
|
||||
"sha256:df3ffa6ef88166bb36f5d1492e71e664868b9b8b6afd55821e0ac0cb96625441",
|
||||
"sha256:e127b2e7fc533728295196f9265d12834530f475bc6cd6f74619df415d04b8b1",
|
||||
"sha256:e9ff57452fcadfd1a379314234657b8f4e9967ae64480ddf7c2fd82139bc8cf8",
|
||||
"sha256:eb9fa02b9f5bbdb1d036a0c68999337793fa244528e0ce825e4b97cb7f7db99f",
|
||||
"sha256:ec3d0a6c37a816ea6e3550697c60d90861c9b0f982a98a40b59ac1f7a360bfa9",
|
||||
"sha256:ef123b6aead12e0784f72970e8d94a96ac0d0aa4438c7ab9235e2f8669a0a5ae",
|
||||
"sha256:f90bb3c680804ec9630bcf8c0965e54b4de84d33b17d7da57c87c30f0c64c6f5",
|
||||
"sha256:fb1df55f5700187c6db4b47dbdaf8a0653a111341ac7fccc596b397aa3399e65",
|
||||
"sha256:fd68db1a0f5d9374a7b735414efe48d2b3ecbf0adea39299bb48030988f16149",
|
||||
"sha256:fd6b5b6df14f98b2e7e474c1c7ea55fc32dcab038b3b34b76a591dec8ba50915",
|
||||
"sha256:fd7e3158be382f8f140caccc0dc7742a7553ce4bf2978982abe3054d2cedd705",
|
||||
"sha256:fe989bbed9d6f332c1e24d333936f3fa1375f380cd8028da0b985dcdefa6015a",
|
||||
"sha256:ff2ca039a161d49a8c713f5380def315c6f793df5fe348b94782b1dbee37a644"
|
||||
],
|
||||
"version": "==3.8.0"
|
||||
"version": "==3.8.1"
|
||||
},
|
||||
"bitstring": {
|
||||
"hashes": [
|
||||
"sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a",
|
||||
"sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a"
|
||||
"sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37",
|
||||
"sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737"
|
||||
],
|
||||
"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": {
|
||||
"hashes": [
|
||||
@@ -231,80 +255,219 @@
|
||||
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'",
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc",
|
||||
"sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c",
|
||||
"sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67",
|
||||
"sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4",
|
||||
"sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0",
|
||||
"sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c",
|
||||
"sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5",
|
||||
"sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444",
|
||||
"sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153",
|
||||
"sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9",
|
||||
"sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01",
|
||||
"sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217",
|
||||
"sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b",
|
||||
"sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c",
|
||||
"sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a",
|
||||
"sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83",
|
||||
"sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5",
|
||||
"sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7",
|
||||
"sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb",
|
||||
"sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c",
|
||||
"sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1",
|
||||
"sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42",
|
||||
"sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab",
|
||||
"sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df",
|
||||
"sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e",
|
||||
"sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207",
|
||||
"sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18",
|
||||
"sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734",
|
||||
"sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38",
|
||||
"sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110",
|
||||
"sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18",
|
||||
"sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44",
|
||||
"sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d",
|
||||
"sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48",
|
||||
"sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e",
|
||||
"sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5",
|
||||
"sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d",
|
||||
"sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53",
|
||||
"sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790",
|
||||
"sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c",
|
||||
"sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b",
|
||||
"sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116",
|
||||
"sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d",
|
||||
"sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10",
|
||||
"sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6",
|
||||
"sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2",
|
||||
"sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776",
|
||||
"sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a",
|
||||
"sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265",
|
||||
"sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008",
|
||||
"sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943",
|
||||
"sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374",
|
||||
"sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246",
|
||||
"sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e",
|
||||
"sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5",
|
||||
"sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616",
|
||||
"sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15",
|
||||
"sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41",
|
||||
"sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960",
|
||||
"sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752",
|
||||
"sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e",
|
||||
"sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72",
|
||||
"sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7",
|
||||
"sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8",
|
||||
"sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b",
|
||||
"sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4",
|
||||
"sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545",
|
||||
"sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706",
|
||||
"sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366",
|
||||
"sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb",
|
||||
"sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a",
|
||||
"sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e",
|
||||
"sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00",
|
||||
"sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f",
|
||||
"sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a",
|
||||
"sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1",
|
||||
"sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66",
|
||||
"sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356",
|
||||
"sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319",
|
||||
"sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4",
|
||||
"sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad",
|
||||
"sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d",
|
||||
"sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5",
|
||||
"sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7",
|
||||
"sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0",
|
||||
"sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686",
|
||||
"sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34",
|
||||
"sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49",
|
||||
"sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c",
|
||||
"sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1",
|
||||
"sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e",
|
||||
"sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60",
|
||||
"sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0",
|
||||
"sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274",
|
||||
"sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d",
|
||||
"sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0",
|
||||
"sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae",
|
||||
"sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f",
|
||||
"sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d",
|
||||
"sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe",
|
||||
"sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3",
|
||||
"sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393",
|
||||
"sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1",
|
||||
"sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af",
|
||||
"sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44",
|
||||
"sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00",
|
||||
"sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c",
|
||||
"sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3",
|
||||
"sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7",
|
||||
"sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd",
|
||||
"sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e",
|
||||
"sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b",
|
||||
"sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8",
|
||||
"sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259",
|
||||
"sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859",
|
||||
"sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46",
|
||||
"sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30",
|
||||
"sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b",
|
||||
"sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46",
|
||||
"sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24",
|
||||
"sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a",
|
||||
"sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24",
|
||||
"sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc",
|
||||
"sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215",
|
||||
"sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063",
|
||||
"sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832",
|
||||
"sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6",
|
||||
"sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79",
|
||||
"sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.4.7"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
|
||||
"sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"
|
||||
"sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5",
|
||||
"sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==8.3.1"
|
||||
"version": "==8.3.2"
|
||||
},
|
||||
"cryptography": {
|
||||
"hashes": [
|
||||
"sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217",
|
||||
"sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d",
|
||||
"sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc",
|
||||
"sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71",
|
||||
"sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971",
|
||||
"sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a",
|
||||
"sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926",
|
||||
"sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc",
|
||||
"sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d",
|
||||
"sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b",
|
||||
"sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20",
|
||||
"sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044",
|
||||
"sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3",
|
||||
"sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715",
|
||||
"sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4",
|
||||
"sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506",
|
||||
"sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f",
|
||||
"sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0",
|
||||
"sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683",
|
||||
"sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3",
|
||||
"sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21",
|
||||
"sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91",
|
||||
"sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c",
|
||||
"sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8",
|
||||
"sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df",
|
||||
"sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c",
|
||||
"sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb",
|
||||
"sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7",
|
||||
"sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04",
|
||||
"sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db",
|
||||
"sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459",
|
||||
"sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea",
|
||||
"sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914",
|
||||
"sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717",
|
||||
"sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9",
|
||||
"sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac",
|
||||
"sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32",
|
||||
"sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec",
|
||||
"sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1",
|
||||
"sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb",
|
||||
"sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac",
|
||||
"sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665",
|
||||
"sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e",
|
||||
"sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb",
|
||||
"sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5",
|
||||
"sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936",
|
||||
"sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de",
|
||||
"sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372",
|
||||
"sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54",
|
||||
"sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422",
|
||||
"sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849",
|
||||
"sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c",
|
||||
"sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963",
|
||||
"sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"
|
||||
"sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65",
|
||||
"sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832",
|
||||
"sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067",
|
||||
"sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de",
|
||||
"sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4",
|
||||
"sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0",
|
||||
"sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b",
|
||||
"sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968",
|
||||
"sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef",
|
||||
"sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b",
|
||||
"sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4",
|
||||
"sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3",
|
||||
"sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308",
|
||||
"sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e",
|
||||
"sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163",
|
||||
"sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f",
|
||||
"sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee",
|
||||
"sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77",
|
||||
"sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85",
|
||||
"sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99",
|
||||
"sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7",
|
||||
"sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83",
|
||||
"sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85",
|
||||
"sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006",
|
||||
"sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb",
|
||||
"sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e",
|
||||
"sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba",
|
||||
"sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325",
|
||||
"sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d",
|
||||
"sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1",
|
||||
"sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1",
|
||||
"sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2",
|
||||
"sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0",
|
||||
"sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455",
|
||||
"sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842",
|
||||
"sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457",
|
||||
"sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15",
|
||||
"sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2",
|
||||
"sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c",
|
||||
"sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb",
|
||||
"sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5",
|
||||
"sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4",
|
||||
"sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902",
|
||||
"sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246",
|
||||
"sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022",
|
||||
"sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f",
|
||||
"sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e",
|
||||
"sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298",
|
||||
"sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"
|
||||
],
|
||||
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
|
||||
"version": "==46.0.3"
|
||||
"version": "==46.0.7"
|
||||
},
|
||||
"esptool": {
|
||||
"hashes": [
|
||||
"sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da"
|
||||
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==5.1.0"
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==5.2.0"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
|
||||
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
@@ -337,45 +500,64 @@
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==0.1.2"
|
||||
},
|
||||
"mpremote": {
|
||||
"microdot": {
|
||||
"hashes": [
|
||||
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
|
||||
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
|
||||
"sha256:363e3ebfc80b7e0415779848c9332e4e7fb7bd365ee54d3620abffe42ed82946",
|
||||
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.27.0"
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"mpremote": {
|
||||
"hashes": [
|
||||
"sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334",
|
||||
"sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.4'",
|
||||
"version": "==1.28.0"
|
||||
},
|
||||
"outcome": {
|
||||
"hashes": [
|
||||
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
||||
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==1.3.0.post0"
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda",
|
||||
"sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"
|
||||
"sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a",
|
||||
"sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.5.1"
|
||||
"version": "==4.9.6"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2",
|
||||
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"
|
||||
"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
|
||||
"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
|
||||
],
|
||||
"markers": "implementation_name != 'PyPy'",
|
||||
"version": "==2.23"
|
||||
"version": "==3.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
|
||||
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
|
||||
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
|
||||
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.19.2"
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.20.0"
|
||||
},
|
||||
"pyjwt": {
|
||||
"hashes": [
|
||||
"sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953",
|
||||
"sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"
|
||||
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
|
||||
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.10.1"
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.12.1"
|
||||
},
|
||||
"pyserial": {
|
||||
"hashes": [
|
||||
@@ -385,6 +567,22 @@
|
||||
"index": "pypi",
|
||||
"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": {
|
||||
"hashes": [
|
||||
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
|
||||
@@ -471,30 +669,127 @@
|
||||
],
|
||||
"version": "==1.7.0"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517",
|
||||
"sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==2.33.1"
|
||||
},
|
||||
"rich": {
|
||||
"hashes": [
|
||||
"sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4",
|
||||
"sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"
|
||||
"sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb",
|
||||
"sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"
|
||||
],
|
||||
"markers": "python_full_version >= '3.8.0'",
|
||||
"version": "==14.2.0"
|
||||
"markers": "python_full_version >= '3.9.0'",
|
||||
"version": "==15.0.0"
|
||||
},
|
||||
"rich-click": {
|
||||
"hashes": [
|
||||
"sha256:48120531493f1533828da80e13e839d471979ec8d7d0ca7b35f86a1379cc74b6",
|
||||
"sha256:9b195721a773b1acf0e16ff9ec68cef1e7d237e53471e6e3f7ade462f86c403a"
|
||||
"sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
|
||||
"sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.9.5"
|
||||
"version": "==1.9.7"
|
||||
},
|
||||
"selenium": {
|
||||
"hashes": [
|
||||
"sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769",
|
||||
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.43.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": {
|
||||
"hashes": [
|
||||
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
||||
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
||||
],
|
||||
"markers": "python_version < '3.13'",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==4.15.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"extras": [
|
||||
"socks"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
|
||||
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.6.3"
|
||||
},
|
||||
"watchfiles": {
|
||||
"hashes": [
|
||||
"sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c",
|
||||
@@ -608,8 +903,135 @@
|
||||
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"websocket-client": {
|
||||
"hashes": [
|
||||
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
|
||||
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"websockets": {
|
||||
"hashes": [
|
||||
"sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c",
|
||||
"sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a",
|
||||
"sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe",
|
||||
"sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e",
|
||||
"sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec",
|
||||
"sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1",
|
||||
"sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64",
|
||||
"sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3",
|
||||
"sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8",
|
||||
"sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206",
|
||||
"sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3",
|
||||
"sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156",
|
||||
"sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d",
|
||||
"sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9",
|
||||
"sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad",
|
||||
"sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2",
|
||||
"sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03",
|
||||
"sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8",
|
||||
"sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230",
|
||||
"sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8",
|
||||
"sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea",
|
||||
"sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641",
|
||||
"sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957",
|
||||
"sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6",
|
||||
"sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6",
|
||||
"sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5",
|
||||
"sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f",
|
||||
"sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00",
|
||||
"sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e",
|
||||
"sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b",
|
||||
"sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72",
|
||||
"sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39",
|
||||
"sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9",
|
||||
"sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79",
|
||||
"sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0",
|
||||
"sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac",
|
||||
"sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35",
|
||||
"sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0",
|
||||
"sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5",
|
||||
"sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c",
|
||||
"sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8",
|
||||
"sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1",
|
||||
"sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244",
|
||||
"sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3",
|
||||
"sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767",
|
||||
"sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a",
|
||||
"sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d",
|
||||
"sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd",
|
||||
"sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e",
|
||||
"sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944",
|
||||
"sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82",
|
||||
"sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d",
|
||||
"sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4",
|
||||
"sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5",
|
||||
"sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904",
|
||||
"sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde",
|
||||
"sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f",
|
||||
"sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c",
|
||||
"sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89",
|
||||
"sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da",
|
||||
"sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==16.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:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
|
||||
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.20.0"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9",
|
||||
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==9.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
README.md
41
README.md
@@ -1,2 +1,43 @@
|
||||
# led-controller
|
||||
|
||||
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices. Outbound paths include:
|
||||
|
||||
- **Serial → ESP-NOW bridge**: JSON lines over UART to an ESP32 that forwards ESP-NOW frames (configure `serial_port` and baud in `settings.json` / Settings model).
|
||||
- **Wi-Fi LED drivers**: TCP JSON lines (default port **8765** on the Pi; drivers discover the controller via **UDP 8766** broadcast).
|
||||
|
||||
## Run
|
||||
|
||||
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
|
||||
- Start app: `pipenv run run` (override listen port with the **`PORT`** environment variable)
|
||||
- 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 zones/presets and apply profiles. Editing actions are hidden.
|
||||
- **Edit mode**: management view. Shows **Zones**, 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 zone content.
|
||||
- In **Run mode**, Profiles supports apply-only behaviour (no create/clone/delete).
|
||||
- In **Edit mode**, Profiles supports create/clone/delete.
|
||||
- Creating a profile always creates a populated `default` zone (starter presets).
|
||||
- Optional **DJ zone** seeding creates:
|
||||
- `dj` zone 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`
|
||||
|
||||
## Driver pattern modules
|
||||
|
||||
Pattern **`.py`** sources live under **`led-driver/src/patterns`**. The Pi app resolves that path via `util.driver_patterns.driver_patterns_dir()`. If you deploy without that tree next to the app, set **`LED_CONTROLLER_PATTERNS_DIR`** to the directory that contains those files.
|
||||
|
||||
@@ -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 @@
|
||||
{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}}
|
||||
@@ -1,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": {
|
||||
"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": ["#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"]}
|
||||
@@ -15,6 +15,12 @@
|
||||
"max_delay": 10000,
|
||||
"max_colors": 0
|
||||
},
|
||||
"colour_cycle": {
|
||||
"n1": "Step Rate",
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"max_colors": 10
|
||||
},
|
||||
"transition": {
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
@@ -50,5 +56,37 @@
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"max_colors": 10
|
||||
},
|
||||
"flicker": {
|
||||
"n1": "Min brightness",
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"max_colors": 10
|
||||
},
|
||||
"flame": {
|
||||
"n1": "Min brightness",
|
||||
"n2": "Breath period (ms)",
|
||||
"n3": "Spark gap min (ms, 0=default 10–30 s, -1=off)",
|
||||
"n4": "Spark gap max (ms)",
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"max_colors": 10
|
||||
},
|
||||
"twinkle": {
|
||||
"n1": "Twinkle activity (1–255, higher = more changes)",
|
||||
"n2": "Density (0–255, higher = more of the strip lit)",
|
||||
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
|
||||
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"max_colors": 10
|
||||
},
|
||||
"radiate": {
|
||||
"n1": "Node spacing (LEDs)",
|
||||
"n2": "Out time (ms)",
|
||||
"n3": "In time (ms)",
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"max_colors": 2
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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": "zones", "zones": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["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 +0,0 @@
|
||||
{"1": {"name": "Main", "names": ["1", "2", "3"], "presets": [["1", "2", "3"]], "presets_flat": ["1", "2", "3"]}, "2": {"name": "Accent", "names": ["4", "5"], "presets": []}, "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
db/zone.json
Normal file
1
db/zone.json
Normal file
@@ -0,0 +1 @@
|
||||
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41"], "default_preset": "4"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
||||
53
dev.py
53
dev.py
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import serial
|
||||
import sys
|
||||
|
||||
print(sys.argv)
|
||||
|
||||
# Extract port (first arg if it's not a command)
|
||||
commands = ["src", "lib", "ls", "reset", "follow", "db"]
|
||||
port = None
|
||||
if len(sys.argv) > 1 and sys.argv[1] not in commands:
|
||||
port = sys.argv[1]
|
||||
|
||||
|
||||
for cmd in sys.argv[1:]:
|
||||
print(cmd)
|
||||
match cmd:
|
||||
case "src":
|
||||
if port:
|
||||
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":" ], cwd="src")
|
||||
else:
|
||||
print("Error: Port required for 'src' command")
|
||||
case "lib":
|
||||
if port:
|
||||
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":" ])
|
||||
else:
|
||||
print("Error: Port required for 'lib' command")
|
||||
case "ls":
|
||||
if port:
|
||||
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":" ])
|
||||
else:
|
||||
print("Error: Port required for 'ls' command")
|
||||
case "reset":
|
||||
if port:
|
||||
with serial.Serial(port, baudrate=115200) as ser:
|
||||
ser.write(b'\x03\x03\x04')
|
||||
else:
|
||||
print("Error: Port required for 'reset' command")
|
||||
case "follow":
|
||||
if port:
|
||||
with serial.Serial(port, baudrate=115200) as ser:
|
||||
while True:
|
||||
if ser.in_waiting > 0: # Check if there is data in the buffer
|
||||
data = ser.readline().decode('utf-8').strip() # Read and decode the data
|
||||
print(data)
|
||||
else:
|
||||
print("Error: Port required for 'follow' command")
|
||||
case "db":
|
||||
if port:
|
||||
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "db", ":" ])
|
||||
else:
|
||||
print("Error: Port required for 'db' command")
|
||||
808
docs/API.md
808
docs/API.md
@@ -1,504 +1,358 @@
|
||||
# LED Controller API Specification
|
||||
# LED Controller API
|
||||
|
||||
**Base URL:** `http://device-ip/` or `http://192.168.4.1/` (when in AP mode)
|
||||
**Protocol:** HTTP/1.1
|
||||
**Content-Type:** `application/json`
|
||||
This document covers:
|
||||
|
||||
## Presets API
|
||||
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
|
||||
2. **LED driver JSON** — the compact **v1** message format. It is sent over the **serial → ESP-NOW bridge** to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields).
|
||||
|
||||
### GET /presets
|
||||
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
|
||||
|
||||
List all presets.
|
||||
**Serial:** UART path and baud come from settings (defaults include `serial_port` such as `/dev/ttyS0` and `serial_baudrate`). **Wi-Fi drivers:** **UDP** on port **8766** is the **discovery** channel: each driver’s JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known Wi‑Fi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings).
|
||||
|
||||
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 (zone/preset selection and profile apply).
|
||||
- **Edit mode**: shows editing/management controls (zones, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
|
||||
|
||||
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 through the **serial sender** (6-byte MAC prefix + payload to the ESP-NOW bridge). 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 from settings 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"}`.
|
||||
|
||||
Wi-Fi devices are not targeted by `/ws` directly; use **`POST /presets/send`**, device routes, or **`POST /patterns/<name>/send`** as appropriate.
|
||||
|
||||
---
|
||||
|
||||
## 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). |
|
||||
|
||||
### Devices — `/devices`
|
||||
|
||||
Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps to an object that always includes:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **`id`** | Same as the storage key (stable handle for URLs). |
|
||||
| **`name`** | Shown in the UI and used in `select` keys. |
|
||||
| **`type`** | `led` (only value today; extensible). |
|
||||
| **`transport`** | `espnow` or `wifi`. |
|
||||
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
|
||||
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
|
||||
|
||||
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/devices` | Map of device id → device object. |
|
||||
| GET | `/devices/<id>` | One device, 404 if missing. |
|
||||
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
|
||||
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
|
||||
| DELETE | `/devices/<id>` | Remove device. |
|
||||
|
||||
### 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_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "<id>": { ... } }` with status 201. |
|
||||
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
|
||||
| POST | `/profiles/<id>/clone` | Clone profile (zones, 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
|
||||
{
|
||||
"preset1": {
|
||||
"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
|
||||
"preset_ids": ["1", "2"],
|
||||
"save": true,
|
||||
"default": "1",
|
||||
"destination_mac": "aabbccddeeff"
|
||||
}
|
||||
```
|
||||
|
||||
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
|
||||
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
|
||||
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
|
||||
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
|
||||
|
||||
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.
|
||||
|
||||
### Zones — `/zones`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/zones` | `zones` (map of zone id → zone object), `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
|
||||
| GET | `/zones/current` | Current zone from cookie/session. |
|
||||
| POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profile’s zone list. |
|
||||
| GET | `/zones/<id>` | Zone JSON. |
|
||||
| PUT | `/zones/<id>` | Update zone. |
|
||||
| DELETE | `/zones/<id>` | Delete zone; can delete `current` to remove the active zone; updates profile zone list. |
|
||||
| POST | `/zones/<id>/set-current` | Sets `current_zone` cookie. |
|
||||
| POST | `/zones/<id>/clone` | Clone zone 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`
|
||||
|
||||
Pattern metadata lives in **`db/pattern.json`**; driver source files live under **`led-driver/src/patterns/`**. Several routes expose a **runtime map** (metadata merged with on-disk `.py` names so new files appear in menus).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/patterns` | Runtime pattern map (object keyed by pattern id). |
|
||||
| GET | `/patterns/definitions` | Same runtime map (intended for UI “definitions” clients). |
|
||||
| GET | `/patterns/ota/manifest` | JSON `{"files":[{"name":"blink.py","url":"http://<Host>/patterns/ota/file/blink.py"},...]}` for OTA pulls. Requires **`Host`** header. |
|
||||
| GET | `/patterns/ota/file/<name>` | Raw **`.py`** source for one driver pattern (`name` must be a safe filename, e.g. `rainbow.py`). |
|
||||
| POST | `/patterns/<name>/send` | Push a **manifest** JSON line to **Wi-Fi** devices so they pull one pattern file over HTTP. Body may include **`device_id`** to target one device; otherwise all Wi-Fi devices with an **`address`** are tried. **`<name>`** may be with or without `.py`. |
|
||||
| POST | `/patterns/upload` | Body JSON: **`name`**, **`code`**, optional **`overwrite`** (default true). Writes **`led-driver/src/patterns/<name>.py`**. |
|
||||
| POST | `/patterns/driver` | Body JSON: **`name`** (identifier), **`code`**, optional metadata (`min_delay`, `max_delay`, `max_colors`, `n1`…`n8`, **`overwrite`**). Creates/updates both the **`.py`** file and **`db/pattern.json`** via the Pattern model. |
|
||||
| GET | `/patterns/<id>` | One pattern record from the Pattern model (metadata only). |
|
||||
| POST | `/patterns` | Create (`name`, optional `data`). |
|
||||
| PUT | `/patterns/<id>` | Update. |
|
||||
| DELETE | `/patterns/<id>` | Delete. |
|
||||
|
||||
**Devices — pattern OTA push**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/devices/<id>/patterns/push` | Wi-Fi only. Asks the driver at **`address`** to pull pattern files from this server. Optional body **`manifest`**: either a **URL string** pointing at a manifest JSON document, or a **manifest object** (same shape as in driver messages). If omitted, a default manifest is built from the request **`Host`** header. |
|
||||
|
||||
---
|
||||
|
||||
## LED driver message format (transport / ESP-NOW / Wi-Fi)
|
||||
|
||||
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, and the same logical object can be sent as a **single JSON text message** to a Wi-Fi driver over the **WebSocket**.
|
||||
|
||||
### 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
|
||||
{
|
||||
"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:** `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": []
|
||||
"v": "1",
|
||||
"save": true,
|
||||
"presets": {
|
||||
"1": {
|
||||
"name": "Red blink",
|
||||
"p": "blink",
|
||||
"c": ["#FF0000"],
|
||||
"d": 200,
|
||||
"b": 255,
|
||||
"a": true,
|
||||
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"living-room": ["1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /profiles/{name}
|
||||
---
|
||||
|
||||
Get a specific profile by name.
|
||||
## Processing summary (driver)
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"name": "profile1",
|
||||
"description": "Profile description",
|
||||
"scenes": []
|
||||
}
|
||||
```
|
||||
1. Reject if `v != "1"`.
|
||||
2. Apply optional top-level **`b`** (global brightness).
|
||||
3. For each entry in **`presets`**, normalize colours and upsert preset by id.
|
||||
4. If this device’s **`name`** appears in **`select`**, run selection (optional step).
|
||||
5. If **`default`** is set, store startup preset id.
|
||||
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`
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
}
|
||||
```
|
||||
- **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.
|
||||
- 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).
|
||||
|
||||
@@ -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)
|
||||
- Real-time brightness and speed control
|
||||
- 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
|
||||
- Preset system for saving and loading pattern configurations
|
||||
- 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
|
||||
- Pattern Selection Card
|
||||
- Brightness & Speed Card
|
||||
- Color Selection Card
|
||||
- Colour Selection Card
|
||||
- Device Status Card
|
||||
- **Action Bar:** Apply and Save buttons
|
||||
|
||||
@@ -273,12 +273,12 @@ Primary interface for real-time LED control and monitoring.
|
||||
- **Default:** 100ms
|
||||
- **Step:** 10ms increments
|
||||
|
||||
**Color Selection**
|
||||
- **Type:** Color picker inputs (HTML5 color input)
|
||||
- **Quantity:** Multiple colors (minimum 2, expandable)
|
||||
- **Format:** Hex color codes (e.g., #FF0000)
|
||||
- **Display:** Large color swatches (60x60px)
|
||||
- **Action:** "Add Color" button for additional colors
|
||||
**Colour Selection**
|
||||
- **Type:** Colour picker inputs (HTML5 colour input)
|
||||
- **Quantity:** Multiple colours (minimum 2, expandable)
|
||||
- **Format:** Hex colour codes (e.g., #FF0000)
|
||||
- **Display:** Large colour swatches (60x60px)
|
||||
- **Action:** "Add Colour" button for additional colours
|
||||
|
||||
**Device Status List**
|
||||
- **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
|
||||
|
||||
#### 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
|
||||
- **Hover Effects:** Card lift (translateY -2px), increased shadow
|
||||
- **Typography:** System font stack, 1.25rem headings
|
||||
@@ -350,10 +350,10 @@ Manage connected devices and create/manage device groups.
|
||||
|
||||
#### Layout
|
||||
- **Header:** Title with "Add Device" button
|
||||
- **Tabs:** Devices and Groups tabs
|
||||
- **Content Area:** Tab-specific content
|
||||
- **Zones:** Devices and Groups zones (zone buttons / zone strip)
|
||||
- **Content Area:** Zone-specific content
|
||||
|
||||
#### Devices Tab
|
||||
#### Devices Zone
|
||||
|
||||
**Device List**
|
||||
- **Display:** List of all known devices
|
||||
@@ -375,7 +375,7 @@ Manage connected devices and create/manage device groups.
|
||||
- **Actions:** Cancel, Save
|
||||
- **Note:** Only one master device per system. Adding a new master will demote existing master to slave.
|
||||
|
||||
#### Groups Tab
|
||||
#### Groups Zone
|
||||
|
||||
**Group List**
|
||||
- **Display:** List of all device groups
|
||||
@@ -397,7 +397,7 @@ Manage connected devices and create/manage device groups.
|
||||
- **Actions:** Cancel, Create
|
||||
|
||||
#### Design Specifications
|
||||
- **Tab Style:** Active tab has purple background, white text
|
||||
- **Zone Style:** Active zone has purple background, white text
|
||||
- **List Items:** Bordered cards with hover effects
|
||||
- **Modal:** Centered overlay with white card, shadow
|
||||
- **Status Badges:** Colored pills (green for online, red for offline)
|
||||
@@ -509,7 +509,7 @@ Comprehensive device configuration interface.
|
||||
- Device Name (text input)
|
||||
- LED Pin (number input, 0-40)
|
||||
- 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**
|
||||
- Pattern (dropdown selection)
|
||||
@@ -577,16 +577,16 @@ Comprehensive device configuration interface.
|
||||
- Range: Slider with real-time value display
|
||||
- Select: Dropdown menu
|
||||
- Checkbox: Toggle switch
|
||||
- Color: HTML5 color picker
|
||||
- Colour: HTML5 colour picker
|
||||
|
||||
**Color Order Selector**
|
||||
**Colour Order Selector**
|
||||
- **Type:** Visual button grid
|
||||
- **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
|
||||
|
||||
#### 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
|
||||
- **Labels:** Bold, 500 weight, dark gray (#333)
|
||||
- **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:
|
||||
- **Name:** Preset name (bold, 1.25rem)
|
||||
- **Pattern Badge:** Current pattern type
|
||||
- **Color Preview:** Swatches showing preset colors
|
||||
- **Colour Preview:** Swatches showing preset colours
|
||||
- **Quick Info:** Delay and brightness values
|
||||
- **Actions:** Apply, Edit, Delete buttons
|
||||
|
||||
@@ -620,7 +620,7 @@ Each preset card displays:
|
||||
**Fields:**
|
||||
- Preset Name (text input, required)
|
||||
- Pattern (dropdown selection)
|
||||
- Colors (multiple color pickers, minimum 2)
|
||||
- Colours (multiple colour pickers, minimum 2)
|
||||
- Delay (slider, 10-1000ms)
|
||||
- Step Offset (number input, optional, default: 0)
|
||||
- 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
|
||||
- **Card Style:** White background, rounded corners, shadow
|
||||
- **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
|
||||
- **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 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
|
||||
|
||||
**Note:** Pattern execution happens on the remote devices. The controller sends configuration commands to devices.
|
||||
@@ -698,7 +698,7 @@ Pattern-specific numeric parameters:
|
||||
|
||||
#### 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.
|
||||
|
||||
@@ -708,7 +708,7 @@ A preset contains the following fields:
|
||||
|
||||
- **name** (string, required): Unique identifier for the preset
|
||||
- **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)
|
||||
- **n1** (integer, optional): Pattern parameter 1 (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
|
||||
- **Name:** Unique group identifier
|
||||
- **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)
|
||||
- Each device in group can receive different step offset
|
||||
- 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 |
|
||||
| `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 |
|
||||
| `dl` | int | Delay (ms) | 10-1000 |
|
||||
| `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 |
|
||||
| `led_pin` | int | GPIO pin | 0-40 |
|
||||
| `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 |
|
||||
| `brightness` | int | Global brightness | 0-100 |
|
||||
| `delay` | int | Delay | 10-1000 |
|
||||
@@ -1247,7 +1247,7 @@ CREATE TABLE IF NOT EXISTS presets (
|
||||
**Preset Fields:**
|
||||
- `name` (string, required): Unique preset identifier
|
||||
- `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)
|
||||
- `n1` through `n8` (integer, optional): Pattern parameters (0-255, default: 0)
|
||||
|
||||
@@ -1289,7 +1289,7 @@ CREATE TABLE IF NOT EXISTS presets (
|
||||
|
||||
**POST /api/presets**
|
||||
- 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
|
||||
|
||||
**GET /api/presets/{name}**
|
||||
@@ -1495,7 +1495,7 @@ peak_mem = usqlite.mem_peak()
|
||||
|
||||
### Flow 2: Create Device Group
|
||||
|
||||
1. User navigates to Device Management → Groups tab
|
||||
1. User navigates to Device Management → Groups zone
|
||||
2. User clicks "Create Group", enters name, selects pattern/settings
|
||||
3. User selects devices to add (can include master), clicks "Create"
|
||||
4. Group appears in list
|
||||
@@ -1506,7 +1506,7 @@ peak_mem = usqlite.mem_peak()
|
||||
|
||||
1. User navigates to Settings page
|
||||
2. User modifies settings in sections:
|
||||
- Basic Settings (pin, LED count, color order)
|
||||
- Basic Settings (pin, LED count, colour order)
|
||||
- Pattern Settings (pattern, delay)
|
||||
- Global Brightness
|
||||
- Advanced Settings (N1-N8 parameters)
|
||||
@@ -1519,7 +1519,7 @@ peak_mem = usqlite.mem_peak()
|
||||
### Flow 4: Multi-Device Control
|
||||
|
||||
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"
|
||||
4. System sends message targeting selected devices/groups
|
||||
5. All targeted devices update simultaneously
|
||||
@@ -1585,7 +1585,7 @@ peak_mem = usqlite.mem_peak()
|
||||
|
||||
## Design Guidelines
|
||||
|
||||
### Color Palette
|
||||
### Colour Palette
|
||||
|
||||
- **Primary:** Purple gradient `#667eea` to `#764ba2`, Light gradient `#f5f7fa` to `#c3cfe2`
|
||||
- **UI:** Primary `#667eea`, Hover `#5568d3`, Secondary `#e0e0e0`
|
||||
@@ -1612,8 +1612,8 @@ peak_mem = usqlite.mem_peak()
|
||||
- Disabled: 50% opacity, no pointer events
|
||||
|
||||
**Inputs:**
|
||||
- Focus: Border color changes to primary purple
|
||||
- Hover: Slight border color change
|
||||
- Focus: Border colour changes to primary purple
|
||||
- Hover: Slight border colour change
|
||||
- Error: Red border
|
||||
|
||||
**Cards:**
|
||||
@@ -1738,7 +1738,7 @@ peak_mem = usqlite.mem_peak()
|
||||
- Validation
|
||||
|
||||
**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 editing and deletion
|
||||
- Name uniqueness validation
|
||||
@@ -1758,7 +1758,7 @@ peak_mem = usqlite.mem_peak()
|
||||
- Configuration parameters are properly formatted
|
||||
|
||||
**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 device group
|
||||
- Preset values match saved configuration
|
||||
@@ -1774,7 +1774,7 @@ peak_mem = usqlite.mem_peak()
|
||||
- Buttons respond to clicks
|
||||
- Sliders update values
|
||||
- Modals open/close
|
||||
- Tabs switch correctly
|
||||
- Zone buttons switch correctly
|
||||
- Preset selector works
|
||||
- Preset creation form validates input
|
||||
- Preset cards display correctly
|
||||
|
||||
114
docs/help.md
Normal file
114
docs/help.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# LED controller — user guide
|
||||
|
||||
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **serial → ESP-NOW bridge** or **Wi-Fi** (TCP to drivers on the LAN), depending on each device’s transport.
|
||||
|
||||
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 zone is highlighted. Extra management buttons appear only in Edit mode.*
|
||||
|
||||
| Mode | Purpose |
|
||||
|------|--------|
|
||||
| **Run mode** | Day-to-day control: choose a zone, tap presets, apply profiles. Management buttons are hidden. |
|
||||
| **Edit mode** | Full setup: zones, 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.
|
||||
|
||||
---
|
||||
|
||||
## Zones
|
||||
|
||||
- **Select a zone**: click its button in the top bar. The main area shows that zone’s preset strip and controls.
|
||||
- **Edit mode — open zone settings**: **right-click** a zone button to change its name, **device IDs** (comma-separated), and which presets appear on the zone. Device identifiers are matched to each device’s **name** when the app builds `select` messages for the driver.
|
||||
- **Zones modal** (Edit mode): create new zones from the header **Zones** button. New zones need a name and device ID list (defaults to `1` if you leave a simple placeholder).
|
||||
- **Brightness slider** (per zone): 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 zone strip
|
||||
|
||||
- **Run and Edit mode**: click the **main part** of a preset tile to **select** that preset on all devices assigned to the current zone (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 zone (so you can **Remove from zone** without deleting the preset from the profile).
|
||||
- **Drag and drop** tiles to reorder them; order is saved for that zone.
|
||||
|
||||

|
||||
|
||||
*The slider controls global brightness for the zone’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 zone**, then selects that preset — **without** `save` on the device (good for auditioning).
|
||||
- **Default**: updates the zone’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 zone** (when you opened the editor from a zone): removes the preset from **this zone’s list only**; the preset remains in the profile for other zones.
|
||||
|
||||

|
||||
|
||||
*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. Zones and presets you see are scoped to that profile.
|
||||
- **Edit mode — Create**: new profiles always get a populated **default** zone. Optionally tick **DJ zone** to also create a `dj` zone (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 zone** in the **current profile**, collects each zone’s preset IDs, and calls **`POST /presets/send`** per zone (including each zone’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) lists pattern names and typical **delay** ranges from the pattern definitions. Choosing a pattern still happens inside the preset editor.
|
||||
|
||||
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge/serial path you configure for preset traffic.
|
||||
|
||||
---
|
||||
|
||||
## 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, Zones, Presets, Help, mode toggle, etc.).
|
||||
|
||||

|
||||
|
||||
*Preset tiles behave the same once a zone is selected.*
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys, pattern **manifest**).
|
||||
- **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
|
||||
|
||||
✅ **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
|
||||
✅ **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
|
||||
✅ **Accessible** - Keyboard accessible and screen reader friendly
|
||||
✅ **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>
|
||||
```
|
||||
|
||||
### 3. Initialize the color picker
|
||||
### 3. Initialize the colour picker
|
||||
|
||||
```javascript
|
||||
const picker = new ColorPicker('#my-color-picker', {
|
||||
@@ -57,8 +57,8 @@ new ColorPicker(container, options)
|
||||
- `options` (object) - Configuration options
|
||||
|
||||
**Options:**
|
||||
- `initialColor` (string) - Initial color in hex format (default: '#FF0000')
|
||||
- `onColorChange` (function) - Callback when color changes (receives hex color string)
|
||||
- `initialColor` (string) - Initial colour in hex format (default: '#FF0000')
|
||||
- `onColorChange` (function) - Callback when colour changes (receives hex colour string)
|
||||
- `showHexInput` (boolean) - Show hex input field (default: true)
|
||||
|
||||
### Methods
|
||||
@@ -101,7 +101,7 @@ const picker = new ColorPicker('#picker1', {
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Color Pickers
|
||||
### Multiple Colour Pickers
|
||||
|
||||
```javascript
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF'];
|
||||
@@ -116,7 +116,7 @@ const pickers = colors.map((color, index) => {
|
||||
});
|
||||
```
|
||||
|
||||
### Dynamic Color Picker Creation
|
||||
### Dynamic Colour Picker Creation
|
||||
|
||||
```javascript
|
||||
function addColorPicker(containerId, initialColor = '#000000') {
|
||||
@@ -139,12 +139,12 @@ addColorPicker('color-2', '#00FF00');
|
||||
|
||||
## 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-preview` - Color preview button
|
||||
- `.color-picker-preview` - Colour preview button
|
||||
- `.color-picker-panel` - Dropdown panel
|
||||
- `.color-picker-main` - Main color area
|
||||
- `.color-picker-main` - Main colour area
|
||||
- `.color-picker-hue` - Hue slider
|
||||
- `.color-picker-controls` - Controls section
|
||||
|
||||
@@ -183,20 +183,20 @@ The color picker uses CSS classes that can be customized:
|
||||
- ✅ iOS 12+
|
||||
- ✅ 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`)
|
||||
- Accepts both uppercase and lowercase input
|
||||
- Automatically validates hex format
|
||||
|
||||
## Integration with LED Driver Mockups
|
||||
|
||||
The color picker is integrated into:
|
||||
- `dashboard.html` - Color selection for patterns
|
||||
- `presets.html` - Color selection when creating/editing presets
|
||||
The colour picker is integrated into:
|
||||
- `dashboard.html` - Colour selection for patterns
|
||||
- `presets.html` - Colour selection when creating/editing presets
|
||||
|
||||
### Example: Getting Colors from Multiple Pickers
|
||||
### Example: Getting Colours from Multiple Pickers
|
||||
|
||||
```javascript
|
||||
const colorPickers = [];
|
||||
@@ -218,7 +218,7 @@ function sendColorsToDevice() {
|
||||
## Performance
|
||||
|
||||
- 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
|
||||
- Memory efficient: No external dependencies
|
||||
|
||||
@@ -235,5 +235,5 @@ Part of the LED Driver project. Use freely in your projects.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tab {
|
||||
.zone {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
@@ -78,16 +78,16 @@
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
.zone.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.zone-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
.zone-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -249,12 +249,12 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('devices')">Devices</button>
|
||||
<button class="tab" onclick="switchTab('groups')">Groups</button>
|
||||
<button class="zone active" onclick="switchTab('devices')">Devices</button>
|
||||
<button class="zone" onclick="switchTab('groups')">Groups</button>
|
||||
</div>
|
||||
|
||||
<!-- Devices Tab -->
|
||||
<div id="devices-tab" class="tab-content active">
|
||||
<!-- Devices Zone -->
|
||||
<div id="devices-zone" class="zone-content active">
|
||||
<div class="card">
|
||||
<h2>Connected Devices</h2>
|
||||
<div class="device-item">
|
||||
@@ -313,8 +313,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups Tab -->
|
||||
<div id="groups-tab" class="tab-content">
|
||||
<!-- Groups Zone -->
|
||||
<div id="groups-zone" class="zone-content">
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>Groups</h2>
|
||||
@@ -386,12 +386,12 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
function switchTab(zone) {
|
||||
document.querySelectorAll('.zone').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.zone-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
event.target.classList.add('active');
|
||||
document.getElementById(tab + '-tab').classList.add('active');
|
||||
document.getElementById(zone + '-zone').classList.add('active');
|
||||
}
|
||||
|
||||
function showAddDeviceModal() {
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
{
|
||||
"grps": [
|
||||
{
|
||||
"n": "group1",
|
||||
"g":{
|
||||
"df": {
|
||||
"pt": "on",
|
||||
"cl": [
|
||||
"000000",
|
||||
"000000"
|
||||
],
|
||||
"br": 100,
|
||||
"dl": 100,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0
|
||||
},
|
||||
{
|
||||
"n": "group2",
|
||||
"pt": "on",
|
||||
"cl": [
|
||||
"000000",
|
||||
"000000"
|
||||
],
|
||||
"br": 100,
|
||||
"cl": ["#ff0000"],
|
||||
"br": 200,
|
||||
"n1": 10,
|
||||
"n2": 10,
|
||||
"n3": 10,
|
||||
"n4": 10,
|
||||
"n5": 10,
|
||||
"n6": 10,
|
||||
"dl": 100
|
||||
},
|
||||
"dj": {
|
||||
"pt": "blink",
|
||||
"cl": ["#00ff00"],
|
||||
"dl": 500
|
||||
}
|
||||
]
|
||||
},
|
||||
"sv": true,
|
||||
"st": 0
|
||||
}
|
||||
1
led-driver
Submodule
1
led-driver
Submodule
Submodule led-driver added at 428ed8b884
1
led-tool
Submodule
1
led-tool
Submodule
Submodule led-tool added at 713cd6e9a1
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.helpers import wraps
|
||||
|
||||
@@ -125,16 +146,61 @@ class Session:
|
||||
return response
|
||||
|
||||
def encode(self, payload, secret_key=None):
|
||||
return jwt.encode(payload, secret_key or self.secret_key,
|
||||
algorithm='HS256')
|
||||
"""Encode session data using JWT if available, otherwise use simple HMAC."""
|
||||
if HAS_JWT:
|
||||
return jwt.encode(payload, secret_key or self.secret_key,
|
||||
algorithm='HS256')
|
||||
else:
|
||||
# Simple encoding for MicroPython: base64(json) + HMAC signature
|
||||
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||
payload_json = json.dumps(payload)
|
||||
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
|
||||
|
||||
# Create HMAC signature
|
||||
if hmac:
|
||||
# Use hmac module if available
|
||||
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||
else:
|
||||
# Fallback: simple SHA256(key + message)
|
||||
h = hashlib.sha256(key + payload_json.encode())
|
||||
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||
|
||||
return f"{payload_b64}.{signature}"
|
||||
|
||||
def decode(self, session, secret_key=None):
|
||||
try:
|
||||
payload = jwt.decode(session, secret_key or self.secret_key,
|
||||
algorithms=['HS256'])
|
||||
except jwt.exceptions.PyJWTError: # pragma: no cover
|
||||
return {}
|
||||
return payload
|
||||
"""Decode session data using JWT if available, otherwise use simple HMAC."""
|
||||
if HAS_JWT:
|
||||
try:
|
||||
payload = jwt.decode(session, secret_key or self.secret_key,
|
||||
algorithms=['HS256'])
|
||||
except jwt.exceptions.PyJWTError: # pragma: no cover
|
||||
return {}
|
||||
return payload
|
||||
else:
|
||||
try:
|
||||
# Simple decoding for MicroPython
|
||||
if '.' not in session:
|
||||
return {}
|
||||
|
||||
payload_b64, signature = session.rsplit('.', 1)
|
||||
payload_json = ubinascii.a2b_base64(payload_b64).decode()
|
||||
|
||||
# Verify HMAC signature
|
||||
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||
if hmac:
|
||||
# Use hmac module if available
|
||||
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||
else:
|
||||
# Fallback: simple SHA256(key + message)
|
||||
h = hashlib.sha256(key + payload_json.encode())
|
||||
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||
|
||||
if signature != expected_signature:
|
||||
return {}
|
||||
|
||||
return json.loads(payload_json)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def with_session(f):
|
||||
|
||||
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
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)"
|
||||
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,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import struct
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
# Connect to the WebSocket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.connect(('192.168.4.1', 80))
|
||||
|
||||
# Send HTTP WebSocket upgrade request
|
||||
key = base64.b64encode(b'test-nonce').decode('utf-8')
|
||||
request = f'''GET /ws HTTP/1.1\r
|
||||
Host: 192.168.4.1\r
|
||||
Upgrade: websocket\r
|
||||
Connection: Upgrade\r
|
||||
Sec-WebSocket-Key: {key}\r
|
||||
Sec-WebSocket-Version: 13\r
|
||||
\r
|
||||
'''
|
||||
s.send(request.encode())
|
||||
|
||||
# Read upgrade response
|
||||
response = s.recv(4096)
|
||||
print(response.decode())
|
||||
|
||||
# Send WebSocket TEXT frame with empty JSON '{}'
|
||||
payload = b'{}'
|
||||
mask = b'\x12\x34\x56\x78'
|
||||
payload_masked = bytes(p ^ mask[i % 4] for i, p in enumerate(payload))
|
||||
|
||||
frame = struct.pack('BB', 0x81, 0x80 | len(payload))
|
||||
frame += mask
|
||||
frame += payload_masked
|
||||
|
||||
s.send(frame)
|
||||
print("Sent empty JSON to WebSocket")
|
||||
s.close()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"session_secret_key": "06a2a6ac631cc8db0658373b37f7fe922a8a3ed3a27229e1adb5448cb368f2b7"}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,6 @@
|
||||
import settings
|
||||
import util.wifi as wifi
|
||||
# Boot script (ESP only; no-op on Pi)
|
||||
import settings # noqa: F401
|
||||
from settings import Settings
|
||||
|
||||
s = Settings()
|
||||
|
||||
name = s.get('name', 'led-controller')
|
||||
wifi.ap(name, '')
|
||||
# AP setup was here when running on ESP; Pi uses system networking.
|
||||
|
||||
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.
393
src/controllers/device.py
Normal file
393
src/controllers/device.py
Normal file
@@ -0,0 +1,393 @@
|
||||
from microdot import Microdot
|
||||
from models.device import (
|
||||
Device,
|
||||
derive_device_mac,
|
||||
validate_device_transport,
|
||||
validate_device_type,
|
||||
)
|
||||
from models.transport import get_current_sender
|
||||
from models.wifi_ws_clients import (
|
||||
normalize_tcp_peer_ip,
|
||||
send_json_line_to_ip,
|
||||
tcp_client_connected,
|
||||
)
|
||||
from util.driver_patterns import driver_patterns_dir
|
||||
from util.espnow_message import build_message
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from urllib.parse import quote
|
||||
|
||||
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
|
||||
_IDENTIFY_PRESET_KEY = "__identify"
|
||||
|
||||
# Short-key payload: 10 Hz full cycle = 50 ms on + 50 ms off (driver ``blink`` toggles each ``d`` ms).
|
||||
_IDENTIFY_DRIVER_PRESET = {
|
||||
"p": "blink",
|
||||
"c": ["#ff0000"],
|
||||
"d": 50,
|
||||
"b": 128,
|
||||
"a": True,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
}
|
||||
|
||||
|
||||
def _compact_v1_json(*, presets=None, select=None, save=False):
|
||||
"""Single-line v1 object; compact so serial/ESP-NOW stays small."""
|
||||
body = {"v": "1"}
|
||||
if presets is not None:
|
||||
body["presets"] = presets
|
||||
if save:
|
||||
body["save"] = True
|
||||
if select is not None:
|
||||
body["select"] = select
|
||||
return json.dumps(body, separators=(",", ":"))
|
||||
|
||||
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
|
||||
IDENTIFY_OFF_DELAY_S = 2.0
|
||||
|
||||
controller = Microdot()
|
||||
devices = Device()
|
||||
|
||||
|
||||
def _device_live_connected(dev_dict):
|
||||
"""
|
||||
Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
|
||||
ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
|
||||
"""
|
||||
tr = (dev_dict.get("transport") or "espnow").strip().lower()
|
||||
if tr != "wifi":
|
||||
return None
|
||||
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
|
||||
if not ip:
|
||||
return False
|
||||
return tcp_client_connected(ip)
|
||||
|
||||
|
||||
def _device_json_with_live_status(dev_dict):
|
||||
row = dict(dev_dict)
|
||||
row["connected"] = _device_live_connected(dev_dict)
|
||||
return row
|
||||
|
||||
|
||||
def _safe_pattern_filename(name):
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
if not name.endswith(".py"):
|
||||
return False
|
||||
if "/" in name or "\\" in name or ".." in name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, timeout_s=10.0):
|
||||
"""POST source to driver /patterns/upload?name=...&reload=...; return True on 2xx."""
|
||||
if not isinstance(ip, str) or not ip.strip():
|
||||
return False
|
||||
if not isinstance(filename, str) or not filename:
|
||||
return False
|
||||
if not isinstance(code_text, str):
|
||||
return False
|
||||
|
||||
name_q = quote(filename, safe="")
|
||||
reload_q = "1" if reload_patterns else "0"
|
||||
path = "/patterns/upload?name=%s&reload=%s" % (name_q, reload_q)
|
||||
body = code_text.encode("utf-8")
|
||||
req = (
|
||||
"POST %s HTTP/1.1\r\n"
|
||||
"Host: %s\r\n"
|
||||
"Content-Type: text/plain; charset=utf-8\r\n"
|
||||
"Content-Length: %d\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n" % (path, ip, len(body))
|
||||
).encode("utf-8") + body
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(timeout_s)
|
||||
sock.connect((ip.strip(), 80))
|
||||
sock.sendall(req)
|
||||
data = b""
|
||||
while True:
|
||||
chunk = sock.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
first_line = data.split(b"\r\n", 1)[0] if data else b""
|
||||
return b" 2" in first_line
|
||||
|
||||
|
||||
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
|
||||
try:
|
||||
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||
off_msg = build_message(select={name: ["off"]})
|
||||
if transport == "wifi":
|
||||
await send_json_line_to_ip(wifi_ip, off_msg)
|
||||
else:
|
||||
await sender.send(off_msg, addr=dev_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@controller.get("")
|
||||
async def list_devices(request):
|
||||
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
||||
devices_data = {}
|
||||
for dev_id in devices.list():
|
||||
d = devices.read(dev_id)
|
||||
if d:
|
||||
devices_data[dev_id] = _device_json_with_live_status(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 (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
||||
dev = devices.read(id)
|
||||
if dev:
|
||||
return json.dumps(_device_json_with_live_status(dev)), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("")
|
||||
async def create_device(request):
|
||||
"""Create a new device."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
return json.dumps({"error": "name is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
device_type = validate_device_type(data.get("type", "led"))
|
||||
transport = validate_device_transport(data.get("transport", "espnow"))
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
address = data.get("address")
|
||||
mac = data.get("mac")
|
||||
if derive_device_mac(mac=mac, address=address, transport=transport) is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address"
|
||||
}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
default_pattern = data.get("default_pattern")
|
||||
zl = data.get("zones")
|
||||
if isinstance(zl, list):
|
||||
zl = [str(t) for t in zl]
|
||||
else:
|
||||
zl = []
|
||||
dev_id = devices.create(
|
||||
name=name,
|
||||
address=address,
|
||||
mac=mac,
|
||||
default_pattern=default_pattern,
|
||||
zones=zl,
|
||||
device_type=device_type,
|
||||
transport=transport,
|
||||
)
|
||||
dev = devices.read(dev_id)
|
||||
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
code = 409 if "already exists" in msg.lower() else 400
|
||||
return json.dumps({"error": msg}), code, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.put("/<id>")
|
||||
async def update_device(request, id):
|
||||
"""Update a device."""
|
||||
try:
|
||||
raw = request.json or {}
|
||||
data = dict(raw)
|
||||
data.pop("id", None)
|
||||
data.pop("addresses", None)
|
||||
data.pop("connected", None)
|
||||
if "name" in data:
|
||||
n = (data.get("name") or "").strip()
|
||||
if not n:
|
||||
return json.dumps({"error": "name cannot be empty"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
data["name"] = n
|
||||
if "type" in data:
|
||||
data["type"] = validate_device_type(data.get("type"))
|
||||
if "transport" in data:
|
||||
data["transport"] = validate_device_transport(data.get("transport"))
|
||||
if "zones" in data and isinstance(data["zones"], list):
|
||||
data["zones"] = [str(t) for t in data["zones"]]
|
||||
if devices.update(id, data):
|
||||
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.delete("/<id>")
|
||||
async def delete_device(request, id):
|
||||
"""Delete a device."""
|
||||
if devices.delete(id):
|
||||
return (
|
||||
json.dumps({"message": "Device deleted successfully"}),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/<id>/identify")
|
||||
async def identify_device(request, id):
|
||||
"""
|
||||
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
|
||||
this device name — same combined shape as profile sends the driver already accepts over TCP
|
||||
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
name = str(dev.get("name") or "").strip()
|
||||
if not name:
|
||||
return json.dumps({"error": "Device must have a name to identify"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
transport = dev.get("transport") or "espnow"
|
||||
wifi_ip = None
|
||||
if transport == "wifi":
|
||||
wifi_ip = dev.get("address")
|
||||
if not wifi_ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
msg = _compact_v1_json(
|
||||
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||
select={name: [_IDENTIFY_PRESET_KEY]},
|
||||
)
|
||||
if transport == "wifi":
|
||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
else:
|
||||
await sender.send(msg, addr=id)
|
||||
|
||||
asyncio.create_task(
|
||||
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||
return json.dumps({"message": "Identify sent"}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/<id>/patterns/push")
|
||||
async def push_patterns_ota(request, id):
|
||||
"""
|
||||
Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload.
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
wifi_ip = str(dev.get("address") or "").strip()
|
||||
if not wifi_ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
base_dir = driver_patterns_dir()
|
||||
try:
|
||||
names = sorted(os.listdir(base_dir))
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
files = [n for n in names if _safe_pattern_filename(n) and n != "__init__.py"]
|
||||
if not files:
|
||||
return json.dumps({"error": "No pattern files found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
sent = []
|
||||
failed = []
|
||||
total = len(files)
|
||||
for idx, filename in enumerate(files):
|
||||
path = os.path.join(base_dir, filename)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
code = f.read()
|
||||
except OSError:
|
||||
failed.append(filename)
|
||||
continue
|
||||
reload_patterns = idx == (total - 1)
|
||||
ok = _http_post_pattern_source(
|
||||
wifi_ip,
|
||||
filename,
|
||||
code,
|
||||
reload_patterns=reload_patterns,
|
||||
timeout_s=10.0,
|
||||
)
|
||||
if ok:
|
||||
sent.append(filename)
|
||||
else:
|
||||
failed.append(filename)
|
||||
|
||||
if not sent:
|
||||
return json.dumps({"error": "Wi-Fi driver did not accept pattern uploads", "failed": failed}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
return json.dumps({
|
||||
"message": "Pattern files uploaded",
|
||||
"sent_count": len(sent),
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
189
src/controllers/led_tool.py
Normal file
189
src/controllers/led_tool.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from microdot import Microdot
|
||||
from serial.tools import list_ports
|
||||
|
||||
controller = Microdot()
|
||||
|
||||
|
||||
def _repo_root() -> str:
|
||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
|
||||
def _led_cli_path() -> str:
|
||||
return os.path.join(_repo_root(), "led-tool", "cli.py")
|
||||
|
||||
|
||||
def _build_led_cli_command(port: str, payload: dict):
|
||||
cmd = [sys.executable, _led_cli_path(), "--port", port]
|
||||
|
||||
flag_map = (
|
||||
("name", "--name"),
|
||||
("led_pin", "--pin"),
|
||||
("num_leds", "--leds"),
|
||||
("brightness", "--brightness"),
|
||||
("transport", "--transport"),
|
||||
("ssid", "--ssid"),
|
||||
("password", "--wifi-password"),
|
||||
("wifi_channel", "--wifi-channel"),
|
||||
("default", "--default"),
|
||||
)
|
||||
|
||||
for key, flag in flag_map:
|
||||
value = payload.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
value_str = str(value).strip()
|
||||
if value_str == "":
|
||||
continue
|
||||
cmd.extend([flag, value_str])
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def _run_led_cli_command(cmd, cli_path: str, timeout_s=180):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
cwd=os.path.dirname(cli_path),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return (
|
||||
json.dumps({"error": "led-tool command timed out after 180 seconds"}),
|
||||
504,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
except Exception as exc:
|
||||
return (
|
||||
json.dumps({"error": str(exc)}),
|
||||
500,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
"ok": result.returncode == 0,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"command": cmd,
|
||||
}
|
||||
),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
def _extract_settings_from_stdout(stdout: str):
|
||||
text = (stdout or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@controller.get("/ports")
|
||||
async def list_serial_ports(request):
|
||||
ports = []
|
||||
for info in list_ports.comports():
|
||||
ports.append(
|
||||
{
|
||||
"device": info.device,
|
||||
"description": info.description,
|
||||
"hwid": info.hwid,
|
||||
}
|
||||
)
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
"ports": ports,
|
||||
"led_cli_exists": os.path.exists(_led_cli_path()),
|
||||
}
|
||||
),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
@controller.post("/settings")
|
||||
async def apply_settings(request):
|
||||
data = request.json or {}
|
||||
port = str(data.get("port") or "").strip()
|
||||
if not port:
|
||||
return (
|
||||
json.dumps({"error": "port is required"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
cli_path = _led_cli_path()
|
||||
if not os.path.exists(cli_path):
|
||||
return (
|
||||
json.dumps({"error": "led-tool/cli.py not found"}),
|
||||
500,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
cmd = _build_led_cli_command(port, data) + ["--follow"]
|
||||
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
|
||||
|
||||
|
||||
@controller.post("/reset")
|
||||
@controller.post("/reset/")
|
||||
async def reset_device(request):
|
||||
data = request.json or {}
|
||||
port = str(data.get("port") or "").strip()
|
||||
if not port:
|
||||
return (
|
||||
json.dumps({"error": "port is required"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
cli_path = _led_cli_path()
|
||||
if not os.path.exists(cli_path):
|
||||
return (
|
||||
json.dumps({"error": "led-tool/cli.py not found"}),
|
||||
500,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
cmd = [sys.executable, cli_path, "--port", port, "--reset", "--follow"]
|
||||
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
|
||||
|
||||
|
||||
@controller.get("/settings")
|
||||
async def read_settings(request):
|
||||
port = str(request.args.get("port") or "").strip()
|
||||
if not port:
|
||||
return (
|
||||
json.dumps({"error": "port is required"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
cli_path = _led_cli_path()
|
||||
if not os.path.exists(cli_path):
|
||||
return (
|
||||
json.dumps({"error": "led-tool/cli.py not found"}),
|
||||
500,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
cmd = [sys.executable, cli_path, "--port", port, "--show"]
|
||||
body, status, headers = _run_led_cli_command(cmd, cli_path)
|
||||
if status != 200:
|
||||
return body, status, headers
|
||||
data = json.loads(body)
|
||||
data["settings"] = _extract_settings_from_stdout(data.get("stdout") or "")
|
||||
return json.dumps(data), status, headers
|
||||
@@ -8,14 +8,18 @@ palettes = Palette()
|
||||
@controller.get('')
|
||||
async def list_palettes(request):
|
||||
"""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>')
|
||||
async def get_palette(request, id):
|
||||
"""Get a specific palette by ID."""
|
||||
palette = palettes.read(id)
|
||||
if palette:
|
||||
return json.dumps(palette), 200, {'Content-Type': 'application/json'}
|
||||
if str(id) in palettes:
|
||||
palette = palettes.read(id)
|
||||
return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
@@ -23,12 +27,11 @@ async def create_palette(request):
|
||||
"""Create a new palette."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "")
|
||||
colors = data.get("colors", None)
|
||||
palette_id = palettes.create(name, colors)
|
||||
if data:
|
||||
palettes.update(palette_id, data)
|
||||
return json.dumps(palettes.read(palette_id)), 201, {'Content-Type': 'application/json'}
|
||||
# Palette no longer needs a name; only colors are stored.
|
||||
palette_id = palettes.create("", colors)
|
||||
created_colors = palettes.read(palette_id) or []
|
||||
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@@ -36,9 +39,13 @@ async def create_palette(request):
|
||||
async def update_palette(request, id):
|
||||
"""Update an existing palette."""
|
||||
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):
|
||||
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
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@@ -1,19 +1,113 @@
|
||||
from microdot import Microdot
|
||||
from models.pattern import Pattern
|
||||
from models.device import Device
|
||||
from util.driver_patterns import (
|
||||
driver_patterns_dir,
|
||||
is_firmware_builtin_pattern_module,
|
||||
normalize_pattern_py_filename,
|
||||
)
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
import os
|
||||
import socket
|
||||
from urllib.parse import quote
|
||||
|
||||
controller = Microdot()
|
||||
patterns = Pattern()
|
||||
|
||||
|
||||
def _project_root():
|
||||
"""Project root (parent of ``src/``). CWD is often ``src/`` when running ``main.py``."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.abspath(os.path.join(here, "..", ".."))
|
||||
|
||||
|
||||
def _safe_pattern_filename(name):
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
if not name.endswith(".py"):
|
||||
return False
|
||||
if "/" in name or "\\" in name or ".." in name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_PATTERN_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
|
||||
|
||||
|
||||
def _normalize_pattern_key(raw):
|
||||
"""Pattern id / module basename (no .py)."""
|
||||
if not isinstance(raw, str):
|
||||
return ""
|
||||
s = raw.strip()
|
||||
if s.lower().endswith(".py"):
|
||||
s = s[:-3].strip()
|
||||
return s
|
||||
|
||||
|
||||
def _valid_pattern_key(key):
|
||||
return bool(key and _PATTERN_KEY_RE.match(key))
|
||||
|
||||
|
||||
def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, timeout_s=10.0):
|
||||
"""POST source to driver /patterns/upload?name=...&reload=...; return True on 2xx."""
|
||||
if not isinstance(ip, str) or not ip.strip():
|
||||
return False
|
||||
if not isinstance(filename, str) or not filename:
|
||||
return False
|
||||
if not isinstance(code_text, str):
|
||||
return False
|
||||
|
||||
name_q = quote(filename, safe="")
|
||||
reload_q = "1" if reload_patterns else "0"
|
||||
path = "/patterns/upload?name=%s&reload=%s" % (name_q, reload_q)
|
||||
body = code_text.encode("utf-8")
|
||||
req = (
|
||||
"POST %s HTTP/1.1\r\n"
|
||||
"Host: %s\r\n"
|
||||
"Content-Type: text/plain; charset=utf-8\r\n"
|
||||
"Content-Length: %d\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n" % (path, ip, len(body))
|
||||
).encode("utf-8") + body
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(timeout_s)
|
||||
sock.connect((ip.strip(), 80))
|
||||
sock.sendall(req)
|
||||
data = b""
|
||||
while True:
|
||||
chunk = sock.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
first_line = data.split(b"\r\n", 1)[0] if data else b""
|
||||
# Accept any 2xx status.
|
||||
return b" 2" in first_line
|
||||
|
||||
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']
|
||||
root = _project_root()
|
||||
paths = [
|
||||
os.path.join(root, "db", "pattern.json"),
|
||||
os.path.join(root, "pattern.json"),
|
||||
"db/pattern.json",
|
||||
"pattern.json",
|
||||
"/db/pattern.json",
|
||||
]
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except OSError:
|
||||
continue
|
||||
@@ -22,16 +116,333 @@ def load_pattern_definitions():
|
||||
print(f"Error loading pattern.json: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def load_driver_pattern_names():
|
||||
"""List available pattern module names from led-driver/src/patterns."""
|
||||
try:
|
||||
names = []
|
||||
for filename in os.listdir(driver_patterns_dir()):
|
||||
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
continue
|
||||
names.append(filename[:-3])
|
||||
names.sort()
|
||||
return names
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def build_runtime_pattern_map():
|
||||
"""
|
||||
Runtime pattern map for UI menus.
|
||||
Keep pattern DB metadata as primary, then add any local driver pattern files
|
||||
missing from the DB so new OTA files still appear in menus.
|
||||
"""
|
||||
definitions = load_pattern_definitions()
|
||||
available = load_driver_pattern_names()
|
||||
result = {}
|
||||
for name, meta in definitions.items():
|
||||
result[name] = dict(meta) if isinstance(meta, dict) else {}
|
||||
for name in available:
|
||||
if name not in result:
|
||||
result[name] = {}
|
||||
return result
|
||||
|
||||
@controller.get('/definitions')
|
||||
async def get_pattern_definitions(request):
|
||||
"""Get pattern definitions from pattern.json."""
|
||||
definitions = load_pattern_definitions()
|
||||
"""Get definitions for patterns currently available on the driver."""
|
||||
definitions = build_runtime_pattern_map()
|
||||
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.get('/ota/manifest')
|
||||
async def ota_manifest(request):
|
||||
"""Manifest of driver pattern source files for OTA pulls."""
|
||||
base_dir = driver_patterns_dir()
|
||||
host = request.headers.get("Host", "")
|
||||
if not host:
|
||||
return json.dumps({"error": "Missing Host header"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
try:
|
||||
names = sorted(os.listdir(base_dir))
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
|
||||
files = []
|
||||
for name in names:
|
||||
if not _safe_pattern_filename(name) or name == "__init__.py":
|
||||
continue
|
||||
files.append({
|
||||
"name": name,
|
||||
"url": "http://%s/patterns/ota/file/%s" % (host, name),
|
||||
})
|
||||
|
||||
return json.dumps({"files": files}), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get('/ota/file/<name>')
|
||||
async def ota_pattern_file(request, name):
|
||||
"""Serve one driver pattern source file for OTA pulls."""
|
||||
fname = normalize_pattern_py_filename(name)
|
||||
if not fname or not _safe_pattern_filename(fname) or fname == "__init__.py":
|
||||
return json.dumps({"error": "Invalid filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if is_firmware_builtin_pattern_module(fname):
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "on and off are built into the driver firmware; there is no module file to serve.",
|
||||
}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
base = driver_patterns_dir()
|
||||
path = os.path.join(base, fname)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "Pattern file not found",
|
||||
"path": path,
|
||||
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
|
||||
}
|
||||
), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
|
||||
|
||||
|
||||
@controller.post('/<name>/send')
|
||||
async def send_pattern_to_device(request, name):
|
||||
"""Push one pattern source file directly to Wi-Fi driver(s) over HTTP."""
|
||||
if not isinstance(name, str):
|
||||
return json.dumps({"error": "Invalid pattern name"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
filename = normalize_pattern_py_filename(name)
|
||||
if not filename or not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
return json.dumps({"error": "Invalid pattern filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if is_firmware_builtin_pattern_module(filename):
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "on and off are built into the driver firmware; send does not apply.",
|
||||
}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
devices = Device()
|
||||
body = request.json or {}
|
||||
requested_device_id = str(body.get("device_id") or "").strip()
|
||||
|
||||
base = driver_patterns_dir()
|
||||
path = os.path.join(base, filename)
|
||||
if not os.path.exists(path):
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "Pattern file not found",
|
||||
"path": path,
|
||||
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
|
||||
}
|
||||
), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
source = f.read()
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
target_ids = []
|
||||
if requested_device_id:
|
||||
dev = devices.read(requested_device_id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
target_ids = [requested_device_id]
|
||||
else:
|
||||
for did in devices.list():
|
||||
dev = devices.read(did) or {}
|
||||
if (dev.get("transport") or "").lower() == "wifi":
|
||||
target_ids.append(str(did))
|
||||
if not target_ids:
|
||||
return json.dumps({"error": "No Wi-Fi devices found"}), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
sent_ids = []
|
||||
for did in target_ids:
|
||||
dev = devices.read(did) or {}
|
||||
ip = str(dev.get("address") or "").strip()
|
||||
if not ip:
|
||||
continue
|
||||
ok = _http_post_pattern_source(ip, filename, source, reload_patterns=True, timeout_s=10.0)
|
||||
if ok:
|
||||
sent_ids.append(did)
|
||||
|
||||
if not sent_ids:
|
||||
return json.dumps({"error": "No Wi-Fi drivers accepted pattern upload"}), 503, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
@controller.post('/upload')
|
||||
async def upload_pattern_file(request):
|
||||
"""
|
||||
Upload a pattern source file to led-controller local storage.
|
||||
|
||||
Body JSON:
|
||||
{
|
||||
"name": "sparkle.py" | "sparkle",
|
||||
"code": "class Sparkle: ...",
|
||||
"overwrite": true | false # optional, default true
|
||||
}
|
||||
"""
|
||||
data = request.json or {}
|
||||
raw_name = data.get("name") or data.get("filename")
|
||||
code = data.get("code")
|
||||
overwrite = data.get("overwrite", True)
|
||||
overwrite = bool(overwrite)
|
||||
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
return json.dumps({"error": "name is required"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
filename = raw_name.strip()
|
||||
if not filename.endswith(".py"):
|
||||
filename += ".py"
|
||||
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
return json.dumps({"error": "invalid pattern filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if is_firmware_builtin_pattern_module(filename):
|
||||
return json.dumps(
|
||||
{"error": "on and off are built into the driver firmware; use a different pattern name."}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return json.dumps({"error": "code is required"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
path = os.path.join(driver_patterns_dir(), filename)
|
||||
exists = os.path.exists(path)
|
||||
if exists and not overwrite:
|
||||
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write(code)
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
|
||||
return json.dumps({
|
||||
"message": "Pattern uploaded",
|
||||
"name": filename,
|
||||
"overwrote": bool(exists),
|
||||
}), 201, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post('/driver')
|
||||
async def create_driver_pattern(request):
|
||||
"""
|
||||
Create a driver pattern: save ``.py`` under led-driver/src/patterns and
|
||||
metadata in db/pattern.json (Pattern model).
|
||||
|
||||
Body JSON:
|
||||
name, code (required),
|
||||
min_delay, max_delay, max_colors (optional numbers),
|
||||
n1..n8 (optional string labels),
|
||||
overwrite (optional, default true).
|
||||
"""
|
||||
data = request.json or {}
|
||||
key = _normalize_pattern_key(data.get("name") or "")
|
||||
if not _valid_pattern_key(key):
|
||||
return json.dumps({
|
||||
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
|
||||
}), 400, {"Content-Type": "application/json"}
|
||||
if is_firmware_builtin_pattern_module(key):
|
||||
return json.dumps(
|
||||
{"error": "on and off are built into the driver firmware; use a different pattern name."}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
code = data.get("code")
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
overwrite = bool(data.get("overwrite", True))
|
||||
|
||||
filename = key + ".py"
|
||||
py_path = os.path.join(driver_patterns_dir(), filename)
|
||||
if os.path.exists(py_path) and not overwrite:
|
||||
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
meta = {}
|
||||
for fld in ("min_delay", "max_delay", "max_colors"):
|
||||
if fld not in data:
|
||||
continue
|
||||
try:
|
||||
meta[fld] = int(data[fld])
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps({"error": "%s must be an integer" % fld}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
for i in range(1, 9):
|
||||
nk = "n%d" % i
|
||||
if nk not in data:
|
||||
continue
|
||||
lab = data[nk]
|
||||
if lab is None:
|
||||
continue
|
||||
s = str(lab).strip()
|
||||
if s:
|
||||
meta[nk] = s
|
||||
|
||||
try:
|
||||
with open(py_path, "w") as f:
|
||||
f.write(code)
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
|
||||
if patterns.read(key):
|
||||
patterns.update(key, meta)
|
||||
else:
|
||||
patterns.create(key, meta)
|
||||
|
||||
return json.dumps({
|
||||
"message": "Pattern created",
|
||||
"name": key,
|
||||
"file": filename,
|
||||
"metadata": patterns.read(key),
|
||||
}), 201, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get('')
|
||||
async def list_patterns(request):
|
||||
"""List all patterns."""
|
||||
return json.dumps(patterns), 200, {'Content-Type': 'application/json'}
|
||||
"""List patterns for UI (DB metadata + local driver additions)."""
|
||||
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.get('/<id>')
|
||||
@@ -47,11 +458,23 @@ async def get_pattern(request, id):
|
||||
async def create_pattern(request):
|
||||
"""Create a new pattern."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "")
|
||||
pattern_id = patterns.create(name, data.get("data", {}))
|
||||
if data:
|
||||
patterns.update(pattern_id, data)
|
||||
payload = request.json or {}
|
||||
name = payload.get("name", "")
|
||||
pattern_data = payload.get("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'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@@ -1,49 +1,322 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.preset import Preset
|
||||
from models.profile import Profile
|
||||
from models.device import Device, normalize_mac
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||
from util.espnow_message import build_message, build_preset_dict
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
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('')
|
||||
async def list_presets(request):
|
||||
"""List all presets."""
|
||||
return json.dumps(presets), 200, {'Content-Type': 'application/json'}
|
||||
@with_session
|
||||
async def list_presets(request, session):
|
||||
"""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>')
|
||||
async def get_preset(request, id):
|
||||
"""Get a specific preset by ID."""
|
||||
preset = presets.read(id)
|
||||
if preset:
|
||||
@controller.get('/<preset_id>')
|
||||
@with_session
|
||||
async def get_preset(request, session, preset_id):
|
||||
"""Get a specific preset by ID (current profile only)."""
|
||||
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({"error": "Preset not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
async def create_preset(request):
|
||||
"""Create a new preset."""
|
||||
@with_session
|
||||
async def create_preset(request, session):
|
||||
"""Create a new preset for the current profile."""
|
||||
try:
|
||||
data = request.json
|
||||
preset_id = presets.create()
|
||||
try:
|
||||
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):
|
||||
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
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/<id>')
|
||||
async def update_preset(request, id):
|
||||
"""Update an existing preset."""
|
||||
@controller.put('/<preset_id>')
|
||||
@with_session
|
||||
async def update_preset(request, session, preset_id):
|
||||
"""Update an existing preset (current profile only)."""
|
||||
try:
|
||||
data = request.json
|
||||
if presets.update(id, data):
|
||||
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
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
|
||||
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
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<id>')
|
||||
async def delete_preset(request, id):
|
||||
"""Delete a preset."""
|
||||
if presets.delete(id):
|
||||
@controller.delete('/<preset_id>')
|
||||
@with_session
|
||||
async def delete_preset(request, *args, **kwargs):
|
||||
"""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({"error": "Preset not found"}), 404
|
||||
|
||||
|
||||
@controller.post('/send')
|
||||
@with_session
|
||||
async def send_presets(request, session):
|
||||
"""
|
||||
Send one or more presets to LED drivers (serial/ESP-NOW and/or TCP Wi-Fi clients).
|
||||
|
||||
Body JSON:
|
||||
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
|
||||
Optional "targets": ["aabbccddeeff", ...] — registry MACs. When set: preset
|
||||
chunks are ESP-NOW broadcast once each; Wi-Fi drivers get the same chunks
|
||||
over TCP; if "default" is set, each target then gets a unicast default
|
||||
message (serial or TCP) with that device name in "targets".
|
||||
Omit targets for broadcast-only serial (legacy).
|
||||
|
||||
Optional "destination_mac" / "to": single MAC when targets is omitted.
|
||||
"""
|
||||
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')
|
||||
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'}
|
||||
|
||||
MAX_BYTES = 240
|
||||
send_delay_s = 0.1
|
||||
entries = list(presets_by_name.items())
|
||||
total_presets = len(entries)
|
||||
|
||||
batch = {}
|
||||
chunk_messages = []
|
||||
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
|
||||
else:
|
||||
chunk_messages.append(
|
||||
build_message(
|
||||
presets=dict(batch),
|
||||
save=False,
|
||||
default=None,
|
||||
)
|
||||
)
|
||||
batch = {name: preset_obj}
|
||||
|
||||
if batch:
|
||||
chunk_messages.append(
|
||||
build_message(
|
||||
presets=dict(batch),
|
||||
save=save_flag,
|
||||
default=default_id,
|
||||
)
|
||||
)
|
||||
|
||||
target_list = None
|
||||
raw_targets = data.get("targets")
|
||||
if isinstance(raw_targets, list) and raw_targets:
|
||||
target_list = []
|
||||
for t in raw_targets:
|
||||
m = normalize_mac(str(t))
|
||||
if m:
|
||||
target_list.append(m)
|
||||
target_list = list(dict.fromkeys(target_list))
|
||||
if not target_list:
|
||||
target_list = None
|
||||
elif destination_mac:
|
||||
dm = normalize_mac(str(destination_mac))
|
||||
target_list = [dm] if dm else None
|
||||
|
||||
try:
|
||||
if target_list:
|
||||
deliveries = await deliver_preset_broadcast_then_per_device(
|
||||
sender,
|
||||
chunk_messages,
|
||||
target_list,
|
||||
Device(),
|
||||
str(default_id) if default_id is not None else None,
|
||||
delay_s=send_delay_s,
|
||||
)
|
||||
else:
|
||||
deliveries, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
chunk_messages,
|
||||
None,
|
||||
Device(),
|
||||
delay_s=send_delay_s,
|
||||
)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
return json.dumps({
|
||||
"message": "Presets sent",
|
||||
"presets_sent": total_presets,
|
||||
"messages_sent": deliveries,
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.post('/push')
|
||||
@with_session
|
||||
async def push_driver_messages(request, session):
|
||||
"""
|
||||
Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP).
|
||||
|
||||
Body:
|
||||
{"sequence": [{ "v": "1", ... }, ...], "targets": ["mac", ...]}
|
||||
or a single {"payload": {...}, "targets": [...]}.
|
||||
"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
except Exception:
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
seq = data.get("sequence")
|
||||
if not seq and data.get("payload") is not None:
|
||||
seq = [data["payload"]]
|
||||
if not isinstance(seq, list) or not seq:
|
||||
return json.dumps({"error": "sequence or payload required"}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
raw_targets = data.get("targets")
|
||||
target_list = None
|
||||
if isinstance(raw_targets, list) and raw_targets:
|
||||
target_list = []
|
||||
for t in raw_targets:
|
||||
m = normalize_mac(str(t))
|
||||
if m:
|
||||
target_list.append(m)
|
||||
target_list = list(dict.fromkeys(target_list))
|
||||
if not target_list:
|
||||
target_list = None
|
||||
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
messages = []
|
||||
for item in seq:
|
||||
if isinstance(item, dict):
|
||||
messages.append(json.dumps(item))
|
||||
elif isinstance(item, str):
|
||||
messages.append(item)
|
||||
else:
|
||||
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
delay_s = data.get("delay_s", 0.05)
|
||||
try:
|
||||
delay_s = float(delay_s)
|
||||
except (TypeError, ValueError):
|
||||
delay_s = 0.05
|
||||
|
||||
try:
|
||||
deliveries, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
messages,
|
||||
target_list,
|
||||
Device(),
|
||||
delay_s=delay_s,
|
||||
)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
return json.dumps({
|
||||
"message": "Delivered",
|
||||
"deliveries": deliveries,
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.profile import Profile
|
||||
from models.zone import Zone
|
||||
from models.preset import Preset
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
profiles = Profile()
|
||||
zones = Zone()
|
||||
presets = Preset()
|
||||
|
||||
@controller.get('')
|
||||
async def list_profiles(request):
|
||||
"""List all profiles."""
|
||||
return json.dumps(profiles), 200, {'Content-Type': 'application/json'}
|
||||
@with_session
|
||||
async def list_profiles(request, session):
|
||||
"""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')
|
||||
@with_session
|
||||
@@ -17,6 +43,8 @@ async def get_current_profile(request, session):
|
||||
"""Get the current profile ID from session (or fallback)."""
|
||||
profile_list = profiles.list()
|
||||
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:
|
||||
current_id = profile_list[0]
|
||||
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
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_profile(request, id):
|
||||
@with_session
|
||||
async def get_profile(request, id, session):
|
||||
"""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)
|
||||
if profile:
|
||||
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
||||
@@ -48,12 +81,273 @@ async def apply_profile(request, session, id):
|
||||
async def create_profile(request):
|
||||
"""Create a new profile."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = dict(request.json or {})
|
||||
name = data.get("name", "")
|
||||
seed_raw = data.get("seed_dj_zone", False)
|
||||
if isinstance(seed_raw, str):
|
||||
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
seed_dj_zone = bool(seed_raw)
|
||||
# Request-only flag: do not persist on profile records.
|
||||
data.pop("seed_dj_zone", None)
|
||||
profile_id = profiles.create(name)
|
||||
# Avoid persisting request-only fields.
|
||||
data.pop("name", None)
|
||||
if 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 zone 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": "Colour Cycle",
|
||||
"pattern": "colour_cycle",
|
||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
"n1": 1,
|
||||
},
|
||||
{
|
||||
"name": "transition",
|
||||
"pattern": "transition",
|
||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 500,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "flicker",
|
||||
"pattern": "flicker",
|
||||
"colors": ["#FFB84D"],
|
||||
"brightness": 255,
|
||||
"delay": 80,
|
||||
"auto": True,
|
||||
"n1": 30,
|
||||
},
|
||||
{
|
||||
"name": "flame",
|
||||
"pattern": "flame",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 50,
|
||||
"auto": True,
|
||||
"n1": 35,
|
||||
"n2": 2600,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
},
|
||||
{
|
||||
"name": "twinkle",
|
||||
"pattern": "twinkle",
|
||||
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
||||
"brightness": 255,
|
||||
"delay": 55,
|
||||
"auto": True,
|
||||
"n1": 72,
|
||||
"n2": 140,
|
||||
"n3": 2,
|
||||
"n4": 6,
|
||||
},
|
||||
]
|
||||
|
||||
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 = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||
zones.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("zones", []) if isinstance(profile.get("zones", []), list) else []
|
||||
profile_tabs.append(str(default_tab_id))
|
||||
|
||||
if seed_dj_zone:
|
||||
# Seed a DJ-focused zone 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 = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||
zones.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, {"zones": 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", "zones")
|
||||
|
||||
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("zones")
|
||||
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
|
||||
source_tabs = source.get("zone_order", [])
|
||||
source_tabs = source_tabs or []
|
||||
cloned_tab_ids = []
|
||||
preset_id_map = {}
|
||||
new_tabs = {}
|
||||
new_presets = {}
|
||||
for zone_id in source_tabs:
|
||||
zone = zones.read(zone_id)
|
||||
if not zone:
|
||||
continue
|
||||
tab_name = zone.get("name") or f"Zone {zone_id}"
|
||||
clone_name = tab_name
|
||||
mapped_presets = map_preset_container(zone.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||
clone_id = allocate_id(zones, tab_cache)
|
||||
clone_data = {
|
||||
"name": clone_name,
|
||||
"names": zone.get("names") or [],
|
||||
"presets": mapped_presets if mapped_presets is not None else []
|
||||
}
|
||||
extra = {k: v for k, v in zone.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,
|
||||
"zones": 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():
|
||||
zones[tid] = tdata
|
||||
profiles[str(new_profile_id)] = new_profile_data
|
||||
|
||||
profiles._palette_model.save()
|
||||
presets.save()
|
||||
zones.save()
|
||||
profiles.save()
|
||||
|
||||
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
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')
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.session import with_session
|
||||
from models.tab import Tab
|
||||
from models.profile import Profile
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
controller = Microdot()
|
||||
tabs = Tab()
|
||||
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
|
||||
|
||||
def get_profile_tab_order(profile_id):
|
||||
"""Get the tab order for a profile."""
|
||||
if not profile_id:
|
||||
return []
|
||||
profile = profiles.read(profile_id)
|
||||
if profile:
|
||||
# Support both "tab_order" (old) and "tabs" (new) format
|
||||
return profile.get("tabs", profile.get("tab_order", []))
|
||||
return []
|
||||
|
||||
def get_current_tab_id(request, session=None):
|
||||
"""Get the current tab ID from session."""
|
||||
if session:
|
||||
current_tab = session.get('current_tab')
|
||||
if current_tab:
|
||||
return current_tab
|
||||
|
||||
# Fallback to first tab in current profile if no session
|
||||
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 tabs_list:
|
||||
return tabs_list[0]
|
||||
return None
|
||||
|
||||
@controller.get('')
|
||||
async def list_tabs(request):
|
||||
"""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)
|
||||
# #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": "H1",
|
||||
"location": "src/controllers/tab.py:tabs_list_fragment",
|
||||
"message": "tabs list fragment",
|
||||
"data": {
|
||||
"profile_id": profile_id,
|
||||
"profile_count": len(profiles.list())
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
if not profile_id:
|
||||
return '<div class="tabs-list">No profile selected</div>', 200, {'Content-Type': 'text/html'}
|
||||
|
||||
tab_order = get_profile_tab_order(profile_id)
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
|
||||
html = '<div class="tabs-list">'
|
||||
for tab_id in tab_order:
|
||||
tab_data = tabs.read(tab_id)
|
||||
if tab_data:
|
||||
active_class = 'active' if str(tab_id) == str(current_tab_id) else ''
|
||||
tab_name = tab_data.get('name', 'Tab ' + str(tab_id))
|
||||
html += (
|
||||
'<button class="tab-button ' + active_class + '" '
|
||||
'hx-get="/tabs/' + str(tab_id) + '/content-fragment" '
|
||||
'hx-target="#tab-content" '
|
||||
'hx-swap="innerHTML" '
|
||||
'hx-push-url="true" '
|
||||
'hx-trigger="click" '
|
||||
'onclick="document.querySelectorAll(\'.tab-button\').forEach(b => b.classList.remove(\'active\')); this.classList.add(\'active\');">'
|
||||
+ tab_name +
|
||||
'</button>'
|
||||
)
|
||||
html += '</div>'
|
||||
return html, 200, {'Content-Type': 'text/html'}
|
||||
|
||||
@controller.get('/create-form-fragment')
|
||||
async def create_tab_form_fragment(request):
|
||||
"""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
|
||||
if id == 'current':
|
||||
return await get_current_tab(request, session)
|
||||
|
||||
tab = tabs.read(id)
|
||||
if not tab:
|
||||
return '<div>Tab not found</div>', 404, {'Content-Type': 'text/html'}
|
||||
|
||||
# Set this tab as the current tab in session
|
||||
session['current_tab'] = str(id)
|
||||
session.save()
|
||||
|
||||
# If this is a direct page load (not HTMX), return full UI so CSS loads.
|
||||
if not request.headers.get('HX-Request'):
|
||||
return send_file('templates/index.html')
|
||||
|
||||
tab_name = tab.get('name', 'Tab ' + str(id))
|
||||
|
||||
html = (
|
||||
'<div class="presets-section" data-tab-id="' + str(id) + '">'
|
||||
'<h3>Presets</h3>'
|
||||
'<div class="profiles-actions" style="margin-bottom: 1rem;">'
|
||||
'<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>'
|
||||
'</div>'
|
||||
'<div id="presets-list-tab" class="presets-list">'
|
||||
'<!-- Presets will be loaded here -->'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
return html, 200, {'Content-Type': 'text/html'}
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_tab(request, id):
|
||||
"""Get a specific tab by ID."""
|
||||
tab = tabs.read(id)
|
||||
if tab:
|
||||
return json.dumps(tab), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Tab not found"}), 404
|
||||
|
||||
@controller.put('/<id>')
|
||||
async def update_tab(request, id):
|
||||
"""Update an existing tab."""
|
||||
try:
|
||||
data = request.json
|
||||
if tabs.update(id, data):
|
||||
return json.dumps(tabs.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Tab not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<id>')
|
||||
@with_session
|
||||
async def delete_tab(request, id, session):
|
||||
"""Delete a tab."""
|
||||
# Check if this is an htmx request (wants HTML fragment)
|
||||
accept_header = request.headers.get('Accept', '')
|
||||
wants_html = 'text/html' in accept_header
|
||||
|
||||
# Handle 'current' tab ID
|
||||
if id == 'current':
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
if current_tab_id:
|
||||
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
|
||||
current_tab_id = get_current_tab_id(request, session)
|
||||
if current_tab_id == id:
|
||||
if 'current_tab' in session:
|
||||
session.pop('current_tab', None)
|
||||
session.save()
|
||||
|
||||
if wants_html:
|
||||
return await tabs_list_fragment.__wrapped__(request, session)
|
||||
else:
|
||||
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
if wants_html:
|
||||
return '<div class="error">Tab not found</div>', 404, {'Content-Type': 'text/html'}
|
||||
return json.dumps({"error": "Tab not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
@with_session
|
||||
async def create_tab(request, session):
|
||||
"""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:
|
||||
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": "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:
|
||||
name = request.form.get('name', '').strip()
|
||||
ids_str = request.form.get('ids', '1').strip()
|
||||
names = [id.strip() for id in ids_str.split(',') if id.strip()]
|
||||
preset_ids = None
|
||||
else:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "")
|
||||
names = data.get("names", None)
|
||||
preset_ids = data.get("presets", None)
|
||||
|
||||
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
|
||||
|
||||
tab_id = tabs.create(name, names, preset_ids)
|
||||
|
||||
# Add to current 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 tab_id not in tabs_list:
|
||||
tabs_list.append(tab_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)
|
||||
# #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 HTML fragment for tabs list
|
||||
return await tabs_list_fragment.__wrapped__(request, session)
|
||||
else:
|
||||
# Return JSON response
|
||||
return json.dumps(tabs.read(tab_id)), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
import sys
|
||||
sys.print_exception(e)
|
||||
if wants_html:
|
||||
return f'<div class="error">Error: {str(e)}</div>', 400, {'Content-Type': 'text/html'}
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
361
src/controllers/zone.py
Normal file
361
src/controllers/zone.py
Normal file
@@ -0,0 +1,361 @@
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.session import with_session
|
||||
from models.zone import Zone
|
||||
from models.profile import Profile
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
zones = Zone()
|
||||
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
|
||||
|
||||
|
||||
def _profile_zone_id_list(profile):
|
||||
"""Ordered zone ids for a profile (``zones``, legacy ``tabs``, or ``zone_order``)."""
|
||||
if not profile or not isinstance(profile, dict):
|
||||
return []
|
||||
z = profile.get("zones")
|
||||
if isinstance(z, list) and z:
|
||||
return list(z)
|
||||
t = profile.get("zones")
|
||||
if isinstance(t, list) and t:
|
||||
return list(t)
|
||||
o = profile.get("zone_order")
|
||||
if isinstance(o, list) and o:
|
||||
return list(o)
|
||||
return []
|
||||
|
||||
|
||||
def get_profile_zone_order(profile_id):
|
||||
if not profile_id:
|
||||
return []
|
||||
profile = profiles.read(profile_id)
|
||||
return _profile_zone_id_list(profile)
|
||||
|
||||
|
||||
def _set_profile_zone_order(profile, ids):
|
||||
profile["zones"] = list(ids)
|
||||
profile.pop("tabs", None)
|
||||
profile.pop("zone_order", None)
|
||||
|
||||
|
||||
def get_current_zone_id(request, session=None):
|
||||
"""Cookie ``current_zone``, legacy ``current_zone``, then first zone in profile."""
|
||||
z = request.cookies.get("current_zone") or request.cookies.get("current_zone")
|
||||
if z:
|
||||
return z
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
profile = profiles.read(profile_id)
|
||||
order = _profile_zone_id_list(profile)
|
||||
if order:
|
||||
return order[0]
|
||||
return None
|
||||
|
||||
|
||||
def _render_zones_list_fragment(request, session):
|
||||
"""Render zone strip HTML for HTMX / JS."""
|
||||
profile_id = get_current_profile_id(session)
|
||||
if not profile_id:
|
||||
return (
|
||||
'<div class="zones-list">No profile selected</div>',
|
||||
200,
|
||||
{"Content-Type": "text/html"},
|
||||
)
|
||||
|
||||
zone_order = get_profile_zone_order(profile_id)
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
|
||||
html = '<div class="zones-list">'
|
||||
for zid in zone_order:
|
||||
zdata = zones.read(zid)
|
||||
if zdata:
|
||||
active_class = "active" if str(zid) == str(current_zone_id) else ""
|
||||
zname = zdata.get("name", "Zone " + str(zid))
|
||||
html += (
|
||||
'<button class="zone-button ' + active_class + '" '
|
||||
'hx-get="/zones/' + str(zid) + '/content-fragment" '
|
||||
'hx-target="#zone-content" '
|
||||
'hx-swap="innerHTML" '
|
||||
'hx-push-url="true" '
|
||||
'hx-trigger="click" '
|
||||
'onclick="document.querySelectorAll(\'.zone-button\').forEach(b => b.classList.remove(\'active\')); this.classList.add(\'active\');">'
|
||||
+ zname
|
||||
+ "</button>"
|
||||
)
|
||||
html += "</div>"
|
||||
return html, 200, {"Content-Type": "text/html"}
|
||||
|
||||
|
||||
def _render_zone_content_fragment(request, session, id):
|
||||
if id == "current":
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
if not current_zone_id:
|
||||
accept_header = request.headers.get("Accept", "")
|
||||
wants_html = "text/html" in accept_header
|
||||
if wants_html:
|
||||
return (
|
||||
'<div class="error">No current zone set</div>',
|
||||
404,
|
||||
{"Content-Type": "text/html"},
|
||||
)
|
||||
return json.dumps({"error": "No current zone set"}), 404
|
||||
id = current_zone_id
|
||||
|
||||
z = zones.read(id)
|
||||
if not z:
|
||||
return '<div>Zone not found</div>', 404, {"Content-Type": "text/html"}
|
||||
|
||||
session["current_zone"] = str(id)
|
||||
session.save()
|
||||
|
||||
if not request.headers.get("HX-Request"):
|
||||
return send_file("templates/index.html")
|
||||
|
||||
html = (
|
||||
'<div class="presets-section" data-zone-id="' + str(id) + '">'
|
||||
"<h3>Presets</h3>"
|
||||
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
|
||||
'<div id="presets-list-zone" class="presets-list">'
|
||||
"<!-- Presets will be loaded here -->"
|
||||
"</div>"
|
||||
"</div>"
|
||||
)
|
||||
return html, 200, {"Content-Type": "text/html"}
|
||||
|
||||
|
||||
@controller.get("/<id>/content-fragment")
|
||||
@with_session
|
||||
async def zone_content_fragment(request, session, id):
|
||||
return _render_zone_content_fragment(request, session, id)
|
||||
|
||||
|
||||
@controller.get("")
|
||||
@with_session
|
||||
async def list_zones(request, session):
|
||||
profile_id = get_current_profile_id(session)
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
zone_order = get_profile_zone_order(profile_id) if profile_id else []
|
||||
|
||||
zones_data = {}
|
||||
for zid in zones.list():
|
||||
zdata = zones.read(zid)
|
||||
if zdata:
|
||||
zones_data[zid] = zdata
|
||||
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
"zones": zones_data,
|
||||
"zone_order": zone_order,
|
||||
"current_zone_id": current_zone_id,
|
||||
"profile_id": profile_id,
|
||||
}
|
||||
),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
@controller.get("/current")
|
||||
@with_session
|
||||
async def get_current_zone(request, session):
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
if not current_zone_id:
|
||||
return (
|
||||
json.dumps({"error": "No current zone set", "zone": None, "zone_id": None}),
|
||||
404,
|
||||
)
|
||||
|
||||
z = zones.read(current_zone_id)
|
||||
if z:
|
||||
return (
|
||||
json.dumps({"zone": z, "zone_id": current_zone_id}),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return (
|
||||
json.dumps({"error": "Zone not found", "zone": None, "zone_id": None}),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@controller.post("/<id>/set-current")
|
||||
async def set_current_zone(request, id):
|
||||
z = zones.read(id)
|
||||
if not z:
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
|
||||
response_data = json.dumps({"message": "Current zone set", "zone_id": id})
|
||||
return (
|
||||
response_data,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": (
|
||||
f"current_zone={id}; Path=/; Max-Age=31536000; SameSite=Lax"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
async def get_zone(request, id):
|
||||
z = zones.read(id)
|
||||
if z:
|
||||
return json.dumps(z), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
|
||||
|
||||
@controller.put("/<id>")
|
||||
async def update_zone(request, id):
|
||||
try:
|
||||
data = request.json
|
||||
if zones.update(id, data):
|
||||
return json.dumps(zones.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.delete("/<id>")
|
||||
@with_session
|
||||
async def delete_zone(request, session, id):
|
||||
try:
|
||||
if id == "current":
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
if current_zone_id:
|
||||
id = current_zone_id
|
||||
else:
|
||||
return json.dumps({"error": "No current zone to delete"}), 404
|
||||
|
||||
if zones.delete(id):
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
profile = profiles.read(profile_id)
|
||||
if profile:
|
||||
zlist = _profile_zone_id_list(profile)
|
||||
if id in zlist:
|
||||
zlist.remove(id)
|
||||
_set_profile_zone_order(profile, zlist)
|
||||
profiles.update(profile_id, profile)
|
||||
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
if current_zone_id == id:
|
||||
response_data = json.dumps({"message": "Zone deleted successfully"})
|
||||
return (
|
||||
response_data,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": (
|
||||
"current_zone=; Path=/; Max-Age=0; SameSite=Lax"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return json.dumps({"message": "Zone deleted successfully"}), 200, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
except Exception as e:
|
||||
import sys
|
||||
|
||||
try:
|
||||
sys.print_exception(e)
|
||||
except Exception:
|
||||
pass
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post("")
|
||||
@with_session
|
||||
async def create_zone(request, session):
|
||||
try:
|
||||
if request.form:
|
||||
name = request.form.get("name", "").strip()
|
||||
ids_str = request.form.get("ids", "1").strip()
|
||||
names = [i.strip() for i in ids_str.split(",") if i.strip()]
|
||||
preset_ids = None
|
||||
else:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "")
|
||||
names = data.get("names")
|
||||
if names is None:
|
||||
names = data.get("ids")
|
||||
preset_ids = data.get("presets", None)
|
||||
|
||||
if not name:
|
||||
return json.dumps({"error": "Zone name cannot be empty"}), 400
|
||||
|
||||
zid = zones.create(name, names, preset_ids)
|
||||
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
profile = profiles.read(profile_id)
|
||||
if profile:
|
||||
zlist = _profile_zone_id_list(profile)
|
||||
if zid not in zlist:
|
||||
zlist.append(zid)
|
||||
_set_profile_zone_order(profile, zlist)
|
||||
profiles.update(profile_id, profile)
|
||||
|
||||
zdata = zones.read(zid)
|
||||
return json.dumps({zid: zdata}), 201, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
import sys
|
||||
|
||||
sys.print_exception(e)
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.post("/<id>/clone")
|
||||
@with_session
|
||||
async def clone_zone(request, session, id):
|
||||
try:
|
||||
source = zones.read(id)
|
||||
if not source:
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
|
||||
data = request.json or {}
|
||||
source_name = source.get("name") or f"Zone {id}"
|
||||
new_name = data.get("name") or f"{source_name} Copy"
|
||||
clone_id = zones.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:
|
||||
zones.update(clone_id, extra)
|
||||
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
profile = profiles.read(profile_id)
|
||||
if profile:
|
||||
zlist = _profile_zone_id_list(profile)
|
||||
if clone_id not in zlist:
|
||||
zlist.append(clone_id)
|
||||
_set_profile_zone_order(profile, zlist)
|
||||
profiles.update(profile_id, profile)
|
||||
|
||||
zdata = zones.read(clone_id)
|
||||
return json.dumps({clone_id: zdata}), 201, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
import sys
|
||||
|
||||
try:
|
||||
sys.print_exception(e)
|
||||
except Exception:
|
||||
pass
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
376
src/main.py
376
src/main.py
@@ -1,33 +1,249 @@
|
||||
import asyncio
|
||||
from settings import Settings
|
||||
import gc
|
||||
import machine
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
import traceback
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.websocket import with_websocket
|
||||
from microdot.session import Session
|
||||
from settings import Settings
|
||||
|
||||
import aioespnow
|
||||
import network
|
||||
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.zone as zone
|
||||
import controllers.palette as palette
|
||||
import controllers.scene as scene
|
||||
import controllers.pattern as pattern
|
||||
import controllers.settings as settings_controller
|
||||
import controllers.device as device_controller
|
||||
import controllers.led_tool as led_tool_controller
|
||||
from models.transport import get_sender, set_sender, get_current_sender
|
||||
from models.device import Device, normalize_mac
|
||||
from models import wifi_ws_clients as tcp_client_registry
|
||||
from util.device_status_broadcaster import (
|
||||
broadcast_device_tcp_snapshot_to,
|
||||
broadcast_device_tcp_status,
|
||||
register_device_status_ws,
|
||||
unregister_device_status_ws,
|
||||
)
|
||||
|
||||
_tcp_device_lock = threading.Lock()
|
||||
|
||||
DISCOVERY_UDP_PORT = 8766
|
||||
|
||||
|
||||
def _register_udp_device_sync(
|
||||
device_name: str, peer_ip: str, mac, device_type=None
|
||||
) -> None:
|
||||
with _tcp_device_lock:
|
||||
try:
|
||||
d = Device()
|
||||
did, persisted = d.upsert_wifi_tcp_client(
|
||||
device_name, peer_ip, mac, device_type=device_type
|
||||
)
|
||||
if did and persisted:
|
||||
print(
|
||||
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"UDP device registry failed: {e}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
|
||||
|
||||
async def _handle_udp_discovery(sock, udp_holder=None) -> None:
|
||||
while True:
|
||||
try:
|
||||
data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except OSError as e:
|
||||
if udp_holder and udp_holder.get("closing"):
|
||||
break
|
||||
print(f"[UDP] recv failed: {e!r}")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[UDP] recv failed: {e!r}")
|
||||
continue
|
||||
peer_ip = addr[0] if addr else ""
|
||||
line = data.split(b"\n", 1)[0].strip()
|
||||
if line:
|
||||
try:
|
||||
parsed = json.loads(line.decode("utf-8"))
|
||||
if isinstance(parsed, dict):
|
||||
dns = str(parsed.get("device_name") or "").strip()
|
||||
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
|
||||
"sta_mac"
|
||||
)
|
||||
device_type = parsed.get("type") or parsed.get("device_type")
|
||||
if dns and normalize_mac(mac):
|
||||
_register_udp_device_sync(dns, peer_ip, mac, device_type)
|
||||
if str(parsed.get("v") or "") == "1":
|
||||
tcp_client_registry.ensure_driver_connection(peer_ip)
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
await asyncio.get_running_loop().sock_sendto(sock, data, addr)
|
||||
except Exception as e:
|
||||
print(f"[UDP] echo send failed: {e!r}")
|
||||
|
||||
|
||||
def _prime_wifi_outbound_driver_connections() -> None:
|
||||
"""
|
||||
For each Wi‑Fi device in the registry with a usable IPv4, start (or keep) the
|
||||
outbound WebSocket task. The client loop reconnects automatically if the link
|
||||
drops. Presets are not pushed automatically; use Send Presets / profile apply.
|
||||
"""
|
||||
n = 0
|
||||
try:
|
||||
dev = Device()
|
||||
for mac_key, doc in list(dev.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if doc.get("transport") != "wifi":
|
||||
continue
|
||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
||||
if not ip:
|
||||
continue
|
||||
tcp_client_registry.ensure_driver_connection(ip)
|
||||
n += 1
|
||||
except Exception as e:
|
||||
print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
return
|
||||
if n:
|
||||
print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")
|
||||
|
||||
|
||||
def _ipv4_address(addr: str) -> str | None:
|
||||
"""Return dotted IPv4 string or None (hostnames skipped for UDP nudge)."""
|
||||
s = (addr or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
parts = s.split(".")
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
try:
|
||||
nums = [int(p) for p in parts]
|
||||
except ValueError:
|
||||
return None
|
||||
if not all(0 <= n <= 255 for n in nums):
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
|
||||
"""
|
||||
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
|
||||
UDP discovery port so the device can announce itself and we can reconnect.
|
||||
"""
|
||||
try:
|
||||
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
|
||||
except (TypeError, ValueError):
|
||||
interval = 10.0
|
||||
if interval <= 0:
|
||||
return
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setblocking(False)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
if udp_holder.get("closing"):
|
||||
break
|
||||
try:
|
||||
dev = Device()
|
||||
except Exception as e:
|
||||
print(f"[hello] device list failed: {e!r}")
|
||||
continue
|
||||
for _mac_key, doc in list(dev.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if doc.get("transport") != "wifi":
|
||||
continue
|
||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
||||
if not ip:
|
||||
continue
|
||||
if tcp_client_registry.tcp_client_connected(ip):
|
||||
continue
|
||||
name = (doc.get("name") or "").strip()
|
||||
mac = normalize_mac(doc.get("id") or _mac_key)
|
||||
if not name or not mac:
|
||||
continue
|
||||
line = (
|
||||
json.dumps(
|
||||
{"m": "hello", "device_name": name, "mac": mac},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
try:
|
||||
await loop.sock_sendto(
|
||||
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
|
||||
)
|
||||
except OSError as e:
|
||||
print(f"[hello] UDP to {ip!r} failed: {e!r}")
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _run_udp_discovery_server(udp_holder=None) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
|
||||
if udp_holder is not None:
|
||||
udp_holder["sock"] = sock
|
||||
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
|
||||
try:
|
||||
await _handle_udp_discovery(sock, udp_holder)
|
||||
finally:
|
||||
if udp_holder is not None:
|
||||
udp_holder.pop("sock", None)
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _send_bridge_wifi_channel(settings, sender):
|
||||
"""Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
|
||||
try:
|
||||
ch = int(settings.get("wifi_channel", 6))
|
||||
except (TypeError, ValueError):
|
||||
ch = 6
|
||||
ch = max(1, min(11, ch))
|
||||
payload = json.dumps({"m": "bridge", "ch": ch}, separators=(",", ":"))
|
||||
try:
|
||||
await sender.send(payload, addr="ffffffffffff")
|
||||
print(f"[startup] bridge Wi-Fi channel -> {ch}")
|
||||
except Exception as e:
|
||||
print(f"[startup] bridge channel message failed: {e}")
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
settings = Settings()
|
||||
print(settings)
|
||||
print("Starting")
|
||||
|
||||
network.WLAN(network.STA_IF).active(True)
|
||||
|
||||
|
||||
e = aioespnow.AIOESPNow()
|
||||
e.active(True)
|
||||
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
|
||||
# Initialize transport (serial to ESP32 bridge)
|
||||
sender = get_sender(settings)
|
||||
set_sender(sender)
|
||||
|
||||
app = Microdot()
|
||||
|
||||
@@ -42,7 +258,7 @@ async def main(port=80):
|
||||
('/profiles', profile, 'profile'),
|
||||
('/groups', group, 'group'),
|
||||
('/sequences', sequence, 'sequence'),
|
||||
('/tabs', tab, 'tab'),
|
||||
('/zones', zone, 'zone'),
|
||||
('/palettes', palette, 'palette'),
|
||||
('/scenes', scene, 'scene'),
|
||||
]
|
||||
@@ -52,17 +268,34 @@ async def main(port=80):
|
||||
app.mount(profile.controller, '/profiles')
|
||||
app.mount(group.controller, '/groups')
|
||||
app.mount(sequence.controller, '/sequences')
|
||||
app.mount(tab.controller, '/tabs')
|
||||
app.mount(zone.controller, '/zones')
|
||||
app.mount(palette.controller, '/palettes')
|
||||
app.mount(scene.controller, '/scenes')
|
||||
app.mount(pattern.controller, '/patterns')
|
||||
|
||||
# Serve index.html at root
|
||||
app.mount(settings_controller.controller, '/settings')
|
||||
app.mount(device_controller.controller, '/devices')
|
||||
app.mount(led_tool_controller.controller, '/led-tool')
|
||||
|
||||
tcp_client_registry.set_settings(settings)
|
||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
||||
|
||||
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||
@app.route('/')
|
||||
def index(request):
|
||||
"""Serve the main web UI."""
|
||||
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
|
||||
@app.route("/static/<path:path>")
|
||||
def static_handler(request, path):
|
||||
@@ -75,27 +308,106 @@ async def main(port=80):
|
||||
@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)
|
||||
await register_device_status_ws(ws)
|
||||
await broadcast_device_tcp_snapshot_to(ws)
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
print(data)
|
||||
else:
|
||||
break
|
||||
if data:
|
||||
try:
|
||||
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:
|
||||
break
|
||||
finally:
|
||||
await unregister_device_status_ws(ws)
|
||||
|
||||
|
||||
|
||||
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
|
||||
# Touch Device singleton early so db/device.json exists before first UDP hello.
|
||||
Device()
|
||||
await _send_bridge_wifi_channel(settings, sender)
|
||||
_prime_wifi_outbound_driver_connections()
|
||||
|
||||
wdt = machine.WDT(timeout=10000)
|
||||
wdt.feed()
|
||||
udp_holder = {"closing": False}
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
while True:
|
||||
gc.collect()
|
||||
for i in range(60):
|
||||
wdt.feed()
|
||||
await asyncio.sleep_ms(500)
|
||||
# cleanup before ending the application
|
||||
def _graceful_shutdown(*_args):
|
||||
print("[server] shutting down...")
|
||||
udp_holder["closing"] = True
|
||||
u = udp_holder.get("sock")
|
||||
if u is not None:
|
||||
try:
|
||||
u.close()
|
||||
except OSError:
|
||||
pass
|
||||
tcp_client_registry.cancel_all_driver_tasks()
|
||||
if getattr(app, "server", None) is not None:
|
||||
app.shutdown()
|
||||
|
||||
shutdown_handlers_registered = False
|
||||
try:
|
||||
try:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _graceful_shutdown)
|
||||
shutdown_handlers_registered = True
|
||||
except (NotImplementedError, RuntimeError):
|
||||
pass
|
||||
|
||||
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
|
||||
try:
|
||||
await asyncio.gather(
|
||||
app.start_server(host="0.0.0.0", port=port),
|
||||
_run_udp_discovery_server(udp_holder),
|
||||
_periodic_wifi_driver_hello_loop(settings, udp_holder),
|
||||
)
|
||||
except OSError as e:
|
||||
if e.errno == errno.EADDRINUSE:
|
||||
print(
|
||||
f"[server] bind failed (address already in use): {e!s}\n"
|
||||
f"[server] HTTP is configured for port {port} (env PORT). "
|
||||
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
srv = getattr(app, "server", None)
|
||||
if srv is not None:
|
||||
try:
|
||||
srv.close()
|
||||
await srv.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
app.server = None
|
||||
except Exception:
|
||||
pass
|
||||
if shutdown_handlers_registered:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.remove_signal_handler(sig)
|
||||
except (NotImplementedError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
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.
285
src/models/device.py
Normal file
285
src/models/device.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
LED driver registry persisted in ``db/device.json``.
|
||||
|
||||
Storage key and **id** field are the device **MAC**: 12 lowercase hex characters
|
||||
(no colons). **name** is for ``select`` / zones (not unique). **address** is the
|
||||
reachability hint: same as MAC for ESP-NOW, or IP/hostname for Wi-Fi.
|
||||
"""
|
||||
|
||||
from models.model import Model
|
||||
|
||||
DEVICE_TYPES = frozenset({"led"})
|
||||
DEVICE_TRANSPORTS = frozenset({"wifi", "espnow"})
|
||||
|
||||
|
||||
def validate_device_type(value):
|
||||
t = (value or "led").strip().lower()
|
||||
if t not in DEVICE_TYPES:
|
||||
raise ValueError(f"type must be one of: {', '.join(sorted(DEVICE_TYPES))}")
|
||||
return t
|
||||
|
||||
|
||||
def validate_device_transport(value):
|
||||
tr = (value or "espnow").strip().lower()
|
||||
if tr not in DEVICE_TRANSPORTS:
|
||||
raise ValueError(
|
||||
f"transport must be one of: {', '.join(sorted(DEVICE_TRANSPORTS))}"
|
||||
)
|
||||
return tr
|
||||
|
||||
|
||||
def normalize_mac(mac):
|
||||
"""Normalise to 12-char lowercase hex or None."""
|
||||
if mac is None:
|
||||
return None
|
||||
s = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def derive_device_mac(mac=None, address=None, transport="espnow"):
|
||||
"""
|
||||
Resolve the device MAC used as storage id.
|
||||
|
||||
Explicit ``mac`` wins. For ESP-NOW, ``address`` is the peer MAC. For Wi-Fi,
|
||||
``mac`` must be supplied (``address`` is typically an IP).
|
||||
"""
|
||||
m = normalize_mac(mac)
|
||||
if m:
|
||||
return m
|
||||
tr = validate_device_transport(transport)
|
||||
if tr == "espnow":
|
||||
return normalize_mac(address)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_address_for_transport(addr, transport):
|
||||
"""ESP-NOW → 12 hex or None; Wi-Fi → trimmed string or None."""
|
||||
tr = validate_device_transport(transport)
|
||||
if tr == "espnow":
|
||||
return normalize_mac(addr)
|
||||
if addr is None:
|
||||
return None
|
||||
s = str(addr).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
class Device(Model):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def load(self):
|
||||
super().load()
|
||||
changed = False
|
||||
for sid, doc in list(self.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if self._migrate_record(str(sid), doc):
|
||||
changed = True
|
||||
if self._rekey_legacy_ids():
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
def _migrate_record(self, storage_id, doc):
|
||||
changed = False
|
||||
if doc.get("type") not in DEVICE_TYPES:
|
||||
doc["type"] = "led"
|
||||
changed = True
|
||||
if doc.get("transport") not in DEVICE_TRANSPORTS:
|
||||
doc["transport"] = "espnow"
|
||||
changed = True
|
||||
raw_list = doc.get("addresses")
|
||||
if isinstance(raw_list, list) and raw_list:
|
||||
picked = None
|
||||
for item in raw_list:
|
||||
n = normalize_mac(item)
|
||||
if n:
|
||||
picked = n
|
||||
break
|
||||
if picked:
|
||||
doc["address"] = picked
|
||||
del doc["addresses"]
|
||||
changed = True
|
||||
elif "addresses" in doc:
|
||||
del doc["addresses"]
|
||||
changed = True
|
||||
tr = doc["transport"]
|
||||
norm = normalize_address_for_transport(doc.get("address"), tr)
|
||||
if doc.get("address") != norm:
|
||||
doc["address"] = norm
|
||||
changed = True
|
||||
mac_key = normalize_mac(storage_id)
|
||||
if mac_key and mac_key == storage_id and str(doc.get("id") or "") != mac_key:
|
||||
doc["id"] = mac_key
|
||||
changed = True
|
||||
elif str(doc.get("id") or "").strip() != storage_id:
|
||||
doc["id"] = storage_id
|
||||
changed = True
|
||||
doc.pop("mac", None)
|
||||
return changed
|
||||
|
||||
def _rekey_legacy_ids(self):
|
||||
"""Move numeric-keyed rows to MAC keys when ESP-NOW MAC is known."""
|
||||
changed = False
|
||||
moves = []
|
||||
for sid in list(self.keys()):
|
||||
doc = self.get(sid)
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if normalize_mac(sid) == sid:
|
||||
continue
|
||||
if not str(sid).isdigit():
|
||||
continue
|
||||
tr = doc.get("transport", "espnow")
|
||||
cand = None
|
||||
if tr == "espnow":
|
||||
cand = normalize_mac(doc.get("address"))
|
||||
if not cand:
|
||||
continue
|
||||
moves.append((sid, cand))
|
||||
for old, mac in moves:
|
||||
if old not in self:
|
||||
continue
|
||||
doc = self.pop(old)
|
||||
if mac in self:
|
||||
existing = dict(self[mac])
|
||||
for k, v in doc.items():
|
||||
if k not in existing or existing[k] in (None, "", []):
|
||||
existing[k] = v
|
||||
doc = existing
|
||||
doc["id"] = mac
|
||||
self[mac] = doc
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def create(
|
||||
self,
|
||||
name="",
|
||||
address=None,
|
||||
mac=None,
|
||||
default_pattern=None,
|
||||
zones=None,
|
||||
device_type="led",
|
||||
transport="espnow",
|
||||
):
|
||||
dt = validate_device_type(device_type)
|
||||
tr = validate_device_transport(transport)
|
||||
mac_hex = derive_device_mac(mac=mac, address=address, transport=tr)
|
||||
if not mac_hex:
|
||||
raise ValueError(
|
||||
"mac is required (12 hex characters); for Wi-Fi pass mac separately from IP address"
|
||||
)
|
||||
if mac_hex in self:
|
||||
raise ValueError("device with this mac already exists")
|
||||
addr = normalize_address_for_transport(address, tr)
|
||||
if tr == "espnow":
|
||||
addr = mac_hex
|
||||
self[mac_hex] = {
|
||||
"id": mac_hex,
|
||||
"name": name,
|
||||
"type": dt,
|
||||
"transport": tr,
|
||||
"address": addr,
|
||||
"default_pattern": default_pattern if default_pattern else None,
|
||||
"zones": list(zones) if zones else [],
|
||||
}
|
||||
self.save()
|
||||
return mac_hex
|
||||
|
||||
def read(self, id):
|
||||
m = normalize_mac(id)
|
||||
if m is not None and m in self:
|
||||
return self.get(m)
|
||||
return self.get(str(id), None)
|
||||
|
||||
def update(self, id, data):
|
||||
id_str = normalize_mac(id)
|
||||
if id_str is None:
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
incoming = dict(data)
|
||||
incoming.pop("id", None)
|
||||
incoming.pop("addresses", None)
|
||||
in_mac = normalize_mac(incoming.get("mac"))
|
||||
if in_mac is not None and in_mac != id_str:
|
||||
raise ValueError("cannot change device mac; delete and re-add")
|
||||
incoming.pop("mac", None)
|
||||
merged = dict(self[id_str])
|
||||
merged.update(incoming)
|
||||
merged["type"] = validate_device_type(merged.get("type"))
|
||||
merged["transport"] = validate_device_transport(merged.get("transport"))
|
||||
tr = merged["transport"]
|
||||
merged["address"] = normalize_address_for_transport(merged.get("address"), tr)
|
||||
if tr == "espnow":
|
||||
merged["address"] = id_str
|
||||
merged["id"] = id_str
|
||||
self[id_str] = merged
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def delete(self, id):
|
||||
id_str = normalize_mac(id)
|
||||
if id_str is None:
|
||||
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())
|
||||
|
||||
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
|
||||
"""
|
||||
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
|
||||
**address** (peer IP), and optionally **type** from the client hello when valid.
|
||||
|
||||
Returns ``(mac_hex | None, persisted)`` where **persisted** is True iff ``save()``
|
||||
ran (new row or field changes). Duplicate hellos with identical data are no-ops.
|
||||
"""
|
||||
mac_hex = normalize_mac(mac)
|
||||
if not mac_hex:
|
||||
return None, False
|
||||
name = (device_name or "").strip()
|
||||
if not name:
|
||||
return None, False
|
||||
ip = normalize_address_for_transport(peer_ip, "wifi")
|
||||
if not ip:
|
||||
return None, False
|
||||
resolved_type = None
|
||||
if device_type is not None:
|
||||
try:
|
||||
resolved_type = validate_device_type(device_type)
|
||||
except ValueError:
|
||||
resolved_type = None
|
||||
if mac_hex in self:
|
||||
prev = self[mac_hex]
|
||||
merged = dict(prev)
|
||||
merged["name"] = name
|
||||
if resolved_type is not None:
|
||||
merged["type"] = resolved_type
|
||||
else:
|
||||
merged["type"] = validate_device_type(merged.get("type"))
|
||||
merged["transport"] = "wifi"
|
||||
merged["address"] = ip
|
||||
merged["id"] = mac_hex
|
||||
if merged == prev:
|
||||
return mac_hex, False
|
||||
self[mac_hex] = merged
|
||||
self.save()
|
||||
return mac_hex, True
|
||||
self[mac_hex] = {
|
||||
"id": mac_hex,
|
||||
"name": name,
|
||||
"type": resolved_type or "led",
|
||||
"transport": "wifi",
|
||||
"address": ip,
|
||||
"default_pattern": None,
|
||||
"zones": [],
|
||||
}
|
||||
self.save()
|
||||
return mac_hex, True
|
||||
125
src/models/http_driver.py
Normal file
125
src/models/http_driver.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Wi-Fi LED drivers over HTTP long-poll (same port as the web UI).
|
||||
|
||||
Drivers POST /driver/v1/poll; the controller responds with queued JSON lines.
|
||||
Presence: last poll within DRIVER_HTTP_SEEN_S counts as connected.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from models.wifi_peer import normalize_wifi_peer_ip
|
||||
|
||||
# Must exceed max ``wait_s`` (60) on /driver/v1/poll so sessions are not pruned mid-wait.
|
||||
DRIVER_HTTP_SEEN_S = 90.0
|
||||
_QUEUE_MAX = 64
|
||||
|
||||
_queues: dict[str, asyncio.Queue] = {}
|
||||
_last_poll: dict[str, float] = {}
|
||||
_connected_flag: set[str] = set()
|
||||
_status_broadcast = None
|
||||
|
||||
|
||||
def set_wifi_driver_status_broadcaster(coro) -> None:
|
||||
global _status_broadcast
|
||||
_status_broadcast = coro
|
||||
|
||||
|
||||
def _schedule_status(ip: str, connected: bool) -> None:
|
||||
fn = _status_broadcast
|
||||
if not fn:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
try:
|
||||
loop.create_task(fn(ip, connected))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_queue(ip: str) -> asyncio.Queue:
|
||||
q = _queues.get(ip)
|
||||
if q is None:
|
||||
q = asyncio.Queue(maxsize=_QUEUE_MAX)
|
||||
_queues[ip] = q
|
||||
return q
|
||||
|
||||
|
||||
def prune_stale_http_sessions() -> None:
|
||||
"""Drop timed-out sessions, clear queues, broadcast disconnect."""
|
||||
now = time.monotonic()
|
||||
for ip in list(_last_poll.keys()):
|
||||
if now - _last_poll[ip] <= DRIVER_HTTP_SEEN_S:
|
||||
continue
|
||||
_last_poll.pop(ip, None)
|
||||
_queues.pop(ip, None)
|
||||
if ip in _connected_flag:
|
||||
_connected_flag.discard(ip)
|
||||
_schedule_status(ip, False)
|
||||
print(f"[HTTP driver] session timed out: {ip}")
|
||||
|
||||
|
||||
def touch_http_session(ip: str) -> None:
|
||||
ip = normalize_wifi_peer_ip(ip)
|
||||
if not ip:
|
||||
return
|
||||
prune_stale_http_sessions()
|
||||
now = time.monotonic()
|
||||
_last_poll[ip] = now
|
||||
if ip not in _connected_flag:
|
||||
_connected_flag.add(ip)
|
||||
_schedule_status(ip, True)
|
||||
|
||||
|
||||
def wifi_driver_connected(ip: str) -> bool:
|
||||
prune_stale_http_sessions()
|
||||
key = normalize_wifi_peer_ip(ip)
|
||||
return bool(key and key in _connected_flag)
|
||||
|
||||
|
||||
def list_connected_driver_ips():
|
||||
prune_stale_http_sessions()
|
||||
return list(_connected_flag)
|
||||
|
||||
|
||||
async def enqueue_json_line(ip: str, json_str: str) -> bool:
|
||||
ip = normalize_wifi_peer_ip(ip)
|
||||
if not ip:
|
||||
return False
|
||||
line = json_str[:-1] if json_str.endswith("\n") else json_str
|
||||
q = _get_queue(ip)
|
||||
while True:
|
||||
try:
|
||||
q.put_nowait(line)
|
||||
return True
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
|
||||
|
||||
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
|
||||
"""Queue one JSON line for the driver to receive on the next long-poll."""
|
||||
return await enqueue_json_line(ip, json_str)
|
||||
|
||||
|
||||
async def collect_lines_after_touch(ip: str, wait_s: float) -> list[str]:
|
||||
"""Wait up to wait_s for first line, then drain the rest (non-blocking)."""
|
||||
ip = normalize_wifi_peer_ip(ip)
|
||||
if not ip:
|
||||
return []
|
||||
q = _get_queue(ip)
|
||||
lines: list[str] = []
|
||||
try:
|
||||
first = await asyncio.wait_for(q.get(), timeout=wait_s)
|
||||
lines.append(first)
|
||||
while True:
|
||||
try:
|
||||
lines.append(q.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return lines
|
||||
@@ -1,5 +1,15 @@
|
||||
import json
|
||||
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):
|
||||
def __new__(cls, *args, **kwargs):
|
||||
@@ -13,13 +23,13 @@ class Model(dict):
|
||||
if hasattr(self, '_initialized'):
|
||||
return
|
||||
|
||||
# Create /db directory if it doesn't exist (MicroPython compatible)
|
||||
db_dir = _db_dir()
|
||||
try:
|
||||
os.mkdir("/db")
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
except OSError:
|
||||
pass # Directory already exists, which is fine
|
||||
pass
|
||||
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__()
|
||||
|
||||
self.load() # Load settings from file during initialization
|
||||
@@ -37,27 +47,67 @@ class Model(dict):
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
# Ensure directory exists
|
||||
db_dir = os.path.dirname(self.file)
|
||||
try:
|
||||
os.mkdir("/db")
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
except OSError:
|
||||
pass # Directory already exists
|
||||
pass
|
||||
j = json.dumps(self)
|
||||
with open(self.file, 'w') as file:
|
||||
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}")
|
||||
except Exception as e:
|
||||
print(f"Error saving {self.class_name} to {self.file}: {e}")
|
||||
import sys
|
||||
sys.print_exception(e)
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
|
||||
def load(self):
|
||||
try:
|
||||
with open(self.file, 'r') as file:
|
||||
loaded_settings = json.load(file)
|
||||
self.update(loaded_settings)
|
||||
# Check if file exists first
|
||||
try:
|
||||
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.")
|
||||
except Exception as e:
|
||||
print(f"Error loading {self.class_name}")
|
||||
except OSError as e:
|
||||
# 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.save()
|
||||
|
||||
@@ -6,22 +6,30 @@ class Palette(Model):
|
||||
|
||||
def create(self, name="", colors=None):
|
||||
next_id = self.get_next_id()
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"colors": colors if colors else []
|
||||
}
|
||||
# Store palette as a simple list of colors; name is ignored.
|
||||
self[next_id] = list(colors) if colors else []
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
def read(self, 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):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
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()
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
from models.model import Model
|
||||
from models.profile import Profile
|
||||
|
||||
class Preset(Model):
|
||||
def __init__(self):
|
||||
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()
|
||||
self[next_id] = {
|
||||
"name": "",
|
||||
@@ -20,6 +36,7 @@ class Preset(Model):
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"profile_id": str(profile_id) if profile_id is not None else None,
|
||||
}
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
from models.model import Model
|
||||
from models.pallet import Palette
|
||||
|
||||
|
||||
class Profile(Model):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
"""Profile model.
|
||||
|
||||
def create(self, name="", profile_type="tabs"):
|
||||
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.
|
||||
"""
|
||||
Create a new profile.
|
||||
profile_type: "tabs" or "scenes" (ignoring scenes for now)
|
||||
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="zones"):
|
||||
"""Create a new profile and its own empty palette.
|
||||
|
||||
profile_type: "zones" or "scenes" (ignoring scenes for now)
|
||||
"""
|
||||
next_id = self.get_next_id()
|
||||
# Create a unique palette for this profile.
|
||||
palette_id = self._palette_model.create(colors=[])
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"type": profile_type, # "tabs" or "scenes"
|
||||
"tabs": [], # Array of tab IDs
|
||||
"type": profile_type, # "zones" or "scenes"
|
||||
"zones": [], # Array of zone IDs
|
||||
"scenes": [], # Array of scene IDs (for future use)
|
||||
"palette": []
|
||||
"palette_id": str(palette_id),
|
||||
}
|
||||
self.save()
|
||||
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()
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
from models.model import Model
|
||||
|
||||
class Tab(Model):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def create(self, name="", names=None, presets=None):
|
||||
next_id = self.get_next_id()
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"names": names if names else [],
|
||||
"presets": presets if presets 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
|
||||
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())
|
||||
68
src/models/transport.py
Normal file
68
src/models/transport.py
Normal file
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
self._write_lock = asyncio.Lock()
|
||||
|
||||
async def send(self, data, addr=None):
|
||||
mac = _parse_mac(addr) if addr is not None else self._default_addr
|
||||
payload = _encode_payload(data)
|
||||
async with self._write_lock:
|
||||
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)
|
||||
8
src/models/wifi_peer.py
Normal file
8
src/models/wifi_peer.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Normalise Wi-Fi client addresses (strip IPv4-mapped IPv6 prefix)."""
|
||||
|
||||
|
||||
def normalize_wifi_peer_ip(ip: str) -> str:
|
||||
s = str(ip).strip()
|
||||
if s.lower().startswith("::ffff:"):
|
||||
s = s[7:]
|
||||
return s
|
||||
281
src/models/wifi_ws_clients.py
Normal file
281
src/models/wifi_ws_clients.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Outbound WebSocket clients to Wi-Fi LED drivers (firmware serves ``/ws`` on device)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import traceback
|
||||
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
_connections: dict[str, object] = {}
|
||||
_send_locks: dict[str, asyncio.Lock] = {}
|
||||
_tasks: dict[str, asyncio.Task] = {}
|
||||
_unreachable_counts: dict[str, int] = {}
|
||||
_settings = None
|
||||
|
||||
_tcp_status_broadcast = None
|
||||
|
||||
|
||||
def set_settings(settings) -> None:
|
||||
global _settings
|
||||
_settings = settings
|
||||
|
||||
|
||||
def set_tcp_status_broadcaster(coro) -> None:
|
||||
global _tcp_status_broadcast
|
||||
_tcp_status_broadcast = coro
|
||||
|
||||
|
||||
def _schedule_status_broadcast(ip: str, connected: bool) -> None:
|
||||
fn = _tcp_status_broadcast
|
||||
if not fn:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
try:
|
||||
loop.create_task(fn(ip, connected))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _benign_ws_connect_failure(exc: BaseException) -> bool:
|
||||
"""True for common \"driver down / no route\" errors while dialling the WebSocket."""
|
||||
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)):
|
||||
return True
|
||||
if isinstance(exc, ConnectionRefusedError):
|
||||
return True
|
||||
if not isinstance(exc, OSError):
|
||||
return False
|
||||
en = exc.errno
|
||||
if en is None:
|
||||
return False
|
||||
codes = {errno.ECONNREFUSED, errno.ETIMEDOUT}
|
||||
for name in ("EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EADDRNOTAVAIL"):
|
||||
if hasattr(errno, name):
|
||||
codes.add(getattr(errno, name))
|
||||
return en in codes
|
||||
|
||||
|
||||
def normalize_tcp_peer_ip(ip: str) -> str:
|
||||
"""Match peer addresses to registry IPs (strip IPv4-mapped IPv6 prefix)."""
|
||||
s = str(ip).strip()
|
||||
if s.lower().startswith("::ffff:"):
|
||||
s = s[7:]
|
||||
return s
|
||||
|
||||
|
||||
def _ws_open(ws) -> bool:
|
||||
try:
|
||||
return ws.close_code is None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def prune_stale_tcp_writers() -> None:
|
||||
"""Drop closed WebSocket entries (name kept for callers)."""
|
||||
stale = [ip for ip, ws in list(_connections.items()) if not _ws_open(ws)]
|
||||
for ip in stale:
|
||||
_connections.pop(ip, None)
|
||||
_schedule_status_broadcast(ip, False)
|
||||
|
||||
|
||||
def _register_ws(ip: str, ws) -> None:
|
||||
key = normalize_tcp_peer_ip(ip)
|
||||
if not key:
|
||||
return
|
||||
_connections[key] = ws
|
||||
_unreachable_counts.pop(key, None)
|
||||
if key not in _send_locks:
|
||||
_send_locks[key] = asyncio.Lock()
|
||||
_schedule_status_broadcast(key, True)
|
||||
print(f"[WS] driver connected {key!r}")
|
||||
|
||||
|
||||
def unregister_tcp_writer(peer_ip: str, ws=None) -> str:
|
||||
"""
|
||||
Remove the WebSocket for peer_ip. If ``ws`` is given, only pop when it is still
|
||||
the registered instance.
|
||||
|
||||
Returns ``removed``, ``noop``, or ``superseded`` (same contract as former TCP registry).
|
||||
"""
|
||||
if not peer_ip:
|
||||
return "noop"
|
||||
key = normalize_tcp_peer_ip(peer_ip)
|
||||
if not key:
|
||||
return "noop"
|
||||
current = _connections.get(key)
|
||||
if ws is not None:
|
||||
if current is None:
|
||||
return "noop"
|
||||
if current is not ws:
|
||||
return "superseded"
|
||||
had = key in _connections
|
||||
if had:
|
||||
_connections.pop(key, None)
|
||||
_schedule_status_broadcast(key, False)
|
||||
print(f"[WS] driver disconnected: {key}")
|
||||
return "removed"
|
||||
return "noop"
|
||||
|
||||
|
||||
def list_connected_ips():
|
||||
"""IPs with an active outbound WebSocket to the driver."""
|
||||
prune_stale_tcp_writers()
|
||||
return list(_connections.keys())
|
||||
|
||||
|
||||
def tcp_client_connected(ip: str) -> bool:
|
||||
"""True if the controller has an outbound WebSocket to this driver IP."""
|
||||
prune_stale_tcp_writers()
|
||||
key = normalize_tcp_peer_ip(ip)
|
||||
return bool(key and key in _connections)
|
||||
|
||||
|
||||
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
|
||||
"""Send one JSON text frame (v1 line; trailing newline stripped for WebSocket)."""
|
||||
ip = normalize_tcp_peer_ip(ip)
|
||||
ws = _connections.get(ip)
|
||||
if ws is None or not _ws_open(ws):
|
||||
return False
|
||||
text = json_str.rstrip("\n")
|
||||
lock = _send_locks.setdefault(ip, asyncio.Lock())
|
||||
try:
|
||||
async with lock:
|
||||
await ws.send(text)
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"[WS] send to {ip} failed: {exc}")
|
||||
unregister_tcp_writer(ip, ws)
|
||||
return False
|
||||
|
||||
|
||||
async def _recv_forward_loop(ip: str, ws) -> None:
|
||||
from models.transport import get_current_sender
|
||||
|
||||
sender = get_current_sender()
|
||||
async for message in ws:
|
||||
if isinstance(message, bytes):
|
||||
try:
|
||||
text = message.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
print(f"[WS] recv {ip} (non-UTF-8, {len(message)} bytes)")
|
||||
continue
|
||||
else:
|
||||
text = message
|
||||
text = text.strip()
|
||||
if not text:
|
||||
continue
|
||||
print(f"[WS] recv {ip}: {text}")
|
||||
if not sender:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
await sender.send(text)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
addr = parsed.pop("to", None)
|
||||
payload = json.dumps(parsed) if parsed else "{}"
|
||||
try:
|
||||
await sender.send(payload, addr=addr)
|
||||
except Exception as e:
|
||||
print(f"[WS] forward to bridge failed: {e}")
|
||||
else:
|
||||
try:
|
||||
await sender.send(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _driver_connection_loop(ip: str) -> None:
|
||||
global _settings
|
||||
if _settings is None:
|
||||
return
|
||||
port = int(_settings.get("wifi_driver_ws_port", 80))
|
||||
path = str(_settings.get("wifi_driver_ws_path", "/ws"))
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
uri = f"ws://{ip}:{port}{path}"
|
||||
retry_interval_s = 2.0
|
||||
retry_window_s = 30.0
|
||||
deadline = asyncio.get_running_loop().time() + retry_window_s
|
||||
try:
|
||||
while True:
|
||||
now = asyncio.get_running_loop().time()
|
||||
if now >= deadline:
|
||||
print(
|
||||
f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s; "
|
||||
"stopping retries until next hello"
|
||||
)
|
||||
break
|
||||
try:
|
||||
print(f"[WS] connecting to {uri!r}")
|
||||
async with websockets.connect(
|
||||
uri,
|
||||
ping_interval=20,
|
||||
ping_timeout=15,
|
||||
open_timeout=30,
|
||||
) as ws:
|
||||
_register_ws(ip, ws)
|
||||
try:
|
||||
await _recv_forward_loop(ip, ws)
|
||||
finally:
|
||||
unregister_tcp_writer(ip, ws)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except ConnectionClosed as e:
|
||||
print(f"[WS] driver {ip} closed: {e}")
|
||||
unregister_tcp_writer(ip, None)
|
||||
except Exception as e:
|
||||
if _benign_ws_connect_failure(e):
|
||||
n = _unreachable_counts.get(ip, 0) + 1
|
||||
_unreachable_counts[ip] = n
|
||||
if n == 1 or (n % 30) == 0:
|
||||
print(f"[WS] driver {ip} unreachable, retry in 2s: {e} (x{n})")
|
||||
else:
|
||||
print(f"[WS] driver {ip} session error: {e!r}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
_unreachable_counts.pop(ip, None)
|
||||
unregister_tcp_writer(ip, None)
|
||||
await asyncio.sleep(retry_interval_s)
|
||||
except asyncio.CancelledError:
|
||||
unregister_tcp_writer(ip, None)
|
||||
raise
|
||||
finally:
|
||||
_tasks.pop(ip, None)
|
||||
|
||||
|
||||
def ensure_driver_connection(peer_ip: str) -> None:
|
||||
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``."""
|
||||
key = normalize_tcp_peer_ip(peer_ip)
|
||||
if not key:
|
||||
return
|
||||
t = _tasks.get(key)
|
||||
if t is not None and not t.done():
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
_tasks[key] = loop.create_task(_driver_connection_loop(key))
|
||||
|
||||
|
||||
def cancel_all_driver_tasks() -> None:
|
||||
"""Signal shutdown: cancel outbound driver connection tasks."""
|
||||
for _ip, t in list(_tasks.items()):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
_tasks.clear()
|
||||
for ip in list(_connections.keys()):
|
||||
_schedule_status_broadcast(ip, False)
|
||||
_connections.clear()
|
||||
_send_locks.clear()
|
||||
_unreachable_counts.clear()
|
||||
62
src/models/zone.py
Normal file
62
src/models/zone.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from models.model import Model
|
||||
|
||||
|
||||
def _maybe_migrate_tab_json_to_zone():
|
||||
"""One-time copy ``db/tab.json`` → ``db/zone.json`` when upgrading."""
|
||||
try:
|
||||
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
db_dir = os.path.join(base, "db")
|
||||
zone_path = os.path.join(db_dir, "zone.json")
|
||||
tab_path = os.path.join(db_dir, "tab.json")
|
||||
if not os.path.exists(zone_path) and os.path.exists(tab_path):
|
||||
shutil.copy2(tab_path, zone_path)
|
||||
print("Migrated db/tab.json -> db/zone.json")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class Zone(Model):
|
||||
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab."""
|
||||
|
||||
def __init__(self):
|
||||
if not getattr(Zone, "_migration_checked", False):
|
||||
_maybe_migrate_tab_json_to_zone()
|
||||
Zone._migration_checked = True
|
||||
super().__init__()
|
||||
|
||||
def create(self, name="", names=None, presets=None):
|
||||
next_id = self.get_next_id()
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"names": names if names else [],
|
||||
"presets": presets if presets else [],
|
||||
"default_preset": None,
|
||||
}
|
||||
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
|
||||
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())
|
||||
39
src/p2p.py
39
src/p2p.py
@@ -1,39 +0,0 @@
|
||||
import network
|
||||
import aioespnow
|
||||
import asyncio
|
||||
import json
|
||||
from time import sleep
|
||||
|
||||
|
||||
class P2P:
|
||||
def __init__(self):
|
||||
network.WLAN(network.STA_IF).active(True)
|
||||
self.broadcast = bytes.fromhex("ffffffffffff")
|
||||
self.e = aioespnow.AIOESPNow()
|
||||
self.e.active(True)
|
||||
try:
|
||||
self.e.add_peer(self.broadcast)
|
||||
except:
|
||||
pass
|
||||
|
||||
async def send(self, data):
|
||||
# Convert data to bytes if it's a string or dict
|
||||
if isinstance(data, str):
|
||||
payload = data.encode()
|
||||
elif isinstance(data, dict):
|
||||
payload = json.dumps(data).encode()
|
||||
else:
|
||||
payload = data # Assume it's already bytes
|
||||
|
||||
# Use asend for async sending - returns boolean indicating success
|
||||
result = await self.e.asend(self.broadcast, payload)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
async def main():
|
||||
p = P2P()
|
||||
await p.send(json.dumps({"dj": {"p": "on", "colors": ["#ff0000"]}}))
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
0
src/profile.py
Normal file
0
src/profile.py
Normal file
@@ -2,11 +2,23 @@ import json
|
||||
import os
|
||||
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):
|
||||
SETTINGS_FILE = "/settings.json"
|
||||
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
if Settings.SETTINGS_FILE is None:
|
||||
Settings.SETTINGS_FILE = _settings_path()
|
||||
self.load() # Load settings from file during initialization
|
||||
|
||||
def generate_secret_key(self):
|
||||
@@ -33,6 +45,18 @@ class Settings(dict):
|
||||
self['session_secret_key'] = self.generate_secret_key()
|
||||
# Save immediately when generating a new key
|
||||
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
|
||||
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
|
||||
if 'wifi_driver_ws_port' not in self:
|
||||
self['wifi_driver_ws_port'] = 80
|
||||
if 'wifi_driver_ws_path' not in self:
|
||||
self['wifi_driver_ws_path'] = '/ws'
|
||||
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
|
||||
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
|
||||
if 'wifi_driver_hello_interval_s' not in self:
|
||||
self['wifi_driver_hello_interval_s'] = 10.0
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
|
||||
@@ -5,7 +5,7 @@ class LightingController {
|
||||
this.state = {
|
||||
lights: {},
|
||||
patterns: {},
|
||||
tab_order: [],
|
||||
zone_order: [],
|
||||
presets: {}
|
||||
};
|
||||
this.selectedColorIndex = 0;
|
||||
@@ -19,8 +19,8 @@ class LightingController {
|
||||
await this.loadState();
|
||||
this.setupEventListeners();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
if (this.state.zone_order.length > 0) {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,19 +62,19 @@ class LightingController {
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Tab management
|
||||
document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal());
|
||||
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal());
|
||||
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab());
|
||||
// Zone management
|
||||
document.getElementById('add-zone-btn').addEventListener('click', () => this.showAddTabModal());
|
||||
document.getElementById('edit-zone-btn').addEventListener('click', () => this.showEditTabModal());
|
||||
document.getElementById('delete-zone-btn').addEventListener('click', () => this.deleteCurrentTab());
|
||||
document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette());
|
||||
document.getElementById('presets-btn').addEventListener('click', () => this.showPresets());
|
||||
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
|
||||
|
||||
// Modal actions
|
||||
document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab());
|
||||
document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal'));
|
||||
document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab());
|
||||
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal'));
|
||||
document.getElementById('add-zone-confirm').addEventListener('click', () => this.createTab());
|
||||
document.getElementById('add-zone-cancel').addEventListener('click', () => this.hideModal('add-zone-modal'));
|
||||
document.getElementById('edit-zone-confirm').addEventListener('click', () => this.updateTab());
|
||||
document.getElementById('edit-zone-cancel').addEventListener('click', () => this.hideModal('edit-zone-modal'));
|
||||
document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal'));
|
||||
document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal'));
|
||||
document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal'));
|
||||
@@ -122,31 +122,15 @@ class LightingController {
|
||||
document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette());
|
||||
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() {
|
||||
const tabsList = document.getElementById('tabs-list');
|
||||
const tabsList = document.getElementById('zones-list');
|
||||
tabsList.innerHTML = '';
|
||||
|
||||
this.state.tab_order.forEach(tabName => {
|
||||
this.state.zone_order.forEach(tabName => {
|
||||
const tabButton = document.createElement('button');
|
||||
tabButton.className = 'tab-button';
|
||||
tabButton.className = 'zone-button';
|
||||
tabButton.textContent = tabName;
|
||||
tabButton.addEventListener('click', () => this.selectTab(tabName));
|
||||
if (tabName === this.currentTab) {
|
||||
@@ -233,13 +217,13 @@ class LightingController {
|
||||
}
|
||||
|
||||
renderPresets(tabName) {
|
||||
const presetsList = document.getElementById('presets-list-tab');
|
||||
const presetsList = document.getElementById('presets-list-zone');
|
||||
presetsList.innerHTML = '';
|
||||
|
||||
const presets = this.state.presets || {};
|
||||
const presetNames = Object.keys(presets);
|
||||
|
||||
// Get current tab's settings for comparison
|
||||
// Get current zone's settings for comparison
|
||||
const currentSettings = this.getCurrentTabSettings(tabName);
|
||||
|
||||
// Always include "on" and "off" presets
|
||||
@@ -283,7 +267,7 @@ class LightingController {
|
||||
const presetButton = document.createElement('button');
|
||||
presetButton.className = 'pattern-button';
|
||||
|
||||
// Check if this preset matches the current tab's settings
|
||||
// Check if this preset matches the current zone's settings
|
||||
const isActive = this.presetMatchesSettings(preset, currentSettings);
|
||||
if (isActive) {
|
||||
presetButton.classList.add('active');
|
||||
@@ -360,7 +344,7 @@ class LightingController {
|
||||
})
|
||||
});
|
||||
|
||||
// Reload state and tab content
|
||||
// Reload state and zone content
|
||||
await this.loadState();
|
||||
await this.loadTabContent(tabName);
|
||||
} else {
|
||||
@@ -607,7 +591,7 @@ class LightingController {
|
||||
}
|
||||
// Reload state from server to ensure consistency
|
||||
await this.loadState();
|
||||
// Reload tab content to update UI
|
||||
// Reload zone content to update UI
|
||||
await this.loadTabContent(tabName);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
@@ -785,23 +769,23 @@ class LightingController {
|
||||
}
|
||||
|
||||
showAddTabModal() {
|
||||
document.getElementById('new-tab-name').value = '';
|
||||
document.getElementById('new-tab-ids').value = '1';
|
||||
document.getElementById('add-tab-modal').classList.add('active');
|
||||
document.getElementById('new-zone-name').value = '';
|
||||
document.getElementById('new-zone-ids').value = '1';
|
||||
document.getElementById('add-zone-modal').classList.add('active');
|
||||
}
|
||||
|
||||
async createTab() {
|
||||
const name = document.getElementById('new-tab-name').value.trim();
|
||||
const idsStr = document.getElementById('new-tab-ids').value.trim();
|
||||
const name = document.getElementById('new-zone-name').value.trim();
|
||||
const idsStr = document.getElementById('new-zone-ids').value.trim();
|
||||
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
|
||||
|
||||
if (!name) {
|
||||
alert('Tab name cannot be empty');
|
||||
alert('Zone name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/tabs', {
|
||||
const response = await fetch('/zones', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, ids })
|
||||
@@ -811,41 +795,41 @@ class LightingController {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
this.selectTab(name);
|
||||
this.hideModal('add-tab-modal');
|
||||
this.hideModal('add-zone-modal');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create tab');
|
||||
alert(error.error || 'Failed to create zone');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create tab:', error);
|
||||
alert('Failed to create tab');
|
||||
console.error('Failed to create zone:', error);
|
||||
alert('Failed to create zone');
|
||||
}
|
||||
}
|
||||
|
||||
showEditTabModal() {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
const light = this.state.lights[this.currentTab];
|
||||
document.getElementById('edit-tab-name').value = this.currentTab;
|
||||
document.getElementById('edit-tab-ids').value = light.names.join(', ');
|
||||
document.getElementById('edit-tab-modal').classList.add('active');
|
||||
document.getElementById('edit-zone-name').value = this.currentTab;
|
||||
document.getElementById('edit-zone-ids').value = light.names.join(', ');
|
||||
document.getElementById('edit-zone-modal').classList.add('active');
|
||||
}
|
||||
|
||||
async updateTab() {
|
||||
const newName = document.getElementById('edit-tab-name').value.trim();
|
||||
const idsStr = document.getElementById('edit-tab-ids').value.trim();
|
||||
const newName = document.getElementById('edit-zone-name').value.trim();
|
||||
const idsStr = document.getElementById('edit-zone-ids').value.trim();
|
||||
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
|
||||
|
||||
if (!newName) {
|
||||
alert('Tab name cannot be empty');
|
||||
alert('Zone name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/tabs/${this.currentTab}`, {
|
||||
const response = await fetch(`/zones/${this.currentTab}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName, ids })
|
||||
@@ -855,45 +839,45 @@ class LightingController {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
this.selectTab(newName);
|
||||
this.hideModal('edit-tab-modal');
|
||||
this.hideModal('edit-zone-modal');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to update tab');
|
||||
alert(error.error || 'Failed to update zone');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update tab:', error);
|
||||
alert('Failed to update tab');
|
||||
console.error('Failed to update zone:', error);
|
||||
alert('Failed to update zone');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCurrentTab() {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the tab '${this.currentTab}'?`)) {
|
||||
if (!confirm(`Are you sure you want to delete the zone '${this.currentTab}'?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/tabs/${this.currentTab}`, {
|
||||
const response = await fetch(`/zones/${this.currentTab}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
if (this.state.zone_order.length > 0) {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
} else {
|
||||
this.currentTab = null;
|
||||
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
|
||||
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete tab:', error);
|
||||
alert('Failed to delete tab');
|
||||
console.error('Failed to delete zone:', error);
|
||||
alert('Failed to delete zone');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,9 +1008,9 @@ class LightingController {
|
||||
if (this.state.current_profile === profileName) {
|
||||
this.state.current_profile = '';
|
||||
this.state.lights = {};
|
||||
this.state.tab_order = [];
|
||||
this.state.zone_order = [];
|
||||
this.renderTabs();
|
||||
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
|
||||
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
|
||||
this.updateCurrentProfileDisplay();
|
||||
}
|
||||
} else {
|
||||
@@ -1048,8 +1032,8 @@ class LightingController {
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
if (this.state.zone_order.length > 0) {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
} else {
|
||||
this.currentTab = null;
|
||||
}
|
||||
@@ -1145,7 +1129,7 @@ class LightingController {
|
||||
swatch.style.cssText = 'width: 40px; height: 40px; background-color: ' + color + '; border: 2px solid #4a4a4a; border-radius: 4px; cursor: pointer; position: relative;';
|
||||
swatch.title = `Click to apply ${color} to selected color`;
|
||||
|
||||
// Click to apply color to currently selected color in active tab
|
||||
// Click to apply color to currently selected color in active zone
|
||||
swatch.addEventListener('click', (e) => {
|
||||
// Only apply if not clicking the remove button
|
||||
if (e.target === swatch || !e.target.closest('button')) {
|
||||
@@ -1167,7 +1151,7 @@ class LightingController {
|
||||
|
||||
applyPaletteColorToSelected(paletteColor) {
|
||||
if (!this.currentTab) {
|
||||
alert('No tab selected. Please select a tab first.');
|
||||
alert('No zone selected. Please select a zone first.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1455,7 +1439,7 @@ class LightingController {
|
||||
|
||||
async applyPreset(presetName) {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1637,7 +1621,7 @@ class LightingController {
|
||||
|
||||
loadCurrentTabToPresetEditor() {
|
||||
if (!this.currentTab || !this.state.lights[this.currentTab]) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const closeButton = document.getElementById('color-palette-close-btn');
|
||||
const paletteContainer = document.getElementById('palette-container');
|
||||
const paletteNewColor = document.getElementById('palette-new-color');
|
||||
const paletteAddButton = document.getElementById('palette-add-color-btn');
|
||||
const profileNameDisplay = document.getElementById('palette-current-profile-name');
|
||||
|
||||
if (!paletteButton || !paletteModal || !paletteContainer) {
|
||||
@@ -12,6 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
let currentProfileId = null;
|
||||
let currentPaletteId = null;
|
||||
let currentPalette = [];
|
||||
let currentProfileName = null;
|
||||
|
||||
@@ -84,7 +84,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Failed to load palette:', error);
|
||||
@@ -99,17 +119,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/profiles/current', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
palette: newPalette,
|
||||
color_palette: newPalette,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save palette');
|
||||
// Ensure we have a palette ID for this profile.
|
||||
if (!currentPaletteId) {
|
||||
const createResponse = await fetch('/palettes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ colors: newPalette }),
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
throw new Error('Failed to create 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;
|
||||
renderPalette();
|
||||
} catch (error) {
|
||||
@@ -131,8 +176,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', closeModal);
|
||||
}
|
||||
if (paletteAddButton && paletteNewColor) {
|
||||
paletteAddButton.addEventListener('click', async () => {
|
||||
if (paletteNewColor) {
|
||||
const addSelectedColor = async () => {
|
||||
const color = paletteNewColor.value;
|
||||
if (!color) {
|
||||
return;
|
||||
@@ -142,11 +187,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
446
src/static/devices.js
Normal file
446
src/static/devices.js
Normal file
@@ -0,0 +1,446 @@
|
||||
// Device registry: name, id (storage key), type (led), transport (wifi|espnow), address
|
||||
|
||||
const HEX_BOX_COUNT = 12;
|
||||
|
||||
/** Last TCP snapshot from WebSocket (so we can apply after async list render). */
|
||||
let lastTcpSnapshotIps = null;
|
||||
|
||||
/** Match server-side ``normalize_tcp_peer_ip`` for WS events vs registry rows. */
|
||||
function normalizeWifiAddressForMatch(addr) {
|
||||
let s = String(addr || '').trim();
|
||||
if (s.toLowerCase().startsWith('::ffff:')) {
|
||||
s = s.slice(7);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const DEVICES_MODAL_POLL_MS = 1000;
|
||||
|
||||
let devicesModalLiveTimer = null;
|
||||
|
||||
function stopDevicesModalLiveRefresh() {
|
||||
if (devicesModalLiveTimer != null) {
|
||||
clearInterval(devicesModalLiveTimer);
|
||||
devicesModalLiveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetch registry and re-render the list (no loading spinner). Keeps scroll position.
|
||||
* Used while the devices modal stays open so new TCP devices, renames, and removals appear live.
|
||||
*/
|
||||
async function refreshDevicesListQuiet() {
|
||||
const modal = document.getElementById('devices-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
const prevTop = container.scrollTop;
|
||||
try {
|
||||
const res = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
renderDevicesList(data || {});
|
||||
container.scrollTop = prevTop;
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function startDevicesModalLiveRefresh() {
|
||||
stopDevicesModalLiveRefresh();
|
||||
devicesModalLiveTimer = setInterval(() => {
|
||||
refreshDevicesListQuiet();
|
||||
}, DEVICES_MODAL_POLL_MS);
|
||||
}
|
||||
|
||||
function updateWifiRowDot(row, connected) {
|
||||
const dot = row.querySelector('.device-status-dot');
|
||||
if (!dot) return;
|
||||
if ((row.dataset.deviceTransport || '') !== 'wifi') return;
|
||||
dot.classList.remove('device-status-dot--online', 'device-status-dot--offline', 'device-status-dot--unknown');
|
||||
if (connected) {
|
||||
dot.classList.add('device-status-dot--online');
|
||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
||||
} else {
|
||||
dot.classList.add('device-status-dot--offline');
|
||||
dot.title = 'Not connected (no Wi-Fi TCP session)';
|
||||
}
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
}
|
||||
|
||||
function applyTcpSnapshot(ips) {
|
||||
const set = new Set(
|
||||
(ips || []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
|
||||
);
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
|
||||
const addr = normalizeWifiAddressForMatch(row.dataset.deviceAddress);
|
||||
updateWifiRowDot(row, set.has(addr));
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep cached snapshot aligned with incremental WS events (connect/disconnect). */
|
||||
function mergeTcpSnapshotPresence(ip, connected) {
|
||||
const n = normalizeWifiAddressForMatch(ip);
|
||||
if (!n) return;
|
||||
const prev = lastTcpSnapshotIps;
|
||||
const set = new Set(
|
||||
(Array.isArray(prev) ? prev : []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
|
||||
);
|
||||
if (connected) {
|
||||
set.add(n);
|
||||
} else {
|
||||
set.delete(n);
|
||||
}
|
||||
lastTcpSnapshotIps = Array.from(set);
|
||||
}
|
||||
|
||||
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 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] || '';
|
||||
});
|
||||
}
|
||||
|
||||
function applyTransportVisibility(transport) {
|
||||
const isWifi = transport === 'wifi';
|
||||
const esp = document.getElementById('edit-device-address-espnow');
|
||||
const wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
|
||||
if (esp) esp.hidden = isWifi;
|
||||
if (wifiWrap) wifiWrap.hidden = !isWifi;
|
||||
}
|
||||
|
||||
function getAddressForPayload(transport) {
|
||||
if (transport === 'wifi') {
|
||||
const el = document.getElementById('edit-device-address-wifi');
|
||||
const v = (el && el.value.trim()) || '';
|
||||
return v || null;
|
||||
}
|
||||
const boxEl = document.getElementById('edit-device-address-boxes');
|
||||
if (!boxEl) return null;
|
||||
const boxes = boxEl.querySelectorAll('.hex-addr-box');
|
||||
const hex = Array.from(boxes).map((b) => b.value).join('').toLowerCase();
|
||||
return hex || null;
|
||||
}
|
||||
|
||||
async function loadDevicesModal() {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
if (typeof window.getEspnowSocket === 'function') {
|
||||
window.getEspnowSocket();
|
||||
}
|
||||
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 yet. Wi-Fi drivers will appear here when they connect over TCP.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
ids.forEach((devId) => {
|
||||
const dev = devices[devId];
|
||||
const t = (dev && dev.type) || 'led';
|
||||
const tr = (dev && dev.transport) || 'espnow';
|
||||
const addrRaw = (dev && dev.address) != null ? String(dev.address).trim() : '';
|
||||
const addrDisplay = addrRaw || '—';
|
||||
|
||||
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';
|
||||
row.dataset.deviceId = devId;
|
||||
row.dataset.deviceTransport = tr;
|
||||
row.dataset.deviceAddress = addrRaw;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'device-status-dot';
|
||||
dot.setAttribute('role', 'img');
|
||||
const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null;
|
||||
if (live === true) {
|
||||
dot.classList.add('device-status-dot--online');
|
||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
} else if (live === false) {
|
||||
dot.classList.add('device-status-dot--offline');
|
||||
dot.title = 'Not connected (no Wi-Fi TCP session)';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
} else {
|
||||
dot.classList.add('device-status-dot--unknown');
|
||||
dot.title = 'ESP-NOW — TCP status does not apply';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (dev && dev.name) || devId;
|
||||
label.style.flex = '1';
|
||||
label.style.minWidth = '100px';
|
||||
|
||||
const macEl = document.createElement('code');
|
||||
macEl.className = 'device-row-mac';
|
||||
macEl.textContent = devId;
|
||||
macEl.title = 'MAC (registry id)';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'muted-text';
|
||||
meta.style.fontSize = '0.85em';
|
||||
meta.textContent = `${t} · ${tr} · ${addrDisplay}`;
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||
|
||||
const identifyBtn = document.createElement('button');
|
||||
identifyBtn.className = 'btn btn-primary btn-small';
|
||||
identifyBtn.type = 'button';
|
||||
identifyBtn.textContent = 'Identify';
|
||||
identifyBtn.title = 'Red blink at 10 Hz (~50% brightness) for 2 s, then off (not saved as a preset)';
|
||||
identifyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}/identify`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'Identify failed');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Identify failed');
|
||||
}
|
||||
});
|
||||
|
||||
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/${encodeURIComponent(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(dot);
|
||||
row.appendChild(label);
|
||||
row.appendChild(macEl);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(identifyBtn);
|
||||
row.appendChild(deleteBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
// Do not re-apply lastTcpSnapshotIps here: it is only updated on WS open and
|
||||
// device_tcp events; re-applying after each /devices poll overwrites correct
|
||||
// API "connected" with a stale list and leaves Wi-Fi rows stuck online.
|
||||
}
|
||||
|
||||
function openEditDeviceModal(devId, dev) {
|
||||
const modal = document.getElementById('edit-device-modal');
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const storageLabel = document.getElementById('edit-device-storage-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const typeSel = document.getElementById('edit-device-type');
|
||||
const transportSel = document.getElementById('edit-device-transport');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
const wifiInput = document.getElementById('edit-device-address-wifi');
|
||||
if (!modal || !idInput) return;
|
||||
idInput.value = devId;
|
||||
if (storageLabel) storageLabel.textContent = devId;
|
||||
if (nameInput) nameInput.value = (dev && dev.name) || '';
|
||||
if (typeSel) typeSel.value = (dev && dev.type) || 'led';
|
||||
const tr = (dev && dev.transport) || 'espnow';
|
||||
if (transportSel) transportSel.value = tr;
|
||||
applyTransportVisibility(tr);
|
||||
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
|
||||
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
async function updateDevice(devId, name, type, transport, address) {
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
type: type || 'led',
|
||||
transport: transport || 'espnow',
|
||||
address,
|
||||
}),
|
||||
});
|
||||
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', () => {
|
||||
window.addEventListener('deviceTcpStatus', (ev) => {
|
||||
const { ip, connected } = ev.detail || {};
|
||||
if (ip == null || typeof connected !== 'boolean') return;
|
||||
mergeTcpSnapshotPresence(ip, connected);
|
||||
const norm = normalizeWifiAddressForMatch(ip);
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
|
||||
if (normalizeWifiAddressForMatch(row.dataset.deviceAddress) === norm) {
|
||||
updateWifiRowDot(row, connected);
|
||||
}
|
||||
});
|
||||
});
|
||||
window.addEventListener('deviceTcpSnapshot', (ev) => {
|
||||
const ips = ev.detail && ev.detail.connectedIps;
|
||||
lastTcpSnapshotIps = ips;
|
||||
applyTcpSnapshot(ips);
|
||||
});
|
||||
|
||||
window.addEventListener('deviceTcpWsOpen', () => {
|
||||
refreshDevicesListQuiet();
|
||||
});
|
||||
|
||||
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||
|
||||
const transportEdit = document.getElementById('edit-device-transport');
|
||||
if (transportEdit) {
|
||||
transportEdit.addEventListener('change', () => {
|
||||
applyTransportVisibility(transportEdit.value);
|
||||
});
|
||||
}
|
||||
|
||||
const devicesBtn = document.getElementById('devices-btn');
|
||||
const devicesModal = document.getElementById('devices-modal');
|
||||
const devicesCloseBtn = document.getElementById('devices-close-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');
|
||||
if (typeof window.getEspnowSocket === 'function') {
|
||||
window.getEspnowSocket();
|
||||
}
|
||||
loadDevicesModal();
|
||||
startDevicesModalLiveRefresh();
|
||||
});
|
||||
}
|
||||
if (devicesCloseBtn) {
|
||||
devicesCloseBtn.addEventListener('click', () => {
|
||||
if (devicesModal) devicesModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
const devicesModalEl = document.getElementById('devices-modal');
|
||||
if (devicesModalEl) {
|
||||
new MutationObserver(() => {
|
||||
if (!devicesModalEl.classList.contains('active')) {
|
||||
stopDevicesModalLiveRefresh();
|
||||
}
|
||||
}).observe(devicesModalEl, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const typeSel = document.getElementById('edit-device-type');
|
||||
const transportSel = document.getElementById('edit-device-transport');
|
||||
const devId = idInput && idInput.value;
|
||||
if (!devId) return;
|
||||
const transport = (transportSel && transportSel.value) || 'espnow';
|
||||
const address = getAddressForPayload(transport);
|
||||
const ok = await updateDevice(
|
||||
devId,
|
||||
nameInput ? nameInput.value.trim() : '',
|
||||
(typeSel && typeSel.value) || 'led',
|
||||
transport,
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user