saltylab-firmware/ui/vesc_panel.js
sl-webui 89f892e5ef feat: VESC motor dashboard panel (Issue #653)
Standalone panel ui/vesc_panel.{html,js,css} with live CAN telemetry
via rosbridge. Subscribes to /vesc/left/state, /vesc/right/state
(std_msgs/String JSON) and /vesc/combined for battery voltage.

Features:
- Canvas arc gauge per motor showing RPM + direction (FWD/REV/STOP)
- Current draw bar (motor + input), duty cycle bar, temperature bars
- FET and motor temperature boxes with warn/crit colour coding
- Sparkline charts for RPM and current (last 60 s, 120 samples)
- Battery card: voltage, total draw, both RPMs, SOC progress bar
- Colour-coded health: green/amber/red at configurable thresholds
- E-stop button: publishes zero /cmd_vel + /saltybot/emergency event
- Stale detection (2 s timeout → OFFLINE state)
- Hz counter + last-stamp display in header
- Mobile-responsive layout (single-column below 640 px)
- WS URL persisted in localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:35:35 -04:00

591 lines
20 KiB
JavaScript

/**
* 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);