Adds 6 telemetry tabs to the social-bot dashboard extending PR #112. IMU Panel (/saltybot/imu, /saltybot/balance_state): - Canvas artificial horizon + compass tape - Three.js 3D robot orientation cube with quaternion SLERP - Angular velocity readouts, balance state detail Battery Panel (/diagnostics): - Voltage, SoC, estimated current, runtime estimate - 120-point voltage history sparkline (2 min) - Reads battery_voltage_v, battery_soc_pct KeyValues from DiagnosticArray Motor Panel (/saltybot/balance_state, /saltybot/rover_pwm): - Auto-detects balance vs rover mode - Bidirectional duty bars for left/right motors - Motor temp from /diagnostics, PID detail for balance bot Map Viewer (/map, /odom, /outdoor/route): - OccupancyGrid canvas renderer (unknown/free/occupied colour-coded) - Robot position + heading arrow, Nav2/OSM path overlay (dashed) - Pan (mouse/touch) + zoom, 2 m scale bar Control Mode (/saltybot/control_mode): - RC / RAMP_TO_AUTO / AUTO / RAMP_TO_RC state badge - Blend alpha progress bar - Safety flags: SLAM ok, RC link ok, stick override active - State machine diagram System Health (/diagnostics): - CPU/GPU temperature gauges with colour-coded bars - RAM, GPU memory, disk resource bars - ROS2 node status list sorted by severity (ERROR → WARN → OK) Also: - Three.js vendor chunk split (471 kB separate lazy chunk) - Updated App.jsx with grouped SOCIAL + TELEMETRY tab nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
8.1 KiB
JavaScript
235 lines
8.1 KiB
JavaScript
/**
|
|
* BatteryPanel.jsx — Battery state monitoring.
|
|
*
|
|
* Topics:
|
|
* /saltybot/balance_state (std_msgs/String JSON)
|
|
* Fields: motor_cmd, state, mode (no direct voltage yet)
|
|
* /diagnostics (diagnostic_msgs/DiagnosticArray)
|
|
* KeyValues: battery_voltage_v, battery_current_a, battery_soc_pct
|
|
*
|
|
* NOTE: Dedicated /saltybot/battery (sensor_msgs/BatteryState) can be added
|
|
* to cmd_vel_bridge_node once firmware sends voltage/current over USB.
|
|
* The panel will pick it up automatically from /diagnostics KeyValues.
|
|
*
|
|
* 4S LiPo range: 12.0 V (empty) → 16.8 V (full)
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
const LIPO_4S_MIN = 12.0;
|
|
const LIPO_4S_MAX = 16.8;
|
|
const HISTORY_MAX = 120; // 2 min at 1 Hz
|
|
|
|
function socFromVoltage(v) {
|
|
if (v <= 0) return null;
|
|
return Math.max(0, Math.min(100, ((v - LIPO_4S_MIN) / (LIPO_4S_MAX - LIPO_4S_MIN)) * 100));
|
|
}
|
|
|
|
function voltColor(v) {
|
|
const soc = socFromVoltage(v);
|
|
if (soc === null) return '#6b7280';
|
|
if (soc > 50) return '#22c55e';
|
|
if (soc > 20) return '#f59e0b';
|
|
return '#ef4444';
|
|
}
|
|
|
|
function SparklineCanvas({ data, width = 280, height = 60 }) {
|
|
const ref = useRef(null);
|
|
|
|
useEffect(() => {
|
|
const canvas = ref.current;
|
|
if (!canvas || data.length < 2) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.width, H = canvas.height;
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
// Background
|
|
ctx.fillStyle = '#020208';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Grid lines at 25% / 50% / 75%
|
|
[25, 50, 75].forEach(pct => {
|
|
const y = H - (pct / 100) * H;
|
|
ctx.strokeStyle = 'rgba(0,255,255,0.05)';
|
|
ctx.lineWidth = 0.5;
|
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
|
});
|
|
|
|
// Voltage range for y-axis
|
|
const minV = Math.min(LIPO_4S_MIN, ...data);
|
|
const maxV = Math.max(LIPO_4S_MAX, ...data);
|
|
const rangeV = maxV - minV || 1;
|
|
|
|
// Line
|
|
ctx.strokeStyle = '#06b6d4';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
data.forEach((v, i) => {
|
|
const x = (i / (data.length - 1)) * W;
|
|
const y = H - ((v - minV) / rangeV) * H * 0.9 - H * 0.05;
|
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
});
|
|
ctx.stroke();
|
|
|
|
// Fill under
|
|
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath();
|
|
ctx.fillStyle = 'rgba(6,182,212,0.08)';
|
|
ctx.fill();
|
|
|
|
// Latest value label
|
|
const last = data[data.length - 1];
|
|
ctx.fillStyle = '#06b6d4';
|
|
ctx.font = 'bold 10px monospace';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(`${last.toFixed(2)}V`, W - 3, 12);
|
|
}, [data]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={ref}
|
|
width={width}
|
|
height={height}
|
|
className="w-full rounded border border-gray-900 block"
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function BatteryPanel({ subscribe }) {
|
|
const [voltage, setVoltage] = useState(0);
|
|
const [current, setCurrent] = useState(null);
|
|
const [soc, setSoc] = useState(null);
|
|
const [history, setHistory] = useState([]);
|
|
const [lastTs, setLastTs] = useState(null);
|
|
const lastHistoryPush = useRef(0);
|
|
|
|
// /diagnostics — look for battery KeyValues
|
|
useEffect(() => {
|
|
const unsub = subscribe(
|
|
'/diagnostics',
|
|
'diagnostic_msgs/DiagnosticArray',
|
|
(msg) => {
|
|
for (const status of msg.status ?? []) {
|
|
const kv = {};
|
|
for (const pair of status.values ?? []) {
|
|
kv[pair.key] = pair.value;
|
|
}
|
|
if (kv.battery_voltage_v !== undefined) {
|
|
const v = parseFloat(kv.battery_voltage_v);
|
|
setVoltage(v);
|
|
setSoc(socFromVoltage(v));
|
|
setLastTs(Date.now());
|
|
// Throttle history to ~1 Hz
|
|
const now = Date.now();
|
|
if (now - lastHistoryPush.current >= 1000) {
|
|
lastHistoryPush.current = now;
|
|
setHistory(h => [...h, v].slice(-HISTORY_MAX));
|
|
}
|
|
}
|
|
if (kv.battery_current_a !== undefined) {
|
|
setCurrent(parseFloat(kv.battery_current_a));
|
|
}
|
|
if (kv.battery_soc_pct !== undefined) {
|
|
setSoc(parseFloat(kv.battery_soc_pct));
|
|
}
|
|
}
|
|
}
|
|
);
|
|
return unsub;
|
|
}, [subscribe]);
|
|
|
|
// Also listen to balance_state for rough motor-current proxy
|
|
useEffect(() => {
|
|
const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
|
|
try {
|
|
const d = JSON.parse(msg.data);
|
|
// motor_cmd ∈ [-1000..1000] → rough current proxy
|
|
if (d.motor_cmd !== undefined && current === null) {
|
|
setCurrent(Math.abs(d.motor_cmd) / 1000 * 20); // rough max 20A
|
|
}
|
|
} catch { /* ignore */ }
|
|
});
|
|
return unsub;
|
|
}, [subscribe, current]);
|
|
|
|
const socPct = soc ?? socFromVoltage(voltage) ?? 0;
|
|
const col = voltColor(voltage);
|
|
const stale = lastTs && Date.now() - lastTs > 10000;
|
|
const runtime = (voltage > 0 && current && current > 0.1)
|
|
? ((socPct / 100) * 16000 / current / 60).toFixed(0) // rough Wh/V/A estimate
|
|
: null;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Main gauges */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 col-span-2 sm:col-span-1">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-1">VOLTAGE</div>
|
|
<div className="text-3xl font-bold" style={{ color: col }}>
|
|
{voltage > 0 ? voltage.toFixed(2) : '—'}
|
|
</div>
|
|
<div className="text-gray-600 text-xs">V {stale ? '(stale)' : ''}</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-1">SOC</div>
|
|
<div className="text-3xl font-bold" style={{ color: col }}>
|
|
{socPct > 0 ? Math.round(socPct) : '—'}
|
|
</div>
|
|
<div className="text-gray-600 text-xs">%</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-1">CURRENT</div>
|
|
<div className="text-3xl font-bold text-orange-400">
|
|
{current != null ? current.toFixed(1) : '—'}
|
|
</div>
|
|
<div className="text-gray-600 text-xs">A {current === null ? '' : '(est.)'}</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-1">EST. RUN</div>
|
|
<div className="text-3xl font-bold text-purple-400">
|
|
{runtime ?? '—'}
|
|
</div>
|
|
<div className="text-gray-600 text-xs">min</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SoC bar */}
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
|
<div className="flex justify-between text-xs mb-2">
|
|
<span className="text-cyan-700 font-bold tracking-widest">STATE OF CHARGE</span>
|
|
<span style={{ color: col }}>{Math.round(socPct)}%</span>
|
|
</div>
|
|
<div className="w-full h-4 bg-gray-900 rounded overflow-hidden border border-gray-800">
|
|
<div
|
|
className="h-full transition-all duration-1000 rounded"
|
|
style={{ width: `${socPct}%`, background: col }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs mt-1 text-gray-700">
|
|
<span>{LIPO_4S_MIN}V empty</span>
|
|
<span>{LIPO_4S_MAX}V full</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Voltage history sparkline */}
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-2">
|
|
VOLTAGE HISTORY (2 min)
|
|
</div>
|
|
{history.length >= 2 ? (
|
|
<SparklineCanvas data={history} height={60} />
|
|
) : (
|
|
<div className="text-gray-600 text-xs text-center py-4 border border-dashed border-gray-800 rounded">
|
|
Waiting for /diagnostics data…
|
|
<div className="mt-1 text-gray-700 text-xs">
|
|
Requires battery_voltage_v KeyValue in DiagnosticArray
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|