docs(ui): update help assets and regenerate help pdf
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ ENV/
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
docs/.help-print.html
|
||||
settings.json
|
||||
*.log
|
||||
*.db
|
||||
|
||||
@@ -7,6 +7,7 @@ LED controller web app for managing profiles, tabs, presets, and colour palettes
|
||||
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
|
||||
- Start app: `pipenv run run`
|
||||
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
|
||||
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
|
||||
|
||||
## UI modes
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"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": "4"}, "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": {"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"]}}
|
||||
112
docs/help.md
Normal file
112
docs/help.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# LED controller — user guide
|
||||
|
||||
This page describes the **main web UI** served from the Raspberry Pi app: profiles, tabs, presets, colour palettes, and sending commands to LED devices over the serial → ESP-NOW bridge.
|
||||
|
||||
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
|
||||
|
||||
Figures below are **schematic** (layout and ideas), not pixel-perfect screenshots.
|
||||
|
||||
---
|
||||
|
||||
## Run mode and Edit mode
|
||||
|
||||
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
|
||||
|
||||

|
||||
|
||||
*The active tab 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. |
|
||||
| **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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Presets on the tab 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).
|
||||
- **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.
|
||||
|
||||

|
||||
|
||||
*The slider controls global brightness for the tab’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).
|
||||
|
||||
---
|
||||
|
||||
## Preset editor
|
||||
|
||||
- **Pattern**: chosen from the dropdown; optional **n1–n8** fields depend on the pattern (see **Pattern-specific parameters** in [API.md](API.md)).
|
||||
- **Colours**: choosing a value in the colour picker **adds** a swatch when the picker closes. Swatches can be **reordered** by dragging. Changing a swatch with the picker **clears** palette linkage for that slot.
|
||||
- **From Palette**: inserts a colour **linked** to the current 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.
|
||||
- **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.
|
||||
|
||||

|
||||
|
||||
*Try previews without persisting on the device; **Save & Send** stores the preset and pushes definitions with save.*
|
||||
|
||||
---
|
||||
|
||||
## Profiles
|
||||
|
||||
- **Apply**: sets the **current profile** in your session. 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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
The **Patterns** dialog (Edit mode) is a **read-only reference**: pattern names and typical **delay** ranges from the pattern definitions. It does not change device behaviour by itself; patterns are chosen inside the preset editor.
|
||||
|
||||
---
|
||||
|
||||
## Colour palette
|
||||
|
||||
**Colour Palette** (Edit mode) edits the **current profile’s** palette swatches. Those colours are reused by **From Palette** in the preset editor and stay in sync while the **P** link remains.
|
||||
|
||||

|
||||
|
||||
*Add or change swatches here; linked preset colours update automatically.*
|
||||
|
||||
---
|
||||
|
||||
## Mobile layout
|
||||
|
||||
On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Tabs, Presets, Help, mode toggle, etc.).
|
||||
|
||||

|
||||
|
||||
*Preset tiles behave the same once a tab is selected.*
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys).
|
||||
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour.
|
||||
BIN
docs/help.pdf
Normal file
BIN
docs/help.pdf
Normal file
Binary file not shown.
14
docs/images/help/colour-palette.svg
Normal file
14
docs/images/help/colour-palette.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 140" width="480" height="140">
|
||||
<title>Colour Palette modal (concept)</title>
|
||||
<rect width="480" height="140" fill="#2a2a2a" stroke="#555" stroke-width="1" rx="6"/>
|
||||
<text x="20" y="28" fill="#fff" font-family="sans-serif" font-size="15" font-weight="600">Colour Palette</text>
|
||||
<text x="20" y="48" fill="#888" font-family="sans-serif" font-size="10">Profile: current profile name</text>
|
||||
<rect x="20" y="58" width="44" height="44" rx="4" fill="#e53935" stroke="#333"/>
|
||||
<rect x="72" y="58" width="44" height="44" rx="4" fill="#fdd835" stroke="#333"/>
|
||||
<rect x="124" y="58" width="44" height="44" rx="4" fill="#43a047" stroke="#333"/>
|
||||
<rect x="176" y="58" width="44" height="44" rx="4" fill="#1e88e5" stroke="#333"/>
|
||||
<rect x="228" y="58" width="44" height="44" rx="4" fill="#8e24aa" stroke="#333"/>
|
||||
<rect x="280" y="70" width="36" height="28" rx="3" fill="#1a1a1a" stroke="#666"/>
|
||||
<text x="288" y="88" fill="#ccc" font-family="sans-serif" font-size="10">+</text>
|
||||
<text x="20" y="122" fill="#aaa" font-family="sans-serif" font-size="10">Swatches belong to the profile; preset editor uses them via From Palette.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
24
docs/images/help/header-toolbar.svg
Normal file
24
docs/images/help/header-toolbar.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 108" width="820" height="108">
|
||||
<title>Header: tab buttons and action bar</title>
|
||||
<rect width="820" height="108" fill="#1a1a1a"/>
|
||||
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
|
||||
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Tabs</text>
|
||||
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
|
||||
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
|
||||
<text x="108" y="63" fill="#fff" font-family="sans-serif" font-size="13">lounge</text>
|
||||
<rect x="192" y="40" width="56" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="204" y="63" fill="#ccc" font-family="sans-serif" font-size="13">dj</text>
|
||||
<text x="380" y="28" fill="#888" font-family="sans-serif" font-size="11">Actions (Edit mode)</text>
|
||||
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
|
||||
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Tabs</text>
|
||||
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
|
||||
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="598" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Patterns</text>
|
||||
<rect x="670" y="40" width="134" height="30" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
|
||||
<text x="688" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Run mode</text>
|
||||
<text x="16" y="98" fill="#aaa" font-family="sans-serif" font-size="10">Active tab highlighted. Mode button shows the mode you switch to next.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
26
docs/images/help/mobile-menu.svg
Normal file
26
docs/images/help/mobile-menu.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="t">
|
||||
<title id="t">Narrow screen: Menu aggregates header actions</title>
|
||||
<rect width="300" height="340" fill="#2e2e2e"/>
|
||||
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
|
||||
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="22" y="30" fill="#eee" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12">Menu <20></text>
|
||||
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="86" y="30" fill="#ccc" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
|
||||
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
|
||||
<text x="142" y="30" fill="#fff" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
|
||||
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
|
||||
<text x="24" y="84" fill="#888" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
|
||||
<g font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12" fill="#e8e8e8">
|
||||
<text x="24" y="108">Run mode</text>
|
||||
<text x="24" y="132">Profiles</text>
|
||||
<text x="24" y="156">Tabs</text>
|
||||
<text x="24" y="180">Presets</text>
|
||||
<text x="24" y="204">Help</text>
|
||||
</g>
|
||||
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
|
||||
<text x="24" y="268" fill="#aaa" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Content area presets as on desktop</text>
|
||||
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||
<text x="36" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
|
||||
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||
<text x="124" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
31
docs/images/help/preset-editor.svg
Normal file
31
docs/images/help/preset-editor.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 400" width="520" height="400">
|
||||
<title>Preset editor modal (simplified)</title>
|
||||
<rect width="520" height="400" fill="#1e1e1e"/>
|
||||
<rect x="40" y="28" width="440" height="344" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="58" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="17" font-weight="600">Preset</text>
|
||||
<text x="60" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Name</text>
|
||||
<rect x="60" y="92" width="200" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="72" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">evening glow</text>
|
||||
<text x="280" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Pattern</text>
|
||||
<rect x="280" y="92" width="160" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="292" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">pulse</text>
|
||||
<text x="60" y="148" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Colours</text>
|
||||
<rect x="60" y="156" width="48" height="48" rx="4" fill="#7e57c2" stroke="#333" stroke-width="1"/>
|
||||
<circle cx="66" cy="162" r="8" fill="#3f51b5" stroke="#fff" stroke-width="1"/>
|
||||
<text x="63" y="166" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="7" font-weight="700">P</text>
|
||||
<rect x="116" y="156" width="48" height="48" rx="4" fill="#26a69a" stroke="#333" stroke-width="1"/>
|
||||
<text x="176" y="184" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">P = palette-linked</text>
|
||||
<text x="60" y="232" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Brightness, delay, n1-n8</text>
|
||||
<rect x="60" y="238" width="120" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="68" y="254" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">0-255</text>
|
||||
<text x="60" y="290" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">Actions</text>
|
||||
<rect x="60" y="298" width="44" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||
<text x="72" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Try</text>
|
||||
<rect x="112" y="298" width="56" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||
<text x="120" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Default</text>
|
||||
<rect x="176" y="298" width="88" height="26" rx="3" fill="#3d5a80" stroke="#5a7ab8" stroke-width="1"/>
|
||||
<text x="188" y="315" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Save+Send</text>
|
||||
<rect x="272" y="298" width="48" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||
<text x="284" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Close</text>
|
||||
<text x="60" y="352" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="9">Try: preview without device save. Save+Send: store and push with save.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
35
docs/images/help/tab-preset-strip.svg
Normal file
35
docs/images/help/tab-preset-strip.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 220" width="800" height="220">
|
||||
<title>Main area: brightness and preset tiles</title>
|
||||
<defs>
|
||||
<linearGradient id="rg1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#ffd54f"/><stop offset="100%" style="stop-color:#fff8e1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rg2" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#e53935"/><stop offset="33%" style="stop-color:#fdd835"/>
|
||||
<stop offset="66%" style="stop-color:#43a047"/><stop offset="100%" style="stop-color:#1e88e5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rg3" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#00897b"/><stop offset="100%" style="stop-color:#4db6ac"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="220" fill="#2e2e2e"/>
|
||||
<text x="20" y="32" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">lounge</text>
|
||||
<text x="20" y="56" fill="#aaa" font-family="sans-serif" font-size="11">Brightness (global)</text>
|
||||
<rect x="20" y="64" width="320" height="8" rx="4" fill="#444"/>
|
||||
<rect x="20" y="64" width="200" height="8" rx="4" fill="#6a9ee2"/>
|
||||
<circle cx="220" cy="68" r="10" fill="#ccc" stroke="#333" stroke-width="1"/>
|
||||
<text x="360" y="74" fill="#888" font-family="sans-serif" font-size="11">drag to adjust</text>
|
||||
<text x="20" y="108" fill="#aaa" font-family="sans-serif" font-size="11">Click tile body to select on tab devices</text>
|
||||
<rect x="20" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
|
||||
<rect x="28" y="128" width="184" height="36" rx="4" fill="url(#rg1)"/>
|
||||
<text x="32" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">warm white</text>
|
||||
<rect x="232" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#6a9ee2" stroke-width="2"/>
|
||||
<rect x="240" y="128" width="184" height="36" rx="4" fill="url(#rg2)"/>
|
||||
<text x="244" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">rainbow</text>
|
||||
<rect x="444" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
|
||||
<rect x="452" y="128" width="184" height="36" rx="4" fill="url(#rg3)"/>
|
||||
<text x="456" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">chase</text>
|
||||
<rect x="656" y="130" width="56" height="48" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="670" y="158" fill="#ddd" font-family="sans-serif" font-size="11">Edit</text>
|
||||
<text x="656" y="198" fill="#888" font-family="sans-serif" font-size="10">Edit mode: drag tiles to reorder</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
19
scripts/build_help_pdf.sh
Executable file
19
scripts/build_help_pdf.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env sh
|
||||
# Build docs/help.pdf from docs/help.md.
|
||||
# Requires: pandoc, chromium (headless print-to-PDF).
|
||||
set -eu
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# HTML next to docs/help.md so relative image paths (e.g. images/help/*.svg) resolve.
|
||||
HTML="$ROOT/docs/.help-print.html"
|
||||
trap 'rm -f "$HTML"' EXIT
|
||||
|
||||
pandoc "$ROOT/docs/help.md" -s \
|
||||
--css="$ROOT/scripts/help-pdf.css" \
|
||||
--metadata title="LED controller — user guide" \
|
||||
-o "$HTML"
|
||||
|
||||
chromium --headless --no-sandbox --disable-gpu \
|
||||
--print-to-pdf="$ROOT/docs/help.pdf" \
|
||||
"file://${HTML}"
|
||||
|
||||
echo "Wrote $ROOT/docs/help.pdf ($(wc -c < "$ROOT/docs/help.pdf") bytes)"
|
||||
96
scripts/help-pdf.css
Normal file
96
scripts/help-pdf.css
Normal file
@@ -0,0 +1,96 @@
|
||||
/* Print stylesheet for docs/help.md → PDF (Chromium headless) */
|
||||
@page {
|
||||
margin: 18mm;
|
||||
size: A4;
|
||||
}
|
||||
html {
|
||||
font-size: 11pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
body {
|
||||
font-family: "DejaVu Sans", "Liberation Sans", Helvetica, Arial, sans-serif;
|
||||
color: #222;
|
||||
max-width: 100%;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.45rem;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 0.25em;
|
||||
margin-top: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
margin-top: 1.25em;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.05rem;
|
||||
margin-top: 1em;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
code {
|
||||
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
background: #f3f3f3;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.65em 0.85em;
|
||||
overflow-x: auto;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.75em 0;
|
||||
font-size: 0.95em;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #bbb;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #eee;
|
||||
}
|
||||
a {
|
||||
color: #1a5276;
|
||||
text-decoration: none;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #ccc;
|
||||
margin: 1.25em 0;
|
||||
}
|
||||
ul, ol {
|
||||
padding-left: 1.35em;
|
||||
}
|
||||
li {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
|
||||
/* Images in docs/help.md */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
p.help-figure-caption {
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
margin: 0.35em 0 1em 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -60,6 +60,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (nameInput && data && typeof data === 'object') {
|
||||
nameInput.value = data.device_name || 'led-controller';
|
||||
}
|
||||
const chInput = document.getElementById('wifi-channel-input');
|
||||
if (chInput && data && typeof data === 'object') {
|
||||
const ch = data.wifi_channel;
|
||||
chInput.value =
|
||||
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading device settings:', error);
|
||||
}
|
||||
@@ -116,15 +122,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
showSettingsMessage('Device name is required', 'error');
|
||||
return;
|
||||
}
|
||||
const chRaw = document.getElementById('wifi-channel-input')
|
||||
? document.getElementById('wifi-channel-input').value
|
||||
: '6';
|
||||
const wifiChannel = parseInt(chRaw, 10);
|
||||
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
|
||||
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/settings/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_name: deviceName }),
|
||||
body: JSON.stringify({
|
||||
device_name: deviceName,
|
||||
wifi_channel: wifiChannel,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
showSettingsMessage('Device name saved. It will be used on next restart.', 'success');
|
||||
showSettingsMessage(
|
||||
'Device settings saved. They will apply on next restart where relevant.',
|
||||
'success',
|
||||
);
|
||||
} else {
|
||||
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
|
||||
}
|
||||
|
||||
@@ -174,6 +174,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 presetSaveButton = document.getElementById('preset-save-btn');
|
||||
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||||
|
||||
@@ -532,6 +533,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
updatePresetEditorTabActionsVisibility();
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
@@ -565,6 +567,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetPatternInput.style.backgroundColor = '';
|
||||
presetPatternInput.style.cursor = '';
|
||||
}
|
||||
updatePresetEditorTabActionsVisibility();
|
||||
};
|
||||
|
||||
const getActiveTabId = () => {
|
||||
@@ -575,6 +578,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return section ? section.dataset.tabId : null;
|
||||
};
|
||||
|
||||
const updatePresetEditorTabActionsVisibility = () => {
|
||||
if (!presetRemoveFromTabButton) return;
|
||||
const show = Boolean(currentEditTabId && currentEditId);
|
||||
presetRemoveFromTabButton.hidden = !show;
|
||||
};
|
||||
|
||||
const updateTabDefaultPreset = async (presetId) => {
|
||||
const tabId = getActiveTabId();
|
||||
if (!tabId) {
|
||||
@@ -786,6 +795,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
editButton.textContent = 'Edit';
|
||||
editButton.addEventListener('click', async () => {
|
||||
currentEditId = presetId;
|
||||
currentEditTabId = null;
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
const presetForEditor = {
|
||||
...(preset || {}),
|
||||
@@ -1241,6 +1251,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (presetRemoveFromTabButton) {
|
||||
presetRemoveFromTabButton.addEventListener('click', async () => {
|
||||
if (!currentEditTabId || !currentEditId) return;
|
||||
if (!window.confirm('Remove this preset from this tab?')) return;
|
||||
await removePresetFromTab(currentEditTabId, currentEditId);
|
||||
clearForm();
|
||||
closeEditor();
|
||||
});
|
||||
}
|
||||
|
||||
presetSaveButton.addEventListener('click', async () => {
|
||||
const payload = buildPresetPayload();
|
||||
if (!payload.name) {
|
||||
@@ -1778,58 +1798,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
editPresetFromTab(presetId, tabId, preset);
|
||||
});
|
||||
|
||||
const defaultBtn = document.createElement('button');
|
||||
defaultBtn.type = 'button';
|
||||
defaultBtn.className = 'btn btn-secondary btn-small';
|
||||
defaultBtn.textContent = 'Default';
|
||||
defaultBtn.title = 'Set as default preset';
|
||||
defaultBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDraggingPreset) return;
|
||||
const section = row.closest('.presets-section');
|
||||
const namesAttr = section && section.getAttribute('data-device-names');
|
||||
const deviceNames = namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
sendDefaultPreset(presetId, deviceNames);
|
||||
// Persist tab-level default if we know the tab from this tile.
|
||||
if (tabId) {
|
||||
try {
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (tabResponse.ok) {
|
||||
const tabData = await tabResponse.json();
|
||||
tabData.default_preset = presetId;
|
||||
await fetch(`/tabs/${tabId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to save tab default preset:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'btn btn-danger btn-small';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.title = 'Remove from this tab';
|
||||
removeBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDraggingPreset) return;
|
||||
if (!window.confirm('Remove this preset from this tab?')) return;
|
||||
await removePresetFromTab(tabId, presetId);
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(defaultBtn);
|
||||
actions.appendChild(removeBtn);
|
||||
row.appendChild(actions);
|
||||
}
|
||||
|
||||
|
||||
@@ -620,15 +620,21 @@ body.preset-ui-run .edit-mode-only {
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
/* Edit only beside the preset tile in edit mode. */
|
||||
.preset-tile-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
gap: 0.2rem;
|
||||
align-content: stretch;
|
||||
flex-shrink: 0;
|
||||
padding: 0.15rem 0 0.15rem 0.25rem;
|
||||
width: 6.5rem;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preset-editor-modal-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.preset-tile-actions .btn {
|
||||
|
||||
@@ -142,13 +142,6 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
||||
openEditTabModal(tabId, tab);
|
||||
});
|
||||
|
||||
const sendPresetsButton = document.createElement("button");
|
||||
sendPresetsButton.className = "btn btn-secondary btn-small";
|
||||
sendPresetsButton.textContent = "Send Presets";
|
||||
sendPresetsButton.addEventListener("click", async () => {
|
||||
await sendTabPresets(tabId);
|
||||
});
|
||||
|
||||
const cloneButton = document.createElement("button");
|
||||
cloneButton.className = "btn btn-secondary btn-small";
|
||||
cloneButton.textContent = "Clone";
|
||||
@@ -233,7 +226,6 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(applyButton);
|
||||
row.appendChild(sendPresetsButton);
|
||||
if (editMode) {
|
||||
row.appendChild(editButton);
|
||||
row.appendChild(cloneButton);
|
||||
@@ -373,69 +365,6 @@ async function loadTabContent(tabId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Send all presets used by a tab via the /presets/send HTTP endpoint.
|
||||
async function sendTabPresets(tabId) {
|
||||
try {
|
||||
// Load tab data to determine which presets are used
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!tabResponse.ok) {
|
||||
alert('Failed to load tab to send presets.');
|
||||
return;
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
// Extract preset IDs from tab (supports grid, flat, and legacy formats)
|
||||
let presetIds = [];
|
||||
if (Array.isArray(tabData.presets_flat)) {
|
||||
presetIds = tabData.presets_flat;
|
||||
} else if (Array.isArray(tabData.presets)) {
|
||||
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||
// Flat array of IDs
|
||||
presetIds = tabData.presets;
|
||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||
// 2D grid
|
||||
presetIds = tabData.presets.flat();
|
||||
}
|
||||
}
|
||||
presetIds = (presetIds || []).filter(Boolean);
|
||||
|
||||
if (!presetIds.length) {
|
||||
alert('This tab has no presets to send.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Call server-side ESPNow sender with just the IDs; it handles chunking.
|
||||
const payload = { preset_ids: presetIds };
|
||||
if (tabData.default_preset) {
|
||||
payload.default = tabData.default_preset;
|
||||
}
|
||||
const response = await fetch('/presets/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const msg = (data && data.error) || 'Failed to send presets.';
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const sent = typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
|
||||
const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?';
|
||||
alert(`Sent ${sent} preset(s) in ${messages} ESPNow message(s).`);
|
||||
} catch (error) {
|
||||
console.error('Failed to send tab presets:', error);
|
||||
alert('Failed to send tab presets.');
|
||||
}
|
||||
}
|
||||
|
||||
// Send all presets used by all tabs in the current profile via /presets/send.
|
||||
async function sendProfilePresets() {
|
||||
try {
|
||||
|
||||
@@ -179,9 +179,10 @@
|
||||
<input type="number" id="preset-n8-input" min="0" max="255" value="0" class="n-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<div class="modal-actions preset-editor-modal-actions">
|
||||
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
|
||||
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
|
||||
<button type="button" class="btn btn-danger" id="preset-remove-from-tab-btn" hidden>Remove from tab</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>
|
||||
@@ -262,8 +263,13 @@
|
||||
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
|
||||
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
|
||||
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
|
||||
<small>STA channel (1–11) for LED drivers and the serial bridge. Use the same value everywhere.</small>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Save Name</button>
|
||||
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -170,11 +170,26 @@
|
||||
|
||||
<div class="settings-header">
|
||||
<h1>Device Settings</h1>
|
||||
<p>Configure WiFi Access Point settings</p>
|
||||
<p>Configure WiFi Access Point and ESP-NOW options</p>
|
||||
</div>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
|
||||
<!-- ESP-NOW (LED driver / bridge channel) -->
|
||||
<div class="settings-section">
|
||||
<h2>ESP-NOW</h2>
|
||||
<form id="espnow-form">
|
||||
<div class="form-group">
|
||||
<label for="wifi-channel-page-input">WiFi channel (ESP-NOW)</label>
|
||||
<input type="number" id="wifi-channel-page-input" name="wifi_channel" min="1" max="11" required>
|
||||
<small>STA channel (1–11) for LED drivers and the serial bridge. Use the same value on every device.</small>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Save channel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Access Point Settings -->
|
||||
<div class="settings-section">
|
||||
<h2>WiFi Access Point Settings</h2>
|
||||
@@ -222,6 +237,46 @@
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function loadEspnowChannel() {
|
||||
try {
|
||||
const response = await fetch('/settings');
|
||||
const data = await response.json();
|
||||
const chInput = document.getElementById('wifi-channel-page-input');
|
||||
if (chInput && data && typeof data === 'object') {
|
||||
const ch = data.wifi_channel;
|
||||
chInput.value =
|
||||
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading ESP-NOW channel:', error);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('espnow-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const chRaw = document.getElementById('wifi-channel-page-input').value;
|
||||
const wifiChannel = parseInt(chRaw, 10);
|
||||
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
|
||||
showMessage('WiFi channel must be between 1 and 11', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/settings/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wifi_channel: wifiChannel }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
showMessage('ESP-NOW channel saved.', 'success');
|
||||
} else {
|
||||
showMessage(`Error: ${result.error || 'Failed to save'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Load AP status and config
|
||||
async function loadAPStatus() {
|
||||
try {
|
||||
@@ -299,6 +354,7 @@
|
||||
});
|
||||
|
||||
// Load all data on page load
|
||||
loadEspnowChannel();
|
||||
loadAPStatus();
|
||||
|
||||
// Refresh status every 10 seconds
|
||||
|
||||
Reference in New Issue
Block a user