sl-webui 78374668bf feat(ui): telemetry dashboard panels (issue #126)
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>
2026-03-02 09:18:39 -05:00

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>
);
}