/** * vesc_panel.js — Saltybot VESC Motor Dashboard (Issue #653) * * Subscribes via rosbridge WebSocket to: * /vesc/left/state std_msgs/String (JSON) — left VESC telemetry * /vesc/right/state std_msgs/String (JSON) — right VESC telemetry * /vesc/combined std_msgs/String (JSON) — battery voltage + totals * * JSON fields per motor state: * can_id, rpm, current_a, current_in_a, duty_cycle, voltage_v, * temp_fet_c, temp_motor_c, fault_code, fault_name, alive, stamp * * JSON fields for combined: * voltage_v, total_current_a, left_rpm, right_rpm, * left_alive, right_alive, stamp * * E-stop publishes: * /saltybot/emergency std_msgs/String — JSON estop event * /cmd_vel geometry_msgs/Twist — zero velocity */ 'use strict'; // ── Thresholds ───────────────────────────────────────────────────────────────── const RPM_MAX = 8000; // full-scale for arc gauge const RPM_WARN = 5600; // 70% const RPM_CRIT = 7200; // 90% const CUR_MAX = 60.0; // A — overcurrent threshold from node params const CUR_WARN = 40.0; const CUR_CRIT = 54.0; const TFET_WARN = 70.0; // °C const TFET_CRIT = 80.0; const TMOT_WARN = 85.0; // °C const TMOT_CRIT = 100.0; const VBATT_MIN = 12.0; // V — 4S LiPo empty const VBATT_MAX = 16.8; // V — 4S LiPo full const VBATT_WARN = 13.2; // ~15% SOC const VBATT_CRIT = 12.4; // ~5% SOC const DUTY_WARN = 0.85; const DUTY_CRIT = 0.95; // Sparkline: sample every 500ms, keep 120 points = 60s history const SPARK_INTERVAL = 500; // ms const SPARK_MAX_PTS = 120; // Stale threshold: if no update in 2s, mark offline const STALE_MS = 2000; // ── Colors ──────────────────────────────────────────────────────────────────── const C_GREEN = '#22c55e'; const C_AMBER = '#f59e0b'; const C_RED = '#ef4444'; const C_CYAN = '#06b6d4'; const C_DIM = '#374151'; const C_MID = '#6b7280'; const C_HI = '#d1d5db'; function healthColor(val, warn, crit) { if (val >= crit) return C_RED; if (val >= warn) return C_AMBER; return C_GREEN; } // ── State ───────────────────────────────────────────────────────────────────── const motors = { left: { state: null, lastMs: 0, rpmHist: [], curHist: [], sparkTs: 0 }, right: { state: null, lastMs: 0, rpmHist: [], curHist: [], sparkTs: 0 }, }; let combined = null; let combinedLastMs = 0; let ros = null; let subLeft = null; let subRight = null; let subComb = null; let pubEmerg = null; let pubCmdVel = null; let msgCount = 0; let hzTs = Date.now(); let hzCounter = 0; let staleTimer = null; // ── DOM helpers ─────────────────────────────────────────────────────────────── function $(id) { return document.getElementById(id); } function setBar(id, pct, color) { const el = $(id); if (!el) return; el.style.width = Math.min(100, Math.max(0, pct * 100)).toFixed(1) + '%'; el.style.background = color; } function setText(id, text) { const el = $(id); if (el) el.textContent = text; } function setColor(id, color) { const el = $(id); if (el) el.style.color = color; } // ── Badge helpers ───────────────────────────────────────────────────────────── function setBadge(id, text, cls) { const el = $(id); if (!el) return; el.textContent = text; el.className = 'sys-badge ' + cls; } function setFaultBadge(side, text, cls) { const el = $('fault-' + side); if (!el) return; el.textContent = text; el.className = 'fault-badge ' + cls; } // ── Arc gauge (canvas) ──────────────────────────────────────────────────────── // // Standard dashboard semicircle: 225° → 315° going the "long way" (270° sweep) // In canvas coords (0 = right, clockwise): // startAngle = 0.75π (135° → bottom-left area) // endAngle = 2.25π (405° = bottom-right area) function drawArcGauge(canvasId, value, maxVal, color) { const canvas = $(canvasId); if (!canvas) return; // Match pixel size to CSS size const rect = canvas.getBoundingClientRect(); if (rect.width > 0) canvas.width = rect.width * devicePixelRatio; if (rect.height > 0) canvas.height = rect.height * devicePixelRatio; const ctx = canvas.getContext('2d'); const dpr = devicePixelRatio || 1; const W = canvas.width; const H = canvas.height; const cx = W / 2; const cy = H * 0.62; const r = Math.min(W, H) * 0.37; const lw = Math.max(6, r * 0.18); const SA = Math.PI * 0.75; // start: 135° const EA = Math.PI * 2.25; // end: 405° (≡ 45°) const pct = Math.min(1, Math.max(0, Math.abs(value) / maxVal)); const VA = SA + pct * (EA - SA); ctx.clearRect(0, 0, W, H); // Background arc ctx.beginPath(); ctx.arc(cx, cy, r, SA, EA); ctx.strokeStyle = '#0c2a3a'; ctx.lineWidth = lw; ctx.lineCap = 'butt'; ctx.stroke(); // Value arc if (pct > 0.003) { ctx.beginPath(); ctx.arc(cx, cy, r, SA, VA); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke(); } // Center RPM value ctx.fillStyle = C_HI; ctx.font = `bold ${Math.round(r * 0.44)}px "Courier New"`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(Math.abs(value).toLocaleString(), cx, cy); // "RPM" label ctx.fillStyle = C_MID; ctx.font = `${Math.round(r * 0.22)}px "Courier New"`; ctx.fillText('RPM', cx, cy + r * 0.48); } // ── Sparkline (canvas) ──────────────────────────────────────────────────────── function drawSparkline(canvasId, history, color, maxVal) { const canvas = $(canvasId); if (!canvas || history.length < 2) { if (canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); } return; } const rect = canvas.getBoundingClientRect(); if (rect.width > 0) { canvas.width = rect.width * devicePixelRatio; canvas.height = canvas.offsetHeight * devicePixelRatio; } const ctx = canvas.getContext('2d'); const W = canvas.width; const H = canvas.height; const pad = 4; ctx.clearRect(0, 0, W, H); const n = history.length; const scale = maxVal > 0 ? maxVal : 1; function ptX(i) { return pad + (i / (n - 1)) * (W - pad * 2); } function ptY(v) { return H - pad - (Math.max(0, Math.min(scale, v)) / scale) * (H - pad * 2); } // Fill ctx.beginPath(); ctx.moveTo(ptX(0), H); for (let i = 0; i < n; i++) ctx.lineTo(ptX(i), ptY(history[i])); ctx.lineTo(ptX(n - 1), H); ctx.closePath(); ctx.fillStyle = color + '28'; ctx.fill(); // Line ctx.beginPath(); ctx.moveTo(ptX(0), ptY(history[0])); for (let i = 1; i < n; i++) ctx.lineTo(ptX(i), ptY(history[i])); ctx.strokeStyle = color; ctx.lineWidth = 1.5 * devicePixelRatio; ctx.lineJoin = 'round'; ctx.stroke(); } // ── History helpers ─────────────────────────────────────────────────────────── function pushHistory(arr, val) { arr.push(val); if (arr.length > SPARK_MAX_PTS) arr.shift(); } // ── Motor rendering ─────────────────────────────────────────────────────────── function renderMotor(side, s) { const isAlive = s && s.alive; const hasFault = isAlive && s.fault_code !== 0; const card = $('card-' + side); if (!isAlive) { setFaultBadge(side, 'OFFLINE', 'offline'); setBadge('badge-' + side, 'OFFLINE', 'badge-stale'); card.className = 'card motor-card state-offline'; drawArcGauge('rpm-arc-' + side, 0, RPM_MAX, C_DIM); setText('dir-' + side, '—'); setText('cur-' + side, '—'); setText('curin-' + side, '—'); setText('duty-' + side, '—'); setBar('cur-bar-' + side, 0, C_DIM); setBar('curin-bar-' + side, 0, C_DIM); setBar('duty-bar-' + side, 0, C_DIM); setText('tfet-' + side, '—'); setText('tmot-' + side, '—'); setBar('tfet-bar-' + side, 0, C_DIM); setBar('tmot-bar-' + side, 0, C_DIM); $('tbox-fet-' + side).className = 'temp-box'; $('tbox-mot-' + side).className = 'temp-box'; return; } const rpm = s.rpm; const cur = Math.abs(s.current_a); const curIn = Math.abs(s.current_in_a); const duty = Math.abs(s.duty_cycle); const tfet = s.temp_fet_c; const tmot = s.temp_motor_c; const faultStr = hasFault ? s.fault_name.replace('FAULT_CODE_', '') : 'OK'; // Overall card health const isWarn = hasFault || cur >= CUR_WARN || duty >= DUTY_WARN || tfet >= TFET_WARN || tmot >= TMOT_WARN; const isFault = hasFault || cur >= CUR_CRIT || duty >= DUTY_CRIT || tfet >= TFET_CRIT || tmot >= TMOT_CRIT; if (isFault) { card.className = 'card motor-card state-fault'; setBadge('badge-' + side, hasFault ? faultStr : 'CRIT', 'badge-error'); setFaultBadge(side, faultStr, 'fault'); } else if (isWarn) { card.className = 'card motor-card state-warn'; setBadge('badge-' + side, 'WARN', 'badge-warn'); setFaultBadge(side, 'WARN', 'warn'); } else { card.className = 'card motor-card'; setBadge('badge-' + side, 'OK', 'badge-ok'); setFaultBadge(side, 'OK', ''); } // RPM arc gauge const rpmColor = healthColor(Math.abs(rpm), RPM_WARN, RPM_CRIT); drawArcGauge('rpm-arc-' + side, rpm, RPM_MAX, rpmColor); // Direction indicator const dirEl = $('dir-' + side); if (Math.abs(rpm) < 30) { dirEl.textContent = 'STOP'; dirEl.style.color = C_MID; } else if (rpm > 0) { dirEl.textContent = 'FWD ▲'; dirEl.style.color = C_GREEN; } else { dirEl.textContent = '▼ REV'; dirEl.style.color = C_AMBER; } // Current (motor) const curColor = healthColor(cur, CUR_WARN, CUR_CRIT); setText('cur-' + side, cur.toFixed(1) + ' A'); setColor('cur-' + side, curColor); setBar('cur-bar-' + side, cur / CUR_MAX, curColor); // Current (input) const curInColor = healthColor(curIn, CUR_WARN * 0.8, CUR_CRIT * 0.8); setText('curin-' + side, curIn.toFixed(1) + ' A'); setColor('curin-' + side, curInColor); setBar('curin-bar-' + side, curIn / CUR_MAX, curInColor); // Duty cycle const dutyColor = healthColor(duty, DUTY_WARN, DUTY_CRIT); setText('duty-' + side, (duty * 100).toFixed(1) + '%'); setColor('duty-' + side, dutyColor); setBar('duty-bar-' + side, duty, dutyColor); // FET temperature const tfetColor = healthColor(tfet, TFET_WARN, TFET_CRIT); setText('tfet-' + side, tfet.toFixed(1) + '°'); setColor('tfet-' + side, tfetColor); setBar('tfet-bar-' + side, tfet / TFET_CRIT, tfetColor); $('tbox-fet-' + side).className = 'temp-box' + (tfet >= TFET_CRIT ? ' crit' : tfet >= TFET_WARN ? ' warn' : ''); // Motor temperature const tmotColor = healthColor(tmot, TMOT_WARN, TMOT_CRIT); setText('tmot-' + side, tmot.toFixed(1) + '°'); setColor('tmot-' + side, tmotColor); setBar('tmot-bar-' + side, tmot / TMOT_CRIT, tmotColor); $('tbox-mot-' + side).className = 'temp-box' + (tmot >= TMOT_CRIT ? ' crit' : tmot >= TMOT_WARN ? ' warn' : ''); // Sparklines const now = Date.now(); const m = motors[side]; if (now - m.sparkTs >= SPARK_INTERVAL) { pushHistory(m.rpmHist, Math.abs(rpm)); pushHistory(m.curHist, cur); m.sparkTs = now; } drawSparkline('spark-rpm-' + side, m.rpmHist, rpmColor, RPM_MAX); drawSparkline('spark-cur-' + side, m.curHist, curColor, CUR_MAX); } // ── Combined / battery rendering ────────────────────────────────────────────── function renderCombined(c) { if (!c) return; const volt = c.voltage_v; const totalCur = c.total_current_a; const leftRpm = c.left_rpm; const rightRpm = c.right_rpm; // Voltage health const voltColor = volt <= VBATT_CRIT ? C_RED : volt <= VBATT_WARN ? C_AMBER : C_GREEN; const voltPct = Math.max(0, Math.min(1, (volt - VBATT_MIN) / (VBATT_MAX - VBATT_MIN))); setText('batt-voltage', volt.toFixed(2)); setColor('batt-voltage', voltColor); setText('batt-volt-pct', (voltPct * 100).toFixed(0) + '%'); setBar('batt-volt-bar', voltPct, voltColor); // Total current const curColor = healthColor(totalCur, CUR_MAX * 0.8, CUR_MAX * 1.1); setText('batt-total-cur', totalCur.toFixed(1)); setColor('batt-total-cur', curColor); setText('batt-cur-pct', totalCur.toFixed(1) + ' A'); setBar('batt-cur-bar', totalCur / (CUR_MAX * 2), curColor); // RPM summary setText('batt-rpm-l', Math.abs(leftRpm).toLocaleString()); setText('batt-rpm-r', Math.abs(rightRpm).toLocaleString()); // Battery status badge if (volt <= VBATT_CRIT) { setBadge('badge-batt', volt.toFixed(2) + ' V', 'badge-error'); } else if (volt <= VBATT_WARN) { setBadge('badge-batt', volt.toFixed(2) + ' V', 'badge-warn'); } else { setBadge('badge-batt', volt.toFixed(2) + ' V', 'badge-ok'); } setBadge('badge-total', totalCur.toFixed(1) + ' A', curColor === C_RED ? 'badge-error' : curColor === C_AMBER ? 'badge-warn' : 'badge-ok'); const d = new Date(c.stamp * 1000); setText('batt-stamp', d.toLocaleTimeString('en-US', { hour12: false })); } // ── ROS connection ──────────────────────────────────────────────────────────── function connect() { const url = $('ws-input').value.trim(); if (!url) return; if (ros) { try { ros.close(); } catch (_) {} } ros = new ROSLIB.Ros({ url }); ros.on('connection', () => { $('conn-dot').className = 'connected'; $('conn-label').textContent = url; $('btn-connect').textContent = 'RECONNECT'; setupSubs(); setupPubs(); }); ros.on('error', (err) => { $('conn-dot').className = 'error'; $('conn-label').textContent = 'ERROR: ' + (err?.message || err); teardown(); }); ros.on('close', () => { $('conn-dot').className = ''; $('conn-label').textContent = 'Disconnected'; teardown(); }); } function setupSubs() { subLeft = new ROSLIB.Topic({ ros, name: '/vesc/left/state', messageType: 'std_msgs/String', throttle_rate: 100, }); subLeft.subscribe((msg) => { try { const s = JSON.parse(msg.data); motors.left.state = s; motors.left.lastMs = Date.now(); renderMotor('left', s); tickHz(); } catch (_) {} }); subRight = new ROSLIB.Topic({ ros, name: '/vesc/right/state', messageType: 'std_msgs/String', throttle_rate: 100, }); subRight.subscribe((msg) => { try { const s = JSON.parse(msg.data); motors.right.state = s; motors.right.lastMs = Date.now(); renderMotor('right', s); tickHz(); } catch (_) {} }); subComb = new ROSLIB.Topic({ ros, name: '/vesc/combined', messageType: 'std_msgs/String', throttle_rate: 100, }); subComb.subscribe((msg) => { try { combined = JSON.parse(msg.data); combinedLastMs = Date.now(); renderCombined(combined); } catch (_) {} }); // Stale check every second if (staleTimer) clearInterval(staleTimer); staleTimer = setInterval(checkStale, 1000); } function setupPubs() { pubEmerg = new ROSLIB.Topic({ ros, name: '/saltybot/emergency', messageType: 'std_msgs/String', }); pubCmdVel = new ROSLIB.Topic({ ros, name: '/cmd_vel', messageType: 'geometry_msgs/Twist', }); } function teardown() { if (subLeft) { subLeft.unsubscribe(); subLeft = null; } if (subRight) { subRight.unsubscribe(); subRight = null; } if (subComb) { subComb.unsubscribe(); subComb = null; } if (staleTimer) { clearInterval(staleTimer); staleTimer = null; } pubEmerg = null; pubCmdVel = null; motors.left.state = null; motors.right.state = null; renderMotor('left', null); renderMotor('right', null); } // ── Stale detection ─────────────────────────────────────────────────────────── function checkStale() { const now = Date.now(); if (motors.left.lastMs && now - motors.left.lastMs > STALE_MS) { motors.left.state = null; renderMotor('left', null); } if (motors.right.lastMs && now - motors.right.lastMs > STALE_MS) { motors.right.state = null; renderMotor('right', null); } } // ── Hz counter ──────────────────────────────────────────────────────────────── function tickHz() { hzCounter++; const now = Date.now(); const elapsedSec = (now - hzTs) / 1000; if (elapsedSec >= 1.0) { const hz = (hzCounter / elapsedSec).toFixed(1); setText('hz-label', hz + ' Hz'); setText('stamp-label', new Date().toLocaleTimeString('en-US', { hour12: false })); hzCounter = 0; hzTs = now; } } // ── E-stop ──────────────────────────────────────────────────────────────────── function fireEstop() { const btn = $('btn-estop'); btn.classList.add('fired'); setTimeout(() => btn.classList.remove('fired'), 2000); // Zero velocity if (pubCmdVel) { pubCmdVel.publish(new ROSLIB.Message({ linear: { x: 0.0, y: 0.0, z: 0.0 }, angular: { x: 0.0, y: 0.0, z: 0.0 }, })); } // Emergency event if (pubEmerg) { pubEmerg.publish(new ROSLIB.Message({ data: JSON.stringify({ type: 'ESTOP', source: 'vesc_panel', timestamp: Date.now() / 1000, msg: 'E-stop triggered from VESC motor dashboard (issue #653)', }), })); } } // ── Event wiring ────────────────────────────────────────────────────────────── $('btn-connect').addEventListener('click', connect); $('ws-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') connect(); }); $('btn-estop').addEventListener('click', fireEstop); // ── Init ────────────────────────────────────────────────────────────────────── const stored = localStorage.getItem('vesc_panel_ws'); if (stored) $('ws-input').value = stored; $('ws-input').addEventListener('change', (e) => { localStorage.setItem('vesc_panel_ws', e.target.value); }); // Initial empty state render renderMotor('left', null); renderMotor('right', null);