45 Commits

Author SHA1 Message Date
pi
75ddd559c9 chore(db,led-tool): sync device/zone data and led-tool submodule
Made-with: Cursor
2026-04-11 15:20:26 +12:00
pi
5a1067263a chore: add pattern samples, http driver helpers, OTA/UDP test tools
- patterns/: sample dynamic pattern modules for OTA
- esp32/msg.json: example bridge message shape
- models/http_driver.py, wifi_peer.py: Wi-Fi driver HTTP poll helpers
- tests: pattern OTA send script and UDP discovery echo server
- Submodule led-driver: http_poll and test utilities

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

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

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

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"
],
"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"
}
}
{"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]}}

View File

@@ -1,11 +1 @@
{
"1": {
"name": "default",
"type": "tabs",
"tabs": [
"1"
],
"scenes": [],
"palette_id": "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"}}

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": ["a", "c", "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
{
"v": "1",
"presets": { ... },
"select": { ... }
"presets": { },
"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
{
"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
### Pattern-specific parameters (`n1``n6`)
#### 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
- **`n1`**: Attack time in milliseconds (fade in)
- **`n2`**: Hold time in milliseconds (full brightness)
- **`n3`**: Decay time in milliseconds (fade out)
- **`delay`**: Delay time in milliseconds (off between pulses)
- **`n1`**: Attack (fade in) ms
- **`n2`**: Hold ms
- **`n3`**: Decay (fade out) ms
- **`d`**: Off time between pulses ms
#### Transition
- **`delay`**: Transition duration in milliseconds
- **`d`**: Transition duration ms
#### Chase
- **`n1`**: Number of LEDs with first color
- **`n2`**: Number of LEDs with second color
- **`n3`**: Movement amount on even steps (can be negative)
- **`n4`**: Movement amount on odd steps (can be negative)
- **`n1`**: LEDs with first colour
- **`n2`**: LEDs with second colour
- **`n3`**: Movement on even steps (may be negative)
- **`n4`**: Movement on odd steps (may be negative)
#### Circle
- **`n1`**: Head movement rate (LEDs per second)
- **`n2`**: Maximum length
- **`n3`**: Tail movement rate (LEDs per second)
- **`n4`**: Minimum length
- **`n1`**: Head speed (LEDs/s)
- **`n2`**: Max length
- **`n3`**: Tail speed (LEDs/s)
- **`n4`**: Min length
## Select Messages
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
### Select Format
### Select messages
```json
{
"select": {
"device_name": ["preset_name"],
"device_name2": ["preset_name2", step_value]
"device_name": ["preset_id"],
"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
- **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)
### Beat and sync behavior
### 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:
- **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
### Example (compact preset map)
```json
{
"v": "1",
"save": true,
"presets": {
"red_blink": {
"pattern": "blink",
"colors": ["#FF0000"],
"delay": 200,
"brightness": 255,
"auto": true
},
"rainbow_manual": {
"pattern": "rainbow",
"delay": 100,
"n1": 2,
"auto": false
},
"pulse_slow": {
"pattern": "pulse",
"colors": ["#00FF00"],
"delay": 500,
"n1": 1000,
"n2": 500,
"n3": 1000,
"auto": false
"1": {
"name": "Red blink",
"p": "blink",
"c": ["#FF0000"],
"d": 200,
"b": 255,
"a": true,
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
}
},
"select": {
"device1": ["red_blink"],
"device2": ["rainbow_manual", 0],
"device3": ["pulse_slow"]
"living-room": ["1"]
}
}
```
## Message Processing
---
1. **Version Check**: Messages with `v != "1"` are rejected
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
## Processing summary (driver)
## 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
- 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)
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
---
## Notes
- Colors are automatically converted from hex strings to RGB tuples
- Color order reordering happens automatically based on device settings
- 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
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).

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

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

View File

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

View File

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

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)

21
esp32/msg.json Normal file
View File

@@ -0,0 +1,21 @@
{
"ch": 6,
"peers": {
"12:3456789012":{
"select": [["name1", "preset1"]]
,
"ff:ff:ff:ff:ff:ff": {
"presets": {
"preset1": {
"pattern": "on",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"delay": 100,
"brightness": 127,
"auto": true
}
}
}
}
}

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 a64457a0d5

1
led-tool Submodule

Submodule led-tool added at 5f7acf38f0

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
}

6
patterns/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from .blink import Blink
from .rainbow import Rainbow
from .pulse import Pulse
from .transition import Transition
from .chase import Chase
from .circle import Circle

33
patterns/blink.py Normal file
View File

@@ -0,0 +1,33 @@
import utime
class Blink:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Blink pattern: toggles LEDs on/off using preset delay, cycling through colors."""
# Use provided colors, or default to white if none
colors = preset.c if preset.c else [(255, 255, 255)]
color_index = 0
state = True # True = on, False = off
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
# Re-read delay each loop so live updates to preset.d take effect
delay_ms = max(1, int(preset.d))
if utime.ticks_diff(current_time, last_update) >= delay_ms:
if state:
base_color = colors[color_index % len(colors)]
color = self.driver.apply_brightness(base_color, preset.b)
self.driver.fill(color)
# Advance to next color for the next "on" phase
color_index += 1
else:
# "Off" phase: turn all LEDs off
self.driver.fill((0, 0, 0))
state = not state
last_update = current_time
# Yield once per tick so other logic can run
yield

124
patterns/chase.py Normal file
View File

@@ -0,0 +1,124 @@
import utime
class Chase:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating.
Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)"""
colors = preset.c
if len(colors) < 1:
# Need at least 1 color
return
# Access colors, delay, and n values from preset
if not colors:
return
# If only one color provided, use it for both colors
if len(colors) < 2:
color0 = colors[0]
color1 = colors[0]
else:
color0 = colors[0]
color1 = colors[1]
color0 = self.driver.apply_brightness(color0, preset.b)
color1 = self.driver.apply_brightness(color1, preset.b)
n1 = max(1, int(preset.n1)) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1
n3 = int(preset.n3) # Step movement on even steps (can be negative)
n4 = int(preset.n4) # Step movement on odd steps (can be negative)
segment_length = n1 + n2
# Calculate position from step_count
step_count = self.driver.step
# Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc.
if step_count % 2 == 0:
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
position = (step_count // 2) * (n3 + n4) + n3
else:
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
position = ((step_count + 1) // 2) * (n3 + n4)
# Wrap position to keep it reasonable
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
# If auto is False, run a single step and then stop
if not preset.a:
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step for next beat
self.driver.step = step_count + 1
# Allow tick() to advance the generator once
yield
return
# Auto mode: continuous loop
# Use transition_duration for timing and force the first update to happen immediately
transition_duration = max(10, int(preset.d))
last_update = utime.ticks_ms() - transition_duration
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= transition_duration:
# Calculate current position from step_count
if step_count % 2 == 0:
position = (step_count // 2) * (n3 + n4) + n3
else:
position = ((step_count + 1) // 2) * (n3 + n4)
# Wrap position
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step
step_count += 1
self.driver.step = step_count
last_update = current_time
# Yield once per tick so other logic can run
yield

96
patterns/circle.py Normal file
View File

@@ -0,0 +1,96 @@
import utime
class Circle:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Circle loading pattern - grows to n2, then tail moves forward at n3 until min length n4"""
head = 0
tail = 0
# Calculate timing from preset
head_rate = max(1, int(preset.n1)) # n1 = head moves per second
tail_rate = max(1, int(preset.n3)) # n3 = tail moves per second
max_length = max(1, int(preset.n2)) # n2 = max length
min_length = max(0, int(preset.n4)) # n4 = min length
head_delay = 1000 // head_rate # ms between head movements
tail_delay = 1000 // tail_rate # ms between tail movements
last_head_move = utime.ticks_ms()
last_tail_move = utime.ticks_ms()
phase = "growing" # "growing", "shrinking", or "off"
# Support up to two colors (like chase). If only one color is provided,
# use black for the second; if none, default to white.
colors = preset.c
if not colors:
base0 = base1 = (255, 255, 255)
elif len(colors) == 1:
base0 = colors[0]
base1 = (0, 0, 0)
else:
base0 = colors[0]
base1 = colors[1]
color0 = self.driver.apply_brightness(base0, preset.b)
color1 = self.driver.apply_brightness(base1, preset.b)
while True:
current_time = utime.ticks_ms()
# Background: use second color during the "off" phase, otherwise clear to black
if phase == "off":
self.driver.n.fill(color1)
else:
self.driver.n.fill((0, 0, 0))
# Calculate segment length
segment_length = (head - tail) % self.driver.num_leds
if segment_length == 0 and head != tail:
segment_length = self.driver.num_leds
# Draw segment from tail to head as a solid color (no per-LED alternation)
current_color = color0
for i in range(segment_length + 1):
led_pos = (tail + i) % self.driver.num_leds
self.driver.n[led_pos] = current_color
# Move head continuously at n1 LEDs per second
if utime.ticks_diff(current_time, last_head_move) >= head_delay:
head = (head + 1) % self.driver.num_leds
last_head_move = current_time
# Tail behavior based on phase
if phase == "growing":
# Growing phase: tail stays at 0 until max length reached
if segment_length >= max_length:
phase = "shrinking"
elif phase == "shrinking":
# Shrinking phase: move tail forward at n3 LEDs per second
if utime.ticks_diff(current_time, last_tail_move) >= tail_delay:
tail = (tail + 1) % self.driver.num_leds
last_tail_move = current_time
# Check if we've reached min length
current_length = (head - tail) % self.driver.num_leds
if current_length == 0 and head != tail:
current_length = self.driver.num_leds
# For min_length = 0, we need at least 1 LED (the head)
if min_length == 0 and current_length <= 1:
phase = "off" # All LEDs off for 1 step
elif min_length > 0 and current_length <= min_length:
phase = "growing" # Cycle repeats
else: # phase == "off"
# Off phase: second color fills the ring for 1 step, then restart
tail = head # Reset tail to head position to start fresh
phase = "growing"
self.driver.n.write()
# Yield once per tick so other logic can run
yield

64
patterns/pulse.py Normal file
View File

@@ -0,0 +1,64 @@
import utime
class Pulse:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
self.driver.off()
# Get colors from preset
colors = preset.c
if not colors:
colors = [(255, 255, 255)]
color_index = 0
cycle_start = utime.ticks_ms()
# State machine based pulse using a single generator loop
while True:
# Read current timing parameters from preset
attack_ms = max(0, int(preset.n1)) # Attack time in ms
hold_ms = max(0, int(preset.n2)) # Hold time in ms
decay_ms = max(0, int(preset.n3)) # Decay time in ms
delay_ms = max(0, int(preset.d))
total_ms = attack_ms + hold_ms + decay_ms + delay_ms
if total_ms <= 0:
total_ms = 1
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, cycle_start)
base_color = colors[color_index % len(colors)]
if elapsed < attack_ms and attack_ms > 0:
# Attack: fade 0 -> 1
factor = elapsed / attack_ms
color = tuple(int(c * factor) for c in base_color)
self.driver.fill(self.driver.apply_brightness(color, preset.b))
elif elapsed < attack_ms + hold_ms:
# Hold: full brightness
self.driver.fill(self.driver.apply_brightness(base_color, preset.b))
elif elapsed < attack_ms + hold_ms + decay_ms and decay_ms > 0:
# Decay: fade 1 -> 0
dec_elapsed = elapsed - attack_ms - hold_ms
factor = max(0.0, 1.0 - (dec_elapsed / decay_ms))
color = tuple(int(c * factor) for c in base_color)
self.driver.fill(self.driver.apply_brightness(color, preset.b))
elif elapsed < total_ms:
# Delay phase: LEDs off between pulses
self.driver.fill((0, 0, 0))
else:
# End of cycle, move to next color and restart timing
color_index += 1
cycle_start = now
if not preset.a:
break
# Skip drawing this tick, start next cycle
yield
continue
# Yield once per tick
yield

51
patterns/rainbow.py Normal file
View File

@@ -0,0 +1,51 @@
import utime
class Rainbow:
def __init__(self, driver):
self.driver = driver
def _wheel(self, pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
elif pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
else:
pos -= 170
return (0, pos * 3, 255 - pos * 3)
def run(self, preset):
step = self.driver.step % 256
step_amount = max(1, int(preset.n1)) # n1 controls step increment
# If auto is False, run a single step and then stop
if not preset.a:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(self._wheel(rc_index & 255), preset.b)
self.driver.n.write()
# Increment step by n1 for next manual call
self.driver.step = (step + step_amount) % 256
# Allow tick() to advance the generator once
yield
return
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
sleep_ms = max(1, int(preset.d)) # Get delay from preset
if utime.ticks_diff(current_time, last_update) >= sleep_ms:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(
self._wheel(rc_index & 255),
preset.b,
)
self.driver.n.write()
step = (step + step_amount) % 256
self.driver.step = step
last_update = current_time
# Yield once per tick so other logic can run
yield

57
patterns/transition.py Normal file
View File

@@ -0,0 +1,57 @@
import utime
class Transition:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Transition between colors, blending over `delay` ms."""
colors = preset.c
if not colors:
self.driver.off()
yield
return
# Only one color: just keep it on
if len(colors) == 1:
while True:
self.driver.fill(self.driver.apply_brightness(colors[0], preset.b))
yield
return
color_index = 0
start_time = utime.ticks_ms()
while True:
if not colors:
break
# Get current and next color based on live list
c1 = colors[color_index % len(colors)]
c2 = colors[(color_index + 1) % len(colors)]
duration = max(10, int(preset.d)) # At least 10ms
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, start_time)
if elapsed >= duration:
# End of this transition step
if not preset.a:
# One-shot: transition from first to second color only
self.driver.fill(self.driver.apply_brightness(c2, preset.b))
break
# Auto: move to next pair
color_index = (color_index + 1) % len(colors)
start_time = now
yield
continue
# Interpolate between c1 and c2
factor = elapsed / duration
interpolated = tuple(
int(c1[i] + (c2[i] - c1[i]) * factor) for i in range(3)
)
self.driver.fill(self.driver.apply_brightness(interpolated, preset.b))
yield

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
import util.wifi as wifi
# Boot script (ESP only; no-op on Pi)
import settings # noqa: F401
from settings import Settings
s = Settings()
name = s.get('name', 'led-controller')
wifi.ap(name, '')
# AP setup was here when running on ESP; Pi uses system networking.

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

@@ -0,0 +1,346 @@
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
import os
# 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
def _driver_patterns_dir():
here = os.path.dirname(__file__)
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
def _build_patterns_manifest(host):
base_dir = _driver_patterns_dir()
names = sorted(os.listdir(base_dir))
files = []
for name in names:
if not _safe_pattern_filename(name) or name == "__init__.py":
continue
files.append(
{
"name": name,
"url": "http://%s/patterns/ota/file/%s" % (host, name),
}
)
return {"files": files}
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",
}
@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
"""
Ask a Wi-Fi LED driver to pull pattern files from this server over HTTP.
Body (optional):
{"manifest": "http://host:port/patterns/ota/manifest"}
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
body = request.json or {}
manifest_payload = body.get("manifest")
if manifest_payload is None:
host = request.headers.get("Host", "")
if not host:
return json.dumps({"error": "Missing Host header"}), 400, {
"Content-Type": "application/json",
}
try:
manifest_payload = _build_patterns_manifest(host)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {
"Content-Type": "application/json",
}
if not isinstance(manifest_payload, (str, dict)):
return json.dumps({"error": "manifest must be a URL string or manifest object"}), 400, {
"Content-Type": "application/json",
}
msg = json.dumps({"v": "1", "manifest": manifest_payload}, separators=(",", ":"))
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",
}
return json.dumps({"message": "Pattern OTA trigger sent", "manifest": manifest_payload}), 200, {
"Content-Type": "application/json",
}

View File

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

View File

@@ -1,19 +1,67 @@
from microdot import Microdot
from models.pattern import Pattern
from models.device import Device
from models.tcp_clients import send_json_line_to_ip
import json
import re
import sys
import os
controller = Microdot()
patterns = Pattern()
def _project_root():
"""Project root (parent of ``src/``). CWD is often ``src/`` when running ``main.py``."""
here = os.path.dirname(os.path.abspath(__file__))
return os.path.abspath(os.path.join(here, "..", ".."))
def _driver_patterns_dir():
here = os.path.dirname(__file__)
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
_PATTERN_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
def _normalize_pattern_key(raw):
"""Pattern id / module basename (no .py)."""
if not isinstance(raw, str):
return ""
s = raw.strip()
if s.lower().endswith(".py"):
s = s[:-3].strip()
return s
def _valid_pattern_key(key):
return bool(key and _PATTERN_KEY_RE.match(key))
def load_pattern_definitions():
"""Load pattern definitions from pattern.json file."""
try:
# Try different paths for local development vs MicroPython
paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json']
root = _project_root()
paths = [
os.path.join(root, "db", "pattern.json"),
os.path.join(root, "pattern.json"),
"db/pattern.json",
"pattern.json",
"/db/pattern.json",
]
for path in paths:
try:
with open(path, 'r') as f:
with open(path, "r") as f:
return json.load(f)
except OSError:
continue
@@ -22,16 +70,301 @@ def load_pattern_definitions():
print(f"Error loading pattern.json: {e}")
return {}
def load_driver_pattern_names():
"""List available pattern module names from led-driver/src/patterns."""
try:
names = []
for filename in os.listdir(_driver_patterns_dir()):
if not _safe_pattern_filename(filename) or filename == "__init__.py":
continue
names.append(filename[:-3])
names.sort()
return names
except OSError:
return []
def build_runtime_pattern_map():
"""
Runtime pattern map for UI menus.
Keep pattern DB metadata as primary, then add any local driver pattern files
missing from the DB so new OTA files still appear in menus.
"""
definitions = load_pattern_definitions()
available = load_driver_pattern_names()
result = {}
for name, meta in definitions.items():
result[name] = dict(meta) if isinstance(meta, dict) else {}
for name in available:
if name not in result:
result[name] = {}
return result
@controller.get('/definitions')
async def get_pattern_definitions(request):
"""Get pattern definitions from pattern.json."""
definitions = load_pattern_definitions()
"""Get definitions for patterns currently available on the driver."""
definitions = build_runtime_pattern_map()
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
@controller.get('/ota/manifest')
async def ota_manifest(request):
"""Manifest of driver pattern source files for OTA pulls."""
base_dir = _driver_patterns_dir()
host = request.headers.get("Host", "")
if not host:
return json.dumps({"error": "Missing Host header"}), 400, {
"Content-Type": "application/json"
}
try:
names = sorted(os.listdir(base_dir))
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
files = []
for name in names:
if not _safe_pattern_filename(name) or name == "__init__.py":
continue
files.append({
"name": name,
"url": "http://%s/patterns/ota/file/%s" % (host, name),
})
return json.dumps({"files": files}), 200, {"Content-Type": "application/json"}
@controller.get('/ota/file/<name>')
async def ota_pattern_file(request, name):
"""Serve one driver pattern source file for OTA pulls."""
if not _safe_pattern_filename(name) or name == "__init__.py":
return json.dumps({"error": "Invalid filename"}), 400, {
"Content-Type": "application/json"
}
path = os.path.join(_driver_patterns_dir(), name)
try:
with open(path, "r") as f:
content = f.read()
except OSError:
return json.dumps({"error": "Pattern file not found"}), 404, {
"Content-Type": "application/json"
}
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
@controller.post('/<name>/send')
async def send_pattern_to_device(request, name):
"""Tell Wi-Fi driver(s) to download one pattern source file over HTTP."""
if not isinstance(name, str):
return json.dumps({"error": "Invalid pattern name"}), 400, {
"Content-Type": "application/json"
}
filename = name if name.endswith(".py") else (name + ".py")
if not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "Invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
devices = Device()
body = request.json or {}
requested_device_id = str(body.get("device_id") or "").strip()
path = os.path.join(_driver_patterns_dir(), filename)
if not os.path.exists(path):
return json.dumps({"error": "Pattern file not found"}), 404, {
"Content-Type": "application/json"
}
file_url = "/patterns/ota/file/%s" % filename
msg = json.dumps(
{
"v": "1",
"manifest": {
"files": [
{
"name": filename,
"url": file_url,
}
]
},
},
separators=(",", ":"),
)
target_ids = []
if requested_device_id:
dev = devices.read(requested_device_id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json"
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json"
}
target_ids = [requested_device_id]
else:
for did in devices.list():
dev = devices.read(did) or {}
if (dev.get("transport") or "").lower() == "wifi":
target_ids.append(str(did))
if not target_ids:
return json.dumps({"error": "No Wi-Fi devices found"}), 404, {
"Content-Type": "application/json"
}
sent_ids = []
for did in target_ids:
dev = devices.read(did) or {}
ip = str(dev.get("address") or "").strip()
if not ip:
continue
ok = await send_json_line_to_ip(ip, msg)
if ok:
sent_ids.append(did)
if not sent_ids:
return json.dumps({"error": "No Wi-Fi drivers connected"}), 503, {
"Content-Type": "application/json"
}
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
"Content-Type": "application/json"
}
@controller.post('/upload')
async def upload_pattern_file(request):
"""
Upload a pattern source file to led-controller local storage.
Body JSON:
{
"name": "sparkle.py" | "sparkle",
"code": "class Sparkle: ...",
"overwrite": true | false # optional, default true
}
"""
data = request.json or {}
raw_name = data.get("name") or data.get("filename")
code = data.get("code")
overwrite = data.get("overwrite", True)
overwrite = bool(overwrite)
if not isinstance(raw_name, str) or not raw_name.strip():
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json"
}
filename = raw_name.strip()
if not filename.endswith(".py"):
filename += ".py"
if not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required"}), 400, {
"Content-Type": "application/json"
}
path = os.path.join(_driver_patterns_dir(), filename)
exists = os.path.exists(path)
if exists and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
try:
with open(path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return json.dumps({
"message": "Pattern uploaded",
"name": filename,
"overwrote": bool(exists),
}), 201, {"Content-Type": "application/json"}
@controller.post('/driver')
async def create_driver_pattern(request):
"""
Create a driver pattern: save ``.py`` under led-driver/src/patterns and
metadata in db/pattern.json (Pattern model).
Body JSON:
name, code (required),
min_delay, max_delay, max_colors (optional numbers),
n1..n8 (optional string labels),
overwrite (optional, default true).
"""
data = request.json or {}
key = _normalize_pattern_key(data.get("name") or "")
if not _valid_pattern_key(key):
return json.dumps({
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
}), 400, {"Content-Type": "application/json"}
code = data.get("code")
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, {
"Content-Type": "application/json"
}
overwrite = bool(data.get("overwrite", True))
filename = key + ".py"
py_path = os.path.join(_driver_patterns_dir(), filename)
if os.path.exists(py_path) and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
meta = {}
for fld in ("min_delay", "max_delay", "max_colors"):
if fld not in data:
continue
try:
meta[fld] = int(data[fld])
except (TypeError, ValueError):
return json.dumps({"error": "%s must be an integer" % fld}), 400, {
"Content-Type": "application/json"
}
for i in range(1, 9):
nk = "n%d" % i
if nk not in data:
continue
lab = data[nk]
if lab is None:
continue
s = str(lab).strip()
if s:
meta[nk] = s
try:
with open(py_path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
if patterns.read(key):
patterns.update(key, meta)
else:
patterns.create(key, meta)
return json.dumps({
"message": "Pattern created",
"name": key,
"file": filename,
"metadata": patterns.read(key),
}), 201, {"Content-Type": "application/json"}
@controller.get('')
async def list_patterns(request):
"""List all patterns."""
return json.dumps(patterns), 200, {'Content-Type': 'application/json'}
"""List patterns for UI (DB metadata + local driver additions)."""
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
@@ -47,11 +380,23 @@ async def get_pattern(request, id):
async def create_pattern(request):
"""Create a new pattern."""
try:
data = request.json or {}
name = data.get("name", "")
pattern_id = patterns.create(name, data.get("data", {}))
if data:
patterns.update(pattern_id, data)
payload = request.json or {}
name = payload.get("name", "")
pattern_data = payload.get("data", {})
# IMPORTANT:
# `patterns.create()` stores `pattern_data` as the underlying dict value.
# If we then call `patterns.update(pattern_id, payload)` with the full
# request object, it may assign `payload["data"]` back onto that same
# dict object, creating a circular reference (json.dumps fails).
pattern_id = patterns.create(name, pattern_data)
# Only merge "extra" metadata fields (anything except name/data).
extra = dict(payload)
extra.pop("name", None)
extra.pop("data", None)
if extra:
patterns.update(pattern_id, extra)
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400

View File

@@ -2,9 +2,10 @@ from microdot import Microdot
from microdot.session import with_session
from models.preset import Preset
from models.profile import Profile
from models.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
import asyncio
import json
controller = Microdot()
@@ -36,11 +37,11 @@ async def list_presets(request, session):
}
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
@controller.get('/<preset_id>')
@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)."""
preset = presets.read(id)
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if preset and str(preset.get("profile_id")) == str(current_profile_id):
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
@@ -70,12 +71,12 @@ async def create_preset(request, session):
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
@controller.put('/<preset_id>')
@with_session
async def update_preset(request, id, session):
async def update_preset(request, session, preset_id):
"""Update an existing preset (current profile only)."""
try:
preset = presets.read(id)
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
@@ -87,21 +88,36 @@ async def update_preset(request, id, session):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(id, data):
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
if presets.update(preset_id, data):
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
@controller.delete('/<preset_id>')
@with_session
async def delete_preset(request, id, session):
async def delete_preset(request, *args, **kwargs):
"""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)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
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({"error": "Preset not found"}), 404
@@ -110,16 +126,17 @@ async def delete_preset(request, id, session):
@with_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:
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
Optional "targets": ["aabbccddeeff", ...] — registry MACs. When set: preset
chunks are ESP-NOW broadcast once each; Wi-Fi drivers get the same chunks
over TCP; if "default" is set, each target then gets a unicast default
message (serial or TCP) with that device name in "targets".
Omit targets for broadcast-only serial (legacy).
The controller:
- looks up each preset in the Preset model
- converts them to API-compliant format
- splits into <= 240-byte ESPNow messages
- sends each message to all configured ESPNow peers.
Optional "destination_mac" / "to": single MAC when targets is omitted.
"""
try:
data = request.json or {}
@@ -132,6 +149,7 @@ async def send_presets(request, session):
save_flag = data.get('save', True)
save_flag = bool(save_flag)
default_id = data.get('default')
destination_mac = data.get('destination_mac') or data.get('to')
# Build API-compliant preset map keyed by preset ID, include name
current_profile_id = get_current_profile_id(session)
@@ -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:
default_id = None
# Use shared ESPNow singleton
esp = ESPNow()
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)
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
MAX_BYTES = 240
SEND_DELAY_MS = 100
send_delay_s = 0.1
entries = list(presets_by_name.items())
total_presets = len(entries)
messages_sent = 0
batch = {}
last_msg = None
chunk_messages = []
for name, preset_obj in entries:
test_batch = dict(batch)
test_batch[name] = preset_obj
@@ -177,28 +190,133 @@ async def send_presets(request, session):
if size <= MAX_BYTES or not batch:
batch = test_batch
last_msg = test_msg
else:
try:
await send_chunk(batch)
except Exception:
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep_ms(SEND_DELAY_MS)
messages_sent += 1
chunk_messages.append(
build_message(
presets=dict(batch),
save=False,
default=None,
)
)
batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
if batch:
try:
await send_chunk(batch)
except Exception:
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep_ms(SEND_DELAY_MS)
messages_sent += 1
chunk_messages.append(
build_message(
presets=dict(batch),
save=save_flag,
default=default_id,
)
)
target_list = None
raw_targets = data.get("targets")
if isinstance(raw_targets, list) and raw_targets:
target_list = []
for t in raw_targets:
m = normalize_mac(str(t))
if m:
target_list.append(m)
target_list = list(dict.fromkeys(target_list))
if not target_list:
target_list = None
elif destination_mac:
dm = normalize_mac(str(destination_mac))
target_list = [dm] if dm else None
try:
if target_list:
deliveries = await deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
target_list,
Device(),
str(default_id) if default_id is not None else None,
delay_s=send_delay_s,
)
else:
deliveries, _chunks = await deliver_json_messages(
sender,
chunk_messages,
None,
Device(),
delay_s=send_delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return json.dumps({
"message": "Presets sent via ESPNow",
"message": "Presets sent",
"presets_sent": total_presets,
"messages_sent": messages_sent
"messages_sent": deliveries,
}), 200, {'Content-Type': 'application/json'}
@controller.post('/push')
@with_session
async def push_driver_messages(request, session):
"""
Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP).
Body:
{"sequence": [{ "v": "1", ... }, ...], "targets": ["mac", ...]}
or a single {"payload": {...}, "targets": [...]}.
"""
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
seq = data.get("sequence")
if not seq and data.get("payload") is not None:
seq = [data["payload"]]
if not isinstance(seq, list) or not seq:
return json.dumps({"error": "sequence or payload required"}), 400, {'Content-Type': 'application/json'}
raw_targets = data.get("targets")
target_list = None
if isinstance(raw_targets, list) and raw_targets:
target_list = []
for t in raw_targets:
m = normalize_mac(str(t))
if m:
target_list.append(m)
target_list = list(dict.fromkeys(target_list))
if not target_list:
target_list = None
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
messages = []
for item in seq:
if isinstance(item, dict):
messages.append(json.dumps(item))
elif isinstance(item, str):
messages.append(item)
else:
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
delay_s = data.get("delay_s", 0.05)
try:
delay_s = float(delay_s)
except (TypeError, ValueError):
delay_s = 0.05
try:
deliveries, _chunks = await deliver_json_messages(
sender,
messages,
target_list,
Device(),
delay_s=delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return json.dumps({
"message": "Delivered",
"deliveries": deliveries,
}), 200, {'Content-Type': 'application/json'}

View File

@@ -1,13 +1,13 @@
from microdot import Microdot
from microdot.session import with_session
from models.profile import Profile
from models.tab import Tab
from models.zone import Zone
from models.preset import Preset
import json
controller = Microdot()
profiles = Profile()
tabs = Tab()
zones = Zone()
presets = Preset()
@controller.get('')
@@ -81,11 +81,117 @@ async def apply_profile(request, session, id):
async def create_profile(request):
"""Create a new profile."""
try:
data = request.json or {}
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# 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)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
@@ -102,7 +208,7 @@ async def clone_profile(request, id):
data = request.json or {}
source_name = source.get("name") or f"Profile {id}"
new_name = data.get("name") or source_name
profile_type = source.get("type", "tabs")
profile_type = source.get("type", "zones")
def allocate_id(model, cache):
if "next" not in cache:
@@ -149,28 +255,28 @@ async def clone_profile(request, id):
palette_colors = []
# Clone tabs and presets used by those tabs
source_tabs = source.get("tabs")
source_tabs = source.get("zones")
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
source_tabs = source.get("tab_order", [])
source_tabs = source.get("zone_order", [])
source_tabs = source_tabs or []
cloned_tab_ids = []
preset_id_map = {}
new_tabs = {}
new_presets = {}
for tab_id in source_tabs:
tab = tabs.read(tab_id)
if not tab:
for zone_id in source_tabs:
zone = zones.read(zone_id)
if not zone:
continue
tab_name = tab.get("name") or f"Tab {tab_id}"
tab_name = zone.get("name") or f"Zone {zone_id}"
clone_name = tab_name
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
clone_id = allocate_id(tabs, tab_cache)
mapped_presets = map_preset_container(zone.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
clone_id = allocate_id(zones, tab_cache)
clone_data = {
"name": clone_name,
"names": tab.get("names") or [],
"names": zone.get("names") or [],
"presets": mapped_presets if mapped_presets is not None else []
}
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
extra = {k: v for k, v in zone.items() if k not in ("name", "names", "presets")}
if "presets_flat" in extra:
extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
if extra:
@@ -181,7 +287,7 @@ async def clone_profile(request, id):
new_profile_data = {
"name": new_name,
"type": profile_type,
"tabs": cloned_tab_ids,
"zones": cloned_tab_ids,
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
"palette_id": str(new_palette_id),
}
@@ -191,12 +297,12 @@ async def clone_profile(request, id):
for pid, pdata in new_presets.items():
presets[pid] = pdata
for tid, tdata in new_tabs.items():
tabs[tid] = tdata
zones[tid] = tdata
profiles[str(new_profile_id)] = new_profile_data
profiles._palette_model.save()
presets.save()
tabs.save()
zones.save()
profiles.save()
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}

View File

@@ -1,6 +1,5 @@
from microdot import Microdot, send_file
from settings import Settings
import util.wifi as wifi
import json
controller = Microdot()
@@ -15,19 +14,18 @@ async def get_settings(request):
@controller.get('/wifi/ap')
async def get_ap_config(request):
"""Get Access Point configuration."""
config = wifi.get_ap_config()
if config:
# Also get saved settings
config['saved_ssid'] = settings.get('wifi_ap_ssid')
config['saved_password'] = settings.get('wifi_ap_password')
config['saved_channel'] = settings.get('wifi_ap_channel')
return json.dumps(config), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to get AP config"}), 500
"""Get saved AP configuration (Pi: no in-device AP)."""
config = {
'saved_ssid': settings.get('wifi_ap_ssid'),
'saved_password': settings.get('wifi_ap_password'),
'saved_channel': settings.get('wifi_ap_channel'),
'active': False,
}
return json.dumps(config), 200, {'Content-Type': 'application/json'}
@controller.post('/wifi/ap')
async def configure_ap(request):
"""Configure Access Point."""
"""Save AP configuration to settings (Pi: no in-device AP)."""
try:
data = request.json
ssid = data.get('ssid')
@@ -43,33 +41,42 @@ async def configure_ap(request):
if channel < 1 or channel > 11:
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
# Save to settings
settings['wifi_ap_ssid'] = ssid
settings['wifi_ap_password'] = password
if channel is not None:
settings['wifi_ap_channel'] = channel
settings.save()
# Configure AP
wifi.ap(ssid, password, channel)
return json.dumps({
"message": "AP configured successfully",
"message": "AP settings saved",
"ssid": ssid,
"channel": channel
}), 200, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 500
def _validate_wifi_channel(value):
"""Return int 111 or raise ValueError."""
ch = int(value)
if ch < 1 or ch > 11:
raise ValueError("wifi_channel must be between 1 and 11")
return ch
@controller.put('/settings')
async def update_settings(request):
"""Update general settings."""
try:
data = request.json
for key, value in data.items():
settings[key] = value
if key == 'wifi_channel' and value is not None:
settings[key] = _validate_wifi_channel(value)
else:
settings[key] = value
settings.save()
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 400
except Exception as e:
return json.dumps({"error": str(e)}), 500

View File

@@ -1,346 +0,0 @@
from microdot import Microdot, send_file
from microdot.session import with_session
from models.tab import Tab
from models.profile import Profile
import json
import os
import time
controller = Microdot()
tabs = Tab()
profiles = Profile()
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
session_profile = session.get('current_profile')
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
def get_profile_tab_order(profile_id):
"""Get the tab order for a profile."""
if not profile_id:
return []
profile = profiles.read(profile_id)
if profile:
# Support both "tab_order" (old) and "tabs" (new) format
return profile.get("tabs", profile.get("tab_order", []))
return []
def get_current_tab_id(request, session=None):
"""Get the current tab ID from cookie."""
# Read from cookie first
current_tab = request.cookies.get('current_tab')
if current_tab:
return current_tab
# Fallback to first tab in current profile
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get("tabs", profile.get("tab_order", []))
if tabs_list:
return tabs_list[0]
return None
def _render_tabs_list_fragment(request, session):
"""Helper function to render tabs list HTML fragment."""
profile_id = get_current_profile_id(session)
# #region agent log
try:
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
_log.write(json.dumps({
"sessionId": "debug-session",
"runId": "tabs-pre-fix",
"hypothesisId": "H1",
"location": "src/controllers/tab.py:_render_tabs_list_fragment",
"message": "tabs list fragment",
"data": {
"profile_id": profile_id,
"profile_count": len(profiles.list())
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except Exception:
pass
# #endregion
if not profile_id:
return '<div class="tabs-list">No profile selected</div>', 200, {'Content-Type': 'text/html'}
tab_order = get_profile_tab_order(profile_id)
current_tab_id = get_current_tab_id(request, session)
html = '<div class="tabs-list">'
for tab_id in tab_order:
tab_data = tabs.read(tab_id)
if tab_data:
active_class = 'active' if str(tab_id) == str(current_tab_id) else ''
tab_name = tab_data.get('name', 'Tab ' + str(tab_id))
html += (
'<button class="tab-button ' + active_class + '" '
'hx-get="/tabs/' + str(tab_id) + '/content-fragment" '
'hx-target="#tab-content" '
'hx-swap="innerHTML" '
'hx-push-url="true" '
'hx-trigger="click" '
'onclick="document.querySelectorAll(\'.tab-button\').forEach(b => b.classList.remove(\'active\')); this.classList.add(\'active\');">'
+ tab_name +
'</button>'
)
html += '</div>'
return html, 200, {'Content-Type': 'text/html'}
def _render_tab_content_fragment(request, session, id):
"""Helper function to render tab content HTML fragment."""
# Handle 'current' as a special case
if id == 'current':
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
if wants_html:
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "No current tab set"}), 404
id = current_tab_id
tab = tabs.read(id)
if not tab:
return '<div>Tab not found</div>', 404, {'Content-Type': 'text/html'}
# Set this tab as the current tab in session
session['current_tab'] = str(id)
session.save()
# If this is a direct page load (not HTMX), return full UI so CSS loads.
if not request.headers.get('HX-Request'):
return send_file('templates/index.html')
tab_name = tab.get('name', 'Tab ' + str(id))
html = (
'<div class="presets-section" data-tab-id="' + str(id) + '">'
'<h3>Presets</h3>'
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
'<div id="presets-list-tab" class="presets-list">'
'<!-- Presets will be loaded here -->'
'</div>'
'</div>'
)
return html, 200, {'Content-Type': 'text/html'}
@controller.get('')
@with_session
async def list_tabs(request, session):
"""List all tabs with current tab info."""
profile_id = get_current_profile_id(session)
current_tab_id = get_current_tab_id(request, session)
# Get tab order for current profile
tab_order = get_profile_tab_order(profile_id) if profile_id else []
# Build tabs list with metadata
tabs_data = {}
for tab_id in tabs.list():
tab_data = tabs.read(tab_id)
if tab_data:
tabs_data[tab_id] = tab_data
return json.dumps({
"tabs": tabs_data,
"tab_order": tab_order,
"current_tab_id": current_tab_id,
"profile_id": profile_id
}), 200, {'Content-Type': 'application/json'}
# Get current tab - returns JSON with tab data and content info
@controller.get('/current')
@with_session
async def get_current_tab(request, session):
"""Get the current tab from session."""
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
return json.dumps({"error": "No current tab set", "tab": None, "tab_id": None}), 404
tab = tabs.read(current_tab_id)
if tab:
return json.dumps({
"tab": tab,
"tab_id": current_tab_id
}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found", "tab": None, "tab_id": None}), 404
@controller.post('/<id>/set-current')
async def set_current_tab(request, id):
"""Set a tab as the current tab in cookie."""
tab = tabs.read(id)
if not tab:
return json.dumps({"error": "Tab not found"}), 404
# Set cookie with current tab
response_data = json.dumps({"message": "Current tab set", "tab_id": id})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': f'current_tab={id}; Path=/; Max-Age=31536000' # 1 year expiry
}
return response
@controller.get('/<id>')
async def get_tab(request, id):
"""Get a specific tab by ID."""
tab = tabs.read(id)
if tab:
return json.dumps(tab), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
@controller.put('/<id>')
async def update_tab(request, id):
"""Update an existing tab."""
try:
data = request.json
if tabs.update(id, data):
return json.dumps(tabs.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
@with_session
async def delete_tab(request, session, id):
"""Delete a tab."""
try:
# Handle 'current' tab ID
if id == 'current':
current_tab_id = get_current_tab_id(request, session)
if current_tab_id:
id = current_tab_id
else:
return json.dumps({"error": "No current tab to delete"}), 404
if tabs.delete(id):
# Remove from profile's tabs
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if id in tabs_list:
tabs_list.remove(id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# Clear cookie if the deleted tab was the current tab
current_tab_id = get_current_tab_id(request, session)
if current_tab_id == id:
response_data = json.dumps({"message": "Tab deleted successfully"})
response = response_data, 200, {
'Content-Type': 'application/json',
'Set-Cookie': 'current_tab=; Path=/; Max-Age=0' # Clear cookie
}
return response
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 500, {'Content-Type': 'application/json'}
@controller.post('')
@with_session
async def create_tab(request, session):
"""Create a new tab."""
try:
# Handle form data or JSON
if request.form:
name = request.form.get('name', '').strip()
ids_str = request.form.get('ids', '1').strip()
names = [id.strip() for id in ids_str.split(',') if id.strip()]
preset_ids = None
else:
data = request.json or {}
name = data.get("name", "")
names = data.get("names", None)
preset_ids = data.get("presets", None)
if not name:
return json.dumps({"error": "Tab name cannot be empty"}), 400
tab_id = tabs.create(name, names, preset_ids)
# Add to current profile's tabs
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if tab_id not in tabs_list:
tabs_list.append(tab_id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# Return JSON response with tab ID
tab_data = tabs.read(tab_id)
return json.dumps({tab_id: tab_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
sys.print_exception(e)
return json.dumps({"error": str(e)}), 400
@controller.post('/<id>/clone')
@with_session
async def clone_tab(request, session, id):
"""Clone an existing tab and add it to the current profile."""
try:
source = tabs.read(id)
if not source:
return json.dumps({"error": "Tab not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Tab {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = tabs.create(new_name, source.get("names"), source.get("presets"))
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
tabs.update(clone_id, extra)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if clone_id not in tabs_list:
tabs_list.append(clone_id)
profile['tabs'] = tabs_list
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
tab_data = tabs.read(clone_id)
return json.dumps({clone_id: tab_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 400

361
src/controllers/zone.py Normal file
View File

@@ -0,0 +1,361 @@
from microdot import Microdot, send_file
from microdot.session import with_session
from models.zone import Zone
from models.profile import Profile
import json
controller = Microdot()
zones = Zone()
profiles = Profile()
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
session_profile = session.get("current_profile")
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
def _profile_zone_id_list(profile):
"""Ordered zone ids for a profile (``zones``, legacy ``tabs``, or ``zone_order``)."""
if not profile or not isinstance(profile, dict):
return []
z = profile.get("zones")
if isinstance(z, list) and z:
return list(z)
t = profile.get("zones")
if isinstance(t, list) and t:
return list(t)
o = profile.get("zone_order")
if isinstance(o, list) and o:
return list(o)
return []
def get_profile_zone_order(profile_id):
if not profile_id:
return []
profile = profiles.read(profile_id)
return _profile_zone_id_list(profile)
def _set_profile_zone_order(profile, ids):
profile["zones"] = list(ids)
profile.pop("tabs", None)
profile.pop("zone_order", None)
def get_current_zone_id(request, session=None):
"""Cookie ``current_zone``, legacy ``current_zone``, then first zone in profile."""
z = request.cookies.get("current_zone") or request.cookies.get("current_zone")
if z:
return z
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
order = _profile_zone_id_list(profile)
if order:
return order[0]
return None
def _render_zones_list_fragment(request, session):
"""Render zone strip HTML for HTMX / JS."""
profile_id = get_current_profile_id(session)
if not profile_id:
return (
'<div class="zones-list">No profile selected</div>',
200,
{"Content-Type": "text/html"},
)
zone_order = get_profile_zone_order(profile_id)
current_zone_id = get_current_zone_id(request, session)
html = '<div class="zones-list">'
for zid in zone_order:
zdata = zones.read(zid)
if zdata:
active_class = "active" if str(zid) == str(current_zone_id) else ""
zname = zdata.get("name", "Zone " + str(zid))
html += (
'<button class="zone-button ' + active_class + '" '
'hx-get="/zones/' + str(zid) + '/content-fragment" '
'hx-target="#zone-content" '
'hx-swap="innerHTML" '
'hx-push-url="true" '
'hx-trigger="click" '
'onclick="document.querySelectorAll(\'.zone-button\').forEach(b => b.classList.remove(\'active\')); this.classList.add(\'active\');">'
+ zname
+ "</button>"
)
html += "</div>"
return html, 200, {"Content-Type": "text/html"}
def _render_zone_content_fragment(request, session, id):
if id == "current":
current_zone_id = get_current_zone_id(request, session)
if not current_zone_id:
accept_header = request.headers.get("Accept", "")
wants_html = "text/html" in accept_header
if wants_html:
return (
'<div class="error">No current zone set</div>',
404,
{"Content-Type": "text/html"},
)
return json.dumps({"error": "No current zone set"}), 404
id = current_zone_id
z = zones.read(id)
if not z:
return '<div>Zone not found</div>', 404, {"Content-Type": "text/html"}
session["current_zone"] = str(id)
session.save()
if not request.headers.get("HX-Request"):
return send_file("templates/index.html")
html = (
'<div class="presets-section" data-zone-id="' + str(id) + '">'
"<h3>Presets</h3>"
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
'<div id="presets-list-zone" class="presets-list">'
"<!-- Presets will be loaded here -->"
"</div>"
"</div>"
)
return html, 200, {"Content-Type": "text/html"}
@controller.get("/<id>/content-fragment")
@with_session
async def zone_content_fragment(request, session, id):
return _render_zone_content_fragment(request, session, id)
@controller.get("")
@with_session
async def list_zones(request, session):
profile_id = get_current_profile_id(session)
current_zone_id = get_current_zone_id(request, session)
zone_order = get_profile_zone_order(profile_id) if profile_id else []
zones_data = {}
for zid in zones.list():
zdata = zones.read(zid)
if zdata:
zones_data[zid] = zdata
return (
json.dumps(
{
"zones": zones_data,
"zone_order": zone_order,
"current_zone_id": current_zone_id,
"profile_id": profile_id,
}
),
200,
{"Content-Type": "application/json"},
)
@controller.get("/current")
@with_session
async def get_current_zone(request, session):
current_zone_id = get_current_zone_id(request, session)
if not current_zone_id:
return (
json.dumps({"error": "No current zone set", "zone": None, "zone_id": None}),
404,
)
z = zones.read(current_zone_id)
if z:
return (
json.dumps({"zone": z, "zone_id": current_zone_id}),
200,
{"Content-Type": "application/json"},
)
return (
json.dumps({"error": "Zone not found", "zone": None, "zone_id": None}),
404,
)
@controller.post("/<id>/set-current")
async def set_current_zone(request, id):
z = zones.read(id)
if not z:
return json.dumps({"error": "Zone not found"}), 404
response_data = json.dumps({"message": "Current zone set", "zone_id": id})
return (
response_data,
200,
{
"Content-Type": "application/json",
"Set-Cookie": (
f"current_zone={id}; Path=/; Max-Age=31536000; SameSite=Lax"
),
},
)
@controller.get("/<id>")
async def get_zone(request, id):
z = zones.read(id)
if z:
return json.dumps(z), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Zone not found"}), 404
@controller.put("/<id>")
async def update_zone(request, id):
try:
data = request.json
if zones.update(id, data):
return json.dumps(zones.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Zone not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete("/<id>")
@with_session
async def delete_zone(request, session, id):
try:
if id == "current":
current_zone_id = get_current_zone_id(request, session)
if current_zone_id:
id = current_zone_id
else:
return json.dumps({"error": "No current zone to delete"}), 404
if zones.delete(id):
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
zlist = _profile_zone_id_list(profile)
if id in zlist:
zlist.remove(id)
_set_profile_zone_order(profile, zlist)
profiles.update(profile_id, profile)
current_zone_id = get_current_zone_id(request, session)
if current_zone_id == id:
response_data = json.dumps({"message": "Zone deleted successfully"})
return (
response_data,
200,
{
"Content-Type": "application/json",
"Set-Cookie": (
"current_zone=; Path=/; Max-Age=0; SameSite=Lax"
),
},
)
return json.dumps({"message": "Zone deleted successfully"}), 200, {
"Content-Type": "application/json"
}
return json.dumps({"error": "Zone not found"}), 404
except Exception as e:
import sys
try:
sys.print_exception(e)
except Exception:
pass
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@controller.post("")
@with_session
async def create_zone(request, session):
try:
if request.form:
name = request.form.get("name", "").strip()
ids_str = request.form.get("ids", "1").strip()
names = [i.strip() for i in ids_str.split(",") if i.strip()]
preset_ids = None
else:
data = request.json or {}
name = data.get("name", "")
names = data.get("names")
if names is None:
names = data.get("ids")
preset_ids = data.get("presets", None)
if not name:
return json.dumps({"error": "Zone name cannot be empty"}), 400
zid = zones.create(name, names, preset_ids)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
zlist = _profile_zone_id_list(profile)
if zid not in zlist:
zlist.append(zid)
_set_profile_zone_order(profile, zlist)
profiles.update(profile_id, profile)
zdata = zones.read(zid)
return json.dumps({zid: zdata}), 201, {"Content-Type": "application/json"}
except Exception as e:
import sys
sys.print_exception(e)
return json.dumps({"error": str(e)}), 400
@controller.post("/<id>/clone")
@with_session
async def clone_zone(request, session, id):
try:
source = zones.read(id)
if not source:
return json.dumps({"error": "Zone not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Zone {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = zones.create(new_name, source.get("names"), source.get("presets"))
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
zones.update(clone_id, extra)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
zlist = _profile_zone_id_list(profile)
if clone_id not in zlist:
zlist.append(clone_id)
_set_profile_zone_order(profile, zlist)
profiles.update(profile_id, profile)
zdata = zones.read(clone_id)
return json.dumps({clone_id: zdata}), 201, {"Content-Type": "application/json"}
except Exception as e:
import sys
try:
sys.print_exception(e)
except Exception:
pass
return json.dumps({"error": str(e)}), 400

View File

@@ -1,24 +1,305 @@
import asyncio
import gc
import errno
import json
import machine
from machine import Pin
import os
import signal
import socket
import threading
import traceback
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import Settings
import aioespnow
import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
import controllers.sequence as sequence
import controllers.tab as tab
import controllers.zone as zone
import controllers.palette as palette
import controllers.scene as scene
import controllers.pattern as pattern
import controllers.settings as settings_controller
from models.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
DISCOVERY_UDP_PORT = 8766
# 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_udp_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"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
)
except Exception as e:
print(f"UDP device registry failed: {e}")
traceback.print_exception(type(e), e, e.__traceback__)
async def _handle_udp_discovery(sock, udp_holder=None) -> None:
while True:
try:
data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
except asyncio.CancelledError:
raise
except OSError as e:
if udp_holder and udp_holder.get("closing"):
break
print(f"[UDP] recv failed: {e!r}")
continue
except Exception as e:
print(f"[UDP] recv failed: {e!r}")
continue
peer_ip = addr[0] if addr else ""
line = data.split(b"\n", 1)[0].strip()
if line:
try:
parsed = json.loads(line.decode("utf-8"))
if isinstance(parsed, dict):
dns = str(parsed.get("device_name") or "").strip()
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
"sta_mac"
)
device_type = parsed.get("type") or parsed.get("device_type")
if dns and normalize_mac(mac):
_register_udp_device_sync(dns, peer_ip, mac, device_type)
except (UnicodeError, ValueError, TypeError):
pass
try:
await asyncio.get_running_loop().sock_sendto(sock, data, addr)
except Exception as e:
print(f"[UDP] echo send failed: {e!r}")
async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
except (AttributeError, OSError):
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except (AttributeError, OSError):
pass
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
if udp_holder is not None:
udp_holder["sock"] = sock
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
try:
await _handle_udp_discovery(sock, udp_holder)
finally:
if udp_holder is not None:
udp_holder.pop("sock", None)
try:
sock.close()
except Exception:
pass
async def _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):
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, tcp_holder=None):
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}")
if tcp_holder is not None:
tcp_holder["server"] = server
try:
async with server:
await server.serve_forever()
finally:
if tcp_holder is not None:
tcp_holder.pop("server", None)
async def main(port=80):
@@ -26,8 +307,9 @@ async def main(port=80):
print(settings)
print("Starting")
# Initialize ESPNow singleton (config + peers)
esp = ESPNow()
# Initialize transport (serial to ESP32 bridge)
sender = get_sender(settings)
set_sender(sender)
app = Microdot()
@@ -42,7 +324,7 @@ async def main(port=80):
('/profiles', profile, 'profile'),
('/groups', group, 'group'),
('/sequences', sequence, 'sequence'),
('/tabs', tab, 'tab'),
('/zones', zone, 'zone'),
('/palettes', palette, 'palette'),
('/scenes', scene, 'scene'),
]
@@ -52,13 +334,16 @@ async def main(port=80):
app.mount(profile.controller, '/profiles')
app.mount(group.controller, '/groups')
app.mount(sequence.controller, '/sequences')
app.mount(tab.controller, '/tabs')
app.mount(zone.controller, '/zones')
app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
# Serve index.html at root
app.mount(device_controller.controller, '/devices')
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
# Serve index.html at root (cwd is src/ when run via pipenv run run)
@app.route('/')
def index(request):
"""Serve the main web UI."""
@@ -87,51 +372,101 @@ async def main(port=80):
@app.route('/ws')
@with_websocket
async def ws(request, ws):
while True:
data = await ws.receive()
print(data)
if data:
# Debug: log incoming WebSocket data
try:
parsed = json.loads(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:
await register_device_status_ws(ws)
await broadcast_device_tcp_snapshot_to(ws)
try:
while True:
data = await ws.receive()
print(data)
if data:
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:
pass
else:
break
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
else:
break
finally:
await unregister_device_status_ws(ws)
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
# Touch Device singleton early so db/device.json exists before first TCP hello.
Device()
await _send_bridge_wifi_channel(settings, sender)
#wdt = machine.WDT(timeout=10000)
#wdt.feed()
tcp_holder = {}
udp_holder = {"closing": False}
loop = asyncio.get_running_loop()
# Initialize heartbeat LED (XIAO ESP32S3 built-in LED on GPIO 21)
led = Pin(15, Pin.OUT)
def _graceful_shutdown(*_args):
print("[server] shutting down...")
udp_holder["closing"] = True
u = udp_holder.get("sock")
if u is not None:
try:
u.close()
except OSError:
pass
s = tcp_holder.get("server")
if s is not None:
s.close()
if getattr(app, "server", None) is not None:
app.shutdown()
shutdown_handlers_registered = False
try:
try:
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _graceful_shutdown)
shutdown_handlers_registered = True
except (NotImplementedError, RuntimeError):
pass
led_state = False
while True:
gc.collect()
for i in range(60):
#wdt.feed()
# Heartbeat: toggle LED every 500 ms
led.value(not led.value())
await asyncio.sleep_ms(500)
# cleanup before ending the application
# Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface
# here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
# never starts, which clears Wi-Fi presence dots.
try:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_tcp_server(settings, tcp_holder),
_run_udp_discovery_server(udp_holder),
)
except OSError as e:
if e.errno == errno.EADDRINUSE:
tcp_p = int(settings.get("tcp_port", 8765))
print(
f"[server] bind failed (address already in use): {e!s}\n"
f"[server] HTTP is configured for port {port} (env PORT); "
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"
)
raise
finally:
if shutdown_handlers_registered:
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.remove_signal_handler(sig)
except (NotImplementedError, OSError, ValueError):
pass
if __name__ == "__main__":
asyncio.run(main())
import os
port = int(os.environ.get("PORT", 80))
asyncio.run(main(port=port))

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

125
src/models/http_driver.py Normal file
View File

@@ -0,0 +1,125 @@
"""Wi-Fi LED drivers over HTTP long-poll (same port as the web UI).
Drivers POST /driver/v1/poll; the controller responds with queued JSON lines.
Presence: last poll within DRIVER_HTTP_SEEN_S counts as connected.
"""
import asyncio
import time
from models.wifi_peer import normalize_wifi_peer_ip
# Must exceed max ``wait_s`` (60) on /driver/v1/poll so sessions are not pruned mid-wait.
DRIVER_HTTP_SEEN_S = 90.0
_QUEUE_MAX = 64
_queues: dict[str, asyncio.Queue] = {}
_last_poll: dict[str, float] = {}
_connected_flag: set[str] = set()
_status_broadcast = None
def set_wifi_driver_status_broadcaster(coro) -> None:
global _status_broadcast
_status_broadcast = coro
def _schedule_status(ip: str, connected: bool) -> None:
fn = _status_broadcast
if not fn:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
try:
loop.create_task(fn(ip, connected))
except Exception:
pass
def _get_queue(ip: str) -> asyncio.Queue:
q = _queues.get(ip)
if q is None:
q = asyncio.Queue(maxsize=_QUEUE_MAX)
_queues[ip] = q
return q
def prune_stale_http_sessions() -> None:
"""Drop timed-out sessions, clear queues, broadcast disconnect."""
now = time.monotonic()
for ip in list(_last_poll.keys()):
if now - _last_poll[ip] <= DRIVER_HTTP_SEEN_S:
continue
_last_poll.pop(ip, None)
_queues.pop(ip, None)
if ip in _connected_flag:
_connected_flag.discard(ip)
_schedule_status(ip, False)
print(f"[HTTP driver] session timed out: {ip}")
def touch_http_session(ip: str) -> None:
ip = normalize_wifi_peer_ip(ip)
if not ip:
return
prune_stale_http_sessions()
now = time.monotonic()
_last_poll[ip] = now
if ip not in _connected_flag:
_connected_flag.add(ip)
_schedule_status(ip, True)
def wifi_driver_connected(ip: str) -> bool:
prune_stale_http_sessions()
key = normalize_wifi_peer_ip(ip)
return bool(key and key in _connected_flag)
def list_connected_driver_ips():
prune_stale_http_sessions()
return list(_connected_flag)
async def enqueue_json_line(ip: str, json_str: str) -> bool:
ip = normalize_wifi_peer_ip(ip)
if not ip:
return False
line = json_str[:-1] if json_str.endswith("\n") else json_str
q = _get_queue(ip)
while True:
try:
q.put_nowait(line)
return True
except asyncio.QueueFull:
try:
q.get_nowait()
except asyncio.QueueEmpty:
pass
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
"""Queue one JSON line for the driver to receive on the next long-poll."""
return await enqueue_json_line(ip, json_str)
async def collect_lines_after_touch(ip: str, wait_s: float) -> list[str]:
"""Wait up to wait_s for first line, then drain the rest (non-blocking)."""
ip = normalize_wifi_peer_ip(ip)
if not ip:
return []
q = _get_queue(ip)
lines: list[str] = []
try:
first = await asyncio.wait_for(q.get(), timeout=wait_s)
lines.append(first)
while True:
try:
lines.append(q.get_nowait())
except asyncio.QueueEmpty:
break
except asyncio.TimeoutError:
pass
return lines

View File

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

View File

@@ -26,18 +26,18 @@ class Profile(Model):
if changed:
self.save()
def create(self, name="", profile_type="tabs"):
def create(self, name="", profile_type="zones"):
"""Create a new profile and its own empty palette.
profile_type: "tabs" or "scenes" (ignoring scenes for now)
profile_type: "zones" or "scenes" (ignoring scenes for now)
"""
next_id = self.get_next_id()
# Create a unique palette for this profile.
palette_id = self._palette_model.create(colors=[])
self[next_id] = {
"name": name,
"type": profile_type, # "tabs" or "scenes"
"tabs": [], # Array of tab IDs
"type": profile_type, # "zones" or "scenes"
"zones": [], # Array of zone IDs
"scenes": [], # Array of scene IDs (for future use)
"palette_id": str(palette_id),
}

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)

8
src/models/wifi_peer.py Normal file
View File

@@ -0,0 +1,8 @@
"""Normalise Wi-Fi client addresses (strip IPv4-mapped IPv6 prefix)."""
def normalize_wifi_peer_ip(ip: str) -> str:
s = str(ip).strip()
if s.lower().startswith("::ffff:"):
s = s[7:]
return s

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 binascii
def _settings_path():
"""Path to settings.json in project root (writable without root)."""
try:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, "settings.json")
except Exception:
return "settings.json"
class Settings(dict):
SETTINGS_FILE = "/settings.json"
SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self):
super().__init__()
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization
def generate_secret_key(self):
@@ -33,6 +45,14 @@ class Settings(dict):
self['session_secret_key'] = self.generate_secret_key()
# Save immediately when generating a new key
self.save()
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self:
self['wifi_channel'] = 6
# Wi-Fi LED drivers: 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):
try:

View File

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

View File

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

446
src/static/devices.js Normal file
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
if (mainMenuBtn && mainMenuDropdown) {
mainMenuBtn.addEventListener('click', () => {
@@ -43,13 +35,6 @@ document.addEventListener('DOMContentLoaded', () => {
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).
@@ -75,6 +60,12 @@ document.addEventListener('DOMContentLoaded', () => {
if (nameInput && data && typeof data === 'object') {
nameInput.value = data.device_name || 'led-controller';
}
const chInput = document.getElementById('wifi-channel-input');
if (chInput && data && typeof data === 'object') {
const ch = data.wifi_channel;
chInput.value =
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
}
} catch (error) {
console.error('Error loading device settings:', error);
}
@@ -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');
if (deviceForm) {
deviceForm.addEventListener('submit', async (e) => {
@@ -139,15 +122,29 @@ document.addEventListener('DOMContentLoaded', () => {
showSettingsMessage('Device name is required', 'error');
return;
}
const chRaw = document.getElementById('wifi-channel-input')
? document.getElementById('wifi-channel-input').value
: '6';
const wifiChannel = parseInt(chRaw, 10);
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
return;
}
try {
const response = await fetch('/settings/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_name: deviceName }),
body: JSON.stringify({
device_name: deviceName,
wifi_channel: wifiChannel,
}),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage('Device name saved. It will be used on next restart.', 'success');
showSettingsMessage(
'Device settings saved. They will apply on next restart where relevant.',
'success',
);
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
}

View File

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

View File

@@ -3,11 +3,301 @@ document.addEventListener('DOMContentLoaded', () => {
const patternsModal = document.getElementById('patterns-modal');
const patternsCloseButton = document.getElementById('patterns-close-btn');
const patternsList = document.getElementById('patterns-list');
const patternAddButton = document.getElementById('pattern-add-btn');
const patternEditorModal = document.getElementById('pattern-editor-modal');
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
const patternCreateBtn = document.getElementById('pattern-create-btn');
const patternCreateName = document.getElementById('pattern-create-name');
const patternCreateMinDelay = document.getElementById('pattern-create-min-delay');
const patternCreateMaxDelay = document.getElementById('pattern-create-max-delay');
const patternCreateMaxColors = document.getElementById('pattern-create-max-colors');
const patternCreateFile = document.getElementById('pattern-create-file');
const patternCreateCode = document.getElementById('pattern-create-code');
const patternCreateOverwrite = document.getElementById('pattern-create-overwrite');
const patternCreateN = [1, 2, 3, 4, 5, 6, 7, 8].map((i) =>
document.getElementById(`pattern-create-n${i}`),
);
const patternCreateNSection = document.getElementById('pattern-create-n-section');
const patternCreateNEmpty = document.getElementById('pattern-create-n-empty');
if (!patternsButton || !patternsModal || !patternsList) {
return;
}
const nReadableStringFromMeta = (meta, key) => {
if (!meta || typeof meta !== 'object') {
return '';
}
const pm = meta.parameter_mappings;
if (pm && typeof pm === 'object' && typeof pm[key] === 'string') {
const s = pm[key].trim();
if (s) {
return s;
}
}
if (typeof meta[key] === 'string') {
return meta[key].trim();
}
return '';
};
const setPatternEditorNFields = (mode, data) => {
const meta = data && typeof data === 'object' ? data : {};
let visible = 0;
const grid = patternCreateNSection && patternCreateNSection.querySelector('.n-params-grid');
const h3 = patternCreateNSection && patternCreateNSection.querySelector('h3');
for (let i = 1; i <= 8; i += 1) {
const key = `n${i}`;
const labelEl = document.querySelector(`label[for="pattern-create-${key}"]`);
const inputEl = document.getElementById(`pattern-create-${key}`);
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
if (mode === 'create') {
if (labelEl) {
labelEl.textContent = '';
labelEl.style.display = 'none';
}
if (inputEl) {
inputEl.value = '';
inputEl.placeholder = 'Readable name (optional)';
inputEl.removeAttribute('aria-label');
}
if (groupEl) {
groupEl.style.display = '';
}
continue;
}
const readable = nReadableStringFromMeta(meta, key);
const show = Boolean(readable);
if (labelEl) {
labelEl.textContent = '';
labelEl.style.display = 'none';
}
if (inputEl) {
inputEl.value = show ? readable : '';
inputEl.placeholder = '';
if (show) {
inputEl.setAttribute('aria-label', readable);
} else {
inputEl.removeAttribute('aria-label');
inputEl.value = '';
}
}
if (groupEl) {
groupEl.style.display = show ? '' : 'none';
}
if (show) {
visible += 1;
}
}
if (mode === 'create') {
if (patternCreateNEmpty) {
patternCreateNEmpty.style.display = 'none';
}
if (grid) {
grid.style.display = '';
}
if (h3) {
h3.style.display = '';
}
if (patternCreateNSection) {
patternCreateNSection.style.display = '';
}
return;
}
if (patternCreateNEmpty) {
patternCreateNEmpty.style.display = visible === 0 ? '' : 'none';
}
if (grid) {
grid.style.display = visible === 0 ? 'none' : '';
}
if (h3) {
h3.style.display = visible === 0 ? 'none' : '';
}
};
const readFileAsText = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('read failed'));
reader.readAsText(file);
});
const collectCreatePayload = async () => {
const name = patternCreateName ? patternCreateName.value.trim() : '';
if (!name) {
throw new Error('Pattern name is required.');
}
let code = '';
const fileInput = patternCreateFile && patternCreateFile.files && patternCreateFile.files[0];
if (fileInput) {
code = await readFileAsText(fileInput);
} else if (patternCreateCode && patternCreateCode.value.trim()) {
code = patternCreateCode.value;
}
if (!code.trim()) {
throw new Error('Choose a .py file or paste source code.');
}
const payload = {
name,
code,
min_delay: parseInt(patternCreateMinDelay && patternCreateMinDelay.value, 10) || 0,
max_delay: parseInt(patternCreateMaxDelay && patternCreateMaxDelay.value, 10) || 0,
max_colors: parseInt(patternCreateMaxColors && patternCreateMaxColors.value, 10) || 0,
overwrite: !!(patternCreateOverwrite && patternCreateOverwrite.checked),
};
patternCreateN.forEach((el, idx) => {
const key = `n${idx + 1}`;
if (el && el.value.trim()) {
payload[key] = el.value.trim();
}
});
return payload;
};
const resetCreateForm = () => {
if (patternCreateName) patternCreateName.value = '';
if (patternCreateFile) patternCreateFile.value = '';
if (patternCreateCode) patternCreateCode.value = '';
if (patternCreateMinDelay) patternCreateMinDelay.value = '10';
if (patternCreateMaxDelay) patternCreateMaxDelay.value = '10000';
if (patternCreateMaxColors) patternCreateMaxColors.value = '10';
patternCreateN.forEach((el) => {
if (el) el.value = '';
});
if (patternCreateOverwrite) patternCreateOverwrite.checked = true;
setPatternEditorNFields('create', {});
};
if (patternCreateBtn) {
patternCreateBtn.addEventListener('click', async () => {
try {
const payload = await collectCreatePayload();
const response = await fetch('/patterns/driver', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error((data && data.error) || 'Create failed');
}
alert(data.message || 'Pattern created.');
resetCreateForm();
if (patternEditorModal) {
patternEditorModal.classList.remove('active');
}
await loadPatterns();
} catch (e) {
console.error('Create pattern failed:', e);
alert(e.message || 'Failed to create pattern.');
}
});
}
const sendPatternToDevices = async (patternName) => {
const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error((data && data.error) || 'Failed to send pattern');
}
const sentCount = data && typeof data.sent_count === 'number' ? data.sent_count : null;
if (sentCount === null) {
alert(`Sent "${patternName}" to devices.`);
} else {
alert(`Sent "${patternName}" to ${sentCount} device(s).`);
}
};
const loadPatternMetadata = async (patternName, fallbackData) => {
const raw = String(patternName || '').trim();
const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw;
try {
const response = await fetch('/patterns/definitions', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load pattern definitions');
}
const definitions = await response.json();
if (definitions && typeof definitions === 'object') {
if (definitions[raw]) {
return definitions[raw];
}
if (norm && definitions[norm]) {
return definitions[norm];
}
if (norm) {
const lower = norm.toLowerCase();
const matched = Object.keys(definitions).find(
(k) => String(k).toLowerCase() === lower,
);
if (matched) {
return definitions[matched];
}
}
}
} catch (error) {
console.error('Load pattern definitions failed:', error);
}
return fallbackData || {};
};
const loadPatternIntoEditor = async (patternName, fallbackData) => {
const data = await loadPatternMetadata(patternName, fallbackData);
if (patternCreateName) {
patternCreateName.value = patternName;
}
if (patternCreateMinDelay) {
patternCreateMinDelay.value =
data && data.min_delay !== undefined ? String(data.min_delay) : '10';
}
if (patternCreateMaxDelay) {
patternCreateMaxDelay.value =
data && data.max_delay !== undefined ? String(data.max_delay) : '10000';
}
if (patternCreateMaxColors) {
patternCreateMaxColors.value =
data && data.max_colors !== undefined ? String(data.max_colors) : '10';
}
setPatternEditorNFields('edit', data);
if (patternCreateOverwrite) {
patternCreateOverwrite.checked = true;
}
if (patternCreateFile) {
patternCreateFile.value = '';
}
try {
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(patternName)}.py`, {
headers: { Accept: 'text/plain' },
});
if (!response.ok) {
throw new Error('Failed to load pattern file');
}
const source = await response.text();
if (patternCreateCode) {
patternCreateCode.value = source || '';
patternCreateCode.focus();
}
} catch (error) {
console.error('Load pattern source failed:', error);
alert('Could not load pattern source into editor.');
}
};
const renderPatterns = (patterns) => {
patternsList.innerHTML = '';
const entries = Object.entries(patterns || {});
@@ -32,13 +322,37 @@ document.addEventListener('DOMContentLoaded', () => {
details.style.color = '#aaa';
details.style.fontSize = '0.85em';
const sendBtn = document.createElement('button');
sendBtn.className = 'btn btn-primary btn-small';
sendBtn.textContent = 'Send';
sendBtn.addEventListener('click', async () => {
try {
await sendPatternToDevices(patternName);
} catch (error) {
console.error('Send pattern failed:', error);
alert(error.message || 'Failed to send pattern.');
}
});
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', async () => {
if (patternEditorModal) {
patternEditorModal.classList.add('active');
}
await loadPatternIntoEditor(patternName, data || {});
});
row.appendChild(label);
row.appendChild(details);
row.appendChild(editBtn);
row.appendChild(sendBtn);
patternsList.appendChild(row);
});
};
const loadPatterns = async () => {
async function loadPatterns() {
patternsList.innerHTML = '';
const loading = document.createElement('p');
loading.className = 'muted-text';
@@ -62,7 +376,7 @@ document.addEventListener('DOMContentLoaded', () => {
errorMessage.textContent = 'Failed to load patterns.';
patternsList.appendChild(errorMessage);
}
};
}
const openModal = () => {
patternsModal.classList.add('active');
@@ -74,13 +388,23 @@ document.addEventListener('DOMContentLoaded', () => {
};
patternsButton.addEventListener('click', openModal);
if (patternAddButton) {
patternAddButton.addEventListener('click', () => {
resetCreateForm();
if (patternEditorModal) {
patternEditorModal.classList.add('active');
}
});
}
if (patternEditorCloseButton) {
patternEditorCloseButton.addEventListener('click', () => {
if (patternEditorModal) {
patternEditorModal.classList.remove('active');
}
});
}
if (patternsCloseButton) {
patternsCloseButton.addEventListener('click', closeModal);
}
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 profilesList = document.getElementById("profiles-list");
const newProfileInput = document.getElementById("new-profile-name");
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
const createProfileButton = document.getElementById("create-profile-btn");
if (!profilesButton || !profilesModal || !profilesList) {
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 = () => {
profilesModal.classList.add("active");
updateProfileEditorControlsVisibility();
loadProfiles();
};
@@ -19,6 +34,18 @@ document.addEventListener("DOMContentLoaded", () => {
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) => {
profilesList.innerHTML = "";
let entries = [];
@@ -41,6 +68,7 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
const editMode = isEditModeActive();
entries.forEach(([profileId, profile]) => {
const row = document.createElement("div");
row.className = "profiles-row";
@@ -66,7 +94,7 @@ document.addEventListener("DOMContentLoaded", () => {
throw new Error("Failed to apply profile");
}
await loadProfiles();
document.body.dispatchEvent(new Event("tabs-updated"));
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Apply profile failed:", error);
alert("Failed to apply profile.");
@@ -115,22 +143,8 @@ document.addEventListener("DOMContentLoaded", () => {
headers: { Accept: "application/json" },
});
}
document.cookie = "current_tab=; path=/; max-age=0";
await loadProfiles();
if (typeof window.loadTabs === "function") {
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>
`;
}
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Clone profile failed:", error);
alert("Failed to clone profile.");
@@ -162,8 +176,10 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label);
row.appendChild(applyButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
if (editMode) {
row.appendChild(cloneButton);
row.appendChild(deleteButton);
}
profilesList.appendChild(row);
});
};
@@ -198,6 +214,9 @@ document.addEventListener("DOMContentLoaded", () => {
};
const createProfile = async () => {
if (!isEditModeActive()) {
return;
}
if (!newProfileInput) {
return;
}
@@ -210,7 +229,10 @@ document.addEventListener("DOMContentLoaded", () => {
const response = await fetch("/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify({
name,
seed_dj_zone: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
}),
});
if (!response.ok) {
throw new Error("Failed to create profile");
@@ -236,23 +258,11 @@ document.addEventListener("DOMContentLoaded", () => {
}
newProfileInput.value = "";
// Clear current tab and refresh the UI so the new profile starts empty.
document.cookie = "current_tab=; path=/; max-age=0";
if (newProfileSeedDjInput) {
newProfileSeedDjInput.checked = false;
}
await loadProfiles();
if (typeof window.loadTabs === "function") {
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>
`;
}
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Create profile failed:", error);
alert("Failed to create profile.");
@@ -274,9 +284,14 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
profilesModal.addEventListener("click", (event) => {
if (event.target === profilesModal) {
closeModal();
}
// Keep modal controls in sync with run/edit mode.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
if (profilesModal.classList.contains('active')) {
updateProfileEditorControlsVisibility();
loadProfiles();
}
});
});
});

View File

@@ -12,6 +12,78 @@ body {
overflow: hidden;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.hex-address-row {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
align-items: center;
}
input.hex-addr-box {
width: 1.35rem;
padding: 0.25rem 0.1rem;
text-align: center;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.device-form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.75rem;
align-items: end;
}
.device-field-label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-bottom: 0.25rem;
}
.device-row-mac {
font-size: 0.82em;
color: #b0b0b0;
letter-spacing: 0.02em;
}
.device-form-actions {
display: flex;
align-items: flex-end;
}
#devices-modal select {
width: 100%;
max-width: 16rem;
padding: 0.35rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
#edit-device-modal select {
width: 100%;
max-width: 20rem;
padding: 0.35rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
.app-container {
display: flex;
flex-direction: column;
@@ -77,6 +149,11 @@ header h1 {
background-color: #333;
}
/* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only {
display: none !important;
}
.btn {
padding: 0.45rem 0.9rem;
border: none;
@@ -126,7 +203,7 @@ header h1 {
overflow: hidden;
}
.tabs-container {
.zones-container {
background-color: transparent;
padding: 0.5rem 0;
flex: 1;
@@ -136,7 +213,7 @@ header h1 {
align-items: center;
}
.tabs-list {
.zones-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
@@ -145,7 +222,7 @@ header h1 {
min-width: 0;
}
.tab-button {
.zone-button {
padding: 0.5rem 1rem;
background-color: #3a3a3a;
color: white;
@@ -157,16 +234,16 @@ header h1 {
transition: background-color 0.2s;
}
.tab-button:hover {
.zone-button:hover {
background-color: #4a4a4a;
}
.tab-button.active {
.zone-button.active {
background-color: #6a5acd;
color: white;
}
.tab-content {
.zone-content {
flex: 1;
display: block;
overflow-y: auto;
@@ -178,7 +255,7 @@ header h1 {
align-items: center;
}
.tab-brightness-group {
.zone-brightness-group {
display: flex;
flex-direction: column;
align-items: stretch;
@@ -186,7 +263,7 @@ header h1 {
margin-left: auto;
}
.tab-brightness-group label {
.zone-brightness-group label {
white-space: nowrap;
font-size: 0.85rem;
}
@@ -381,22 +458,28 @@ header h1 {
.n-param-group {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
justify-content: space-between;
}
.n-param-group label {
min-width: 40px;
flex: 1;
min-width: 0;
font-weight: 500;
}
.n-input {
flex: 1;
flex: 0 0 var(--n-input-width, 5ch);
width: var(--n-input-width, 5ch);
max-width: 100%;
box-sizing: border-box;
padding: 0.5rem;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
text-align: right;
}
.n-input:focus {
@@ -432,8 +515,8 @@ header h1 {
padding: 0;
}
/* Tab preset selecting area: 3 columns, vertical scroll only */
#presets-list-tab {
/* Zone preset selecting area: 3 columns, vertical scroll only */
#presets-list-zone {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -530,6 +613,29 @@ header h1 {
color: #f44336;
}
/* Devices modal: live TCP presence (Wi-Fi only) */
.device-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
align-self: center;
}
.device-status-dot--online {
background: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.45);
}
.device-status-dot--offline {
background: #616161;
}
.device-status-dot--unknown {
background: #424242;
border: 1px solid #757575;
}
.btn-group {
display: flex;
gap: 0.5rem;
@@ -596,8 +702,62 @@ header h1 {
position: relative;
}
/* Preset select buttons inside the tab grid */
#presets-list-tab .pattern-button {
/* Preset tile: main button + optional edit/remove (Edit mode) */
.preset-tile-row {
display: flex;
flex-direction: row;
align-items: stretch;
min-width: 0;
min-height: 0;
}
.preset-tile-row--run .preset-tile-actions {
display: none;
}
.preset-tile-main {
flex: 1;
min-width: 0;
height: 5rem;
}
/* Edit only beside the preset tile in edit mode. */
.preset-tile-actions {
display: flex;
flex-direction: column;
justify-content: stretch;
gap: 0.2rem;
flex-shrink: 0;
padding: 0.15rem 0 0.15rem 0.25rem;
width: auto;
min-width: 0;
}
.preset-editor-modal-actions {
flex-wrap: wrap;
gap: 0.35rem;
}
.preset-tile-actions .btn {
width: 100%;
min-height: 2.35rem;
padding: 0.15rem 0.35rem;
font-size: 0.68rem;
line-height: 1.15;
white-space: normal;
}
.ui-mode-toggle--edit {
background-color: #4a3f8f;
border: 1px solid #7b6fd6;
}
.ui-mode-toggle--edit:hover {
background-color: #5a4f9f;
}
/* Preset select buttons inside the zone grid */
#presets-list-zone .pattern-button {
display: flex;
}
.pattern-button .pattern-button-label {
@@ -812,12 +972,12 @@ header h1 {
padding: 0.4rem 0.7rem;
}
.tabs-container {
.zones-container {
padding: 0.5rem 0;
border-bottom: none;
}
.tab-content {
.zone-content {
padding: 0.5rem;
}
@@ -909,6 +1069,65 @@ header h1 {
background-color: #3a3a3a;
border-radius: 4px;
}
.zone-modal-create-row {
flex-wrap: wrap;
align-items: center;
}
.zone-modal-create-row input[type="text"] {
flex: 1;
min-width: 8rem;
}
.zone-devices-label {
display: block;
margin-top: 0.75rem;
margin-bottom: 0.35rem;
font-weight: 600;
}
.zone-devices-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
max-height: 14rem;
overflow-y: auto;
}
.zone-device-row-label {
flex: 1;
min-width: 0;
}
.zone-device-add-select {
flex: 1;
min-width: 10rem;
padding: 0.5rem;
background-color: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
.zone-devices-add {
margin-top: 0;
flex-wrap: wrap;
}
.zone-presets-section-label {
display: block;
margin-top: 1rem;
margin-bottom: 0.35rem;
font-weight: 600;
}
.edit-zone-presets-scroll {
max-height: 200px;
overflow-y: auto;
margin-bottom: 1rem;
}
/* Hide any text content in palette rows - only show color swatches */
#palette-container .profiles-row {
font-size: 0; /* Hide any text nodes */
@@ -982,7 +1201,7 @@ header h1 {
}
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
@media (max-width: 800px) {
#presets-list-tab {
#presets-list-zone {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@@ -1021,8 +1240,8 @@ header h1 {
font-size: 0.9rem;
}
/* Tab content placeholder (no tab selected) */
.tab-content-placeholder {
/* Zone content placeholder (no zone selected) */
.zone-content-placeholder {
padding: 2rem;
text-align: center;
color: #aaa;
@@ -1038,6 +1257,48 @@ header h1 {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.preset-editor-field label {
align-self: stretch;
}
.preset-editor-field input[type="number"] {
width: var(--n-input-width, 5ch);
max-width: 100%;
box-sizing: border-box;
text-align: right;
}
/* Pattern editor: numeric metadata row */
#pattern-editor-modal input[type="number"] {
width: var(--n-input-width, 5ch);
max-width: 100%;
box-sizing: border-box;
text-align: right;
}
/* Pattern editor: human-readable n labels (text), full width */
#pattern-editor-modal .n-params-grid {
grid-template-columns: 1fr;
}
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) {
justify-content: stretch;
}
#pattern-editor-modal .pattern-n-readable-input {
flex: 1 1 auto;
width: 100%;
min-width: 0;
text-align: left;
}
@supports not selector(:has(*)) {
#pattern-editor-modal #pattern-create-n-section .n-param-group {
justify-content: stretch;
}
}
/* Settings modal */

View File

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

View File

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

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

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

View File

@@ -3,83 +3,86 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Controller - Tab Mode</title>
<title>LED Controller - Zone Mode</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-container">
<header>
<div class="tabs-container">
<div id="tabs-list">
Loading tabs...
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
<div class="header-actions">
<button class="btn btn-secondary" id="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="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 type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div>
<div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" data-target="tabs-btn">Tabs</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" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" 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>
</div>
</div>
</header>
<div class="main-content">
<div id="tab-content" class="tab-content">
<div class="tab-content-placeholder">
Select a tab to get started
<div id="zone-content" class="zone-content">
<div class="zone-content-placeholder">
Select a zone to get started
</div>
</div>
</div>
</div>
<!-- Tabs Modal -->
<div id="tabs-modal" class="modal">
<div id="zones-modal" class="modal">
<div class="modal-content">
<h2>Tabs</h2>
<div class="profiles-actions">
<input type="text" id="new-tab-name" placeholder="Tab name">
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1">
<button class="btn btn-primary" id="create-tab-btn">Create</button>
<div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button>
</div>
<div id="tabs-list-modal" class="profiles-list"></div>
<div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="tabs-close-btn">Close</button>
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
</div>
</div>
</div>
<!-- Edit Tab Modal -->
<div id="edit-tab-modal" class="modal">
<!-- Edit Zone Modal -->
<div id="edit-zone-modal" class="modal">
<div class="modal-content">
<h2>Edit Tab</h2>
<form id="edit-tab-form">
<input type="hidden" id="edit-tab-id">
<h2>Edit Zone</h2>
<form id="edit-zone-form">
<input type="hidden" id="edit-zone-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
<label>Tab Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
<label>Device IDs (comma-separated):</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
<label style="margin-top: 1rem;">Add presets to this tab</label>
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
<label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label class="zone-devices-label">Devices in this zone</label>
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
<label class="zone-presets-section-label">Presets on this zone</label>
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="zone-presets-section-label">Add presets to this zone</label>
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
</form>
</div>
</div>
@@ -92,6 +95,12 @@
<input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button>
</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 class="modal-actions">
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
@@ -99,6 +108,50 @@
</div>
</div>
<!-- Devices Modal (registry: Wi-Fi drivers appear when they connect over TCP) -->
<div id="devices-modal" class="modal">
<div class="modal-content">
<h2>Devices</h2>
<div id="devices-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
</div>
</div>
</div>
<div id="edit-device-modal" class="modal">
<div class="modal-content">
<h2>Edit device</h2>
<form id="edit-device-form">
<input type="hidden" id="edit-device-id">
<p class="muted-text" style="margin-bottom:0.75rem;">MAC (id): <code id="edit-device-storage-id"></code></p>
<label for="edit-device-name">Name</label>
<input type="text" id="edit-device-name" required autocomplete="off">
<label for="edit-device-type" style="margin-top:0.75rem;display:block;">Type</label>
<select id="edit-device-type">
<option value="led">LED</option>
</select>
<label for="edit-device-transport" style="margin-top:0.75rem;display:block;">Transport</label>
<select id="edit-device-transport">
<option value="espnow">ESP-NOW</option>
<option value="wifi">WiFi</option>
</select>
<div id="edit-device-address-espnow" style="margin-top:0.75rem;">
<label class="device-field-label">MAC (12 hex, optional)</label>
<div id="edit-device-address-boxes" class="hex-address-row" aria-label="MAC address"></div>
</div>
<div id="edit-device-address-wifi-wrap" style="margin-top:0.75rem;" hidden>
<label for="edit-device-address-wifi">Address (IP or hostname)</label>
<input type="text" id="edit-device-address-wifi" placeholder="192.168.1.50" autocomplete="off">
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Presets Modal -->
<div id="presets-modal" class="modal">
<div class="modal-content">
@@ -123,12 +176,11 @@
<option value="">Pattern</option>
</select>
</div>
<label>Colors</label>
<label>Colours</label>
<div id="preset-colors-container" class="preset-colors-container"></div>
<div class="profiles-actions">
<input type="color" id="preset-new-color" value="#ffffff">
<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">Add from Palette</button>
<input type="color" id="preset-new-color" value="#ffffff" title="Choose colour (auto-adds)">
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
</div>
<div class="profiles-actions">
<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">
</div>
</div>
<div class="modal-actions">
<div class="modal-actions preset-editor-modal-actions">
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
<button type="button" class="btn btn-danger" id="preset-remove-from-zone-btn" hidden>Remove from zone</button>
<button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-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>
</div>
</div>
@@ -189,6 +240,9 @@
<div id="patterns-modal" class="modal">
<div class="modal-content">
<h2>Patterns</h2>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
</div>
<div id="patterns-list" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
@@ -196,15 +250,86 @@
</div>
</div>
<!-- Color Palette Modal -->
<!-- Pattern Editor Modal -->
<div id="pattern-editor-modal" class="modal">
<div class="modal-content">
<h2>Pattern</h2>
<p class="muted-text" style="margin: 0 0 0.75rem 0;">Add a driver <code>.py</code> file and editor metadata (stored in the pattern database).</p>
<div class="profiles-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<label for="pattern-create-name" style="min-width: 7rem;">Name</label>
<input type="text" id="pattern-create-name" class="preset-name-like" placeholder="e.g. sparkle" pattern="[a-zA-Z_][a-zA-Z0-9_]*" style="flex: 1; min-width: 12rem;" autocomplete="off">
</div>
<div class="profiles-row pattern-editor-meta-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<label for="pattern-create-min-delay" style="min-width: 7rem;">Min delay (ms)</label>
<input type="number" id="pattern-create-min-delay" min="0" value="10">
<label for="pattern-create-max-delay">Max delay (ms)</label>
<input type="number" id="pattern-create-max-delay" min="0" value="10000">
<label for="pattern-create-max-colors">Max colours</label>
<input type="number" id="pattern-create-max-colors" min="0" value="10">
</div>
<div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;">
<h3 class="muted-text">Readable parameter names</h3>
<p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names are stored for this pattern.</p>
<div class="n-params-grid">
<div class="n-param-group">
<label for="pattern-create-n1"></label>
<input type="text" id="pattern-create-n1" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n2"></label>
<input type="text" id="pattern-create-n2" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n3"></label>
<input type="text" id="pattern-create-n3" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n4"></label>
<input type="text" id="pattern-create-n4" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n5"></label>
<input type="text" id="pattern-create-n5" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n6"></label>
<input type="text" id="pattern-create-n6" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n7"></label>
<input type="text" id="pattern-create-n7" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n8"></label>
<input type="text" id="pattern-create-n8" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
</div>
</div>
<div class="profiles-row" style="flex-direction: column; align-items: stretch; gap: 0.35rem; margin-bottom: 0.5rem;">
<label for="pattern-create-file">Pattern file</label>
<input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY">
<label for="pattern-create-code" class="muted-text" style="font-size: 0.85em;">Or paste Python source (if no file chosen)</label>
<textarea id="pattern-create-code" rows="5" style="width: 100%; font-family: monospace; font-size: 0.85rem;" placeholder="# class MyPattern: ..."></textarea>
</div>
<div class="modal-actions">
<label style="display: inline-flex; align-items: center; gap: 0.35rem; margin-right: auto;">
<input type="checkbox" id="pattern-create-overwrite" checked>
<span>Overwrite existing file</span>
</label>
<button type="button" class="btn btn-primary" id="pattern-create-btn">Save</button>
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
</div>
</div>
</div>
<!-- Colour Palette Modal -->
<div id="color-palette-modal" class="modal">
<div class="modal-content">
<h2>Color Palette</h2>
<h2>Colour Palette</h2>
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
<div id="palette-container" class="profiles-list"></div>
<div class="profiles-actions">
<input type="color" id="palette-new-color" value="#ffffff">
<button class="btn btn-primary" id="palette-add-color-btn">Add Color</button>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
@@ -218,26 +343,25 @@
<h2>Help</h2>
<p class="muted-text">How to use the LED controller UI.</p>
<h3>Tabs & devices</h3>
<h3>Run mode</h3>
<ul>
<li><strong>Select tab</strong>: left-click a tab 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>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>Select zone</strong>: left-click a zone button in the top bar.</li>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the zone.</li>
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
<li><strong>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>
<h3>Presets in a tab</h3>
<h3>Edit mode</h3>
<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>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</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>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li>
</ul>
<h3>Presets, profiles & colors</h3>
<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>
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li>
<li><strong>Devices</strong>: view, edit, or remove registry entries (tabs use <strong>names</strong>; each row is keyed by <strong>MAC</strong>).</li>
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul>
<div class="modal-actions">
@@ -263,8 +387,13 @@
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
</div>
<div class="form-group">
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
<small>STA channel (111) for LED drivers and the serial bridge. Use the same value everywhere.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save Name</button>
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
</div>
</form>
</div>
@@ -287,7 +416,7 @@
<div class="form-group">
<label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small>
</div>
@@ -310,12 +439,13 @@
</div>
<!-- Styles moved to /static/style.css -->
<script src="/static/tabs.js"></script>
<script src="/static/zones.js"></script>
<script src="/static/help.js"></script>
<script src="/static/color_palette.js"></script>
<script src="/static/profiles.js"></script>
<script src="/static/tab_palette.js"></script>
<script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script>
<script src="/static/devices.js"></script>
</body>
</html>

View File

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

View File

@@ -74,7 +74,7 @@ See `docs/API.md` for the complete ESPNow API specification.
## Key Features
- **Version Field**: All messages include `"v": "1"` for version tracking
- **Preset Format**: Presets use hex 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]`
- **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

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

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

@@ -0,0 +1,192 @@
"""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=(",", ":"))
def _wifi_message_for_device(msg, device_name):
"""
For Wi-Fi TCP fanout, narrow a v1 select map to a single device name.
Returns the original message when no narrowing applies.
"""
if not device_name:
return msg
try:
body = json.loads(msg)
except Exception:
return msg
if not isinstance(body, dict):
return msg
select = body.get("select")
if not isinstance(select, dict):
return msg
if device_name not in select:
return msg
body["select"] = {device_name: select[device_name]}
return json.dumps(body, separators=(",", ":"))
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:
name = str(doc.get("name") or "").strip()
wifi_msg = _wifi_message_for_device(msg, name)
wifi_tasks.append(send_json_line_to_ip(ip, wifi_msg))
else:
espnow_hex.append(mac)
tasks = []
espnow_peer_count = 0
if len(espnow_hex) > 1:
tasks.append(
sender.send(
_split_serial_envelope(msg, espnow_hex),
addr=_BROADCAST_MAC_HEX,
)
)
espnow_peer_count = len(espnow_hex)
elif len(espnow_hex) == 1:
tasks.append(sender.send(msg, addr=espnow_hex[0]))
espnow_peer_count = 1
tasks.extend(wifi_tasks)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
n_serial = len(tasks) - len(wifi_tasks)
for i, r in enumerate(results):
if i < n_serial:
if r is True:
deliveries += espnow_peer_count
elif isinstance(r, Exception):
print(f"[driver_delivery] serial delivery failed: {r!r}")
else:
if r is True:
deliveries += 1
elif isinstance(r, Exception):
print(f"[driver_delivery] Wi-Fi delivery failed: {r!r}")
await asyncio.sleep(delay_s)
return deliveries, len(messages)

View File

@@ -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
@@ -9,14 +10,14 @@ import json
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:
presets: Dictionary mapping preset names to preset objects, or None
select: Dictionary mapping device names to select lists, or None
Returns:
JSON string ready to send via ESPNow
JSON string ready to send over the transport
Example:
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)

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