/** * diagnostics_panel.js — Saltybot System Diagnostics Dashboard (Issue #562) * * Connects to rosbridge WebSocket and subscribes to: * /diagnostics diagnostic_msgs/DiagnosticArray — everything * /saltybot/balance_state std_msgs/String (JSON) — motor cmd / state * * Diagnostic KeyValues decoded: * battery_voltage_v, battery_current_a, battery_soc_pct * cpu_temp_c, gpu_temp_c, board_temp_c * motor_temp_l_c, motor_temp_r_c * motor_current_l_a, motor_current_r_a * ram_used_mb, ram_total_mb * gpu_used_mb, gpu_total_mb * disk_used_gb, disk_total_gb * wifi_rssi_dbm, wifi_latency_ms * mqtt_connected * * Auto-refresh: rosbridge subscriptions push at ~2 Hz; UI polls canvas draws * at the same rate and updates DOM elements. */ 'use strict'; // ── Constants ───────────────────────────────────────────────────────────────── const LIPO_MIN = 12.0; // 4S empty const LIPO_MAX = 16.8; // 4S full const BATT_HISTORY_MAX = 120; // ~2 min at 1 Hz // Alert thresholds const THRESHOLDS = { voltage_warn: 13.6, voltage_crit: 13.0, soc_warn: 25, soc_crit: 10, cpu_temp_warn: 75, cpu_temp_crit: 90, gpu_temp_warn: 75, gpu_temp_crit: 90, motor_temp_warn: 70, motor_temp_crit: 85, ram_pct_warn: 80, ram_pct_crit: 95, disk_pct_warn: 80, disk_pct_crit: 95, rssi_warn: -70, rssi_crit: -80, latency_warn: 150, latency_crit: 500, current_warn: 8.0, current_crit: 12.0, }; // ── State ───────────────────────────────────────────────────────────────────── let ros = null; let diagSub = null; let balanceSub = null; const state = { // Battery voltage: null, current: null, soc: null, battHistory: [], // [{ts, v}] // Temperatures cpuTemp: null, gpuTemp: null, boardTemp: null, motorTempL: null, motorTempR: null, // Motor motorCurrentL: null, motorCurrentR: null, motorCmdL: null, motorCmdR: null, balanceState: 'UNKNOWN', // Resources ramUsed: null, ramTotal: null, gpuUsed: null, gpuTotal: null, diskUsed: null, diskTotal: null, // Network rssi: null, latency: null, // MQTT mqttConnected: null, // ROS nodes (DiagnosticStatus array) nodes: [], // Overall health overallLevel: 3, // 3=STALE by default lastUpdate: null, }; // ── Utility ─────────────────────────────────────────────────────────────────── function numOrNull(s) { const n = parseFloat(s); return isNaN(n) ? null : n; } function pct(used, total) { if (!total || total === 0) return 0; return Math.min(100, Math.max(0, (used / total) * 100)); } function socFromVoltage(v) { if (v == null || v <= 0) return null; return Math.max(0, Math.min(100, ((v - LIPO_MIN) / (LIPO_MAX - LIPO_MIN)) * 100)); } function threshColor(value, warnThr, critThr, invert = false) { // invert: lower is worse (e.g. voltage, SOC, RSSI) if (value == null) return '#6b7280'; const warn = invert ? value <= warnThr : value >= warnThr; const crit = invert ? value <= critThr : value >= critThr; if (crit) return '#ef4444'; if (warn) return '#f59e0b'; return '#22c55e'; } function levelClass(level) { return ['ns-ok', 'ns-warn', 'ns-error', 'ns-stale'][level] ?? 'ns-stale'; } function levelLabel(level) { return ['OK', 'WARN', 'ERROR', 'STALE'][level] ?? 'STALE'; } function setBadgeClass(el, level) { el.className = 'sys-badge ' + ['badge-ok','badge-warn','badge-error','badge-stale'][level ?? 3]; } // ── Connection ──────────────────────────────────────────────────────────────── function connect() { const url = document.getElementById('ws-input').value.trim(); if (!url) return; if (ros) ros.close(); ros = new ROSLIB.Ros({ url }); ros.on('connection', () => { document.getElementById('conn-dot').className = 'connected'; document.getElementById('conn-label').textContent = url; setupSubscriptions(); }); ros.on('error', (err) => { document.getElementById('conn-dot').className = 'error'; document.getElementById('conn-label').textContent = 'ERROR: ' + (err?.message || err); teardown(); }); ros.on('close', () => { document.getElementById('conn-dot').className = ''; document.getElementById('conn-label').textContent = 'Disconnected'; teardown(); }); } function setupSubscriptions() { // /diagnostics — throttle 500ms → ~2 Hz diagSub = new ROSLIB.Topic({ ros, name: '/diagnostics', messageType: 'diagnostic_msgs/DiagnosticArray', throttle_rate: 500, }); diagSub.subscribe(onDiagnostics); // /saltybot/balance_state balanceSub = new ROSLIB.Topic({ ros, name: '/saltybot/balance_state', messageType: 'std_msgs/String', throttle_rate: 500, }); balanceSub.subscribe(onBalanceState); } function teardown() { if (diagSub) { diagSub.unsubscribe(); diagSub = null; } if (balanceSub) { balanceSub.unsubscribe(); balanceSub = null; } state.overallLevel = 3; state.lastUpdate = null; render(); } // ── Message handlers ────────────────────────────────────────────────────────── function onDiagnostics(msg) { const statuses = msg.status ?? []; state.nodes = []; let overallLevel = 0; for (const status of statuses) { const kv = {}; for (const { key, value } of (status.values ?? [])) { kv[key] = value; } // Battery if ('battery_voltage_v' in kv) state.voltage = numOrNull(kv.battery_voltage_v); if ('battery_current_a' in kv) state.current = numOrNull(kv.battery_current_a); if ('battery_soc_pct' in kv) state.soc = numOrNull(kv.battery_soc_pct); // Temperatures if ('cpu_temp_c' in kv) state.cpuTemp = numOrNull(kv.cpu_temp_c); if ('gpu_temp_c' in kv) state.gpuTemp = numOrNull(kv.gpu_temp_c); if ('board_temp_c' in kv) state.boardTemp = numOrNull(kv.board_temp_c); if ('motor_temp_l_c' in kv) state.motorTempL = numOrNull(kv.motor_temp_l_c); if ('motor_temp_r_c' in kv) state.motorTempR = numOrNull(kv.motor_temp_r_c); // Motor current if ('motor_current_l_a' in kv) state.motorCurrentL = numOrNull(kv.motor_current_l_a); if ('motor_current_r_a' in kv) state.motorCurrentR = numOrNull(kv.motor_current_r_a); // Resources if ('ram_used_mb' in kv) state.ramUsed = numOrNull(kv.ram_used_mb); if ('ram_total_mb' in kv) state.ramTotal = numOrNull(kv.ram_total_mb); if ('gpu_used_mb' in kv) state.gpuUsed = numOrNull(kv.gpu_used_mb); if ('gpu_total_mb' in kv) state.gpuTotal = numOrNull(kv.gpu_total_mb); if ('disk_used_gb' in kv) state.diskUsed = numOrNull(kv.disk_used_gb); if ('disk_total_gb' in kv) state.diskTotal = numOrNull(kv.disk_total_gb); // Network if ('wifi_rssi_dbm' in kv) state.rssi = numOrNull(kv.wifi_rssi_dbm); if ('wifi_latency_ms' in kv) state.latency = numOrNull(kv.wifi_latency_ms); // MQTT if ('mqtt_connected' in kv) state.mqttConnected = kv.mqtt_connected === 'true'; // Node health state.nodes.push({ name: status.name || status.hardware_id || 'unknown', level: status.level ?? 3, message: status.message || '', }); overallLevel = Math.max(overallLevel, status.level ?? 0); } state.overallLevel = overallLevel; state.lastUpdate = new Date(); // Battery history if (state.voltage != null) { state.battHistory.push({ ts: Date.now(), v: state.voltage }); if (state.battHistory.length > BATT_HISTORY_MAX) { state.battHistory.shift(); } } // Derive SOC from voltage if not provided if (state.soc == null && state.voltage != null) { state.soc = socFromVoltage(state.voltage); } render(); flashRefreshDot(); } function onBalanceState(msg) { try { const data = JSON.parse(msg.data); state.balanceState = data.state ?? 'UNKNOWN'; state.motorCmdL = data.motor_cmd ?? null; state.motorCmdR = data.motor_cmd ?? null; // single motor_cmd field } catch (_) {} } // ── Refresh indicator ───────────────────────────────────────────────────────── function flashRefreshDot() { const dot = document.getElementById('refresh-dot'); dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400); } // ── Sparkline canvas ────────────────────────────────────────────────────────── function drawSparkline() { const canvas = document.getElementById('batt-sparkline'); if (!canvas) return; const ctx = canvas.getContext('2d'); const W = canvas.width = canvas.offsetWidth || 300; const H = canvas.height; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, W, H); const data = state.battHistory; if (data.length < 2) { ctx.fillStyle = '#374151'; ctx.font = '10px Courier New'; ctx.textAlign = 'center'; ctx.fillText('Awaiting battery data…', W / 2, H / 2 + 4); return; } const vMin = LIPO_MIN - 0.2; const vMax = LIPO_MAX + 0.2; // Grid lines ctx.strokeStyle = '#1e3a5f'; ctx.lineWidth = 0.5; [0.25, 0.5, 0.75].forEach((f) => { const y = H - f * H; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }); // Plot ctx.lineWidth = 1.5; ctx.beginPath(); data.forEach((pt, i) => { const x = (i / (data.length - 1)) * W; const y = H - ((pt.v - vMin) / (vMax - vMin)) * H; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); const lastV = data[data.length - 1].v; ctx.strokeStyle = threshColor(lastV, THRESHOLDS.voltage_warn, THRESHOLDS.voltage_crit, true); ctx.stroke(); // Fill under ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath(); ctx.fillStyle = 'rgba(6,182,212,0.06)'; ctx.fill(); // Labels ctx.fillStyle = '#374151'; ctx.font = '9px Courier New'; ctx.textAlign = 'left'; ctx.fillText(`${LIPO_MAX}V`, 2, 10); ctx.fillText(`${LIPO_MIN}V`, 2, H - 2); } // ── Gauge helpers ───────────────────────────────────────────────────────────── function setGauge(barId, pctVal, color) { const el = document.getElementById(barId); if (!el) return; el.style.width = `${Math.max(0, Math.min(100, pctVal))}%`; el.style.background = color; } function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text ?? '—'; } function setColor(id, color) { const el = document.getElementById(id); if (el) el.style.color = color; } function setTempBox(id, tempC, warnT, critT) { const box = document.getElementById(id + '-box'); const val = document.getElementById(id + '-val'); const bar = document.getElementById(id + '-bar'); const color = threshColor(tempC, warnT, critT); if (val) { val.textContent = tempC != null ? tempC.toFixed(0) + '°C' : '—'; val.style.color = color; } if (bar) { const p = tempC != null ? Math.min(100, (tempC / 100) * 100) : 0; bar.style.width = p + '%'; bar.style.background = color; } if (box && tempC != null) { box.style.borderColor = tempC >= critT ? '#991b1b' : tempC >= warnT ? '#92400e' : '#1e3a5f'; } } // ── RSSI bars ───────────────────────────────────────────────────────────────── function rssiToLevel(dBm) { if (dBm == null) return { label: 'N/A', color: '#374151', bars: 0 }; if (dBm >= -50) return { label: 'Excellent', color: '#22c55e', bars: 5 }; if (dBm >= -60) return { label: 'Good', color: '#3b82f6', bars: 4 }; if (dBm >= -70) return { label: 'Fair', color: '#f59e0b', bars: 3 }; if (dBm >= -80) return { label: 'Weak', color: '#ef4444', bars: 2 }; return { label: 'Poor', color: '#7f1d1d', bars: 1 }; } function drawRssiBars() { const container = document.getElementById('rssi-bars'); if (!container) return; const lv = rssiToLevel(state.rssi); const BAR_H = [4, 8, 12, 16, 20]; container.innerHTML = ''; for (let i = 0; i < 5; i++) { const bar = document.createElement('div'); bar.className = 'rssi-bar'; bar.style.height = BAR_H[i] + 'px'; bar.style.width = '7px'; bar.style.background = i < lv.bars ? lv.color : '#1f2937'; container.appendChild(bar); } } // ── Node list renderer ──────────────────────────────────────────────────────── function renderNodes() { const container = document.getElementById('node-list'); if (!container) return; if (state.nodes.length === 0) { container.innerHTML = '