/** * 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 ( ); } 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 (
{/* Main gauges */}
VOLTAGE
{voltage > 0 ? voltage.toFixed(2) : '—'}
V {stale ? '(stale)' : ''}
SOC
{socPct > 0 ? Math.round(socPct) : '—'}
%
CURRENT
{current != null ? current.toFixed(1) : '—'}
A {current === null ? '' : '(est.)'}
EST. RUN
{runtime ?? '—'}
min
{/* SoC bar */}
STATE OF CHARGE {Math.round(socPct)}%
{LIPO_4S_MIN}V empty {LIPO_4S_MAX}V full
{/* Voltage history sparkline */}
VOLTAGE HISTORY (2 min)
{history.length >= 2 ? ( ) : (
Waiting for /diagnostics data…
Requires battery_voltage_v KeyValue in DiagnosticArray
)}
); }