feat(ui): devices tcp status, tabs send, preset websocket hooks
Made-with: Cursor
This commit is contained in:
@@ -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]');
|
||||
|
||||
Reference in New Issue
Block a user