feat: VESC motor dashboard panel (Issue #653) #662
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