Compare commits
2 Commits
9257f4c7de
...
3746a5b92d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3746a5b92d | |||
| 4e6ecacd37 |
@ -5,7 +5,7 @@ Overview
|
|||||||
────────
|
────────
|
||||||
Orchestrates dock detection, Nav2 corridor approach, visual-servo final
|
Orchestrates dock detection, Nav2 corridor approach, visual-servo final
|
||||||
alignment, and charge monitoring. Interrupts the active Nav2 mission when
|
alignment, and charge monitoring. Interrupts the active Nav2 mission when
|
||||||
battery drops to 15 % and resumes when charged to 80 %.
|
battery drops to 20 % and resumes when charged to 80 %.
|
||||||
|
|
||||||
Pipeline (20 Hz)
|
Pipeline (20 Hz)
|
||||||
────────────────
|
────────────────
|
||||||
@ -49,7 +49,7 @@ Parameters
|
|||||||
aruco_marker_id 42
|
aruco_marker_id 42
|
||||||
aruco_marker_size_m 0.10
|
aruco_marker_size_m 0.10
|
||||||
ir_threshold 0.50
|
ir_threshold 0.50
|
||||||
battery_low_pct 15.0
|
battery_low_pct 20.0
|
||||||
battery_high_pct 80.0
|
battery_high_pct 80.0
|
||||||
servo_range_m 1.00 m (switch to IBVS within this distance)
|
servo_range_m 1.00 m (switch to IBVS within this distance)
|
||||||
k_linear 0.30
|
k_linear 0.30
|
||||||
@ -100,6 +100,12 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
_NAV2_OK = False
|
_NAV2_OK = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from saltybot_social_msgs.msg import Mood
|
||||||
|
_SOCIAL_OK = True
|
||||||
|
except ImportError:
|
||||||
|
_SOCIAL_OK = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@ -208,6 +214,11 @@ class DockingNode(Node):
|
|||||||
self._status_pub = self.create_publisher(
|
self._status_pub = self.create_publisher(
|
||||||
DockingStatus, "/saltybot/docking_status", reliable
|
DockingStatus, "/saltybot/docking_status", reliable
|
||||||
)
|
)
|
||||||
|
self._mood_pub = None
|
||||||
|
if _SOCIAL_OK:
|
||||||
|
self._mood_pub = self.create_publisher(
|
||||||
|
Mood, "/saltybot/mood", reliable
|
||||||
|
)
|
||||||
|
|
||||||
# ── Services ─────────────────────────────────────────────────────────
|
# ── Services ─────────────────────────────────────────────────────────
|
||||||
if _MSGS_OK:
|
if _MSGS_OK:
|
||||||
@ -240,7 +251,7 @@ class DockingNode(Node):
|
|||||||
self.declare_parameter("aruco_marker_id", 42)
|
self.declare_parameter("aruco_marker_id", 42)
|
||||||
self.declare_parameter("aruco_marker_size_m", 0.10)
|
self.declare_parameter("aruco_marker_size_m", 0.10)
|
||||||
self.declare_parameter("ir_threshold", 0.50)
|
self.declare_parameter("ir_threshold", 0.50)
|
||||||
self.declare_parameter("battery_low_pct", 15.0)
|
self.declare_parameter("battery_low_pct", 20.0)
|
||||||
self.declare_parameter("battery_high_pct", 80.0)
|
self.declare_parameter("battery_high_pct", 80.0)
|
||||||
self.declare_parameter("servo_range_m", 1.00)
|
self.declare_parameter("servo_range_m", 1.00)
|
||||||
self.declare_parameter("k_linear", 0.30)
|
self.declare_parameter("k_linear", 0.30)
|
||||||
@ -343,6 +354,12 @@ class DockingNode(Node):
|
|||||||
# ── State-entry side effects ──────────────────────────────────────────
|
# ── State-entry side effects ──────────────────────────────────────────
|
||||||
if out.state_changed:
|
if out.state_changed:
|
||||||
self.get_logger().info(f"Docking FSM → {out.state.value}")
|
self.get_logger().info(f"Docking FSM → {out.state.value}")
|
||||||
|
# Publish charging mood when docking state is reached
|
||||||
|
if out.state == DockingState.CHARGING and self._mood_pub is not None:
|
||||||
|
mood_msg = Mood()
|
||||||
|
mood_msg.mood = "happy"
|
||||||
|
mood_msg.intensity = 1.0
|
||||||
|
self._mood_pub.publish(mood_msg)
|
||||||
|
|
||||||
if out.request_nav2:
|
if out.request_nav2:
|
||||||
self._send_nav2_goal()
|
self._send_nav2_goal()
|
||||||
|
|||||||
329
ui/social-bot/src/components/OpsDashboard.jsx
Normal file
329
ui/social-bot/src/components/OpsDashboard.jsx
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
function quatToEuler(qx, qy, qz, qw) {
|
||||||
|
const sinr_cosp = 2 * (qw * qx + qy * qz), cosr_cosp = 1 - 2 * (qx * qx + qy * qy);
|
||||||
|
const roll = Math.atan2(sinr_cosp, cosr_cosp);
|
||||||
|
const sinp = 2 * (qw * qy - qz * qx);
|
||||||
|
const pitch = Math.abs(sinp) >= 1 ? Math.PI / 2 * Math.sign(sinp) : Math.asin(sinp);
|
||||||
|
const siny_cosp = 2 * (qw * qz + qx * qy), cosy_cosp = 1 - 2 * (qy * qy + qz * qz);
|
||||||
|
const yaw = Math.atan2(siny_cosp, cosy_cosp);
|
||||||
|
return { roll: (roll * 180) / Math.PI, pitch: (pitch * 180) / Math.PI, yaw: (yaw * 180) / Math.PI };
|
||||||
|
}
|
||||||
|
function AttitudeGauge({ roll, pitch, yaw }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
|
||||||
|
const r = Math.min(W, H) / 2 - 10;
|
||||||
|
ctx.fillStyle = '#020208';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
ctx.strokeStyle = '#06b6d4';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = 'rgba(6,182,212,0.3)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = -90; i <= 90; i += 30) {
|
||||||
|
const angle = (i * Math.PI) / 180;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + Math.cos(angle) * r, cy + Math.sin(angle) * r);
|
||||||
|
ctx.lineTo(cx + Math.cos(angle) * (r - 8), cy + Math.sin(angle) * (r - 8));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate((roll * Math.PI) / 180);
|
||||||
|
ctx.strokeStyle = '#f59e0b';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
const horizonY = (-pitch / 90) * (r * 0.6);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-r * 0.7, horizonY);
|
||||||
|
ctx.lineTo(r * 0.7, horizonY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
ctx.fillStyle = '#06b6d4';
|
||||||
|
ctx.font = 'bold 10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(`YAW ${yaw.toFixed(0)}°`, cx, 15);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillStyle = '#f59e0b';
|
||||||
|
ctx.fillText(`R:${roll.toFixed(0)}°`, cx - r + 5, cy + r - 5);
|
||||||
|
ctx.fillText(`P:${pitch.toFixed(0)}°`, cx - r + 5, cy + r + 8);
|
||||||
|
}, [roll, pitch, yaw]);
|
||||||
|
return <canvas ref={canvasRef} width={180} height={180} className="bg-gray-950 rounded border border-cyan-950" />;
|
||||||
|
}
|
||||||
|
function LidarMap({ scanMsg }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !scanMsg) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2, maxR = Math.min(W, H) / 2 - 20;
|
||||||
|
ctx.fillStyle = '#020208';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
ctx.strokeStyle = 'rgba(6,182,212,0.2)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let d = 1; d <= 5; d++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, (d / 5) * maxR, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = 'rgba(6,182,212,0.3)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
[0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2].forEach((angle) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy);
|
||||||
|
ctx.lineTo(cx + Math.cos(angle) * maxR, cy + Math.sin(angle) * maxR);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
const ranges = scanMsg.ranges ?? [];
|
||||||
|
const angleMin = scanMsg.angle_min ?? 0;
|
||||||
|
const angleIncrement = scanMsg.angle_increment ?? 0.01;
|
||||||
|
ctx.fillStyle = '#06b6d4';
|
||||||
|
ranges.forEach((range, idx) => {
|
||||||
|
if (range === 0 || !isFinite(range) || range > 8) return;
|
||||||
|
const angle = angleMin + idx * angleIncrement;
|
||||||
|
const r = (Math.min(range, 5) / 5) * maxR;
|
||||||
|
ctx.fillRect(cx + Math.cos(angle) * r - 1, cy - Math.sin(angle) * r - 1, 2, 2);
|
||||||
|
});
|
||||||
|
ctx.strokeStyle = '#f59e0b';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy);
|
||||||
|
ctx.lineTo(cx, cy - maxR * 0.3);
|
||||||
|
ctx.stroke();
|
||||||
|
}, [scanMsg]);
|
||||||
|
return <canvas ref={canvasRef} width={200} height={200} className="bg-gray-950 rounded border border-cyan-950" />;
|
||||||
|
}
|
||||||
|
function OdomMap({ odomMsg }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const trailRef = useRef([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (odomMsg?.pose?.pose?.position) {
|
||||||
|
trailRef.current.push({ x: odomMsg.pose.pose.position.x, y: odomMsg.pose.pose.position.y });
|
||||||
|
if (trailRef.current.length > 500) trailRef.current.shift();
|
||||||
|
}
|
||||||
|
}, [odomMsg]);
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2, scale = 50;
|
||||||
|
ctx.fillStyle = '#020208';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
ctx.strokeStyle = 'rgba(6,182,212,0.1)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let i = -10; i <= 10; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + i * scale, 0);
|
||||||
|
ctx.lineTo(cx + i * scale, H);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, cy + i * scale);
|
||||||
|
ctx.lineTo(W, cy + i * scale);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
if (trailRef.current.length > 1) {
|
||||||
|
ctx.strokeStyle = '#06b6d4';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
trailRef.current.forEach((pt, i) => {
|
||||||
|
const x = cx + pt.x * scale;
|
||||||
|
const y = cy - pt.y * scale;
|
||||||
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
if (odomMsg?.pose?.pose?.position) {
|
||||||
|
const x = cx + odomMsg.pose.pose.position.x * scale;
|
||||||
|
const y = cy - odomMsg.pose.pose.position.y * scale;
|
||||||
|
ctx.fillStyle = '#f59e0b';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 5, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}, [odomMsg, trailRef.current.length]);
|
||||||
|
return <canvas ref={canvasRef} width={250} height={250} className="bg-gray-950 rounded border border-cyan-950" />;
|
||||||
|
}
|
||||||
|
function BatteryWidget({ batteryData }) {
|
||||||
|
const voltage = batteryData?.voltage ?? 0, current = batteryData?.current ?? 0, soc = batteryData?.soc ?? 0;
|
||||||
|
let color = '#22c55e';
|
||||||
|
if (soc < 20) color = '#ef4444';
|
||||||
|
else if (soc < 50) color = '#f59e0b';
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-xs font-bold text-cyan-700 tracking-widest mb-3">BATTERY</div>
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<div className="text-3xl font-bold" style={{ color }}>{soc.toFixed(0)}%</div>
|
||||||
|
<div className="text-xs text-gray-500 text-right">
|
||||||
|
<div>{voltage.toFixed(1)}V</div>
|
||||||
|
<div>{current.toFixed(1)}A</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-gray-900 rounded overflow-hidden border border-gray-800">
|
||||||
|
<div className="h-full transition-all duration-500" style={{ width: `${soc}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function MotorWidget({ motorData }) {
|
||||||
|
const left = motorData?.left ?? 0, right = motorData?.right ?? 0;
|
||||||
|
const dutyBar = (norm) => ({
|
||||||
|
pct: Math.abs(norm) * 50,
|
||||||
|
color: norm > 0 ? '#f97316' : '#3b82f6',
|
||||||
|
left: norm >= 0 ? '50%' : `${50 - Math.abs(norm) * 50}%`,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-xs font-bold text-cyan-700 tracking-widest mb-3">MOTORS</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[['L', left], ['R', right]].map(([label, val]) => {
|
||||||
|
const bar = dutyBar(val);
|
||||||
|
return (
|
||||||
|
<div key={label}>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-gray-600">{label}:</span>
|
||||||
|
<span className="text-orange-400 font-bold">{(val * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-2 bg-gray-900 rounded border border-gray-800 overflow-hidden">
|
||||||
|
<div className="absolute inset-y-0 left-1/2 w-px bg-gray-700" />
|
||||||
|
<div className="absolute inset-y-0 transition-all duration-100" style={{ left: bar.left, width: `${bar.pct}%`, background: bar.color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SystemWidget({ sysData }) {
|
||||||
|
const cpuTemp = sysData?.cpuTemp ?? 0, gpuTemp = sysData?.gpuTemp ?? 0, ramPct = sysData?.ramPct ?? 0, diskPct = sysData?.diskPct ?? 0;
|
||||||
|
const tempColor = (t) => { if (t > 80) return '#ef4444'; if (t > 60) return '#f59e0b'; return '#22c55e'; };
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-xs font-bold text-cyan-700 tracking-widest mb-3">SYSTEM</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs text-gray-600">CPU</div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: tempColor(cpuTemp) }}>{cpuTemp.toFixed(0)}°C</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs text-gray-600">GPU</div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: tempColor(gpuTemp) }}>{gpuTemp.toFixed(0)}°C</div>
|
||||||
|
</div>
|
||||||
|
<div><div className="text-xs text-gray-600 mb-1">RAM {ramPct.toFixed(0)}%</div>
|
||||||
|
<div className="h-1.5 bg-gray-900 rounded overflow-hidden border border-gray-800">
|
||||||
|
<div className="h-full bg-cyan-500 transition-all duration-500" style={{ width: `${Math.min(ramPct, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><div className="text-xs text-gray-600 mb-1">Disk {diskPct.toFixed(0)}%</div>
|
||||||
|
<div className="h-1.5 bg-gray-900 rounded overflow-hidden border border-gray-800">
|
||||||
|
<div className="h-full bg-amber-500 transition-all duration-500" style={{ width: `${Math.min(diskPct, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SocialWidget({ isSpeaking, faceId }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-xs font-bold text-cyan-700 tracking-widest mb-3">SOCIAL</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${isSpeaking ? 'bg-green-400 animate-pulse' : 'bg-gray-700'}`} />
|
||||||
|
<span className="text-xs">{isSpeaking ? 'Speaking' : 'Silent'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">Face: <span className="text-gray-400">{faceId || 'none'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function OpsDashboard({ subscribe }) {
|
||||||
|
const [imu, setImu] = useState({ roll: 0, pitch: 0, yaw: 0 });
|
||||||
|
const [battery, setBattery] = useState({ voltage: 0, current: 0, soc: 0 });
|
||||||
|
const [motors, setMotors] = useState({ left: 0, right: 0 });
|
||||||
|
const [system, setSystem] = useState({ cpuTemp: 0, gpuTemp: 0, ramPct: 0, diskPct: 0 });
|
||||||
|
const [scan, setScan] = useState(null);
|
||||||
|
const [odom, setOdom] = useState(null);
|
||||||
|
const [social, setSocial] = useState({ isSpeaking: false, faceId: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/saltybot/imu', 'sensor_msgs/Imu', (msg) => {
|
||||||
|
const q = msg.orientation;
|
||||||
|
setImu(quatToEuler(q.x, q.y, q.z, q.w));
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
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.battery_voltage_v !== undefined) {
|
||||||
|
setBattery(prev => ({ ...prev, voltage: parseFloat(kv.battery_voltage_v), soc: parseFloat(kv.battery_soc_pct) || 0, current: parseFloat(kv.battery_current_a) || 0 }));
|
||||||
|
}
|
||||||
|
if (kv.cpu_temp_c !== undefined || kv.gpu_temp_c !== undefined) {
|
||||||
|
setSystem(prev => ({ ...prev, cpuTemp: parseFloat(kv.cpu_temp_c) || prev.cpuTemp, gpuTemp: parseFloat(kv.gpu_temp_c) || prev.gpuTemp, ramPct: parseFloat(kv.ram_pct) || prev.ramPct, diskPct: parseFloat(kv.disk_pct) || prev.diskPct }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(msg.data);
|
||||||
|
const cmd = state.motor_cmd ?? 0;
|
||||||
|
const norm = Math.max(-1, Math.min(1, cmd / 1000));
|
||||||
|
setMotors({ left: norm, right: norm });
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
useEffect(() => subscribe('/scan', 'sensor_msgs/LaserScan', setScan), [subscribe]);
|
||||||
|
useEffect(() => subscribe('/odom', 'nav_msgs/Odometry', setOdom), [subscribe]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/social/speech/is_speaking', 'std_msgs/Bool', (msg) => {
|
||||||
|
setSocial(prev => ({ ...prev, isSpeaking: msg.data }));
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/social/face/active', 'std_msgs/String', (msg) => {
|
||||||
|
setSocial(prev => ({ ...prev, faceId: msg.data }));
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full gap-4 overflow-y-auto p-2 sm:p-4">
|
||||||
|
<div className="text-center mb-2">
|
||||||
|
<h2 className="text-lg sm:text-2xl font-bold text-orange-400 tracking-wider">⚡ OPERATIONS DASHBOARD</h2>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">Real-time telemetry • 10Hz critical • 1Hz system</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-max">
|
||||||
|
<BatteryWidget batteryData={battery} />
|
||||||
|
<MotorWidget motorData={motors} />
|
||||||
|
<SocialWidget {...social} />
|
||||||
|
<div className="flex justify-center"><AttitudeGauge roll={imu.roll} pitch={imu.pitch} yaw={imu.yaw} /></div>
|
||||||
|
<div className="flex justify-center"><LidarMap scanMsg={scan} /></div>
|
||||||
|
<SystemWidget sysData={system} />
|
||||||
|
<div className="flex justify-center lg:col-span-1"><OdomMap odomMsg={odom} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs text-gray-600 bg-gray-950 border border-cyan-950 rounded p-3">
|
||||||
|
<div><span className="text-gray-500">Battery</span><br /><span className="text-green-400 font-bold">{battery.soc.toFixed(0)}% • {battery.voltage.toFixed(1)}V</span></div>
|
||||||
|
<div><span className="text-gray-500">Motors</span><br /><span className="text-orange-400 font-bold">L:{(motors.left * 100).toFixed(0)}% • R:{(motors.right * 100).toFixed(0)}%</span></div>
|
||||||
|
<div><span className="text-gray-500">Attitude</span><br /><span className="text-blue-400 font-bold">R:{imu.roll.toFixed(0)}° Y:{imu.yaw.toFixed(0)}°</span></div>
|
||||||
|
<div><span className="text-gray-500">System</span><br /><span className="text-cyan-400 font-bold">CPU:{system.cpuTemp.toFixed(0)}°C RAM:{system.ramPct.toFixed(0)}%</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user