37 Commits

Author SHA1 Message Date
ff92451a76 feat(profiles): seed twinkle preset defaults
Made-with: Cursor
2026-04-21 20:43:25 +12:00
60485bc06a feat(ui): add clear device presets action
Made-with: Cursor
2026-04-21 00:44:38 +12:00
f6f299c3e5 feat(presets): add radiate pattern defaults
Made-with: Cursor
2026-04-20 23:38:02 +12:00
66485f5c59 chore(led-driver): bump submodule for patterns and tests
Made-with: Cursor
2026-04-19 23:28:22 +12:00
5f9ff9bcc9 style(ui): presets patterns and layout tweaks
Made-with: Cursor
2026-04-19 23:28:08 +12:00
35730b36f0 feat(api): improve pattern deploy and device tcp handling
Made-with: Cursor
2026-04-19 23:28:01 +12:00
d516833cc3 feat(profiles): seed colour cycle flicker and flame presets
Made-with: Cursor
2026-04-19 23:27:57 +12:00
220be64dec feat(db): add flicker flame presets and pattern metadata
Made-with: Cursor
2026-04-19 23:27:49 +12:00
b433477c64 chore(db): trim device registry
Made-with: Cursor
2026-04-19 23:27:37 +12:00
43b7047c57 chore(submodule): bump led-tool for cli upload flags
Made-with: Cursor
2026-04-15 00:46:40 +12:00
167417d1ec feat(ui): add web led-tool usb controls
Made-with: Cursor
2026-04-15 00:46:31 +12:00
fb8141b320 fix(server): close http listener cleanly on shutdown 2026-04-15 00:00:23 +12:00
96712dda88 feat(controller): migrate wifi drivers from tcp to websocket clients 2026-04-14 23:13:26 +12:00
f5a7b42e7c fix(rules): revert unintended submodule changes 2026-04-14 21:54:02 +12:00
1b1e9d727e chore(rules): enforce strict user-scoped changes 2026-04-14 21:50:55 +12:00
668d29b786 chore(test): move pytest defaults to pyproject.toml
Made-with: Cursor
2026-04-12 02:39:39 +12:00
e5f42e099e chore: remove esp32 firmware tree and dev mpremote helper
Made-with: Cursor
2026-04-12 02:39:37 +12:00
a9edda38ef test(browser): fixture, env host and pacing, safer colour inputs
Made-with: Cursor
2026-04-12 02:34:46 +12:00
edec5ff460 chore(git): ignore pytest cache and ropeproject
Made-with: Cursor
2026-04-12 02:34:44 +12:00
pi
264eb7296f test: fix zone_ctl fixture, pattern assertions, and browser cleanup
Made-with: Cursor
2026-04-12 00:27:43 +12:00
pi
fbd4295302 feat(ui): patterns list and create form layout
Made-with: Cursor
2026-04-12 00:13:58 +12:00
pi
7bdb324ebc feat(patterns): driver_patterns helper, on/off ota guard, drop duplicate py tree
Made-with: Cursor
2026-04-12 00:13:56 +12:00
pi
28b19b5219 docs: zones, transports, pattern ota, and submodule readmes
Made-with: Cursor
2026-04-12 00:13:54 +12:00
pi
75ddd559c9 chore(db,led-tool): sync device/zone data and led-tool submodule
Made-with: Cursor
2026-04-11 15:20:26 +12:00
pi
5a1067263a chore: add pattern samples, http driver helpers, OTA/UDP test tools
- patterns/: sample dynamic pattern modules for OTA
- esp32/msg.json: example bridge message shape
- models/http_driver.py, wifi_peer.py: Wi-Fi driver HTTP poll helpers
- tests: pattern OTA send script and UDP discovery echo server
- Submodule led-driver: http_poll and test utilities

Made-with: Cursor
2026-04-11 15:19:15 +12:00
pi
e67de6215a feat(patterns,api): pattern OTA, graceful shutdown, driver delivery updates
- Pattern controller/UI and presets patterns tab for OTA to Wi-Fi drivers
- Device controller extensions; driver_delivery chunk handling
- main: SIGINT/SIGTERM shutdown, TCP/UDP server close coordination
- Submodule led-driver: Wi-Fi default transport, lazy espnow import, dynamic patterns

Made-with: Cursor
2026-04-11 15:10:23 +12:00
pi
7179b6531e feat(controller): udp hello discovery and remove tcp registration
Made-with: Cursor
2026-04-06 21:28:13 +12:00
pi
fd618d7714 feat(zones): rename tabs to zones across api, ui, and storage
Made-with: Cursor
2026-04-06 18:22:03 +12:00
pi
d1ffb857c8 feat(ui): devices tcp status, tabs send, preset websocket hooks
Made-with: Cursor
2026-04-06 00:22:00 +12:00
pi
f8eba0ee7e feat(api): tcp driver registry, identify, preset push delivery
- Track Wi-Fi TCP clients, liveness pings, disconnect broadcast, bind errors via gather\n- Device list/get include connected; POST identify with __identify preset\n- Presets push/send delivery helpers; bump led-driver hello type

Made-with: Cursor
2026-04-06 00:21:57 +12:00
pi
e6b5bf2cf1 feat(devices): wifi tcp registry, device API/UI, tests; bump led-tool
Made-with: Cursor
2026-04-05 21:13:07 +12:00
pi
fbae75b957 chore(cursor): add scoped-fixes rule for minimal changes
Made-with: Cursor
2026-04-05 21:13:03 +12:00
pi
93476655fc test: add tcp mock server with bind conflict hints
Made-with: Cursor
2026-04-05 16:41:23 +12:00
pi
09a87b79d2 docs(ui): update help assets and regenerate help pdf 2026-03-26 00:40:40 +13:00
pi
ec39df00fc feat(settings/espnow): validate wifi_channel and wire into firmware 2026-03-26 00:40:21 +13:00
pi
43d494bcb9 fix(api): prevent circular reference in pattern create 2026-03-26 00:40:08 +13:00
pi
fed312a397 fix(test/endpoints): add pytest coverage for all Microdot routes 2026-03-26 00:39:41 +13:00
85 changed files with 9150 additions and 2858 deletions

View 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/`**.

View 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 users *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.

View 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.

5
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# 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
@@ -23,7 +25,10 @@ ENV/
Thumbs.db
# Project specific
docs/.help-print.html
settings.json
*.log
*.db
*.sqlite
.pytest_cache/
.ropeproject/

View File

@@ -13,8 +13,10 @@ requests = "*"
selenium = "*"
adafruit-ampy = "*"
microdot = "*"
websockets = "*"
[dev-packages]
pytest = "*"
[requires]
python_version = "3.12"
@@ -25,3 +27,4 @@ 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"

738
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "12b64c3bf5857d958f790f2416072408e2244631242ba2598210d89df330e184"
"sha256": "18691f772c7660e4a087c90560c87a9217a09e9b6db97825d21c092a06d64b89"
},
"pipfile-spec": 6,
"requires": {
@@ -26,128 +26,128 @@
},
"anyio": {
"hashes": [
"sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703",
"sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
"sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
],
"markers": "python_version >= '3.9'",
"version": "==4.12.1"
"markers": "python_version >= '3.10'",
"version": "==4.13.0"
},
"attrs": {
"hashes": [
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
"sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"
"sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
"sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"
],
"markers": "python_version >= '3.9'",
"version": "==25.4.0"
"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": [
@@ -257,191 +257,208 @@
},
"charset-normalizer": {
"hashes": [
"sha256:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4",
"sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66",
"sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54",
"sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05",
"sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765",
"sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064",
"sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819",
"sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e",
"sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412",
"sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc",
"sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e",
"sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281",
"sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af",
"sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2",
"sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe",
"sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8",
"sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262",
"sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac",
"sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85",
"sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c",
"sha256:1f2da5cbb9becfcd607757a169e38fb82aa5fd86fae6653dea716e7b613fe2cf",
"sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139",
"sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770",
"sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d",
"sha256:2b970382e4a36bed897c19f310f31d7d13489c11b4f468ddfba42d41cddfb918",
"sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3",
"sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7",
"sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39",
"sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d",
"sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990",
"sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765",
"sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1",
"sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa",
"sha256:4b8551b6e6531e156db71193771c93bda78ffc4d1e6372517fe58ad3b91e4659",
"sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d",
"sha256:50bcbca6603c06a1dcc7b056ed45c37715fb5d2768feb3bcd37d2313c587a5b9",
"sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9",
"sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2",
"sha256:573ef5814c4b7c0d59a7710aa920eaaaef383bd71626aa420fba27b5cab92e8d",
"sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475",
"sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c",
"sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81",
"sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67",
"sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99",
"sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5",
"sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694",
"sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf",
"sha256:65b3c403a5b6b8034b655e7385de4f72b7b244869a22b32d4030b99a60593eca",
"sha256:66dee73039277eb35380d1b82cccc69cc82b13a66f9f4a18da32d573acf02b7c",
"sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c",
"sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636",
"sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f",
"sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02",
"sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497",
"sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f",
"sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2",
"sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d",
"sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873",
"sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a",
"sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e",
"sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1",
"sha256:8ce11cd4d62d11166f2b441e30ace226c19a3899a7cf0796f668fba49a9fb123",
"sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550",
"sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc",
"sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36",
"sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644",
"sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4",
"sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0",
"sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e",
"sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f",
"sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4",
"sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98",
"sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294",
"sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22",
"sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23",
"sha256:ae8b03427410731469c4033934cf473426faff3e04b69d2dfb64a4281a3719f8",
"sha256:afca7f78067dd27c2b848f1b234623d26b87529296c6c5652168cc1954f2f3b2",
"sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362",
"sha256:b3e71afc578b98512bfe7bdb822dd6bc57d4b0093b4b6e5487c1e96ad4ace242",
"sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4",
"sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95",
"sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d",
"sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94",
"sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6",
"sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2",
"sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4",
"sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8",
"sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e",
"sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a",
"sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce",
"sha256:d29dd9c016f2078b43d0c357511e87eee5b05108f3dd603423cb389b89813969",
"sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f",
"sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923",
"sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6",
"sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee",
"sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6",
"sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467",
"sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f",
"sha256:e22d1059b951e7ae7c20ef6b06afd10fb95e3c41bf3c4fbc874dba113321c193",
"sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7",
"sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9",
"sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95",
"sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763",
"sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7",
"sha256:ec56a2266f32bc06ed3c3e2a8f58417ce02f7e0356edc89786e52db13c593c98",
"sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60",
"sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade",
"sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c",
"sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2",
"sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f",
"sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a",
"sha256:fc1c64934b8faf7584924143eb9db4770bbdb16659626e1a1a4d9efbcb68d947",
"sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3"
"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.5"
"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:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
"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.5"
"version": "==46.0.7"
},
"esptool": {
"hashes": [
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==5.2.0"
},
"h11": {
@@ -489,15 +506,17 @@
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==2.6.0"
},
"mpremote": {
"hashes": [
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
"sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334",
"sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31"
],
"index": "pypi",
"version": "==1.27.0"
"markers": "python_version >= '3.4'",
"version": "==1.28.0"
},
"outcome": {
"hashes": [
@@ -509,11 +528,11 @@
},
"platformdirs": {
"hashes": [
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
"sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a",
"sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"
],
"markers": "python_version >= '3.10'",
"version": "==4.9.4"
"version": "==4.9.6"
},
"pycparser": {
"hashes": [
@@ -525,11 +544,11 @@
},
"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": [
@@ -537,6 +556,7 @@
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.12.1"
},
"pyserial": {
@@ -651,19 +671,20 @@
},
"requests": {
"hashes": [
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
"sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517",
"sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
],
"index": "pypi",
"version": "==2.32.5"
"markers": "python_version >= '3.10'",
"version": "==2.33.1"
},
"rich": {
"hashes": [
"sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
"sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
"sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb",
"sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"
],
"markers": "python_full_version >= '3.8.0'",
"version": "==14.3.3"
"markers": "python_full_version >= '3.9.0'",
"version": "==15.0.0"
},
"rich-click": {
"hashes": [
@@ -675,11 +696,12 @@
},
"selenium": {
"hashes": [
"sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa",
"sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1"
"sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769",
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
],
"index": "pypi",
"version": "==4.41.0"
"markers": "python_version >= '3.10'",
"version": "==4.43.0"
},
"sniffio": {
"hashes": [
@@ -758,6 +780,9 @@
"version": "==4.15.0"
},
"urllib3": {
"extras": [
"socks"
],
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
@@ -878,6 +903,7 @@
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1"
},
"websocket-client": {
@@ -888,6 +914,74 @@
"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",
@@ -897,5 +991,47 @@
"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"
}
}
}

View File

@@ -1,26 +1,30 @@
# led-controller
LED controller web app for managing profiles, tabs, presets, and colour palettes, and sending commands to LED devices over the serial -> ESP-NOW bridge.
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`
- 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 tabs/presets and apply profiles. Editing actions are hidden.
- **Edit mode**: management view. Shows Tabs, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
- **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 tab content.
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete).
- 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` tab (starter presets).
- Optional **DJ tab** seeding creates:
- `dj` tab bound to device name `dj`
- 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
@@ -34,3 +38,6 @@ LED controller web app for managing profiles, tabs, presets, and colour palettes
- 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.

View File

@@ -1 +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": []}}

View File

@@ -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 1030 s, -1=off)",
"n4": "Spark gap max (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"twinkle": {
"n1": "Twinkle activity (1255, higher = more changes)",
"n2": "Density (0255, higher = more of the strip lit)",
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"radiate": {
"n1": "Node spacing (LEDs)",
"n2": "Out time (ms)",
"n3": "In time (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": {"name": "default", "type": "tabs", "tabs": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "tabs", "tabs": ["6", "7"], "scenes": [], "palette_id": "12"}}
{"1": {"name": "default", "type": "zones", "zones": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}

View File

@@ -1 +0,0 @@
{"1": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["4", "2", "7"], ["15", "3", "14"], ["5", "8", "10"], ["11", "9", "12"], ["1", "13", "37"]], "presets_flat": ["4", "2", "7", "15", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37"], "default_preset": "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": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

1
db/zone.json Normal file
View 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
View File

@@ -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")

View File

@@ -2,10 +2,12 @@
This document covers:
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
2. **LED driver JSON** — the compact message format sent over the serialESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
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 **serialESP-NOW bridge** to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields).
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
**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 drivers 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 WiFi 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.
@@ -15,8 +17,8 @@ All JSON APIs use `Content-Type: application/json` for bodies and responses unle
The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (tab/preset selection and profile apply).
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, and profile management actions).
- **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:
@@ -50,10 +52,12 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
Connect to **`ws://<host>:<port>/ws`**.
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
- Send **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
@@ -70,6 +74,29 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
### 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 |
@@ -77,9 +104,9 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_tab` (request-only) seeds a DJ tab + presets. New profiles always get a populated `default` tab. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles` | 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 (tabs, palettes, presets). Body may include `name`. |
| 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. |
@@ -120,18 +147,18 @@ Stored preset records can include:
- `colors`: resolved hex colours for editor/display.
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
### Tabs — `/tabs`
### Zones — `/zones`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
| GET | `/tabs/current` | Current tab from cookie/session. |
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profiles tab list. |
| GET | `/tabs/<id>` | Tab JSON. |
| PUT | `/tabs/<id>` | Update tab. |
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
| 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 profiles 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`
@@ -175,20 +202,33 @@ Stored preset records can include:
### 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/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
| GET | `/patterns` | All pattern records. |
| GET | `/patterns/<id>` | One pattern. |
| 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)
## 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.
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

View File

@@ -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)
@@ -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
@@ -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
View 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 devices 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.
![Schematic: zone buttons on the left; Profiles, Zones, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg)
*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 zones 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 devices **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.
![Schematic: zone title, brightness slider, and a row of preset tiles; Edit mode adds an Edit control and drag handles for reordering.](images/help/zone-preset-strip.svg)
*The slider controls global brightness for the zones 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 **n1n8** 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 profiles palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
- **Brightness (0255)** 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 zones **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 zones list only**; the preset remains in the profile for other zones.
![Schematic: preset editor with name, pattern, colour swatches (one with a P badge for palette-linked), and action buttons.](images/help/preset-editor.svg)
*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 zones preset IDs, and calls **`POST /presets/send`** per zone (including each zones **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 profiles** palette swatches. Those colours are reused by **From Palette** in the preset editor and stay in sync while the **P** link remains.
![Schematic: palette modal with a row of swatches for the current profile.](images/help/colour-palette.svg)
*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.).
![Schematic: narrow layout with Menu and the same header actions in a dropdown.](images/help/mobile-menu.svg)
*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

Binary file not shown.

View 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

View 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

View 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

View 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

View 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

View File

@@ -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() {

View File

@@ -1,112 +0,0 @@
# Benchmark: LRU eviction vs add-then-remove-after-use on ESP32.
# Run on device: mpremote run esp32/benchmark_peers.py
# (add/del_peer are timed; send() may fail if no peer is listening - timing still valid)
import espnow
import network
import time
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
MAX_PEERS = 20
ITERATIONS = 50
PAYLOAD = b"x" * 32 # small payload
network.WLAN(network.STA_IF).active(True)
esp = espnow.ESPNow()
esp.active(True)
esp.add_peer(BROADCAST)
# Build 19 dummy MACs so we have 20 peers total (broadcast + 19).
def mac(i):
return bytes([0, 0, 0, 0, 0, i])
peers_list = [mac(i) for i in range(1, 20)]
for p in peers_list:
esp.add_peer(p)
# One "new" MAC we'll add/remove.
new_mac = bytes([0, 0, 0, 0, 0, 99])
def bench_lru():
"""LRU: ensure_peer (evict oldest + add new), send, update last_used."""
last_used = {BROADCAST: time.ticks_ms()}
for p in peers_list:
last_used[p] = time.ticks_ms()
# Pre-remove one so we have 19; ensure_peer(new) will add 20th.
esp.del_peer(peers_list[-1])
last_used.pop(peers_list[-1], None)
# Now 19 peers. Each iteration: ensure_peer(new) -> add_peer(new), send, update.
# Next iter: ensure_peer(new) -> already there, just send. So we need to force
# eviction each time: use a different "new" each time so we always evict+add.
t0 = time.ticks_us()
for i in range(ITERATIONS):
addr = bytes([0, 0, 0, 0, 0, 50 + (i % 30)]) # 30 different "new" MACs
peers = esp.get_peers()
peer_macs = [p[0] for p in peers]
if addr not in peer_macs:
if len(peer_macs) >= MAX_PEERS:
oldest_mac = None
oldest_ts = time.ticks_ms()
for m in peer_macs:
if m == BROADCAST:
continue
ts = last_used.get(m, 0)
if ts <= oldest_ts:
oldest_ts = ts
oldest_mac = m
if oldest_mac is not None:
esp.del_peer(oldest_mac)
last_used.pop(oldest_mac, None)
esp.add_peer(addr)
esp.send(addr, PAYLOAD)
last_used[addr] = time.ticks_ms()
t1 = time.ticks_us()
return time.ticks_diff(t1, t0)
def bench_add_then_remove():
"""Add peer, send, del_peer (remove after use). At 20 we must del one first."""
# Start full: 20 peers. To add new we del any one, add new, send, del new.
victim = peers_list[0]
t0 = time.ticks_us()
for i in range(ITERATIONS):
esp.del_peer(victim) # make room
esp.add_peer(new_mac)
esp.send(new_mac, PAYLOAD)
esp.del_peer(new_mac)
esp.add_peer(victim) # put victim back so we're at 20 again
t1 = time.ticks_us()
return time.ticks_diff(t1, t0)
def bench_send_existing():
"""Baseline: send to existing peer only (no add/del)."""
t0 = time.ticks_us()
for _ in range(ITERATIONS):
esp.send(peers_list[0], PAYLOAD)
t1 = time.ticks_us()
return time.ticks_diff(t1, t0)
print("ESP-NOW peer benchmark ({} iterations)".format(ITERATIONS))
print()
# Baseline: send to existing peer
try:
us = bench_send_existing()
print("Send to existing peer only: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
except Exception as e:
print("Send existing failed:", e)
print()
# LRU: evict oldest then add new, send
try:
us = bench_lru()
print("LRU (evict oldest + add + send): {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
except Exception as e:
print("LRU failed:", e)
print()
# Add then remove after use
try:
us = bench_add_then_remove()
print("Add then remove after use: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
except Exception as e:
print("Add-then-remove failed:", e)
print()
print("Done.")

View File

@@ -1,66 +0,0 @@
# Serial-to-ESP-NOW bridge: receives from Pi on UART, forwards to ESP-NOW peers.
# Wire format: first 6 bytes = destination MAC, rest = payload. Address is always 6 bytes.
from machine import Pin, UART
import espnow
import network
import time
UART_BAUD = 912000
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
MAX_PEERS = 20
network.WLAN(network.STA_IF).active(True)
esp = espnow.ESPNow()
esp.active(True)
esp.add_peer(BROADCAST)
uart = UART(1, UART_BAUD, tx=Pin(21), rx=Pin(6))
# Track last send time per peer for LRU eviction (remove oldest when at limit).
last_used = {BROADCAST: time.ticks_ms()}
# ESP_ERR_ESPNOW_EXIST: peer already registered (ignore when adding).
ESP_ERR_ESPNOW_EXIST = -12395
def ensure_peer(addr):
"""Ensure addr is in the peer list. When at 20 peers, remove the oldest-used (LRU)."""
peers = esp.get_peers()
peer_macs = [p[0] for p in peers]
if addr in peer_macs:
return
if len(peer_macs) >= MAX_PEERS:
# Remove the peer we used least recently (oldest).
oldest_mac = None
oldest_ts = time.ticks_ms()
for mac in peer_macs:
if mac == BROADCAST:
continue
ts = last_used.get(mac, 0)
if ts <= oldest_ts:
oldest_ts = ts
oldest_mac = mac
if oldest_mac is not None:
esp.del_peer(oldest_mac)
last_used.pop(oldest_mac, None)
try:
esp.add_peer(addr)
except OSError as e:
if e.args[0] != ESP_ERR_ESPNOW_EXIST:
raise
print("Starting ESP32 main.py")
while True:
if uart.any():
data = uart.read()
if not data or len(data) < 6:
continue
print(f"Received data: {data}")
addr = data[:6]
payload = data[6:]
ensure_peer(addr)
esp.send(addr, payload)
last_used[addr] = time.ticks_ms()

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_endpoints_pytest.py"]

19
scripts/build_help_pdf.sh Executable file
View 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)"

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
# Copy esp32/main.py to the connected ESP32 as /main.py (single line, no wrap).
cd "$(dirname "$0")/.."
pipenv run mpremote fs cp esp32/main.py :/main.py

96
scripts/help-pdf.css Normal file
View 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;
}

View File

@@ -1,29 +1,170 @@
from microdot import Microdot
from models.device import Device
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."""
"""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] = 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."""
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
dev = devices.read(id)
if dev:
return json.dumps(dev), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404
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("")
@@ -32,37 +173,221 @@ async def create_device(request):
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")
tabs = data.get("tabs")
if isinstance(tabs, list):
tabs = [str(t) for t in tabs]
zl = data.get("zones")
if isinstance(zl, list):
zl = [str(t) for t in zl]
else:
tabs = []
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs)
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
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put("/<id>")
async def update_device(request, id):
"""Update a device."""
try:
data = request.json or {}
if "tabs" in data and isinstance(data["tabs"], list):
data["tabs"] = [str(t) for t in data["tabs"]]
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
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
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
return json.dumps({"error": "Device not found"}), 404
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
View 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

View File

@@ -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

View File

@@ -2,9 +2,10 @@ 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 asyncio
import json
controller = Microdot()
@@ -125,13 +126,17 @@ async def delete_preset(request, *args, **kwargs):
@with_session
async def send_presets(request, session):
"""
Send one or more presets to the LED driver (via serial transport).
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).
The controller looks up each preset, converts to API format, chunks into
<= 240-byte messages, and sends them over the configured transport.
Optional "destination_mac" / "to": single MAC when targets is omitted.
"""
try:
data = request.json or {}
@@ -144,7 +149,6 @@ async def send_presets(request, session):
save_flag = data.get('save', True)
save_flag = bool(save_flag)
default_id = data.get('default')
# Optional 12-char hex MAC to send to one device; omit for default (e.g. broadcast).
destination_mac = data.get('destination_mac') or data.get('to')
# Build API-compliant preset map keyed by preset ID, include name
@@ -171,23 +175,13 @@ async def send_presets(request, session):
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
async def send_chunk(chunk_presets, is_last):
# Save/default should only be sent with the final presets chunk.
msg = build_message(
presets=chunk_presets,
save=save_flag and is_last,
default=default_id if is_last else None,
)
await sender.send(msg, addr=destination_mac)
MAX_BYTES = 240
send_delay_s = 0.1
entries = list(presets_by_name.items())
total_presets = len(entries)
messages_sent = 0
batch = {}
last_msg = None
chunk_messages = []
for name, preset_obj in entries:
test_batch = dict(batch)
test_batch[name] = preset_obj
@@ -196,28 +190,133 @@ async def send_presets(request, session):
if size <= MAX_BYTES or not batch:
batch = test_batch
last_msg = test_msg
else:
try:
await send_chunk(batch, False)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep(send_delay_s)
messages_sent += 1
chunk_messages.append(
build_message(
presets=dict(batch),
save=False,
default=None,
)
)
batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
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:
await send_chunk(batch, True)
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'}
await asyncio.sleep(send_delay_s)
messages_sent += 1
return json.dumps({
"message": "Presets sent",
"presets_sent": total_presets,
"messages_sent": messages_sent
"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'}

View File

@@ -1,13 +1,13 @@
from microdot import Microdot
from microdot.session import with_session
from models.profile import Profile
from models.tab import Tab
from models.zone import Zone
from models.preset import Preset
import json
controller = Microdot()
profiles = Profile()
tabs = Tab()
zones = Zone()
presets = Preset()
@controller.get('')
@@ -83,20 +83,20 @@ async def create_profile(request):
try:
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_tab", False)
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_tab = bool(seed_raw)
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_tab", None)
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)
# New profiles always start with a default tab pre-populated with starter presets.
# New profiles always start with a default zone pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
@@ -124,6 +124,15 @@ async def create_profile(request):
"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",
@@ -132,6 +141,39 @@ async def create_profile(request):
"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:
@@ -139,18 +181,18 @@ async def create_profile(request):
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
tabs.update(default_tab_id, {
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("tabs", []) if isinstance(profile.get("tabs", []), list) else []
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_tab:
# Seed a DJ-focused tab with three starter presets.
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
@@ -182,15 +224,15 @@ async def create_profile(request):
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
tabs.update(dj_tab_id, {
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, {"tabs": profile_tabs})
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'}
@@ -208,7 +250,7 @@ async def clone_profile(request, id):
data = request.json or {}
source_name = source.get("name") or f"Profile {id}"
new_name = data.get("name") or source_name
profile_type = source.get("type", "tabs")
profile_type = source.get("type", "zones")
def allocate_id(model, cache):
if "next" not in cache:
@@ -255,28 +297,28 @@ async def clone_profile(request, id):
palette_colors = []
# Clone tabs and presets used by those tabs
source_tabs = source.get("tabs")
source_tabs = source.get("zones")
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
source_tabs = source.get("tab_order", [])
source_tabs = source.get("zone_order", [])
source_tabs = source_tabs or []
cloned_tab_ids = []
preset_id_map = {}
new_tabs = {}
new_presets = {}
for tab_id in source_tabs:
tab = tabs.read(tab_id)
if not tab:
for zone_id in source_tabs:
zone = zones.read(zone_id)
if not zone:
continue
tab_name = tab.get("name") or f"Tab {tab_id}"
tab_name = zone.get("name") or f"Zone {zone_id}"
clone_name = tab_name
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
clone_id = allocate_id(tabs, tab_cache)
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": tab.get("names") or [],
"names": zone.get("names") or [],
"presets": mapped_presets if mapped_presets is not None else []
}
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
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:
@@ -287,7 +329,7 @@ async def clone_profile(request, id):
new_profile_data = {
"name": new_name,
"type": profile_type,
"tabs": cloned_tab_ids,
"zones": cloned_tab_ids,
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
"palette_id": str(new_palette_id),
}
@@ -297,12 +339,12 @@ async def clone_profile(request, id):
for pid, pdata in new_presets.items():
presets[pid] = pdata
for tid, tdata in new_tabs.items():
tabs[tid] = tdata
zones[tid] = tdata
profiles[str(new_profile_id)] = new_profile_data
profiles._palette_model.save()
presets.save()
tabs.save()
zones.save()
profiles.save()
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}

View File

@@ -55,15 +55,28 @@ async def configure_ap(request):
except Exception as e:
return json.dumps({"error": str(e)}), 500
def _validate_wifi_channel(value):
"""Return int 111 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

View File

@@ -1,346 +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 cookie."""
# Read from cookie first
current_tab = request.cookies.get('current_tab')
if current_tab:
return current_tab
# Fallback to first tab in current profile
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
def _render_tabs_list_fragment(request, session):
"""Helper function to render tabs list HTML fragment."""
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:_render_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'}
def _render_tab_content_fragment(request, session, id):
"""Helper function to render tab content HTML fragment."""
# Handle 'current' as a special case
if id == 'current':
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
if wants_html:
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "No current tab set"}), 404
id = current_tab_id
tab = tabs.read(id)
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;"></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('')
@with_session
async def list_tabs(request, session):
"""List all tabs with current tab info."""
profile_id = get_current_profile_id(session)
current_tab_id = get_current_tab_id(request, session)
# Get tab order for current profile
tab_order = get_profile_tab_order(profile_id) if profile_id else []
# Build tabs list with metadata
tabs_data = {}
for tab_id in tabs.list():
tab_data = tabs.read(tab_id)
if tab_data:
tabs_data[tab_id] = tab_data
return json.dumps({
"tabs": tabs_data,
"tab_order": tab_order,
"current_tab_id": current_tab_id,
"profile_id": profile_id
}), 200, {'Content-Type': 'application/json'}
# Get current tab - returns JSON with tab data and content info
@controller.get('/current')
@with_session
async def get_current_tab(request, session):
"""Get the current tab from session."""
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
return json.dumps({"error": "No current tab set", "tab": None, "tab_id": None}), 404
tab = tabs.read(current_tab_id)
if tab:
return json.dumps({
"tab": tab,
"tab_id": current_tab_id
}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found", "tab": None, "tab_id": None}), 404
@controller.post('/<id>/set-current')
async def set_current_tab(request, id):
"""Set a tab as the current tab in cookie."""
tab = tabs.read(id)
if not tab:
return json.dumps({"error": "Tab not found"}), 404
# Set cookie with current tab
response_data = json.dumps({"message": "Current tab set", "tab_id": id})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': f'current_tab={id}; Path=/; Max-Age=31536000' # 1 year expiry
}
return response
@controller.get('/<id>')
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, session, id):
"""Delete a tab."""
try:
# 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:
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 cookie if the deleted tab was the current tab
current_tab_id = get_current_tab_id(request, session)
if current_tab_id == id:
response_data = json.dumps({"message": "Tab deleted successfully"})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': 'current_tab=; Path=/; Max-Age=0' # Clear cookie
}
return response
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 500, {'Content-Type': 'application/json'}
@controller.post('')
@with_session
async def create_tab(request, session):
"""Create a new tab."""
try:
# Handle form data 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:
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)
# Return JSON response with tab ID
tab_data = tabs.read(tab_id)
return json.dumps({tab_id: tab_data}), 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_tab(request, session, id):
"""Clone an existing tab and add it to the current profile."""
try:
source = tabs.read(id)
if not source:
return json.dumps({"error": "Tab not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Tab {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = tabs.create(new_name, source.get("names"), source.get("presets"))
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
tabs.update(clone_id, extra)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if clone_id not in tabs_list:
tabs_list.append(clone_id)
profile['tabs'] = tabs_list
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
tab_data = tabs.read(clone_id)
return json.dumps({clone_id: tab_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 400

361
src/controllers/zone.py Normal file
View 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

View File

@@ -1,6 +1,11 @@
import asyncio
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
@@ -10,12 +15,225 @@ 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
from models.transport import get_sender, set_sender
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 WiFi 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):
@@ -40,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'),
]
@@ -50,11 +268,16 @@ 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')
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('/')
@@ -85,6 +308,9 @@ async def main(port=80):
@app.route('/ws')
@with_websocket
async def ws(request, ws):
await register_device_status_ws(ws)
await broadcast_device_tcp_snapshot_to(ws)
try:
while True:
data = await ws.receive()
print(data)
@@ -112,14 +338,74 @@ async def main(port=80):
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()
while True:
await asyncio.sleep(30)
# cleanup before ending the application
udp_holder = {"closing": False}
loop = asyncio.get_running_loop()
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__":
import os

View File

@@ -1,48 +1,228 @@
"""
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 _normalize_address(addr):
"""Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons)."""
if addr is None:
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(addr).strip().lower().replace(":", "").replace("-", "")
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 create(self, name="", address=None, default_pattern=None, tabs=None):
next_id = self.get_next_id()
addr = _normalize_address(address)
self[next_id] = {
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,
"tabs": list(tabs) if tabs else [],
"zones": list(zones) if zones else [],
}
self.save()
return next_id
return mac_hex
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
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
if "address" in data and data["address"] is not None:
data = dict(data)
data["address"] = _normalize_address(data["address"])
self[id_str].update(data)
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
@@ -52,3 +232,54 @@ class Device(Model):
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
View 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

View File

@@ -26,18 +26,18 @@ class Profile(Model):
if changed:
self.save()
def create(self, name="", profile_type="tabs"):
def create(self, name="", profile_type="zones"):
"""Create a new profile and its own empty palette.
profile_type: "tabs" or "scenes" (ignoring scenes for now)
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_id": str(palette_id),
}

View File

@@ -1,39 +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 [],
"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())

View File

@@ -39,10 +39,12 @@ class SerialSender:
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

8
src/models/wifi_peer.py Normal file
View 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

View 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
View 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())

View File

@@ -45,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; 111
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:

View File

@@ -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'));
@@ -125,12 +125,12 @@ class LightingController {
}
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) {
@@ -217,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
@@ -267,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');
@@ -344,7 +344,7 @@ class LightingController {
})
});
// Reload state and tab content
// Reload state and zone content
await this.loadState();
await this.loadTabContent(tabName);
} else {
@@ -591,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();
@@ -769,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 })
@@ -795,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 })
@@ -839,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');
}
}
@@ -1008,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 {
@@ -1032,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;
}
@@ -1129,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')) {
@@ -1151,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;
}
@@ -1439,7 +1439,7 @@ class LightingController {
async applyPreset(presetName) {
if (!this.currentTab) {
alert('Please select a tab first');
alert('Please select a zone first');
return;
}
@@ -1621,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;
}

View File

@@ -1,7 +1,101 @@
// Device management: list, create, edit, delete (name and 6-byte address)
// 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 = '';
@@ -42,12 +136,6 @@ function makeHexAddressBoxes(container) {
}
}
function getAddressFromBoxes(container) {
if (!container) return '';
const boxes = container.querySelectorAll('.hex-addr-box');
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
}
function setAddressToBoxes(container, addrStr) {
if (!container) return;
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
@@ -57,9 +145,33 @@ function setAddressToBoxes(container, addrStr) {
});
}
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' } });
@@ -80,42 +192,95 @@ function renderDevicesList(devices) {
if (ids.length === 0) {
const p = document.createElement('p');
p.className = 'muted-text';
p.textContent = 'No devices. Create one above.';
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';
const addr = (dev && dev.address) ? dev.address : '—';
meta.textContent = `Address: ${addr}`;
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/${devId}`, { method: 'DELETE' });
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'DELETE' });
if (res.ok) await loadDevicesModal();
else {
const data = await res.json().catch(() => ({}));
@@ -127,53 +292,53 @@ function renderDevicesList(devices) {
}
});
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) || '';
setAddressToBoxes(addressBoxes, (dev && dev.address) || '');
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 createDevice(name, address) {
async function updateDevice(devId, name, type, transport, address) {
try {
const res = await fetch('/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address: address || null }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
await loadDevicesModal();
return true;
}
alert(data.error || 'Failed to create device');
return false;
} catch (e) {
console.error('createDevice:', e);
alert('Failed to create device');
return false;
}
}
async function updateDevice(devId, name, address) {
try {
const res = await fetch(`/devices/${devId}`, {
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address: address || null }),
body: JSON.stringify({
name,
type: type || 'led',
transport: transport || 'espnow',
address,
}),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
@@ -190,14 +355,41 @@ async function updateDevice(devId, name, address) {
}
document.addEventListener('DOMContentLoaded', () => {
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
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 newName = document.getElementById('new-device-name');
const createBtn = document.getElementById('create-device-btn');
const editForm = document.getElementById('edit-device-form');
const editCloseBtn = document.getElementById('edit-device-close-btn');
const editDeviceModal = document.getElementById('edit-device-modal');
@@ -205,41 +397,44 @@ document.addEventListener('DOMContentLoaded', () => {
if (devicesBtn && devicesModal) {
devicesBtn.addEventListener('click', () => {
devicesModal.classList.add('active');
if (typeof window.getEspnowSocket === 'function') {
window.getEspnowSocket();
}
loadDevicesModal();
startDevicesModalLiveRefresh();
});
}
if (devicesCloseBtn) {
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
devicesCloseBtn.addEventListener('click', () => {
if (devicesModal) devicesModal.classList.remove('active');
});
}
const newAddressBoxes = document.getElementById('new-device-address-boxes');
const doCreate = async () => {
const name = (newName && newName.value.trim()) || '';
if (!name) {
alert('Device name is required.');
return;
const devicesModalEl = document.getElementById('devices-modal');
if (devicesModalEl) {
new MutationObserver(() => {
if (!devicesModalEl.classList.contains('active')) {
stopDevicesModalLiveRefresh();
}
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
const ok = await createDevice(name, address);
if (ok && newName) {
newName.value = '';
setAddressToBoxes(newAddressBoxes, '');
}).observe(devicesModalEl, { attributes: true, attributeFilter: ['class'] });
}
};
if (createBtn) createBtn.addEventListener('click', doCreate);
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
if (editForm) {
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name');
const addressBoxes = document.getElementById('edit-device-address-boxes');
const typeSel = document.getElementById('edit-device-type');
const transportSel = document.getElementById('edit-device-transport');
const devId = idInput && idInput.value;
if (!devId) return;
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
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');

View File

@@ -60,6 +60,12 @@ document.addEventListener('DOMContentLoaded', () => {
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);
}
@@ -116,15 +122,29 @@ document.addEventListener('DOMContentLoaded', () => {
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 }),
body: JSON.stringify({
device_name: deviceName,
wifi_channel: wifiChannel,
}),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage('Device name saved. It will be used on next restart.', 'success');
showSettingsMessage(
'Device settings saved. They will apply on next restart where relevant.',
'success',
);
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
}

255
src/static/led_tool.js Normal file
View File

@@ -0,0 +1,255 @@
document.addEventListener('DOMContentLoaded', () => {
const openBtn = document.getElementById('led-tool-btn');
const modal = document.getElementById('led-tool-modal');
const closeBtn = document.getElementById('led-tool-close-btn');
const refreshPortsBtn = document.getElementById('led-tool-refresh-ports-btn');
const form = document.getElementById('led-tool-form');
const readBtn = document.getElementById('led-tool-read-btn');
const resetBtn = document.getElementById('led-tool-reset-btn');
const portSelect = document.getElementById('led-tool-port');
const outputEl = document.getElementById('led-tool-output');
const messageEl = document.getElementById('led-tool-message');
if (!openBtn || !modal || !form || !portSelect || !outputEl || !messageEl) {
return;
}
const showMessage = (text, type = 'success') => {
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
};
const setOutput = (text) => {
outputEl.value = text || '';
};
const parseApiResponse = async (response) => {
const bodyText = await response.text();
let data = null;
try {
data = bodyText ? JSON.parse(bodyText) : {};
} catch (error) {
data = { error: bodyText || `HTTP ${response.status}` };
}
return data;
};
const setFieldValue = (id, value) => {
const el = document.getElementById(id);
if (!el) return;
if (value === undefined || value === null) return;
el.value = String(value);
};
const populateFormFromSettings = (settings) => {
if (!settings || typeof settings !== 'object') return false;
setFieldValue('led-tool-name', settings.name);
setFieldValue('led-tool-num-leds', settings.num_leds);
setFieldValue('led-tool-led-pin', settings.led_pin);
setFieldValue('led-tool-brightness', settings.brightness);
setFieldValue('led-tool-transport', settings.transport_type);
setFieldValue('led-tool-ssid', settings.ssid);
setFieldValue('led-tool-password', settings.password);
setFieldValue('led-tool-wifi-channel', settings.wifi_channel);
setFieldValue('led-tool-default', settings.default);
return true;
};
const loadPorts = async () => {
const defaultPort = '/dev/ttyACM0';
try {
const response = await fetch('/led-tool/ports');
const data = await response.json();
const previous = portSelect.value;
portSelect.innerHTML = '<option value="">Select a serial port</option>';
for (const port of data.ports || []) {
const option = document.createElement('option');
option.value = port.device;
option.textContent = `${port.device} - ${port.description || 'Unknown'}`;
portSelect.appendChild(option);
}
if (previous) {
portSelect.value = previous;
} else if ((data.ports || []).some((p) => p.device === defaultPort)) {
portSelect.value = defaultPort;
} else {
const fallback = document.createElement('option');
fallback.value = defaultPort;
fallback.textContent = `${defaultPort} - default`;
portSelect.appendChild(fallback);
portSelect.value = defaultPort;
}
if (!data.led_cli_exists) {
showMessage('led-tool/cli.py was not found on the host.', 'error');
} else if ((data.ports || []).length === 0) {
showMessage('No serial ports found.', 'error');
} else {
showMessage(`Found ${(data.ports || []).length} serial port(s).`, 'success');
}
} catch (error) {
showMessage(`Failed to read serial ports: ${error.message}`, 'error');
}
};
openBtn.addEventListener('click', () => {
modal.classList.add('active');
loadPorts();
});
if (closeBtn) {
closeBtn.addEventListener('click', () => {
modal.classList.remove('active');
});
}
if (refreshPortsBtn) {
refreshPortsBtn.addEventListener('click', () => {
loadPorts();
});
}
if (readBtn) {
readBtn.addEventListener('click', async () => {
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
setOutput('Reading settings from device...');
showMessage('Reading settings over USB...', 'success');
try {
const response = await fetch(`/led-tool/settings?port=${encodeURIComponent(port)}`);
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Read failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
const populated = populateFormFromSettings(data.settings);
if (populated) {
showMessage('Settings read and fields populated.', 'success');
} else {
showMessage('Settings read successfully.', 'success');
}
} else {
showMessage('Read completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
}
if (resetBtn) {
resetBtn.addEventListener('click', async () => {
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
setOutput('Resetting device and following output...');
showMessage('Resetting device over USB...', 'success');
try {
const response = await fetch('/led-tool/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port }),
});
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Reset failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
showMessage('Device reset complete.', 'success');
} else {
showMessage('Reset completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
const payload = {
port,
name: document.getElementById('led-tool-name')?.value?.trim() || '',
num_leds: document.getElementById('led-tool-num-leds')?.value?.trim() || '',
led_pin: document.getElementById('led-tool-led-pin')?.value?.trim() || '',
brightness: document.getElementById('led-tool-brightness')?.value?.trim() || '',
transport: document.getElementById('led-tool-transport')?.value?.trim() || '',
ssid: document.getElementById('led-tool-ssid')?.value?.trim() || '',
password: document.getElementById('led-tool-password')?.value?.trim() || '',
wifi_channel: document.getElementById('led-tool-wifi-channel')?.value?.trim() || '',
default: document.getElementById('led-tool-default')?.value?.trim() || '',
};
setOutput('Running led-tool command...');
showMessage('Running command over USB...', 'success');
try {
const response = await fetch('/led-tool/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Command failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
showMessage('Settings applied via USB.', 'success');
} else {
showMessage('Command completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
});

View File

@@ -19,34 +19,34 @@ const numTabs = 3;
// Select the container for tabs and content
const tabsContainer = document.querySelector(".tabs");
const tabContentContainer = document.querySelector(".tab-content");
const tabContentContainer = document.querySelector(".zone-content");
// Create tabs dynamically
for (let i = 1; i <= numTabs; i++) {
// Create the tab button
// Create the zone button
const tabButton = document.createElement("button");
tabButton.classList.add("tab");
tabButton.id = `tab${i}`;
tabButton.textContent = `Tab ${i}`;
tabButton.classList.add("zone");
tabButton.id = `zone${i}`;
tabButton.textContent = `Zone ${i}`;
// Add the tab button to the container
// Add the zone button to the container
tabsContainer.appendChild(tabButton);
// Create the corresponding tab content (RGB slider)
// Create the corresponding zone content (RGB slider)
const tabContent = document.createElement("div");
tabContent.classList.add("tab-pane");
tabContent.classList.add("zone-pane");
tabContent.id = `content${i}`;
const slider = document.createElement("rgb-slider");
slider.id = i;
tabContent.appendChild(slider);
// Add the tab content to the container
// Add the zone content to the container
tabContentContainer.appendChild(tabContent);
// Listen for color change on each RGB slider
slider.addEventListener("color-change", (e) => {
const { r, g, b } = e.detail;
console.log(`Color changed in tab ${i}:`, e.detail);
console.log(`Color changed in zone ${i}:`, e.detail);
// Send RGB data to WebSocket server
if (ws.readyState === WebSocket.OPEN) {
const colorData = { r, g, b };
@@ -56,26 +56,26 @@ for (let i = 1; i <= numTabs; i++) {
}
// Function to switch tabs
function switchTab(tabId) {
const tabs = document.querySelectorAll(".tab");
const tabContents = document.querySelectorAll(".tab-pane");
function switchTab(zoneId) {
const tabs = document.querySelectorAll(".zone");
const tabContents = document.querySelectorAll(".zone-pane");
tabs.forEach((tab) => tab.classList.remove("active"));
zones.forEach((zone) => zone.classList.remove("active"));
tabContents.forEach((content) => content.classList.remove("active"));
// Activate the clicked tab and corresponding content
document.getElementById(tabId).classList.add("active");
// Activate the clicked zone and corresponding content
document.getElementById(zoneId).classList.add("active");
document
.getElementById("content" + tabId.replace("tab", ""))
.getElementById("content" + zoneId.replace("zone", ""))
.classList.add("active");
}
// Add event listeners to tabs
tabsContainer.addEventListener("click", (e) => {
if (e.target.classList.contains("tab")) {
if (e.target.classList.contains("zone")) {
switchTab(e.target.id);
}
});
// Initially set the first tab as active
// Initially set the first zone as active
switchTab("tab1");

View File

@@ -3,11 +3,315 @@ document.addEventListener('DOMContentLoaded', () => {
const patternsModal = document.getElementById('patterns-modal');
const patternsCloseButton = document.getElementById('patterns-close-btn');
const patternsList = document.getElementById('patterns-list');
const patternAddButton = document.getElementById('pattern-add-btn');
const patternEditorModal = document.getElementById('pattern-editor-modal');
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
const patternCreateBtn = document.getElementById('pattern-create-btn');
const patternCreateName = document.getElementById('pattern-create-name');
const patternCreateMinDelay = document.getElementById('pattern-create-min-delay');
const patternCreateMaxDelay = document.getElementById('pattern-create-max-delay');
const patternCreateMaxColors = document.getElementById('pattern-create-max-colors');
const patternCreateFile = document.getElementById('pattern-create-file');
const patternCreateCode = document.getElementById('pattern-create-code');
const patternCreateOverwrite = document.getElementById('pattern-create-overwrite');
const patternCreateN = [1, 2, 3, 4, 5, 6, 7, 8].map((i) =>
document.getElementById(`pattern-create-n${i}`),
);
const patternCreateNSection = document.getElementById('pattern-create-n-section');
const patternCreateNEmpty = document.getElementById('pattern-create-n-empty');
if (!patternsButton || !patternsModal || !patternsList) {
return;
}
const nReadableStringFromMeta = (meta, key) => {
if (!meta || typeof meta !== 'object') {
return '';
}
const pm = meta.parameter_mappings;
if (pm && typeof pm === 'object' && typeof pm[key] === 'string') {
const s = pm[key].trim();
if (s) {
return s;
}
}
if (typeof meta[key] === 'string') {
return meta[key].trim();
}
return '';
};
const setPatternEditorNFields = (mode, data) => {
const meta = data && typeof data === 'object' ? data : {};
let visible = 0;
const grid = patternCreateNSection && patternCreateNSection.querySelector('.n-params-grid');
const h3 = patternCreateNSection && patternCreateNSection.querySelector('h3');
for (let i = 1; i <= 8; i += 1) {
const key = `n${i}`;
const labelEl = document.querySelector(`label[for="pattern-create-${key}"]`);
const inputEl = document.getElementById(`pattern-create-${key}`);
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
if (mode === 'create') {
if (labelEl) {
labelEl.textContent = `${key}:`;
labelEl.style.display = '';
}
if (inputEl) {
inputEl.value = '';
inputEl.placeholder = 'Readable name (optional)';
inputEl.removeAttribute('aria-label');
}
if (groupEl) {
groupEl.style.display = '';
}
continue;
}
const readable = nReadableStringFromMeta(meta, key);
const show = Boolean(readable);
if (labelEl) {
labelEl.textContent = '';
labelEl.style.display = 'none';
}
if (inputEl) {
inputEl.value = show ? readable : '';
inputEl.placeholder = '';
if (show) {
inputEl.setAttribute('aria-label', readable);
} else {
inputEl.removeAttribute('aria-label');
inputEl.value = '';
}
}
if (groupEl) {
groupEl.style.display = show ? '' : 'none';
}
if (show) {
visible += 1;
}
}
if (mode === 'create') {
if (patternCreateNEmpty) {
patternCreateNEmpty.style.display = 'none';
}
if (grid) {
grid.style.display = '';
}
if (h3) {
h3.style.display = '';
}
if (patternCreateNSection) {
patternCreateNSection.style.display = '';
}
return;
}
if (patternCreateNEmpty) {
patternCreateNEmpty.style.display = visible === 0 ? '' : 'none';
}
if (grid) {
grid.style.display = visible === 0 ? 'none' : '';
}
if (h3) {
h3.style.display = visible === 0 ? 'none' : '';
}
};
const readFileAsText = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('read failed'));
reader.readAsText(file);
});
const collectCreatePayload = async () => {
const name = patternCreateName ? patternCreateName.value.trim() : '';
if (!name) {
throw new Error('Pattern name is required.');
}
let code = '';
const fileInput = patternCreateFile && patternCreateFile.files && patternCreateFile.files[0];
if (fileInput) {
code = await readFileAsText(fileInput);
} else if (patternCreateCode && patternCreateCode.value.trim()) {
code = patternCreateCode.value;
}
if (!code.trim()) {
throw new Error('Choose a .py file or paste source code.');
}
const payload = {
name,
code,
min_delay: parseInt(patternCreateMinDelay && patternCreateMinDelay.value, 10) || 0,
max_delay: parseInt(patternCreateMaxDelay && patternCreateMaxDelay.value, 10) || 0,
max_colors: parseInt(patternCreateMaxColors && patternCreateMaxColors.value, 10) || 0,
overwrite: !!(patternCreateOverwrite && patternCreateOverwrite.checked),
};
patternCreateN.forEach((el, idx) => {
const key = `n${idx + 1}`;
if (el && el.value.trim()) {
payload[key] = el.value.trim();
}
});
return payload;
};
const resetCreateForm = () => {
if (patternCreateName) patternCreateName.value = '';
if (patternCreateFile) patternCreateFile.value = '';
if (patternCreateCode) patternCreateCode.value = '';
if (patternCreateMinDelay) patternCreateMinDelay.value = '10';
if (patternCreateMaxDelay) patternCreateMaxDelay.value = '10000';
if (patternCreateMaxColors) patternCreateMaxColors.value = '10';
patternCreateN.forEach((el) => {
if (el) el.value = '';
});
if (patternCreateOverwrite) patternCreateOverwrite.checked = true;
setPatternEditorNFields('create', {});
};
if (patternCreateBtn) {
patternCreateBtn.addEventListener('click', async () => {
try {
const payload = await collectCreatePayload();
const response = await fetch('/patterns/driver', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error((data && data.error) || 'Create failed');
}
alert(data.message || 'Pattern created.');
resetCreateForm();
if (patternEditorModal) {
patternEditorModal.classList.remove('active');
}
await loadPatterns();
} catch (e) {
console.error('Create pattern failed:', e);
alert(e.message || 'Failed to create pattern.');
}
});
}
/** on/off are implemented in driver firmware (presets.py), not as OTA ``.py`` files. */
const FIRMWARE_BUILTIN_PATTERNS = new Set(['on', 'off']);
const isFirmwareBuiltinPattern = (patternName) => {
const id = String(patternName || '')
.trim()
.replace(/\.py$/i, '')
.toLowerCase();
return FIRMWARE_BUILTIN_PATTERNS.has(id);
};
const sendPatternToDevices = async (patternName) => {
const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error((data && data.error) || 'Failed to send pattern');
}
const sentCount = data && typeof data.sent_count === 'number' ? data.sent_count : null;
if (sentCount === null) {
alert(`Sent "${patternName}" to devices.`);
} else {
alert(`Sent "${patternName}" to ${sentCount} device(s).`);
}
};
const loadPatternMetadata = async (patternName, fallbackData) => {
const raw = String(patternName || '').trim();
const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw;
try {
const response = await fetch('/patterns/definitions', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load pattern definitions');
}
const definitions = await response.json();
if (definitions && typeof definitions === 'object') {
if (definitions[raw]) {
return definitions[raw];
}
if (norm && definitions[norm]) {
return definitions[norm];
}
if (norm) {
const lower = norm.toLowerCase();
const matched = Object.keys(definitions).find(
(k) => String(k).toLowerCase() === lower,
);
if (matched) {
return definitions[matched];
}
}
}
} catch (error) {
console.error('Load pattern definitions failed:', error);
}
return fallbackData || {};
};
const loadPatternIntoEditor = async (patternName, fallbackData) => {
const data = await loadPatternMetadata(patternName, fallbackData);
if (patternCreateName) {
patternCreateName.value = patternName;
}
if (patternCreateMinDelay) {
patternCreateMinDelay.value =
data && data.min_delay !== undefined ? String(data.min_delay) : '10';
}
if (patternCreateMaxDelay) {
patternCreateMaxDelay.value =
data && data.max_delay !== undefined ? String(data.max_delay) : '10000';
}
if (patternCreateMaxColors) {
patternCreateMaxColors.value =
data && data.max_colors !== undefined ? String(data.max_colors) : '10';
}
setPatternEditorNFields('edit', data);
if (patternCreateOverwrite) {
patternCreateOverwrite.checked = true;
}
if (patternCreateFile) {
patternCreateFile.value = '';
}
try {
const raw = String(patternName || '').trim();
const fileSegment = /\.py$/i.test(raw) ? raw : `${raw}.py`;
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(fileSegment)}`, {
headers: { Accept: 'text/plain' },
});
if (!response.ok) {
throw new Error('Failed to load pattern file');
}
const source = await response.text();
if (patternCreateCode) {
patternCreateCode.value = source || '';
patternCreateCode.focus();
}
} catch (error) {
console.error('Load pattern source failed:', error);
alert('Could not load pattern source into editor.');
}
};
const renderPatterns = (patterns) => {
patternsList.innerHTML = '';
const entries = Object.entries(patterns || {});
@@ -25,20 +329,46 @@ document.addEventListener('DOMContentLoaded', () => {
const label = document.createElement('span');
label.textContent = patternName;
const details = document.createElement('span');
const minDelay = data && data.min_delay !== undefined ? data.min_delay : '-';
const maxDelay = data && data.max_delay !== undefined ? data.max_delay : '-';
details.textContent = `${minDelay}${maxDelay} ms`;
details.style.color = '#aaa';
details.style.fontSize = '0.85em';
row.appendChild(label);
row.appendChild(details);
if (isFirmwareBuiltinPattern(patternName)) {
const note = document.createElement('span');
note.className = 'muted-text';
note.style.fontSize = '0.85em';
note.textContent = 'Built-in (no OTA module)';
row.appendChild(note);
} else {
const sendBtn = document.createElement('button');
sendBtn.className = 'btn btn-primary btn-small';
sendBtn.textContent = 'Send';
sendBtn.addEventListener('click', async () => {
try {
await sendPatternToDevices(patternName);
} catch (error) {
console.error('Send pattern failed:', error);
alert(error.message || 'Failed to send pattern.');
}
});
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', async () => {
if (patternEditorModal) {
patternEditorModal.classList.add('active');
}
await loadPatternIntoEditor(patternName, data || {});
});
row.appendChild(editBtn);
row.appendChild(sendBtn);
}
patternsList.appendChild(row);
});
};
const loadPatterns = async () => {
async function loadPatterns() {
patternsList.innerHTML = '';
const loading = document.createElement('p');
loading.className = 'muted-text';
@@ -47,6 +377,7 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const response = await fetch('/patterns', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
@@ -62,7 +393,7 @@ document.addEventListener('DOMContentLoaded', () => {
errorMessage.textContent = 'Failed to load patterns.';
patternsList.appendChild(errorMessage);
}
};
}
const openModal = () => {
patternsModal.classList.add('active');
@@ -74,6 +405,21 @@ document.addEventListener('DOMContentLoaded', () => {
};
patternsButton.addEventListener('click', openModal);
if (patternAddButton) {
patternAddButton.addEventListener('click', () => {
resetCreateForm();
if (patternEditorModal) {
patternEditorModal.classList.add('active');
}
});
}
if (patternEditorCloseButton) {
patternEditorCloseButton.addEventListener('click', () => {
if (patternEditorModal) {
patternEditorModal.classList.remove('active');
}
});
}
if (patternsCloseButton) {
patternsCloseButton.addEventListener('click', closeModal);
}

File diff suppressed because it is too large Load Diff

View File

@@ -35,8 +35,8 @@ document.addEventListener("DOMContentLoaded", () => {
};
const refreshTabsForActiveProfile = async () => {
// Clear stale current tab so tab controller falls back to first tab of applied profile.
document.cookie = "current_tab=; path=/; max-age=0";
// Clear stale current zone so zone controller falls back to first zone of applied profile.
document.cookie = "current_zone=; path=/; max-age=0";
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
await window.tabsManager.loadTabs();
@@ -231,7 +231,7 @@ document.addEventListener("DOMContentLoaded", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
seed_dj_zone: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
}),
});
if (!response.ok) {

View File

@@ -12,6 +12,78 @@ body {
overflow: hidden;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.hex-address-row {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
align-items: center;
}
input.hex-addr-box {
width: 1.35rem;
padding: 0.25rem 0.1rem;
text-align: center;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.device-form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.75rem;
align-items: end;
}
.device-field-label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-bottom: 0.25rem;
}
.device-row-mac {
font-size: 0.82em;
color: #b0b0b0;
letter-spacing: 0.02em;
}
.device-form-actions {
display: flex;
align-items: flex-end;
}
#devices-modal select {
width: 100%;
max-width: 16rem;
padding: 0.35rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
#edit-device-modal select {
width: 100%;
max-width: 20rem;
padding: 0.35rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
.app-container {
display: flex;
flex-direction: column;
@@ -131,7 +203,7 @@ body.preset-ui-run .edit-mode-only {
overflow: hidden;
}
.tabs-container {
.zones-container {
background-color: transparent;
padding: 0.5rem 0;
flex: 1;
@@ -141,7 +213,7 @@ body.preset-ui-run .edit-mode-only {
align-items: center;
}
.tabs-list {
.zones-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
@@ -150,7 +222,7 @@ body.preset-ui-run .edit-mode-only {
min-width: 0;
}
.tab-button {
.zone-button {
padding: 0.5rem 1rem;
background-color: #3a3a3a;
color: white;
@@ -162,16 +234,16 @@ body.preset-ui-run .edit-mode-only {
transition: background-color 0.2s;
}
.tab-button:hover {
.zone-button:hover {
background-color: #4a4a4a;
}
.tab-button.active {
.zone-button.active {
background-color: #6a5acd;
color: white;
}
.tab-content {
.zone-content {
flex: 1;
display: block;
overflow-y: auto;
@@ -183,7 +255,7 @@ body.preset-ui-run .edit-mode-only {
align-items: center;
}
.tab-brightness-group {
.zone-brightness-group {
display: flex;
flex-direction: column;
align-items: stretch;
@@ -191,7 +263,7 @@ body.preset-ui-run .edit-mode-only {
margin-left: auto;
}
.tab-brightness-group label {
.zone-brightness-group label {
white-space: nowrap;
font-size: 0.85rem;
}
@@ -386,22 +458,28 @@ body.preset-ui-run .edit-mode-only {
.n-param-group {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
justify-content: space-between;
}
.n-param-group label {
min-width: 40px;
flex: 1;
min-width: 0;
font-weight: 500;
}
.n-input {
flex: 1;
flex: 0 0 var(--n-input-width, 5ch);
width: var(--n-input-width, 5ch);
max-width: 100%;
box-sizing: border-box;
padding: 0.5rem;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
text-align: right;
}
.n-input:focus {
@@ -437,8 +515,8 @@ body.preset-ui-run .edit-mode-only {
padding: 0;
}
/* Tab preset selecting area: 3 columns, vertical scroll only */
#presets-list-tab {
/* Zone preset selecting area: 3 columns, vertical scroll only */
#presets-list-zone {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -535,6 +613,29 @@ body.preset-ui-run .edit-mode-only {
color: #f44336;
}
/* Devices modal: live TCP presence (Wi-Fi only) */
.device-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
align-self: center;
}
.device-status-dot--online {
background: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.45);
}
.device-status-dot--offline {
background: #616161;
}
.device-status-dot--unknown {
background: #424242;
border: 1px solid #757575;
}
.btn-group {
display: flex;
gap: 0.5rem;
@@ -620,15 +721,21 @@ body.preset-ui-run .edit-mode-only {
height: 5rem;
}
/* Edit only beside the preset tile in edit mode. */
.preset-tile-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
display: flex;
flex-direction: column;
justify-content: stretch;
gap: 0.2rem;
align-content: stretch;
flex-shrink: 0;
padding: 0.15rem 0 0.15rem 0.25rem;
width: 6.5rem;
width: auto;
min-width: 0;
}
.preset-editor-modal-actions {
flex-wrap: wrap;
gap: 0.35rem;
}
.preset-tile-actions .btn {
@@ -649,8 +756,8 @@ body.preset-ui-run .edit-mode-only {
background-color: #5a4f9f;
}
/* Preset select buttons inside the tab grid */
#presets-list-tab .pattern-button {
/* Preset select buttons inside the zone grid */
#presets-list-zone .pattern-button {
display: flex;
}
.pattern-button .pattern-button-label {
@@ -865,12 +972,12 @@ body.preset-ui-run .edit-mode-only {
padding: 0.4rem 0.7rem;
}
.tabs-container {
.zones-container {
padding: 0.5rem 0;
border-bottom: none;
}
.tab-content {
.zone-content {
padding: 0.5rem;
}
@@ -962,6 +1069,65 @@ body.preset-ui-run .edit-mode-only {
background-color: #3a3a3a;
border-radius: 4px;
}
.zone-modal-create-row {
flex-wrap: wrap;
align-items: center;
}
.zone-modal-create-row input[type="text"] {
flex: 1;
min-width: 8rem;
}
.zone-devices-label {
display: block;
margin-top: 0.75rem;
margin-bottom: 0.35rem;
font-weight: 600;
}
.zone-devices-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
max-height: 14rem;
overflow-y: auto;
}
.zone-device-row-label {
flex: 1;
min-width: 0;
}
.zone-device-add-select {
flex: 1;
min-width: 10rem;
padding: 0.5rem;
background-color: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
.zone-devices-add {
margin-top: 0;
flex-wrap: wrap;
}
.zone-presets-section-label {
display: block;
margin-top: 1rem;
margin-bottom: 0.35rem;
font-weight: 600;
}
.edit-zone-presets-scroll {
max-height: 200px;
overflow-y: auto;
margin-bottom: 1rem;
}
/* Hide any text content in palette rows - only show color swatches */
#palette-container .profiles-row {
font-size: 0; /* Hide any text nodes */
@@ -1035,7 +1201,7 @@ body.preset-ui-run .edit-mode-only {
}
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
@media (max-width: 800px) {
#presets-list-tab {
#presets-list-zone {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@@ -1074,8 +1240,8 @@ body.preset-ui-run .edit-mode-only {
font-size: 0.9rem;
}
/* Tab content placeholder (no tab selected) */
.tab-content-placeholder {
/* Zone content placeholder (no zone selected) */
.zone-content-placeholder {
padding: 2rem;
text-align: center;
color: #aaa;
@@ -1087,10 +1253,66 @@ body.preset-ui-run .edit-mode-only {
}
/* Preset editor: brightness/delay field wrappers */
.preset-editor-field {
#preset-editor-modal .preset-editor-field {
flex: 1;
min-width: 10rem;
display: flex;
flex-direction: column;
align-items: stretch;
}
#preset-editor-modal .preset-editor-field label {
align-self: stretch;
}
#preset-editor-modal .preset-editor-field input[type="number"] {
width: 100%;
min-width: 5.5rem;
max-width: 7rem;
box-sizing: border-box;
text-align: right;
}
/* Preset editor n-parameter inputs need extra room for values + spinner controls. */
#preset-editor-modal .n-input {
width: 6.5ch;
min-width: 5.5rem;
}
/* Pattern editor: numeric metadata row */
#pattern-editor-modal input[type="number"] {
width: var(--n-input-width, 5ch);
max-width: 100%;
box-sizing: border-box;
text-align: right;
}
/* Pattern editor: human-readable n labels (text), full width */
#pattern-editor-modal .n-params-grid {
grid-template-columns: 1fr;
}
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) {
justify-content: stretch;
}
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) label {
flex: 0 0 auto;
min-width: 2.25rem;
font-weight: 500;
}
#pattern-editor-modal .pattern-n-readable-input {
flex: 1 1 auto;
width: 100%;
min-width: 0;
text-align: left;
}
@supports not selector(:has(*)) {
#pattern-editor-modal #pattern-create-n-section .n-param-group {
justify-content: stretch;
}
}
/* Settings modal */

View File

@@ -1,11 +1,11 @@
/* General tab styles */
/* General zone styles */
.tabs {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.tab {
.zone {
padding: 10px 20px;
margin: 0 10px;
cursor: pointer;
@@ -15,23 +15,23 @@
transition: background-color 0.3s ease;
}
.tab:hover {
.zone:hover {
background-color: #ddd;
}
.tab.active {
.zone.active {
background-color: #ccc;
}
.tab-content {
.zone-content {
display: flex;
justify-content: center;
}
.tab-pane {
.zone-pane {
display: none;
}
.tab-pane.active {
.zone-pane.active {
display: block;
}

View File

@@ -1,816 +0,0 @@
// Tab management JavaScript
let currentTabId = null;
const isEditModeActive = () => {
const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
};
// Get current tab from cookie
function getCurrentTabFromCookie() {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'current_tab') {
return value;
}
}
return null;
}
// Load tabs list
async function loadTabs() {
try {
const response = await fetch('/tabs');
const data = await response.json();
// Get current tab from cookie first, then from server response
const cookieTabId = getCurrentTabFromCookie();
const serverCurrent = data.current_tab_id;
const tabs = data.tabs || {};
const tabIds = Object.keys(tabs);
let candidateId = cookieTabId || serverCurrent || null;
// If the candidate doesn't exist anymore (e.g. after DB reset), fall back to first tab.
if (candidateId && !tabIds.includes(String(candidateId))) {
candidateId = tabIds.length > 0 ? tabIds[0] : null;
// Clear stale cookie
document.cookie = 'current_tab=; path=/; max-age=0';
}
currentTabId = candidateId;
renderTabsList(data.tabs, data.tab_order, currentTabId);
// Load current tab content if available
if (currentTabId) {
await loadTabContent(currentTabId);
} else if (data.tab_order && data.tab_order.length > 0) {
// Set first tab as current if none is set
const firstTabId = data.tab_order[0];
await setCurrentTab(firstTabId);
await loadTabContent(firstTabId);
}
} catch (error) {
console.error('Failed to load tabs:', error);
const container = document.getElementById('tabs-list');
if (container) {
container.innerHTML = '<div class="error">Failed to load tabs</div>';
}
}
}
// Render tabs list in the main UI
function renderTabsList(tabs, tabOrder, currentTabId) {
const container = document.getElementById('tabs-list');
if (!container) return;
if (!tabOrder || tabOrder.length === 0) {
container.innerHTML = '<div class="muted-text">No tabs available</div>';
return;
}
const editMode = isEditModeActive();
let html = '<div class="tabs-list">';
for (const tabId of tabOrder) {
const tab = tabs[tabId];
if (tab) {
const activeClass = tabId === currentTabId ? 'active' : '';
const tabName = tab.name || `Tab ${tabId}`;
html += `
<button class="tab-button ${activeClass}"
data-tab-id="${tabId}"
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
onclick="selectTab('${tabId}')">
${tabName}
</button>
`;
}
}
html += '</div>';
container.innerHTML = html;
}
// Render tabs list in modal (like profiles)
function renderTabsListModal(tabs, tabOrder, currentTabId) {
const container = document.getElementById('tabs-list-modal');
if (!container) return;
container.innerHTML = "";
let entries = [];
if (Array.isArray(tabOrder)) {
entries = tabOrder.map((tabId) => [tabId, tabs[tabId] || {}]);
} else if (tabs && typeof tabs === "object") {
entries = Object.entries(tabs).filter(([key]) => {
return key !== 'current_tab_id' && key !== 'tabs' && key !== 'tab_order';
});
}
if (entries.length === 0) {
const empty = document.createElement("p");
empty.className = "muted-text";
empty.textContent = "No tabs found.";
container.appendChild(empty);
return;
}
const editMode = isEditModeActive();
entries.forEach(([tabId, tab]) => {
const row = document.createElement("div");
row.className = "profiles-row";
const label = document.createElement("span");
label.textContent = (tab && tab.name) || tabId;
if (String(tabId) === String(currentTabId)) {
label.textContent = `${label.textContent}`;
label.style.fontWeight = "bold";
label.style.color = "#FFD700";
}
const applyButton = document.createElement("button");
applyButton.className = "btn btn-secondary btn-small";
applyButton.textContent = "Select";
applyButton.addEventListener("click", async () => {
await selectTab(tabId);
document.getElementById('tabs-modal').classList.remove('active');
});
const editButton = document.createElement("button");
editButton.className = "btn btn-secondary btn-small";
editButton.textContent = "Edit";
editButton.addEventListener("click", () => {
openEditTabModal(tabId, tab);
});
const sendPresetsButton = document.createElement("button");
sendPresetsButton.className = "btn btn-secondary btn-small";
sendPresetsButton.textContent = "Send Presets";
sendPresetsButton.addEventListener("click", async () => {
await sendTabPresets(tabId);
});
const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone";
cloneButton.addEventListener("click", async () => {
const baseName = (tab && tab.name) || tabId;
const suggested = `${baseName} Copy`;
const name = prompt("New tab name:", suggested);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Tab name cannot be empty.");
return;
}
try {
const response = await fetch(`/tabs/${tabId}/clone`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ name: trimmed }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to clone tab" }));
throw new Error(errorData.error || "Failed to clone tab");
}
const data = await response.json().catch(() => null);
let newTabId = null;
if (data && typeof data === "object") {
if (data.id) {
newTabId = String(data.id);
} else {
const ids = Object.keys(data);
if (ids.length > 0) {
newTabId = String(ids[0]);
}
}
}
await loadTabsModal();
if (newTabId) {
await selectTab(newTabId);
} else {
await loadTabs();
}
} catch (error) {
console.error("Clone tab failed:", error);
alert("Failed to clone tab: " + error.message);
}
});
const deleteButton = document.createElement("button");
deleteButton.className = "btn btn-danger btn-small";
deleteButton.textContent = "Delete";
deleteButton.addEventListener("click", async () => {
const confirmed = confirm(`Delete tab "${label.textContent}"?`);
if (!confirmed) {
return;
}
try {
const response = await fetch(`/tabs/${tabId}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to delete tab" }));
throw new Error(errorData.error || "Failed to delete tab");
}
// Clear cookie if deleted tab was current
if (tabId === currentTabId) {
document.cookie = 'current_tab=; path=/; max-age=0';
currentTabId = null;
}
await loadTabsModal();
await loadTabs(); // Reload main tabs list
} catch (error) {
console.error("Delete tab failed:", error);
alert("Failed to delete tab: " + error.message);
}
});
row.appendChild(label);
row.appendChild(applyButton);
row.appendChild(sendPresetsButton);
if (editMode) {
row.appendChild(editButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
}
container.appendChild(row);
});
}
// Load tabs in modal
async function loadTabsModal() {
const container = document.getElementById('tabs-list-modal');
if (!container) return;
container.innerHTML = "";
const loading = document.createElement("p");
loading.className = "muted-text";
loading.textContent = "Loading tabs...";
container.appendChild(loading);
try {
const response = await fetch("/tabs", {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error("Failed to load tabs");
}
const data = await response.json();
const tabs = data.tabs || data;
const currentTabId = getCurrentTabFromCookie() || data.current_tab_id || null;
renderTabsListModal(tabs, data.tab_order || [], currentTabId);
} catch (error) {
console.error("Load tabs failed:", error);
container.innerHTML = "";
const errorMessage = document.createElement("p");
errorMessage.className = "muted-text";
errorMessage.textContent = "Failed to load tabs.";
container.appendChild(errorMessage);
}
}
// Select a tab
async function selectTab(tabId) {
// Update active state
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
const btn = document.querySelector(`[data-tab-id="${tabId}"]`);
if (btn) {
btn.classList.add('active');
}
// Set as current tab
await setCurrentTab(tabId);
// Load tab content
loadTabContent(tabId);
}
// Set current tab in cookie
async function setCurrentTab(tabId) {
try {
const response = await fetch(`/tabs/${tabId}/set-current`, {
method: 'POST'
});
const data = await response.json();
if (response.ok) {
currentTabId = tabId;
// Also set cookie on client side
document.cookie = `current_tab=${tabId}; path=/; max-age=31536000`;
} else {
console.error('Failed to set current tab:', data.error);
}
} catch (error) {
console.error('Error setting current tab:', error);
}
}
// Load tab content
async function loadTabContent(tabId) {
const container = document.getElementById('tab-content');
if (!container) return;
try {
const response = await fetch(`/tabs/${tabId}`);
const tab = await response.json();
if (tab.error) {
container.innerHTML = `<div class="error">${tab.error}</div>`;
return;
}
// Render tab content (presets section)
const tabName = tab.name || `Tab ${tabId}`;
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
container.innerHTML = `
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
<div class="tab-brightness-group">
<label for="tab-brightness-slider">Brightness</label>
<input type="range" id="tab-brightness-slider" min="0" max="255" value="255">
</div>
</div>
<div id="presets-list-tab" class="presets-list">
<!-- Presets will be loaded here by presets.js -->
</div>
</div>
`;
// Wire up per-tab brightness slider to send global brightness via ESPNow.
const brightnessSlider = container.querySelector('#tab-brightness-slider');
let brightnessSendTimeout = null;
if (brightnessSlider) {
brightnessSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10) || 0;
if (brightnessSendTimeout) {
clearTimeout(brightnessSendTimeout);
}
brightnessSendTimeout = setTimeout(() => {
if (typeof window.sendEspnowRaw === 'function') {
try {
window.sendEspnowRaw({ v: '1', b: val, save: true });
} catch (err) {
console.error('Failed to send brightness via ESPNow:', err);
}
}
}, 150);
});
}
// Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') {
renderTabPresets(tabId);
}
} catch (error) {
console.error('Failed to load tab content:', error);
container.innerHTML = '<div class="error">Failed to load tab content</div>';
}
}
// Send all presets used by a tab via the /presets/send HTTP endpoint.
async function sendTabPresets(tabId) {
try {
// Load tab data to determine which presets are used
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
alert('Failed to load tab to send presets.');
return;
}
const tabData = await tabResponse.json();
// Extract preset IDs from tab (supports grid, flat, and legacy formats)
let presetIds = [];
if (Array.isArray(tabData.presets_flat)) {
presetIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
// Flat array of IDs
presetIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
// 2D grid
presetIds = tabData.presets.flat();
}
}
presetIds = (presetIds || []).filter(Boolean);
if (!presetIds.length) {
alert('This tab has no presets to send.');
return;
}
// Call server-side ESPNow sender with just the IDs; it handles chunking.
const payload = { preset_ids: presetIds };
if (tabData.default_preset) {
payload.default = tabData.default_preset;
}
const response = await fetch('/presets/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const msg = (data && data.error) || 'Failed to send presets.';
alert(msg);
return;
}
const sent = typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?';
alert(`Sent ${sent} preset(s) in ${messages} ESPNow message(s).`);
} catch (error) {
console.error('Failed to send tab presets:', error);
alert('Failed to send tab presets.');
}
}
// Send all presets used by all tabs in the current profile via /presets/send.
async function sendProfilePresets() {
try {
// Load current profile to get its tabs
const profileRes = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
});
if (!profileRes.ok) {
alert('Failed to load current profile.');
return;
}
const profileData = await profileRes.json();
const profile = profileData.profile || {};
let tabList = null;
if (Array.isArray(profile.tabs)) {
tabList = profile.tabs;
} else if (profile.tabs) {
tabList = [profile.tabs];
}
if (!tabList || tabList.length === 0) {
if (Array.isArray(profile.tab_order)) {
tabList = profile.tab_order;
} else if (profile.tab_order) {
tabList = [profile.tab_order];
} else {
tabList = [];
}
}
if (!tabList || tabList.length === 0) {
console.warn('sendProfilePresets: no tabs found', {
profileData,
profile,
});
}
if (!tabList.length) {
alert('Current profile has no tabs to send presets for.');
return;
}
let totalSent = 0;
let totalMessages = 0;
let tabsWithPresets = 0;
for (const tabId of tabList) {
try {
const tabResp = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResp.ok) {
continue;
}
const tabData = await tabResp.json();
let presetIds = [];
if (Array.isArray(tabData.presets_flat)) {
presetIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
presetIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
presetIds = tabData.presets.flat();
}
}
presetIds = (presetIds || []).filter(Boolean);
if (!presetIds.length) {
continue;
}
tabsWithPresets += 1;
const payload = { preset_ids: presetIds };
if (tabData.default_preset) {
payload.default = tabData.default_preset;
}
const response = await fetch('/presets/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const msg = (data && data.error) || `Failed to send presets for tab ${tabId}.`;
console.warn(msg);
continue;
}
totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0;
} catch (e) {
console.error('Failed to send profile presets for tab:', tabId, e);
}
}
if (!tabsWithPresets) {
alert('No presets to send for the current profile.');
return;
}
const messagesLabel = totalMessages ? totalMessages : '?';
alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`);
} catch (error) {
console.error('Failed to send profile presets:', error);
alert('Failed to send profile presets.');
}
}
// Populate the "Add presets to this tab" list: only presets NOT already in the tab, each with a Select button.
async function populateEditTabPresetsList(tabId) {
const listEl = document.getElementById('edit-tab-presets-list');
if (!listEl) return;
listEl.innerHTML = '<span class="muted-text">Loading…</span>';
try {
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' } });
if (!tabRes.ok) {
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
return;
}
const tabData = await tabRes.json();
let inTabIds = [];
if (Array.isArray(tabData.presets_flat)) {
inTabIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
inTabIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
inTabIds = tabData.presets.flat();
}
}
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
const allIds = Object.keys(allPresets);
const availableToAdd = allIds.filter(id => !inTabIds.includes(id));
listEl.innerHTML = '';
if (availableToAdd.length === 0) {
listEl.innerHTML = '<span class="muted-text">No presets to add. All presets are already in this tab.</span>';
return;
}
for (const presetId of availableToAdd) {
const preset = allPresets[presetId] || {};
const name = preset.name || presetId;
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.justifyContent = 'space-between';
row.style.gap = '0.5rem';
const label = document.createElement('span');
label.textContent = name;
const selectBtn = document.createElement('button');
selectBtn.type = 'button';
selectBtn.className = 'btn btn-primary btn-small';
selectBtn.textContent = 'Select';
selectBtn.addEventListener('click', async () => {
if (typeof window.addPresetToTab === 'function') {
await window.addPresetToTab(presetId, tabId);
await populateEditTabPresetsList(tabId);
}
});
row.appendChild(label);
row.appendChild(selectBtn);
listEl.appendChild(row);
}
} catch (e) {
console.error('populateEditTabPresetsList:', e);
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
}
}
// Open edit tab modal
function openEditTabModal(tabId, tab) {
const modal = document.getElementById('edit-tab-modal');
const idInput = document.getElementById('edit-tab-id');
const nameInput = document.getElementById('edit-tab-name');
const idsInput = document.getElementById('edit-tab-ids');
if (idInput) idInput.value = tabId;
if (nameInput) nameInput.value = tab ? (tab.name || '') : '';
if (idsInput) idsInput.value = tab && tab.names ? tab.names.join(', ') : '1';
if (modal) modal.classList.add('active');
populateEditTabPresetsList(tabId);
}
// Update an existing tab
async function updateTab(tabId, name, ids) {
try {
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
const response = await fetch(`/tabs/${tabId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
names: names
})
});
const data = await response.json();
if (response.ok) {
// Reload tabs list
await loadTabsModal();
await loadTabs();
// Close modal
document.getElementById('edit-tab-modal').classList.remove('active');
return true;
} else {
alert(`Error: ${data.error || 'Failed to update tab'}`);
return false;
}
} catch (error) {
console.error('Failed to update tab:', error);
alert('Failed to update tab');
return false;
}
}
// Create a new tab
async function createTab(name, ids) {
try {
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
const response = await fetch('/tabs', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
names: names
})
});
const data = await response.json();
if (response.ok) {
// Reload tabs list
await loadTabsModal();
await loadTabs();
// Select the new tab
if (data && Object.keys(data).length > 0) {
const newTabId = Object.keys(data)[0];
await selectTab(newTabId);
}
return true;
} else {
alert(`Error: ${data.error || 'Failed to create tab'}`);
return false;
}
} catch (error) {
console.error('Failed to create tab:', error);
alert('Failed to create tab');
return false;
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
loadTabs();
// Set up tabs modal
const tabsButton = document.getElementById('tabs-btn');
const tabsModal = document.getElementById('tabs-modal');
const tabsCloseButton = document.getElementById('tabs-close-btn');
const newTabNameInput = document.getElementById('new-tab-name');
const newTabIdsInput = document.getElementById('new-tab-ids');
const createTabButton = document.getElementById('create-tab-btn');
if (tabsButton && tabsModal) {
tabsButton.addEventListener('click', () => {
tabsModal.classList.add('active');
loadTabsModal();
});
}
if (tabsCloseButton) {
tabsCloseButton.addEventListener('click', () => {
tabsModal.classList.remove('active');
});
}
// Right-click on a tab button in the main header bar to edit that tab
document.addEventListener('contextmenu', async (event) => {
if (!isEditModeActive()) {
return;
}
const btn = event.target.closest('.tab-button');
if (!btn || !btn.dataset.tabId) {
return;
}
event.preventDefault();
const tabId = btn.dataset.tabId;
try {
const response = await fetch(`/tabs/${tabId}`);
if (response.ok) {
const tab = await response.json();
openEditTabModal(tabId, tab);
} else {
alert('Failed to load tab for editing');
}
} catch (error) {
console.error('Failed to load tab:', error);
alert('Failed to load tab for editing');
}
});
// Set up create tab
const createTabHandler = async () => {
if (!newTabNameInput) return;
const name = newTabNameInput.value.trim();
const ids = (newTabIdsInput && newTabIdsInput.value.trim()) || '1';
if (name) {
await createTab(name, ids);
if (newTabNameInput) newTabNameInput.value = '';
if (newTabIdsInput) newTabIdsInput.value = '1';
}
};
if (createTabButton) {
createTabButton.addEventListener('click', createTabHandler);
}
if (newTabNameInput) {
newTabNameInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
createTabHandler();
}
});
}
// Set up edit tab form
const editTabForm = document.getElementById('edit-tab-form');
if (editTabForm) {
editTabForm.addEventListener('submit', async (e) => {
e.preventDefault();
const idInput = document.getElementById('edit-tab-id');
const nameInput = document.getElementById('edit-tab-name');
const idsInput = document.getElementById('edit-tab-ids');
const tabId = idInput ? idInput.value : null;
const name = nameInput ? nameInput.value.trim() : '';
const ids = idsInput ? idsInput.value.trim() : '1';
if (tabId && name) {
await updateTab(tabId, name, ids);
editTabForm.reset();
}
});
}
// Profile-wide "Send Presets" button in header
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
if (sendProfilePresetsBtn) {
sendProfilePresetsBtn.addEventListener('click', async () => {
await sendProfilePresets();
});
}
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', async () => {
await loadTabs();
if (tabsModal && tabsModal.classList.contains('active')) {
await loadTabsModal();
}
});
});
});
// Export for use in other scripts
window.tabsManager = {
loadTabs,
loadTabsModal,
selectTab,
createTab,
updateTab,
openEditTabModal,
getCurrentTabId: () => currentTabId
};

View File

@@ -1,24 +1,24 @@
document.addEventListener('DOMContentLoaded', () => {
let selectedIndex = null;
const getTab = async (tabId) => {
const response = await fetch(`/tabs/${tabId}`, {
const getTab = async (zoneId) => {
const response = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('No tab found');
throw new Error('No zone found');
}
return response.json();
};
const saveTabColors = async (tabId, colors) => {
const response = await fetch(`/tabs/${tabId}`, {
const saveTabColors = async (zoneId, colors) => {
const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ colors }),
});
if (!response.ok) {
throw new Error('Failed to save tab colors');
throw new Error('Failed to save zone colors');
}
return response.json();
};
@@ -101,23 +101,23 @@ document.addEventListener('DOMContentLoaded', () => {
const initTabPalette = async () => {
const paletteContainer = document.getElementById('color-palette');
const addButton = document.getElementById('tab-color-add-btn');
const addFromPaletteButton = document.getElementById('tab-color-add-from-palette-btn');
const colorInput = document.getElementById('tab-color-input');
const addButton = document.getElementById('zone-color-add-btn');
const addFromPaletteButton = document.getElementById('zone-color-add-from-palette-btn');
const colorInput = document.getElementById('zone-color-input');
if (!paletteContainer || !addButton || !colorInput) {
return;
}
const tabId = paletteContainer.dataset.tabId;
if (!tabId) {
const zoneId = paletteContainer.dataset.zoneId;
if (!zoneId) {
renderPalette(paletteContainer, []);
return;
}
let tabData;
try {
tabData = await getTab(tabId);
tabData = await getTab(zoneId);
} catch (error) {
renderPalette(paletteContainer, []);
return;
@@ -134,7 +134,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
try {
const updated = colors.filter((_, i) => i !== index);
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = null;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -152,7 +152,7 @@ document.addEventListener('DOMContentLoaded', () => {
const updated = [...colors];
const [moved] = updated.splice(fromIndex, 1);
updated.splice(toIndex, 0, moved);
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = toIndex;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -169,7 +169,7 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const updated = [...colors];
updated[index] = newColor;
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = index;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -192,7 +192,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
try {
const updated = [...colors, newColor];
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = colors.length - 1;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -229,7 +229,7 @@ document.addEventListener('DOMContentLoaded', () => {
try {
if (!colors.includes(picked)) {
const updated = [...colors, picked];
const saved = await saveTabColors(tabId, updated);
const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated;
selectedIndex = colors.indexOf(picked);
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -252,7 +252,7 @@ document.addEventListener('DOMContentLoaded', () => {
};
document.body.addEventListener('htmx:afterSwap', (event) => {
if (event.target && event.target.id === 'tab-content') {
if (event.target && event.target.id === 'zone-content') {
selectedIndex = null;
initTabPalette();
}

997
src/static/zones.js Normal file
View File

@@ -0,0 +1,997 @@
// Zone management JavaScript
let currentZoneId = null;
const isEditModeActive = () => {
const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
};
// Get current zone from cookie
function getCurrentZoneFromCookie() {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'current_zone') {
return value;
}
}
return null;
}
async function fetchDevicesMap() {
try {
const response = await fetch("/devices", { headers: { Accept: "application/json" } });
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === "object" ? data : {};
} catch (e) {
console.error("fetchDevicesMap:", e);
return {};
}
}
/** Registry MACs for zone device names (order matches zone names; skips unknown names). */
async function resolveZoneDeviceMacs(zoneNames) {
const dm = await fetchDevicesMap();
const rows = namesToRows(Array.isArray(zoneNames) ? zoneNames : [], dm);
const macs = rows.map((r) => r.mac).filter(Boolean);
return [...new Set(macs)];
}
function namesToRows(zoneNames, devicesMap) {
const usedMacs = new Set();
const list = Array.isArray(zoneNames) ? zoneNames : [];
return list.map((name) => {
const n = String(name || "").trim();
const matches = Object.entries(devicesMap || {}).filter(
([mac, d]) => d && String((d.name || "").trim()) === n && !usedMacs.has(mac),
);
if (matches.length === 0) {
return { mac: null, name: n || "unknown" };
}
const [mac] = matches[0];
usedMacs.add(mac);
return { mac, name: n };
});
}
function rowsToNames(rows) {
return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0);
}
function renderZoneDevicesEditor(containerEl, rows, devicesMap) {
if (!containerEl) return;
containerEl.innerHTML = "";
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
rows.forEach((row, idx) => {
const div = document.createElement("div");
div.className = "zone-device-row profiles-row";
const label = document.createElement("span");
label.className = "zone-device-row-label";
const strong = document.createElement("strong");
strong.textContent = row.name || "—";
label.appendChild(strong);
label.appendChild(document.createTextNode(" "));
const sub = document.createElement("span");
sub.className = "muted-text";
sub.textContent = row.mac ? row.mac : "(not in registry)";
label.appendChild(sub);
const rm = document.createElement("button");
rm.type = "button";
rm.className = "btn btn-danger btn-small";
rm.textContent = "Remove";
rm.addEventListener("click", () => {
rows.splice(idx, 1);
renderZoneDevicesEditor(containerEl, rows, devicesMap);
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
});
const macsInRows = new Set(rows.map((r) => r.mac).filter(Boolean));
const addWrap = document.createElement("div");
addWrap.className = "zone-devices-add profiles-actions";
const sel = document.createElement("select");
sel.className = "zone-device-add-select";
sel.appendChild(new Option("Add device…", ""));
entries.forEach(([mac, d]) => {
if (macsInRows.has(mac)) return;
const labelName = d && d.name ? String(d.name).trim() : "";
const optLabel = labelName ? `${labelName}${mac}` : mac;
sel.appendChild(new Option(optLabel, mac));
});
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "btn btn-primary btn-small";
addBtn.textContent = "Add";
addBtn.addEventListener("click", () => {
const mac = sel.value;
if (!mac || !devicesMap[mac]) return;
const n = String((devicesMap[mac].name || "").trim() || mac);
rows.push({ mac, name: n });
sel.value = "";
renderZoneDevicesEditor(containerEl, rows, devicesMap);
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap);
}
/** Default device name list when creating a zone (refined in Edit zone). */
async function defaultDeviceNamesForNewTab() {
const dm = await fetchDevicesMap();
const macs = Object.keys(dm);
if (macs.length > 0) {
const m0 = macs[0];
return [String((dm[m0].name || "").trim() || m0)];
}
return ["1"];
}
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
function parseTabDeviceNames(section) {
if (!section) return [];
const enc = section.getAttribute("data-device-names-json");
if (enc) {
try {
const arr = JSON.parse(decodeURIComponent(enc));
return Array.isArray(arr) ? arr.map((n) => String(n).trim()).filter((n) => n.length > 0) : [];
} catch (e) {
/* ignore */
}
}
const legacy = section.getAttribute("data-device-names");
if (legacy) {
return legacy.split(",").map((n) => n.trim()).filter((n) => n.length > 0);
}
return [];
}
window.parseTabDeviceNames = parseTabDeviceNames;
window.parseZoneDeviceNames = parseTabDeviceNames;
function escapeHtmlAttr(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
}
// Load tabs list
async function loadZones() {
try {
const response = await fetch('/zones');
const data = await response.json();
// Get current zone from cookie first, then from server response
const cookieTabId = getCurrentZoneFromCookie();
const serverCurrent = data.current_zone_id;
const tabs = data.zones || {};
const zoneIds = Object.keys(tabs);
let candidateId = cookieTabId || serverCurrent || null;
// If the candidate doesn't exist anymore (e.g. after DB reset), fall back to first zone.
if (candidateId && !zoneIds.includes(String(candidateId))) {
candidateId = zoneIds.length > 0 ? zoneIds[0] : null;
// Clear stale cookie
document.cookie = 'current_zone=; path=/; max-age=0';
}
currentZoneId = candidateId;
renderZonesList(data.zones, data.zone_order, currentZoneId);
// Load current zone content if available
if (currentZoneId) {
await loadZoneContent(currentZoneId);
} else if (data.zone_order && data.zone_order.length > 0) {
// Set first zone as current if none is set
const firstTabId = data.zone_order[0];
await setCurrentZone(firstTabId);
await loadZoneContent(firstTabId);
}
} catch (error) {
console.error('Failed to load zones:', error);
const container = document.getElementById('zones-list');
if (container) {
container.innerHTML = '<div class="error">Failed to load zones</div>';
}
}
}
// Render tabs list in the main UI
function renderZonesList(tabs, tabOrder, currentZoneId) {
const container = document.getElementById('zones-list');
if (!container) return;
if (!tabOrder || tabOrder.length === 0) {
container.innerHTML = '<div class="muted-text">No zones available</div>';
return;
}
const editMode = isEditModeActive();
let html = '<div class="zones-list">';
for (const zoneId of tabOrder) {
const zone = tabs[zoneId];
if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : '';
const tabName = zone.name || `Zone ${zoneId}`;
html += `
<button class="zone-button ${activeClass}"
data-zone-id="${zoneId}"
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
onclick="selectZone('${zoneId}')">
${tabName}
</button>
`;
}
}
html += '</div>';
container.innerHTML = html;
}
// Render tabs list in modal (like profiles)
function renderZonesListModal(tabs, tabOrder, currentZoneId) {
const container = document.getElementById('zones-list-modal');
if (!container) return;
container.innerHTML = "";
let entries = [];
if (Array.isArray(tabOrder)) {
entries = tabOrder.map((zoneId) => [zoneId, tabs[zoneId] || {}]);
} else if (tabs && typeof tabs === "object") {
entries = Object.entries(tabs).filter(([key]) => {
return key !== 'current_zone_id' && key !== 'zones' && key !== 'zone_order';
});
}
if (entries.length === 0) {
const empty = document.createElement("p");
empty.className = "muted-text";
empty.textContent = "No zones found.";
container.appendChild(empty);
return;
}
const editMode = isEditModeActive();
entries.forEach(([zoneId, zone]) => {
const row = document.createElement("div");
row.className = "profiles-row";
row.dataset.zoneId = String(zoneId);
const label = document.createElement("span");
label.textContent = (zone && zone.name) || zoneId;
if (String(zoneId) === String(currentZoneId)) {
label.textContent = `${label.textContent}`;
label.style.fontWeight = "bold";
label.style.color = "#FFD700";
}
const applyButton = document.createElement("button");
applyButton.className = "btn btn-secondary btn-small";
applyButton.textContent = "Select";
applyButton.addEventListener("click", async () => {
await selectZone(zoneId);
document.getElementById('zones-modal').classList.remove('active');
});
const editButton = document.createElement("button");
editButton.className = "btn btn-secondary btn-small";
editButton.textContent = "Edit";
editButton.addEventListener("click", async () => {
await openEditZoneModal(zoneId, zone);
});
const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone";
cloneButton.addEventListener("click", async () => {
const baseName = (zone && zone.name) || zoneId;
const suggested = `${baseName} Copy`;
const name = prompt("New zone name:", suggested);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Zone name cannot be empty.");
return;
}
try {
const response = await fetch(`/zones/${zoneId}/clone`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ name: trimmed }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to clone zone" }));
throw new Error(errorData.error || "Failed to clone zone");
}
const data = await response.json().catch(() => null);
let newTabId = null;
if (data && typeof data === "object") {
if (data.id) {
newTabId = String(data.id);
} else {
const ids = Object.keys(data);
if (ids.length > 0) {
newTabId = String(ids[0]);
}
}
}
await loadZonesModal();
if (newTabId) {
await selectZone(newTabId);
} else {
await loadZones();
}
} catch (error) {
console.error("Clone zone failed:", error);
alert("Failed to clone zone: " + error.message);
}
});
const deleteButton = document.createElement("button");
deleteButton.className = "btn btn-danger btn-small";
deleteButton.textContent = "Delete";
deleteButton.addEventListener("click", async () => {
const confirmed = confirm(`Delete zone "${label.textContent}"?`);
if (!confirmed) {
return;
}
try {
const response = await fetch(`/zones/${zoneId}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to delete zone" }));
throw new Error(errorData.error || "Failed to delete zone");
}
// Clear cookie if deleted zone was current
if (zoneId === currentZoneId) {
document.cookie = 'current_zone=; path=/; max-age=0';
currentZoneId = null;
}
await loadZonesModal();
await loadZones(); // Reload main tabs list
} catch (error) {
console.error("Delete zone failed:", error);
alert("Failed to delete zone: " + error.message);
}
});
row.appendChild(label);
row.appendChild(applyButton);
if (editMode) {
row.appendChild(editButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
}
container.appendChild(row);
});
}
// Load tabs in modal
async function loadZonesModal() {
const container = document.getElementById('zones-list-modal');
if (!container) return;
container.innerHTML = "";
const loading = document.createElement("p");
loading.className = "muted-text";
loading.textContent = "Loading zones...";
container.appendChild(loading);
try {
const response = await fetch("/zones", {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error("Failed to load zones");
}
const data = await response.json();
const tabs = data.zones || data;
const currentZoneId = getCurrentZoneFromCookie() || data.current_zone_id || null;
renderZonesListModal(tabs, data.zone_order || [], currentZoneId);
} catch (error) {
console.error("Load tabs failed:", error);
container.innerHTML = "";
const errorMessage = document.createElement("p");
errorMessage.className = "muted-text";
errorMessage.textContent = "Failed to load zones.";
container.appendChild(errorMessage);
}
}
// Select a zone
async function selectZone(zoneId) {
// Update active state
document.querySelectorAll('.zone-button').forEach(btn => {
btn.classList.remove('active');
});
const btn = document.querySelector(`[data-zone-id="${zoneId}"]`);
if (btn) {
btn.classList.add('active');
}
// Set as current zone
await setCurrentZone(zoneId);
// Load zone content
loadZoneContent(zoneId);
}
// Set current zone in cookie
async function setCurrentZone(zoneId) {
try {
const response = await fetch(`/zones/${zoneId}/set-current`, {
method: 'POST'
});
const data = await response.json();
if (response.ok) {
currentZoneId = zoneId;
// Also set cookie on client side
document.cookie = `current_zone=${zoneId}; path=/; max-age=31536000`;
} else {
console.error('Failed to set current zone:', data.error);
}
} catch (error) {
console.error('Error setting current zone:', error);
}
}
// Load zone content
async function loadZoneContent(zoneId) {
const container = document.getElementById('zone-content');
if (!container) return;
try {
const response = await fetch(`/zones/${zoneId}`);
const zone = await response.json();
if (zone.error) {
container.innerHTML = `<div class="error">${zone.error}</div>`;
return;
}
// Render zone content (presets section)
const tabName = zone.name || `Zone ${zoneId}`;
const names = Array.isArray(zone.names) ? zone.names : [];
const namesJsonAttr = encodeURIComponent(JSON.stringify(names));
const legacyOk = names.length > 0 && !names.some((n) => /[",]/.test(String(n)));
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : "";
container.innerHTML = `
<div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}>
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
<div class="zone-brightness-group">
<label for="zone-brightness-slider">Brightness</label>
<input type="range" id="zone-brightness-slider" min="0" max="255" value="255">
</div>
</div>
<div id="presets-list-zone" class="presets-list">
<!-- Presets will be loaded here by presets.js -->
</div>
</div>
`;
// Wire up per-zone brightness slider to send global brightness via ESPNow.
const brightnessSlider = container.querySelector('#zone-brightness-slider');
let brightnessSendTimeout = null;
if (brightnessSlider) {
brightnessSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10) || 0;
if (brightnessSendTimeout) {
clearTimeout(brightnessSendTimeout);
}
brightnessSendTimeout = setTimeout(() => {
if (typeof window.sendEspnowRaw === 'function') {
try {
window.sendEspnowRaw({ v: '1', b: val, save: true });
} catch (err) {
console.error('Failed to send brightness via ESPNow:', err);
}
}
}, 150);
});
}
// Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') {
renderTabPresets(zoneId);
}
} catch (error) {
console.error('Failed to load zone content:', error);
container.innerHTML = '<div class="error">Failed to load zone content</div>';
}
}
// Send all presets used by all tabs in the current profile via /presets/send.
async function sendProfilePresets() {
try {
// Load current profile to get its tabs
const profileRes = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
});
if (!profileRes.ok) {
alert('Failed to load current profile.');
return;
}
const profileData = await profileRes.json();
const profile = profileData.profile || {};
let zoneList = null;
if (Array.isArray(profile.zones)) {
zoneList = profile.zones;
} else if (profile.zones) {
zoneList = [profile.zones];
}
if (!zoneList || zoneList.length === 0) {
if (Array.isArray(profile.zones)) {
zoneList = profile.zones;
} else if (profile.zones) {
zoneList = [profile.zones];
}
}
if (!zoneList || zoneList.length === 0) {
console.warn('sendProfilePresets: no zones found', {
profileData,
profile,
});
}
if (!zoneList.length) {
alert('Current profile has no zones to send presets for.');
return;
}
let totalSent = 0;
let totalMessages = 0;
let zonesWithPresets = 0;
for (const zoneId of zoneList) {
try {
const tabResp = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResp.ok) {
continue;
}
const tabData = await tabResp.json();
let presetIds = [];
if (Array.isArray(tabData.presets_flat)) {
presetIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
presetIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
presetIds = tabData.presets.flat();
}
}
presetIds = (presetIds || []).filter(Boolean);
if (!presetIds.length) {
continue;
}
zonesWithPresets += 1;
const zoneNames = Array.isArray(tabData.names) ? tabData.names : [];
const targets = await resolveZoneDeviceMacs(zoneNames);
const payload = { preset_ids: presetIds };
if (tabData.default_preset) {
payload.default = tabData.default_preset;
}
if (targets.length > 0) {
payload.targets = targets;
}
const response = await fetch('/presets/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const msg = (data && data.error) || `Failed to send presets for zone ${zoneId}.`;
console.warn(msg);
continue;
}
totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0;
} catch (e) {
console.error('Failed to send profile presets for zone:', zoneId, e);
}
}
if (!zonesWithPresets) {
alert('No presets to send for the current profile.');
return;
}
const messagesLabel = totalMessages ? totalMessages : '?';
alert(`Sent ${totalSent} preset(s) across ${zonesWithPresets} zone(s) (${messagesLabel} driver send(s)).`);
} catch (error) {
console.error('Failed to send profile presets:', error);
alert('Failed to send profile presets.');
}
}
function tabPresetIdsInOrder(tabData) {
let ids = [];
if (Array.isArray(tabData.presets_flat)) {
ids = tabData.presets_flat.slice();
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === "string") {
ids = tabData.presets.slice();
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
ids = tabData.presets.flat();
}
}
return (ids || []).filter(Boolean);
}
// Presets already on the zone (remove) and presets available to add (select).
async function refreshEditTabPresetsUi(zoneId) {
const currentEl = document.getElementById("edit-zone-presets-current");
const addEl = document.getElementById("edit-zone-presets-list");
if (!zoneId || !currentEl || !addEl) return;
currentEl.innerHTML = '<span class="muted-text">Loading…</span>';
addEl.innerHTML = '<span class="muted-text">Loading…</span>';
try {
const tabRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: "application/json" } });
if (!tabRes.ok) {
const msg = '<span class="muted-text">Failed to load zone presets.</span>';
currentEl.innerHTML = msg;
addEl.innerHTML = msg;
return;
}
const tabData = await tabRes.json();
const inTabIds = tabPresetIdsInOrder(tabData);
const inTabSet = new Set(inTabIds.map((id) => String(id)));
const presetsRes = await fetch("/presets", { headers: { Accept: "application/json" } });
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
const makeRow = () => {
const row = document.createElement("div");
row.className = "profiles-row";
row.style.display = "flex";
row.style.alignItems = "center";
row.style.justifyContent = "space-between";
row.style.gap = "0.5rem";
return row;
};
currentEl.innerHTML = "";
if (inTabIds.length === 0) {
currentEl.innerHTML = '<span class="muted-text">No presets on this zone yet.</span>';
} else {
for (const presetId of inTabIds) {
const preset = allPresets[presetId] || {};
const name = preset.name || presetId;
const row = makeRow();
const label = document.createElement("span");
label.textContent = name;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "btn btn-danger btn-small";
removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", async () => {
if (typeof window.removePresetFromTab !== "function") return;
if (!window.confirm(`Remove this preset from the zone?\n\n${name}`)) return;
await window.removePresetFromTab(zoneId, presetId);
await refreshEditTabPresetsUi(zoneId);
});
row.appendChild(label);
row.appendChild(removeBtn);
currentEl.appendChild(row);
}
}
const allIds = Object.keys(allPresets);
const availableToAdd = allIds.filter((id) => !inTabSet.has(String(id)));
addEl.innerHTML = "";
if (availableToAdd.length === 0) {
addEl.innerHTML =
'<span class="muted-text">No presets to add. All presets are already on this zone.</span>';
} else {
const addWrap = document.createElement("div");
addWrap.className = "zone-devices-add profiles-actions";
const sel = document.createElement("select");
sel.className = "zone-device-add-select";
sel.setAttribute("aria-label", "Preset to add to this zone");
sel.appendChild(new Option("Add preset", ""));
const sorted = availableToAdd.slice().sort((a, b) => {
const na = (allPresets[a] && allPresets[a].name) || a;
const nb = (allPresets[b] && allPresets[b].name) || b;
return String(na).localeCompare(String(nb), undefined, { sensitivity: "base" });
});
sorted.forEach((presetId) => {
const preset = allPresets[presetId] || {};
const name = preset.name || presetId;
sel.appendChild(new Option(`${name} — ${presetId}`, presetId));
});
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "btn btn-primary btn-small";
addBtn.textContent = "Add";
addBtn.addEventListener("click", async () => {
const presetId = sel.value;
if (!presetId) return;
if (typeof window.addPresetToTab === "function") {
await window.addPresetToTab(presetId, zoneId);
sel.value = "";
await refreshEditTabPresetsUi(zoneId);
}
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
addEl.appendChild(addWrap);
}
} catch (e) {
console.error("refreshEditTabPresetsUi:", e);
const msg = '<span class="muted-text">Failed to load presets.</span>';
currentEl.innerHTML = msg;
addEl.innerHTML = msg;
}
}
async function populateEditTabPresetsList(zoneId) {
await refreshEditTabPresetsUi(zoneId);
}
// Open edit zone modal
async function openEditZoneModal(zoneId, zone) {
const modal = document.getElementById("edit-zone-modal");
const idInput = document.getElementById("edit-zone-id");
const nameInput = document.getElementById("edit-zone-name");
const editor = document.getElementById("edit-zone-devices-editor");
let tabData = zone;
if (!tabData || typeof tabData !== "object" || tabData.error) {
try {
const response = await fetch(`/zones/${zoneId}`);
if (response.ok) {
tabData = await response.json();
}
} catch (e) {
console.error("openEditZoneModal fetch zone:", e);
}
}
tabData = tabData || {};
if (idInput) idInput.value = zoneId;
if (nameInput) nameInput.value = tabData.name || "";
const devicesMap = await fetchDevicesMap();
const zoneNames =
Array.isArray(tabData.names) && tabData.names.length > 0 ? tabData.names : ["1"];
window.__editTabDeviceRows = namesToRows(zoneNames, devicesMap);
renderZoneDevicesEditor(editor, window.__editTabDeviceRows, devicesMap);
if (modal) modal.classList.add("active");
await refreshEditTabPresetsUi(zoneId);
}
function normalizeTabNamesArg(namesOrString) {
if (Array.isArray(namesOrString)) {
return namesOrString.map((n) => String(n).trim()).filter((n) => n.length > 0);
}
if (typeof namesOrString === "string" && namesOrString.trim()) {
return namesOrString.split(",").map((id) => id.trim()).filter((id) => id.length > 0);
}
return ["1"];
}
// Update an existing zone
async function updateZone(zoneId, name, namesOrString) {
try {
let names = normalizeTabNamesArg(namesOrString);
if (!names.length) names = ["1"];
const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
names: names
})
});
const data = await response.json();
if (response.ok) {
// Reload tabs list
await loadZonesModal();
await loadZones();
// Close modal
document.getElementById('edit-zone-modal').classList.remove('active');
return true;
} else {
alert(`Error: ${data.error || 'Failed to update zone'}`);
return false;
}
} catch (error) {
console.error('Failed to update zone:', error);
alert('Failed to update zone');
return false;
}
}
// Create a new zone
async function createZone(name, namesOrString) {
try {
let names = normalizeTabNamesArg(namesOrString);
if (!names.length) names = ["1"];
const response = await fetch('/zones', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
names: names
})
});
const data = await response.json();
if (response.ok) {
// Reload tabs list
await loadZonesModal();
await loadZones();
// Select the new zone
if (data && Object.keys(data).length > 0) {
const newTabId = Object.keys(data)[0];
await selectZone(newTabId);
}
return true;
} else {
alert(`Error: ${data.error || 'Failed to create zone'}`);
return false;
}
} catch (error) {
console.error('Failed to create zone:', error);
alert('Failed to create zone');
return false;
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
loadZones();
// Set up tabs modal
const tabsButton = document.getElementById('zones-btn');
const zonesModal = document.getElementById('zones-modal');
const tabsCloseButton = document.getElementById('zones-close-btn');
const newTabNameInput = document.getElementById("new-zone-name");
const createZoneButton = document.getElementById("create-zone-btn");
if (tabsButton && zonesModal) {
tabsButton.addEventListener("click", async () => {
zonesModal.classList.add("active");
await loadZonesModal();
});
}
if (tabsCloseButton) {
tabsCloseButton.addEventListener('click', () => {
zonesModal.classList.remove('active');
});
}
// Right-click on a zone button in the main header bar to edit that zone
document.addEventListener('contextmenu', async (event) => {
if (!isEditModeActive()) {
return;
}
const btn = event.target.closest('.zone-button');
if (!btn || !btn.dataset.zoneId) {
return;
}
event.preventDefault();
const zoneId = btn.dataset.zoneId;
try {
const response = await fetch(`/zones/${zoneId}`);
if (response.ok) {
const zone = await response.json();
await openEditZoneModal(zoneId, zone);
} else {
alert('Failed to load zone for editing');
}
} catch (error) {
console.error('Failed to load zone:', error);
alert('Failed to load zone for editing');
}
});
// Set up create zone
const createZoneHandler = async () => {
if (!newTabNameInput) return;
const name = newTabNameInput.value.trim();
if (name) {
const deviceNames = await defaultDeviceNamesForNewTab();
await createZone(name, deviceNames);
if (newTabNameInput) newTabNameInput.value = "";
}
};
if (createZoneButton) {
createZoneButton.addEventListener('click', createZoneHandler);
}
if (newTabNameInput) {
newTabNameInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
createZoneHandler();
}
});
}
// Set up edit zone form
const editZoneForm = document.getElementById('edit-zone-form');
if (editZoneForm) {
editZoneForm.addEventListener("submit", async (e) => {
e.preventDefault();
const idInput = document.getElementById("edit-zone-id");
const nameInput = document.getElementById("edit-zone-name");
const zoneId = idInput ? idInput.value : null;
const name = nameInput ? nameInput.value.trim() : "";
const rows = window.__editTabDeviceRows || [];
const deviceNames = rowsToNames(rows);
if (zoneId && name) {
if (deviceNames.length === 0) {
alert("Add at least one device.");
return;
}
await updateZone(zoneId, name, deviceNames);
editZoneForm.reset();
}
});
}
// Profile-wide "Send Presets" button in header
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
if (sendProfilePresetsBtn) {
sendProfilePresetsBtn.addEventListener('click', async () => {
await sendProfilePresets();
});
}
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', async () => {
await loadZones();
if (zonesModal && zonesModal.classList.contains("active")) {
await loadZonesModal();
}
});
});
});
// Export for use in other scripts
window.zonesManager = {
loadZones,
loadZonesModal,
selectZone,
createZone,
updateZone,
openEditZoneModal,
resolveZoneDeviceMacs,
resolveTabDeviceMacs: resolveZoneDeviceMacs,
getCurrentZoneId: () => currentZoneId,
};
window.tabsManager = window.zonesManager;
window.tabsManager.getCurrentTabId = () => currentZoneId;
window.tabsManager.loadTabs = loadZones;
window.tabsManager.loadTabsModal = loadZonesModal;
window.tabsManager.openEditTabModal = openEditZoneModal;

View File

@@ -3,24 +3,26 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Controller - Tab Mode</title>
<title>LED Controller - Zone Mode</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-container">
<header>
<div class="tabs-container">
<div id="tabs-list">
Loading tabs...
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
<div class="header-actions">
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div>
@@ -29,57 +31,60 @@
<div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
<button type="button" data-target="help-btn">Help</button>
</div>
</div>
</header>
<div class="main-content">
<div id="tab-content" class="tab-content">
<div class="tab-content-placeholder">
Select a tab to get started
<div id="zone-content" class="zone-content">
<div class="zone-content-placeholder">
Select a zone to get started
</div>
</div>
</div>
</div>
<!-- Tabs Modal -->
<div id="tabs-modal" class="modal">
<div id="zones-modal" class="modal">
<div class="modal-content">
<h2>Tabs</h2>
<div class="profiles-actions">
<input type="text" id="new-tab-name" placeholder="Tab name">
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1">
<button class="btn btn-primary" id="create-tab-btn">Create</button>
<div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button>
</div>
<div id="tabs-list-modal" class="profiles-list"></div>
<div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="tabs-close-btn">Close</button>
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
</div>
</div>
</div>
<!-- Edit Tab Modal -->
<div id="edit-tab-modal" class="modal">
<!-- Edit Zone Modal -->
<div id="edit-zone-modal" class="modal">
<div class="modal-content">
<h2>Edit Tab</h2>
<form id="edit-tab-form">
<input type="hidden" id="edit-tab-id">
<h2>Edit Zone</h2>
<form id="edit-zone-form">
<input type="hidden" id="edit-zone-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
<label>Tab Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
<label>Device IDs (comma-separated):</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
<label style="margin-top: 1rem;">Add presets to this tab</label>
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
<label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label class="zone-devices-label">Devices in this zone</label>
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
<label class="zone-presets-section-label">Presets on this zone</label>
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="zone-presets-section-label">Add presets to this zone</label>
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
</form>
</div>
</div>
@@ -95,7 +100,7 @@
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="new-profile-seed-dj">
DJ tab
DJ zone
</label>
</div>
<div id="profiles-list" class="profiles-list"></div>
@@ -105,12 +110,57 @@
</div>
</div>
<!-- Devices Modal (registry: Wi-Fi drivers appear when they connect over TCP) -->
<div id="devices-modal" class="modal">
<div class="modal-content">
<h2>Devices</h2>
<div id="devices-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
</div>
</div>
</div>
<div id="edit-device-modal" class="modal">
<div class="modal-content">
<h2>Edit device</h2>
<form id="edit-device-form">
<input type="hidden" id="edit-device-id">
<p class="muted-text" style="margin-bottom:0.75rem;">MAC (id): <code id="edit-device-storage-id"></code></p>
<label for="edit-device-name">Name</label>
<input type="text" id="edit-device-name" required autocomplete="off">
<label for="edit-device-type" style="margin-top:0.75rem;display:block;">Type</label>
<select id="edit-device-type">
<option value="led">LED</option>
</select>
<label for="edit-device-transport" style="margin-top:0.75rem;display:block;">Transport</label>
<select id="edit-device-transport">
<option value="espnow">ESP-NOW</option>
<option value="wifi">WiFi</option>
</select>
<div id="edit-device-address-espnow" style="margin-top:0.75rem;">
<label class="device-field-label">MAC (12 hex, optional)</label>
<div id="edit-device-address-boxes" class="hex-address-row" aria-label="MAC address"></div>
</div>
<div id="edit-device-address-wifi-wrap" style="margin-top:0.75rem;" hidden>
<label for="edit-device-address-wifi">Address (IP or hostname)</label>
<input type="text" id="edit-device-address-wifi" placeholder="192.168.1.50" autocomplete="off">
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Presets Modal -->
<div id="presets-modal" class="modal">
<div class="modal-content">
<h2>Presets</h2>
<div class="modal-actions">
<button class="btn btn-primary" id="preset-add-btn">Add</button>
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
</div>
<div id="presets-list" class="profiles-list"></div>
<div class="modal-actions">
@@ -179,9 +229,10 @@
<input type="number" id="preset-n8-input" min="0" max="255" value="0" class="n-input">
</div>
</div>
<div class="modal-actions">
<div class="modal-actions preset-editor-modal-actions">
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
<button type="button" class="btn btn-danger" id="preset-remove-from-zone-btn" hidden>Remove from zone</button>
<button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
</div>
@@ -192,6 +243,9 @@
<div id="patterns-modal" class="modal">
<div class="modal-content">
<h2>Patterns</h2>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
</div>
<div id="patterns-list" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
@@ -199,6 +253,78 @@
</div>
</div>
<!-- Pattern Editor Modal -->
<div id="pattern-editor-modal" class="modal">
<div class="modal-content">
<h2>Pattern</h2>
<p class="muted-text" style="margin: 0 0 0.75rem 0;">Add a driver <code>.py</code> file and editor metadata (stored in the pattern database).</p>
<div class="profiles-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<label for="pattern-create-name" style="min-width: 7rem;">Name</label>
<input type="text" id="pattern-create-name" class="preset-name-like" placeholder="e.g. sparkle" pattern="[a-zA-Z_][a-zA-Z0-9_]*" style="flex: 1; min-width: 12rem;" autocomplete="off">
</div>
<div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;">
<h3 class="muted-text">Readable parameter names</h3>
<p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names are stored for this pattern.</p>
<div class="n-params-grid">
<div class="n-param-group">
<label for="pattern-create-n1"></label>
<input type="text" id="pattern-create-n1" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n2"></label>
<input type="text" id="pattern-create-n2" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n3"></label>
<input type="text" id="pattern-create-n3" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n4"></label>
<input type="text" id="pattern-create-n4" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n5"></label>
<input type="text" id="pattern-create-n5" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n6"></label>
<input type="text" id="pattern-create-n6" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n7"></label>
<input type="text" id="pattern-create-n7" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n8"></label>
<input type="text" id="pattern-create-n8" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
</div>
</div>
<div class="profiles-row pattern-editor-meta-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<label for="pattern-create-min-delay" style="min-width: 7rem;">Min delay (ms)</label>
<input type="number" id="pattern-create-min-delay" min="0" value="10">
<label for="pattern-create-max-delay">Max delay (ms)</label>
<input type="number" id="pattern-create-max-delay" min="0" value="10000">
<label for="pattern-create-max-colors">Max colours</label>
<input type="number" id="pattern-create-max-colors" min="0" value="10">
</div>
<div class="profiles-row" style="flex-direction: column; align-items: stretch; gap: 0.35rem; margin-bottom: 0.5rem;">
<label for="pattern-create-file">Pattern file</label>
<input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY">
<label for="pattern-create-code" class="muted-text" style="font-size: 0.85em;">Or paste Python source (if no file chosen)</label>
<textarea id="pattern-create-code" rows="5" style="width: 100%; font-family: monospace; font-size: 0.85rem;" placeholder="# class MyPattern: ..."></textarea>
</div>
<div class="modal-actions">
<label style="display: inline-flex; align-items: center; gap: 0.35rem; margin-right: auto;">
<input type="checkbox" id="pattern-create-overwrite" checked>
<span>Overwrite existing file</span>
</label>
<button type="button" class="btn btn-primary" id="pattern-create-btn">Save</button>
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
</div>
</div>
</div>
<!-- Colour Palette Modal -->
<div id="color-palette-modal" class="modal">
<div class="modal-content">
@@ -222,10 +348,11 @@
<h3>Run mode</h3>
<ul>
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the tab.</li>
<li><strong>Select zone</strong>: left-click a zone button in the top bar.</li>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the zone.</li>
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current tab to all tab devices.</li>
<li><strong>Devices</strong>: open <strong>Devices</strong> to see drivers (Wi-Fi clients appear when they connect); edit or remove rows as needed.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li>
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul>
@@ -234,11 +361,19 @@
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> tab and can optionally seed a <strong>DJ tab</strong>.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li>
<li><strong>Devices</strong>: view, edit, or remove registry entries (tabs use <strong>names</strong>; each row is keyed by <strong>MAC</strong>).</li>
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul>
<h3>What led-tool does</h3>
<ul>
<li><strong>USB device setup</strong>: updates <code>settings.json</code> on ESP32 drivers over serial (for example name, pin, LED count, Wi-Fi credentials).</li>
<li><strong>Deploy and maintenance</strong>: uploads driver files, flashes firmware, resets device, and follows serial logs.</li>
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages.</li>
</ul>
<div class="modal-actions">
<button class="btn btn-secondary" id="help-close-btn">Close</button>
</div>
@@ -262,8 +397,13 @@
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
</div>
<div class="form-group">
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
<small>STA channel (111) for LED drivers and the serial bridge. Use the same value everywhere.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save Name</button>
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
</div>
</form>
</div>
@@ -308,13 +448,91 @@
</div>
</div>
<!-- LED Tool Modal -->
<div id="led-tool-modal" class="modal">
<div class="modal-content">
<h2>LED Tool (USB)</h2>
<p class="muted-text" style="margin-top: 0;">Configure a driver connected over USB using <code>led-tool</code>.</p>
<div id="led-tool-message" class="message" style="margin-bottom: 0.75rem;"></div>
<form id="led-tool-form">
<div class="form-group">
<label for="led-tool-port">Serial port</label>
<div class="profiles-actions" style="gap: 0.5rem;">
<select id="led-tool-port" required style="flex:1;">
<option value="">Select a serial port</option>
</select>
<button type="button" class="btn btn-secondary" id="led-tool-refresh-ports-btn">Refresh</button>
</div>
</div>
<div class="form-group">
<label for="led-tool-name">Name</label>
<input type="text" id="led-tool-name" placeholder="led-abcdef123456">
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-num-leds">Num LEDs</label>
<input type="number" id="led-tool-num-leds" min="1" max="5000" placeholder="60">
</div>
<div class="preset-editor-field">
<label for="led-tool-led-pin">LED pin</label>
<input type="number" id="led-tool-led-pin" min="0" max="48" placeholder="4">
</div>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-brightness">Brightness</label>
<input type="number" id="led-tool-brightness" min="0" max="255" placeholder="255">
</div>
<div class="preset-editor-field">
<label for="led-tool-wifi-channel">WiFi channel</label>
<input type="number" id="led-tool-wifi-channel" min="1" max="11" placeholder="6">
</div>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-transport">Transport</label>
<select id="led-tool-transport">
<option value="">(no change)</option>
<option value="espnow">espnow</option>
<option value="wifi">wifi</option>
</select>
</div>
<div class="preset-editor-field">
<label for="led-tool-default">Default preset</label>
<input type="text" id="led-tool-default" placeholder="on">
</div>
</div>
<div class="form-group">
<label for="led-tool-ssid">SSID</label>
<input type="text" id="led-tool-ssid" placeholder="Your WiFi SSID">
</div>
<div class="form-group">
<label for="led-tool-password">WiFi password</label>
<input type="password" id="led-tool-password" placeholder="WiFi password">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="led-tool-read-btn">Read</button>
<button type="button" class="btn btn-secondary" id="led-tool-reset-btn">Reset</button>
<button type="submit" class="btn btn-primary">Apply via USB</button>
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
</div>
</form>
<label for="led-tool-output" style="margin-top:0.5rem; display:block;">Command output</label>
<textarea id="led-tool-output" rows="12" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
</div>
</div>
<!-- Styles moved to /static/style.css -->
<script src="/static/tabs.js"></script>
<script src="/static/zones.js"></script>
<script src="/static/help.js"></script>
<script src="/static/led_tool.js"></script>
<script src="/static/color_palette.js"></script>
<script src="/static/profiles.js"></script>
<script src="/static/tab_palette.js"></script>
<script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script>
<script src="/static/devices.js"></script>
</body>
</html>

View File

@@ -170,11 +170,26 @@
<div class="settings-header">
<h1>Device Settings</h1>
<p>Configure WiFi Access Point settings</p>
<p>Configure WiFi Access Point and ESP-NOW options</p>
</div>
<div id="message" class="message"></div>
<!-- ESP-NOW (LED driver / bridge channel) -->
<div class="settings-section">
<h2>ESP-NOW</h2>
<form id="espnow-form">
<div class="form-group">
<label for="wifi-channel-page-input">WiFi channel (ESP-NOW)</label>
<input type="number" id="wifi-channel-page-input" name="wifi_channel" min="1" max="11" required>
<small>STA channel (111) for LED drivers and the serial bridge. Use the same value on every device.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save channel</button>
</div>
</form>
</div>
<!-- WiFi Access Point Settings -->
<div class="settings-section">
<h2>WiFi Access Point Settings</h2>
@@ -222,6 +237,46 @@
}, 5000);
}
async function loadEspnowChannel() {
try {
const response = await fetch('/settings');
const data = await response.json();
const chInput = document.getElementById('wifi-channel-page-input');
if (chInput && data && typeof data === 'object') {
const ch = data.wifi_channel;
chInput.value =
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
}
} catch (error) {
console.error('Error loading ESP-NOW channel:', error);
}
}
document.getElementById('espnow-form').addEventListener('submit', async (e) => {
e.preventDefault();
const chRaw = document.getElementById('wifi-channel-page-input').value;
const wifiChannel = parseInt(chRaw, 10);
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
showMessage('WiFi channel must be between 1 and 11', 'error');
return;
}
try {
const response = await fetch('/settings/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wifi_channel: wifiChannel }),
});
const result = await response.json();
if (response.ok) {
showMessage('ESP-NOW channel saved.', 'success');
} else {
showMessage(`Error: ${result.error || 'Failed to save'}`, 'error');
}
} catch (error) {
showMessage(`Error: ${error.message}`, 'error');
}
});
// Load AP status and config
async function loadAPStatus() {
try {
@@ -299,6 +354,7 @@
});
// Load all data on page load
loadEspnowChannel();
loadAPStatus();
// Refresh status every 10 seconds

View File

@@ -1,6 +1,6 @@
# ESPNow Message Builder
# Driver message builder (`espnow_message`)
This utility module provides functions to build ESPNow messages according to the LED Driver API specification.
This utility builds **v1** JSON payloads for LED drivers (serial/ESP-NOW bridge and Wi-Fi TCP). See **`docs/API.md`** for the full wire format.
## Usage
@@ -69,7 +69,7 @@ presets = build_presets_dict(presets_data)
## API Specification
See `docs/API.md` for the complete ESPNow API specification.
See **`docs/API.md`** for REST routes, session scoping, and the compact preset keys on the wire.
## Key Features

View File

@@ -0,0 +1,52 @@
"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients."""
import json
import threading
from typing import Any, Set
# Threading lock: safe across asyncio tasks and avoids binding asyncio.Lock to the wrong loop.
_clients_lock = threading.Lock()
_clients: Set[Any] = set()
async def register_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.add(ws)
async def unregister_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.discard(ws)
async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
from models.wifi_ws_clients import normalize_tcp_peer_ip
ip = normalize_tcp_peer_ip(ip)
if not ip:
return
msg = json.dumps({"type": "device_tcp", "ip": ip, "connected": bool(connected)})
with _clients_lock:
targets = list(_clients)
dead = []
for ws in targets:
try:
await ws.send(msg)
except Exception as exc:
dead.append(ws)
print(f"[device_status_broadcaster] ws.send failed: {exc!r}")
if dead:
with _clients_lock:
for ws in dead:
_clients.discard(ws)
async def broadcast_device_tcp_snapshot_to(ws: Any) -> None:
from models import wifi_ws_clients as tcp
ips = tcp.list_connected_ips()
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})
try:
await ws.send(msg)
except Exception as exc:
print(f"[device_status_broadcaster] snapshot send failed: {exc!r}")

224
src/util/driver_delivery.py Normal file
View File

@@ -0,0 +1,224 @@
"""Deliver driver JSON messages over serial (ESP-NOW) and/or WebSocket (Wi-Fi drivers)."""
import asyncio
import json
from models.device import normalize_mac
from models.wifi_ws_clients import send_json_line_to_ip
# Serial bridge (ESP32): broadcast MAC + this envelope → firmware unicasts ``body`` to each peer.
_SPLIT_MODE = "split"
_BROADCAST_MAC_HEX = "ffffffffffff"
def _split_serial_envelope(inner_json_str, peer_hex_list):
"""One UART frame: broadcast dest + JSON {m:split, peers:[hex,...], body:<object>}."""
body = json.loads(inner_json_str)
env = {"m": _SPLIT_MODE, "peers": list(peer_hex_list), "body": body}
return json.dumps(env, separators=(",", ":"))
def _wifi_message_for_device(msg, device_name):
"""
For Wi-Fi WebSocket fanout, narrow a v1 select map to a single device name.
Returns the original message when no narrowing applies.
"""
if not device_name:
return msg
try:
body = json.loads(msg)
except Exception:
return msg
if not isinstance(body, dict):
return msg
select = body.get("select")
if not isinstance(select, dict):
return msg
if device_name not in select:
return msg
body["select"] = {device_name: select[device_name]}
return json.dumps(body, separators=(",", ":"))
def _combine_preset_chunks_for_wifi(chunk_messages):
"""Merge chunked v1 preset messages into one v1 JSON string for Wi-Fi."""
merged_presets = {}
save_flag = False
default_id = None
for msg in chunk_messages:
try:
body = json.loads(msg)
except Exception:
continue
if not isinstance(body, dict):
continue
presets = body.get("presets")
if isinstance(presets, dict):
merged_presets.update(presets)
if body.get("save"):
save_flag = True
if body.get("default") is not None:
default_id = body.get("default")
out = {"v": "1", "presets": merged_presets}
if save_flag:
out["save"] = True
if default_id is not None:
out["default"] = default_id
return json.dumps(out, separators=(",", ":"))
async def deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
target_macs,
devices_model,
default_id,
delay_s=0.1,
):
"""
Send preset definition chunks: ESP-NOW broadcast once per chunk; same chunk to each
Wi-Fi driver over WebSocket. If default_id is set, send a per-target default message
(unicast serial or WebSocket) with targets=[device name] for each registry entry.
"""
if not chunk_messages:
return 0
seen = set()
ordered = []
for raw in target_macs:
m = normalize_mac(str(raw)) if raw else None
if not m or m in seen:
continue
seen.add(m)
ordered.append(m)
wifi_ips = []
for mac in ordered:
doc = devices_model.read(mac)
if doc and doc.get("transport") == "wifi" and doc.get("address"):
wifi_ips.append(str(doc["address"]).strip())
deliveries = 0
wifi_combined_msg = _combine_preset_chunks_for_wifi(chunk_messages)
for msg in chunk_messages:
tasks = [sender.send(msg, addr=_BROADCAST_MAC_HEX)]
results = await asyncio.gather(*tasks, return_exceptions=True)
if results and results[0] is True:
deliveries += 1
await asyncio.sleep(delay_s)
for ip in wifi_ips:
if not ip:
continue
try:
if await send_json_line_to_ip(ip, wifi_combined_msg):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
await asyncio.sleep(delay_s)
if default_id:
did = str(default_id)
for mac in ordered:
doc = devices_model.read(mac) or {}
name = str(doc.get("name") or "").strip() or mac
body = {"v": "1", "default": did, "save": True, "targets": [name]}
out = json.dumps(body, separators=(",", ":"))
if doc.get("transport") == "wifi" and doc.get("address"):
ip = str(doc["address"]).strip()
try:
if await send_json_line_to_ip(ip, out):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default Wi-Fi send failed: {e!r}")
else:
try:
await sender.send(out, addr=mac)
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default serial failed: {e!r}")
await asyncio.sleep(delay_s)
return deliveries
async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1):
"""
Send each message string to the bridge and/or Wi-Fi WebSocket clients.
If target_macs is None or empty: one serial send per message (default/broadcast address).
Otherwise: Wi-Fi uses WebSocket in parallel. Multiple ESP-NOW peers are sent in **one** serial
write to the ESP32 (broadcast + split envelope); the bridge unicasts ``body`` to each
peer. A single ESP-NOW peer still uses one unicast serial frame. Wi-Fi and serial
tasks run together in one asyncio.gather.
Returns (delivery_count, chunk_count) where chunk_count is len(messages).
"""
if not messages:
return 0, 0
if not target_macs:
deliveries = 0
for msg in messages:
await sender.send(msg)
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries, len(messages)
seen = set()
ordered_macs = []
for raw in target_macs:
m = normalize_mac(str(raw)) if raw else None
if not m or m in seen:
continue
seen.add(m)
ordered_macs.append(m)
deliveries = 0
for msg in messages:
wifi_tasks = []
espnow_hex = []
for mac in ordered_macs:
doc = devices_model.read(mac)
if doc and doc.get("transport") == "wifi":
ip = doc.get("address")
if ip:
name = str(doc.get("name") or "").strip()
wifi_msg = _wifi_message_for_device(msg, name)
wifi_tasks.append(send_json_line_to_ip(ip, wifi_msg))
else:
espnow_hex.append(mac)
tasks = []
espnow_peer_count = 0
if len(espnow_hex) > 1:
tasks.append(
sender.send(
_split_serial_envelope(msg, espnow_hex),
addr=_BROADCAST_MAC_HEX,
)
)
espnow_peer_count = len(espnow_hex)
elif len(espnow_hex) == 1:
tasks.append(sender.send(msg, addr=espnow_hex[0]))
espnow_peer_count = 1
tasks.extend(wifi_tasks)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
n_serial = len(tasks) - len(wifi_tasks)
for i, r in enumerate(results):
if i < n_serial:
if r is True:
deliveries += espnow_peer_count
elif isinstance(r, Exception):
print(f"[driver_delivery] serial delivery failed: {r!r}")
else:
if r is True:
deliveries += 1
elif isinstance(r, Exception):
print(f"[driver_delivery] Wi-Fi delivery failed: {r!r}")
await asyncio.sleep(delay_s)
return deliveries, len(messages)

View File

@@ -0,0 +1,53 @@
import os
_ENV_PATTERNS_DIR = "LED_CONTROLLER_PATTERNS_DIR"
def driver_patterns_dir():
"""Absolute path to driver pattern ``.py`` modules.
If ``LED_CONTROLLER_PATTERNS_DIR`` is set to an existing directory, that wins
(for installs where ``led-driver`` is not next to this repo). Otherwise uses
``<project-root>/led-driver/src/patterns``.
"""
env = (os.environ.get(_ENV_PATTERNS_DIR) or "").strip()
if env and os.path.isdir(env):
return os.path.abspath(env)
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.abspath(os.path.join(here, "..", ".."))
return os.path.join(root, "led-driver", "src", "patterns")
def normalize_pattern_py_filename(name):
"""Return a single ``*.py`` basename (no paths), or ``\"\"`` if invalid.
Strips repeated ``.py`` suffixes so ``blink.py.py`` becomes ``blink.py``.
"""
if not isinstance(name, str):
return ""
s = name.strip()
if not s:
return ""
lower = s.lower()
while lower.endswith(".py"):
s = s[:-3]
s = s.strip()
lower = s.lower()
if not s:
return ""
if "/" in s or "\\" in s or ".." in s:
return ""
return s + ".py"
# Implemented in led-driver ``presets.py`` only — no separate ``patterns/*.py``.
FIRMWARE_BUILTIN_PATTERN_IDS = frozenset({"on", "off"})
def is_firmware_builtin_pattern_module(name):
"""True for ``on`` / ``off``, with or without a ``.py`` suffix."""
if not isinstance(name, str):
return False
s = name.strip().lower()
while s.endswith(".py"):
s = s[:-3].strip()
return s in FIRMWARE_BUILTIN_PATTERN_IDS

View File

@@ -1,79 +1,47 @@
# Tests
This directory contains tests for the LED Controller project.
Tests for the LED Controller project live under **`tests/`** (pytest + legacy scripts).
## Directory Structure
## Layout
- `test_endpoints.py` - HTTP endpoint tests that mimic web browser requests (runs against 192.168.4.1)
- `test_ws.py` - WebSocket tests
- `test_p2p.py` - ESP-NOW P2P tests
- `models/` - Model unit tests
- `web.py` - Local development web server
| Path | Role |
|------|------|
| `test_endpoints.py` | HTTP endpoint checks (**`LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS=1`**); **`test_zones`** / **`test_zone_edit_workflow`** hit **`/zones`** |
| `test_endpoints_pytest.py` | Pytest-style endpoint coverage |
| `test_browser.py` | Selenium UI flows (set **`LED_CONTROLLER_RUN_BROWSER_TESTS=1`** to run; uses **`test_zones_ui`** and legacy **`tabsManager`** JS aliases) |
| `test_pattern_ota_send.py` | Pattern OTA / Wi-Fi send helpers |
| `tcp_test_server.py`, `async_tcp_server.py` | TCP test doubles for driver protocol |
| `udp_server.py` | UDP discovery / hello test listener (port **8766**) |
| `ws.py` | WebSocket client checks |
| `p2p.py` | ESP-NOWrelated helpers / experiments |
| `web.py` | Local dev static server (not the main app) |
| `conftest.py` | Pytest fixtures |
| `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) |
## Running Tests
## Running tests
### Browser Tests (Real Browser Automation)
### Pytest (recommended)
Tests the web interface in an actual browser using Selenium:
From the project root (with dev dependencies installed):
```bash
pipenv run pytest tests/ -q
```
### Browser tests (real browser)
```bash
python tests/test_browser.py
```
These tests:
- Open a real Chrome browser
- Navigate to the device at 192.168.4.1
- Interact with UI elements (buttons, forms, modals)
- Test complete user workflows
- Verify visual elements and interactions
Requires **Selenium**, Chrome/Chromium, and a matching **ChromeDriver**.
**Requirements:**
```bash
pip install selenium
# Also need ChromeDriver installed and in PATH
# Download from: https://chromedriver.chromium.org/
```
### Endpoint Tests (Browser-like HTTP)
Tests HTTP endpoints by making requests to the device at 192.168.4.1:
```bash
python tests/test_endpoints.py
```
These tests:
- Mimic web browser requests with proper headers
- Handle cookies for session management
- Test all CRUD operations (GET, POST, PUT, DELETE)
- Verify responses and status codes
**Requirements:**
```bash
pip install requests
```
### WebSocket Tests
```bash
python tests/test_ws.py
```
**Requirements:**
```bash
pip install websockets
```
### Model Tests
### Model tests only
```bash
python tests/models/run_all.py
```
### Local Development Server
### Local static server
Run the local development server (port 5000):
```bash
python tests/web.py
```
`tests/web.py` serves files for quick UI experiments; it is **not** the Microdot app. For the real server use **`pipenv run run`** from the repo root.

182
tests/async_tcp_server.py Normal file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
# Standalone async TCP server (stdlib only). Multiple simultaneous clients.
# No watchdog: runs on a full host (e.g. Raspberry Pi); ESP32 clients may use WDT.
# For RTT latency, clients may send lines like ``rtt 12345`` (ticks); they are echoed back.
#
# Run from anywhere (default: all IPv4 interfaces, port 9000):
# python3 async_tcp_server.py
# python3 async_tcp_server.py --port 9000
# Localhost only:
# python3 async_tcp_server.py --host 127.0.0.1
#
# Or from this directory:
# chmod +x async_tcp_server.py && ./async_tcp_server.py
import argparse
import asyncio
import time
class _ClientRegistry:
"""Track writers and broadcast newline-terminated lines to all clients."""
def __init__(self) -> None:
self._writers: set[asyncio.StreamWriter] = set()
def add(self, writer: asyncio.StreamWriter) -> None:
self._writers.add(writer)
def remove(self, writer: asyncio.StreamWriter) -> None:
self._writers.discard(writer)
def count(self) -> int:
return len(self._writers)
async def broadcast_line(self, line: str) -> None:
data = (line.rstrip("\r\n") + "\n").encode("utf-8")
for writer in list(self._writers):
try:
writer.write(data)
await writer.drain()
except Exception as e:
print(f"[tcp] broadcast failed, dropping client: {e}")
self._writers.discard(writer)
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def _periodic_broadcast(
registry: _ClientRegistry,
interval_sec: float,
message: str,
) -> None:
while True:
await asyncio.sleep(interval_sec)
if registry.count() == 0:
continue
line = message.format(t=time.time())
print(f"[tcp] broadcast to {registry.count()} client(s): {line!r}")
await registry.broadcast_line(line)
async def _handle_client(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
registry: _ClientRegistry,
) -> None:
peer = writer.get_extra_info("peername")
print(f"[tcp] connected: {peer}")
registry.add(writer)
try:
while not reader.at_eof():
data = await reader.readline()
if not data:
break
message = data.decode("utf-8", errors="replace").rstrip("\r\n")
# Echo newline-delimited lines (simple test harness behaviour).
# Clients may send ``rtt <ticks>`` for round-trip timing; echo unchanged.
t0 = time.perf_counter()
writer.write((message + "\n").encode("utf-8"))
await writer.drain()
if message.startswith("rtt "):
server_ms = (time.perf_counter() - t0) * 1000.0
print(
f"[tcp] echoed rtt from {peer} "
f"(host write+drain ~{server_ms:.2f} ms)"
)
finally:
registry.remove(writer)
writer.close()
await writer.wait_closed()
print(f"[tcp] disconnected: {peer}")
def _make_client_handler(registry: _ClientRegistry):
async def _handler(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
await _handle_client(reader, writer, registry)
return _handler
async def _run(
host: str,
port: int,
broadcast_interval: float | None,
broadcast_message: str,
) -> None:
registry = _ClientRegistry()
handler = _make_client_handler(registry)
server = await asyncio.start_server(handler, host, port)
print(f"[tcp] listening on {host}:{port} (Ctrl+C to stop)")
if broadcast_interval is not None and broadcast_interval > 0:
print(
f"[tcp] periodic broadcast every {broadcast_interval}s "
f"(use {{t}} in --message for unix time)"
)
async with server:
tasks = []
if broadcast_interval is not None and broadcast_interval > 0:
tasks.append(
asyncio.create_task(
_periodic_broadcast(registry, broadcast_interval, broadcast_message),
name="broadcast",
)
)
try:
if tasks:
await asyncio.gather(server.serve_forever(), *tasks)
else:
await server.serve_forever()
finally:
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
def main() -> None:
parser = argparse.ArgumentParser(
description="Standalone asyncio TCP server (multiple connections).",
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="bind address (default: all IPv4 interfaces)",
)
parser.add_argument("--port", type=int, default=9000, help="bind port")
parser.add_argument(
"--interval",
type=float,
default=5.0,
metavar="SEC",
help="seconds between broadcast lines to all clients (default: 5)",
)
parser.add_argument(
"--message",
default="ping {t:.0f}",
help='broadcast line (newline added); use "{t}" for time.time() (default: %(default)s)',
)
parser.add_argument(
"--no-broadcast",
action="store_true",
help="disable periodic broadcast (echo-only)",
)
args = parser.parse_args()
interval = None if args.no_broadcast else args.interval
try:
asyncio.run(_run(args.host, args.port, interval, args.message))
except KeyboardInterrupt:
print("\n[tcp] stopped")
if __name__ == "__main__":
main()

14
tests/conftest.py Normal file
View File

@@ -0,0 +1,14 @@
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
LIB_PATH = PROJECT_ROOT / "lib"
# Last insert(0) wins: order must be (root, lib, src) so src/models wins over
# tests/models (same package name "models" on sys.path when pytest imports tests).
for p in (str(PROJECT_ROOT), str(LIB_PATH), str(SRC_PATH)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)

300
tests/device_ws_cycle.py Normal file
View File

@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Discover a WiFi LED driver via UDP hello, then drive it over WebSocket.
1. Listens on UDP (default port 8766) for the same JSON line the firmware sends
(``v``, ``device_name``, ``mac``, ``type``: ``led``).
2. Opens ``ws://<device-ip>:<port>/ws``.
3. Pushes a few test presets (``v``: ``"1"``) and cycles ``select`` for the
reported ``device_name``.
The firmware sends UDP hello about one second **after** HTTP is listening, so
this script retries the WebSocket handshake by default.
The device ``settings.json`` ``name`` must match ``device_name`` in the hello
(and in each ``select`` map).
Examples::
pipenv install --dev
pipenv run python tests/device_ws_cycle.py
pipenv run python tests/device_ws_cycle.py --timeout 60 --cycle-s 4
# Skip UDP; connect directly (set ``--device-name`` to the device's ``name``)::
pipenv run python tests/device_ws_cycle.py --host 192.168.1.42 --device-name a
"""
from __future__ import annotations
import argparse
import asyncio
import json
import socket
import sys
def _parse_hello_line(data: bytes) -> tuple[dict | None, bytes]:
line = data.split(b"\n", 1)[0].strip()
if not line:
return None, line
try:
obj = json.loads(line.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return None, line
if not isinstance(obj, dict):
return None, line
return obj, line
def wait_for_udp_hello(
bind: str,
port: int,
timeout_s: float,
echo: bool,
) -> tuple[str, str, dict]:
"""Block until a valid hello arrives. Returns (device_ip, device_name, hello_dict)."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
except OSError:
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except OSError:
pass
sock.bind((bind, port))
sock.settimeout(timeout_s)
print(
f"UDP listening on {bind}:{port} (timeout {timeout_s}s) — "
"power the device or wait for hello…",
)
while True:
try:
data, addr = sock.recvfrom(2048)
except socket.timeout as e:
raise SystemExit(f"No UDP hello before timeout: {e}") from e
peer_ip = addr[0]
parsed, raw_line = _parse_hello_line(data)
if parsed is None:
print(f"Ignored datagram from {peer_ip!r}: {raw_line!r}")
continue
if str(parsed.get("v") or "") != "1":
print(f"Ignored v={parsed.get('v')!r} from {peer_ip!r}")
continue
dev_type = parsed.get("type") or parsed.get("device_type")
if dev_type is not None and dev_type != "led":
print(f"Ignored type={dev_type!r} from {peer_ip!r}")
continue
name = str(parsed.get("device_name") or "").strip()
mac = parsed.get("mac")
if not name or not mac:
print(
f"Ignored hello without device_name/mac from {peer_ip!r}: {parsed!r}",
)
continue
print(
f"Heard hello: ip={peer_ip!r} device_name={name!r} mac={mac!r}",
)
if echo:
try:
sock.sendto(data, addr)
except OSError as e:
print(f"UDP echo to {addr} failed: {e!r}")
return peer_ip, name, parsed
finally:
try:
sock.close()
except OSError:
pass
PRESETS = {
"_test_on": {"p": "on", "c": [(0, 80, 200)]},
"_test_blink": {"p": "blink", "d": 120, "b": 200, "c": [(255, 40, 0), (0, 40, 255)]},
"_test_rainbow": {"p": "rainbow", "d": 12, "n1": 2, "a": True},
}
PRESET_ORDER = ["_test_on", "_test_blink", "_test_rainbow"]
async def cycle_presets(
host: str,
device_name: str,
ws_port: int,
ws_path: str,
cycle_s: float,
passes: int,
*,
ws_open_timeout_s: float,
ws_connect_retries: int,
ws_connect_retry_delay_s: float,
) -> None:
try:
import websockets
except ImportError as e:
raise SystemExit(
"Install websockets: pipenv install websockets (or: pip install websockets)"
) from e
path = ws_path if ws_path.startswith("/") else "/" + ws_path
uri = f"ws://{host}:{ws_port}{path}"
print(f"WebSocket connect {uri!r}")
n = max(1, ws_connect_retries)
last_err: BaseException | None = None
for attempt in range(n):
try:
async with websockets.connect(
uri,
open_timeout=ws_open_timeout_s,
) as ws:
print("Connected.")
push = json.dumps({"v": "1", "presets": PRESETS})
await ws.send(push)
print(f"Sent presets: {list(PRESETS.keys())}")
await asyncio.sleep(0.25)
for p in range(passes):
print(f"--- pass {p + 1}/{passes} ---")
for pname in PRESET_ORDER:
sel = json.dumps({"v": "1", "select": {device_name: [pname]}})
await ws.send(sel)
print(f" select {pname!r}")
await asyncio.sleep(cycle_s)
print("Done.")
return
except (TimeoutError, OSError, ConnectionError) as e:
last_err = e
if attempt + 1 < n:
print(
f" connect failed ({e!r}), retry {attempt + 2}/{n} in "
f"{ws_connect_retry_delay_s}s …",
)
await asyncio.sleep(ws_connect_retry_delay_s)
raise TimeoutError(
f"WebSocket handshake failed after {n} attempts: {last_err!r}",
) from last_err
def main() -> int:
parser = argparse.ArgumentParser(
description="UDP hello discovery + WebSocket preset cycle (led-driver)",
)
parser.add_argument(
"--bind",
default="0.0.0.0",
help="UDP bind address (default 0.0.0.0)",
)
parser.add_argument(
"-p",
"--udp-port",
type=int,
default=8766,
help="UDP listen port (default 8766)",
)
parser.add_argument(
"--timeout",
type=float,
default=120.0,
help="Seconds to wait for first hello (default 120)",
)
parser.add_argument(
"--no-echo",
action="store_true",
help="Do not echo the datagram back (firmware often uses wait_reply=False)",
)
parser.add_argument(
"--host",
default="",
metavar="IP",
help="Skip UDP and use this device IP",
)
parser.add_argument(
"--device-name",
default="",
metavar="NAME",
help="Device settings name for select map (required with --host if not default)",
)
parser.add_argument(
"--ws-port",
type=int,
default=80,
help="Device WebSocket port (default 80)",
)
parser.add_argument(
"--ws-path",
default="/ws",
help="WebSocket path (default /ws)",
)
parser.add_argument(
"--cycle-s",
type=float,
default=3.0,
help="Seconds between select commands (default 3)",
)
parser.add_argument(
"--passes",
type=int,
default=2,
help="How many full cycles through all test presets (default 2)",
)
parser.add_argument(
"--ws-open-timeout",
type=float,
default=30.0,
help="Per-attempt WebSocket handshake timeout in seconds (default 30)",
)
parser.add_argument(
"--ws-retries",
type=int,
default=15,
help="WebSocket connect attempts (default 15; use with device hello after HTTP)",
)
parser.add_argument(
"--ws-retry-delay",
type=float,
default=1.0,
help="Seconds between WebSocket retries (default 1)",
)
args = parser.parse_args()
if args.host:
host = args.host.strip()
device_name = (args.device_name or "a").strip()
if not device_name:
print("--device-name is required when using a generic --host", file=sys.stderr)
return 1
print(f"Using host {host!r} device_name {device_name!r} (no UDP)")
else:
host, device_name, _hello = wait_for_udp_hello(
args.bind,
args.udp_port,
args.timeout,
echo=not args.no_echo,
)
try:
asyncio.run(
cycle_presets(
host=host,
device_name=device_name,
ws_port=args.ws_port,
ws_path=args.ws_path,
cycle_s=args.cycle_s,
passes=max(1, args.passes),
ws_open_timeout_s=args.ws_open_timeout,
ws_connect_retries=args.ws_retries,
ws_connect_retry_delay_s=args.ws_retry_delay,
)
)
except KeyboardInterrupt:
print("\nInterrupted.")
return 130
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -10,7 +10,7 @@ from test_preset import test_preset
from test_profile import test_profile
from test_group import test_group
from test_sequence import test_sequence
from test_tab import test_tab
from test_zone import test_zone
from test_palette import test_palette
from test_device import test_device
@@ -26,7 +26,7 @@ def run_all_tests():
("Profile", test_profile),
("Group", test_group),
("Sequence", test_sequence),
("Tab", test_tab),
("Zone", test_zone),
("Palette", test_palette),
("Device", test_device),
]

View File

@@ -1,57 +1,88 @@
from models.device import Device
import os
import sys
from pathlib import Path
def test_device():
"""Test Device model CRUD operations."""
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
# Prefer src/models; pytest may have registered tests/models as top-level ``models``.
_src = Path(__file__).resolve().parents[2] / "src"
_sp = str(_src)
if _sp in sys.path:
sys.path.remove(_sp)
sys.path.insert(0, _sp)
_m = sys.modules.get("models")
if _m is not None:
mf = (getattr(_m, "__file__", "") or "").replace("\\", "/")
if "/tests/models" in mf:
del sys.modules["models"]
from models.device import Device
def _fresh_device():
"""New empty device DB and new Device singleton (tests only)."""
db_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db"
)
device_file = os.path.join(db_dir, "device.json")
if os.path.exists(device_file):
os.remove(device_file)
if hasattr(Device, "_instance"):
del Device._instance
return Device()
devices = Device()
def test_device():
"""Test Device model CRUD operations (id = MAC)."""
devices = _fresh_device()
mac = "aabbccddeeff"
print("Testing create device")
device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"])
device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", zones=["1", "2"])
print(f"Created device with ID: {device_id}")
assert device_id is not None
assert device_id == mac
assert device_id in devices
print("\nTesting read device")
device = devices.read(device_id)
print(f"Read: {device}")
assert device is not None
assert device["id"] == mac
assert device["name"] == "Test Device"
assert device["address"] == "aabbccddeeff"
assert device["type"] == "led"
assert device["transport"] == "espnow"
assert device["address"] == mac
assert device["default_pattern"] == "on"
assert device["tabs"] == ["1", "2"]
assert device["zones"] == ["1", "2"]
print("\nTesting address normalization")
print("\nTesting read by colon MAC")
assert devices.read("aa:bb:cc:dd:ee:ff")["id"] == mac
print("\nTesting address normalization on update (espnow keeps MAC as address)")
devices.update(device_id, {"address": "11:22:33:44:55:66"})
updated = devices.read(device_id)
assert updated["address"] == "112233445566"
assert updated["address"] == mac
print("\nTesting update device")
print("\nTesting update device fields")
update_data = {
"name": "Updated Device",
"default_pattern": "rainbow",
"tabs": ["1", "2", "3"],
"zones": ["1", "2", "3"],
}
result = devices.update(device_id, update_data)
assert result is True
updated = devices.read(device_id)
assert updated["name"] == "Updated Device"
assert updated["default_pattern"] == "rainbow"
assert len(updated["tabs"]) == 3
assert len(updated["zones"]) == 3
print("\nTesting list devices")
device_list = devices.list()
print(f"Device list: {device_list}")
assert device_id in device_list
assert mac in device_list
print("\nTesting delete device")
deleted = devices.delete(device_id)
assert deleted is True
assert device_id not in devices
assert mac not in devices
print("\nTesting read after delete")
device = devices.read(device_id)
@@ -60,5 +91,78 @@ def test_device():
print("\nAll device tests passed!")
def test_upsert_wifi_tcp_client():
devices = _fresh_device()
assert devices.upsert_wifi_tcp_client("", "192.168.1.10", None) == (None, False)
assert devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", "bad") == (
None,
False,
)
m1 = "001122334455"
m2 = "001122334466"
i1, p1 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1)
assert i1 == m1 and p1 is True
d = devices.read(i1)
assert d["name"] == "kitchen"
assert d["type"] == "led"
assert d["transport"] == "wifi"
assert d["address"] == "192.168.1.20"
noop_mac, noop_p = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1)
assert noop_mac == m1 and noop_p is False
i2, p2 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.21", m2)
assert i2 == m2 and p2 is True
assert devices.read(m1)["address"] == "192.168.1.20"
assert devices.read(m2)["address"] == "192.168.1.21"
assert devices.read(m1)["name"] == devices.read(m2)["name"] == "kitchen"
again, p_again = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.99", m1)
assert again == m1 and p_again is True
assert devices.read(m1)["address"] == "192.168.1.99"
bogus_mac, bogus_p = devices.upsert_wifi_tcp_client(
"kitchen", "192.168.1.100", m1, device_type="bogus"
)
assert bogus_mac == m1 and bogus_p is True
assert devices.read(m1)["type"] == "led"
i3, p3 = devices.upsert_wifi_tcp_client("hall", "10.0.0.5", "deadbeefcafe")
assert i3 == "deadbeefcafe" and p3 is True
assert len(devices.list()) == 3
def test_device_can_change_address():
devices = _fresh_device()
m = "feedfacec0de"
did = devices.create("mover", mac=m, address="192.168.1.1", transport="wifi")
assert did == m
devices.update(did, {"address": "10.0.0.99"})
assert devices.read(did)["address"] == "10.0.0.99"
def test_device_duplicate_names_allowed():
devices = _fresh_device()
a1 = devices.create("alpha", address="aa:bb:cc:dd:ee:ff")
a2 = devices.create("alpha", address="11:22:33:44:55:66")
assert a1 != a2
assert devices.read(a1)["name"] == devices.read(a2)["name"] == "alpha"
def test_device_duplicate_mac_rejected():
devices = _fresh_device()
devices.create("one", address="aa:bb:cc:dd:ee:ff")
try:
devices.create("two", address="aa-bb-cc-dd-ee-ff")
assert False, "expected ValueError"
except ValueError as e:
assert "already exists" in str(e).lower()
if __name__ == "__main__":
test_device()
test_upsert_wifi_tcp_client()
test_device_can_change_address()
test_device_duplicate_names_allowed()
test_device_duplicate_mac_rejected()

View File

@@ -3,7 +3,7 @@ import os
def test_profile():
"""Test Profile model CRUD operations.
Profile create() sets name, type, tabs (list of tab IDs), scenes, palette_id.
Profile create() sets name, type, zones (list of zone IDs), scenes, palette_id.
"""
# Clean up any existing test file (model uses db/profile.json from project root)
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
@@ -24,20 +24,20 @@ def test_profile():
print(f"Read: {profile}")
assert profile is not None
assert profile["name"] == "test_profile"
assert "tabs" in profile
assert "zones" in profile
assert "palette_id" in profile
assert "type" in profile
print("\nTesting update profile")
update_data = {
"name": "updated_profile",
"tabs": ["tab1"],
"zones": ["tab1"],
}
result = profiles.update(profile_id, update_data)
assert result is True
updated = profiles.read(profile_id)
assert updated["name"] == "updated_profile"
assert "tab1" in updated["tabs"]
assert "tab1" in updated["zones"]
print("\nTesting list profiles")
profile_list = profiles.list()

View File

@@ -1,57 +0,0 @@
from models.tab import Tab
import os
def test_tab():
"""Test Tab model CRUD operations."""
# Clean up any existing test file
if os.path.exists("Tab.json"):
os.remove("Tab.json")
tabs = Tab()
print("Testing create tab")
tab_id = tabs.create("test_tab", ["1", "2", "3"], ["preset1", "preset2"])
print(f"Created tab with ID: {tab_id}")
assert tab_id is not None
assert tab_id in tabs
print("\nTesting read tab")
tab = tabs.read(tab_id)
print(f"Read: {tab}")
assert tab is not None
assert tab["name"] == "test_tab"
assert len(tab["names"]) == 3
assert len(tab["presets"]) == 2
print("\nTesting update tab")
update_data = {
"name": "updated_tab",
"names": ["4", "5"],
"presets": ["preset3"]
}
result = tabs.update(tab_id, update_data)
assert result is True
updated = tabs.read(tab_id)
assert updated["name"] == "updated_tab"
assert len(updated["names"]) == 2
assert len(updated["presets"]) == 1
print("\nTesting list tabs")
tab_list = tabs.list()
print(f"Tab list: {tab_list}")
assert tab_id in tab_list
print("\nTesting delete tab")
deleted = tabs.delete(tab_id)
assert deleted is True
assert tab_id not in tabs
print("\nTesting read after delete")
tab = tabs.read(tab_id)
assert tab is None
print("\nAll tab tests passed!")
if __name__ == '__main__':
test_tab()

57
tests/models/test_zone.py Normal file
View File

@@ -0,0 +1,57 @@
from models.zone import Zone
import os
def test_zone():
"""Test Zone model CRUD operations."""
if os.path.exists("Zone.json"):
os.remove("Zone.json")
zones = Zone()
print("Testing create zone")
zone_id = zones.create("test_zone", ["1", "2", "3"], ["preset1", "preset2"])
print(f"Created zone with ID: {zone_id}")
assert zone_id is not None
assert zone_id in zones
print("\nTesting read zone")
zone = zones.read(zone_id)
print(f"Read: {zone}")
assert zone is not None
assert zone["name"] == "test_zone"
assert len(zone["names"]) == 3
assert len(zone["presets"]) == 2
print("\nTesting update zone")
update_data = {
"name": "updated_zone",
"names": ["4", "5"],
"presets": ["preset3"],
}
result = zones.update(zone_id, update_data)
assert result is True
updated = zones.read(zone_id)
assert updated["name"] == "updated_zone"
assert len(updated["names"]) == 2
assert len(updated["presets"]) == 1
print("\nTesting list zones")
zone_list = zones.list()
print(f"Zone list: {zone_list}")
assert zone_id in zone_list
print("\nTesting delete zone")
deleted = zones.delete(zone_id)
assert deleted is True
assert zone_id not in zones
print("\nTesting read after delete")
zone = zones.read(zone_id)
assert zone is None
print("\nAll zone tests passed!")
if __name__ == "__main__":
test_zone()

216
tests/tcp_test_server.py Normal file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""
Simple TCP test server for led-controller.
Listens on the same TCP port used by led-driver WiFi transport and
every 5 seconds sends a newline-delimited JSON message with v="1".
Clients talking to the real Pi registry should send a first line JSON object
that includes device_name, mac (12 hex), and type (e.g. led) so the controller
can register the device by MAC.
"""
import asyncio
import contextlib
import json
import os
import sys
from typing import Dict, Set
CLIENTS: Set[asyncio.StreamWriter] = set()
# Map each client writer to the device_name it reported.
CLIENT_DEVICE: Dict[asyncio.StreamWriter, str] = {}
async def _send_off_to_all():
"""Best-effort send an 'off' message to all connected devices."""
if not CLIENTS:
return
print("[TCP TEST] Sending 'off' to all clients before shutdown")
dead = []
for w in CLIENTS:
device_name = CLIENT_DEVICE.get(w)
if not device_name:
continue
payload = {
"v": "1",
"select": {device_name: ["off"]},
}
line = json.dumps(payload) + "\n"
data = line.encode("utf-8")
try:
w.write(data)
await w.drain()
except Exception as e:
peer = w.get_extra_info("peername")
print(f"[TCP TEST] Error sending 'off' to {peer}: {e}")
dead.append(w)
for w in dead:
CLIENTS.discard(w)
CLIENT_DEVICE.pop(w, None)
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
peer = writer.get_extra_info("peername")
print(f"[TCP TEST] Client connected: {peer}")
CLIENTS.add(writer)
buf = b""
try:
# Wait for client to send its device_name JSON, then send presets once.
sent_presets = False
while True:
data = await reader.read(100)
if not data:
break
buf += data
print(f"[TCP TEST] From client {peer}: {data!r}")
# Handle newline-delimited JSON from client.
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
line = line.strip()
if not line:
continue
try:
msg = json.loads(line.decode("utf-8"))
except Exception:
continue
if isinstance(msg, dict) and "device_name" in msg:
device_name = str(msg.get("device_name") or "")
CLIENT_DEVICE[writer] = device_name
print(f"[TCP TEST] Registered device_name {device_name!r} for {peer}")
if not sent_presets and device_name:
hello_payload = {
"v": "1",
"presets": {
"solid_red": {
"p": "on",
"c": ["#ff0000"],
"d": 100,
},
"solid_blue": {
"p": "on",
"c": ["#0000ff"],
"d": 100,
},
},
"select": {
device_name: ["solid_red"],
},
"b": 32,
}
try:
writer.write((json.dumps(hello_payload) + "\n").encode("utf-8"))
await writer.drain()
sent_presets = True
print(
f"[TCP TEST] Sent initial presets/select for device "
f"{device_name!r} to {peer}"
)
except Exception as e:
print(f"[TCP TEST] Failed to send initial presets/select to {peer}: {e}")
except Exception as e:
print(f"[TCP TEST] Client error: {peer} {e}")
finally:
print(f"[TCP TEST] Client disconnected: {peer}")
CLIENTS.discard(writer)
CLIENT_DEVICE.pop(writer, None)
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def broadcaster(port: int):
"""Broadcast preset selection / brightness changes every 5 seconds."""
counter = 0
while True:
await asyncio.sleep(5)
counter += 1
# Toggle between two presets and brightness levels.
if CLIENTS:
print(f"[TCP TEST] Broadcasting to {len(CLIENTS)} client(s)")
dead = []
for w in CLIENTS:
device_name = CLIENT_DEVICE.get(w)
if not device_name:
continue
if counter % 2 == 0:
preset_name = "solid_red"
payload = {
"v": "1",
"select": {device_name: [preset_name]},
}
else:
preset_name = "solid_blue"
payload = {
"v": "1",
"select": {device_name: [preset_name]},
}
line = json.dumps(payload) + "\n"
data = line.encode("utf-8")
try:
w.write(data)
await w.drain()
peer = w.get_extra_info("peername")
print(
f"[TCP TEST] Sent preset {preset_name!r} to device {device_name!r} "
f"for client {peer}"
)
except Exception as e:
peer = w.get_extra_info("peername")
print(f"[TCP TEST] Error writing to {peer}: {e}")
dead.append(w)
for w in dead:
CLIENTS.discard(w)
CLIENT_DEVICE.pop(w, None)
async def main():
port = int(os.environ.get("PORT", os.environ.get("TCP_PORT", "8765")))
host = "0.0.0.0"
print(f"[TCP TEST] Starting TCP test server on {host}:{port}")
try:
server = await asyncio.start_server(handle_client, host=host, port=port)
except OSError as e:
if e.errno == 98: # EADDRINUSE
print(
f"[TCP TEST] Port {port} is already in use.\n"
f" If led-controller.service is enabled, it binds this port for ESP TCP "
f"transport after boot. Stop it for a standalone mock:\n"
f" sudo systemctl stop led-controller\n"
f" Or keep the main app and use another port for this mock:\n"
f" TCP_PORT=8766 pipenv run tcp-test\n"
f" (point test clients at that port). See also: sudo ss -tlnp | grep {port}",
file=sys.stderr,
)
raise
async with server:
broadcaster_task = asyncio.create_task(broadcaster(port))
try:
await server.serve_forever()
finally:
# On shutdown, try to turn all connected devices off.
await _send_off_to_all()
broadcaster_task.cancel()
with contextlib.suppress(Exception):
await broadcaster_task
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n[TCP TEST] Shutting down.")

View File

@@ -1,13 +1,37 @@
#!/usr/bin/env python3
"""
Browser automation tests using Selenium.
Tests run against the device at 192.168.4.1 in an actual browser.
Tests run against the device in an actual browser. Target host defaults to
``192.168.4.1``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname,
or a full ``http://`` / ``https://`` base URL).
Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE``
(default ``0.5``, i.e. half the nominal pause). Set to ``1`` for the old pacing,
or ``0`` to skip fixed sleeps (may flake). Driver implicit wait defaults to
``2`` seconds; override with ``LED_CONTROLLER_BROWSER_IMPLICIT_WAIT``.
On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium
and chromedriver are installed (e.g. chromium-browser chromium-chromedriver).
"""
import os
import sys
import pytest
if os.environ.get("LED_CONTROLLER_RUN_BROWSER_TESTS") != "1":
# pytest catches Skipped; plain `python tests/test_browser.py` does not.
if __name__ == "__main__":
print(
"Browser tests are disabled by default. "
"Set LED_CONTROLLER_RUN_BROWSER_TESTS=1 to run.",
file=sys.stderr,
)
raise SystemExit(0)
pytest.skip(
"Legacy device browser automation script; enable explicitly to run.",
allow_module_level=True,
)
import time
import requests
from typing import Optional, List
@@ -19,10 +43,46 @@ from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.common.action_chains import ActionChains
from selenium.common.exceptions import TimeoutException, NoSuchElementException
from selenium.common.exceptions import (
TimeoutException,
NoSuchElementException,
ElementNotInteractableException,
)
_DEFAULT_DEVICE_HOST = "192.168.4.1"
def _device_base_url() -> str:
raw = os.environ.get("LED_CONTROLLER_DEVICE_IP", _DEFAULT_DEVICE_HOST).strip()
if not raw:
raw = _DEFAULT_DEVICE_HOST
if raw.startswith(("http://", "https://")):
return raw.rstrip("/")
return f"http://{raw}"
# Base URL for the device
BASE_URL = "http://192.168.4.1"
BASE_URL = _device_base_url()
def _browser_sleep(seconds: float) -> None:
"""Scale fixed UI pauses via LED_CONTROLLER_BROWSER_SLEEP_SCALE (default 0.5)."""
try:
scale = float(os.environ.get("LED_CONTROLLER_BROWSER_SLEEP_SCALE", "0.5"))
except ValueError:
scale = 0.5
if scale <= 0:
return
time.sleep(max(0.0, float(seconds)) * scale)
def _implicit_wait_s() -> int:
try:
v = float(os.environ.get("LED_CONTROLLER_BROWSER_IMPLICIT_WAIT", "2"))
except ValueError:
v = 2.0
return int(max(0, min(60, round(v))))
class BrowserTest:
"""Browser automation test class."""
@@ -31,7 +91,7 @@ class BrowserTest:
self.base_url = base_url
self.driver = None
self.headless = headless
self.created_tabs: List[str] = []
self.created_zones: List[str] = []
self.created_profiles: List[str] = []
self.created_presets: List[str] = []
@@ -48,7 +108,7 @@ class BrowserTest:
opts.add_argument('--disable-gpu')
opts.add_argument('--window-size=1920,1080')
self.driver = webdriver.Chrome(options=opts)
self.driver.implicitly_wait(5)
self.driver.implicitly_wait(_implicit_wait_s())
print("✓ Browser started (Chrome)")
return True
except Exception as e:
@@ -59,7 +119,7 @@ class BrowserTest:
if self.headless:
opts.add_argument('--headless')
self.driver = webdriver.Firefox(options=opts)
self.driver.implicitly_wait(5)
self.driver.implicitly_wait(_implicit_wait_s())
print("✓ Browser started (Firefox)")
return True
except Exception as e:
@@ -83,7 +143,7 @@ class BrowserTest:
url = f"{self.base_url}{path}"
try:
self.driver.get(url)
time.sleep(1) # Wait for page load
_browser_sleep(1) # Wait for page load
return True
except Exception as e:
print(f"✗ Failed to navigate to {url}: {e}")
@@ -99,11 +159,18 @@ class BrowserTest:
except TimeoutException:
return None
def _scroll_into_view(self, element) -> None:
self.driver.execute_script(
"arguments[0].scrollIntoView({block: 'center', inline: 'nearest'});",
element,
)
def click_element(self, by, value, timeout=10, use_js=False):
"""Click an element."""
try:
element = self.wait_for_element(by, value, timeout)
if element:
self._scroll_into_view(element)
if use_js:
# Use JavaScript click for elements that might be intercepted
self.driver.execute_script("arguments[0].click();", element)
@@ -113,7 +180,7 @@ class BrowserTest:
element.click()
except Exception:
self.driver.execute_script("arguments[0].click();", element)
time.sleep(0.5) # Wait for action
_browser_sleep(0.5) # Wait for action
return True
return False
except Exception as e:
@@ -128,7 +195,7 @@ class BrowserTest:
alert.accept()
else:
alert.dismiss()
time.sleep(0.3)
_browser_sleep(0.3)
return True
except TimeoutException:
return False
@@ -152,14 +219,14 @@ class BrowserTest:
except Exception as e:
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
# Delete created tabs by ID
for tab_id in self.created_tabs:
# Delete created zones by ID
for zone_id in self.created_zones:
try:
response = session.delete(f"{self.base_url}/tabs/{tab_id}")
response = session.delete(f"{self.base_url}/zones/{zone_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up tab: {tab_id}")
print(f" ✓ Cleaned up zone: {zone_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup tab {tab_id}: {e}")
print(f" ⚠ Failed to cleanup zone {zone_id}: {e}")
# Delete created profiles by ID
for profile_id in self.created_profiles:
@@ -171,20 +238,20 @@ class BrowserTest:
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
# Also try to cleanup by name pattern (in case IDs weren't tracked)
test_names = ['Browser Test Tab', 'Browser Test Profile', 'Browser Test Preset',
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Tab']
test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset',
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone']
# Cleanup tabs by name
# Cleanup zones by name
try:
tabs_response = session.get(f"{self.base_url}/tabs")
if tabs_response.status_code == 200:
tabs_data = tabs_response.json()
tabs = tabs_data.get('tabs', {})
for tab_id, tab_data in tabs.items():
if isinstance(tab_data, dict) and tab_data.get('name') in test_names:
zones_response = session.get(f"{self.base_url}/zones")
if zones_response.status_code == 200:
zones_data = zones_response.json()
zones_map = zones_data.get('zones', {})
for zone_id, zone_row in zones_map.items():
if isinstance(zone_row, dict) and zone_row.get('name') in test_names:
try:
session.delete(f"{self.base_url}/tabs/{tab_id}")
print(f" ✓ Cleaned up tab by name: {tab_data.get('name')}")
session.delete(f"{self.base_url}/zones/{zone_id}")
print(f" ✓ Cleaned up zone by name: {zone_row.get('name')}")
except:
pass
except:
@@ -223,7 +290,7 @@ class BrowserTest:
pass
# Clear the lists
self.created_tabs.clear()
self.created_zones.clear()
self.created_profiles.clear()
self.created_presets.clear()
except Exception as e:
@@ -234,8 +301,34 @@ class BrowserTest:
try:
element = self.wait_for_element(by, value, timeout)
if element:
self._scroll_into_view(element)
# Chrome often reports <input type="color"> as not interactable for clear/send_keys.
if (element.get_attribute("type") or "").lower() == "color":
hex_v = text.strip()
if hex_v and not hex_v.startswith("#"):
hex_v = "#" + hex_v
self.driver.execute_script(
"""
var el = arguments[0], v = arguments[1];
el.value = v;
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
""",
element,
hex_v,
)
return True
element.clear()
try:
element.send_keys(text)
except ElementNotInteractableException:
self.driver.execute_script(
"arguments[0].value = arguments[1];"
"arguments[0].dispatchEvent(new Event('input', {bubbles: true}));"
"arguments[0].dispatchEvent(new Event('change', {bubbles: true}));",
element,
text,
)
return True
return False
except Exception as e:
@@ -264,7 +357,7 @@ class BrowserTest:
try:
actions = ActionChains(self.driver)
actions.drag_and_drop(source_element, target_element).perform()
time.sleep(0.5) # Wait for drop to complete
_browser_sleep(0.5) # Wait for drop to complete
return True
except Exception as e:
print(f"✗ Drag and drop failed: {e}")
@@ -275,12 +368,18 @@ class BrowserTest:
try:
actions = ActionChains(self.driver)
actions.drag_and_drop_by_offset(element, x_offset, y_offset).perform()
time.sleep(0.5)
_browser_sleep(0.5)
return True
except Exception as e:
print(f"✗ Drag and drop by offset failed: {e}")
return False
@pytest.fixture
def browser() -> BrowserTest:
return BrowserTest()
def test_browser_connection(browser: BrowserTest) -> bool:
"""Test basic browser connection."""
print("Testing browser connection...")
@@ -299,9 +398,9 @@ def test_browser_connection(browser: BrowserTest) -> bool:
finally:
browser.teardown()
def test_tabs_ui(browser: BrowserTest) -> bool:
"""Test tabs UI in browser."""
print("\n=== Testing Tabs UI in Browser ===")
def test_zones_ui(browser: BrowserTest) -> bool:
"""Test zones UI in browser."""
print("\n=== Testing Zones UI in Browser ===")
passed = 0
total = 0
@@ -319,75 +418,73 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
browser.teardown()
return False
# Test 2: Open tabs modal
# Test 2: Open zones modal
total += 1
if browser.click_element(By.ID, 'tabs-btn'):
print("✓ Clicked Tabs button")
if browser.click_element(By.ID, 'zones-btn'):
print("✓ Clicked Zones button")
# Wait for modal to appear
time.sleep(0.5)
modal = browser.wait_for_element(By.ID, 'tabs-modal')
_browser_sleep(0.5)
modal = browser.wait_for_element(By.ID, 'zones-modal')
if modal and 'active' in modal.get_attribute('class'):
print("Tabs modal opened")
print("Zones modal opened")
passed += 1
else:
print("Tabs modal didn't open")
print("Zones modal didn't open")
else:
print("✗ Failed to click Tabs button")
print("✗ Failed to click Zones button")
# Test 3: Create a tab via UI
# Test 3: Create a zone via UI
total += 1
try:
# Fill in tab name
if browser.fill_input(By.ID, 'new-tab-name', 'Browser Test Tab'):
print(" ✓ Filled tab name")
# Fill in device IDs
if browser.fill_input(By.ID, 'new-tab-ids', '1,2,3'):
print(" ✓ Filled device IDs")
# Fill in zone name
if browser.fill_input(By.ID, 'new-zone-name', 'Browser Test Zone'):
print(" ✓ Filled zone name")
# Devices default from registry or placeholder name "1"
# Click create button
if browser.click_element(By.ID, 'create-tab-btn'):
if browser.click_element(By.ID, 'create-zone-btn'):
print(" ✓ Clicked create button")
time.sleep(1) # Wait for creation
# Check if tab appears in list and extract ID
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal')
_browser_sleep(1) # Wait for creation
# Check if zone appears in list and extract ID
tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list:
list_text = tabs_list.text
if 'Browser Test Tab' in list_text:
print("✓ Created tab via UI")
# Try to extract tab ID from the list (look for data-tab-id attribute)
if 'Browser Test Zone' in list_text:
print("✓ Created zone via UI")
# Try to extract zone ID from the list (look for data-zone-id attribute)
try:
tab_rows = browser.driver.find_elements(By.CSS_SELECTOR, '#tabs-list-modal .profiles-row')
tab_rows = browser.driver.find_elements(By.CSS_SELECTOR, '#zones-list-modal .profiles-row')
for row in tab_rows:
if 'Browser Test Tab' in row.text:
tab_id = row.get_attribute('data-tab-id')
if tab_id:
browser.created_tabs.append(tab_id)
if 'Browser Test Zone' in row.text:
zone_id = row.get_attribute('data-zone-id')
if zone_id:
browser.created_zones.append(zone_id)
break
except:
pass # If we can't extract ID, cleanup will try by name
passed += 1
else:
print("Tab not found in list after creation")
print("Zone not found in list after creation")
else:
print("Tabs list not found")
print("Zones list not found")
else:
print("✗ Failed to click create button")
except Exception as e:
print(f"✗ Failed to create tab via UI: {e}")
print(f"✗ Failed to create zone via UI: {e}")
# Test 4: Edit a tab via UI (right-click in Tabs list)
# Test 4: Edit a zone via UI (right-click in zones list)
total += 1
try:
# First, close and reopen modal to refresh
browser.click_element(By.ID, 'tabs-close-btn')
time.sleep(0.5)
browser.click_element(By.ID, 'tabs-btn')
time.sleep(0.5)
browser.click_element(By.ID, 'zones-close-btn')
_browser_sleep(0.5)
browser.click_element(By.ID, 'zones-btn')
_browser_sleep(0.5)
# Right-click the row corresponding to 'Browser Test Tab'
# Right-click the row corresponding to 'Browser Test Zone'
try:
tab_row = browser.driver.find_element(
By.XPATH,
"//div[@id='tabs-list-modal']//div[contains(@class,'profiles-row')][.//span[contains(text(), 'Browser Test Tab')]]"
"//div[@id='zones-list-modal']//div[contains(@class,'profiles-row')][.//span[contains(text(), 'Browser Test Zone')]]"
)
except Exception:
tab_row = None
@@ -395,20 +492,20 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
if tab_row:
actions = ActionChains(browser.driver)
actions.context_click(tab_row).perform()
time.sleep(0.5)
_browser_sleep(0.5)
# Check if edit modal opened
edit_modal = browser.wait_for_element(By.ID, 'edit-tab-modal')
edit_modal = browser.wait_for_element(By.ID, 'edit-zone-modal')
if edit_modal:
print("✓ Edit modal opened via right-click")
# Fill in new name
if browser.fill_input(By.ID, 'edit-tab-name', 'Edited Browser Tab'):
print(" ✓ Filled new tab name")
if browser.fill_input(By.ID, 'edit-zone-name', 'Edited Browser Zone'):
print(" ✓ Filled new zone name")
# Submit form
edit_form = browser.wait_for_element(By.ID, 'edit-tab-form')
edit_form = browser.wait_for_element(By.ID, 'edit-zone-form')
if edit_form:
browser.driver.execute_script("arguments[0].submit();", edit_form)
time.sleep(1) # Wait for update
_browser_sleep(1) # Wait for update
print("✓ Submitted edit form")
passed += 1
else:
@@ -416,24 +513,24 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
else:
print("✗ Edit modal didn't open after right-click")
else:
print("✗ Could not find tab row for 'Browser Test Tab'")
print("✗ Could not find zone row for 'Browser Test Zone'")
except Exception as e:
print(f"✗ Failed to edit tab via UI: {e}")
print(f"✗ Failed to edit zone via UI: {e}")
import traceback
traceback.print_exc()
# Test 5: Check current tab cookie
# Test 5: Check current zone cookie
total += 1
cookie = browser.get_cookie('current_tab')
cookie = browser.get_cookie('current_zone')
if cookie:
print(f"✓ Found current_tab cookie: {cookie.get('value')}")
print(f"✓ Found current_zone cookie: {cookie.get('value')}")
passed += 1
else:
print("⚠ No current_tab cookie found (might be normal if no tab selected)")
print("⚠ No current_zone cookie found (might be normal if no zone selected)")
passed += 1 # Not a failure, just informational
# Close modal
browser.click_element(By.ID, 'tabs-close-btn')
browser.click_element(By.ID, 'zones-close-btn')
except Exception as e:
print(f"✗ Browser test error: {e}")
@@ -443,7 +540,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
browser.cleanup_test_data()
browser.teardown()
print(f"\nBrowser tabs UI tests: {passed}/{total} passed")
print(f"\nBrowser zones UI tests: {passed}/{total} passed")
return passed == total
def test_profiles_ui(browser: BrowserTest) -> bool:
@@ -469,7 +566,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
total += 1
if browser.click_element(By.ID, 'profiles-btn'):
print("✓ Clicked Profiles button")
time.sleep(0.5)
_browser_sleep(0.5)
modal = browser.wait_for_element(By.ID, 'profiles-modal')
if modal:
print("✓ Profiles modal opened")
@@ -484,7 +581,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
print(" ✓ Filled profile name")
if browser.click_element(By.ID, 'create-profile-btn'):
print(" ✓ Clicked create button")
time.sleep(1)
_browser_sleep(1)
# Check if profile appears
profiles_list = browser.wait_for_element(By.ID, 'profiles-list')
if profiles_list and 'Browser Test Profile' in profiles_list.text:
@@ -512,7 +609,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
def test_mobile_tab_presets_two_columns():
"""
Verify that the tab preset selecting area shows roughly two preset tiles per row
Verify that the zone preset selecting area shows roughly two preset tiles per row
on a phone-sized viewport.
"""
bt = BrowserTest(base_url=BASE_URL, headless=True)
@@ -524,18 +621,18 @@ def test_mobile_tab_presets_two_columns():
bt.driver.set_window_size(400, 800)
assert bt.navigate('/'), "Failed to load main page"
# Click the first tab button to load presets for that tab
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.tab-button', timeout=10)
assert first_tab is not None, "No tab buttons found"
# Click the first zone button to load presets for that zone
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10)
assert first_tab is not None, "No zone buttons found"
first_tab.click()
time.sleep(1)
_browser_sleep(1)
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
assert container is not None, "presets-list-tab not found"
container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
assert container is not None, "presets-list-zone not found"
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .preset-tile-row')
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .preset-tile-row')
# Need at least 2 presets to make this meaningful
assert len(tiles) >= 2, "Fewer than 2 presets found for tab"
assert len(tiles) >= 2, "Fewer than 2 presets found for zone"
container_width = container.size['width']
first_width = tiles[0].size['width']
@@ -570,7 +667,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
total += 1
if browser.click_element(By.ID, 'presets-btn'):
print("✓ Clicked Presets button")
time.sleep(0.5)
_browser_sleep(0.5)
modal = browser.wait_for_element(By.ID, 'presets-modal')
if modal:
print("✓ Presets modal opened")
@@ -583,7 +680,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
try:
if browser.click_element(By.ID, 'preset-add-btn'):
print(" ✓ Clicked Add Preset button")
time.sleep(0.5)
_browser_sleep(0.5)
editor_modal = browser.wait_for_element(By.ID, 'preset-editor-modal')
if editor_modal:
print("✓ Preset editor modal opened")
@@ -621,7 +718,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
# Save preset
if browser.click_element(By.ID, 'preset-save-btn'):
print(" ✓ Clicked save button")
time.sleep(1)
_browser_sleep(1)
# Check if preset appears in list
presets_list = browser.wait_for_element(By.ID, 'presets-list')
if presets_list and 'Browser Test Preset' in presets_list.text:
@@ -638,7 +735,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
# Close editor modal
browser.click_element(By.ID, 'preset-editor-close-btn', use_js=True)
time.sleep(0.5)
_browser_sleep(0.5)
# Close presets modal
browser.click_element(By.ID, 'presets-close-btn', use_js=True)
@@ -676,7 +773,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
total += 1
if browser.click_element(By.ID, 'color-palette-btn'):
print("✓ Clicked Color Palette button")
time.sleep(0.5)
_browser_sleep(0.5)
modal = browser.wait_for_element(By.ID, 'color-palette-modal')
if modal:
print("✓ Color palette modal opened")
@@ -696,7 +793,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
# Click add color button
if browser.click_element(By.ID, 'palette-add-color-btn'):
print(" ✓ Clicked Add Color button")
time.sleep(0.5)
_browser_sleep(0.5)
# Handle alert if color already exists
browser.handle_alert(accept=True, timeout=1)
# Check if color appears in palette
@@ -728,7 +825,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
target = color_swatches[1]
if browser.drag_and_drop(source, target):
print("✓ Dragged color to reorder")
time.sleep(0.5)
_browser_sleep(0.5)
passed += 1
else:
print("✗ Drag and drop failed")
@@ -753,8 +850,8 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
return passed >= total - 1 # Allow one failure (alert handling might be flaky)
def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
"""Test dragging presets around in a tab."""
print("\n=== Testing Preset Drag and Drop in Tab ===")
"""Test dragging presets around in a zone."""
print("\n=== Testing Preset Drag and Drop in Zone ===")
passed = 0
total = 0
@@ -762,7 +859,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
return False
try:
# Test 1: Load page and ensure we have a tab
# Test 1: Load page and ensure we have a zone
total += 1
if browser.navigate('/'):
print("✓ Loaded main page")
@@ -771,134 +868,133 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
browser.teardown()
return False
# Test 2: Open tabs modal and create/select a tab
# Test 2: Open zones modal and create/select a zone
total += 1
browser.click_element(By.ID, 'tabs-btn')
time.sleep(0.5)
browser.click_element(By.ID, 'zones-btn')
_browser_sleep(0.5)
# Check if we have tabs, if not create one
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal')
if tabs_list and 'No tabs found' in tabs_list.text:
# Create a tab
browser.fill_input(By.ID, 'new-tab-name', 'Drag Test Tab')
browser.fill_input(By.ID, 'new-tab-ids', '1')
browser.click_element(By.ID, 'create-tab-btn')
time.sleep(1)
# Check if we have zones, if not create one
tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list and 'No zones found' in tabs_list.text:
# Create a zone
browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone')
browser.click_element(By.ID, 'create-zone-btn')
_browser_sleep(1)
# Select first tab (or the one we just created)
# Select first zone (or the one we just created)
select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]")
if select_buttons:
select_buttons[0].click()
time.sleep(1)
print("✓ Selected a tab")
_browser_sleep(1)
print("✓ Selected a zone")
passed += 1
else:
print("✗ No tabs available to select")
browser.click_element(By.ID, 'tabs-close-btn')
print("✗ No zones available to select")
browser.click_element(By.ID, 'zones-close-btn')
browser.teardown()
return False
browser.click_element(By.ID, 'tabs-close-btn', use_js=True)
time.sleep(0.5)
browser.click_element(By.ID, 'zones-close-btn', use_js=True)
_browser_sleep(0.5)
# Test 3: Open presets modal and create presets
total += 1
browser.click_element(By.ID, 'presets-btn')
time.sleep(0.5)
_browser_sleep(0.5)
# Create first preset
browser.click_element(By.ID, 'preset-add-btn')
time.sleep(0.5)
_browser_sleep(0.5)
browser.fill_input(By.ID, 'preset-name-input', 'Preset 1')
browser.fill_input(By.ID, 'preset-new-color', '#ff0000')
browser.click_element(By.ID, 'preset-add-color-btn')
browser.click_element(By.ID, 'preset-save-btn')
time.sleep(1)
_browser_sleep(1)
# Create second preset
browser.click_element(By.ID, 'preset-add-btn')
time.sleep(0.5)
_browser_sleep(0.5)
browser.fill_input(By.ID, 'preset-name-input', 'Preset 2')
browser.fill_input(By.ID, 'preset-new-color', '#00ff00')
browser.click_element(By.ID, 'preset-add-color-btn')
browser.click_element(By.ID, 'preset-save-btn')
time.sleep(1)
_browser_sleep(1)
# Create third preset
browser.click_element(By.ID, 'preset-add-btn')
time.sleep(0.5)
_browser_sleep(0.5)
browser.fill_input(By.ID, 'preset-name-input', 'Preset 3')
browser.fill_input(By.ID, 'preset-new-color', '#0000ff')
browser.click_element(By.ID, 'preset-add-color-btn')
browser.click_element(By.ID, 'preset-save-btn')
time.sleep(1)
_browser_sleep(1)
browser.click_element(By.ID, 'presets-close-btn', use_js=True)
time.sleep(0.5)
_browser_sleep(0.5)
print("✓ Created 3 presets for drag test")
passed += 1
# Test 4: Add presets to the tab (via Edit Tab modal Select buttons in list)
# Test 4: Add presets to the zone (via Edit Zone modal Add buttons in list)
total += 1
try:
tab_id = browser.driver.execute_script(
zone_id = browser.driver.execute_script(
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
)
if not tab_id:
print("✗ Could not get current tab id")
if not zone_id:
print("✗ Could not get current zone id")
else:
browser.driver.execute_script(
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
tab_id
zone_id
)
time.sleep(1)
list_el = browser.wait_for_element(By.ID, 'edit-tab-presets-list', timeout=5)
_browser_sleep(1)
list_el = browser.wait_for_element(By.ID, 'edit-zone-presets-list', timeout=5)
if list_el:
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-tab-presets-list']//button[text()='Select']")
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if len(select_buttons) >= 2:
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5)
_browser_sleep(1.5)
browser.handle_alert(accept=True, timeout=1)
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-tab-presets-list']//button[text()='Select']")
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if len(select_buttons) >= 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5)
_browser_sleep(1.5)
browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 2 presets to tab")
print(" ✓ Added 2 presets to zone")
passed += 1
elif len(select_buttons) == 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5)
_browser_sleep(1.5)
browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 1 preset to tab")
print(" ✓ Added 1 preset to zone")
passed += 1
else:
print(" ⚠ No presets available to add (all already in tab)")
print(" ⚠ No presets available to add (all already in zone)")
else:
print("✗ Edit tab presets list not found")
print("✗ Edit zone presets list not found")
except Exception as e:
print(f"✗ Failed to add presets to tab: {e}")
print(f"✗ Failed to add presets to zone: {e}")
import traceback
traceback.print_exc()
# Test 5: Find presets in tab and test drag and drop (Edit mode only)
# Test 5: Find presets in zone and test drag and drop (Edit mode only)
total += 1
try:
# Wait for presets to load in the tab
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
# Wait for presets to load in the zone
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5)
if presets_list_tab:
time.sleep(1) # Wait for presets to render
_browser_sleep(1) # Wait for presets to render
# Reordering is only available in Edit mode (tiles get .draggable-preset)
mode_toggle = browser.wait_for_element(By.CSS_SELECTOR, '.ui-mode-toggle', timeout=5)
if mode_toggle and mode_toggle.get_attribute('aria-pressed') == 'false':
mode_toggle.click()
time.sleep(0.5)
_browser_sleep(0.5)
# Find draggable preset elements - wait a bit more for rendering
time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
_browser_sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2:
print(f" ✓ Found {len(draggable_presets)} draggable presets")
@@ -913,10 +1009,10 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
# Use ActionChains for drag and drop
actions = ActionChains(browser.driver)
actions.click_and_hold(source).move_to_element(target).release().perform()
time.sleep(1) # Wait for reorder to complete
_browser_sleep(1) # Wait for reorder to complete
# Check if order changed
draggable_presets_after = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
draggable_presets_after = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets_after) >= 2:
new_order = [p.text for p in draggable_presets_after]
print(f" New order: {new_order[:3]}")
@@ -930,45 +1026,45 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
else:
print("✗ Presets disappeared after drag")
elif len(draggable_presets) == 1:
print(f"⚠ Only 1 preset found in tab (need 2 for drag test). Preset: {draggable_presets[0].text}")
tab_id = browser.driver.execute_script(
print(f"⚠ Only 1 preset found in zone (need 2 for drag test). Preset: {draggable_presets[0].text}")
zone_id = browser.driver.execute_script(
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
)
if tab_id:
if zone_id:
browser.driver.execute_script(
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
tab_id
zone_id
)
time.sleep(1)
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-tab-presets-list']//button[text()='Select']")
_browser_sleep(1)
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if select_buttons:
print(" Attempting to add another preset...")
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5)
_browser_sleep(1.5)
browser.handle_alert(accept=True, timeout=1)
try:
browser.driver.execute_script("document.getElementById('edit-tab-modal').classList.remove('active');")
browser.driver.execute_script("document.getElementById('edit-zone-modal').classList.remove('active');")
except Exception:
pass
time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
_browser_sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2:
print(" ✓ Added another preset, now testing drag...")
source = draggable_presets[0]
target = draggable_presets[1]
actions = ActionChains(browser.driver)
actions.click_and_hold(source).move_to_element(target).release().perform()
time.sleep(1)
_browser_sleep(1)
print("✓ Performed drag and drop")
passed += 1
else:
print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding")
else:
print(" ✗ No Select buttons found in Edit Tab modal")
print(" ✗ No Add buttons found in Edit Zone modal")
else:
print(f"✗ No presets found in tab (found {len(draggable_presets)})")
print(f"✗ No presets found in zone (found {len(draggable_presets)})")
else:
print("✗ Presets list in tab not found")
print("✗ Presets list in zone not found")
except Exception as e:
print(f"✗ Drag and drop test error: {e}")
import traceback
@@ -1006,7 +1102,7 @@ def main():
# Run browser tests
results.append(("Browser Connection", test_browser_connection(browser)))
results.append(("Tabs UI", test_tabs_ui(browser)))
results.append(("Zones UI", test_zones_ui(browser)))
results.append(("Profiles UI", test_profiles_ui(browser)))
results.append(("Presets UI", test_presets_ui(browser)))
results.append(("Color Palette UI", test_color_palette_ui(browser)))

View File

@@ -4,6 +4,15 @@ Endpoint tests that mimic web browser requests.
Tests run against the device at 192.168.4.1
"""
import os
import pytest
if os.environ.get("LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS") != "1":
pytest.skip(
"Legacy device integration endpoint tests; enable explicitly to run.",
allow_module_level=True,
)
import requests
import json
import sys
@@ -73,128 +82,128 @@ def test_connection(client: TestClient) -> bool:
print(f"✗ Connection error: {e}")
return False
def test_tabs(client: TestClient) -> bool:
"""Test tabs endpoints."""
print("\n=== Testing Tabs Endpoints ===")
def test_zones(client: TestClient) -> bool:
"""Test zones endpoints."""
print("\n=== Testing Zones Endpoints ===")
passed = 0
total = 0
# Test 1: List tabs
# Test 1: List zones
total += 1
try:
response = client.get('/tabs')
response = client.get('/zones')
if response.status_code == 200:
data = response.json()
print(f"✓ GET /tabs - Found {len(data.get('tabs', {}))} tabs")
print(f"✓ GET /zones - Found {len(data.get('zones', {}))} zones")
passed += 1
else:
print(f"✗ GET /tabs - Status: {response.status_code}")
print(f"✗ GET /zones - Status: {response.status_code}")
except Exception as e:
print(f"✗ GET /tabs - Error: {e}")
print(f"✗ GET /zones - Error: {e}")
# Test 2: Create tab
# Test 2: Create zone
total += 1
try:
tab_data = {
"name": "Test Tab",
zone_data = {
"name": "Test Zone",
"names": ["1", "2"]
}
response = client.post('/tabs', json_data=tab_data)
response = client.post('/zones', json_data=zone_data)
if response.status_code == 201:
created_tab = response.json()
# Response format: {tab_id: {tab_data}}
if isinstance(created_tab, dict):
# Get the first key which should be the tab ID
tab_id = next(iter(created_tab.keys())) if created_tab else None
created_zone = response.json()
# Response format: {zone_id: {zone object}}
if isinstance(created_zone, dict):
# Get the first key which should be the zone ID
zone_id = next(iter(created_zone.keys())) if created_zone else None
else:
tab_id = None
print(f"✓ POST /tabs - Created tab: {tab_id}")
zone_id = None
print(f"✓ POST /zones - Created zone: {zone_id}")
passed += 1
# Test 3: Get specific tab
if tab_id:
# Test 3: Get specific zone
if zone_id:
total += 1
response = client.get(f'/tabs/{tab_id}')
response = client.get(f'/zones/{zone_id}')
if response.status_code == 200:
print(f"✓ GET /tabs/{tab_id} - Retrieved tab")
print(f"✓ GET /zones/{zone_id} - Retrieved zone")
passed += 1
else:
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}")
print(f"✗ GET /zones/{zone_id} - Status: {response.status_code}")
# Test 4: Set current tab
# Test 4: Set current zone
total += 1
response = client.post(f'/tabs/{tab_id}/set-current')
response = client.post(f'/zones/{zone_id}/set-current')
if response.status_code == 200:
print(f"✓ POST /tabs/{tab_id}/set-current - Set current tab")
print(f"✓ POST /zones/{zone_id}/set-current - Set current zone")
# Check cookie was set
cookie = client.get_cookie('current_tab')
if cookie == tab_id:
print(f" ✓ Cookie 'current_tab' set to {tab_id}")
cookie = client.get_cookie('current_zone')
if cookie == zone_id:
print(f" ✓ Cookie 'current_zone' set to {zone_id}")
passed += 1
else:
print(f"✗ POST /tabs/{tab_id}/set-current - Status: {response.status_code}")
print(f"✗ POST /zones/{zone_id}/set-current - Status: {response.status_code}")
# Test 5: Get current tab
# Test 5: Get current zone
total += 1
response = client.get('/tabs/current')
response = client.get('/zones/current')
if response.status_code == 200:
data = response.json()
if data.get('tab_id') == tab_id:
print(f"✓ GET /tabs/current - Current tab is {tab_id}")
if data.get('zone_id') == zone_id:
print(f"✓ GET /zones/current - Current zone is {zone_id}")
passed += 1
else:
print(f"✗ GET /tabs/current - Wrong tab ID")
print(f"✗ GET /zones/current - Wrong zone ID")
else:
print(f"✗ GET /tabs/current - Status: {response.status_code}")
print(f"✗ GET /zones/current - Status: {response.status_code}")
# Test 6: Update tab (edit functionality)
# Test 6: Update zone (edit functionality)
total += 1
update_data = {
"name": "Updated Test Tab",
"name": "Updated Test Zone",
"names": ["1", "2", "3"] # Update device IDs too
}
response = client.put(f'/tabs/{tab_id}', json_data=update_data)
response = client.put(f'/zones/{zone_id}', json_data=update_data)
if response.status_code == 200:
updated = response.json()
if updated.get('name') == "Updated Test Tab" and updated.get('names') == ["1", "2", "3"]:
print(f"✓ PUT /tabs/{tab_id} - Updated tab (name and device IDs)")
if updated.get('name') == "Updated Test Zone" and updated.get('names') == ["1", "2", "3"]:
print(f"✓ PUT /zones/{zone_id} - Updated zone (name and device IDs)")
passed += 1
else:
print(f"✗ PUT /tabs/{tab_id} - Update didn't work correctly")
print(f" Expected name='Updated Test Tab', got '{updated.get('name')}'")
print(f"✗ PUT /zones/{zone_id} - Update didn't work correctly")
print(f" Expected name='Updated Test Zone', got '{updated.get('name')}'")
print(f" Expected names=['1','2','3'], got {updated.get('names')}")
else:
print(f"✗ PUT /tabs/{tab_id} - Status: {response.status_code}, Response: {response.text}")
print(f"✗ PUT /zones/{zone_id} - Status: {response.status_code}, Response: {response.text}")
# Test 6b: Verify update persisted
total += 1
response = client.get(f'/tabs/{tab_id}')
response = client.get(f'/zones/{zone_id}')
if response.status_code == 200:
verified = response.json()
if verified.get('name') == "Updated Test Tab":
print(f"✓ GET /tabs/{tab_id} - Verified update persisted")
if verified.get('name') == "Updated Test Zone":
print(f"✓ GET /zones/{zone_id} - Verified update persisted")
passed += 1
else:
print(f"✗ GET /tabs/{tab_id} - Update didn't persist")
print(f"✗ GET /zones/{zone_id} - Update didn't persist")
else:
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}")
print(f"✗ GET /zones/{zone_id} - Status: {response.status_code}")
# Test 7: Delete tab
# Test 7: Delete zone
total += 1
response = client.delete(f'/tabs/{tab_id}')
response = client.delete(f'/zones/{zone_id}')
if response.status_code == 200:
print(f"✓ DELETE /tabs/{tab_id} - Deleted tab")
print(f"✓ DELETE /zones/{zone_id} - Deleted zone")
passed += 1
else:
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
else:
print(f"✗ POST /tabs - Status: {response.status_code}, Response: {response.text}")
print(f"✗ POST /zones - Status: {response.status_code}, Response: {response.text}")
except Exception as e:
print(f"✗ POST /tabs - Error: {e}")
print(f"✗ POST /zones - Error: {e}")
import traceback
traceback.print_exc()
print(f"\nTabs tests: {passed}/{total} passed")
print(f"\nZones tests: {passed}/{total} passed")
return passed == total
def test_profiles(client: TestClient) -> bool:
@@ -396,95 +405,118 @@ def test_patterns(client: TestClient) -> bool:
except Exception as e:
print(f"✗ GET /patterns/definitions - Error: {e}")
# Test 3: Firmware-only on/off — no OTA file (400 from API).
total += 1
try:
r = client.get("/patterns/ota/file/on.py")
if r.status_code == 400 and "error" in r.json():
print("✓ GET /patterns/ota/file/on.py - Rejected as built-in")
passed += 1
else:
print(f"✗ GET /patterns/ota/file/on.py - Expected 400, got {r.status_code}")
except Exception as e:
print(f"✗ GET /patterns/ota/file/on.py - Error: {e}")
total += 1
try:
r = client.post("/patterns/off/send", json_data={})
if r.status_code == 400 and "error" in r.json():
print("✓ POST /patterns/off/send - Rejected as built-in")
passed += 1
else:
print(f"✗ POST /patterns/off/send - Expected 400, got {r.status_code}")
except Exception as e:
print(f"✗ POST /patterns/off/send - Error: {e}")
print(f"\nPatterns tests: {passed}/{total} passed")
return passed == total
def test_tab_edit_workflow(client: TestClient) -> bool:
"""Test complete tab edit workflow like a browser would."""
print("\n=== Testing Tab Edit Workflow ===")
def test_zone_edit_workflow(client: TestClient) -> bool:
"""Test complete zone edit workflow like a browser would."""
print("\n=== Testing Zone Edit Workflow ===")
passed = 0
total = 0
# Step 1: Create a tab to edit
# Step 1: Create a zone to edit
total += 1
try:
tab_data = {
"name": "Tab to Edit",
zone_data = {
"name": "Zone to Edit",
"names": ["1"]
}
response = client.post('/tabs', json_data=tab_data)
response = client.post('/zones', json_data=zone_data)
if response.status_code == 201:
created = response.json()
if isinstance(created, dict):
tab_id = next(iter(created.keys())) if created else None
zone_id = next(iter(created.keys())) if created else None
else:
tab_id = None
zone_id = None
if tab_id:
print(f"✓ Created tab {tab_id} for editing")
if zone_id:
print(f"✓ Created zone {zone_id} for editing")
passed += 1
# Step 2: Get the tab to verify initial state
# Step 2: Get the zone to verify initial state
total += 1
response = client.get(f'/tabs/{tab_id}')
response = client.get(f'/zones/{zone_id}')
if response.status_code == 200:
original_tab = response.json()
print(f"✓ Retrieved tab - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}")
original_zone = response.json()
print(f"✓ Retrieved zone - Name: '{original_zone.get('name')}', IDs: {original_zone.get('names')}")
passed += 1
# Step 3: Edit the tab (simulate browser edit form submission)
# Step 3: Edit the zone (simulate browser edit form submission)
total += 1
edit_data = {
"name": "Edited Tab Name",
"name": "Edited Zone Name",
"names": ["2", "3", "4"]
}
response = client.put(f'/tabs/{tab_id}', json_data=edit_data)
response = client.put(f'/zones/{zone_id}', json_data=edit_data)
if response.status_code == 200:
edited = response.json()
if edited.get('name') == "Edited Tab Name" and edited.get('names') == ["2", "3", "4"]:
print(f"✓ PUT /tabs/{tab_id} - Successfully edited tab")
if edited.get('name') == "Edited Zone Name" and edited.get('names') == ["2", "3", "4"]:
print(f"✓ PUT /zones/{zone_id} - Successfully edited zone")
print(f" New name: '{edited.get('name')}'")
print(f" New device IDs: {edited.get('names')}")
passed += 1
else:
print(f"✗ PUT /tabs/{tab_id} - Edit didn't work correctly")
print(f"✗ PUT /zones/{zone_id} - Edit didn't work correctly")
print(f" Got: {edited}")
else:
print(f"✗ PUT /tabs/{tab_id} - Status: {response.status_code}, Response: {response.text}")
print(f"✗ PUT /zones/{zone_id} - Status: {response.status_code}, Response: {response.text}")
# Step 4: Verify edit persisted by getting the tab again
# Step 4: Verify edit persisted by getting the zone again
total += 1
response = client.get(f'/tabs/{tab_id}')
response = client.get(f'/zones/{zone_id}')
if response.status_code == 200:
verified = response.json()
if verified.get('name') == "Edited Tab Name" and verified.get('names') == ["2", "3", "4"]:
print(f"✓ GET /tabs/{tab_id} - Verified edit persisted")
if verified.get('name') == "Edited Zone Name" and verified.get('names') == ["2", "3", "4"]:
print(f"✓ GET /zones/{zone_id} - Verified edit persisted")
passed += 1
else:
print(f"✗ GET /tabs/{tab_id} - Edit didn't persist")
print(f" Expected name='Edited Tab Name', got '{verified.get('name')}'")
print(f"✗ GET /zones/{zone_id} - Edit didn't persist")
print(f" Expected name='Edited Zone Name', got '{verified.get('name')}'")
print(f" Expected names=['2','3','4'], got {verified.get('names')}")
else:
print(f"✗ GET /tabs/{tab_id} - Status: {response.status_code}")
print(f"✗ GET /zones/{zone_id} - Status: {response.status_code}")
# Step 5: Clean up - delete the test tab
# Step 5: Clean up - delete the test zone
total += 1
response = client.delete(f'/tabs/{tab_id}')
response = client.delete(f'/zones/{zone_id}')
if response.status_code == 200:
print(f"✓ DELETE /tabs/{tab_id} - Cleaned up test tab")
print(f"✓ DELETE /zones/{zone_id} - Cleaned up test zone")
passed += 1
else:
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
else:
print(f"✗ Failed to extract tab ID from create response")
print(f"✗ Failed to extract zone ID from create response")
else:
print(f"✗ POST /tabs - Status: {response.status_code}, Response: {response.text}")
print(f"✗ POST /zones - Status: {response.status_code}, Response: {response.text}")
except Exception as e:
print(f"Tab edit workflow - Error: {e}")
print(f"Zone edit workflow - Error: {e}")
import traceback
traceback.print_exc()
print(f"\nTab edit workflow tests: {passed}/{total} passed")
print(f"\nZone edit workflow tests: {passed}/{total} passed")
return passed == total
def test_static_files(client: TestClient) -> bool:
@@ -496,7 +528,7 @@ def test_static_files(client: TestClient) -> bool:
static_files = [
'/static/style.css',
'/static/app.js',
'/static/tabs.js',
'/static/zones.js',
'/static/presets.js',
'/static/profiles.js',
'/static/devices.js',
@@ -534,8 +566,8 @@ def main():
results = []
# Run all tests
results.append(("Tabs", test_tabs(client)))
results.append(("Tab Edit Workflow", test_tab_edit_workflow(client)))
results.append(("Zones", test_zones(client)))
results.append(("Zone Edit Workflow", test_zone_edit_workflow(client)))
results.append(("Profiles", test_profiles(client)))
results.append(("Presets", test_presets(client)))
results.append(("Patterns", test_patterns(client)))

View File

@@ -0,0 +1,757 @@
import asyncio
import builtins
import json
import os
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
import pytest
import requests
# Ensure imports resolve to the repo's `src/` + `lib/` code.
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
LIB_PATH = PROJECT_ROOT / "lib"
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
from microdot import Microdot, send_file # noqa: E402
from microdot.session import Session # noqa: E402
from microdot.websocket import with_websocket # noqa: E402
class DummySender:
def __init__(self):
self.sent: list[tuple[str, Optional[str]]] = []
async def send(self, data: Any, addr: Optional[str] = None):
if isinstance(data, (bytes, bytearray)):
data = bytes(data).decode(errors="ignore")
self.sent.append((data, addr))
return True
def _json(resp: requests.Response) -> Dict[str, Any]:
# Many endpoints already set Content-Type; but be tolerant for now.
return resp.json() # pragma: no cover
def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) -> str:
for obj_id, data in list_resp_json.items():
if isinstance(data, dict) and data.get(field) == value:
return str(obj_id)
raise AssertionError(f"Could not find id for {field}={value!r}")
def _start_microdot_server(app: Microdot, host: str, port: int):
"""
Start Microdot server on a background thread.
Returns (thread, chosen_port).
"""
def runner():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.start_server(host=host, port=port))
finally:
try:
loop.close()
except Exception:
pass
thread = threading.Thread(target=runner, daemon=True)
thread.start()
# Poll until the socket is bound and app.server is available.
chosen_port = None
deadline = time.time() + 5.0
while time.time() < deadline:
server = getattr(app, "server", None)
if server and getattr(server, "sockets", None):
sockets = server.sockets or []
if sockets:
chosen_port = sockets[0].getsockname()[1]
break
time.sleep(0.05)
if chosen_port is None:
raise RuntimeError("Microdot server failed to start in time")
return thread, chosen_port
@pytest.fixture(scope="function")
def server(monkeypatch, tmp_path_factory):
"""
Start the Microdot app in-process and return a test client.
"""
tmp_root = tmp_path_factory.mktemp("endpoint-tests")
tmp_db_dir = tmp_root / "db"
tmp_settings_file = tmp_root / "settings.json"
# Be defensive: pytest runners can sometimes alter sys.path ordering.
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
# Patch Settings so endpoint tests never touch real `settings.json`.
import settings as settings_mod # noqa: E402
settings_mod.Settings.SETTINGS_FILE = str(tmp_settings_file)
# Patch the Model db directory so endpoint CRUD is isolated.
import models.model as model_mod # noqa: E402
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(tmp_db_dir))
# Reset model singletons (controllers instantiate model classes at import time).
# Import the classes first so we can delete their `_instance` attribute if present.
import models.preset as models_preset # noqa: E402
import models.profile as models_profile # noqa: E402
import models.group as models_group # noqa: E402
import models.zone as models_zone # noqa: E402
import models.pallet as models_pallet # noqa: E402
import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402
import models.squence as models_sequence # noqa: E402
import models.device as models_device # noqa: E402
for cls in (
models_preset.Preset,
models_profile.Profile,
models_group.Group,
models_zone.Zone,
models_pallet.Palette,
models_scene.Scene,
models_pattern.Pattern,
models_sequence.Sequence,
models_device.Device,
):
if hasattr(cls, "_instance"):
delattr(cls, "_instance")
# Patch open() so pattern definitions work after we `chdir` into src/.
orig_open = builtins.open
def patched_open(file, *args, **kwargs):
if isinstance(file, str):
# Pattern controller loads definitions from a relative db/ path.
if file in {"db/pattern.json", "pattern.json", "/db/pattern.json"}:
file = str(PROJECT_ROOT / "db" / "pattern.json")
return orig_open(file, *args, **kwargs)
monkeypatch.setattr(builtins, "open", patched_open)
old_cwd = os.getcwd()
os.chdir(str(SRC_PATH))
dummy_sender = DummySender()
try:
# Ensure controllers are imported fresh after our patching.
for mod_name in (
"controllers.preset",
"controllers.profile",
"controllers.group",
"controllers.sequence",
"controllers.zone",
"controllers.palette",
"controllers.scene",
"controllers.pattern",
"controllers.settings",
"controllers.device",
):
sys.modules.pop(mod_name, None)
# Import controllers after patching db/settings/model singletons.
import controllers.preset as preset_ctl # noqa: E402
import controllers.profile as profile_ctl # noqa: E402
import controllers.group as group_ctl # noqa: E402
import controllers.sequence as sequence_ctl # noqa: E402
import controllers.zone as zone_ctl # noqa: E402
import controllers.palette as palette_ctl # noqa: E402
import controllers.scene as scene_ctl # noqa: E402
import controllers.pattern as pattern_ctl # noqa: E402
import controllers.settings as settings_ctl # noqa: E402
import controllers.device as device_ctl # noqa: E402
# Configure transport sender used by /presets/send.
from models.transport import set_sender # noqa: E402
set_sender(dummy_sender)
app = Microdot()
# Session secret key comes from settings (patched to tmp).
settings = settings_mod.Settings()
secret_key = settings.get(
"session_secret_key",
"led-controller-secret-key-change-in-production",
)
Session(app, secret_key=secret_key)
# Mount model controllers under their public prefixes.
app.mount(preset_ctl.controller, "/presets")
app.mount(profile_ctl.controller, "/profiles")
app.mount(group_ctl.controller, "/groups")
app.mount(sequence_ctl.controller, "/sequences")
app.mount(zone_ctl.controller, "/zones")
app.mount(palette_ctl.controller, "/palettes")
app.mount(scene_ctl.controller, "/scenes")
app.mount(pattern_ctl.controller, "/patterns")
app.mount(settings_ctl.controller, "/settings")
app.mount(device_ctl.controller, "/devices")
@app.route("/")
def index(request):
return send_file("templates/index.html")
@app.route("/settings")
def settings_page(request):
return send_file("templates/settings.html")
@app.route("/favicon.ico")
def favicon(request):
return "", 204
@app.route("/static/<path:path>")
def static_handler(request, path):
if ".." in path:
return "Not found", 404
return send_file("static/" + path)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
# Minimal websocket handler: forward raw JSON/text payloads to dummy sender.
while True:
data = await ws.receive()
if not data:
break
try:
parsed = json.loads(data)
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await dummy_sender.send(payload, addr=addr)
except Exception:
await dummy_sender.send(data)
thread, chosen_port = _start_microdot_server(app, host="127.0.0.1", port=0)
base_url = f"http://127.0.0.1:{chosen_port}"
client = requests.Session()
client.headers.update(
{
"User-Agent": "pytest/requests",
"Accept": "application/json",
}
)
yield {
"base_url": base_url,
"client": client,
"sender": dummy_sender,
"thread": thread,
"app": app,
}
finally:
# Stop server cleanly.
try:
app = locals().get("app")
if app is not None:
app.shutdown()
except Exception:
pass
# Give it a moment to close sockets.
time.sleep(0.1)
try:
thread = locals().get("thread")
if thread is not None:
thread.join(timeout=5)
except Exception:
pass
os.chdir(old_cwd)
def test_main_routes(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
resp = c.get(f"{base_url}/")
assert resp.status_code == 200
assert "LED Controller" in resp.text
resp = c.get(f"{base_url}/favicon.ico")
assert resp.status_code == 204
resp = c.get(f"{base_url}/static/style.css")
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings/page")
assert resp.status_code == 200
assert "LED Controller" in resp.text
resp = c.get(f"{base_url}/ws")
# WebSocket endpoints should reject non-upgraded HTTP requests.
assert resp.status_code != 200
assert resp.status_code in {400, 401, 403, 404, 405, 426}
def test_settings_controller(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
assert "wifi_channel" in data
resp = c.get(f"{base_url}/settings/wifi/ap")
assert resp.status_code == 200
ap = resp.json()
assert "saved_ssid" in ap
assert "active" in ap
unique_ssid = f"pytest-ssid-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/settings/wifi/ap",
json={"ssid": unique_ssid, "password": "secret", "channel": 1},
)
assert resp.status_code == 200
msg = resp.json()
assert msg["ssid"] == unique_ssid
assert msg["channel"] == 1
resp = c.post(
f"{base_url}/settings/wifi/ap",
json={"ssid": "bad-ssid", "password": "secret", "channel": 12},
)
assert resp.status_code == 400
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 11})
assert resp.status_code == 200
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
assert resp.status_code == 400
def test_profiles_presets_zones_endpoints(server, monkeypatch):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
import controllers.device as device_ctl
monkeypatch.setattr(device_ctl, "IDENTIFY_OFF_DELAY_S", 0.05)
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
assert resp.status_code == 201
created = resp.json()
assert isinstance(created, dict)
profile_id = next(iter(created.keys()))
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
assert resp.status_code == 200
resp = c.get(f"{base_url}/profiles/current")
assert resp.status_code == 200
current = resp.json()
assert str(current["id"]) == str(profile_id)
# Presets CRUD (scoped to current profile session).
resp = c.get(f"{base_url}/presets")
assert resp.status_code == 200
presets = resp.json()
assert isinstance(presets, dict)
assert presets # seeded presets should exist
first_preset_id = next(iter(presets.keys()))
resp = c.get(f"{base_url}/presets/{first_preset_id}")
assert resp.status_code == 200
assert resp.json() # dict
unique_preset_name = f"pytest-preset-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/presets",
json={
"name": unique_preset_name,
"pattern": "on",
"colors": ["#ff0000"],
"brightness": 123,
"delay": 100,
},
)
assert resp.status_code == 201
created_preset = resp.json()
new_preset_id = next(iter(created_preset.keys()))
assert created_preset[new_preset_id]["profile_id"] == str(profile_id)
resp = c.put(
f"{base_url}/presets/{new_preset_id}",
json={"brightness": 77},
)
assert resp.status_code == 200
assert resp.json()["brightness"] == 77
sender.sent.clear()
resp = c.post(
f"{base_url}/presets/send",
json={"preset_ids": [new_preset_id], "save": False},
)
assert resp.status_code == 200
sent_result = resp.json()
assert sent_result["presets_sent"] >= 1
assert len(sender.sent) >= 1
resp = c.delete(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 404
# Zones CRUD (scoped to current profile session).
unique_zone_name = f"pytest-zone-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/zones",
json={"name": unique_zone_name, "names": ["1", "2"]},
)
assert resp.status_code == 201
created_zones = resp.json()
zone_id = next(iter(created_zones.keys()))
resp = c.get(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200
assert resp.json()["name"] == unique_zone_name
resp = c.post(f"{base_url}/zones/{zone_id}/set-current")
assert resp.status_code == 200
resp = c.get(f"{base_url}/zones/current")
assert resp.status_code == 200
assert resp.json()["zone_id"] == str(zone_id)
resp = c.put(
f"{base_url}/zones/{zone_id}",
json={"name": f"{unique_zone_name}-updated", "names": ["3"]},
)
assert resp.status_code == 200
assert resp.json()["names"] == ["3"]
resp = c.post(f"{base_url}/zones/{zone_id}/clone", json={"name": "pytest-zone-clone"})
assert resp.status_code == 201
clone_payload = resp.json()
clone_id = next(iter(clone_payload.keys()))
resp = c.get(f"{base_url}/zones/{clone_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/zones/{clone_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200
# Profile clone + update endpoints.
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
assert resp.status_code == 201
cloned = resp.json()
clone_profile_id = next(iter(cloned.keys()))
resp = c.post(f"{base_url}/profiles/{clone_profile_id}/apply")
assert resp.status_code == 200
resp = c.put(
f"{base_url}/profiles/current",
json={"name": f"{clone_name}-updated"},
)
assert resp.status_code == 200
resp = c.put(
f"{base_url}/profiles/{clone_profile_id}",
json={"name": f"{clone_name}-updated-2"},
)
assert resp.status_code == 200
resp = c.delete(f"{base_url}/profiles/{clone_profile_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/profiles/{profile_id}")
assert resp.status_code == 200
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
# Groups.
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
assert resp.status_code == 201
groups_list = c.get(f"{base_url}/groups").json()
group_id = _find_id_by_field(groups_list, "name", unique_group_name)
resp = c.get(f"{base_url}/groups/{group_id}")
assert resp.status_code == 200
assert resp.json()["name"] == unique_group_name
resp = c.put(f"{base_url}/groups/{group_id}", json={"brightness": 10})
assert resp.status_code == 200
assert resp.json()["brightness"] == 10
resp = c.delete(f"{base_url}/groups/{group_id}")
assert resp.status_code == 200
# Sequences.
unique_seq_group_name = f"pytest-seq-group-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/sequences",
json={"group_name": unique_seq_group_name, "presets": []},
)
assert resp.status_code == 201
sequences_list = c.get(f"{base_url}/sequences").json()
seq_id = _find_id_by_field(sequences_list, "group_name", unique_seq_group_name)
resp = c.get(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200
resp = c.put(f"{base_url}/sequences/{seq_id}", json={"sequence_duration": 1234})
assert resp.status_code == 200
assert resp.json()["sequence_duration"] == 1234
resp = c.delete(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200
# Scenes.
unique_scene_name = f"pytest-scene-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/scenes", json={"name": unique_scene_name})
assert resp.status_code == 201
scenes_list = c.get(f"{base_url}/scenes").json()
scene_id = _find_id_by_field(scenes_list, "name", unique_scene_name)
resp = c.get(f"{base_url}/scenes/{scene_id}")
assert resp.status_code == 200
resp = c.put(f"{base_url}/scenes/{scene_id}", json={"name": unique_scene_name + "-updated"})
assert resp.status_code == 200
assert resp.json()["name"].endswith("-updated")
resp = c.delete(f"{base_url}/scenes/{scene_id}")
assert resp.status_code == 200
# Palettes.
colors = ["#112233", "#445566"]
resp = c.post(f"{base_url}/palettes", json={"colors": colors})
assert resp.status_code == 201
palette_payload = resp.json()
palette_id = str(palette_payload["id"])
resp = c.get(f"{base_url}/palettes/{palette_id}")
assert resp.status_code == 200
assert resp.json()["id"] == palette_id
resp = c.put(f"{base_url}/palettes/{palette_id}", json={"colors": ["#000000"]})
assert resp.status_code == 200
assert resp.json()["colors"] == ["#000000"]
resp = c.delete(f"{base_url}/palettes/{palette_id}")
assert resp.status_code == 200
# Devices (LED driver registry).
resp = c.get(f"{base_url}/devices")
assert resp.status_code == 200
assert resp.json() == {}
resp = c.post(f"{base_url}/devices", json={})
assert resp.status_code == 400
resp = c.post(
f"{base_url}/devices",
json={"name": "pytest-dev", "address": "aa:bb:cc:dd:ee:ff"},
)
assert resp.status_code == 201
dev_map = resp.json()
dev_id = next(iter(dev_map.keys()))
assert dev_id == "aabbccddeeff"
assert dev_map[dev_id]["name"] == "pytest-dev"
assert dev_map[dev_id]["id"] == dev_id
assert dev_map[dev_id]["type"] == "led"
assert dev_map[dev_id]["transport"] == "espnow"
assert dev_map[dev_id]["address"] == "aabbccddeeff"
resp = c.get(f"{base_url}/devices/{dev_id}")
assert resp.status_code == 200
assert resp.json()["name"] == "pytest-dev"
assert resp.json()["type"] == "led"
assert resp.json().get("connected") is None
resp = c.get(f"{base_url}/devices")
assert resp.status_code == 200
assert resp.json()[dev_id].get("connected") is None
sender.sent.clear()
resp = c.post(f"{base_url}/devices/{dev_id}/identify")
assert resp.status_code == 200
assert resp.json().get("message")
assert len(sender.sent) >= 1
first = json.loads(sender.sent[0][0])
assert "presets" in first and "select" in first
assert first["presets"]["__identify"]["p"] == "blink"
assert first["presets"]["__identify"]["d"] == 50
assert first["select"]["pytest-dev"] == ["__identify"]
deadline = time.monotonic() + 2.0
while len(sender.sent) < 2 and time.monotonic() < deadline:
time.sleep(0.02)
assert len(sender.sent) >= 2
second = json.loads(sender.sent[1][0])
assert second.get("select") == {"pytest-dev": ["off"]}
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi",
"type": "led",
"transport": "wifi",
"address": "192.168.50.10",
"mac": "102030405060",
},
)
assert resp.status_code == 201
wid = "102030405060"
assert wid in resp.json()
assert resp.json()[wid]["transport"] == "wifi"
assert resp.json()[wid]["address"] == "192.168.50.10"
resp = c.get(f"{base_url}/devices/{wid}")
assert resp.status_code == 200
assert resp.json().get("connected") is False
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi",
"transport": "wifi",
"address": "192.168.50.11",
"mac": "102030405061",
},
)
assert resp.status_code == 201
wid2 = "102030405061"
assert wid2 in resp.json()
assert resp.json()[wid2]["name"] == "pytest-wifi"
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi-dupmac",
"transport": "wifi",
"address": "192.168.50.99",
"mac": "102030405060",
},
)
assert resp.status_code == 409
resp = c.post(
f"{base_url}/devices",
json={"name": "no-mac-wifi", "transport": "wifi", "address": "192.168.50.12"},
)
assert resp.status_code == 400
resp = c.post(
f"{base_url}/devices",
json={"name": "bad-tr", "transport": "serial"},
)
assert resp.status_code == 400
resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": " "})
assert resp.status_code == 400
resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": "renamed"})
assert resp.status_code == 200
assert resp.json()["name"] == "renamed"
resp = c.put(f"{base_url}/devices/{wid}", json={"name": "renamed"})
assert resp.status_code == 200
assert resp.json()["name"] == "renamed"
resp = c.delete(f"{base_url}/devices/{wid2}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/devices/{wid}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/devices/{dev_id}")
assert resp.status_code == 200
# Patterns.
resp = c.get(f"{base_url}/patterns/definitions")
assert resp.status_code == 200
definitions = resp.json()
assert isinstance(definitions, dict)
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/patterns",
json={"name": pattern_id, "data": {"foo": "bar"}},
)
assert resp.status_code == 201
assert resp.json()["foo"] == "bar"
resp = c.get(f"{base_url}/patterns")
assert resp.status_code == 200
patterns_list = resp.json()
assert isinstance(patterns_list, dict)
# Runtime list merges repo ``db/pattern.json`` + driver ``.py`` names; test DB
# entries are still exposed on GET /patterns/<id> after POST.
assert "blink" in patterns_list or len(patterns_list) >= 1
resp = c.get(f"{base_url}/patterns/{pattern_id}")
assert resp.status_code == 200
assert resp.json()["foo"] == "bar"
resp = c.put(f"{base_url}/patterns/{pattern_id}", json={"baz": 1})
assert resp.status_code == 200
assert resp.json()["baz"] == 1
resp = c.delete(f"{base_url}/patterns/{pattern_id}")
assert resp.status_code == 200
# on/off are firmware-only in presets.py — no OTA file; API rejects serve/send/upload/driver.
resp = c.get(f"{base_url}/patterns/ota/file/on.py")
assert resp.status_code == 400
assert "error" in resp.json()
resp = c.post(f"{base_url}/patterns/off/send", json={})
assert resp.status_code == 400
assert "error" in resp.json()
resp = c.post(
f"{base_url}/patterns/upload",
json={"name": "on.py", "code": "class On:\n def run(self, p):\n yield\n"},
)
assert resp.status_code == 400
resp = c.post(
f"{base_url}/patterns/driver",
json={
"name": "off",
"code": "class Off:\n def run(self, p):\n yield\n",
},
)
assert resp.status_code == 400

View File

@@ -1,3 +1,7 @@
import pytest
pytest.skip("Legacy manual server script (not a pytest suite).", allow_module_level=True)
from microdot import Microdot
from src.profile import profile_app

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""
Manual test helper for pattern OTA send flow.
Examples:
python tests/test_pattern_ota_send.py --base-url http://led.local --pattern blink
python tests/test_pattern_ota_send.py --base-url http://127.0.0.1:8080 --pattern blink --device-id 102030405060
"""
import argparse
import json
import sys
from urllib import request, error
def _http_json(method, url, payload=None):
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = request.Request(url, data=data, method=method, headers=headers)
try:
with request.urlopen(req, timeout=15) as resp:
body = resp.read().decode("utf-8")
return resp.status, json.loads(body) if body else {}
except error.HTTPError as e:
body = e.read().decode("utf-8")
try:
parsed = json.loads(body) if body else {}
except Exception:
parsed = {"raw": body}
return e.code, parsed
def main():
parser = argparse.ArgumentParser(description="Test /patterns/<name>/send OTA flow.")
parser.add_argument(
"--base-url",
default="http://127.0.0.1",
help="Controller base URL (default: http://127.0.0.1)",
)
parser.add_argument(
"--pattern",
required=True,
help="Pattern name (without .py), e.g. blink",
)
parser.add_argument(
"--device-id",
default="",
help="Optional device id (MAC). If omitted, sends to all Wi-Fi devices.",
)
args = parser.parse_args()
base = args.base_url.rstrip("/")
pattern = args.pattern.strip()
if not pattern:
print("Pattern name is required.")
return 2
# Quick visibility before send.
status, patterns = _http_json("GET", f"{base}/patterns")
print(f"GET /patterns -> {status}")
if status != 200:
print(patterns)
return 1
if pattern not in patterns:
print(f"Pattern {pattern!r} not found in /patterns list.")
return 1
status, devices = _http_json("GET", f"{base}/devices")
print(f"GET /devices -> {status}")
if status != 200:
print(devices)
return 1
wifi_ids = [
did
for did, d in (devices or {}).items()
if isinstance(d, dict) and str(d.get("transport", "")).lower() == "wifi"
]
print(f"Wi-Fi devices in registry: {len(wifi_ids)}")
if wifi_ids:
print(" - " + "\n - ".join(wifi_ids))
payload = {"device_id": args.device_id} if args.device_id else {}
status, result = _http_json(
"POST", f"{base}/patterns/{pattern}/send", payload=payload
)
print(f"POST /patterns/{pattern}/send -> {status}")
print(json.dumps(result, indent=2))
if status != 200:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

89
tests/udp_server.py Normal file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""UDP echo server for testing the led-driver UDP client (MicroPython ESP32).
Listens on UDP, prints each datagram (peer + payload), sends the same bytes back.
Run on the Pi (or any host on the LAN):
python3 tests/udp_server.py
python3 tests/udp_server.py -p 8766 --bind 0.0.0.0
Pair with **`led-driver/tests/udp_client.py`**: the device broadcasts a hello; this server
echoes so the client learns the controller's **unicast IP** from the reply (firmware uses that
for HTTP to the web server only; it is not stored in settings). Some WiFi APs block broadcast between clients —
prefer a wired listener.
"""
from __future__ import annotations
import argparse
import json
import socket
import sys
DEFAULT_PORT = 8766
def main() -> int:
parser = argparse.ArgumentParser(description="UDP echo server for led-driver tests")
parser.add_argument(
"--bind",
default="0.0.0.0",
metavar="ADDR",
help="Address to bind (default: all interfaces)",
)
parser.add_argument(
"-p",
"--port",
type=int,
default=DEFAULT_PORT,
help=f"UDP port (default: {DEFAULT_PORT})",
)
args = parser.parse_args()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
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
try:
sock.bind((args.bind, args.port))
except OSError as e:
print(f"bind {args.bind!r}:{args.port} failed: {e}", file=sys.stderr)
return 1
print(f"UDP echo listening on {args.bind}:{args.port} (Ctrl+C to stop)")
while True:
try:
data, addr = sock.recvfrom(2048)
except KeyboardInterrupt:
print("\nStopping.")
return 0
client_ip, client_port = addr[0], addr[1]
text = data.decode("utf-8", errors="replace")
print(f"client_ip={client_ip} client_udp_port={client_port} ({len(data)} bytes)")
print(f" payload: {text!r}")
line = data.split(b"\n", 1)[0].strip()
if line:
try:
obj = json.loads(line.decode("utf-8"))
if isinstance(obj, dict) and obj.get("type") == "led":
print(
" hello: device_name=%r mac=%r v=%r"
% (obj.get("device_name"), obj.get("mac"), obj.get("v"))
)
except (UnicodeError, ValueError, TypeError):
pass
try:
sock.sendto(data, addr)
except OSError as e:
print(f" sendto failed: {e}", file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main())