diff --git a/package-lock.json b/package-lock.json index da93818..2dc79f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "leaflet": "^1.9.4", "next": "15.1.6", "next-runtime-env": "^3.3.0", + "ping": "^1.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hot-toast": "^2.6.0", @@ -25,6 +26,7 @@ "recharts": "^2.15.1", "roslib": "^1.4.1", "three": "^0.173.0", + "uuid": "^13.0.0", "webrtc-adapter": "^9.0.1" }, "devDependencies": { @@ -5358,6 +5360,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/ping": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ping/-/ping-1.0.0.tgz", + "integrity": "sha512-3dxdgGtV+7P/EVo42JhkGSomeO/0GGicSz3mI/yK+AI+VGNAOfakw5XfcbGI4IjyBY+ZZwvuRXdhnNF2uliKew==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/pngparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pngparse/-/pngparse-2.0.1.tgz", @@ -5691,6 +5702,19 @@ "react-dnd": "^16.0.1" } }, + "node_modules/react-mosaic-component/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/react-popper": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", @@ -7001,16 +7025,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/vary": { diff --git a/package.json b/package.json index ec55702..0e1fbba 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "leaflet": "^1.9.4", "next": "15.1.6", "next-runtime-env": "^3.3.0", + "ping": "^1.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hot-toast": "^2.6.0", @@ -26,6 +27,7 @@ "recharts": "^2.15.1", "roslib": "^1.4.1", "three": "^0.173.0", + "uuid": "^13.0.0", "webrtc-adapter": "^9.0.1" }, "devDependencies": { diff --git a/src/app/dashboard/api/route.ts b/src/app/dashboard/api/route.ts index 22188b2..b4c9131 100644 --- a/src/app/dashboard/api/route.ts +++ b/src/app/dashboard/api/route.ts @@ -2,6 +2,36 @@ const USERNAME = 'ubnt'; const PASSWORD = 'samitherover'; const baseStationIP = '192.168.0.2'; +const hosts = ['192.168.0.2', '172.19.228.1']; // Add more hosts here as needed + +const ping = require('ping'); + +// Ping configuration +const pingConfig = { + timeout: 1, // timeout set to 1 second +}; + +// Function to ping multiple hosts and return RTT times in dictionary format +async function pingHosts(hosts: string[]): Promise<{ [key: string]: number }> { + const results: { [key: string]: number } = {}; + + const pingPromises = hosts.map(async (host) => { + try { + const res = await ping.promise.probe(host, pingConfig); + if (res.alive) { + results[host] = parseFloat(res.time as string); + } else { + results[host] = -1; + } + } catch (error) { + results[host] = -1; + } + }); + + await Promise.all(pingPromises); + return results; +} + // Authenticates with the base station async function authenticate() { const response = await fetch(`http://${baseStationIP}/api/auth`, { @@ -39,25 +69,45 @@ async function fetchStatus(cookie: string) { } export async function GET(request: Request) { + // Initialize response data with defaults + let uplinkCapacity = 0; + let downlinkCapacity = 0; + let uplinkThroughput = 0; + let downlinkThroughput = 0; + let baseStationError: string | null = null; + + // Try to fetch base station data, but don't fail if it's unavailable try { const authStatus = await authenticate(); - - const status = await fetchStatus(authStatus.cookie); - // Extract only the needed fields for the frontend - const uplinkCapacity = status.wireless?.polling?.ucap ?? 0; - const downlinkCapacity = status.wireless?.polling?.dcap ?? 0; - const uplinkThroughput = status.wireless?.throughput?.tx ?? 0; - const downlinkThroughput = status.wireless?.throughput?.rx ?? 0; + uplinkCapacity = status.wireless?.polling?.ucap ?? 0; + downlinkCapacity = status.wireless?.polling?.dcap ?? 0; + uplinkThroughput = status.wireless?.throughput?.tx ?? 0; + downlinkThroughput = status.wireless?.throughput?.rx ?? 0; + } catch (error: any) { + baseStationError = error.message; + console.warn('Failed to fetch base station data:', error.message); + } + + // Always ping hosts, regardless of base station status + try { + const pings = await pingHosts(hosts); return Response.json({ uplinkCapacity, downlinkCapacity, uplinkThroughput, downlinkThroughput, + pings, + baseStationError, // Include error info in response }); } catch (error: any) { - return new Response(JSON.stringify({ error: error.message }), { status: 500 }); + console.error('Failed to ping hosts:', error); + return Response.json( + { error: 'Failed to ping hosts: ' + error.message }, + { status: 500 } + ); } -} \ No newline at end of file +} + diff --git a/src/components/panels/MosaicDashboard.tsx b/src/components/panels/MosaicDashboard.tsx index a1d4be6..674fd8c 100644 --- a/src/components/panels/MosaicDashboard.tsx +++ b/src/components/panels/MosaicDashboard.tsx @@ -17,6 +17,8 @@ import GoalSetterPanel from './GoalSetterPanel'; import GasSensor from './GasSensor'; import NetworkHealthTelemetryPanel from './NetworkHealthTelemetryPanel'; import VideoControls from './VideoControls'; +import MotorStatusPanel from './MotorStatusPanel'; +import { v4 as uuidv4 } from 'uuid'; type TileType = | 'mapView' @@ -26,7 +28,8 @@ type TileType = | 'gasSensor' | 'orientationDisplay' | 'goalSetter' - | 'networkHealthMonitor'; + | 'networkHealthMonitor' + | 'MotorStatusPanel'; type TileId = `${TileType}:${string}`; @@ -39,6 +42,7 @@ const TILE_DISPLAY_NAMES: Record = { orientationDisplay: 'Rover Orientation', goalSetter: 'Nav2', networkHealthMonitor: 'Connection Health', + MotorStatusPanel: 'motor', }; const ALL_TILE_TYPES: TileType[] = [ @@ -50,10 +54,11 @@ const ALL_TILE_TYPES: TileType[] = [ 'waypointList', 'gasSensor', 'goalSetter', + 'MotorStatusPanel', ]; function makeTileId(type: TileType): TileId { - const uid = crypto.randomUUID(); + const uid = uuidv4(); return `${type}:${uid}`; } @@ -314,6 +319,18 @@ const MosaicDashboard: React.FC = () => { ); + case 'MotorStatusPanel': + return ( + + title={TILE_DISPLAY_NAMES[type]} + path={path} + additionalControls={ + + } + > + + + ); default: return
Unknown tile
; diff --git a/src/components/panels/MotorStatusPanel.tsx b/src/components/panels/MotorStatusPanel.tsx new file mode 100644 index 0000000..9bdab32 --- /dev/null +++ b/src/components/panels/MotorStatusPanel.tsx @@ -0,0 +1,134 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import ROSLIB from 'roslib'; +import { useROS } from '@/ros/ROSContext'; + +type MotorStatus = { + velocity: number; + temperature: number; + output_current: number; +}; + +const MOTORS = { + fl: { label: 'FLeft', topic: '/frontLeft/status' }, + fr: { label: 'FRight', topic: '/frontRight/status' }, + rl: { label: 'BLeft', topic: '/backLeft/status' }, + rr: { label: 'BRight', topic: '/backRight/status' }, +} as const; + +type MotorKey = keyof typeof MOTORS; + +const MotorStatusPanel: React.FC = () => { + const { ros } = useROS(); + + const [motorStats, setMotorStats] = useState< + Record + >({ + fl: null, + fr: null, + rl: null, + rr: null, + }); + + useEffect(() => { + if (!ros) return; + + const unsubscribers = ( + Object.entries(MOTORS) as [MotorKey, typeof MOTORS[MotorKey]][] + ).map(([key, { topic }]) => { + const rosTopic = new ROSLIB.Topic({ + ros, + name: topic, + messageType: 'ros_phoenix/msg/MotorStatus', + throttle_rate: 100, + }); + + const handler = (msg: any) => { + setMotorStats(prev => ({ + ...prev, + [key]: { + velocity: msg.velocity, + temperature: msg.temperature, + output_current: msg.output_current, + }, + })); + }; + + rosTopic.subscribe(handler); + return () => rosTopic.unsubscribe(handler); + }); + + return () => unsubscribers.forEach(unsub => unsub()); + }, [ros]); + + return ( +
+ + + + + + + + + + + {(Object.entries(MOTORS) as [MotorKey, typeof MOTORS[MotorKey]][]).map( + ([key, { label }]) => { + const stat = motorStats[key]; + + return ( + + + + + + + ); + } + )} + +
MotorVel.Temp.Curr.
{label}{stat ? stat.velocity.toFixed(2) : '-'}{stat ? `${stat.temperature.toFixed(2)}°C` : '-'}{stat ? `${stat.output_current.toFixed(2)}A` : '-'}
+ + +
+ ); +}; + +export default MotorStatusPanel; diff --git a/src/components/panels/NetworkHealthTelemetryPanel.tsx b/src/components/panels/NetworkHealthTelemetryPanel.tsx index 7496694..eb5f52e 100644 --- a/src/components/panels/NetworkHealthTelemetryPanel.tsx +++ b/src/components/panels/NetworkHealthTelemetryPanel.tsx @@ -9,6 +9,15 @@ import { Cell, LabelList, } from "recharts"; + +const dotStyle = (up: boolean): React.CSSProperties => ({ + width: 12, + height: 12, + borderRadius: "50%", + display: "inline-block", + backgroundColor: up ? "#22c55e" : "#ef4444", +}); + const NetworkHealthTelemetryPanel: React.FC = () => { const [stats, setStats] = useState({ uplinkThroughput: 0, @@ -17,47 +26,71 @@ const NetworkHealthTelemetryPanel: React.FC = () => { downlinkCapacity: 100, }); + const [pings, setPings] = useState<{ [key: string]: number }>({}); + const [baseStationError, setBaseStationError] = useState(null); + + console.log('pings state:', pings); + useEffect(() => { let interval: NodeJS.Timeout; - // Polling function to fetch data from the API route + const poll = async () => { try { - const response = await fetch("/dashboard/api", { + const response = await fetch("/dashboard/api", { method: "GET", + headers: { + "Content-Type": "application/json", + } }); if (!response.ok) { - console.error("Failed to fetch status from API route"); + console.error(`API returned ${response.status}: ${response.statusText}`); return; } + const data = await response.json(); const uplinkCapacity = data.uplinkCapacity ?? 0; const downlinkCapacity = data.downlinkCapacity ?? 0; const uplinkThroughput = data.uplinkThroughput?? 0; const downlinkThroughput = data.downlinkThroughput ?? 0; - setStats({ uplinkThroughput, downlinkThroughput, uplinkCapacity, downlinkCapacity, }); + // Set ping results for each host + if (data.pings) { + setPings(data.pings); + } + // Track base station errors + if (data.baseStationError) { + setBaseStationError(data.baseStationError); + } else { + setBaseStationError(null); + } } catch (error) { console.error("Polling error:", error); } }; - // Initial poll + start interval poll(); interval = setInterval(poll, 1000); - // Cleanup on unmount - return () => { - if (interval) clearInterval(interval); - }; + return () => clearInterval(interval); }, []); - // rounded throughput kbps / 10 to get 2 decimal places, then divided by 100 to finish conversion to Mbps + const rows = [ + // Add ping results for each host + ...Object.keys(pings).map((host) => ({ + + name: host, + rttMs: pings[host] ?? 0, + up: pings[host] !== -1 && pings[host] !== undefined, + + })), + ]; + const data = [ { name: "Uplink", @@ -72,6 +105,48 @@ const NetworkHealthTelemetryPanel: React.FC = () => { ]; return ( +
+
+ + + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
+ Name + + RTT (ms) + + Status +
{r.name} + {r.rttMs === -1 ? "Offline" : r.rttMs} + + +
+
{ }} > -
+
{
+
); }; diff --git a/src/components/panels/OrientationDisplayPanel.tsx b/src/components/panels/OrientationDisplayPanel.tsx index f828c85..5b766b9 100644 --- a/src/components/panels/OrientationDisplayPanel.tsx +++ b/src/components/panels/OrientationDisplayPanel.tsx @@ -4,30 +4,11 @@ import * as THREE from 'three'; import ROSLIB from 'roslib'; import { useROS } from '@/ros/ROSContext'; -type MotorStatus = { - velocity: number; - temperature: number; - output_current: number; - bus_voltage: number; -}; - const OrientationDisplayPanel: React.FC = () => { const { ros } = useROS(); const containerRef = useRef(null); const cubeRef = useRef(null); - const [motorStats, setMotorStats] = useState<{ - fl: MotorStatus | null; - fr: MotorStatus | null; - rl: MotorStatus | null; - rr: MotorStatus | null; - }>({ - fl: null, - fr: null, - rl: null, - rr: null, - }); - useEffect(() => { if (!containerRef.current) return; @@ -121,95 +102,10 @@ const OrientationDisplayPanel: React.FC = () => { return () => imuTopic.unsubscribe(handleIMU); }, [ros]); - useEffect(() => { - if (!ros) return; - - const motors = { - fl: '/frontLeft/status', - fr: '/frontRight/status', - rl: '/backLeft/status', - rr: '/backRight/status', - }; - - const subscriptions = Object.entries(motors).map(([key, topicName]) => { - const topic = new ROSLIB.Topic({ - ros, - name: topicName, - messageType: 'ros_phoenix/msg/MotorStatus', - throttle_rate: 100, - }); - - const handler = (msg: any) => { - setMotorStats(prev => ({ - ...prev, - [key]: { - velocity: msg.velocity, - temperature: msg.temperature, - output_current: msg.output_current, - bus_voltage: msg.bus_voltage, - }, - })); - }; - - topic.subscribe(handler); - return () => topic.unsubscribe(handler); - }); - - return () => subscriptions.forEach(unsub => unsub()); - }, [ros]); - - const renderBar = (value: number, color: string, clamp: number) => { - const percentage = Math.min(Math.max(value / clamp, 0), 1) * 100; - return ( -
-
-
- ); - }; - - const renderMotorInfo = (label: string, data: MotorStatus | null) => { - if (!data) return
{label}: waiting for data...
; - return ( -
- {label}
-
{data.velocity < 0 ? renderBar(Math.abs(data.velocity), '#f00', 20) : renderBar(data.velocity, '#0f0', 20)} {data.velocity.toFixed(2)} m/s
-
{renderBar(data.output_current, '#ff0', 6)} {data.output_current.toFixed(2)} A
- Temp: {data.temperature.toFixed(1)} °C
-
- ); - }; - - const getBusVoltage = () => { - const voltages = Object.values(motorStats) - .filter((stat): stat is MotorStatus => stat !== null) - .map(stat => stat.bus_voltage); - - if (voltages.length === 0) return null; - - const avg = voltages.reduce((sum, v) => sum + v, 0) / voltages.length; - return avg.toFixed(2); - }; return (
-
{getBusVoltage() ? `Bus Voltage: ${getBusVoltage()} V` : 'Waiting for voltage...'}
-
{renderMotorInfo('Front Left', motorStats.fl)}
-
{renderMotorInfo('Front Right', motorStats.fr)}
-
{renderMotorInfo('Rear Left', motorStats.rl)}
-
{renderMotorInfo('Rear Right', motorStats.rr)}
);