feat(bridge): add wifi/serial bridge runtime and UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 00:38:21 +12:00
parent 2cf019079e
commit 78dc8ffc77
92 changed files with 5679 additions and 1790 deletions

View File

@@ -18,6 +18,15 @@ const DEVICES_MODAL_POLL_MS = 1000;
let devicesModalLiveTimer = null;
/** Last ESP-NOW ping result per MAC (hex, no separators). Cleared only when a new ping marks offline. */
const espnowPingStatusByMac = new Map();
/** Aggregate ping dot state (Devices / Settings ping buttons). */
let lastEspnowPingAggregate = {
state: 'unknown',
title: 'Not pinged yet',
};
function stopDevicesModalLiveRefresh() {
if (devicesModalLiveTimer != null) {
clearInterval(devicesModalLiveTimer);
@@ -53,11 +62,189 @@ function startDevicesModalLiveRefresh() {
}, DEVICES_MODAL_POLL_MS);
}
const DEVICE_DOT_CLASSES = [
'device-status-dot--online',
'device-status-dot--offline',
'device-status-dot--unknown',
'device-status-dot--pinging',
];
function normalizeDeviceMacKey(mac) {
return String(mac || '')
.trim()
.toLowerCase()
.replace(/[:-]/g, '');
}
function findPingResponse(responses, deviceId) {
if (!responses || typeof responses !== 'object') return null;
const want = normalizeDeviceMacKey(deviceId);
for (const [mac, info] of Object.entries(responses)) {
if (normalizeDeviceMacKey(mac) === want) return info;
}
return null;
}
function setDeviceStatusDot(dot, state, title) {
if (!dot) return;
dot.classList.remove(...DEVICE_DOT_CLASSES);
if (state === 'online') dot.classList.add('device-status-dot--online');
else if (state === 'offline') dot.classList.add('device-status-dot--offline');
else if (state === 'pinging') dot.classList.add('device-status-dot--pinging');
else dot.classList.add('device-status-dot--unknown');
dot.title = title;
dot.setAttribute('aria-label', title);
}
function updatePingStatusDot(dotEl, state, title) {
if (!dotEl) return;
dotEl.classList.remove(...DEVICE_DOT_CLASSES);
if (state === 'online') dotEl.classList.add('device-status-dot--online');
else if (state === 'offline') dotEl.classList.add('device-status-dot--offline');
else if (state === 'pinging') dotEl.classList.add('device-status-dot--pinging');
else dotEl.classList.add('device-status-dot--unknown');
dotEl.title = title;
dotEl.setAttribute('aria-label', title);
}
function rememberEspnowPingAggregate(state, title) {
lastEspnowPingAggregate = { state, title };
}
function applyEspnowPingAggregateToDots() {
for (const id of ['devices-ping-dot']) {
updatePingStatusDot(document.getElementById(id), lastEspnowPingAggregate.state, lastEspnowPingAggregate.title);
}
}
async function runUpdateGroups(btn) {
const statusEl = document.getElementById('devices-groups-status');
const prevLabel = btn ? btn.textContent : '';
if (btn) {
btn.disabled = true;
btn.textContent = 'Updating…';
}
if (statusEl) statusEl.textContent = 'Sending group membership…';
try {
const res = await fetch('/devices/groups', {
method: 'POST',
headers: { Accept: 'application/json' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
const err = data.error || 'Update groups failed';
if (statusEl) statusEl.textContent = err;
return;
}
const sent = Number(data.sent) || 0;
const failed = Number(data.failed) || 0;
if (statusEl) {
statusEl.textContent =
failed > 0
? `Sent to ${sent} driver${sent === 1 ? '' : 's'}, ${failed} failed`
: `Sent to ${sent} driver${sent === 1 ? '' : 's'}`;
}
} catch (error) {
if (statusEl) statusEl.textContent = error.message;
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = prevLabel;
}
}
}
async function runEspnowPing({ btn, dot, statusEl } = {}) {
const prevLabel = btn ? btn.textContent : '';
if (btn) {
btn.disabled = true;
btn.textContent = 'Pinging…';
}
updatePingStatusDot(dot, 'pinging', 'Ping in progress…');
if (statusEl) statusEl.textContent = 'Waiting for replies (3 s)…';
applyEspnowPingToDeviceRows(null, 'pinging');
try {
const res = await fetch('/devices/ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ timeout_s: 3 }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
const err = data.error || 'Ping failed';
rememberEspnowPingAggregate('offline', err);
updatePingStatusDot(dot, 'offline', err);
applyEspnowPingAggregateToDots();
if (statusEl) statusEl.textContent = err;
return;
}
const count = Object.keys(data.responses || {}).length;
const registered = Number(data.registered) || 0;
const aggState = count > 0 ? 'online' : 'offline';
const aggTitle =
count > 0
? `${count} driver${count === 1 ? '' : 's'} replied`
: 'No drivers replied';
rememberEspnowPingAggregate(aggState, aggTitle);
updatePingStatusDot(dot, aggState, aggTitle);
applyEspnowPingAggregateToDots();
if (statusEl) {
let msg = `${count} response${count === 1 ? '' : 's'}`;
if (registered > 0) {
msg += ` · ${registered} new in list`;
}
statusEl.textContent = msg;
}
await refreshDevicesListQuiet();
applyEspnowPingToDeviceRows(data.responses, 'done');
} catch (error) {
const msg = `Error: ${error.message}`;
rememberEspnowPingAggregate('offline', msg);
updatePingStatusDot(dot, 'offline', msg);
applyEspnowPingAggregateToDots();
if (statusEl) statusEl.textContent = error.message;
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = prevLabel;
}
}
}
function applyEspnowPingToDeviceRows(responses, phase) {
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.querySelectorAll('.profiles-row[data-device-transport="espnow"]').forEach((row) => {
const dot = row.querySelector('.device-status-dot');
if (!dot) return;
if (phase === 'pinging') {
setDeviceStatusDot(dot, 'pinging', 'Ping in progress…');
return;
}
const macKey = normalizeDeviceMacKey(row.dataset.deviceId);
const info = findPingResponse(responses, row.dataset.deviceId);
if (info) {
const rtt = info.rtt_ms != null ? `${info.rtt_ms} ms` : 'ok';
const title = `Ping reply (${rtt})`;
setDeviceStatusDot(dot, 'online', title);
espnowPingStatusByMac.set(macKey, { state: 'online', title });
} else {
const title = 'No ping reply';
setDeviceStatusDot(dot, 'offline', title);
espnowPingStatusByMac.set(macKey, { state: 'offline', title });
}
});
}
function espnowPingStatusForMac(devId) {
return espnowPingStatusByMac.get(normalizeDeviceMacKey(devId)) || null;
}
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');
dot.classList.remove(...DEVICE_DOT_CLASSES);
if (connected) {
dot.classList.add('device-status-dot--online');
dot.title = 'Connected (Wi-Fi TCP session)';
@@ -277,17 +464,16 @@ function renderDevicesList(devices) {
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);
setDeviceStatusDot(dot, 'online', 'Connected (Wi-Fi TCP session)');
} 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);
setDeviceStatusDot(dot, 'offline', 'Not connected (no Wi-Fi TCP session)');
} else {
dot.classList.add('device-status-dot--unknown');
dot.title = 'ESP-NOW — TCP status does not apply';
dot.setAttribute('aria-label', dot.title);
const pingCached = espnowPingStatusForMac(devId);
if (pingCached) {
setDeviceStatusDot(dot, pingCached.state, pingCached.title);
} else {
setDeviceStatusDot(dot, 'unknown', 'ESP-NOW — ping or identify to test reachability');
}
}
const label = document.createElement('span');
@@ -571,6 +757,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (typeof window.getEspnowSocket === 'function') {
window.getEspnowSocket();
}
applyEspnowPingAggregateToDots();
loadDevicesModal();
startDevicesModalLiveRefresh();
});
@@ -581,6 +768,22 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
const devicesPingBtn = document.getElementById('devices-ping-btn');
if (devicesPingBtn) {
devicesPingBtn.addEventListener('click', () => {
runEspnowPing({
btn: devicesPingBtn,
dot: document.getElementById('devices-ping-dot'),
statusEl: document.getElementById('devices-ping-status'),
});
});
}
const devicesUpdateGroupsBtn = document.getElementById('devices-update-groups-btn');
if (devicesUpdateGroupsBtn) {
devicesUpdateGroupsBtn.addEventListener('click', () => runUpdateGroups(devicesUpdateGroupsBtn));
}
const devicesModalEl = document.getElementById('devices-modal');
if (devicesModalEl) {
new MutationObserver(() => {
@@ -658,3 +861,9 @@ document.addEventListener('DOMContentLoaded', () => {
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
}
});
if (typeof window !== 'undefined') {
window.applyEspnowPingToDeviceRows = applyEspnowPingToDeviceRows;
window.runEspnowPing = runEspnowPing;
window.applyEspnowPingAggregateToDots = applyEspnowPingAggregateToDots;
}