feat(ui): devices tcp status, tabs send, preset websocket hooks

Made-with: Cursor
This commit is contained in:
pi
2026-04-06 00:22:00 +12:00
parent f8eba0ee7e
commit d1ffb857c8
5 changed files with 743 additions and 152 deletions

View File

@@ -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 = '<span class="muted-text">Loading...</span>';
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) {

View File

@@ -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]');

View File

@@ -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 */

View File

@@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
}
// Load tabs list
async function loadTabs() {
try {
@@ -138,8 +279,8 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
const editButton = document.createElement("button");
editButton.className = "btn btn-secondary btn-small";
editButton.textContent = "Edit";
editButton.addEventListener("click", () => {
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 = `
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
<div class="presets-section" data-tab-id="${tabId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}>
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
<div class="tab-brightness-group">
<label for="tab-brightness-slider">Brightness</label>
@@ -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 = '<span class="muted-text">Loading…</span>';
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 = '<span class="muted-text">Loading…</span>';
addEl.innerHTML = '<span class="muted-text">Loading…</span>';
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 = '<span class="muted-text">Failed to load presets.</span>';
const msg = '<span class="muted-text">Failed to load tab presets.</span>';
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 = '<span class="muted-text">No presets on this tab yet.</span>';
} 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 = '<span class="muted-text">No presets to add. All presets are already in this tab.</span>';
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 =
'<span class="muted-text">No presets to add. All presets are already on this tab.</span>';
} 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 = '<span class="muted-text">Failed to load presets.</span>';
console.error("refreshEditTabPresetsUi:", e);
const msg = '<span class="muted-text">Failed to load presets.</span>';
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,
};

View File

@@ -16,7 +16,7 @@
</div>
<div class="header-actions">
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
@@ -30,7 +30,7 @@
<div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
@@ -54,9 +54,8 @@
<div id="tabs-modal" class="modal">
<div class="modal-content">
<h2>Tabs</h2>
<div class="profiles-actions">
<div class="profiles-actions tab-modal-create-row">
<input type="text" id="new-tab-name" placeholder="Tab name">
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1">
<button class="btn btn-primary" id="create-tab-btn">Create</button>
</div>
<div id="tabs-list-modal" class="profiles-list"></div>
@@ -78,10 +77,12 @@
</div>
<label>Tab Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
<label>Device IDs (comma-separated):</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
<label style="margin-top: 1rem;">Add presets to this tab</label>
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
<label class="tab-devices-label">Devices in this tab</label>
<div id="edit-tab-devices-editor" class="tab-devices-editor"></div>
<label class="tab-presets-section-label">Presets on this tab</label>
<div id="edit-tab-presets-current" class="profiles-list edit-tab-presets-scroll"></div>
<label class="tab-presets-section-label">Add presets to this tab</label>
<div id="edit-tab-presets-list" class="profiles-list edit-tab-presets-scroll"></div>
</form>
</div>
</div>
@@ -111,7 +112,6 @@
<div id="devices-modal" class="modal">
<div class="modal-content">
<h2>Devices</h2>
<p class="muted-text">Wi-Fi LED drivers register over TCP when each hello line includes <code>device_name</code> and <code>mac</code> (12 hex). The registry key is the <strong>MAC</strong>; <strong>name</strong> is used in tabs and <code>select</code> (several devices may share the same name).</p>
<div id="devices-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
@@ -368,9 +368,9 @@
<script src="/static/help.js"></script>
<script src="/static/color_palette.js"></script>
<script src="/static/profiles.js"></script>
<script src="/static/devices.js"></script>
<script src="/static/tab_palette.js"></script>
<script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script>
<script src="/static/devices.js"></script>
</body>
</html>