Merge pull request 'feat: VESC motor dashboard panel (Issue #653)' (#662) from sl-webui/issue-653-vesc-panel into main
This commit is contained in:
commit
cfdd74a9dc
246
ui/vesc_panel.css
Normal file
246
ui/vesc_panel.css
Normal 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
229
ui/vesc_panel.html
Normal 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.0–16.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.0–16.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 (0–120 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: <13.2V warn · <12.4V critical · FET >70°C warn · Motor >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
590
ui/vesc_panel.js
Normal 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);
|
||||||
Loading…
x
Reference in New Issue
Block a user