diff --git a/.gitignore b/.gitignore index 9040ecc..a4f92ab 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ ENV/ Thumbs.db # Project specific +docs/.help-print.html settings.json *.log *.db diff --git a/README.md b/README.md index 80f934d..31804cf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/db/tab.json b/db/tab.json index c7cea31..636c57b 100644 --- a/db/tab.json +++ b/db/tab.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/docs/help.md b/docs/help.md new file mode 100644 index 0000000..fdf26ff --- /dev/null +++ b/docs/help.md @@ -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. diff --git a/docs/help.pdf b/docs/help.pdf new file mode 100644 index 0000000..84e979c Binary files /dev/null and b/docs/help.pdf differ diff --git a/docs/images/help/colour-palette.svg b/docs/images/help/colour-palette.svg new file mode 100644 index 0000000..6f352d7 --- /dev/null +++ b/docs/images/help/colour-palette.svg @@ -0,0 +1,14 @@ + diff --git a/docs/images/help/header-toolbar.svg b/docs/images/help/header-toolbar.svg new file mode 100644 index 0000000..c727a74 --- /dev/null +++ b/docs/images/help/header-toolbar.svg @@ -0,0 +1,24 @@ + diff --git a/docs/images/help/mobile-menu.svg b/docs/images/help/mobile-menu.svg new file mode 100644 index 0000000..6f63cfa --- /dev/null +++ b/docs/images/help/mobile-menu.svg @@ -0,0 +1,26 @@ + diff --git a/docs/images/help/preset-editor.svg b/docs/images/help/preset-editor.svg new file mode 100644 index 0000000..9c84583 --- /dev/null +++ b/docs/images/help/preset-editor.svg @@ -0,0 +1,31 @@ + diff --git a/docs/images/help/tab-preset-strip.svg b/docs/images/help/tab-preset-strip.svg new file mode 100644 index 0000000..ebdce32 --- /dev/null +++ b/docs/images/help/tab-preset-strip.svg @@ -0,0 +1,35 @@ + diff --git a/scripts/build_help_pdf.sh b/scripts/build_help_pdf.sh new file mode 100755 index 0000000..30a75ea --- /dev/null +++ b/scripts/build_help_pdf.sh @@ -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)" diff --git a/scripts/help-pdf.css b/scripts/help-pdf.css new file mode 100644 index 0000000..88632ea --- /dev/null +++ b/scripts/help-pdf.css @@ -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; +} diff --git a/src/static/help.js b/src/static/help.js index ff65644..18cf36b 100644 --- a/src/static/help.js +++ b/src/static/help.js @@ -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'); } diff --git a/src/static/presets.js b/src/static/presets.js index 2de75bb..768886f 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -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); } diff --git a/src/static/style.css b/src/static/style.css index c639da5..1e13a6f 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -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 { diff --git a/src/static/tabs.js b/src/static/tabs.js index 9434988..9d25fe7 100644 --- a/src/static/tabs.js +++ b/src/static/tabs.js @@ -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 { diff --git a/src/templates/index.html b/src/templates/index.html index 907ea74..b984799 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -179,9 +179,10 @@ -
name.local) and UI display.
Configure WiFi Access Point settings
+Configure WiFi Access Point and ESP-NOW options