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 = ` -