/* Settings - robot discovery, network, transport and safety limits. This is where the "works on any IP" promise lives: the dashboard never stores a fixed robot address, it discovers one on whatever network you are currently attached to, and it reports every address it is itself reachable on. */ import { store, get, post, toast, num } from '../core.js'; import { el, card, pageHead, button, note, badge, table, input, select, field, emptyState, toggle, range, kv, segmented, setChildren, } from '../ui.js'; export default { id: 'settings', label: 'Settings', icon: 'settings', async render() { const root = el('div.stack'); let config = store.settings?.values || {}; let network = store.network || {}; root.appendChild(pageHead( 'Settings', 'Connection, transport and safety limits. Changes are saved to config.json immediately.', )); /* ================================================================== Robot connection + discovery ================================================================== */ const hostInput = input({ value: config.robot_host || '', placeholder: 'IP address or hostname' }); const probeResult = el('div'); const scanResult = el('div'); const scanProgress = el('div', { style: { display: 'none' } }); const saveHost = async (value) => { const data = await post('/api/settings', { robot_host: value }); config = data.settings.values; store.settings = data.settings; toast('Robot host saved', value || 'cleared', 'good'); if (data.needs_restart) restartNote.style.display = ''; }; const probeBtn = button('Test', async (node) => { const host = hostInput.value.trim(); if (!host) { hostInput.focus(); return; } node.disabled = true; probeResult.replaceChildren(el('span.muted', { text: 'Probing…' })); try { const data = await post('/api/network/probe', { host }); const info = data.detail || {}; setChildren(probeResult, el('div.row', {}, badge(data.ok ? 'reachable' : 'no answer', data.ok ? 'good' : 'critical'), info.hostname ? badge(info.hostname) : null, info.open_ports?.length ? badge(`ports ${info.open_ports.join(', ')}`, 'accent') : null, badge(`${num(info.latency_ms, 0)} ms`), ), info.is_pc1 ? note('This is PC1, the motion-control unit. The documentation forbids using it as a ' + 'build or run host. Point the dashboard at PC2 instead.', 'critical', '⚠') : null, ); } catch (err) { probeResult.replaceChildren(note(err.message, 'critical', '⚠')); } finally { node.disabled = false; } }); const scanBtn = button('Scan this network', async (node) => { node.disabled = true; scanProgress.style.display = ''; scanProgress.replaceChildren(el('span.muted', { text: 'Starting sweep…' })); scanResult.replaceChildren(); try { const data = await post('/api/network/scan', {}, ); const hosts = data.hosts || []; scanResult.replaceChildren( el('div.row', { style: { marginBottom: '10px' } }, badge(`${hosts.length} responding`, hosts.length ? 'good' : 'warning'), ...(data.networks || []).map((n) => badge(n, 'accent')), ), hosts.length ? table([ { key: 'host', label: 'Address' }, { key: 'hostname', label: 'Name', get: (r) => r.hostname || '—' }, { key: 'ports', label: 'Open ports', get: (r) => r.open_ports.join(', ') }, { key: 'latency', label: 'Latency', align: 'right', get: (r) => `${num(r.latency_ms, 0)} ms` }, { key: 'tags', label: '', get: (r) => el('div.row.tight', {}, r.is_self ? badge('this machine', 'default') : null, r.is_pc1 ? badge('PC1 — do not use', 'critical') : null, ), }, { key: 'use', label: '', get: (r) => button('Use', async () => { hostInput.value = r.host; await saveHost(r.host); }, { size: 'sm', style: r.is_pc1 ? 'warn' : 'primary' }), }, ], hosts) : emptyState('Nothing answered', 'No host on this subnet responded on the ports an X2 exposes. ' + 'Check that the robot is powered and on the same network.'), ); } catch (err) { scanResult.replaceChildren(note(err.message, 'critical', '⚠')); } finally { node.disabled = false; scanProgress.style.display = 'none'; } }, { iconName: 'scan', style: 'primary' }); const restartNote = el('div', { style: { display: 'none' } }, note('Transport settings changed. Restart the bridge to apply them.', 'warning', '⚠'), ); const restartBtn = button('Restart bridge', async (node) => { node.disabled = true; try { const data = await post('/api/bridge/restart'); toast(data.ok ? 'Bridge restarted' : 'Restart failed', data.message, data.ok ? 'good' : 'critical'); if (data.ok) { restartNote.style.display = 'none'; const boot = await get('/api/bootstrap'); store.bridge = boot.bridge; store.state = boot.state; } } catch (err) { toast('Restart failed', err.message, 'critical'); } finally { node.disabled = false; } }, { iconName: 'refresh' }); root.appendChild(card('Robot connection', { sub: 'No fixed address is stored' }, el('div.stack', {}, field('Robot host', el('div.row', { style: { gap: '8px', flexWrap: 'nowrap' } }, hostInput, probeBtn, button('Save', () => saveHost(hostInput.value.trim())), ), 'Leave empty to rely on ROS 2 DDS multicast discovery, which finds the robot on the local ' + 'segment without an address. Set it when you want the dashboard to report and probe a ' + 'specific host.', ), probeResult, el('div.row.between', {}, el('span', { text: 'Find the robot automatically', style: { fontSize: '12px', color: 'var(--text-2)' } }), scanBtn, ), scanProgress, scanResult, note('The sweep covers whichever subnets this machine is on right now. Move to a different ' + 'Wi-Fi network and scan again — nothing is cached.', 'info'), el('div', { style: { borderTop: '1px solid var(--border-soft)', margin: '4px 0 2px' } }), toggle('Find the robot automatically if its address changes', config.auto_discover, (value) => saveSetting('auto_discover', value)), toggle('Start the agent over SSH if the robot is on but not answering', config.auto_start_agent, (value) => saveSetting('auto_start_agent', value)), el('div.grid.cols-2', { style: { gap: '10px' } }, field('SSH user', input({ value: config.robot_ssh_user, onchange: (e) => saveSetting('robot_ssh_user', e.target.value), }), ), field('SSH password', input({ type: 'password', value: config.robot_ssh_password, placeholder: config.robot_ssh_password ? '' : 'not saved', onchange: (e) => saveSetting('robot_ssh_password', e.target.value), }), ), ), note('The robot cannot start the dashboard agent by itself: the agi account is not ' + 'allowed to enable systemd lingering, and the robot\'s clock jumps backwards after ' + 'boot, which stalls cron. With these credentials the dashboard logs in and starts ' + 'it for you after a power cycle. Stored in config.json on this machine.', 'warning', '⚠'), restartNote, ), )); /* ================================================================== Dashboard reachability ================================================================== */ const urlHost = el('div'); function paintUrls() { const urls = network.urls || []; const primary = network.address?.ip ? `http://${network.address.ip}:${network.port}` : null; const describe = (url) => { if (url === primary) return { label: 'use this — works everywhere', tone: 'good' }; if (url.includes('localhost') || url.includes('127.0.0.1')) { return { label: 'this machine only', tone: 'default' }; } // Names need mDNS, which many access points block between clients. if (!/\/\/\d+\.\d+\.\d+\.\d+/.test(url)) { return { label: 'needs mDNS — often blocked on Wi-Fi', tone: 'warning' }; } return { label: 'other adapter', tone: 'accent' }; }; urlHost.replaceChildren( el('div.stack', { style: { gap: '8px' } }, ...urls.map((url) => { const meta = describe(url); return el('div.row.between', { style: { padding: '10px 13px', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', }, }, el('span', { text: url, style: { fontFamily: 'var(--mono)', fontSize: '12.5px' } }), el('div.row.tight', {}, badge(meta.label, meta.tone), button('Copy', async () => { try { await navigator.clipboard.writeText(url); toast('Copied', url, 'good', 1800); } catch { toast('Copy blocked', 'Select and copy the address manually.', 'warning'); } }, { size: 'sm', style: 'ghost' }), ), ); }), ), ); } const nameHost = el('div'); function paintName() { const adv = network.advertised; setChildren(nameHost, field('Dashboard name on the network', el('div.row', { style: { gap: '8px', flexWrap: 'nowrap' } }, input({ value: config.dashboard_name, placeholder: 'agibot', onchange: (e) => saveSetting('dashboard_name', e.target.value.trim().toLowerCase()), }), adv?.running ? badge('publishing', 'good') : adv?.conflict ? badge('name taken', 'critical') : badge('not publishing', 'warning'), ), 'The dashboard publishes this over mDNS, so the link is about the robot rather than ' + 'this PC. Takes effect when the server restarts.', ), adv?.error ? el('div', { style: { marginTop: '8px' } }, note(adv.error, 'warning', '⚠')) : null, ); } const qrHost = el('div'); const devicesHost = el('div'); function paintQr() { const detail = network.address || {}; if (!detail.ip) { setChildren(qrHost, note(`No network address right now — ${detail.reason_text || 'not connected'}. ` + 'The dashboard keeps checking and will show the link the moment the ' + 'network is back.', 'warning', '⚠'), ); return; } const url = `http://${detail.ip}:${network.port}`; setChildren(qrHost, el('div.row', { style: { gap: '18px', alignItems: 'center', flexWrap: 'wrap' } }, el('img', { src: `/api/qr?url=${encodeURIComponent(url)}&t=${Date.now()}`, alt: `QR code for ${url}`, style: { width: '156px', height: '156px', background: '#fff', padding: '8px', borderRadius: 'var(--radius-sm)', }, }), el('div', { style: { flex: '1', minWidth: '220px' } }, el('div', { text: 'Open this on your phone', style: { fontWeight: '600', marginBottom: '6px' } }), el('div', { text: url, style: { fontFamily: 'var(--mono)', fontSize: '17px', color: 'var(--text)' }, }), (() => { const kindLabel = detail.kind === 'wifi' ? 'Wi-Fi' : 'wired'; const adapter = detail.adapter || ''; return el('div.row.tight', { style: { marginTop: '8px' } }, badge(kindLabel, detail.kind === 'wifi' ? 'good' : 'accent'), // The adapter is usually just called "Wi-Fi" too - only worth a // second badge when it actually says something different. adapter && adapter.toLowerCase() !== kindLabel.toLowerCase() ? badge(adapter) : null, detail.gateway ? badge(`via ${detail.gateway}`) : null, badge('live', 'good'), ); })(), el('div.hint', { text: 'Read from the network adapter and re-checked continuously — if this PC ' + 'gets a new address the link above updates by itself. Scan or type it; ' + 'it needs no name resolution, so it works even where .local does not.', style: { marginTop: '8px' }, }), el('div.row.tight', { style: { marginTop: '8px' } }, button('Copy link', async () => { try { await navigator.clipboard.writeText(url); toast('Copied', url, 'good', 1800); } catch { toast('Copy blocked', 'Select and copy it manually.', 'warning'); } }, { size: 'sm' }), ), ), ), (detail.rejected || []).length ? el('details', { style: { marginTop: '10px' } }, el('summary', { text: `${detail.rejected.length} other address(es) ignored`, style: { cursor: 'pointer', fontSize: '12px', color: 'var(--text-3)' }, }), el('div', { style: { marginTop: '8px' } }, table([ { key: 'ip', label: 'Address' }, { key: 'adapter', label: 'Adapter' }, { key: 'why', label: 'Why it is not used' }, ], detail.rejected), ), ) : null, ); } function paintDevices() { const devices = network.other_devices || []; setChildren(devicesHost, el('div.row', { style: { marginBottom: '8px' } }, el('strong', { text: 'Devices that have opened this dashboard', style: { fontSize: '12.5px' } }), badge(String(devices.length), devices.length ? 'good' : 'warning'), ), devices.length ? table([ { key: 'address', label: 'Address' }, { key: 'hits', label: 'Requests', align: 'right' }, { key: 'agent', label: 'Browser', get: (r) => { const a = r.agent || ''; if (/iPhone|iPad/i.test(a)) return 'iPhone / iPad'; if (/Android/i.test(a)) return 'Android'; if (/Macintosh/i.test(a)) return 'Mac'; if (/Windows/i.test(a)) return 'Windows'; return a.slice(0, 28) || '—'; }, }, ], devices) : note('Nothing but this PC has loaded the dashboard yet. If your phone appears here ' + 'after scanning the QR code, the network is fine and only the .local name is ' + 'being blocked — which is the access point filtering multicast, not something ' + 'this dashboard can fix.', 'warning', '⚠'), ); } root.appendChild(card('Open on another device', { sub: 'The server binds every interface', actions: [button('Refresh', async () => { network = await get('/api/network'); store.network = network; paintUrls(); paintName(); paintQr(); paintDevices(); }, { size: 'sm', style: 'ghost', iconName: 'refresh' })], }, el('div.stack', {}, qrHost, urlHost, nameHost, devicesHost, note('The .local link goes over mDNS, which iPhone, iPad, Mac, Android 12+ and Windows ' + 'understand. Many access points block multicast between wireless clients, and then ' + 'no .local name resolves from a phone no matter what the PC does — scan the QR code ' + 'instead.', 'info'), ), )); paintUrls(); paintName(); paintQr(); paintDevices(); // The server pushes a "network" message whenever the detected address // changes, so a page left open never shows a link that stopped working. const unsubscribeNetwork = store.on('network', (data) => { network = { ...network, ...data }; store.network = network; paintUrls(); paintQr(); toast('Network address changed', data.address?.ip ? `Now at ${data.address.ip}` : 'No network address', data.address?.ip ? 'good' : 'warning'); }); /* ================================================================== Transport ================================================================== */ const transportFields = el('div.stack'); function paintTransport() { const bridge = store.bridge || {}; const online = store.state?.connection?.online; setChildren(transportFields, el('div.row', { style: { marginBottom: '4px' } }, badge(bridge.simulated ? 'Simulation' : online ? 'Live' : 'Robot off', bridge.simulated ? 'warning' : online ? 'good' : 'critical'), bridge.agent?.agent_version ? badge(`agent ${bridge.agent.agent_version}`, 'accent') : null, bridge.agent?.hostname ? badge(bridge.agent.hostname) : null, ), bridge.simulated ? note('No robot address is set, so the dashboard is driving the built-in simulator. ' + 'Enter the robot address above and restart the bridge to attach to the real X2.', 'warning', '⚠') : null, bridge.error ? note(bridge.error, 'critical', '⚠') : null, field('Bridge mode', select([ { value: 'auto', label: 'Auto — use the robot agent when an address is set' }, { value: 'agent', label: 'Robot only — never fall back to simulation' }, { value: 'mock', label: 'Simulation only — never touch a real robot' }, ], { value: config.bridge_mode, onChange: (value) => saveSetting('bridge_mode', value), }), ), field('Agent port', input({ type: 'number', min: 1, max: 65535, value: config.agent_port, onchange: (e) => saveSetting('agent_port', Number(e.target.value)), }), 'The TCP port x2_agent.py listens on aboard the robot.', ), field('ROS_DOMAIN_ID', input({ type: 'number', min: 0, max: 232, value: config.ros_domain_id, onchange: (e) => saveSetting('ros_domain_id', Number(e.target.value)), }), 'Reference only — the agent inherits the domain from the shell that launched it.', ), el('div.row', {}, restartBtn), ); } /* ================================================================== Safety and server ================================================================== */ const limits = store.spec?.velocity_limits || {}; const safetyCard = card('Safety limits', { sub: 'Applied server-side to every command' }, el('div.stack', {}, el('div', {}, el('div', { text: 'Max forward velocity', style: labelStyle }), range({ min: 0.1, max: limits.forward?.max ?? 0.8, step: 0.05, value: config.max_forward_velocity, precision: 2, unit: ' m/s', onChange: (v) => saveSetting('max_forward_velocity', v), }), ), el('div', {}, el('div', { text: 'Max lateral velocity', style: labelStyle }), range({ min: 0.1, max: limits.lateral?.max ?? 0.7, step: 0.05, value: config.max_lateral_velocity, precision: 2, unit: ' m/s', onChange: (v) => saveSetting('max_lateral_velocity', v), }), ), el('div', {}, el('div', { text: 'Max yaw rate', style: labelStyle }), range({ min: 0.1, max: limits.angular?.max ?? 0.8, step: 0.05, value: config.max_angular_velocity, precision: 2, unit: ' rad/s', onChange: (v) => saveSetting('max_angular_velocity', v), }), ), el('div', {}, el('div', { text: 'Dead-man timeout', style: labelStyle }), range({ min: 0.2, max: 3, step: 0.1, value: config.locomotion_deadman_s, precision: 1, unit: ' s', onChange: (v) => saveSetting('locomotion_deadman_s', v), }), el('div.hint', { text: 'Velocity is zeroed if the browser stops sending for this long.' }), ), toggle('Confirm before zero-torque', config.require_confirm_zero_torque, (value) => saveSetting('require_confirm_zero_torque', value)), ), ); const serverCard = card('Server', { sub: store.settings?.config_path || 'config.json' }, el('div.stack', {}, field('Display name', input({ value: config.robot_label, onchange: (e) => saveSetting('robot_label', e.target.value), }), ), field('HTTP port', input({ type: 'number', min: 1, max: 65535, value: config.port, onchange: (e) => saveSetting('port', Number(e.target.value)), }), 'Takes effect the next time the server starts.', ), el('div', {}, el('div', { text: 'Telemetry rate', style: labelStyle }), range({ min: 1, max: 30, step: 1, value: config.telemetry_hz, precision: 0, unit: ' Hz', onChange: (v) => saveSetting('telemetry_hz', v), }), el('div.hint', { text: 'How often state is pushed to the browser. Lower it on a weak link.' }), ), el('div', { style: { marginTop: '4px' } }, kv([ ['Server version', store.server?.version || '—'], ['Connected clients', String(store.server?.clients ?? '—')], ['Config file', store.settings?.config_path || '—'], ]), ), ), ); root.appendChild(el('div.grid.cols-2', {}, card('Transport', { sub: 'How the dashboard reaches the robot' }, transportFields), safetyCard, )); root.appendChild(serverCard); paintTransport(); /* ================================================================== Helpers ================================================================== */ async function saveSetting(key, value) { try { const data = await post('/api/settings', { [key]: value }); config = data.settings.values; store.settings = data.settings; if (data.needs_restart) restartNote.style.display = ''; toast('Saved', `${key} = ${value}`, 'good', 1800); } catch (err) { toast('Could not save', err.message, 'critical'); } } const unsubscribe = store.on('scan_progress', (data) => { scanProgress.replaceChildren( el('div', {}, el('div.row.between', { style: { fontSize: '12px', color: 'var(--text-2)', marginBottom: '5px' } }, el('span', { text: 'Sweeping subnet' }), el('span', { text: `${data.done} / ${data.total}` }), ), el('div.meter', {}, el('div.meter-fill', { style: { width: `${(data.done / data.total) * 100}%` } }), ), ), ); }); return { node: root, dispose: () => { unsubscribe(); unsubscribeNetwork(); }, }; }, }; const labelStyle = { fontSize: '12px', color: 'var(--text-2)', marginBottom: '5px' };