Architecture change (2026-04-03): Mamba F722S (STM32F722) and BlackPill replaced by ESP32 BALANCE (PID loop) and ESP32 IO (motors/sensors/comms). - Update CLAUDE.md, docs, chassis BOM/ASSEMBLY, pinout, power-budget, wiring-diagram, TEAM.md, AUTONOMOUS_ARMING.md, docker-compose - Update all ROS2 package comments, config labels, launch args (stm32_port→esp32_port, /dev/stm32-bridge→/dev/esp32-bridge) - Update WebUI: stm32Mode→esp32Mode, stm32Version→esp32Version, "STM32 State/Mode" labels → "ESP32 State/Mode" (ControlMode, SettingsPanel) - Add TODO(esp32-migration) markers on stm32_protocol.py and mamba_protocol.py binary frame layouts — pending ESP32 protocol spec from max - Fix roslib CDN 1.3.0→1.4.0 in all 11 HTML panels (fixes ROS2 Humble rosbridge "Received a message without an op" incompatibility) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
199 lines
7.2 KiB
JavaScript
199 lines
7.2 KiB
JavaScript
/**
|
||
* ControlMode.jsx — RC / Autonomous control mode display.
|
||
*
|
||
* Topics:
|
||
* /saltybot/control_mode (std_msgs/String JSON)
|
||
* {
|
||
* mode: "RC" | "RAMP_TO_AUTO" | "AUTO" | "RAMP_TO_RC",
|
||
* blend_alpha: 0.0..1.0,
|
||
* slam_ok: bool,
|
||
* rc_link_ok: bool,
|
||
* override_active: bool
|
||
* }
|
||
* /saltybot/balance_state (std_msgs/String JSON) — robot state, mode label
|
||
*/
|
||
|
||
import { useEffect, useState } from 'react';
|
||
|
||
const MODE_CONFIG = {
|
||
RC: {
|
||
label: 'RC MANUAL',
|
||
color: 'text-blue-300',
|
||
bg: 'bg-blue-950',
|
||
border: 'border-blue-600',
|
||
description: 'Pilot has full control via CRSF/ELRS RC link',
|
||
},
|
||
RAMP_TO_AUTO: {
|
||
label: 'RAMP → AUTO',
|
||
color: 'text-amber-300',
|
||
bg: 'bg-amber-950',
|
||
border: 'border-amber-600',
|
||
description: 'Transitioning from RC to autonomous (500 ms blend)',
|
||
},
|
||
AUTO: {
|
||
label: 'AUTONOMOUS',
|
||
color: 'text-green-300',
|
||
bg: 'bg-green-950',
|
||
border: 'border-green-600',
|
||
description: 'Jetson AI/Nav2 in full control',
|
||
},
|
||
RAMP_TO_RC: {
|
||
label: 'RAMP → RC',
|
||
color: 'text-orange-300',
|
||
bg: 'bg-orange-950',
|
||
border: 'border-orange-600',
|
||
description: 'Returning control to pilot (500 ms blend)',
|
||
},
|
||
};
|
||
|
||
function SafetyFlag({ label, ok, invert }) {
|
||
const isOk = invert ? !ok : ok;
|
||
return (
|
||
<div className={`flex items-center gap-2 rounded px-3 py-2 border ${
|
||
isOk ? 'bg-green-950 border-green-800' : 'bg-red-950 border-red-800'
|
||
}`}>
|
||
<div className={`w-2 h-2 rounded-full ${isOk ? 'bg-green-400' : 'bg-red-400 animate-pulse'}`} />
|
||
<span className="text-xs">
|
||
<span className={isOk ? 'text-green-300' : 'text-red-300'}>{label}</span>
|
||
</span>
|
||
<span className={`ml-auto text-xs font-bold ${isOk ? 'text-green-400' : 'text-red-400'}`}>
|
||
{isOk ? 'OK' : 'FAULT'}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ControlMode({ subscribe }) {
|
||
const [ctrlMode, setCtrlMode] = useState(null);
|
||
const [balState, setBalState] = useState(null);
|
||
const [lastTs, setLastTs] = useState(null);
|
||
|
||
useEffect(() => {
|
||
const unsub = subscribe('/saltybot/control_mode', 'std_msgs/String', (msg) => {
|
||
try {
|
||
setCtrlMode(JSON.parse(msg.data));
|
||
setLastTs(Date.now());
|
||
} catch { /* ignore */ }
|
||
});
|
||
return unsub;
|
||
}, [subscribe]);
|
||
|
||
useEffect(() => {
|
||
const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
|
||
try { setBalState(JSON.parse(msg.data)); }
|
||
catch { /* ignore */ }
|
||
});
|
||
return unsub;
|
||
}, [subscribe]);
|
||
|
||
const mode = ctrlMode?.mode ?? 'RC';
|
||
const cfg = MODE_CONFIG[mode] ?? MODE_CONFIG.RC;
|
||
const alpha = ctrlMode?.blend_alpha ?? 0;
|
||
const stale = lastTs && Date.now() - lastTs > 5000;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Main mode badge */}
|
||
<div className={`${cfg.bg} border ${cfg.border} rounded-xl p-5`}>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="text-gray-500 text-xs font-bold tracking-widest mb-1">CONTROL MODE</div>
|
||
<div className={`text-3xl font-bold tracking-widest ${cfg.color}`}>{cfg.label}</div>
|
||
<div className="text-gray-500 text-xs mt-1">{cfg.description}</div>
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
<div className="text-gray-600 text-xs">Blend α</div>
|
||
<div className={`text-2xl font-bold ${alpha > 0.5 ? 'text-green-400' : 'text-blue-400'}`}>
|
||
{alpha.toFixed(2)}
|
||
</div>
|
||
{stale && <div className="text-red-500 text-xs mt-1">STALE</div>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Blend alpha bar */}
|
||
{(mode === 'RAMP_TO_AUTO' || mode === 'RAMP_TO_RC' || alpha > 0) && (
|
||
<div className="mt-3">
|
||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||
<span>RC</span>
|
||
<span>AUTO</span>
|
||
</div>
|
||
<div className="w-full h-3 bg-gray-950 rounded overflow-hidden border border-gray-800">
|
||
<div
|
||
className="h-full transition-all duration-200 rounded"
|
||
style={{
|
||
width: `${alpha * 100}%`,
|
||
background: `linear-gradient(to right, #2563eb, #16a34a)`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Safety flags */}
|
||
<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">SAFETY INTERLOCKS</div>
|
||
<div className="space-y-2">
|
||
<SafetyFlag label="SLAM Fix" ok={ctrlMode?.slam_ok ?? false} />
|
||
<SafetyFlag label="RC Link" ok={ctrlMode?.rc_link_ok ?? false} />
|
||
<SafetyFlag label="Stick Override" ok={ctrlMode?.override_active ?? false} invert />
|
||
</div>
|
||
{!ctrlMode && (
|
||
<div className="text-gray-600 text-xs mt-2">
|
||
Waiting for /saltybot/control_mode…
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Balance state detail */}
|
||
{balState && (
|
||
<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">BALANCE STATE</div>
|
||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||
<div>
|
||
<span className="text-gray-600">ESP32 State: </span>
|
||
<span className={`font-bold ${
|
||
balState.state === 'ARMED' ? 'text-green-400' :
|
||
balState.state === 'TILT FAULT' ? 'text-red-400' : 'text-gray-400'
|
||
}`}>{balState.state}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-600">ESP32 Mode: </span>
|
||
<span className="text-cyan-400">{balState.mode}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-600">Pitch: </span>
|
||
<span className="text-amber-400">{balState.pitch_deg}°</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-600">Motor CMD: </span>
|
||
<span className="text-orange-400">{balState.motor_cmd}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Mode transition guide */}
|
||
<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">STATE MACHINE</div>
|
||
<div className="flex items-center gap-1 text-xs flex-wrap">
|
||
{['RC', 'RAMP_TO_AUTO', 'AUTO', 'RAMP_TO_RC'].map((m, i) => (
|
||
<div key={m} className="flex items-center gap-1">
|
||
<span className={`px-2 py-0.5 rounded border ${
|
||
mode === m
|
||
? `${MODE_CONFIG[m].bg} ${MODE_CONFIG[m].border} ${MODE_CONFIG[m].color} font-bold`
|
||
: 'border-gray-800 text-gray-600'
|
||
}`}>{m}</span>
|
||
{i < 3 && <span className="text-gray-700">→</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-2 text-gray-700 text-xs space-y-0.5">
|
||
<div>AUX2 switch → RC⇄AUTO | Stick > 10% while AUTO → instant RC</div>
|
||
<div>SLAM fix lost while AUTO → RAMP_TO_RC | RC link lost → instant RC</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|