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

@@ -41,157 +41,534 @@ document.addEventListener('DOMContentLoaded', () => {
const settingsButton = document.getElementById('settings-btn');
const settingsModal = document.getElementById('settings-modal');
const settingsCloseButton = document.getElementById('settings-close-btn');
const settingsTabButtons = document.querySelectorAll('[data-settings-tab]');
const settingsTabPanels = document.querySelectorAll('[data-settings-panel]');
const ledToolIframe = document.getElementById('led-tool-iframe');
let settingsActiveTab = 'bridge';
const showSettingsMessage = (text, type = 'success') => {
const messageEl = document.getElementById('settings-message');
if (!messageEl) return;
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
setTimeout(() => {
messageEl.classList.remove('show');
}, 5000);
};
async function loadDeviceSettings() {
try {
const response = await fetch('/settings');
const data = await response.json();
const nameInput = document.getElementById('device-name-input');
if (nameInput && data && typeof data === 'object') {
nameInput.value = data.device_name || 'led-controller';
}
const chInput = document.getElementById('wifi-channel-input');
if (chInput && data && typeof data === 'object') {
const ch = data.wifi_channel;
chInput.value =
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
}
} catch (error) {
console.error('Error loading device settings:', error);
function loadLedToolIframe() {
if (!ledToolIframe) return;
const blank = !ledToolIframe.src || ledToolIframe.src === 'about:blank';
if (blank) {
ledToolIframe.src = '/led-tool/editor';
}
}
async function loadAPStatus() {
try {
const response = await fetch('/settings/wifi/ap');
const config = await response.json();
const statusEl = document.getElementById('ap-status');
if (!statusEl) return;
if (config.active) {
statusEl.innerHTML = `
<h4>AP Status: <span class="status-connected">Active</span></h4>
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
`;
} else {
statusEl.innerHTML = `
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
<p>Access Point is not currently active</p>
`;
}
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
} catch (error) {
console.error('Error loading AP status:', error);
function unloadLedToolIframe() {
if (ledToolIframe) {
ledToolIframe.src = 'about:blank';
}
}
function switchSettingsTab(tabId) {
if (!tabId) tabId = 'bridge';
settingsActiveTab = tabId;
for (const btn of settingsTabButtons) {
const on = btn.getAttribute('data-settings-tab') === tabId;
btn.classList.toggle('active', on);
btn.setAttribute('aria-selected', on ? 'true' : 'false');
}
for (const panel of settingsTabPanels) {
const on = panel.getAttribute('data-settings-panel') === tabId;
panel.classList.toggle('active', on);
panel.hidden = !on;
}
if (settingsModal) {
settingsModal.classList.toggle('settings-modal--led-tool', tabId === 'led-tool');
}
if (tabId === 'led-tool') {
loadLedToolIframe();
}
}
for (const btn of settingsTabButtons) {
btn.addEventListener('click', () => {
switchSettingsTab(btn.getAttribute('data-settings-tab'));
});
}
window.openSettingsModal = (tabId) => {
if (!settingsModal) return;
if (tabId) {
switchSettingsTab(tabId);
} else {
switchSettingsTab(settingsActiveTab);
}
settingsModal.classList.add('active');
if (!tabId || tabId === 'bridge') {
loadBridgeSettings();
}
};
const bridgeWsStatus = document.getElementById('bridge-ws-status');
const bridgeConnectionDetails = document.getElementById('bridge-connection-details');
const bridgeProfilesList = document.getElementById('bridge-profiles-list');
let lastBridgeSettings = null;
const bridgeSerialPortSelect = document.getElementById('bridge-serial-port');
const bridgeSerialBaudInput = document.getElementById('bridge-serial-baud');
const bridgeSerialConnectBtn = document.getElementById('bridge-serial-connect-btn');
const bridgeSerialSaveProfileBtn = document.getElementById('bridge-serial-save-profile-btn');
const bridgeSerialRefreshBtn = document.getElementById('bridge-serial-refresh-btn');
const bridgeWifiInterfaceSelect = document.getElementById('bridge-wifi-interface');
const bridgeWifiRefreshInterfacesBtn = document.getElementById('bridge-wifi-refresh-interfaces-btn');
const bridgeWifiSsidSelect = document.getElementById('bridge-wifi-ssid');
const bridgeWifiSsidManual = document.getElementById('bridge-wifi-ssid-manual');
const bridgeWifiPassword = document.getElementById('bridge-wifi-password');
const bridgeWifiConnectBtn = document.getElementById('bridge-wifi-connect-btn');
const bridgeWifiSaveProfileBtn = document.getElementById('bridge-wifi-save-profile-btn');
const bridgeWifiScanBtn = document.getElementById('bridge-wifi-scan-btn');
const bridgeWifiApIp = document.getElementById('bridge-wifi-ap-ip');
const bridgeWifiWsPort = document.getElementById('bridge-wifi-ws-port');
function setBridgeWsStatus(text, isError = false) {
if (!bridgeWsStatus) return;
bridgeWsStatus.textContent = text || '';
bridgeWsStatus.style.color = isError ? '#f44336' : '';
}
function connLabel(ok) {
return ok ? 'connected' : 'not connected';
}
function bridgeStatusLine(data) {
if (!data) return '';
const mode = data.bridge_transport === 'serial' ? 'USB serial' : 'WiFi';
const active = data.active_bridge_id
? (data.bridges || []).find((b) => b.id === data.active_bridge_id)
: null;
const activeBit = active ? ` — active profile: ${active.label}` : '';
if (data.bridge_transport === 'wifi' && data.bridge_ws_url) {
return `${mode}: ${data.bridge_ws_url} (${connLabel(data.bridge_connected)})${activeBit}`;
}
if (data.bridge_serial_port) {
return `${mode}: ${data.bridge_serial_port} (${connLabel(data.bridge_connected)})${activeBit}`;
}
return `Bridge ${mode} (${connLabel(data.bridge_connected)})${activeBit}`;
}
function renderBridgeConnectionDetails(data) {
if (!bridgeConnectionDetails) return;
bridgeConnectionDetails.innerHTML = '';
if (!data) return;
const rows = [
['Transport in use', data.bridge_transport === 'serial' ? 'USB serial' : 'WiFi'],
[
'WiFi WebSocket',
data.bridge_ws_url
? `${data.bridge_ws_url} (${connLabel(data.bridge_wifi_connected)})`
: connLabel(false),
],
[
'USB serial',
data.bridge_serial_port
? `${data.bridge_serial_port} (${connLabel(data.bridge_serial_connected)})`
: connLabel(false),
],
];
const active = (data.bridges || []).find((b) => b.id === data.active_bridge_id);
if (active) {
const detail =
active.transport === 'wifi'
? `WiFi ${active.ssid}`
: `USB ${active.serial_port}`;
rows.push(['Active saved profile', `${active.label} (${detail})`]);
} else if (data.bridge_connected) {
rows.push(['Active saved profile', '— (connected, no matching saved profile)']);
}
for (const [k, v] of rows) {
const li = document.createElement('li');
li.textContent = `${k}: ${v}`;
bridgeConnectionDetails.appendChild(li);
}
}
function resolvedBridgeSsid() {
const manual = bridgeWifiSsidManual?.value?.trim();
if (manual) return manual;
return bridgeWifiSsidSelect?.value?.trim() || '';
}
async function loadBridgeSettings() {
try {
const bridgesRes = await fetch('/settings/wifi/bridges');
const bridgesData = await bridgesRes.json().catch(() => ({}));
lastBridgeSettings = bridgesData;
if (bridgeSerialBaudInput && bridgesData.bridge_serial_baudrate) {
bridgeSerialBaudInput.value = String(bridgesData.bridge_serial_baudrate);
}
await loadSerialPorts(bridgesData.bridge_serial_port || '');
await loadWifiInterfaces(bridgesData.wifi_interface || '');
renderBridgeConnectionDetails(bridgesData);
setBridgeWsStatus(bridgeStatusLine(bridgesData));
renderBridgeProfiles(bridgesData.bridges || [], bridgesData);
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function loadWifiInterfaces(selectedDevice) {
if (!bridgeWifiInterfaceSelect) return;
try {
const res = await fetch('/settings/wifi/interfaces');
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'WiFi interfaces unavailable', true);
return;
}
const current = selectedDevice || bridgeWifiInterfaceSelect.value;
bridgeWifiInterfaceSelect.innerHTML = '<option value="">— select adapter —</option>';
for (const iface of data.interfaces || []) {
const opt = document.createElement('option');
opt.value = iface.device;
const bits = [iface.device];
if (iface.label && iface.label !== iface.device) bits.push(iface.label);
if (iface.state) bits.push(`(${iface.state})`);
opt.textContent = bits.join(' — ');
bridgeWifiInterfaceSelect.appendChild(opt);
}
if (current) bridgeWifiInterfaceSelect.value = current;
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function scanBridgeWifi() {
const device = bridgeWifiInterfaceSelect?.value?.trim();
if (!device) {
setBridgeWsStatus('Select a WiFi adapter first', true);
return;
}
setBridgeWsStatus('Scanning…');
try {
const res = await fetch(
`/settings/wifi/scan?device=${encodeURIComponent(device)}`
);
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Scan failed', true);
return;
}
if (!bridgeWifiSsidSelect) return;
const prev = resolvedBridgeSsid();
bridgeWifiSsidSelect.innerHTML = '<option value="">— select network —</option>';
for (const net of data.networks || []) {
const opt = document.createElement('option');
opt.value = net.ssid;
opt.textContent = `${net.ssid} (${net.signal}%)`;
bridgeWifiSsidSelect.appendChild(opt);
}
if (prev) {
bridgeWifiSsidSelect.value = prev;
if (!bridgeWifiSsidSelect.value && bridgeWifiSsidManual) {
bridgeWifiSsidManual.value = prev;
}
}
setBridgeWsStatus(`Found ${(data.networks || []).length} network(s)`);
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function loadSerialPorts(selectedPort) {
if (!bridgeSerialPortSelect) return;
try {
const res = await fetch('/led-tool/ports');
const data = await res.json().catch(() => ({}));
const current = selectedPort || bridgeSerialPortSelect.value;
bridgeSerialPortSelect.innerHTML = '<option value="">— select port —</option>';
for (const p of data.ports || []) {
const opt = document.createElement('option');
opt.value = p.device;
opt.textContent = p.description ? `${p.device}${p.description}` : p.device;
bridgeSerialPortSelect.appendChild(opt);
}
if (current) bridgeSerialPortSelect.value = current;
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
function profileStatusFor(p, data) {
const activeId = data.active_bridge_id || '';
const isActive = Boolean(activeId && p.id === activeId && data.bridge_connected);
if (isActive) {
return { text: 'Connected', className: 'settings-bridge-profile-status--connected' };
}
return { text: 'Not connected', className: 'settings-bridge-profile-status--idle' };
}
async function deleteBridgeProfile(id, label) {
const name = label || id;
if (!window.confirm(`Delete saved bridge profile “${name}”?`)) return;
setBridgeWsStatus('Deleting…');
try {
const res = await fetch(`/settings/wifi/bridges/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Delete failed', true);
return;
}
setBridgeWsStatus(data.message || 'Profile deleted');
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
function renderBridgeProfiles(profiles, bridgesData) {
if (!bridgeProfilesList) return;
bridgeProfilesList.innerHTML = '';
const data = bridgesData || lastBridgeSettings || {};
const activeId = data.active_bridge_id || '';
if (!profiles.length) {
bridgeProfilesList.innerHTML = '<li>No saved bridge profiles.</li>';
return;
}
for (const p of profiles) {
const li = document.createElement('li');
const isActive = Boolean(activeId && p.id === activeId && data.bridge_connected);
li.className =
'settings-bridge-profile-row' + (isActive ? ' settings-bridge-profile-row--active' : '');
const main = document.createElement('div');
main.className = 'settings-bridge-profile-main';
const label = document.createElement('span');
label.className = 'settings-bridge-profile-label';
if (p.transport === 'wifi') {
label.textContent = `${p.label} — WiFi ${p.ssid}`;
} else {
label.textContent = `${p.label} — USB ${p.serial_port}`;
}
const status = document.createElement('span');
const st = profileStatusFor(p, data);
status.className = 'settings-bridge-profile-status ' + st.className;
status.textContent = st.text;
main.appendChild(label);
main.appendChild(status);
const actions = document.createElement('div');
actions.className = 'settings-bridge-profile-actions';
const connectBtn = document.createElement('button');
connectBtn.type = 'button';
connectBtn.className = 'btn btn-secondary btn-small';
connectBtn.textContent = 'Connect';
connectBtn.addEventListener('click', () => connectSavedBridge(p.id));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn btn-secondary btn-small settings-bridge-profile-delete';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', () => deleteBridgeProfile(p.id, p.label));
actions.appendChild(connectBtn);
actions.appendChild(deleteBtn);
li.appendChild(main);
li.appendChild(actions);
bridgeProfilesList.appendChild(li);
}
}
async function connectSavedBridge(id) {
setBridgeWsStatus('Connecting…');
try {
const res = await fetch(`/settings/wifi/bridges/${encodeURIComponent(id)}/connect`, {
method: 'POST',
headers: { Accept: 'application/json' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Connect failed', true);
return;
}
setBridgeWsStatus(data.message ? `${data.message}${bridgeStatusLine(data)}` : bridgeStatusLine(data));
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function connectBridgeWifi(saveProfile) {
const device = bridgeWifiInterfaceSelect?.value?.trim();
const ssid = resolvedBridgeSsid();
const password = bridgeWifiPassword?.value || '';
const apIp = bridgeWifiApIp?.value?.trim() || '192.168.4.1';
const wsPort = parseInt(bridgeWifiWsPort?.value, 10) || 80;
const label = document.getElementById('bridge-wifi-label')?.value?.trim() || ssid;
if (!device) {
setBridgeWsStatus('Select a WiFi adapter', true);
return;
}
if (!ssid) {
setBridgeWsStatus('Enter or select a bridge SSID', true);
return;
}
setBridgeWsStatus('Connecting…');
try {
const res = await fetch('/settings/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
device,
ssid,
password,
ap_ip: apIp,
ws_port: wsPort,
label,
save_profile: saveProfile,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Connect failed', true);
return;
}
setBridgeWsStatus(data.message ? `${data.message}${bridgeStatusLine(data)}` : bridgeStatusLine(data));
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function connectBridgeSerial(saveProfile) {
const port = bridgeSerialPortSelect ? bridgeSerialPortSelect.value : '';
const baud = parseInt(bridgeSerialBaudInput?.value, 10) || 115200;
const label = document.getElementById('bridge-serial-label')?.value?.trim() || port;
if (!port) {
setBridgeWsStatus('Select a USB serial port', true);
return;
}
setBridgeWsStatus('Connecting…');
try {
const res = await fetch('/settings/wifi/serial/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ port, baudrate: baud, label, save_profile: saveProfile }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Connect failed', true);
return;
}
setBridgeWsStatus(data.message ? `${data.message}${bridgeStatusLine(data)}` : bridgeStatusLine(data));
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
if (bridgeSerialRefreshBtn) {
bridgeSerialRefreshBtn.addEventListener('click', () => loadSerialPorts());
}
if (bridgeSerialConnectBtn) {
bridgeSerialConnectBtn.addEventListener('click', () => connectBridgeSerial(true));
}
if (bridgeWifiRefreshInterfacesBtn) {
bridgeWifiRefreshInterfacesBtn.addEventListener('click', () => loadWifiInterfaces());
}
if (bridgeWifiScanBtn) {
bridgeWifiScanBtn.addEventListener('click', () => scanBridgeWifi());
}
if (bridgeWifiConnectBtn) {
bridgeWifiConnectBtn.addEventListener('click', () => connectBridgeWifi(true));
}
if (bridgeWifiSaveProfileBtn) {
bridgeWifiSaveProfileBtn.addEventListener('click', async () => {
const device = bridgeWifiInterfaceSelect?.value?.trim();
const ssid = resolvedBridgeSsid();
if (!ssid) {
setBridgeWsStatus('SSID required to save profile', true);
return;
}
const password = bridgeWifiPassword?.value || '';
const apIp = bridgeWifiApIp?.value?.trim() || '192.168.4.1';
const wsPort = parseInt(bridgeWifiWsPort?.value, 10) || 80;
const label = document.getElementById('bridge-wifi-label')?.value?.trim() || ssid;
try {
const res = await fetch('/settings/wifi/bridges');
const data = await res.json().catch(() => ({}));
const bridges = Array.isArray(data.bridges) ? data.bridges : [];
bridges.push({
id: crypto.randomUUID ? crypto.randomUUID().slice(0, 12) : String(Date.now()),
label,
transport: 'wifi',
ssid,
password,
ap_ip: apIp,
ws_port: wsPort,
});
const putRes = await fetch('/settings/wifi/bridges', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bridges, wifi_interface: device || data.wifi_interface }),
});
const putData = await putRes.json().catch(() => ({}));
if (!putRes.ok || !putData.ok) {
setBridgeWsStatus(putData.error || 'Save failed', true);
return;
}
setBridgeWsStatus('WiFi profile saved');
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
});
}
if (bridgeSerialSaveProfileBtn) {
bridgeSerialSaveProfileBtn.addEventListener('click', async () => {
const port = bridgeSerialPortSelect ? bridgeSerialPortSelect.value : '';
if (!port) {
setBridgeWsStatus('Port required to save profile', true);
return;
}
const baud = parseInt(bridgeSerialBaudInput?.value, 10) || 115200;
const label = document.getElementById('bridge-serial-label')?.value?.trim() || port;
try {
const res = await fetch('/settings/wifi/bridges');
const data = await res.json().catch(() => ({}));
const bridges = Array.isArray(data.bridges) ? data.bridges : [];
bridges.push({
id: crypto.randomUUID ? crypto.randomUUID().slice(0, 12) : String(Date.now()),
label,
transport: 'serial',
serial_port: port,
serial_baudrate: baud,
});
const putRes = await fetch('/settings/wifi/bridges', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bridges }),
});
const putData = await putRes.json().catch(() => ({}));
if (!putRes.ok || !putData.ok) {
setBridgeWsStatus(putData.error || 'Save failed', true);
return;
}
setBridgeWsStatus('Serial profile saved');
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
});
}
if (settingsButton && settingsModal) {
settingsButton.addEventListener('click', () => {
switchSettingsTab('bridge');
settingsModal.classList.add('active');
// Load current WiFi status/config when opening
loadDeviceSettings();
loadAPStatus();
loadBridgeSettings();
});
}
if (settingsCloseButton && settingsModal) {
settingsCloseButton.addEventListener('click', () => {
settingsModal.classList.remove('active');
});
}
const deviceForm = document.getElementById('device-form');
if (deviceForm) {
deviceForm.addEventListener('submit', async (e) => {
e.preventDefault();
const nameInput = document.getElementById('device-name-input');
const deviceName = nameInput ? nameInput.value.trim() : '';
if (!deviceName) {
showSettingsMessage('Device name is required', 'error');
return;
}
const chRaw = document.getElementById('wifi-channel-input')
? document.getElementById('wifi-channel-input').value
: '6';
const wifiChannel = parseInt(chRaw, 10);
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
return;
}
try {
const response = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_name: deviceName,
wifi_channel: wifiChannel,
}),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage(
'Device settings saved. They will apply on next restart where relevant.',
'success',
);
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
}
} catch (error) {
showSettingsMessage(`Error: ${error.message}`, 'error');
}
});
}
const apForm = document.getElementById('ap-form');
if (apForm) {
apForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
ssid: document.getElementById('ap-ssid').value,
password: document.getElementById('ap-password').value,
channel: document.getElementById('ap-channel').value || null,
};
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
showSettingsMessage('AP password must be at least 8 characters', 'error');
return;
}
if (formData.channel) {
formData.channel = parseInt(formData.channel, 10);
if (formData.channel < 1 || formData.channel > 11) {
showSettingsMessage('Channel must be between 1 and 11', 'error');
return;
}
}
try {
const response = await fetch('/settings/wifi/ap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage('Access Point configured successfully!', 'success');
setTimeout(loadAPStatus, 1000);
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
}
} catch (error) {
showSettingsMessage(`Error: ${error.message}`, 'error');
}
settingsModal.classList.remove('settings-modal--led-tool');
unloadLedToolIframe();
});
}
});