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>
211 lines
7.4 KiB
JavaScript
211 lines
7.4 KiB
JavaScript
/**
|
|
* MotorPanel.jsx — Motor telemetry display.
|
|
*
|
|
* Balance bot: /saltybot/balance_state (std_msgs/String JSON)
|
|
* motor_cmd [-1000..1000], bridge_speed, bridge_steer
|
|
*
|
|
* Rover mode: /saltybot/rover_pwm (std_msgs/String JSON)
|
|
* ch1_us, ch2_us, ch3_us, ch4_us [1000..2000]
|
|
*
|
|
* Temperatures: /diagnostics (diagnostic_msgs/DiagnosticArray)
|
|
* motor_temp_l_c, motor_temp_r_c
|
|
*
|
|
* Displays PWM-bar for each motor, RPM proxy, temperature.
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
const PWM_MIN = 1000;
|
|
const PWM_MID = 1500;
|
|
const PWM_MAX = 2000;
|
|
|
|
/** Map [-1..1] normalised value to bar width and color. */
|
|
function dutyBar(norm) {
|
|
const pct = Math.abs(norm) * 50;
|
|
const color = norm > 0 ? '#f97316' : '#3b82f6';
|
|
const left = norm >= 0 ? '50%' : `${50 - pct}%`;
|
|
return { pct, color, left };
|
|
}
|
|
|
|
function MotorGauge({ label, norm, pwmUs, tempC }) {
|
|
const { pct, color, left } = dutyBar(norm);
|
|
const tempWarn = tempC != null && tempC > 70;
|
|
|
|
return (
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-cyan-700 text-xs font-bold tracking-widest">{label}</span>
|
|
{tempC != null && (
|
|
<span className={`text-xs font-bold ${tempWarn ? 'text-red-400' : 'text-gray-400'}`}>
|
|
{tempC.toFixed(0)}°C
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* PWM value */}
|
|
<div className="text-2xl font-bold text-orange-400 mb-2">
|
|
{pwmUs != null ? `${pwmUs} µs` : `${Math.round(norm * 1000)}`}
|
|
</div>
|
|
|
|
{/* Bidirectional duty bar */}
|
|
<div className="relative h-3 bg-gray-900 rounded overflow-hidden border border-gray-800 mb-1">
|
|
<div className="absolute inset-y-0 left-1/2 w-px bg-gray-700" />
|
|
<div
|
|
className="absolute inset-y-0 transition-all duration-100 rounded"
|
|
style={{ left, width: `${pct}%`, background: color }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs text-gray-700">
|
|
<span>REV</span>
|
|
<span>{(norm * 100).toFixed(0)}%</span>
|
|
<span>FWD</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MotorPanel({ subscribe }) {
|
|
const [balState, setBalState] = useState(null);
|
|
const [roverPwm, setRoverPwm] = useState(null);
|
|
const [temps, setTemps] = useState({ l: null, r: null });
|
|
|
|
// Balance state (2-wheel robot)
|
|
useEffect(() => {
|
|
const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
|
|
try { setBalState(JSON.parse(msg.data)); }
|
|
catch { /* ignore */ }
|
|
});
|
|
return unsub;
|
|
}, [subscribe]);
|
|
|
|
// Rover PWM (4-wheel rover)
|
|
useEffect(() => {
|
|
const unsub = subscribe('/saltybot/rover_pwm', 'std_msgs/String', (msg) => {
|
|
try { setRoverPwm(JSON.parse(msg.data)); }
|
|
catch { /* ignore */ }
|
|
});
|
|
return unsub;
|
|
}, [subscribe]);
|
|
|
|
// Motor temperatures from diagnostics
|
|
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.motor_temp_l_c !== undefined || kv.motor_temp_r_c !== undefined) {
|
|
setTemps({
|
|
l: kv.motor_temp_l_c != null ? parseFloat(kv.motor_temp_l_c) : null,
|
|
r: kv.motor_temp_r_c != null ? parseFloat(kv.motor_temp_r_c) : null,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
return unsub;
|
|
}, [subscribe]);
|
|
|
|
// Determine display mode: rover if rover_pwm active, else balance
|
|
const isRover = roverPwm != null;
|
|
|
|
let leftNorm = 0, rightNorm = 0;
|
|
let leftPwm = null, rightPwm = null;
|
|
|
|
if (isRover) {
|
|
// ch1=left-front, ch2=right-front, ch3=left-rear, ch4=right-rear
|
|
leftPwm = Math.round((roverPwm.ch1_us + (roverPwm.ch3_us ?? roverPwm.ch1_us)) / 2);
|
|
rightPwm = Math.round((roverPwm.ch2_us + (roverPwm.ch4_us ?? roverPwm.ch2_us)) / 2);
|
|
leftNorm = (leftPwm - PWM_MID) / (PWM_MAX - PWM_MID);
|
|
rightNorm = (rightPwm - PWM_MID) / (PWM_MAX - PWM_MID);
|
|
} else if (balState) {
|
|
const cmd = (balState.motor_cmd ?? 0) / 1000;
|
|
const steer = (balState.bridge_steer ?? 0) / 1000;
|
|
leftNorm = cmd + steer;
|
|
rightNorm = cmd - steer;
|
|
}
|
|
|
|
// Clip to ±1
|
|
leftNorm = Math.max(-1, Math.min(1, leftNorm));
|
|
rightNorm = Math.max(-1, Math.min(1, rightNorm));
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Mode indicator */}
|
|
<div className="flex items-center gap-2">
|
|
<div className={`px-2 py-0.5 rounded text-xs font-bold border ${
|
|
isRover
|
|
? 'bg-purple-950 border-purple-700 text-purple-300'
|
|
: 'bg-blue-950 border-blue-700 text-blue-300'
|
|
}`}>
|
|
{isRover ? 'ROVER (4WD)' : 'BALANCE (2WD)'}
|
|
</div>
|
|
{!balState && !roverPwm && (
|
|
<span className="text-gray-600 text-xs">No motor data</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Motor gauges */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<MotorGauge
|
|
label="LEFT MOTOR"
|
|
norm={leftNorm}
|
|
pwmUs={isRover ? leftPwm : null}
|
|
tempC={temps.l}
|
|
/>
|
|
<MotorGauge
|
|
label="RIGHT MOTOR"
|
|
norm={rightNorm}
|
|
pwmUs={isRover ? rightPwm : null}
|
|
tempC={temps.r}
|
|
/>
|
|
</div>
|
|
|
|
{/* Balance-specific details */}
|
|
{balState && !isRover && (
|
|
<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-3">PID DETAILS</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
|
<div className="bg-gray-900 rounded p-2 text-center">
|
|
<div className="text-gray-600">CMD</div>
|
|
<div className="text-orange-400 font-bold">{balState.motor_cmd}</div>
|
|
</div>
|
|
<div className="bg-gray-900 rounded p-2 text-center">
|
|
<div className="text-gray-600">ERROR</div>
|
|
<div className="text-red-400 font-bold">{balState.pid_error_deg}°</div>
|
|
</div>
|
|
<div className="bg-gray-900 rounded p-2 text-center">
|
|
<div className="text-gray-600">INTEGRAL</div>
|
|
<div className="text-purple-400 font-bold">{balState.integral}</div>
|
|
</div>
|
|
<div className="bg-gray-900 rounded p-2 text-center">
|
|
<div className="text-gray-600">FRAMES</div>
|
|
<div className="text-gray-400 font-bold">{balState.frames}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Rover PWM details */}
|
|
{roverPwm && (
|
|
<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-3">CHANNEL PWM (µs)</div>
|
|
<div className="grid grid-cols-4 gap-2 text-xs">
|
|
{[1,2,3,4].map(ch => {
|
|
const us = roverPwm[`ch${ch}_us`] ?? PWM_MID;
|
|
const norm = (us - PWM_MID) / (PWM_MAX - PWM_MID);
|
|
return (
|
|
<div key={ch} className="bg-gray-900 rounded p-2 text-center">
|
|
<div className="text-gray-600">CH{ch}</div>
|
|
<div className="font-bold" style={{ color: norm > 0.05 ? '#f97316' : norm < -0.05 ? '#3b82f6' : '#6b7280' }}>
|
|
{us}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|