feat: VESC motor dashboard panel (Issue #653) #662

Merged
sl-jetson merged 1 commits from sl-webui/issue-653-vesc-panel into main 2026-03-18 07:56:44 -04:00
3 changed files with 1065 additions and 0 deletions
Showing only changes of commit 89f892e5ef - Show all commits

246
ui/vesc_panel.css Normal file
View File

@ -0,0 +1,246 @@
/* vesc_panel.css — Saltybot VESC Motor Dashboard (Issue #653) */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg0: #050510;
--bg1: #070712;
--bg2: #0a0a1a;
--bd: #0c2a3a;
--bd2: #1e3a5f;
--dim: #374151;
--mid: #6b7280;
--base: #9ca3af;
--hi: #d1d5db;
--cyan: #06b6d4;
--green: #22c55e;
--amber: #f59e0b;
--red: #ef4444;
--orange: #f97316;
--purple: #a855f7;
}
body {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
background: var(--bg0);
color: var(--base);
min-height: 100dvh;
display: flex;
flex-direction: column;
}
/* ── Header ── */
#header {
display: flex;
align-items: center;
padding: 6px 16px;
background: var(--bg1);
border-bottom: 1px solid var(--bd);
flex-shrink: 0;
gap: 10px;
flex-wrap: wrap;
}
.logo { color: var(--orange); font-weight: bold; letter-spacing: 0.15em; font-size: 13px; flex-shrink: 0; }
#conn-bar { display: flex; align-items: center; gap: 6px; }
#header-right { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.meta-label { font-size: 10px; color: var(--mid); }
#conn-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--dim); flex-shrink: 0; transition: background 0.3s;
}
#conn-dot.connected { background: var(--green); }
#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
#ws-input {
background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
color: #67e8f9; padding: 2px 8px; font-family: monospace; font-size: 11px; width: 200px;
}
#ws-input:focus { outline: none; border-color: var(--cyan); }
.hdr-btn {
padding: 3px 10px; border-radius: 4px; border: 1px solid var(--bd2);
background: var(--bg2); color: #67e8f9; font-family: monospace;
font-size: 10px; font-weight: bold; cursor: pointer; transition: background 0.15s;
white-space: nowrap;
}
.hdr-btn:hover { background: #0e4f69; }
/* ── Status bar ── */
#status-bar {
display: flex; gap: 8px; align-items: center;
padding: 4px 16px; background: var(--bg1);
border-bottom: 1px solid var(--bd); font-size: 10px; flex-wrap: wrap;
}
.sys-badge {
padding: 2px 8px; border-radius: 3px; font-weight: bold;
border: 1px solid; letter-spacing: 0.05em; font-size: 10px; white-space: nowrap;
}
.badge-ok { background: #052e16; border-color: #166534; color: #4ade80; }
.badge-warn { background: #451a03; border-color: #92400e; color: #fcd34d; }
.badge-error { background: #450a0a; border-color: #991b1b; color: #f87171; animation: blink 1s infinite; }
.badge-stale { background: #111827; border-color: #374151; color: #6b7280; }
/* E-stop button */
.estop-btn {
margin-left: auto;
padding: 4px 18px;
border-radius: 5px;
border: 2px solid #991b1b;
background: #450a0a;
color: #f87171;
font-family: monospace;
font-size: 11px;
font-weight: bold;
cursor: pointer;
letter-spacing: 0.1em;
transition: background 0.15s, transform 0.1s;
}
.estop-btn:hover { background: #7f1d1d; border-color: var(--red); }
.estop-btn:active { transform: scale(0.96); }
.estop-btn.fired {
background: var(--red); color: #fff;
border-color: #fca5a5; animation: blink 0.5s 3;
}
/* ── Dashboard grid ── */
#dashboard {
flex: 1;
display: grid;
grid-template-columns: repeat(2, 1fr) 1.1fr;
gap: 12px;
padding: 12px;
align-content: start;
overflow-y: auto;
}
@media (max-width: 1100px) { #dashboard { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { #dashboard { grid-template-columns: 1fr; } }
/* ── Card ── */
.card {
background: var(--bg1);
border: 1px solid var(--bd);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card.state-offline { border-color: #374151; opacity: 0.65; }
.card.state-warn { border-color: #92400e; }
.card.state-fault { border-color: #991b1b; }
.card-title {
font-size: 9px; font-weight: bold; letter-spacing: 0.15em;
color: #0891b2; text-transform: uppercase;
display: flex; align-items: center; justify-content: space-between;
}
/* Fault badge */
.fault-badge {
padding: 1px 8px; border-radius: 3px; font-size: 9px; font-weight: bold;
letter-spacing: 0.08em; border: 1px solid;
background: #052e16; border-color: #166534; color: #4ade80;
}
.fault-badge.warn { background: #451a03; border-color: #92400e; color: #fcd34d; }
.fault-badge.fault { background: #450a0a; border-color: #991b1b; color: #f87171; animation: blink 1s infinite; }
.fault-badge.offline { background: #111827; border-color: #374151; color: #6b7280; }
/* ── Arc gauge wrap ── */
.gauge-row-top {
display: flex;
gap: 10px;
align-items: flex-start;
}
.arc-wrap {
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.arc-wrap canvas { display: block; }
.arc-dir {
font-size: 9px; font-weight: bold; letter-spacing: 0.1em;
color: var(--mid); text-align: center; margin-top: 2px;
}
/* ── Motor stats (right of arc) ── */
.motor-stats {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.stat-row {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 9px; margin-bottom: 2px;
}
.stat-label { color: var(--mid); }
.stat-val { font-family: monospace; font-size: 11px; color: var(--hi); }
/* ── Bar gauge ── */
.bar-track {
width: 100%; height: 6px;
background: var(--bg2); border-radius: 3px;
overflow: hidden; border: 1px solid var(--bd2);
}
.bar-track.mini { height: 4px; margin-top: 3px; }
.bar-fill {
height: 100%; width: 0%; border-radius: 3px;
background: var(--green);
transition: width 0.4s ease, background 0.4s ease;
}
/* ── Temperature boxes ── */
.temp-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
}
.temp-box {
background: var(--bg2); border: 1px solid var(--bd2);
border-radius: 6px; padding: 6px 8px; text-align: center;
}
.temp-box.warn { border-color: #92400e; }
.temp-box.crit { border-color: #991b1b; }
.temp-label { font-size: 8px; color: var(--mid); margin-bottom: 2px; letter-spacing: 0.08em; }
.temp-val { font-size: 18px; font-weight: bold; font-family: monospace; color: var(--hi); }
/* ── Sparklines ── */
.spark-section { display: flex; flex-direction: column; gap: 2px; }
.spark-label { font-size: 8px; color: var(--dim); letter-spacing: 0.05em; }
.sparkline {
width: 100%; display: block; border-radius: 3px;
border: 1px solid var(--bd2); background: var(--bg2);
}
/* ── Battery card ── */
.batt-row {
display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-end;
}
.batt-metric { display: flex; flex-direction: column; align-items: flex-start; }
.batt-metric-label { font-size: 8px; color: var(--mid); letter-spacing: 0.08em; margin-bottom: 2px; }
.big-num { font-size: 28px; font-weight: bold; font-family: monospace; color: var(--green); }
.batt-unit { font-size: 10px; color: var(--mid); }
/* ── Footer ── */
#footer {
background: var(--bg1); border-top: 1px solid var(--bd);
padding: 3px 16px;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0; font-size: 10px; color: var(--dim);
flex-wrap: wrap; gap: 6px;
}
/* ── Mobile tweaks ── */
@media (max-width: 640px) {
.gauge-row-top { flex-direction: column; align-items: center; }
.arc-wrap canvas { width: 120px; height: 86px; }
.motor-stats { width: 100%; }
.batt-row { gap: 10px; }
.big-num { font-size: 22px; }
#footer { flex-direction: column; align-items: flex-start; }
}

229
ui/vesc_panel.html Normal file
View File

@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>Saltybot — VESC Motor Dashboard</title>
<link rel="stylesheet" href="vesc_panel.css">
<script src="https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js"></script>
</head>
<body>
<!-- ── Header ── -->
<div id="header">
<div class="logo">⚡ SALTYBOT — VESC MOTORS</div>
<div id="conn-bar">
<div id="conn-dot"></div>
<input id="ws-input" type="text" value="ws://localhost:9090" placeholder="ws://robot-ip:9090" />
<button id="btn-connect" class="hdr-btn">CONNECT</button>
<span id="conn-label" style="color:#4b5563;font-size:10px">Not connected</span>
</div>
<div id="header-right">
<span id="hz-label" class="meta-label">— Hz</span>
<span style="color:#374151">|</span>
<span id="stamp-label" class="meta-label">No data</span>
</div>
</div>
<!-- ── Status bar ── -->
<div id="status-bar">
<span style="color:#6b7280;font-size:10px">LEFT</span>
<span class="sys-badge badge-stale" id="badge-left">OFFLINE</span>
<span style="color:#4b5563"></span>
<span style="color:#6b7280;font-size:10px">RIGHT</span>
<span class="sys-badge badge-stale" id="badge-right">OFFLINE</span>
<span style="color:#4b5563"></span>
<span style="color:#6b7280;font-size:10px">BATTERY</span>
<span class="sys-badge badge-stale" id="badge-batt"></span>
<span style="color:#4b5563"></span>
<span style="color:#6b7280;font-size:10px">TOTAL DRAW</span>
<span class="sys-badge badge-stale" id="badge-total"></span>
<button id="btn-estop" class="estop-btn">⛔ E-STOP</button>
</div>
<!-- ── Dashboard ── -->
<div id="dashboard">
<!-- ╔═════════ LEFT MOTOR ═════════╗ -->
<div class="card motor-card" id="card-left">
<div class="card-title">
LEFT MOTOR
<span class="fault-badge" id="fault-left">OK</span>
</div>
<!-- Arc gauge + direction -->
<div class="gauge-row-top">
<div class="arc-wrap">
<canvas id="rpm-arc-left" width="140" height="100"></canvas>
<div class="arc-dir" id="dir-left"></div>
</div>
<div class="motor-stats">
<div class="stat-row">
<span class="stat-label">CURRENT (MTR)</span>
<span class="stat-val" id="cur-left"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="cur-bar-left"></div></div>
<div class="stat-row" style="margin-top:6px">
<span class="stat-label">CURRENT (IN)</span>
<span class="stat-val" id="curin-left"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="curin-bar-left"></div></div>
<div class="stat-row" style="margin-top:6px">
<span class="stat-label">DUTY CYCLE</span>
<span class="stat-val" id="duty-left"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="duty-bar-left"></div></div>
</div>
</div>
<!-- Temperatures -->
<div class="temp-row">
<div class="temp-box" id="tbox-fet-left">
<div class="temp-label">FET TEMP</div>
<div class="temp-val" id="tfet-left"></div>
<div class="bar-track mini"><div class="bar-fill" id="tfet-bar-left"></div></div>
</div>
<div class="temp-box" id="tbox-mot-left">
<div class="temp-label">MOTOR TEMP</div>
<div class="temp-val" id="tmot-left"></div>
<div class="bar-track mini"><div class="bar-fill" id="tmot-bar-left"></div></div>
</div>
</div>
<!-- Sparklines -->
<div class="spark-section">
<div class="spark-label">RPM · 60s</div>
<canvas class="sparkline" id="spark-rpm-left" height="40"></canvas>
<div class="spark-label" style="margin-top:4px">CURRENT · 60s</div>
<canvas class="sparkline" id="spark-cur-left" height="40"></canvas>
</div>
</div>
<!-- ╔═════════ RIGHT MOTOR ═════════╗ -->
<div class="card motor-card" id="card-right">
<div class="card-title">
RIGHT MOTOR
<span class="fault-badge" id="fault-right">OK</span>
</div>
<div class="gauge-row-top">
<div class="arc-wrap">
<canvas id="rpm-arc-right" width="140" height="100"></canvas>
<div class="arc-dir" id="dir-right"></div>
</div>
<div class="motor-stats">
<div class="stat-row">
<span class="stat-label">CURRENT (MTR)</span>
<span class="stat-val" id="cur-right"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="cur-bar-right"></div></div>
<div class="stat-row" style="margin-top:6px">
<span class="stat-label">CURRENT (IN)</span>
<span class="stat-val" id="curin-right"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="curin-bar-right"></div></div>
<div class="stat-row" style="margin-top:6px">
<span class="stat-label">DUTY CYCLE</span>
<span class="stat-val" id="duty-right"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="duty-bar-right"></div></div>
</div>
</div>
<div class="temp-row">
<div class="temp-box" id="tbox-fet-right">
<div class="temp-label">FET TEMP</div>
<div class="temp-val" id="tfet-right"></div>
<div class="bar-track mini"><div class="bar-fill" id="tfet-bar-right"></div></div>
</div>
<div class="temp-box" id="tbox-mot-right">
<div class="temp-label">MOTOR TEMP</div>
<div class="temp-val" id="tmot-right"></div>
<div class="bar-track mini"><div class="bar-fill" id="tmot-bar-right"></div></div>
</div>
</div>
<div class="spark-section">
<div class="spark-label">RPM · 60s</div>
<canvas class="sparkline" id="spark-rpm-right" height="40"></canvas>
<div class="spark-label" style="margin-top:4px">CURRENT · 60s</div>
<canvas class="sparkline" id="spark-cur-right" height="40"></canvas>
</div>
</div>
<!-- ╔═════════ BATTERY / COMBINED ═════════╗ -->
<div class="card" id="card-batt">
<div class="card-title">
BATTERY — 4S LiPo (12.016.8 V)
<span class="meta-label" id="batt-stamp"></span>
</div>
<div class="batt-row">
<!-- Big voltage -->
<div class="batt-metric">
<div class="batt-metric-label">VOLTAGE</div>
<div class="big-num" id="batt-voltage"></div>
<div class="batt-unit">V</div>
</div>
<div class="batt-metric">
<div class="batt-metric-label">TOTAL DRAW</div>
<div class="big-num" id="batt-total-cur" style="color:#06b6d4"></div>
<div class="batt-unit">A</div>
</div>
<div class="batt-metric">
<div class="batt-metric-label">LEFT RPM</div>
<div class="big-num" id="batt-rpm-l" style="color:#a855f7;font-size:20px"></div>
</div>
<div class="batt-metric">
<div class="batt-metric-label">RIGHT RPM</div>
<div class="big-num" id="batt-rpm-r" style="color:#a855f7;font-size:20px"></div>
</div>
</div>
<!-- Voltage bar -->
<div>
<div class="stat-row">
<span class="stat-label">Voltage (12.016.8 V)</span>
<span class="stat-val" id="batt-volt-pct"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="batt-volt-bar"></div></div>
</div>
<!-- Total current bar -->
<div>
<div class="stat-row">
<span class="stat-label">Total Current (0120 A)</span>
<span class="stat-val" id="batt-cur-pct"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="batt-cur-bar"></div></div>
</div>
<div style="font-size:9px;color:#374151">
Voltage zones: &lt;13.2V warn · &lt;12.4V critical · FET &gt;70°C warn · Motor &gt;85°C warn
</div>
</div>
</div>
<!-- ── Footer ── -->
<div id="footer">
<span>rosbridge: <code id="footer-ws">ws://localhost:9090</code></span>
<span>topics: /vesc/left/state · /vesc/right/state · /vesc/combined (std_msgs/String JSON)</span>
<span>vesc motor dashboard — issue #653</span>
</div>
<script src="vesc_panel.js"></script>
<script>
document.getElementById('ws-input').addEventListener('input', (e) => {
document.getElementById('footer-ws').textContent = e.target.value;
});
</script>
</body>
</html>

590
ui/vesc_panel.js Normal file
View File

@ -0,0 +1,590 @@
/**
* 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);