1 Commits

Author SHA1 Message Date
b2077c0199 Improve ESP-NOW messaging and tab defaults
- Use shared ESPNOW payload limit and message splitting
- Expand default tab names and add flash/build artifacts.

Made-with: Cursor
2026-03-14 02:41:08 +13:00
118 changed files with 4597 additions and 11223 deletions

View File

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

View File

@@ -1,45 +0,0 @@
---
description: led-driver — MicroPython ESP32: mpremote, imports, layout, I/O, no pycache in src
globs: led-driver/**
alwaysApply: false
---
# led-driver (MicroPython / ESP32)
## Device and tests
1. Validate **MicroPython behaviour** under **`led-driver/`** with **`mpremote connect <PORT> …`** on the chip. Host **`python3`** does **not** prove the firmware build.
2. **Execution target is fixed:** treat **`led-driver/`** code as firmware that runs **only on MicroPython ESP32 devices**. Do **not** run `led-driver/src/main.py` (or other firmware modules) with host CPython as a normal execution path.
3. **Flow:** `mpremote connect <PORT> cp <local> :<on-flash>` then `run <script>.py`. Inline commands only — no **`.sh`** wrappers unless the user asks. Default serial placeholder: **`/dev/ttyACM0`**.
4. Checks that **import and run** code from **`led-driver/src/`** belong in **`led-driver/tests/`** and run with **`mpremote run …`**. **Do not** add **`pytest`** under **`led-controller/tests/`** that **`sys.path`**-loads **`led-driver/src`** and runs those modules on CPython.
## Import layout
4. **No** **`sys.path.insert`**, **`__file__`** path stitching, or other import-path hacks under **`led-driver/`**. Use device flash search path, or host **`PYTHONPATH`** / layout you control.
5. **No** “import fixer” code — fix copy order, flash paths, or env instead.
## Imports (fail loudly)
6. If a dependency does not load, **crash** and fix deployment or filesystem. **Do not** catch **`ImportError`** / **`ModuleNotFoundError`** around **`import`** / **`from … import`** for app/firmware modules (`settings`, `utils`, `network`, `machine`, …).
7. **Allowed — stdlib name pairs only** (MicroPython vs CPython): one **`except ImportError`**, then **one** fallback import, **no** extra logic in **`except`**:
- `uos` → `os`
- `ubinascii` → `binascii`
- `utime` → `time`
Not for “maybe the file exists on flash” — only different **stdlib** names.
8. **No** large inline reimplementations after **`except ImportError`** — deploy the real module.
## I/O
9. Non-blocking **recv** / **accept**: use plain **`except OSError:`** (or **break** on empty). **No** errno / EAGAIN / EWOULDBLOCK tables or **`getattr(errno, …)`** unless fixing a **documented** target bug.
10. Minimal **`try` / `except OSError`** around optional socket options (e.g. **`SO_REUSEADDR`**) is fine.
## Host Python and `src/`
11. **Do not** leave **`__pycache__/`** or **`.pyc`** under **`led-driver/src/`** from host runs. Remove if created; **`.gitignore`** already ignores it. Prefer **`PYTHONDONTWRITEBYTECODE=1`** or **`-B`** when host Python must touch **`led-driver/src/`**.

View File

@@ -1,12 +0,0 @@
---
description: Require test pattern, pattern metadata, and test preset for new patterns
alwaysApply: true
---
# Pattern workflow requirements
1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`.
2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there.
3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern.

View File

@@ -1,18 +0,0 @@
---
description: Fix only the issue or task the user gave; no refactors unless requested
alwaysApply: true
---
# Scoped fixes (no overscoping)
1. **Change only what is needed** to satisfy the users *current* request (bug, error, feature, or explicit follow-up). Prefer the smallest diff that fixes it.
2. **Refactors:** Do **not** refactor (restructure, rename, extract functions, change abstractions, or “make it nicer”) **unless the user explicitly asked for a refactor**. A bug fix may touch nearby lines only as much as required to correct the bug.
3. **Do not** rename, reformat, or “clean up” unrelated code; do not add extra error handling, logging, or features you were not asked for.
4. **Related issues:** If you spot other problems (missing functions, wrong types elsewhere, style), you may **mention them in prose** — do **not** fix them unless the user explicitly asks.
5. **Tests and docs:** Add or change tests or documentation **only** when the user asked for them or they are strictly required to verify the requested fix.
6. **Multiple distinct fixes:** If the user reported one error (e.g. a single `TypeError`), fix **that** cause first. Offer to tackle follow-ups separately rather than bundling.

View File

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

View File

@@ -1,16 +0,0 @@
---
description: enforce strict user-scoped changes only
alwaysApply: true
---
# Strict User Scope
1. Only implement exactly what the user asked for in the current message.
2. Do not add extra refactors, cleanups, renames, architecture changes, or behavioural changes unless the user explicitly asked for them.
3. If a potential improvement is noticed, mention it briefly and ask before changing code.
4. For revert/undo requests, perform the narrowest possible revert and do not modify anything else.
5. Keep edits minimal and local to the requested area.

7
.gitignore vendored
View File

@@ -1,7 +1,5 @@
# Python
__pycache__/
# led-driver/src is MicroPython source — never keep host __pycache__ there (see .cursor/rules/led-driver.mdc)
led-driver/src/__pycache__/
*.py[cod]
*$py.class
*.so
@@ -25,10 +23,7 @@ ENV/
Thumbs.db
# Project specific
docs/.help-print.html
settings.json
*.log
*.db
*.sqlite
.pytest_cache/
.ropeproject/

6
.gitmodules vendored
View File

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

View File

@@ -12,11 +12,8 @@ watchfiles = "*"
requests = "*"
selenium = "*"
adafruit-ampy = "*"
microdot = "*"
websockets = "*"
[dev-packages]
pytest = "*"
[requires]
python_version = "3.12"
@@ -24,7 +21,4 @@ python_version = "3.12"
[scripts]
web = "python /home/pi/led-controller/tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests"
install = "pipenv install"
run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
help-pdf = "sh scripts/build_help_pdf.sh"
install = "pipenv install"

873
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,2 @@
# led-controller
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices. Outbound paths include:
- **Serial → ESP-NOW bridge**: JSON lines over UART to an ESP32 that forwards ESP-NOW frames (configure `serial_port` and baud in `settings.json` / Settings model).
- **Wi-Fi LED drivers**: TCP JSON lines (default port **8765** on the Pi; drivers discover the controller via **UDP 8766** broadcast).
## Run
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
- Start app: `pipenv run run` (override listen port with the **`PORT`** environment variable)
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
## UI modes
- **Run mode**: focused control view. Select zones/presets and apply profiles. Editing actions are hidden.
- **Edit mode**: management view. Shows **Zones**, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
## Profiles
- Applying a profile updates session scope and refreshes the active zone content.
- In **Run mode**, Profiles supports apply-only behaviour (no create/clone/delete).
- In **Edit mode**, Profiles supports create/clone/delete.
- Creating a profile always creates a populated `default` zone (starter presets).
- Optional **DJ zone** seeding creates:
- `dj` zone bound to device name `dj`
- starter DJ presets (rainbow, single colour, transition)
## Preset colours and palette linking
- In preset editor, selecting a colour picker value auto-adds it when the picker closes.
- Use **From Palette** to add a palette-linked preset colour.
- Linked colours are stored as palette references and shown with a `P` badge.
- When profile palette colours change, linked preset colours update across that profile.
## API docs
- Main API reference: `docs/API.md`
## Driver pattern modules
Pattern **`.py`** sources live under **`led-driver/src/patterns`**. The Pi app resolves that path via `util.driver_patterns.driver_patterns_dir()`. If you deploy without that tree next to the app, set **`LED_CONTROLLER_PATTERNS_DIR`** to the directory that contains those files.

BIN
build_static/app.js.gz Normal file

Binary file not shown.

37
build_static/styles.css Normal file
View File

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

BIN
build_static/styles.css.gz Normal file

Binary file not shown.

2
clear-debug-log.sh Executable file
View File

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

View File

@@ -1 +0,0 @@
{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}}

View File

@@ -1 +1,17 @@
{"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 +1,12 @@
{"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 +1,54 @@
{"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "circle": {"n1": "Head Rate", "n2": "Max Length", "n3": "Tail Rate", "n4": "Min Length", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1\u2013255, higher = more changes)", "n2": "Density (0\u2013255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}}
{
"on": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 1
},
"off": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0
},
"rainbow": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0
},
"transition": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"chase": {
"n1": "Colour 1 Length",
"n2": "Colour 2 Length",
"n3": "Step 1",
"n4": "Step 2",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2
},
"pulse": {
"n1": "Attack",
"n2": "Hold",
"n3": "Decay",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"circle": {
"n1": "Head Rate",
"n2": "Max Length",
"n3": "Tail Rate",
"n4": "Min Length",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2
},
"blink": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
}
}

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1 +1,30 @@
{"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
}
}

27
db/tab.json Normal file
View File

@@ -0,0 +1,27 @@
{
"1": {
"name": "default",
"names": [
"a","b","c","d","e","f","g","h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"
],
"presets": [
[
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15"
]
]
}
}

View File

@@ -1 +0,0 @@
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41"}, "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, "presets_flat": []}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

53
dev.py Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
import subprocess
import serial
import sys
print(sys.argv)
# Extract port (first arg if it's not a command)
commands = ["src", "lib", "ls", "reset", "follow", "db"]
port = None
if len(sys.argv) > 1 and sys.argv[1] not in commands:
port = sys.argv[1]
for cmd in sys.argv[1:]:
print(cmd)
match cmd:
case "src":
if port:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":" ], cwd="src")
else:
print("Error: Port required for 'src' command")
case "lib":
if port:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":" ])
else:
print("Error: Port required for 'lib' command")
case "ls":
if port:
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":" ])
else:
print("Error: Port required for 'ls' command")
case "reset":
if port:
with serial.Serial(port, baudrate=115200) as ser:
ser.write(b'\x03\x03\x04')
else:
print("Error: Port required for 'reset' command")
case "follow":
if port:
with serial.Serial(port, baudrate=115200) as ser:
while True:
if ser.in_waiting > 0: # Check if there is data in the buffer
data = ser.readline().decode('utf-8').strip() # Read and decode the data
print(data)
else:
print("Error: Port required for 'follow' command")
case "db":
if port:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "db", ":" ])
else:
print("Error: Port required for 'db' command")

View File

@@ -1,358 +1,263 @@
# LED Controller API
# LED Driver ESPNow API Documentation
This document covers:
This document describes the ESPNow message format for controlling LED driver devices.
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
2. **LED driver JSON** — the compact **v1** message format. It is sent over the **serial → ESP-NOW bridge** to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields).
## Message Format
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
**Serial:** UART path and baud come from settings (defaults include `serial_port` such as `/dev/ttyS0` and `serial_baudrate`). **Wi-Fi drivers:** **UDP** on port **8766** is the **discovery** channel: each drivers JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known WiFi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings).
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
---
## UI behavior notes
The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (zone/preset selection and profile apply).
- **Edit mode**: shows editing/management controls (zones, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
Profiles are available in both modes, but behavior differs:
- **Run mode**: profile **apply** only.
- **Edit mode**: profile **create/clone/delete/apply**.
`POST /presets/send` is wired to the **Send Presets** UI action, which is exposed in Edit mode.
---
## Session and scoping
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
---
## Static pages and assets
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Main UI (`templates/index.html`) |
| GET | `/settings` | Settings page (`templates/settings.html`) |
| GET | `/favicon.ico` | Empty response (204) |
| GET | `/static/<path>` | Static files under `src/static/` |
---
## WebSocket: `/ws`
Connect to **`ws://<host>:<port>/ws`**.
- Send **JSON**: the object is forwarded through the **serial sender** (6-byte MAC prefix + payload to the ESP-NOW bridge). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used.
- Send **non-JSON text**: forwarded as raw bytes with the default address.
- On send failure, the server may reply with `{"error": "Send failed"}`.
Wi-Fi devices are not targeted by `/ws` directly; use **`POST /presets/send`**, device routes, or **`POST /patterns/<name>/send`** as appropriate.
---
## HTTP API by resource
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
### Settings — `/settings`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
| GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
### Devices — `/devices`
Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps to an object that always includes:
| Field | Description |
|-------|-------------|
| **`id`** | Same as the storage key (stable handle for URLs). |
| **`name`** | Shown in the UI and used in `select` keys. |
| **`type`** | `led` (only value today; extensible). |
| **`transport`** | `espnow` or `wifi`. |
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/devices` | Map of device id → device object. |
| GET | `/devices/<id>` | One device, 404 if missing. |
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
| DELETE | `/devices/<id>` | Remove device. |
### Profiles — `/profiles`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
| POST | `/profiles/<id>/clone` | Clone profile (zones, palettes, presets). Body may include `name`. |
| PUT | `/profiles/current` | Update the current profile (from session). |
| PUT | `/profiles/<id>` | Update profile by id. |
| DELETE | `/profiles/<id>` | Delete profile. |
### Presets — `/presets`
Scoped to **current profile** in session (see above).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
| DELETE | `/presets/<id>` | Delete preset. |
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
**`POST /presets/send` body:**
```json
{
"preset_ids": ["1", "2"],
"save": true,
"default": "1",
"destination_mac": "aabbccddeeff"
}
```
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
Stored preset records can include:
- `colors`: resolved hex colours for editor/display.
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
### Zones — `/zones`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/zones` | `zones` (map of zone id → zone object), `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
| GET | `/zones/current` | Current zone from cookie/session. |
| POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profiles zone list. |
| GET | `/zones/<id>` | Zone JSON. |
| PUT | `/zones/<id>` | Update zone. |
| DELETE | `/zones/<id>` | Delete zone; can delete `current` to remove the active zone; updates profile zone list. |
| POST | `/zones/<id>/set-current` | Sets `current_zone` cookie. |
| POST | `/zones/<id>/clone` | Clone zone into current profile. |
### Palettes — `/palettes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/palettes` | Map of id → colour list. |
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
| PUT | `/palettes/<id>` | Update colours (`name` ignored). |
| DELETE | `/palettes/<id>` | Delete palette. |
### Groups — `/groups`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/groups` | All groups. |
| GET | `/groups/<id>` | One group. |
| POST | `/groups` | Create; optional `name` and fields. |
| PUT | `/groups/<id>` | Update. |
| DELETE | `/groups/<id>` | Delete. |
### Scenes — `/scenes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scenes` | All scenes. |
| GET | `/scenes/<id>` | One scene. |
| POST | `/scenes` | Create (body JSON stored on scene). |
| PUT | `/scenes/<id>` | Update. |
| DELETE | `/scenes/<id>` | Delete. |
### Sequences — `/sequences`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/sequences` | All sequences. |
| GET | `/sequences/<id>` | One sequence. |
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
| PUT | `/sequences/<id>` | Update. |
| DELETE | `/sequences/<id>` | Delete. |
### Patterns — `/patterns`
Pattern metadata lives in **`db/pattern.json`**; driver source files live under **`led-driver/src/patterns/`**. Several routes expose a **runtime map** (metadata merged with on-disk `.py` names so new files appear in menus).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/patterns` | Runtime pattern map (object keyed by pattern id). |
| GET | `/patterns/definitions` | Same runtime map (intended for UI “definitions” clients). |
| GET | `/patterns/ota/manifest` | JSON `{"files":[{"name":"blink.py","url":"http://<Host>/patterns/ota/file/blink.py"},...]}` for OTA pulls. Requires **`Host`** header. |
| GET | `/patterns/ota/file/<name>` | Raw **`.py`** source for one driver pattern (`name` must be a safe filename, e.g. `rainbow.py`). |
| POST | `/patterns/<name>/send` | Push a **manifest** JSON line to **Wi-Fi** devices so they pull one pattern file over HTTP. Body may include **`device_id`** to target one device; otherwise all Wi-Fi devices with an **`address`** are tried. **`<name>`** may be with or without `.py`. |
| POST | `/patterns/upload` | Body JSON: **`name`**, **`code`**, optional **`overwrite`** (default true). Writes **`led-driver/src/patterns/<name>.py`**. |
| POST | `/patterns/driver` | Body JSON: **`name`** (identifier), **`code`**, optional metadata (`min_delay`, `max_delay`, `max_colors`, `n1``n8`, **`overwrite`**). Creates/updates both the **`.py`** file and **`db/pattern.json`** via the Pattern model. |
| GET | `/patterns/<id>` | One pattern record from the Pattern model (metadata only). |
| POST | `/patterns` | Create (`name`, optional `data`). |
| PUT | `/patterns/<id>` | Update. |
| DELETE | `/patterns/<id>` | Delete. |
**Devices — pattern OTA push**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/devices/<id>/patterns/push` | Wi-Fi only. Asks the driver at **`address`** to pull pattern files from this server. Optional body **`manifest`**: either a **URL string** pointing at a manifest JSON document, or a **manifest object** (same shape as in driver messages). If omitted, a default manifest is built from the request **`Host`** header. |
---
## LED driver message format (transport / ESP-NOW / Wi-Fi)
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge, and the same logical object can be sent as a **single JSON text message** to a Wi-Fi driver over the **WebSocket**.
### Top-level fields
All messages are JSON objects sent via ESPNow with the following structure:
```json
{
"v": "1",
"presets": { },
"select": { },
"save": true,
"default": "preset_id",
"b": 255
"presets": { ... },
"select": { ... }
}
```
- **`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).
### Version Field
### Preset object (wire / driver keys)
- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored.
On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
## Presets
| Key | Meaning | Notes |
|-----|---------|--------|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
| `c` | Colours | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
| `d` | Delay ms | Default 100 |
| `b` | Preset brightness | 0255; combined with global `b` on the device |
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
| `n1``n6` | Pattern parameters | See below |
Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings.
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` / …).
### Pattern-specific parameters (`n1``n6`)
#### Rainbow
- **`n1`**: Step increment on the colour wheel per update (default 1).
#### Pulse
- **`n1`**: Attack (fade in) ms
- **`n2`**: Hold ms
- **`n3`**: Decay (fade out) ms
- **`d`**: Off time between pulses ms
#### Transition
- **`d`**: Transition duration ms
#### Chase
- **`n1`**: LEDs with first colour
- **`n2`**: LEDs with second colour
- **`n3`**: Movement on even steps (may be negative)
- **`n4`**: Movement on odd steps (may be negative)
#### Circle
- **`n1`**: Head speed (LEDs/s)
- **`n2`**: Max length
- **`n3`**: Tail speed (LEDs/s)
- **`n4`**: Min length
### Select messages
### Preset Structure
```json
{
"select": {
"device_name": ["preset_id"],
"other_device": ["preset_id", 10]
"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
}
}
}
```
- One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
- Two elements: explicit **step** for sync.
### Preset Fields
### Beat and sync behavior
- **`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
- 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.
- **`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
### Example (compact preset map)
- **`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
- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1`
#### Pulse
- **`n1`**: Attack time in milliseconds (fade in)
- **`n2`**: Hold time in milliseconds (full brightness)
- **`n3`**: Decay time in milliseconds (fade out)
- **`delay`**: Delay time in milliseconds (off between pulses)
#### Transition
- **`delay`**: Transition duration in milliseconds
#### Chase
- **`n1`**: Number of LEDs with first color
- **`n2`**: Number of LEDs with second color
- **`n3`**: Movement amount on even steps (can be negative)
- **`n4`**: Movement amount on odd steps (can be negative)
#### Circle
- **`n1`**: Head movement rate (LEDs per second)
- **`n2`**: Maximum length
- **`n3`**: Tail movement rate (LEDs per second)
- **`n4`**: Minimum length
## Select Messages
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
### Select Format
```json
{
"select": {
"device_name": ["preset_name"],
"device_name2": ["preset_name2", step_value]
}
}
```
### Select Fields
- **`select`**: Object mapping device names to selection lists
- **Key**: Device name (as configured in device settings)
- **Value**: List with one or two elements:
- `["preset_name"]` - Select preset (uses default step behavior)
- `["preset_name", step]` - Select preset with explicit step value (for synchronization)
### Step Synchronization
The step value allows precise synchronization across multiple devices:
- **Without step**: `["preset_name"]`
- If switching to different preset: step resets to 0
- If selecting "off" pattern: step resets to 0
- If selecting same preset (beat): step is preserved, pattern restarts
- **With step**: `["preset_name", 10]`
- Explicitly sets step to the specified value
- Useful for synchronizing multiple devices to the same step
### Beat Functionality
Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator:
- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat
- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat
Example beat sequence:
```json
// Beat 1
{"select": {"device1": ["rainbow_preset"]}}
// Beat 2 (same preset = beat)
{"select": {"device1": ["rainbow_preset"]}}
// Beat 3
{"select": {"device1": ["rainbow_preset"]}}
```
## Synchronization
### Using "off" Pattern
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
```json
{
"select": {
"device1": ["off"],
"device2": ["off"]
}
}
```
After all devices are "off", switching to a pattern ensures they all start from step 0:
```json
{
"select": {
"device1": ["rainbow_preset"],
"device2": ["rainbow_preset"]
}
}
```
### Using Step Parameter
For precise synchronization, use the step parameter:
```json
{
"select": {
"device1": ["rainbow_preset", 10],
"device2": ["rainbow_preset", 10],
"device3": ["rainbow_preset", 10]
}
}
```
All devices will start at step 10 and advance together on subsequent beats.
## Complete Example
```json
{
"v": "1",
"save": true,
"presets": {
"1": {
"name": "Red blink",
"p": "blink",
"c": ["#FF0000"],
"d": 200,
"b": 255,
"a": true,
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
"red_blink": {
"pattern": "blink",
"colors": ["#FF0000"],
"delay": 200,
"brightness": 255,
"auto": true
},
"rainbow_manual": {
"pattern": "rainbow",
"delay": 100,
"n1": 2,
"auto": false
},
"pulse_slow": {
"pattern": "pulse",
"colors": ["#00FF00"],
"delay": 500,
"n1": 1000,
"n2": 500,
"n3": 1000,
"auto": false
}
},
"select": {
"living-room": ["1"]
"device1": ["red_blink"],
"device2": ["rainbow_manual", 0],
"device3": ["pulse_slow"]
}
}
```
---
## Message Processing
## Processing summary (driver)
1. **Version Check**: Messages with `v != "1"` are rejected
2. **Preset Processing**: Presets are created or updated (upsert behavior)
3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order
4. **Selection**: Devices select their assigned preset, optionally with step value
1. Reject if `v != "1"`.
2. Apply optional top-level **`b`** (global brightness).
3. For each entry in **`presets`**, normalize colours and upsert preset by id.
4. If this devices **`name`** appears in **`select`**, run selection (optional step).
5. If **`default`** is set, store startup preset id.
6. If **`save`** is set, persist presets.
## Best Practices
---
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 (HTTP)
## Error Handling
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
---
- Invalid version: Message is ignored
- Missing preset: Selection fails, device keeps current preset
- Invalid pattern: Selection fails, device keeps current preset
- Missing colors: Pattern uses default white color
- Invalid step: Step value is used as-is (may cause unexpected behavior)
## Notes
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).
- Colors are automatically converted from hex strings to RGB tuples
- Color order reordering happens automatically based on device settings
- Step counter wraps around (0-255 for rainbow, unbounded for others)
- Manual mode patterns stop after one step/cycle, waiting for next beat
- Auto mode patterns run continuously until changed

View File

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

View File

@@ -1,114 +0,0 @@
# LED controller — user guide
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **serial → ESP-NOW bridge** or **Wi-Fi** (TCP to drivers on the LAN), depending on each devices transport.
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
Figures below are **schematic** (layout and ideas), not pixel-perfect screenshots.
---
## Run mode and Edit mode
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
![Schematic: zone buttons on the left; Profiles, Zones, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg)
*The active zone is highlighted. Extra management buttons appear only in Edit mode.*
| Mode | Purpose |
|------|--------|
| **Run mode** | Day-to-day control: choose a zone, tap presets, apply profiles. Management buttons are hidden. |
| **Edit mode** | Full setup: zones, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles.
---
## Zones
- **Select a zone**: click its button in the top bar. The main area shows that zones preset strip and controls.
- **Edit mode — open zone settings**: **right-click** a zone button to change its name, **device IDs** (comma-separated), and which presets appear on the zone. Device identifiers are matched to each devices **name** when the app builds `select` messages for the driver.
- **Zones modal** (Edit mode): create new zones from the header **Zones** button. New zones need a name and device ID list (defaults to `1` if you leave a simple placeholder).
- **Brightness slider** (per zone): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link.
---
## Presets on the zone strip
- **Run and Edit mode**: click the **main part** of a preset tile to **select** that preset on all devices assigned to the current zone (same logical action as a `select` in the driver API).
- **Edit mode only**:
- **Edit** beside a tile opens the preset editor for that preset, scoped to the current zone (so you can **Remove from zone** without deleting the preset from the profile).
- **Drag and drop** tiles to reorder them; order is saved for that zone.
![Schematic: zone title, brightness slider, and a row of preset tiles; Edit mode adds an Edit control and drag handles for reordering.](images/help/zone-preset-strip.svg)
*The slider controls global brightness for the zones devices. Click the coloured area of a tile to select that preset.*
The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add** new presets, **Edit**, **Send** (push definition over the transport), and **Delete** (removes the preset from the profile entirely).
---
## Preset editor
- **Pattern**: chosen from the dropdown; optional **n1n8** fields depend on the pattern (see **Pattern-specific parameters** in [API.md](API.md)).
- **Colours**: choosing a value in the colour picker **adds** a swatch when the picker closes. Swatches can be **reordered** by dragging. Changing a swatch with the picker **clears** palette linkage for that slot.
- **From Palette**: inserts a colour **linked** to the current profiles palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
- **Brightness (0255)** and **Delay (ms)**: stored on the preset and sent with the compact preset payload.
- **Try**: sends the current form values to devices on the **current zone**, then selects that preset — **without** `save` on the device (good for auditioning).
- **Default**: updates the zones **default preset** and sends a **default** hint for those devices; it does not force the same live selection behaviour as clicking a tile.
- **Save & Send**: writes the preset to the server, then pushes definitions with **save** so devices may persist them. It does **not** auto-select the preset on devices (use the strip or **Try** if you want that).
- **Remove from zone** (when you opened the editor from a zone): removes the preset from **this zones list only**; the preset remains in the profile for other zones.
![Schematic: preset editor with name, pattern, colour swatches (one with a P badge for palette-linked), and action buttons.](images/help/preset-editor.svg)
*Try previews without persisting on the device; **Save & Send** stores the preset and pushes definitions with save.*
---
## Profiles
- **Apply**: sets the **current profile** in your session. Zones and presets you see are scoped to that profile.
- **Edit mode — Create**: new profiles always get a populated **default** zone. Optionally tick **DJ zone** to also create a `dj` zone (device name `dj`) with starter DJ-oriented presets.
- **Clone** / **Delete**: available in Edit mode from the profile list.
---
## Send Presets (Edit mode)
**Send Presets** walks **every zone** in the **current profile**, collects each zones preset IDs, and calls **`POST /presets/send`** per zone (including each zones **default** preset when set). Use this to bulk-push definitions to hardware after editing, without clicking **Send** on every preset individually.
---
## Patterns
The **Patterns** dialog (Edit mode) lists pattern names and typical **delay** ranges from the pattern definitions. Choosing a pattern still happens inside the preset editor.
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge/serial path you configure for preset traffic.
---
## Colour palette
**Colour Palette** (Edit mode) edits the **current profiles** palette swatches. Those colours are reused by **From Palette** in the preset editor and stay in sync while the **P** link remains.
![Schematic: palette modal with a row of swatches for the current profile.](images/help/colour-palette.svg)
*Add or change swatches here; linked preset colours update automatically.*
---
## Mobile layout
On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Zones, Presets, Help, mode toggle, etc.).
![Schematic: narrow layout with Menu and the same header actions in a dropdown.](images/help/mobile-menu.svg)
*Preset tiles behave the same once a zone is selected.*
---
## Further reading
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys, pattern **manifest**).
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour.

Binary file not shown.

View File

@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 140" width="480" height="140">
<title>Colour Palette modal (concept)</title>
<rect width="480" height="140" fill="#2a2a2a" stroke="#555" stroke-width="1" rx="6"/>
<text x="20" y="28" fill="#fff" font-family="sans-serif" font-size="15" font-weight="600">Colour Palette</text>
<text x="20" y="48" fill="#888" font-family="sans-serif" font-size="10">Profile: current profile name</text>
<rect x="20" y="58" width="44" height="44" rx="4" fill="#e53935" stroke="#333"/>
<rect x="72" y="58" width="44" height="44" rx="4" fill="#fdd835" stroke="#333"/>
<rect x="124" y="58" width="44" height="44" rx="4" fill="#43a047" stroke="#333"/>
<rect x="176" y="58" width="44" height="44" rx="4" fill="#1e88e5" stroke="#333"/>
<rect x="228" y="58" width="44" height="44" rx="4" fill="#8e24aa" stroke="#333"/>
<rect x="280" y="70" width="36" height="28" rx="3" fill="#1a1a1a" stroke="#666"/>
<text x="288" y="88" fill="#ccc" font-family="sans-serif" font-size="10">+</text>
<text x="20" y="122" fill="#aaa" font-family="sans-serif" font-size="10">Swatches belong to the profile; preset editor uses them via From Palette.</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 108" width="820" height="108">
<title>Header: tab buttons and action bar</title>
<rect width="820" height="108" fill="#1a1a1a"/>
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Tabs</text>
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
<text x="108" y="63" fill="#fff" font-family="sans-serif" font-size="13">lounge</text>
<rect x="192" y="40" width="56" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="204" y="63" fill="#ccc" font-family="sans-serif" font-size="13">dj</text>
<text x="380" y="28" fill="#888" font-family="sans-serif" font-size="11">Actions (Edit mode)</text>
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Tabs</text>
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="598" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Patterns</text>
<rect x="670" y="40" width="134" height="30" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
<text x="688" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Run mode</text>
<text x="16" y="98" fill="#aaa" font-family="sans-serif" font-size="10">Active tab highlighted. Mode button shows the mode you switch to next.</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,26 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="t">
<title id="t">Narrow screen: Menu aggregates header actions</title>
<rect width="300" height="340" fill="#2e2e2e"/>
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="22" y="30" fill="#eee" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12">Menu <20></text>
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="86" y="30" fill="#ccc" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
<text x="142" y="30" fill="#fff" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
<text x="24" y="84" fill="#888" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
<g font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12" fill="#e8e8e8">
<text x="24" y="108">Run mode</text>
<text x="24" y="132">Profiles</text>
<text x="24" y="156">Tabs</text>
<text x="24" y="180">Presets</text>
<text x="24" y="204">Help</text>
</g>
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
<text x="24" y="268" fill="#aaa" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Content area  presets as on desktop</text>
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="36" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="124" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,31 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 400" width="520" height="400">
<title>Preset editor modal (simplified)</title>
<rect width="520" height="400" fill="#1e1e1e"/>
<rect x="40" y="28" width="440" height="344" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="58" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="17" font-weight="600">Preset</text>
<text x="60" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Name</text>
<rect x="60" y="92" width="200" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="72" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">evening glow</text>
<text x="280" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Pattern</text>
<rect x="280" y="92" width="160" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="292" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">pulse</text>
<text x="60" y="148" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Colours</text>
<rect x="60" y="156" width="48" height="48" rx="4" fill="#7e57c2" stroke="#333" stroke-width="1"/>
<circle cx="66" cy="162" r="8" fill="#3f51b5" stroke="#fff" stroke-width="1"/>
<text x="63" y="166" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="7" font-weight="700">P</text>
<rect x="116" y="156" width="48" height="48" rx="4" fill="#26a69a" stroke="#333" stroke-width="1"/>
<text x="176" y="184" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">P = palette-linked</text>
<text x="60" y="232" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Brightness, delay, n1-n8</text>
<rect x="60" y="238" width="120" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="68" y="254" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">0-255</text>
<text x="60" y="290" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">Actions</text>
<rect x="60" y="298" width="44" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="72" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Try</text>
<rect x="112" y="298" width="56" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="120" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Default</text>
<rect x="176" y="298" width="88" height="26" rx="3" fill="#3d5a80" stroke="#5a7ab8" stroke-width="1"/>
<text x="188" y="315" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Save+Send</text>
<rect x="272" y="298" width="48" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="284" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Close</text>
<text x="60" y="352" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="9">Try: preview without device save. Save+Send: store and push with save.</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,35 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 220" width="800" height="220">
<title>Main area: brightness and preset tiles</title>
<defs>
<linearGradient id="rg1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#ffd54f"/><stop offset="100%" style="stop-color:#fff8e1"/>
</linearGradient>
<linearGradient id="rg2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#e53935"/><stop offset="33%" style="stop-color:#fdd835"/>
<stop offset="66%" style="stop-color:#43a047"/><stop offset="100%" style="stop-color:#1e88e5"/>
</linearGradient>
<linearGradient id="rg3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00897b"/><stop offset="100%" style="stop-color:#4db6ac"/>
</linearGradient>
</defs>
<rect width="800" height="220" fill="#2e2e2e"/>
<text x="20" y="32" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">lounge</text>
<text x="20" y="56" fill="#aaa" font-family="sans-serif" font-size="11">Brightness (global)</text>
<rect x="20" y="64" width="320" height="8" rx="4" fill="#444"/>
<rect x="20" y="64" width="200" height="8" rx="4" fill="#6a9ee2"/>
<circle cx="220" cy="68" r="10" fill="#ccc" stroke="#333" stroke-width="1"/>
<text x="360" y="74" fill="#888" font-family="sans-serif" font-size="11">drag to adjust</text>
<text x="20" y="108" fill="#aaa" font-family="sans-serif" font-size="11">Click tile body to select on tab devices</text>
<rect x="20" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
<rect x="28" y="128" width="184" height="36" rx="4" fill="url(#rg1)"/>
<text x="32" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">warm white</text>
<rect x="232" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#6a9ee2" stroke-width="2"/>
<rect x="240" y="128" width="184" height="36" rx="4" fill="url(#rg2)"/>
<text x="244" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">rainbow</text>
<rect x="444" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
<rect x="452" y="128" width="184" height="36" rx="4" fill="url(#rg3)"/>
<text x="456" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">chase</text>
<rect x="656" y="130" width="56" height="48" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="670" y="158" fill="#ddd" font-family="sans-serif" font-size="11">Edit</text>
<text x="656" y="198" fill="#888" font-family="sans-serif" font-size="10">Edit mode: drag tiles to reorder</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

View File

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

View File

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

244
flash.sh Executable file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env sh
set -eu
# Environment variables:
# PORT - serial port (default: /dev/ttyUSB0)
# BAUD - baud rate (default: 460800)
# FIRMWARE - local path to firmware .bin
# FW_URL - URL to download firmware if FIRMWARE not provided or missing
PORT=${PORT:-}
BAUD=${BAUD:-460800}
CHIP=${CHIP:-esp32} # esp32 | esp32c3
# Map chip-specific settings
ESPT_CHIP="$CHIP"
FLASH_OFFSET=0x1000
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
BOARD_ID="ESP32_GENERIC"
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
case "$CHIP" in
esp32c3)
ESPT_CHIP="esp32c3"
FLASH_OFFSET=0x0
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32C3/"
BOARD_ID="ESP32_GENERIC_C3"
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
;;
esp32)
ESPT_CHIP="esp32"
FLASH_OFFSET=0x1000
DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/"
BOARD_ID="ESP32_GENERIC"
BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/"
;;
*)
echo "Unsupported CHIP: $CHIP (supported: esp32, esp32c3)" >&2
exit 1
;;
esac
# Download-only mode: fetch the appropriate firmware and exit
if [ -n "${DOWNLOAD_ONLY:-}" ]; then
# Prefer resolving latest if nothing provided
if [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
LATEST=1
fi
if ! resolve_firmware; then
echo "Failed to resolve firmware for CHIP=$CHIP" >&2
exit 1
fi
echo "$FIRMWARE"
exit 0
fi
# Helper: resolve the latest firmware URL for a given board pattern with multiple fallbacks
resolve_latest_url() {
board_pattern="$1" # e.g., ESP32_GENERIC_C3-.*\.bin
# Candidate pages to try in order
pages="${BOARD_PAGE} ${DOWNLOAD_PAGE:-$DEFAULT_DOWNLOAD_PAGE} https://micropython.org/download/ https://micropython.org/resources/firmware/"
for page in $pages; do
echo "Trying to resolve latest from $page" >&2
html=$(curl -fsSL -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' -e 'https://micropython.org/download/' "$page" || true)
[ -z "$html" ] && continue
# Prefer matching the board pattern
url=$(printf "%s" "$html" \
| sed -n 's/.*href=\"\([^\"]*\.bin\)\".*/\1/p' \
| grep -E "$board_pattern" \
| head -n1)
if [ -n "$url" ]; then
case "$url" in
http*) echo "$url"; return 0 ;;
/*) echo "https://micropython.org$url"; return 0 ;;
*) echo "$page$url"; return 0 ;;
esac
fi
done
return 1
}
# If LATEST is set and neither FIRMWARE nor FW_URL are provided, auto-detect latest URL
if [ -n "${LATEST:-}" ] && [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then
# Default board identifiers for each chip
case "$CHIP" in
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
esp32) BOARD_ID="ESP32_GENERIC" ;;
*) BOARD_ID="ESP32_GENERIC" ;;
esac
pattern="${BOARD_ID}-.*\\.bin"
echo "Resolving latest firmware for $BOARD_ID"
if FW_URL=$(resolve_latest_url "$pattern"); then
export FW_URL
echo "Latest firmware resolved to: $FW_URL"
else
echo "Failed to resolve latest firmware for pattern $pattern" >&2
exit 1
fi
fi
# Resolve firmware path, downloading if needed
resolve_firmware() {
if [ -z "${FIRMWARE:-}" ]; then
if [ -n "${FW_URL:-}" ] || [ -n "${LATEST:-}" ]; then
# If FW_URL still unset, resolve latest using board-specific pattern
if [ -z "${FW_URL:-}" ]; then
case "$CHIP" in
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
esp32) BOARD_ID="ESP32_GENERIC" ;;
*) BOARD_ID="ESP32_GENERIC" ;;
esac
pattern="${BOARD_ID}-.*\\.bin"
echo "Resolving latest firmware for $BOARD_ID"
if ! FW_URL=$(resolve_latest_url "$pattern"); then
echo "Failed to resolve latest firmware for pattern $pattern" >&2
exit 1
fi
fi
mkdir -p .cache
FIRMWARE=".cache/$(basename "$FW_URL")"
if [ ! -f "$FIRMWARE" ]; then
echo "Downloading firmware from $FW_URL to $FIRMWARE"
curl -L --fail -o "$FIRMWARE" "$FW_URL"
else
echo "Firmware already downloaded at $FIRMWARE"
fi
else
# Default fallback: fetch latest using board-specific pattern
case "$CHIP" in
esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;;
esp32) BOARD_ID="ESP32_GENERIC" ;;
*) BOARD_ID="ESP32_GENERIC" ;;
esac
pattern="${BOARD_ID}-.*\\.bin"
echo "No FIRMWARE or FW_URL specified. Auto-fetching latest for $BOARD_ID"
if ! FW_URL=$(resolve_latest_url "$pattern"); then
echo "Failed to resolve latest firmware for pattern $pattern" >&2
exit 1
fi
mkdir -p .cache
FIRMWARE=".cache/$(basename "$FW_URL")"
if [ ! -f "$FIRMWARE" ]; then
echo "Downloading firmware from $FW_URL to $FIRMWARE"
curl -L --fail -o "$FIRMWARE" "$FW_URL"
else
echo "Firmware already downloaded at $FIRMWARE"
fi
fi
else
if [ ! -f "$FIRMWARE" ]; then
if [ -n "${FW_URL:-}" ]; then
mkdir -p "$(dirname "$FIRMWARE")"
echo "Firmware not found at $FIRMWARE. Downloading from $FW_URL"
curl -L --fail -o "$FIRMWARE" "$FW_URL"
else
echo "Firmware file not found: $FIRMWARE. Provide FW_URL to download automatically." >&2
exit 1
fi
fi
fi
}
# Auto-detect PORT if not specified
if [ -z "$PORT" ]; then
candidates="$(ls /dev/tty/ACM* /dev/tty/USB* 2>/dev/null || true)"
# Some systems expose without /dev/tty/ prefix patterns; try common Linux paths
[ -z "$candidates" ] && candidates="$(ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true)"
# Prefer ACM (often for C3) then USB
PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyACM[0-9]+" | head -n1 || true)
[ -z "$PORT" ] && PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyUSB[0-9]+" | head -n1 || true)
if [ -z "$PORT" ]; then
echo "No serial port detected. Connect the board and set PORT=/dev/ttyACM0 (or /dev/ttyUSB0)." >&2
exit 1
fi
echo "Auto-detected PORT=$PORT"
fi
# Preflight: ensure port exists
if [ ! -e "$PORT" ]; then
echo "Port $PORT does not exist. Detected candidates:" >&2
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true
exit 1
fi
ESPL="python -m esptool"
detect_chip() {
# Try to detect actual connected chip using esptool and override if needed
out=$($ESPL --port "$PORT" --baud "$BAUD" chip_id 2>&1 || true)
case "$out" in
*"ESP32-C3"*) DETECTED_CHIP=esp32c3 ;;
*"ESP32"*) DETECTED_CHIP=esp32 ;;
*) DETECTED_CHIP="" ;;
esac
if [ -n "$DETECTED_CHIP" ] && [ "$DETECTED_CHIP" != "$ESPT_CHIP" ]; then
echo "Detected chip $DETECTED_CHIP differs from requested $ESPT_CHIP. Using detected chip."
ESPT_CHIP="$DETECTED_CHIP"
case "$ESPT_CHIP" in
esp32c3) FLASH_OFFSET=0x0 ;;
esp32) FLASH_OFFSET=0x1000 ;;
esac
fi
}
detect_chip
# Now that we know the actual chip, resolve the correct firmware for it
resolve_firmware
# Validate firmware matches detected chip; if not, auto-correct by fetching the right image
EXPECTED_BOARD_ID="ESP32_GENERIC"
case "$ESPT_CHIP" in
esp32c3) EXPECTED_BOARD_ID="ESP32_GENERIC_C3" ;;
esp32) EXPECTED_BOARD_ID="ESP32_GENERIC" ;;
esac
FW_BASENAME="$(basename "$FIRMWARE")"
case "$FW_BASENAME" in
${EXPECTED_BOARD_ID}-*.bin) : ;; # ok
*)
echo "Firmware $FW_BASENAME does not match detected chip ($ESPT_CHIP). Fetching correct image for $EXPECTED_BOARD_ID..."
pattern="${EXPECTED_BOARD_ID}-.*\\.bin"
if ! FW_URL=$(resolve_latest_url "$pattern"); then
echo "Failed to resolve a firmware matching $EXPECTED_BOARD_ID" >&2
exit 1
fi
mkdir -p .cache
FIRMWARE=".cache/$(basename "$FW_URL")"
if [ ! -f "$FIRMWARE" ]; then
echo "Downloading firmware from $FW_URL to $FIRMWARE"
curl -L --fail -o "$FIRMWARE" "$FW_URL"
else
echo "Firmware already downloaded at $FIRMWARE"
fi
;;
esac
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" erase_flash
echo "Writing firmware $FIRMWARE to $FLASH_OFFSET..."
$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" write_flash -z "$FLASH_OFFSET" "$FIRMWARE"
echo "Done."

4
install.sh Executable file
View File

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

Submodule led-driver deleted from 4575ef16ad

Submodule led-simulator deleted from 7ce56b64df

Submodule led-tool deleted from eee9327e15

23
msg.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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,3 +0,0 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_endpoints_pytest.py"]

173
run_web.py Normal file
View File

@@ -0,0 +1,173 @@
#!/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())

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env sh
# Build docs/help.pdf from docs/help.md.
# Requires: pandoc, chromium (headless print-to-PDF).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# HTML next to docs/help.md so relative image paths (e.g. images/help/*.svg) resolve.
HTML="$ROOT/docs/.help-print.html"
trap 'rm -f "$HTML"' EXIT
pandoc "$ROOT/docs/help.md" -s \
--css="$ROOT/scripts/help-pdf.css" \
--metadata title="LED controller — user guide" \
-o "$HTML"
chromium --headless --no-sandbox --disable-gpu \
--print-to-pdf="$ROOT/docs/help.pdf" \
"file://${HTML}"
echo "Wrote $ROOT/docs/help.pdf ($(wc -c < "$ROOT/docs/help.pdf") bytes)"

View File

@@ -1,96 +0,0 @@
/* Print stylesheet for docs/help.md → PDF (Chromium headless) */
@page {
margin: 18mm;
size: A4;
}
html {
font-size: 11pt;
line-height: 1.4;
}
body {
font-family: "DejaVu Sans", "Liberation Sans", Helvetica, Arial, sans-serif;
color: #222;
max-width: 100%;
}
h1 {
font-size: 1.45rem;
border-bottom: 2px solid #333;
padding-bottom: 0.25em;
margin-top: 0;
}
h2 {
font-size: 1.15rem;
margin-top: 1.25em;
page-break-after: avoid;
}
h3 {
font-size: 1.05rem;
margin-top: 1em;
page-break-after: avoid;
}
code {
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
font-size: 0.92em;
background: #f3f3f3;
padding: 0.1em 0.35em;
border-radius: 3px;
}
pre {
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
font-size: 0.88em;
background: #f5f5f5;
border: 1px solid #ddd;
padding: 0.65em 0.85em;
overflow-x: auto;
page-break-inside: avoid;
}
pre code {
background: none;
padding: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0.75em 0;
font-size: 0.95em;
page-break-inside: avoid;
}
th, td {
border: 1px solid #bbb;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
th {
background: #eee;
}
a {
color: #1a5276;
text-decoration: none;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 1.25em 0;
}
ul, ol {
padding-left: 1.35em;
}
li {
margin: 0.2em 0;
}
/* Images in docs/help.md */
img {
max-width: 100%;
height: auto;
page-break-inside: avoid;
border: 1px solid #ccc;
border-radius: 4px;
}
p.help-figure-caption {
font-size: 0.9em;
color: #555;
margin: 0.35em 0 1em 0;
line-height: 1.35;
}

View File

@@ -1,20 +0,0 @@
#!/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

@@ -1,17 +0,0 @@
[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

View File

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

View File

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

44
send_empty_json.py Normal file
View File

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

1
settings.json Normal file
View File

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

View File

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

View File

@@ -1,393 +0,0 @@
from microdot import Microdot
from models.device import (
Device,
derive_device_mac,
validate_device_transport,
validate_device_type,
)
from models.transport import get_current_sender
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
tcp_client_connected,
)
from util.driver_patterns import driver_patterns_dir
from util.espnow_message import build_message
import asyncio
import json
import os
import socket
from urllib.parse import quote
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
_IDENTIFY_PRESET_KEY = "__identify"
# Short-key payload: 10 Hz full cycle = 50 ms on + 50 ms off (driver ``blink`` toggles each ``d`` ms).
_IDENTIFY_DRIVER_PRESET = {
"p": "blink",
"c": ["#ff0000"],
"d": 50,
"b": 128,
"a": True,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
}
def _compact_v1_json(*, presets=None, select=None, save=False):
"""Single-line v1 object; compact so serial/ESP-NOW stays small."""
body = {"v": "1"}
if presets is not None:
body["presets"] = presets
if save:
body["save"] = True
if select is not None:
body["select"] = select
return json.dumps(body, separators=(",", ":"))
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
IDENTIFY_OFF_DELAY_S = 2.0
controller = Microdot()
devices = Device()
def _device_live_connected(dev_dict):
"""
Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
"""
tr = (dev_dict.get("transport") or "espnow").strip().lower()
if tr != "wifi":
return None
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
if not ip:
return False
return tcp_client_connected(ip)
def _device_json_with_live_status(dev_dict):
row = dict(dev_dict)
row["connected"] = _device_live_connected(dev_dict)
return row
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, timeout_s=10.0):
"""POST source to driver /patterns/upload?name=...&reload=...; return True on 2xx."""
if not isinstance(ip, str) or not ip.strip():
return False
if not isinstance(filename, str) or not filename:
return False
if not isinstance(code_text, str):
return False
name_q = quote(filename, safe="")
reload_q = "1" if reload_patterns else "0"
path = "/patterns/upload?name=%s&reload=%s" % (name_q, reload_q)
body = code_text.encode("utf-8")
req = (
"POST %s HTTP/1.1\r\n"
"Host: %s\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n"
"\r\n" % (path, ip, len(body))
).encode("utf-8") + body
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(timeout_s)
sock.connect((ip.strip(), 80))
sock.sendall(req)
data = b""
while True:
chunk = sock.recv(1024)
if not chunk:
break
data += chunk
except OSError:
return False
finally:
try:
sock.close()
except Exception:
pass
first_line = data.split(b"\r\n", 1)[0] if data else b""
return b" 2" in first_line
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
off_msg = build_message(select={name: ["off"]})
if transport == "wifi":
await send_json_line_to_ip(wifi_ip, off_msg)
else:
await sender.send(off_msg, addr=dev_id)
except Exception:
pass
@controller.get("")
async def list_devices(request):
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
devices_data = {}
for dev_id in devices.list():
d = devices.read(dev_id)
if d:
devices_data[dev_id] = _device_json_with_live_status(d)
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
async def get_device(request, id):
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
dev = devices.read(id)
if dev:
return json.dumps(_device_json_with_live_status(dev)), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@controller.post("")
async def create_device(request):
"""Create a new device."""
try:
data = request.json or {}
name = data.get("name", "").strip()
if not name:
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json",
}
try:
device_type = validate_device_type(data.get("type", "led"))
transport = validate_device_transport(data.get("transport", "espnow"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {
"Content-Type": "application/json",
}
address = data.get("address")
mac = data.get("mac")
if derive_device_mac(mac=mac, address=address, transport=transport) is None:
return json.dumps(
{
"error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address"
}
), 400, {"Content-Type": "application/json"}
default_pattern = data.get("default_pattern")
zl = data.get("zones")
if isinstance(zl, list):
zl = [str(t) for t in zl]
else:
zl = []
dev_id = devices.create(
name=name,
address=address,
mac=mac,
default_pattern=default_pattern,
zones=zl,
device_type=device_type,
transport=transport,
)
dev = devices.read(dev_id)
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
except ValueError as e:
msg = str(e)
code = 409 if "already exists" in msg.lower() else 400
return json.dumps({"error": msg}), code, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put("/<id>")
async def update_device(request, id):
"""Update a device."""
try:
raw = request.json or {}
data = dict(raw)
data.pop("id", None)
data.pop("addresses", None)
data.pop("connected", None)
if "name" in data:
n = (data.get("name") or "").strip()
if not n:
return json.dumps({"error": "name cannot be empty"}), 400, {
"Content-Type": "application/json",
}
data["name"] = n
if "type" in data:
data["type"] = validate_device_type(data.get("type"))
if "transport" in data:
data["transport"] = validate_device_transport(data.get("transport"))
if "zones" in data and isinstance(data["zones"], list):
data["zones"] = [str(t) for t in data["zones"]]
if devices.update(id, data):
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.delete("/<id>")
async def delete_device(request, id):
"""Delete a device."""
if devices.delete(id):
return (
json.dumps({"message": "Device deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@controller.post("/<id>/identify")
async def identify_device(request, id):
"""
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
this device name — same combined shape as profile sends the driver already accepts over TCP
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
name = str(dev.get("name") or "").strip()
if not name:
return json.dumps({"error": "Device must have a name to identify"}), 400, {
"Content-Type": "application/json",
}
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
else:
await sender.send(msg, addr=id)
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json",
}
@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
"""
Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
base_dir = driver_patterns_dir()
try:
names = sorted(os.listdir(base_dir))
except OSError as e:
return json.dumps({"error": str(e)}), 500, {
"Content-Type": "application/json",
}
files = [n for n in names if _safe_pattern_filename(n) and n != "__init__.py"]
if not files:
return json.dumps({"error": "No pattern files found"}), 404, {
"Content-Type": "application/json",
}
sent = []
failed = []
total = len(files)
for idx, filename in enumerate(files):
path = os.path.join(base_dir, filename)
try:
with open(path, "r") as f:
code = f.read()
except OSError:
failed.append(filename)
continue
reload_patterns = idx == (total - 1)
ok = _http_post_pattern_source(
wifi_ip,
filename,
code,
reload_patterns=reload_patterns,
timeout_s=10.0,
)
if ok:
sent.append(filename)
else:
failed.append(filename)
if not sent:
return json.dumps({"error": "Wi-Fi driver did not accept pattern uploads", "failed": failed}), 503, {
"Content-Type": "application/json",
}
return json.dumps({
"message": "Pattern files uploaded",
"sent_count": len(sent),
"sent": sent,
"failed": failed,
}), 200, {
"Content-Type": "application/json",
}

View File

@@ -1,189 +0,0 @@
import json
import os
import subprocess
import sys
from microdot import Microdot
from serial.tools import list_ports
controller = Microdot()
def _repo_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
def _led_cli_path() -> str:
return os.path.join(_repo_root(), "led-tool", "cli.py")
def _build_led_cli_command(port: str, payload: dict):
cmd = [sys.executable, _led_cli_path(), "--port", port]
flag_map = (
("name", "--name"),
("led_pin", "--pin"),
("num_leds", "--leds"),
("brightness", "--brightness"),
("transport", "--transport"),
("ssid", "--ssid"),
("password", "--wifi-password"),
("wifi_channel", "--wifi-channel"),
("default", "--default"),
)
for key, flag in flag_map:
value = payload.get(key)
if value is None:
continue
value_str = str(value).strip()
if value_str == "":
continue
cmd.extend([flag, value_str])
return cmd
def _run_led_cli_command(cmd, cli_path: str, timeout_s=180):
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_s,
cwd=os.path.dirname(cli_path),
)
except subprocess.TimeoutExpired:
return (
json.dumps({"error": "led-tool command timed out after 180 seconds"}),
504,
{"Content-Type": "application/json"},
)
except Exception as exc:
return (
json.dumps({"error": str(exc)}),
500,
{"Content-Type": "application/json"},
)
return (
json.dumps(
{
"ok": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"command": cmd,
}
),
200,
{"Content-Type": "application/json"},
)
def _extract_settings_from_stdout(stdout: str):
text = (stdout or "").strip()
if not text:
return None
try:
parsed = json.loads(text)
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
@controller.get("/ports")
async def list_serial_ports(request):
ports = []
for info in list_ports.comports():
ports.append(
{
"device": info.device,
"description": info.description,
"hwid": info.hwid,
}
)
return (
json.dumps(
{
"ports": ports,
"led_cli_exists": os.path.exists(_led_cli_path()),
}
),
200,
{"Content-Type": "application/json"},
)
@controller.post("/settings")
async def apply_settings(request):
data = request.json or {}
port = str(data.get("port") or "").strip()
if not port:
return (
json.dumps({"error": "port is required"}),
400,
{"Content-Type": "application/json"},
)
cli_path = _led_cli_path()
if not os.path.exists(cli_path):
return (
json.dumps({"error": "led-tool/cli.py not found"}),
500,
{"Content-Type": "application/json"},
)
cmd = _build_led_cli_command(port, data) + ["--follow"]
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
@controller.post("/reset")
@controller.post("/reset/")
async def reset_device(request):
data = request.json or {}
port = str(data.get("port") or "").strip()
if not port:
return (
json.dumps({"error": "port is required"}),
400,
{"Content-Type": "application/json"},
)
cli_path = _led_cli_path()
if not os.path.exists(cli_path):
return (
json.dumps({"error": "led-tool/cli.py not found"}),
500,
{"Content-Type": "application/json"},
)
cmd = [sys.executable, cli_path, "--port", port, "--reset", "--follow"]
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
@controller.get("/settings")
async def read_settings(request):
port = str(request.args.get("port") or "").strip()
if not port:
return (
json.dumps({"error": "port is required"}),
400,
{"Content-Type": "application/json"},
)
cli_path = _led_cli_path()
if not os.path.exists(cli_path):
return (
json.dumps({"error": "led-tool/cli.py not found"}),
500,
{"Content-Type": "application/json"},
)
cmd = [sys.executable, cli_path, "--port", port, "--show"]
body, status, headers = _run_led_cli_command(cmd, cli_path)
if status != 200:
return body, status, headers
data = json.loads(body)
data["settings"] = _extract_settings_from_stdout(data.get("stdout") or "")
return json.dumps(data), status, headers

View File

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

View File

@@ -1,113 +1,19 @@
from microdot import Microdot
from models.pattern import Pattern
from models.device import Device
from util.driver_patterns import (
driver_patterns_dir,
is_firmware_builtin_pattern_module,
normalize_pattern_py_filename,
)
import json
import re
import os
import socket
from urllib.parse import quote
import sys
controller = Microdot()
patterns = Pattern()
def _project_root():
"""Project root (parent of ``src/``). CWD is often ``src/`` when running ``main.py``."""
here = os.path.dirname(os.path.abspath(__file__))
return os.path.abspath(os.path.join(here, "..", ".."))
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
_PATTERN_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
def _normalize_pattern_key(raw):
"""Pattern id / module basename (no .py)."""
if not isinstance(raw, str):
return ""
s = raw.strip()
if s.lower().endswith(".py"):
s = s[:-3].strip()
return s
def _valid_pattern_key(key):
return bool(key and _PATTERN_KEY_RE.match(key))
def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, timeout_s=10.0):
"""POST source to driver /patterns/upload?name=...&reload=...; return True on 2xx."""
if not isinstance(ip, str) or not ip.strip():
return False
if not isinstance(filename, str) or not filename:
return False
if not isinstance(code_text, str):
return False
name_q = quote(filename, safe="")
reload_q = "1" if reload_patterns else "0"
path = "/patterns/upload?name=%s&reload=%s" % (name_q, reload_q)
body = code_text.encode("utf-8")
req = (
"POST %s HTTP/1.1\r\n"
"Host: %s\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n"
"\r\n" % (path, ip, len(body))
).encode("utf-8") + body
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(timeout_s)
sock.connect((ip.strip(), 80))
sock.sendall(req)
data = b""
while True:
chunk = sock.recv(1024)
if not chunk:
break
data += chunk
except OSError:
return False
finally:
try:
sock.close()
except Exception:
pass
first_line = data.split(b"\r\n", 1)[0] if data else b""
# Accept any 2xx status.
return b" 2" in first_line
def load_pattern_definitions():
"""Load pattern definitions from pattern.json file."""
try:
root = _project_root()
paths = [
os.path.join(root, "db", "pattern.json"),
os.path.join(root, "pattern.json"),
"db/pattern.json",
"pattern.json",
"/db/pattern.json",
]
# Try different paths for local development vs MicroPython
paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json']
for path in paths:
try:
with open(path, "r") as f:
with open(path, 'r') as f:
return json.load(f)
except OSError:
continue
@@ -116,333 +22,16 @@ def load_pattern_definitions():
print(f"Error loading pattern.json: {e}")
return {}
def load_driver_pattern_names():
"""List available pattern module names from led-driver/src/patterns."""
try:
names = []
for filename in os.listdir(driver_patterns_dir()):
if not _safe_pattern_filename(filename) or filename == "__init__.py":
continue
names.append(filename[:-3])
names.sort()
return names
except OSError:
return []
def build_runtime_pattern_map():
"""
Runtime pattern map for UI menus.
Keep pattern DB metadata as primary, then add any local driver pattern files
missing from the DB so new OTA files still appear in menus.
"""
definitions = load_pattern_definitions()
available = load_driver_pattern_names()
result = {}
for name, meta in definitions.items():
result[name] = dict(meta) if isinstance(meta, dict) else {}
for name in available:
if name not in result:
result[name] = {}
return result
@controller.get('/definitions')
async def get_pattern_definitions(request):
"""Get definitions for patterns currently available on the driver."""
definitions = build_runtime_pattern_map()
"""Get pattern definitions from pattern.json."""
definitions = load_pattern_definitions()
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
@controller.get('/ota/manifest')
async def ota_manifest(request):
"""Manifest of driver pattern source files for OTA pulls."""
base_dir = driver_patterns_dir()
host = request.headers.get("Host", "")
if not host:
return json.dumps({"error": "Missing Host header"}), 400, {
"Content-Type": "application/json"
}
try:
names = sorted(os.listdir(base_dir))
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
files = []
for name in names:
if not _safe_pattern_filename(name) or name == "__init__.py":
continue
files.append({
"name": name,
"url": "http://%s/patterns/ota/file/%s" % (host, name),
})
return json.dumps({"files": files}), 200, {"Content-Type": "application/json"}
@controller.get('/ota/file/<name>')
async def ota_pattern_file(request, name):
"""Serve one driver pattern source file for OTA pulls."""
fname = normalize_pattern_py_filename(name)
if not fname or not _safe_pattern_filename(fname) or fname == "__init__.py":
return json.dumps({"error": "Invalid filename"}), 400, {
"Content-Type": "application/json"
}
if is_firmware_builtin_pattern_module(fname):
return json.dumps(
{
"error": "on and off are built into the driver firmware; there is no module file to serve.",
}
), 400, {
"Content-Type": "application/json"
}
base = driver_patterns_dir()
path = os.path.join(base, fname)
try:
with open(path, "r") as f:
content = f.read()
except OSError:
return json.dumps(
{
"error": "Pattern file not found",
"path": path,
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
}
), 404, {
"Content-Type": "application/json"
}
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
@controller.post('/<name>/send')
async def send_pattern_to_device(request, name):
"""Push one pattern source file directly to Wi-Fi driver(s) over HTTP."""
if not isinstance(name, str):
return json.dumps({"error": "Invalid pattern name"}), 400, {
"Content-Type": "application/json"
}
filename = normalize_pattern_py_filename(name)
if not filename or not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "Invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
if is_firmware_builtin_pattern_module(filename):
return json.dumps(
{
"error": "on and off are built into the driver firmware; send does not apply.",
}
), 400, {
"Content-Type": "application/json"
}
devices = Device()
body = request.json or {}
requested_device_id = str(body.get("device_id") or "").strip()
base = driver_patterns_dir()
path = os.path.join(base, filename)
if not os.path.exists(path):
return json.dumps(
{
"error": "Pattern file not found",
"path": path,
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
}
), 404, {
"Content-Type": "application/json"
}
try:
with open(path, "r") as f:
source = f.read()
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
target_ids = []
if requested_device_id:
dev = devices.read(requested_device_id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json"
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json"
}
target_ids = [requested_device_id]
else:
for did in devices.list():
dev = devices.read(did) or {}
if (dev.get("transport") or "").lower() == "wifi":
target_ids.append(str(did))
if not target_ids:
return json.dumps({"error": "No Wi-Fi devices found"}), 404, {
"Content-Type": "application/json"
}
sent_ids = []
for did in target_ids:
dev = devices.read(did) or {}
ip = str(dev.get("address") or "").strip()
if not ip:
continue
ok = _http_post_pattern_source(ip, filename, source, reload_patterns=True, timeout_s=10.0)
if ok:
sent_ids.append(did)
if not sent_ids:
return json.dumps({"error": "No Wi-Fi drivers accepted pattern upload"}), 503, {
"Content-Type": "application/json"
}
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
"Content-Type": "application/json"
}
@controller.post('/upload')
async def upload_pattern_file(request):
"""
Upload a pattern source file to led-controller local storage.
Body JSON:
{
"name": "sparkle.py" | "sparkle",
"code": "class Sparkle: ...",
"overwrite": true | false # optional, default true
}
"""
data = request.json or {}
raw_name = data.get("name") or data.get("filename")
code = data.get("code")
overwrite = data.get("overwrite", True)
overwrite = bool(overwrite)
if not isinstance(raw_name, str) or not raw_name.strip():
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json"
}
filename = raw_name.strip()
if not filename.endswith(".py"):
filename += ".py"
if not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
if is_firmware_builtin_pattern_module(filename):
return json.dumps(
{"error": "on and off are built into the driver firmware; use a different pattern name."}
), 400, {
"Content-Type": "application/json"
}
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required"}), 400, {
"Content-Type": "application/json"
}
path = os.path.join(driver_patterns_dir(), filename)
exists = os.path.exists(path)
if exists and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
try:
with open(path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return json.dumps({
"message": "Pattern uploaded",
"name": filename,
"overwrote": bool(exists),
}), 201, {"Content-Type": "application/json"}
@controller.post('/driver')
async def create_driver_pattern(request):
"""
Create a driver pattern: save ``.py`` under led-driver/src/patterns and
metadata in db/pattern.json (Pattern model).
Body JSON:
name, code (required),
min_delay, max_delay, max_colors (optional numbers),
n1..n8 (optional string labels),
overwrite (optional, default true).
"""
data = request.json or {}
key = _normalize_pattern_key(data.get("name") or "")
if not _valid_pattern_key(key):
return json.dumps({
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
}), 400, {"Content-Type": "application/json"}
if is_firmware_builtin_pattern_module(key):
return json.dumps(
{"error": "on and off are built into the driver firmware; use a different pattern name."}
), 400, {
"Content-Type": "application/json"
}
code = data.get("code")
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, {
"Content-Type": "application/json"
}
overwrite = bool(data.get("overwrite", True))
filename = key + ".py"
py_path = os.path.join(driver_patterns_dir(), filename)
if os.path.exists(py_path) and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
meta = {}
for fld in ("min_delay", "max_delay", "max_colors"):
if fld not in data:
continue
try:
meta[fld] = int(data[fld])
except (TypeError, ValueError):
return json.dumps({"error": "%s must be an integer" % fld}), 400, {
"Content-Type": "application/json"
}
for i in range(1, 9):
nk = "n%d" % i
if nk not in data:
continue
lab = data[nk]
if lab is None:
continue
s = str(lab).strip()
if s:
meta[nk] = s
try:
with open(py_path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
if patterns.read(key):
patterns.update(key, meta)
else:
patterns.create(key, meta)
return json.dumps({
"message": "Pattern created",
"name": key,
"file": filename,
"metadata": patterns.read(key),
}), 201, {"Content-Type": "application/json"}
@controller.get('')
async def list_patterns(request):
"""List patterns for UI (DB metadata + local driver additions)."""
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
"""List all patterns."""
return json.dumps(patterns), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
@@ -458,23 +47,11 @@ async def get_pattern(request, id):
async def create_pattern(request):
"""Create a new pattern."""
try:
payload = request.json or {}
name = payload.get("name", "")
pattern_data = payload.get("data", {})
# IMPORTANT:
# `patterns.create()` stores `pattern_data` as the underlying dict value.
# If we then call `patterns.update(pattern_id, payload)` with the full
# request object, it may assign `payload["data"]` back onto that same
# dict object, creating a circular reference (json.dumps fails).
pattern_id = patterns.create(name, pattern_data)
# Only merge "extra" metadata fields (anything except name/data).
extra = dict(payload)
extra.pop("name", None)
extra.pop("data", None)
if extra:
patterns.update(pattern_id, extra)
data = request.json or {}
name = data.get("name", "")
pattern_id = patterns.create(name, data.get("data", {}))
if data:
patterns.update(pattern_id, data)
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,239 +1,25 @@
import asyncio
import errno
import gc
import json
import os
import signal
import socket
import threading
import traceback
import machine
from machine import Pin
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import Settings
import aioespnow
import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
import controllers.sequence as sequence
import controllers.zone as zone
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
import controllers.device as device_controller
import controllers.led_tool as led_tool_controller
from models.transport import get_sender, set_sender, get_current_sender
from models.device import Device, normalize_mac
from models import wifi_ws_clients as tcp_client_registry
from util.device_status_broadcaster import (
broadcast_device_tcp_snapshot_to,
broadcast_device_tcp_status,
register_device_status_ws,
unregister_device_status_ws,
)
_tcp_device_lock = threading.Lock()
DISCOVERY_UDP_PORT = 8766
def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None
) -> None:
with _tcp_device_lock:
try:
d = Device()
did, persisted = d.upsert_wifi_tcp_client(
device_name, peer_ip, mac, device_type=device_type
)
if did and persisted:
print(
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
)
except Exception as e:
print(f"UDP device registry failed: {e}")
traceback.print_exception(type(e), e, e.__traceback__)
async def _handle_udp_discovery(sock, udp_holder=None) -> None:
while True:
try:
data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
except asyncio.CancelledError:
raise
except OSError as e:
if udp_holder and udp_holder.get("closing"):
break
print(f"[UDP] recv failed: {e!r}")
continue
except Exception as e:
print(f"[UDP] recv failed: {e!r}")
continue
peer_ip = addr[0] if addr else ""
line = data.split(b"\n", 1)[0].strip()
if line:
try:
parsed = json.loads(line.decode("utf-8"))
if isinstance(parsed, dict):
dns = str(parsed.get("device_name") or "").strip()
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
"sta_mac"
)
device_type = parsed.get("type") or parsed.get("device_type")
if dns and normalize_mac(mac):
_register_udp_device_sync(dns, peer_ip, mac, device_type)
if str(parsed.get("v") or "") == "1":
tcp_client_registry.ensure_driver_connection(peer_ip)
except (UnicodeError, ValueError, TypeError):
pass
try:
await asyncio.get_running_loop().sock_sendto(sock, data, addr)
except Exception as e:
print(f"[UDP] echo send failed: {e!r}")
def _prime_wifi_outbound_driver_connections() -> None:
"""
For each WiFi device in the registry with a usable IPv4, start (or keep) the
outbound WebSocket task. The client loop reconnects automatically if the link
drops. Presets are not pushed automatically; use Send Presets / profile apply.
"""
n = 0
try:
dev = Device()
for mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
tcp_client_registry.ensure_driver_connection(ip)
n += 1
except Exception as e:
print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
return
if n:
print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")
def _ipv4_address(addr: str) -> str | None:
"""Return dotted IPv4 string or None (hostnames skipped for UDP nudge)."""
s = (addr or "").strip()
if not s:
return None
parts = s.split(".")
if len(parts) != 4:
return None
try:
nums = [int(p) for p in parts]
except ValueError:
return None
if not all(0 <= n <= 255 for n in nums):
return None
return s
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
"""
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
UDP discovery port so the device can announce itself and we can reconnect.
"""
try:
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
except (TypeError, ValueError):
interval = 10.0
if interval <= 0:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
loop = asyncio.get_running_loop()
try:
while True:
await asyncio.sleep(interval)
if udp_holder.get("closing"):
break
try:
dev = Device()
except Exception as e:
print(f"[hello] device list failed: {e!r}")
continue
for _mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
if tcp_client_registry.tcp_client_connected(ip):
continue
name = (doc.get("name") or "").strip()
mac = normalize_mac(doc.get("id") or _mac_key)
if not name or not mac:
continue
line = (
json.dumps(
{"m": "hello", "device_name": name, "mac": mac},
separators=(",", ":"),
)
+ "\n"
)
try:
await loop.sock_sendto(
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
)
except OSError as e:
print(f"[hello] UDP to {ip!r} failed: {e!r}")
finally:
try:
sock.close()
except OSError:
pass
async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
except (AttributeError, OSError):
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except (AttributeError, OSError):
pass
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
if udp_holder is not None:
udp_holder["sock"] = sock
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
try:
await _handle_udp_discovery(sock, udp_holder)
finally:
if udp_holder is not None:
udp_holder.pop("sock", None)
try:
sock.close()
except Exception:
pass
async def _send_bridge_wifi_channel(settings, sender):
"""Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
try:
ch = int(settings.get("wifi_channel", 6))
except (TypeError, ValueError):
ch = 6
ch = max(1, min(11, ch))
payload = json.dumps({"m": "bridge", "ch": ch}, separators=(",", ":"))
try:
await sender.send(payload, addr="ffffffffffff")
print(f"[startup] bridge Wi-Fi channel -> {ch}")
except Exception as e:
print(f"[startup] bridge channel message failed: {e}")
from models.espnow import ESPNow
from util.espnow_message import split_espnow_message
async def main(port=80):
@@ -241,9 +27,8 @@ async def main(port=80):
print(settings)
print("Starting")
# Initialize transport (serial to ESP32 bridge)
sender = get_sender(settings)
set_sender(sender)
# Initialize ESPNow singleton (config + peers)
esp = ESPNow()
app = Microdot()
@@ -258,7 +43,7 @@ async def main(port=80):
('/profiles', profile, 'profile'),
('/groups', group, 'group'),
('/sequences', sequence, 'sequence'),
('/zones', zone, 'zone'),
('/tabs', tab, 'tab'),
('/palettes', palette, 'palette'),
('/scenes', scene, 'scene'),
]
@@ -268,18 +53,13 @@ async def main(port=80):
app.mount(profile.controller, '/profiles')
app.mount(group.controller, '/groups')
app.mount(sequence.controller, '/sequences')
app.mount(zone.controller, '/zones')
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')
app.mount(device_controller.controller, '/devices')
app.mount(led_tool_controller.controller, '/led-tool')
tcp_client_registry.set_settings(settings)
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
# Serve index.html at root (cwd is src/ when run via pipenv run run)
# Serve index.html at root
@app.route('/')
def index(request):
"""Serve the main web UI."""
@@ -290,7 +70,7 @@ async def main(port=80):
def settings_page(request):
"""Serve the settings page."""
return send_file('templates/settings.html')
# Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico')
def favicon(request):
@@ -308,106 +88,59 @@ async def main(port=80):
@app.route('/ws')
@with_websocket
async def ws(request, ws):
await register_device_status_ws(ws)
await broadcast_device_tcp_snapshot_to(ws)
try:
while True:
data = await ws.receive()
print(data)
if data:
while True:
data = await ws.receive()
print(data)
if data:
# Debug: log incoming WebSocket data
try:
parsed = json.loads(data)
print("WS received JSON:", parsed)
except Exception:
print("WS received raw:", data)
# Forward JSON over ESPNow; split into multiple frames if > 250 bytes
try:
try:
parsed = json.loads(data)
print("WS received JSON:", parsed)
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await sender.send(payload, addr=addr)
except json.JSONDecodeError:
# Not JSON: send raw with default address
try:
await sender.send(data)
except Exception:
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
chunks = split_espnow_message(parsed)
except (json.JSONDecodeError, ValueError):
chunks = [data]
for i, chunk in enumerate(chunks):
if i > 0:
await asyncio.sleep_ms(100)
await esp.send(chunk)
except Exception:
try:
await ws.send(json.dumps({"error": "ESP-NOW send failed"}))
except Exception:
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
else:
break
finally:
await unregister_device_status_ws(ws)
pass
else:
break
# Touch Device singleton early so db/device.json exists before first UDP hello.
Device()
await _send_bridge_wifi_channel(settings, sender)
_prime_wifi_outbound_driver_connections()
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
udp_holder = {"closing": False}
loop = asyncio.get_running_loop()
#wdt = machine.WDT(timeout=10000)
#wdt.feed()
def _graceful_shutdown(*_args):
print("[server] shutting down...")
udp_holder["closing"] = True
u = udp_holder.get("sock")
if u is not None:
try:
u.close()
except OSError:
pass
tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None:
app.shutdown()
# Initialize heartbeat LED (XIAO ESP32S3 built-in LED on GPIO 21)
led = Pin(15, Pin.OUT)
shutdown_handlers_registered = False
try:
try:
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _graceful_shutdown)
shutdown_handlers_registered = True
except (NotImplementedError, RuntimeError):
pass
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_udp_discovery_server(udp_holder),
_periodic_wifi_driver_hello_loop(settings, udp_holder),
)
except OSError as e:
if e.errno == errno.EADDRINUSE:
print(
f"[server] bind failed (address already in use): {e!s}\n"
f"[server] HTTP is configured for port {port} (env PORT). "
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
)
raise
finally:
srv = getattr(app, "server", None)
if srv is not None:
try:
srv.close()
await srv.wait_closed()
except Exception:
pass
try:
app.server = None
except Exception:
pass
if shutdown_handlers_registered:
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.remove_signal_handler(sig)
except (NotImplementedError, OSError, ValueError):
pass
led_state = False
while True:
gc.collect()
for i in range(60):
#wdt.feed()
# Heartbeat: toggle LED every 500 ms
led.value(not led.value())
await asyncio.sleep_ms(500)
# cleanup before ending the application
if __name__ == "__main__":
import os
port = int(os.environ.get("PORT", 80))
asyncio.run(main(port=port))
asyncio.run(main())

View File

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

69
src/models/espnow.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

39
src/models/tab.py Normal file
View File

@@ -0,0 +1,39 @@
from models.model import Model
class Tab(Model):
def __init__(self):
super().__init__()
def create(self, name="", names=None, presets=None):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"names": names if names else [],
"presets": presets if presets else [],
"default_preset": None
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

View File

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

View File

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

View File

@@ -1,281 +0,0 @@
"""Outbound WebSocket clients to Wi-Fi LED drivers (firmware serves ``/ws`` on device)."""
from __future__ import annotations
import asyncio
import errno
import json
import traceback
import websockets
from websockets.exceptions import ConnectionClosed
_connections: dict[str, object] = {}
_send_locks: dict[str, asyncio.Lock] = {}
_tasks: dict[str, asyncio.Task] = {}
_unreachable_counts: dict[str, int] = {}
_settings = None
_tcp_status_broadcast = None
def set_settings(settings) -> None:
global _settings
_settings = settings
def set_tcp_status_broadcaster(coro) -> None:
global _tcp_status_broadcast
_tcp_status_broadcast = coro
def _schedule_status_broadcast(ip: str, connected: bool) -> None:
fn = _tcp_status_broadcast
if not fn:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
try:
loop.create_task(fn(ip, connected))
except Exception:
pass
def _benign_ws_connect_failure(exc: BaseException) -> bool:
"""True for common \"driver down / no route\" errors while dialling the WebSocket."""
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)):
return True
if isinstance(exc, ConnectionRefusedError):
return True
if not isinstance(exc, OSError):
return False
en = exc.errno
if en is None:
return False
codes = {errno.ECONNREFUSED, errno.ETIMEDOUT}
for name in ("EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EADDRNOTAVAIL"):
if hasattr(errno, name):
codes.add(getattr(errno, name))
return en in codes
def normalize_tcp_peer_ip(ip: str) -> str:
"""Match peer addresses to registry IPs (strip IPv4-mapped IPv6 prefix)."""
s = str(ip).strip()
if s.lower().startswith("::ffff:"):
s = s[7:]
return s
def _ws_open(ws) -> bool:
try:
return ws.close_code is None
except Exception:
return False
def prune_stale_tcp_writers() -> None:
"""Drop closed WebSocket entries (name kept for callers)."""
stale = [ip for ip, ws in list(_connections.items()) if not _ws_open(ws)]
for ip in stale:
_connections.pop(ip, None)
_schedule_status_broadcast(ip, False)
def _register_ws(ip: str, ws) -> None:
key = normalize_tcp_peer_ip(ip)
if not key:
return
_connections[key] = ws
_unreachable_counts.pop(key, None)
if key not in _send_locks:
_send_locks[key] = asyncio.Lock()
_schedule_status_broadcast(key, True)
print(f"[WS] driver connected {key!r}")
def unregister_tcp_writer(peer_ip: str, ws=None) -> str:
"""
Remove the WebSocket for peer_ip. If ``ws`` is given, only pop when it is still
the registered instance.
Returns ``removed``, ``noop``, or ``superseded`` (same contract as former TCP registry).
"""
if not peer_ip:
return "noop"
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return "noop"
current = _connections.get(key)
if ws is not None:
if current is None:
return "noop"
if current is not ws:
return "superseded"
had = key in _connections
if had:
_connections.pop(key, None)
_schedule_status_broadcast(key, False)
print(f"[WS] driver disconnected: {key}")
return "removed"
return "noop"
def list_connected_ips():
"""IPs with an active outbound WebSocket to the driver."""
prune_stale_tcp_writers()
return list(_connections.keys())
def tcp_client_connected(ip: str) -> bool:
"""True if the controller has an outbound WebSocket to this driver IP."""
prune_stale_tcp_writers()
key = normalize_tcp_peer_ip(ip)
return bool(key and key in _connections)
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
"""Send one JSON text frame (v1 line; trailing newline stripped for WebSocket)."""
ip = normalize_tcp_peer_ip(ip)
ws = _connections.get(ip)
if ws is None or not _ws_open(ws):
return False
text = json_str.rstrip("\n")
lock = _send_locks.setdefault(ip, asyncio.Lock())
try:
async with lock:
await ws.send(text)
return True
except Exception as exc:
print(f"[WS] send to {ip} failed: {exc}")
unregister_tcp_writer(ip, ws)
return False
async def _recv_forward_loop(ip: str, ws) -> None:
from models.transport import get_current_sender
sender = get_current_sender()
async for message in ws:
if isinstance(message, bytes):
try:
text = message.decode("utf-8")
except UnicodeDecodeError:
print(f"[WS] recv {ip} (non-UTF-8, {len(message)} bytes)")
continue
else:
text = message
text = text.strip()
if not text:
continue
print(f"[WS] recv {ip}: {text}")
if not sender:
continue
try:
parsed = json.loads(text)
except json.JSONDecodeError:
try:
await sender.send(text)
except Exception:
pass
continue
if isinstance(parsed, dict):
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else "{}"
try:
await sender.send(payload, addr=addr)
except Exception as e:
print(f"[WS] forward to bridge failed: {e}")
else:
try:
await sender.send(text)
except Exception:
pass
async def _driver_connection_loop(ip: str) -> None:
global _settings
if _settings is None:
return
port = int(_settings.get("wifi_driver_ws_port", 80))
path = str(_settings.get("wifi_driver_ws_path", "/ws"))
if not path.startswith("/"):
path = "/" + path
uri = f"ws://{ip}:{port}{path}"
retry_interval_s = 2.0
retry_window_s = 30.0
deadline = asyncio.get_running_loop().time() + retry_window_s
try:
while True:
now = asyncio.get_running_loop().time()
if now >= deadline:
print(
f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s; "
"stopping retries until next hello"
)
break
try:
print(f"[WS] connecting to {uri!r}")
async with websockets.connect(
uri,
ping_interval=20,
ping_timeout=15,
open_timeout=30,
) as ws:
_register_ws(ip, ws)
try:
await _recv_forward_loop(ip, ws)
finally:
unregister_tcp_writer(ip, ws)
except asyncio.CancelledError:
raise
except ConnectionClosed as e:
print(f"[WS] driver {ip} closed: {e}")
unregister_tcp_writer(ip, None)
except Exception as e:
if _benign_ws_connect_failure(e):
n = _unreachable_counts.get(ip, 0) + 1
_unreachable_counts[ip] = n
if n == 1 or (n % 30) == 0:
print(f"[WS] driver {ip} unreachable, retry in 2s: {e} (x{n})")
else:
print(f"[WS] driver {ip} session error: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
_unreachable_counts.pop(ip, None)
unregister_tcp_writer(ip, None)
await asyncio.sleep(retry_interval_s)
except asyncio.CancelledError:
unregister_tcp_writer(ip, None)
raise
finally:
_tasks.pop(ip, None)
def ensure_driver_connection(peer_ip: str) -> None:
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``."""
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return
t = _tasks.get(key)
if t is not None and not t.done():
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
_tasks[key] = loop.create_task(_driver_connection_loop(key))
def cancel_all_driver_tasks() -> None:
"""Signal shutdown: cancel outbound driver connection tasks."""
for _ip, t in list(_tasks.items()):
if not t.done():
t.cancel()
_tasks.clear()
for ip in list(_connections.keys()):
_schedule_status_broadcast(ip, False)
_connections.clear()
_send_locks.clear()
_unreachable_counts.clear()

View File

@@ -1,62 +0,0 @@
import os
import shutil
from models.model import Model
def _maybe_migrate_tab_json_to_zone():
"""One-time copy ``db/tab.json`` → ``db/zone.json`` when upgrading."""
try:
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
db_dir = os.path.join(base, "db")
zone_path = os.path.join(db_dir, "zone.json")
tab_path = os.path.join(db_dir, "tab.json")
if not os.path.exists(zone_path) and os.path.exists(tab_path):
shutil.copy2(tab_path, zone_path)
print("Migrated db/tab.json -> db/zone.json")
except OSError:
pass
class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab."""
def __init__(self):
if not getattr(Zone, "_migration_checked", False):
_maybe_migrate_tab_json_to_zone()
Zone._migration_checked = True
super().__init__()
def create(self, name="", names=None, presets=None):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"names": names if names else [],
"presets": presets if presets else [],
"default_preset": None,
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

39
src/p2p.py Normal file
View File

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

View File

@@ -2,23 +2,11 @@ import json
import os
import binascii
def _settings_path():
"""Path to settings.json in project root (writable without root)."""
try:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, "settings.json")
except Exception:
return "settings.json"
class Settings(dict):
SETTINGS_FILE = None # Set in __init__ from _settings_path()
SETTINGS_FILE = "/settings.json"
def __init__(self):
super().__init__()
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization
def generate_secret_key(self):
@@ -45,18 +33,6 @@ class Settings(dict):
self['session_secret_key'] = self.generate_secret_key()
# Save immediately when generating a new key
self.save()
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self:
self['wifi_channel'] = 6
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
if 'wifi_driver_ws_port' not in self:
self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws'
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0
def save(self):
try:

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,14 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
if (helpModal) {
helpModal.addEventListener('click', (event) => {
if (event.target === helpModal) {
helpModal.classList.remove('active');
}
});
}
// Mobile main menu: forward clicks to existing header buttons
if (mainMenuBtn && mainMenuDropdown) {
mainMenuBtn.addEventListener('click', () => {
@@ -35,6 +43,13 @@ document.addEventListener('DOMContentLoaded', () => {
mainMenuDropdown.classList.remove('open');
}
});
// Close menu when clicking outside
document.addEventListener('click', (event) => {
if (!mainMenuDropdown.contains(event.target) && event.target !== mainMenuBtn) {
mainMenuDropdown.classList.remove('open');
}
});
}
// Settings modal wiring (reusing existing settings endpoints).
@@ -60,12 +75,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (nameInput && data && typeof data === 'object') {
nameInput.value = data.device_name || 'led-controller';
}
const chInput = document.getElementById('wifi-channel-input');
if (chInput && data && typeof data === 'object') {
const ch = data.wifi_channel;
chInput.value =
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
}
} catch (error) {
console.error('Error loading device settings:', error);
}
@@ -112,6 +121,14 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
if (settingsModal) {
settingsModal.addEventListener('click', (event) => {
if (event.target === settingsModal) {
settingsModal.classList.remove('active');
}
});
}
const deviceForm = document.getElementById('device-form');
if (deviceForm) {
deviceForm.addEventListener('submit', async (e) => {
@@ -122,29 +139,15 @@ document.addEventListener('DOMContentLoaded', () => {
showSettingsMessage('Device name is required', 'error');
return;
}
const chRaw = document.getElementById('wifi-channel-input')
? document.getElementById('wifi-channel-input').value
: '6';
const wifiChannel = parseInt(chRaw, 10);
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
return;
}
try {
const response = await fetch('/settings/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_name: deviceName,
wifi_channel: wifiChannel,
}),
body: JSON.stringify({ device_name: deviceName }),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage(
'Device settings saved. They will apply on next restart where relevant.',
'success',
);
showSettingsMessage('Device name saved. It will be used on next restart.', 'success');
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
}

View File

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

View File

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

View File

@@ -3,381 +3,11 @@ document.addEventListener('DOMContentLoaded', () => {
const patternsModal = document.getElementById('patterns-modal');
const patternsCloseButton = document.getElementById('patterns-close-btn');
const patternsList = document.getElementById('patterns-list');
const patternAddButton = document.getElementById('pattern-add-btn');
const patternSendAllButton = document.getElementById('pattern-send-all-btn');
const patternEditorModal = document.getElementById('pattern-editor-modal');
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
const patternCreateBtn = document.getElementById('pattern-create-btn');
const patternCreateName = document.getElementById('pattern-create-name');
const patternCreateMinDelay = document.getElementById('pattern-create-min-delay');
const patternCreateMaxDelay = document.getElementById('pattern-create-max-delay');
const patternCreateMaxColors = document.getElementById('pattern-create-max-colors');
const patternCreateFile = document.getElementById('pattern-create-file');
const patternCreateCode = document.getElementById('pattern-create-code');
const patternCreateOverwrite = document.getElementById('pattern-create-overwrite');
const patternCreateN = [1, 2, 3, 4, 5, 6, 7, 8].map((i) =>
document.getElementById(`pattern-create-n${i}`),
);
const patternCreateNSection = document.getElementById('pattern-create-n-section');
const patternCreateNEmpty = document.getElementById('pattern-create-n-empty');
if (!patternsButton || !patternsModal || !patternsList) {
return;
}
const coercePresetInt = (v, def = 0) => {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
};
const getCurrentProfileId = async () => {
try {
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
if (!response.ok) {
return null;
}
const data = await response.json();
return data && (data.id || (data.profile && data.profile.id)) ? String(data.id || data.profile.id) : null;
} catch (_) {
return 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;
return String(preset.profile_id) === String(currentProfileId);
}),
);
};
const tabDeviceNamesFromSection = (section) => {
if (typeof window.parseTabDeviceNames === 'function') {
return window.parseTabDeviceNames(section);
}
const namesAttr = section && section.getAttribute('data-device-names');
return namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
};
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => {
const body = {
sequence,
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
delay_s: delayS,
};
const res = await fetch('/presets/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err && err.error) || res.statusText || 'Send failed');
}
return res.json().catch(() => ({}));
};
const nReadableStringFromMeta = (meta, key) => {
if (!meta || typeof meta !== 'object') {
return '';
}
const pm = meta.parameter_mappings;
if (pm && typeof pm === 'object' && typeof pm[key] === 'string') {
const s = pm[key].trim();
if (s) {
return s;
}
}
if (typeof meta[key] === 'string') {
return meta[key].trim();
}
return '';
};
const setPatternEditorNFields = (mode, data) => {
const meta = data && typeof data === 'object' ? data : {};
let visible = 0;
const grid = patternCreateNSection && patternCreateNSection.querySelector('.n-params-grid');
const h3 = patternCreateNSection && patternCreateNSection.querySelector('h3');
for (let i = 1; i <= 8; i += 1) {
const key = `n${i}`;
const labelEl = document.querySelector(`label[for="pattern-create-${key}"]`);
const inputEl = document.getElementById(`pattern-create-${key}`);
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
if (mode === 'create') {
if (labelEl) {
labelEl.textContent = `${key}:`;
labelEl.style.display = '';
}
if (inputEl) {
inputEl.value = '';
inputEl.placeholder = 'Readable name (optional)';
inputEl.removeAttribute('aria-label');
}
if (groupEl) {
groupEl.style.display = '';
}
continue;
}
const readable = nReadableStringFromMeta(meta, key);
const show = Boolean(readable);
if (labelEl) {
labelEl.textContent = '';
labelEl.style.display = 'none';
}
if (inputEl) {
inputEl.value = show ? readable : '';
inputEl.placeholder = '';
if (show) {
inputEl.setAttribute('aria-label', readable);
} else {
inputEl.removeAttribute('aria-label');
inputEl.value = '';
}
}
if (groupEl) {
groupEl.style.display = show ? '' : 'none';
}
if (show) {
visible += 1;
}
}
if (mode === 'create') {
if (patternCreateNEmpty) {
patternCreateNEmpty.style.display = 'none';
}
if (grid) {
grid.style.display = '';
}
if (h3) {
h3.style.display = '';
}
if (patternCreateNSection) {
patternCreateNSection.style.display = '';
}
return;
}
if (patternCreateNEmpty) {
patternCreateNEmpty.style.display = visible === 0 ? '' : 'none';
}
if (grid) {
grid.style.display = visible === 0 ? 'none' : '';
}
if (h3) {
h3.style.display = visible === 0 ? 'none' : '';
}
};
const readFileAsText = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('read failed'));
reader.readAsText(file);
});
const collectCreatePayload = async () => {
const name = patternCreateName ? patternCreateName.value.trim() : '';
if (!name) {
throw new Error('Pattern name is required.');
}
let code = '';
const fileInput = patternCreateFile && patternCreateFile.files && patternCreateFile.files[0];
if (fileInput) {
code = await readFileAsText(fileInput);
} else if (patternCreateCode && patternCreateCode.value.trim()) {
code = patternCreateCode.value;
}
if (!code.trim()) {
throw new Error('Choose a .py file or paste source code.');
}
const payload = {
name,
code,
min_delay: parseInt(patternCreateMinDelay && patternCreateMinDelay.value, 10) || 0,
max_delay: parseInt(patternCreateMaxDelay && patternCreateMaxDelay.value, 10) || 0,
max_colors: parseInt(patternCreateMaxColors && patternCreateMaxColors.value, 10) || 0,
overwrite: !!(patternCreateOverwrite && patternCreateOverwrite.checked),
};
patternCreateN.forEach((el, idx) => {
const key = `n${idx + 1}`;
if (el && el.value.trim()) {
payload[key] = el.value.trim();
}
});
return payload;
};
const resetCreateForm = () => {
if (patternCreateName) patternCreateName.value = '';
if (patternCreateFile) patternCreateFile.value = '';
if (patternCreateCode) patternCreateCode.value = '';
if (patternCreateMinDelay) patternCreateMinDelay.value = '10';
if (patternCreateMaxDelay) patternCreateMaxDelay.value = '10000';
if (patternCreateMaxColors) patternCreateMaxColors.value = '10';
patternCreateN.forEach((el) => {
if (el) el.value = '';
});
if (patternCreateOverwrite) patternCreateOverwrite.checked = true;
setPatternEditorNFields('create', {});
};
if (patternCreateBtn) {
patternCreateBtn.addEventListener('click', async () => {
try {
const payload = await collectCreatePayload();
const response = await fetch('/patterns/driver', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error((data && data.error) || 'Create failed');
}
alert(data.message || 'Pattern created.');
resetCreateForm();
if (patternEditorModal) {
patternEditorModal.classList.remove('active');
}
await loadPatterns();
} catch (e) {
console.error('Create pattern failed:', e);
alert(e.message || 'Failed to create pattern.');
}
});
}
/** on/off are implemented in driver firmware (presets.py), not as OTA ``.py`` files. */
const FIRMWARE_BUILTIN_PATTERNS = new Set(['on', 'off']);
const isFirmwareBuiltinPattern = (patternName) => {
const id = String(patternName || '')
.trim()
.replace(/\.py$/i, '')
.toLowerCase();
return FIRMWARE_BUILTIN_PATTERNS.has(id);
};
const sendPatternToDevices = async (patternName) => {
const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error((data && data.error) || 'Failed to send pattern');
}
const sentCount = data && typeof data.sent_count === 'number' ? data.sent_count : null;
if (sentCount === null) {
alert(`Sent "${patternName}" to devices.`);
} else {
alert(`Sent "${patternName}" to ${sentCount} device(s).`);
}
};
const loadPatternMetadata = async (patternName, fallbackData) => {
const raw = String(patternName || '').trim();
const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw;
try {
const response = await fetch('/patterns/definitions', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load pattern definitions');
}
const definitions = await response.json();
if (definitions && typeof definitions === 'object') {
if (definitions[raw]) {
return definitions[raw];
}
if (norm && definitions[norm]) {
return definitions[norm];
}
if (norm) {
const lower = norm.toLowerCase();
const matched = Object.keys(definitions).find(
(k) => String(k).toLowerCase() === lower,
);
if (matched) {
return definitions[matched];
}
}
}
} catch (error) {
console.error('Load pattern definitions failed:', error);
}
return fallbackData || {};
};
const loadPatternIntoEditor = async (patternName, fallbackData) => {
const data = await loadPatternMetadata(patternName, fallbackData);
if (patternCreateName) {
patternCreateName.value = patternName;
}
if (patternCreateMinDelay) {
patternCreateMinDelay.value =
data && data.min_delay !== undefined ? String(data.min_delay) : '10';
}
if (patternCreateMaxDelay) {
patternCreateMaxDelay.value =
data && data.max_delay !== undefined ? String(data.max_delay) : '10000';
}
if (patternCreateMaxColors) {
patternCreateMaxColors.value =
data && data.max_colors !== undefined ? String(data.max_colors) : '10';
}
setPatternEditorNFields('edit', data);
if (patternCreateOverwrite) {
patternCreateOverwrite.checked = true;
}
if (patternCreateFile) {
patternCreateFile.value = '';
}
try {
const raw = String(patternName || '').trim();
const fileSegment = /\.py$/i.test(raw) ? raw : `${raw}.py`;
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(fileSegment)}`, {
headers: { Accept: 'text/plain' },
});
if (!response.ok) {
throw new Error('Failed to load pattern file');
}
const source = await response.text();
if (patternCreateCode) {
patternCreateCode.value = source || '';
patternCreateCode.focus();
}
} catch (error) {
console.error('Load pattern source failed:', error);
alert('Could not load pattern source into editor.');
}
};
const renderPatterns = (patterns) => {
patternsList.innerHTML = '';
const entries = Object.entries(patterns || {});
@@ -395,46 +25,20 @@ document.addEventListener('DOMContentLoaded', () => {
const label = document.createElement('span');
label.textContent = patternName;
const details = document.createElement('span');
const minDelay = data && data.min_delay !== undefined ? data.min_delay : '-';
const maxDelay = data && data.max_delay !== undefined ? data.max_delay : '-';
details.textContent = `${minDelay}${maxDelay} ms`;
details.style.color = '#aaa';
details.style.fontSize = '0.85em';
row.appendChild(label);
if (isFirmwareBuiltinPattern(patternName)) {
const note = document.createElement('span');
note.className = 'muted-text';
note.style.fontSize = '0.85em';
note.textContent = 'Built-in (no OTA module)';
row.appendChild(note);
} else {
const sendBtn = document.createElement('button');
sendBtn.className = 'btn btn-primary btn-small';
sendBtn.textContent = 'Send';
sendBtn.addEventListener('click', async () => {
try {
await sendPatternToDevices(patternName);
} catch (error) {
console.error('Send pattern failed:', error);
alert(error.message || 'Failed to send pattern.');
}
});
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', async () => {
if (patternEditorModal) {
patternEditorModal.classList.add('active');
}
await loadPatternIntoEditor(patternName, data || {});
});
row.appendChild(editBtn);
row.appendChild(sendBtn);
}
row.appendChild(details);
patternsList.appendChild(row);
});
};
async function loadPatterns() {
const loadPatterns = async () => {
patternsList.innerHTML = '';
const loading = document.createElement('p');
loading.className = 'muted-text';
@@ -443,7 +47,6 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const response = await fetch('/patterns', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
@@ -459,7 +62,7 @@ document.addEventListener('DOMContentLoaded', () => {
errorMessage.textContent = 'Failed to load patterns.';
patternsList.appendChild(errorMessage);
}
}
};
const openModal = () => {
patternsModal.classList.add('active');
@@ -471,112 +74,13 @@ document.addEventListener('DOMContentLoaded', () => {
};
patternsButton.addEventListener('click', openModal);
if (patternAddButton) {
patternAddButton.addEventListener('click', () => {
resetCreateForm();
if (patternEditorModal) {
patternEditorModal.classList.add('active');
}
});
}
if (patternEditorCloseButton) {
patternEditorCloseButton.addEventListener('click', () => {
if (patternEditorModal) {
patternEditorModal.classList.remove('active');
}
});
}
if (patternsCloseButton) {
patternsCloseButton.addEventListener('click', closeModal);
}
if (patternSendAllButton) {
patternSendAllButton.addEventListener('click', async () => {
const section = document.querySelector('.presets-section[data-zone-id]');
const zoneId = section ? section.dataset.zoneId : null;
if (!zoneId) {
alert('Could not determine current zone.');
return;
}
const deviceNames = tabDeviceNamesFromSection(section);
if (!deviceNames.length) {
alert('No devices found in the current zone.');
return;
}
try {
const [zoneRes, presetsRes] = await Promise.all([
fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }),
fetch('/presets', { headers: { Accept: 'application/json' } }),
]);
if (!zoneRes.ok || !presetsRes.ok) {
throw new Error('Failed to load zone presets');
}
const zoneData = await zoneRes.json();
const allPresetsRaw = await presetsRes.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
const zonePresetIds = Array.isArray(zoneData.presets_flat)
? zoneData.presets_flat.map((id) => String(id))
: [];
if (!zonePresetIds.length) {
alert('No presets found in this zone.');
return;
}
const wirePresets = {};
zonePresetIds.forEach((presetId) => {
const preset = allPresets[presetId];
if (!preset) {
return;
}
const colors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors
: ['#FFFFFF'];
wirePresets[presetId] = {
pattern: preset.pattern || 'off',
colors,
delay: typeof preset.delay === 'number' ? preset.delay : 100,
brightness: typeof preset.brightness === 'number'
? preset.brightness
: (typeof preset.br === 'number' ? preset.br : 127),
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
n1: coercePresetInt(preset.n1),
n2: coercePresetInt(preset.n2),
n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6),
};
});
if (!Object.keys(wirePresets).length) {
alert('No matching presets found to send.');
return;
}
const select = {};
deviceNames.forEach((name) => {
if (name) {
select[name] = zonePresetIds.slice();
}
});
const targetMacs =
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
: [];
const sequence = [
{ v: '1', clear_presets: true, save: true },
{ v: '1', presets: wirePresets, save: true },
];
if (Object.keys(select).length) {
sequence.push({ v: '1', select });
}
await postDriverSequence(sequence, targetMacs, 0.05);
} catch (error) {
console.error('Send all patterns failed:', error);
alert('Failed to send all patterns.');
}
});
}
patternsModal.addEventListener('click', (event) => {
if (event.target === patternsModal) {
closeModal();
}
});
});

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -12,78 +12,6 @@ body {
overflow: hidden;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.hex-address-row {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
align-items: center;
}
input.hex-addr-box {
width: 1.35rem;
padding: 0.25rem 0.1rem;
text-align: center;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.device-form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.75rem;
align-items: end;
}
.device-field-label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-bottom: 0.25rem;
}
.device-row-mac {
font-size: 0.82em;
color: #b0b0b0;
letter-spacing: 0.02em;
}
.device-form-actions {
display: flex;
align-items: flex-end;
}
#devices-modal select {
width: 100%;
max-width: 16rem;
padding: 0.35rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
#edit-device-modal select {
width: 100%;
max-width: 20rem;
padding: 0.35rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
.app-container {
display: flex;
flex-direction: column;
@@ -149,45 +77,6 @@ header h1 {
background-color: #333;
}
.menu-brightness-control {
padding: 0.45rem 0.75rem 0.55rem;
border-bottom: 1px solid #333;
}
.menu-brightness-control label {
display: block;
font-size: 0.78rem;
color: #bdbdbd;
margin-bottom: 0.3rem;
}
.menu-brightness-control input[type="range"] {
width: 100%;
}
.header-brightness-control {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 13rem;
padding: 0.2rem 0.1rem;
}
.header-brightness-control label {
font-size: 0.8rem;
color: #bdbdbd;
white-space: nowrap;
}
.header-brightness-control input[type="range"] {
width: 8.5rem;
}
/* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only {
display: none !important;
}
.btn {
padding: 0.45rem 0.9rem;
border: none;
@@ -237,7 +126,7 @@ body.preset-ui-run .edit-mode-only {
overflow: hidden;
}
.zones-container {
.tabs-container {
background-color: transparent;
padding: 0.5rem 0;
flex: 1;
@@ -247,7 +136,7 @@ body.preset-ui-run .edit-mode-only {
align-items: center;
}
.zones-list {
.tabs-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
@@ -256,7 +145,7 @@ body.preset-ui-run .edit-mode-only {
min-width: 0;
}
.zone-button {
.tab-button {
padding: 0.5rem 1rem;
background-color: #3a3a3a;
color: white;
@@ -268,29 +157,28 @@ body.preset-ui-run .edit-mode-only {
transition: background-color 0.2s;
}
.zone-button:hover {
.tab-button:hover {
background-color: #4a4a4a;
}
.zone-button.active {
.tab-button.active {
background-color: #6a5acd;
color: white;
}
.zone-content {
.tab-content {
flex: 1;
display: block;
overflow-y: auto;
overflow-x: hidden;
padding: 0.5rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px) + 3.5rem);
-webkit-overflow-scrolling: touch;
padding: 0.5rem 1rem 1rem;
}
.presets-toolbar {
align-items: center;
}
.zone-brightness-group {
.tab-brightness-group {
display: flex;
flex-direction: column;
align-items: stretch;
@@ -298,7 +186,7 @@ body.preset-ui-run .edit-mode-only {
margin-left: auto;
}
.zone-brightness-group label {
.tab-brightness-group label {
white-space: nowrap;
font-size: 0.85rem;
}
@@ -493,28 +381,22 @@ body.preset-ui-run .edit-mode-only {
.n-param-group {
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
gap: 0.5rem;
}
.n-param-group label {
flex: 1;
min-width: 0;
min-width: 40px;
font-weight: 500;
}
.n-input {
flex: 0 0 var(--n-input-width, 5ch);
width: var(--n-input-width, 5ch);
max-width: 100%;
box-sizing: border-box;
flex: 1;
padding: 0.5rem;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
text-align: right;
}
.n-input:focus {
@@ -550,8 +432,8 @@ body.preset-ui-run .edit-mode-only {
padding: 0;
}
/* Zone preset selecting area: 3 columns, vertical scroll only */
#presets-list-zone {
/* Tab preset selecting area: 3 columns, vertical scroll only */
#presets-list-tab {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -563,12 +445,6 @@ body.preset-ui-run .edit-mode-only {
row-gap: 0.3rem;
align-content: start;
width: 100%;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem);
scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem);
}
#presets-list-zone > :last-child {
margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 2.5rem);
}
/* Settings modal layout */
@@ -654,29 +530,6 @@ body.preset-ui-run .edit-mode-only {
color: #f44336;
}
/* Devices modal: live TCP presence (Wi-Fi only) */
.device-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
align-self: center;
}
.device-status-dot--online {
background: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.45);
}
.device-status-dot--offline {
background: #616161;
}
.device-status-dot--unknown {
background: #424242;
border: 1px solid #757575;
}
.btn-group {
display: flex;
gap: 0.5rem;
@@ -743,62 +596,8 @@ body.preset-ui-run .edit-mode-only {
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;
}
/* Edit only beside the preset tile in edit mode. */
.preset-tile-actions {
display: flex;
flex-direction: column;
justify-content: stretch;
gap: 0.2rem;
flex-shrink: 0;
padding: 0.15rem 0 0.15rem 0.25rem;
width: auto;
min-width: 0;
}
.preset-editor-modal-actions {
flex-wrap: wrap;
gap: 0.35rem;
}
.preset-tile-actions .btn {
width: 100%;
min-height: 2.35rem;
padding: 0.15rem 0.35rem;
font-size: 0.68rem;
line-height: 1.15;
white-space: normal;
}
.ui-mode-toggle--edit {
background-color: #4a3f8f;
border: 1px solid #7b6fd6;
}
.ui-mode-toggle--edit:hover {
background-color: #5a4f9f;
}
/* Preset select buttons inside the zone grid */
#presets-list-zone .pattern-button {
/* Preset select buttons inside the tab grid */
#presets-list-tab .pattern-button {
display: flex;
}
.pattern-button .pattern-button-label {
@@ -990,7 +789,7 @@ body.preset-ui-run .edit-mode-only {
}
/* Mobile-friendly layout */
@media (max-width: 1000px) {
@media (max-width: 800px) {
header {
flex-direction: row;
align-items: center;
@@ -1013,12 +812,12 @@ body.preset-ui-run .edit-mode-only {
padding: 0.4rem 0.7rem;
}
.zones-container {
.tabs-container {
padding: 0.5rem 0;
border-bottom: none;
}
.zone-content {
.tab-content {
padding: 0.5rem;
}
@@ -1042,9 +841,6 @@ body.preset-ui-run .edit-mode-only {
min-width: 280px;
max-width: 95vw;
padding: 1.25rem;
max-height: calc(100dvh - 1rem);
overflow-y: auto;
padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
}
.form-row {
@@ -1062,10 +858,6 @@ body.preset-ui-run .edit-mode-only {
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.7);
overflow-y: auto;
padding: 1rem;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.modal.active {
display: flex;
@@ -1078,20 +870,6 @@ body.preset-ui-run .edit-mode-only {
border-radius: 8px;
min-width: 400px;
max-width: 600px;
max-height: calc(100dvh - 2rem);
overflow-y: auto;
padding-bottom: calc(2rem + env(safe-area-inset-bottom, 0px));
-webkit-overflow-scrolling: touch;
}
/* Real-phone viewport fallback for browsers with unstable 100dvh behavior. */
@supports (-webkit-touch-callout: none) {
.modal {
min-height: -webkit-fill-available;
}
.modal-content {
max-height: calc(-webkit-fill-available - 2rem);
}
}
.modal-content label {
display: block;
@@ -1131,65 +909,6 @@ body.preset-ui-run .edit-mode-only {
background-color: #3a3a3a;
border-radius: 4px;
}
.zone-modal-create-row {
flex-wrap: wrap;
align-items: center;
}
.zone-modal-create-row input[type="text"] {
flex: 1;
min-width: 8rem;
}
.zone-devices-label {
display: block;
margin-top: 0.75rem;
margin-bottom: 0.35rem;
font-weight: 600;
}
.zone-devices-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
max-height: 14rem;
overflow-y: auto;
}
.zone-device-row-label {
flex: 1;
min-width: 0;
}
.zone-device-add-select {
flex: 1;
min-width: 10rem;
padding: 0.5rem;
background-color: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
.zone-devices-add {
margin-top: 0;
flex-wrap: wrap;
}
.zone-presets-section-label {
display: block;
margin-top: 1rem;
margin-bottom: 0.35rem;
font-weight: 600;
}
.edit-zone-presets-scroll {
max-height: 200px;
overflow-y: auto;
margin-bottom: 1rem;
}
/* Hide any text content in palette rows - only show color swatches */
#palette-container .profiles-row {
font-size: 0; /* Hide any text nodes */
@@ -1262,11 +981,9 @@ body.preset-ui-run .edit-mode-only {
min-height: 80px;
}
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
@media (max-width: 1000px) {
#presets-list-zone {
@media (max-width: 800px) {
#presets-list-tab {
grid-template-columns: repeat(3, minmax(0, 1fr));
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);
scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);
}
}
/* Help modal readability */
@@ -1304,8 +1021,8 @@ body.preset-ui-run .edit-mode-only {
font-size: 0.9rem;
}
/* Zone content placeholder (no zone selected) */
.zone-content-placeholder {
/* Tab content placeholder (no tab selected) */
.tab-content-placeholder {
padding: 2rem;
text-align: center;
color: #aaa;
@@ -1317,66 +1034,10 @@ body.preset-ui-run .edit-mode-only {
}
/* Preset editor: brightness/delay field wrappers */
#preset-editor-modal .preset-editor-field {
.preset-editor-field {
flex: 1;
min-width: 10rem;
display: flex;
flex-direction: column;
align-items: stretch;
}
#preset-editor-modal .preset-editor-field label {
align-self: stretch;
}
#preset-editor-modal .preset-editor-field input[type="number"] {
width: 100%;
min-width: 5.5rem;
max-width: 7rem;
box-sizing: border-box;
text-align: right;
}
/* Preset editor n-parameter inputs need extra room for values + spinner controls. */
#preset-editor-modal .n-input {
width: 6.5ch;
min-width: 5.5rem;
}
/* Pattern editor: numeric metadata row */
#pattern-editor-modal input[type="number"] {
width: var(--n-input-width, 5ch);
max-width: 100%;
box-sizing: border-box;
text-align: right;
}
/* Pattern editor: human-readable n labels (text), full width */
#pattern-editor-modal .n-params-grid {
grid-template-columns: 1fr;
}
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) {
justify-content: stretch;
}
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) label {
flex: 0 0 auto;
min-width: 2.25rem;
font-weight: 500;
}
#pattern-editor-modal .pattern-n-readable-input {
flex: 1 1 auto;
width: 100%;
min-width: 0;
text-align: left;
}
@supports not selector(:has(*)) {
#pattern-editor-modal #pattern-create-n-section .n-param-group {
justify-content: stretch;
}
}
/* Settings modal */

View File

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

View File

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

809
src/static/tabs.js Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# Driver message builder (`espnow_message`)
# ESPNow Message Builder
This utility builds **v1** JSON payloads for LED drivers (serial/ESP-NOW bridge and Wi-Fi TCP). See **`docs/API.md`** for the full wire format.
This utility module provides functions to build ESPNow messages according to the LED Driver API specification.
## Usage
@@ -69,12 +69,12 @@ presets = build_presets_dict(presets_data)
## API Specification
See **`docs/API.md`** for REST routes, session scoping, and the compact preset keys on the wire.
See `docs/API.md` for the complete ESPNow API specification.
## Key Features
- **Version Field**: All messages include `"v": "1"` for version tracking
- **Preset Format**: Presets use hex colour strings (`#RRGGBB`), not RGB tuples
- **Preset Format**: Presets use hex color strings (`#RRGGBB`), not RGB tuples
- **Select Format**: Select values are always lists: `["preset_name"]` or `["preset_name", step]`
- **Colour Conversion**: Automatically converts RGB tuples to hex strings
- **Color Conversion**: Automatically converts RGB tuples to hex strings
- **Default Values**: Provides sensible defaults for missing fields

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,27 @@
"""
Message builder for LED driver API communication.
ESPNow message builder utility for LED driver communication.
Builds JSON messages according to the LED driver API specification
for sending presets and select commands over the transport (e.g. serial).
This module provides utilities to build ESPNow messages according to the API specification.
ESPNow has a 250-byte payload limit; messages larger than that must be split into multiple
frames.
"""
import json
# ESPNow payload limit (bytes). Messages larger than this must be split.
ESPNOW_MAX_PAYLOAD_BYTES = 240
def build_message(presets=None, select=None, save=False, default=None):
"""
Build an API message (presets and/or select) as a JSON string.
Build an ESPNow message according to the API specification.
Args:
presets: Dictionary mapping preset names to preset objects, or None
select: Dictionary mapping device names to select lists, or None
Returns:
JSON string ready to send over the transport
JSON string ready to send via ESPNow
Example:
message = build_message(
@@ -55,6 +59,82 @@ def build_message(presets=None, select=None, save=False, default=None):
return json.dumps(message)
def split_espnow_message(msg_dict, max_bytes=None):
"""
Split a message dict into one or more JSON strings each within ESPNow payload limit.
If the message fits in max_bytes, returns a single-element list. Otherwise splits
"select" and/or "presets" into multiple messages (other keys like v, b, default, save
are included only in the first message).
Args:
msg_dict: Full message as a dict (e.g. from json.loads).
max_bytes: Max payload size in bytes (default ESPNOW_MAX_PAYLOAD_BYTES).
Returns:
List of JSON strings, each <= max_bytes, to send in order.
"""
if max_bytes is None:
max_bytes = ESPNOW_MAX_PAYLOAD_BYTES
single = json.dumps(msg_dict)
if len(single) <= max_bytes:
return [single]
# Keys to attach only to the first message we emit
first_only = {k: msg_dict[k] for k in ("b", "default", "save") if k in msg_dict}
out = []
def emit(chunk_dict, is_first):
m = {"v": msg_dict.get("v", "1")}
if is_first and first_only:
m.update(first_only)
m.update(chunk_dict)
s = json.dumps(m)
if len(s) > max_bytes:
raise ValueError(f"Chunk still too large ({len(s)} > {max_bytes})")
out.append(s)
def chunk_dict(key, items_dict):
if not items_dict:
return
items = list(items_dict.items())
i = 0
first = True
while i < len(items):
chunk = {}
while i < len(items):
k, v = items[i]
trial = dict(chunk)
trial[k] = v
trial_msg = {"v": msg_dict.get("v", "1"), key: trial}
if first_only and first:
trial_msg.update(first_only)
if len(json.dumps(trial_msg)) <= max_bytes:
chunk[k] = v
i += 1
else:
if not chunk:
# Single entry too large; send as-is and hope receiver accepts
chunk[k] = v
i += 1
break
if chunk:
emit({key: chunk}, first)
first = False
if not chunk:
break
if "select" in msg_dict:
chunk_dict("select", msg_dict["select"])
if "presets" in msg_dict:
chunk_dict("presets", msg_dict["presets"])
if not out:
# Fallback: emit one message even if over limit (receiver may reject)
out = [single]
return out
def build_select_message(device_name, preset_name, step=None):
"""
Build a select message for a single device.

42
src/util/wifi.py Normal file
View File

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

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