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
|
||||
|
||||
- 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 **Edit mode**, Profiles supports create/clone/delete.
|
||||
- Creating a profile always creates a populated `default` tab (starter presets).
|
||||
- Optional **DJ tab** seeding creates:
|
||||
- `dj` tab bound to device name `dj`
|
||||
- Creating a profile always creates a populated `default` zone (starter presets).
|
||||
- Optional **DJ zone** seeding creates:
|
||||
- `dj` zone bound to device name `dj`
|
||||
- starter DJ presets (rainbow, single colour, transition)
|
||||
|
||||
## Preset colours and palette linking
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **Run mode**: optimized for operation (tab/preset selection and profile apply).
|
||||
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, and profile management actions).
|
||||
- **Run mode**: optimized for operation (zone/preset selection and profile apply).
|
||||
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
|
||||
|
||||
Profiles are available in both modes, but behavior differs:
|
||||
|
||||
@@ -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. |
|
||||
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
|
||||
|
||||
### Devices — `/devices`
|
||||
|
||||
Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps to an object that always includes:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **`id`** | Same as the storage key (stable handle for URLs). |
|
||||
| **`name`** | Shown in tabs and used in `select` keys. |
|
||||
| **`type`** | `led` (only value today; extensible). |
|
||||
| **`transport`** | `espnow` or `wifi`. |
|
||||
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
|
||||
| **`default_pattern`**, **`tabs`** | Optional, as before. |
|
||||
|
||||
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/devices` | Map of device id → device object. |
|
||||
| GET | `/devices/<id>` | One device, 404 if missing. |
|
||||
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`tabs`**. Returns `{ "<id>": { ... } }`, 201. |
|
||||
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
|
||||
| DELETE | `/devices/<id>` | Remove device. |
|
||||
|
||||
### Profiles — `/profiles`
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -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/current` | `{"id": "...", "profile": {...}}` |
|
||||
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
|
||||
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_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>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
|
||||
| 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.
|
||||
- `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 |
|
||||
|--------|------|-------------|
|
||||
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
|
||||
| GET | `/tabs/current` | Current tab from cookie/session. |
|
||||
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profile’s tab list. |
|
||||
| GET | `/tabs/<id>` | Tab JSON. |
|
||||
| PUT | `/tabs/<id>` | Update tab. |
|
||||
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
|
||||
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
|
||||
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
|
||||
| GET | `/zones` | `tabs`, `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
|
||||
| GET | `/zones/current` | Current zone from cookie/session. |
|
||||
| POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profile’s zone list. |
|
||||
| GET | `/zones/<id>` | Zone JSON. |
|
||||
| PUT | `/zones/<id>` | Update zone. |
|
||||
| DELETE | `/zones/<id>` | Delete zone; can delete `current` to remove the active zone; updates profile zone list. |
|
||||
| POST | `/zones/<id>/set-current` | Sets `current_zone` cookie. |
|
||||
| POST | `/zones/<id>/clone` | Clone zone into current profile. |
|
||||
|
||||
### Palettes — `/palettes`
|
||||
|
||||
|
||||
@@ -351,9 +351,9 @@ Manage connected devices and create/manage device groups.
|
||||
#### Layout
|
||||
- **Header:** Title with "Add Device" button
|
||||
- **Tabs:** Devices and Groups tabs
|
||||
- **Content Area:** Tab-specific content
|
||||
- **Content Area:** Zone-specific content
|
||||
|
||||
#### Devices Tab
|
||||
#### Devices Zone
|
||||
|
||||
**Device List**
|
||||
- **Display:** List of all known devices
|
||||
@@ -375,7 +375,7 @@ Manage connected devices and create/manage device groups.
|
||||
- **Actions:** Cancel, Save
|
||||
- **Note:** Only one master device per system. Adding a new master will demote existing master to slave.
|
||||
|
||||
#### Groups Tab
|
||||
#### Groups Zone
|
||||
|
||||
**Group List**
|
||||
- **Display:** List of all device groups
|
||||
@@ -397,7 +397,7 @@ Manage connected devices and create/manage device groups.
|
||||
- **Actions:** Cancel, Create
|
||||
|
||||
#### Design Specifications
|
||||
- **Tab Style:** Active tab has purple background, white text
|
||||
- **Zone Style:** Active zone has purple background, white text
|
||||
- **List Items:** Bordered cards with hover effects
|
||||
- **Modal:** Centered overlay with white card, shadow
|
||||
- **Status Badges:** Colored pills (green for online, red for offline)
|
||||
@@ -1495,7 +1495,7 @@ peak_mem = usqlite.mem_peak()
|
||||
|
||||
### Flow 2: Create Device Group
|
||||
|
||||
1. User navigates to Device Management → Groups tab
|
||||
1. User navigates to Device Management → Groups zone
|
||||
2. User clicks "Create Group", enters name, selects pattern/settings
|
||||
3. User selects devices to add (can include master), clicks "Create"
|
||||
4. Group appears in list
|
||||
|
||||
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 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 |
|
||||
|------|--------|
|
||||
| **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. |
|
||||
|
||||
**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
|
||||
|
||||
- **Select a tab**: click its button in the top bar. The main area shows that tab’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.
|
||||
- **Select a zone**: click its button in the top bar. The main area shows that zone’s preset strip and controls.
|
||||
- **Edit mode — open zone settings**: **right-click** a zone button to change its name, **device IDs** (comma-separated), and which presets appear on the zone. Device identifiers are matched to each 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).
|
||||
- **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** 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).
|
||||
- **Drag and drop** tiles to reorder them; order is saved for that tab.
|
||||
- **Edit** beside a tile opens the preset editor for that preset, scoped to the current zone (so you can **Remove from zone** without deleting the preset from the profile).
|
||||
- **Drag and drop** tiles to reorder them; order is saved for that zone.
|
||||
|
||||

|
||||

|
||||
|
||||
*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).
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **Try**: sends the current form values to devices on the **current tab**, 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.
|
||||
- **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 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).
|
||||
- **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
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 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);
|
||||
}
|
||||
|
||||
.tab {
|
||||
.zone {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
@@ -78,16 +78,16 @@
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
.zone.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.zone-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
.zone-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -249,12 +249,12 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('devices')">Devices</button>
|
||||
<button class="tab" onclick="switchTab('groups')">Groups</button>
|
||||
<button class="zone active" onclick="switchTab('devices')">Devices</button>
|
||||
<button class="zone" onclick="switchTab('groups')">Groups</button>
|
||||
</div>
|
||||
|
||||
<!-- Devices Tab -->
|
||||
<div id="devices-tab" class="tab-content active">
|
||||
<!-- Devices Zone -->
|
||||
<div id="devices-zone" class="zone-content active">
|
||||
<div class="card">
|
||||
<h2>Connected Devices</h2>
|
||||
<div class="device-item">
|
||||
@@ -313,8 +313,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups Tab -->
|
||||
<div id="groups-tab" class="tab-content">
|
||||
<!-- Groups Zone -->
|
||||
<div id="groups-zone" class="zone-content">
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>Groups</h2>
|
||||
@@ -386,12 +386,12 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
function switchTab(zone) {
|
||||
document.querySelectorAll('.zone').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.zone-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
event.target.classList.add('active');
|
||||
document.getElementById(tab + '-tab').classList.add('active');
|
||||
document.getElementById(zone + '-zone').classList.add('active');
|
||||
}
|
||||
|
||||
function showAddDeviceModal() {
|
||||
|
||||
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.
|
||||
# Wire format: first 6 bytes = destination MAC, rest = payload. Address is always 6 bytes.
|
||||
# Serial-to-ESP-NOW bridge: JSON in both directions on UART + ESP-NOW.
|
||||
#
|
||||
# Pi → UART (two supported forms):
|
||||
# A) Legacy: 6 bytes destination MAC + UTF-8 JSON payload (one write = one frame).
|
||||
# B) Newline JSON: one object per line, UTF-8, ending with \n
|
||||
# - Multicast via ESP32: {"m":"split","peers":["12hex",...],"body":{...}}
|
||||
# - Unicast / broadcast: {"to":"12hex","v":"1",...} (all keys except to/dest go to peers)
|
||||
#
|
||||
# ESP-NOW → Pi: newline-delimited JSON, one object per packet:
|
||||
# {"dir":"espnow_rx","from":"<12hex>","payload":{...}} if body was JSON
|
||||
# {"dir":"espnow_rx","from":"<12hex>","payload_text":"..."} if UTF-8 not JSON
|
||||
# {"dir":"espnow_rx","from":"<12hex>","payload_b64":"..."} if binary
|
||||
from machine import Pin, UART
|
||||
import espnow
|
||||
import json
|
||||
import network
|
||||
import time
|
||||
import ubinascii
|
||||
|
||||
UART_BAUD = 912000
|
||||
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||
MAX_PEERS = 20
|
||||
# Match led-driver / controller default settings wifi_channel (1–11)
|
||||
WIFI_CHANNEL = 6
|
||||
|
||||
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))
|
||||
|
||||
# Track last send time per peer for LRU eviction (remove oldest when at limit).
|
||||
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
|
||||
|
||||
|
||||
def ensure_peer(addr):
|
||||
"""Ensure addr is in the peer list. When at 20 peers, remove the oldest-used (LRU)."""
|
||||
peers = esp.get_peers()
|
||||
peer_macs = [p[0] for p in peers]
|
||||
if addr in peer_macs:
|
||||
return
|
||||
if len(peer_macs) >= MAX_PEERS:
|
||||
# Remove the peer we used least recently (oldest).
|
||||
oldest_mac = None
|
||||
oldest_ts = time.ticks_ms()
|
||||
for mac in peer_macs:
|
||||
@@ -57,16 +64,190 @@ def ensure_peer(addr):
|
||||
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:
|
||||
idle = True
|
||||
if uart.any():
|
||||
data = uart.read()
|
||||
if not data or len(data) < 6:
|
||||
continue
|
||||
print(f"Received data: {data}")
|
||||
addr = data[:6]
|
||||
payload = data[6:]
|
||||
ensure_peer(addr)
|
||||
esp.send(addr, payload)
|
||||
last_used[addr] = time.ticks_ms()
|
||||
idle = False
|
||||
uart_rx_buf += uart.read()
|
||||
drain_uart_json_lines()
|
||||
drain_uart_legacy_frame()
|
||||
|
||||
try:
|
||||
peer, msg = esp.recv(0)
|
||||
except OSError:
|
||||
peer, msg = None, None
|
||||
|
||||
if peer is not None and msg is not None:
|
||||
idle = False
|
||||
if len(peer) == 6:
|
||||
forward_espnow_to_uart(peer, msg)
|
||||
|
||||
if idle:
|
||||
time.sleep_ms(1)
|
||||
|
||||
21
esp32/msg.json
Normal file
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 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 os
|
||||
|
||||
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
|
||||
_IDENTIFY_PRESET_KEY = "__identify"
|
||||
|
||||
# Short-key payload: 10 Hz full cycle = 50 ms on + 50 ms off (driver ``blink`` toggles each ``d`` ms).
|
||||
_IDENTIFY_DRIVER_PRESET = {
|
||||
"p": "blink",
|
||||
"c": ["#ff0000"],
|
||||
"d": 50,
|
||||
"b": 128,
|
||||
"a": True,
|
||||
"n1": 0,
|
||||
"n2": 0,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
"n5": 0,
|
||||
"n6": 0,
|
||||
}
|
||||
|
||||
|
||||
def _compact_v1_json(*, presets=None, select=None, save=False):
|
||||
"""Single-line v1 object; compact so serial/ESP-NOW stays small."""
|
||||
body = {"v": "1"}
|
||||
if presets is not None:
|
||||
body["presets"] = presets
|
||||
if save:
|
||||
body["save"] = True
|
||||
if select is not None:
|
||||
body["select"] = select
|
||||
return json.dumps(body, separators=(",", ":"))
|
||||
|
||||
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
|
||||
IDENTIFY_OFF_DELAY_S = 2.0
|
||||
|
||||
controller = Microdot()
|
||||
devices = Device()
|
||||
|
||||
|
||||
def _device_live_connected(dev_dict):
|
||||
"""
|
||||
Wi-Fi: whether a TCP client is registered for this device's address (IP).
|
||||
ESP-NOW: None (no TCP session on the Pi for that transport).
|
||||
"""
|
||||
tr = (dev_dict.get("transport") or "espnow").strip().lower()
|
||||
if tr != "wifi":
|
||||
return None
|
||||
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
|
||||
if not ip:
|
||||
return False
|
||||
return tcp_client_connected(ip)
|
||||
|
||||
|
||||
def _device_json_with_live_status(dev_dict):
|
||||
row = dict(dev_dict)
|
||||
row["connected"] = _device_live_connected(dev_dict)
|
||||
return row
|
||||
|
||||
|
||||
def _driver_patterns_dir():
|
||||
here = os.path.dirname(__file__)
|
||||
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
|
||||
|
||||
|
||||
def _safe_pattern_filename(name):
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
if not name.endswith(".py"):
|
||||
return False
|
||||
if "/" in name or "\\" in name or ".." in name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_patterns_manifest(host):
|
||||
base_dir = _driver_patterns_dir()
|
||||
names = sorted(os.listdir(base_dir))
|
||||
files = []
|
||||
for name in names:
|
||||
if not _safe_pattern_filename(name) or name == "__init__.py":
|
||||
continue
|
||||
files.append(
|
||||
{
|
||||
"name": name,
|
||||
"url": "http://%s/patterns/ota/file/%s" % (host, name),
|
||||
}
|
||||
)
|
||||
return {"files": files}
|
||||
|
||||
|
||||
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
|
||||
try:
|
||||
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||
off_msg = build_message(select={name: ["off"]})
|
||||
if transport == "wifi":
|
||||
await send_json_line_to_ip(wifi_ip, off_msg)
|
||||
else:
|
||||
await sender.send(off_msg, addr=dev_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@controller.get("")
|
||||
async def list_devices(request):
|
||||
"""List all devices."""
|
||||
"""List all devices (includes ``connected`` for live Wi-Fi TCP presence)."""
|
||||
devices_data = {}
|
||||
for dev_id in devices.list():
|
||||
d = devices.read(dev_id)
|
||||
if d:
|
||||
devices_data[dev_id] = d
|
||||
devices_data[dev_id] = _device_json_with_live_status(d)
|
||||
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
async def get_device(request, id):
|
||||
"""Get a device by ID."""
|
||||
"""Get a device by ID (includes ``connected`` for live Wi-Fi TCP presence)."""
|
||||
dev = devices.read(id)
|
||||
if dev:
|
||||
return json.dumps(dev), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404
|
||||
return json.dumps(_device_json_with_live_status(dev)), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("")
|
||||
@@ -32,37 +146,201 @@ async def create_device(request):
|
||||
try:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
return json.dumps({"error": "name is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
device_type = validate_device_type(data.get("type", "led"))
|
||||
transport = validate_device_transport(data.get("transport", "espnow"))
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
address = data.get("address")
|
||||
mac = data.get("mac")
|
||||
if derive_device_mac(mac=mac, address=address, transport=transport) is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address"
|
||||
}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
default_pattern = data.get("default_pattern")
|
||||
tabs = data.get("tabs")
|
||||
if isinstance(tabs, list):
|
||||
tabs = [str(t) for t in tabs]
|
||||
zl = data.get("zones")
|
||||
if isinstance(zl, list):
|
||||
zl = [str(t) for t in zl]
|
||||
else:
|
||||
tabs = []
|
||||
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs)
|
||||
zl = []
|
||||
dev_id = devices.create(
|
||||
name=name,
|
||||
address=address,
|
||||
mac=mac,
|
||||
default_pattern=default_pattern,
|
||||
zones=zl,
|
||||
device_type=device_type,
|
||||
transport=transport,
|
||||
)
|
||||
dev = devices.read(dev_id)
|
||||
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
code = 409 if "already exists" in msg.lower() else 400
|
||||
return json.dumps({"error": msg}), code, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.put("/<id>")
|
||||
async def update_device(request, id):
|
||||
"""Update a device."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
if "tabs" in data and isinstance(data["tabs"], list):
|
||||
data["tabs"] = [str(t) for t in data["tabs"]]
|
||||
raw = request.json or {}
|
||||
data = dict(raw)
|
||||
data.pop("id", None)
|
||||
data.pop("addresses", None)
|
||||
data.pop("connected", None)
|
||||
if "name" in data:
|
||||
n = (data.get("name") or "").strip()
|
||||
if not n:
|
||||
return json.dumps({"error": "name cannot be empty"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
data["name"] = n
|
||||
if "type" in data:
|
||||
data["type"] = validate_device_type(data.get("type"))
|
||||
if "transport" in data:
|
||||
data["transport"] = validate_device_transport(data.get("transport"))
|
||||
if "zones" in data and isinstance(data["zones"], list):
|
||||
data["zones"] = [str(t) for t in data["zones"]]
|
||||
if devices.update(id, data):
|
||||
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.delete("/<id>")
|
||||
async def delete_device(request, id):
|
||||
"""Delete a device."""
|
||||
if devices.delete(id):
|
||||
return json.dumps({"message": "Device deleted successfully"}), 200
|
||||
return json.dumps({"error": "Device not found"}), 404
|
||||
return (
|
||||
json.dumps({"message": "Device deleted successfully"}),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/<id>/identify")
|
||||
async def identify_device(request, id):
|
||||
"""
|
||||
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
|
||||
this device name — same combined shape as profile sends the driver already accepts over TCP
|
||||
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
name = str(dev.get("name") or "").strip()
|
||||
if not name:
|
||||
return json.dumps({"error": "Device must have a name to identify"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
transport = dev.get("transport") or "espnow"
|
||||
wifi_ip = None
|
||||
if transport == "wifi":
|
||||
wifi_ip = dev.get("address")
|
||||
if not wifi_ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
msg = _compact_v1_json(
|
||||
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||
select={name: [_IDENTIFY_PRESET_KEY]},
|
||||
)
|
||||
if transport == "wifi":
|
||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
else:
|
||||
await sender.send(msg, addr=id)
|
||||
|
||||
asyncio.create_task(
|
||||
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||
return json.dumps({"message": "Identify sent"}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/<id>/patterns/push")
|
||||
async def push_patterns_ota(request, id):
|
||||
"""
|
||||
Ask a Wi-Fi LED driver to pull pattern files from this server over HTTP.
|
||||
|
||||
Body (optional):
|
||||
{"manifest": "http://host:port/patterns/ota/manifest"}
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
wifi_ip = str(dev.get("address") or "").strip()
|
||||
if not wifi_ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
body = request.json or {}
|
||||
manifest_payload = body.get("manifest")
|
||||
if manifest_payload is None:
|
||||
host = request.headers.get("Host", "")
|
||||
if not host:
|
||||
return json.dumps({"error": "Missing Host header"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
manifest_payload = _build_patterns_manifest(host)
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if not isinstance(manifest_payload, (str, dict)):
|
||||
return json.dumps({"error": "manifest must be a URL string or manifest object"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
msg = json.dumps({"v": "1", "manifest": manifest_payload}, separators=(",", ":"))
|
||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps({"message": "Pattern OTA trigger sent", "manifest": manifest_payload}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@@ -1,19 +1,67 @@
|
||||
from microdot import Microdot
|
||||
from models.pattern import Pattern
|
||||
from models.device import Device
|
||||
from models.tcp_clients import send_json_line_to_ip
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import os
|
||||
|
||||
controller = Microdot()
|
||||
patterns = Pattern()
|
||||
|
||||
|
||||
def _project_root():
|
||||
"""Project root (parent of ``src/``). CWD is often ``src/`` when running ``main.py``."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.abspath(os.path.join(here, "..", ".."))
|
||||
|
||||
|
||||
def _driver_patterns_dir():
|
||||
here = os.path.dirname(__file__)
|
||||
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
|
||||
|
||||
|
||||
def _safe_pattern_filename(name):
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
if not name.endswith(".py"):
|
||||
return False
|
||||
if "/" in name or "\\" in name or ".." in name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_PATTERN_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
|
||||
|
||||
|
||||
def _normalize_pattern_key(raw):
|
||||
"""Pattern id / module basename (no .py)."""
|
||||
if not isinstance(raw, str):
|
||||
return ""
|
||||
s = raw.strip()
|
||||
if s.lower().endswith(".py"):
|
||||
s = s[:-3].strip()
|
||||
return s
|
||||
|
||||
|
||||
def _valid_pattern_key(key):
|
||||
return bool(key and _PATTERN_KEY_RE.match(key))
|
||||
|
||||
def load_pattern_definitions():
|
||||
"""Load pattern definitions from pattern.json file."""
|
||||
try:
|
||||
# Try different paths for local development vs MicroPython
|
||||
paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json']
|
||||
root = _project_root()
|
||||
paths = [
|
||||
os.path.join(root, "db", "pattern.json"),
|
||||
os.path.join(root, "pattern.json"),
|
||||
"db/pattern.json",
|
||||
"pattern.json",
|
||||
"/db/pattern.json",
|
||||
]
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except OSError:
|
||||
continue
|
||||
@@ -22,16 +70,301 @@ def load_pattern_definitions():
|
||||
print(f"Error loading pattern.json: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def load_driver_pattern_names():
|
||||
"""List available pattern module names from led-driver/src/patterns."""
|
||||
try:
|
||||
names = []
|
||||
for filename in os.listdir(_driver_patterns_dir()):
|
||||
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
continue
|
||||
names.append(filename[:-3])
|
||||
names.sort()
|
||||
return names
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def build_runtime_pattern_map():
|
||||
"""
|
||||
Runtime pattern map for UI menus.
|
||||
Keep pattern DB metadata as primary, then add any local driver pattern files
|
||||
missing from the DB so new OTA files still appear in menus.
|
||||
"""
|
||||
definitions = load_pattern_definitions()
|
||||
available = load_driver_pattern_names()
|
||||
result = {}
|
||||
for name, meta in definitions.items():
|
||||
result[name] = dict(meta) if isinstance(meta, dict) else {}
|
||||
for name in available:
|
||||
if name not in result:
|
||||
result[name] = {}
|
||||
return result
|
||||
|
||||
@controller.get('/definitions')
|
||||
async def get_pattern_definitions(request):
|
||||
"""Get pattern definitions from pattern.json."""
|
||||
definitions = load_pattern_definitions()
|
||||
"""Get definitions for patterns currently available on the driver."""
|
||||
definitions = build_runtime_pattern_map()
|
||||
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.get('/ota/manifest')
|
||||
async def ota_manifest(request):
|
||||
"""Manifest of driver pattern source files for OTA pulls."""
|
||||
base_dir = _driver_patterns_dir()
|
||||
host = request.headers.get("Host", "")
|
||||
if not host:
|
||||
return json.dumps({"error": "Missing Host header"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
try:
|
||||
names = sorted(os.listdir(base_dir))
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
|
||||
files = []
|
||||
for name in names:
|
||||
if not _safe_pattern_filename(name) or name == "__init__.py":
|
||||
continue
|
||||
files.append({
|
||||
"name": name,
|
||||
"url": "http://%s/patterns/ota/file/%s" % (host, name),
|
||||
})
|
||||
|
||||
return json.dumps({"files": files}), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get('/ota/file/<name>')
|
||||
async def ota_pattern_file(request, name):
|
||||
"""Serve one driver pattern source file for OTA pulls."""
|
||||
if not _safe_pattern_filename(name) or name == "__init__.py":
|
||||
return json.dumps({"error": "Invalid filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
path = os.path.join(_driver_patterns_dir(), name)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return json.dumps({"error": "Pattern file not found"}), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
|
||||
|
||||
|
||||
@controller.post('/<name>/send')
|
||||
async def send_pattern_to_device(request, name):
|
||||
"""Tell Wi-Fi driver(s) to download one pattern source file over HTTP."""
|
||||
if not isinstance(name, str):
|
||||
return json.dumps({"error": "Invalid pattern name"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
filename = name if name.endswith(".py") else (name + ".py")
|
||||
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
return json.dumps({"error": "Invalid pattern filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
devices = Device()
|
||||
body = request.json or {}
|
||||
requested_device_id = str(body.get("device_id") or "").strip()
|
||||
|
||||
path = os.path.join(_driver_patterns_dir(), filename)
|
||||
if not os.path.exists(path):
|
||||
return json.dumps({"error": "Pattern file not found"}), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
file_url = "/patterns/ota/file/%s" % filename
|
||||
|
||||
msg = json.dumps(
|
||||
{
|
||||
"v": "1",
|
||||
"manifest": {
|
||||
"files": [
|
||||
{
|
||||
"name": filename,
|
||||
"url": file_url,
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
target_ids = []
|
||||
if requested_device_id:
|
||||
dev = devices.read(requested_device_id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
target_ids = [requested_device_id]
|
||||
else:
|
||||
for did in devices.list():
|
||||
dev = devices.read(did) or {}
|
||||
if (dev.get("transport") or "").lower() == "wifi":
|
||||
target_ids.append(str(did))
|
||||
if not target_ids:
|
||||
return json.dumps({"error": "No Wi-Fi devices found"}), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
sent_ids = []
|
||||
for did in target_ids:
|
||||
dev = devices.read(did) or {}
|
||||
ip = str(dev.get("address") or "").strip()
|
||||
if not ip:
|
||||
continue
|
||||
ok = await send_json_line_to_ip(ip, msg)
|
||||
if ok:
|
||||
sent_ids.append(did)
|
||||
|
||||
if not sent_ids:
|
||||
return json.dumps({"error": "No Wi-Fi drivers connected"}), 503, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
@controller.post('/upload')
|
||||
async def upload_pattern_file(request):
|
||||
"""
|
||||
Upload a pattern source file to led-controller local storage.
|
||||
|
||||
Body JSON:
|
||||
{
|
||||
"name": "sparkle.py" | "sparkle",
|
||||
"code": "class Sparkle: ...",
|
||||
"overwrite": true | false # optional, default true
|
||||
}
|
||||
"""
|
||||
data = request.json or {}
|
||||
raw_name = data.get("name") or data.get("filename")
|
||||
code = data.get("code")
|
||||
overwrite = data.get("overwrite", True)
|
||||
overwrite = bool(overwrite)
|
||||
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
return json.dumps({"error": "name is required"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
filename = raw_name.strip()
|
||||
if not filename.endswith(".py"):
|
||||
filename += ".py"
|
||||
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
return json.dumps({"error": "invalid pattern filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return json.dumps({"error": "code is required"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
path = os.path.join(_driver_patterns_dir(), filename)
|
||||
exists = os.path.exists(path)
|
||||
if exists and not overwrite:
|
||||
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write(code)
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
|
||||
return json.dumps({
|
||||
"message": "Pattern uploaded",
|
||||
"name": filename,
|
||||
"overwrote": bool(exists),
|
||||
}), 201, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post('/driver')
|
||||
async def create_driver_pattern(request):
|
||||
"""
|
||||
Create a driver pattern: save ``.py`` under led-driver/src/patterns and
|
||||
metadata in db/pattern.json (Pattern model).
|
||||
|
||||
Body JSON:
|
||||
name, code (required),
|
||||
min_delay, max_delay, max_colors (optional numbers),
|
||||
n1..n8 (optional string labels),
|
||||
overwrite (optional, default true).
|
||||
"""
|
||||
data = request.json or {}
|
||||
key = _normalize_pattern_key(data.get("name") or "")
|
||||
if not _valid_pattern_key(key):
|
||||
return json.dumps({
|
||||
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
|
||||
}), 400, {"Content-Type": "application/json"}
|
||||
|
||||
code = data.get("code")
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
overwrite = bool(data.get("overwrite", True))
|
||||
|
||||
filename = key + ".py"
|
||||
py_path = os.path.join(_driver_patterns_dir(), filename)
|
||||
if os.path.exists(py_path) and not overwrite:
|
||||
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
meta = {}
|
||||
for fld in ("min_delay", "max_delay", "max_colors"):
|
||||
if fld not in data:
|
||||
continue
|
||||
try:
|
||||
meta[fld] = int(data[fld])
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps({"error": "%s must be an integer" % fld}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
for i in range(1, 9):
|
||||
nk = "n%d" % i
|
||||
if nk not in data:
|
||||
continue
|
||||
lab = data[nk]
|
||||
if lab is None:
|
||||
continue
|
||||
s = str(lab).strip()
|
||||
if s:
|
||||
meta[nk] = s
|
||||
|
||||
try:
|
||||
with open(py_path, "w") as f:
|
||||
f.write(code)
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
|
||||
if patterns.read(key):
|
||||
patterns.update(key, meta)
|
||||
else:
|
||||
patterns.create(key, meta)
|
||||
|
||||
return json.dumps({
|
||||
"message": "Pattern created",
|
||||
"name": key,
|
||||
"file": filename,
|
||||
"metadata": patterns.read(key),
|
||||
}), 201, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get('')
|
||||
async def list_patterns(request):
|
||||
"""List all patterns."""
|
||||
return json.dumps(patterns), 200, {'Content-Type': 'application/json'}
|
||||
"""List patterns for UI (DB metadata + local driver additions)."""
|
||||
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.get('/<id>')
|
||||
|
||||
@@ -2,9 +2,10 @@ from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.preset import Preset
|
||||
from models.profile import Profile
|
||||
from models.device import Device, normalize_mac
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||
from util.espnow_message import build_message, build_preset_dict
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
@@ -125,13 +126,17 @@ async def delete_preset(request, *args, **kwargs):
|
||||
@with_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:
|
||||
{"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
|
||||
<= 240-byte messages, and sends them over the configured transport.
|
||||
Optional "destination_mac" / "to": single MAC when targets is omitted.
|
||||
"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
@@ -144,7 +149,6 @@ async def send_presets(request, session):
|
||||
save_flag = data.get('save', True)
|
||||
save_flag = bool(save_flag)
|
||||
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')
|
||||
|
||||
# Build API-compliant preset map keyed by preset ID, include name
|
||||
@@ -171,23 +175,13 @@ async def send_presets(request, session):
|
||||
if not sender:
|
||||
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
|
||||
send_delay_s = 0.1
|
||||
entries = list(presets_by_name.items())
|
||||
total_presets = len(entries)
|
||||
messages_sent = 0
|
||||
|
||||
batch = {}
|
||||
last_msg = None
|
||||
chunk_messages = []
|
||||
for name, preset_obj in entries:
|
||||
test_batch = dict(batch)
|
||||
test_batch[name] = preset_obj
|
||||
@@ -196,28 +190,133 @@ async def send_presets(request, session):
|
||||
|
||||
if size <= MAX_BYTES or not batch:
|
||||
batch = test_batch
|
||||
last_msg = test_msg
|
||||
else:
|
||||
try:
|
||||
await send_chunk(batch, False)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
await asyncio.sleep(send_delay_s)
|
||||
messages_sent += 1
|
||||
chunk_messages.append(
|
||||
build_message(
|
||||
presets=dict(batch),
|
||||
save=False,
|
||||
default=None,
|
||||
)
|
||||
)
|
||||
batch = {name: preset_obj}
|
||||
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
|
||||
|
||||
if batch:
|
||||
try:
|
||||
await send_chunk(batch, True)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
await asyncio.sleep(send_delay_s)
|
||||
messages_sent += 1
|
||||
chunk_messages.append(
|
||||
build_message(
|
||||
presets=dict(batch),
|
||||
save=save_flag,
|
||||
default=default_id,
|
||||
)
|
||||
)
|
||||
|
||||
target_list = None
|
||||
raw_targets = data.get("targets")
|
||||
if isinstance(raw_targets, list) and raw_targets:
|
||||
target_list = []
|
||||
for t in raw_targets:
|
||||
m = normalize_mac(str(t))
|
||||
if m:
|
||||
target_list.append(m)
|
||||
target_list = list(dict.fromkeys(target_list))
|
||||
if not target_list:
|
||||
target_list = None
|
||||
elif destination_mac:
|
||||
dm = normalize_mac(str(destination_mac))
|
||||
target_list = [dm] if dm else None
|
||||
|
||||
try:
|
||||
if target_list:
|
||||
deliveries = await deliver_preset_broadcast_then_per_device(
|
||||
sender,
|
||||
chunk_messages,
|
||||
target_list,
|
||||
Device(),
|
||||
str(default_id) if default_id is not None else None,
|
||||
delay_s=send_delay_s,
|
||||
)
|
||||
else:
|
||||
deliveries, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
chunk_messages,
|
||||
None,
|
||||
Device(),
|
||||
delay_s=send_delay_s,
|
||||
)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
return json.dumps({
|
||||
"message": "Presets sent",
|
||||
"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'}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.profile import Profile
|
||||
from models.tab import Tab
|
||||
from models.zone import Zone
|
||||
from models.preset import Preset
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
profiles = Profile()
|
||||
tabs = Tab()
|
||||
zones = Zone()
|
||||
presets = Preset()
|
||||
|
||||
@controller.get('')
|
||||
@@ -83,20 +83,20 @@ async def create_profile(request):
|
||||
try:
|
||||
data = dict(request.json or {})
|
||||
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):
|
||||
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:
|
||||
seed_dj_tab = bool(seed_raw)
|
||||
seed_dj_zone = bool(seed_raw)
|
||||
# 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)
|
||||
# Avoid persisting request-only fields.
|
||||
data.pop("name", None)
|
||||
if 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_defs = [
|
||||
{
|
||||
@@ -139,18 +139,18 @@ async def create_profile(request):
|
||||
presets.update(pid, preset_data)
|
||||
default_preset_ids.append(str(pid))
|
||||
|
||||
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||
tabs.update(default_tab_id, {
|
||||
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||
zones.update(default_tab_id, {
|
||||
"presets_flat": default_preset_ids,
|
||||
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||
})
|
||||
|
||||
profile = profiles.read(profile_id) or {}
|
||||
profile_tabs = profile.get("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))
|
||||
|
||||
if seed_dj_tab:
|
||||
# Seed a DJ-focused tab with three starter presets.
|
||||
if seed_dj_zone:
|
||||
# Seed a DJ-focused zone with three starter presets.
|
||||
seeded_preset_ids = []
|
||||
preset_defs = [
|
||||
{
|
||||
@@ -182,15 +182,15 @@ async def create_profile(request):
|
||||
presets.update(pid, preset_data)
|
||||
seeded_preset_ids.append(str(pid))
|
||||
|
||||
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||
tabs.update(dj_tab_id, {
|
||||
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||
zones.update(dj_tab_id, {
|
||||
"presets_flat": seeded_preset_ids,
|
||||
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||
})
|
||||
|
||||
profile_tabs.append(str(dj_tab_id))
|
||||
|
||||
profiles.update(profile_id, {"tabs": profile_tabs})
|
||||
profiles.update(profile_id, {"zones": profile_tabs})
|
||||
|
||||
profile_data = profiles.read(profile_id)
|
||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
@@ -208,7 +208,7 @@ async def clone_profile(request, id):
|
||||
data = request.json or {}
|
||||
source_name = source.get("name") or f"Profile {id}"
|
||||
new_name = data.get("name") or source_name
|
||||
profile_type = source.get("type", "tabs")
|
||||
profile_type = source.get("type", "zones")
|
||||
|
||||
def allocate_id(model, cache):
|
||||
if "next" not in cache:
|
||||
@@ -255,28 +255,28 @@ async def clone_profile(request, id):
|
||||
palette_colors = []
|
||||
|
||||
# Clone tabs and presets used by those tabs
|
||||
source_tabs = source.get("tabs")
|
||||
source_tabs = source.get("zones")
|
||||
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
|
||||
source_tabs = source.get("tab_order", [])
|
||||
source_tabs = source.get("zone_order", [])
|
||||
source_tabs = source_tabs or []
|
||||
cloned_tab_ids = []
|
||||
preset_id_map = {}
|
||||
new_tabs = {}
|
||||
new_presets = {}
|
||||
for tab_id in source_tabs:
|
||||
tab = tabs.read(tab_id)
|
||||
if not tab:
|
||||
for zone_id in source_tabs:
|
||||
zone = zones.read(zone_id)
|
||||
if not zone:
|
||||
continue
|
||||
tab_name = tab.get("name") or f"Tab {tab_id}"
|
||||
tab_name = zone.get("name") or f"Zone {zone_id}"
|
||||
clone_name = tab_name
|
||||
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||
clone_id = allocate_id(tabs, tab_cache)
|
||||
mapped_presets = map_preset_container(zone.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||
clone_id = allocate_id(zones, tab_cache)
|
||||
clone_data = {
|
||||
"name": clone_name,
|
||||
"names": tab.get("names") or [],
|
||||
"names": zone.get("names") or [],
|
||||
"presets": mapped_presets if mapped_presets is not None else []
|
||||
}
|
||||
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
|
||||
extra = {k: v for k, v in zone.items() if k not in ("name", "names", "presets")}
|
||||
if "presets_flat" in extra:
|
||||
extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||
if extra:
|
||||
@@ -287,7 +287,7 @@ async def clone_profile(request, id):
|
||||
new_profile_data = {
|
||||
"name": new_name,
|
||||
"type": profile_type,
|
||||
"tabs": cloned_tab_ids,
|
||||
"zones": cloned_tab_ids,
|
||||
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
|
||||
"palette_id": str(new_palette_id),
|
||||
}
|
||||
@@ -297,12 +297,12 @@ async def clone_profile(request, id):
|
||||
for pid, pdata in new_presets.items():
|
||||
presets[pid] = pdata
|
||||
for tid, tdata in new_tabs.items():
|
||||
tabs[tid] = tdata
|
||||
zones[tid] = tdata
|
||||
profiles[str(new_profile_id)] = new_profile_data
|
||||
|
||||
profiles._palette_model.save()
|
||||
presets.save()
|
||||
tabs.save()
|
||||
zones.save()
|
||||
profiles.save()
|
||||
|
||||
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
|
||||
@@ -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
|
||||
|
||||
405
src/main.py
405
src/main.py
@@ -1,6 +1,11 @@
|
||||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
import traceback
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.websocket import with_websocket
|
||||
from microdot.session import Session
|
||||
@@ -10,12 +15,291 @@ import controllers.preset as preset
|
||||
import controllers.profile as profile
|
||||
import controllers.group as group
|
||||
import controllers.sequence as sequence
|
||||
import controllers.tab as tab
|
||||
import controllers.zone as zone
|
||||
import controllers.palette as palette
|
||||
import controllers.scene as scene
|
||||
import controllers.pattern as pattern
|
||||
import controllers.settings as settings_controller
|
||||
from models.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):
|
||||
@@ -40,7 +324,7 @@ async def main(port=80):
|
||||
('/profiles', profile, 'profile'),
|
||||
('/groups', group, 'group'),
|
||||
('/sequences', sequence, 'sequence'),
|
||||
('/tabs', tab, 'tab'),
|
||||
('/zones', zone, 'zone'),
|
||||
('/palettes', palette, 'palette'),
|
||||
('/scenes', scene, 'scene'),
|
||||
]
|
||||
@@ -50,12 +334,15 @@ async def main(port=80):
|
||||
app.mount(profile.controller, '/profiles')
|
||||
app.mount(group.controller, '/groups')
|
||||
app.mount(sequence.controller, '/sequences')
|
||||
app.mount(tab.controller, '/tabs')
|
||||
app.mount(zone.controller, '/zones')
|
||||
app.mount(palette.controller, '/palettes')
|
||||
app.mount(scene.controller, '/scenes')
|
||||
app.mount(pattern.controller, '/patterns')
|
||||
app.mount(settings_controller.controller, '/settings')
|
||||
|
||||
app.mount(device_controller.controller, '/devices')
|
||||
|
||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
||||
|
||||
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||
@app.route('/')
|
||||
def index(request):
|
||||
@@ -85,41 +372,99 @@ async def main(port=80):
|
||||
@app.route('/ws')
|
||||
@with_websocket
|
||||
async def ws(request, ws):
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
print(data)
|
||||
if data:
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
print("WS received JSON:", parsed)
|
||||
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
|
||||
addr = parsed.pop("to", None)
|
||||
payload = json.dumps(parsed) if parsed else data
|
||||
await sender.send(payload, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON: send raw with default address
|
||||
await register_device_status_ws(ws)
|
||||
await broadcast_device_tcp_snapshot_to(ws)
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
print(data)
|
||||
if data:
|
||||
try:
|
||||
await 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:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
finally:
|
||||
await unregister_device_status_ws(ws)
|
||||
|
||||
|
||||
|
||||
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
|
||||
# Touch Device singleton early so db/device.json exists before first TCP hello.
|
||||
Device()
|
||||
await _send_bridge_wifi_channel(settings, sender)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
# cleanup before ending the application
|
||||
tcp_holder = {}
|
||||
udp_holder = {"closing": False}
|
||||
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__":
|
||||
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
|
||||
|
||||
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)."""
|
||||
if addr is None:
|
||||
|
||||
def validate_device_type(value):
|
||||
t = (value or "led").strip().lower()
|
||||
if t not in DEVICE_TYPES:
|
||||
raise ValueError(f"type must be one of: {', '.join(sorted(DEVICE_TYPES))}")
|
||||
return t
|
||||
|
||||
|
||||
def validate_device_transport(value):
|
||||
tr = (value or "espnow").strip().lower()
|
||||
if tr not in DEVICE_TRANSPORTS:
|
||||
raise ValueError(
|
||||
f"transport must be one of: {', '.join(sorted(DEVICE_TRANSPORTS))}"
|
||||
)
|
||||
return tr
|
||||
|
||||
|
||||
def normalize_mac(mac):
|
||||
"""Normalise to 12-char lowercase hex or None."""
|
||||
if mac is None:
|
||||
return None
|
||||
s = str(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):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def derive_device_mac(mac=None, address=None, transport="espnow"):
|
||||
"""
|
||||
Resolve the device MAC used as storage id.
|
||||
|
||||
Explicit ``mac`` wins. For ESP-NOW, ``address`` is the peer MAC. For Wi-Fi,
|
||||
``mac`` must be supplied (``address`` is typically an IP).
|
||||
"""
|
||||
m = normalize_mac(mac)
|
||||
if m:
|
||||
return m
|
||||
tr = validate_device_transport(transport)
|
||||
if tr == "espnow":
|
||||
return normalize_mac(address)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_address_for_transport(addr, transport):
|
||||
"""ESP-NOW → 12 hex or None; Wi-Fi → trimmed string or None."""
|
||||
tr = validate_device_transport(transport)
|
||||
if tr == "espnow":
|
||||
return normalize_mac(addr)
|
||||
if addr is None:
|
||||
return None
|
||||
s = str(addr).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
class Device(Model):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def create(self, name="", address=None, default_pattern=None, tabs=None):
|
||||
next_id = self.get_next_id()
|
||||
addr = _normalize_address(address)
|
||||
self[next_id] = {
|
||||
def load(self):
|
||||
super().load()
|
||||
changed = False
|
||||
for sid, doc in list(self.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if self._migrate_record(str(sid), doc):
|
||||
changed = True
|
||||
if self._rekey_legacy_ids():
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
def _migrate_record(self, storage_id, doc):
|
||||
changed = False
|
||||
if doc.get("type") not in DEVICE_TYPES:
|
||||
doc["type"] = "led"
|
||||
changed = True
|
||||
if doc.get("transport") not in DEVICE_TRANSPORTS:
|
||||
doc["transport"] = "espnow"
|
||||
changed = True
|
||||
raw_list = doc.get("addresses")
|
||||
if isinstance(raw_list, list) and raw_list:
|
||||
picked = None
|
||||
for item in raw_list:
|
||||
n = normalize_mac(item)
|
||||
if n:
|
||||
picked = n
|
||||
break
|
||||
if picked:
|
||||
doc["address"] = picked
|
||||
del doc["addresses"]
|
||||
changed = True
|
||||
elif "addresses" in doc:
|
||||
del doc["addresses"]
|
||||
changed = True
|
||||
tr = doc["transport"]
|
||||
norm = normalize_address_for_transport(doc.get("address"), tr)
|
||||
if doc.get("address") != norm:
|
||||
doc["address"] = norm
|
||||
changed = True
|
||||
mac_key = normalize_mac(storage_id)
|
||||
if mac_key and mac_key == storage_id and str(doc.get("id") or "") != mac_key:
|
||||
doc["id"] = mac_key
|
||||
changed = True
|
||||
elif str(doc.get("id") or "").strip() != storage_id:
|
||||
doc["id"] = storage_id
|
||||
changed = True
|
||||
doc.pop("mac", None)
|
||||
return changed
|
||||
|
||||
def _rekey_legacy_ids(self):
|
||||
"""Move numeric-keyed rows to MAC keys when ESP-NOW MAC is known."""
|
||||
changed = False
|
||||
moves = []
|
||||
for sid in list(self.keys()):
|
||||
doc = self.get(sid)
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if normalize_mac(sid) == sid:
|
||||
continue
|
||||
if not str(sid).isdigit():
|
||||
continue
|
||||
tr = doc.get("transport", "espnow")
|
||||
cand = None
|
||||
if tr == "espnow":
|
||||
cand = normalize_mac(doc.get("address"))
|
||||
if not cand:
|
||||
continue
|
||||
moves.append((sid, cand))
|
||||
for old, mac in moves:
|
||||
if old not in self:
|
||||
continue
|
||||
doc = self.pop(old)
|
||||
if mac in self:
|
||||
existing = dict(self[mac])
|
||||
for k, v in doc.items():
|
||||
if k not in existing or existing[k] in (None, "", []):
|
||||
existing[k] = v
|
||||
doc = existing
|
||||
doc["id"] = mac
|
||||
self[mac] = doc
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def create(
|
||||
self,
|
||||
name="",
|
||||
address=None,
|
||||
mac=None,
|
||||
default_pattern=None,
|
||||
zones=None,
|
||||
device_type="led",
|
||||
transport="espnow",
|
||||
):
|
||||
dt = validate_device_type(device_type)
|
||||
tr = validate_device_transport(transport)
|
||||
mac_hex = derive_device_mac(mac=mac, address=address, transport=tr)
|
||||
if not mac_hex:
|
||||
raise ValueError(
|
||||
"mac is required (12 hex characters); for Wi-Fi pass mac separately from IP address"
|
||||
)
|
||||
if mac_hex in self:
|
||||
raise ValueError("device with this mac already exists")
|
||||
addr = normalize_address_for_transport(address, tr)
|
||||
if tr == "espnow":
|
||||
addr = mac_hex
|
||||
self[mac_hex] = {
|
||||
"id": mac_hex,
|
||||
"name": name,
|
||||
"type": dt,
|
||||
"transport": tr,
|
||||
"address": addr,
|
||||
"default_pattern": default_pattern if default_pattern else None,
|
||||
"tabs": list(tabs) if tabs else [],
|
||||
"zones": list(zones) if zones else [],
|
||||
}
|
||||
self.save()
|
||||
return next_id
|
||||
return mac_hex
|
||||
|
||||
def read(self, id):
|
||||
id_str = str(id)
|
||||
return self.get(id_str, None)
|
||||
m = normalize_mac(id)
|
||||
if m is not None and m in self:
|
||||
return self.get(m)
|
||||
return self.get(str(id), None)
|
||||
|
||||
def update(self, id, data):
|
||||
id_str = str(id)
|
||||
id_str = normalize_mac(id)
|
||||
if id_str is None:
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
if "address" in data and data["address"] is not None:
|
||||
data = dict(data)
|
||||
data["address"] = _normalize_address(data["address"])
|
||||
self[id_str].update(data)
|
||||
incoming = dict(data)
|
||||
incoming.pop("id", None)
|
||||
incoming.pop("addresses", None)
|
||||
in_mac = normalize_mac(incoming.get("mac"))
|
||||
if in_mac is not None and in_mac != id_str:
|
||||
raise ValueError("cannot change device mac; delete and re-add")
|
||||
incoming.pop("mac", None)
|
||||
merged = dict(self[id_str])
|
||||
merged.update(incoming)
|
||||
merged["type"] = validate_device_type(merged.get("type"))
|
||||
merged["transport"] = validate_device_transport(merged.get("transport"))
|
||||
tr = merged["transport"]
|
||||
merged["address"] = normalize_address_for_transport(merged.get("address"), tr)
|
||||
if tr == "espnow":
|
||||
merged["address"] = id_str
|
||||
merged["id"] = id_str
|
||||
self[id_str] = merged
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def delete(self, id):
|
||||
id_str = str(id)
|
||||
id_str = normalize_mac(id)
|
||||
if id_str is None:
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
self.pop(id_str)
|
||||
@@ -52,3 +232,48 @@ class Device(Model):
|
||||
|
||||
def list(self):
|
||||
return list(self.keys())
|
||||
|
||||
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
|
||||
"""
|
||||
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
|
||||
**address** (peer IP), and optionally **type** from the client hello when valid.
|
||||
"""
|
||||
mac_hex = normalize_mac(mac)
|
||||
if not mac_hex:
|
||||
return None
|
||||
name = (device_name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
ip = normalize_address_for_transport(peer_ip, "wifi")
|
||||
if not ip:
|
||||
return None
|
||||
resolved_type = None
|
||||
if device_type is not None:
|
||||
try:
|
||||
resolved_type = validate_device_type(device_type)
|
||||
except ValueError:
|
||||
resolved_type = None
|
||||
if mac_hex in self:
|
||||
merged = dict(self[mac_hex])
|
||||
merged["name"] = name
|
||||
if resolved_type is not None:
|
||||
merged["type"] = resolved_type
|
||||
else:
|
||||
merged["type"] = validate_device_type(merged.get("type"))
|
||||
merged["transport"] = "wifi"
|
||||
merged["address"] = ip
|
||||
merged["id"] = mac_hex
|
||||
self[mac_hex] = merged
|
||||
self.save()
|
||||
return mac_hex
|
||||
self[mac_hex] = {
|
||||
"id": mac_hex,
|
||||
"name": name,
|
||||
"type": resolved_type or "led",
|
||||
"transport": "wifi",
|
||||
"address": ip,
|
||||
"default_pattern": None,
|
||||
"zones": [],
|
||||
}
|
||||
self.save()
|
||||
return mac_hex
|
||||
|
||||
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:
|
||||
self.save()
|
||||
|
||||
def create(self, name="", profile_type="tabs"):
|
||||
def create(self, name="", profile_type="zones"):
|
||||
"""Create a new profile and its own empty palette.
|
||||
|
||||
profile_type: "tabs" or "scenes" (ignoring scenes for now)
|
||||
profile_type: "zones" or "scenes" (ignoring scenes for now)
|
||||
"""
|
||||
next_id = self.get_next_id()
|
||||
# Create a unique palette for this profile.
|
||||
palette_id = self._palette_model.create(colors=[])
|
||||
self[next_id] = {
|
||||
"name": name,
|
||||
"type": profile_type, # "tabs" or "scenes"
|
||||
"tabs": [], # Array of tab IDs
|
||||
"type": profile_type, # "zones" or "scenes"
|
||||
"zones": [], # Array of zone IDs
|
||||
"scenes": [], # Array of scene IDs (for future use)
|
||||
"palette_id": str(palette_id),
|
||||
}
|
||||
|
||||
@@ -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._default_addr = _parse_mac(default_addr)
|
||||
self._write_lock = asyncio.Lock()
|
||||
|
||||
async def send(self, data, addr=None):
|
||||
mac = _parse_mac(addr) if addr is not None else self._default_addr
|
||||
payload = _encode_payload(data)
|
||||
await _to_thread(self._serial.write, mac + payload)
|
||||
async with self._write_lock:
|
||||
await _to_thread(self._serial.write, mac + payload)
|
||||
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
|
||||
if 'wifi_channel' not in self:
|
||||
self['wifi_channel'] = 6
|
||||
# Wi-Fi LED drivers: newline-delimited JSON over TCP (see led-driver WiFi transport)
|
||||
if 'tcp_enabled' not in self:
|
||||
self['tcp_enabled'] = True
|
||||
if 'tcp_port' not in self:
|
||||
self['tcp_port'] = 8765
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
|
||||
@@ -5,7 +5,7 @@ class LightingController {
|
||||
this.state = {
|
||||
lights: {},
|
||||
patterns: {},
|
||||
tab_order: [],
|
||||
zone_order: [],
|
||||
presets: {}
|
||||
};
|
||||
this.selectedColorIndex = 0;
|
||||
@@ -19,8 +19,8 @@ class LightingController {
|
||||
await this.loadState();
|
||||
this.setupEventListeners();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
if (this.state.zone_order.length > 0) {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,19 +62,19 @@ class LightingController {
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Tab management
|
||||
document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal());
|
||||
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal());
|
||||
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab());
|
||||
// Zone management
|
||||
document.getElementById('add-zone-btn').addEventListener('click', () => this.showAddTabModal());
|
||||
document.getElementById('edit-zone-btn').addEventListener('click', () => this.showEditTabModal());
|
||||
document.getElementById('delete-zone-btn').addEventListener('click', () => this.deleteCurrentTab());
|
||||
document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette());
|
||||
document.getElementById('presets-btn').addEventListener('click', () => this.showPresets());
|
||||
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
|
||||
|
||||
// Modal actions
|
||||
document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab());
|
||||
document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal'));
|
||||
document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab());
|
||||
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal'));
|
||||
document.getElementById('add-zone-confirm').addEventListener('click', () => this.createTab());
|
||||
document.getElementById('add-zone-cancel').addEventListener('click', () => this.hideModal('add-zone-modal'));
|
||||
document.getElementById('edit-zone-confirm').addEventListener('click', () => this.updateTab());
|
||||
document.getElementById('edit-zone-cancel').addEventListener('click', () => this.hideModal('edit-zone-modal'));
|
||||
document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal'));
|
||||
document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal'));
|
||||
document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal'));
|
||||
@@ -125,12 +125,12 @@ class LightingController {
|
||||
}
|
||||
|
||||
renderTabs() {
|
||||
const tabsList = document.getElementById('tabs-list');
|
||||
const tabsList = document.getElementById('zones-list');
|
||||
tabsList.innerHTML = '';
|
||||
|
||||
this.state.tab_order.forEach(tabName => {
|
||||
this.state.zone_order.forEach(tabName => {
|
||||
const tabButton = document.createElement('button');
|
||||
tabButton.className = 'tab-button';
|
||||
tabButton.className = 'zone-button';
|
||||
tabButton.textContent = tabName;
|
||||
tabButton.addEventListener('click', () => this.selectTab(tabName));
|
||||
if (tabName === this.currentTab) {
|
||||
@@ -217,13 +217,13 @@ class LightingController {
|
||||
}
|
||||
|
||||
renderPresets(tabName) {
|
||||
const presetsList = document.getElementById('presets-list-tab');
|
||||
const presetsList = document.getElementById('presets-list-zone');
|
||||
presetsList.innerHTML = '';
|
||||
|
||||
const presets = this.state.presets || {};
|
||||
const presetNames = Object.keys(presets);
|
||||
|
||||
// Get current tab's settings for comparison
|
||||
// Get current zone's settings for comparison
|
||||
const currentSettings = this.getCurrentTabSettings(tabName);
|
||||
|
||||
// Always include "on" and "off" presets
|
||||
@@ -267,7 +267,7 @@ class LightingController {
|
||||
const presetButton = document.createElement('button');
|
||||
presetButton.className = 'pattern-button';
|
||||
|
||||
// Check if this preset matches the current tab's settings
|
||||
// Check if this preset matches the current zone's settings
|
||||
const isActive = this.presetMatchesSettings(preset, currentSettings);
|
||||
if (isActive) {
|
||||
presetButton.classList.add('active');
|
||||
@@ -344,7 +344,7 @@ class LightingController {
|
||||
})
|
||||
});
|
||||
|
||||
// Reload state and tab content
|
||||
// Reload state and zone content
|
||||
await this.loadState();
|
||||
await this.loadTabContent(tabName);
|
||||
} else {
|
||||
@@ -591,7 +591,7 @@ class LightingController {
|
||||
}
|
||||
// Reload state from server to ensure consistency
|
||||
await this.loadState();
|
||||
// Reload tab content to update UI
|
||||
// Reload zone content to update UI
|
||||
await this.loadTabContent(tabName);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
@@ -769,23 +769,23 @@ class LightingController {
|
||||
}
|
||||
|
||||
showAddTabModal() {
|
||||
document.getElementById('new-tab-name').value = '';
|
||||
document.getElementById('new-tab-ids').value = '1';
|
||||
document.getElementById('add-tab-modal').classList.add('active');
|
||||
document.getElementById('new-zone-name').value = '';
|
||||
document.getElementById('new-zone-ids').value = '1';
|
||||
document.getElementById('add-zone-modal').classList.add('active');
|
||||
}
|
||||
|
||||
async createTab() {
|
||||
const name = document.getElementById('new-tab-name').value.trim();
|
||||
const idsStr = document.getElementById('new-tab-ids').value.trim();
|
||||
const name = document.getElementById('new-zone-name').value.trim();
|
||||
const idsStr = document.getElementById('new-zone-ids').value.trim();
|
||||
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
|
||||
|
||||
if (!name) {
|
||||
alert('Tab name cannot be empty');
|
||||
alert('Zone name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/tabs', {
|
||||
const response = await fetch('/zones', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, ids })
|
||||
@@ -795,41 +795,41 @@ class LightingController {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
this.selectTab(name);
|
||||
this.hideModal('add-tab-modal');
|
||||
this.hideModal('add-zone-modal');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create tab');
|
||||
alert(error.error || 'Failed to create zone');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create tab:', error);
|
||||
alert('Failed to create tab');
|
||||
console.error('Failed to create zone:', error);
|
||||
alert('Failed to create zone');
|
||||
}
|
||||
}
|
||||
|
||||
showEditTabModal() {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
const light = this.state.lights[this.currentTab];
|
||||
document.getElementById('edit-tab-name').value = this.currentTab;
|
||||
document.getElementById('edit-tab-ids').value = light.names.join(', ');
|
||||
document.getElementById('edit-tab-modal').classList.add('active');
|
||||
document.getElementById('edit-zone-name').value = this.currentTab;
|
||||
document.getElementById('edit-zone-ids').value = light.names.join(', ');
|
||||
document.getElementById('edit-zone-modal').classList.add('active');
|
||||
}
|
||||
|
||||
async updateTab() {
|
||||
const newName = document.getElementById('edit-tab-name').value.trim();
|
||||
const idsStr = document.getElementById('edit-tab-ids').value.trim();
|
||||
const newName = document.getElementById('edit-zone-name').value.trim();
|
||||
const idsStr = document.getElementById('edit-zone-ids').value.trim();
|
||||
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
|
||||
|
||||
if (!newName) {
|
||||
alert('Tab name cannot be empty');
|
||||
alert('Zone name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/tabs/${this.currentTab}`, {
|
||||
const response = await fetch(`/zones/${this.currentTab}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName, ids })
|
||||
@@ -839,45 +839,45 @@ class LightingController {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
this.selectTab(newName);
|
||||
this.hideModal('edit-tab-modal');
|
||||
this.hideModal('edit-zone-modal');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to update tab');
|
||||
alert(error.error || 'Failed to update zone');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update tab:', error);
|
||||
alert('Failed to update tab');
|
||||
console.error('Failed to update zone:', error);
|
||||
alert('Failed to update zone');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCurrentTab() {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the tab '${this.currentTab}'?`)) {
|
||||
if (!confirm(`Are you sure you want to delete the zone '${this.currentTab}'?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/tabs/${this.currentTab}`, {
|
||||
const response = await fetch(`/zones/${this.currentTab}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
if (this.state.zone_order.length > 0) {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
} else {
|
||||
this.currentTab = null;
|
||||
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
|
||||
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete tab:', error);
|
||||
alert('Failed to delete tab');
|
||||
console.error('Failed to delete zone:', error);
|
||||
alert('Failed to delete zone');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,9 +1008,9 @@ class LightingController {
|
||||
if (this.state.current_profile === profileName) {
|
||||
this.state.current_profile = '';
|
||||
this.state.lights = {};
|
||||
this.state.tab_order = [];
|
||||
this.state.zone_order = [];
|
||||
this.renderTabs();
|
||||
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
|
||||
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
|
||||
this.updateCurrentProfileDisplay();
|
||||
}
|
||||
} else {
|
||||
@@ -1032,8 +1032,8 @@ class LightingController {
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
if (this.state.zone_order.length > 0) {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
} else {
|
||||
this.currentTab = null;
|
||||
}
|
||||
@@ -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.title = `Click to apply ${color} to selected color`;
|
||||
|
||||
// Click to apply color to currently selected color in active tab
|
||||
// Click to apply color to currently selected color in active zone
|
||||
swatch.addEventListener('click', (e) => {
|
||||
// Only apply if not clicking the remove button
|
||||
if (e.target === swatch || !e.target.closest('button')) {
|
||||
@@ -1151,7 +1151,7 @@ class LightingController {
|
||||
|
||||
applyPaletteColorToSelected(paletteColor) {
|
||||
if (!this.currentTab) {
|
||||
alert('No tab selected. Please select a tab first.');
|
||||
alert('No zone selected. Please select a zone first.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1439,7 +1439,7 @@ class LightingController {
|
||||
|
||||
async applyPreset(presetName) {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1621,7 +1621,7 @@ class LightingController {
|
||||
|
||||
loadCurrentTabToPresetEditor() {
|
||||
if (!this.currentTab || !this.state.lights[this.currentTab]) {
|
||||
alert('Please select a tab first');
|
||||
alert('Please select a zone first');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/** Last TCP snapshot from WebSocket (so we can apply after async list render). */
|
||||
let lastTcpSnapshotIps = null;
|
||||
|
||||
/** Match server-side ``normalize_tcp_peer_ip`` for WS events vs registry rows. */
|
||||
function normalizeWifiAddressForMatch(addr) {
|
||||
let s = String(addr || '').trim();
|
||||
if (s.toLowerCase().startsWith('::ffff:')) {
|
||||
s = s.slice(7);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const DEVICES_MODAL_POLL_MS = 1000;
|
||||
|
||||
let devicesModalLiveTimer = null;
|
||||
|
||||
function stopDevicesModalLiveRefresh() {
|
||||
if (devicesModalLiveTimer != null) {
|
||||
clearInterval(devicesModalLiveTimer);
|
||||
devicesModalLiveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetch registry and re-render the list (no loading spinner). Keeps scroll position.
|
||||
* Used while the devices modal stays open so new TCP devices, renames, and removals appear live.
|
||||
*/
|
||||
async function refreshDevicesListQuiet() {
|
||||
const modal = document.getElementById('devices-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
const prevTop = container.scrollTop;
|
||||
try {
|
||||
const res = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
renderDevicesList(data || {});
|
||||
container.scrollTop = prevTop;
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function startDevicesModalLiveRefresh() {
|
||||
stopDevicesModalLiveRefresh();
|
||||
devicesModalLiveTimer = setInterval(() => {
|
||||
refreshDevicesListQuiet();
|
||||
}, DEVICES_MODAL_POLL_MS);
|
||||
}
|
||||
|
||||
function updateWifiRowDot(row, connected) {
|
||||
const dot = row.querySelector('.device-status-dot');
|
||||
if (!dot) return;
|
||||
if ((row.dataset.deviceTransport || '') !== 'wifi') return;
|
||||
dot.classList.remove('device-status-dot--online', 'device-status-dot--offline', 'device-status-dot--unknown');
|
||||
if (connected) {
|
||||
dot.classList.add('device-status-dot--online');
|
||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
||||
} else {
|
||||
dot.classList.add('device-status-dot--offline');
|
||||
dot.title = 'Not connected (no Wi-Fi TCP session)';
|
||||
}
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
}
|
||||
|
||||
function applyTcpSnapshot(ips) {
|
||||
const set = new Set(
|
||||
(ips || []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
|
||||
);
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
|
||||
const addr = normalizeWifiAddressForMatch(row.dataset.deviceAddress);
|
||||
updateWifiRowDot(row, set.has(addr));
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep cached snapshot aligned with incremental WS events (connect/disconnect). */
|
||||
function mergeTcpSnapshotPresence(ip, connected) {
|
||||
const n = normalizeWifiAddressForMatch(ip);
|
||||
if (!n) return;
|
||||
const prev = lastTcpSnapshotIps;
|
||||
const set = new Set(
|
||||
(Array.isArray(prev) ? prev : []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
|
||||
);
|
||||
if (connected) {
|
||||
set.add(n);
|
||||
} else {
|
||||
set.delete(n);
|
||||
}
|
||||
lastTcpSnapshotIps = Array.from(set);
|
||||
}
|
||||
|
||||
function makeHexAddressBoxes(container) {
|
||||
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||
container.innerHTML = '';
|
||||
@@ -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) {
|
||||
if (!container) return;
|
||||
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() {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
if (typeof window.getEspnowSocket === 'function') {
|
||||
window.getEspnowSocket();
|
||||
}
|
||||
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||
try {
|
||||
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
@@ -80,42 +192,95 @@ function renderDevicesList(devices) {
|
||||
if (ids.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
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);
|
||||
return;
|
||||
}
|
||||
ids.forEach((devId) => {
|
||||
const dev = devices[devId];
|
||||
const t = (dev && dev.type) || 'led';
|
||||
const tr = (dev && dev.transport) || 'espnow';
|
||||
const addrRaw = (dev && dev.address) != null ? String(dev.address).trim() : '';
|
||||
const addrDisplay = addrRaw || '—';
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.5rem';
|
||||
row.style.flexWrap = 'wrap';
|
||||
row.dataset.deviceId = devId;
|
||||
row.dataset.deviceTransport = tr;
|
||||
row.dataset.deviceAddress = addrRaw;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'device-status-dot';
|
||||
dot.setAttribute('role', 'img');
|
||||
const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null;
|
||||
if (live === true) {
|
||||
dot.classList.add('device-status-dot--online');
|
||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
} else if (live === false) {
|
||||
dot.classList.add('device-status-dot--offline');
|
||||
dot.title = 'Not connected (no Wi-Fi TCP session)';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
} else {
|
||||
dot.classList.add('device-status-dot--unknown');
|
||||
dot.title = 'ESP-NOW — TCP status does not apply';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (dev && dev.name) || devId;
|
||||
label.style.flex = '1';
|
||||
label.style.minWidth = '100px';
|
||||
|
||||
const macEl = document.createElement('code');
|
||||
macEl.className = 'device-row-mac';
|
||||
macEl.textContent = devId;
|
||||
macEl.title = 'MAC (registry id)';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'muted-text';
|
||||
meta.style.fontSize = '0.85em';
|
||||
const addr = (dev && dev.address) ? dev.address : '—';
|
||||
meta.textContent = `Address: ${addr}`;
|
||||
meta.textContent = `${t} · ${tr} · ${addrDisplay}`;
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||
|
||||
const identifyBtn = document.createElement('button');
|
||||
identifyBtn.className = 'btn btn-primary btn-small';
|
||||
identifyBtn.type = 'button';
|
||||
identifyBtn.textContent = 'Identify';
|
||||
identifyBtn.title = 'Red blink at 10 Hz (~50% brightness) for 2 s, then off (not saved as a preset)';
|
||||
identifyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}/identify`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'Identify failed');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Identify failed');
|
||||
}
|
||||
});
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' });
|
||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'DELETE' });
|
||||
if (res.ok) await loadDevicesModal();
|
||||
else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -127,53 +292,53 @@ function renderDevicesList(devices) {
|
||||
}
|
||||
});
|
||||
|
||||
row.appendChild(dot);
|
||||
row.appendChild(label);
|
||||
row.appendChild(macEl);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(identifyBtn);
|
||||
row.appendChild(deleteBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
// Do not re-apply lastTcpSnapshotIps here: it is only updated on WS open and
|
||||
// device_tcp events; re-applying after each /devices poll overwrites correct
|
||||
// API "connected" with a stale list and leaves Wi-Fi rows stuck online.
|
||||
}
|
||||
|
||||
function openEditDeviceModal(devId, dev) {
|
||||
const modal = document.getElementById('edit-device-modal');
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const storageLabel = document.getElementById('edit-device-storage-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const typeSel = document.getElementById('edit-device-type');
|
||||
const transportSel = document.getElementById('edit-device-transport');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
const wifiInput = document.getElementById('edit-device-address-wifi');
|
||||
if (!modal || !idInput) return;
|
||||
idInput.value = devId;
|
||||
if (storageLabel) storageLabel.textContent = devId;
|
||||
if (nameInput) nameInput.value = (dev && dev.name) || '';
|
||||
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');
|
||||
}
|
||||
|
||||
async function createDevice(name, address) {
|
||||
async function updateDevice(devId, name, type, transport, address) {
|
||||
try {
|
||||
const res = await fetch('/devices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, address: address || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
await loadDevicesModal();
|
||||
return true;
|
||||
}
|
||||
alert(data.error || 'Failed to create device');
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('createDevice:', e);
|
||||
alert('Failed to create device');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDevice(devId, name, address) {
|
||||
try {
|
||||
const res = await fetch(`/devices/${devId}`, {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
|
||||
method: 'PUT',
|
||||
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(() => ({}));
|
||||
if (res.ok) {
|
||||
@@ -190,14 +355,41 @@ async function updateDevice(devId, name, address) {
|
||||
}
|
||||
|
||||
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'));
|
||||
|
||||
const transportEdit = document.getElementById('edit-device-transport');
|
||||
if (transportEdit) {
|
||||
transportEdit.addEventListener('change', () => {
|
||||
applyTransportVisibility(transportEdit.value);
|
||||
});
|
||||
}
|
||||
|
||||
const devicesBtn = document.getElementById('devices-btn');
|
||||
const devicesModal = document.getElementById('devices-modal');
|
||||
const devicesCloseBtn = document.getElementById('devices-close-btn');
|
||||
const newName = document.getElementById('new-device-name');
|
||||
const createBtn = document.getElementById('create-device-btn');
|
||||
const editForm = document.getElementById('edit-device-form');
|
||||
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
||||
const editDeviceModal = document.getElementById('edit-device-modal');
|
||||
@@ -205,41 +397,44 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (devicesBtn && devicesModal) {
|
||||
devicesBtn.addEventListener('click', () => {
|
||||
devicesModal.classList.add('active');
|
||||
if (typeof window.getEspnowSocket === 'function') {
|
||||
window.getEspnowSocket();
|
||||
}
|
||||
loadDevicesModal();
|
||||
startDevicesModalLiveRefresh();
|
||||
});
|
||||
}
|
||||
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) {
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
const typeSel = document.getElementById('edit-device-type');
|
||||
const transportSel = document.getElementById('edit-device-transport');
|
||||
const devId = idInput && idInput.value;
|
||||
if (!devId) return;
|
||||
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
|
||||
const transport = (transportSel && transportSel.value) || 'espnow';
|
||||
const address = getAddressForPayload(transport);
|
||||
const ok = await updateDevice(
|
||||
devId,
|
||||
nameInput ? nameInput.value.trim() : '',
|
||||
(typeSel && typeSel.value) || 'led',
|
||||
transport,
|
||||
address
|
||||
);
|
||||
if (ok) editDeviceModal.classList.remove('active');
|
||||
|
||||
@@ -19,34 +19,34 @@ const numTabs = 3;
|
||||
|
||||
// Select the container for tabs and content
|
||||
const tabsContainer = document.querySelector(".tabs");
|
||||
const tabContentContainer = document.querySelector(".tab-content");
|
||||
const tabContentContainer = document.querySelector(".zone-content");
|
||||
|
||||
// Create tabs dynamically
|
||||
for (let i = 1; i <= numTabs; i++) {
|
||||
// Create the tab button
|
||||
// Create the zone button
|
||||
const tabButton = document.createElement("button");
|
||||
tabButton.classList.add("tab");
|
||||
tabButton.id = `tab${i}`;
|
||||
tabButton.textContent = `Tab ${i}`;
|
||||
tabButton.classList.add("zone");
|
||||
tabButton.id = `zone${i}`;
|
||||
tabButton.textContent = `Zone ${i}`;
|
||||
|
||||
// Add the tab button to the container
|
||||
// Add the zone button to the container
|
||||
tabsContainer.appendChild(tabButton);
|
||||
|
||||
// Create the corresponding tab content (RGB slider)
|
||||
// Create the corresponding zone content (RGB slider)
|
||||
const tabContent = document.createElement("div");
|
||||
tabContent.classList.add("tab-pane");
|
||||
tabContent.classList.add("zone-pane");
|
||||
tabContent.id = `content${i}`;
|
||||
const slider = document.createElement("rgb-slider");
|
||||
slider.id = i;
|
||||
tabContent.appendChild(slider);
|
||||
|
||||
// Add the tab content to the container
|
||||
// Add the zone content to the container
|
||||
tabContentContainer.appendChild(tabContent);
|
||||
|
||||
// Listen for color change on each RGB slider
|
||||
slider.addEventListener("color-change", (e) => {
|
||||
const { r, g, b } = e.detail;
|
||||
console.log(`Color changed in tab ${i}:`, e.detail);
|
||||
console.log(`Color changed in zone ${i}:`, e.detail);
|
||||
// Send RGB data to WebSocket server
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const colorData = { r, g, b };
|
||||
@@ -56,26 +56,26 @@ for (let i = 1; i <= numTabs; i++) {
|
||||
}
|
||||
|
||||
// Function to switch tabs
|
||||
function switchTab(tabId) {
|
||||
const tabs = document.querySelectorAll(".tab");
|
||||
const tabContents = document.querySelectorAll(".tab-pane");
|
||||
function switchTab(zoneId) {
|
||||
const tabs = document.querySelectorAll(".zone");
|
||||
const tabContents = document.querySelectorAll(".zone-pane");
|
||||
|
||||
tabs.forEach((tab) => tab.classList.remove("active"));
|
||||
zones.forEach((zone) => zone.classList.remove("active"));
|
||||
tabContents.forEach((content) => content.classList.remove("active"));
|
||||
|
||||
// Activate the clicked tab and corresponding content
|
||||
document.getElementById(tabId).classList.add("active");
|
||||
// Activate the clicked zone and corresponding content
|
||||
document.getElementById(zoneId).classList.add("active");
|
||||
document
|
||||
.getElementById("content" + tabId.replace("tab", ""))
|
||||
.getElementById("content" + zoneId.replace("zone", ""))
|
||||
.classList.add("active");
|
||||
}
|
||||
|
||||
// Add event listeners to tabs
|
||||
tabsContainer.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("tab")) {
|
||||
if (e.target.classList.contains("zone")) {
|
||||
switchTab(e.target.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Initially set the first tab as active
|
||||
// Initially set the first zone as active
|
||||
switchTab("tab1");
|
||||
|
||||
@@ -3,11 +3,301 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const patternsModal = document.getElementById('patterns-modal');
|
||||
const patternsCloseButton = document.getElementById('patterns-close-btn');
|
||||
const patternsList = document.getElementById('patterns-list');
|
||||
const patternAddButton = document.getElementById('pattern-add-btn');
|
||||
const patternEditorModal = document.getElementById('pattern-editor-modal');
|
||||
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
|
||||
const patternCreateBtn = document.getElementById('pattern-create-btn');
|
||||
const patternCreateName = document.getElementById('pattern-create-name');
|
||||
const patternCreateMinDelay = document.getElementById('pattern-create-min-delay');
|
||||
const patternCreateMaxDelay = document.getElementById('pattern-create-max-delay');
|
||||
const patternCreateMaxColors = document.getElementById('pattern-create-max-colors');
|
||||
const patternCreateFile = document.getElementById('pattern-create-file');
|
||||
const patternCreateCode = document.getElementById('pattern-create-code');
|
||||
const patternCreateOverwrite = document.getElementById('pattern-create-overwrite');
|
||||
const patternCreateN = [1, 2, 3, 4, 5, 6, 7, 8].map((i) =>
|
||||
document.getElementById(`pattern-create-n${i}`),
|
||||
);
|
||||
const patternCreateNSection = document.getElementById('pattern-create-n-section');
|
||||
const patternCreateNEmpty = document.getElementById('pattern-create-n-empty');
|
||||
|
||||
if (!patternsButton || !patternsModal || !patternsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nReadableStringFromMeta = (meta, key) => {
|
||||
if (!meta || typeof meta !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const pm = meta.parameter_mappings;
|
||||
if (pm && typeof pm === 'object' && typeof pm[key] === 'string') {
|
||||
const s = pm[key].trim();
|
||||
if (s) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
if (typeof meta[key] === 'string') {
|
||||
return meta[key].trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const setPatternEditorNFields = (mode, data) => {
|
||||
const meta = data && typeof data === 'object' ? data : {};
|
||||
let visible = 0;
|
||||
const grid = patternCreateNSection && patternCreateNSection.querySelector('.n-params-grid');
|
||||
const h3 = patternCreateNSection && patternCreateNSection.querySelector('h3');
|
||||
|
||||
for (let i = 1; i <= 8; i += 1) {
|
||||
const key = `n${i}`;
|
||||
const labelEl = document.querySelector(`label[for="pattern-create-${key}"]`);
|
||||
const inputEl = document.getElementById(`pattern-create-${key}`);
|
||||
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
|
||||
|
||||
if (mode === 'create') {
|
||||
if (labelEl) {
|
||||
labelEl.textContent = '';
|
||||
labelEl.style.display = 'none';
|
||||
}
|
||||
if (inputEl) {
|
||||
inputEl.value = '';
|
||||
inputEl.placeholder = 'Readable name (optional)';
|
||||
inputEl.removeAttribute('aria-label');
|
||||
}
|
||||
if (groupEl) {
|
||||
groupEl.style.display = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const readable = nReadableStringFromMeta(meta, key);
|
||||
const show = Boolean(readable);
|
||||
if (labelEl) {
|
||||
labelEl.textContent = '';
|
||||
labelEl.style.display = 'none';
|
||||
}
|
||||
if (inputEl) {
|
||||
inputEl.value = show ? readable : '';
|
||||
inputEl.placeholder = '';
|
||||
if (show) {
|
||||
inputEl.setAttribute('aria-label', readable);
|
||||
} else {
|
||||
inputEl.removeAttribute('aria-label');
|
||||
inputEl.value = '';
|
||||
}
|
||||
}
|
||||
if (groupEl) {
|
||||
groupEl.style.display = show ? '' : 'none';
|
||||
}
|
||||
if (show) {
|
||||
visible += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'create') {
|
||||
if (patternCreateNEmpty) {
|
||||
patternCreateNEmpty.style.display = 'none';
|
||||
}
|
||||
if (grid) {
|
||||
grid.style.display = '';
|
||||
}
|
||||
if (h3) {
|
||||
h3.style.display = '';
|
||||
}
|
||||
if (patternCreateNSection) {
|
||||
patternCreateNSection.style.display = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (patternCreateNEmpty) {
|
||||
patternCreateNEmpty.style.display = visible === 0 ? '' : 'none';
|
||||
}
|
||||
if (grid) {
|
||||
grid.style.display = visible === 0 ? 'none' : '';
|
||||
}
|
||||
if (h3) {
|
||||
h3.style.display = visible === 0 ? 'none' : '';
|
||||
}
|
||||
};
|
||||
|
||||
const readFileAsText = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(reader.error || new Error('read failed'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
const collectCreatePayload = async () => {
|
||||
const name = patternCreateName ? patternCreateName.value.trim() : '';
|
||||
if (!name) {
|
||||
throw new Error('Pattern name is required.');
|
||||
}
|
||||
let code = '';
|
||||
const fileInput = patternCreateFile && patternCreateFile.files && patternCreateFile.files[0];
|
||||
if (fileInput) {
|
||||
code = await readFileAsText(fileInput);
|
||||
} else if (patternCreateCode && patternCreateCode.value.trim()) {
|
||||
code = patternCreateCode.value;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
throw new Error('Choose a .py file or paste source code.');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
code,
|
||||
min_delay: parseInt(patternCreateMinDelay && patternCreateMinDelay.value, 10) || 0,
|
||||
max_delay: parseInt(patternCreateMaxDelay && patternCreateMaxDelay.value, 10) || 0,
|
||||
max_colors: parseInt(patternCreateMaxColors && patternCreateMaxColors.value, 10) || 0,
|
||||
overwrite: !!(patternCreateOverwrite && patternCreateOverwrite.checked),
|
||||
};
|
||||
|
||||
patternCreateN.forEach((el, idx) => {
|
||||
const key = `n${idx + 1}`;
|
||||
if (el && el.value.trim()) {
|
||||
payload[key] = el.value.trim();
|
||||
}
|
||||
});
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
if (patternCreateName) patternCreateName.value = '';
|
||||
if (patternCreateFile) patternCreateFile.value = '';
|
||||
if (patternCreateCode) patternCreateCode.value = '';
|
||||
if (patternCreateMinDelay) patternCreateMinDelay.value = '10';
|
||||
if (patternCreateMaxDelay) patternCreateMaxDelay.value = '10000';
|
||||
if (patternCreateMaxColors) patternCreateMaxColors.value = '10';
|
||||
patternCreateN.forEach((el) => {
|
||||
if (el) el.value = '';
|
||||
});
|
||||
if (patternCreateOverwrite) patternCreateOverwrite.checked = true;
|
||||
setPatternEditorNFields('create', {});
|
||||
};
|
||||
|
||||
if (patternCreateBtn) {
|
||||
patternCreateBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const payload = await collectCreatePayload();
|
||||
const response = await fetch('/patterns/driver', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error((data && data.error) || 'Create failed');
|
||||
}
|
||||
alert(data.message || 'Pattern created.');
|
||||
resetCreateForm();
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.remove('active');
|
||||
}
|
||||
await loadPatterns();
|
||||
} catch (e) {
|
||||
console.error('Create pattern failed:', e);
|
||||
alert(e.message || 'Failed to create pattern.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sendPatternToDevices = async (patternName) => {
|
||||
const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error((data && data.error) || 'Failed to send pattern');
|
||||
}
|
||||
const sentCount = data && typeof data.sent_count === 'number' ? data.sent_count : null;
|
||||
if (sentCount === null) {
|
||||
alert(`Sent "${patternName}" to devices.`);
|
||||
} else {
|
||||
alert(`Sent "${patternName}" to ${sentCount} device(s).`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPatternMetadata = async (patternName, fallbackData) => {
|
||||
const raw = String(patternName || '').trim();
|
||||
const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw;
|
||||
try {
|
||||
const response = await fetch('/patterns/definitions', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load pattern definitions');
|
||||
}
|
||||
const definitions = await response.json();
|
||||
if (definitions && typeof definitions === 'object') {
|
||||
if (definitions[raw]) {
|
||||
return definitions[raw];
|
||||
}
|
||||
if (norm && definitions[norm]) {
|
||||
return definitions[norm];
|
||||
}
|
||||
if (norm) {
|
||||
const lower = norm.toLowerCase();
|
||||
const matched = Object.keys(definitions).find(
|
||||
(k) => String(k).toLowerCase() === lower,
|
||||
);
|
||||
if (matched) {
|
||||
return definitions[matched];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load pattern definitions failed:', error);
|
||||
}
|
||||
return fallbackData || {};
|
||||
};
|
||||
|
||||
const loadPatternIntoEditor = async (patternName, fallbackData) => {
|
||||
const data = await loadPatternMetadata(patternName, fallbackData);
|
||||
if (patternCreateName) {
|
||||
patternCreateName.value = patternName;
|
||||
}
|
||||
if (patternCreateMinDelay) {
|
||||
patternCreateMinDelay.value =
|
||||
data && data.min_delay !== undefined ? String(data.min_delay) : '10';
|
||||
}
|
||||
if (patternCreateMaxDelay) {
|
||||
patternCreateMaxDelay.value =
|
||||
data && data.max_delay !== undefined ? String(data.max_delay) : '10000';
|
||||
}
|
||||
if (patternCreateMaxColors) {
|
||||
patternCreateMaxColors.value =
|
||||
data && data.max_colors !== undefined ? String(data.max_colors) : '10';
|
||||
}
|
||||
setPatternEditorNFields('edit', data);
|
||||
if (patternCreateOverwrite) {
|
||||
patternCreateOverwrite.checked = true;
|
||||
}
|
||||
if (patternCreateFile) {
|
||||
patternCreateFile.value = '';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(patternName)}.py`, {
|
||||
headers: { Accept: 'text/plain' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load pattern file');
|
||||
}
|
||||
const source = await response.text();
|
||||
if (patternCreateCode) {
|
||||
patternCreateCode.value = source || '';
|
||||
patternCreateCode.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load pattern source failed:', error);
|
||||
alert('Could not load pattern source into editor.');
|
||||
}
|
||||
};
|
||||
|
||||
const renderPatterns = (patterns) => {
|
||||
patternsList.innerHTML = '';
|
||||
const entries = Object.entries(patterns || {});
|
||||
@@ -32,13 +322,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
details.style.color = '#aaa';
|
||||
details.style.fontSize = '0.85em';
|
||||
|
||||
const sendBtn = document.createElement('button');
|
||||
sendBtn.className = 'btn btn-primary btn-small';
|
||||
sendBtn.textContent = 'Send';
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await sendPatternToDevices(patternName);
|
||||
} catch (error) {
|
||||
console.error('Send pattern failed:', error);
|
||||
alert(error.message || 'Failed to send pattern.');
|
||||
}
|
||||
});
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', async () => {
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.add('active');
|
||||
}
|
||||
await loadPatternIntoEditor(patternName, data || {});
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(details);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(sendBtn);
|
||||
patternsList.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const loadPatterns = async () => {
|
||||
async function loadPatterns() {
|
||||
patternsList.innerHTML = '';
|
||||
const loading = document.createElement('p');
|
||||
loading.className = 'muted-text';
|
||||
@@ -62,7 +376,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
errorMessage.textContent = 'Failed to load patterns.';
|
||||
patternsList.appendChild(errorMessage);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const openModal = () => {
|
||||
patternsModal.classList.add('active');
|
||||
@@ -74,6 +388,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
patternsButton.addEventListener('click', openModal);
|
||||
if (patternAddButton) {
|
||||
patternAddButton.addEventListener('click', () => {
|
||||
resetCreateForm();
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (patternEditorCloseButton) {
|
||||
patternEditorCloseButton.addEventListener('click', () => {
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (patternsCloseButton) {
|
||||
patternsCloseButton.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
@@ -74,12 +74,14 @@ const getEspnowSocket = () => {
|
||||
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);
|
||||
espnowSocketReady = false;
|
||||
|
||||
espnowSocket.onopen = () => {
|
||||
espnowSocketReady = true;
|
||||
window.dispatchEvent(new CustomEvent('deviceTcpWsOpen'));
|
||||
// Flush any queued messages
|
||||
espnowPendingMessages.forEach((msg) => {
|
||||
try {
|
||||
@@ -94,6 +96,18 @@ const getEspnowSocket = () => {
|
||||
espnowSocket.onmessage = (event) => {
|
||||
try {
|
||||
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) {
|
||||
console.error('ESP-NOW:', 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.
|
||||
// Uses the preset ID as the select key.
|
||||
const sendSelectForCurrentTabDevices = (presetId, sectionEl) => {
|
||||
const section = sectionEl || document.querySelector('.presets-section[data-tab-id]');
|
||||
function tabDeviceNamesFromSection(section) {
|
||||
if (typeof window.parseTabDeviceNames === 'function') {
|
||||
return window.parseTabDeviceNames(section);
|
||||
}
|
||||
const namesAttr = section && section.getAttribute('data-device-names');
|
||||
return namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
const namesAttr = section.getAttribute('data-device-names');
|
||||
const deviceNames = namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
|
||||
if (!deviceNames.length) {
|
||||
return;
|
||||
@@ -148,15 +189,23 @@ const sendSelectForCurrentTabDevices = (presetId, sectionEl) => {
|
||||
|
||||
const select = {};
|
||||
deviceNames.forEach((name) => {
|
||||
select[name] = [presetId];
|
||||
if (name) {
|
||||
select[name] = [presetId];
|
||||
}
|
||||
});
|
||||
|
||||
const message = {
|
||||
v: '1',
|
||||
select,
|
||||
};
|
||||
const targetMacs =
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
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', () => {
|
||||
@@ -174,7 +223,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetBrightnessInput = document.getElementById('preset-brightness-input');
|
||||
const presetDelayInput = document.getElementById('preset-delay-input');
|
||||
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 presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||||
|
||||
@@ -498,32 +547,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetPatternInput.style.backgroundColor = '';
|
||||
presetPatternInput.style.cursor = '';
|
||||
}
|
||||
|
||||
// Update labels and visibility based on pattern
|
||||
updatePresetNLabels(patternName);
|
||||
|
||||
|
||||
// Get pattern config to map descriptive names back to n keys
|
||||
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
||||
const nToLabel = {};
|
||||
if (patternConfig && typeof patternConfig === 'object') {
|
||||
// Now n keys are keys, labels are values
|
||||
Object.entries(patternConfig).forEach(([nKey, label]) => {
|
||||
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
|
||||
nToLabel[nKey] = label;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Set n values, checking both n keys and descriptive names
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const nKey = `n${i}`;
|
||||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||||
if (inputEl) {
|
||||
// First check if preset has n key directly
|
||||
if (preset[nKey] !== undefined) {
|
||||
inputEl.value = preset[nKey] || 0;
|
||||
} else {
|
||||
// Check if preset has descriptive name (from pattern.json mapping)
|
||||
const label = nToLabel[nKey];
|
||||
if (label && preset[label] !== undefined) {
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -574,8 +620,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (currentEditTabId) {
|
||||
return currentEditTabId;
|
||||
}
|
||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||
return section ? section.dataset.tabId : null;
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
return section ? section.dataset.zoneId : null;
|
||||
};
|
||||
|
||||
const updatePresetEditorTabActionsVisibility = () => {
|
||||
@@ -585,12 +631,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
const updateTabDefaultPreset = async (presetId) => {
|
||||
const tabId = getActiveTabId();
|
||||
if (!tabId) {
|
||||
const zoneId = getActiveTabId();
|
||||
if (!zoneId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
@@ -598,13 +644,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
tabData.default_preset = presetId;
|
||||
await fetch(`/tabs/${tabId}`, {
|
||||
await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
} 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 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 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') {
|
||||
// Now n values are keys and descriptive names are values
|
||||
Object.entries(patternConfig).forEach(([key, label]) => {
|
||||
if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') {
|
||||
labels[key] = `${label}:`;
|
||||
visibleNKeys.add(key); // Mark this n key as visible
|
||||
const text = label.trim();
|
||||
if (text) {
|
||||
labels[key] = `${text}:`;
|
||||
visibleNKeys.add(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update labels and show/hide input groups
|
||||
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const nKey = `n${i}`;
|
||||
const labelEl = document.getElementById(`preset-${nKey}-label`);
|
||||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||||
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
|
||||
|
||||
const show = visibleNKeys.has(nKey);
|
||||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||||
|
||||
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 (visibleNKeys.has(nKey)) {
|
||||
groupEl.style.display = ''; // Show
|
||||
} else {
|
||||
groupEl.style.display = 'none'; // Hide
|
||||
}
|
||||
groupEl.style.display = show ? '' : 'none';
|
||||
}
|
||||
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 () => {
|
||||
currentEditId = presetId;
|
||||
currentEditTabId = null;
|
||||
await loadPatterns();
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
const presetForEditor = {
|
||||
...(preset || {}),
|
||||
@@ -812,10 +880,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const sendButton = document.createElement('button');
|
||||
sendButton.className = 'btn btn-primary btn-small';
|
||||
sendButton.textContent = 'Send';
|
||||
sendButton.title = 'Send this preset via ESPNow';
|
||||
sendButton.title = 'Send this preset to drivers';
|
||||
sendButton.addEventListener('click', () => {
|
||||
// Just send the definition; selection happens when user clicks the preset.
|
||||
sendPresetViaEspNow(presetId, preset || {});
|
||||
void sendPresetViaEspNow(presetId, preset || {}, []);
|
||||
});
|
||||
|
||||
const deleteButton = document.createElement('button');
|
||||
@@ -901,22 +969,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
const showAddPresetToTabModal = async (optionalTabId) => {
|
||||
let tabId = optionalTabId;
|
||||
if (!tabId) {
|
||||
// Get current tab ID from the presets section
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
tabId = leftPanel ? leftPanel.dataset.tabId : null;
|
||||
let zoneId = optionalTabId;
|
||||
if (!zoneId) {
|
||||
// Get current zone ID from the presets section
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
|
||||
}
|
||||
if (!tabId) {
|
||||
if (!zoneId) {
|
||||
// Fallback: try to get from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const tabIndex = pathParts.indexOf('tabs');
|
||||
const tabIndex = pathParts.indexOf('zones');
|
||||
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
|
||||
tabId = pathParts[tabIndex + 1];
|
||||
zoneId = pathParts[tabIndex + 1];
|
||||
}
|
||||
}
|
||||
if (!tabId) {
|
||||
alert('Could not determine current tab.');
|
||||
if (!zoneId) {
|
||||
alert('Could not determine current zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -931,10 +999,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const allPresetsRaw = await response.json();
|
||||
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||
|
||||
// Load only the current tab's presets so we can avoid duplicates within this tab.
|
||||
// Load only the current zone's presets so we can avoid duplicates within this zone.
|
||||
let currentTabPresets = [];
|
||||
try {
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (tabResponse.ok) {
|
||||
@@ -950,19 +1018,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not load current tab presets:', e);
|
||||
console.warn('Could not load current zone presets:', e);
|
||||
}
|
||||
|
||||
// Create modal
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
modal.id = 'add-preset-to-tab-modal';
|
||||
modal.id = 'add-preset-to-zone-modal';
|
||||
modal.innerHTML = `
|
||||
<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 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>
|
||||
`;
|
||||
@@ -974,7 +1042,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
|
||||
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 {
|
||||
availableToAdd.forEach(presetId => {
|
||||
const preset = allPresets[presetId];
|
||||
@@ -993,7 +1061,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
addButton.className = 'btn btn-primary btn-small';
|
||||
addButton.textContent = 'Add';
|
||||
addButton.addEventListener('click', async () => {
|
||||
await addPresetToTab(presetId, tabId);
|
||||
await addPresetToTab(presetId, zoneId);
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
@@ -1005,7 +1073,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -1018,34 +1086,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
window.showAddPresetToTabModal = showAddPresetToTabModal;
|
||||
} catch (e) {}
|
||||
|
||||
const addPresetToTab = async (presetId, tabId) => {
|
||||
if (!tabId) {
|
||||
// Try to get tab ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
tabId = leftPanel ? leftPanel.dataset.tabId : null;
|
||||
const addPresetToTab = async (presetId, zoneId) => {
|
||||
if (!zoneId) {
|
||||
// Try to get zone ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
|
||||
|
||||
if (!tabId) {
|
||||
if (!zoneId) {
|
||||
// Fallback: try to get from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const tabIndex = pathParts.indexOf('tabs');
|
||||
const tabIndex = pathParts.indexOf('zones');
|
||||
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
|
||||
tabId = pathParts[tabIndex + 1];
|
||||
zoneId = pathParts[tabIndex + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!tabId) {
|
||||
alert('Could not determine current tab.');
|
||||
if (!zoneId) {
|
||||
alert('Could not determine current zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current tab data
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Get current zone data
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
throw new Error('Failed to load tab');
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
@@ -1062,7 +1130,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
if (flat.includes(presetId)) {
|
||||
alert('Preset is already added to this tab.');
|
||||
alert('Preset is already added to this zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1071,23 +1139,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
tabData.presets = newGrid;
|
||||
tabData.presets_flat = flat;
|
||||
|
||||
// Update tab
|
||||
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Update zone
|
||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
|
||||
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') {
|
||||
await renderTabPresets(tabId);
|
||||
await renderTabPresets(zoneId);
|
||||
} else if (window.htmx) {
|
||||
htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, {
|
||||
target: '#tab-content',
|
||||
htmx.ajax('GET', `/zones/${zoneId}/content-fragment`, {
|
||||
target: '#zone-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
} else {
|
||||
@@ -1095,8 +1163,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add preset to tab:', error);
|
||||
alert('Failed to add preset to tab.');
|
||||
console.error('Failed to add preset to zone:', error);
|
||||
alert('Failed to add preset to zone.');
|
||||
}
|
||||
};
|
||||
try {
|
||||
@@ -1220,12 +1288,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
alert('Preset name is required to send.');
|
||||
return;
|
||||
}
|
||||
// Send current editor values and then select on all devices in the current tab (if any)
|
||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||
const namesAttr = section && section.getAttribute('data-device-names');
|
||||
const deviceNames = namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
// Send current editor values and then select on all devices in the current zone (if any)
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
||||
const presetId = currentEditId || payload.name;
|
||||
// Try sends preset first, then select; never persist on device.
|
||||
@@ -1240,21 +1305,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
alert('Preset name is required.');
|
||||
return;
|
||||
}
|
||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||
const namesAttr = section && section.getAttribute('data-device-names');
|
||||
const deviceNames = namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
const presetId = currentEditId || payload.name;
|
||||
await updateTabDefaultPreset(presetId);
|
||||
sendDefaultPreset(presetId, deviceNames);
|
||||
await sendDefaultPreset(presetId, deviceNames);
|
||||
});
|
||||
}
|
||||
|
||||
if (presetRemoveFromTabButton) {
|
||||
presetRemoveFromTabButton.addEventListener('click', async () => {
|
||||
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);
|
||||
clearForm();
|
||||
closeEditor();
|
||||
@@ -1285,32 +1347,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (currentEditId) {
|
||||
// PUT returns the preset object directly; use the existing ID
|
||||
// Save & Send should not force-select the preset on devices.
|
||||
sendPresetViaEspNow(currentEditId, saved, [], true, false);
|
||||
await sendPresetViaEspNow(currentEditId, saved, [], true, false);
|
||||
} else {
|
||||
// POST returns { id: preset }
|
||||
const entries = Object.entries(saved);
|
||||
if (entries.length > 0) {
|
||||
const [newId, presetData] = entries[0];
|
||||
// Save & Send should not force-select the preset on devices.
|
||||
sendPresetViaEspNow(newId, presetData, [], true, false);
|
||||
await sendPresetViaEspNow(newId, presetData, [], true, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: send what we just built
|
||||
// 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();
|
||||
clearForm();
|
||||
closeEditor();
|
||||
|
||||
// Reload tab presets if we're in a tab view
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
// Reload zone presets if we're in a zone view
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
if (leftPanel) {
|
||||
const tabId = leftPanel.dataset.tabId;
|
||||
if (tabId && typeof renderTabPresets !== 'undefined') {
|
||||
renderTabPresets(tabId);
|
||||
const zoneId = leftPanel.dataset.zoneId;
|
||||
if (zoneId && typeof renderTabPresets !== 'undefined') {
|
||||
renderTabPresets(zoneId);
|
||||
}
|
||||
}
|
||||
} 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) => {
|
||||
const { presetId, preset, tabId } = event.detail;
|
||||
const { presetId, preset, zoneId } = event.detail;
|
||||
currentEditId = presetId;
|
||||
currentEditTabId = tabId || null;
|
||||
currentEditTabId = zoneId || null;
|
||||
await loadPatterns();
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
setFormValues({
|
||||
@@ -1340,7 +1402,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
clearForm();
|
||||
});
|
||||
|
||||
// Build ESPNow messages for a single preset.
|
||||
// Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
|
||||
// Send order:
|
||||
// 1) preset payload (optionally 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;
|
||||
}
|
||||
|
||||
// 1) Send presets first, without save.
|
||||
sendEspnowMessage(presetMessage);
|
||||
const names = Array.isArray(deviceNames) ? deviceNames : [];
|
||||
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.
|
||||
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
|
||||
const sequence = [presetMessage];
|
||||
if (names.length > 0) {
|
||||
const select = {};
|
||||
deviceNames.forEach((name) => {
|
||||
names.forEach((name) => {
|
||||
if (name) {
|
||||
select[name] = [presetId];
|
||||
}
|
||||
});
|
||||
if (Object.keys(select).length > 0) {
|
||||
// Small gap helps slower receivers process preset update before select.
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
sendEspnowMessage({ v: '1', select });
|
||||
sequence.push({ v: '1', select });
|
||||
}
|
||||
}
|
||||
|
||||
await postDriverSequence(sequence, targetMacs, 0.05);
|
||||
} catch (error) {
|
||||
console.error('Failed to send preset via ESPNow:', error);
|
||||
alert('Failed to send preset via ESPNow.');
|
||||
console.error('Failed to send preset to devices:', error);
|
||||
alert('Failed to send preset to devices.');
|
||||
}
|
||||
};
|
||||
|
||||
const sendDefaultPreset = (presetId, deviceNames) => {
|
||||
const sendDefaultPreset = async (presetId, deviceNames) => {
|
||||
if (!presetId) {
|
||||
alert('Select a preset to set as default.');
|
||||
return;
|
||||
}
|
||||
// Default should only set startup preset, not trigger live selection.
|
||||
// Save is attached to default messages.
|
||||
// When device names are provided, scope the default update to those devices.
|
||||
const targets = Array.isArray(deviceNames)
|
||||
const nameTargets = Array.isArray(deviceNames)
|
||||
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
const message = { v: '1', default: presetId };
|
||||
message.save = true;
|
||||
if (targets.length > 0) {
|
||||
message.targets = targets;
|
||||
if (nameTargets.length > 0) {
|
||||
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 {
|
||||
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.
|
||||
window.sendEspnowRaw = sendEspnowMessage;
|
||||
window.getEspnowSocket = getEspnowSocket;
|
||||
} catch (e) {
|
||||
// window may not exist in some environments; ignore.
|
||||
}
|
||||
|
||||
// Store selected preset per tab
|
||||
// Store selected preset per zone
|
||||
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';
|
||||
|
||||
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
|
||||
@@ -1502,15 +1578,15 @@ const arrayToGrid = (presetIds, columns = 3) => {
|
||||
return grid;
|
||||
};
|
||||
|
||||
// Function to save preset grid for a tab
|
||||
const savePresetGrid = async (tabId, presetGrid) => {
|
||||
// Function to save preset grid for a zone
|
||||
const savePresetGrid = async (zoneId, presetGrid) => {
|
||||
try {
|
||||
// Get current tab data
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Get current zone data
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
throw new Error('Failed to load tab');
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
@@ -1519,8 +1595,8 @@ const savePresetGrid = async (tabId, presetGrid) => {
|
||||
// Also store as flat array for backward compatibility
|
||||
tabData.presets_flat = presetGrid.flat();
|
||||
|
||||
// Save updated tab
|
||||
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Save updated zone
|
||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
@@ -1574,18 +1650,18 @@ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Function to render presets for a specific tab in 2D grid
|
||||
const renderTabPresets = async (tabId) => {
|
||||
const presetsList = document.getElementById('presets-list-tab');
|
||||
// Function to render presets for a specific zone in 2D grid
|
||||
const renderTabPresets = async (zoneId) => {
|
||||
const presetsList = document.getElementById('presets-list-zone');
|
||||
if (!presetsList) return;
|
||||
|
||||
try {
|
||||
// Get tab data to see which presets are associated
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Get zone data to see which presets are associated
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
throw new Error('Failed to load tab');
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
@@ -1612,7 +1688,7 @@ const renderTabPresets = async (tabId) => {
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
|
||||
presetsList.innerHTML = '';
|
||||
presetsList.dataset.reorderTabId = tabId;
|
||||
presetsList.dataset.reorderTabId = zoneId;
|
||||
|
||||
// Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
|
||||
if (!presetsList.dataset.dragWired) {
|
||||
@@ -1662,7 +1738,7 @@ const renderTabPresets = async (tabId) => {
|
||||
|
||||
try {
|
||||
if (!saveId) {
|
||||
console.warn('No tab id for preset reorder save');
|
||||
console.warn('No zone id for preset reorder save');
|
||||
return;
|
||||
}
|
||||
await savePresetGrid(saveId, newGrid);
|
||||
@@ -1676,19 +1752,19 @@ const renderTabPresets = async (tabId) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Get the currently selected preset for this tab
|
||||
const selectedPresetId = selectedPresets[tabId];
|
||||
// Get the currently selected preset for this zone
|
||||
const selectedPresetId = selectedPresets[zoneId];
|
||||
|
||||
// Render presets in grid layout
|
||||
// Flatten the grid and render all presets (grid CSS will handle layout)
|
||||
const flatPresets = presetGrid.flat().filter(id => id);
|
||||
|
||||
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');
|
||||
empty.className = 'muted-text';
|
||||
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);
|
||||
} else {
|
||||
flatPresets.forEach((presetId) => {
|
||||
@@ -1699,18 +1775,18 @@ const renderTabPresets = async (tabId) => {
|
||||
...preset,
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
} 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>';
|
||||
}
|
||||
};
|
||||
|
||||
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
const uiMode = getPresetUiMode();
|
||||
|
||||
const row = document.createElement('div');
|
||||
@@ -1749,14 +1825,16 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
if (isDraggingPreset) return;
|
||||
const presetsListEl = document.getElementById('presets-list-tab');
|
||||
const presetsListEl = document.getElementById('presets-list-zone');
|
||||
if (presetsListEl) {
|
||||
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
|
||||
}
|
||||
button.classList.add('active');
|
||||
selectedPresets[tabId] = presetId;
|
||||
selectedPresets[zoneId] = presetId;
|
||||
const section = row.closest('.presets-section');
|
||||
sendSelectForCurrentTabDevices(presetId, section);
|
||||
sendSelectForCurrentTabDevices(presetId, section).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
if (canDrag) {
|
||||
@@ -1769,7 +1847,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
|
||||
row.addEventListener('dragend', () => {
|
||||
row.classList.remove('dragging');
|
||||
const presetsListEl = document.getElementById('presets-list-tab');
|
||||
const presetsListEl = document.getElementById('presets-list-zone');
|
||||
if (presetsListEl) {
|
||||
delete presetsListEl.dataset.dropTargetId;
|
||||
}
|
||||
@@ -1795,7 +1873,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDraggingPreset) return;
|
||||
editPresetFromTab(presetId, tabId, preset);
|
||||
editPresetFromTab(presetId, zoneId, preset);
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
@@ -1805,7 +1883,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
return row;
|
||||
};
|
||||
|
||||
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
||||
const editPresetFromTab = async (presetId, zoneId, existingPreset) => {
|
||||
try {
|
||||
let preset = existingPreset;
|
||||
if (!preset) {
|
||||
@@ -1821,7 +1899,7 @@ const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
||||
|
||||
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
|
||||
const editEvent = new CustomEvent('editPreset', {
|
||||
detail: { presetId, preset, tabId }
|
||||
detail: { presetId, preset, zoneId }
|
||||
});
|
||||
document.dispatchEvent(editEvent);
|
||||
} 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)
|
||||
// Expected call style: removePresetFromTab(tabId, presetId)
|
||||
const removePresetFromTab = async (tabId, presetId) => {
|
||||
if (!tabId) {
|
||||
// Try to get tab ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
tabId = leftPanel ? leftPanel.dataset.tabId : null;
|
||||
// Remove a preset from a specific zone (does not delete the preset itself)
|
||||
// Expected call style: removePresetFromTab(zoneId, presetId)
|
||||
const removePresetFromTab = async (zoneId, presetId) => {
|
||||
if (!zoneId) {
|
||||
// Try to get zone ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
|
||||
|
||||
if (!tabId) {
|
||||
if (!zoneId) {
|
||||
// Fallback: try to get from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const tabIndex = pathParts.indexOf('tabs');
|
||||
const tabIndex = pathParts.indexOf('zones');
|
||||
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
|
||||
tabId = pathParts[tabIndex + 1];
|
||||
zoneId = pathParts[tabIndex + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!tabId) {
|
||||
alert('Could not determine current tab.');
|
||||
if (!zoneId) {
|
||||
alert('Could not determine current zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current tab data
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
// Get current zone data
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
throw new Error('Failed to load tab');
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
@@ -1878,7 +1956,7 @@ const removePresetFromTab = async (tabId, presetId) => {
|
||||
const beforeLen = flat.length;
|
||||
flat = flat.filter(id => String(id) !== String(presetId));
|
||||
if (flat.length === beforeLen) {
|
||||
alert('Preset is not in this tab.');
|
||||
alert('Preset is not in this zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1886,19 +1964,19 @@ const removePresetFromTab = async (tabId, presetId) => {
|
||||
tabData.presets = newGrid;
|
||||
tabData.presets_flat = flat;
|
||||
|
||||
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
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) {
|
||||
console.error('Failed to remove preset from tab:', error);
|
||||
alert('Failed to remove preset from tab.');
|
||||
console.error('Failed to remove preset from zone:', error);
|
||||
alert('Failed to remove preset from zone.');
|
||||
}
|
||||
};
|
||||
try {
|
||||
@@ -1907,13 +1985,13 @@ try {
|
||||
|
||||
// Listen for HTMX swaps to render presets
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
if (event.target && event.target.id === 'tab-content') {
|
||||
// Get tab ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
if (event.target && event.target.id === 'zone-content') {
|
||||
// Get zone ID from the left-panel
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
if (leftPanel) {
|
||||
const tabId = leftPanel.dataset.tabId;
|
||||
if (tabId) {
|
||||
renderTabPresets(tabId);
|
||||
const zoneId = leftPanel.dataset.zoneId;
|
||||
if (zoneId) {
|
||||
renderTabPresets(zoneId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1926,11 +2004,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
|
||||
setPresetUiMode(next);
|
||||
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');
|
||||
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) {
|
||||
renderTabPresets(leftPanel.dataset.tabId);
|
||||
renderTabPresets(leftPanel.dataset.zoneId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,8 +35,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
};
|
||||
|
||||
const refreshTabsForActiveProfile = async () => {
|
||||
// Clear stale current tab so tab controller falls back to first tab of applied profile.
|
||||
document.cookie = "current_tab=; path=/; max-age=0";
|
||||
// Clear stale current zone so zone controller falls back to first zone of applied profile.
|
||||
document.cookie = "current_zone=; path=/; max-age=0";
|
||||
|
||||
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
|
||||
await window.tabsManager.loadTabs();
|
||||
@@ -231,7 +231,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
|
||||
seed_dj_zone: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -12,6 +12,78 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.hex-address-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input.hex-addr-box {
|
||||
width: 1.35rem;
|
||||
padding: 0.25rem 0.1rem;
|
||||
text-align: center;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.device-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.device-field-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.device-row-mac {
|
||||
font-size: 0.82em;
|
||||
color: #b0b0b0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.device-form-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
#devices-modal select {
|
||||
width: 100%;
|
||||
max-width: 16rem;
|
||||
padding: 0.35rem;
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#edit-device-modal select {
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
padding: 0.35rem;
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -131,7 +203,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
.zones-container {
|
||||
background-color: transparent;
|
||||
padding: 0.5rem 0;
|
||||
flex: 1;
|
||||
@@ -141,7 +213,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tabs-list {
|
||||
.zones-list {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
@@ -150,7 +222,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
.zone-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #3a3a3a;
|
||||
color: white;
|
||||
@@ -162,16 +234,16 @@ body.preset-ui-run .edit-mode-only {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
.zone-button:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
.zone-button.active {
|
||||
background-color: #6a5acd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.zone-content {
|
||||
flex: 1;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
@@ -183,7 +255,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-brightness-group {
|
||||
.zone-brightness-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -191,7 +263,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tab-brightness-group label {
|
||||
.zone-brightness-group label {
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -386,22 +458,28 @@ body.preset-ui-run .edit-mode-only {
|
||||
.n-param-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.n-param-group label {
|
||||
min-width: 40px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.n-input {
|
||||
flex: 1;
|
||||
flex: 0 0 var(--n-input-width, 5ch);
|
||||
width: var(--n-input-width, 5ch);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
color: white;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.n-input:focus {
|
||||
@@ -437,8 +515,8 @@ body.preset-ui-run .edit-mode-only {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Tab preset selecting area: 3 columns, vertical scroll only */
|
||||
#presets-list-tab {
|
||||
/* Zone preset selecting area: 3 columns, vertical scroll only */
|
||||
#presets-list-zone {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -535,6 +613,29 @@ body.preset-ui-run .edit-mode-only {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
/* Devices modal: live TCP presence (Wi-Fi only) */
|
||||
.device-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.device-status-dot--online {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 6px rgba(76, 175, 80, 0.45);
|
||||
}
|
||||
|
||||
.device-status-dot--offline {
|
||||
background: #616161;
|
||||
}
|
||||
|
||||
.device-status-dot--unknown {
|
||||
background: #424242;
|
||||
border: 1px solid #757575;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -655,8 +756,8 @@ body.preset-ui-run .edit-mode-only {
|
||||
background-color: #5a4f9f;
|
||||
}
|
||||
|
||||
/* Preset select buttons inside the tab grid */
|
||||
#presets-list-tab .pattern-button {
|
||||
/* Preset select buttons inside the zone grid */
|
||||
#presets-list-zone .pattern-button {
|
||||
display: flex;
|
||||
}
|
||||
.pattern-button .pattern-button-label {
|
||||
@@ -871,12 +972,12 @@ body.preset-ui-run .edit-mode-only {
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
.zones-container {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.zone-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -968,6 +1069,65 @@ body.preset-ui-run .edit-mode-only {
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.zone-modal-create-row {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.zone-modal-create-row input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.zone-devices-label {
|
||||
display: block;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zone-devices-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.zone-device-row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.zone-device-add-select {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.zone-devices-add {
|
||||
margin-top: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.zone-presets-section-label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-zone-presets-scroll {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Hide any text content in palette rows - only show color swatches */
|
||||
#palette-container .profiles-row {
|
||||
font-size: 0; /* Hide any text nodes */
|
||||
@@ -1041,7 +1201,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
}
|
||||
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
|
||||
@media (max-width: 800px) {
|
||||
#presets-list-tab {
|
||||
#presets-list-zone {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1080,8 +1240,8 @@ body.preset-ui-run .edit-mode-only {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Tab content placeholder (no tab selected) */
|
||||
.tab-content-placeholder {
|
||||
/* Zone content placeholder (no zone selected) */
|
||||
.zone-content-placeholder {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
@@ -1097,6 +1257,48 @@ body.preset-ui-run .edit-mode-only {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.preset-editor-field label {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.preset-editor-field input[type="number"] {
|
||||
width: var(--n-input-width, 5ch);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Pattern editor: numeric metadata row */
|
||||
#pattern-editor-modal input[type="number"] {
|
||||
width: var(--n-input-width, 5ch);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Pattern editor: human-readable n labels (text), full width */
|
||||
#pattern-editor-modal .n-params-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
#pattern-editor-modal .pattern-n-readable-input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@supports not selector(:has(*)) {
|
||||
#pattern-editor-modal #pattern-create-n-section .n-param-group {
|
||||
justify-content: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Settings modal */
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* General tab styles */
|
||||
/* General zone styles */
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
.zone {
|
||||
padding: 10px 20px;
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
@@ -15,23 +15,23 @@
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
.zone:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
.zone.active {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.zone-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
.zone-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-pane.active {
|
||||
.zone-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
let selectedIndex = null;
|
||||
|
||||
const getTab = async (tabId) => {
|
||||
const response = await fetch(`/tabs/${tabId}`, {
|
||||
const getTab = async (zoneId) => {
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('No tab found');
|
||||
throw new Error('No zone found');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const saveTabColors = async (tabId, colors) => {
|
||||
const response = await fetch(`/tabs/${tabId}`, {
|
||||
const saveTabColors = async (zoneId, colors) => {
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ colors }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save tab colors');
|
||||
throw new Error('Failed to save zone colors');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
@@ -101,23 +101,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const initTabPalette = async () => {
|
||||
const paletteContainer = document.getElementById('color-palette');
|
||||
const addButton = document.getElementById('tab-color-add-btn');
|
||||
const addFromPaletteButton = document.getElementById('tab-color-add-from-palette-btn');
|
||||
const colorInput = document.getElementById('tab-color-input');
|
||||
const addButton = document.getElementById('zone-color-add-btn');
|
||||
const addFromPaletteButton = document.getElementById('zone-color-add-from-palette-btn');
|
||||
const colorInput = document.getElementById('zone-color-input');
|
||||
|
||||
if (!paletteContainer || !addButton || !colorInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = paletteContainer.dataset.tabId;
|
||||
if (!tabId) {
|
||||
const zoneId = paletteContainer.dataset.zoneId;
|
||||
if (!zoneId) {
|
||||
renderPalette(paletteContainer, []);
|
||||
return;
|
||||
}
|
||||
|
||||
let tabData;
|
||||
try {
|
||||
tabData = await getTab(tabId);
|
||||
tabData = await getTab(zoneId);
|
||||
} catch (error) {
|
||||
renderPalette(paletteContainer, []);
|
||||
return;
|
||||
@@ -134,7 +134,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
try {
|
||||
const updated = colors.filter((_, i) => i !== index);
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = null;
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -152,7 +152,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const updated = [...colors];
|
||||
const [moved] = updated.splice(fromIndex, 1);
|
||||
updated.splice(toIndex, 0, moved);
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = toIndex;
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -169,7 +169,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
const updated = [...colors];
|
||||
updated[index] = newColor;
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = index;
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -192,7 +192,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
try {
|
||||
const updated = [...colors, newColor];
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = colors.length - 1;
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -229,7 +229,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
if (!colors.includes(picked)) {
|
||||
const updated = [...colors, picked];
|
||||
const saved = await saveTabColors(tabId, updated);
|
||||
const saved = await saveTabColors(zoneId, updated);
|
||||
colors = saved.colors || updated;
|
||||
selectedIndex = colors.indexOf(picked);
|
||||
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||
@@ -252,7 +252,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
if (event.target && event.target.id === 'tab-content') {
|
||||
if (event.target && event.target.id === 'zone-content') {
|
||||
selectedIndex = null;
|
||||
initTabPalette();
|
||||
}
|
||||
997
src/static/zones.js
Normal file
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>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LED Controller - Tab Mode</title>
|
||||
<title>LED Controller - Zone Mode</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<header>
|
||||
<div class="tabs-container">
|
||||
<div id="tabs-list">
|
||||
Loading tabs...
|
||||
<div class="zones-container">
|
||||
<div id="zones-list">
|
||||
Loading zones...
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" id="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="patterns-btn">Patterns</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">
|
||||
<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" 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="patterns-btn">Patterns</button>
|
||||
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
||||
@@ -40,46 +42,47 @@
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
<div id="tab-content" class="tab-content">
|
||||
<div class="tab-content-placeholder">
|
||||
Select a tab to get started
|
||||
<div id="zone-content" class="zone-content">
|
||||
<div class="zone-content-placeholder">
|
||||
Select a zone to get started
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Modal -->
|
||||
<div id="tabs-modal" class="modal">
|
||||
<div id="zones-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Tabs</h2>
|
||||
<div class="profiles-actions">
|
||||
<input type="text" id="new-tab-name" placeholder="Tab name">
|
||||
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1">
|
||||
<button class="btn btn-primary" id="create-tab-btn">Create</button>
|
||||
<div class="profiles-actions zone-modal-create-row">
|
||||
<input type="text" id="new-zone-name" placeholder="Zone name">
|
||||
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
||||
</div>
|
||||
<div id="tabs-list-modal" class="profiles-list"></div>
|
||||
<div id="zones-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="tabs-close-btn">Close</button>
|
||||
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Tab Modal -->
|
||||
<div id="edit-tab-modal" class="modal">
|
||||
<!-- Edit Zone Modal -->
|
||||
<div id="edit-zone-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Edit Tab</h2>
|
||||
<form id="edit-tab-form">
|
||||
<input type="hidden" id="edit-tab-id">
|
||||
<h2>Edit Zone</h2>
|
||||
<form id="edit-zone-form">
|
||||
<input type="hidden" id="edit-zone-id">
|
||||
<div class="modal-actions" style="margin-bottom: 1rem;">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
||||
</div>
|
||||
<label>Tab Name:</label>
|
||||
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
|
||||
<label>Device IDs (comma-separated):</label>
|
||||
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
|
||||
<label style="margin-top: 1rem;">Add presets to this tab</label>
|
||||
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
|
||||
<label>Zone Name:</label>
|
||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||
<label class="zone-devices-label">Devices in this zone</label>
|
||||
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
|
||||
<label class="zone-presets-section-label">Presets on this zone</label>
|
||||
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
<label class="zone-presets-section-label">Add presets to this zone</label>
|
||||
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +98,7 @@
|
||||
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||
<input type="checkbox" id="new-profile-seed-dj">
|
||||
DJ tab
|
||||
DJ zone
|
||||
</label>
|
||||
</div>
|
||||
<div id="profiles-list" class="profiles-list"></div>
|
||||
@@ -105,6 +108,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Devices Modal (registry: Wi-Fi drivers appear when they connect over TCP) -->
|
||||
<div id="devices-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Devices</h2>
|
||||
<div id="devices-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-device-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Edit device</h2>
|
||||
<form id="edit-device-form">
|
||||
<input type="hidden" id="edit-device-id">
|
||||
<p class="muted-text" style="margin-bottom:0.75rem;">MAC (id): <code id="edit-device-storage-id"></code></p>
|
||||
<label for="edit-device-name">Name</label>
|
||||
<input type="text" id="edit-device-name" required autocomplete="off">
|
||||
<label for="edit-device-type" style="margin-top:0.75rem;display:block;">Type</label>
|
||||
<select id="edit-device-type">
|
||||
<option value="led">LED</option>
|
||||
</select>
|
||||
<label for="edit-device-transport" style="margin-top:0.75rem;display:block;">Transport</label>
|
||||
<select id="edit-device-transport">
|
||||
<option value="espnow">ESP-NOW</option>
|
||||
<option value="wifi">WiFi</option>
|
||||
</select>
|
||||
<div id="edit-device-address-espnow" style="margin-top:0.75rem;">
|
||||
<label class="device-field-label">MAC (12 hex, optional)</label>
|
||||
<div id="edit-device-address-boxes" class="hex-address-row" aria-label="MAC address"></div>
|
||||
</div>
|
||||
<div id="edit-device-address-wifi-wrap" style="margin-top:0.75rem;" hidden>
|
||||
<label for="edit-device-address-wifi">Address (IP or hostname)</label>
|
||||
<input type="text" id="edit-device-address-wifi" placeholder="192.168.1.50" autocomplete="off">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Presets Modal -->
|
||||
<div id="presets-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
@@ -182,7 +229,7 @@
|
||||
<div class="modal-actions preset-editor-modal-actions">
|
||||
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
|
||||
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
|
||||
<button type="button" class="btn btn-danger" id="preset-remove-from-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-secondary" id="preset-editor-close-btn">Close</button>
|
||||
</div>
|
||||
@@ -193,6 +240,9 @@
|
||||
<div id="patterns-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Patterns</h2>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
|
||||
</div>
|
||||
<div id="patterns-list" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
|
||||
@@ -200,6 +250,78 @@
|
||||
</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 -->
|
||||
<div id="color-palette-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
@@ -223,10 +345,11 @@
|
||||
|
||||
<h3>Run mode</h3>
|
||||
<ul>
|
||||
<li><strong>Select tab</strong>: left-click a tab 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 zone</strong>: left-click a zone button in the top bar.</li>
|
||||
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the zone.</li>
|
||||
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
|
||||
<li><strong>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>
|
||||
</ul>
|
||||
|
||||
@@ -235,8 +358,9 @@
|
||||
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
|
||||
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
|
||||
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
|
||||
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab 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>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
|
||||
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li>
|
||||
<li><strong>Devices</strong>: view, edit, or remove registry entries (tabs use <strong>names</strong>; each row is keyed by <strong>MAC</strong>).</li>
|
||||
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
|
||||
</ul>
|
||||
|
||||
@@ -315,12 +439,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Styles moved to /static/style.css -->
|
||||
<script src="/static/tabs.js"></script>
|
||||
<script src="/static/zones.js"></script>
|
||||
<script src="/static/help.js"></script>
|
||||
<script src="/static/color_palette.js"></script>
|
||||
<script src="/static/profiles.js"></script>
|
||||
<script src="/static/tab_palette.js"></script>
|
||||
<script src="/static/zone_palette.js"></script>
|
||||
<script src="/static/patterns.js"></script>
|
||||
<script src="/static/presets.js"></script>
|
||||
<script src="/static/devices.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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"
|
||||
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:
|
||||
sys.path.remove(p)
|
||||
sys.path.insert(0, p)
|
||||
|
||||
@@ -10,7 +10,7 @@ from test_preset import test_preset
|
||||
from test_profile import test_profile
|
||||
from test_group import test_group
|
||||
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_device import test_device
|
||||
|
||||
@@ -26,7 +26,7 @@ def run_all_tests():
|
||||
("Profile", test_profile),
|
||||
("Group", test_group),
|
||||
("Sequence", test_sequence),
|
||||
("Tab", test_tab),
|
||||
("Zone", test_zone),
|
||||
("Palette", test_palette),
|
||||
("Device", test_device),
|
||||
]
|
||||
|
||||
@@ -1,57 +1,88 @@
|
||||
from models.device import Device
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def test_device():
|
||||
"""Test Device model CRUD operations."""
|
||||
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||
# Prefer src/models; pytest may have registered tests/models as top-level ``models``.
|
||||
_src = Path(__file__).resolve().parents[2] / "src"
|
||||
_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")
|
||||
if os.path.exists(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")
|
||||
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}")
|
||||
assert device_id is not None
|
||||
assert device_id == mac
|
||||
assert device_id in devices
|
||||
|
||||
print("\nTesting read device")
|
||||
device = devices.read(device_id)
|
||||
print(f"Read: {device}")
|
||||
assert device is not None
|
||||
assert device["id"] == mac
|
||||
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["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"})
|
||||
updated = devices.read(device_id)
|
||||
assert updated["address"] == "112233445566"
|
||||
assert updated["address"] == mac
|
||||
|
||||
print("\nTesting update device")
|
||||
print("\nTesting update device fields")
|
||||
update_data = {
|
||||
"name": "Updated Device",
|
||||
"default_pattern": "rainbow",
|
||||
"tabs": ["1", "2", "3"],
|
||||
"zones": ["1", "2", "3"],
|
||||
}
|
||||
result = devices.update(device_id, update_data)
|
||||
assert result is True
|
||||
updated = devices.read(device_id)
|
||||
assert updated["name"] == "Updated Device"
|
||||
assert updated["default_pattern"] == "rainbow"
|
||||
assert len(updated["tabs"]) == 3
|
||||
assert len(updated["zones"]) == 3
|
||||
|
||||
print("\nTesting list devices")
|
||||
device_list = devices.list()
|
||||
print(f"Device list: {device_list}")
|
||||
assert device_id in device_list
|
||||
assert mac in device_list
|
||||
|
||||
print("\nTesting delete device")
|
||||
deleted = devices.delete(device_id)
|
||||
assert deleted is True
|
||||
assert device_id not in devices
|
||||
assert mac not in devices
|
||||
|
||||
print("\nTesting read after delete")
|
||||
device = devices.read(device_id)
|
||||
@@ -60,5 +91,74 @@ def test_device():
|
||||
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__":
|
||||
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():
|
||||
"""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)
|
||||
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}")
|
||||
assert profile is not None
|
||||
assert profile["name"] == "test_profile"
|
||||
assert "tabs" in profile
|
||||
assert "zones" in profile
|
||||
assert "palette_id" in profile
|
||||
assert "type" in profile
|
||||
|
||||
print("\nTesting update profile")
|
||||
update_data = {
|
||||
"name": "updated_profile",
|
||||
"tabs": ["tab1"],
|
||||
"zones": ["tab1"],
|
||||
}
|
||||
result = profiles.update(profile_id, update_data)
|
||||
assert result is True
|
||||
updated = profiles.read(profile_id)
|
||||
assert updated["name"] == "updated_profile"
|
||||
assert "tab1" in updated["tabs"]
|
||||
assert "tab1" in updated["zones"]
|
||||
|
||||
print("\nTesting list profiles")
|
||||
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}")
|
||||
|
||||
# Delete created tabs by ID
|
||||
for tab_id in self.created_tabs:
|
||||
for zone_id in self.created_tabs:
|
||||
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:
|
||||
print(f" ✓ Cleaned up tab: {tab_id}")
|
||||
print(f" ✓ Cleaned up zone: {zone_id}")
|
||||
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
|
||||
for profile_id in self.created_profiles:
|
||||
@@ -180,20 +180,20 @@ class BrowserTest:
|
||||
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
|
||||
|
||||
# Also try to cleanup by name pattern (in case IDs weren't tracked)
|
||||
test_names = ['Browser Test Tab', 'Browser Test Profile', 'Browser Test Preset',
|
||||
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Tab']
|
||||
test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset',
|
||||
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone']
|
||||
|
||||
# Cleanup tabs by name
|
||||
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:
|
||||
tabs_data = tabs_response.json()
|
||||
tabs = tabs_data.get('tabs', {})
|
||||
for tab_id, tab_data in tabs.items():
|
||||
tabs = tabs_data.get('zones', {})
|
||||
for zone_id, tab_data in zones.items():
|
||||
if isinstance(tab_data, dict) and tab_data.get('name') in test_names:
|
||||
try:
|
||||
session.delete(f"{self.base_url}/tabs/{tab_id}")
|
||||
print(f" ✓ Cleaned up tab by name: {tab_data.get('name')}")
|
||||
session.delete(f"{self.base_url}/zones/{zone_id}")
|
||||
print(f" ✓ Cleaned up zone by name: {tab_data.get('name')}")
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
@@ -330,11 +330,11 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
|
||||
# Test 2: Open tabs modal
|
||||
total += 1
|
||||
if browser.click_element(By.ID, 'tabs-btn'):
|
||||
if browser.click_element(By.ID, 'zones-btn'):
|
||||
print("✓ Clicked Tabs button")
|
||||
# Wait for modal to appear
|
||||
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'):
|
||||
print("✓ Tabs modal opened")
|
||||
passed += 1
|
||||
@@ -343,60 +343,58 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
else:
|
||||
print("✗ Failed to click Tabs button")
|
||||
|
||||
# Test 3: Create a tab via UI
|
||||
# Test 3: Create a zone via UI
|
||||
total += 1
|
||||
try:
|
||||
# Fill in tab name
|
||||
if browser.fill_input(By.ID, 'new-tab-name', 'Browser Test Tab'):
|
||||
print(" ✓ Filled tab name")
|
||||
# Fill in device IDs
|
||||
if browser.fill_input(By.ID, 'new-tab-ids', '1,2,3'):
|
||||
print(" ✓ Filled device IDs")
|
||||
# Fill in zone name
|
||||
if browser.fill_input(By.ID, 'new-zone-name', 'Browser Test Zone'):
|
||||
print(" ✓ Filled zone name")
|
||||
# Devices default from registry or placeholder name "1"
|
||||
# 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")
|
||||
time.sleep(1) # Wait for creation
|
||||
# Check if tab appears in list and extract ID
|
||||
tabs_list = browser.wait_for_element(By.ID, 'tabs-list-modal')
|
||||
# Check if zone appears in list and extract ID
|
||||
tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
|
||||
if tabs_list:
|
||||
list_text = tabs_list.text
|
||||
if 'Browser Test Tab' in list_text:
|
||||
print("✓ Created tab via UI")
|
||||
# Try to extract tab ID from the list (look for data-tab-id attribute)
|
||||
if 'Browser Test Zone' in list_text:
|
||||
print("✓ Created zone via UI")
|
||||
# Try to extract zone ID from the list (look for data-zone-id attribute)
|
||||
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:
|
||||
if 'Browser Test Tab' in row.text:
|
||||
tab_id = row.get_attribute('data-tab-id')
|
||||
if tab_id:
|
||||
browser.created_tabs.append(tab_id)
|
||||
if 'Browser Test Zone' in row.text:
|
||||
zone_id = row.get_attribute('data-zone-id')
|
||||
if zone_id:
|
||||
browser.created_tabs.append(zone_id)
|
||||
break
|
||||
except:
|
||||
pass # If we can't extract ID, cleanup will try by name
|
||||
passed += 1
|
||||
else:
|
||||
print("✗ Tab not found in list after creation")
|
||||
print("✗ Zone not found in list after creation")
|
||||
else:
|
||||
print("✗ Tabs list not found")
|
||||
else:
|
||||
print("✗ Failed to click create button")
|
||||
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
|
||||
try:
|
||||
# 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)
|
||||
browser.click_element(By.ID, 'tabs-btn')
|
||||
browser.click_element(By.ID, 'zones-btn')
|
||||
time.sleep(0.5)
|
||||
|
||||
# Right-click the row corresponding to 'Browser Test Tab'
|
||||
# Right-click the row corresponding to 'Browser Test Zone'
|
||||
try:
|
||||
tab_row = browser.driver.find_element(
|
||||
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:
|
||||
tab_row = None
|
||||
@@ -407,14 +405,14 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
time.sleep(0.5)
|
||||
|
||||
# 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:
|
||||
print("✓ Edit modal opened via right-click")
|
||||
# Fill in new name
|
||||
if browser.fill_input(By.ID, 'edit-tab-name', 'Edited Browser Tab'):
|
||||
print(" ✓ Filled new tab name")
|
||||
if browser.fill_input(By.ID, 'edit-zone-name', 'Edited Browser Zone'):
|
||||
print(" ✓ Filled new zone name")
|
||||
# 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:
|
||||
browser.driver.execute_script("arguments[0].submit();", edit_form)
|
||||
time.sleep(1) # Wait for update
|
||||
@@ -425,24 +423,24 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
else:
|
||||
print("✗ Edit modal didn't open after right-click")
|
||||
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:
|
||||
print(f"✗ Failed to edit tab via UI: {e}")
|
||||
print(f"✗ Failed to edit zone via UI: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Test 5: Check current tab cookie
|
||||
# Test 5: Check current zone cookie
|
||||
total += 1
|
||||
cookie = browser.get_cookie('current_tab')
|
||||
cookie = browser.get_cookie('current_zone')
|
||||
if cookie:
|
||||
print(f"✓ Found current_tab cookie: {cookie.get('value')}")
|
||||
print(f"✓ Found current_zone cookie: {cookie.get('value')}")
|
||||
passed += 1
|
||||
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
|
||||
|
||||
# Close modal
|
||||
browser.click_element(By.ID, 'tabs-close-btn')
|
||||
browser.click_element(By.ID, 'zones-close-btn')
|
||||
|
||||
except Exception as 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():
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
assert bt.navigate('/'), "Failed to load main page"
|
||||
|
||||
# Click the first tab button to load presets for that tab
|
||||
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.tab-button', timeout=10)
|
||||
assert first_tab is not None, "No tab buttons found"
|
||||
# Click the first zone button to load presets for that zone
|
||||
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10)
|
||||
assert first_tab is not None, "No zone buttons found"
|
||||
first_tab.click()
|
||||
time.sleep(1)
|
||||
|
||||
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
|
||||
assert container is not None, "presets-list-tab not found"
|
||||
container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
|
||||
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
|
||||
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']
|
||||
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)
|
||||
|
||||
def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
"""Test dragging presets around in a tab."""
|
||||
print("\n=== Testing Preset Drag and Drop in Tab ===")
|
||||
"""Test dragging presets around in a zone."""
|
||||
print("\n=== Testing Preset Drag and Drop in Zone ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
@@ -771,7 +769,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Test 1: Load page and ensure we have a tab
|
||||
# Test 1: Load page and ensure we have a zone
|
||||
total += 1
|
||||
if browser.navigate('/'):
|
||||
print("✓ Loaded main page")
|
||||
@@ -780,34 +778,33 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
browser.teardown()
|
||||
return False
|
||||
|
||||
# Test 2: Open tabs modal and create/select a tab
|
||||
# Test 2: Open tabs modal and create/select a zone
|
||||
total += 1
|
||||
browser.click_element(By.ID, 'tabs-btn')
|
||||
browser.click_element(By.ID, 'zones-btn')
|
||||
time.sleep(0.5)
|
||||
|
||||
# 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:
|
||||
# Create a tab
|
||||
browser.fill_input(By.ID, 'new-tab-name', 'Drag Test Tab')
|
||||
browser.fill_input(By.ID, 'new-tab-ids', '1')
|
||||
browser.click_element(By.ID, 'create-tab-btn')
|
||||
# Create a zone
|
||||
browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone')
|
||||
browser.click_element(By.ID, 'create-zone-btn')
|
||||
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')]")
|
||||
if select_buttons:
|
||||
select_buttons[0].click()
|
||||
time.sleep(1)
|
||||
print("✓ Selected a tab")
|
||||
print("✓ Selected a zone")
|
||||
passed += 1
|
||||
else:
|
||||
print("✗ No tabs available to select")
|
||||
browser.click_element(By.ID, 'tabs-close-btn')
|
||||
browser.click_element(By.ID, 'zones-close-btn')
|
||||
browser.teardown()
|
||||
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)
|
||||
|
||||
# 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")
|
||||
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
|
||||
try:
|
||||
tab_id = browser.driver.execute_script(
|
||||
zone_id = browser.driver.execute_script(
|
||||
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
|
||||
)
|
||||
if not tab_id:
|
||||
print("✗ Could not get current tab id")
|
||||
if not zone_id:
|
||||
print("✗ Could not get current zone id")
|
||||
else:
|
||||
browser.driver.execute_script(
|
||||
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
|
||||
tab_id
|
||||
zone_id
|
||||
)
|
||||
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:
|
||||
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:
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
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:
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
print(" ✓ Added 2 presets to tab")
|
||||
print(" ✓ Added 2 presets to zone")
|
||||
passed += 1
|
||||
elif len(select_buttons) == 1:
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
print(" ✓ Added 1 preset to tab")
|
||||
print(" ✓ Added 1 preset to zone")
|
||||
passed += 1
|
||||
else:
|
||||
print(" ⚠ No presets available to add (all already in tab)")
|
||||
print(" ⚠ No presets available to add (all already in zone)")
|
||||
else:
|
||||
print("✗ Edit tab presets list not found")
|
||||
print("✗ Edit zone presets list not found")
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to add presets to tab: {e}")
|
||||
print(f"✗ Failed to add presets to zone: {e}")
|
||||
import traceback
|
||||
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
|
||||
try:
|
||||
# Wait for presets to load in the tab
|
||||
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
|
||||
# Wait for presets to load in the zone
|
||||
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5)
|
||||
if presets_list_tab:
|
||||
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
|
||||
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:
|
||||
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
|
||||
|
||||
# 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:
|
||||
new_order = [p.text for p in draggable_presets_after]
|
||||
print(f" New order: {new_order[:3]}")
|
||||
@@ -939,28 +936,28 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
else:
|
||||
print("✗ Presets disappeared after drag")
|
||||
elif len(draggable_presets) == 1:
|
||||
print(f"⚠ Only 1 preset found in tab (need 2 for drag test). Preset: {draggable_presets[0].text}")
|
||||
tab_id = browser.driver.execute_script(
|
||||
print(f"⚠ Only 1 preset found in zone (need 2 for drag test). Preset: {draggable_presets[0].text}")
|
||||
zone_id = browser.driver.execute_script(
|
||||
"return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;"
|
||||
)
|
||||
if tab_id:
|
||||
if zone_id:
|
||||
browser.driver.execute_script(
|
||||
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
|
||||
tab_id
|
||||
zone_id
|
||||
)
|
||||
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:
|
||||
print(" Attempting to add another preset...")
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
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:
|
||||
pass
|
||||
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:
|
||||
print(" ✓ Added another preset, now testing drag...")
|
||||
source = draggable_presets[0]
|
||||
@@ -973,11 +970,11 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
else:
|
||||
print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding")
|
||||
else:
|
||||
print(" ✗ No Select buttons found in Edit Tab modal")
|
||||
print(" ✗ No Add buttons found in Edit Zone modal")
|
||||
else:
|
||||
print(f"✗ No presets found in tab (found {len(draggable_presets)})")
|
||||
print(f"✗ No presets found in zone (found {len(draggable_presets)})")
|
||||
else:
|
||||
print("✗ Presets list in tab not found")
|
||||
print("✗ Presets list in zone not found")
|
||||
except Exception as e:
|
||||
print(f"✗ Drag and drop test error: {e}")
|
||||
import traceback
|
||||
|
||||
@@ -91,115 +91,115 @@ def test_tabs(client: TestClient) -> bool:
|
||||
# Test 1: List tabs
|
||||
total += 1
|
||||
try:
|
||||
response = client.get('/tabs')
|
||||
response = client.get('/zones')
|
||||
if response.status_code == 200:
|
||||
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
|
||||
else:
|
||||
print(f"✗ GET /tabs - Status: {response.status_code}")
|
||||
print(f"✗ GET /zones - Status: {response.status_code}")
|
||||
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
|
||||
try:
|
||||
tab_data = {
|
||||
"name": "Test Tab",
|
||||
"name": "Test Zone",
|
||||
"names": ["1", "2"]
|
||||
}
|
||||
response = client.post('/tabs', json_data=tab_data)
|
||||
response = client.post('/zones', json_data=tab_data)
|
||||
if response.status_code == 201:
|
||||
created_tab = response.json()
|
||||
# Response format: {tab_id: {tab_data}}
|
||||
# Response format: {zone_id: {tab_data}}
|
||||
if isinstance(created_tab, dict):
|
||||
# Get the first key which should be the tab ID
|
||||
tab_id = next(iter(created_tab.keys())) if created_tab else None
|
||||
# Get the first key which should be the zone ID
|
||||
zone_id = next(iter(created_tab.keys())) if created_tab else None
|
||||
else:
|
||||
tab_id = None
|
||||
print(f"✓ POST /tabs - Created tab: {tab_id}")
|
||||
zone_id = None
|
||||
print(f"✓ POST /zones - Created zone: {zone_id}")
|
||||
passed += 1
|
||||
|
||||
# Test 3: Get specific tab
|
||||
if tab_id:
|
||||
# Test 3: Get specific zone
|
||||
if zone_id:
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
response = client.get(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ GET /tabs/{tab_id} - Retrieved tab")
|
||||
print(f"✓ GET /zones/{zone_id} - Retrieved zone")
|
||||
passed += 1
|
||||
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
|
||||
response = client.post(f'/tabs/{tab_id}/set-current')
|
||||
response = client.post(f'/zones/{zone_id}/set-current')
|
||||
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
|
||||
cookie = client.get_cookie('current_tab')
|
||||
if cookie == tab_id:
|
||||
print(f" ✓ Cookie 'current_tab' set to {tab_id}")
|
||||
cookie = client.get_cookie('current_zone')
|
||||
if cookie == zone_id:
|
||||
print(f" ✓ Cookie 'current_zone' set to {zone_id}")
|
||||
passed += 1
|
||||
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
|
||||
response = client.get('/tabs/current')
|
||||
response = client.get('/zones/current')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('tab_id') == tab_id:
|
||||
print(f"✓ GET /tabs/current - Current tab is {tab_id}")
|
||||
if data.get('zone_id') == zone_id:
|
||||
print(f"✓ GET /zones/current - Current zone is {zone_id}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/current - Wrong tab ID")
|
||||
print(f"✗ GET /zones/current - Wrong zone ID")
|
||||
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
|
||||
update_data = {
|
||||
"name": "Updated Test Tab",
|
||||
"name": "Updated Test Zone",
|
||||
"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:
|
||||
updated = response.json()
|
||||
if updated.get('name') == "Updated Test Tab" and updated.get('names') == ["1", "2", "3"]:
|
||||
print(f"✓ PUT /tabs/{tab_id} - Updated tab (name and device IDs)")
|
||||
if updated.get('name') == "Updated Test Zone" and updated.get('names') == ["1", "2", "3"]:
|
||||
print(f"✓ PUT /zones/{zone_id} - Updated zone (name and device IDs)")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ PUT /tabs/{tab_id} - Update didn't work correctly")
|
||||
print(f" Expected name='Updated Test Tab', got '{updated.get('name')}'")
|
||||
print(f"✗ PUT /zones/{zone_id} - Update didn't work correctly")
|
||||
print(f" Expected name='Updated Test Zone', got '{updated.get('name')}'")
|
||||
print(f" Expected names=['1','2','3'], got {updated.get('names')}")
|
||||
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
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
response = client.get(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
verified = response.json()
|
||||
if verified.get('name') == "Updated Test Tab":
|
||||
print(f"✓ GET /tabs/{tab_id} - Verified update persisted")
|
||||
if verified.get('name') == "Updated Test Zone":
|
||||
print(f"✓ GET /zones/{zone_id} - Verified update persisted")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/{tab_id} - Update didn't persist")
|
||||
print(f"✗ GET /zones/{zone_id} - Update didn't persist")
|
||||
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
|
||||
response = client.delete(f'/tabs/{tab_id}')
|
||||
response = client.delete(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
print(f"✓ DELETE /tabs/{tab_id} - Deleted tab")
|
||||
print(f"✓ DELETE /zones/{zone_id} - Deleted zone")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
|
||||
print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
|
||||
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:
|
||||
print(f"✗ POST /tabs - Error: {e}")
|
||||
print(f"✗ POST /zones - Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -409,87 +409,87 @@ def test_patterns(client: TestClient) -> bool:
|
||||
return passed == total
|
||||
|
||||
def test_tab_edit_workflow(client: TestClient) -> bool:
|
||||
"""Test complete tab edit workflow like a browser would."""
|
||||
print("\n=== Testing Tab Edit Workflow ===")
|
||||
"""Test complete zone edit workflow like a browser would."""
|
||||
print("\n=== Testing Zone Edit Workflow ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
# Step 1: Create a tab to edit
|
||||
# Step 1: Create a zone to edit
|
||||
total += 1
|
||||
try:
|
||||
tab_data = {
|
||||
"name": "Tab to Edit",
|
||||
"name": "Zone to Edit",
|
||||
"names": ["1"]
|
||||
}
|
||||
response = client.post('/tabs', json_data=tab_data)
|
||||
response = client.post('/zones', json_data=tab_data)
|
||||
if response.status_code == 201:
|
||||
created = response.json()
|
||||
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:
|
||||
tab_id = None
|
||||
zone_id = None
|
||||
|
||||
if tab_id:
|
||||
print(f"✓ Created tab {tab_id} for editing")
|
||||
if zone_id:
|
||||
print(f"✓ Created zone {zone_id} for editing")
|
||||
passed += 1
|
||||
|
||||
# Step 2: Get the tab to verify initial state
|
||||
# Step 2: Get the zone to verify initial state
|
||||
total += 1
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
response = client.get(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
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
|
||||
|
||||
# Step 3: Edit the tab (simulate browser edit form submission)
|
||||
# Step 3: Edit the zone (simulate browser edit form submission)
|
||||
total += 1
|
||||
edit_data = {
|
||||
"name": "Edited Tab Name",
|
||||
"name": "Edited Zone Name",
|
||||
"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:
|
||||
edited = response.json()
|
||||
if edited.get('name') == "Edited Tab Name" and edited.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ PUT /tabs/{tab_id} - Successfully edited tab")
|
||||
if edited.get('name') == "Edited Zone Name" and edited.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ PUT /zones/{zone_id} - Successfully edited zone")
|
||||
print(f" New name: '{edited.get('name')}'")
|
||||
print(f" New device IDs: {edited.get('names')}")
|
||||
passed += 1
|
||||
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}")
|
||||
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
|
||||
response = client.get(f'/tabs/{tab_id}')
|
||||
response = client.get(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
verified = response.json()
|
||||
if verified.get('name') == "Edited Tab Name" and verified.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ GET /tabs/{tab_id} - Verified edit persisted")
|
||||
if verified.get('name') == "Edited Zone Name" and verified.get('names') == ["2", "3", "4"]:
|
||||
print(f"✓ GET /zones/{zone_id} - Verified edit persisted")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /tabs/{tab_id} - Edit didn't persist")
|
||||
print(f" Expected name='Edited Tab Name', got '{verified.get('name')}'")
|
||||
print(f"✗ GET /zones/{zone_id} - Edit didn't persist")
|
||||
print(f" Expected name='Edited Zone Name', got '{verified.get('name')}'")
|
||||
print(f" Expected names=['2','3','4'], got {verified.get('names')}")
|
||||
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
|
||||
response = client.delete(f'/tabs/{tab_id}')
|
||||
response = client.delete(f'/zones/{zone_id}')
|
||||
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
|
||||
else:
|
||||
print(f"✗ DELETE /tabs/{tab_id} - Status: {response.status_code}")
|
||||
print(f"✗ DELETE /zones/{zone_id} - Status: {response.status_code}")
|
||||
else:
|
||||
print(f"✗ Failed to extract tab ID from create response")
|
||||
print(f"✗ Failed to extract zone ID from create response")
|
||||
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:
|
||||
print(f"✗ Tab edit workflow - Error: {e}")
|
||||
print(f"✗ Zone edit workflow - Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -505,7 +505,7 @@ def test_static_files(client: TestClient) -> bool:
|
||||
static_files = [
|
||||
'/static/style.css',
|
||||
'/static/app.js',
|
||||
'/static/tabs.js',
|
||||
'/static/zones.js',
|
||||
'/static/presets.js',
|
||||
'/static/profiles.js',
|
||||
'/static/devices.js',
|
||||
@@ -544,7 +544,7 @@ def main():
|
||||
|
||||
# Run all tests
|
||||
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(("Presets", test_presets(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.profile as models_profile # 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.scene as models_scene # noqa: E402
|
||||
import models.pattern as models_pattern # noqa: E402
|
||||
import models.squence as models_sequence # noqa: E402
|
||||
import models.device as models_device # noqa: E402
|
||||
|
||||
for cls in (
|
||||
models_preset.Preset,
|
||||
models_profile.Profile,
|
||||
models_group.Group,
|
||||
models_tab.Tab,
|
||||
models_tab.Zone,
|
||||
models_pallet.Palette,
|
||||
models_scene.Scene,
|
||||
models_pattern.Pattern,
|
||||
models_sequence.Sequence,
|
||||
models_device.Device,
|
||||
):
|
||||
if hasattr(cls, "_instance"):
|
||||
delattr(cls, "_instance")
|
||||
@@ -162,11 +164,12 @@ def server(monkeypatch, tmp_path_factory):
|
||||
"controllers.profile",
|
||||
"controllers.group",
|
||||
"controllers.sequence",
|
||||
"controllers.tab",
|
||||
"controllers.zone",
|
||||
"controllers.palette",
|
||||
"controllers.scene",
|
||||
"controllers.pattern",
|
||||
"controllers.settings",
|
||||
"controllers.device",
|
||||
):
|
||||
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.group as group_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.scene as scene_ctl # noqa: E402
|
||||
import controllers.pattern as pattern_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.
|
||||
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(group_ctl.controller, "/groups")
|
||||
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(scene_ctl.controller, "/scenes")
|
||||
app.mount(pattern_ctl.controller, "/patterns")
|
||||
app.mount(settings_ctl.controller, "/settings")
|
||||
app.mount(device_ctl.controller, "/devices")
|
||||
|
||||
@app.route("/")
|
||||
def index(request):
|
||||
@@ -343,11 +348,15 @@ def test_settings_controller(server):
|
||||
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"]
|
||||
base_url: str = server["base_url"]
|
||||
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]}"
|
||||
|
||||
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
|
||||
|
||||
# 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(
|
||||
f"{base_url}/tabs",
|
||||
f"{base_url}/zones",
|
||||
json={"name": unique_tab_name, "names": ["1", "2"]},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
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.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
|
||||
|
||||
resp = c.get(f"{base_url}/tabs/current")
|
||||
resp = c.get(f"{base_url}/zones/current")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["tab_id"] == str(tab_id)
|
||||
assert resp.json()["zone_id"] == str(zone_id)
|
||||
|
||||
resp = c.put(
|
||||
f"{base_url}/tabs/{tab_id}",
|
||||
f"{base_url}/zones/{zone_id}",
|
||||
json={"name": f"{unique_tab_name}-updated", "names": ["3"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
clone_payload = resp.json()
|
||||
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
|
||||
|
||||
resp = c.delete(f"{base_url}/tabs/{clone_id}")
|
||||
resp = c.delete(f"{base_url}/zones/{clone_id}")
|
||||
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
|
||||
|
||||
# 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}")
|
||||
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.
|
||||
resp = c.get(f"{base_url}/patterns/definitions")
|
||||
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