41 Commits

Author SHA1 Message Date
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
63235c7822 fix(ui): enforce save semantics for default and preset chunks 2026-03-22 02:53:34 +13:00
5badf17719 refactor(ui): simplify modal interactions and refresh fixtures 2026-03-22 02:00:28 +13:00
4597573ac5 fix(ui): update preset send/default behavior in edit mode 2026-03-22 01:47:32 +13:00
1550122ced fix(ui): populate preset patterns when definitions are empty
Made-with: Cursor
2026-03-22 00:08:12 +13:00
b7c45fd72c docs(ui): switch user-facing spelling to colour
Made-with: Cursor
2026-03-22 00:00:12 +13:00
9479d0d292 chore(cursor): add commit and spelling rules
Made-with: Cursor
2026-03-21 23:54:33 +13:00
3698385af4 feat(ui): help sections, menu order, remove settings, send presets edit-only
Made-with: Cursor
2026-03-21 23:51:02 +13:00
ef968ebe39 docs: run/edit mode, profiles behavior, send presets
Made-with: Cursor
2026-03-21 23:51:00 +13:00
a5432db99a feat(ui): gate profile create/clone/delete to edit mode
Made-with: Cursor
2026-03-21 23:50:59 +13:00
764d918d5b data: update local db fixtures and browser test expectations
Made-with: Cursor
2026-03-21 23:15:55 +13:00
edadb40cb6 docs: rewrite API reference for current HTTP and driver flows
Made-with: Cursor
2026-03-21 23:15:44 +13:00
9323719a85 feat(ui): add run/edit workflow and improve preset color editing
Made-with: Cursor
2026-03-21 23:15:31 +13:00
91de705647 feat(profiles): seed new profiles and refresh tabs on apply
Made-with: Cursor
2026-03-21 23:15:19 +13:00
3ee7b74152 fix(api): stabilize palette and preset endpoints
Made-with: Cursor
2026-03-21 23:15:08 +13:00
98bbdcbb3d chore: add dev watch command to Pipfile scripts
Made-with: Cursor
2026-03-21 23:15:00 +13:00
a2abd3e833 data: refresh db JSON fixtures
Made-with: Cursor
2026-03-21 20:17:33 +13:00
550217c443 ui: data-bwignore on AP password fields for password managers
Made-with: Cursor
2026-03-21 20:17:33 +13:00
2d2032e8b9 esp32: log startup and UART receive for debugging
Made-with: Cursor
2026-03-21 20:17:33 +13:00
81bf4dded5 docs: update msg.json example payload
Made-with: Cursor
2026-03-21 20:17:33 +13:00
a75e27e3d2 feat: device model, API, static UI, and endpoint tests
Made-with: Cursor
2026-03-21 20:17:33 +13:00
13538c39a6 tests: skip browser tests when no driver; try Firefox after Chrome
Made-with: Cursor
2026-03-21 20:17:33 +13:00
7b724e9ce1 tests: point model tests at db/ and align palette assertions
Made-with: Cursor
2026-03-21 20:17:33 +13:00
aaca5435e9 chore: gitignore local settings.json (session secret)
Made-with: Cursor
2026-03-21 20:17:33 +13:00
b64dacc1c3 Stop ignoring esp32; drop esp32 rules from .gitignore
Made-with: Cursor
2026-03-21 20:08:24 +13:00
8689bdb6ef Restore esp32 MicroPython sources (main, benchmark_peers)
Adjust .gitignore to ignore esp32/* except *.py so firmware .bin stays untracked.

Made-with: Cursor
2026-03-21 19:59:52 +13:00
c178e87966 Ignore esp32 folder 2026-03-21 19:53:19 +13:00
dfe7ae50d2 Add led-tool and led-driver submodules 2026-03-21 19:52:59 +13:00
8e87559af6 Add led-tool and led-driver as submodules 2026-03-21 19:52:14 +13:00
aa3546e9ac Remove obsolete scripts and root config files
Drop clear-debug-log, install, run_web, send_empty_json, esp32 helpers,
and root msg.json/settings.json in favor of current layout.

Made-with: Cursor
2026-03-21 19:47:29 +13:00
b56af23cbf Add scripts: start, copy ESP32 main, install boot service
Made-with: Cursor
2026-03-15 23:43:27 +13:00
ac9fca8d4b Pi port: serial transport, addressed ESP-NOW bridge, port 80
- Run app on Raspberry Pi: serial to ESP32 bridge at 912000 baud, /dev/ttyS0
- Remove ESP-NOW/MicroPython-only code from src (espnow, p2p, wifi, machine/Pin)
- Transport: always send 6-byte MAC + payload; optional to/destination_mac in API and WebSocket
- Settings and model DB use project paths (no root); fix sys.print_exception for CPython
- Preset/settings controllers use get_current_sender(); template paths for cwd=src
- Pipfile: run from src, PORT from env; scripts for port 80 (setcap) and test
- ESP32 bridge: receive 6-byte addr + payload, LRU peer management (20 max), handle ESP_ERR_ESPNOW_EXIST
- Add esp32/main.py, esp32/benchmark_peers.py, scripts/setup-port80.sh, scripts/test-port80.sh

Made-with: Cursor
2026-03-15 17:16:07 +13:00
102 changed files with 8101 additions and 3776 deletions

26
.cursor/rules/commit.mdc Normal file
View File

@@ -0,0 +1,26 @@
---
description: Git commit messages and how to split work into commits
alwaysApply: true
---
# Commits
When preparing commits (especially when the user asks to commit):
1. **Prefer multiple commits** over one large commit when changes span distinct concerns (e.g. UI vs docs vs API). One logical unit per commit.
2. **Message format:** `type(scope): short imperative subject` (lowercase subject after the colon; no trailing period).
- **Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf` (use what fits).
- **Scope:** optional but encouraged — e.g. `ui`, `api`, `profiles`, `presets`, `esp32`.
3. **Subject line:** ~50 characters or less; describe *what* changed, not the ticket number alone.
4. **Body:** only when needed (breaking change, non-obvious rationale, or multiple bullets). Otherwise subject is enough.
**Examples**
- `feat(ui): gate profile delete to edit mode`
- `docs: document run vs edit in API`
- `fix(api): resolve preset delete route argument clash`
**Do not**
- Squash unrelated fixes and doc tweaks into one commit unless the user explicitly wants a single commit.
- Use vague messages like `update`, `fixes`, or `wip`.

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,10 @@
---
description: British spelling for user-facing text; technical identifiers stay as-is
alwaysApply: true
---
# Spelling: colour
- **User-facing strings** (Help modal, button labels, README prose, `docs/`, error messages shown in the UI): use **British English** — **colour**, **favour**, **behaviour**, etc., unless quoting existing product names.
- **Do not rename** existing code for spelling: **identifiers**, file names, URL paths, JSON keys, CSS properties (`color`), HTML attributes (`type="color"`), and API field names stay as they are (`color`, `colors`, `palette`, etc.) so nothing breaks.
- **New** UI copy and docs should follow **colour** in prose; new code symbols may still use `color` when matching surrounding APIs or conventions.

3
.gitignore vendored
View File

@@ -23,7 +23,8 @@ ENV/
Thumbs.db Thumbs.db
# Project specific # Project specific
docs/.help-print.html
settings.json
*.log *.log
*.db *.db
*.sqlite *.sqlite

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "led-driver"]
path = led-driver
url = git@git.technical.kiwi:technicalkiwi/led-driver.git
[submodule "led-tool"]
path = led-tool
url = git@git.technical.kiwi:technicalkiwi/led-tool.git

View File

@@ -12,8 +12,10 @@ watchfiles = "*"
requests = "*" requests = "*"
selenium = "*" selenium = "*"
adafruit-ampy = "*" adafruit-ampy = "*"
microdot = "*"
[dev-packages] [dev-packages]
pytest = "*"
[requires] [requires]
python_version = "3.12" python_version = "3.12"
@@ -22,3 +24,6 @@ python_version = "3.12"
web = "python /home/pi/led-controller/tests/web.py" web = "python /home/pi/led-controller/tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests" watch = "python -m watchfiles 'python tests/web.py' src tests"
install = "pipenv install" 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"

572
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "c963cd52164ac13fda5e6f3c5975bc14db6cea03ad4973de02ad91a0ab10d2ea" "sha256": "6cec0fe6dec67c9177363a558131f333153b6caa47e1ddeca303cb0d19954cf8"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -26,27 +26,19 @@
}, },
"anyio": { "anyio": {
"hashes": [ "hashes": [
"sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
"sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c" "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.10'",
"version": "==4.12.1" "version": "==4.13.0"
},
"async-generator": {
"hashes": [
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
],
"markers": "python_version >= '3.5'",
"version": "==1.10"
}, },
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
"sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==25.4.0" "version": "==26.1.0"
}, },
"bitarray": { "bitarray": {
"hashes": [ "hashes": [
@@ -159,19 +151,19 @@
}, },
"bitstring": { "bitstring": {
"hashes": [ "hashes": [
"sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a", "sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37",
"sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a" "sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.3.1" "version": "==4.4.0"
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
"sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120" "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2026.1.4" "version": "==2026.2.25"
}, },
"cffi": { "cffi": {
"hashes": [ "hashes": [
@@ -265,122 +257,138 @@
}, },
"charset-normalizer": { "charset-normalizer": {
"hashes": [ "hashes": [
"sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e",
"sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c",
"sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5",
"sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815",
"sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f",
"sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0",
"sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484",
"sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407",
"sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6",
"sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8",
"sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264",
"sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815",
"sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2",
"sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4",
"sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579",
"sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f",
"sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa",
"sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95",
"sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab",
"sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297",
"sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a",
"sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e",
"sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84",
"sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8",
"sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0",
"sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9",
"sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f",
"sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1",
"sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843",
"sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565",
"sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7",
"sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c",
"sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b",
"sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7",
"sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687",
"sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9",
"sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14",
"sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89",
"sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f",
"sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0",
"sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9",
"sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a",
"sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389",
"sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0",
"sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30",
"sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd",
"sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e",
"sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9",
"sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc",
"sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532",
"sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d",
"sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae",
"sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2",
"sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64",
"sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f",
"sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557",
"sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e",
"sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff",
"sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398",
"sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db",
"sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a",
"sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43",
"sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597",
"sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c",
"sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e",
"sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2",
"sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54",
"sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e",
"sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4",
"sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4",
"sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7",
"sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6",
"sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5",
"sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194",
"sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69",
"sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f",
"sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316",
"sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e",
"sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73",
"sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8",
"sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923",
"sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88",
"sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f",
"sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21",
"sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4",
"sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6",
"sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc",
"sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2",
"sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866",
"sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021",
"sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2",
"sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d",
"sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8",
"sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de",
"sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237",
"sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4",
"sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778",
"sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb",
"sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc",
"sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602",
"sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4",
"sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f",
"sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5",
"sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611",
"sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8",
"sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf",
"sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d",
"sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b",
"sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db",
"sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e",
"sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077",
"sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd",
"sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef",
"sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e",
"sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8",
"sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe",
"sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058",
"sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17",
"sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833",
"sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421",
"sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550",
"sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff",
"sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2",
"sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc",
"sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982",
"sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d",
"sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed",
"sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104",
"sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.4.4" "version": "==3.4.6"
}, },
"click": { "click": {
"hashes": [ "hashes": [
@@ -392,66 +400,65 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
"sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
"sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
"sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
"sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
"sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
"sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
"sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
"sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
"sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
"sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
"sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
"sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
"sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
"sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
"sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
"sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
"sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
"sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
"sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
"sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
"sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
"sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
"sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
"sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
"sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
"sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
"sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
"sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
"sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
"sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
"sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
"sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
"sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
"sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
"sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
"sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
"sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
"sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
"sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
"sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
"sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
"sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
"sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
"sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
"sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
"sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
"sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
"sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822" "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
], ],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.4" "version": "==46.0.5"
}, },
"esptool": { "esptool": {
"hashes": [ "hashes": [
"sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da" "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'", "version": "==5.2.0"
"version": "==5.1.0"
}, },
"h11": { "h11": {
"hashes": [ "hashes": [
@@ -469,14 +476,6 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==3.11" "version": "==3.11"
}, },
"importlib-metadata": {
"hashes": [
"sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb",
"sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"
],
"markers": "python_version >= '3.9'",
"version": "==8.7.1"
},
"intelhex": { "intelhex": {
"hashes": [ "hashes": [
"sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4", "sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4",
@@ -500,23 +499,22 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.1.2" "version": "==0.1.2"
}, },
"microdot": {
"hashes": [
"sha256:363e3ebfc80b7e0415779848c9332e4e7fb7bd365ee54d3620abffe42ed82946",
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
],
"index": "pypi",
"version": "==2.6.0"
},
"mpremote": { "mpremote": {
"hashes": [ "hashes": [
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4", "sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5" "sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.4'",
"version": "==1.27.0" "version": "==1.27.0"
}, },
"mypy-extensions": {
"hashes": [
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505",
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"
],
"markers": "python_version >= '3.8'",
"version": "==1.1.0"
},
"outcome": { "outcome": {
"hashes": [ "hashes": [
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
@@ -525,21 +523,13 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==1.3.0.post0" "version": "==1.3.0.post0"
}, },
"packaging": {
"hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
],
"markers": "python_version >= '3.8'",
"version": "==26.0"
},
"platformdirs": { "platformdirs": {
"hashes": [ "hashes": [
"sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
"sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31" "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==4.5.1" "version": "==4.9.4"
}, },
"pycparser": { "pycparser": {
"hashes": [ "hashes": [
@@ -559,12 +549,11 @@
}, },
"pyjwt": { "pyjwt": {
"hashes": [ "hashes": [
"sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
"sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469" "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'", "version": "==2.12.1"
"version": "==2.11.0"
}, },
"pyserial": { "pyserial": {
"hashes": [ "hashes": [
@@ -584,11 +573,11 @@
}, },
"python-dotenv": { "python-dotenv": {
"hashes": [ "hashes": [
"sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
"sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.10'",
"version": "==1.2.1" "version": "==1.2.2"
}, },
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
@@ -682,16 +671,15 @@
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.32.5" "version": "==2.32.5"
}, },
"rich": { "rich": {
"hashes": [ "hashes": [
"sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
"sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8" "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
], ],
"markers": "python_full_version >= '3.8.0'", "markers": "python_full_version >= '3.8.0'",
"version": "==14.3.2" "version": "==14.3.3"
}, },
"rich-click": { "rich-click": {
"hashes": [ "hashes": [
@@ -703,12 +691,11 @@
}, },
"selenium": { "selenium": {
"hashes": [ "hashes": [
"sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c", "sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa",
"sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729" "sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'", "version": "==4.41.0"
"version": "==4.40.0"
}, },
"sniffio": { "sniffio": {
"hashes": [ "hashes": [
@@ -725,20 +712,50 @@
], ],
"version": "==2.4.0" "version": "==2.4.0"
}, },
"tibs": {
"hashes": [
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
"sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e",
"sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7",
"sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb",
"sha256:1906729038b85c3b4c040aa28a456d85bc976d0c5007177350eb73374ffa0fd0",
"sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b",
"sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54",
"sha256:1f95d5db62960205a1e9eba73ce67dc14e7366ae080cd4e5b6f005ebd90faf02",
"sha256:29480bf03e3372a5f9cc59ea0541f76f8efd696d4f0d214715e94247c342a037",
"sha256:2a618de62004d9217d2d2ab0f7f9bbdd098c12642dc01f07b3fb00f0b5f3131a",
"sha256:42725200f1b02687ed6e6a1c01e0ec150dc829d21d901ffc74cc0ac4d821f57f",
"sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392",
"sha256:4b7510235379368b7523f624d46e0680f3706e3a3965877a6583cdcb598b8bac",
"sha256:501728d096e10d9a165aa526743d47418a6bbfd7b084fa47ecb22be7641d3edb",
"sha256:63255749f937c5e6fedcc7d54e7bd359aef711017e6855f373b0510a14ee2215",
"sha256:6a9feed5931b881809a950eca0e01e757113e2383a2af06a3e6982f110c869e2",
"sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f",
"sha256:77103a9f1af72ac4cf5006828d0fb21578d19ce55fd990e9a1c8e46fd549561f",
"sha256:7d6592ed93c6748acd39df484c1ee24d40ee247c2a20ca38ba03363506fd24f3",
"sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98",
"sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c",
"sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2",
"sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44",
"sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452",
"sha256:ace018a057459e3dccd06a4aae1c5c8cd57e352b263dcef534ae39bf3e03b5cf",
"sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3",
"sha256:b9535dc7b7484904a58b51bd8e64da7efbf1d8466ff7e84ed1d78f4ddc561c99",
"sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2",
"sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41",
"sha256:f5eea45851c960628a2bd29847765d55e19a687c5374456ad2c8cf6410eb1efa",
"sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f"
],
"markers": "python_version >= '3.8'",
"version": "==0.5.7"
},
"trio": { "trio": {
"hashes": [ "hashes": [
"sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b",
"sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5" "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==0.32.0" "version": "==0.33.0"
},
"trio-typing": {
"hashes": [
"sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3",
"sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264"
],
"version": "==0.10.0"
}, },
"trio-websocket": { "trio-websocket": {
"hashes": [ "hashes": [
@@ -748,20 +765,6 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==0.12.2" "version": "==0.12.2"
}, },
"types-certifi": {
"hashes": [
"sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f",
"sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a"
],
"version": "==2021.10.8.3"
},
"types-urllib3": {
"hashes": [
"sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f",
"sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"
],
"version": "==1.26.25.14"
},
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
@@ -771,9 +774,6 @@
"version": "==4.15.0" "version": "==4.15.0"
}, },
"urllib3": { "urllib3": {
"extras": [
"socks"
],
"hashes": [ "hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
@@ -894,7 +894,6 @@
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf" "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"websocket-client": { "websocket-client": {
@@ -912,15 +911,48 @@
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==1.3.2" "version": "==1.3.2"
},
"zipp": {
"hashes": [
"sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e",
"sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"
],
"markers": "python_version >= '3.9'",
"version": "==3.23.0"
} }
}, },
"develop": {} "develop": {
"iniconfig": {
"hashes": [
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730",
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"
],
"markers": "python_version >= '3.10'",
"version": "==2.3.0"
},
"packaging": {
"hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
],
"markers": "python_version >= '3.8'",
"version": "==26.0"
},
"pluggy": {
"hashes": [
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"
],
"markers": "python_version >= '3.9'",
"version": "==1.6.0"
},
"pygments": {
"hashes": [
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
],
"markers": "python_version >= '3.8'",
"version": "==2.19.2"
},
"pytest": {
"hashes": [
"sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b",
"sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"
],
"index": "pypi",
"version": "==9.0.2"
}
}
} }

View File

@@ -1,2 +1,37 @@
# led-controller # led-controller
LED controller web app for managing profiles, tabs, presets, and colour palettes, and sending commands to LED devices over the serial -> ESP-NOW bridge.
## Run
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
- Start app: `pipenv run run`
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
## UI modes
- **Run mode**: focused control view. Select tabs/presets and apply profiles. Editing actions are hidden.
- **Edit mode**: management view. Shows Tabs, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
## Profiles
- Applying a profile updates session scope and refreshes the active zone content.
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete).
- In **Edit mode**, Profiles supports create/clone/delete.
- Creating a profile always creates a populated `default` zone (starter presets).
- Optional **DJ zone** seeding creates:
- `dj` zone bound to device name `dj`
- starter DJ presets (rainbow, single colour, transition)
## Preset colours and palette linking
- In preset editor, selecting a colour picker value auto-adds it when the picker closes.
- Use **From Palette** to add a palette-linked preset colour.
- Linked colours are stored as palette references and shown with a `P` badge.
- When profile palette colours change, linked preset colours update across that profile.
## API docs
- Main API reference: `docs/API.md`

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env sh
rm -f /home/pi/led-controller/.cursor/debug.log

1
db/device.json Normal file
View File

@@ -0,0 +1 @@
{"aabbccddeeff": {"id": "aabbccddeeff", "name": "one", "type": "led", "transport": "espnow", "address": "aabbccddeeff", "default_pattern": null, "zones": []}, "f0f5bdfd78b8": {"id": "f0f5bdfd78b8", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.215", "default_pattern": null, "zones": []}}

View File

@@ -1,17 +1 @@
{ {"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}
"1": {
"name": "Main Group",
"devices": [
"1",
"2",
"3"
]
},
"2": {
"name": "Accent Group",
"devices": [
"4",
"5"
]
}
}

View File

@@ -1,12 +1 @@
{ {"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}
"1": [
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FFFFFF",
"#000000"
]
}

View File

@@ -1,276 +1 @@
{ {"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 5000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, 6, 2, 3]}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}}
"1": {
"name": "on",
"pattern": "on",
"colors": [
"#FFFFFF"
],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"2": {
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"3": {
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 2,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"4": {
"name": "transition",
"pattern": "transition",
"colors": [
"#FF0000",
"#00FF00",
"#0000FF"
],
"brightness": 255,
"delay": 500,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"5": {
"name": "chase",
"pattern": "chase",
"colors": [
"#FF0000",
"#0000FF"
],
"brightness": 255,
"delay": 200,
"auto": true,
"n1": 5,
"n2": 5,
"n3": 1,
"n4": 1,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"6": {
"name": "pulse",
"pattern": "pulse",
"colors": [
"#00FF00"
],
"brightness": 255,
"delay": 500,
"auto": true,
"n1": 1000,
"n2": 500,
"n3": 1000,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"7": {
"name": "circle",
"pattern": "circle",
"colors": [
"#FFA500",
"#800080"
],
"brightness": 255,
"delay": 200,
"auto": true,
"n1": 2,
"n2": 10,
"n3": 2,
"n4": 5,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"8": {
"name": "blink",
"pattern": "blink",
"colors": [
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00"
],
"brightness": 255,
"delay": 1000,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"9": {
"name": "warm white",
"pattern": "on",
"colors": ["#FFF5E6"],
"brightness": 200,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"10": {
"name": "cool white",
"pattern": "on",
"colors": ["#E6F2FF"],
"brightness": 200,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"11": {
"name": "red",
"pattern": "on",
"colors": ["#FF0000"],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"12": {
"name": "blue",
"pattern": "on",
"colors": ["#0000FF"],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"13": {
"name": "rainbow slow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 150,
"auto": true,
"n1": 1,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"14": {
"name": "pulse slow",
"pattern": "pulse",
"colors": ["#FF6600"],
"brightness": 255,
"delay": 800,
"auto": true,
"n1": 2000,
"n2": 1000,
"n3": 2000,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"15": {
"name": "blink red green",
"pattern": "blink",
"colors": ["#FF0000", "#00FF00"],
"brightness": 255,
"delay": 500,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
}
}

View File

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

View File

@@ -1,30 +1 @@
{ {"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}}
"1": {
"group_name": "Main Group",
"presets": [
"1",
"2"
],
"sequence_duration": 3000,
"sequence_transition": 500,
"sequence_loop": true,
"sequence_repeat_count": 0,
"sequence_active": false,
"sequence_index": 0,
"sequence_start_time": 0
},
"2": {
"group_name": "Accent Group",
"presets": [
"2",
"3"
],
"sequence_duration": 2000,
"sequence_transition": 300,
"sequence_loop": true,
"sequence_repeat_count": 0,
"sequence_active": false,
"sequence_index": 0,
"sequence_start_time": 0
}
}

View File

@@ -1,27 +0,0 @@
{
"1": {
"name": "default",
"names": [
"1","2","3","4","5","6","7","8"
],
"presets": [
[
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15"
]
]
}
}

1
db/zone.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"name": "default", "names": ["e", "c", "d", "a"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "10", "11"], ["9", "12", "1"], ["13", "37", "6"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37", "6"], "default_preset": "15"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

View File

@@ -1,263 +1,341 @@
# LED Driver ESPNow API Documentation # LED Controller API
This document describes the ESPNow message format for controlling LED driver devices. This document covers:
## Message Format 1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
2. **LED driver JSON** — the compact message format sent over the serial→ESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
All messages are JSON objects sent via ESPNow with the following structure: Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
---
## UI behavior notes
The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (zone/preset selection and profile apply).
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
Profiles are available in both modes, but behavior differs:
- **Run mode**: profile **apply** only.
- **Edit mode**: profile **create/clone/delete/apply**.
`POST /presets/send` is wired to the **Send Presets** UI action, which is exposed in Edit mode.
---
## Session and scoping
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
---
## Static pages and assets
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Main UI (`templates/index.html`) |
| GET | `/settings` | Settings page (`templates/settings.html`) |
| GET | `/favicon.ico` | Empty response (204) |
| GET | `/static/<path>` | Static files under `src/static/` |
---
## WebSocket: `/ws`
Connect to **`ws://<host>:<port>/ws`**.
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
- Send **non-JSON text**: forwarded as raw bytes with the default address.
- On send failure, the server may reply with `{"error": "Send failed"}`.
---
## HTTP API by resource
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
### Settings — `/settings`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
| GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| 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 tabs 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`**, **`tabs`** | Optional, as before. |
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`**, **`tabs`**. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
| DELETE | `/devices/<id>` | Remove device. |
### Profiles — `/profiles`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
| PUT | `/profiles/current` | Update the current profile (from session). |
| PUT | `/profiles/<id>` | Update profile by id. |
| DELETE | `/profiles/<id>` | Delete profile. |
### Presets — `/presets`
Scoped to **current profile** in session (see above).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
| DELETE | `/presets/<id>` | Delete preset. |
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
**`POST /presets/send` body:**
```json
{
"preset_ids": ["1", "2"],
"save": true,
"default": "1",
"destination_mac": "aabbccddeeff"
}
```
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
Stored preset records can include:
- `colors`: resolved hex colours for editor/display.
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
### Tabs — `/zones`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/zones` | `tabs`, `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`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/palettes` | Map of id → colour list. |
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
| PUT | `/palettes/<id>` | Update colours (`name` ignored). |
| DELETE | `/palettes/<id>` | Delete palette. |
### Groups — `/groups`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/groups` | All groups. |
| GET | `/groups/<id>` | One group. |
| POST | `/groups` | Create; optional `name` and fields. |
| PUT | `/groups/<id>` | Update. |
| DELETE | `/groups/<id>` | Delete. |
### Scenes — `/scenes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scenes` | All scenes. |
| GET | `/scenes/<id>` | One scene. |
| POST | `/scenes` | Create (body JSON stored on scene). |
| PUT | `/scenes/<id>` | Update. |
| DELETE | `/scenes/<id>` | Delete. |
### Sequences — `/sequences`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/sequences` | All sequences. |
| GET | `/sequences/<id>` | One sequence. |
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
| PUT | `/sequences/<id>` | Update. |
| DELETE | `/sequences/<id>` | Delete. |
### Patterns — `/patterns`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
| GET | `/patterns` | All pattern records. |
| GET | `/patterns/<id>` | One pattern. |
| POST | `/patterns` | Create (`name`, optional `data`). |
| PUT | `/patterns/<id>` | Update. |
| DELETE | `/patterns/<id>` | Delete. |
---
## LED driver message format (transport / ESP-NOW)
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
### Top-level fields
```json ```json
{ {
"v": "1", "v": "1",
"presets": { ... }, "presets": { },
"select": { ... } "select": { },
"save": true,
"default": "preset_id",
"b": 255
} }
``` ```
### Version Field - **`v`** (required): Must be `"1"` or the driver ignores the message.
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
- **`default`**: Preset id string to use as startup default on the device.
- **`b`**: Optional **global** brightness 0255 (driver applies this in addition to per-preset brightness).
- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored. ### Preset object (wire / driver keys)
## Presets On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings. | Key | Meaning | Notes |
|-----|---------|--------|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
| `c` | Colours | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
| `d` | Delay ms | Default 100 |
| `b` | Preset brightness | 0255; combined with global `b` on the device |
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
| `n1``n6` | Pattern parameters | See below |
### Preset Structure The HTTP apps **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
```json ### Pattern-specific parameters (`n1``n6`)
{
"presets": {
"preset_name": {
"pattern": "pattern_type",
"colors": ["#RRGGBB", ...],
"delay": 100,
"brightness": 127,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0
}
}
}
```
### Preset Fields
- **`pattern`** (required): Pattern type. Options:
- `"off"` - Turn off all LEDs
- `"on"` - Solid color
- `"blink"` - Blinking pattern
- `"rainbow"` - Rainbow color cycle
- `"pulse"` - Pulse/fade pattern
- `"transition"` - Color transition
- `"chase"` - Chasing pattern
- `"circle"` - Circle loading pattern
- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
- Colors are automatically converted from hex to RGB and reordered based on device color order setting
- Supports multiple colors for patterns that use them
- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100`
- **`brightness`** (optional): Brightness level (0-255). Default: `127`
- **`auto`** (optional): Auto mode flag. Default: `true`
- `true`: Pattern runs continuously
- `false`: Pattern advances one step per beat (manual mode)
- **`n1` through `n6`** (optional): Pattern-specific numeric parameters. Default: `0`
- See pattern-specific documentation below
### Pattern-Specific Parameters
#### Rainbow #### Rainbow
- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1` - **`n1`**: Step increment on the colour wheel per update (default 1).
#### Pulse #### Pulse
- **`n1`**: Attack time in milliseconds (fade in) - **`n1`**: Attack (fade in) ms
- **`n2`**: Hold time in milliseconds (full brightness) - **`n2`**: Hold ms
- **`n3`**: Decay time in milliseconds (fade out) - **`n3`**: Decay (fade out) ms
- **`delay`**: Delay time in milliseconds (off between pulses) - **`d`**: Off time between pulses ms
#### Transition #### Transition
- **`delay`**: Transition duration in milliseconds - **`d`**: Transition duration ms
#### Chase #### Chase
- **`n1`**: Number of LEDs with first color - **`n1`**: LEDs with first colour
- **`n2`**: Number of LEDs with second color - **`n2`**: LEDs with second colour
- **`n3`**: Movement amount on even steps (can be negative) - **`n3`**: Movement on even steps (may be negative)
- **`n4`**: Movement amount on odd steps (can be negative) - **`n4`**: Movement on odd steps (may be negative)
#### Circle #### Circle
- **`n1`**: Head movement rate (LEDs per second) - **`n1`**: Head speed (LEDs/s)
- **`n2`**: Maximum length - **`n2`**: Max length
- **`n3`**: Tail movement rate (LEDs per second) - **`n3`**: Tail speed (LEDs/s)
- **`n4`**: Minimum length - **`n4`**: Min length
## Select Messages ### Select messages
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
### Select Format
```json ```json
{ {
"select": { "select": {
"device_name": ["preset_name"], "device_name": ["preset_id"],
"device_name2": ["preset_name2", step_value] "other_device": ["preset_id", 10]
} }
} }
``` ```
### Select Fields - One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
- Two elements: explicit **step** for sync.
- **`select`**: Object mapping device names to selection lists ### Beat and sync behavior
- **Key**: Device name (as configured in device settings)
- **Value**: List with one or two elements:
- `["preset_name"]` - Select preset (uses default step behavior)
- `["preset_name", step]` - Select preset with explicit step value (for synchronization)
### Step Synchronization - Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
The step value allows precise synchronization across multiple devices: ### Example (compact preset map)
- **Without step**: `["preset_name"]`
- If switching to different preset: step resets to 0
- If selecting "off" pattern: step resets to 0
- If selecting same preset (beat): step is preserved, pattern restarts
- **With step**: `["preset_name", 10]`
- Explicitly sets step to the specified value
- Useful for synchronizing multiple devices to the same step
### Beat Functionality
Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator:
- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat
- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat
Example beat sequence:
```json
// Beat 1
{"select": {"device1": ["rainbow_preset"]}}
// Beat 2 (same preset = beat)
{"select": {"device1": ["rainbow_preset"]}}
// Beat 3
{"select": {"device1": ["rainbow_preset"]}}
```
## Synchronization
### Using "off" Pattern
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
```json
{
"select": {
"device1": ["off"],
"device2": ["off"]
}
}
```
After all devices are "off", switching to a pattern ensures they all start from step 0:
```json
{
"select": {
"device1": ["rainbow_preset"],
"device2": ["rainbow_preset"]
}
}
```
### Using Step Parameter
For precise synchronization, use the step parameter:
```json
{
"select": {
"device1": ["rainbow_preset", 10],
"device2": ["rainbow_preset", 10],
"device3": ["rainbow_preset", 10]
}
}
```
All devices will start at step 10 and advance together on subsequent beats.
## Complete Example
```json ```json
{ {
"v": "1", "v": "1",
"save": true,
"presets": { "presets": {
"red_blink": { "1": {
"pattern": "blink", "name": "Red blink",
"colors": ["#FF0000"], "p": "blink",
"delay": 200, "c": ["#FF0000"],
"brightness": 255, "d": 200,
"auto": true "b": 255,
}, "a": true,
"rainbow_manual": { "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
"pattern": "rainbow",
"delay": 100,
"n1": 2,
"auto": false
},
"pulse_slow": {
"pattern": "pulse",
"colors": ["#00FF00"],
"delay": 500,
"n1": 1000,
"n2": 500,
"n3": 1000,
"auto": false
} }
}, },
"select": { "select": {
"device1": ["red_blink"], "living-room": ["1"]
"device2": ["rainbow_manual", 0],
"device3": ["pulse_slow"]
} }
} }
``` ```
## Message Processing ---
1. **Version Check**: Messages with `v != "1"` are rejected ## Processing summary (driver)
2. **Preset Processing**: Presets are created or updated (upsert behavior)
3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order
4. **Selection**: Devices select their assigned preset, optionally with step value
## Best Practices 1. Reject if `v != "1"`.
2. Apply optional top-level **`b`** (global brightness).
3. For each entry in **`presets`**, normalize colours and upsert preset by id.
4. If this devices **`name`** appears in **`select`**, run selection (optional step).
5. If **`default`** is set, store startup preset id.
6. If **`save`** is set, persist presets.
1. **Always include version**: Set `"v": "1"` in all messages ---
2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns
3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns
4. **Step for precision**: Use step parameter when exact synchronization is required
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
## Error Handling ## Error handling (HTTP)
- Invalid version: Message is ignored Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
- Missing preset: Selection fails, device keeps current preset
- Invalid pattern: Selection fails, device keeps current preset ---
- Missing colors: Pattern uses default white color
- Invalid step: Step value is used as-is (may cause unexpected behavior)
## Notes ## Notes
- Colors are automatically converted from hex strings to RGB tuples - **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
- Color order reordering happens automatically based on device settings - For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).
- Step counter wraps around (0-255 for rainbow, unbounded for others)
- Manual mode patterns stop after one step/cycle, waiting for next beat
- Auto mode patterns run continuously until changed

View File

@@ -44,7 +44,7 @@ The LED Driver system is a MicroPython-based application for controlling LED str
- Pattern configuration and control (patterns run on remote devices) - Pattern configuration and control (patterns run on remote devices)
- Real-time brightness and speed control - Real-time brightness and speed control
- Global brightness setting (system-wide brightness multiplier) - Global brightness setting (system-wide brightness multiplier)
- Multi-color support with customizable color palettes - Multi-colour support with customizable colour palettes
- Device grouping for synchronized control - Device grouping for synchronized control
- Preset system for saving and loading pattern configurations - Preset system for saving and loading pattern configurations
- Profile and Scene system for complex lighting setups - Profile and Scene system for complex lighting setups
@@ -239,7 +239,7 @@ Primary interface for real-time LED control and monitoring.
- **Grid Layout:** 4-column responsive grid - **Grid Layout:** 4-column responsive grid
- Pattern Selection Card - Pattern Selection Card
- Brightness & Speed Card - Brightness & Speed Card
- Color Selection Card - Colour Selection Card
- Device Status Card - Device Status Card
- **Action Bar:** Apply and Save buttons - **Action Bar:** Apply and Save buttons
@@ -273,12 +273,12 @@ Primary interface for real-time LED control and monitoring.
- **Default:** 100ms - **Default:** 100ms
- **Step:** 10ms increments - **Step:** 10ms increments
**Color Selection** **Colour Selection**
- **Type:** Color picker inputs (HTML5 color input) - **Type:** Colour picker inputs (HTML5 colour input)
- **Quantity:** Multiple colors (minimum 2, expandable) - **Quantity:** Multiple colours (minimum 2, expandable)
- **Format:** Hex color codes (e.g., #FF0000) - **Format:** Hex colour codes (e.g., #FF0000)
- **Display:** Large color swatches (60x60px) - **Display:** Large colour swatches (60x60px)
- **Action:** "Add Color" button for additional colors - **Action:** "Add Colour" button for additional colours
**Device Status List** **Device Status List**
- **Type:** List of connected devices - **Type:** List of connected devices
@@ -295,7 +295,7 @@ Primary interface for real-time LED control and monitoring.
- **Save to Device:** Persist settings to device storage - **Save to Device:** Persist settings to device storage
#### Design Specifications #### Design Specifications
- **Color Scheme:** Purple gradient background (#667eea to #764ba2) - **Colour Scheme:** Purple gradient background (#667eea to #764ba2)
- **Cards:** White background, rounded corners (12px), shadow - **Cards:** White background, rounded corners (12px), shadow
- **Hover Effects:** Card lift (translateY -2px), increased shadow - **Hover Effects:** Card lift (translateY -2px), increased shadow
- **Typography:** System font stack, 1.25rem headings - **Typography:** System font stack, 1.25rem headings
@@ -351,9 +351,9 @@ Manage connected devices and create/manage device groups.
#### Layout #### Layout
- **Header:** Title with "Add Device" button - **Header:** Title with "Add Device" button
- **Tabs:** Devices and Groups tabs - **Tabs:** Devices and Groups tabs
- **Content Area:** Tab-specific content - **Content Area:** Zone-specific content
#### Devices Tab #### Devices Zone
**Device List** **Device List**
- **Display:** List of all known devices - **Display:** List of all known devices
@@ -375,7 +375,7 @@ Manage connected devices and create/manage device groups.
- **Actions:** Cancel, Save - **Actions:** Cancel, Save
- **Note:** Only one master device per system. Adding a new master will demote existing master to slave. - **Note:** Only one master device per system. Adding a new master will demote existing master to slave.
#### Groups Tab #### Groups Zone
**Group List** **Group List**
- **Display:** List of all device groups - **Display:** List of all device groups
@@ -397,7 +397,7 @@ Manage connected devices and create/manage device groups.
- **Actions:** Cancel, Create - **Actions:** Cancel, Create
#### Design Specifications #### 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 - **List Items:** Bordered cards with hover effects
- **Modal:** Centered overlay with white card, shadow - **Modal:** Centered overlay with white card, shadow
- **Status Badges:** Colored pills (green for online, red for offline) - **Status Badges:** Colored pills (green for online, red for offline)
@@ -509,7 +509,7 @@ Comprehensive device configuration interface.
- Device Name (text input) - Device Name (text input)
- LED Pin (number input, 0-40) - LED Pin (number input, 0-40)
- Number of LEDs (number input, 1-1000) - Number of LEDs (number input, 1-1000)
- Color Order (visual selector with 6 options: RGB, RBG, GRB, GBR, BRG, BGR) - Colour Order (visual selector with 6 options: RGB, RBG, GRB, GBR, BRG, BGR)
**2. Pattern Settings** **2. Pattern Settings**
- Pattern (dropdown selection) - Pattern (dropdown selection)
@@ -577,16 +577,16 @@ Comprehensive device configuration interface.
- Range: Slider with real-time value display - Range: Slider with real-time value display
- Select: Dropdown menu - Select: Dropdown menu
- Checkbox: Toggle switch - Checkbox: Toggle switch
- Color: HTML5 color picker - Colour: HTML5 colour picker
**Color Order Selector** **Colour Order Selector**
- **Type:** Visual button grid - **Type:** Visual button grid
- **Options:** RGB, RBG, GRB, GBR, BRG, BGR - **Options:** RGB, RBG, GRB, GBR, BRG, BGR
- **Display:** Color boxes showing order (R=red, G=green, B=blue) - **Display:** Colour boxes showing order (R=red, G=green, B=blue)
- **Selection:** Single selection with visual feedback - **Selection:** Single selection with visual feedback
#### Design Specifications #### Design Specifications
- **Section Headers:** Purple color (#667eea), 1.5rem font, bottom border - **Section Headers:** Purple colour (#667eea), 1.5rem font, bottom border
- **Form Groups:** 24px spacing between fields - **Form Groups:** 24px spacing between fields
- **Labels:** Bold, 500 weight, dark gray (#333) - **Labels:** Bold, 500 weight, dark gray (#333)
- **Help Text:** Small gray text below inputs - **Help Text:** Small gray text below inputs
@@ -611,7 +611,7 @@ Save, load, and manage preset configurations for quick pattern switching.
Each preset card displays: Each preset card displays:
- **Name:** Preset name (bold, 1.25rem) - **Name:** Preset name (bold, 1.25rem)
- **Pattern Badge:** Current pattern type - **Pattern Badge:** Current pattern type
- **Color Preview:** Swatches showing preset colors - **Colour Preview:** Swatches showing preset colours
- **Quick Info:** Delay and brightness values - **Quick Info:** Delay and brightness values
- **Actions:** Apply, Edit, Delete buttons - **Actions:** Apply, Edit, Delete buttons
@@ -620,7 +620,7 @@ Each preset card displays:
**Fields:** **Fields:**
- Preset Name (text input, required) - Preset Name (text input, required)
- Pattern (dropdown selection) - Pattern (dropdown selection)
- Colors (multiple color pickers, minimum 2) - Colours (multiple colour pickers, minimum 2)
- Delay (slider, 10-1000ms) - Delay (slider, 10-1000ms)
- Step Offset (number input, optional, default: 0) - Step Offset (number input, optional, default: 0)
- Tooltip: "Step offset for group synchronization. Applied per device when preset is used in a group." - Tooltip: "Step offset for group synchronization. Applied per device when preset is used in a group."
@@ -667,7 +667,7 @@ Each preset card displays:
#### Design Specifications #### Design Specifications
- **Card Style:** White background, rounded corners, shadow - **Card Style:** White background, rounded corners, shadow
- **Pattern Badge:** Colored pill with pattern name - **Pattern Badge:** Colored pill with pattern name
- **Color Swatches:** 40x40px squares in card header - **Colour Swatches:** 40x40px squares in card header
- **Hover Effect:** Card lift, border highlight - **Hover Effect:** Card lift, border highlight
- **Selected State:** Purple border, subtle background tint - **Selected State:** Purple border, subtle background tint
@@ -681,7 +681,7 @@ Patterns are configured on the controller and sent to remote devices for executi
- **Pattern Type:** Identifier for the pattern (e.g., "on", "off", "blink", "chase", "pulse", "rainbow", etc.) - **Pattern Type:** Identifier for the pattern (e.g., "on", "off", "blink", "chase", "pulse", "rainbow", etc.)
- **Pattern Parameters:** Numeric parameters (N1-N8) that configure pattern-specific behavior - **Pattern Parameters:** Numeric parameters (N1-N8) that configure pattern-specific behavior
- **Colors:** Color palette for the pattern - **Colours:** Colour palette for the pattern
- **Timing:** Delay and speed settings - **Timing:** Delay and speed settings
**Note:** Pattern execution happens on the remote devices. The controller sends configuration commands to devices. **Note:** Pattern execution happens on the remote devices. The controller sends configuration commands to devices.
@@ -698,7 +698,7 @@ Pattern-specific numeric parameters:
#### Overview #### Overview
Presets allow users to save complete pattern configurations for quick recall and application. A preset encapsulates all pattern settings including pattern type, colors, timing, and all pattern parameters. Presets allow users to save complete pattern configurations for quick recall and application. A preset encapsulates all pattern settings including pattern type, colours, timing, and all pattern parameters.
**Note:** Presets are optional. Devices can be controlled directly without presets. **Note:** Presets are optional. Devices can be controlled directly without presets.
@@ -708,7 +708,7 @@ A preset contains the following fields:
- **name** (string, required): Unique identifier for the preset - **name** (string, required): Unique identifier for the preset
- **pattern** (string, required): Pattern type identifier (sent to remote devices) - **pattern** (string, required): Pattern type identifier (sent to remote devices)
- **colors** (array of strings, required): Array of hex color codes (minimum 2 colors) - **colours** (array of strings, required): Array of hex colour codes (minimum 2 colours)
- **delay** (integer, required): Delay in milliseconds (10-1000) - **delay** (integer, required): Delay in milliseconds (10-1000)
- **n1** (integer, optional): Pattern parameter 1 (0-255, default: 0) - **n1** (integer, optional): Pattern parameter 1 (0-255, default: 0)
- **n2** (integer, optional): Pattern parameter 2 (0-255, default: 0) - **n2** (integer, optional): Pattern parameter 2 (0-255, default: 0)
@@ -889,7 +889,7 @@ A preset contains the following fields:
#### Group Properties #### Group Properties
- **Name:** Unique group identifier - **Name:** Unique group identifier
- **Devices:** List of device names (can include master and/or slaves) - **Devices:** List of device names (can include master and/or slaves)
- **Settings:** Pattern, delay, colors - **Settings:** Pattern, delay, colours
- **Step Offset:** Per-device step offset sent to devices for synchronized patterns (integer, can be negative) - **Step Offset:** Per-device step offset sent to devices for synchronized patterns (integer, can be negative)
- Each device in group can receive different step offset - Each device in group can receive different step offset
- Creates wave/chase effect across multiple LED strips - Creates wave/chase effect across multiple LED strips
@@ -953,7 +953,7 @@ Byte 1: Flags (bit 0: names, bit 1: groups, bit 2: settings, bit 3: save)
|-----|------|-------------|--------------| |-----|------|-------------|--------------|
| `pt` | string | Pattern type | on, off, blink, chase, circle, pulse, rainbow, transition | | `pt` | string | Pattern type | on, off, blink, chase, circle, pulse, rainbow, transition |
| `pm` | string | Pattern mode | auto, single_shot | | `pm` | string | Pattern mode | auto, single_shot |
| `cl` | array | Colors (hex strings) | Array of hex color codes | | `cl` | array | Colours (hex strings) | Array of hex colour codes |
| `br` | int | Global brightness | 0-100 | | `br` | int | Global brightness | 0-100 |
| `dl` | int | Delay (ms) | 10-1000 | | `dl` | int | Delay (ms) | 10-1000 |
| `n1` | int | Parameter 1 | 0-255 | | `n1` | int | Parameter 1 | 0-255 |
@@ -966,7 +966,7 @@ Byte 1: Flags (bit 0: names, bit 1: groups, bit 2: settings, bit 3: save)
| `n8` | int | Parameter 8 | 0-255 | | `n8` | int | Parameter 8 | 0-255 |
| `led_pin` | int | GPIO pin | 0-40 | | `led_pin` | int | GPIO pin | 0-40 |
| `num_leds` | int | LED count | 1-1000 | | `num_leds` | int | LED count | 1-1000 |
| `color_order` | string | Color order | rgb, rbg, grb, gbr, brg, bgr | | `color_order` | string | Colour order | rgb, rbg, grb, gbr, brg, bgr |
| `name` | string | Device name | Any string | | `name` | string | Device name | Any string |
| `brightness` | int | Global brightness | 0-100 | | `brightness` | int | Global brightness | 0-100 |
| `delay` | int | Delay | 10-1000 | | `delay` | int | Delay | 10-1000 |
@@ -1247,7 +1247,7 @@ CREATE TABLE IF NOT EXISTS presets (
**Preset Fields:** **Preset Fields:**
- `name` (string, required): Unique preset identifier - `name` (string, required): Unique preset identifier
- `pattern` (string, required): Pattern type - `pattern` (string, required): Pattern type
- `colors` (array of strings, required): Hex color codes (minimum 2) - `colors` (array of strings, required): Hex colour codes (minimum 2)
- `delay` (integer, required): Delay in milliseconds (10-1000) - `delay` (integer, required): Delay in milliseconds (10-1000)
- `n1` through `n8` (integer, optional): Pattern parameters (0-255, default: 0) - `n1` through `n8` (integer, optional): Pattern parameters (0-255, default: 0)
@@ -1289,7 +1289,7 @@ CREATE TABLE IF NOT EXISTS presets (
**POST /api/presets** **POST /api/presets**
- Create a new preset - Create a new preset
- Body: Preset object (name, pattern, colors, delay, n1-n8) - Body: Preset object (name, pattern, colours, delay, n1-n8)
- Response: Created preset object - Response: Created preset object
**GET /api/presets/{name}** **GET /api/presets/{name}**
@@ -1495,7 +1495,7 @@ peak_mem = usqlite.mem_peak()
### Flow 2: Create Device Group ### 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 2. User clicks "Create Group", enters name, selects pattern/settings
3. User selects devices to add (can include master), clicks "Create" 3. User selects devices to add (can include master), clicks "Create"
4. Group appears in list 4. Group appears in list
@@ -1506,7 +1506,7 @@ peak_mem = usqlite.mem_peak()
1. User navigates to Settings page 1. User navigates to Settings page
2. User modifies settings in sections: 2. User modifies settings in sections:
- Basic Settings (pin, LED count, color order) - Basic Settings (pin, LED count, colour order)
- Pattern Settings (pattern, delay) - Pattern Settings (pattern, delay)
- Global Brightness - Global Brightness
- Advanced Settings (N1-N8 parameters) - Advanced Settings (N1-N8 parameters)
@@ -1519,7 +1519,7 @@ peak_mem = usqlite.mem_peak()
### Flow 4: Multi-Device Control ### Flow 4: Multi-Device Control
1. User selects multiple devices or a group 1. User selects multiple devices or a group
2. User changes pattern/colors/global brightness 2. User changes pattern/colours/global brightness
3. User clicks "Apply Settings" 3. User clicks "Apply Settings"
4. System sends message targeting selected devices/groups 4. System sends message targeting selected devices/groups
5. All targeted devices update simultaneously 5. All targeted devices update simultaneously
@@ -1585,7 +1585,7 @@ peak_mem = usqlite.mem_peak()
## Design Guidelines ## Design Guidelines
### Color Palette ### Colour Palette
- **Primary:** Purple gradient `#667eea` to `#764ba2`, Light gradient `#f5f7fa` to `#c3cfe2` - **Primary:** Purple gradient `#667eea` to `#764ba2`, Light gradient `#f5f7fa` to `#c3cfe2`
- **UI:** Primary `#667eea`, Hover `#5568d3`, Secondary `#e0e0e0` - **UI:** Primary `#667eea`, Hover `#5568d3`, Secondary `#e0e0e0`
@@ -1612,8 +1612,8 @@ peak_mem = usqlite.mem_peak()
- Disabled: 50% opacity, no pointer events - Disabled: 50% opacity, no pointer events
**Inputs:** **Inputs:**
- Focus: Border color changes to primary purple - Focus: Border colour changes to primary purple
- Hover: Slight border color change - Hover: Slight border colour change
- Error: Red border - Error: Red border
**Cards:** **Cards:**
@@ -1738,7 +1738,7 @@ peak_mem = usqlite.mem_peak()
- Validation - Validation
**Preset Management:** **Preset Management:**
- Preset creation with all fields (name, pattern, colors, delay, n1-n8) - Preset creation with all fields (name, pattern, colours, delay, n1-n8)
- Preset loading and application - Preset loading and application
- Preset editing and deletion - Preset editing and deletion
- Name uniqueness validation - Name uniqueness validation
@@ -1758,7 +1758,7 @@ peak_mem = usqlite.mem_peak()
- Configuration parameters are properly formatted - Configuration parameters are properly formatted
**Preset Application:** **Preset Application:**
- Preset loads all parameters correctly (pattern, colors, delay, n1-n8) - Preset loads all parameters correctly (pattern, colours, delay, n1-n8)
- Preset applies to single device - Preset applies to single device
- Preset applies to device group - Preset applies to device group
- Preset values match saved configuration - Preset values match saved configuration

112
docs/help.md Normal file
View File

@@ -0,0 +1,112 @@
# LED controller — user guide
This page describes the **main web UI** served from the Raspberry Pi app: profiles, tabs, presets, colour palettes, and sending commands to LED devices over the serial → ESP-NOW bridge.
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
Figures below are **schematic** (layout and ideas), not pixel-perfect screenshots.
---
## Run mode and Edit mode
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
![Schematic: zone buttons on the left; Profiles, Tabs, 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: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles.
---
## Tabs
- **Select a 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.
- **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder).
- **Brightness slider** (per 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. Tabs 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) is a **read-only reference**: pattern names and typical **delay** ranges from the pattern definitions. It does not change device behaviour by itself; patterns are chosen inside the preset editor.
---
## Colour palette
**Colour Palette** (Edit mode) edits the **current 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, Tabs, 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).
- **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

@@ -1,13 +1,13 @@
# Custom Color Picker Component # Custom Colour Picker Component
A cross-platform, cross-browser color picker component that provides a consistent user experience across all operating systems and browsers. A cross-platform, cross-browser colour picker component that provides a consistent user experience across all operating systems and browsers.
## Features ## Features
**Consistent UI** - Same appearance and behavior on Windows, macOS, Linux, iOS, and Android **Consistent UI** - Same appearance and behavior on Windows, macOS, Linux, iOS, and Android
**Browser Support** - Works in Chrome, Firefox, Safari, Edge, Opera, and mobile browsers **Browser Support** - Works in Chrome, Firefox, Safari, Edge, Opera, and mobile browsers
**Touch Support** - Full touch/gesture support for mobile devices **Touch Support** - Full touch/gesture support for mobile devices
**HSB Color Model** - Uses Hue, Saturation, Brightness for intuitive color selection **HSB Colour Model** - Uses Hue, Saturation, Brightness for intuitive colour selection
**Multiple Input Methods** - Hex input, RGB inputs, and visual picker **Multiple Input Methods** - Hex input, RGB inputs, and visual picker
**Accessible** - Keyboard accessible and screen reader friendly **Accessible** - Keyboard accessible and screen reader friendly
**Customizable** - Easy to style and integrate **Customizable** - Easy to style and integrate
@@ -33,7 +33,7 @@ A cross-platform, cross-browser color picker component that provides a consisten
<div id="my-color-picker"></div> <div id="my-color-picker"></div>
``` ```
### 3. Initialize the color picker ### 3. Initialize the colour picker
```javascript ```javascript
const picker = new ColorPicker('#my-color-picker', { const picker = new ColorPicker('#my-color-picker', {
@@ -57,8 +57,8 @@ new ColorPicker(container, options)
- `options` (object) - Configuration options - `options` (object) - Configuration options
**Options:** **Options:**
- `initialColor` (string) - Initial color in hex format (default: '#FF0000') - `initialColor` (string) - Initial colour in hex format (default: '#FF0000')
- `onColorChange` (function) - Callback when color changes (receives hex color string) - `onColorChange` (function) - Callback when colour changes (receives hex colour string)
- `showHexInput` (boolean) - Show hex input field (default: true) - `showHexInput` (boolean) - Show hex input field (default: true)
### Methods ### Methods
@@ -101,7 +101,7 @@ const picker = new ColorPicker('#picker1', {
}); });
``` ```
### Multiple Color Pickers ### Multiple Colour Pickers
```javascript ```javascript
const colors = ['#FF0000', '#00FF00', '#0000FF']; const colors = ['#FF0000', '#00FF00', '#0000FF'];
@@ -116,7 +116,7 @@ const pickers = colors.map((color, index) => {
}); });
``` ```
### Dynamic Color Picker Creation ### Dynamic Colour Picker Creation
```javascript ```javascript
function addColorPicker(containerId, initialColor = '#000000') { function addColorPicker(containerId, initialColor = '#000000') {
@@ -139,12 +139,12 @@ addColorPicker('color-2', '#00FF00');
## Styling ## Styling
The color picker uses CSS classes that can be customized: The colour picker uses CSS classes that can be customized:
- `.color-picker-container` - Main container - `.color-picker-container` - Main container
- `.color-picker-preview` - Color preview button - `.color-picker-preview` - Colour preview button
- `.color-picker-panel` - Dropdown panel - `.color-picker-panel` - Dropdown panel
- `.color-picker-main` - Main color area - `.color-picker-main` - Main colour area
- `.color-picker-hue` - Hue slider - `.color-picker-hue` - Hue slider
- `.color-picker-controls` - Controls section - `.color-picker-controls` - Controls section
@@ -183,20 +183,20 @@ The color picker uses CSS classes that can be customized:
- ✅ iOS 12+ - ✅ iOS 12+
- ✅ Android 7+ - ✅ Android 7+
## Color Format ## Colour Format
The color picker uses **hex color format** (`#RRGGBB`): The colour picker uses **hex colour format** (`#RRGGBB`):
- Always returns uppercase hex strings (e.g., `#FF0000`) - Always returns uppercase hex strings (e.g., `#FF0000`)
- Accepts both uppercase and lowercase input - Accepts both uppercase and lowercase input
- Automatically validates hex format - Automatically validates hex format
## Integration with LED Driver Mockups ## Integration with LED Driver Mockups
The color picker is integrated into: The colour picker is integrated into:
- `dashboard.html` - Color selection for patterns - `dashboard.html` - Colour selection for patterns
- `presets.html` - Color selection when creating/editing presets - `presets.html` - Colour selection when creating/editing presets
### Example: Getting Colors from Multiple Pickers ### Example: Getting Colours from Multiple Pickers
```javascript ```javascript
const colorPickers = []; const colorPickers = [];
@@ -218,7 +218,7 @@ function sendColorsToDevice() {
## Performance ## Performance
- Lightweight: ~14KB JavaScript, ~4KB CSS - Lightweight: ~14KB JavaScript, ~4KB CSS
- Fast rendering: Uses Canvas API for color gradients - Fast rendering: Uses Canvas API for colour gradients
- Smooth interactions: Optimized event handling - Smooth interactions: Optimized event handling
- Memory efficient: No external dependencies - Memory efficient: No external dependencies
@@ -235,5 +235,5 @@ Part of the LED Driver project. Use freely in your projects.
## Demo ## Demo
See `color-picker-demo.html` for a live demonstration of the color picker component. See `color-picker-demo.html` for a live demonstration of the colour picker component.

View File

@@ -67,7 +67,7 @@
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} }
.tab { .zone {
flex: 1; flex: 1;
padding: 12px 24px; padding: 12px 24px;
border: none; border: none;
@@ -78,16 +78,16 @@
transition: all 0.2s; transition: all 0.2s;
} }
.tab.active { .zone.active {
background: #667eea; background: #667eea;
color: white; color: white;
} }
.tab-content { .zone-content {
display: none; display: none;
} }
.tab-content.active { .zone-content.active {
display: block; display: block;
} }
@@ -249,12 +249,12 @@
</div> </div>
<div class="tabs"> <div class="tabs">
<button class="tab active" onclick="switchTab('devices')">Devices</button> <button class="zone active" onclick="switchTab('devices')">Devices</button>
<button class="tab" onclick="switchTab('groups')">Groups</button> <button class="zone" onclick="switchTab('groups')">Groups</button>
</div> </div>
<!-- Devices Tab --> <!-- Devices Zone -->
<div id="devices-tab" class="tab-content active"> <div id="devices-zone" class="zone-content active">
<div class="card"> <div class="card">
<h2>Connected Devices</h2> <h2>Connected Devices</h2>
<div class="device-item"> <div class="device-item">
@@ -313,8 +313,8 @@
</div> </div>
</div> </div>
<!-- Groups Tab --> <!-- Groups Zone -->
<div id="groups-tab" class="tab-content"> <div id="groups-zone" class="zone-content">
<div class="card"> <div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>Groups</h2> <h2>Groups</h2>
@@ -386,12 +386,12 @@
</div> </div>
<script> <script>
function switchTab(tab) { function switchTab(zone) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.zone').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); document.querySelectorAll('.zone-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active'); event.target.classList.add('active');
document.getElementById(tab + '-tab').classList.add('active'); document.getElementById(zone + '-zone').classList.add('active');
} }
function showAddDeviceModal() { function showAddDeviceModal() {

View File

@@ -1,30 +1,23 @@
{ {
"grps": [ "g":{
{ "df": {
"n": "group1",
"pt": "on", "pt": "on",
"cl": [ "cl": ["#ff0000"],
"000000", "br": 200,
"000000" "n1": 10,
], "n2": 10,
"br": 100, "n3": 10,
"dl": 100, "n4": 10,
"n1": 0, "n5": 10,
"n2": 0, "n6": 10,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0
},
{
"n": "group2",
"pt": "on",
"cl": [
"000000",
"000000"
],
"br": 100,
"dl": 100 "dl": 100
},
"dj": {
"pt": "blink",
"cl": ["#00ff00"],
"dl": 500
} }
] },
"sv": true,
"st": 0
} }

112
esp32/benchmark_peers.py Normal file
View File

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

253
esp32/main.py Normal file
View File

@@ -0,0 +1,253 @@
# Serial-to-ESP-NOW bridge: JSON in both directions on UART + ESP-NOW.
#
# Pi → UART (two supported forms):
# A) Legacy: 6 bytes destination MAC + UTF-8 JSON payload (one write = one frame).
# B) Newline JSON: one object per line, UTF-8, ending with \n
# - Multicast via ESP32: {"m":"split","peers":["12hex",...],"body":{...}}
# - Unicast / broadcast: {"to":"12hex","v":"1",...} (all keys except to/dest go to peers)
#
# ESP-NOW → Pi: newline-delimited JSON, one object per packet:
# {"dir":"espnow_rx","from":"<12hex>","payload":{...}} if body was JSON
# {"dir":"espnow_rx","from":"<12hex>","payload_text":"..."} if UTF-8 not JSON
# {"dir":"espnow_rx","from":"<12hex>","payload_b64":"..."} if binary
from machine import Pin, UART
import espnow
import json
import network
import time
import ubinascii
UART_BAUD = 912000
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
MAX_PEERS = 20
WIFI_CHANNEL = 6
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE, channel=WIFI_CHANNEL)
print("WiFi STA channel:", sta.config("channel"), "(WIFI_CHANNEL=%s)" % WIFI_CHANNEL)
esp = espnow.ESPNow()
esp.active(True)
esp.add_peer(BROADCAST)
uart = UART(1, UART_BAUD, tx=Pin(21), rx=Pin(6))
last_used = {BROADCAST: time.ticks_ms()}
uart_rx_buf = b""
ESP_ERR_ESPNOW_EXIST = -12395
def ensure_peer(addr):
peers = esp.get_peers()
peer_macs = [p[0] for p in peers]
if addr in peer_macs:
return
if len(peer_macs) >= MAX_PEERS:
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
def try_apply_bridge_config(obj):
"""Pi sends {"m":"bridge","ch":1..11} — set STA channel only; do not ESP-NOW forward."""
if not isinstance(obj, dict) or obj.get("m") != "bridge":
return False
ch = obj.get("ch")
if ch is None:
ch = obj.get("wifi_channel")
if ch is None:
return True
try:
n = int(ch)
if 1 <= n <= 11:
sta.config(pm=network.WLAN.PM_NONE, channel=n)
print("Bridge STA channel ->", n)
except Exception as e:
print("bridge config:", e)
return True
def send_split_from_obj(obj):
"""obj has m=split, peers=[12hex,...], body=dict."""
body = obj.get("body")
if body is None:
return
try:
out = json.dumps(body).encode("utf-8")
except (TypeError, ValueError):
return
for peer in obj.get("peers") or []:
if not isinstance(peer, str) or len(peer) != 12:
continue
try:
mac = bytes.fromhex(peer)
except ValueError:
continue
if len(mac) != 6:
continue
ensure_peer(mac)
esp.send(mac, out)
last_used[mac] = time.ticks_ms()
def process_broadcast_payload_split_or_flood(payload):
try:
text = payload.decode("utf-8")
obj = json.loads(text)
except Exception:
obj = None
if isinstance(obj, dict) and try_apply_bridge_config(obj):
return
if (
isinstance(obj, dict)
and obj.get("m") == "split"
and isinstance(obj.get("peers"), list)
):
send_split_from_obj(obj)
return
ensure_peer(BROADCAST)
esp.send(BROADCAST, payload)
last_used[BROADCAST] = time.ticks_ms()
def process_legacy_uart_frame(data):
if not data or len(data) < 6:
return
addr = data[:6]
payload = data[6:]
if addr == BROADCAST:
process_broadcast_payload_split_or_flood(payload)
return
ensure_peer(addr)
esp.send(addr, payload)
last_used[addr] = time.ticks_ms()
def handle_json_command_line(obj):
if not isinstance(obj, dict):
return
if try_apply_bridge_config(obj):
return
if obj.get("m") == "split" and isinstance(obj.get("peers"), list):
send_split_from_obj(obj)
return
to = obj.get("to") or obj.get("dest")
if isinstance(to, str) and len(to) == 12:
try:
mac = bytes.fromhex(to)
except ValueError:
return
if len(mac) != 6:
return
body = {k: v for k, v in obj.items() if k not in ("to", "dest")}
if not body:
return
try:
out = json.dumps(body).encode("utf-8")
except (TypeError, ValueError):
return
ensure_peer(mac)
esp.send(mac, out)
last_used[mac] = time.ticks_ms()
def drain_uart_json_lines():
"""Parse leading newline-delimited JSON objects from uart_rx_buf; leave rest."""
global uart_rx_buf
while True:
s = uart_rx_buf.lstrip()
if not s:
uart_rx_buf = b""
return
if s[0] != ord("{"):
uart_rx_buf = s
return
nl = s.find(b"\n")
if nl < 0:
uart_rx_buf = s
return
line = s[:nl].strip()
uart_rx_buf = s[nl + 1 :]
if line:
try:
text = line.decode("utf-8")
obj = json.loads(text)
handle_json_command_line(obj)
except Exception as e:
print("UART JSON line error:", e)
# continue; there may be another JSON line in buffer
def drain_uart_legacy_frame():
"""If buffer does not start with '{', treat whole buffer as one 6-byte MAC + JSON frame."""
global uart_rx_buf
s = uart_rx_buf
if not s or s[0] == ord("{"):
return
if len(s) < 6:
return
data = s
uart_rx_buf = b""
process_legacy_uart_frame(data)
def forward_espnow_to_uart(mac, msg):
peer_hex = ubinascii.hexlify(mac).decode()
try:
text = msg.decode("utf-8")
try:
payload = json.loads(text)
line_obj = {"dir": "espnow_rx", "from": peer_hex, "payload": payload}
except ValueError:
line_obj = {"dir": "espnow_rx", "from": peer_hex, "payload_text": text}
except UnicodeDecodeError:
line_obj = {
"dir": "espnow_rx",
"from": peer_hex,
"payload_b64": ubinascii.b64encode(msg).decode(),
}
try:
line = json.dumps(line_obj) + "\n"
uart.write(line.encode("utf-8"))
except Exception as e:
print("UART TX error:", e)
print("Starting ESP32 bridge (UART JSON + legacy MAC+JSON, ESP-NOW RX → UART JSON lines)")
while True:
idle = True
if uart.any():
idle = False
uart_rx_buf += uart.read()
drain_uart_json_lines()
drain_uart_legacy_frame()
try:
peer, msg = esp.recv(0)
except OSError:
peer, msg = None, None
if peer is not None and msg is not None:
idle = False
if len(peer) == 6:
forward_espnow_to_uart(peer, msg)
if idle:
time.sleep_ms(1)

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
# Install script - runs pipenv install
pipenv install "$@"

1
led-driver Submodule

Submodule led-driver added at cef9e00819

1
led-tool Submodule

Submodule led-tool added at e86312437c

View File

@@ -1,23 +0,0 @@
{
"g":{
"df": {
"pt": "on",
"cl": ["#ff0000"],
"br": 200,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"dl": 100
},
"dj": {
"pt": "blink",
"cl": ["#00ff00"],
"dl": 500
}
},
"sv": true,
"st": 0
}

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
python_files = test_endpoints_pytest.py

View File

@@ -1,173 +0,0 @@
#!/usr/bin/env python3
"""
Local development web server - imports and runs main.py with port 5000
"""
import sys
import os
import asyncio
# Add src and lib to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib'))
# Import the main module
from src import main as main_module
# Override the port in the main function
async def run_local():
"""Run main with port 5000 for local development."""
from settings import Settings
import gc
# Mock MicroPython modules for local development
class MockMachine:
class WDT:
def __init__(self, timeout):
pass
def feed(self):
pass
import sys as sys_module
sys_module.modules['machine'] = MockMachine()
class MockESPNow:
def __init__(self):
self.active_value = False
self.peers = []
def active(self, value):
self.active_value = value
print(f"[MOCK] ESPNow active: {value}")
def add_peer(self, peer):
self.peers.append(peer)
print(f"[MOCK] Added peer: {peer.hex() if hasattr(peer, 'hex') else peer}")
async def asend(self, peer, data):
print(f"[MOCK] Would send to {peer.hex() if hasattr(peer, 'hex') else peer}: {data}")
class MockAIOESPNow:
def __init__(self):
pass
def active(self, value):
return MockESPNow()
def add_peer(self, peer):
pass
class MockNetwork:
class WLAN:
def __init__(self, interface):
self.interface = interface
def active(self, value):
print(f"[MOCK] WLAN({self.interface}) active: {value}")
STA_IF = 0
# Replace MicroPython modules with mocks
sys_module.modules['aioespnow'] = type('module', (), {'AIOESPNow': MockESPNow})()
sys_module.modules['network'] = MockNetwork()
# Mock gc if needed
if not hasattr(gc, 'collect'):
class MockGC:
def collect(self):
pass
gc = MockGC()
settings = Settings()
print("Starting LED Controller Web Server (Local Development)")
print("=" * 60)
# Mock network
import network
network.WLAN(network.STA_IF).active(True)
# Mock ESPNow
import aioespnow
e = aioespnow.AIOESPNow()
e.active(True)
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
import controllers.sequence as sequence
import controllers.tab as tab
import controllers.palette as palette
import controllers.scene as scene
import controllers.pattern as pattern
import controllers.settings as settings_controller
app = Microdot()
# Initialize sessions with a secret key from settings
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
Session(app, secret_key=secret_key)
# Mount model controllers as subroutes
app.mount(preset.controller, '/presets')
app.mount(profile.controller, '/profiles')
app.mount(group.controller, '/groups')
app.mount(sequence.controller, '/sequences')
app.mount(tab.controller, '/tabs')
app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
# Serve index.html at root
@app.route('/')
def index(request):
"""Serve the main web UI."""
return send_file('src/templates/index.html')
# Serve settings page
@app.route('/settings')
def settings_page(request):
"""Serve the settings page."""
return send_file('src/templates/settings.html')
# Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico')
def favicon(request):
return '', 204
# Static file route
@app.route("/static/<path:path>")
def static_handler(request, path):
"""Serve static files."""
if '..' in path:
return 'Not found', 404
return send_file('src/static/' + path)
@app.route('/ws')
@with_websocket
async def ws(request, ws):
while True:
data = await ws.receive()
if data:
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data)
print(data)
else:
break
# Use port 5000 for local development
port = 5000
print(f"Starting server on http://0.0.0.0:{port}")
print(f"Open http://localhost:{port} in your browser")
print("=" * 60)
try:
await app.start_server(host="0.0.0.0", port=port, debug=True)
except KeyboardInterrupt:
print("\nShutting down server...")
if __name__ == '__main__':
# Change to project root
os.chdir(os.path.dirname(os.path.abspath(__file__)))
# Override settings path for local development
import settings as settings_module
settings_module.Settings.SETTINGS_FILE = os.path.join(os.getcwd(), 'settings.json')
asyncio.run(run_local())

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

4
scripts/cp-esp32-main.sh Normal file
View File

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

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

20
scripts/install-boot-service.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Install systemd service so LED controller starts at boot.
# Run once: sudo scripts/install-boot-service.sh
set -e
cd "$(dirname "$0")/.."
REPO="$(pwd)"
SERVICE_NAME="led-controller.service"
UNIT_PATH="/etc/systemd/system/$SERVICE_NAME"
if [ ! -f "scripts/led-controller.service" ]; then
echo "Run this script from the repo root."
exit 1
fi
chmod +x scripts/start.sh
sudo cp "scripts/led-controller.service" "$UNIT_PATH"
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
echo "Installed and enabled $SERVICE_NAME"
echo "Start now: sudo systemctl start $SERVICE_NAME"
echo "Status: sudo systemctl status $SERVICE_NAME"
echo "Logs: journalctl -u $SERVICE_NAME -f"

View File

@@ -0,0 +1,17 @@
[Unit]
Description=LED Controller web server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/led-controller
Environment=PORT=80
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/bin/bash /home/pi/led-controller/scripts/start.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

35
scripts/setup-port80.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Allow the app to bind to port 80 without root.
# Run once: sudo scripts/setup-port80.sh (from repo root)
# Or: scripts/setup-port80.sh (will prompt for sudo only for setcap)
set -e
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
# If run under sudo, use the invoking user's pipenv so the venv is found
if [ -n "$SUDO_USER" ]; then
VENV="$(sudo -u "$SUDO_USER" bash -c "cd '$REPO_ROOT' && pipenv --venv" 2>/dev/null)" || true
else
VENV="$(pipenv --venv 2>/dev/null)" || true
fi
if [ -z "$VENV" ]; then
echo "Run 'pipenv install' first, then run this script again."
exit 1
fi
PYTHON="${VENV}/bin/python3"
if [ ! -f "$PYTHON" ]; then
PYTHON="${VENV}/bin/python"
fi
if [ ! -f "$PYTHON" ]; then
echo "Python not found in venv: $VENV"
exit 1
fi
# Use the real binary (setcap can fail on symlinks or some filesystems)
REAL_PYTHON="$(readlink -f "$PYTHON" 2>/dev/null)" || REAL_PYTHON="$PYTHON"
if sudo setcap 'cap_net_bind_service=+ep' "$REAL_PYTHON" 2>/dev/null; then
echo "OK: port 80 enabled for $REAL_PYTHON"
echo "Start the app with: pipenv run run"
else
echo "setcap failed on $REAL_PYTHON"
exit 1
fi

5
scripts/start.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Start the LED controller web server (port 80 by default).
cd "$(dirname "$0")/.."
export PORT="${PORT:-80}"
pipenv run run

33
scripts/test-port80.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Test the app on port 80. Run after: sudo scripts/setup-port80.sh
# Usage: ./scripts/test-port80.sh
set -e
cd "$(dirname "$0")/.."
APP_URL="${APP_URL:-http://127.0.0.1:80}"
echo "Starting app on port 80 in background..."
pipenv run run &
PID=$!
trap "kill $PID 2>/dev/null; exit" EXIT
echo "Waiting for server to start..."
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -s -o /dev/null -w "%{http_code}" "$APP_URL/" 2>/dev/null | grep -q 200; then
echo "Server is up."
break
fi
sleep 1
done
echo "Requesting $APP_URL/ ..."
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/")
if [ "$CODE" = "200" ]; then
echo "OK: GET / returned HTTP $CODE"
curl -s "$APP_URL/" | head -5
echo "..."
exit 0
else
echo "FAIL: GET / returned HTTP $CODE (expected 200)"
exit 1
fi

View File

@@ -1,44 +0,0 @@
#!/usr/bin/env python3
import socket
import struct
import base64
import hashlib
# Connect to the WebSocket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('192.168.4.1', 80))
# Send HTTP WebSocket upgrade request
key = base64.b64encode(b'test-nonce').decode('utf-8')
request = f'''GET /ws HTTP/1.1\r
Host: 192.168.4.1\r
Upgrade: websocket\r
Connection: Upgrade\r
Sec-WebSocket-Key: {key}\r
Sec-WebSocket-Version: 13\r
\r
'''
s.send(request.encode())
# Read upgrade response
response = s.recv(4096)
print(response.decode())
# Send WebSocket TEXT frame with empty JSON '{}'
payload = b'{}'
mask = b'\x12\x34\x56\x78'
payload_masked = bytes(p ^ mask[i % 4] for i, p in enumerate(payload))
frame = struct.pack('BB', 0x81, 0x80 | len(payload))
frame += mask
frame += payload_masked
s.send(frame)
print("Sent empty JSON to WebSocket")
s.close()

View File

@@ -1 +0,0 @@
{"session_secret_key": "06a2a6ac631cc8db0658373b37f7fe922a8a3ed3a27229e1adb5448cb368f2b7"}

View File

@@ -1,8 +1,6 @@
import settings # Boot script (ESP only; no-op on Pi)
import util.wifi as wifi import settings # noqa: F401
from settings import Settings from settings import Settings
s = Settings() s = Settings()
# AP setup was here when running on ESP; Pi uses system networking.
name = s.get('name', 'led-controller')
wifi.ap(name, '')

261
src/controllers/device.py Normal file
View File

@@ -0,0 +1,261 @@
from microdot import Microdot
from models.device import (
Device,
derive_device_mac,
validate_device_transport,
validate_device_type,
)
from models.transport import get_current_sender
from models.tcp_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
tcp_client_connected,
)
from util.espnow_message import build_message
import asyncio
import json
# 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 a TCP client is registered for this device's address (IP).
ESP-NOW: None (no TCP 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
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
off_msg = build_message(select={name: ["off"]})
if transport == "wifi":
await send_json_line_to_ip(wifi_ip, off_msg)
else:
await sender.send(off_msg, addr=dev_id)
except Exception:
pass
@controller.get("")
async def list_devices(request):
"""List all devices (includes ``connected`` for live Wi-Fi TCP presence)."""
devices_data = {}
for dev_id in devices.list():
d = devices.read(dev_id)
if d:
devices_data[dev_id] = _device_json_with_live_status(d)
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
async def get_device(request, id):
"""Get a device by ID (includes ``connected`` for live Wi-Fi TCP presence)."""
dev = devices.read(id)
if dev:
return json.dumps(_device_json_with_live_status(dev)), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@controller.post("")
async def create_device(request):
"""Create a new device."""
try:
data = request.json or {}
name = data.get("name", "").strip()
if not name:
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json",
}
try:
device_type = validate_device_type(data.get("type", "led"))
transport = validate_device_transport(data.get("transport", "espnow"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {
"Content-Type": "application/json",
}
address = data.get("address")
mac = data.get("mac")
if derive_device_mac(mac=mac, address=address, transport=transport) is None:
return json.dumps(
{
"error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address"
}
), 400, {"Content-Type": "application/json"}
default_pattern = data.get("default_pattern")
zl = data.get("zones")
if isinstance(zl, list):
zl = [str(t) for t in zl]
else:
zl = []
dev_id = devices.create(
name=name,
address=address,
mac=mac,
default_pattern=default_pattern,
zones=zl,
device_type=device_type,
transport=transport,
)
dev = devices.read(dev_id)
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
except ValueError as e:
msg = str(e)
code = 409 if "already exists" in msg.lower() else 400
return json.dumps({"error": msg}), code, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put("/<id>")
async def update_device(request, id):
"""Update a device."""
try:
raw = request.json or {}
data = dict(raw)
data.pop("id", None)
data.pop("addresses", None)
data.pop("connected", None)
if "name" in data:
n = (data.get("name") or "").strip()
if not n:
return json.dumps({"error": "name cannot be empty"}), 400, {
"Content-Type": "application/json",
}
data["name"] = n
if "type" in data:
data["type"] = validate_device_type(data.get("type"))
if "transport" in data:
data["transport"] = validate_device_transport(data.get("transport"))
if "zones" in data and isinstance(data["zones"], list):
data["zones"] = [str(t) for t in data["zones"]]
if devices.update(id, data):
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.delete("/<id>")
async def delete_device(request, id):
"""Delete a device."""
if devices.delete(id):
return (
json.dumps({"message": "Device deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@controller.post("/<id>/identify")
async def identify_device(request, id):
"""
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
this device name — same combined shape as profile sends the driver already accepts over TCP
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
name = str(dev.get("name") or "").strip()
if not name:
return json.dumps({"error": "Device must have a name to identify"}), 400, {
"Content-Type": "application/json",
}
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
else:
await sender.send(msg, addr=id)
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json",
}

View File

@@ -17,9 +17,9 @@ async def list_palettes(request):
@controller.get('/<id>') @controller.get('/<id>')
async def get_palette(request, id): async def get_palette(request, id):
"""Get a specific palette by ID.""" """Get a specific palette by ID."""
palette = palettes.read(id) if str(id) in palettes:
if palette: palette = palettes.read(id)
return json.dumps({"colors": palette, "id": str(id)}), 200, {'Content-Type': 'application/json'} return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404 return json.dumps({"error": "Palette not found"}), 404
@controller.post('') @controller.post('')
@@ -30,11 +30,8 @@ async def create_palette(request):
colors = data.get("colors", None) colors = data.get("colors", None)
# Palette no longer needs a name; only colors are stored. # Palette no longer needs a name; only colors are stored.
palette_id = palettes.create("", colors) palette_id = palettes.create("", colors)
palette = palettes.read(palette_id) or {} created_colors = palettes.read(palette_id) or []
# Include the ID in the response payload so clients can link it. return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
palette_with_id = {"id": str(palette_id)}
palette_with_id.update(palette)
return json.dumps(palette_with_id), 201, {'Content-Type': 'application/json'}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@@ -47,10 +44,8 @@ async def update_palette(request, id):
if "name" in data: if "name" in data:
data.pop("name", None) data.pop("name", None)
if palettes.update(id, data): if palettes.update(id, data):
palette = palettes.read(id) or {} colors = palettes.read(id) or []
palette_with_id = {"id": str(id)} return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
palette_with_id.update(palette)
return json.dumps(palette_with_id), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404 return json.dumps({"error": "Palette not found"}), 404
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400

View File

@@ -47,11 +47,23 @@ async def get_pattern(request, id):
async def create_pattern(request): async def create_pattern(request):
"""Create a new pattern.""" """Create a new pattern."""
try: try:
data = request.json or {} payload = request.json or {}
name = data.get("name", "") name = payload.get("name", "")
pattern_id = patterns.create(name, data.get("data", {})) pattern_data = payload.get("data", {})
if data:
patterns.update(pattern_id, data) # IMPORTANT:
# `patterns.create()` stores `pattern_data` as the underlying dict value.
# If we then call `patterns.update(pattern_id, payload)` with the full
# request object, it may assign `payload["data"]` back onto that same
# dict object, creating a circular reference (json.dumps fails).
pattern_id = patterns.create(name, pattern_data)
# Only merge "extra" metadata fields (anything except name/data).
extra = dict(payload)
extra.pop("name", None)
extra.pop("data", None)
if extra:
patterns.update(pattern_id, extra)
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'} return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400

View File

@@ -2,9 +2,10 @@ from microdot import Microdot
from microdot.session import with_session from microdot.session import with_session
from models.preset import Preset from models.preset import Preset
from models.profile import Profile from models.profile import Profile
from models.espnow import ESPNow 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 from util.espnow_message import build_message, build_preset_dict
import asyncio
import json import json
controller = Microdot() controller = Microdot()
@@ -36,11 +37,11 @@ async def list_presets(request, session):
} }
return json.dumps(scoped), 200, {'Content-Type': 'application/json'} return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>') @controller.get('/<preset_id>')
@with_session @with_session
async def get_preset(request, id, session): async def get_preset(request, session, preset_id):
"""Get a specific preset by ID (current profile only).""" """Get a specific preset by ID (current profile only)."""
preset = presets.read(id) preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if preset and str(preset.get("profile_id")) == str(current_profile_id): if preset and str(preset.get("profile_id")) == str(current_profile_id):
return json.dumps(preset), 200, {'Content-Type': 'application/json'} return json.dumps(preset), 200, {'Content-Type': 'application/json'}
@@ -70,12 +71,12 @@ async def create_preset(request, session):
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.put('/<id>') @controller.put('/<preset_id>')
@with_session @with_session
async def update_preset(request, id, session): async def update_preset(request, session, preset_id):
"""Update an existing preset (current profile only).""" """Update an existing preset (current profile only)."""
try: try:
preset = presets.read(id) preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id): if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
@@ -87,21 +88,36 @@ async def update_preset(request, id, session):
data = {} data = {}
data = dict(data) data = dict(data)
data["profile_id"] = str(current_profile_id) data["profile_id"] = str(current_profile_id)
if presets.update(id, data): if presets.update(preset_id, data):
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'} return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>') @controller.delete('/<preset_id>')
@with_session @with_session
async def delete_preset(request, id, session): async def delete_preset(request, *args, **kwargs):
"""Delete a preset (current profile only).""" """Delete a preset (current profile only)."""
preset = presets.read(id) # Be tolerant of wrapper/arg-order variations.
session = None
preset_id = None
if len(args) > 0:
session = args[0]
if len(args) > 1:
preset_id = args[1]
if 'session' in kwargs and kwargs.get('session') is not None:
session = kwargs.get('session')
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
preset_id = kwargs.get('preset_id')
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
preset_id = kwargs.get('id')
if preset_id is None:
return json.dumps({"error": "Preset ID is required"}), 400
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id): if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
if presets.delete(id): if presets.delete(preset_id):
return json.dumps({"message": "Preset deleted successfully"}), 200 return json.dumps({"message": "Preset deleted successfully"}), 200
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
@@ -110,16 +126,17 @@ async def delete_preset(request, id, session):
@with_session @with_session
async def send_presets(request, session): async def send_presets(request, session):
""" """
Send one or more presets over ESPNow. Send one or more presets to LED drivers (serial/ESP-NOW and/or TCP Wi-Fi clients).
Body JSON: Body JSON:
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]} {"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: Optional "destination_mac" / "to": single MAC when targets is omitted.
- looks up each preset in the Preset model
- converts them to API-compliant format
- splits into <= 240-byte ESPNow messages
- sends each message to all configured ESPNow peers.
""" """
try: try:
data = request.json or {} data = request.json or {}
@@ -132,6 +149,7 @@ async def send_presets(request, session):
save_flag = data.get('save', True) save_flag = data.get('save', True)
save_flag = bool(save_flag) save_flag = bool(save_flag)
default_id = data.get('default') default_id = data.get('default')
destination_mac = data.get('destination_mac') or data.get('to')
# Build API-compliant preset map keyed by preset ID, include name # Build API-compliant preset map keyed by preset ID, include name
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
@@ -153,22 +171,17 @@ async def send_presets(request, session):
if default_id is not None and str(default_id) not in presets_by_name: if default_id is not None and str(default_id) not in presets_by_name:
default_id = None default_id = None
# Use shared ESPNow singleton sender = get_current_sender()
esp = ESPNow() if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
async def send_chunk(chunk_presets):
# Include save flag so the led-driver can persist when desired.
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
await esp.send(msg)
MAX_BYTES = 240 MAX_BYTES = 240
SEND_DELAY_MS = 100 send_delay_s = 0.1
entries = list(presets_by_name.items()) entries = list(presets_by_name.items())
total_presets = len(entries) total_presets = len(entries)
messages_sent = 0
batch = {} batch = {}
last_msg = None chunk_messages = []
for name, preset_obj in entries: for name, preset_obj in entries:
test_batch = dict(batch) test_batch = dict(batch)
test_batch[name] = preset_obj test_batch[name] = preset_obj
@@ -177,28 +190,133 @@ async def send_presets(request, session):
if size <= MAX_BYTES or not batch: if size <= MAX_BYTES or not batch:
batch = test_batch batch = test_batch
last_msg = test_msg
else: else:
try: chunk_messages.append(
await send_chunk(batch) build_message(
except Exception: presets=dict(batch),
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'} save=False,
await asyncio.sleep_ms(SEND_DELAY_MS) default=None,
messages_sent += 1 )
)
batch = {name: preset_obj} batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
if batch: if batch:
try: chunk_messages.append(
await send_chunk(batch) build_message(
except Exception: presets=dict(batch),
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'} save=save_flag,
await asyncio.sleep_ms(SEND_DELAY_MS) default=default_id,
messages_sent += 1 )
)
target_list = None
raw_targets = data.get("targets")
if isinstance(raw_targets, list) and raw_targets:
target_list = []
for t in raw_targets:
m = normalize_mac(str(t))
if m:
target_list.append(m)
target_list = list(dict.fromkeys(target_list))
if not target_list:
target_list = None
elif destination_mac:
dm = normalize_mac(str(destination_mac))
target_list = [dm] if dm else None
try:
if target_list:
deliveries = await deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
target_list,
Device(),
str(default_id) if default_id is not None else None,
delay_s=send_delay_s,
)
else:
deliveries, _chunks = await deliver_json_messages(
sender,
chunk_messages,
None,
Device(),
delay_s=send_delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return json.dumps({ return json.dumps({
"message": "Presets sent via ESPNow", "message": "Presets sent",
"presets_sent": total_presets, "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'} }), 200, {'Content-Type': 'application/json'}

View File

@@ -1,13 +1,13 @@
from microdot import Microdot from microdot import Microdot
from microdot.session import with_session from microdot.session import with_session
from models.profile import Profile from models.profile import Profile
from models.tab import Tab from models.zone import Zone
from models.preset import Preset from models.preset import Preset
import json import json
controller = Microdot() controller = Microdot()
profiles = Profile() profiles = Profile()
tabs = Tab() zones = Zone()
presets = Preset() presets = Preset()
@controller.get('') @controller.get('')
@@ -81,11 +81,117 @@ async def apply_profile(request, session, id):
async def create_profile(request): async def create_profile(request):
"""Create a new profile.""" """Create a new profile."""
try: try:
data = request.json or {} data = dict(request.json or {})
name = data.get("name", "") name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", None)
profile_id = profiles.create(name) profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data: if data:
profiles.update(profile_id, data) profiles.update(profile_id, data)
# New profiles always start with a default zone pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"zones": profile_tabs})
profile_data = profiles.read(profile_id) profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'} return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e: except Exception as e:
@@ -102,7 +208,7 @@ async def clone_profile(request, id):
data = request.json or {} data = request.json or {}
source_name = source.get("name") or f"Profile {id}" source_name = source.get("name") or f"Profile {id}"
new_name = data.get("name") or source_name 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): def allocate_id(model, cache):
if "next" not in cache: if "next" not in cache:
@@ -149,28 +255,28 @@ async def clone_profile(request, id):
palette_colors = [] palette_colors = []
# Clone tabs and presets used by those tabs # 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: 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 [] source_tabs = source_tabs or []
cloned_tab_ids = [] cloned_tab_ids = []
preset_id_map = {} preset_id_map = {}
new_tabs = {} new_tabs = {}
new_presets = {} new_presets = {}
for tab_id in source_tabs: for zone_id in source_tabs:
tab = tabs.read(tab_id) zone = zones.read(zone_id)
if not tab: if not zone:
continue 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 clone_name = tab_name
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets) mapped_presets = map_preset_container(zone.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
clone_id = allocate_id(tabs, tab_cache) clone_id = allocate_id(zones, tab_cache)
clone_data = { clone_data = {
"name": clone_name, "name": clone_name,
"names": tab.get("names") or [], "names": zone.get("names") or [],
"presets": mapped_presets if mapped_presets is not None else [] "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: 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) extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
if extra: if extra:
@@ -181,7 +287,7 @@ async def clone_profile(request, id):
new_profile_data = { new_profile_data = {
"name": new_name, "name": new_name,
"type": profile_type, "type": profile_type,
"tabs": cloned_tab_ids, "zones": cloned_tab_ids,
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [], "scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
"palette_id": str(new_palette_id), "palette_id": str(new_palette_id),
} }
@@ -191,12 +297,12 @@ async def clone_profile(request, id):
for pid, pdata in new_presets.items(): for pid, pdata in new_presets.items():
presets[pid] = pdata presets[pid] = pdata
for tid, tdata in new_tabs.items(): for tid, tdata in new_tabs.items():
tabs[tid] = tdata zones[tid] = tdata
profiles[str(new_profile_id)] = new_profile_data profiles[str(new_profile_id)] = new_profile_data
profiles._palette_model.save() profiles._palette_model.save()
presets.save() presets.save()
tabs.save() zones.save()
profiles.save() profiles.save()
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'} return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}

View File

@@ -1,6 +1,5 @@
from microdot import Microdot, send_file from microdot import Microdot, send_file
from settings import Settings from settings import Settings
import util.wifi as wifi
import json import json
controller = Microdot() controller = Microdot()
@@ -15,19 +14,18 @@ async def get_settings(request):
@controller.get('/wifi/ap') @controller.get('/wifi/ap')
async def get_ap_config(request): async def get_ap_config(request):
"""Get Access Point configuration.""" """Get saved AP configuration (Pi: no in-device AP)."""
config = wifi.get_ap_config() config = {
if config: 'saved_ssid': settings.get('wifi_ap_ssid'),
# Also get saved settings 'saved_password': settings.get('wifi_ap_password'),
config['saved_ssid'] = settings.get('wifi_ap_ssid') 'saved_channel': settings.get('wifi_ap_channel'),
config['saved_password'] = settings.get('wifi_ap_password') 'active': False,
config['saved_channel'] = settings.get('wifi_ap_channel') }
return json.dumps(config), 200, {'Content-Type': 'application/json'} return json.dumps(config), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to get AP config"}), 500
@controller.post('/wifi/ap') @controller.post('/wifi/ap')
async def configure_ap(request): async def configure_ap(request):
"""Configure Access Point.""" """Save AP configuration to settings (Pi: no in-device AP)."""
try: try:
data = request.json data = request.json
ssid = data.get('ssid') ssid = data.get('ssid')
@@ -43,33 +41,42 @@ async def configure_ap(request):
if channel < 1 or channel > 11: if channel < 1 or channel > 11:
return json.dumps({"error": "Channel must be between 1 and 11"}), 400 return json.dumps({"error": "Channel must be between 1 and 11"}), 400
# Save to settings
settings['wifi_ap_ssid'] = ssid settings['wifi_ap_ssid'] = ssid
settings['wifi_ap_password'] = password settings['wifi_ap_password'] = password
if channel is not None: if channel is not None:
settings['wifi_ap_channel'] = channel settings['wifi_ap_channel'] = channel
settings.save() settings.save()
# Configure AP
wifi.ap(ssid, password, channel)
return json.dumps({ return json.dumps({
"message": "AP configured successfully", "message": "AP settings saved",
"ssid": ssid, "ssid": ssid,
"channel": channel "channel": channel
}), 200, {'Content-Type': 'application/json'} }), 200, {'Content-Type': 'application/json'}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 500 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') @controller.put('/settings')
async def update_settings(request): async def update_settings(request):
"""Update general settings.""" """Update general settings."""
try: try:
data = request.json data = request.json
for key, value in data.items(): for key, value in data.items():
settings[key] = value if key == 'wifi_channel' and value is not None:
settings[key] = _validate_wifi_channel(value)
else:
settings[key] = value
settings.save() settings.save()
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'} return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 400
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 500 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,24 +1,243 @@
import asyncio import asyncio
import gc import errno
import json import json
import machine import os
from machine import Pin import socket
import threading
import traceback
from microdot import Microdot, send_file from microdot import Microdot, send_file
from microdot.websocket import with_websocket from microdot.websocket import with_websocket
from microdot.session import Session from microdot.session import Session
from settings import Settings from settings import Settings
import aioespnow
import controllers.preset as preset import controllers.preset as preset
import controllers.profile as profile import controllers.profile as profile
import controllers.group as group import controllers.group as group
import controllers.sequence as sequence import controllers.sequence as sequence
import controllers.tab as tab import controllers.zone as zone
import controllers.palette as palette import controllers.palette as palette
import controllers.scene as scene import controllers.scene as scene
import controllers.pattern as pattern import controllers.pattern as pattern
import controllers.settings as settings_controller import controllers.settings as settings_controller
from models.espnow import ESPNow import controllers.device as device_controller
from models.transport import get_sender, set_sender, get_current_sender
from models.device import Device, normalize_mac
from models import tcp_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()
# Wi-Fi drivers send one hello line then stay quiet; periodic outbound data makes dead peers
# fail drain() within this interval (keepalive alone is often slow or ineffective).
TCP_LIVENESS_PING_INTERVAL_S = 12.0
# Keepalive or lossy Wi-Fi can still surface OSError(110) / TimeoutError on recv or wait_closed.
_TCP_PEER_GONE = (
BrokenPipeError,
ConnectionResetError,
ConnectionAbortedError,
ConnectionRefusedError,
TimeoutError,
OSError,
)
def _tcp_socket_from_writer(writer):
sock = writer.get_extra_info("socket")
if sock is not None:
return sock
transport = getattr(writer, "transport", None)
if transport is not None:
return transport.get_extra_info("socket")
return None
def _enable_tcp_keepalive(writer) -> None:
"""
Detect vanished peers (power off, Wi-Fi drop) without waiting for a send() failure.
Linux: shorten time before the first keepalive probe; other platforms: SO_KEEPALIVE only.
"""
sock = _tcp_socket_from_writer(writer)
if sock is None:
return
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
except OSError:
return
if hasattr(socket, "TCP_KEEPIDLE"):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 120)
except OSError:
pass
if hasattr(socket, "TCP_KEEPINTVL"):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15)
except OSError:
pass
if hasattr(socket, "TCP_KEEPCNT"):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4)
except OSError:
pass
# Do not set TCP_USER_TIMEOUT: a short value causes Errno 110 on recv for Wi-Fi peers
# when ACKs are delayed (ESP power save, lossy links). Liveness pings already clear dead
# sessions via drain().
async def _tcp_liveness_ping_loop(writer, peer_ip: str) -> None:
"""Send a bare newline so ``drain()`` fails soon after the peer disappears."""
while True:
await asyncio.sleep(TCP_LIVENESS_PING_INTERVAL_S)
if writer.is_closing():
return
try:
writer.write(b"\n")
await writer.drain()
except Exception as exc:
print(f"[TCP] liveness ping failed {peer_ip!r}: {exc!r}")
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
try:
writer.close()
except Exception:
pass
return
def _register_tcp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None
) -> None:
with _tcp_device_lock:
try:
d = Device()
did = d.upsert_wifi_tcp_client(
device_name, peer_ip, mac, device_type=device_type
)
if did:
print(
f"TCP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
)
except Exception as e:
print(f"TCP device registry failed: {e}")
traceback.print_exception(type(e), e, e.__traceback__)
async def _handle_tcp_client(reader, writer):
"""Read newline-delimited JSON from Wi-Fi LED drivers; forward to serial bridge."""
peer = writer.get_extra_info("peername")
peer_ip = peer[0] if peer else ""
peer_label = f"{peer_ip}:{peer[1]}" if peer and len(peer) > 1 else peer_ip or "?"
print(f"[TCP] client connected {peer_label}")
_enable_tcp_keepalive(writer)
tcp_client_registry.register_tcp_writer(peer_ip, writer)
ping_task = asyncio.create_task(_tcp_liveness_ping_loop(writer, peer_ip))
sender = get_current_sender()
buf = b""
try:
while True:
try:
chunk = await reader.read(4096)
except asyncio.CancelledError:
raise
except _TCP_PEER_GONE as e:
print(f"[TCP] read ended ({peer_label}): {e!r}")
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
break
if not chunk:
break
buf += chunk
while b"\n" in buf:
raw_line, buf = buf.split(b"\n", 1)
line = raw_line.strip()
if not line:
continue
try:
text = line.decode("utf-8")
except UnicodeDecodeError:
print(
f"[TCP] recv {peer_label} (non-UTF-8, {len(line)} bytes): {line!r}"
)
continue
print(f"[TCP] recv {peer_label}: {text}")
try:
parsed = json.loads(text)
except json.JSONDecodeError:
if sender:
try:
await sender.send(text)
except Exception:
pass
continue
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_tcp_device_sync(
dns, peer_ip, mac, device_type=device_type
)
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else "{}"
if sender:
try:
await sender.send(payload, addr=addr)
except Exception as e:
print(f"TCP forward to bridge failed: {e}")
elif sender:
try:
await sender.send(text)
except Exception:
pass
finally:
# Drop registry + broadcast connected:false before awaiting ping/close so the UI
# does not stay green if ping or wait_closed blocks on a timed-out peer.
outcome = tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
if outcome == "superseded":
print(
f"[TCP] TCP session ended (same IP already has a newer connection): {peer_label}"
)
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
try:
writer.close()
await writer.wait_closed()
except asyncio.CancelledError:
raise
except _TCP_PEER_GONE:
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
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 _run_tcp_server(settings):
if not settings.get("tcp_enabled", True):
print("TCP server disabled (tcp_enabled=false)")
return
port = int(settings.get("tcp_port", 8765))
server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port)
print(f"TCP server listening on 0.0.0.0:{port}")
async with server:
await server.serve_forever()
async def main(port=80): async def main(port=80):
@@ -26,8 +245,9 @@ async def main(port=80):
print(settings) print(settings)
print("Starting") print("Starting")
# Initialize ESPNow singleton (config + peers) # Initialize transport (serial to ESP32 bridge)
esp = ESPNow() sender = get_sender(settings)
set_sender(sender)
app = Microdot() app = Microdot()
@@ -42,7 +262,7 @@ async def main(port=80):
('/profiles', profile, 'profile'), ('/profiles', profile, 'profile'),
('/groups', group, 'group'), ('/groups', group, 'group'),
('/sequences', sequence, 'sequence'), ('/sequences', sequence, 'sequence'),
('/tabs', tab, 'tab'), ('/zones', zone, 'zone'),
('/palettes', palette, 'palette'), ('/palettes', palette, 'palette'),
('/scenes', scene, 'scene'), ('/scenes', scene, 'scene'),
] ]
@@ -52,13 +272,16 @@ async def main(port=80):
app.mount(profile.controller, '/profiles') app.mount(profile.controller, '/profiles')
app.mount(group.controller, '/groups') app.mount(group.controller, '/groups')
app.mount(sequence.controller, '/sequences') app.mount(sequence.controller, '/sequences')
app.mount(tab.controller, '/tabs') app.mount(zone.controller, '/zones')
app.mount(palette.controller, '/palettes') app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes') app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns') app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings') app.mount(settings_controller.controller, '/settings')
app.mount(device_controller.controller, '/devices')
# Serve index.html at root 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('/') @app.route('/')
def index(request): def index(request):
"""Serve the main web UI.""" """Serve the main web UI."""
@@ -87,51 +310,65 @@ async def main(port=80):
@app.route('/ws') @app.route('/ws')
@with_websocket @with_websocket
async def ws(request, ws): async def ws(request, ws):
while True: await register_device_status_ws(ws)
data = await ws.receive() await broadcast_device_tcp_snapshot_to(ws)
print(data) try:
if data: while True:
# Debug: log incoming WebSocket data data = await ws.receive()
try: print(data)
parsed = json.loads(data) if data:
print("WS received JSON:", parsed)
except Exception:
print("WS received raw:", data)
# Forward raw JSON payload over ESPNow to configured peers
try:
await esp.send(data)
except Exception:
try: try:
await ws.send(json.dumps({"error": "ESP-NOW send failed"})) parsed = json.loads(data)
print("WS received JSON:", parsed)
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await sender.send(payload, addr=addr)
except json.JSONDecodeError:
# Not JSON: send raw with default address
try:
await sender.send(data)
except Exception:
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
except Exception: except Exception:
pass try:
else: await ws.send(json.dumps({"error": "Send failed"}))
break except Exception:
pass
else:
break
finally:
await unregister_device_status_ws(ws)
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port)) # Touch Device singleton early so db/device.json exists before first TCP hello.
Device()
await _send_bridge_wifi_channel(settings, sender)
#wdt = machine.WDT(timeout=10000) # Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface
#wdt.feed() # here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
# never starts, which clears Wi-Fi presence dots.
# Initialize heartbeat LED (XIAO ESP32S3 built-in LED on GPIO 21) try:
await asyncio.gather(
led = Pin(15, Pin.OUT) app.start_server(host="0.0.0.0", port=port),
_run_tcp_server(settings),
)
led_state = False except OSError as e:
if e.errno == errno.EADDRINUSE:
while True: tcp_p = int(settings.get("tcp_port", 8765))
gc.collect() print(
for i in range(60): f"[server] bind failed (address already in use): {e!s}\n"
#wdt.feed() f"[server] HTTP is configured for port {port} (env PORT); "
# Heartbeat: toggle LED every 500 ms f"Wi-Fi LED drivers use tcp_port {tcp_p}. "
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
led.value(not led.value()) )
await asyncio.sleep_ms(500) raise
# cleanup before ending the application
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) import os
port = int(os.environ.get("PORT", 80))
asyncio.run(main(port=port))

279
src/models/device.py Normal file
View File

@@ -0,0 +1,279 @@
"""
LED driver registry persisted in ``db/device.json``.
Storage key and **id** field are the device **MAC**: 12 lowercase hex characters
(no colons). **name** is for ``select`` / zones (not unique). **address** is the
reachability hint: same as MAC for ESP-NOW, or IP/hostname for Wi-Fi.
"""
from models.model import Model
DEVICE_TYPES = frozenset({"led"})
DEVICE_TRANSPORTS = frozenset({"wifi", "espnow"})
def validate_device_type(value):
t = (value or "led").strip().lower()
if t not in DEVICE_TYPES:
raise ValueError(f"type must be one of: {', '.join(sorted(DEVICE_TYPES))}")
return t
def validate_device_transport(value):
tr = (value or "espnow").strip().lower()
if tr not in DEVICE_TRANSPORTS:
raise ValueError(
f"transport must be one of: {', '.join(sorted(DEVICE_TRANSPORTS))}"
)
return tr
def normalize_mac(mac):
"""Normalise to 12-char lowercase hex or None."""
if mac is None:
return None
s = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
return s
return None
def derive_device_mac(mac=None, address=None, transport="espnow"):
"""
Resolve the device MAC used as storage id.
Explicit ``mac`` wins. For ESP-NOW, ``address`` is the peer MAC. For Wi-Fi,
``mac`` must be supplied (``address`` is typically an IP).
"""
m = normalize_mac(mac)
if m:
return m
tr = validate_device_transport(transport)
if tr == "espnow":
return normalize_mac(address)
return None
def normalize_address_for_transport(addr, transport):
"""ESP-NOW → 12 hex or None; Wi-Fi → trimmed string or None."""
tr = validate_device_transport(transport)
if tr == "espnow":
return normalize_mac(addr)
if addr is None:
return None
s = str(addr).strip()
return s if s else None
class Device(Model):
def __init__(self):
super().__init__()
def load(self):
super().load()
changed = False
for sid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if self._migrate_record(str(sid), doc):
changed = True
if self._rekey_legacy_ids():
changed = True
if changed:
self.save()
def _migrate_record(self, storage_id, doc):
changed = False
if doc.get("type") not in DEVICE_TYPES:
doc["type"] = "led"
changed = True
if doc.get("transport") not in DEVICE_TRANSPORTS:
doc["transport"] = "espnow"
changed = True
raw_list = doc.get("addresses")
if isinstance(raw_list, list) and raw_list:
picked = None
for item in raw_list:
n = normalize_mac(item)
if n:
picked = n
break
if picked:
doc["address"] = picked
del doc["addresses"]
changed = True
elif "addresses" in doc:
del doc["addresses"]
changed = True
tr = doc["transport"]
norm = normalize_address_for_transport(doc.get("address"), tr)
if doc.get("address") != norm:
doc["address"] = norm
changed = True
mac_key = normalize_mac(storage_id)
if mac_key and mac_key == storage_id and str(doc.get("id") or "") != mac_key:
doc["id"] = mac_key
changed = True
elif str(doc.get("id") or "").strip() != storage_id:
doc["id"] = storage_id
changed = True
doc.pop("mac", None)
return changed
def _rekey_legacy_ids(self):
"""Move numeric-keyed rows to MAC keys when ESP-NOW MAC is known."""
changed = False
moves = []
for sid in list(self.keys()):
doc = self.get(sid)
if not isinstance(doc, dict):
continue
if normalize_mac(sid) == sid:
continue
if not str(sid).isdigit():
continue
tr = doc.get("transport", "espnow")
cand = None
if tr == "espnow":
cand = normalize_mac(doc.get("address"))
if not cand:
continue
moves.append((sid, cand))
for old, mac in moves:
if old not in self:
continue
doc = self.pop(old)
if mac in self:
existing = dict(self[mac])
for k, v in doc.items():
if k not in existing or existing[k] in (None, "", []):
existing[k] = v
doc = existing
doc["id"] = mac
self[mac] = doc
changed = True
return changed
def create(
self,
name="",
address=None,
mac=None,
default_pattern=None,
zones=None,
device_type="led",
transport="espnow",
):
dt = validate_device_type(device_type)
tr = validate_device_transport(transport)
mac_hex = derive_device_mac(mac=mac, address=address, transport=tr)
if not mac_hex:
raise ValueError(
"mac is required (12 hex characters); for Wi-Fi pass mac separately from IP address"
)
if mac_hex in self:
raise ValueError("device with this mac already exists")
addr = normalize_address_for_transport(address, tr)
if tr == "espnow":
addr = mac_hex
self[mac_hex] = {
"id": mac_hex,
"name": name,
"type": dt,
"transport": tr,
"address": addr,
"default_pattern": default_pattern if default_pattern else None,
"zones": list(zones) if zones else [],
}
self.save()
return mac_hex
def read(self, id):
m = normalize_mac(id)
if m is not None and m in self:
return self.get(m)
return self.get(str(id), None)
def update(self, id, data):
id_str = normalize_mac(id)
if id_str is None:
id_str = str(id)
if id_str not in self:
return False
incoming = dict(data)
incoming.pop("id", None)
incoming.pop("addresses", None)
in_mac = normalize_mac(incoming.get("mac"))
if in_mac is not None and in_mac != id_str:
raise ValueError("cannot change device mac; delete and re-add")
incoming.pop("mac", None)
merged = dict(self[id_str])
merged.update(incoming)
merged["type"] = validate_device_type(merged.get("type"))
merged["transport"] = validate_device_transport(merged.get("transport"))
tr = merged["transport"]
merged["address"] = normalize_address_for_transport(merged.get("address"), tr)
if tr == "espnow":
merged["address"] = id_str
merged["id"] = id_str
self[id_str] = merged
self.save()
return True
def delete(self, id):
id_str = normalize_mac(id)
if id_str is None:
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
"""
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
**address** (peer IP), and optionally **type** from the client hello when valid.
"""
mac_hex = normalize_mac(mac)
if not mac_hex:
return None
name = (device_name or "").strip()
if not name:
return None
ip = normalize_address_for_transport(peer_ip, "wifi")
if not ip:
return None
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:
merged = dict(self[mac_hex])
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
self[mac_hex] = merged
self.save()
return mac_hex
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

View File

@@ -1,69 +0,0 @@
import network
import aioespnow
class ESPNow:
"""
Singleton ESPNow helper:
- Manages a single AIOESPNow instance
- Adds a single broadcast-like peer
- Exposes async send(data) to send to that peer.
"""
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if getattr(self, "_initialized", False):
return
# ESP-NOW requires a WiFi interface to be active (STA or AP). Activate STA
# so ESP-NOW has an interface to use; we don't need to connect to an AP.
try:
sta = network.WLAN(network.STA_IF)
sta.active(True)
except Exception as e:
print("ESPNow: STA active failed:", e)
self._esp = aioespnow.AIOESPNow()
self._esp.active(True)
try:
self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
except Exception:
# Ignore add_peer failures (e.g. duplicate)
pass
self._initialized = True
async def send(self, data):
"""
Async send to the broadcast peer.
- data: bytes or str (JSON)
"""
if isinstance(data, str):
payload = data.encode()
else:
payload = data
# Debug: show what we're sending and its size
try:
preview = payload.decode('utf-8')
except Exception:
preview = str(payload)
if len(preview) > 200:
preview = preview[:200] + "...(truncated)"
print("ESPNow.send len=", len(payload), "payload=", preview)
try:
await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload)
except Exception as e:
print("ESPNow.send error:", e)
raise

View File

@@ -1,5 +1,15 @@
import json import json
import os import os
import traceback
# DB directory: project root / db (writable without root)
def _db_dir():
try:
# src/models/model.py -> project root
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base, "db")
except Exception:
return "db"
class Model(dict): class Model(dict):
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
@@ -13,13 +23,13 @@ class Model(dict):
if hasattr(self, '_initialized'): if hasattr(self, '_initialized'):
return return
# Create /db directory if it doesn't exist (MicroPython compatible) db_dir = _db_dir()
try: try:
os.mkdir("/db") os.makedirs(db_dir, exist_ok=True)
except OSError: except OSError:
pass # Directory already exists, which is fine pass
self.class_name = self.__class__.__name__ self.class_name = self.__class__.__name__
self.file = f"/db/{self.class_name.lower()}.json" self.file = os.path.join(db_dir, f"{self.class_name.lower()}.json")
super().__init__() super().__init__()
self.load() # Load settings from file during initialization self.load() # Load settings from file during initialization
@@ -37,11 +47,11 @@ class Model(dict):
def save(self): def save(self):
try: try:
# Ensure directory exists db_dir = os.path.dirname(self.file)
try: try:
os.mkdir("/db") os.makedirs(db_dir, exist_ok=True)
except OSError: except OSError:
pass # Directory already exists pass
j = json.dumps(self) j = json.dumps(self)
with open(self.file, 'w') as file: with open(self.file, 'w') as file:
file.write(j) file.write(j)
@@ -54,8 +64,7 @@ class Model(dict):
print(f"{self.class_name} saved successfully to {self.file}") print(f"{self.class_name} saved successfully to {self.file}")
except Exception as e: except Exception as e:
print(f"Error saving {self.class_name} to {self.file}: {e}") print(f"Error saving {self.class_name} to {self.file}: {e}")
import sys traceback.print_exception(type(e), e, e.__traceback__)
sys.print_exception(e)
def load(self): def load(self):
try: try:

View File

@@ -26,18 +26,18 @@ class Profile(Model):
if changed: if changed:
self.save() 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. """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() next_id = self.get_next_id()
# Create a unique palette for this profile. # Create a unique palette for this profile.
palette_id = self._palette_model.create(colors=[]) palette_id = self._palette_model.create(colors=[])
self[next_id] = { self[next_id] = {
"name": name, "name": name,
"type": profile_type, # "tabs" or "scenes" "type": profile_type, # "zones" or "scenes"
"tabs": [], # Array of tab IDs "zones": [], # Array of zone IDs
"scenes": [], # Array of scene IDs (for future use) "scenes": [], # Array of scene IDs (for future use)
"palette_id": str(palette_id), "palette_id": str(palette_id),
} }

12
src/models/serial.py Normal file
View File

@@ -0,0 +1,12 @@
class Serial:
def __init__(self, port, baudrate):
self.port = port
self.baudrate = baudrate
self.uart = UART(1, baudrate, tx=Pin(21), rx=Pin(6))
def send(self, data):
self.uart.write(data)
def receive(self):
return self.uart.read()

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

115
src/models/tcp_clients.py Normal file
View File

@@ -0,0 +1,115 @@
"""Track connected Wi-Fi LED drivers (TCP clients) for outbound JSON lines."""
import asyncio
_writers = {}
def prune_stale_tcp_writers() -> None:
"""Remove writers that are already closing so the UI does not stay online."""
stale = [(ip, w) for ip, w in list(_writers.items()) if w.is_closing()]
for ip, w in stale:
unregister_tcp_writer(ip, w)
def normalize_tcp_peer_ip(ip: str) -> str:
"""Match asyncio peer addresses to registry IPs (strip IPv4-mapped IPv6 prefix)."""
s = str(ip).strip()
if s.lower().startswith("::ffff:"):
s = s[7:]
return s
# Optional ``async def (ip: str, connected: bool) -> None`` set from ``main``.
_tcp_status_broadcast = None
def set_tcp_status_broadcaster(coro) -> None:
global _tcp_status_broadcast
_tcp_status_broadcast = coro
def _schedule_tcp_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 register_tcp_writer(peer_ip: str, writer) -> None:
if not peer_ip:
return
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return
old = _writers.get(key)
_writers[key] = writer
_schedule_tcp_status_broadcast(key, True)
if old is not None and old is not writer:
try:
old.close()
except Exception:
pass
def unregister_tcp_writer(peer_ip: str, writer=None) -> str:
"""
Remove the writer for peer_ip. If ``writer`` is given, only pop when it is still
the registered instance (avoids a replaced TCP session removing the new one).
Returns ``removed`` (cleared live session + UI offline), ``noop`` (already gone),
or ``superseded`` (this writer is not the registered one for that IP).
"""
if not peer_ip:
return "noop"
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return "noop"
current = _writers.get(key)
if writer is not None:
if current is None:
return "noop"
if current is not writer:
return "superseded"
had = key in _writers
if had:
_writers.pop(key, None)
_schedule_tcp_status_broadcast(key, False)
print(f"[TCP] device disconnected: {key}")
return "removed"
return "noop"
def list_connected_ips():
"""IPs with an active TCP writer (for UI snapshot)."""
prune_stale_tcp_writers()
return list(_writers.keys())
def tcp_client_connected(ip: str) -> bool:
"""True if a Wi-Fi driver is connected on this IP (TCP writer registered)."""
prune_stale_tcp_writers()
key = normalize_tcp_peer_ip(ip)
return bool(key and key in _writers)
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
"""Send one newline-terminated JSON message to a connected TCP client."""
ip = normalize_tcp_peer_ip(ip)
writer = _writers.get(ip)
if not writer:
return False
try:
line = json_str if json_str.endswith("\n") else json_str + "\n"
writer.write(line.encode("utf-8"))
await writer.drain()
return True
except Exception as exc:
print(f"[TCP] send to {ip} failed: {exc}")
unregister_tcp_writer(ip, writer)
return False

68
src/models/transport.py Normal file
View File

@@ -0,0 +1,68 @@
import asyncio
import json
# Default: broadcast (6 bytes). Pi always sends 6-byte address + payload to ESP32.
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
def _encode_payload(data):
if isinstance(data, str):
return data.encode()
if isinstance(data, dict):
return json.dumps(data).encode()
return data
def _parse_mac(addr):
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
if addr is None or addr == b"":
return BROADCAST_MAC
if isinstance(addr, bytes) and len(addr) == 6:
return addr
if isinstance(addr, str) and len(addr) == 12:
return bytes.fromhex(addr)
return BROADCAST_MAC
async def _to_thread(func, *args):
to_thread = getattr(asyncio, "to_thread", None)
if to_thread:
return await to_thread(func, *args)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, func, *args)
class SerialSender:
def __init__(self, port, baudrate, default_addr=None):
import serial
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
self._default_addr = _parse_mac(default_addr)
self._write_lock = asyncio.Lock()
async def send(self, data, addr=None):
mac = _parse_mac(addr) if addr is not None else self._default_addr
payload = _encode_payload(data)
async with self._write_lock:
await _to_thread(self._serial.write, mac + payload)
return True
_current_sender = None
def set_sender(sender):
global _current_sender
_current_sender = sender
def get_current_sender():
return _current_sender
def get_sender(settings):
port = settings.get("serial_port", "/dev/ttyS0")
baudrate = settings.get("serial_baudrate", 912000)
default_addr = settings.get("serial_destination_mac", "ffffffffffff")
return SerialSender(port, baudrate, default_addr=default_addr)

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

@@ -1,39 +0,0 @@
import network
import aioespnow
import asyncio
import json
from time import sleep
class P2P:
def __init__(self):
network.WLAN(network.STA_IF).active(True)
self.broadcast = bytes.fromhex("ffffffffffff")
self.e = aioespnow.AIOESPNow()
self.e.active(True)
try:
self.e.add_peer(self.broadcast)
except:
pass
async def send(self, data):
# Convert data to bytes if it's a string or dict
if isinstance(data, str):
payload = data.encode()
elif isinstance(data, dict):
payload = json.dumps(data).encode()
else:
payload = data # Assume it's already bytes
# Use asend for async sending - returns boolean indicating success
result = await self.e.asend(self.broadcast, payload)
return result
async def main():
p = P2P()
await p.send(json.dumps({"dj": {"p": "on", "colors": ["#ff0000"]}}))
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -2,11 +2,23 @@ import json
import os import os
import binascii import binascii
def _settings_path():
"""Path to settings.json in project root (writable without root)."""
try:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, "settings.json")
except Exception:
return "settings.json"
class Settings(dict): class Settings(dict):
SETTINGS_FILE = "/settings.json" SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self): def __init__(self):
super().__init__() super().__init__()
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization self.load() # Load settings from file during initialization
def generate_secret_key(self): def generate_secret_key(self):
@@ -33,6 +45,14 @@ class Settings(dict):
self['session_secret_key'] = self.generate_secret_key() self['session_secret_key'] = self.generate_secret_key()
# Save immediately when generating a new key # Save immediately when generating a new key
self.save() self.save()
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self:
self['wifi_channel'] = 6
# Wi-Fi LED drivers: newline-delimited JSON over TCP (see led-driver WiFi transport)
if 'tcp_enabled' not in self:
self['tcp_enabled'] = True
if 'tcp_port' not in self:
self['tcp_port'] = 8765
def save(self): def save(self):
try: try:

View File

@@ -5,7 +5,7 @@ class LightingController {
this.state = { this.state = {
lights: {}, lights: {},
patterns: {}, patterns: {},
tab_order: [], zone_order: [],
presets: {} presets: {}
}; };
this.selectedColorIndex = 0; this.selectedColorIndex = 0;
@@ -19,8 +19,8 @@ class LightingController {
await this.loadState(); await this.loadState();
this.setupEventListeners(); this.setupEventListeners();
this.renderTabs(); this.renderTabs();
if (this.state.tab_order.length > 0) { if (this.state.zone_order.length > 0) {
this.selectTab(this.state.tab_order[0]); this.selectTab(this.state.zone_order[0]);
} }
} }
@@ -62,19 +62,19 @@ class LightingController {
} }
setupEventListeners() { setupEventListeners() {
// Tab management // Zone management
document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal()); document.getElementById('add-zone-btn').addEventListener('click', () => this.showAddTabModal());
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal()); document.getElementById('edit-zone-btn').addEventListener('click', () => this.showEditTabModal());
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab()); document.getElementById('delete-zone-btn').addEventListener('click', () => this.deleteCurrentTab());
document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette()); document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette());
document.getElementById('presets-btn').addEventListener('click', () => this.showPresets()); document.getElementById('presets-btn').addEventListener('click', () => this.showPresets());
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles()); document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
// Modal actions // Modal actions
document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab()); document.getElementById('add-zone-confirm').addEventListener('click', () => this.createTab());
document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal')); document.getElementById('add-zone-cancel').addEventListener('click', () => this.hideModal('add-zone-modal'));
document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab()); document.getElementById('edit-zone-confirm').addEventListener('click', () => this.updateTab());
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal')); 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('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal'));
document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-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')); document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal'));
@@ -122,31 +122,15 @@ class LightingController {
document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette()); document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette());
document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor()); document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor());
// Close modals on outside click
document.getElementById('add-tab-modal').addEventListener('click', (e) => {
if (e.target.id === 'add-tab-modal') this.hideModal('add-tab-modal');
});
document.getElementById('edit-tab-modal').addEventListener('click', (e) => {
if (e.target.id === 'edit-tab-modal') this.hideModal('edit-tab-modal');
});
document.getElementById('profiles-modal').addEventListener('click', (e) => {
if (e.target.id === 'profiles-modal') this.hideModal('profiles-modal');
});
document.getElementById('presets-modal').addEventListener('click', (e) => {
if (e.target.id === 'presets-modal') this.hideModal('presets-modal');
});
document.getElementById('preset-editor-modal').addEventListener('click', (e) => {
if (e.target.id === 'preset-editor-modal') this.hideModal('preset-editor-modal');
});
} }
renderTabs() { renderTabs() {
const tabsList = document.getElementById('tabs-list'); const tabsList = document.getElementById('zones-list');
tabsList.innerHTML = ''; tabsList.innerHTML = '';
this.state.tab_order.forEach(tabName => { this.state.zone_order.forEach(tabName => {
const tabButton = document.createElement('button'); const tabButton = document.createElement('button');
tabButton.className = 'tab-button'; tabButton.className = 'zone-button';
tabButton.textContent = tabName; tabButton.textContent = tabName;
tabButton.addEventListener('click', () => this.selectTab(tabName)); tabButton.addEventListener('click', () => this.selectTab(tabName));
if (tabName === this.currentTab) { if (tabName === this.currentTab) {
@@ -233,13 +217,13 @@ class LightingController {
} }
renderPresets(tabName) { renderPresets(tabName) {
const presetsList = document.getElementById('presets-list-tab'); const presetsList = document.getElementById('presets-list-zone');
presetsList.innerHTML = ''; presetsList.innerHTML = '';
const presets = this.state.presets || {}; const presets = this.state.presets || {};
const presetNames = Object.keys(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); const currentSettings = this.getCurrentTabSettings(tabName);
// Always include "on" and "off" presets // Always include "on" and "off" presets
@@ -283,7 +267,7 @@ class LightingController {
const presetButton = document.createElement('button'); const presetButton = document.createElement('button');
presetButton.className = 'pattern-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); const isActive = this.presetMatchesSettings(preset, currentSettings);
if (isActive) { if (isActive) {
presetButton.classList.add('active'); presetButton.classList.add('active');
@@ -360,7 +344,7 @@ class LightingController {
}) })
}); });
// Reload state and tab content // Reload state and zone content
await this.loadState(); await this.loadState();
await this.loadTabContent(tabName); await this.loadTabContent(tabName);
} else { } else {
@@ -607,7 +591,7 @@ class LightingController {
} }
// Reload state from server to ensure consistency // Reload state from server to ensure consistency
await this.loadState(); await this.loadState();
// Reload tab content to update UI // Reload zone content to update UI
await this.loadTabContent(tabName); await this.loadTabContent(tabName);
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
@@ -785,23 +769,23 @@ class LightingController {
} }
showAddTabModal() { showAddTabModal() {
document.getElementById('new-tab-name').value = ''; document.getElementById('new-zone-name').value = '';
document.getElementById('new-tab-ids').value = '1'; document.getElementById('new-zone-ids').value = '1';
document.getElementById('add-tab-modal').classList.add('active'); document.getElementById('add-zone-modal').classList.add('active');
} }
async createTab() { async createTab() {
const name = document.getElementById('new-tab-name').value.trim(); const name = document.getElementById('new-zone-name').value.trim();
const idsStr = document.getElementById('new-tab-ids').value.trim(); const idsStr = document.getElementById('new-zone-ids').value.trim();
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id); const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
if (!name) { if (!name) {
alert('Tab name cannot be empty'); alert('Zone name cannot be empty');
return; return;
} }
try { try {
const response = await fetch('/tabs', { const response = await fetch('/zones', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, ids }) body: JSON.stringify({ name, ids })
@@ -811,41 +795,41 @@ class LightingController {
await this.loadState(); await this.loadState();
this.renderTabs(); this.renderTabs();
this.selectTab(name); this.selectTab(name);
this.hideModal('add-tab-modal'); this.hideModal('add-zone-modal');
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to create tab'); alert(error.error || 'Failed to create zone');
} }
} catch (error) { } catch (error) {
console.error('Failed to create tab:', error); console.error('Failed to create zone:', error);
alert('Failed to create tab'); alert('Failed to create zone');
} }
} }
showEditTabModal() { showEditTabModal() {
if (!this.currentTab) { if (!this.currentTab) {
alert('Please select a tab first'); alert('Please select a zone first');
return; return;
} }
const light = this.state.lights[this.currentTab]; const light = this.state.lights[this.currentTab];
document.getElementById('edit-tab-name').value = this.currentTab; document.getElementById('edit-zone-name').value = this.currentTab;
document.getElementById('edit-tab-ids').value = light.names.join(', '); document.getElementById('edit-zone-ids').value = light.names.join(', ');
document.getElementById('edit-tab-modal').classList.add('active'); document.getElementById('edit-zone-modal').classList.add('active');
} }
async updateTab() { async updateTab() {
const newName = document.getElementById('edit-tab-name').value.trim(); const newName = document.getElementById('edit-zone-name').value.trim();
const idsStr = document.getElementById('edit-tab-ids').value.trim(); const idsStr = document.getElementById('edit-zone-ids').value.trim();
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id); const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
if (!newName) { if (!newName) {
alert('Tab name cannot be empty'); alert('Zone name cannot be empty');
return; return;
} }
try { try {
const response = await fetch(`/tabs/${this.currentTab}`, { const response = await fetch(`/zones/${this.currentTab}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, ids }) body: JSON.stringify({ name: newName, ids })
@@ -855,45 +839,45 @@ class LightingController {
await this.loadState(); await this.loadState();
this.renderTabs(); this.renderTabs();
this.selectTab(newName); this.selectTab(newName);
this.hideModal('edit-tab-modal'); this.hideModal('edit-zone-modal');
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to update tab'); alert(error.error || 'Failed to update zone');
} }
} catch (error) { } catch (error) {
console.error('Failed to update tab:', error); console.error('Failed to update zone:', error);
alert('Failed to update tab'); alert('Failed to update zone');
} }
} }
async deleteCurrentTab() { async deleteCurrentTab() {
if (!this.currentTab) { if (!this.currentTab) {
alert('Please select a tab first'); alert('Please select a zone first');
return; 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; return;
} }
try { try {
const response = await fetch(`/tabs/${this.currentTab}`, { const response = await fetch(`/zones/${this.currentTab}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (response.ok) { if (response.ok) {
await this.loadState(); await this.loadState();
this.renderTabs(); this.renderTabs();
if (this.state.tab_order.length > 0) { if (this.state.zone_order.length > 0) {
this.selectTab(this.state.tab_order[0]); this.selectTab(this.state.zone_order[0]);
} else { } else {
this.currentTab = null; 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) { } catch (error) {
console.error('Failed to delete tab:', error); console.error('Failed to delete zone:', error);
alert('Failed to delete tab'); alert('Failed to delete zone');
} }
} }
@@ -1024,9 +1008,9 @@ class LightingController {
if (this.state.current_profile === profileName) { if (this.state.current_profile === profileName) {
this.state.current_profile = ''; this.state.current_profile = '';
this.state.lights = {}; this.state.lights = {};
this.state.tab_order = []; this.state.zone_order = [];
this.renderTabs(); 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(); this.updateCurrentProfileDisplay();
} }
} else { } else {
@@ -1048,8 +1032,8 @@ class LightingController {
if (response.ok) { if (response.ok) {
await this.loadState(); await this.loadState();
this.renderTabs(); this.renderTabs();
if (this.state.tab_order.length > 0) { if (this.state.zone_order.length > 0) {
this.selectTab(this.state.tab_order[0]); this.selectTab(this.state.zone_order[0]);
} else { } else {
this.currentTab = null; this.currentTab = null;
} }
@@ -1145,7 +1129,7 @@ class LightingController {
swatch.style.cssText = 'width: 40px; height: 40px; background-color: ' + color + '; border: 2px solid #4a4a4a; border-radius: 4px; cursor: pointer; position: relative;'; swatch.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`; 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) => { swatch.addEventListener('click', (e) => {
// Only apply if not clicking the remove button // Only apply if not clicking the remove button
if (e.target === swatch || !e.target.closest('button')) { if (e.target === swatch || !e.target.closest('button')) {
@@ -1167,7 +1151,7 @@ class LightingController {
applyPaletteColorToSelected(paletteColor) { applyPaletteColorToSelected(paletteColor) {
if (!this.currentTab) { if (!this.currentTab) {
alert('No tab selected. Please select a tab first.'); alert('No zone selected. Please select a zone first.');
return; return;
} }
@@ -1455,7 +1439,7 @@ class LightingController {
async applyPreset(presetName) { async applyPreset(presetName) {
if (!this.currentTab) { if (!this.currentTab) {
alert('Please select a tab first'); alert('Please select a zone first');
return; return;
} }
@@ -1637,7 +1621,7 @@ class LightingController {
loadCurrentTabToPresetEditor() { loadCurrentTabToPresetEditor() {
if (!this.currentTab || !this.state.lights[this.currentTab]) { if (!this.currentTab || !this.state.lights[this.currentTab]) {
alert('Please select a tab first'); alert('Please select a zone first');
return; return;
} }

View File

@@ -4,7 +4,6 @@ document.addEventListener('DOMContentLoaded', () => {
const closeButton = document.getElementById('color-palette-close-btn'); const closeButton = document.getElementById('color-palette-close-btn');
const paletteContainer = document.getElementById('palette-container'); const paletteContainer = document.getElementById('palette-container');
const paletteNewColor = document.getElementById('palette-new-color'); const paletteNewColor = document.getElementById('palette-new-color');
const paletteAddButton = document.getElementById('palette-add-color-btn');
const profileNameDisplay = document.getElementById('palette-current-profile-name'); const profileNameDisplay = document.getElementById('palette-current-profile-name');
if (!paletteButton || !paletteModal || !paletteContainer) { if (!paletteButton || !paletteModal || !paletteContainer) {
@@ -177,8 +176,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (closeButton) { if (closeButton) {
closeButton.addEventListener('click', closeModal); closeButton.addEventListener('click', closeModal);
} }
if (paletteAddButton && paletteNewColor) { if (paletteNewColor) {
paletteAddButton.addEventListener('click', async () => { const addSelectedColor = async () => {
const color = paletteNewColor.value; const color = paletteNewColor.value;
if (!color) { if (!color) {
return; return;
@@ -188,11 +187,8 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
await savePalette([...currentPalette, color]); await savePalette([...currentPalette, color]);
}); };
// Add when the picker closes (user confirms selection).
paletteNewColor.addEventListener('change', addSelectedColor);
} }
paletteModal.addEventListener('click', (event) => {
if (event.target === paletteModal) {
closeModal();
}
});
}); });

446
src/static/devices.js Normal file
View File

@@ -0,0 +1,446 @@
// Device registry: name, id (storage key), type (led), transport (wifi|espnow), address
const HEX_BOX_COUNT = 12;
/** Last TCP snapshot from WebSocket (so we can apply after async list render). */
let lastTcpSnapshotIps = null;
/** Match server-side ``normalize_tcp_peer_ip`` for WS events vs registry rows. */
function normalizeWifiAddressForMatch(addr) {
let s = String(addr || '').trim();
if (s.toLowerCase().startsWith('::ffff:')) {
s = s.slice(7);
}
return s;
}
const DEVICES_MODAL_POLL_MS = 1000;
let devicesModalLiveTimer = null;
function stopDevicesModalLiveRefresh() {
if (devicesModalLiveTimer != null) {
clearInterval(devicesModalLiveTimer);
devicesModalLiveTimer = null;
}
}
/**
* Refetch registry and re-render the list (no loading spinner). Keeps scroll position.
* Used while the devices modal stays open so new TCP devices, renames, and removals appear live.
*/
async function refreshDevicesListQuiet() {
const modal = document.getElementById('devices-modal');
if (!modal || !modal.classList.contains('active')) return;
const container = document.getElementById('devices-list-modal');
if (!container) return;
const prevTop = container.scrollTop;
try {
const res = await fetch('/devices', { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const data = await res.json();
renderDevicesList(data || {});
container.scrollTop = prevTop;
} catch (_) {
/* ignore */
}
}
function startDevicesModalLiveRefresh() {
stopDevicesModalLiveRefresh();
devicesModalLiveTimer = setInterval(() => {
refreshDevicesListQuiet();
}, DEVICES_MODAL_POLL_MS);
}
function updateWifiRowDot(row, connected) {
const dot = row.querySelector('.device-status-dot');
if (!dot) return;
if ((row.dataset.deviceTransport || '') !== 'wifi') return;
dot.classList.remove('device-status-dot--online', 'device-status-dot--offline', 'device-status-dot--unknown');
if (connected) {
dot.classList.add('device-status-dot--online');
dot.title = 'Connected (Wi-Fi TCP session)';
} else {
dot.classList.add('device-status-dot--offline');
dot.title = 'Not connected (no Wi-Fi TCP session)';
}
dot.setAttribute('aria-label', dot.title);
}
function applyTcpSnapshot(ips) {
const set = new Set(
(ips || []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
);
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
const addr = normalizeWifiAddressForMatch(row.dataset.deviceAddress);
updateWifiRowDot(row, set.has(addr));
});
}
/** Keep cached snapshot aligned with incremental WS events (connect/disconnect). */
function mergeTcpSnapshotPresence(ip, connected) {
const n = normalizeWifiAddressForMatch(ip);
if (!n) return;
const prev = lastTcpSnapshotIps;
const set = new Set(
(Array.isArray(prev) ? prev : []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
);
if (connected) {
set.add(n);
} else {
set.delete(n);
}
lastTcpSnapshotIps = Array.from(set);
}
function makeHexAddressBoxes(container) {
if (!container || container.querySelector('.hex-addr-box')) return;
container.innerHTML = '';
for (let i = 0; i < HEX_BOX_COUNT; i++) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'hex-addr-box';
input.maxLength = 1;
input.autocomplete = 'off';
input.setAttribute('data-index', i);
input.setAttribute('inputmode', 'numeric');
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
input.addEventListener('input', (e) => {
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
e.target.value = v;
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
e.target.nextElementSibling.focus();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
e.target.previousElementSibling.focus();
}
});
input.addEventListener('paste', (e) => {
e.preventDefault();
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
const boxes = container.querySelectorAll('.hex-addr-box');
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
boxes[j].value = pasted[j];
}
if (pasted.length > 0) {
const nextIdx = Math.min(pasted.length, boxes.length - 1);
boxes[nextIdx].focus();
}
});
container.appendChild(input);
}
}
function setAddressToBoxes(container, addrStr) {
if (!container) return;
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
const boxes = container.querySelectorAll('.hex-addr-box');
boxes.forEach((b, i) => {
b.value = s[i] || '';
});
}
function applyTransportVisibility(transport) {
const isWifi = transport === 'wifi';
const esp = document.getElementById('edit-device-address-espnow');
const wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
if (esp) esp.hidden = isWifi;
if (wifiWrap) wifiWrap.hidden = !isWifi;
}
function getAddressForPayload(transport) {
if (transport === 'wifi') {
const el = document.getElementById('edit-device-address-wifi');
const v = (el && el.value.trim()) || '';
return v || null;
}
const boxEl = document.getElementById('edit-device-address-boxes');
if (!boxEl) return null;
const boxes = boxEl.querySelectorAll('.hex-addr-box');
const hex = Array.from(boxes).map((b) => b.value).join('').toLowerCase();
return hex || null;
}
async function loadDevicesModal() {
const container = document.getElementById('devices-list-modal');
if (!container) return;
if (typeof window.getEspnowSocket === 'function') {
window.getEspnowSocket();
}
container.innerHTML = '<span class="muted-text">Loading...</span>';
try {
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Failed to load devices');
const devices = await response.json();
renderDevicesList(devices || {});
} catch (e) {
console.error('loadDevicesModal:', e);
container.innerHTML = '<span class="muted-text">Failed to load devices.</span>';
}
}
function renderDevicesList(devices) {
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.innerHTML = '';
const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object');
if (ids.length === 0) {
const p = document.createElement('p');
p.className = 'muted-text';
p.textContent = 'No devices yet. Wi-Fi drivers will appear here when they connect over TCP.';
container.appendChild(p);
return;
}
ids.forEach((devId) => {
const dev = devices[devId];
const t = (dev && dev.type) || 'led';
const tr = (dev && dev.transport) || 'espnow';
const addrRaw = (dev && dev.address) != null ? String(dev.address).trim() : '';
const addrDisplay = addrRaw || '—';
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.5rem';
row.style.flexWrap = 'wrap';
row.dataset.deviceId = devId;
row.dataset.deviceTransport = tr;
row.dataset.deviceAddress = addrRaw;
const dot = document.createElement('span');
dot.className = 'device-status-dot';
dot.setAttribute('role', 'img');
const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null;
if (live === true) {
dot.classList.add('device-status-dot--online');
dot.title = 'Connected (Wi-Fi TCP session)';
dot.setAttribute('aria-label', dot.title);
} else if (live === false) {
dot.classList.add('device-status-dot--offline');
dot.title = 'Not connected (no Wi-Fi TCP session)';
dot.setAttribute('aria-label', dot.title);
} else {
dot.classList.add('device-status-dot--unknown');
dot.title = 'ESP-NOW — TCP status does not apply';
dot.setAttribute('aria-label', dot.title);
}
const label = document.createElement('span');
label.textContent = (dev && dev.name) || devId;
label.style.flex = '1';
label.style.minWidth = '100px';
const macEl = document.createElement('code');
macEl.className = 'device-row-mac';
macEl.textContent = devId;
macEl.title = 'MAC (registry id)';
const meta = document.createElement('span');
meta.className = 'muted-text';
meta.style.fontSize = '0.85em';
meta.textContent = `${t} · ${tr} · ${addrDisplay}`;
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
const identifyBtn = document.createElement('button');
identifyBtn.className = 'btn btn-primary btn-small';
identifyBtn.type = 'button';
identifyBtn.textContent = 'Identify';
identifyBtn.title = 'Red blink at 10 Hz (~50% brightness) for 2 s, then off (not saved as a preset)';
identifyBtn.addEventListener('click', async () => {
try {
const res = await fetch(`/devices/${encodeURIComponent(devId)}/identify`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Identify failed');
return;
}
} catch (err) {
console.error(err);
alert('Identify failed');
}
});
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-secondary btn-small';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', async () => {
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
try {
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'DELETE' });
if (res.ok) await loadDevicesModal();
else {
const data = await res.json().catch(() => ({}));
alert(data.error || 'Delete failed');
}
} catch (err) {
console.error(err);
alert('Delete failed');
}
});
row.appendChild(dot);
row.appendChild(label);
row.appendChild(macEl);
row.appendChild(meta);
row.appendChild(editBtn);
row.appendChild(identifyBtn);
row.appendChild(deleteBtn);
container.appendChild(row);
});
// Do not re-apply lastTcpSnapshotIps here: it is only updated on WS open and
// device_tcp events; re-applying after each /devices poll overwrites correct
// API "connected" with a stale list and leaves Wi-Fi rows stuck online.
}
function openEditDeviceModal(devId, dev) {
const modal = document.getElementById('edit-device-modal');
const idInput = document.getElementById('edit-device-id');
const storageLabel = document.getElementById('edit-device-storage-id');
const nameInput = document.getElementById('edit-device-name');
const typeSel = document.getElementById('edit-device-type');
const transportSel = document.getElementById('edit-device-transport');
const addressBoxes = document.getElementById('edit-device-address-boxes');
const wifiInput = document.getElementById('edit-device-address-wifi');
if (!modal || !idInput) return;
idInput.value = devId;
if (storageLabel) storageLabel.textContent = devId;
if (nameInput) nameInput.value = (dev && dev.name) || '';
if (typeSel) typeSel.value = (dev && dev.type) || 'led';
const tr = (dev && dev.transport) || 'espnow';
if (transportSel) transportSel.value = tr;
applyTransportVisibility(tr);
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
modal.classList.add('active');
}
async function updateDevice(devId, name, type, transport, address) {
try {
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
type: type || 'led',
transport: transport || 'espnow',
address,
}),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
await loadDevicesModal();
return true;
}
alert(data.error || 'Failed to update device');
return false;
} catch (e) {
console.error('updateDevice:', e);
alert('Failed to update device');
return false;
}
}
document.addEventListener('DOMContentLoaded', () => {
window.addEventListener('deviceTcpStatus', (ev) => {
const { ip, connected } = ev.detail || {};
if (ip == null || typeof connected !== 'boolean') return;
mergeTcpSnapshotPresence(ip, connected);
const norm = normalizeWifiAddressForMatch(ip);
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
if (normalizeWifiAddressForMatch(row.dataset.deviceAddress) === norm) {
updateWifiRowDot(row, connected);
}
});
});
window.addEventListener('deviceTcpSnapshot', (ev) => {
const ips = ev.detail && ev.detail.connectedIps;
lastTcpSnapshotIps = ips;
applyTcpSnapshot(ips);
});
window.addEventListener('deviceTcpWsOpen', () => {
refreshDevicesListQuiet();
});
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
const transportEdit = document.getElementById('edit-device-transport');
if (transportEdit) {
transportEdit.addEventListener('change', () => {
applyTransportVisibility(transportEdit.value);
});
}
const devicesBtn = document.getElementById('devices-btn');
const devicesModal = document.getElementById('devices-modal');
const devicesCloseBtn = document.getElementById('devices-close-btn');
const editForm = document.getElementById('edit-device-form');
const editCloseBtn = document.getElementById('edit-device-close-btn');
const editDeviceModal = document.getElementById('edit-device-modal');
if (devicesBtn && devicesModal) {
devicesBtn.addEventListener('click', () => {
devicesModal.classList.add('active');
if (typeof window.getEspnowSocket === 'function') {
window.getEspnowSocket();
}
loadDevicesModal();
startDevicesModalLiveRefresh();
});
}
if (devicesCloseBtn) {
devicesCloseBtn.addEventListener('click', () => {
if (devicesModal) devicesModal.classList.remove('active');
});
}
const devicesModalEl = document.getElementById('devices-modal');
if (devicesModalEl) {
new MutationObserver(() => {
if (!devicesModalEl.classList.contains('active')) {
stopDevicesModalLiveRefresh();
}
}).observe(devicesModalEl, { attributes: true, attributeFilter: ['class'] });
}
if (editForm) {
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name');
const typeSel = document.getElementById('edit-device-type');
const transportSel = document.getElementById('edit-device-transport');
const devId = idInput && idInput.value;
if (!devId) return;
const transport = (transportSel && transportSel.value) || 'espnow';
const address = getAddressForPayload(transport);
const ok = await updateDevice(
devId,
nameInput ? nameInput.value.trim() : '',
(typeSel && typeSel.value) || 'led',
transport,
address
);
if (ok) editDeviceModal.classList.remove('active');
});
}
if (editCloseBtn) {
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
}
});

View File

@@ -18,14 +18,6 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
if (helpModal) {
helpModal.addEventListener('click', (event) => {
if (event.target === helpModal) {
helpModal.classList.remove('active');
}
});
}
// Mobile main menu: forward clicks to existing header buttons // Mobile main menu: forward clicks to existing header buttons
if (mainMenuBtn && mainMenuDropdown) { if (mainMenuBtn && mainMenuDropdown) {
mainMenuBtn.addEventListener('click', () => { mainMenuBtn.addEventListener('click', () => {
@@ -43,13 +35,6 @@ document.addEventListener('DOMContentLoaded', () => {
mainMenuDropdown.classList.remove('open'); mainMenuDropdown.classList.remove('open');
} }
}); });
// Close menu when clicking outside
document.addEventListener('click', (event) => {
if (!mainMenuDropdown.contains(event.target) && event.target !== mainMenuBtn) {
mainMenuDropdown.classList.remove('open');
}
});
} }
// Settings modal wiring (reusing existing settings endpoints). // Settings modal wiring (reusing existing settings endpoints).
@@ -75,6 +60,12 @@ document.addEventListener('DOMContentLoaded', () => {
if (nameInput && data && typeof data === 'object') { if (nameInput && data && typeof data === 'object') {
nameInput.value = data.device_name || 'led-controller'; 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) { } catch (error) {
console.error('Error loading device settings:', error); console.error('Error loading device settings:', error);
} }
@@ -121,14 +112,6 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
if (settingsModal) {
settingsModal.addEventListener('click', (event) => {
if (event.target === settingsModal) {
settingsModal.classList.remove('active');
}
});
}
const deviceForm = document.getElementById('device-form'); const deviceForm = document.getElementById('device-form');
if (deviceForm) { if (deviceForm) {
deviceForm.addEventListener('submit', async (e) => { deviceForm.addEventListener('submit', async (e) => {
@@ -139,15 +122,29 @@ document.addEventListener('DOMContentLoaded', () => {
showSettingsMessage('Device name is required', 'error'); showSettingsMessage('Device name is required', 'error');
return; 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 { try {
const response = await fetch('/settings/settings', { const response = await fetch('/settings/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, 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(); const result = await response.json();
if (response.ok) { 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 { } else {
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error'); showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
} }

View File

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

View File

@@ -78,9 +78,4 @@ document.addEventListener('DOMContentLoaded', () => {
patternsCloseButton.addEventListener('click', closeModal); patternsCloseButton.addEventListener('click', closeModal);
} }
patternsModal.addEventListener('click', (event) => {
if (event.target === patternsModal) {
closeModal();
}
});
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,29 @@ document.addEventListener("DOMContentLoaded", () => {
const profilesCloseButton = document.getElementById("profiles-close-btn"); const profilesCloseButton = document.getElementById("profiles-close-btn");
const profilesList = document.getElementById("profiles-list"); const profilesList = document.getElementById("profiles-list");
const newProfileInput = document.getElementById("new-profile-name"); const newProfileInput = document.getElementById("new-profile-name");
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
const createProfileButton = document.getElementById("create-profile-btn"); const createProfileButton = document.getElementById("create-profile-btn");
if (!profilesButton || !profilesModal || !profilesList) { if (!profilesButton || !profilesModal || !profilesList) {
return; return;
} }
const isEditModeActive = () => {
const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
};
const updateProfileEditorControlsVisibility = () => {
const editMode = isEditModeActive();
const actions = profilesModal.querySelector('.profiles-actions');
if (actions) {
actions.style.display = editMode ? '' : 'none';
}
};
const openModal = () => { const openModal = () => {
profilesModal.classList.add("active"); profilesModal.classList.add("active");
updateProfileEditorControlsVisibility();
loadProfiles(); loadProfiles();
}; };
@@ -19,6 +34,18 @@ document.addEventListener("DOMContentLoaded", () => {
profilesModal.classList.remove("active"); profilesModal.classList.remove("active");
}; };
const refreshTabsForActiveProfile = async () => {
// Clear stale current 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();
}
if (window.tabsManager && typeof window.tabsManager.loadTabsModal === "function") {
await window.tabsManager.loadTabsModal();
}
};
const renderProfiles = (profiles, currentProfileId) => { const renderProfiles = (profiles, currentProfileId) => {
profilesList.innerHTML = ""; profilesList.innerHTML = "";
let entries = []; let entries = [];
@@ -41,6 +68,7 @@ document.addEventListener("DOMContentLoaded", () => {
return; return;
} }
const editMode = isEditModeActive();
entries.forEach(([profileId, profile]) => { entries.forEach(([profileId, profile]) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "profiles-row"; row.className = "profiles-row";
@@ -66,7 +94,7 @@ document.addEventListener("DOMContentLoaded", () => {
throw new Error("Failed to apply profile"); throw new Error("Failed to apply profile");
} }
await loadProfiles(); await loadProfiles();
document.body.dispatchEvent(new Event("tabs-updated")); await refreshTabsForActiveProfile();
} catch (error) { } catch (error) {
console.error("Apply profile failed:", error); console.error("Apply profile failed:", error);
alert("Failed to apply profile."); alert("Failed to apply profile.");
@@ -115,22 +143,8 @@ document.addEventListener("DOMContentLoaded", () => {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
}); });
} }
document.cookie = "current_tab=; path=/; max-age=0";
await loadProfiles(); await loadProfiles();
if (typeof window.loadTabs === "function") { await refreshTabsForActiveProfile();
await window.loadTabs();
}
if (typeof window.loadTabsModal === "function") {
await window.loadTabsModal();
}
const tabContent = document.getElementById("tab-content");
if (tabContent) {
tabContent.innerHTML = `
<div class="tab-content-placeholder">
Select a tab to get started
</div>
`;
}
} catch (error) { } catch (error) {
console.error("Clone profile failed:", error); console.error("Clone profile failed:", error);
alert("Failed to clone profile."); alert("Failed to clone profile.");
@@ -162,8 +176,10 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label); row.appendChild(label);
row.appendChild(applyButton); row.appendChild(applyButton);
row.appendChild(cloneButton); if (editMode) {
row.appendChild(deleteButton); row.appendChild(cloneButton);
row.appendChild(deleteButton);
}
profilesList.appendChild(row); profilesList.appendChild(row);
}); });
}; };
@@ -198,6 +214,9 @@ document.addEventListener("DOMContentLoaded", () => {
}; };
const createProfile = async () => { const createProfile = async () => {
if (!isEditModeActive()) {
return;
}
if (!newProfileInput) { if (!newProfileInput) {
return; return;
} }
@@ -210,7 +229,10 @@ document.addEventListener("DOMContentLoaded", () => {
const response = await fetch("/profiles", { const response = await fetch("/profiles", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }), body: JSON.stringify({
name,
seed_dj_zone: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
}),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to create profile"); throw new Error("Failed to create profile");
@@ -236,23 +258,11 @@ document.addEventListener("DOMContentLoaded", () => {
} }
newProfileInput.value = ""; newProfileInput.value = "";
// Clear current tab and refresh the UI so the new profile starts empty. if (newProfileSeedDjInput) {
document.cookie = "current_tab=; path=/; max-age=0"; newProfileSeedDjInput.checked = false;
}
await loadProfiles(); await loadProfiles();
if (typeof window.loadTabs === "function") { await refreshTabsForActiveProfile();
await window.loadTabs();
}
if (typeof window.loadTabsModal === "function") {
await window.loadTabsModal();
}
const tabContent = document.getElementById("tab-content");
if (tabContent) {
tabContent.innerHTML = `
<div class="tab-content-placeholder">
Select a tab to get started
</div>
`;
}
} catch (error) { } catch (error) {
console.error("Create profile failed:", error); console.error("Create profile failed:", error);
alert("Failed to create profile."); alert("Failed to create profile.");
@@ -274,9 +284,14 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
} }
profilesModal.addEventListener("click", (event) => { // Keep modal controls in sync with run/edit mode.
if (event.target === profilesModal) { document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
closeModal(); btn.addEventListener('click', () => {
} if (profilesModal.classList.contains('active')) {
updateProfileEditorControlsVisibility();
loadProfiles();
}
});
}); });
}); });

View File

@@ -12,6 +12,78 @@ body {
overflow: hidden; 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 { .app-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -77,6 +149,11 @@ header h1 {
background-color: #333; background-color: #333;
} }
/* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only {
display: none !important;
}
.btn { .btn {
padding: 0.45rem 0.9rem; padding: 0.45rem 0.9rem;
border: none; border: none;
@@ -126,7 +203,7 @@ header h1 {
overflow: hidden; overflow: hidden;
} }
.tabs-container { .zones-container {
background-color: transparent; background-color: transparent;
padding: 0.5rem 0; padding: 0.5rem 0;
flex: 1; flex: 1;
@@ -136,7 +213,7 @@ header h1 {
align-items: center; align-items: center;
} }
.tabs-list { .zones-list {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
overflow-x: auto; overflow-x: auto;
@@ -145,7 +222,7 @@ header h1 {
min-width: 0; min-width: 0;
} }
.tab-button { .zone-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #3a3a3a; background-color: #3a3a3a;
color: white; color: white;
@@ -157,16 +234,16 @@ header h1 {
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.tab-button:hover { .zone-button:hover {
background-color: #4a4a4a; background-color: #4a4a4a;
} }
.tab-button.active { .zone-button.active {
background-color: #6a5acd; background-color: #6a5acd;
color: white; color: white;
} }
.tab-content { .zone-content {
flex: 1; flex: 1;
display: block; display: block;
overflow-y: auto; overflow-y: auto;
@@ -178,7 +255,7 @@ header h1 {
align-items: center; align-items: center;
} }
.tab-brightness-group { .zone-brightness-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -186,7 +263,7 @@ header h1 {
margin-left: auto; margin-left: auto;
} }
.tab-brightness-group label { .zone-brightness-group label {
white-space: nowrap; white-space: nowrap;
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -432,8 +509,8 @@ header h1 {
padding: 0; padding: 0;
} }
/* Tab preset selecting area: 3 columns, vertical scroll only */ /* Zone preset selecting area: 3 columns, vertical scroll only */
#presets-list-tab { #presets-list-zone {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -530,6 +607,29 @@ header h1 {
color: #f44336; 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 { .btn-group {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -596,8 +696,62 @@ header h1 {
position: relative; position: relative;
} }
/* Preset select buttons inside the tab grid */ /* Preset tile: main button + optional edit/remove (Edit mode) */
#presets-list-tab .pattern-button { .preset-tile-row {
display: flex;
flex-direction: row;
align-items: stretch;
min-width: 0;
min-height: 0;
}
.preset-tile-row--run .preset-tile-actions {
display: none;
}
.preset-tile-main {
flex: 1;
min-width: 0;
height: 5rem;
}
/* Edit only beside the preset tile in edit mode. */
.preset-tile-actions {
display: flex;
flex-direction: column;
justify-content: stretch;
gap: 0.2rem;
flex-shrink: 0;
padding: 0.15rem 0 0.15rem 0.25rem;
width: auto;
min-width: 0;
}
.preset-editor-modal-actions {
flex-wrap: wrap;
gap: 0.35rem;
}
.preset-tile-actions .btn {
width: 100%;
min-height: 2.35rem;
padding: 0.15rem 0.35rem;
font-size: 0.68rem;
line-height: 1.15;
white-space: normal;
}
.ui-mode-toggle--edit {
background-color: #4a3f8f;
border: 1px solid #7b6fd6;
}
.ui-mode-toggle--edit:hover {
background-color: #5a4f9f;
}
/* Preset select buttons inside the zone grid */
#presets-list-zone .pattern-button {
display: flex; display: flex;
} }
.pattern-button .pattern-button-label { .pattern-button .pattern-button-label {
@@ -812,12 +966,12 @@ header h1 {
padding: 0.4rem 0.7rem; padding: 0.4rem 0.7rem;
} }
.tabs-container { .zones-container {
padding: 0.5rem 0; padding: 0.5rem 0;
border-bottom: none; border-bottom: none;
} }
.tab-content { .zone-content {
padding: 0.5rem; padding: 0.5rem;
} }
@@ -909,6 +1063,65 @@ header h1 {
background-color: #3a3a3a; background-color: #3a3a3a;
border-radius: 4px; 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 */ /* Hide any text content in palette rows - only show color swatches */
#palette-container .profiles-row { #palette-container .profiles-row {
font-size: 0; /* Hide any text nodes */ font-size: 0; /* Hide any text nodes */
@@ -982,7 +1195,7 @@ header h1 {
} }
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */ /* Presets list: 3 columns and vertical scroll (defined above); mobile same */
@media (max-width: 800px) { @media (max-width: 800px) {
#presets-list-tab { #presets-list-zone {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
@@ -1021,8 +1234,8 @@ header h1 {
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Tab content placeholder (no tab selected) */ /* Zone content placeholder (no zone selected) */
.tab-content-placeholder { .zone-content-placeholder {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: #aaa; color: #aaa;

View File

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

View File

@@ -1,809 +0,0 @@
// Tab management JavaScript
let currentTabId = null;
// 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) {
loadTabContent(currentTabId);
} else if (data.tab_order && data.tab_order.length > 0) {
// Set first tab as current if none is set
await setCurrentTab(data.tab_order[0]);
}
} 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;
}
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="Click to select, right-click to edit"
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;
}
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(editButton);
row.appendChild(sendPresetsButton);
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 });
} 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');
});
}
if (tabsModal) {
tabsModal.addEventListener('click', (event) => {
if (event.target === tabsModal) {
tabsModal.classList.remove('active');
}
});
}
// Right-click on a tab button in the main header bar to edit that tab
document.addEventListener('contextmenu', async (event) => {
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();
}
});
}
// Close edit modal when clicking outside
const editTabModal = document.getElementById('edit-tab-modal');
if (editTabModal) {
editTabModal.addEventListener('click', (event) => {
if (event.target === editTabModal) {
editTabModal.classList.remove('active');
}
});
}
// Profile-wide "Send Presets" button in header
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
if (sendProfilePresetsBtn) {
sendProfilePresetsBtn.addEventListener('click', async () => {
await sendProfilePresets();
});
}
});
// Export for use in other scripts
window.tabsManager = {
loadTabs,
selectTab,
createTab,
updateTab,
openEditTabModal,
getCurrentTabId: () => currentTabId
};

View File

@@ -1,24 +1,24 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
let selectedIndex = null; let selectedIndex = null;
const getTab = async (tabId) => { const getTab = async (zoneId) => {
const response = await fetch(`/tabs/${tabId}`, { const response = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('No tab found'); throw new Error('No zone found');
} }
return response.json(); return response.json();
}; };
const saveTabColors = async (tabId, colors) => { const saveTabColors = async (zoneId, colors) => {
const response = await fetch(`/tabs/${tabId}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ colors }), body: JSON.stringify({ colors }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to save tab colors'); throw new Error('Failed to save zone colors');
} }
return response.json(); return response.json();
}; };
@@ -101,23 +101,23 @@ document.addEventListener('DOMContentLoaded', () => {
const initTabPalette = async () => { const initTabPalette = async () => {
const paletteContainer = document.getElementById('color-palette'); const paletteContainer = document.getElementById('color-palette');
const addButton = document.getElementById('tab-color-add-btn'); const addButton = document.getElementById('zone-color-add-btn');
const addFromPaletteButton = document.getElementById('tab-color-add-from-palette-btn'); const addFromPaletteButton = document.getElementById('zone-color-add-from-palette-btn');
const colorInput = document.getElementById('tab-color-input'); const colorInput = document.getElementById('zone-color-input');
if (!paletteContainer || !addButton || !colorInput) { if (!paletteContainer || !addButton || !colorInput) {
return; return;
} }
const tabId = paletteContainer.dataset.tabId; const zoneId = paletteContainer.dataset.zoneId;
if (!tabId) { if (!zoneId) {
renderPalette(paletteContainer, []); renderPalette(paletteContainer, []);
return; return;
} }
let tabData; let tabData;
try { try {
tabData = await getTab(tabId); tabData = await getTab(zoneId);
} catch (error) { } catch (error) {
renderPalette(paletteContainer, []); renderPalette(paletteContainer, []);
return; return;
@@ -134,7 +134,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
try { try {
const updated = colors.filter((_, i) => i !== index); const updated = colors.filter((_, i) => i !== index);
const saved = await saveTabColors(tabId, updated); const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated; colors = saved.colors || updated;
selectedIndex = null; selectedIndex = null;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder); renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -152,7 +152,7 @@ document.addEventListener('DOMContentLoaded', () => {
const updated = [...colors]; const updated = [...colors];
const [moved] = updated.splice(fromIndex, 1); const [moved] = updated.splice(fromIndex, 1);
updated.splice(toIndex, 0, moved); updated.splice(toIndex, 0, moved);
const saved = await saveTabColors(tabId, updated); const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated; colors = saved.colors || updated;
selectedIndex = toIndex; selectedIndex = toIndex;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder); renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -169,7 +169,7 @@ document.addEventListener('DOMContentLoaded', () => {
try { try {
const updated = [...colors]; const updated = [...colors];
updated[index] = newColor; updated[index] = newColor;
const saved = await saveTabColors(tabId, updated); const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated; colors = saved.colors || updated;
selectedIndex = index; selectedIndex = index;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder); renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -192,7 +192,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
try { try {
const updated = [...colors, newColor]; const updated = [...colors, newColor];
const saved = await saveTabColors(tabId, updated); const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated; colors = saved.colors || updated;
selectedIndex = colors.length - 1; selectedIndex = colors.length - 1;
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder); renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -229,7 +229,7 @@ document.addEventListener('DOMContentLoaded', () => {
try { try {
if (!colors.includes(picked)) { if (!colors.includes(picked)) {
const updated = [...colors, picked]; const updated = [...colors, picked];
const saved = await saveTabColors(tabId, updated); const saved = await saveTabColors(zoneId, updated);
colors = saved.colors || updated; colors = saved.colors || updated;
selectedIndex = colors.indexOf(picked); selectedIndex = colors.indexOf(picked);
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder); renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
@@ -252,7 +252,7 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
document.body.addEventListener('htmx:afterSwap', (event) => { document.body.addEventListener('htmx:afterSwap', (event) => {
if (event.target && event.target.id === 'tab-content') { if (event.target && event.target.id === 'zone-content') {
selectedIndex = null; selectedIndex = null;
initTabPalette(); 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,83 +3,86 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Controller - Tab Mode</title> <title>LED Controller - Zone Mode</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
<header> <header>
<div class="tabs-container"> <div class="zones-container">
<div id="tabs-list"> <div id="zones-list">
Loading tabs... Loading zones...
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
<button class="btn btn-secondary" id="presets-btn">Presets</button>
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="settings-btn">Settings</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" id="help-btn">Help</button> <button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div> </div>
<div class="header-menu-mobile"> <div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button> <button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown"> <div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" data-target="tabs-btn">Tabs</button> <button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" data-target="color-palette-btn">Color Palette</button>
<button type="button" data-target="presets-btn">Presets</button>
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" data-target="patterns-btn">Patterns</button>
<button type="button" data-target="profiles-btn">Profiles</button> <button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" data-target="settings-btn">Settings</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" data-target="help-btn">Help</button> <button type="button" data-target="help-btn">Help</button>
</div> </div>
</div> </div>
</header> </header>
<div class="main-content"> <div class="main-content">
<div id="tab-content" class="tab-content"> <div id="zone-content" class="zone-content">
<div class="tab-content-placeholder"> <div class="zone-content-placeholder">
Select a tab to get started Select a zone to get started
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Tabs Modal --> <!-- Tabs Modal -->
<div id="tabs-modal" class="modal"> <div id="zones-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Tabs</h2> <h2>Tabs</h2>
<div class="profiles-actions"> <div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-tab-name" placeholder="Tab name"> <input type="text" id="new-zone-name" placeholder="Zone name">
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1"> <button class="btn btn-primary" id="create-zone-btn">Create</button>
<button class="btn btn-primary" id="create-tab-btn">Create</button>
</div> </div>
<div id="tabs-list-modal" class="profiles-list"></div> <div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions"> <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> </div>
</div> </div>
<!-- Edit Tab Modal --> <!-- Edit Zone Modal -->
<div id="edit-tab-modal" class="modal"> <div id="edit-zone-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit Tab</h2> <h2>Edit Zone</h2>
<form id="edit-tab-form"> <form id="edit-zone-form">
<input type="hidden" id="edit-tab-id"> <input type="hidden" id="edit-zone-id">
<div class="modal-actions" style="margin-bottom: 1rem;"> <div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button> <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> </div>
<label>Tab Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label>Device IDs (comma-separated):</label> <label class="zone-devices-label">Devices in this zone</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required> <div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
<label style="margin-top: 1rem;">Add presets to this tab</label> <label class="zone-presets-section-label">Presets on this zone</label>
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div> <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> </form>
</div> </div>
</div> </div>
@@ -92,6 +95,12 @@
<input type="text" id="new-profile-name" placeholder="Profile name"> <input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button> <button class="btn btn-primary" id="create-profile-btn">Create</button>
</div> </div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="new-profile-seed-dj">
DJ zone
</label>
</div>
<div id="profiles-list" class="profiles-list"></div> <div id="profiles-list" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="profiles-close-btn">Close</button> <button class="btn btn-secondary" id="profiles-close-btn">Close</button>
@@ -99,6 +108,50 @@
</div> </div>
</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 --> <!-- Presets Modal -->
<div id="presets-modal" class="modal"> <div id="presets-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -123,12 +176,11 @@
<option value="">Pattern</option> <option value="">Pattern</option>
</select> </select>
</div> </div>
<label>Colors</label> <label>Colours</label>
<div id="preset-colors-container" class="preset-colors-container"></div> <div id="preset-colors-container" class="preset-colors-container"></div>
<div class="profiles-actions"> <div class="profiles-actions">
<input type="color" id="preset-new-color" value="#ffffff"> <input type="color" id="preset-new-color" value="#ffffff" title="Choose colour (auto-adds)">
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button> <button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
</div> </div>
<div class="profiles-actions"> <div class="profiles-actions">
<div class="preset-editor-field"> <div class="preset-editor-field">
@@ -174,12 +226,11 @@
<input type="number" id="preset-n8-input" min="0" max="255" value="0" class="n-input"> <input type="number" id="preset-n8-input" min="0" max="255" value="0" class="n-input">
</div> </div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions preset-editor-modal-actions">
<button class="btn btn-secondary" id="preset-send-btn">Try</button> <button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</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-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-danger" id="preset-remove-from-tab-btn">Remove from Tab</button>
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button> <button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
</div> </div>
</div> </div>
@@ -196,15 +247,14 @@
</div> </div>
</div> </div>
<!-- Color Palette Modal --> <!-- Colour Palette Modal -->
<div id="color-palette-modal" class="modal"> <div id="color-palette-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Color Palette</h2> <h2>Colour Palette</h2>
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p> <p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
<div id="palette-container" class="profiles-list"></div> <div id="palette-container" class="profiles-list"></div>
<div class="profiles-actions"> <div class="profiles-actions">
<input type="color" id="palette-new-color" value="#ffffff"> <input type="color" id="palette-new-color" value="#ffffff">
<button class="btn btn-primary" id="palette-add-color-btn">Add Color</button>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button> <button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
@@ -218,26 +268,25 @@
<h2>Help</h2> <h2>Help</h2>
<p class="muted-text">How to use the LED controller UI.</p> <p class="muted-text">How to use the LED controller UI.</p>
<h3>Tabs & devices</h3> <h3>Run mode</h3>
<ul> <ul>
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li> <li><strong>Select zone</strong>: left-click a zone button in the top bar.</li>
<li><strong>Edit tab</strong>: right-click a tab button, or click <strong>Edit</strong> in the Tabs modal.</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>Send all presets</strong>: open the <strong>Tabs</strong> menu and click <strong>Send Presets</strong> next to the tab to push every preset used in that tab to all devices.</li> <li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</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> </ul>
<h3>Presets in a tab</h3> <h3>Edit mode</h3>
<ul> <ul>
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li> <li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
<li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li> <li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
<li><strong>Remove from tab</strong>: right-click a preset tile and choose <strong>Remove from this tab</strong> (the preset itself is not deleted, only its link from this tab).</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 preset tiles to change their order; the new layout is saved automatically.</li> <li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
</ul> <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>
<h3>Presets, profiles & colors</h3> <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>
<li><strong>Presets</strong>: use the <strong>Presets</strong> button in the header to create and manage reusable presets.</li>
<li><strong>Profiles</strong>: use <strong>Profiles</strong> to save and recall groups of settings.</li>
<li><strong>Color Palette</strong>: use <strong>Color Palette</strong> to build a reusable set of colors you can pull into presets.</li>
</ul> </ul>
<div class="modal-actions"> <div class="modal-actions">
@@ -263,8 +312,13 @@
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required> <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> <small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
</div> </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"> <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> </div>
</form> </form>
</div> </div>
@@ -287,7 +341,7 @@
<div class="form-group"> <div class="form-group">
<label for="ap-password">AP Password</label> <label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)"> <input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small> <small>Leave empty for open network (min 8 characters if set)</small>
</div> </div>
@@ -310,12 +364,13 @@
</div> </div>
<!-- Styles moved to /static/style.css --> <!-- 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/help.js"></script>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>
<script src="/static/profiles.js"></script> <script src="/static/profiles.js"></script>
<script src="/static/tab_palette.js"></script> <script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script> <script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script> <script src="/static/presets.js"></script>
<script src="/static/devices.js"></script>
</body> </body>
</html> </html>

View File

@@ -170,11 +170,26 @@
<div class="settings-header"> <div class="settings-header">
<h1>Device Settings</h1> <h1>Device Settings</h1>
<p>Configure WiFi Access Point settings</p> <p>Configure WiFi Access Point and ESP-NOW options</p>
</div> </div>
<div id="message" class="message"></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 --> <!-- WiFi Access Point Settings -->
<div class="settings-section"> <div class="settings-section">
<h2>WiFi Access Point Settings</h2> <h2>WiFi Access Point Settings</h2>
@@ -193,7 +208,7 @@
<div class="form-group"> <div class="form-group">
<label for="ap-password">AP Password</label> <label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)"> <input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small> <small>Leave empty for open network (min 8 characters if set)</small>
</div> </div>
@@ -222,6 +237,46 @@
}, 5000); }, 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 // Load AP status and config
async function loadAPStatus() { async function loadAPStatus() {
try { try {
@@ -299,6 +354,7 @@
}); });
// Load all data on page load // Load all data on page load
loadEspnowChannel();
loadAPStatus(); loadAPStatus();
// Refresh status every 10 seconds // Refresh status every 10 seconds

View File

@@ -74,7 +74,7 @@ See `docs/API.md` for the complete ESPNow API specification.
## Key Features ## Key Features
- **Version Field**: All messages include `"v": "1"` for version tracking - **Version Field**: All messages include `"v": "1"` for version tracking
- **Preset Format**: Presets use hex color strings (`#RRGGBB`), not RGB tuples - **Preset Format**: Presets use hex colour strings (`#RRGGBB`), not RGB tuples
- **Select Format**: Select values are always lists: `["preset_name"]` or `["preset_name", step]` - **Select Format**: Select values are always lists: `["preset_name"]` or `["preset_name", step]`
- **Color Conversion**: Automatically converts RGB tuples to hex strings - **Colour Conversion**: Automatically converts RGB tuples to hex strings
- **Default Values**: Provides sensible defaults for missing fields - **Default Values**: Provides sensible defaults for missing fields

View File

@@ -0,0 +1,52 @@
"""Push Wi-Fi TCP 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.tcp_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 tcp_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}")

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

@@ -0,0 +1,168 @@
"""Deliver driver JSON messages over serial (ESP-NOW) and/or TCP (Wi-Fi clients)."""
import asyncio
import json
from models.device import normalize_mac
from models.tcp_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=(",", ":"))
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 TCP. If default_id is set, send a per-target default message
(unicast serial or TCP) 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
for msg in chunk_messages:
tasks = [sender.send(msg, addr=_BROADCAST_MAC_HEX)]
for ip in wifi_ips:
if ip:
tasks.append(send_json_line_to_ip(ip, msg))
results = await asyncio.gather(*tasks, return_exceptions=True)
if results and results[0] is True:
deliveries += 1
for r in results[1:]:
if r is True:
deliveries += 1
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 TCP 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 TCP clients.
If target_macs is None or empty: one serial send per message (default/broadcast address).
Otherwise: Wi-Fi uses TCP 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:
wifi_tasks.append(send_json_line_to_ip(ip, 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

@@ -1,7 +1,8 @@
""" """
ESPNow message builder utility for LED driver communication. Message builder for LED driver API communication.
This module provides utilities to build ESPNow messages according to the API specification. Builds JSON messages according to the LED driver API specification
for sending presets and select commands over the transport (e.g. serial).
""" """
import json import json
@@ -9,14 +10,14 @@ import json
def build_message(presets=None, select=None, save=False, default=None): def build_message(presets=None, select=None, save=False, default=None):
""" """
Build an ESPNow message according to the API specification. Build an API message (presets and/or select) as a JSON string.
Args: Args:
presets: Dictionary mapping preset names to preset objects, or None presets: Dictionary mapping preset names to preset objects, or None
select: Dictionary mapping device names to select lists, or None select: Dictionary mapping device names to select lists, or None
Returns: Returns:
JSON string ready to send via ESPNow JSON string ready to send over the transport
Example: Example:
message = build_message( message = build_message(

View File

@@ -1,42 +0,0 @@
import network
def ap(ssid, password, channel=None):
ap_if = network.WLAN(network.AP_IF)
ap_mac = ap_if.config('mac')
print(ssid)
ap_if.active(True)
if channel is not None:
ap_if.config(essid=ssid, password=password, channel=channel)
else:
ap_if.config(essid=ssid, password=password)
ap_if.active(False)
ap_if.active(True)
print(ap_if.ifconfig())
def get_mac():
ap_if = network.WLAN(network.AP_IF)
return ap_if.config('mac')
def get_ap_config():
"""Get current AP configuration."""
try:
ap_if = network.WLAN(network.AP_IF)
if ap_if.active():
config = ap_if.ifconfig()
return {
'ssid': ap_if.config('essid'),
'channel': ap_if.config('channel'),
'ip': config[0] if config else None,
'active': True
}
return {
'ssid': None,
'channel': None,
'ip': None,
'active': False
}
except Exception as e:
print(f"Error getting AP config: {e}")
return None

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)

View File

@@ -10,8 +10,9 @@ from test_preset import test_preset
from test_profile import test_profile from test_profile import test_profile
from test_group import test_group from test_group import test_group
from test_sequence import test_sequence 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_palette import test_palette
from test_device import test_device
def run_all_tests(): def run_all_tests():
"""Run all model tests.""" """Run all model tests."""
@@ -25,8 +26,9 @@ def run_all_tests():
("Profile", test_profile), ("Profile", test_profile),
("Group", test_group), ("Group", test_group),
("Sequence", test_sequence), ("Sequence", test_sequence),
("Tab", test_tab), ("Zone", test_zone),
("Palette", test_palette), ("Palette", test_palette),
("Device", test_device),
] ]
passed = 0 passed = 0

164
tests/models/test_device.py Normal file
View File

@@ -0,0 +1,164 @@
import os
import sys
from pathlib import Path
# 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()
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", zones=["1", "2"])
print(f"Created device with ID: {device_id}")
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["type"] == "led"
assert device["transport"] == "espnow"
assert device["address"] == mac
assert device["default_pattern"] == "on"
assert device["zones"] == ["1", "2"]
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"] == mac
print("\nTesting update device fields")
update_data = {
"name": "Updated Device",
"default_pattern": "rainbow",
"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["zones"]) == 3
print("\nTesting list devices")
device_list = devices.list()
print(f"Device list: {device_list}")
assert mac in device_list
print("\nTesting delete device")
deleted = devices.delete(device_id)
assert deleted is True
assert mac not in devices
print("\nTesting read after delete")
device = devices.read(device_id)
assert device is None
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) is None
assert devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", "bad") is None
m1 = "001122334455"
m2 = "001122334466"
i1 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1)
assert i1 == m1
d = devices.read(i1)
assert d["name"] == "kitchen"
assert d["type"] == "led"
assert d["transport"] == "wifi"
assert d["address"] == "192.168.1.20"
i2 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.21", m2)
assert i2 == m2
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 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.99", m1)
assert again == m1
assert devices.read(m1)["address"] == "192.168.1.99"
assert (
devices.upsert_wifi_tcp_client(
"kitchen", "192.168.1.100", m1, device_type="bogus"
)
== m1
)
assert devices.read(m1)["type"] == "led"
i3 = devices.upsert_wifi_tcp_client("hall", "10.0.0.5", "deadbeefcafe")
assert i3 == "deadbeefcafe"
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

@@ -7,9 +7,11 @@ def test_model():
class TestModel(Model): class TestModel(Model):
pass pass
# Clean up any existing test file # Clean up any existing test file (model uses db/<classname>.json)
if os.path.exists("TestModel.json"): db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
os.remove("TestModel.json") testmodel_file = os.path.join(db_dir, "testmodel.json")
if os.path.exists(testmodel_file):
os.remove(testmodel_file)
model = TestModel() model = TestModel()
@@ -43,8 +45,8 @@ def test_model():
assert hasattr(model2, 'set_defaults') assert hasattr(model2, 'set_defaults')
# Clean up # Clean up
if os.path.exists("TestModel.json"): if os.path.exists(testmodel_file):
os.remove("TestModel.json") os.remove(testmodel_file)
print("\nAll model base class tests passed!") print("\nAll model base class tests passed!")

View File

@@ -2,10 +2,14 @@ from models.pallet import Palette
import os import os
def test_palette(): def test_palette():
"""Test Palette model CRUD operations.""" """Test Palette model CRUD operations.
# Clean up any existing test file Palette stores a list of colors per ID; read() returns that list (or unwraps from dict).
if os.path.exists("Palette.json"): """
os.remove("Palette.json") # Clean up any existing test file (model uses db/palette.json from project root)
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
palette_file = os.path.join(db_dir, "palette.json")
if os.path.exists(palette_file):
os.remove(palette_file)
palettes = Palette() palettes = Palette()
@@ -19,10 +23,12 @@ def test_palette():
print("\nTesting read palette") print("\nTesting read palette")
palette = palettes.read(palette_id) palette = palettes.read(palette_id)
print(f"Read: {palette}") print(f"Read: {palette}")
# read() returns list of colors (name is not stored)
assert palette is not None assert palette is not None
assert palette["name"] == "test_palette" assert isinstance(palette, list) or (isinstance(palette, dict) and "colors" in palette)
assert len(palette["colors"]) == 4 colors_read = palette if isinstance(palette, list) else palette.get("colors", [])
assert "#FF0000" in palette["colors"] assert len(colors_read) == 4
assert "#FF0000" in colors_read
print("\nTesting update palette") print("\nTesting update palette")
update_data = { update_data = {
@@ -32,9 +38,9 @@ def test_palette():
result = palettes.update(palette_id, update_data) result = palettes.update(palette_id, update_data)
assert result is True assert result is True
updated = palettes.read(palette_id) updated = palettes.read(palette_id)
assert updated["name"] == "updated_palette" updated_colors = updated if isinstance(updated, list) else (updated.get("colors") or [])
assert len(updated["colors"]) == 3 assert len(updated_colors) == 3
assert "#FF00FF" in updated["colors"] assert "#FF00FF" in updated_colors
print("\nTesting list palettes") print("\nTesting list palettes")
palette_list = palettes.list() palette_list = palettes.list()
@@ -48,7 +54,8 @@ def test_palette():
print("\nTesting read after delete") print("\nTesting read after delete")
palette = palettes.read(palette_id) palette = palettes.read(palette_id)
assert palette is None # read() returns [] when id is missing (value or [])
assert palette == [] or palette is None
print("\nAll palette tests passed!") print("\nAll palette tests passed!")

View File

@@ -2,10 +2,14 @@ from models.profile import Profile
import os import os
def test_profile(): def test_profile():
"""Test Profile model CRUD operations.""" """Test Profile model CRUD operations.
# Clean up any existing test file Profile create() sets name, type, zones (list of zone IDs), scenes, palette_id.
if os.path.exists("Profile.json"): """
os.remove("Profile.json") # 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")
profile_file = os.path.join(db_dir, "profile.json")
if os.path.exists(profile_file):
os.remove(profile_file)
profiles = Profile() profiles = Profile()
@@ -20,22 +24,20 @@ def test_profile():
print(f"Read: {profile}") print(f"Read: {profile}")
assert profile is not None assert profile is not None
assert profile["name"] == "test_profile" assert profile["name"] == "test_profile"
assert "tabs" in profile assert "zones" in profile
assert "palette" in profile assert "palette_id" in profile
assert "tab_order" in profile assert "type" in profile
print("\nTesting update profile") print("\nTesting update profile")
update_data = { update_data = {
"name": "updated_profile", "name": "updated_profile",
"tabs": {"tab1": {"names": ["1"], "presets": []}}, "zones": ["tab1"],
"palette": ["#FF0000", "#00FF00"],
"tab_order": ["tab1"]
} }
result = profiles.update(profile_id, update_data) result = profiles.update(profile_id, update_data)
assert result is True assert result is True
updated = profiles.read(profile_id) updated = profiles.read(profile_id)
assert updated["name"] == "updated_profile" assert updated["name"] == "updated_profile"
assert "tab1" in updated["tabs"] assert "tab1" in updated["zones"]
print("\nTesting list profiles") print("\nTesting list profiles")
profile_list = profiles.list() 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

@@ -2,8 +2,20 @@
""" """
Browser automation tests using Selenium. Browser automation tests using Selenium.
Tests run against the device at 192.168.4.1 in an actual browser. Tests run against the device at 192.168.4.1 in an actual browser.
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 pytest
if os.environ.get("LED_CONTROLLER_RUN_BROWSER_TESTS") != "1":
pytest.skip(
"Legacy device browser automation script; enable explicitly to run.",
allow_module_level=True,
)
import sys import sys
import time import time
import requests import requests
@@ -13,8 +25,8 @@ from selenium import webdriver
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.chrome.service import Service from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.common.exceptions import TimeoutException, NoSuchElementException from selenium.common.exceptions import TimeoutException, NoSuchElementException
@@ -33,24 +45,41 @@ class BrowserTest:
self.created_presets: List[str] = [] self.created_presets: List[str] = []
def setup(self): def setup(self):
"""Set up the browser driver.""" """Set up the browser driver. Tries Chrome first, then Firefox."""
err_chrome, err_firefox = None, None
# Try Chrome first
try: try:
chrome_options = Options() opts = ChromeOptions()
if self.headless: if self.headless:
chrome_options.add_argument('--headless') opts.add_argument('--headless')
chrome_options.add_argument('--no-sandbox') opts.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage') opts.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--disable-gpu') opts.add_argument('--disable-gpu')
chrome_options.add_argument('--window-size=1920,1080') opts.add_argument('--window-size=1920,1080')
self.driver = webdriver.Chrome(options=opts)
self.driver = webdriver.Chrome(options=chrome_options)
self.driver.implicitly_wait(5) self.driver.implicitly_wait(5)
print("✓ Browser started") print("✓ Browser started (Chrome)")
return True return True
except Exception as e: except Exception as e:
print(f"✗ Failed to start browser: {e}") err_chrome = e
print(" Make sure Chrome and ChromeDriver are installed") # Fallback to Firefox
return False try:
opts = FirefoxOptions()
if self.headless:
opts.add_argument('--headless')
self.driver = webdriver.Firefox(options=opts)
self.driver.implicitly_wait(5)
print("✓ Browser started (Firefox)")
return True
except Exception as e:
err_firefox = e
print("✗ Failed to start browser.")
if err_chrome:
print(f" Chrome: {err_chrome}")
if err_firefox:
print(f" Firefox: {err_firefox}")
print(" On Raspberry Pi (aarch64), install: chromium-browser and chromium-chromedriver")
return False
def teardown(self): def teardown(self):
"""Close the browser.""" """Close the browser."""
@@ -133,13 +162,13 @@ class BrowserTest:
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}") print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
# Delete created tabs by ID # Delete created tabs by ID
for tab_id in self.created_tabs: for zone_id in self.created_tabs:
try: 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: if response.status_code == 200:
print(f" ✓ Cleaned up tab: {tab_id}") print(f" ✓ Cleaned up zone: {zone_id}")
except Exception as e: 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 # Delete created profiles by ID
for profile_id in self.created_profiles: for profile_id in self.created_profiles:
@@ -151,20 +180,20 @@ class BrowserTest:
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}") print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
# Also try to cleanup by name pattern (in case IDs weren't tracked) # Also try to cleanup by name pattern (in case IDs weren't tracked)
test_names = ['Browser Test Tab', 'Browser Test Profile', 'Browser Test Preset', test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset',
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Tab'] 'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone']
# Cleanup tabs by name # Cleanup tabs by name
try: try:
tabs_response = session.get(f"{self.base_url}/tabs") tabs_response = session.get(f"{self.base_url}/zones")
if tabs_response.status_code == 200: if tabs_response.status_code == 200:
tabs_data = tabs_response.json() tabs_data = tabs_response.json()
tabs = tabs_data.get('tabs', {}) tabs = tabs_data.get('zones', {})
for tab_id, tab_data in tabs.items(): for zone_id, tab_data in zones.items():
if isinstance(tab_data, dict) and tab_data.get('name') in test_names: if isinstance(tab_data, dict) and tab_data.get('name') in test_names:
try: try:
session.delete(f"{self.base_url}/tabs/{tab_id}") session.delete(f"{self.base_url}/zones/{zone_id}")
print(f" ✓ Cleaned up tab by name: {tab_data.get('name')}") print(f" ✓ Cleaned up zone by name: {tab_data.get('name')}")
except: except:
pass pass
except: except:
@@ -209,46 +238,6 @@ class BrowserTest:
except Exception as e: except Exception as e:
print(f" ⚠ Cleanup error: {e}") print(f" ⚠ Cleanup error: {e}")
def cleanup_test_data(self):
"""Clean up test data created during tests."""
try:
# Use requests to make API calls for cleanup
session = requests.Session()
# Delete created presets
for preset_id in self.created_presets:
try:
response = session.delete(f"{self.base_url}/presets/{preset_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up preset: {preset_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
# Delete created tabs
for tab_id in self.created_tabs:
try:
response = session.delete(f"{self.base_url}/tabs/{tab_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up tab: {tab_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup tab {tab_id}: {e}")
# Delete created profiles
for profile_id in self.created_profiles:
try:
response = session.delete(f"{self.base_url}/profiles/{profile_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up profile: {profile_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
# Clear the lists
self.created_tabs.clear()
self.created_profiles.clear()
self.created_presets.clear()
except Exception as e:
print(f" ⚠ Cleanup error: {e}")
def fill_input(self, by, value, text, timeout=10): def fill_input(self, by, value, text, timeout=10):
"""Fill an input field.""" """Fill an input field."""
try: try:
@@ -341,11 +330,11 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
# Test 2: Open tabs modal # Test 2: Open tabs modal
total += 1 total += 1
if browser.click_element(By.ID, 'tabs-btn'): if browser.click_element(By.ID, 'zones-btn'):
print("✓ Clicked Tabs button") print("✓ Clicked Tabs button")
# Wait for modal to appear # Wait for modal to appear
time.sleep(0.5) time.sleep(0.5)
modal = browser.wait_for_element(By.ID, 'tabs-modal') modal = browser.wait_for_element(By.ID, 'zones-modal')
if modal and 'active' in modal.get_attribute('class'): if modal and 'active' in modal.get_attribute('class'):
print("✓ Tabs modal opened") print("✓ Tabs modal opened")
passed += 1 passed += 1
@@ -354,60 +343,58 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
else: else:
print("✗ Failed to click Tabs button") print("✗ Failed to click Tabs button")
# Test 3: Create a tab via UI # Test 3: Create a zone via UI
total += 1 total += 1
try: try:
# Fill in tab name # Fill in zone name
if browser.fill_input(By.ID, 'new-tab-name', 'Browser Test Tab'): if browser.fill_input(By.ID, 'new-zone-name', 'Browser Test Zone'):
print(" ✓ Filled tab name") print(" ✓ Filled zone name")
# Fill in device IDs # Devices default from registry or placeholder name "1"
if browser.fill_input(By.ID, 'new-tab-ids', '1,2,3'):
print(" ✓ Filled device IDs")
# Click create button # 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") print(" ✓ Clicked create button")
time.sleep(1) # Wait for creation time.sleep(1) # Wait for creation
# Check if tab appears in list and extract ID # Check if zone appears in list and extract ID
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal') tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list: if tabs_list:
list_text = tabs_list.text list_text = tabs_list.text
if 'Browser Test Tab' in list_text: if 'Browser Test Zone' in list_text:
print("✓ Created tab via UI") print("✓ Created zone via UI")
# Try to extract tab ID from the list (look for data-tab-id attribute) # Try to extract zone ID from the list (look for data-zone-id attribute)
try: 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: for row in tab_rows:
if 'Browser Test Tab' in row.text: if 'Browser Test Zone' in row.text:
tab_id = row.get_attribute('data-tab-id') zone_id = row.get_attribute('data-zone-id')
if tab_id: if zone_id:
browser.created_tabs.append(tab_id) browser.created_tabs.append(zone_id)
break break
except: except:
pass # If we can't extract ID, cleanup will try by name pass # If we can't extract ID, cleanup will try by name
passed += 1 passed += 1
else: else:
print("Tab not found in list after creation") print("Zone not found in list after creation")
else: else:
print("✗ Tabs list not found") print("✗ Tabs list not found")
else: else:
print("✗ Failed to click create button") print("✗ Failed to click create button")
except Exception as e: 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 Tabs list)
total += 1 total += 1
try: try:
# First, close and reopen modal to refresh # First, close and reopen modal to refresh
browser.click_element(By.ID, 'tabs-close-btn') browser.click_element(By.ID, 'zones-close-btn')
time.sleep(0.5) time.sleep(0.5)
browser.click_element(By.ID, 'tabs-btn') browser.click_element(By.ID, 'zones-btn')
time.sleep(0.5) time.sleep(0.5)
# Right-click the row corresponding to 'Browser Test Tab' # Right-click the row corresponding to 'Browser Test Zone'
try: try:
tab_row = browser.driver.find_element( tab_row = browser.driver.find_element(
By.XPATH, 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: except Exception:
tab_row = None tab_row = None
@@ -418,14 +405,14 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
time.sleep(0.5) time.sleep(0.5)
# Check if edit modal opened # 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: if edit_modal:
print("✓ Edit modal opened via right-click") print("✓ Edit modal opened via right-click")
# Fill in new name # Fill in new name
if browser.fill_input(By.ID, 'edit-tab-name', 'Edited Browser Tab'): if browser.fill_input(By.ID, 'edit-zone-name', 'Edited Browser Zone'):
print(" ✓ Filled new tab name") print(" ✓ Filled new zone name")
# Submit form # 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: if edit_form:
browser.driver.execute_script("arguments[0].submit();", edit_form) browser.driver.execute_script("arguments[0].submit();", edit_form)
time.sleep(1) # Wait for update time.sleep(1) # Wait for update
@@ -436,24 +423,24 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
else: else:
print("✗ Edit modal didn't open after right-click") print("✗ Edit modal didn't open after right-click")
else: 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: except Exception as e:
print(f"✗ Failed to edit tab via UI: {e}") print(f"✗ Failed to edit zone via UI: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Test 5: Check current tab cookie # Test 5: Check current zone cookie
total += 1 total += 1
cookie = browser.get_cookie('current_tab') cookie = browser.get_cookie('current_zone')
if cookie: if cookie:
print(f"✓ Found current_tab cookie: {cookie.get('value')}") print(f"✓ Found current_zone cookie: {cookie.get('value')}")
passed += 1 passed += 1
else: 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 passed += 1 # Not a failure, just informational
# Close modal # Close modal
browser.click_element(By.ID, 'tabs-close-btn') browser.click_element(By.ID, 'zones-close-btn')
except Exception as e: except Exception as e:
print(f"✗ Browser test error: {e}") print(f"✗ Browser test error: {e}")
@@ -532,7 +519,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
def test_mobile_tab_presets_two_columns(): 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. on a phone-sized viewport.
""" """
bt = BrowserTest(base_url=BASE_URL, headless=True) bt = BrowserTest(base_url=BASE_URL, headless=True)
@@ -544,18 +531,18 @@ def test_mobile_tab_presets_two_columns():
bt.driver.set_window_size(400, 800) bt.driver.set_window_size(400, 800)
assert bt.navigate('/'), "Failed to load main page" assert bt.navigate('/'), "Failed to load main page"
# Click the first tab button to load presets for that tab # Click the first zone button to load presets for that zone
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.tab-button', timeout=10) first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10)
assert first_tab is not None, "No tab buttons found" assert first_tab is not None, "No zone buttons found"
first_tab.click() first_tab.click()
time.sleep(1) time.sleep(1)
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10) container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
assert container is not None, "presets-list-tab not found" assert container is not None, "presets-list-zone not found"
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .preset-tile-row')
# Need at least 2 presets to make this meaningful # 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'] container_width = container.size['width']
first_width = tiles[0].size['width'] first_width = tiles[0].size['width']
@@ -773,8 +760,8 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
return passed >= total - 1 # Allow one failure (alert handling might be flaky) return passed >= total - 1 # Allow one failure (alert handling might be flaky)
def test_preset_drag_and_drop(browser: BrowserTest) -> bool: def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
"""Test dragging presets around in a tab.""" """Test dragging presets around in a zone."""
print("\n=== Testing Preset Drag and Drop in Tab ===") print("\n=== Testing Preset Drag and Drop in Zone ===")
passed = 0 passed = 0
total = 0 total = 0
@@ -782,7 +769,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
return False return False
try: try:
# Test 1: Load page and ensure we have a tab # Test 1: Load page and ensure we have a zone
total += 1 total += 1
if browser.navigate('/'): if browser.navigate('/'):
print("✓ Loaded main page") print("✓ Loaded main page")
@@ -791,34 +778,33 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
browser.teardown() browser.teardown()
return False return False
# Test 2: Open tabs modal and create/select a tab # Test 2: Open tabs modal and create/select a zone
total += 1 total += 1
browser.click_element(By.ID, 'tabs-btn') browser.click_element(By.ID, 'zones-btn')
time.sleep(0.5) time.sleep(0.5)
# Check if we have tabs, if not create one # Check if we have tabs, if not create one
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal') tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list and 'No tabs found' in tabs_list.text: if tabs_list and 'No tabs found' in tabs_list.text:
# Create a tab # Create a zone
browser.fill_input(By.ID, 'new-tab-name', 'Drag Test Tab') browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone')
browser.fill_input(By.ID, 'new-tab-ids', '1') browser.click_element(By.ID, 'create-zone-btn')
browser.click_element(By.ID, 'create-tab-btn')
time.sleep(1) time.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')]") select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]")
if select_buttons: if select_buttons:
select_buttons[0].click() select_buttons[0].click()
time.sleep(1) time.sleep(1)
print("✓ Selected a tab") print("✓ Selected a zone")
passed += 1 passed += 1
else: else:
print("✗ No tabs available to select") print("✗ No tabs available to select")
browser.click_element(By.ID, 'tabs-close-btn') browser.click_element(By.ID, 'zones-close-btn')
browser.teardown() browser.teardown()
return False return False
browser.click_element(By.ID, 'tabs-close-btn', use_js=True) browser.click_element(By.ID, 'zones-close-btn', use_js=True)
time.sleep(0.5) time.sleep(0.5)
# Test 3: Open presets modal and create presets # Test 3: Open presets modal and create presets
@@ -859,60 +845,66 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
print("✓ Created 3 presets for drag test") print("✓ Created 3 presets for drag test")
passed += 1 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 total += 1
try: try:
tab_id = browser.driver.execute_script( zone_id = browser.driver.execute_script(
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;" "return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
) )
if not tab_id: if not zone_id:
print("✗ Could not get current tab id") print("✗ Could not get current zone id")
else: else:
browser.driver.execute_script( browser.driver.execute_script(
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
tab_id zone_id
) )
time.sleep(1) time.sleep(1)
list_el = browser.wait_for_element(By.ID, 'edit-tab-presets-list', timeout=5) list_el = browser.wait_for_element(By.ID, 'edit-zone-presets-list', timeout=5)
if list_el: 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: if len(select_buttons) >= 2:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) 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: if len(select_buttons) >= 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 2 presets to tab") print(" ✓ Added 2 presets to zone")
passed += 1 passed += 1
elif len(select_buttons) == 1: elif len(select_buttons) == 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 1 preset to tab") print(" ✓ Added 1 preset to zone")
passed += 1 passed += 1
else: else:
print(" ⚠ No presets available to add (all already in tab)") print(" ⚠ No presets available to add (all already in zone)")
else: else:
print("✗ Edit tab presets list not found") print("✗ Edit zone presets list not found")
except Exception as e: except Exception as e:
print(f"✗ Failed to add presets to tab: {e}") print(f"✗ Failed to add presets to zone: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Test 5: Find presets in tab and test drag and drop # Test 5: Find presets in zone and test drag and drop (Edit mode only)
total += 1 total += 1
try: try:
# Wait for presets to load in the tab # Wait for presets to load in the zone
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5) presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5)
if presets_list_tab: if presets_list_tab:
time.sleep(1) # Wait for presets to render time.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)
# Find draggable preset elements - wait a bit more for rendering # Find draggable preset elements - wait a bit more for rendering
time.sleep(1) time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2: if len(draggable_presets) >= 2:
print(f" ✓ Found {len(draggable_presets)} draggable presets") print(f" ✓ Found {len(draggable_presets)} draggable presets")
@@ -930,7 +922,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
time.sleep(1) # Wait for reorder to complete time.sleep(1) # Wait for reorder to complete
# Check if order changed # 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: if len(draggable_presets_after) >= 2:
new_order = [p.text for p in draggable_presets_after] new_order = [p.text for p in draggable_presets_after]
print(f" New order: {new_order[:3]}") print(f" New order: {new_order[:3]}")
@@ -944,28 +936,28 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
else: else:
print("✗ Presets disappeared after drag") print("✗ Presets disappeared after drag")
elif len(draggable_presets) == 1: elif len(draggable_presets) == 1:
print(f"⚠ Only 1 preset found in tab (need 2 for drag test). Preset: {draggable_presets[0].text}") print(f"⚠ Only 1 preset found in zone (need 2 for drag test). Preset: {draggable_presets[0].text}")
tab_id = browser.driver.execute_script( zone_id = browser.driver.execute_script(
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;" "return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
) )
if tab_id: if zone_id:
browser.driver.execute_script( browser.driver.execute_script(
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
tab_id zone_id
) )
time.sleep(1) time.sleep(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 select_buttons: if select_buttons:
print(" Attempting to add another preset...") print(" Attempting to add another preset...")
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
try: 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: except Exception:
pass pass
time.sleep(1) time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2: if len(draggable_presets) >= 2:
print(" ✓ Added another preset, now testing drag...") print(" ✓ Added another preset, now testing drag...")
source = draggable_presets[0] source = draggable_presets[0]
@@ -978,11 +970,11 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
else: else:
print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding") print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding")
else: else:
print(" ✗ No Select buttons found in Edit Tab modal") print(" ✗ No Add buttons found in Edit Zone modal")
else: else:
print(f"✗ No presets found in tab (found {len(draggable_presets)})") print(f"✗ No presets found in zone (found {len(draggable_presets)})")
else: else:
print("✗ Presets list in tab not found") print("✗ Presets list in zone not found")
except Exception as e: except Exception as e:
print(f"✗ Drag and drop test error: {e}") print(f"✗ Drag and drop test error: {e}")
import traceback import traceback
@@ -1006,6 +998,14 @@ def main():
print(f"Testing against: {BASE_URL}") print(f"Testing against: {BASE_URL}")
print("=" * 60) print("=" * 60)
# On Pi OS Lite there is no browser by default; skip with exit 0 instead of failing
browser = BrowserTest(headless=True)
if not browser.setup():
print("\nSkipped (Pi OS Lite / no browser). Install chromium-browser and")
print("chromium-chromedriver to run browser tests, or run on Pi OS with desktop.")
sys.exit(0)
browser.teardown()
browser = BrowserTest(headless=False) # Set to True for headless mode browser = BrowserTest(headless=False) # Set to True for headless mode
results = [] results = []

View File

@@ -4,6 +4,15 @@ Endpoint tests that mimic web browser requests.
Tests run against the device at 192.168.4.1 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 requests
import json import json
import sys import sys
@@ -82,115 +91,115 @@ def test_tabs(client: TestClient) -> bool:
# Test 1: List tabs # Test 1: List tabs
total += 1 total += 1
try: try:
response = client.get('/tabs') response = client.get('/zones')
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
print(f"✓ GET /tabs - Found {len(data.get('tabs', {}))} tabs") print(f"✓ GET /zones - Found {len(data.get('zones', {}))} tabs")
passed += 1 passed += 1
else: else:
print(f"✗ GET /tabs - Status: {response.status_code}") print(f"✗ GET /zones - Status: {response.status_code}")
except Exception as e: 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 total += 1
try: try:
tab_data = { tab_data = {
"name": "Test Tab", "name": "Test Zone",
"names": ["1", "2"] "names": ["1", "2"]
} }
response = client.post('/tabs', json_data=tab_data) response = client.post('/zones', json_data=tab_data)
if response.status_code == 201: if response.status_code == 201:
created_tab = response.json() created_tab = response.json()
# Response format: {tab_id: {tab_data}} # Response format: {zone_id: {tab_data}}
if isinstance(created_tab, dict): if isinstance(created_tab, dict):
# Get the first key which should be the tab ID # Get the first key which should be the zone ID
tab_id = next(iter(created_tab.keys())) if created_tab else None zone_id = next(iter(created_tab.keys())) if created_tab else None
else: else:
tab_id = None zone_id = None
print(f"✓ POST /tabs - Created tab: {tab_id}") print(f"✓ POST /zones - Created zone: {zone_id}")
passed += 1 passed += 1
# Test 3: Get specific tab # Test 3: Get specific zone
if tab_id: if zone_id:
total += 1 total += 1
response = client.get(f'/tabs/{tab_id}') response = client.get(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
print(f"✓ GET /tabs/{tab_id} - Retrieved tab") print(f"✓ GET /zones/{zone_id} - Retrieved zone")
passed += 1 passed += 1
else: 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 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: 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 # Check cookie was set
cookie = client.get_cookie('current_tab') cookie = client.get_cookie('current_zone')
if cookie == tab_id: if cookie == zone_id:
print(f" ✓ Cookie 'current_tab' set to {tab_id}") print(f" ✓ Cookie 'current_zone' set to {zone_id}")
passed += 1 passed += 1
else: 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 total += 1
response = client.get('/tabs/current') response = client.get('/zones/current')
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
if data.get('tab_id') == tab_id: if data.get('zone_id') == zone_id:
print(f"✓ GET /tabs/current - Current tab is {tab_id}") print(f"✓ GET /zones/current - Current zone is {zone_id}")
passed += 1 passed += 1
else: else:
print(f"✗ GET /tabs/current - Wrong tab ID") print(f"✗ GET /zones/current - Wrong zone ID")
else: 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 total += 1
update_data = { update_data = {
"name": "Updated Test Tab", "name": "Updated Test Zone",
"names": ["1", "2", "3"] # Update device IDs too "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: if response.status_code == 200:
updated = response.json() updated = response.json()
if updated.get('name') == "Updated Test Tab" and updated.get('names') == ["1", "2", "3"]: if updated.get('name') == "Updated Test Zone" and updated.get('names') == ["1", "2", "3"]:
print(f"✓ PUT /tabs/{tab_id} - Updated tab (name and device IDs)") print(f"✓ PUT /zones/{zone_id} - Updated zone (name and device IDs)")
passed += 1 passed += 1
else: else:
print(f"✗ PUT /tabs/{tab_id} - Update didn't work correctly") print(f"✗ PUT /zones/{zone_id} - Update didn't work correctly")
print(f" Expected name='Updated Test Tab', got '{updated.get('name')}'") print(f" Expected name='Updated Test Zone', got '{updated.get('name')}'")
print(f" Expected names=['1','2','3'], got {updated.get('names')}") print(f" Expected names=['1','2','3'], got {updated.get('names')}")
else: 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 # Test 6b: Verify update persisted
total += 1 total += 1
response = client.get(f'/tabs/{tab_id}') response = client.get(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
verified = response.json() verified = response.json()
if verified.get('name') == "Updated Test Tab": if verified.get('name') == "Updated Test Zone":
print(f"✓ GET /tabs/{tab_id} - Verified update persisted") print(f"✓ GET /zones/{zone_id} - Verified update persisted")
passed += 1 passed += 1
else: else:
print(f"✗ GET /tabs/{tab_id} - Update didn't persist") print(f"✗ GET /zones/{zone_id} - Update didn't persist")
else: 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 total += 1
response = client.delete(f'/tabs/{tab_id}') response = client.delete(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
print(f"✓ DELETE /tabs/{tab_id} - Deleted tab") print(f"✓ DELETE /zones/{zone_id} - Deleted zone")
passed += 1 passed += 1
else: else:
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}") print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
else: 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: except Exception as e:
print(f"✗ POST /tabs - Error: {e}") print(f"✗ POST /zones - Error: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@@ -400,87 +409,87 @@ def test_patterns(client: TestClient) -> bool:
return passed == total return passed == total
def test_tab_edit_workflow(client: TestClient) -> bool: def test_tab_edit_workflow(client: TestClient) -> bool:
"""Test complete tab edit workflow like a browser would.""" """Test complete zone edit workflow like a browser would."""
print("\n=== Testing Tab Edit Workflow ===") print("\n=== Testing Zone Edit Workflow ===")
passed = 0 passed = 0
total = 0 total = 0
# Step 1: Create a tab to edit # Step 1: Create a zone to edit
total += 1 total += 1
try: try:
tab_data = { tab_data = {
"name": "Tab to Edit", "name": "Zone to Edit",
"names": ["1"] "names": ["1"]
} }
response = client.post('/tabs', json_data=tab_data) response = client.post('/zones', json_data=tab_data)
if response.status_code == 201: if response.status_code == 201:
created = response.json() created = response.json()
if isinstance(created, dict): 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: else:
tab_id = None zone_id = None
if tab_id: if zone_id:
print(f"✓ Created tab {tab_id} for editing") print(f"✓ Created zone {zone_id} for editing")
passed += 1 passed += 1
# Step 2: Get the tab to verify initial state # Step 2: Get the zone to verify initial state
total += 1 total += 1
response = client.get(f'/tabs/{tab_id}') response = client.get(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
original_tab = response.json() original_tab = response.json()
print(f"✓ Retrieved tab - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}") print(f"✓ Retrieved zone - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}")
passed += 1 passed += 1
# Step 3: Edit the tab (simulate browser edit form submission) # Step 3: Edit the zone (simulate browser edit form submission)
total += 1 total += 1
edit_data = { edit_data = {
"name": "Edited Tab Name", "name": "Edited Zone Name",
"names": ["2", "3", "4"] "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: if response.status_code == 200:
edited = response.json() edited = response.json()
if edited.get('name') == "Edited Tab Name" and edited.get('names') == ["2", "3", "4"]: if edited.get('name') == "Edited Zone Name" and edited.get('names') == ["2", "3", "4"]:
print(f"✓ PUT /tabs/{tab_id} - Successfully edited tab") print(f"✓ PUT /zones/{zone_id} - Successfully edited zone")
print(f" New name: '{edited.get('name')}'") print(f" New name: '{edited.get('name')}'")
print(f" New device IDs: {edited.get('names')}") print(f" New device IDs: {edited.get('names')}")
passed += 1 passed += 1
else: 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}") print(f" Got: {edited}")
else: 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 total += 1
response = client.get(f'/tabs/{tab_id}') response = client.get(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
verified = response.json() verified = response.json()
if verified.get('name') == "Edited Tab Name" and verified.get('names') == ["2", "3", "4"]: if verified.get('name') == "Edited Zone Name" and verified.get('names') == ["2", "3", "4"]:
print(f"✓ GET /tabs/{tab_id} - Verified edit persisted") print(f"✓ GET /zones/{zone_id} - Verified edit persisted")
passed += 1 passed += 1
else: else:
print(f"✗ GET /tabs/{tab_id} - Edit didn't persist") print(f"✗ GET /zones/{zone_id} - Edit didn't persist")
print(f" Expected name='Edited Tab Name', got '{verified.get('name')}'") print(f" Expected name='Edited Zone Name', got '{verified.get('name')}'")
print(f" Expected names=['2','3','4'], got {verified.get('names')}") print(f" Expected names=['2','3','4'], got {verified.get('names')}")
else: 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 total += 1
response = client.delete(f'/tabs/{tab_id}') response = client.delete(f'/zones/{zone_id}')
if response.status_code == 200: 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 passed += 1
else: else:
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}") print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
else: else:
print(f"✗ Failed to extract tab ID from create response") print(f"✗ Failed to extract zone ID from create response")
else: 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: except Exception as e:
print(f"Tab edit workflow - Error: {e}") print(f"Zone edit workflow - Error: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@@ -496,9 +505,10 @@ def test_static_files(client: TestClient) -> bool:
static_files = [ static_files = [
'/static/style.css', '/static/style.css',
'/static/app.js', '/static/app.js',
'/static/tabs.js', '/static/zones.js',
'/static/presets.js', '/static/presets.js',
'/static/profiles.js', '/static/profiles.js',
'/static/devices.js',
] ]
for file_path in static_files: for file_path in static_files:
@@ -534,7 +544,7 @@ def main():
# Run all tests # Run all tests
results.append(("Tabs", test_tabs(client))) results.append(("Tabs", test_tabs(client)))
results.append(("Tab Edit Workflow", test_tab_edit_workflow(client))) results.append(("Zone Edit Workflow", test_tab_edit_workflow(client)))
results.append(("Profiles", test_profiles(client))) results.append(("Profiles", test_profiles(client)))
results.append(("Presets", test_presets(client))) results.append(("Presets", test_presets(client)))
results.append(("Patterns", test_patterns(client))) results.append(("Patterns", test_patterns(client)))

Some files were not shown because too many files have changed in this diff Show More