From d1ffb857c849bcc115d76570ba49c684a6a86fc2 Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 6 Apr 2026 00:22:00 +1200 Subject: [PATCH] feat(ui): devices tcp status, tabs send, preset websocket hooks Made-with: Cursor --- src/static/devices.js | 202 ++++++++++++++++++- src/static/presets.js | 161 ++++++++++----- src/static/style.css | 88 ++++++++ src/static/tabs.js | 424 +++++++++++++++++++++++++++++++-------- src/templates/index.html | 20 +- 5 files changed, 743 insertions(+), 152 deletions(-) diff --git a/src/static/devices.js b/src/static/devices.js index 15e06cb..3bb5b78 100644 --- a/src/static/devices.js +++ b/src/static/devices.js @@ -2,6 +2,100 @@ 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 = ''; @@ -75,6 +169,9 @@ function getAddressForPayload(transport) { async function loadDevicesModal() { const container = document.getElementById('devices-list-modal'); if (!container) return; + if (typeof window.getEspnowSocket === 'function') { + window.getEspnowSocket(); + } container.innerHTML = 'Loading...'; try { const response = await fetch('/devices', { headers: { Accept: 'application/json' } }); @@ -101,31 +198,82 @@ function renderDevicesList(devices) { } 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 t = (dev && dev.type) || 'led'; - const tr = (dev && dev.transport) || 'espnow'; - const addr = (dev && dev.address) ? dev.address : '—'; - meta.textContent = `${t} · ${tr} · ${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'; @@ -144,12 +292,18 @@ 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) { @@ -201,6 +355,29 @@ async function updateDevice(devId, name, type, transport, address) { } document.addEventListener('DOMContentLoaded', () => { + 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'); @@ -220,11 +397,26 @@ 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'] }); } if (editForm) { diff --git a/src/static/presets.js b/src/static/presets.js index 768886f..674513d 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -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) => { +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 tab (ESP-NOW or Wi-Fi). +const sendSelectForCurrentTabDevices = async (presetId, sectionEl) => { const section = sectionEl || document.querySelector('.presets-section[data-tab-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', () => { @@ -812,10 +861,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'); @@ -1222,10 +1271,7 @@ document.addEventListener('DOMContentLoaded', () => { } // 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) - : []; + 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. @@ -1241,13 +1287,10 @@ document.addEventListener('DOMContentLoaded', () => { 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 deviceNames = tabDeviceNamesFromSection(section); const presetId = currentEditId || payload.name; await updateTabDefaultPreset(presetId); - sendDefaultPreset(presetId, deviceNames); + await sendDefaultPreset(presetId, deviceNames); }); } @@ -1285,20 +1328,20 @@ 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(); @@ -1340,7 +1383,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,55 +1423,69 @@ 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. try { window.sendPresetViaEspNow = sendPresetViaEspNow; + window.postDriverSequence = postDriverSequence; // Expose a generic ESPNow sender so other scripts (tabs.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. } @@ -1756,7 +1813,9 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => { button.classList.add('active'); selectedPresets[tabId] = presetId; const section = row.closest('.presets-section'); - sendSelectForCurrentTabDevices(presetId, section); + sendSelectForCurrentTabDevices(presetId, section).catch((err) => { + console.error(err); + }); }); if (canDrag) { @@ -1926,6 +1985,12 @@ 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]'); diff --git a/src/static/style.css b/src/static/style.css index 540ad84..4a74605 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -53,6 +53,12 @@ input.hex-addr-box { 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; @@ -601,6 +607,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; @@ -1034,6 +1063,65 @@ body.preset-ui-run .edit-mode-only { background-color: #3a3a3a; border-radius: 4px; } + +.tab-modal-create-row { + flex-wrap: wrap; + align-items: center; +} + +.tab-modal-create-row input[type="text"] { + flex: 1; + min-width: 8rem; +} + +.tab-devices-label { + display: block; + margin-top: 0.75rem; + margin-bottom: 0.35rem; + font-weight: 600; +} + +.tab-devices-editor { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.5rem; + max-height: 14rem; + overflow-y: auto; +} + +.tab-device-row-label { + flex: 1; + min-width: 0; +} + +.tab-device-add-select { + flex: 1; + min-width: 10rem; + padding: 0.5rem; + background-color: #3a3a3a; + border: 1px solid #4a4a4a; + border-radius: 4px; + color: white; +} + +.tab-devices-add { + margin-top: 0; + flex-wrap: wrap; +} + +.tab-presets-section-label { + display: block; + margin-top: 1rem; + margin-bottom: 0.35rem; + font-weight: 600; +} + +.edit-tab-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 */ diff --git a/src/static/tabs.js b/src/static/tabs.js index 9d25fe7..a22bada 100644 --- a/src/static/tabs.js +++ b/src/static/tabs.js @@ -18,6 +18,147 @@ function getCurrentTabFromCookie() { 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 tab device names (order matches tab names; skips unknown names). */ +async function resolveTabDeviceMacs(tabNames) { + const dm = await fetchDevicesMap(); + const rows = namesToRows(Array.isArray(tabNames) ? tabNames : [], dm); + const macs = rows.map((r) => r.mac).filter(Boolean); + return [...new Set(macs)]; +} + +function namesToRows(tabNames, devicesMap) { + const usedMacs = new Set(); + const list = Array.isArray(tabNames) ? tabNames : []; + 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 renderTabDevicesEditor(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 = "tab-device-row profiles-row"; + const label = document.createElement("span"); + label.className = "tab-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); + renderTabDevicesEditor(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 = "tab-devices-add profiles-actions"; + const sel = document.createElement("select"); + sel.className = "tab-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 = ""; + renderTabDevicesEditor(containerEl, rows, devicesMap); + }); + addWrap.appendChild(sel); + addWrap.appendChild(addBtn); + containerEl.appendChild(addWrap); +} + +/** Default device name list when creating a tab (refined in Edit tab). */ +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 tab 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; + +function escapeHtmlAttr(s) { + return String(s) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/ { - openEditTabModal(tabId, tab); + editButton.addEventListener("click", async () => { + await openEditTabModal(tabId, tab); }); const cloneButton = document.createElement("button"); @@ -319,9 +460,12 @@ async function loadTabContent(tabId) { // Render tab content (presets section) const tabName = tab.name || `Tab ${tabId}`; - const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : ''; + const names = Array.isArray(tab.names) ? tab.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 = ` -
+
@@ -433,10 +577,15 @@ async function sendProfilePresets() { continue; } tabsWithPresets += 1; + const tabNames = Array.isArray(tabData.names) ? tabData.names : []; + const targets = await resolveTabDeviceMacs(tabNames); 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: { @@ -464,94 +613,187 @@ async function sendProfilePresets() { } const messagesLabel = totalMessages ? totalMessages : '?'; - alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`); + alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) (${messagesLabel} driver send(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 = 'Loading…'; +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 tab (remove) and presets available to add (select). +async function refreshEditTabPresetsUi(tabId) { + const currentEl = document.getElementById("edit-tab-presets-current"); + const addEl = document.getElementById("edit-tab-presets-list"); + if (!tabId || !currentEl || !addEl) return; + + currentEl.innerHTML = 'Loading…'; + addEl.innerHTML = 'Loading…'; + try { - const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' } }); + const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: "application/json" } }); if (!tabRes.ok) { - listEl.innerHTML = 'Failed to load presets.'; + const msg = 'Failed to load tab presets.'; + currentEl.innerHTML = msg; + addEl.innerHTML = msg; 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 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 = 'No presets on this tab yet.'; + } 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 tab?\n\n${name}`)) return; + await window.removePresetFromTab(tabId, presetId); + await refreshEditTabPresetsUi(tabId); + }); + row.appendChild(label); + row.appendChild(removeBtn); + currentEl.appendChild(row); } } - 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 = ''; + const availableToAdd = allIds.filter((id) => !inTabSet.has(String(id))); + addEl.innerHTML = ""; if (availableToAdd.length === 0) { - listEl.innerHTML = 'No presets to add. All presets are already in this tab.'; - 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') { + addEl.innerHTML = + 'No presets to add. All presets are already on this tab.'; + } else { + const addWrap = document.createElement("div"); + addWrap.className = "tab-devices-add profiles-actions"; + const sel = document.createElement("select"); + sel.className = "tab-device-add-select"; + sel.setAttribute("aria-label", "Preset to add to this tab"); + 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, tabId); - await populateEditTabPresetsList(tabId); + sel.value = ""; + await refreshEditTabPresetsUi(tabId); } }); - row.appendChild(label); - row.appendChild(selectBtn); - listEl.appendChild(row); + addWrap.appendChild(sel); + addWrap.appendChild(addBtn); + addEl.appendChild(addWrap); } } catch (e) { - console.error('populateEditTabPresetsList:', e); - listEl.innerHTML = 'Failed to load presets.'; + console.error("refreshEditTabPresetsUi:", e); + const msg = 'Failed to load presets.'; + currentEl.innerHTML = msg; + addEl.innerHTML = msg; } } +async function populateEditTabPresetsList(tabId) { + await refreshEditTabPresetsUi(tabId); +} + // 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'); - +async 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 editor = document.getElementById("edit-tab-devices-editor"); + + let tabData = tab; + if (!tabData || typeof tabData !== "object" || tabData.error) { + try { + const response = await fetch(`/tabs/${tabId}`); + if (response.ok) { + tabData = await response.json(); + } + } catch (e) { + console.error("openEditTabModal fetch tab:", e); + } + } + tabData = tabData || {}; + 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); + if (nameInput) nameInput.value = tabData.name || ""; + + const devicesMap = await fetchDevicesMap(); + const tabNames = + Array.isArray(tabData.names) && tabData.names.length > 0 ? tabData.names : ["1"]; + window.__editTabDeviceRows = namesToRows(tabNames, devicesMap); + renderTabDevicesEditor(editor, window.__editTabDeviceRows, devicesMap); + + if (modal) modal.classList.add("active"); + await refreshEditTabPresetsUi(tabId); +} + +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 tab -async function updateTab(tabId, name, ids) { +async function updateTab(tabId, name, namesOrString) { try { - const names = ids ? ids.split(',').map(id => id.trim()) : ['1']; + let names = normalizeTabNamesArg(namesOrString); + if (!names.length) names = ["1"]; const response = await fetch(`/tabs/${tabId}`, { method: 'PUT', headers: { @@ -583,9 +825,10 @@ async function updateTab(tabId, name, ids) { } // Create a new tab -async function createTab(name, ids) { +async function createTab(name, namesOrString) { try { - const names = ids ? ids.split(',').map(id => id.trim()) : ['1']; + let names = normalizeTabNamesArg(namesOrString); + if (!names.length) names = ["1"]; const response = await fetch('/tabs', { method: 'POST', headers: { @@ -627,14 +870,13 @@ document.addEventListener('DOMContentLoaded', () => { 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'); - + const newTabNameInput = document.getElementById("new-tab-name"); + const createTabButton = document.getElementById("create-tab-btn"); + if (tabsButton && tabsModal) { - tabsButton.addEventListener('click', () => { - tabsModal.classList.add('active'); - loadTabsModal(); + tabsButton.addEventListener("click", async () => { + tabsModal.classList.add("active"); + await loadTabsModal(); }); } @@ -659,7 +901,7 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch(`/tabs/${tabId}`); if (response.ok) { const tab = await response.json(); - openEditTabModal(tabId, tab); + await openEditTabModal(tabId, tab); } else { alert('Failed to load tab for editing'); } @@ -673,12 +915,11 @@ document.addEventListener('DOMContentLoaded', () => { 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'; + const deviceNames = await defaultDeviceNamesForNewTab(); + await createTab(name, deviceNames); + if (newTabNameInput) newTabNameInput.value = ""; } }; @@ -697,18 +938,22 @@ document.addEventListener('DOMContentLoaded', () => { // Set up edit tab form const editTabForm = document.getElementById('edit-tab-form'); if (editTabForm) { - editTabForm.addEventListener('submit', async (e) => { + 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 idInput = document.getElementById("edit-tab-id"); + const nameInput = document.getElementById("edit-tab-name"); + const tabId = idInput ? idInput.value : null; - const name = nameInput ? nameInput.value.trim() : ''; - const ids = idsInput ? idsInput.value.trim() : '1'; - + const name = nameInput ? nameInput.value.trim() : ""; + const rows = window.__editTabDeviceRows || []; + const deviceNames = rowsToNames(rows); + if (tabId && name) { - await updateTab(tabId, name, ids); + if (deviceNames.length === 0) { + alert("Add at least one device."); + return; + } + await updateTab(tabId, name, deviceNames); editTabForm.reset(); } }); @@ -726,7 +971,7 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { btn.addEventListener('click', async () => { await loadTabs(); - if (tabsModal && tabsModal.classList.contains('active')) { + if (tabsModal && tabsModal.classList.contains("active")) { await loadTabsModal(); } }); @@ -741,5 +986,6 @@ window.tabsManager = { createTab, updateTab, openEditTabModal, - getCurrentTabId: () => currentTabId + resolveTabDeviceMacs, + getCurrentTabId: () => currentTabId, }; diff --git a/src/templates/index.html b/src/templates/index.html index 01444cc..4bc894b 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -16,7 +16,7 @@
- + @@ -30,7 +30,7 @@