diff --git a/ui/vesc_panel.css b/ui/vesc_panel.css
new file mode 100644
index 0000000..6e30f24
--- /dev/null
+++ b/ui/vesc_panel.css
@@ -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; }
+}
diff --git a/ui/vesc_panel.html b/ui/vesc_panel.html
new file mode 100644
index 0000000..0a14356
--- /dev/null
+++ b/ui/vesc_panel.html
@@ -0,0 +1,229 @@
+
+
+
+
+
+Saltybot — VESC Motor Dashboard
+
+
+
+
+
+
+
+
+
+
+ LEFT
+ OFFLINE
+ │
+ RIGHT
+ OFFLINE
+ │
+ BATTERY
+ —
+ │
+ TOTAL DRAW
+ —
+
+
+
+
+
+
+
+
+
+
+ LEFT MOTOR
+ OK
+
+
+
+
+
+
+
+ CURRENT (MTR)
+ —
+
+
+
+
+ CURRENT (IN)
+ —
+
+
+
+
+ DUTY CYCLE
+ —
+
+
+
+
+
+
+
+
+
+
+
RPM · 60s
+
+
CURRENT · 60s
+
+
+
+
+
+
+
+ RIGHT MOTOR
+ OK
+
+
+
+
+
+
+ CURRENT (MTR)
+ —
+
+
+
+
+ CURRENT (IN)
+ —
+
+
+
+
+ DUTY CYCLE
+ —
+
+
+
+
+
+
+
+
+
RPM · 60s
+
+
CURRENT · 60s
+
+
+
+
+
+
+
+ BATTERY — 4S LiPo (12.0–16.8 V)
+ —
+
+
+
+
+
+
+
+ Voltage (12.0–16.8 V)
+ —
+
+
+
+
+
+
+
+ Total Current (0–120 A)
+ —
+
+
+
+
+
+ Voltage zones: <13.2V warn · <12.4V critical · FET >70°C warn · Motor >85°C warn
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/vesc_panel.js b/ui/vesc_panel.js
new file mode 100644
index 0000000..f9c3a9b
--- /dev/null
+++ b/ui/vesc_panel.js
@@ -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);