diff --git a/src/app/dashboard/api/route.ts b/src/app/dashboard/api/route.ts index 798182e..9f75430 100644 --- a/src/app/dashboard/api/route.ts +++ b/src/app/dashboard/api/route.ts @@ -1,4 +1,6 @@ -const hosts = ['192.168.0.2', '192.168.0.3', '192.168.0.55']; // Add more hosts here as needed +import { BASE_AP_IP, ROVER_AP_IP, ROVER_IP } from '@/constants'; + +const hosts = [BASE_AP_IP, ROVER_AP_IP, ROVER_IP]; const ping = require('ping'); diff --git a/src/components/ConnectionStatusDisplay.tsx b/src/components/ConnectionStatusDisplay.tsx index 0432fd1..ec90092 100644 --- a/src/components/ConnectionStatusDisplay.tsx +++ b/src/components/ConnectionStatusDisplay.tsx @@ -2,10 +2,11 @@ import React, { useState, useEffect } from 'react'; import Cookies from 'js-cookie'; import { useROS } from '@/ros/ROSContext'; +import { ROVER_IP } from '@/constants'; const ConnectionStatusDisplay: React.FC = () => { const { connectionStatus, connect, disconnect } = useROS(); - const [url, setUrl] = useState('ws://localhost:9090'); + const [url, setUrl] = useState(`ws://${ROVER_IP}:9090`); // Load url from cookie on mount useEffect(() => { diff --git a/src/components/ContainerCard.tsx b/src/components/ContainerCard.tsx index 49d08d3..c5b6a9b 100644 --- a/src/components/ContainerCard.tsx +++ b/src/components/ContainerCard.tsx @@ -1,7 +1,4 @@ import React, { useEffect, useRef, useState } from 'react'; -import { useEnvContext } from 'next-runtime-env'; - - export interface ContainerOption { key: string; @@ -32,8 +29,6 @@ const ContainerCard: React.FC = ({ const [eventMsg, setEventMsg] = useState(externalEventMsg); const socketRef = useRef(null); const logBoxRef = useRef(null); - const { NEXT_PUBLIC_LAUNCHSERVER } = useEnvContext(); - const apiBase = NEXT_PUBLIC_LAUNCHSERVER || 'localhost:8080'; // Sync status and clear logs/eventMsg when container ID changes (new container after restart) useEffect(() => { diff --git a/src/components/ContainerList.tsx b/src/components/ContainerList.tsx index ce991c0..2c8f9f9 100644 --- a/src/components/ContainerList.tsx +++ b/src/components/ContainerList.tsx @@ -3,15 +3,16 @@ import React, { useEffect, useState, useRef } from 'react'; import ContainerCard, { ContainerOption } from './ContainerCard'; import SetResetPanel from './SetResetPanel'; import toast from "react-hot-toast"; +import { ROVER_IP } from '@/constants'; const dockersToPull = [ - 'cprtsoftware/rover:arm64', + 'cprtsoftware/rover:latest', 'cprtsoftware/web-ui:latest', 'cprtsoftware/container-launcher:latest', ]; -const DEFAULT_API_BASE = 'http://localhost:8080'; +const DEFAULT_API_BASE = `http://${ROVER_IP}:8080`; const ContainerList: React.FC = () => { const [options, setOptions] = useState([]); diff --git a/src/components/panels/ArmControlPanel.tsx b/src/components/panels/ArmControlPanel.tsx index a690a53..50e866d 100644 --- a/src/components/panels/ArmControlPanel.tsx +++ b/src/components/panels/ArmControlPanel.tsx @@ -9,33 +9,127 @@ const ArmControlPanel: React.FC = () => { const [poseNames, setPoseNames] = useState([]); const [selectedPose, setSelectedPose] = useState(''); + const [presetName, setPresetName] = useState(''); + const [armState, setArmState] = useState(null); + const [servoStatus, setServoStatus] = useState(null); const [distance, setDistance] = useState(null); const [distanceStatus, setDistanceStatus] = useState(null); + const [checkCollisions, setCheckCollisions] = useState(false); + const [collisionParameterLoaded, setCollisionParameterLoaded] = useState(false); + const [collisionParameterUpdating, setCollisionParameterUpdating] = useState(false); const [response, setResponse] = useState<{ success: boolean; message: string } | null>(null); - const refreshPoseNames = () => { + const refreshPoseNames = (preferredPose?: string) => { if (!ros) return; const service = new ROSLIB.Service({ ros, - name: '/get_names_poses', - serviceType: 'interfaces/srv/GetPoses', + name: '/move_group_interface/get_named_targets', + serviceType: 'interfaces/srv/GetNamedTargets', }); const request = new ROSLIB.ServiceRequest({}); service.callService(request, (result: any) => { - const names = result.pose_names ?? []; + const success = result.success ?? false; + const msg = result.message ?? ''; + if (!success) { + setPoseNames([]); + setSelectedPose(''); + setResponse({ success: false, message: `Failed to get named targets: ${msg}` }); + return; + } + + const names = result.names ?? []; setPoseNames(names); - if (names.length > 0 && !selectedPose) { + if (preferredPose && names.includes(preferredPose)) { + setSelectedPose(preferredPose); + } else if (names.length > 0 && !selectedPose) { setSelectedPose(names[0]); + } else if (selectedPose && !names.includes(selectedPose)) { + setSelectedPose(names[0] ?? ''); + } + }); + }; + + const refreshCollisionChecking = () => { + if (!ros) return; + + setCollisionParameterLoaded(false); + + const service = new ROSLIB.Service({ + ros, + name: '/servo_node/get_parameters', + serviceType: 'rcl_interfaces/srv/GetParameters', + }); + + const request = new ROSLIB.ServiceRequest({ + names: ['moveit_servo.check_collisions'], + }); + + service.callService(request, (result: any) => { + const value = result.values?.[0]; + + if (!value || value.type !== 1) { + setCollisionParameterLoaded(false); + setResponse({ + success: false, + message: 'Failed to read /servo_node check_collisions parameter', + }); + return; } + + setCheckCollisions(value.bool_value ?? false); + setCollisionParameterLoaded(true); }); }; useEffect(() => { refreshPoseNames(); + refreshCollisionChecking(); + }, [ros]); + + useEffect(() => { + if (!ros) return; + + const stateTopic = new ROSLIB.Topic({ + ros, + name: '/arm_teleop_node/state', + messageType: 'std_msgs/msg/String', + queue_size: 1, + }); + + const handleState = (msg: any) => { + setArmState(msg.data); + }; + + stateTopic.subscribe(handleState); + + return () => { + stateTopic.unsubscribe(handleState); + }; + }, [ros]); + + useEffect(() => { + if (!ros) return; + + const statusTopic = new ROSLIB.Topic({ + ros, + name: '/servo_node/status', + messageType: 'moveit_msgs/msg/ServoStatus', + queue_size: 1, + }); + + const handleServoStatus = (msg: any) => { + setServoStatus(msg.message); + }; + + statusTopic.subscribe(handleServoStatus); + + return () => { + statusTopic.unsubscribe(handleServoStatus); + }; }, [ros]); useEffect(() => { @@ -70,6 +164,95 @@ const ArmControlPanel: React.FC = () => { return distance_with_offset.toFixed(3); }; + const handleCollisionCheckingChange = (enabled: boolean) => { + if (!ros) { + alert('ROS is not connected'); + return; + } + + setCollisionParameterUpdating(true); + + const service = new ROSLIB.Service({ + ros, + name: '/servo_node/set_parameters', + serviceType: 'rcl_interfaces/srv/SetParameters', + }); + + const request = new ROSLIB.ServiceRequest({ + parameters: [ + { + name: 'moveit_servo.check_collisions', + value: { + type: 1, + bool_value: enabled, + }, + }, + ], + }); + + service.callService(request, (result: any) => { + const parameterResult = result.results?.[0]; + const success = parameterResult?.successful ?? false; + const reason = parameterResult?.reason ?? ''; + + setCollisionParameterUpdating(false); + + if (!success) { + setResponse({ + success: false, + message: reason || 'Failed to update collision checking', + }); + refreshCollisionChecking(); + return; + } + + setCheckCollisions(enabled); + setResponse({ + success: true, + message: `Collision checking ${enabled ? 'enabled' : 'disabled'}`, + }); + }); + }; + + const handleSavePreset = () => { + if (!ros) { + alert('ROS is not connected'); + return; + } + + const name = presetName.trim(); + + if (!name) { + setResponse({ success: false, message: 'Preset name cannot be empty' }); + return; + } + + const service = new ROSLIB.Service({ + ros, + name: '/move_group_interface/save_current_pose', + serviceType: 'interfaces/srv/SaveCurrentPose', + }); + + const request = new ROSLIB.ServiceRequest({ + name, + }); + + service.callService(request, (result: any) => { + const success = result.success ?? false; + const message = result.message ?? ''; + + setResponse({ + success, + message, + }); + + if (success) { + setPresetName(''); + refreshPoseNames(name); + } + }); + }; + const handleGo = () => { if (!ros) { alert('ROS is not connected'); @@ -83,8 +266,8 @@ const ArmControlPanel: React.FC = () => { const service = new ROSLIB.Service({ ros, - name: '/go_to_pose', - serviceType: 'interfaces/srv/GoToPose', + name: '/move_group_interface/go_to_named_pose', + serviceType: 'interfaces/srv/GoToNamedPose', }); const request = new ROSLIB.ServiceRequest({ @@ -123,6 +306,13 @@ const ArmControlPanel: React.FC = () => { return (
+
+ State: + {armState ?? 'No data'} + Servo Status: + {servoStatus ?? 'No data'} +
+
+
+ setPresetName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSavePreset(); + } + }} + placeholder="New preset name" + /> + + +
+
Distance: {getDistanceDisplay()}
- @@ -183,7 +409,8 @@ const ArmControlPanel: React.FC = () => { margin-bottom: 0.25rem; } - select { + select, + input { padding: 0.5rem; border: 1px solid #333; border-radius: 4px; @@ -191,6 +418,16 @@ const ArmControlPanel: React.FC = () => { color: #f1f1f1; } + input::placeholder { + color: #999; + } + + .preset-group { + display: flex; + flex-direction: column; + margin-bottom: 0.75rem; + } + .display-row { display: flex; justify-content: space-between; @@ -211,6 +448,40 @@ const ArmControlPanel: React.FC = () => { font-weight: 600; } + .toggle-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + padding: 0.5rem; + border: 1px solid #333; + border-radius: 4px; + background: #2b2b2b; + cursor: pointer; + } + + .toggle-row span { + display: flex; + flex-direction: column; + color: #ccc; + } + + .toggle-row small { + margin-top: 0.15rem; + color: #888; + font-size: 0.7rem; + } + + .toggle-row input { + width: 1.1rem; + height: 1.1rem; + cursor: pointer; + } + + .toggle-row input:disabled { + cursor: not-allowed; + } + button { background: #0070f3; color: #f1f1f1; @@ -230,6 +501,18 @@ const ArmControlPanel: React.FC = () => { cursor: not-allowed; } + .save-button { + background: #198754; + } + + .save-button:hover { + background: #146c43; + } + + .save-button:disabled { + background: #555; + } + .stop-button { background: #dc3545; } @@ -256,4 +539,4 @@ const ArmControlPanel: React.FC = () => { ); }; -export default ArmControlPanel; \ No newline at end of file +export default ArmControlPanel; diff --git a/src/components/panels/MapView.tsx b/src/components/panels/MapView.tsx index 4ea70ca..e95b47d 100644 --- a/src/components/panels/MapView.tsx +++ b/src/components/panels/MapView.tsx @@ -9,9 +9,9 @@ import BreadcrumbTrail from '../BreadCrumbTrail'; import WaypointCreatorWindow from '../WaypointCreatorWindow'; import MapInteractionHandler from '../MapInteractionHandler'; import MapCompass from '../MapCompass'; -import { useEnvContext } from 'next-runtime-env'; import { useROS } from '@/ros/ROSContext'; import ROSLIB from 'roslib'; +import { TILING_SERVER } from '@/constants'; const DEFAULT_MAP_CENTER: LatLngTuple = [38.405884, -110.791719]; @@ -50,8 +50,6 @@ const MapView: React.FC = ({offline}) => { const [antennaLoc, setAntennaLoc] = useState(DEFAULT_MAP_CENTER); const [antennaHead, setAntennaHead] = useState(0); const { waypoints } = useWaypoints(); - const { NEXT_PUBLIC_TILE_SERVER } = useEnvContext(); - const tileServer = NEXT_PUBLIC_TILE_SERVER || "localhost:80"; useEffect(() => { if (!ros) return; @@ -112,7 +110,7 @@ const MapView: React.FC = ({offline}) => { > diff --git a/src/components/panels/MosaicDashboard.tsx b/src/components/panels/MosaicDashboard.tsx index 4638018..9b4eced 100644 --- a/src/components/panels/MosaicDashboard.tsx +++ b/src/components/panels/MosaicDashboard.tsx @@ -25,6 +25,9 @@ import EspSensorPanel from './EspSensorPanel'; import PDBRailsPanel from './PDBRails'; import ArmControlPanel from './ArmControlPanel'; import WebRTCClient from './WebRTCClient'; +import TimerPanel from './TimerPanel'; + +import { ROVER_IP } from '@/constants'; type TileType = | 'mapView' @@ -43,6 +46,7 @@ type TileType = | 'pdbRails' | 'armControlPanel' | 'webRTCClient' + | 'timerPanel' ; type TileId = `${TileType}:${number}`; @@ -64,6 +68,7 @@ const TILE_DISPLAY_NAMES: Record = { pdbRails: 'PDB Rails', armControlPanel: 'Arm Control', webRTCClient: 'WebRTC Client', + timerPanel: 'Multi-Timer', }; const ALL_TILE_TYPES: TileType[] = [ @@ -83,6 +88,7 @@ const ALL_TILE_TYPES: TileType[] = [ 'pdbRails', 'armControlPanel', 'webRTCClient', + 'timerPanel', ]; function tileTypeOf(id: TileId): TileType { @@ -438,10 +444,18 @@ const MosaicDashboard: React.FC = () => { case 'webRTCClient': return( - + ); + case 'timerPanel': + return ( + + + + ); default: return
Unknown tile
; } diff --git a/src/components/panels/MotorStatusPanel.tsx b/src/components/panels/MotorStatusPanel.tsx index 1f4ef08..e45261a 100644 --- a/src/components/panels/MotorStatusPanel.tsx +++ b/src/components/panels/MotorStatusPanel.tsx @@ -8,6 +8,7 @@ type MotorStatus = { velocity: number; output_current: number; active_errors: number; + remaining: number; }; const MOTORS = { @@ -121,6 +122,12 @@ const MotorStatusPanel: React.FC = () => { ); }; + const handleResetAll = () => { + (Object.entries(MOTORS) as [MotorKey, typeof MOTORS[MotorKey]][]).forEach( + ([key, { topic }]) => handleResetErrors(key, topic), + ); + }; + useEffect(() => { if (!ros) return; @@ -140,7 +147,8 @@ const MotorStatusPanel: React.FC = () => { [key]: { velocity: msg.velocity, output_current: msg.output_current, - active_errors: msg.active_errors + active_errors: msg.active_errors, + remaining: 2 }, })); }; @@ -152,8 +160,30 @@ const MotorStatusPanel: React.FC = () => { return () => unsubscribers.forEach(unsub => unsub()); }, [ros]); + useEffect(() => { + const interval = window.setInterval(() => { + setMotorStats(prev => { + const next = { ...prev }; + (Object.keys(prev) as MotorKey[]).forEach(key => { + const stat = prev[key]; + if (!stat) return; + next[key] = { ...stat, remaining: stat.remaining - 0.1 }; + }); + return next; + }); + }, 100); + + return () => window.clearInterval(interval); + }, []); return (
+ @@ -169,10 +199,16 @@ const MotorStatusPanel: React.FC = () => { ([key, { label, topic }]) => { const stat = motorStats[key]; var errorStr = '-'; + var color = "#6c757d"; if (stat) { var errNames: string[] = []; Object.keys(MotorError).forEach((key: string) => { if (stat.active_errors & MotorError[key]) errNames.push(key) }) errorStr = errNames.length ? errNames.join(', ') : "NONE"; + color = errNames.length ? "#ef4444" : "#22c55e"; + if (stat.remaining < 0) { + errorStr = "STALE"; + color = "#ffc107"; + } } return ( @@ -180,7 +216,7 @@ const MotorStatusPanel: React.FC = () => { - +
{label} {stat && Number.isFinite(stat.velocity) ? stat.velocity.toFixed(2) : '-'} {stat && Number.isFinite(stat.output_current) ? `${stat.output_current.toFixed(2)}A` : '-'}{errorStr}{errorStr}