Compare commits
10 Commits
09a87b79d2
...
pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75ddd559c9 | ||
|
|
5a1067263a | ||
|
|
e67de6215a | ||
|
|
7179b6531e | ||
|
|
fd618d7714 | ||
|
|
d1ffb857c8 | ||
|
|
f8eba0ee7e | ||
|
|
e6b5bf2cf1 | ||
|
|
fbae75b957 | ||
|
|
93476655fc |
18
.cursor/rules/scoped-fixes.mdc
Normal file
18
.cursor/rules/scoped-fixes.mdc
Normal 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 user’s *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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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": []}}
|
||||||
@@ -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"}}
|
||||||
@@ -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
1
db/zone.json
Normal 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"]}}
|
||||||
47
docs/API.md
47
docs/API.md
@@ -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` (1–11). Persists AP-related settings. |
|
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). 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 profile’s tab list. |
|
| POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profile’s 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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
36
docs/help.md
36
docs/help.md
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*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 tab’s preset strip and controls.
|
- **Select a zone**: click its button in the top bar. The main area shows that zone’s 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 device’s **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 device’s **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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*The slider controls global brightness for the tab’s devices. Click the coloured area of a tile to select that preset.*
|
*The slider controls global brightness for the zone’s 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 profile’s 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 profile’s palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
|
||||||
- **Brightness (0–255)** and **Delay (ms)**: stored on the preset and sent with the compact preset payload.
|
- **Brightness (0–255)** 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 tab’s **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 zone’s **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 tab’s 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 zone’s list only**; the preset remains in the profile for other zones.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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 tab’s preset IDs, and calls **`POST /presets/send`** per tab (including each tab’s **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 zone’s preset IDs, and calls **`POST /presets/send`** per zone (including each zone’s **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
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
*Preset tiles behave the same once a tab is selected.*
|
*Preset tiles behave the same once a zone is selected.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
217
esp32/main.py
217
esp32/main.py
@@ -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 (1–11)
|
|
||||||
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
21
esp32/msg.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Submodule led-driver updated: c42dff8975...a64457a0d5
2
led-tool
2
led-tool
Submodule led-tool updated: 3844aa9d6a...5f7acf38f0
6
patterns/__init__.py
Normal file
6
patterns/__init__.py
Normal 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
33
patterns/blink.py
Normal 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
124
patterns/chase.py
Normal 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
96
patterns/circle.py
Normal 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
64
patterns/pulse.py
Normal 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
51
patterns/rainbow.py
Normal 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
57
patterns/transition.py
Normal 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
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>')
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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
361
src/controllers/zone.py
Normal 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
|
||||||
|
|
||||||
403
src/main.py
403
src/main.py
@@ -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
|
||||||
|
|||||||
@@ -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
125
src/models/http_driver.py
Normal 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
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
115
src/models/tcp_clients.py
Normal 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
|
||||||
@@ -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
8
src/models/wifi_peer.py
Normal 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
62
src/models/zone.py
Normal 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())
|
||||||
@@ -48,6 +48,11 @@ class Settings(dict):
|
|||||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||||
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:
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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
997
src/static/zones.js
Normal 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, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
@@ -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 & Send</button>
|
<button class="btn btn-primary" id="preset-save-btn">Save & 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>
|
||||||
|
|||||||
52
src/util/device_status_broadcaster.py
Normal file
52
src/util/device_status_broadcaster.py
Normal 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
192
src/util/driver_delivery.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
57
tests/models/test_zone.py
Normal 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
216
tests/tcp_test_server.py
Normal 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.")
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
99
tests/test_pattern_ota_send.py
Normal file
99
tests/test_pattern_ota_send.py
Normal 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
89
tests/udp_server.py
Normal 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 Wi‑Fi 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())
|
||||||
Reference in New Issue
Block a user