10 Commits

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

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

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

Made-with: Cursor
2026-04-06 00:21:57 +12:00
pi
e6b5bf2cf1 feat(devices): wifi tcp registry, device API/UI, tests; bump led-tool
Made-with: Cursor
2026-04-05 21:13:07 +12:00
pi
fbae75b957 chore(cursor): add scoped-fixes rule for minimal changes
Made-with: Cursor
2026-04-05 21:13:03 +12:00
pi
93476655fc test: add tcp mock server with bind conflict hints
Made-with: Cursor
2026-04-05 16:41:23 +12:00
63 changed files with 6366 additions and 2050 deletions

View File

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

View File

@@ -16,12 +16,12 @@ LED controller web app for managing profiles, tabs, presets, and colour palettes
## Profiles ## Profiles
- Applying a profile updates session scope and refreshes the active tab content. - Applying a profile updates session scope and refreshes the active zone content.
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete). - In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete).
- In **Edit mode**, Profiles supports create/clone/delete. - In **Edit mode**, Profiles supports create/clone/delete.
- Creating a profile always creates a populated `default` tab (starter presets). - Creating a profile always creates a populated `default` zone (starter presets).
- Optional **DJ tab** seeding creates: - Optional **DJ zone** seeding creates:
- `dj` tab bound to device name `dj` - `dj` zone bound to device name `dj`
- starter DJ presets (rainbow, single colour, transition) - starter DJ presets (rainbow, single colour, transition)
## Preset colours and palette linking ## Preset colours and palette linking

View File

@@ -1 +1 @@
{} {"f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.182", "default_pattern": null, "zones": []}, "188b0e1560a8": {"id": "188b0e1560a8", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.242", "default_pattern": null, "zones": []}, "24ec4acaffcc": {"id": "24ec4acaffcc", "name": "c", "type": "led", "transport": "wifi", "address": "10.1.1.171", "default_pattern": null, "zones": []}}

View File

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

View File

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

1
db/zone.json Normal file
View File

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

View File

@@ -15,8 +15,8 @@ All JSON APIs use `Content-Type: application/json` for bodies and responses unle
The main UI has two modes controlled by the mode toggle: The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (tab/preset selection and profile apply). - **Run mode**: optimized for operation (zone/preset selection and profile apply).
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, and profile management actions). - **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
Profiles are available in both modes, but behavior differs: Profiles are available in both modes, but behavior differs:
@@ -70,6 +70,29 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. | | 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). | | GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
### Devices — `/devices`
Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps to an object that always includes:
| Field | Description |
|-------|-------------|
| **`id`** | Same as the storage key (stable handle for URLs). |
| **`name`** | Shown in tabs and used in `select` keys. |
| **`type`** | `led` (only value today; extensible). |
| **`transport`** | `espnow` or `wifi`. |
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
| **`default_pattern`**, **`tabs`** | Optional, as before. |
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/devices` | Map of device id → device object. |
| GET | `/devices/<id>` | One device, 404 if missing. |
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`tabs`**. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
| DELETE | `/devices/<id>` | Remove device. |
### Profiles — `/profiles` ### Profiles — `/profiles`
| Method | Path | Description | | Method | Path | Description |
@@ -77,7 +100,7 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. | | GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` | | GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. | | GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_tab` (request-only) seeds a DJ tab + presets. New profiles always get a populated `default` tab. Returns `{ "<id>": { ... } }` with status 201. | | POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. | | POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. | | POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
| PUT | `/profiles/current` | Update the current profile (from session). | | PUT | `/profiles/current` | Update the current profile (from session). |
@@ -120,18 +143,18 @@ Stored preset records can include:
- `colors`: resolved hex colours for editor/display. - `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. - `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
### Tabs — `/tabs` ### Tabs — `/zones`
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. | | GET | `/zones` | `tabs`, `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
| GET | `/tabs/current` | Current tab from cookie/session. | | GET | `/zones/current` | Current zone from cookie/session. |
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profiles tab list. | | POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profiles zone list. |
| GET | `/tabs/<id>` | Tab JSON. | | GET | `/zones/<id>` | Zone JSON. |
| PUT | `/tabs/<id>` | Update tab. | | PUT | `/zones/<id>` | Update zone. |
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. | | DELETE | `/zones/<id>` | Delete zone; can delete `current` to remove the active zone; updates profile zone list. |
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. | | POST | `/zones/<id>/set-current` | Sets `current_zone` cookie. |
| POST | `/tabs/<id>/clone` | Clone tab into current profile. | | POST | `/zones/<id>/clone` | Clone zone into current profile. |
### Palettes — `/palettes` ### Palettes — `/palettes`

View File

@@ -351,9 +351,9 @@ Manage connected devices and create/manage device groups.
#### Layout #### Layout
- **Header:** Title with "Add Device" button - **Header:** Title with "Add Device" button
- **Tabs:** Devices and Groups tabs - **Tabs:** Devices and Groups tabs
- **Content Area:** Tab-specific content - **Content Area:** Zone-specific content
#### Devices Tab #### Devices Zone
**Device List** **Device List**
- **Display:** List of all known devices - **Display:** List of all known devices
@@ -375,7 +375,7 @@ Manage connected devices and create/manage device groups.
- **Actions:** Cancel, Save - **Actions:** Cancel, Save
- **Note:** Only one master device per system. Adding a new master will demote existing master to slave. - **Note:** Only one master device per system. Adding a new master will demote existing master to slave.
#### Groups Tab #### Groups Zone
**Group List** **Group List**
- **Display:** List of all device groups - **Display:** List of all device groups
@@ -397,7 +397,7 @@ Manage connected devices and create/manage device groups.
- **Actions:** Cancel, Create - **Actions:** Cancel, Create
#### Design Specifications #### Design Specifications
- **Tab Style:** Active tab has purple background, white text - **Zone Style:** Active zone has purple background, white text
- **List Items:** Bordered cards with hover effects - **List Items:** Bordered cards with hover effects
- **Modal:** Centered overlay with white card, shadow - **Modal:** Centered overlay with white card, shadow
- **Status Badges:** Colored pills (green for online, red for offline) - **Status Badges:** Colored pills (green for online, red for offline)
@@ -1495,7 +1495,7 @@ peak_mem = usqlite.mem_peak()
### Flow 2: Create Device Group ### Flow 2: Create Device Group
1. User navigates to Device Management → Groups tab 1. User navigates to Device Management → Groups zone
2. User clicks "Create Group", enters name, selects pattern/settings 2. User clicks "Create Group", enters name, selects pattern/settings
3. User selects devices to add (can include master), clicks "Create" 3. User selects devices to add (can include master), clicks "Create"
4. Group appears in list 4. Group appears in list

View File

@@ -12,13 +12,13 @@ Figures below are **schematic** (layout and ideas), not pixel-perfect screenshot
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. 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: tab buttons on the left; Profiles, Tabs, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg) ![Schematic: zone buttons on the left; Profiles, Tabs, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg)
*The active tab is highlighted. Extra management buttons appear only in Edit mode.* *The active zone is highlighted. Extra management buttons appear only in Edit mode.*
| Mode | Purpose | | Mode | Purpose |
|------|--------| |------|--------|
| **Run mode** | Day-to-day control: choose a tab, tap presets, apply profiles. Management buttons are hidden. | | **Run mode** | Day-to-day control: choose a zone, tap presets, apply profiles. Management buttons are hidden. |
| **Edit mode** | Full setup: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. | | **Edit mode** | Full setup: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles. **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.
@@ -27,23 +27,23 @@ The header has a mode toggle (desktop and mobile menu). The **label on the butto
## Tabs ## Tabs
- **Select a tab**: click its button in the top bar. The main area shows that tabs preset strip and controls. - **Select a zone**: click its button in the top bar. The main area shows that zones preset strip and controls.
- **Edit mode — open tab settings**: **right-click** a tab button to change its name, **device IDs** (comma-separated), and which presets appear on the tab. Device identifiers are matched to each devices **name** when the app builds `select` messages for the driver. - **Edit mode — open zone settings**: **right-click** a zone button to change its name, **device IDs** (comma-separated), and which presets appear on the zone. Device identifiers are matched to each devices **name** when the app builds `select` messages for the driver.
- **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder). - **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder).
- **Brightness slider** (per tab): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link. - **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 tab strip ## 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 tab (same logical action as a `select` in the driver API). - **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 mode only**:
- **Edit** beside a tile opens the preset editor for that preset, scoped to the current tab (so you can **Remove from tab** without deleting the preset from the profile). - **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 tab. - **Drag and drop** tiles to reorder them; order is saved for that zone.
![Schematic: tab title, brightness slider, and a row of preset tiles; Edit mode adds an Edit control and drag handles for reordering.](images/help/tab-preset-strip.svg) ![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 tabs devices. Click the coloured area of a tile to select that preset.* *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). 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).
@@ -55,10 +55,10 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
- **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. - **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. - **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. - **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 tab**, then selects that preset — **without** `save` on the device (good for auditioning). - **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 tabs **default preset** and sends a **default** hint for those devices; it does not force the same live selection behaviour as clicking a tile. - **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). - **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 tab** (when you opened the editor from a tab): removes the preset from **this tabs list only**; the preset remains in the profile for other tabs. - **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) ![Schematic: preset editor with name, pattern, colour swatches (one with a P badge for palette-linked), and action buttons.](images/help/preset-editor.svg)
@@ -69,14 +69,14 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
## Profiles ## Profiles
- **Apply**: sets the **current profile** in your session. Tabs and presets you see are scoped to that profile. - **Apply**: sets the **current profile** in your session. Tabs and presets you see are scoped to that profile.
- **Edit mode — Create**: new profiles always get a populated **default** tab. Optionally tick **DJ tab** to also create a `dj` tab (device name `dj`) with starter DJ-oriented presets. - **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. - **Clone** / **Delete**: available in Edit mode from the profile list.
--- ---
## Send Presets (Edit mode) ## Send Presets (Edit mode)
**Send Presets** walks **every tab** in the **current profile**, collects each tabs preset IDs, and calls **`POST /presets/send`** per tab (including each tabs **default** preset when set). Use this to bulk-push definitions to hardware after editing, without clicking **Send** on every preset individually. **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.
--- ---
@@ -102,7 +102,7 @@ On narrow screens, use **Menu** to reach the same actions as the desktop header
![Schematic: narrow layout with Menu and the same header actions in a dropdown.](images/help/mobile-menu.svg) ![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 tab is selected.* *Preset tiles behave the same once a zone is selected.*
--- ---

View File

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

View File

@@ -1,14 +1,25 @@
# Serial-to-ESP-NOW bridge: receives from Pi on UART, forwards to ESP-NOW peers. # Serial-to-ESP-NOW bridge: JSON in both directions on UART + ESP-NOW.
# Wire format: first 6 bytes = destination MAC, rest = payload. Address is always 6 bytes. #
# Pi → UART (two supported forms):
# A) Legacy: 6 bytes destination MAC + UTF-8 JSON payload (one write = one frame).
# B) Newline JSON: one object per line, UTF-8, ending with \n
# - Multicast via ESP32: {"m":"split","peers":["12hex",...],"body":{...}}
# - Unicast / broadcast: {"to":"12hex","v":"1",...} (all keys except to/dest go to peers)
#
# ESP-NOW → Pi: newline-delimited JSON, one object per packet:
# {"dir":"espnow_rx","from":"<12hex>","payload":{...}} if body was JSON
# {"dir":"espnow_rx","from":"<12hex>","payload_text":"..."} if UTF-8 not JSON
# {"dir":"espnow_rx","from":"<12hex>","payload_b64":"..."} if binary
from machine import Pin, UART from machine import Pin, UART
import espnow import espnow
import json
import network import network
import time import time
import ubinascii
UART_BAUD = 912000 UART_BAUD = 912000
BROADCAST = b"\xff\xff\xff\xff\xff\xff" BROADCAST = b"\xff\xff\xff\xff\xff\xff"
MAX_PEERS = 20 MAX_PEERS = 20
# Match led-driver / controller default settings wifi_channel (111)
WIFI_CHANNEL = 6 WIFI_CHANNEL = 6
sta = network.WLAN(network.STA_IF) sta = network.WLAN(network.STA_IF)
@@ -22,22 +33,18 @@ esp.add_peer(BROADCAST)
uart = UART(1, UART_BAUD, tx=Pin(21), rx=Pin(6)) uart = UART(1, UART_BAUD, tx=Pin(21), rx=Pin(6))
# Track last send time per peer for LRU eviction (remove oldest when at limit).
last_used = {BROADCAST: time.ticks_ms()} last_used = {BROADCAST: time.ticks_ms()}
uart_rx_buf = b""
# ESP_ERR_ESPNOW_EXIST: peer already registered (ignore when adding).
ESP_ERR_ESPNOW_EXIST = -12395 ESP_ERR_ESPNOW_EXIST = -12395
def ensure_peer(addr): def ensure_peer(addr):
"""Ensure addr is in the peer list. When at 20 peers, remove the oldest-used (LRU)."""
peers = esp.get_peers() peers = esp.get_peers()
peer_macs = [p[0] for p in peers] peer_macs = [p[0] for p in peers]
if addr in peer_macs: if addr in peer_macs:
return return
if len(peer_macs) >= MAX_PEERS: if len(peer_macs) >= MAX_PEERS:
# Remove the peer we used least recently (oldest).
oldest_mac = None oldest_mac = None
oldest_ts = time.ticks_ms() oldest_ts = time.ticks_ms()
for mac in peer_macs: for mac in peer_macs:
@@ -57,16 +64,190 @@ def ensure_peer(addr):
raise raise
print("Starting ESP32 main.py") def try_apply_bridge_config(obj):
"""Pi sends {"m":"bridge","ch":1..11} — set STA channel only; do not ESP-NOW forward."""
if not isinstance(obj, dict) or obj.get("m") != "bridge":
return False
ch = obj.get("ch")
if ch is None:
ch = obj.get("wifi_channel")
if ch is None:
return True
try:
n = int(ch)
if 1 <= n <= 11:
sta.config(pm=network.WLAN.PM_NONE, channel=n)
print("Bridge STA channel ->", n)
except Exception as e:
print("bridge config:", e)
return True
def send_split_from_obj(obj):
"""obj has m=split, peers=[12hex,...], body=dict."""
body = obj.get("body")
if body is None:
return
try:
out = json.dumps(body).encode("utf-8")
except (TypeError, ValueError):
return
for peer in obj.get("peers") or []:
if not isinstance(peer, str) or len(peer) != 12:
continue
try:
mac = bytes.fromhex(peer)
except ValueError:
continue
if len(mac) != 6:
continue
ensure_peer(mac)
esp.send(mac, out)
last_used[mac] = time.ticks_ms()
def process_broadcast_payload_split_or_flood(payload):
try:
text = payload.decode("utf-8")
obj = json.loads(text)
except Exception:
obj = None
if isinstance(obj, dict) and try_apply_bridge_config(obj):
return
if (
isinstance(obj, dict)
and obj.get("m") == "split"
and isinstance(obj.get("peers"), list)
):
send_split_from_obj(obj)
return
ensure_peer(BROADCAST)
esp.send(BROADCAST, payload)
last_used[BROADCAST] = time.ticks_ms()
def process_legacy_uart_frame(data):
if not data or len(data) < 6:
return
addr = data[:6]
payload = data[6:]
if addr == BROADCAST:
process_broadcast_payload_split_or_flood(payload)
return
ensure_peer(addr)
esp.send(addr, payload)
last_used[addr] = time.ticks_ms()
def handle_json_command_line(obj):
if not isinstance(obj, dict):
return
if try_apply_bridge_config(obj):
return
if obj.get("m") == "split" and isinstance(obj.get("peers"), list):
send_split_from_obj(obj)
return
to = obj.get("to") or obj.get("dest")
if isinstance(to, str) and len(to) == 12:
try:
mac = bytes.fromhex(to)
except ValueError:
return
if len(mac) != 6:
return
body = {k: v for k, v in obj.items() if k not in ("to", "dest")}
if not body:
return
try:
out = json.dumps(body).encode("utf-8")
except (TypeError, ValueError):
return
ensure_peer(mac)
esp.send(mac, out)
last_used[mac] = time.ticks_ms()
def drain_uart_json_lines():
"""Parse leading newline-delimited JSON objects from uart_rx_buf; leave rest."""
global uart_rx_buf
while True:
s = uart_rx_buf.lstrip()
if not s:
uart_rx_buf = b""
return
if s[0] != ord("{"):
uart_rx_buf = s
return
nl = s.find(b"\n")
if nl < 0:
uart_rx_buf = s
return
line = s[:nl].strip()
uart_rx_buf = s[nl + 1 :]
if line:
try:
text = line.decode("utf-8")
obj = json.loads(text)
handle_json_command_line(obj)
except Exception as e:
print("UART JSON line error:", e)
# continue; there may be another JSON line in buffer
def drain_uart_legacy_frame():
"""If buffer does not start with '{', treat whole buffer as one 6-byte MAC + JSON frame."""
global uart_rx_buf
s = uart_rx_buf
if not s or s[0] == ord("{"):
return
if len(s) < 6:
return
data = s
uart_rx_buf = b""
process_legacy_uart_frame(data)
def forward_espnow_to_uart(mac, msg):
peer_hex = ubinascii.hexlify(mac).decode()
try:
text = msg.decode("utf-8")
try:
payload = json.loads(text)
line_obj = {"dir": "espnow_rx", "from": peer_hex, "payload": payload}
except ValueError:
line_obj = {"dir": "espnow_rx", "from": peer_hex, "payload_text": text}
except UnicodeDecodeError:
line_obj = {
"dir": "espnow_rx",
"from": peer_hex,
"payload_b64": ubinascii.b64encode(msg).decode(),
}
try:
line = json.dumps(line_obj) + "\n"
uart.write(line.encode("utf-8"))
except Exception as e:
print("UART TX error:", e)
print("Starting ESP32 bridge (UART JSON + legacy MAC+JSON, ESP-NOW RX → UART JSON lines)")
while True: while True:
idle = True
if uart.any(): if uart.any():
data = uart.read() idle = False
if not data or len(data) < 6: uart_rx_buf += uart.read()
continue drain_uart_json_lines()
print(f"Received data: {data}") drain_uart_legacy_frame()
addr = data[:6]
payload = data[6:] try:
ensure_peer(addr) peer, msg = esp.recv(0)
esp.send(addr, payload) except OSError:
last_used[addr] = time.ticks_ms() peer, msg = None, None
if peer is not None and msg is not None:
idle = False
if len(peer) == 6:
forward_espnow_to_uart(peer, msg)
if idle:
time.sleep_ms(1)

21
esp32/msg.json Normal file
View File

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

6
patterns/__init__.py Normal file
View File

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

33
patterns/blink.py Normal file
View File

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

124
patterns/chase.py Normal file
View File

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

96
patterns/circle.py Normal file
View File

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

64
patterns/pulse.py Normal file
View File

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

51
patterns/rainbow.py Normal file
View File

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

57
patterns/transition.py Normal file
View File

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

View File

@@ -1,29 +1,143 @@
from microdot import Microdot from microdot import Microdot
from models.device import Device from models.device import (
Device,
derive_device_mac,
validate_device_transport,
validate_device_type,
)
from models.transport import get_current_sender
from models.tcp_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
tcp_client_connected,
)
from util.espnow_message import build_message
import asyncio
import json import json
import os
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
_IDENTIFY_PRESET_KEY = "__identify"
# Short-key payload: 10 Hz full cycle = 50 ms on + 50 ms off (driver ``blink`` toggles each ``d`` ms).
_IDENTIFY_DRIVER_PRESET = {
"p": "blink",
"c": ["#ff0000"],
"d": 50,
"b": 128,
"a": True,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
}
def _compact_v1_json(*, presets=None, select=None, save=False):
"""Single-line v1 object; compact so serial/ESP-NOW stays small."""
body = {"v": "1"}
if presets is not None:
body["presets"] = presets
if save:
body["save"] = True
if select is not None:
body["select"] = select
return json.dumps(body, separators=(",", ":"))
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
IDENTIFY_OFF_DELAY_S = 2.0
controller = Microdot() controller = Microdot()
devices = Device() devices = Device()
def _device_live_connected(dev_dict):
"""
Wi-Fi: whether a TCP client is registered for this device's address (IP).
ESP-NOW: None (no TCP session on the Pi for that transport).
"""
tr = (dev_dict.get("transport") or "espnow").strip().lower()
if tr != "wifi":
return None
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
if not ip:
return False
return tcp_client_connected(ip)
def _device_json_with_live_status(dev_dict):
row = dict(dev_dict)
row["connected"] = _device_live_connected(dev_dict)
return row
def _driver_patterns_dir():
here = os.path.dirname(__file__)
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
def _build_patterns_manifest(host):
base_dir = _driver_patterns_dir()
names = sorted(os.listdir(base_dir))
files = []
for name in names:
if not _safe_pattern_filename(name) or name == "__init__.py":
continue
files.append(
{
"name": name,
"url": "http://%s/patterns/ota/file/%s" % (host, name),
}
)
return {"files": files}
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
off_msg = build_message(select={name: ["off"]})
if transport == "wifi":
await send_json_line_to_ip(wifi_ip, off_msg)
else:
await sender.send(off_msg, addr=dev_id)
except Exception:
pass
@controller.get("") @controller.get("")
async def list_devices(request): async def list_devices(request):
"""List all devices.""" """List all devices (includes ``connected`` for live Wi-Fi TCP presence)."""
devices_data = {} devices_data = {}
for dev_id in devices.list(): for dev_id in devices.list():
d = devices.read(dev_id) d = devices.read(dev_id)
if d: if d:
devices_data[dev_id] = d devices_data[dev_id] = _device_json_with_live_status(d)
return json.dumps(devices_data), 200, {"Content-Type": "application/json"} return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.get("/<id>") @controller.get("/<id>")
async def get_device(request, id): async def get_device(request, id):
"""Get a device by ID.""" """Get a device by ID (includes ``connected`` for live Wi-Fi TCP presence)."""
dev = devices.read(id) dev = devices.read(id)
if dev: if dev:
return json.dumps(dev), 200, {"Content-Type": "application/json"} return json.dumps(_device_json_with_live_status(dev)), 200, {
return json.dumps({"error": "Device not found"}), 404 "Content-Type": "application/json",
}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@controller.post("") @controller.post("")
@@ -32,37 +146,201 @@ async def create_device(request):
try: try:
data = request.json or {} data = request.json or {}
name = data.get("name", "").strip() 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") 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") default_pattern = data.get("default_pattern")
tabs = data.get("tabs") zl = data.get("zones")
if isinstance(tabs, list): if isinstance(zl, list):
tabs = [str(t) for t in tabs] zl = [str(t) for t in zl]
else: else:
tabs = [] zl = []
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs) 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) dev = devices.read(dev_id)
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"} 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: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put("/<id>") @controller.put("/<id>")
async def update_device(request, id): async def update_device(request, id):
"""Update a device.""" """Update a device."""
try: try:
data = request.json or {} raw = request.json or {}
if "tabs" in data and isinstance(data["tabs"], list): data = dict(raw)
data["tabs"] = [str(t) for t in data["tabs"]] 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): if devices.update(id, data):
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"} return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404 return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.delete("/<id>") @controller.delete("/<id>")
async def delete_device(request, id): async def delete_device(request, id):
"""Delete a device.""" """Delete a device."""
if devices.delete(id): if devices.delete(id):
return json.dumps({"message": "Device deleted successfully"}), 200 return (
return json.dumps({"error": "Device not found"}), 404 json.dumps({"message": "Device deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@controller.post("/<id>/identify")
async def identify_device(request, id):
"""
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
this device name — same combined shape as profile sends the driver already accepts over TCP
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
name = str(dev.get("name") or "").strip()
if not name:
return json.dumps({"error": "Device must have a name to identify"}), 400, {
"Content-Type": "application/json",
}
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
else:
await sender.send(msg, addr=id)
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json",
}
@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
"""
Ask a Wi-Fi LED driver to pull pattern files from this server over HTTP.
Body (optional):
{"manifest": "http://host:port/patterns/ota/manifest"}
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
body = request.json or {}
manifest_payload = body.get("manifest")
if manifest_payload is None:
host = request.headers.get("Host", "")
if not host:
return json.dumps({"error": "Missing Host header"}), 400, {
"Content-Type": "application/json",
}
try:
manifest_payload = _build_patterns_manifest(host)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {
"Content-Type": "application/json",
}
if not isinstance(manifest_payload, (str, dict)):
return json.dumps({"error": "manifest must be a URL string or manifest object"}), 400, {
"Content-Type": "application/json",
}
msg = json.dumps({"v": "1", "manifest": manifest_payload}, separators=(",", ":"))
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
return json.dumps({"message": "Pattern OTA trigger sent", "manifest": manifest_payload}), 200, {
"Content-Type": "application/json",
}

View File

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

View File

@@ -2,9 +2,10 @@ from microdot import Microdot
from microdot.session import with_session from microdot.session import with_session
from models.preset import Preset from models.preset import Preset
from models.profile import Profile from models.profile import Profile
from models.device import Device, normalize_mac
from models.transport import get_current_sender from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
from util.espnow_message import build_message, build_preset_dict from util.espnow_message import build_message, build_preset_dict
import asyncio
import json import json
controller = Microdot() controller = Microdot()
@@ -125,13 +126,17 @@ async def delete_preset(request, *args, **kwargs):
@with_session @with_session
async def send_presets(request, session): async def send_presets(request, session):
""" """
Send one or more presets to the LED driver (via serial transport). Send one or more presets to LED drivers (serial/ESP-NOW and/or TCP Wi-Fi clients).
Body JSON: Body JSON:
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]} {"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
Optional "targets": ["aabbccddeeff", ...] — registry MACs. When set: preset
chunks are ESP-NOW broadcast once each; Wi-Fi drivers get the same chunks
over TCP; if "default" is set, each target then gets a unicast default
message (serial or TCP) with that device name in "targets".
Omit targets for broadcast-only serial (legacy).
The controller looks up each preset, converts to API format, chunks into Optional "destination_mac" / "to": single MAC when targets is omitted.
<= 240-byte messages, and sends them over the configured transport.
""" """
try: try:
data = request.json or {} data = request.json or {}
@@ -144,7 +149,6 @@ async def send_presets(request, session):
save_flag = data.get('save', True) save_flag = data.get('save', True)
save_flag = bool(save_flag) save_flag = bool(save_flag)
default_id = data.get('default') default_id = data.get('default')
# Optional 12-char hex MAC to send to one device; omit for default (e.g. broadcast).
destination_mac = data.get('destination_mac') or data.get('to') destination_mac = data.get('destination_mac') or data.get('to')
# Build API-compliant preset map keyed by preset ID, include name # Build API-compliant preset map keyed by preset ID, include name
@@ -171,23 +175,13 @@ async def send_presets(request, session):
if not sender: if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'} return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
async def send_chunk(chunk_presets, is_last):
# Save/default should only be sent with the final presets chunk.
msg = build_message(
presets=chunk_presets,
save=save_flag and is_last,
default=default_id if is_last else None,
)
await sender.send(msg, addr=destination_mac)
MAX_BYTES = 240 MAX_BYTES = 240
send_delay_s = 0.1 send_delay_s = 0.1
entries = list(presets_by_name.items()) entries = list(presets_by_name.items())
total_presets = len(entries) total_presets = len(entries)
messages_sent = 0
batch = {} batch = {}
last_msg = None chunk_messages = []
for name, preset_obj in entries: for name, preset_obj in entries:
test_batch = dict(batch) test_batch = dict(batch)
test_batch[name] = preset_obj test_batch[name] = preset_obj
@@ -196,28 +190,133 @@ async def send_presets(request, session):
if size <= MAX_BYTES or not batch: if size <= MAX_BYTES or not batch:
batch = test_batch batch = test_batch
last_msg = test_msg
else: else:
try: chunk_messages.append(
await send_chunk(batch, False) build_message(
except Exception: presets=dict(batch),
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} save=False,
await asyncio.sleep(send_delay_s) default=None,
messages_sent += 1 )
)
batch = {name: preset_obj} batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
if batch: if batch:
try: chunk_messages.append(
await send_chunk(batch, True) build_message(
except Exception: presets=dict(batch),
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} save=save_flag,
await asyncio.sleep(send_delay_s) default=default_id,
messages_sent += 1 )
)
target_list = None
raw_targets = data.get("targets")
if isinstance(raw_targets, list) and raw_targets:
target_list = []
for t in raw_targets:
m = normalize_mac(str(t))
if m:
target_list.append(m)
target_list = list(dict.fromkeys(target_list))
if not target_list:
target_list = None
elif destination_mac:
dm = normalize_mac(str(destination_mac))
target_list = [dm] if dm else None
try:
if target_list:
deliveries = await deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
target_list,
Device(),
str(default_id) if default_id is not None else None,
delay_s=send_delay_s,
)
else:
deliveries, _chunks = await deliver_json_messages(
sender,
chunk_messages,
None,
Device(),
delay_s=send_delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return json.dumps({ return json.dumps({
"message": "Presets sent", "message": "Presets sent",
"presets_sent": total_presets, "presets_sent": total_presets,
"messages_sent": messages_sent "messages_sent": deliveries,
}), 200, {'Content-Type': 'application/json'}
@controller.post('/push')
@with_session
async def push_driver_messages(request, session):
"""
Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP).
Body:
{"sequence": [{ "v": "1", ... }, ...], "targets": ["mac", ...]}
or a single {"payload": {...}, "targets": [...]}.
"""
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
seq = data.get("sequence")
if not seq and data.get("payload") is not None:
seq = [data["payload"]]
if not isinstance(seq, list) or not seq:
return json.dumps({"error": "sequence or payload required"}), 400, {'Content-Type': 'application/json'}
raw_targets = data.get("targets")
target_list = None
if isinstance(raw_targets, list) and raw_targets:
target_list = []
for t in raw_targets:
m = normalize_mac(str(t))
if m:
target_list.append(m)
target_list = list(dict.fromkeys(target_list))
if not target_list:
target_list = None
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
messages = []
for item in seq:
if isinstance(item, dict):
messages.append(json.dumps(item))
elif isinstance(item, str):
messages.append(item)
else:
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
delay_s = data.get("delay_s", 0.05)
try:
delay_s = float(delay_s)
except (TypeError, ValueError):
delay_s = 0.05
try:
deliveries, _chunks = await deliver_json_messages(
sender,
messages,
target_list,
Device(),
delay_s=delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return json.dumps({
"message": "Delivered",
"deliveries": deliveries,
}), 200, {'Content-Type': 'application/json'} }), 200, {'Content-Type': 'application/json'}

View File

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

View File

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

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

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

View File

@@ -1,6 +1,11 @@
import asyncio import asyncio
import errno
import json import json
import os import os
import signal
import socket
import threading
import traceback
from microdot import Microdot, send_file from microdot import Microdot, send_file
from microdot.websocket import with_websocket from microdot.websocket import with_websocket
from microdot.session import Session from microdot.session import Session
@@ -10,12 +15,291 @@ import controllers.preset as preset
import controllers.profile as profile import controllers.profile as profile
import controllers.group as group import controllers.group as group
import controllers.sequence as sequence import controllers.sequence as sequence
import controllers.tab as tab import controllers.zone as zone
import controllers.palette as palette import controllers.palette as palette
import controllers.scene as scene import controllers.scene as scene
import controllers.pattern as pattern import controllers.pattern as pattern
import controllers.settings as settings_controller import controllers.settings as settings_controller
from models.transport import get_sender, set_sender import controllers.device as device_controller
from models.transport import get_sender, set_sender, get_current_sender
from models.device import Device, normalize_mac
from models import tcp_clients as tcp_client_registry
from util.device_status_broadcaster import (
broadcast_device_tcp_snapshot_to,
broadcast_device_tcp_status,
register_device_status_ws,
unregister_device_status_ws,
)
_tcp_device_lock = threading.Lock()
# Wi-Fi drivers send one hello line then stay quiet; periodic outbound data makes dead peers
# fail drain() within this interval (keepalive alone is often slow or ineffective).
TCP_LIVENESS_PING_INTERVAL_S = 12.0
DISCOVERY_UDP_PORT = 8766
# Keepalive or lossy Wi-Fi can still surface OSError(110) / TimeoutError on recv or wait_closed.
_TCP_PEER_GONE = (
BrokenPipeError,
ConnectionResetError,
ConnectionAbortedError,
ConnectionRefusedError,
TimeoutError,
OSError,
)
def _tcp_socket_from_writer(writer):
sock = writer.get_extra_info("socket")
if sock is not None:
return sock
transport = getattr(writer, "transport", None)
if transport is not None:
return transport.get_extra_info("socket")
return None
def _enable_tcp_keepalive(writer) -> None:
"""
Detect vanished peers (power off, Wi-Fi drop) without waiting for a send() failure.
Linux: shorten time before the first keepalive probe; other platforms: SO_KEEPALIVE only.
"""
sock = _tcp_socket_from_writer(writer)
if sock is None:
return
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
except OSError:
return
if hasattr(socket, "TCP_KEEPIDLE"):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 120)
except OSError:
pass
if hasattr(socket, "TCP_KEEPINTVL"):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15)
except OSError:
pass
if hasattr(socket, "TCP_KEEPCNT"):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4)
except OSError:
pass
# Do not set TCP_USER_TIMEOUT: a short value causes Errno 110 on recv for Wi-Fi peers
# when ACKs are delayed (ESP power save, lossy links). Liveness pings already clear dead
# sessions via drain().
async def _tcp_liveness_ping_loop(writer, peer_ip: str) -> None:
"""Send a bare newline so ``drain()`` fails soon after the peer disappears."""
while True:
await asyncio.sleep(TCP_LIVENESS_PING_INTERVAL_S)
if writer.is_closing():
return
try:
writer.write(b"\n")
await writer.drain()
except Exception as exc:
print(f"[TCP] liveness ping failed {peer_ip!r}: {exc!r}")
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
try:
writer.close()
except Exception:
pass
return
def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None
) -> None:
with _tcp_device_lock:
try:
d = Device()
did = d.upsert_wifi_tcp_client(
device_name, peer_ip, mac, device_type=device_type
)
if did:
print(
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
)
except Exception as e:
print(f"UDP device registry failed: {e}")
traceback.print_exception(type(e), e, e.__traceback__)
async def _handle_udp_discovery(sock, udp_holder=None) -> None:
while True:
try:
data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
except asyncio.CancelledError:
raise
except OSError as e:
if udp_holder and udp_holder.get("closing"):
break
print(f"[UDP] recv failed: {e!r}")
continue
except Exception as e:
print(f"[UDP] recv failed: {e!r}")
continue
peer_ip = addr[0] if addr else ""
line = data.split(b"\n", 1)[0].strip()
if line:
try:
parsed = json.loads(line.decode("utf-8"))
if isinstance(parsed, dict):
dns = str(parsed.get("device_name") or "").strip()
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
"sta_mac"
)
device_type = parsed.get("type") or parsed.get("device_type")
if dns and normalize_mac(mac):
_register_udp_device_sync(dns, peer_ip, mac, device_type)
except (UnicodeError, ValueError, TypeError):
pass
try:
await asyncio.get_running_loop().sock_sendto(sock, data, addr)
except Exception as e:
print(f"[UDP] echo send failed: {e!r}")
async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
except (AttributeError, OSError):
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except (AttributeError, OSError):
pass
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
if udp_holder is not None:
udp_holder["sock"] = sock
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
try:
await _handle_udp_discovery(sock, udp_holder)
finally:
if udp_holder is not None:
udp_holder.pop("sock", None)
try:
sock.close()
except Exception:
pass
async def _handle_tcp_client(reader, writer):
"""Read newline-delimited JSON from Wi-Fi LED drivers; forward to serial bridge."""
peer = writer.get_extra_info("peername")
peer_ip = peer[0] if peer else ""
peer_label = f"{peer_ip}:{peer[1]}" if peer and len(peer) > 1 else peer_ip or "?"
print(f"[TCP] client connected {peer_label}")
_enable_tcp_keepalive(writer)
tcp_client_registry.register_tcp_writer(peer_ip, writer)
ping_task = asyncio.create_task(_tcp_liveness_ping_loop(writer, peer_ip))
sender = get_current_sender()
buf = b""
try:
while True:
try:
chunk = await reader.read(4096)
except asyncio.CancelledError:
raise
except _TCP_PEER_GONE as e:
print(f"[TCP] read ended ({peer_label}): {e!r}")
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
break
if not chunk:
break
buf += chunk
while b"\n" in buf:
raw_line, buf = buf.split(b"\n", 1)
line = raw_line.strip()
if not line:
continue
try:
text = line.decode("utf-8")
except UnicodeDecodeError:
print(
f"[TCP] recv {peer_label} (non-UTF-8, {len(line)} bytes): {line!r}"
)
continue
print(f"[TCP] recv {peer_label}: {text}")
try:
parsed = json.loads(text)
except json.JSONDecodeError:
if sender:
try:
await sender.send(text)
except Exception:
pass
continue
if isinstance(parsed, dict):
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else "{}"
if sender:
try:
await sender.send(payload, addr=addr)
except Exception as e:
print(f"TCP forward to bridge failed: {e}")
elif sender:
try:
await sender.send(text)
except Exception:
pass
finally:
# Drop registry + broadcast connected:false before awaiting ping/close so the UI
# does not stay green if ping or wait_closed blocks on a timed-out peer.
outcome = tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
if outcome == "superseded":
print(
f"[TCP] TCP session ended (same IP already has a newer connection): {peer_label}"
)
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
try:
writer.close()
await writer.wait_closed()
except asyncio.CancelledError:
raise
except _TCP_PEER_GONE:
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
async def _send_bridge_wifi_channel(settings, sender):
"""Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
try:
ch = int(settings.get("wifi_channel", 6))
except (TypeError, ValueError):
ch = 6
ch = max(1, min(11, ch))
payload = json.dumps({"m": "bridge", "ch": ch}, separators=(",", ":"))
try:
await sender.send(payload, addr="ffffffffffff")
print(f"[startup] bridge Wi-Fi channel -> {ch}")
except Exception as e:
print(f"[startup] bridge channel message failed: {e}")
async def _run_tcp_server(settings, tcp_holder=None):
if not settings.get("tcp_enabled", True):
print("TCP server disabled (tcp_enabled=false)")
return
port = int(settings.get("tcp_port", 8765))
server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port)
print(f"TCP server listening on 0.0.0.0:{port}")
if tcp_holder is not None:
tcp_holder["server"] = server
try:
async with server:
await server.serve_forever()
finally:
if tcp_holder is not None:
tcp_holder.pop("server", None)
async def main(port=80): async def main(port=80):
@@ -40,7 +324,7 @@ async def main(port=80):
('/profiles', profile, 'profile'), ('/profiles', profile, 'profile'),
('/groups', group, 'group'), ('/groups', group, 'group'),
('/sequences', sequence, 'sequence'), ('/sequences', sequence, 'sequence'),
('/tabs', tab, 'tab'), ('/zones', zone, 'zone'),
('/palettes', palette, 'palette'), ('/palettes', palette, 'palette'),
('/scenes', scene, 'scene'), ('/scenes', scene, 'scene'),
] ]
@@ -50,11 +334,14 @@ async def main(port=80):
app.mount(profile.controller, '/profiles') app.mount(profile.controller, '/profiles')
app.mount(group.controller, '/groups') app.mount(group.controller, '/groups')
app.mount(sequence.controller, '/sequences') app.mount(sequence.controller, '/sequences')
app.mount(tab.controller, '/tabs') app.mount(zone.controller, '/zones')
app.mount(palette.controller, '/palettes') app.mount(palette.controller, '/palettes')
app.mount(scene.controller, '/scenes') app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns') app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings') app.mount(settings_controller.controller, '/settings')
app.mount(device_controller.controller, '/devices')
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 (cwd is src/ when run via pipenv run run)
@app.route('/') @app.route('/')
@@ -85,41 +372,99 @@ async def main(port=80):
@app.route('/ws') @app.route('/ws')
@with_websocket @with_websocket
async def ws(request, ws): async def ws(request, ws):
while True: await register_device_status_ws(ws)
data = await ws.receive() await broadcast_device_tcp_snapshot_to(ws)
print(data) try:
if data: while True:
try: data = await ws.receive()
parsed = json.loads(data) print(data)
print("WS received JSON:", parsed) if data:
# 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: try:
await sender.send(data) parsed = json.loads(data)
print("WS received JSON:", parsed)
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await sender.send(payload, addr=addr)
except json.JSONDecodeError:
# Not JSON: send raw with default address
try:
await sender.send(data)
except Exception:
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
except Exception: except Exception:
try: try:
await ws.send(json.dumps({"error": "Send failed"})) await ws.send(json.dumps({"error": "Send failed"}))
except Exception: except Exception:
pass pass
except Exception: else:
try: break
await ws.send(json.dumps({"error": "Send failed"})) finally:
except Exception: await unregister_device_status_ws(ws)
pass
else:
break
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port)) # Touch Device singleton early so db/device.json exists before first TCP hello.
Device()
await _send_bridge_wifi_channel(settings, sender)
while True: tcp_holder = {}
await asyncio.sleep(30) udp_holder = {"closing": False}
# cleanup before ending the application loop = asyncio.get_running_loop()
def _graceful_shutdown(*_args):
print("[server] shutting down...")
udp_holder["closing"] = True
u = udp_holder.get("sock")
if u is not None:
try:
u.close()
except OSError:
pass
s = tcp_holder.get("server")
if s is not None:
s.close()
if getattr(app, "server", None) is not None:
app.shutdown()
shutdown_handlers_registered = False
try:
try:
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _graceful_shutdown)
shutdown_handlers_registered = True
except (NotImplementedError, RuntimeError):
pass
# Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface
# here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
# never starts, which clears Wi-Fi presence dots.
try:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_tcp_server(settings, tcp_holder),
_run_udp_discovery_server(udp_holder),
)
except OSError as e:
if e.errno == errno.EADDRINUSE:
tcp_p = int(settings.get("tcp_port", 8765))
print(
f"[server] bind failed (address already in use): {e!s}\n"
f"[server] HTTP is configured for port {port} (env PORT); "
f"Wi-Fi LED drivers use tcp_port {tcp_p}. "
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
)
raise
finally:
if shutdown_handlers_registered:
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.remove_signal_handler(sig)
except (NotImplementedError, OSError, ValueError):
pass
if __name__ == "__main__": if __name__ == "__main__":
import os import os

View File

@@ -1,49 +1,229 @@
"""
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 from models.model import Model
DEVICE_TYPES = frozenset({"led"})
DEVICE_TRANSPORTS = frozenset({"wifi", "espnow"})
def _normalize_address(addr):
"""Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons).""" def validate_device_type(value):
if addr is None: 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 return None
s = str(addr).strip().lower().replace(":", "").replace("-", "") s = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s): if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
return s return s
return None 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): class Device(Model):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
def create(self, name="", address=None, default_pattern=None, tabs=None): def load(self):
next_id = self.get_next_id() super().load()
addr = _normalize_address(address) changed = False
self[next_id] = { 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, "name": name,
"type": dt,
"transport": tr,
"address": addr, "address": addr,
"default_pattern": default_pattern if default_pattern else None, "default_pattern": default_pattern if default_pattern else None,
"tabs": list(tabs) if tabs else [], "zones": list(zones) if zones else [],
} }
self.save() self.save()
return next_id return mac_hex
def read(self, id): def read(self, id):
id_str = str(id) m = normalize_mac(id)
return self.get(id_str, None) if m is not None and m in self:
return self.get(m)
return self.get(str(id), None)
def update(self, id, data): def update(self, id, data):
id_str = str(id) id_str = normalize_mac(id)
if id_str is None:
id_str = str(id)
if id_str not in self: if id_str not in self:
return False return False
if "address" in data and data["address"] is not None: incoming = dict(data)
data = dict(data) incoming.pop("id", None)
data["address"] = _normalize_address(data["address"]) incoming.pop("addresses", None)
self[id_str].update(data) 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() self.save()
return True return True
def delete(self, id): def delete(self, id):
id_str = str(id) id_str = normalize_mac(id)
if id_str is None:
id_str = str(id)
if id_str not in self: if id_str not in self:
return False return False
self.pop(id_str) self.pop(id_str)
@@ -52,3 +232,48 @@ class Device(Model):
def list(self): def list(self):
return list(self.keys()) return list(self.keys())
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
"""
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
**address** (peer IP), and optionally **type** from the client hello when valid.
"""
mac_hex = normalize_mac(mac)
if not mac_hex:
return None
name = (device_name or "").strip()
if not name:
return None
ip = normalize_address_for_transport(peer_ip, "wifi")
if not ip:
return None
resolved_type = None
if device_type is not None:
try:
resolved_type = validate_device_type(device_type)
except ValueError:
resolved_type = None
if mac_hex in self:
merged = dict(self[mac_hex])
merged["name"] = name
if resolved_type is not None:
merged["type"] = resolved_type
else:
merged["type"] = validate_device_type(merged.get("type"))
merged["transport"] = "wifi"
merged["address"] = ip
merged["id"] = mac_hex
self[mac_hex] = merged
self.save()
return mac_hex
self[mac_hex] = {
"id": mac_hex,
"name": name,
"type": resolved_type or "led",
"transport": "wifi",
"address": ip,
"default_pattern": None,
"zones": [],
}
self.save()
return mac_hex

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

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

View File

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

View File

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

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

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

View File

@@ -39,11 +39,13 @@ class SerialSender:
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1) self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
self._default_addr = _parse_mac(default_addr) self._default_addr = _parse_mac(default_addr)
self._write_lock = asyncio.Lock()
async def send(self, data, addr=None): async def send(self, data, addr=None):
mac = _parse_mac(addr) if addr is not None else self._default_addr mac = _parse_mac(addr) if addr is not None else self._default_addr
payload = _encode_payload(data) payload = _encode_payload(data)
await _to_thread(self._serial.write, mac + payload) async with self._write_lock:
await _to_thread(self._serial.write, mac + payload)
return True return True

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

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

62
src/models/zone.py Normal file
View File

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

View File

@@ -48,6 +48,11 @@ class Settings(dict):
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111 # ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self: if 'wifi_channel' not in self:
self['wifi_channel'] = 6 self['wifi_channel'] = 6
# Wi-Fi LED drivers: newline-delimited JSON over TCP (see led-driver WiFi transport)
if 'tcp_enabled' not in self:
self['tcp_enabled'] = True
if 'tcp_port' not in self:
self['tcp_port'] = 8765
def save(self): def save(self):
try: try:

View File

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

View File

@@ -1,7 +1,101 @@
// Device management: list, create, edit, delete (name and 6-byte address) // Device registry: name, id (storage key), type (led), transport (wifi|espnow), address
const HEX_BOX_COUNT = 12; 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) { function makeHexAddressBoxes(container) {
if (!container || container.querySelector('.hex-addr-box')) return; if (!container || container.querySelector('.hex-addr-box')) return;
container.innerHTML = ''; container.innerHTML = '';
@@ -42,12 +136,6 @@ function makeHexAddressBoxes(container) {
} }
} }
function getAddressFromBoxes(container) {
if (!container) return '';
const boxes = container.querySelectorAll('.hex-addr-box');
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
}
function setAddressToBoxes(container, addrStr) { function setAddressToBoxes(container, addrStr) {
if (!container) return; if (!container) return;
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT); const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
@@ -57,9 +145,33 @@ function setAddressToBoxes(container, addrStr) {
}); });
} }
function applyTransportVisibility(transport) {
const isWifi = transport === 'wifi';
const esp = document.getElementById('edit-device-address-espnow');
const wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
if (esp) esp.hidden = isWifi;
if (wifiWrap) wifiWrap.hidden = !isWifi;
}
function getAddressForPayload(transport) {
if (transport === 'wifi') {
const el = document.getElementById('edit-device-address-wifi');
const v = (el && el.value.trim()) || '';
return v || null;
}
const boxEl = document.getElementById('edit-device-address-boxes');
if (!boxEl) return null;
const boxes = boxEl.querySelectorAll('.hex-addr-box');
const hex = Array.from(boxes).map((b) => b.value).join('').toLowerCase();
return hex || null;
}
async function loadDevicesModal() { async function loadDevicesModal() {
const container = document.getElementById('devices-list-modal'); const container = document.getElementById('devices-list-modal');
if (!container) return; if (!container) return;
if (typeof window.getEspnowSocket === 'function') {
window.getEspnowSocket();
}
container.innerHTML = '<span class="muted-text">Loading...</span>'; container.innerHTML = '<span class="muted-text">Loading...</span>';
try { try {
const response = await fetch('/devices', { headers: { Accept: 'application/json' } }); const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
@@ -80,42 +192,95 @@ function renderDevicesList(devices) {
if (ids.length === 0) { if (ids.length === 0) {
const p = document.createElement('p'); const p = document.createElement('p');
p.className = 'muted-text'; p.className = 'muted-text';
p.textContent = 'No devices. Create one above.'; p.textContent = 'No devices yet. Wi-Fi drivers will appear here when they connect over TCP.';
container.appendChild(p); container.appendChild(p);
return; return;
} }
ids.forEach((devId) => { ids.forEach((devId) => {
const dev = devices[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'); const row = document.createElement('div');
row.className = 'profiles-row'; row.className = 'profiles-row';
row.style.display = 'flex'; row.style.display = 'flex';
row.style.alignItems = 'center'; row.style.alignItems = 'center';
row.style.gap = '0.5rem'; row.style.gap = '0.5rem';
row.style.flexWrap = 'wrap'; 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'); const label = document.createElement('span');
label.textContent = (dev && dev.name) || devId; label.textContent = (dev && dev.name) || devId;
label.style.flex = '1'; label.style.flex = '1';
label.style.minWidth = '100px'; 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'); const meta = document.createElement('span');
meta.className = 'muted-text'; meta.className = 'muted-text';
meta.style.fontSize = '0.85em'; meta.style.fontSize = '0.85em';
const addr = (dev && dev.address) ? dev.address : '—'; meta.textContent = `${t} · ${tr} · ${addrDisplay}`;
meta.textContent = `Address: ${addr}`;
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small'; editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit'; editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev)); 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'); const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-secondary btn-small'; deleteBtn.className = 'btn btn-secondary btn-small';
deleteBtn.textContent = 'Delete'; deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return; if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
try { try {
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' }); const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'DELETE' });
if (res.ok) await loadDevicesModal(); if (res.ok) await loadDevicesModal();
else { else {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -127,53 +292,53 @@ function renderDevicesList(devices) {
} }
}); });
row.appendChild(dot);
row.appendChild(label); row.appendChild(label);
row.appendChild(macEl);
row.appendChild(meta); row.appendChild(meta);
row.appendChild(editBtn); row.appendChild(editBtn);
row.appendChild(identifyBtn);
row.appendChild(deleteBtn); row.appendChild(deleteBtn);
container.appendChild(row); 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) { function openEditDeviceModal(devId, dev) {
const modal = document.getElementById('edit-device-modal'); const modal = document.getElementById('edit-device-modal');
const idInput = document.getElementById('edit-device-id'); const idInput = document.getElementById('edit-device-id');
const storageLabel = document.getElementById('edit-device-storage-id');
const nameInput = document.getElementById('edit-device-name'); 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 addressBoxes = document.getElementById('edit-device-address-boxes');
const wifiInput = document.getElementById('edit-device-address-wifi');
if (!modal || !idInput) return; if (!modal || !idInput) return;
idInput.value = devId; idInput.value = devId;
if (storageLabel) storageLabel.textContent = devId;
if (nameInput) nameInput.value = (dev && dev.name) || ''; if (nameInput) nameInput.value = (dev && dev.name) || '';
setAddressToBoxes(addressBoxes, (dev && dev.address) || ''); if (typeSel) typeSel.value = (dev && dev.type) || 'led';
const tr = (dev && dev.transport) || 'espnow';
if (transportSel) transportSel.value = tr;
applyTransportVisibility(tr);
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
modal.classList.add('active'); modal.classList.add('active');
} }
async function createDevice(name, address) { async function updateDevice(devId, name, type, transport, address) {
try { try {
const res = await fetch('/devices', { const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address: address || null }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
await loadDevicesModal();
return true;
}
alert(data.error || 'Failed to create device');
return false;
} catch (e) {
console.error('createDevice:', e);
alert('Failed to create device');
return false;
}
}
async function updateDevice(devId, name, address) {
try {
const res = await fetch(`/devices/${devId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address: address || null }), body: JSON.stringify({
name,
type: type || 'led',
transport: transport || 'espnow',
address,
}),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (res.ok) { if (res.ok) {
@@ -190,14 +355,41 @@ async function updateDevice(devId, name, address) {
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
makeHexAddressBoxes(document.getElementById('new-device-address-boxes')); window.addEventListener('deviceTcpStatus', (ev) => {
const { ip, connected } = ev.detail || {};
if (ip == null || typeof connected !== 'boolean') return;
mergeTcpSnapshotPresence(ip, connected);
const norm = normalizeWifiAddressForMatch(ip);
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
if (normalizeWifiAddressForMatch(row.dataset.deviceAddress) === norm) {
updateWifiRowDot(row, connected);
}
});
});
window.addEventListener('deviceTcpSnapshot', (ev) => {
const ips = ev.detail && ev.detail.connectedIps;
lastTcpSnapshotIps = ips;
applyTcpSnapshot(ips);
});
window.addEventListener('deviceTcpWsOpen', () => {
refreshDevicesListQuiet();
});
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes')); 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 devicesBtn = document.getElementById('devices-btn');
const devicesModal = document.getElementById('devices-modal'); const devicesModal = document.getElementById('devices-modal');
const devicesCloseBtn = document.getElementById('devices-close-btn'); const devicesCloseBtn = document.getElementById('devices-close-btn');
const newName = document.getElementById('new-device-name');
const createBtn = document.getElementById('create-device-btn');
const editForm = document.getElementById('edit-device-form'); const editForm = document.getElementById('edit-device-form');
const editCloseBtn = document.getElementById('edit-device-close-btn'); const editCloseBtn = document.getElementById('edit-device-close-btn');
const editDeviceModal = document.getElementById('edit-device-modal'); const editDeviceModal = document.getElementById('edit-device-modal');
@@ -205,41 +397,44 @@ document.addEventListener('DOMContentLoaded', () => {
if (devicesBtn && devicesModal) { if (devicesBtn && devicesModal) {
devicesBtn.addEventListener('click', () => { devicesBtn.addEventListener('click', () => {
devicesModal.classList.add('active'); devicesModal.classList.add('active');
if (typeof window.getEspnowSocket === 'function') {
window.getEspnowSocket();
}
loadDevicesModal(); loadDevicesModal();
startDevicesModalLiveRefresh();
}); });
} }
if (devicesCloseBtn) { if (devicesCloseBtn) {
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active')); 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'] });
} }
const newAddressBoxes = document.getElementById('new-device-address-boxes');
const doCreate = async () => {
const name = (newName && newName.value.trim()) || '';
if (!name) {
alert('Device name is required.');
return;
}
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
const ok = await createDevice(name, address);
if (ok && newName) {
newName.value = '';
setAddressToBoxes(newAddressBoxes, '');
}
};
if (createBtn) createBtn.addEventListener('click', doCreate);
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
if (editForm) { if (editForm) {
editForm.addEventListener('submit', async (e) => { editForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const idInput = document.getElementById('edit-device-id'); const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name'); const nameInput = document.getElementById('edit-device-name');
const addressBoxes = document.getElementById('edit-device-address-boxes'); const typeSel = document.getElementById('edit-device-type');
const transportSel = document.getElementById('edit-device-transport');
const devId = idInput && idInput.value; const devId = idInput && idInput.value;
if (!devId) return; if (!devId) return;
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : ''; const transport = (transportSel && transportSel.value) || 'espnow';
const address = getAddressForPayload(transport);
const ok = await updateDevice( const ok = await updateDevice(
devId, devId,
nameInput ? nameInput.value.trim() : '', nameInput ? nameInput.value.trim() : '',
(typeSel && typeSel.value) || 'led',
transport,
address address
); );
if (ok) editDeviceModal.classList.remove('active'); if (ok) editDeviceModal.classList.remove('active');

View File

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

View File

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

View File

@@ -74,12 +74,14 @@ const getEspnowSocket = () => {
return espnowSocket; return espnowSocket;
} }
const wsUrl = `ws://${window.location.host}/ws`; const wsScheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsScheme}//${window.location.host}/ws`;
espnowSocket = new WebSocket(wsUrl); espnowSocket = new WebSocket(wsUrl);
espnowSocketReady = false; espnowSocketReady = false;
espnowSocket.onopen = () => { espnowSocket.onopen = () => {
espnowSocketReady = true; espnowSocketReady = true;
window.dispatchEvent(new CustomEvent('deviceTcpWsOpen'));
// Flush any queued messages // Flush any queued messages
espnowPendingMessages.forEach((msg) => { espnowPendingMessages.forEach((msg) => {
try { try {
@@ -94,6 +96,18 @@ const getEspnowSocket = () => {
espnowSocket.onmessage = (event) => { espnowSocket.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data && data.type === 'device_tcp' && typeof data.connected === 'boolean' && data.ip) {
window.dispatchEvent(
new CustomEvent('deviceTcpStatus', { detail: { ip: data.ip, connected: data.connected } }),
);
return;
}
if (data && data.type === 'device_tcp_snapshot' && Array.isArray(data.connected_ips)) {
window.dispatchEvent(
new CustomEvent('deviceTcpSnapshot', { detail: { connectedIps: data.connected_ips } }),
);
return;
}
if (data && data.error) { if (data && data.error) {
console.error('ESP-NOW:', data.error); console.error('ESP-NOW:', data.error);
alert('ESP-NOW send failed. ' + (data.error === 'ESP-NOW send failed' ? 'Check device WiFi/interface.' : data.error)); alert('ESP-NOW send failed. ' + (data.error === 'ESP-NOW send failed' ? 'Check device WiFi/interface.' : data.error));
@@ -130,17 +144,44 @@ const sendEspnowMessage = (obj) => {
} }
}; };
// Send a select message for a preset to all device names in the current tab. function tabDeviceNamesFromSection(section) {
// Uses the preset ID as the select key. if (typeof window.parseTabDeviceNames === 'function') {
const sendSelectForCurrentTabDevices = (presetId, sectionEl) => { return window.parseTabDeviceNames(section);
const section = sectionEl || document.querySelector('.presets-section[data-tab-id]'); }
const namesAttr = section && section.getAttribute('data-device-names');
return namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
}
async function postDriverSequence(sequence, targetMacs, delayS) {
const body = {
sequence,
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
};
if (delayS != null && delayS >= 0) {
body.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(() => ({}));
}
// Send a select message for a preset to all devices on the current zone (ESP-NOW or Wi-Fi).
const sendSelectForCurrentTabDevices = async (presetId, sectionEl) => {
const section = sectionEl || document.querySelector('.presets-section[data-zone-id]');
if (!section || !presetId) { if (!section || !presetId) {
return; return;
} }
const namesAttr = section.getAttribute('data-device-names'); const deviceNames = tabDeviceNamesFromSection(section);
const deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
if (!deviceNames.length) { if (!deviceNames.length) {
return; return;
@@ -148,15 +189,23 @@ const sendSelectForCurrentTabDevices = (presetId, sectionEl) => {
const select = {}; const select = {};
deviceNames.forEach((name) => { deviceNames.forEach((name) => {
select[name] = [presetId]; if (name) {
select[name] = [presetId];
}
}); });
const message = { const targetMacs =
v: '1', typeof window.tabsManager !== 'undefined' &&
select, typeof window.tabsManager.resolveTabDeviceMacs === 'function'
}; ? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
: [];
sendEspnowMessage(message); try {
await postDriverSequence([{ v: '1', select }], targetMacs);
} catch (err) {
console.error('sendSelectForCurrentTabDevices:', err);
alert('Failed to send preset selection to devices.');
}
}; };
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -174,7 +223,7 @@ document.addEventListener('DOMContentLoaded', () => {
const presetBrightnessInput = document.getElementById('preset-brightness-input'); const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input'); const presetDelayInput = document.getElementById('preset-delay-input');
const presetDefaultButton = document.getElementById('preset-default-btn'); const presetDefaultButton = document.getElementById('preset-default-btn');
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-tab-btn'); const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
const presetSaveButton = document.getElementById('preset-save-btn'); const presetSaveButton = document.getElementById('preset-save-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn'); const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
@@ -499,14 +548,10 @@ document.addEventListener('DOMContentLoaded', () => {
presetPatternInput.style.cursor = ''; presetPatternInput.style.cursor = '';
} }
// Update labels and visibility based on pattern
updatePresetNLabels(patternName);
// Get pattern config to map descriptive names back to n keys // Get pattern config to map descriptive names back to n keys
const patternConfig = cachedPatterns && cachedPatterns[patternName]; const patternConfig = cachedPatterns && cachedPatterns[patternName];
const nToLabel = {}; const nToLabel = {};
if (patternConfig && typeof patternConfig === 'object') { if (patternConfig && typeof patternConfig === 'object') {
// Now n keys are keys, labels are values
Object.entries(patternConfig).forEach(([nKey, label]) => { Object.entries(patternConfig).forEach(([nKey, label]) => {
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') { if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
nToLabel[nKey] = label; nToLabel[nKey] = label;
@@ -519,11 +564,9 @@ document.addEventListener('DOMContentLoaded', () => {
const nKey = `n${i}`; const nKey = `n${i}`;
const inputEl = document.getElementById(`preset-${nKey}-input`); const inputEl = document.getElementById(`preset-${nKey}-input`);
if (inputEl) { if (inputEl) {
// First check if preset has n key directly
if (preset[nKey] !== undefined) { if (preset[nKey] !== undefined) {
inputEl.value = preset[nKey] || 0; inputEl.value = preset[nKey] || 0;
} else { } else {
// Check if preset has descriptive name (from pattern.json mapping)
const label = nToLabel[nKey]; const label = nToLabel[nKey];
if (label && preset[label] !== undefined) { if (label && preset[label] !== undefined) {
inputEl.value = preset[label] || 0; inputEl.value = preset[label] || 0;
@@ -533,6 +576,9 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
} }
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
updatePresetNLabels(patternName);
updatePresetEditorTabActionsVisibility(); updatePresetEditorTabActionsVisibility();
}; };
@@ -574,8 +620,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (currentEditTabId) { if (currentEditTabId) {
return currentEditTabId; return currentEditTabId;
} }
const section = document.querySelector('.presets-section[data-tab-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
return section ? section.dataset.tabId : null; return section ? section.dataset.zoneId : null;
}; };
const updatePresetEditorTabActionsVisibility = () => { const updatePresetEditorTabActionsVisibility = () => {
@@ -585,12 +631,12 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
const updateTabDefaultPreset = async (presetId) => { const updateTabDefaultPreset = async (presetId) => {
const tabId = getActiveTabId(); const zoneId = getActiveTabId();
if (!tabId) { if (!zoneId) {
return; return;
} }
try { try {
const tabResponse = await fetch(`/tabs/${tabId}`, { const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (!tabResponse.ok) { if (!tabResponse.ok) {
@@ -598,13 +644,13 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
tabData.default_preset = presetId; tabData.default_preset = presetId;
await fetch(`/tabs/${tabId}`, { await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData), body: JSON.stringify(tabData),
}); });
} catch (error) { } catch (error) {
console.warn('Failed to save tab default preset:', error); console.warn('Failed to save zone default preset:', error);
} }
}; };
@@ -725,44 +771,65 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
const updatePresetNLabels = (patternName) => { const updatePresetNLabels = (patternName) => {
const rawPatternName = String(patternName || '').trim();
const normalizedPatternName = rawPatternName.endsWith('.py')
? rawPatternName.slice(0, -3)
: rawPatternName;
let patternConfig =
(cachedPatterns && cachedPatterns[rawPatternName]) ||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
null;
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
const lower = normalizedPatternName.toLowerCase();
const matchedKey = Object.keys(cachedPatterns).find(
(k) => String(k).toLowerCase() === lower,
);
if (matchedKey) {
patternConfig = cachedPatterns[matchedKey];
}
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
patternConfig = patternConfig.data;
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') {
patternConfig = patternConfig.parameter_mappings;
}
const labels = {}; const labels = {};
const visibleNKeys = new Set(); const visibleNKeys = new Set();
// Initialize all labels with default n1:, n2:, etc.
for (let i = 1; i <= 8; i++) {
labels[`n${i}`] = `n${i}:`;
}
const patternConfig = cachedPatterns && cachedPatterns[patternName];
if (patternConfig && typeof patternConfig === 'object') { if (patternConfig && typeof patternConfig === 'object') {
// Now n values are keys and descriptive names are values
Object.entries(patternConfig).forEach(([key, label]) => { Object.entries(patternConfig).forEach(([key, label]) => {
if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') { if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') {
labels[key] = `${label}:`; const text = label.trim();
visibleNKeys.add(key); // Mark this n key as visible if (text) {
labels[key] = `${text}:`;
visibleNKeys.add(key);
}
} }
}); });
} }
// Update labels and show/hide input groups
for (let i = 1; i <= 8; i++) { for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`; const nKey = `n${i}`;
const labelEl = document.getElementById(`preset-${nKey}-label`); const labelEl = document.getElementById(`preset-${nKey}-label`);
const inputEl = document.getElementById(`preset-${nKey}-input`);
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null; const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
const show = visibleNKeys.has(nKey);
const inputEl = document.getElementById(`preset-${nKey}-input`);
if (labelEl) { if (labelEl) {
labelEl.textContent = labels[nKey]; labelEl.textContent = show ? labels[nKey] : '';
} }
// Show or hide the entire group based on whether it has a mapping
if (groupEl) { if (groupEl) {
if (visibleNKeys.has(nKey)) { groupEl.style.display = show ? '' : 'none';
groupEl.style.display = ''; // Show
} else {
groupEl.style.display = 'none'; // Hide
}
} }
if (inputEl && !show) {
inputEl.value = '0';
}
}
const nGrid = presetEditorModal && presetEditorModal.querySelector('.n-params-grid');
if (nGrid) {
nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none';
} }
}; };
@@ -796,6 +863,7 @@ document.addEventListener('DOMContentLoaded', () => {
editButton.addEventListener('click', async () => { editButton.addEventListener('click', async () => {
currentEditId = presetId; currentEditId = presetId;
currentEditTabId = null; currentEditTabId = null;
await loadPatterns();
const paletteColors = await getCurrentProfilePaletteColors(); const paletteColors = await getCurrentProfilePaletteColors();
const presetForEditor = { const presetForEditor = {
...(preset || {}), ...(preset || {}),
@@ -812,10 +880,10 @@ document.addEventListener('DOMContentLoaded', () => {
const sendButton = document.createElement('button'); const sendButton = document.createElement('button');
sendButton.className = 'btn btn-primary btn-small'; sendButton.className = 'btn btn-primary btn-small';
sendButton.textContent = 'Send'; sendButton.textContent = 'Send';
sendButton.title = 'Send this preset via ESPNow'; sendButton.title = 'Send this preset to drivers';
sendButton.addEventListener('click', () => { sendButton.addEventListener('click', () => {
// Just send the definition; selection happens when user clicks the preset. // Just send the definition; selection happens when user clicks the preset.
sendPresetViaEspNow(presetId, preset || {}); void sendPresetViaEspNow(presetId, preset || {}, []);
}); });
const deleteButton = document.createElement('button'); const deleteButton = document.createElement('button');
@@ -901,22 +969,22 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const showAddPresetToTabModal = async (optionalTabId) => { const showAddPresetToTabModal = async (optionalTabId) => {
let tabId = optionalTabId; let zoneId = optionalTabId;
if (!tabId) { if (!zoneId) {
// Get current tab ID from the presets section // Get current zone ID from the presets section
const leftPanel = document.querySelector('.presets-section[data-tab-id]'); const leftPanel = document.querySelector('.presets-section[data-zone-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null; zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
} }
if (!tabId) { if (!zoneId) {
// Fallback: try to get from URL // Fallback: try to get from URL
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs'); const tabIndex = pathParts.indexOf('zones');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1]; zoneId = pathParts[tabIndex + 1];
} }
} }
if (!tabId) { if (!zoneId) {
alert('Could not determine current tab.'); alert('Could not determine current zone.');
return; return;
} }
@@ -931,10 +999,10 @@ document.addEventListener('DOMContentLoaded', () => {
const allPresetsRaw = await response.json(); const allPresetsRaw = await response.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw); const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
// Load only the current tab's presets so we can avoid duplicates within this tab. // Load only the current zone's presets so we can avoid duplicates within this zone.
let currentTabPresets = []; let currentTabPresets = [];
try { try {
const tabResponse = await fetch(`/tabs/${tabId}`, { const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (tabResponse.ok) { if (tabResponse.ok) {
@@ -950,19 +1018,19 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
} catch (e) { } catch (e) {
console.warn('Could not load current tab presets:', e); console.warn('Could not load current zone presets:', e);
} }
// Create modal // Create modal
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'modal active'; modal.className = 'modal active';
modal.id = 'add-preset-to-tab-modal'; modal.id = 'add-preset-to-zone-modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content"> <div class="modal-content">
<h2>Add Preset to Tab</h2> <h2>Add Preset to Zone</h2>
<div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div> <div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="add-preset-to-tab-close-btn">Close</button> <button class="btn btn-secondary" id="add-preset-to-zone-close-btn">Close</button>
</div> </div>
</div> </div>
`; `;
@@ -974,7 +1042,7 @@ document.addEventListener('DOMContentLoaded', () => {
const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId)); const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
if (availableToAdd.length === 0) { if (availableToAdd.length === 0) {
listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this tab, or create a preset first.</p>'; listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this zone, or create a preset first.</p>';
} else { } else {
availableToAdd.forEach(presetId => { availableToAdd.forEach(presetId => {
const preset = allPresets[presetId]; const preset = allPresets[presetId];
@@ -993,7 +1061,7 @@ document.addEventListener('DOMContentLoaded', () => {
addButton.className = 'btn btn-primary btn-small'; addButton.className = 'btn btn-primary btn-small';
addButton.textContent = 'Add'; addButton.textContent = 'Add';
addButton.addEventListener('click', async () => { addButton.addEventListener('click', async () => {
await addPresetToTab(presetId, tabId); await addPresetToTab(presetId, zoneId);
modal.remove(); modal.remove();
}); });
@@ -1005,7 +1073,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// Close button handler // Close button handler
document.getElementById('add-preset-to-tab-close-btn').addEventListener('click', () => { document.getElementById('add-preset-to-zone-close-btn').addEventListener('click', () => {
modal.remove(); modal.remove();
}); });
@@ -1018,34 +1086,34 @@ document.addEventListener('DOMContentLoaded', () => {
window.showAddPresetToTabModal = showAddPresetToTabModal; window.showAddPresetToTabModal = showAddPresetToTabModal;
} catch (e) {} } catch (e) {}
const addPresetToTab = async (presetId, tabId) => { const addPresetToTab = async (presetId, zoneId) => {
if (!tabId) { if (!zoneId) {
// Try to get tab ID from the left-panel // Try to get zone ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]'); const leftPanel = document.querySelector('.presets-section[data-zone-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null; zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
if (!tabId) { if (!zoneId) {
// Fallback: try to get from URL // Fallback: try to get from URL
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs'); const tabIndex = pathParts.indexOf('zones');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1]; zoneId = pathParts[tabIndex + 1];
} }
} }
} }
if (!tabId) { if (!zoneId) {
alert('Could not determine current tab.'); alert('Could not determine current zone.');
return; return;
} }
try { try {
// Get current tab data // Get current zone data
const tabResponse = await fetch(`/tabs/${tabId}`, { const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (!tabResponse.ok) { if (!tabResponse.ok) {
throw new Error('Failed to load tab'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
@@ -1062,7 +1130,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
if (flat.includes(presetId)) { if (flat.includes(presetId)) {
alert('Preset is already added to this tab.'); alert('Preset is already added to this zone.');
return; return;
} }
@@ -1071,23 +1139,23 @@ document.addEventListener('DOMContentLoaded', () => {
tabData.presets = newGrid; tabData.presets = newGrid;
tabData.presets_flat = flat; tabData.presets_flat = flat;
// Update tab // Update zone
const updateResponse = await fetch(`/tabs/${tabId}`, { const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData), body: JSON.stringify(tabData),
}); });
if (!updateResponse.ok) { if (!updateResponse.ok) {
throw new Error('Failed to update tab'); throw new Error('Failed to update zone');
} }
// Reload the tab content to show the new preset // Reload the zone content to show the new preset
if (typeof renderTabPresets === 'function') { if (typeof renderTabPresets === 'function') {
await renderTabPresets(tabId); await renderTabPresets(zoneId);
} else if (window.htmx) { } else if (window.htmx) {
htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, { htmx.ajax('GET', `/zones/${zoneId}/content-fragment`, {
target: '#tab-content', target: '#zone-content',
swap: 'innerHTML' swap: 'innerHTML'
}); });
} else { } else {
@@ -1095,8 +1163,8 @@ document.addEventListener('DOMContentLoaded', () => {
window.location.reload(); window.location.reload();
} }
} catch (error) { } catch (error) {
console.error('Failed to add preset to tab:', error); console.error('Failed to add preset to zone:', error);
alert('Failed to add preset to tab.'); alert('Failed to add preset to zone.');
} }
}; };
try { try {
@@ -1220,12 +1288,9 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required to send.'); alert('Preset name is required to send.');
return; return;
} }
// Send current editor values and then select on all devices in the current tab (if any) // Send current editor values and then select on all devices in the current zone (if any)
const section = document.querySelector('.presets-section[data-tab-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
const namesAttr = section && section.getAttribute('data-device-names'); const deviceNames = tabDeviceNamesFromSection(section);
const deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name // Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
const presetId = currentEditId || payload.name; const presetId = currentEditId || payload.name;
// Try sends preset first, then select; never persist on device. // Try sends preset first, then select; never persist on device.
@@ -1240,21 +1305,18 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required.'); alert('Preset name is required.');
return; return;
} }
const section = document.querySelector('.presets-section[data-tab-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
const namesAttr = section && section.getAttribute('data-device-names'); const deviceNames = tabDeviceNamesFromSection(section);
const deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
const presetId = currentEditId || payload.name; const presetId = currentEditId || payload.name;
await updateTabDefaultPreset(presetId); await updateTabDefaultPreset(presetId);
sendDefaultPreset(presetId, deviceNames); await sendDefaultPreset(presetId, deviceNames);
}); });
} }
if (presetRemoveFromTabButton) { if (presetRemoveFromTabButton) {
presetRemoveFromTabButton.addEventListener('click', async () => { presetRemoveFromTabButton.addEventListener('click', async () => {
if (!currentEditTabId || !currentEditId) return; if (!currentEditTabId || !currentEditId) return;
if (!window.confirm('Remove this preset from this tab?')) return; if (!window.confirm('Remove this preset from this zone?')) return;
await removePresetFromTab(currentEditTabId, currentEditId); await removePresetFromTab(currentEditTabId, currentEditId);
clearForm(); clearForm();
closeEditor(); closeEditor();
@@ -1285,32 +1347,32 @@ document.addEventListener('DOMContentLoaded', () => {
if (currentEditId) { if (currentEditId) {
// PUT returns the preset object directly; use the existing ID // PUT returns the preset object directly; use the existing ID
// Save & Send should not force-select the preset on devices. // Save & Send should not force-select the preset on devices.
sendPresetViaEspNow(currentEditId, saved, [], true, false); await sendPresetViaEspNow(currentEditId, saved, [], true, false);
} else { } else {
// POST returns { id: preset } // POST returns { id: preset }
const entries = Object.entries(saved); const entries = Object.entries(saved);
if (entries.length > 0) { if (entries.length > 0) {
const [newId, presetData] = entries[0]; const [newId, presetData] = entries[0];
// Save & Send should not force-select the preset on devices. // Save & Send should not force-select the preset on devices.
sendPresetViaEspNow(newId, presetData, [], true, false); await sendPresetViaEspNow(newId, presetData, [], true, false);
} }
} }
} else { } else {
// Fallback: send what we just built // Fallback: send what we just built
// Save & Send should not force-select the preset on devices. // Save & Send should not force-select the preset on devices.
sendPresetViaEspNow(payload.name, payload, [], true, false); await sendPresetViaEspNow(payload.name, payload, [], true, false);
} }
await loadPresets(); await loadPresets();
clearForm(); clearForm();
closeEditor(); closeEditor();
// Reload tab presets if we're in a tab view // Reload zone presets if we're in a zone view
const leftPanel = document.querySelector('.presets-section[data-tab-id]'); const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) { if (leftPanel) {
const tabId = leftPanel.dataset.tabId; const zoneId = leftPanel.dataset.zoneId;
if (tabId && typeof renderTabPresets !== 'undefined') { if (zoneId && typeof renderTabPresets !== 'undefined') {
renderTabPresets(tabId); renderTabPresets(zoneId);
} }
} }
} catch (error) { } catch (error) {
@@ -1319,11 +1381,11 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
// Listen for edit preset events from tab preset buttons // Listen for edit preset events from zone preset buttons
document.addEventListener('editPreset', async (event) => { document.addEventListener('editPreset', async (event) => {
const { presetId, preset, tabId } = event.detail; const { presetId, preset, zoneId } = event.detail;
currentEditId = presetId; currentEditId = presetId;
currentEditTabId = tabId || null; currentEditTabId = zoneId || null;
await loadPatterns(); await loadPatterns();
const paletteColors = await getCurrentProfilePaletteColors(); const paletteColors = await getCurrentProfilePaletteColors();
setFormValues({ setFormValues({
@@ -1340,7 +1402,7 @@ document.addEventListener('DOMContentLoaded', () => {
clearForm(); clearForm();
}); });
// Build ESPNow messages for a single preset. // Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
// Send order: // Send order:
// 1) preset payload (optionally with save) // 1) preset payload (optionally with save)
// 2) optional select for device names (never with save) // 2) optional select for device names (never with save)
@@ -1380,62 +1442,76 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
presetMessage.default = presetId; presetMessage.default = presetId;
} }
// 1) Send presets first, without save. const names = Array.isArray(deviceNames) ? deviceNames : [];
sendEspnowMessage(presetMessage); const targetMacs =
names.length > 0 &&
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(names)
: [];
// Optionally send a separate select message for specific devices. const sequence = [presetMessage];
if (Array.isArray(deviceNames) && deviceNames.length > 0) { if (names.length > 0) {
const select = {}; const select = {};
deviceNames.forEach((name) => { names.forEach((name) => {
if (name) { if (name) {
select[name] = [presetId]; select[name] = [presetId];
} }
}); });
if (Object.keys(select).length > 0) { if (Object.keys(select).length > 0) {
// Small gap helps slower receivers process preset update before select. sequence.push({ v: '1', select });
await new Promise((resolve) => setTimeout(resolve, 30));
sendEspnowMessage({ v: '1', select });
} }
} }
await postDriverSequence(sequence, targetMacs, 0.05);
} catch (error) { } catch (error) {
console.error('Failed to send preset via ESPNow:', error); console.error('Failed to send preset to devices:', error);
alert('Failed to send preset via ESPNow.'); alert('Failed to send preset to devices.');
} }
}; };
const sendDefaultPreset = (presetId, deviceNames) => { const sendDefaultPreset = async (presetId, deviceNames) => {
if (!presetId) { if (!presetId) {
alert('Select a preset to set as default.'); alert('Select a preset to set as default.');
return; return;
} }
// Default should only set startup preset, not trigger live selection. const nameTargets = Array.isArray(deviceNames)
// Save is attached to default messages.
// When device names are provided, scope the default update to those devices.
const targets = Array.isArray(deviceNames)
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0) ? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
: []; : [];
const message = { v: '1', default: presetId }; const message = { v: '1', default: presetId };
message.save = true; message.save = true;
if (targets.length > 0) { if (nameTargets.length > 0) {
message.targets = targets; message.targets = nameTargets;
}
const macTargets =
nameTargets.length > 0 &&
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(nameTargets)
: [];
try {
await postDriverSequence([message], macTargets);
} catch (e) {
console.error('sendDefaultPreset:', e);
alert('Failed to send default preset to devices.');
} }
sendEspnowMessage(message);
}; };
// Expose for other scripts (tabs.js) so they can reuse the shared WebSocket. // Expose for other scripts (zones.js) so they can reuse the shared WebSocket.
try { try {
window.sendPresetViaEspNow = sendPresetViaEspNow; window.sendPresetViaEspNow = sendPresetViaEspNow;
// Expose a generic ESPNow sender so other scripts (tabs.js) can send window.postDriverSequence = postDriverSequence;
// Expose a generic ESPNow sender so other scripts (zones.js) can send
// non-preset messages such as global brightness. // non-preset messages such as global brightness.
window.sendEspnowRaw = sendEspnowMessage; window.sendEspnowRaw = sendEspnowMessage;
window.getEspnowSocket = getEspnowSocket;
} catch (e) { } catch (e) {
// window may not exist in some environments; ignore. // window may not exist in some environments; ignore.
} }
// Store selected preset per tab // Store selected preset per zone
const selectedPresets = {}; const selectedPresets = {};
// Run vs Edit for tab preset strip (in-memory only — each full page load starts in run mode) // Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode)
let presetUiMode = 'run'; let presetUiMode = 'run';
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run'); const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
@@ -1502,15 +1578,15 @@ const arrayToGrid = (presetIds, columns = 3) => {
return grid; return grid;
}; };
// Function to save preset grid for a tab // Function to save preset grid for a zone
const savePresetGrid = async (tabId, presetGrid) => { const savePresetGrid = async (zoneId, presetGrid) => {
try { try {
// Get current tab data // Get current zone data
const tabResponse = await fetch(`/tabs/${tabId}`, { const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (!tabResponse.ok) { if (!tabResponse.ok) {
throw new Error('Failed to load tab'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
@@ -1519,8 +1595,8 @@ const savePresetGrid = async (tabId, presetGrid) => {
// Also store as flat array for backward compatibility // Also store as flat array for backward compatibility
tabData.presets_flat = presetGrid.flat(); tabData.presets_flat = presetGrid.flat();
// Save updated tab // Save updated zone
const updateResponse = await fetch(`/tabs/${tabId}`, { const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData), body: JSON.stringify(tabData),
@@ -1574,18 +1650,18 @@ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
} }
}; };
// Function to render presets for a specific tab in 2D grid // Function to render presets for a specific zone in 2D grid
const renderTabPresets = async (tabId) => { const renderTabPresets = async (zoneId) => {
const presetsList = document.getElementById('presets-list-tab'); const presetsList = document.getElementById('presets-list-zone');
if (!presetsList) return; if (!presetsList) return;
try { try {
// Get tab data to see which presets are associated // Get zone data to see which presets are associated
const tabResponse = await fetch(`/tabs/${tabId}`, { const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (!tabResponse.ok) { if (!tabResponse.ok) {
throw new Error('Failed to load tab'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
@@ -1612,7 +1688,7 @@ const renderTabPresets = async (tabId) => {
const paletteColors = await getCurrentProfilePaletteColors(); const paletteColors = await getCurrentProfilePaletteColors();
presetsList.innerHTML = ''; presetsList.innerHTML = '';
presetsList.dataset.reorderTabId = tabId; presetsList.dataset.reorderTabId = zoneId;
// Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise) // Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
if (!presetsList.dataset.dragWired) { if (!presetsList.dataset.dragWired) {
@@ -1662,7 +1738,7 @@ const renderTabPresets = async (tabId) => {
try { try {
if (!saveId) { if (!saveId) {
console.warn('No tab id for preset reorder save'); console.warn('No zone id for preset reorder save');
return; return;
} }
await savePresetGrid(saveId, newGrid); await savePresetGrid(saveId, newGrid);
@@ -1676,19 +1752,19 @@ const renderTabPresets = async (tabId) => {
}); });
} }
// Get the currently selected preset for this tab // Get the currently selected preset for this zone
const selectedPresetId = selectedPresets[tabId]; const selectedPresetId = selectedPresets[zoneId];
// Render presets in grid layout // Render presets in grid layout
// Flatten the grid and render all presets (grid CSS will handle layout) // Flatten the grid and render all presets (grid CSS will handle layout)
const flatPresets = presetGrid.flat().filter(id => id); const flatPresets = presetGrid.flat().filter(id => id);
if (flatPresets.length === 0) { if (flatPresets.length === 0) {
// Show empty message if this tab has no presets // Show empty message if this zone has no presets
const empty = document.createElement('p'); const empty = document.createElement('p');
empty.className = 'muted-text'; empty.className = 'muted-text';
empty.style.gridColumn = '1 / -1'; // Span all columns empty.style.gridColumn = '1 / -1'; // Span all columns
empty.textContent = 'No presets added to this tab. Open the tab\'s Edit menu and click "Add Preset" to add one.'; empty.textContent = 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
presetsList.appendChild(empty); presetsList.appendChild(empty);
} else { } else {
flatPresets.forEach((presetId) => { flatPresets.forEach((presetId) => {
@@ -1699,18 +1775,18 @@ const renderTabPresets = async (tabId) => {
...preset, ...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors), colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
}; };
const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected); const wrapper = createPresetButton(presetId, displayPreset, zoneId, isSelected);
presetsList.appendChild(wrapper); presetsList.appendChild(wrapper);
} }
}); });
} }
} catch (error) { } catch (error) {
console.error('Failed to render tab presets:', error); console.error('Failed to render zone presets:', error);
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>'; presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
} }
}; };
const createPresetButton = (presetId, preset, tabId, isSelected = false) => { const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
const uiMode = getPresetUiMode(); const uiMode = getPresetUiMode();
const row = document.createElement('div'); const row = document.createElement('div');
@@ -1749,14 +1825,16 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
if (isDraggingPreset) return; if (isDraggingPreset) return;
const presetsListEl = document.getElementById('presets-list-tab'); const presetsListEl = document.getElementById('presets-list-zone');
if (presetsListEl) { if (presetsListEl) {
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active')); presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
} }
button.classList.add('active'); button.classList.add('active');
selectedPresets[tabId] = presetId; selectedPresets[zoneId] = presetId;
const section = row.closest('.presets-section'); const section = row.closest('.presets-section');
sendSelectForCurrentTabDevices(presetId, section); sendSelectForCurrentTabDevices(presetId, section).catch((err) => {
console.error(err);
});
}); });
if (canDrag) { if (canDrag) {
@@ -1769,7 +1847,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
row.addEventListener('dragend', () => { row.addEventListener('dragend', () => {
row.classList.remove('dragging'); row.classList.remove('dragging');
const presetsListEl = document.getElementById('presets-list-tab'); const presetsListEl = document.getElementById('presets-list-zone');
if (presetsListEl) { if (presetsListEl) {
delete presetsListEl.dataset.dropTargetId; delete presetsListEl.dataset.dropTargetId;
} }
@@ -1795,7 +1873,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (isDraggingPreset) return; if (isDraggingPreset) return;
editPresetFromTab(presetId, tabId, preset); editPresetFromTab(presetId, zoneId, preset);
}); });
actions.appendChild(editBtn); actions.appendChild(editBtn);
@@ -1805,7 +1883,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
return row; return row;
}; };
const editPresetFromTab = async (presetId, tabId, existingPreset) => { const editPresetFromTab = async (presetId, zoneId, existingPreset) => {
try { try {
let preset = existingPreset; let preset = existingPreset;
if (!preset) { if (!preset) {
@@ -1821,7 +1899,7 @@ const editPresetFromTab = async (presetId, tabId, existingPreset) => {
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope // Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
const editEvent = new CustomEvent('editPreset', { const editEvent = new CustomEvent('editPreset', {
detail: { presetId, preset, tabId } detail: { presetId, preset, zoneId }
}); });
document.dispatchEvent(editEvent); document.dispatchEvent(editEvent);
} catch (error) { } catch (error) {
@@ -1830,36 +1908,36 @@ const editPresetFromTab = async (presetId, tabId, existingPreset) => {
} }
}; };
// Remove a preset from a specific tab (does not delete the preset itself) // Remove a preset from a specific zone (does not delete the preset itself)
// Expected call style: removePresetFromTab(tabId, presetId) // Expected call style: removePresetFromTab(zoneId, presetId)
const removePresetFromTab = async (tabId, presetId) => { const removePresetFromTab = async (zoneId, presetId) => {
if (!tabId) { if (!zoneId) {
// Try to get tab ID from the left-panel // Try to get zone ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]'); const leftPanel = document.querySelector('.presets-section[data-zone-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null; zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
if (!tabId) { if (!zoneId) {
// Fallback: try to get from URL // Fallback: try to get from URL
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs'); const tabIndex = pathParts.indexOf('zones');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1]; zoneId = pathParts[tabIndex + 1];
} }
} }
} }
if (!tabId) { if (!zoneId) {
alert('Could not determine current tab.'); alert('Could not determine current zone.');
return; return;
} }
try { try {
// Get current tab data // Get current zone data
const tabResponse = await fetch(`/tabs/${tabId}`, { const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (!tabResponse.ok) { if (!tabResponse.ok) {
throw new Error('Failed to load tab'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
@@ -1878,7 +1956,7 @@ const removePresetFromTab = async (tabId, presetId) => {
const beforeLen = flat.length; const beforeLen = flat.length;
flat = flat.filter(id => String(id) !== String(presetId)); flat = flat.filter(id => String(id) !== String(presetId));
if (flat.length === beforeLen) { if (flat.length === beforeLen) {
alert('Preset is not in this tab.'); alert('Preset is not in this zone.');
return; return;
} }
@@ -1886,19 +1964,19 @@ const removePresetFromTab = async (tabId, presetId) => {
tabData.presets = newGrid; tabData.presets = newGrid;
tabData.presets_flat = flat; tabData.presets_flat = flat;
const updateResponse = await fetch(`/tabs/${tabId}`, { const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData), body: JSON.stringify(tabData),
}); });
if (!updateResponse.ok) { if (!updateResponse.ok) {
throw new Error('Failed to update tab presets'); throw new Error('Failed to update zone presets');
} }
await renderTabPresets(tabId); await renderTabPresets(zoneId);
} catch (error) { } catch (error) {
console.error('Failed to remove preset from tab:', error); console.error('Failed to remove preset from zone:', error);
alert('Failed to remove preset from tab.'); alert('Failed to remove preset from zone.');
} }
}; };
try { try {
@@ -1907,13 +1985,13 @@ try {
// Listen for HTMX swaps to render presets // Listen for HTMX swaps to render presets
document.body.addEventListener('htmx:afterSwap', (event) => { document.body.addEventListener('htmx:afterSwap', (event) => {
if (event.target && event.target.id === 'tab-content') { if (event.target && event.target.id === 'zone-content') {
// Get tab ID from the left-panel // Get zone ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]'); const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) { if (leftPanel) {
const tabId = leftPanel.dataset.tabId; const zoneId = leftPanel.dataset.zoneId;
if (tabId) { if (zoneId) {
renderTabPresets(tabId); renderTabPresets(zoneId);
} }
} }
} }
@@ -1926,11 +2004,17 @@ document.addEventListener('DOMContentLoaded', () => {
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit'; const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
setPresetUiMode(next); setPresetUiMode(next);
updateUiModeToggleButtons(); updateUiModeToggleButtons();
if (next === 'run') {
['devices-modal', 'edit-device-modal'].forEach((id) => {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
});
}
const mainMenu = document.getElementById('main-menu-dropdown'); const mainMenu = document.getElementById('main-menu-dropdown');
if (mainMenu) mainMenu.classList.remove('open'); if (mainMenu) mainMenu.classList.remove('open');
const leftPanel = document.querySelector('.presets-section[data-tab-id]'); const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) { if (leftPanel) {
renderTabPresets(leftPanel.dataset.tabId); renderTabPresets(leftPanel.dataset.zoneId);
} }
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -3,20 +3,21 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Controller - Tab Mode</title> <title>LED Controller - Zone Mode</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
<header> <header>
<div class="tabs-container"> <div class="zones-container">
<div id="tabs-list"> <div id="zones-list">
Loading tabs... Loading zones...
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button> <button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button> <button class="btn btn-secondary edit-mode-only" id="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="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="color-palette-btn">Colour Palette</button>
@@ -29,7 +30,8 @@
<div id="main-menu-dropdown" class="main-menu-dropdown"> <div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button> <button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" data-target="profiles-btn">Profiles</button> <button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button> <button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button> <button type="button" class="edit-mode-only" data-target="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="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="color-palette-btn">Colour Palette</button>
@@ -40,46 +42,47 @@
</header> </header>
<div class="main-content"> <div class="main-content">
<div id="tab-content" class="tab-content"> <div id="zone-content" class="zone-content">
<div class="tab-content-placeholder"> <div class="zone-content-placeholder">
Select a tab to get started Select a zone to get started
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Tabs Modal --> <!-- Tabs Modal -->
<div id="tabs-modal" class="modal"> <div id="zones-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Tabs</h2> <h2>Tabs</h2>
<div class="profiles-actions"> <div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-tab-name" placeholder="Tab name"> <input type="text" id="new-zone-name" placeholder="Zone name">
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1"> <button class="btn btn-primary" id="create-zone-btn">Create</button>
<button class="btn btn-primary" id="create-tab-btn">Create</button>
</div> </div>
<div id="tabs-list-modal" class="profiles-list"></div> <div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="tabs-close-btn">Close</button> <button class="btn btn-secondary" id="zones-close-btn">Close</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Edit Tab Modal --> <!-- Edit Zone Modal -->
<div id="edit-tab-modal" class="modal"> <div id="edit-zone-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit Tab</h2> <h2>Edit Zone</h2>
<form id="edit-tab-form"> <form id="edit-zone-form">
<input type="hidden" id="edit-tab-id"> <input type="hidden" id="edit-zone-id">
<div class="modal-actions" style="margin-bottom: 1rem;"> <div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button> <button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div> </div>
<label>Tab Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label>Device IDs (comma-separated):</label> <label class="zone-devices-label">Devices in this zone</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required> <div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
<label style="margin-top: 1rem;">Add presets to this tab</label> <label class="zone-presets-section-label">Presets on this zone</label>
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div> <div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="zone-presets-section-label">Add presets to this zone</label>
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
</form> </form>
</div> </div>
</div> </div>
@@ -95,7 +98,7 @@
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;"> <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;"> <label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="new-profile-seed-dj"> <input type="checkbox" id="new-profile-seed-dj">
DJ tab DJ zone
</label> </label>
</div> </div>
<div id="profiles-list" class="profiles-list"></div> <div id="profiles-list" class="profiles-list"></div>
@@ -105,6 +108,50 @@
</div> </div>
</div> </div>
<!-- Devices Modal (registry: Wi-Fi drivers appear when they connect over TCP) -->
<div id="devices-modal" class="modal">
<div class="modal-content">
<h2>Devices</h2>
<div id="devices-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
</div>
</div>
</div>
<div id="edit-device-modal" class="modal">
<div class="modal-content">
<h2>Edit device</h2>
<form id="edit-device-form">
<input type="hidden" id="edit-device-id">
<p class="muted-text" style="margin-bottom:0.75rem;">MAC (id): <code id="edit-device-storage-id"></code></p>
<label for="edit-device-name">Name</label>
<input type="text" id="edit-device-name" required autocomplete="off">
<label for="edit-device-type" style="margin-top:0.75rem;display:block;">Type</label>
<select id="edit-device-type">
<option value="led">LED</option>
</select>
<label for="edit-device-transport" style="margin-top:0.75rem;display:block;">Transport</label>
<select id="edit-device-transport">
<option value="espnow">ESP-NOW</option>
<option value="wifi">WiFi</option>
</select>
<div id="edit-device-address-espnow" style="margin-top:0.75rem;">
<label class="device-field-label">MAC (12 hex, optional)</label>
<div id="edit-device-address-boxes" class="hex-address-row" aria-label="MAC address"></div>
</div>
<div id="edit-device-address-wifi-wrap" style="margin-top:0.75rem;" hidden>
<label for="edit-device-address-wifi">Address (IP or hostname)</label>
<input type="text" id="edit-device-address-wifi" placeholder="192.168.1.50" autocomplete="off">
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Presets Modal --> <!-- Presets Modal -->
<div id="presets-modal" class="modal"> <div id="presets-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -182,7 +229,7 @@
<div class="modal-actions preset-editor-modal-actions"> <div class="modal-actions preset-editor-modal-actions">
<button class="btn btn-secondary" id="preset-send-btn">Try</button> <button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-secondary" id="preset-default-btn">Default</button> <button class="btn btn-secondary" id="preset-default-btn">Default</button>
<button type="button" class="btn btn-danger" id="preset-remove-from-tab-btn" hidden>Remove from tab</button> <button type="button" class="btn btn-danger" id="preset-remove-from-zone-btn" hidden>Remove from zone</button>
<button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button> <button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button> <button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
</div> </div>
@@ -193,6 +240,9 @@
<div id="patterns-modal" class="modal"> <div id="patterns-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Patterns</h2> <h2>Patterns</h2>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
</div>
<div id="patterns-list" class="profiles-list"></div> <div id="patterns-list" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="patterns-close-btn">Close</button> <button class="btn btn-secondary" id="patterns-close-btn">Close</button>
@@ -200,6 +250,78 @@
</div> </div>
</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 class="profiles-row pattern-editor-meta-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<label for="pattern-create-min-delay" style="min-width: 7rem;">Min delay (ms)</label>
<input type="number" id="pattern-create-min-delay" min="0" value="10">
<label for="pattern-create-max-delay">Max delay (ms)</label>
<input type="number" id="pattern-create-max-delay" min="0" value="10000">
<label for="pattern-create-max-colors">Max colours</label>
<input type="number" id="pattern-create-max-colors" min="0" value="10">
</div>
<div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;">
<h3 class="muted-text">Readable parameter names</h3>
<p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names are stored for this pattern.</p>
<div class="n-params-grid">
<div class="n-param-group">
<label for="pattern-create-n1"></label>
<input type="text" id="pattern-create-n1" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n2"></label>
<input type="text" id="pattern-create-n2" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n3"></label>
<input type="text" id="pattern-create-n3" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n4"></label>
<input type="text" id="pattern-create-n4" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n5"></label>
<input type="text" id="pattern-create-n5" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n6"></label>
<input type="text" id="pattern-create-n6" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n7"></label>
<input type="text" id="pattern-create-n7" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
<div class="n-param-group">
<label for="pattern-create-n8"></label>
<input type="text" id="pattern-create-n8" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
</div>
</div>
</div>
<div class="profiles-row" style="flex-direction: column; align-items: stretch; gap: 0.35rem; margin-bottom: 0.5rem;">
<label for="pattern-create-file">Pattern file</label>
<input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY">
<label for="pattern-create-code" class="muted-text" style="font-size: 0.85em;">Or paste Python source (if no file chosen)</label>
<textarea id="pattern-create-code" rows="5" style="width: 100%; font-family: monospace; font-size: 0.85rem;" placeholder="# class MyPattern: ..."></textarea>
</div>
<div class="modal-actions">
<label style="display: inline-flex; align-items: center; gap: 0.35rem; margin-right: auto;">
<input type="checkbox" id="pattern-create-overwrite" checked>
<span>Overwrite existing file</span>
</label>
<button type="button" class="btn btn-primary" id="pattern-create-btn">Save</button>
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
</div>
</div>
</div>
<!-- Colour Palette Modal --> <!-- Colour Palette Modal -->
<div id="color-palette-modal" class="modal"> <div id="color-palette-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -223,10 +345,11 @@
<h3>Run mode</h3> <h3>Run mode</h3>
<ul> <ul>
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li> <li><strong>Select zone</strong>: left-click a zone button in the top bar.</li>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the tab.</li> <li><strong>Select 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>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current tab to all tab devices.</li> <li><strong>Devices</strong>: open <strong>Devices</strong> to see drivers (Wi-Fi clients appear when they connect); edit or remove rows as needed.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li>
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li> <li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul> </ul>
@@ -235,8 +358,9 @@
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li> <li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li> <li><strong>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>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab order.</li> <li><strong>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> tab and can optionally seed a <strong>DJ tab</strong>.</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>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul> </ul>
@@ -315,12 +439,13 @@
</div> </div>
<!-- Styles moved to /static/style.css --> <!-- Styles moved to /static/style.css -->
<script src="/static/tabs.js"></script> <script src="/static/zones.js"></script>
<script src="/static/help.js"></script> <script src="/static/help.js"></script>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>
<script src="/static/profiles.js"></script> <script src="/static/profiles.js"></script>
<script src="/static/tab_palette.js"></script> <script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script> <script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script> <script src="/static/presets.js"></script>
<script src="/static/devices.js"></script>
</body> </body>
</html> </html>

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

216
tests/tcp_test_server.py Normal file
View File

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

View File

@@ -162,13 +162,13 @@ class BrowserTest:
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}") print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
# Delete created tabs by ID # Delete created tabs by ID
for tab_id in self.created_tabs: for zone_id in self.created_tabs:
try: try:
response = session.delete(f"{self.base_url}/tabs/{tab_id}") response = session.delete(f"{self.base_url}/zones/{zone_id}")
if response.status_code == 200: if response.status_code == 200:
print(f" ✓ Cleaned up tab: {tab_id}") print(f" ✓ Cleaned up zone: {zone_id}")
except Exception as e: except Exception as e:
print(f" ⚠ Failed to cleanup tab {tab_id}: {e}") print(f" ⚠ Failed to cleanup zone {zone_id}: {e}")
# Delete created profiles by ID # Delete created profiles by ID
for profile_id in self.created_profiles: for profile_id in self.created_profiles:
@@ -180,20 +180,20 @@ class BrowserTest:
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}") print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
# Also try to cleanup by name pattern (in case IDs weren't tracked) # Also try to cleanup by name pattern (in case IDs weren't tracked)
test_names = ['Browser Test Tab', 'Browser Test Profile', 'Browser Test Preset', test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset',
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Tab'] 'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone']
# Cleanup tabs by name # Cleanup tabs by name
try: try:
tabs_response = session.get(f"{self.base_url}/tabs") tabs_response = session.get(f"{self.base_url}/zones")
if tabs_response.status_code == 200: if tabs_response.status_code == 200:
tabs_data = tabs_response.json() tabs_data = tabs_response.json()
tabs = tabs_data.get('tabs', {}) tabs = tabs_data.get('zones', {})
for tab_id, tab_data in tabs.items(): for zone_id, tab_data in zones.items():
if isinstance(tab_data, dict) and tab_data.get('name') in test_names: if isinstance(tab_data, dict) and tab_data.get('name') in test_names:
try: try:
session.delete(f"{self.base_url}/tabs/{tab_id}") session.delete(f"{self.base_url}/zones/{zone_id}")
print(f" ✓ Cleaned up tab by name: {tab_data.get('name')}") print(f" ✓ Cleaned up zone by name: {tab_data.get('name')}")
except: except:
pass pass
except: except:
@@ -330,11 +330,11 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
# Test 2: Open tabs modal # Test 2: Open tabs modal
total += 1 total += 1
if browser.click_element(By.ID, 'tabs-btn'): if browser.click_element(By.ID, 'zones-btn'):
print("✓ Clicked Tabs button") print("✓ Clicked Tabs button")
# Wait for modal to appear # Wait for modal to appear
time.sleep(0.5) time.sleep(0.5)
modal = browser.wait_for_element(By.ID, 'tabs-modal') modal = browser.wait_for_element(By.ID, 'zones-modal')
if modal and 'active' in modal.get_attribute('class'): if modal and 'active' in modal.get_attribute('class'):
print("✓ Tabs modal opened") print("✓ Tabs modal opened")
passed += 1 passed += 1
@@ -343,60 +343,58 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
else: else:
print("✗ Failed to click Tabs button") print("✗ Failed to click Tabs button")
# Test 3: Create a tab via UI # Test 3: Create a zone via UI
total += 1 total += 1
try: try:
# Fill in tab name # Fill in zone name
if browser.fill_input(By.ID, 'new-tab-name', 'Browser Test Tab'): if browser.fill_input(By.ID, 'new-zone-name', 'Browser Test Zone'):
print(" ✓ Filled tab name") print(" ✓ Filled zone name")
# Fill in device IDs # Devices default from registry or placeholder name "1"
if browser.fill_input(By.ID, 'new-tab-ids', '1,2,3'):
print(" ✓ Filled device IDs")
# Click create button # Click create button
if browser.click_element(By.ID, 'create-tab-btn'): if browser.click_element(By.ID, 'create-zone-btn'):
print(" ✓ Clicked create button") print(" ✓ Clicked create button")
time.sleep(1) # Wait for creation time.sleep(1) # Wait for creation
# Check if tab appears in list and extract ID # Check if zone appears in list and extract ID
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal') tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list: if tabs_list:
list_text = tabs_list.text list_text = tabs_list.text
if 'Browser Test Tab' in list_text: if 'Browser Test Zone' in list_text:
print("✓ Created tab via UI") print("✓ Created zone via UI")
# Try to extract tab ID from the list (look for data-tab-id attribute) # Try to extract zone ID from the list (look for data-zone-id attribute)
try: try:
tab_rows = browser.driver.find_elements(By.CSS_SELECTOR, '#tabs-list-modal .profiles-row') tab_rows = browser.driver.find_elements(By.CSS_SELECTOR, '#zones-list-modal .profiles-row')
for row in tab_rows: for row in tab_rows:
if 'Browser Test Tab' in row.text: if 'Browser Test Zone' in row.text:
tab_id = row.get_attribute('data-tab-id') zone_id = row.get_attribute('data-zone-id')
if tab_id: if zone_id:
browser.created_tabs.append(tab_id) browser.created_tabs.append(zone_id)
break break
except: except:
pass # If we can't extract ID, cleanup will try by name pass # If we can't extract ID, cleanup will try by name
passed += 1 passed += 1
else: else:
print("Tab not found in list after creation") print("Zone not found in list after creation")
else: else:
print("✗ Tabs list not found") print("✗ Tabs list not found")
else: else:
print("✗ Failed to click create button") print("✗ Failed to click create button")
except Exception as e: except Exception as e:
print(f"✗ Failed to create tab via UI: {e}") print(f"✗ Failed to create zone via UI: {e}")
# Test 4: Edit a tab via UI (right-click in Tabs list) # Test 4: Edit a zone via UI (right-click in Tabs list)
total += 1 total += 1
try: try:
# First, close and reopen modal to refresh # First, close and reopen modal to refresh
browser.click_element(By.ID, 'tabs-close-btn') browser.click_element(By.ID, 'zones-close-btn')
time.sleep(0.5) time.sleep(0.5)
browser.click_element(By.ID, 'tabs-btn') browser.click_element(By.ID, 'zones-btn')
time.sleep(0.5) time.sleep(0.5)
# Right-click the row corresponding to 'Browser Test Tab' # Right-click the row corresponding to 'Browser Test Zone'
try: try:
tab_row = browser.driver.find_element( tab_row = browser.driver.find_element(
By.XPATH, By.XPATH,
"//div[@id='tabs-list-modal']//div[contains(@class,'profiles-row')][.//span[contains(text(), 'Browser Test Tab')]]" "//div[@id='zones-list-modal']//div[contains(@class,'profiles-row')][.//span[contains(text(), 'Browser Test Zone')]]"
) )
except Exception: except Exception:
tab_row = None tab_row = None
@@ -407,14 +405,14 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
time.sleep(0.5) time.sleep(0.5)
# Check if edit modal opened # Check if edit modal opened
edit_modal = browser.wait_for_element(By.ID, 'edit-tab-modal') edit_modal = browser.wait_for_element(By.ID, 'edit-zone-modal')
if edit_modal: if edit_modal:
print("✓ Edit modal opened via right-click") print("✓ Edit modal opened via right-click")
# Fill in new name # Fill in new name
if browser.fill_input(By.ID, 'edit-tab-name', 'Edited Browser Tab'): if browser.fill_input(By.ID, 'edit-zone-name', 'Edited Browser Zone'):
print(" ✓ Filled new tab name") print(" ✓ Filled new zone name")
# Submit form # Submit form
edit_form = browser.wait_for_element(By.ID, 'edit-tab-form') edit_form = browser.wait_for_element(By.ID, 'edit-zone-form')
if edit_form: if edit_form:
browser.driver.execute_script("arguments[0].submit();", edit_form) browser.driver.execute_script("arguments[0].submit();", edit_form)
time.sleep(1) # Wait for update time.sleep(1) # Wait for update
@@ -425,24 +423,24 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
else: else:
print("✗ Edit modal didn't open after right-click") print("✗ Edit modal didn't open after right-click")
else: else:
print("✗ Could not find tab row for 'Browser Test Tab'") print("✗ Could not find zone row for 'Browser Test Zone'")
except Exception as e: except Exception as e:
print(f"✗ Failed to edit tab via UI: {e}") print(f"✗ Failed to edit zone via UI: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Test 5: Check current tab cookie # Test 5: Check current zone cookie
total += 1 total += 1
cookie = browser.get_cookie('current_tab') cookie = browser.get_cookie('current_zone')
if cookie: if cookie:
print(f"✓ Found current_tab cookie: {cookie.get('value')}") print(f"✓ Found current_zone cookie: {cookie.get('value')}")
passed += 1 passed += 1
else: else:
print("⚠ No current_tab cookie found (might be normal if no tab selected)") print("⚠ No current_zone cookie found (might be normal if no zone selected)")
passed += 1 # Not a failure, just informational passed += 1 # Not a failure, just informational
# Close modal # Close modal
browser.click_element(By.ID, 'tabs-close-btn') browser.click_element(By.ID, 'zones-close-btn')
except Exception as e: except Exception as e:
print(f"✗ Browser test error: {e}") print(f"✗ Browser test error: {e}")
@@ -521,7 +519,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
def test_mobile_tab_presets_two_columns(): def test_mobile_tab_presets_two_columns():
""" """
Verify that the tab preset selecting area shows roughly two preset tiles per row Verify that the zone preset selecting area shows roughly two preset tiles per row
on a phone-sized viewport. on a phone-sized viewport.
""" """
bt = BrowserTest(base_url=BASE_URL, headless=True) bt = BrowserTest(base_url=BASE_URL, headless=True)
@@ -533,18 +531,18 @@ def test_mobile_tab_presets_two_columns():
bt.driver.set_window_size(400, 800) bt.driver.set_window_size(400, 800)
assert bt.navigate('/'), "Failed to load main page" assert bt.navigate('/'), "Failed to load main page"
# Click the first tab button to load presets for that tab # Click the first zone button to load presets for that zone
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.tab-button', timeout=10) first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10)
assert first_tab is not None, "No tab buttons found" assert first_tab is not None, "No zone buttons found"
first_tab.click() first_tab.click()
time.sleep(1) time.sleep(1)
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10) container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
assert container is not None, "presets-list-tab not found" assert container is not None, "presets-list-zone not found"
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .preset-tile-row') tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .preset-tile-row')
# Need at least 2 presets to make this meaningful # Need at least 2 presets to make this meaningful
assert len(tiles) >= 2, "Fewer than 2 presets found for tab" assert len(tiles) >= 2, "Fewer than 2 presets found for zone"
container_width = container.size['width'] container_width = container.size['width']
first_width = tiles[0].size['width'] first_width = tiles[0].size['width']
@@ -762,8 +760,8 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
return passed >= total - 1 # Allow one failure (alert handling might be flaky) return passed >= total - 1 # Allow one failure (alert handling might be flaky)
def test_preset_drag_and_drop(browser: BrowserTest) -> bool: def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
"""Test dragging presets around in a tab.""" """Test dragging presets around in a zone."""
print("\n=== Testing Preset Drag and Drop in Tab ===") print("\n=== Testing Preset Drag and Drop in Zone ===")
passed = 0 passed = 0
total = 0 total = 0
@@ -771,7 +769,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
return False return False
try: try:
# Test 1: Load page and ensure we have a tab # Test 1: Load page and ensure we have a zone
total += 1 total += 1
if browser.navigate('/'): if browser.navigate('/'):
print("✓ Loaded main page") print("✓ Loaded main page")
@@ -780,34 +778,33 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
browser.teardown() browser.teardown()
return False return False
# Test 2: Open tabs modal and create/select a tab # Test 2: Open tabs modal and create/select a zone
total += 1 total += 1
browser.click_element(By.ID, 'tabs-btn') browser.click_element(By.ID, 'zones-btn')
time.sleep(0.5) time.sleep(0.5)
# Check if we have tabs, if not create one # Check if we have tabs, if not create one
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal') tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list and 'No tabs found' in tabs_list.text: if tabs_list and 'No tabs found' in tabs_list.text:
# Create a tab # Create a zone
browser.fill_input(By.ID, 'new-tab-name', 'Drag Test Tab') browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone')
browser.fill_input(By.ID, 'new-tab-ids', '1') browser.click_element(By.ID, 'create-zone-btn')
browser.click_element(By.ID, 'create-tab-btn')
time.sleep(1) time.sleep(1)
# Select first tab (or the one we just created) # Select first zone (or the one we just created)
select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]") select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]")
if select_buttons: if select_buttons:
select_buttons[0].click() select_buttons[0].click()
time.sleep(1) time.sleep(1)
print("✓ Selected a tab") print("✓ Selected a zone")
passed += 1 passed += 1
else: else:
print("✗ No tabs available to select") print("✗ No tabs available to select")
browser.click_element(By.ID, 'tabs-close-btn') browser.click_element(By.ID, 'zones-close-btn')
browser.teardown() browser.teardown()
return False return False
browser.click_element(By.ID, 'tabs-close-btn', use_js=True) browser.click_element(By.ID, 'zones-close-btn', use_js=True)
time.sleep(0.5) time.sleep(0.5)
# Test 3: Open presets modal and create presets # Test 3: Open presets modal and create presets
@@ -848,54 +845,54 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
print("✓ Created 3 presets for drag test") print("✓ Created 3 presets for drag test")
passed += 1 passed += 1
# Test 4: Add presets to the tab (via Edit Tab modal Select buttons in list) # Test 4: Add presets to the zone (via Edit Zone modal Add buttons in list)
total += 1 total += 1
try: try:
tab_id = browser.driver.execute_script( zone_id = browser.driver.execute_script(
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;" "return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
) )
if not tab_id: if not zone_id:
print("✗ Could not get current tab id") print("✗ Could not get current zone id")
else: else:
browser.driver.execute_script( browser.driver.execute_script(
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
tab_id zone_id
) )
time.sleep(1) time.sleep(1)
list_el = browser.wait_for_element(By.ID, 'edit-tab-presets-list', timeout=5) list_el = browser.wait_for_element(By.ID, 'edit-zone-presets-list', timeout=5)
if list_el: if list_el:
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-tab-presets-list']//button[text()='Select']") select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if len(select_buttons) >= 2: if len(select_buttons) >= 2:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-tab-presets-list']//button[text()='Select']") select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if len(select_buttons) >= 1: if len(select_buttons) >= 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 2 presets to tab") print(" ✓ Added 2 presets to zone")
passed += 1 passed += 1
elif len(select_buttons) == 1: elif len(select_buttons) == 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 1 preset to tab") print(" ✓ Added 1 preset to zone")
passed += 1 passed += 1
else: else:
print(" ⚠ No presets available to add (all already in tab)") print(" ⚠ No presets available to add (all already in zone)")
else: else:
print("✗ Edit tab presets list not found") print("✗ Edit zone presets list not found")
except Exception as e: except Exception as e:
print(f"✗ Failed to add presets to tab: {e}") print(f"✗ Failed to add presets to zone: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Test 5: Find presets in tab and test drag and drop (Edit mode only) # Test 5: Find presets in zone and test drag and drop (Edit mode only)
total += 1 total += 1
try: try:
# Wait for presets to load in the tab # Wait for presets to load in the zone
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5) presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5)
if presets_list_tab: if presets_list_tab:
time.sleep(1) # Wait for presets to render time.sleep(1) # Wait for presets to render
@@ -907,7 +904,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
# Find draggable preset elements - wait a bit more for rendering # Find draggable preset elements - wait a bit more for rendering
time.sleep(1) time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2: if len(draggable_presets) >= 2:
print(f" ✓ Found {len(draggable_presets)} draggable presets") print(f" ✓ Found {len(draggable_presets)} draggable presets")
@@ -925,7 +922,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
time.sleep(1) # Wait for reorder to complete time.sleep(1) # Wait for reorder to complete
# Check if order changed # Check if order changed
draggable_presets_after = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') draggable_presets_after = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets_after) >= 2: if len(draggable_presets_after) >= 2:
new_order = [p.text for p in draggable_presets_after] new_order = [p.text for p in draggable_presets_after]
print(f" New order: {new_order[:3]}") print(f" New order: {new_order[:3]}")
@@ -939,28 +936,28 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
else: else:
print("✗ Presets disappeared after drag") print("✗ Presets disappeared after drag")
elif len(draggable_presets) == 1: elif len(draggable_presets) == 1:
print(f"⚠ Only 1 preset found in tab (need 2 for drag test). Preset: {draggable_presets[0].text}") print(f"⚠ Only 1 preset found in zone (need 2 for drag test). Preset: {draggable_presets[0].text}")
tab_id = browser.driver.execute_script( zone_id = browser.driver.execute_script(
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;" "return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
) )
if tab_id: if zone_id:
browser.driver.execute_script( browser.driver.execute_script(
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
tab_id zone_id
) )
time.sleep(1) time.sleep(1)
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-tab-presets-list']//button[text()='Select']") select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if select_buttons: if select_buttons:
print(" Attempting to add another preset...") print(" Attempting to add another preset...")
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) browser.driver.execute_script("arguments[0].click();", select_buttons[0])
time.sleep(1.5) time.sleep(1.5)
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
try: try:
browser.driver.execute_script("document.getElementById('edit-tab-modal').classList.remove('active');") browser.driver.execute_script("document.getElementById('edit-zone-modal').classList.remove('active');")
except Exception: except Exception:
pass pass
time.sleep(1) time.sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset') draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2: if len(draggable_presets) >= 2:
print(" ✓ Added another preset, now testing drag...") print(" ✓ Added another preset, now testing drag...")
source = draggable_presets[0] source = draggable_presets[0]
@@ -973,11 +970,11 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
else: else:
print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding") print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding")
else: else:
print(" ✗ No Select buttons found in Edit Tab modal") print(" ✗ No Add buttons found in Edit Zone modal")
else: else:
print(f"✗ No presets found in tab (found {len(draggable_presets)})") print(f"✗ No presets found in zone (found {len(draggable_presets)})")
else: else:
print("✗ Presets list in tab not found") print("✗ Presets list in zone not found")
except Exception as e: except Exception as e:
print(f"✗ Drag and drop test error: {e}") print(f"✗ Drag and drop test error: {e}")
import traceback import traceback

View File

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

View File

@@ -119,21 +119,23 @@ def server(monkeypatch, tmp_path_factory):
import models.preset as models_preset # noqa: E402 import models.preset as models_preset # noqa: E402
import models.profile as models_profile # noqa: E402 import models.profile as models_profile # noqa: E402
import models.group as models_group # noqa: E402 import models.group as models_group # noqa: E402
import models.tab as models_tab # noqa: E402 import models.zone as models_tab # noqa: E402
import models.pallet as models_pallet # noqa: E402 import models.pallet as models_pallet # noqa: E402
import models.scene as models_scene # noqa: E402 import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402 import models.pattern as models_pattern # noqa: E402
import models.squence as models_sequence # noqa: E402 import models.squence as models_sequence # noqa: E402
import models.device as models_device # noqa: E402
for cls in ( for cls in (
models_preset.Preset, models_preset.Preset,
models_profile.Profile, models_profile.Profile,
models_group.Group, models_group.Group,
models_tab.Tab, models_tab.Zone,
models_pallet.Palette, models_pallet.Palette,
models_scene.Scene, models_scene.Scene,
models_pattern.Pattern, models_pattern.Pattern,
models_sequence.Sequence, models_sequence.Sequence,
models_device.Device,
): ):
if hasattr(cls, "_instance"): if hasattr(cls, "_instance"):
delattr(cls, "_instance") delattr(cls, "_instance")
@@ -162,11 +164,12 @@ def server(monkeypatch, tmp_path_factory):
"controllers.profile", "controllers.profile",
"controllers.group", "controllers.group",
"controllers.sequence", "controllers.sequence",
"controllers.tab", "controllers.zone",
"controllers.palette", "controllers.palette",
"controllers.scene", "controllers.scene",
"controllers.pattern", "controllers.pattern",
"controllers.settings", "controllers.settings",
"controllers.device",
): ):
sys.modules.pop(mod_name, None) sys.modules.pop(mod_name, None)
@@ -175,11 +178,12 @@ def server(monkeypatch, tmp_path_factory):
import controllers.profile as profile_ctl # noqa: E402 import controllers.profile as profile_ctl # noqa: E402
import controllers.group as group_ctl # noqa: E402 import controllers.group as group_ctl # noqa: E402
import controllers.sequence as sequence_ctl # noqa: E402 import controllers.sequence as sequence_ctl # noqa: E402
import controllers.tab as tab_ctl # noqa: E402 import controllers.zone as zone_ctl # noqa: E402
import controllers.palette as palette_ctl # noqa: E402 import controllers.palette as palette_ctl # noqa: E402
import controllers.scene as scene_ctl # noqa: E402 import controllers.scene as scene_ctl # noqa: E402
import controllers.pattern as pattern_ctl # noqa: E402 import controllers.pattern as pattern_ctl # noqa: E402
import controllers.settings as settings_ctl # noqa: E402 import controllers.settings as settings_ctl # noqa: E402
import controllers.device as device_ctl # noqa: E402
# Configure transport sender used by /presets/send. # Configure transport sender used by /presets/send.
from models.transport import set_sender # noqa: E402 from models.transport import set_sender # noqa: E402
@@ -201,11 +205,12 @@ def server(monkeypatch, tmp_path_factory):
app.mount(profile_ctl.controller, "/profiles") app.mount(profile_ctl.controller, "/profiles")
app.mount(group_ctl.controller, "/groups") app.mount(group_ctl.controller, "/groups")
app.mount(sequence_ctl.controller, "/sequences") app.mount(sequence_ctl.controller, "/sequences")
app.mount(tab_ctl.controller, "/tabs") app.mount(tab_ctl.controller, "/zones")
app.mount(palette_ctl.controller, "/palettes") app.mount(palette_ctl.controller, "/palettes")
app.mount(scene_ctl.controller, "/scenes") app.mount(scene_ctl.controller, "/scenes")
app.mount(pattern_ctl.controller, "/patterns") app.mount(pattern_ctl.controller, "/patterns")
app.mount(settings_ctl.controller, "/settings") app.mount(settings_ctl.controller, "/settings")
app.mount(device_ctl.controller, "/devices")
@app.route("/") @app.route("/")
def index(request): def index(request):
@@ -343,11 +348,15 @@ def test_settings_controller(server):
assert resp.status_code == 400 assert resp.status_code == 400
def test_profiles_presets_tabs_endpoints(server): def test_profiles_presets_tabs_endpoints(server, monkeypatch):
c: requests.Session = server["client"] c: requests.Session = server["client"]
base_url: str = server["base_url"] base_url: str = server["base_url"]
sender: DummySender = server["sender"] sender: DummySender = server["sender"]
import controllers.device as device_ctl
monkeypatch.setattr(device_ctl, "IDENTIFY_OFF_DELAY_S", 0.05)
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}" unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name}) resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
@@ -415,45 +424,45 @@ def test_profiles_presets_tabs_endpoints(server):
assert resp.status_code == 404 assert resp.status_code == 404
# Tabs CRUD (scoped to current profile session). # Tabs CRUD (scoped to current profile session).
unique_tab_name = f"pytest-tab-{uuid.uuid4().hex[:8]}" unique_tab_name = f"pytest-zone-{uuid.uuid4().hex[:8]}"
resp = c.post( resp = c.post(
f"{base_url}/tabs", f"{base_url}/zones",
json={"name": unique_tab_name, "names": ["1", "2"]}, json={"name": unique_tab_name, "names": ["1", "2"]},
) )
assert resp.status_code == 201 assert resp.status_code == 201
created_tabs = resp.json() created_tabs = resp.json()
tab_id = next(iter(created_tabs.keys())) zone_id = next(iter(created_tabs.keys()))
resp = c.get(f"{base_url}/tabs/{tab_id}") resp = c.get(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["name"] == unique_tab_name assert resp.json()["name"] == unique_tab_name
resp = c.post(f"{base_url}/tabs/{tab_id}/set-current") resp = c.post(f"{base_url}/zones/{zone_id}/set-current")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.get(f"{base_url}/tabs/current") resp = c.get(f"{base_url}/zones/current")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["tab_id"] == str(tab_id) assert resp.json()["zone_id"] == str(zone_id)
resp = c.put( resp = c.put(
f"{base_url}/tabs/{tab_id}", f"{base_url}/zones/{zone_id}",
json={"name": f"{unique_tab_name}-updated", "names": ["3"]}, json={"name": f"{unique_tab_name}-updated", "names": ["3"]},
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["names"] == ["3"] assert resp.json()["names"] == ["3"]
resp = c.post(f"{base_url}/tabs/{tab_id}/clone", json={"name": "pytest-tab-clone"}) resp = c.post(f"{base_url}/zones/{zone_id}/clone", json={"name": "pytest-zone-clone"})
assert resp.status_code == 201 assert resp.status_code == 201
clone_payload = resp.json() clone_payload = resp.json()
clone_id = next(iter(clone_payload.keys())) clone_id = next(iter(clone_payload.keys()))
resp = c.get(f"{base_url}/tabs/{clone_id}") resp = c.get(f"{base_url}/zones/{clone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.delete(f"{base_url}/tabs/{clone_id}") resp = c.delete(f"{base_url}/zones/{clone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.delete(f"{base_url}/tabs/{tab_id}") resp = c.delete(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
# Profile clone + update endpoints. # Profile clone + update endpoints.
@@ -562,6 +571,132 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
resp = c.delete(f"{base_url}/palettes/{palette_id}") resp = c.delete(f"{base_url}/palettes/{palette_id}")
assert resp.status_code == 200 assert resp.status_code == 200
# Devices (LED driver registry).
resp = c.get(f"{base_url}/devices")
assert resp.status_code == 200
assert resp.json() == {}
resp = c.post(f"{base_url}/devices", json={})
assert resp.status_code == 400
resp = c.post(
f"{base_url}/devices",
json={"name": "pytest-dev", "address": "aa:bb:cc:dd:ee:ff"},
)
assert resp.status_code == 201
dev_map = resp.json()
dev_id = next(iter(dev_map.keys()))
assert dev_id == "aabbccddeeff"
assert dev_map[dev_id]["name"] == "pytest-dev"
assert dev_map[dev_id]["id"] == dev_id
assert dev_map[dev_id]["type"] == "led"
assert dev_map[dev_id]["transport"] == "espnow"
assert dev_map[dev_id]["address"] == "aabbccddeeff"
resp = c.get(f"{base_url}/devices/{dev_id}")
assert resp.status_code == 200
assert resp.json()["name"] == "pytest-dev"
assert resp.json()["type"] == "led"
assert resp.json().get("connected") is None
resp = c.get(f"{base_url}/devices")
assert resp.status_code == 200
assert resp.json()[dev_id].get("connected") is None
sender.sent.clear()
resp = c.post(f"{base_url}/devices/{dev_id}/identify")
assert resp.status_code == 200
assert resp.json().get("message")
assert len(sender.sent) >= 1
first = json.loads(sender.sent[0][0])
assert "presets" in first and "select" in first
assert first["presets"]["__identify"]["p"] == "blink"
assert first["presets"]["__identify"]["d"] == 50
assert first["select"]["pytest-dev"] == ["__identify"]
deadline = time.monotonic() + 2.0
while len(sender.sent) < 2 and time.monotonic() < deadline:
time.sleep(0.02)
assert len(sender.sent) >= 2
second = json.loads(sender.sent[1][0])
assert second.get("select") == {"pytest-dev": ["off"]}
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi",
"type": "led",
"transport": "wifi",
"address": "192.168.50.10",
"mac": "102030405060",
},
)
assert resp.status_code == 201
wid = "102030405060"
assert wid in resp.json()
assert resp.json()[wid]["transport"] == "wifi"
assert resp.json()[wid]["address"] == "192.168.50.10"
resp = c.get(f"{base_url}/devices/{wid}")
assert resp.status_code == 200
assert resp.json().get("connected") is False
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi",
"transport": "wifi",
"address": "192.168.50.11",
"mac": "102030405061",
},
)
assert resp.status_code == 201
wid2 = "102030405061"
assert wid2 in resp.json()
assert resp.json()[wid2]["name"] == "pytest-wifi"
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi-dupmac",
"transport": "wifi",
"address": "192.168.50.99",
"mac": "102030405060",
},
)
assert resp.status_code == 409
resp = c.post(
f"{base_url}/devices",
json={"name": "no-mac-wifi", "transport": "wifi", "address": "192.168.50.12"},
)
assert resp.status_code == 400
resp = c.post(
f"{base_url}/devices",
json={"name": "bad-tr", "transport": "serial"},
)
assert resp.status_code == 400
resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": " "})
assert resp.status_code == 400
resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": "renamed"})
assert resp.status_code == 200
assert resp.json()["name"] == "renamed"
resp = c.put(f"{base_url}/devices/{wid}", json={"name": "renamed"})
assert resp.status_code == 200
assert resp.json()["name"] == "renamed"
resp = c.delete(f"{base_url}/devices/{wid2}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/devices/{wid}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/devices/{dev_id}")
assert resp.status_code == 200
# Patterns. # Patterns.
resp = c.get(f"{base_url}/patterns/definitions") resp = c.get(f"{base_url}/patterns/definitions")
assert resp.status_code == 200 assert resp.status_code == 200

View File

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

89
tests/udp_server.py Normal file
View File

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