Compare commits

...

21 Commits

Author SHA1 Message Date
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
45 changed files with 1567 additions and 1251 deletions

2
.gitignore vendored
View File

@@ -23,7 +23,7 @@ ENV/
Thumbs.db Thumbs.db
# Project specific # Project specific
settings.json
*.log *.log
*.db *.db
*.sqlite *.sqlite

6
.gitmodules vendored Normal file
View File

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

View File

@@ -24,3 +24,4 @@ web = "python /home/pi/led-controller/tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests" watch = "python -m watchfiles 'python tests/web.py' src tests"
install = "pipenv install" install = "pipenv install"
run = "sh -c 'cd src && python main.py'" run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"

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

View File

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

View File

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

View File

@@ -1,276 +1 @@
{ {"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "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"}, "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"}}
"1": {
"name": "on",
"pattern": "on",
"colors": [
"#FFFFFF"
],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"2": {
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"3": {
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 2,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"4": {
"name": "transition",
"pattern": "transition",
"colors": [
"#FF0000",
"#00FF00",
"#0000FF"
],
"brightness": 255,
"delay": 500,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"5": {
"name": "chase",
"pattern": "chase",
"colors": [
"#FF0000",
"#0000FF"
],
"brightness": 255,
"delay": 200,
"auto": true,
"n1": 5,
"n2": 5,
"n3": 1,
"n4": 1,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"6": {
"name": "pulse",
"pattern": "pulse",
"colors": [
"#00FF00"
],
"brightness": 255,
"delay": 500,
"auto": true,
"n1": 1000,
"n2": 500,
"n3": 1000,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"7": {
"name": "circle",
"pattern": "circle",
"colors": [
"#FFA500",
"#800080"
],
"brightness": 255,
"delay": 200,
"auto": true,
"n1": 2,
"n2": 10,
"n3": 2,
"n4": 5,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"8": {
"name": "blink",
"pattern": "blink",
"colors": [
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00"
],
"brightness": 255,
"delay": 1000,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"9": {
"name": "warm white",
"pattern": "on",
"colors": ["#FFF5E6"],
"brightness": 200,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"10": {
"name": "cool white",
"pattern": "on",
"colors": ["#E6F2FF"],
"brightness": 200,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"11": {
"name": "red",
"pattern": "on",
"colors": ["#FF0000"],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"12": {
"name": "blue",
"pattern": "on",
"colors": ["#0000FF"],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"13": {
"name": "rainbow slow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 150,
"auto": true,
"n1": 1,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"14": {
"name": "pulse slow",
"pattern": "pulse",
"colors": ["#FF6600"],
"brightness": 255,
"delay": 800,
"auto": true,
"n1": 2000,
"n2": 1000,
"n3": 2000,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"15": {
"name": "blink red green",
"pattern": "blink",
"colors": ["#FF0000", "#00FF00"],
"brightness": 255,
"delay": 500,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
}
}

View File

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

View File

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

View File

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

View File

@@ -1,263 +1,297 @@
# 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.
---
## 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). |
### 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. 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).
### Tabs — `/tabs`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
| GET | `/tabs/current` | Current tab from cookie/session. |
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profiles tab list. |
| GET | `/tabs/<id>` | Tab JSON. |
| PUT | `/tabs/<id>` | Update tab. |
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
### Palettes — `/palettes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/palettes` | Map of id → color list. |
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
| PUT | `/palettes/<id>` | Update colors (`name` ignored). |
| DELETE | `/palettes/<id>` | Delete palette. |
### Groups — `/groups`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/groups` | All groups. |
| GET | `/groups/<id>` | One group. |
| POST | `/groups` | Create; optional `name` and fields. |
| PUT | `/groups/<id>` | Update. |
| DELETE | `/groups/<id>` | Delete. |
### Scenes — `/scenes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scenes` | All scenes. |
| GET | `/scenes/<id>` | One scene. |
| POST | `/scenes` | Create (body JSON stored on scene). |
| PUT | `/scenes/<id>` | Update. |
| DELETE | `/scenes/<id>` | Delete. |
### Sequences — `/sequences`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/sequences` | All sequences. |
| GET | `/sequences/<id>` | One sequence. |
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
| PUT | `/sequences/<id>` | Update. |
| DELETE | `/sequences/<id>` | Delete. |
### Patterns — `/patterns`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
| GET | `/patterns` | All pattern records. |
| GET | `/patterns/<id>` | One pattern. |
| POST | `/patterns` | Create (`name`, optional `data`). |
| PUT | `/patterns/<id>` | Update. |
| DELETE | `/patterns/<id>` | Delete. |
---
## LED driver message format (transport / ESP-NOW)
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
### Top-level fields
```json ```json
{ {
"v": "1", "v": "1",
"presets": { ... }, "presets": { },
"select": { ... } "select": { },
"save": true,
"default": "preset_id",
"b": 255
} }
``` ```
### Version Field - **`v`** (required): Must be `"1"` or the driver ignores the message.
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
- **`default`**: Preset id string to use as startup default on the device.
- **`b`**: Optional **global** brightness 0255 (driver applies this in addition to per-preset brightness).
- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored. ### Preset object (wire / driver keys)
## Presets On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings. | Key | Meaning | Notes |
|-----|---------|--------|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
| `c` | Colors | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
| `d` | Delay ms | Default 100 |
| `b` | Preset brightness | 0255; combined with global `b` on the device |
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
| `n1``n6` | Pattern parameters | See below |
### Preset Structure The HTTP apps **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
```json ### Pattern-specific parameters (`n1``n6`)
{
"presets": {
"preset_name": {
"pattern": "pattern_type",
"colors": ["#RRGGBB", ...],
"delay": 100,
"brightness": 127,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0
}
}
}
```
### Preset Fields
- **`pattern`** (required): Pattern type. Options:
- `"off"` - Turn off all LEDs
- `"on"` - Solid color
- `"blink"` - Blinking pattern
- `"rainbow"` - Rainbow color cycle
- `"pulse"` - Pulse/fade pattern
- `"transition"` - Color transition
- `"chase"` - Chasing pattern
- `"circle"` - Circle loading pattern
- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
- Colors are automatically converted from hex to RGB and reordered based on device color order setting
- Supports multiple colors for patterns that use them
- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100`
- **`brightness`** (optional): Brightness level (0-255). Default: `127`
- **`auto`** (optional): Auto mode flag. Default: `true`
- `true`: Pattern runs continuously
- `false`: Pattern advances one step per beat (manual mode)
- **`n1` through `n6`** (optional): Pattern-specific numeric parameters. Default: `0`
- See pattern-specific documentation below
### Pattern-Specific Parameters
#### Rainbow #### Rainbow
- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1` - **`n1`**: Step increment on the color wheel per update (default 1).
#### Pulse #### Pulse
- **`n1`**: Attack time in milliseconds (fade in) - **`n1`**: Attack (fade in) ms
- **`n2`**: Hold time in milliseconds (full brightness) - **`n2`**: Hold ms
- **`n3`**: Decay time in milliseconds (fade out) - **`n3`**: Decay (fade out) ms
- **`delay`**: Delay time in milliseconds (off between pulses) - **`d`**: Off time between pulses ms
#### Transition #### Transition
- **`delay`**: Transition duration in milliseconds - **`d`**: Transition duration ms
#### Chase #### Chase
- **`n1`**: Number of LEDs with first color - **`n1`**: LEDs with first color
- **`n2`**: Number of LEDs with second color - **`n2`**: LEDs with second color
- **`n3`**: Movement amount on even steps (can be negative) - **`n3`**: Movement on even steps (may be negative)
- **`n4`**: Movement amount on odd steps (can be negative) - **`n4`**: Movement on odd steps (may be negative)
#### Circle #### Circle
- **`n1`**: Head movement rate (LEDs per second) - **`n1`**: Head speed (LEDs/s)
- **`n2`**: Maximum length - **`n2`**: Max length
- **`n3`**: Tail movement rate (LEDs per second) - **`n3`**: Tail speed (LEDs/s)
- **`n4`**: Minimum length - **`n4`**: Min length
## Select Messages ### Select messages
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
### Select Format
```json ```json
{ {
"select": { "select": {
"device_name": ["preset_name"], "device_name": ["preset_id"],
"device_name2": ["preset_name2", step_value] "other_device": ["preset_id", 10]
} }
} }
``` ```
### Select Fields - One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
- Two elements: explicit **step** for sync.
- **`select`**: Object mapping device names to selection lists ### Beat and sync behavior
- **Key**: Device name (as configured in device settings)
- **Value**: List with one or two elements:
- `["preset_name"]` - Select preset (uses default step behavior)
- `["preset_name", step]` - Select preset with explicit step value (for synchronization)
### Step Synchronization - Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
The step value allows precise synchronization across multiple devices: ### Example (compact preset map)
- **Without step**: `["preset_name"]`
- If switching to different preset: step resets to 0
- If selecting "off" pattern: step resets to 0
- If selecting same preset (beat): step is preserved, pattern restarts
- **With step**: `["preset_name", 10]`
- Explicitly sets step to the specified value
- Useful for synchronizing multiple devices to the same step
### Beat Functionality
Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator:
- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat
- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat
Example beat sequence:
```json
// Beat 1
{"select": {"device1": ["rainbow_preset"]}}
// Beat 2 (same preset = beat)
{"select": {"device1": ["rainbow_preset"]}}
// Beat 3
{"select": {"device1": ["rainbow_preset"]}}
```
## Synchronization
### Using "off" Pattern
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
```json
{
"select": {
"device1": ["off"],
"device2": ["off"]
}
}
```
After all devices are "off", switching to a pattern ensures they all start from step 0:
```json
{
"select": {
"device1": ["rainbow_preset"],
"device2": ["rainbow_preset"]
}
}
```
### Using Step Parameter
For precise synchronization, use the step parameter:
```json
{
"select": {
"device1": ["rainbow_preset", 10],
"device2": ["rainbow_preset", 10],
"device3": ["rainbow_preset", 10]
}
}
```
All devices will start at step 10 and advance together on subsequent beats.
## Complete Example
```json ```json
{ {
"v": "1", "v": "1",
"save": true,
"presets": { "presets": {
"red_blink": { "1": {
"pattern": "blink", "name": "Red blink",
"colors": ["#FF0000"], "p": "blink",
"delay": 200, "c": ["#FF0000"],
"brightness": 255, "d": 200,
"auto": true "b": 255,
}, "a": true,
"rainbow_manual": { "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
"pattern": "rainbow",
"delay": 100,
"n1": 2,
"auto": false
},
"pulse_slow": {
"pattern": "pulse",
"colors": ["#00FF00"],
"delay": 500,
"n1": 1000,
"n2": 500,
"n3": 1000,
"auto": false
} }
}, },
"select": { "select": {
"device1": ["red_blink"], "living-room": ["1"]
"device2": ["rainbow_manual", 0],
"device3": ["pulse_slow"]
} }
} }
``` ```
## Message Processing ---
1. **Version Check**: Messages with `v != "1"` are rejected ## Processing summary (driver)
2. **Preset Processing**: Presets are created or updated (upsert behavior)
3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order
4. **Selection**: Devices select their assigned preset, optionally with step value
## Best Practices 1. Reject if `v != "1"`.
2. Apply optional top-level **`b`** (global brightness).
3. For each entry in **`presets`**, normalize colors and upsert preset by id.
4. If this devices **`name`** appears in **`select`**, run selection (optional step).
5. If **`default`** is set, store startup preset id.
6. If **`save`** is set, persist presets.
1. **Always include version**: Set `"v": "1"` in all messages ---
2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns
3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns
4. **Step for precision**: Use step parameter when exact synchronization is required
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
## Error Handling ## Error handling (HTTP)
- Invalid version: Message is ignored Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
- Missing preset: Selection fails, device keeps current preset
- Invalid pattern: Selection fails, device keeps current preset ---
- Missing colors: Pattern uses default white color
- Invalid step: Step value is used as-is (may cause unexpected behavior)
## Notes ## Notes
- Colors are automatically converted from hex strings to RGB tuples - **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
- Color order reordering happens automatically based on device settings - For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).
- Step counter wraps around (0-255 for rainbow, unbounded for others)
- Manual mode patterns stop after one step/cycle, waiting for next beat
- Auto mode patterns run continuously until changed

View File

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

View File

@@ -51,11 +51,14 @@ def ensure_peer(addr):
raise raise
print("Starting ESP32 main.py")
while True: while True:
if uart.any(): if uart.any():
data = uart.read() data = uart.read()
if not data or len(data) < 6: if not data or len(data) < 6:
continue continue
print(f"Received data: {data}")
addr = data[:6] addr = data[:6]
payload = data[6:] payload = data[6:]
ensure_peer(addr) ensure_peer(addr)

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 4c7646b2fe

1
led-tool Submodule

Submodule led-tool added at 3844aa9d6a

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
}

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

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

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

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

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

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

@@ -0,0 +1,68 @@
from microdot import Microdot
from models.device import Device
import json
controller = Microdot()
devices = Device()
@controller.get("")
async def list_devices(request):
"""List all devices."""
devices_data = {}
for dev_id in devices.list():
d = devices.read(dev_id)
if d:
devices_data[dev_id] = d
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
async def get_device(request, id):
"""Get a device by ID."""
dev = devices.read(id)
if dev:
return json.dumps(dev), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404
@controller.post("")
async def create_device(request):
"""Create a new device."""
try:
data = request.json or {}
name = data.get("name", "").strip()
address = data.get("address")
default_pattern = data.get("default_pattern")
tabs = data.get("tabs")
if isinstance(tabs, list):
tabs = [str(t) for t in tabs]
else:
tabs = []
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs)
dev = devices.read(dev_id)
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put("/<id>")
async def update_device(request, id):
"""Update a device."""
try:
data = request.json or {}
if "tabs" in data and isinstance(data["tabs"], list):
data["tabs"] = [str(t) for t in data["tabs"]]
if devices.update(id, data):
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete("/<id>")
async def delete_device(request, id):
"""Delete a device."""
if devices.delete(id):
return json.dumps({"message": "Device deleted successfully"}), 200
return json.dumps({"error": "Device not found"}), 404

View File

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

View File

@@ -36,11 +36,11 @@ async def list_presets(request, session):
} }
return json.dumps(scoped), 200, {'Content-Type': 'application/json'} return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>') @controller.get('/<preset_id>')
@with_session @with_session
async def get_preset(request, id, session): async def get_preset(request, session, preset_id):
"""Get a specific preset by ID (current profile only).""" """Get a specific preset by ID (current profile only)."""
preset = presets.read(id) preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if preset and str(preset.get("profile_id")) == str(current_profile_id): if preset and str(preset.get("profile_id")) == str(current_profile_id):
return json.dumps(preset), 200, {'Content-Type': 'application/json'} return json.dumps(preset), 200, {'Content-Type': 'application/json'}
@@ -70,12 +70,12 @@ async def create_preset(request, session):
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.put('/<id>') @controller.put('/<preset_id>')
@with_session @with_session
async def update_preset(request, id, session): async def update_preset(request, session, preset_id):
"""Update an existing preset (current profile only).""" """Update an existing preset (current profile only)."""
try: try:
preset = presets.read(id) preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id): if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
@@ -87,21 +87,36 @@ async def update_preset(request, id, session):
data = {} data = {}
data = dict(data) data = dict(data)
data["profile_id"] = str(current_profile_id) data["profile_id"] = str(current_profile_id)
if presets.update(id, data): if presets.update(preset_id, data):
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'} return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>') @controller.delete('/<preset_id>')
@with_session @with_session
async def delete_preset(request, id, session): async def delete_preset(request, *args, **kwargs):
"""Delete a preset (current profile only).""" """Delete a preset (current profile only)."""
preset = presets.read(id) # Be tolerant of wrapper/arg-order variations.
session = None
preset_id = None
if len(args) > 0:
session = args[0]
if len(args) > 1:
preset_id = args[1]
if 'session' in kwargs and kwargs.get('session') is not None:
session = kwargs.get('session')
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
preset_id = kwargs.get('preset_id')
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
preset_id = kwargs.get('id')
if preset_id is None:
return json.dumps({"error": "Preset ID is required"}), 400
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id): if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404
if presets.delete(id): if presets.delete(preset_id):
return json.dumps({"message": "Preset deleted successfully"}), 200 return json.dumps({"message": "Preset deleted successfully"}), 200
return json.dumps({"error": "Preset not found"}), 404 return json.dumps({"error": "Preset not found"}), 404

View File

@@ -81,11 +81,117 @@ async def apply_profile(request, session, id):
async def create_profile(request): async def create_profile(request):
"""Create a new profile.""" """Create a new profile."""
try: try:
data = request.json or {} data = dict(request.json or {})
name = data.get("name", "") name = data.get("name", "")
seed_raw = data.get("seed_dj_tab", False)
if isinstance(seed_raw, str):
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_tab = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_tab", None)
profile_id = profiles.create(name) profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data: if data:
profiles.update(profile_id, data) profiles.update(profile_id, data)
# New profiles always start with a default tab pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
tabs.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_tab:
# Seed a DJ-focused tab with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
tabs.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"tabs": profile_tabs})
profile_data = profiles.read(profile_id) profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'} return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e: except Exception as e:

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

@@ -0,0 +1,54 @@
from models.model import Model
def _normalize_address(addr):
"""Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons)."""
if addr is None:
return None
s = str(addr).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
return s
return None
class Device(Model):
def __init__(self):
super().__init__()
def create(self, name="", address=None, default_pattern=None, tabs=None):
next_id = self.get_next_id()
addr = _normalize_address(address)
self[next_id] = {
"name": name,
"address": addr,
"default_pattern": default_pattern if default_pattern else None,
"tabs": list(tabs) if tabs else [],
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
if "address" in data and data["address"] is not None:
data = dict(data)
data["address"] = _normalize_address(data["address"])
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

View File

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

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

@@ -0,0 +1,251 @@
// Device management: list, create, edit, delete (name and 6-byte address)
const HEX_BOX_COUNT = 12;
function makeHexAddressBoxes(container) {
if (!container || container.querySelector('.hex-addr-box')) return;
container.innerHTML = '';
for (let i = 0; i < HEX_BOX_COUNT; i++) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'hex-addr-box';
input.maxLength = 1;
input.autocomplete = 'off';
input.setAttribute('data-index', i);
input.setAttribute('inputmode', 'numeric');
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
input.addEventListener('input', (e) => {
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
e.target.value = v;
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
e.target.nextElementSibling.focus();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
e.target.previousElementSibling.focus();
}
});
input.addEventListener('paste', (e) => {
e.preventDefault();
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
const boxes = container.querySelectorAll('.hex-addr-box');
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
boxes[j].value = pasted[j];
}
if (pasted.length > 0) {
const nextIdx = Math.min(pasted.length, boxes.length - 1);
boxes[nextIdx].focus();
}
});
container.appendChild(input);
}
}
function getAddressFromBoxes(container) {
if (!container) return '';
const boxes = container.querySelectorAll('.hex-addr-box');
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
}
function setAddressToBoxes(container, addrStr) {
if (!container) return;
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
const boxes = container.querySelectorAll('.hex-addr-box');
boxes.forEach((b, i) => {
b.value = s[i] || '';
});
}
async function loadDevicesModal() {
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.innerHTML = '<span class="muted-text">Loading...</span>';
try {
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Failed to load devices');
const devices = await response.json();
renderDevicesList(devices || {});
} catch (e) {
console.error('loadDevicesModal:', e);
container.innerHTML = '<span class="muted-text">Failed to load devices.</span>';
}
}
function renderDevicesList(devices) {
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.innerHTML = '';
const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object');
if (ids.length === 0) {
const p = document.createElement('p');
p.className = 'muted-text';
p.textContent = 'No devices. Create one above.';
container.appendChild(p);
return;
}
ids.forEach((devId) => {
const dev = devices[devId];
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.5rem';
row.style.flexWrap = 'wrap';
const label = document.createElement('span');
label.textContent = (dev && dev.name) || devId;
label.style.flex = '1';
label.style.minWidth = '100px';
const meta = document.createElement('span');
meta.className = 'muted-text';
meta.style.fontSize = '0.85em';
const addr = (dev && dev.address) ? dev.address : '—';
meta.textContent = `Address: ${addr}`;
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-secondary btn-small';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', async () => {
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
try {
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' });
if (res.ok) await loadDevicesModal();
else {
const data = await res.json().catch(() => ({}));
alert(data.error || 'Delete failed');
}
} catch (err) {
console.error(err);
alert('Delete failed');
}
});
row.appendChild(label);
row.appendChild(meta);
row.appendChild(editBtn);
row.appendChild(deleteBtn);
container.appendChild(row);
});
}
function openEditDeviceModal(devId, dev) {
const modal = document.getElementById('edit-device-modal');
const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name');
const addressBoxes = document.getElementById('edit-device-address-boxes');
if (!modal || !idInput) return;
idInput.value = devId;
if (nameInput) nameInput.value = (dev && dev.name) || '';
setAddressToBoxes(addressBoxes, (dev && dev.address) || '');
modal.classList.add('active');
}
async function createDevice(name, address) {
try {
const res = await fetch('/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address: address || null }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
await loadDevicesModal();
return true;
}
alert(data.error || 'Failed to create device');
return false;
} catch (e) {
console.error('createDevice:', e);
alert('Failed to create device');
return false;
}
}
async function updateDevice(devId, name, address) {
try {
const res = await fetch(`/devices/${devId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address: address || null }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
await loadDevicesModal();
return true;
}
alert(data.error || 'Failed to update device');
return false;
} catch (e) {
console.error('updateDevice:', e);
alert('Failed to update device');
return false;
}
}
document.addEventListener('DOMContentLoaded', () => {
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
const devicesBtn = document.getElementById('devices-btn');
const devicesModal = document.getElementById('devices-modal');
const devicesCloseBtn = document.getElementById('devices-close-btn');
const newName = document.getElementById('new-device-name');
const createBtn = document.getElementById('create-device-btn');
const editForm = document.getElementById('edit-device-form');
const editCloseBtn = document.getElementById('edit-device-close-btn');
const editDeviceModal = document.getElementById('edit-device-modal');
if (devicesBtn && devicesModal) {
devicesBtn.addEventListener('click', () => {
devicesModal.classList.add('active');
loadDevicesModal();
});
}
if (devicesCloseBtn) {
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
}
const newAddressBoxes = document.getElementById('new-device-address-boxes');
const doCreate = async () => {
const name = (newName && newName.value.trim()) || '';
if (!name) {
alert('Device name is required.');
return;
}
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
const ok = await createDevice(name, address);
if (ok && newName) {
newName.value = '';
setAddressToBoxes(newAddressBoxes, '');
}
};
if (createBtn) createBtn.addEventListener('click', doCreate);
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
if (editForm) {
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name');
const addressBoxes = document.getElementById('edit-device-address-boxes');
const devId = idInput && idInput.value;
if (!devId) return;
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
const ok = await updateDevice(
devId,
nameInput ? nameInput.value.trim() : '',
address
);
if (ok) editDeviceModal.classList.remove('active');
});
}
if (editCloseBtn) {
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
}
});

View File

@@ -2,6 +2,72 @@
let espnowSocket = null; let espnowSocket = null;
let espnowSocketReady = false; let espnowSocketReady = false;
let espnowPendingMessages = []; let espnowPendingMessages = [];
let currentProfileIdCache = null;
const getCurrentProfileId = async () => {
try {
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
if (!res.ok) return currentProfileIdCache ? String(currentProfileIdCache) : null;
const data = await res.json();
const id = data && (data.id || (data.profile && data.profile.id));
currentProfileIdCache = id ? String(id) : null;
return currentProfileIdCache;
} catch (_) {
return currentProfileIdCache ? String(currentProfileIdCache) : null;
}
};
const filterPresetsForCurrentProfile = async (presetsObj) => {
const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {};
const currentProfileId = await getCurrentProfileId();
if (!currentProfileId) return scoped;
return Object.fromEntries(
Object.entries(scoped).filter(([, preset]) => {
if (!preset || typeof preset !== 'object') return false;
if (!('profile_id' in preset)) return true; // Legacy records
return String(preset.profile_id) === String(currentProfileId);
}),
);
};
const getCurrentProfileData = async () => {
try {
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
return await res.json();
} catch (_) {
return null;
}
};
const getCurrentProfilePaletteColors = async () => {
const profileData = await getCurrentProfileData();
const profile = profileData && profileData.profile;
const paletteId = profile && (profile.palette_id || profile.paletteId);
if (!paletteId) return [];
try {
const res = await fetch(`/palettes/${paletteId}`, { headers: { Accept: 'application/json' } });
if (!res.ok) return [];
const pal = await res.json();
return Array.isArray(pal.colors) ? pal.colors : [];
} catch (_) {
return [];
}
};
const resolveColorsWithPaletteRefs = (colors, paletteRefs, paletteColors) => {
const baseColors = Array.isArray(colors) ? colors : [];
const refs = Array.isArray(paletteRefs) ? paletteRefs : [];
const pal = Array.isArray(paletteColors) ? paletteColors : [];
return baseColors.map((color, idx) => {
const refRaw = refs[idx];
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
return pal[ref];
}
return color;
});
};
const getEspnowSocket = () => { const getEspnowSocket = () => {
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) { if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
@@ -105,7 +171,6 @@ document.addEventListener('DOMContentLoaded', () => {
const presetPatternInput = document.getElementById('preset-pattern-input'); const presetPatternInput = document.getElementById('preset-pattern-input');
const presetColorsContainer = document.getElementById('preset-colors-container'); const presetColorsContainer = document.getElementById('preset-colors-container');
const presetNewColorInput = document.getElementById('preset-new-color'); const presetNewColorInput = document.getElementById('preset-new-color');
const presetAddColorButton = document.getElementById('preset-add-color-btn');
const presetBrightnessInput = document.getElementById('preset-brightness-input'); const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input'); const presetDelayInput = document.getElementById('preset-delay-input');
const presetDefaultButton = document.getElementById('preset-default-btn'); const presetDefaultButton = document.getElementById('preset-default-btn');
@@ -123,6 +188,7 @@ document.addEventListener('DOMContentLoaded', () => {
let cachedPresets = {}; let cachedPresets = {};
let cachedPatterns = {}; let cachedPatterns = {};
let currentPresetColors = []; // Track colors for the current preset let currentPresetColors = []; // Track colors for the current preset
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
// Function to get max colors for current pattern // Function to get max colors for current pattern
const getMaxColors = () => { const getMaxColors = () => {
@@ -158,7 +224,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Hide/show the actions (color picker and buttons) // Hide/show the actions (color picker and buttons)
const colorActions = presetColorsContainer.nextElementSibling; const colorActions = presetColorsContainer.nextElementSibling;
if (colorActions && (colorActions.querySelector('#preset-add-color-btn') || colorActions.querySelector('#preset-new-color'))) { if (colorActions && colorActions.querySelector('#preset-new-color')) {
colorActions.style.display = shouldShow ? '' : 'none'; colorActions.style.display = shouldShow ? '' : 'none';
} }
} }
@@ -172,11 +238,24 @@ document.addEventListener('DOMContentLoaded', () => {
return parseInt(input.value, 10) || 0; return parseInt(input.value, 10) || 0;
}; };
const renderPresetColors = (colors) => { const renderPresetColors = (colors, paletteRefs) => {
if (!presetColorsContainer) return; if (!presetColorsContainer) return;
presetColorsContainer.innerHTML = ''; presetColorsContainer.innerHTML = '';
currentPresetColors = colors || []; currentPresetColors = Array.isArray(colors) ? colors.slice() : [];
if (Array.isArray(paletteRefs)) {
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
const refRaw = paletteRefs[i];
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
return Number.isInteger(ref) ? ref : null;
});
} else {
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
const refRaw = currentPresetPaletteRefs[i];
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
return Number.isInteger(ref) ? ref : null;
});
}
// Get max colors for current pattern // Get max colors for current pattern
const maxColors = getMaxColors(); const maxColors = getMaxColors();
@@ -185,7 +264,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (currentPresetColors.length === 0) { if (currentPresetColors.length === 0) {
const empty = document.createElement('p'); const empty = document.createElement('p');
empty.className = 'muted-text'; empty.className = 'muted-text';
empty.textContent = `No colors added. Click "Add Color" to add colors.${maxColorsText}`; empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`;
presetColorsContainer.appendChild(empty); presetColorsContainer.appendChild(empty);
return; return;
} }
@@ -208,6 +287,8 @@ document.addEventListener('DOMContentLoaded', () => {
swatchWrapper.style.cssText = 'position: relative; display: inline-block;'; swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
swatchWrapper.draggable = true; swatchWrapper.draggable = true;
swatchWrapper.dataset.colorIndex = index; swatchWrapper.dataset.colorIndex = index;
const refAtIndex = currentPresetPaletteRefs[index];
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
swatchWrapper.classList.add('draggable-color-swatch'); swatchWrapper.classList.add('draggable-color-swatch');
const swatch = document.createElement('div'); const swatch = document.createElement('div');
@@ -222,6 +303,31 @@ document.addEventListener('DOMContentLoaded', () => {
transition: opacity 0.2s, transform 0.2s; transition: opacity 0.2s, transform 0.2s;
`; `;
swatch.title = `${color} - Drag to reorder`; swatch.title = `${color} - Drag to reorder`;
if (Number.isInteger(refAtIndex)) {
const linkedBadge = document.createElement('span');
linkedBadge.textContent = 'P';
linkedBadge.title = `Linked to palette color #${refAtIndex + 1}`;
linkedBadge.style.cssText = `
position: absolute;
left: -6px;
top: -6px;
min-width: 18px;
height: 18px;
border-radius: 9px;
background: #3f51b5;
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
z-index: 11;
border: 1px solid rgba(255,255,255,0.35);
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
`;
swatchWrapper.appendChild(linkedBadge);
}
// Color picker overlay // Color picker overlay
const colorPicker = document.createElement('input'); const colorPicker = document.createElement('input');
@@ -239,7 +345,9 @@ document.addEventListener('DOMContentLoaded', () => {
`; `;
colorPicker.addEventListener('change', (e) => { colorPicker.addEventListener('change', (e) => {
currentPresetColors[index] = e.target.value; currentPresetColors[index] = e.target.value;
renderPresetColors(currentPresetColors); // Manual picker edit breaks palette linkage for this slot.
currentPresetPaletteRefs[index] = null;
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
}); });
// Prevent color picker from interfering with drag // Prevent color picker from interfering with drag
colorPicker.addEventListener('mousedown', (e) => { colorPicker.addEventListener('mousedown', (e) => {
@@ -271,7 +379,8 @@ document.addEventListener('DOMContentLoaded', () => {
removeBtn.addEventListener('click', (e) => { removeBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
currentPresetColors.splice(index, 1); currentPresetColors.splice(index, 1);
renderPresetColors(currentPresetColors); currentPresetPaletteRefs.splice(index, 1);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
}); });
// Prevent remove button from interfering with drag // Prevent remove button from interfering with drag
removeBtn.addEventListener('mousedown', (e) => { removeBtn.addEventListener('mousedown', (e) => {
@@ -326,12 +435,18 @@ document.addEventListener('DOMContentLoaded', () => {
const colorPicker = el.querySelector('input[type="color"]'); const colorPicker = el.querySelector('input[type="color"]');
return colorPicker ? colorPicker.value : null; return colorPicker ? colorPicker.value : null;
}).filter(color => color !== null); }).filter(color => color !== null);
const newRefOrder = colorElements.map((el) => {
const refRaw = el.dataset.paletteRef;
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
return Number.isInteger(ref) ? ref : null;
});
// Update current colors array // Update current colors array
currentPresetColors = newColorOrder; currentPresetColors = newColorOrder;
currentPresetPaletteRefs = newRefOrder;
// Re-render to update indices // Re-render to update indices
renderPresetColors(currentPresetColors); renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
}); });
presetColorsContainer.appendChild(swatchContainer); presetColorsContainer.appendChild(swatchContainer);
@@ -361,7 +476,8 @@ document.addEventListener('DOMContentLoaded', () => {
const patternName = preset.pattern || ''; const patternName = preset.pattern || '';
presetPatternInput.value = patternName; presetPatternInput.value = patternName;
const colors = Array.isArray(preset.colors) ? preset.colors : []; const colors = Array.isArray(preset.colors) ? preset.colors : [];
renderPresetColors(colors); const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : [];
renderPresetColors(colors, paletteRefs);
presetBrightnessInput.value = preset.brightness || 0; presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0; presetDelayInput.value = preset.delay || 0;
@@ -424,6 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
currentEditId = null; currentEditId = null;
currentEditTabId = null; currentEditTabId = null;
currentPresetColors = []; currentPresetColors = [];
currentPresetPaletteRefs = [];
setFormValues({ setFormValues({
name: '', name: '',
pattern: '', pattern: '',
@@ -505,6 +622,7 @@ document.addEventListener('DOMContentLoaded', () => {
name: presetNameInput ? presetNameInput.value.trim() : '', name: presetNameInput ? presetNameInput.value.trim() : '',
pattern: presetPatternInput ? presetPatternInput.value.trim() : '', pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
colors: currentPresetColors || [], colors: currentPresetColors || [],
palette_refs: currentPresetPaletteRefs || [],
// Use canonical field names expected by the device / API // Use canonical field names expected by the device / API
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0, brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0, delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
@@ -633,9 +751,18 @@ document.addEventListener('DOMContentLoaded', () => {
const editButton = document.createElement('button'); const editButton = document.createElement('button');
editButton.className = 'btn btn-secondary btn-small'; editButton.className = 'btn btn-secondary btn-small';
editButton.textContent = 'Edit'; editButton.textContent = 'Edit';
editButton.addEventListener('click', () => { editButton.addEventListener('click', async () => {
currentEditId = presetId; currentEditId = presetId;
setFormValues(preset || {}); const paletteColors = await getCurrentProfilePaletteColors();
const presetForEditor = {
...(preset || {}),
colors: resolveColorsWithPaletteRefs(
(preset && preset.colors) || [],
(preset && preset.palette_refs) || [],
paletteColors,
),
};
setFormValues(presetForEditor);
openEditor(); openEditor();
}); });
@@ -698,7 +825,8 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load presets'); throw new Error('Failed to load presets');
} }
const presets = await response.json(); const presets = await response.json();
renderPresets(presets); const filtered = await filterPresetsForCurrentProfile(presets);
renderPresets(filtered);
} catch (error) { } catch (error) {
console.error('Load presets failed:', error); console.error('Load presets failed:', error);
presetsList.innerHTML = ''; presetsList.innerHTML = '';
@@ -757,7 +885,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to load presets'); throw new Error('Failed to load presets');
} }
const allPresets = await response.json(); const allPresetsRaw = await response.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
// Load only the current tab's presets so we can avoid duplicates within this tab. // Load only the current tab's presets so we can avoid duplicates within this tab.
let currentTabPresets = []; let currentTabPresets = [];
@@ -946,12 +1075,12 @@ document.addEventListener('DOMContentLoaded', () => {
// Update color section visibility // Update color section visibility
updateColorSectionVisibility(); updateColorSectionVisibility();
// Re-render colors to show updated max colors limit // Re-render colors to show updated max colors limit
renderPresetColors(currentPresetColors); renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
}); });
} }
// Add Color button handler // Color picker auto-add handler
if (presetAddColorButton && presetNewColorInput) { if (presetNewColorInput) {
presetAddColorButton.addEventListener('click', () => { const tryAddSelectedColor = () => {
const color = presetNewColorInput.value; const color = presetNewColorInput.value;
if (!color) return; if (!color) return;
@@ -967,60 +1096,86 @@ document.addEventListener('DOMContentLoaded', () => {
} }
currentPresetColors.push(color); currentPresetColors.push(color);
renderPresetColors(currentPresetColors); currentPresetPaletteRefs.push(null);
}); renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
};
// Add when the picker closes (user confirms selection).
presetNewColorInput.addEventListener('change', tryAddSelectedColor);
} }
// Add from Palette button handler
if (presetAddFromPaletteButton) { if (presetAddFromPaletteButton) {
presetAddFromPaletteButton.addEventListener('click', () => { presetAddFromPaletteButton.addEventListener('click', async () => {
const openButton = document.getElementById('color-palette-btn'); try {
if (openButton) { const paletteColors = await getCurrentProfilePaletteColors();
openButton.click(); if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
} alert('No profile palette colors available.');
const modal = document.getElementById('color-palette-modal'); return;
const modalList = document.getElementById('palette-container'); }
if (modal) {
modal.classList.add('active');
}
if (!modalList) {
return;
}
const handlePick = (event) => { const modal = document.createElement('div');
const row = event.target.closest('[data-color]'); modal.className = 'modal active';
if (!row) { modal.innerHTML = `
return; <div class="modal-content">
} <h2>Pick Palette Color</h2>
const picked = row.dataset.color; <div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
if (!picked) { <div class="modal-actions">
return; <button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
} </div>
</div>
if (currentPresetColors.includes(picked)) { `;
alert('This color is already in the list.'); document.body.appendChild(modal);
return;
} const list = modal.querySelector('#pick-palette-list');
paletteColors.forEach((color, idx) => {
const maxColors = getMaxColors(); const row = document.createElement('div');
if (currentPresetColors.length >= maxColors) { row.className = 'profiles-row';
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`); row.style.display = 'flex';
if (modal) { row.style.alignItems = 'center';
modal.classList.remove('active'); row.style.gap = '0.75rem';
row.dataset.paletteIndex = String(idx);
row.dataset.paletteColor = color;
row.innerHTML = `
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
<span style="flex:1">${color}</span>
<button class="btn btn-primary btn-small" type="button">Use</button>
`;
list.appendChild(row);
});
const close = () => modal.remove();
modal.querySelector('#pick-palette-close-btn').addEventListener('click', close);
modal.addEventListener('click', (e) => {
if (e.target === modal) close();
});
list.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
const row = e.target.closest('[data-palette-index]');
if (!row) return;
const color = row.dataset.paletteColor;
const ref = parseInt(row.dataset.paletteIndex, 10);
if (!color || !Number.isInteger(ref)) return;
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
alert('That palette color is already linked.');
return;
}
const maxColors = getMaxColors();
if (currentPresetColors.length >= maxColors) {
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
return;
} }
modalList.removeEventListener('click', handlePick);
return;
}
currentPresetColors.push(picked);
renderPresetColors(currentPresetColors);
if (modal) {
modal.classList.remove('active');
}
modalList.removeEventListener('click', handlePick);
};
modalList.addEventListener('click', handlePick); currentPresetColors.push(color);
currentPresetPaletteRefs.push(ref);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
close();
});
} catch (err) {
console.error('Failed to add from palette:', err);
alert('Failed to load palette colors.');
}
}); });
} }
const presetSendButton = document.getElementById('preset-send-btn'); const presetSendButton = document.getElementById('preset-send-btn');
@@ -1136,7 +1291,15 @@ document.addEventListener('DOMContentLoaded', () => {
currentEditId = presetId; currentEditId = presetId;
currentEditTabId = tabId || null; currentEditTabId = tabId || null;
await loadPatterns(); await loadPatterns();
setFormValues(preset); const paletteColors = await getCurrentProfilePaletteColors();
setFormValues({
...(preset || {}),
colors: resolveColorsWithPaletteRefs(
(preset && preset.colors) || [],
(preset && preset.palette_refs) || [],
paletteColors,
),
});
openEditor(); openEditor();
}); });
@@ -1175,11 +1338,13 @@ document.addEventListener('DOMContentLoaded', () => {
// Build an ESPNow preset message for a single preset and optionally include a select // Build an ESPNow preset message for a single preset and optionally include a select
// for the given device names, then send it via WebSocket. // for the given device names, then send it via WebSocket.
// saveToDevice defaults to true. // saveToDevice defaults to true.
const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => { const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
try { try {
const colors = Array.isArray(preset.colors) && preset.colors.length const baseColors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors ? preset.colors
: ['#FFFFFF']; : ['#FFFFFF'];
const paletteColors = await getCurrentProfilePaletteColors();
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
const message = { const message = {
v: '1', v: '1',
@@ -1261,97 +1426,29 @@ try {
// Store selected preset per tab // Store selected preset per tab
const selectedPresets = {}; const selectedPresets = {};
// Run vs Edit for tab preset strip (in-memory only — each full page load starts in run mode)
let presetUiMode = 'run';
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
const setPresetUiMode = (mode) => {
presetUiMode = mode === 'edit' ? 'edit' : 'run';
};
const updateUiModeToggleButtons = () => {
const mode = getPresetUiMode();
// Label is the mode you switch *to* (opposite of current)
const label = mode === 'edit' ? 'Run mode' : 'Edit mode';
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.textContent = label;
btn.setAttribute('aria-pressed', mode === 'edit' ? 'true' : 'false');
btn.classList.toggle('ui-mode-toggle--edit', mode === 'edit');
});
document.body.classList.toggle('preset-ui-edit', mode === 'edit');
document.body.classList.toggle('preset-ui-run', mode === 'run');
};
// Track if we're currently dragging a preset // Track if we're currently dragging a preset
let isDraggingPreset = false; let isDraggingPreset = false;
// Context menu for tab presets
let presetContextMenu = null;
let presetContextTarget = null;
const ensurePresetContextMenu = () => {
if (presetContextMenu) {
return presetContextMenu;
}
const menu = document.createElement('div');
menu.id = 'preset-context-menu';
menu.style.cssText = `
position: fixed;
z-index: 2000;
background: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
padding: 0.25rem 0;
min-width: 160px;
display: none;
`;
const addItem = (label, action) => {
const item = document.createElement('button');
item.type = 'button';
item.textContent = label;
item.dataset.action = action;
item.style.cssText = `
display: block;
width: 100%;
padding: 0.4rem 0.75rem;
background: transparent;
color: #eee;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.9rem;
`;
item.addEventListener('mouseover', () => {
item.style.backgroundColor = '#3a3a3a';
});
item.addEventListener('mouseout', () => {
item.style.backgroundColor = 'transparent';
});
menu.appendChild(item);
};
addItem('Edit preset…', 'edit');
menu.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn || !presetContextTarget) {
return;
}
const { presetId } = presetContextTarget;
const action = btn.dataset.action;
hidePresetContextMenu();
if (action === 'edit') {
await editPresetFromTab(presetId);
}
});
document.body.appendChild(menu);
presetContextMenu = menu;
// Hide on outside click
document.addEventListener('click', (e) => {
if (!presetContextMenu) return;
if (e.target.closest('#preset-context-menu')) return;
hidePresetContextMenu();
});
return menu;
};
const showPresetContextMenu = (x, y, tabId, presetId, preset) => {
const menu = ensurePresetContextMenu();
presetContextTarget = { tabId, presetId, preset };
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.display = 'block';
};
const hidePresetContextMenu = () => {
if (presetContextMenu) {
presetContextMenu.style.display = 'none';
}
presetContextTarget = null;
};
// Function to convert 2D grid to flat array (for backward compatibility) // Function to convert 2D grid to flat array (for backward compatibility)
const gridToArray = (presetsGrid) => { const gridToArray = (presetsGrid) => {
@@ -1449,6 +1546,25 @@ const getDropTarget = (container, x, y) => {
return closest.element; return closest.element;
}; };
/**
* Move dragged tile onto the drop target's slot.
* When moving down the list (fromIdx < toIdx), insertBefore(dragging, dropTarget) lands one index
* too early; use the next element sibling so the item occupies the target slot.
*/
const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
const siblings = [...presetsList.querySelectorAll('.draggable-preset')];
const fromIdx = siblings.indexOf(dragging);
const toIdx = siblings.indexOf(dropTarget);
if (fromIdx === -1 || toIdx === -1) return;
if (fromIdx < toIdx) {
const next = dropTarget.nextElementSibling;
presetsList.insertBefore(dragging, next);
} else {
presetsList.insertBefore(dragging, dropTarget);
}
};
// Function to render presets for a specific tab in 2D grid // Function to render presets for a specific tab in 2D grid
const renderTabPresets = async (tabId) => { const renderTabPresets = async (tabId) => {
const presetsList = document.getElementById('presets-list-tab'); const presetsList = document.getElementById('presets-list-tab');
@@ -1482,47 +1598,74 @@ const renderTabPresets = async (tabId) => {
if (!presetsResponse.ok) { if (!presetsResponse.ok) {
throw new Error('Failed to load presets'); throw new Error('Failed to load presets');
} }
const allPresets = await presetsResponse.json(); const allPresetsRaw = await presetsResponse.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
const paletteColors = await getCurrentProfilePaletteColors();
presetsList.innerHTML = ''; presetsList.innerHTML = '';
presetsList.dataset.reorderTabId = tabId;
// Add drag and drop handlers to the container
presetsList.addEventListener('dragover', (e) => { // Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
e.preventDefault(); if (!presetsList.dataset.dragWired) {
const dragging = presetsList.querySelector('.dragging'); presetsList.dataset.dragWired = '1';
if (!dragging) return; // dragenter + dropEffect tell the browser this zone accepts a move (avoids ⊘ cursor)
presetsList.addEventListener('dragenter', (e) => {
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY); if (getPresetUiMode() !== 'edit') return;
if (dropTarget && dropTarget !== dragging) { e.preventDefault();
// Insert before drop target so the dragged item takes that cell's position });
presetsList.insertBefore(dragging, dropTarget); presetsList.addEventListener('dragover', (e) => {
} if (getPresetUiMode() !== 'edit') return;
}); e.preventDefault();
try {
presetsList.addEventListener('drop', async (e) => { e.dataTransfer.dropEffect = 'move';
e.preventDefault(); } catch (_) {}
const dragging = presetsList.querySelector('.dragging'); const dragging = presetsList.querySelector('.dragging');
if (!dragging) return; if (!dragging) return;
// Get new grid layout from DOM const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')]; // Keep dragover side-effect free; commit placement only on drop.
const presetIds = presetElements.map(el => el.dataset.presetId); if (!dropTarget || dropTarget === dragging) {
delete presetsList.dataset.dropTargetId;
// Convert to 2D grid (3 columns) return;
const newGrid = arrayToGrid(presetIds, 3); }
presetsList.dataset.dropTargetId = dropTarget.dataset.presetId || '';
// Save new grid });
try {
await savePresetGrid(tabId, newGrid); presetsList.addEventListener('drop', async (e) => {
// Re-render to ensure consistency if (getPresetUiMode() !== 'edit') return;
await renderTabPresets(tabId); e.preventDefault();
} catch (error) { const dragging = presetsList.querySelector('.dragging');
console.error('Failed to save preset grid:', error); if (!dragging) return;
alert('Failed to save preset order. Please try again.'); const targetId = presetsList.dataset.dropTargetId;
// Re-render to restore original order if (targetId) {
await renderTabPresets(tabId); const dropTarget = presetsList.querySelector(`.draggable-preset[data-preset-id="${targetId}"]:not(.dragging)`);
} if (dropTarget) {
}); insertDraggingOntoTarget(presetsList, dragging, dropTarget);
}
}
delete presetsList.dataset.dropTargetId;
const saveId = presetsList.dataset.reorderTabId;
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
const presetIds = presetElements.map((el) => el.dataset.presetId);
const newGrid = arrayToGrid(presetIds, 3);
try {
if (!saveId) {
console.warn('No tab id for preset reorder save');
return;
}
await savePresetGrid(saveId, newGrid);
await renderTabPresets(saveId);
} catch (error) {
console.error('Failed to save preset grid:', error);
alert('Failed to save preset order. Please try again.');
const fallbackId = presetsList.dataset.reorderTabId;
if (fallbackId) await renderTabPresets(fallbackId);
}
});
}
// Get the currently selected preset for this tab // Get the currently selected preset for this tab
const selectedPresetId = selectedPresets[tabId]; const selectedPresetId = selectedPresets[tabId];
@@ -1543,7 +1686,11 @@ const renderTabPresets = async (tabId) => {
const preset = allPresets[presetId]; const preset = allPresets[presetId];
if (preset) { if (preset) {
const isSelected = presetId === selectedPresetId; const isSelected = presetId === selectedPresetId;
const wrapper = createPresetButton(presetId, preset, tabId, isSelected); const displayPreset = {
...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
};
const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected);
presetsList.appendChild(wrapper); presetsList.appendChild(wrapper);
} }
}); });
@@ -1555,15 +1702,22 @@ const renderTabPresets = async (tabId) => {
}; };
const createPresetButton = (presetId, preset, tabId, isSelected = false) => { const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
const uiMode = getPresetUiMode();
const row = document.createElement('div');
const canDrag = uiMode === 'edit';
row.className = `preset-tile-row preset-tile-row--${uiMode}${canDrag ? ' draggable-preset' : ''}`;
row.draggable = canDrag;
row.dataset.presetId = presetId;
const button = document.createElement('button'); const button = document.createElement('button');
button.className = 'pattern-button draggable-preset'; button.type = 'button';
button.draggable = true; button.className = 'pattern-button preset-tile-main';
button.dataset.presetId = presetId;
if (isSelected) { if (isSelected) {
button.classList.add('active'); button.classList.add('active');
} }
const colors = Array.isArray(preset.colors) ? preset.colors.filter(c => c) : []; const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow'; const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
const barColors = isRainbow const barColors = isRainbow
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF'] ? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
@@ -1584,38 +1738,76 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
presetNameLabel.className = 'pattern-button-label'; presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel); button.appendChild(presetNameLabel);
button.addEventListener('click', (e) => { button.addEventListener('click', () => {
if (isDraggingPreset) return; if (isDraggingPreset) return;
const presetsList = document.getElementById('presets-list-tab'); const presetsListEl = document.getElementById('presets-list-tab');
if (presetsList) { if (presetsListEl) {
presetsList.querySelectorAll('.pattern-button').forEach(btn => btn.classList.remove('active')); presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
} }
button.classList.add('active'); button.classList.add('active');
selectedPresets[tabId] = presetId; selectedPresets[tabId] = presetId;
const section = button.closest('.presets-section'); const section = row.closest('.presets-section');
sendSelectForCurrentTabDevices(presetId, section); sendSelectForCurrentTabDevices(presetId, section);
}); });
button.addEventListener('contextmenu', async (e) => { if (canDrag) {
e.preventDefault(); row.addEventListener('dragstart', (e) => {
if (isDraggingPreset) return; isDraggingPreset = true;
await editPresetFromTab(presetId, tabId, preset); row.classList.add('dragging');
}); e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', presetId);
});
button.addEventListener('dragstart', (e) => { row.addEventListener('dragend', () => {
isDraggingPreset = true; row.classList.remove('dragging');
button.classList.add('dragging'); const presetsListEl = document.getElementById('presets-list-tab');
e.dataTransfer.effectAllowed = 'move'; if (presetsListEl) {
e.dataTransfer.setData('text/plain', presetId); delete presetsListEl.dataset.dropTargetId;
}); }
document.querySelectorAll('.draggable-preset').forEach((el) => el.classList.remove('drag-over'));
setTimeout(() => {
isDraggingPreset = false;
}, 100);
});
}
button.addEventListener('dragend', (e) => { row.appendChild(button);
button.classList.remove('dragging');
document.querySelectorAll('.draggable-preset').forEach(el => el.classList.remove('drag-over'));
setTimeout(() => { isDraggingPreset = false; }, 100);
});
return button; if (uiMode === 'edit') {
const actions = document.createElement('div');
actions.className = 'preset-tile-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.title = 'Edit preset';
editBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (isDraggingPreset) return;
editPresetFromTab(presetId, tabId, preset);
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger btn-small';
removeBtn.textContent = 'Remove';
removeBtn.title = 'Remove from this tab';
removeBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (isDraggingPreset) return;
if (!window.confirm('Remove this preset from this tab?')) return;
await removePresetFromTab(tabId, presetId);
});
actions.appendChild(editBtn);
actions.appendChild(removeBtn);
row.appendChild(actions);
}
return row;
}; };
const editPresetFromTab = async (presetId, tabId, existingPreset) => { const editPresetFromTab = async (presetId, tabId, existingPreset) => {
@@ -1731,3 +1923,20 @@ document.body.addEventListener('htmx:afterSwap', (event) => {
} }
} }
}); });
document.addEventListener('DOMContentLoaded', () => {
updateUiModeToggleButtons();
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
setPresetUiMode(next);
updateUiModeToggleButtons();
const mainMenu = document.getElementById('main-menu-dropdown');
if (mainMenu) mainMenu.classList.remove('open');
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
if (leftPanel) {
renderTabPresets(leftPanel.dataset.tabId);
}
});
});
});

View File

@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", () => {
const profilesCloseButton = document.getElementById("profiles-close-btn"); const profilesCloseButton = document.getElementById("profiles-close-btn");
const profilesList = document.getElementById("profiles-list"); const profilesList = document.getElementById("profiles-list");
const newProfileInput = document.getElementById("new-profile-name"); const newProfileInput = document.getElementById("new-profile-name");
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
const createProfileButton = document.getElementById("create-profile-btn"); const createProfileButton = document.getElementById("create-profile-btn");
if (!profilesButton || !profilesModal || !profilesList) { if (!profilesButton || !profilesModal || !profilesList) {
@@ -19,6 +20,18 @@ document.addEventListener("DOMContentLoaded", () => {
profilesModal.classList.remove("active"); profilesModal.classList.remove("active");
}; };
const refreshTabsForActiveProfile = async () => {
// Clear stale current tab so tab controller falls back to first tab of applied profile.
document.cookie = "current_tab=; path=/; max-age=0";
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
await window.tabsManager.loadTabs();
}
if (window.tabsManager && typeof window.tabsManager.loadTabsModal === "function") {
await window.tabsManager.loadTabsModal();
}
};
const renderProfiles = (profiles, currentProfileId) => { const renderProfiles = (profiles, currentProfileId) => {
profilesList.innerHTML = ""; profilesList.innerHTML = "";
let entries = []; let entries = [];
@@ -66,7 +79,7 @@ document.addEventListener("DOMContentLoaded", () => {
throw new Error("Failed to apply profile"); throw new Error("Failed to apply profile");
} }
await loadProfiles(); await loadProfiles();
document.body.dispatchEvent(new Event("tabs-updated")); await refreshTabsForActiveProfile();
} catch (error) { } catch (error) {
console.error("Apply profile failed:", error); console.error("Apply profile failed:", error);
alert("Failed to apply profile."); alert("Failed to apply profile.");
@@ -115,22 +128,8 @@ document.addEventListener("DOMContentLoaded", () => {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
}); });
} }
document.cookie = "current_tab=; path=/; max-age=0";
await loadProfiles(); await loadProfiles();
if (typeof window.loadTabs === "function") { await refreshTabsForActiveProfile();
await window.loadTabs();
}
if (typeof window.loadTabsModal === "function") {
await window.loadTabsModal();
}
const tabContent = document.getElementById("tab-content");
if (tabContent) {
tabContent.innerHTML = `
<div class="tab-content-placeholder">
Select a tab to get started
</div>
`;
}
} catch (error) { } catch (error) {
console.error("Clone profile failed:", error); console.error("Clone profile failed:", error);
alert("Failed to clone profile."); alert("Failed to clone profile.");
@@ -210,7 +209,10 @@ document.addEventListener("DOMContentLoaded", () => {
const response = await fetch("/profiles", { const response = await fetch("/profiles", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }), body: JSON.stringify({
name,
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
}),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to create profile"); throw new Error("Failed to create profile");
@@ -236,23 +238,11 @@ document.addEventListener("DOMContentLoaded", () => {
} }
newProfileInput.value = ""; newProfileInput.value = "";
// Clear current tab and refresh the UI so the new profile starts empty. if (newProfileSeedDjInput) {
document.cookie = "current_tab=; path=/; max-age=0"; newProfileSeedDjInput.checked = false;
}
await loadProfiles(); await loadProfiles();
if (typeof window.loadTabs === "function") { await refreshTabsForActiveProfile();
await window.loadTabs();
}
if (typeof window.loadTabsModal === "function") {
await window.loadTabsModal();
}
const tabContent = document.getElementById("tab-content");
if (tabContent) {
tabContent.innerHTML = `
<div class="tab-content-placeholder">
Select a tab to get started
</div>
`;
}
} catch (error) { } catch (error) {
console.error("Create profile failed:", error); console.error("Create profile failed:", error);
alert("Failed to create profile."); alert("Failed to create profile.");

View File

@@ -77,6 +77,11 @@ header h1 {
background-color: #333; background-color: #333;
} }
/* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only {
display: none !important;
}
.btn { .btn {
padding: 0.45rem 0.9rem; padding: 0.45rem 0.9rem;
border: none; border: none;
@@ -596,6 +601,52 @@ header h1 {
position: relative; position: relative;
} }
/* Preset tile: main button + optional edit/remove (Edit mode) */
.preset-tile-row {
display: flex;
flex-direction: row;
align-items: stretch;
min-width: 0;
min-height: 0;
}
.preset-tile-row--run .preset-tile-actions {
display: none;
}
.preset-tile-main {
flex: 1;
min-width: 0;
height: 5rem;
}
.preset-tile-actions {
display: flex;
flex-direction: column;
gap: 0.2rem;
justify-content: center;
flex-shrink: 0;
padding: 0.15rem 0 0.15rem 0.25rem;
}
.preset-tile-actions .btn {
flex: 1 1 0;
min-height: 0;
padding: 0.15rem 0.35rem;
font-size: 0.68rem;
line-height: 1.15;
white-space: nowrap;
}
.ui-mode-toggle--edit {
background-color: #4a3f8f;
border: 1px solid #7b6fd6;
}
.ui-mode-toggle--edit:hover {
background-color: #5a4f9f;
}
/* Preset select buttons inside the tab grid */ /* Preset select buttons inside the tab grid */
#presets-list-tab .pattern-button { #presets-list-tab .pattern-button {
display: flex; display: flex;

View File

@@ -1,6 +1,11 @@
// Tab management JavaScript // Tab management JavaScript
let currentTabId = null; let currentTabId = null;
const isEditModeActive = () => {
const toggle = document.querySelector('.ui-mode-toggle');
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
};
// Get current tab from cookie // Get current tab from cookie
function getCurrentTabFromCookie() { function getCurrentTabFromCookie() {
const cookies = document.cookie.split(';'); const cookies = document.cookie.split(';');
@@ -38,10 +43,12 @@ async function loadTabs() {
// Load current tab content if available // Load current tab content if available
if (currentTabId) { if (currentTabId) {
loadTabContent(currentTabId); await loadTabContent(currentTabId);
} else if (data.tab_order && data.tab_order.length > 0) { } else if (data.tab_order && data.tab_order.length > 0) {
// Set first tab as current if none is set // Set first tab as current if none is set
await setCurrentTab(data.tab_order[0]); const firstTabId = data.tab_order[0];
await setCurrentTab(firstTabId);
await loadTabContent(firstTabId);
} }
} catch (error) { } catch (error) {
console.error('Failed to load tabs:', error); console.error('Failed to load tabs:', error);
@@ -62,6 +69,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
return; return;
} }
const editMode = isEditModeActive();
let html = '<div class="tabs-list">'; let html = '<div class="tabs-list">';
for (const tabId of tabOrder) { for (const tabId of tabOrder) {
const tab = tabs[tabId]; const tab = tabs[tabId];
@@ -71,7 +79,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
html += ` html += `
<button class="tab-button ${activeClass}" <button class="tab-button ${activeClass}"
data-tab-id="${tabId}" data-tab-id="${tabId}"
title="Click to select, right-click to edit" title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
onclick="selectTab('${tabId}')"> onclick="selectTab('${tabId}')">
${tabName} ${tabName}
</button> </button>
@@ -106,6 +114,7 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
return; return;
} }
const editMode = isEditModeActive();
entries.forEach(([tabId, tab]) => { entries.forEach(([tabId, tab]) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "profiles-row"; row.className = "profiles-row";
@@ -224,10 +233,12 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
row.appendChild(label); row.appendChild(label);
row.appendChild(applyButton); row.appendChild(applyButton);
row.appendChild(editButton);
row.appendChild(sendPresetsButton); row.appendChild(sendPresetsButton);
row.appendChild(cloneButton); if (editMode) {
row.appendChild(deleteButton); row.appendChild(editButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
}
container.appendChild(row); container.appendChild(row);
}); });
} }
@@ -714,6 +725,9 @@ document.addEventListener('DOMContentLoaded', () => {
// Right-click on a tab button in the main header bar to edit that tab // Right-click on a tab button in the main header bar to edit that tab
document.addEventListener('contextmenu', async (event) => { document.addEventListener('contextmenu', async (event) => {
if (!isEditModeActive()) {
return;
}
const btn = event.target.closest('.tab-button'); const btn = event.target.closest('.tab-button');
if (!btn || !btn.dataset.tabId) { if (!btn || !btn.dataset.tabId) {
return; return;
@@ -796,11 +810,22 @@ document.addEventListener('DOMContentLoaded', () => {
await sendProfilePresets(); await sendProfilePresets();
}); });
} }
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', async () => {
await loadTabs();
if (tabsModal && tabsModal.classList.contains('active')) {
await loadTabsModal();
}
});
});
}); });
// Export for use in other scripts // Export for use in other scripts
window.tabsManager = { window.tabsManager = {
loadTabs, loadTabs,
loadTabsModal,
selectTab, selectTab,
createTab, createTab,
updateTab, updateTab,

View File

@@ -15,24 +15,26 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" id="tabs-btn">Tabs</button> <button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button> <button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Color Palette</button>
<button class="btn btn-secondary" id="presets-btn">Presets</button> <button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary" id="send-profile-presets-btn">Send 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 edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <button class="btn btn-secondary edit-mode-only" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="settings-btn">Settings</button> <button class="btn btn-secondary" id="settings-btn">Settings</button>
<button class="btn btn-secondary" id="help-btn">Help</button> <button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div> </div>
<div class="header-menu-mobile"> <div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button> <button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown"> <div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" data-target="tabs-btn">Tabs</button> <button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" data-target="color-palette-btn">Color Palette</button> <button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
<button type="button" data-target="presets-btn">Presets</button> <button type="button" class="edit-mode-only" data-target="color-palette-btn">Color Palette</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" data-target="send-profile-presets-btn">Send 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="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" data-target="profiles-btn">Profiles</button> <button type="button" class="edit-mode-only" data-target="profiles-btn">Profiles</button>
<button type="button" data-target="settings-btn">Settings</button> <button type="button" data-target="settings-btn">Settings</button>
<button type="button" data-target="help-btn">Help</button> <button type="button" data-target="help-btn">Help</button>
</div> </div>
@@ -92,6 +94,12 @@
<input type="text" id="new-profile-name" placeholder="Profile name"> <input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button> <button class="btn btn-primary" id="create-profile-btn">Create</button>
</div> </div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="new-profile-seed-dj">
DJ tab
</label>
</div>
<div id="profiles-list" class="profiles-list"></div> <div id="profiles-list" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="profiles-close-btn">Close</button> <button class="btn btn-secondary" id="profiles-close-btn">Close</button>
@@ -126,9 +134,8 @@
<label>Colors</label> <label>Colors</label>
<div id="preset-colors-container" class="preset-colors-container"></div> <div id="preset-colors-container" class="preset-colors-container"></div>
<div class="profiles-actions"> <div class="profiles-actions">
<input type="color" id="preset-new-color" value="#ffffff"> <input type="color" id="preset-new-color" value="#ffffff" title="Choose color (auto-adds)">
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button> <button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
</div> </div>
<div class="profiles-actions"> <div class="profiles-actions">
<div class="preset-editor-field"> <div class="preset-editor-field">
@@ -204,7 +211,6 @@
<div id="palette-container" class="profiles-list"></div> <div id="palette-container" class="profiles-list"></div>
<div class="profiles-actions"> <div class="profiles-actions">
<input type="color" id="palette-new-color" value="#ffffff"> <input type="color" id="palette-new-color" value="#ffffff">
<button class="btn btn-primary" id="palette-add-color-btn">Add Color</button>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button> <button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
@@ -228,9 +234,10 @@
<h3>Presets in a tab</h3> <h3>Presets in a tab</h3>
<ul> <ul>
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li> <li><strong>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>Run vs Edit mode</strong>: use the mode button in the menu (it shows the mode you will <em>switch to</em>). In <strong>Edit mode</strong>, each preset tile shows <strong>Edit</strong> and <strong>Remove</strong> on the right.</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>Edit preset</strong>: switch to <strong>Edit mode</strong> (menu button) and use <strong>Edit</strong> on each tile.</li>
<li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li> <li><strong>Remove from tab</strong>: in <strong>Edit mode</strong>, use <strong>Remove</strong> on the tile (the preset itself is not deleted, only its link from this tab).</li>
<li><strong>Reorder presets</strong>: in <strong>Edit mode</strong> only, drag tiles to change order; the layout saves automatically.</li>
</ul> </ul>
<h3>Presets, profiles & colors</h3> <h3>Presets, profiles & colors</h3>
@@ -287,7 +294,7 @@
<div class="form-group"> <div class="form-group">
<label for="ap-password">AP Password</label> <label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)"> <input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small> <small>Leave empty for open network (min 8 characters if set)</small>
</div> </div>

View File

@@ -193,7 +193,7 @@
<div class="form-group"> <div class="form-group">
<label for="ap-password">AP Password</label> <label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)"> <input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small> <small>Leave empty for open network (min 8 characters if set)</small>
</div> </div>

View File

@@ -12,6 +12,7 @@ from test_group import test_group
from test_sequence import test_sequence from test_sequence import test_sequence
from test_tab import test_tab from test_tab import test_tab
from test_palette import test_palette from test_palette import test_palette
from test_device import test_device
def run_all_tests(): def run_all_tests():
"""Run all model tests.""" """Run all model tests."""
@@ -27,6 +28,7 @@ def run_all_tests():
("Sequence", test_sequence), ("Sequence", test_sequence),
("Tab", test_tab), ("Tab", test_tab),
("Palette", test_palette), ("Palette", test_palette),
("Device", test_device),
] ]
passed = 0 passed = 0

View File

@@ -0,0 +1,64 @@
from models.device import Device
import os
def test_device():
"""Test Device model CRUD operations."""
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
device_file = os.path.join(db_dir, "device.json")
if os.path.exists(device_file):
os.remove(device_file)
devices = Device()
print("Testing create device")
device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"])
print(f"Created device with ID: {device_id}")
assert device_id is not None
assert device_id in devices
print("\nTesting read device")
device = devices.read(device_id)
print(f"Read: {device}")
assert device is not None
assert device["name"] == "Test Device"
assert device["address"] == "aabbccddeeff"
assert device["default_pattern"] == "on"
assert device["tabs"] == ["1", "2"]
print("\nTesting address normalization")
devices.update(device_id, {"address": "11:22:33:44:55:66"})
updated = devices.read(device_id)
assert updated["address"] == "112233445566"
print("\nTesting update device")
update_data = {
"name": "Updated Device",
"default_pattern": "rainbow",
"tabs": ["1", "2", "3"],
}
result = devices.update(device_id, update_data)
assert result is True
updated = devices.read(device_id)
assert updated["name"] == "Updated Device"
assert updated["default_pattern"] == "rainbow"
assert len(updated["tabs"]) == 3
print("\nTesting list devices")
device_list = devices.list()
print(f"Device list: {device_list}")
assert device_id in device_list
print("\nTesting delete device")
deleted = devices.delete(device_id)
assert deleted is True
assert device_id not in devices
print("\nTesting read after delete")
device = devices.read(device_id)
assert device is None
print("\nAll device tests passed!")
if __name__ == "__main__":
test_device()

View File

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

View File

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

View File

@@ -2,10 +2,14 @@ from models.profile import Profile
import os import os
def test_profile(): def test_profile():
"""Test Profile model CRUD operations.""" """Test Profile model CRUD operations.
# Clean up any existing test file Profile create() sets name, type, tabs (list of tab IDs), scenes, palette_id.
if os.path.exists("Profile.json"): """
os.remove("Profile.json") # Clean up any existing test file (model uses db/profile.json from project root)
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
profile_file = os.path.join(db_dir, "profile.json")
if os.path.exists(profile_file):
os.remove(profile_file)
profiles = Profile() profiles = Profile()
@@ -21,15 +25,13 @@ def test_profile():
assert profile is not None assert profile is not None
assert profile["name"] == "test_profile" assert profile["name"] == "test_profile"
assert "tabs" in profile assert "tabs" in profile
assert "palette" in profile assert "palette_id" in profile
assert "tab_order" in profile assert "type" in profile
print("\nTesting update profile") print("\nTesting update profile")
update_data = { update_data = {
"name": "updated_profile", "name": "updated_profile",
"tabs": {"tab1": {"names": ["1"], "presets": []}}, "tabs": ["tab1"],
"palette": ["#FF0000", "#00FF00"],
"tab_order": ["tab1"]
} }
result = profiles.update(profile_id, update_data) result = profiles.update(profile_id, update_data)
assert result is True assert result is True

View File

@@ -2,6 +2,9 @@
""" """
Browser automation tests using Selenium. Browser automation tests using Selenium.
Tests run against the device at 192.168.4.1 in an actual browser. Tests run against the device at 192.168.4.1 in an actual browser.
On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium
and chromedriver are installed (e.g. chromium-browser chromium-chromedriver).
""" """
import sys import sys
@@ -13,8 +16,8 @@ from selenium import webdriver
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.chrome.service import Service from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.common.exceptions import TimeoutException, NoSuchElementException from selenium.common.exceptions import TimeoutException, NoSuchElementException
@@ -33,24 +36,41 @@ class BrowserTest:
self.created_presets: List[str] = [] self.created_presets: List[str] = []
def setup(self): def setup(self):
"""Set up the browser driver.""" """Set up the browser driver. Tries Chrome first, then Firefox."""
err_chrome, err_firefox = None, None
# Try Chrome first
try: try:
chrome_options = Options() opts = ChromeOptions()
if self.headless: if self.headless:
chrome_options.add_argument('--headless') opts.add_argument('--headless')
chrome_options.add_argument('--no-sandbox') opts.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage') opts.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--disable-gpu') opts.add_argument('--disable-gpu')
chrome_options.add_argument('--window-size=1920,1080') opts.add_argument('--window-size=1920,1080')
self.driver = webdriver.Chrome(options=opts)
self.driver = webdriver.Chrome(options=chrome_options)
self.driver.implicitly_wait(5) self.driver.implicitly_wait(5)
print("✓ Browser started") print("✓ Browser started (Chrome)")
return True return True
except Exception as e: except Exception as e:
print(f"✗ Failed to start browser: {e}") err_chrome = e
print(" Make sure Chrome and ChromeDriver are installed") # Fallback to Firefox
return False try:
opts = FirefoxOptions()
if self.headless:
opts.add_argument('--headless')
self.driver = webdriver.Firefox(options=opts)
self.driver.implicitly_wait(5)
print("✓ Browser started (Firefox)")
return True
except Exception as e:
err_firefox = e
print("✗ Failed to start browser.")
if err_chrome:
print(f" Chrome: {err_chrome}")
if err_firefox:
print(f" Firefox: {err_firefox}")
print(" On Raspberry Pi (aarch64), install: chromium-browser and chromium-chromedriver")
return False
def teardown(self): def teardown(self):
"""Close the browser.""" """Close the browser."""
@@ -209,46 +229,6 @@ class BrowserTest:
except Exception as e: except Exception as e:
print(f" ⚠ Cleanup error: {e}") print(f" ⚠ Cleanup error: {e}")
def cleanup_test_data(self):
"""Clean up test data created during tests."""
try:
# Use requests to make API calls for cleanup
session = requests.Session()
# Delete created presets
for preset_id in self.created_presets:
try:
response = session.delete(f"{self.base_url}/presets/{preset_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up preset: {preset_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
# Delete created tabs
for tab_id in self.created_tabs:
try:
response = session.delete(f"{self.base_url}/tabs/{tab_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up tab: {tab_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup tab {tab_id}: {e}")
# Delete created profiles
for profile_id in self.created_profiles:
try:
response = session.delete(f"{self.base_url}/profiles/{profile_id}")
if response.status_code == 200:
print(f" ✓ Cleaned up profile: {profile_id}")
except Exception as e:
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
# Clear the lists
self.created_tabs.clear()
self.created_profiles.clear()
self.created_presets.clear()
except Exception as e:
print(f" ⚠ Cleanup error: {e}")
def fill_input(self, by, value, text, timeout=10): def fill_input(self, by, value, text, timeout=10):
"""Fill an input field.""" """Fill an input field."""
try: try:
@@ -553,7 +533,7 @@ def test_mobile_tab_presets_two_columns():
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10) container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
assert container is not None, "presets-list-tab not found" assert container is not None, "presets-list-tab not found"
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .preset-tile-row')
# Need at least 2 presets to make this meaningful # Need at least 2 presets to make this meaningful
assert len(tiles) >= 2, "Fewer than 2 presets found for tab" assert len(tiles) >= 2, "Fewer than 2 presets found for tab"
@@ -902,14 +882,20 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Test 5: Find presets in tab and test drag and drop # Test 5: Find presets in tab and test drag and drop (Edit mode only)
total += 1 total += 1
try: try:
# Wait for presets to load in the tab # Wait for presets to load in the tab
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5) presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
if presets_list_tab: if presets_list_tab:
time.sleep(1) # Wait for presets to render time.sleep(1) # Wait for presets to render
# Reordering is only available in Edit mode (tiles get .draggable-preset)
mode_toggle = browser.wait_for_element(By.CSS_SELECTOR, '.ui-mode-toggle', timeout=5)
if mode_toggle and mode_toggle.get_attribute('aria-pressed') == 'false':
mode_toggle.click()
time.sleep(0.5)
# Find draggable preset elements - wait a bit more for rendering # Find draggable preset elements - wait a bit more for rendering
time.sleep(1) time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
@@ -1005,11 +991,19 @@ def main():
print("LED Controller Browser Tests") print("LED Controller Browser Tests")
print(f"Testing against: {BASE_URL}") print(f"Testing against: {BASE_URL}")
print("=" * 60) print("=" * 60)
# On Pi OS Lite there is no browser by default; skip with exit 0 instead of failing
browser = BrowserTest(headless=True)
if not browser.setup():
print("\nSkipped (Pi OS Lite / no browser). Install chromium-browser and")
print("chromium-chromedriver to run browser tests, or run on Pi OS with desktop.")
sys.exit(0)
browser.teardown()
browser = BrowserTest(headless=False) # Set to True for headless mode browser = BrowserTest(headless=False) # Set to True for headless mode
results = [] results = []
# Run browser tests # Run browser tests
results.append(("Browser Connection", test_browser_connection(browser))) results.append(("Browser Connection", test_browser_connection(browser)))
results.append(("Tabs UI", test_tabs_ui(browser))) results.append(("Tabs UI", test_tabs_ui(browser)))

View File

@@ -499,6 +499,7 @@ def test_static_files(client: TestClient) -> bool:
'/static/tabs.js', '/static/tabs.js',
'/static/presets.js', '/static/presets.js',
'/static/profiles.js', '/static/profiles.js',
'/static/devices.js',
] ]
for file_path in static_files: for file_path in static_files: