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

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