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

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