// Device registry: name, id (storage key), type (led), transport (wifi|espnow), address const HEX_BOX_COUNT = 12; /** Last TCP snapshot from WebSocket (so we can apply after async list render). */ let lastTcpSnapshotIps = null; /** Match server-side ``normalize_tcp_peer_ip`` for WS events vs registry rows. */ function normalizeWifiAddressForMatch(addr) { let s = String(addr || '').trim(); if (s.toLowerCase().startsWith('::ffff:')) { s = s.slice(7); } return s; } const DEVICES_MODAL_POLL_MS = 1000; let devicesModalLiveTimer = null; function stopDevicesModalLiveRefresh() { if (devicesModalLiveTimer != null) { clearInterval(devicesModalLiveTimer); devicesModalLiveTimer = null; } } /** * Refetch registry and re-render the list (no loading spinner). Keeps scroll position. * Used while the devices modal stays open so new TCP devices, renames, and removals appear live. */ async function refreshDevicesListQuiet() { const modal = document.getElementById('devices-modal'); if (!modal || !modal.classList.contains('active')) return; const container = document.getElementById('devices-list-modal'); if (!container) return; const prevTop = container.scrollTop; try { const res = await fetch('/devices', { headers: { Accept: 'application/json' } }); if (!res.ok) return; const data = await res.json(); renderDevicesList(data || {}); container.scrollTop = prevTop; } catch (_) { /* ignore */ } } function startDevicesModalLiveRefresh() { stopDevicesModalLiveRefresh(); devicesModalLiveTimer = setInterval(() => { refreshDevicesListQuiet(); }, DEVICES_MODAL_POLL_MS); } function updateWifiRowDot(row, connected) { const dot = row.querySelector('.device-status-dot'); if (!dot) return; if ((row.dataset.deviceTransport || '') !== 'wifi') return; dot.classList.remove('device-status-dot--online', 'device-status-dot--offline', 'device-status-dot--unknown'); if (connected) { dot.classList.add('device-status-dot--online'); dot.title = 'Connected (Wi-Fi TCP session)'; } else { dot.classList.add('device-status-dot--offline'); dot.title = 'Not connected (no Wi-Fi TCP session)'; } dot.setAttribute('aria-label', dot.title); } function applyTcpSnapshot(ips) { const set = new Set( (ips || []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean), ); const container = document.getElementById('devices-list-modal'); if (!container) return; container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => { const addr = normalizeWifiAddressForMatch(row.dataset.deviceAddress); updateWifiRowDot(row, set.has(addr)); }); } /** Keep cached snapshot aligned with incremental WS events (connect/disconnect). */ function mergeTcpSnapshotPresence(ip, connected) { const n = normalizeWifiAddressForMatch(ip); if (!n) return; const prev = lastTcpSnapshotIps; const set = new Set( (Array.isArray(prev) ? prev : []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean), ); if (connected) { set.add(n); } else { set.delete(n); } lastTcpSnapshotIps = Array.from(set); } function makeHexAddressBoxes(container) { if (!container || container.querySelector('.hex-addr-box')) return; container.innerHTML = ''; for (let i = 0; i < HEX_BOX_COUNT; i++) { const input = document.createElement('input'); input.type = 'text'; input.className = 'hex-addr-box'; input.maxLength = 1; input.autocomplete = 'off'; input.setAttribute('data-index', i); input.setAttribute('inputmode', 'numeric'); input.setAttribute('aria-label', `Hex digit ${i + 1}`); input.addEventListener('input', (e) => { const v = e.target.value.replace(/[^0-9a-fA-F]/g, ''); e.target.value = v; if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) { e.target.nextElementSibling.focus(); } }); input.addEventListener('keydown', (e) => { if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) { e.target.previousElementSibling.focus(); } }); input.addEventListener('paste', (e) => { e.preventDefault(); const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT); const boxes = container.querySelectorAll('.hex-addr-box'); for (let j = 0; j < pasted.length && j < boxes.length; j++) { boxes[j].value = pasted[j]; } if (pasted.length > 0) { const nextIdx = Math.min(pasted.length, boxes.length - 1); boxes[nextIdx].focus(); } }); container.appendChild(input); } } function setAddressToBoxes(container, addrStr) { if (!container) return; const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT); const boxes = container.querySelectorAll('.hex-addr-box'); boxes.forEach((b, i) => { b.value = s[i] || ''; }); } function applyTransportVisibility(transport) { const isWifi = transport === 'wifi'; const esp = document.getElementById('edit-device-address-espnow'); const wifiWrap = document.getElementById('edit-device-address-wifi-wrap'); const drvWrap = document.getElementById('edit-device-wifi-driver-wrap'); if (esp) esp.hidden = isWifi; if (wifiWrap) wifiWrap.hidden = !isWifi; if (drvWrap) drvWrap.hidden = !isWifi; } function getAddressForPayload(transport) { if (transport === 'wifi') { const el = document.getElementById('edit-device-address-wifi'); const v = (el && el.value.trim()) || ''; return v || null; } const boxEl = document.getElementById('edit-device-address-boxes'); if (!boxEl) return null; const boxes = boxEl.querySelectorAll('.hex-addr-box'); const hex = Array.from(boxes).map((b) => b.value).join('').toLowerCase(); return hex || null; } function collectDeviceEditPayload() { const idInput = document.getElementById('edit-device-id'); const nameInput = document.getElementById('edit-device-name'); const typeSel = document.getElementById('edit-device-type'); const transportSel = document.getElementById('edit-device-transport'); const devId = idInput && idInput.value; const transport = (transportSel && transportSel.value) || 'espnow'; const address = getAddressForPayload(transport); const obr = document.getElementById('edit-device-output-brightness'); let output_brightness = 255; if (obr && obr.value !== '') { const n = parseInt(obr.value, 10); output_brightness = !Number.isNaN(n) ? Math.max(0, Math.min(255, n)) : 255; } const payload = { name: nameInput ? nameInput.value.trim() : '', type: (typeSel && typeSel.value) || 'led', transport, address, output_brightness, }; if (transport === 'wifi') { const dn = document.getElementById('edit-device-wifi-driver-name'); const nl = document.getElementById('edit-device-wifi-num-leds'); const co = document.getElementById('edit-device-wifi-color-order'); const ws = document.getElementById('edit-device-wifi-startup-mode'); if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim(); if (nl && nl.value !== '') { const n = parseInt(nl.value, 10); if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n; } if (co && co.value) payload.wifi_color_order = co.value; if (ws && ws.value) payload.wifi_startup_mode = ws.value; } return { devId, payload }; } function refreshEditDeviceDebug() { const ta = document.getElementById('edit-device-debug'); if (!ta) return; try { const { devId, payload } = collectDeviceEditPayload(); const loaded = window.__editDeviceLoadedSnapshot; ta.value = JSON.stringify( { device_id: devId || null, loaded_from_server: loaded != null ? loaded : null, save_payload_preview: payload, }, null, 2, ); } catch (e) { ta.value = String(e); } } 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' } }); if (!response.ok) throw new Error('Failed to load devices'); const devices = await response.json(); renderDevicesList(devices || {}); } catch (e) { console.error('loadDevicesModal:', e); container.innerHTML = 'Failed to load devices.'; } } function renderDevicesList(devices) { const container = document.getElementById('devices-list-modal'); if (!container) return; container.innerHTML = ''; const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object'); if (ids.length === 0) { const p = document.createElement('p'); p.className = 'muted-text'; p.textContent = 'No devices yet. Wi-Fi drivers will appear here when they connect over TCP.'; container.appendChild(p); return; } ids.forEach((devId) => { const dev = devices[devId]; const t = (dev && dev.type) || 'led'; const tr = (dev && dev.transport) || 'espnow'; const addrRaw = (dev && dev.address) != null ? String(dev.address).trim() : ''; const addrDisplay = addrRaw || '—'; const row = document.createElement('div'); row.className = 'profiles-row'; row.style.display = 'flex'; row.style.alignItems = 'center'; row.style.gap = '0.5rem'; row.style.flexWrap = 'wrap'; row.dataset.deviceId = devId; row.dataset.deviceTransport = tr; row.dataset.deviceAddress = addrRaw; const dot = document.createElement('span'); dot.className = 'device-status-dot'; dot.setAttribute('role', 'img'); const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null; if (live === true) { dot.classList.add('device-status-dot--online'); dot.title = 'Connected (Wi-Fi TCP session)'; dot.setAttribute('aria-label', dot.title); } else if (live === false) { dot.classList.add('device-status-dot--offline'); dot.title = 'Not connected (no Wi-Fi TCP session)'; dot.setAttribute('aria-label', dot.title); } else { dot.classList.add('device-status-dot--unknown'); dot.title = 'ESP-NOW — TCP status does not apply'; dot.setAttribute('aria-label', dot.title); } const label = document.createElement('span'); label.textContent = (dev && dev.name) || devId; label.style.flex = '1'; label.style.minWidth = '100px'; const macEl = document.createElement('code'); macEl.className = 'device-row-mac'; macEl.textContent = devId; macEl.title = 'MAC (registry id)'; const meta = document.createElement('span'); meta.className = 'muted-text'; meta.style.fontSize = '0.85em'; meta.textContent = `${t} · ${tr} · ${addrDisplay}`; const editBtn = document.createElement('button'); editBtn.className = 'btn btn-secondary btn-small'; editBtn.textContent = 'Edit'; editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev)); const identifyBtn = document.createElement('button'); identifyBtn.className = 'btn btn-primary btn-small'; identifyBtn.type = 'button'; identifyBtn.textContent = 'Identify'; identifyBtn.title = 'Red blink at 10 Hz (~50% brightness) for 2 s, then off (not saved as a preset)'; identifyBtn.addEventListener('click', async () => { try { const res = await fetch(`/devices/${encodeURIComponent(devId)}/identify`, { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json' }, }); const data = await res.json().catch(() => ({})); if (!res.ok) { alert(data.error || 'Identify failed'); return; } } catch (err) { console.error(err); alert('Identify failed'); } }); const deleteBtn = document.createElement('button'); deleteBtn.className = 'btn btn-secondary btn-small'; deleteBtn.textContent = 'Delete'; deleteBtn.addEventListener('click', async () => { if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return; try { const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'DELETE' }); if (res.ok) await loadDevicesModal(); else { const data = await res.json().catch(() => ({})); alert(data.error || 'Delete failed'); } } catch (err) { console.error(err); alert('Delete failed'); } }); 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) { try { window.__editDeviceLoadedSnapshot = dev ? JSON.parse(JSON.stringify(dev)) : null; } catch (e) { window.__editDeviceLoadedSnapshot = dev || null; } const modal = document.getElementById('edit-device-modal'); const idInput = document.getElementById('edit-device-id'); const storageLabel = document.getElementById('edit-device-storage-id'); const nameInput = document.getElementById('edit-device-name'); const typeSel = document.getElementById('edit-device-type'); const transportSel = document.getElementById('edit-device-transport'); const addressBoxes = document.getElementById('edit-device-address-boxes'); const wifiInput = document.getElementById('edit-device-address-wifi'); if (!modal || !idInput) return; idInput.value = devId; if (storageLabel) storageLabel.textContent = devId; if (nameInput) nameInput.value = (dev && dev.name) || ''; if (typeSel) typeSel.value = (dev && dev.type) || 'led'; const tr = (dev && dev.transport) || 'espnow'; if (transportSel) transportSel.value = tr; applyTransportVisibility(tr); setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : ''); if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : ''; const wName = document.getElementById('edit-device-wifi-driver-name'); const wLeds = document.getElementById('edit-device-wifi-num-leds'); const wCo = document.getElementById('edit-device-wifi-color-order'); const wStart = document.getElementById('edit-device-wifi-startup-mode'); if (wName) { const savedDisp = dev && Object.prototype.hasOwnProperty.call(dev, 'wifi_driver_display_name') ? dev.wifi_driver_display_name : undefined; if (savedDisp != null && String(savedDisp).trim() !== '') { wName.value = String(savedDisp).trim(); } else { wName.value = dev && dev.name ? String(dev.name) : ''; } } if (wLeds) { wLeds.value = dev && dev.wifi_driver_num_leds != null && dev.wifi_driver_num_leds !== '' ? String(dev.wifi_driver_num_leds) : ''; } if (wCo) { const co = (dev && dev.wifi_color_order) || 'rgb'; wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase()) ? String(co).toLowerCase() : 'rgb'; } if (wStart) { const sm = (dev && dev.wifi_startup_mode) || 'default'; wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase()) ? String(sm).toLowerCase() : 'default'; } const obr = document.getElementById('edit-device-output-brightness'); const obv = document.getElementById('edit-device-output-brightness-value'); if (obr) { let bv = 255; if (dev && dev.output_brightness != null && dev.output_brightness !== '') { const n = parseInt(String(dev.output_brightness), 10); if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n)); } obr.value = String(bv); if (obv) obv.textContent = String(bv); } refreshEditDeviceDebug(); modal.classList.add('active'); } async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) { try { const payload = { name, type: type || 'led', transport: transport || 'espnow', address, }; if (typeof outputBrightness === 'number') { payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness))); } if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') { if (wifiDriverFields.wifi_driver_display_name != null) { payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name; } if (wifiDriverFields.wifi_driver_num_leds != null) { payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds; } if (wifiDriverFields.wifi_color_order != null) { payload.wifi_color_order = wifiDriverFields.wifi_color_order; } if (wifiDriverFields.wifi_startup_mode != null) { payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode; } } const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (res.ok) { await loadDevicesModal(); return true; } alert(data.error || 'Failed to update device'); return false; } catch (e) { console.error('updateDevice:', e); alert('Failed to update device'); return false; } } async function pushWifiDriverConfig(devId, fields) { const push = {}; if (fields.name != null && String(fields.name).trim()) push.name = String(fields.name).trim(); if (fields.num_leds != null && fields.num_leds !== '') { const n = parseInt(String(fields.num_leds), 10); if (!Number.isNaN(n) && n >= 1) push.num_leds = n; } if (fields.color_order != null && String(fields.color_order).trim()) { push.color_order = String(fields.color_order).trim().toLowerCase(); } if (fields.startup_mode != null && String(fields.startup_mode).trim()) { const sm = String(fields.startup_mode).trim().toLowerCase(); if (sm === 'default' || sm === 'last' || sm === 'off') push.startup_mode = sm; } if (Object.keys(push).length === 0) return { ok: true, skipped: true }; try { const res = await fetch(`/devices/${encodeURIComponent(devId)}/driver-config`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(push), }); const data = await res.json().catch(() => ({})); if (!res.ok) { alert(data.error || 'Could not send settings to the driver (is it connected?)'); return { ok: false }; } return { ok: true }; } catch (e) { console.error('pushWifiDriverConfig:', e); alert('Could not send settings to the driver'); return { ok: false }; } } 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 devOutBr = document.getElementById('edit-device-output-brightness'); const devOutBrVal = document.getElementById('edit-device-output-brightness-value'); if (devOutBr && devOutBrVal) { devOutBr.addEventListener('input', () => { devOutBrVal.textContent = devOutBr.value; }); } const transportEdit = document.getElementById('edit-device-transport'); if (transportEdit) { transportEdit.addEventListener('change', () => { applyTransportVisibility(transportEdit.value); refreshEditDeviceDebug(); }); } const devicesBtn = document.getElementById('devices-btn'); const devicesModal = document.getElementById('devices-modal'); const devicesCloseBtn = document.getElementById('devices-close-btn'); const editForm = document.getElementById('edit-device-form'); const editCloseBtn = document.getElementById('edit-device-close-btn'); const editDeviceModal = document.getElementById('edit-device-modal'); if (devicesBtn && devicesModal) { devicesBtn.addEventListener('click', () => { devicesModal.classList.add('active'); if (typeof window.getEspnowSocket === 'function') { window.getEspnowSocket(); } loadDevicesModal(); startDevicesModalLiveRefresh(); }); } if (devicesCloseBtn) { 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) { editForm.addEventListener('input', () => refreshEditDeviceDebug()); editForm.addEventListener('change', () => refreshEditDeviceDebug()); editForm.addEventListener('submit', async (e) => { e.preventDefault(); const { devId, payload } = collectDeviceEditPayload(); if (!devId) return; const transport = payload.transport || 'espnow'; let wifiDriverFields = null; if (transport === 'wifi') { wifiDriverFields = {}; if (payload.wifi_driver_display_name != null) { wifiDriverFields.wifi_driver_display_name = payload.wifi_driver_display_name; } if (payload.wifi_driver_num_leds != null) { wifiDriverFields.wifi_driver_num_leds = payload.wifi_driver_num_leds; } if (payload.wifi_color_order != null) { wifiDriverFields.wifi_color_order = payload.wifi_color_order; } if (payload.wifi_startup_mode != null) { wifiDriverFields.wifi_startup_mode = payload.wifi_startup_mode; } } const ok = await updateDevice( devId, payload.name, payload.type, transport, payload.address, wifiDriverFields, payload.output_brightness, ); if (!ok) return; try { const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); if (!brRes.ok && brRes.status !== 503) { const brData = await brRes.json().catch(() => ({})); console.warn('brightness push:', brData.error || brRes.status); } } catch (e) { console.warn('brightness push failed', e); } if (transport === 'wifi' && wifiDriverFields) { const dn = document.getElementById('edit-device-wifi-driver-name'); const nl = document.getElementById('edit-device-wifi-num-leds'); const co = document.getElementById('edit-device-wifi-color-order'); const ws = document.getElementById('edit-device-wifi-startup-mode'); const pushRes = await pushWifiDriverConfig(devId, { name: dn ? dn.value : '', num_leds: nl ? nl.value : '', color_order: co ? co.value : '', startup_mode: ws ? ws.value : '', }); if (!pushRes.ok) return; } editDeviceModal.classList.remove('active'); }); } if (editCloseBtn) { editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active')); } });