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 + +
+
+
+
+ + +
+
+
FET TEMP
+
+
+
+
+
MOTOR TEMP
+
+
+
+
+ + +
+
RPM · 60s
+ +
CURRENT · 60s
+ +
+
+ + +
+
+ RIGHT MOTOR + OK +
+ +
+
+ +
+
+
+
+ CURRENT (MTR) + +
+
+ +
+ CURRENT (IN) + +
+
+ +
+ DUTY CYCLE + +
+
+
+
+ +
+
+
FET TEMP
+
+
+
+
+
MOTOR TEMP
+
+
+
+
+ +
+
RPM · 60s
+ +
CURRENT · 60s
+ +
+
+ + +
+
+ BATTERY — 4S LiPo (12.0–16.8 V) + +
+ +
+ +
+
VOLTAGE
+
+
V
+
+
+
TOTAL DRAW
+
+
A
+
+
+
LEFT RPM
+
+
+
+
RIGHT RPM
+
+
+
+ + +
+
+ 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);