+
FLEET ALERTS
+
+ {/* Summary */}
+
+ {[
+ { label: 'ERRORS', count: errors.length, color: errors.length ? 'text-red-400 bg-red-950 border-red-800' : 'text-gray-600 bg-gray-900 border-gray-800' },
+ { label: 'WARNINGS',count: warns.length, color: warns.length ? 'text-amber-400 bg-amber-950 border-amber-800' : 'text-gray-600 bg-gray-900 border-gray-800' },
+ { label: 'OFFLINE', count: offline.length, color: offline.length ? 'text-gray-400 bg-gray-900 border-gray-700' : 'text-gray-600 bg-gray-900 border-gray-800' },
+ ].map(({ label, count, color }) => (
+
+ ))}
+
+
+ {/* Offline robots */}
+ {offline.length > 0 && (
+
+
OFFLINE ROBOTS
+
+ {offline.map(r => (
+
+
+
{r.name || r.id}
+
{r.host}:{r.port}
+ {connections[r.id]?.error && (
+
{connections[r.id].error}
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Alert list */}
+ {allAlerts.length === 0 ? (
+
+ {robots.length === 0 ? 'No robots configured.' : 'No active alerts across fleet.'}
+
+ ) : (
+
+ {[...allAlerts]
+ .sort((a, b) => b.level - a.level)
+ .map((a, i) => (
+
= 2 ? 'bg-red-950 border-red-800' : 'bg-amber-950 border-amber-800'
+ }`}>
+
= 2 ? 'bg-red-400 animate-pulse' : 'bg-amber-400'}`} />
+
+ {a.name}
+ {a.message && {a.message}}
+
+
{a.robotName}
+
+ ))
+ }
+
+ )}
+
+ );
+}
+
+// ── Sub-view: Robot Detail ───────────────────────────────────────────────────
+
+const DETAIL_TABS = [
+ { id: 'imu', label: 'IMU' },
+ { id: 'battery', label: 'Battery' },
+ { id: 'motors', label: 'Motors' },
+ { id: 'control', label: 'Control' },
+ { id: 'health', label: 'Health' },
+];
+
+function RobotDetail({ robot, connection, data, subscribeRobot, onBack }) {
+ const [tab, setTab] = useState('imu');
+
+ // Adapter: wrap subscribeRobot(id, ...) to match subscribe(name, type, cb) signature
+ const subscribe = useCallback(
+ (name, type, cb) => subscribeRobot(robot.id, name, type, cb),
+ [subscribeRobot, robot.id]
+ );
+
+ const conn = connection ?? { connected: false };
+
+ return (
+
+ {/* Back + header */}
+
+
+
+
+
{robot.name || robot.id}
+
{robot.host}:{robot.port}
+
+ {robot.variant}
+
+
+ {data?.battery?.soc != null && (
+
+
+ {data.battery.voltage?.toFixed(1)}V
+
+ )}
+
+
+ {/* Detail tabs */}
+
+ {DETAIL_TABS.map(t => (
+
+ ))}
+
+
+ {!conn.connected ? (
+
+ Robot disconnected {conn.error ? `— ${conn.error}` : ''}
+
+ ) : (
+
+ {tab === 'imu' && }
+ {tab === 'battery' && }
+ {tab === 'motors' && }
+ {tab === 'control' && }
+ {tab === 'health' && }
+
+ )}
+
+ );
+}
+
+// ── Root FleetPanel ──────────────────────────────────────────────────────────
+
+export function FleetPanel() {
+ const {
+ robots, addRobot, removeRobot,
+ connections, robotData,
+ subscribeRobot, sendGoal, sendPatrol,
+ scanning, scanSubnet,
+ } = useFleet();
+
+ const [view, setView] = useState('robots');
+ const [selectedId, setSelectedId] = useState(null);
+ const [showAdd, setShowAdd] = useState(false);
+
+ const handleSelect = useCallback((id) => {
+ setSelectedId(id);
+ }, []);
+
+ const handleBack = useCallback(() => {
+ setSelectedId(null);
+ }, []);
+
+ const handleScanLan = useCallback(async () => {
+ const baseIp = robots[0]?.host ?? '192.168.1.100';
+ const found = await scanSubnet(baseIp, [9090, 9092]);
+ found.forEach(({ host, port }, i) => {
+ const existingHosts = robots.map(r => r.host);
+ if (!existingHosts.includes(host)) {
+ addRobot({
+ id: `discovered-${Date.now()}-${i}`,
+ name: `Discovered ${host}`,
+ variant: 'balance',
+ host,
+ port,
+ });
+ }
+ });
+ }, [robots, scanSubnet, addRobot]);
+
+ // If a robot is selected → show RobotDetail overlay
+ if (selectedId) {
+ const robot = robots.find(r => r.id === selectedId);
+ if (robot) {
+ return (
+
+ );
+ }
+ setSelectedId(null);
+ }
+
+ return (
+
+ {/* Fleet sub-nav */}
+
+ {FLEET_VIEWS.map(v => (
+
+ ))}
+
+
+ {/* Content */}
+ {view === 'robots' && (
+
setShowAdd(true)}
+ onScan={handleScanLan}
+ scanning={scanning}
+ />
+ )}
+ {view === 'map' && (
+
+ )}
+ {view === 'missions' && (
+
+ )}
+ {view === 'video' && (
+
+ )}
+ {view === 'alerts' && (
+
+ )}
+
+ {showAdd && (
+ setShowAdd(false)} />
+ )}
+
+ );
+}
diff --git a/ui/social-bot/src/hooks/useFleet.js b/ui/social-bot/src/hooks/useFleet.js
new file mode 100644
index 0000000..d9c0476
--- /dev/null
+++ b/ui/social-bot/src/hooks/useFleet.js
@@ -0,0 +1,347 @@
+/**
+ * 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,
+ };
+}