- useFleet.js: multi-robot ROSLIB.Ros multiplexer with localStorage persistence; per-robot balance_state/control_mode/odom/diagnostics subscriptions; sendGoal, sendPatrol, scanSubnet helpers - FleetPanel.jsx: fleet sub-views (Robots/Map/Missions/Video/Alerts) plus RobotDetail overlay reusing ImuPanel/BatteryPanel/MotorPanel/ ControlMode/SystemHealth via subscribeRobot adapter pattern - App.jsx: adds FLEET tab group pointing to FleetPanel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
348 lines
12 KiB
JavaScript
348 lines
12 KiB
JavaScript
/**
|
|
* useFleet.js — Multi-robot fleet connection manager.
|
|
*
|
|
* Maintains one ROSLIB.Ros WebSocket connection per robot.
|
|
* Aggregates fleet-summary telemetry (battery, pose, alerts, mode) for each.
|
|
* Persists robot registry to localStorage.
|
|
*
|
|
* API:
|
|
* const { robots, addRobot, removeRobot, updateRobot,
|
|
* connections, robotData,
|
|
* subscribeRobot, publishRobot, scanSubnet } = useFleet();
|
|
*
|
|
* Robot shape:
|
|
* { id, name, variant, host, port, videoPort, videoTopic }
|
|
* variant: 'balance' | 'rover' | 'tank' | 'social'
|
|
* videoTopic: '/camera/panoramic/compressed' (default)
|
|
*/
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import ROSLIB from 'roslib';
|
|
|
|
// ── Storage ────────────────────────────────────────────────────────────────────
|
|
|
|
const STORAGE_KEY = 'saltybot_fleet_v2';
|
|
|
|
const DEFAULT_ROBOTS = [
|
|
{
|
|
id: 'salty-01',
|
|
name: 'SaltyBot #1',
|
|
variant: 'balance',
|
|
host: '192.168.1.100',
|
|
port: 9090,
|
|
videoPort: 8080,
|
|
videoTopic: '/camera/panoramic/compressed',
|
|
},
|
|
];
|
|
|
|
function loadRobots() {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
|
|
return Array.isArray(saved) && saved.length > 0 ? saved : DEFAULT_ROBOTS;
|
|
} catch {
|
|
return DEFAULT_ROBOTS;
|
|
}
|
|
}
|
|
|
|
// ── Robot data structure ───────────────────────────────────────────────────────
|
|
|
|
const EMPTY_DATA = () => ({
|
|
state: null, // 'DISARMED'|'ARMED'|'TILT FAULT'
|
|
stm32Mode: null, // 'MANUAL'|'ASSISTED'
|
|
controlMode: null, // 'RC'|'RAMP_TO_AUTO'|'AUTO'|'RAMP_TO_RC'
|
|
blendAlpha: 0,
|
|
pipeline: null, // 'idle'|'listening'|'thinking'|'speaking'
|
|
pitch: 0,
|
|
motorCmd: 0,
|
|
battery: { voltage: 0, soc: 0 },
|
|
pose: null, // { x, y, yaw }
|
|
alerts: [], // [{ level, name, message }]
|
|
lastSeen: null,
|
|
});
|
|
|
|
// ── Hook ──────────────────────────────────────────────────────────────────────
|
|
|
|
export function useFleet() {
|
|
const [robots, setRobots] = useState(loadRobots);
|
|
const [connections, setConnections] = useState({});
|
|
const [robotData, setRobotData] = useState(() =>
|
|
Object.fromEntries(loadRobots().map(r => [r.id, EMPTY_DATA()]))
|
|
);
|
|
const [scanning, setScanning] = useState(false);
|
|
|
|
const rosRef = useRef({}); // { [id]: ROSLIB.Ros }
|
|
const subsRef = useRef({}); // { [id]: ROSLIB.Topic[] }
|
|
|
|
// Persist robots
|
|
useEffect(() => {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(robots));
|
|
}, [robots]);
|
|
|
|
// ── Connection lifecycle ────────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
const currentIds = new Set(robots.map(r => r.id));
|
|
|
|
// Disconnect removed robots
|
|
Object.keys(rosRef.current).forEach(id => {
|
|
if (!currentIds.has(id)) {
|
|
subsRef.current[id]?.forEach(t => { try { t.unsubscribe(); } catch {} });
|
|
delete subsRef.current[id];
|
|
try { rosRef.current[id].close(); } catch {}
|
|
delete rosRef.current[id];
|
|
setConnections(c => { const n = { ...c }; delete n[id]; return n; });
|
|
}
|
|
});
|
|
|
|
// Connect new robots
|
|
robots.forEach(robot => {
|
|
if (rosRef.current[robot.id]) return;
|
|
|
|
const ros = new ROSLIB.Ros({ url: `ws://${robot.host}:${robot.port}` });
|
|
rosRef.current[robot.id] = ros;
|
|
subsRef.current[robot.id] = [];
|
|
|
|
ros.on('connection', () => {
|
|
setConnections(c => ({ ...c, [robot.id]: { connected: true, error: null } }));
|
|
_setupSubs(robot.id, ros);
|
|
});
|
|
ros.on('error', err => {
|
|
setConnections(c => ({
|
|
...c,
|
|
[robot.id]: { connected: false, error: String(err?.message ?? err ?? 'error') }
|
|
}));
|
|
});
|
|
ros.on('close', () => {
|
|
setConnections(c => ({
|
|
...c,
|
|
[robot.id]: { connected: false, error: null }
|
|
}));
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
Object.values(subsRef.current).forEach(subs =>
|
|
subs.forEach(t => { try { t.unsubscribe(); } catch {} })
|
|
);
|
|
Object.values(rosRef.current).forEach(ros => { try { ros.close(); } catch {} });
|
|
rosRef.current = {};
|
|
subsRef.current = {};
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [robots]);
|
|
|
|
// ── Fleet-summary subscriptions ────────────────────────────────────────────
|
|
|
|
function _sub(id, ros, name, type, cb) {
|
|
const t = new ROSLIB.Topic({ ros, name, messageType: type });
|
|
t.subscribe(cb);
|
|
subsRef.current[id] = [...(subsRef.current[id] ?? []), t];
|
|
}
|
|
|
|
function _update(id, patch) {
|
|
setRobotData(prev => ({
|
|
...prev,
|
|
[id]: { ...(prev[id] ?? EMPTY_DATA()), ...patch },
|
|
}));
|
|
}
|
|
|
|
function _setupSubs(id, ros) {
|
|
// Balance state
|
|
_sub(id, ros, '/saltybot/balance_state', 'std_msgs/String', msg => {
|
|
try {
|
|
const d = JSON.parse(msg.data);
|
|
_update(id, {
|
|
state: d.state,
|
|
stm32Mode: d.mode,
|
|
pitch: d.pitch_deg ?? 0,
|
|
motorCmd: d.motor_cmd ?? 0,
|
|
lastSeen: Date.now(),
|
|
});
|
|
} catch {}
|
|
});
|
|
|
|
// Control mode (RC ↔ AUTO blend)
|
|
_sub(id, ros, '/saltybot/control_mode', 'std_msgs/String', msg => {
|
|
try {
|
|
const d = JSON.parse(msg.data);
|
|
_update(id, { controlMode: d.mode, blendAlpha: d.blend_alpha ?? 0 });
|
|
} catch {}
|
|
});
|
|
|
|
// Odometry → pose
|
|
_sub(id, ros, '/odom', 'nav_msgs/Odometry', msg => {
|
|
const p = msg.pose?.pose;
|
|
if (!p) return;
|
|
const o = p.orientation;
|
|
const yaw = Math.atan2(
|
|
2 * (o.w * o.z + o.x * o.y),
|
|
1 - 2 * (o.y * o.y + o.z * o.z)
|
|
);
|
|
_update(id, { pose: { x: p.position.x, y: p.position.y, yaw }, lastSeen: Date.now() });
|
|
});
|
|
|
|
// Diagnostics → battery + alerts
|
|
_sub(id, ros, '/diagnostics', 'diagnostic_msgs/DiagnosticArray', msg => {
|
|
const alerts = [];
|
|
const kv = {};
|
|
for (const status of msg.status ?? []) {
|
|
if (status.level > 0) {
|
|
alerts.push({ level: status.level, name: status.name, message: status.message });
|
|
}
|
|
for (const pair of status.values ?? []) kv[pair.key] = pair.value;
|
|
}
|
|
const patch = { alerts };
|
|
if (kv.battery_voltage_v != null) {
|
|
const v = parseFloat(kv.battery_voltage_v);
|
|
patch.battery = {
|
|
voltage: v,
|
|
soc: Math.max(0, Math.min(100, ((v - 12) / (16.8 - 12)) * 100)),
|
|
};
|
|
}
|
|
_update(id, patch);
|
|
});
|
|
|
|
// Social pipeline state (optional)
|
|
_sub(id, ros, '/social/orchestrator/state', 'std_msgs/String', msg => {
|
|
try {
|
|
_update(id, { pipeline: JSON.parse(msg.data)?.state ?? null });
|
|
} catch {}
|
|
});
|
|
}
|
|
|
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
|
|
const addRobot = useCallback(robot => {
|
|
const id = robot.id ?? `bot-${Date.now()}`;
|
|
const full = {
|
|
videoPort: 8080,
|
|
videoTopic: '/camera/panoramic/compressed',
|
|
...robot,
|
|
id,
|
|
};
|
|
setRobots(r => [...r.filter(b => b.id !== id), full]);
|
|
setRobotData(d => ({ ...d, [id]: EMPTY_DATA() }));
|
|
}, []);
|
|
|
|
const removeRobot = useCallback(id => {
|
|
setRobots(r => r.filter(b => b.id !== id));
|
|
setRobotData(d => { const n = { ...d }; delete n[id]; return n; });
|
|
}, []);
|
|
|
|
const updateRobot = useCallback((id, patch) => {
|
|
// Force reconnect by dropping the existing connection
|
|
subsRef.current[id]?.forEach(t => { try { t.unsubscribe(); } catch {} });
|
|
subsRef.current[id] = [];
|
|
try { rosRef.current[id]?.close(); } catch {}
|
|
delete rosRef.current[id];
|
|
setRobots(r => r.map(b => b.id === id ? { ...b, ...patch } : b));
|
|
}, []);
|
|
|
|
/** Subscribe to a topic on a specific robot. Returns unsubscribe fn. */
|
|
const subscribeRobot = useCallback((id, name, type, cb) => {
|
|
const ros = rosRef.current[id];
|
|
if (!ros) return () => {};
|
|
const t = new ROSLIB.Topic({ ros, name, messageType: type });
|
|
t.subscribe(cb);
|
|
subsRef.current[id] = [...(subsRef.current[id] ?? []), t];
|
|
return () => {
|
|
try { t.unsubscribe(); } catch {}
|
|
subsRef.current[id] = subsRef.current[id]?.filter(x => x !== t) ?? [];
|
|
};
|
|
}, []);
|
|
|
|
/** Publish to a topic on a specific robot. */
|
|
const publishRobot = useCallback((id, name, type, data) => {
|
|
const ros = rosRef.current[id];
|
|
if (!ros) return;
|
|
const t = new ROSLIB.Topic({ ros, name, messageType: type });
|
|
t.publish(new ROSLIB.Message(data));
|
|
}, []);
|
|
|
|
/** Send a single Nav2 goal pose to a robot. */
|
|
const sendGoal = useCallback((id, x, y, yaw = 0) => {
|
|
const cy2 = Math.cos(yaw / 2);
|
|
const sy2 = Math.sin(yaw / 2);
|
|
publishRobot(id, '/goal_pose', 'geometry_msgs/PoseStamped', {
|
|
header: { frame_id: 'map', stamp: { sec: 0, nanosec: 0 } },
|
|
pose: {
|
|
position: { x, y, z: 0 },
|
|
orientation: { x: 0, y: 0, z: sy2, w: cy2 },
|
|
},
|
|
});
|
|
}, [publishRobot]);
|
|
|
|
/** Send a patrol route (array of {x,y,yaw?}) to a robot via PoseArray → GPS follower. */
|
|
const sendPatrol = useCallback((id, waypoints) => {
|
|
publishRobot(id, '/outdoor/waypoints', 'geometry_msgs/PoseArray', {
|
|
header: { frame_id: 'map', stamp: { sec: 0, nanosec: 0 } },
|
|
poses: waypoints.map(({ x, y, yaw = 0 }) => {
|
|
const cy2 = Math.cos(yaw / 2);
|
|
const sy2 = Math.sin(yaw / 2);
|
|
return {
|
|
position: { x, y, z: 0 },
|
|
orientation: { x: 0, y: 0, z: sy2, w: cy2 },
|
|
};
|
|
}),
|
|
});
|
|
}, [publishRobot]);
|
|
|
|
/**
|
|
* Probe a list of candidate URLs to find reachable rosbridge servers.
|
|
* Reports each discovered robot back via the addRobot callback.
|
|
*/
|
|
const scanSubnet = useCallback(async (baseIp, portRange = [9090, 9097]) => {
|
|
setScanning(true);
|
|
const [portMin, portMax] = portRange;
|
|
const ipParts = baseIp.split('.');
|
|
const prefix = ipParts.slice(0, 3).join('.');
|
|
|
|
const results = [];
|
|
const probes = [];
|
|
for (let host4 = 1; host4 <= 254; host4++) {
|
|
for (let port = portMin; port <= portMax; port++) {
|
|
probes.push({ host: `${prefix}.${host4}`, port });
|
|
}
|
|
}
|
|
|
|
// Probe in batches of 32 to avoid thundering-herd
|
|
const BATCH = 32;
|
|
for (let i = 0; i < probes.length; i += BATCH) {
|
|
const batch = probes.slice(i, i + BATCH);
|
|
await Promise.allSettled(
|
|
batch.map(({ host, port }) =>
|
|
new Promise(resolve => {
|
|
const ws = new WebSocket(`ws://${host}:${port}`);
|
|
const timer = setTimeout(() => { try { ws.close(); } catch {} resolve(null); }, 800);
|
|
ws.onopen = () => {
|
|
clearTimeout(timer);
|
|
results.push({ host, port });
|
|
ws.close();
|
|
resolve({ host, port });
|
|
};
|
|
ws.onerror = () => { clearTimeout(timer); resolve(null); };
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
setScanning(false);
|
|
return results;
|
|
}, []);
|
|
|
|
return {
|
|
robots,
|
|
addRobot,
|
|
removeRobot,
|
|
updateRobot,
|
|
connections,
|
|
robotData,
|
|
subscribeRobot,
|
|
publishRobot,
|
|
sendGoal,
|
|
sendPatrol,
|
|
scanning,
|
|
scanSubnet,
|
|
};
|
|
}
|