// 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'));
}
});