/* dashboard.js — Saltybot Main Dashboard (Issue #630) */ 'use strict'; // ── Auto-detect candidates ───────────────────────────────────────────────── // Try current page's hostname first (if served from robot), then localhost const AUTO_CANDIDATES = (() => { const candidates = []; if (location.hostname && location.hostname !== '' && location.hostname !== 'localhost') { candidates.push(`ws://${location.hostname}:9090`); } candidates.push('ws://localhost:9090'); candidates.push('ws://saltybot.local:9090'); return candidates; })(); // ── Panel definitions (status tracking) ─────────────────────────────────── const PANELS = [ { id: 'map', watchTopic: '/saltybot/pose/fused', msgType: 'geometry_msgs/PoseStamped' }, { id: 'gamepad', watchTopic: null, msgType: null }, // output only { id: 'diag', watchTopic: '/diagnostics', msgType: 'diagnostic_msgs/DiagnosticArray' }, { id: 'events', watchTopic: '/rosout', msgType: 'rcl_interfaces/msg/Log' }, { id: 'settings', watchTopic: null, msgType: null }, // service-based { id: 'gimbal', watchTopic: '/gimbal/state', msgType: 'geometry_msgs/Vector3' }, { id: 'can', watchTopic: '/vesc/left/state', msgType: 'std_msgs/String' }, { id: 'gps', watchTopic: 'saltybot/gps/fix', msgType: 'std_msgs/String' }, ]; // ── State ────────────────────────────────────────────────────────────────── const state = { connected: false, estop: false, driveMode: 'MANUAL', battery: null, // 0..100 % voltage: null, safetyState: null, // 'CLEAR' | 'WARN' | 'DANGER' | 'ESTOP' closestM: null, uptime: 0, // seconds since ROS connection sessionStart: null, // Date for session timer msgCount: 0, lastMsgTs: 0, msgRate: 0, latency: null, // Per-panel last-message timestamps panelTs: {}, }; // ── ROS ──────────────────────────────────────────────────────────────────── let ros = null; let cmdVelTopic = null; let modeTopic = null; let uptimeInterval = null; let panelWatches = {}; // id → ROSLIB.Topic let pingTs = 0; let pingTimeout = null; function connect(url) { if (ros) { Object.values(panelWatches).forEach(t => { try { t.unsubscribe(); } catch(_){} }); panelWatches = {}; try { ros.close(); } catch(_){} } ros = new ROSLIB.Ros({ url }); ros.on('connection', () => { state.connected = true; state.sessionStart = state.sessionStart || new Date(); localStorage.setItem('dashboard_ws_url', url); $connDot.className = 'connected'; $connLabel.style.color = '#22c55e'; $connLabel.textContent = 'Connected'; $wsInput.value = url; document.getElementById('info-ws').textContent = url; setupTopics(); schedulePing(); updateDetectBar('connected', `Connected to ${url}`); }); ros.on('error', () => { state.connected = false; $connDot.className = 'error'; $connLabel.style.color = '#ef4444'; $connLabel.textContent = 'Error'; updateDetectBar('error', `Error connecting to ${url}`); }); ros.on('close', () => { state.connected = false; $connDot.className = ''; $connLabel.style.color = '#6b7280'; $connLabel.textContent = 'Disconnected'; }); } function setupTopics() { // ── Battery & temps from /diagnostics ── const diagTopic = new ROSLIB.Topic({ ros, name: '/diagnostics', messageType: 'diagnostic_msgs/DiagnosticArray', throttle_rate: 2000, }); diagTopic.subscribe(msg => { state.msgCount++; state.lastMsgTs = performance.now(); (msg.status || []).forEach(s => { (s.values || []).forEach(kv => { if (kv.key === 'battery_voltage_v') { state.voltage = parseFloat(kv.value); // 4S LiPo: 12.0V empty, 16.8V full state.battery = Math.max(0, Math.min(100, ((state.voltage - 12.0) / (16.8 - 12.0)) * 100)); } if (kv.key === 'battery_soc_pct') { state.battery = parseFloat(kv.value); } }); }); markPanelLive('diag'); renderBattery(); }); // ── Safety zone ── const safetyTopic = new ROSLIB.Topic({ ros, name: '/saltybot/safety_zone/status', messageType: 'std_msgs/String', throttle_rate: 500, }); safetyTopic.subscribe(msg => { state.msgCount++; try { const d = JSON.parse(msg.data); state.safetyState = d.state || d.fwd_zone || 'CLEAR'; state.closestM = d.closest_m != null ? d.closest_m : null; if (d.estop !== undefined) { // only auto-latch E-stop if ROS says it's active and we aren't tracking it locally if (d.estop && !state.estop) activateEstop(false); } } catch(_) { state.safetyState = msg.data; } renderSafety(); }); // ── Balance state / drive mode ── const modeSub = new ROSLIB.Topic({ ros, name: '/saltybot/balance_state', messageType: 'std_msgs/String', throttle_rate: 1000, }); modeSub.subscribe(msg => { try { const d = JSON.parse(msg.data); if (d.mode) { state.driveMode = d.mode.toUpperCase(); renderMode(); } } catch(_) {} }); // ── Pose (for map card liveness) ── const poseTopic = new ROSLIB.Topic({ ros, name: '/saltybot/pose/fused', messageType: 'geometry_msgs/PoseStamped', throttle_rate: 1000, }); poseTopic.subscribe(() => { markPanelLive('map'); }); // ── /rosout (for events card) ── const rosoutTopic = new ROSLIB.Topic({ ros, name: '/rosout', messageType: 'rcl_interfaces/msg/Log', throttle_rate: 2000, }); rosoutTopic.subscribe(() => { markPanelLive('events'); }); // ── Gimbal state ── const gimbalTopic = new ROSLIB.Topic({ ros, name: '/gimbal/state', messageType: 'geometry_msgs/Vector3', throttle_rate: 1000, }); gimbalTopic.subscribe(() => { markPanelLive('gimbal'); }); // ── VESC left state (for CAN monitor card liveness) ── const vescWatch = new ROSLIB.Topic({ ros, name: '/vesc/left/state', messageType: 'std_msgs/String', throttle_rate: 1000, }); vescWatch.subscribe(() => { markPanelLive('can'); }); // ── GPS fix (for GPS map card liveness) ── const gpsWatch = new ROSLIB.Topic({ ros, name: 'saltybot/gps/fix', messageType: 'std_msgs/String', throttle_rate: 2000, }); gpsWatch.subscribe(() => { markPanelLive('gps'); }); // ── cmd_vel monitor (for gamepad card liveness) ── const cmdVelWatch = new ROSLIB.Topic({ ros, name: '/cmd_vel', messageType: 'geometry_msgs/Twist', throttle_rate: 500, }); cmdVelWatch.subscribe(() => { markPanelLive('gamepad'); }); // ── Publisher topics ── cmdVelTopic = new ROSLIB.Topic({ ros, name: '/cmd_vel', messageType: 'geometry_msgs/Twist', }); modeTopic = new ROSLIB.Topic({ ros, name: '/saltybot/drive_mode', messageType: 'std_msgs/String', }); // ── Robot uptime from /diagnostics or fallback to /rosapi ── const upSvc = new ROSLIB.Service({ ros, name: '/rosapi/get_time', serviceType: 'rosapi/GetTime', }); upSvc.callService({}, () => { state.uptime = 0; if (uptimeInterval) clearInterval(uptimeInterval); uptimeInterval = setInterval(() => { state.uptime++; renderUptime(); }, 1000); }); } // ── E-stop ───────────────────────────────────────────────────────────────── function activateEstop(sendCmd = true) { state.estop = true; if (sendCmd && cmdVelTopic) { cmdVelTopic.publish(new ROSLIB.Message({ linear: { x: 0, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 }, })); } document.getElementById('btn-estop').style.display = 'none'; document.getElementById('btn-resume').style.display = ''; document.getElementById('btn-estop').classList.add('active'); document.getElementById('val-estop').textContent = 'ACTIVE'; document.getElementById('val-estop').style.color = '#ef4444'; // Flash all cards document.querySelectorAll('.panel-card').forEach(c => c.classList.add('estop-flash')); } function resumeFromEstop() { state.estop = false; document.getElementById('btn-estop').style.display = ''; document.getElementById('btn-resume').style.display = 'none'; document.getElementById('btn-estop').classList.remove('active'); document.getElementById('val-estop').textContent = 'OFF'; document.getElementById('val-estop').style.color = '#6b7280'; document.querySelectorAll('.panel-card').forEach(c => c.classList.remove('estop-flash')); } document.getElementById('btn-estop').addEventListener('click', () => activateEstop(true)); document.getElementById('btn-resume').addEventListener('click', resumeFromEstop); // Space bar = emergency E-stop from dashboard document.addEventListener('keydown', e => { if (e.code === 'Space' && e.target.tagName !== 'INPUT') { e.preventDefault(); if (state.estop) resumeFromEstop(); else activateEstop(true); } }); // ── Drive mode buttons ───────────────────────────────────────────────────── document.querySelectorAll('.mode-btn').forEach(btn => { btn.addEventListener('click', () => { state.driveMode = btn.dataset.mode; if (modeTopic) { modeTopic.publish(new ROSLIB.Message({ data: state.driveMode })); } renderMode(); }); }); // ── Panel liveness tracking ──────────────────────────────────────────────── const LIVE_TIMEOUT_MS = 5000; function markPanelLive(id) { state.panelTs[id] = Date.now(); renderPanelCard(id); } function renderPanelCard(id) { const ts = state.panelTs[id]; const dot = document.getElementById(`dot-${id}`); const statusEl = document.getElementById(`status-${id}`); const msgEl = document.getElementById(`msg-${id}`); if (!dot || !statusEl) return; if (!state.connected) { dot.className = 'card-dot'; statusEl.className = 'card-status'; statusEl.textContent = 'OFFLINE'; if (msgEl) msgEl.textContent = 'Not connected'; return; } // Settings card is always config if (id === 'settings') return; // Gamepad is output only — show "READY" when connected if (id === 'gamepad') { const age = ts ? Date.now() - ts : Infinity; if (age < LIVE_TIMEOUT_MS) { dot.className = 'card-dot live'; statusEl.className = 'card-status live'; statusEl.textContent = 'ACTIVE'; if (msgEl) msgEl.textContent = 'Receiving /cmd_vel'; } else { dot.className = 'card-dot'; statusEl.className = 'card-status'; statusEl.textContent = 'READY'; if (msgEl) msgEl.textContent = 'Awaiting input'; } return; } if (!ts) { dot.className = 'card-dot idle'; statusEl.className = 'card-status idle'; statusEl.textContent = 'IDLE'; if (msgEl) msgEl.textContent = 'No messages yet'; return; } const age = Date.now() - ts; if (age < LIVE_TIMEOUT_MS) { dot.className = 'card-dot live'; statusEl.className = 'card-status live'; statusEl.textContent = 'LIVE'; if (msgEl) msgEl.textContent = `${(age / 1000).toFixed(1)}s ago`; } else { dot.className = 'card-dot idle'; statusEl.className = 'card-status idle'; statusEl.textContent = 'IDLE'; if (msgEl) msgEl.textContent = `${Math.floor(age / 1000)}s ago`; } } // ── Render helpers ───────────────────────────────────────────────────────── function renderBattery() { const el = document.getElementById('val-battery'); const fill = document.getElementById('batt-fill'); if (state.battery === null) { el.textContent = '—'; return; } const pct = state.battery.toFixed(0); el.textContent = `${pct}%${state.voltage ? ' · ' + state.voltage.toFixed(1) + 'V' : ''}`; fill.style.width = pct + '%'; fill.style.background = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'; el.style.color = pct > 50 ? '#d1d5db' : pct > 20 ? '#f59e0b' : '#ef4444'; } function renderSafety() { const el = document.getElementById('val-safety'); if (!state.safetyState) { el.textContent = '—'; return; } const s = state.safetyState.toUpperCase(); el.textContent = s + (state.closestM != null ? ` · ${state.closestM.toFixed(2)}m` : ''); el.style.color = s === 'DANGER' || s === 'ESTOP' ? '#ef4444' : s === 'WARN' ? '#f59e0b' : '#22c55e'; } function renderMode() { document.getElementById('val-mode').textContent = state.driveMode; document.querySelectorAll('.mode-btn').forEach(b => { b.classList.toggle('active', b.dataset.mode === state.driveMode); }); } function renderUptime() { const el = document.getElementById('val-uptime'); if (!state.connected) { el.textContent = '—'; return; } const s = state.uptime; el.textContent = s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m ${s%60}s` : `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`; } function renderSession() { if (!state.sessionStart) return; const secs = Math.floor((Date.now() - state.sessionStart) / 1000); const h = String(Math.floor(secs / 3600)).padStart(2, '0'); const m = String(Math.floor((secs % 3600) / 60)).padStart(2, '0'); const s = String(secs % 60).padStart(2, '0'); document.getElementById('session-time').textContent = `${h}:${m}:${s}`; } function renderMsgRate() { const now = performance.now(); if (state.lastMsgTs) { const dt = (now - state.lastMsgTs) / 1000; document.getElementById('info-rate').textContent = dt < 10 ? Math.round(1 / dt) + ' msg/s' : '—'; } } // ── Latency ping ─────────────────────────────────────────────────────────── function schedulePing() { if (pingTimeout) clearTimeout(pingTimeout); pingTimeout = setTimeout(() => { if (!ros || !state.connected) return; const svc = new ROSLIB.Service({ ros, name: '/rosapi/get_time', serviceType: 'rosapi/GetTime' }); pingTs = performance.now(); svc.callService({}, () => { state.latency = Math.round(performance.now() - pingTs); document.getElementById('info-latency').textContent = state.latency + ' ms'; document.getElementById('info-ip').textContent = $wsInput.value.replace('ws://','').split(':')[0]; schedulePing(); }, () => { schedulePing(); }); }, 5000); } // ── Auto-detect ──────────────────────────────────────────────────────────── const $detectStatus = document.getElementById('detect-status'); const $detectDots = document.getElementById('detect-dots'); const $detectBar = document.getElementById('detect-bar'); function updateDetectBar(cls, msg) { $detectBar.className = cls; $detectStatus.textContent = msg; } function buildDetectDots() { $detectDots.innerHTML = AUTO_CANDIDATES.map((_, i) => `
` ).join(''); } function tryAutoDetect(idx) { if (idx >= AUTO_CANDIDATES.length) { updateDetectBar('error', '✗ Auto-detect failed — enter URL manually'); return; } const url = AUTO_CANDIDATES[idx]; const dot = document.getElementById(`ddot-${idx}`); if (dot) dot.className = 'detect-dot trying'; updateDetectBar('', `🔍 Trying ${url}…`); const testRos = new ROSLIB.Ros({ url }); const timer = setTimeout(() => { testRos.close(); if (dot) dot.className = 'detect-dot fail'; tryAutoDetect(idx + 1); }, 2000); testRos.on('connection', () => { clearTimeout(timer); if (dot) dot.className = 'detect-dot ok'; // Mark remaining as skipped for (let j = idx + 1; j < AUTO_CANDIDATES.length; j++) { const d = document.getElementById(`ddot-${j}`); if (d) d.className = 'detect-dot'; } testRos.close(); // Connect for real connect(url); }); testRos.on('error', () => { clearTimeout(timer); if (dot) dot.className = 'detect-dot fail'; tryAutoDetect(idx + 1); }); } // ── Connection bar ───────────────────────────────────────────────────────── const $connDot = document.getElementById('conn-dot'); const $connLabel = document.getElementById('conn-label'); const $wsInput = document.getElementById('ws-input'); document.getElementById('btn-connect').addEventListener('click', () => { connect($wsInput.value.trim() || 'ws://localhost:9090'); }); // ── Liveness refresh loop ────────────────────────────────────────────────── setInterval(() => { PANELS.forEach(p => renderPanelCard(p.id)); renderMsgRate(); renderSession(); }, 1000); // ── Session timer ────────────────────────────────────────────────────────── state.sessionStart = new Date(); setInterval(renderSession, 1000); // ── Init ─────────────────────────────────────────────────────────────────── buildDetectDots(); // Restore saved URL or auto-detect const savedUrl = localStorage.getItem('dashboard_ws_url'); if (savedUrl) { $wsInput.value = savedUrl; updateDetectBar('', `⟳ Reconnecting to ${savedUrl}…`); connect(savedUrl); } else { tryAutoDetect(0); }