diff --git a/package-lock.json b/package-lock.json index c62cd8f..d594bdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@react-three/drei": "^9.121.4", "@react-three/fiber": "^8.17.14", "fetch-cookie": "^3.2.0", + "html2canvas": "^1.4.1", "js-cookie": "^3.0.5", "leaflet": "^1.9.4", "next": "15.1.6", @@ -1960,6 +1961,14 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2348,6 +2357,14 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -3963,6 +3980,18 @@ "react-is": "^16.7.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -6792,6 +6821,14 @@ "node": ">=6" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/three": { "version": "0.173.0", "resolved": "https://registry.npmjs.org/three/-/three-0.173.0.tgz", @@ -7155,6 +7192,14 @@ "node": ">= 4" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", diff --git a/package.json b/package.json index c91f813..e8cb702 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@react-three/drei": "^9.121.4", "@react-three/fiber": "^8.17.14", "fetch-cookie": "^3.2.0", + "html2canvas": "^1.4.1", "js-cookie": "^3.0.5", "leaflet": "^1.9.4", "next": "15.1.6", diff --git a/src/components/CameraSourceDropdown.tsx b/src/components/CameraSourceDropdown.tsx index a7553d6..6ddccc3 100644 --- a/src/components/CameraSourceDropdown.tsx +++ b/src/components/CameraSourceDropdown.tsx @@ -21,7 +21,7 @@ const CameraSourceDropdown: React.FC = ({ onChange }) } const service = new ROSLIB.Service({ ros: ros!, - name: "/get_cameras", + name: "/input_node/get_cameras", serviceType: "interfaces/srv/GetCameras", // adjust service type if it's differently named }); diff --git a/src/components/RtpStats.tsx b/src/components/RtpStats.tsx index 6d0240e..368f3d4 100644 --- a/src/components/RtpStats.tsx +++ b/src/components/RtpStats.tsx @@ -10,8 +10,6 @@ type RtpStatsMsg = { num_late: number; num_duplicates: number; avg_jitter: number; - recovered: number; - unrecovered: number; }; const formatNumber = (ms: number | null | undefined) => { @@ -45,8 +43,6 @@ const RtpStats: React.FC = () => { num_late: msg.num_late, num_duplicates: msg.num_duplicates, avg_jitter: msg.avg_jitter / 1000000, // convert ns to ms - recovered: msg.recovered, - unrecovered: msg.unrecovered, }; setStats(newData); setLastUpdateMs(Date.now()); @@ -117,16 +113,6 @@ const RtpStats: React.FC = () => { {formatNumber(stats?.avg_jitter)} -
- FEC recovered - {formatNumber(stats?.recovered)} -
- -
- FEC unrecovered - {formatNumber(stats?.unrecovered)} -
-
Updated diff --git a/src/components/TelemetryGraph.tsx b/src/components/TelemetryGraph.tsx new file mode 100644 index 0000000..dba80ce --- /dev/null +++ b/src/components/TelemetryGraph.tsx @@ -0,0 +1,216 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import ROSLIB from 'roslib'; +import { useROS } from '@/ros/ROSContext'; +import html2canvas from 'html2canvas'; +import { + ResponsiveContainer, + LineChart, + Line, + CartesianGrid, + XAxis, + YAxis, + Tooltip, +} from 'recharts'; + +interface Props { + topic: string; + label: string; + color?: string; +} + +interface Point { + time: number; + value: number; +} + +const TelemetryGraph: React.FC = ({ topic, label, color = '#4da3ff' }) => { + const { ros } = useROS(); + const [data, setData] = useState([]); + const [windowSize, setWindowSize] = useState(30); + const containerRef = useRef(null); + + useEffect(() => { + if (!ros) return; + + const rosTopic = new ROSLIB.Topic({ + ros, + name: topic, + messageType: 'std_msgs/msg/UInt16', + }); + + const handleMsg = (msg: any) => { + const newPoint: Point = { + time: Date.now(), + value: Number(msg.data), + }; + + setData((prev) => { + const updated = [...prev, newPoint]; + return updated.length > windowSize ? updated.slice(-windowSize) : updated; + }); + }; + + rosTopic.subscribe(handleMsg); + return () => rosTopic.unsubscribe(handleMsg); + }, [ros, topic, windowSize]); + + const downloadPNG = async () => { + if (!containerRef.current) return; + + const canvas = await html2canvas(containerRef.current, { + backgroundColor: '#181818', + }); + + const link = document.createElement('a'); + link.download = `${label}.png`; + link.href = canvas.toDataURL('image/png'); + link.click(); + }; + + const formatTime = (time: number) => + new Date(time).toLocaleTimeString([], { + minute: '2-digit', + second: '2-digit', + }); + + return ( +
+
+
+

{label}

+

{data.length > 0 ? data[data.length - 1].value : '--'}

+
+ +
+ + + +
+
+ +
+ + + + + + + + + formatTime(Number(value))} + contentStyle={{ + background: '#222', + border: '1px solid #444', + borderRadius: '8px', + color: '#fff', + }} + /> + + + + +
+ + +
+ ); +}; + +export default TelemetryGraph; \ No newline at end of file diff --git a/src/components/panels/MosaicDashboard.tsx b/src/components/panels/MosaicDashboard.tsx index 14fa730..692a5cf 100644 --- a/src/components/panels/MosaicDashboard.tsx +++ b/src/components/panels/MosaicDashboard.tsx @@ -19,6 +19,8 @@ import NetworkHealthTelemetryPanel from './NetworkHealthTelemetryPanel'; import VideoControls from './VideoControls'; import MotorStatusPanel from './MotorStatusPanel'; import AntennaControlPanel from './AntennaControlPanel'; +import ScienceControlPanel from './ScienceControlPanel'; +import { CO2Graph, MethaneGraph } from './ScienceGraphPanels'; type TileType = | 'mapView' @@ -30,7 +32,10 @@ type TileType = | 'goalSetter' | 'networkHealthMonitor' | 'MotorStatusPanel' - | 'antennaControlPanel'; + | 'antennaControlPanel' + | 'scienceControlPanel' + | 'co2Graph' + | 'methaneGraph'; type TileId = `${TileType}:${number}`; @@ -43,8 +48,11 @@ const TILE_DISPLAY_NAMES: Record = { orientationDisplay: 'Rover Orientation', goalSetter: 'Nav2', networkHealthMonitor: 'Connection Health', - MotorStatusPanel: 'motor', + MotorStatusPanel: 'Motor Status', antennaControlPanel: 'Antenna Control', + scienceControlPanel: 'Science Motor Control', + co2Graph: 'CO2 Graph', + methaneGraph: 'Methane Graph', }; const ALL_TILE_TYPES: TileType[] = [ @@ -58,6 +66,9 @@ const ALL_TILE_TYPES: TileType[] = [ 'goalSetter', 'MotorStatusPanel', 'antennaControlPanel', + 'scienceControlPanel', + 'co2Graph', + 'methaneGraph' ]; function tileTypeOf(id: TileId): TileType { @@ -369,6 +380,24 @@ const MosaicDashboard: React.FC = () => { ); + case 'scienceControlPanel': + return ( + + + + ); + case 'co2Graph': + return( + + + + ) + case 'methaneGraph': + return( + + + + ) default: return
Unknown tile
; diff --git a/src/components/panels/ScienceControlPanel.tsx b/src/components/panels/ScienceControlPanel.tsx new file mode 100644 index 0000000..9635437 --- /dev/null +++ b/src/components/panels/ScienceControlPanel.tsx @@ -0,0 +1,501 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useROS } from '@/ros/ROSContext'; +import ROSLIB from 'roslib'; + +// -------------------- +// Types + Config +// -------------------- +type DCMotorConfig = { + id: number; + name: string; + defaultTime: number; + defaultDuty: number; + type: 'dc'; +}; + +type ServoConfig = { + id: number; + name: string; + defaultPosition: number; + minPulseUs: number; + maxPulseUs: number; + periodUs: number; + maxDegrees: number; + type: 'servo'; +}; + +type MotorConfig = DCMotorConfig | ServoConfig; + +type DCMotorProps = { + motor: DCMotorConfig; + sendCommand: (motorID: number, value: number, duration?: number) => void; + disabled: boolean; +}; + +type ServoMotorProps = { + motor: ServoConfig; + sendCommand: (motorID: number, value: number, duration?: number) => void; + disabled: boolean; +}; + +function isDCMotor(motor: MotorConfig): motor is DCMotorConfig { + return motor.type === 'dc'; +} + +const motors: MotorConfig[] = [ + { id: 27, name: 'Strip', defaultTime: 2.0, defaultDuty: 50, type: 'dc' }, + { id: 26, name: 'Resin', defaultTime: 3.0, defaultDuty: 60, type: 'dc' }, + { id: 25, name: 'Polar', defaultTime: 1.5, defaultDuty: 40, type: 'dc' }, + { id: 33, name: 'Benit', defaultTime: 2.5, defaultDuty: 70, type: 'dc' }, + { id: 35, name: 'Stirrer', defaultTime: 2.0, defaultDuty: 55, type: 'dc' }, + + { + id: 32, + name: 'Disk Servo', + defaultPosition: 90, + minPulseUs: 615, + maxPulseUs: 2495, + periodUs: 20000, + maxDegrees: 195, + type: 'servo', + }, + { + id: 25, + name: 'Polar Servo', + defaultPosition: 45, + minPulseUs: 615, + maxPulseUs: 2495, + periodUs: 20000, + maxDegrees: 195, + type: 'servo', + } +]; + +// -------------------- +// Panel +// -------------------- +const ScienceControlPanel: React.FC = () => { + const { ros } = useROS(); + + const sendCommand = ( + motorID: number, + value: number, + duration?: number + ) => { + if (!ros) return; + + const safeDuration = Math.min(Math.max(duration ?? 0, 0), 31.5); + const safeDuty = Math.min(Math.max(value, 0), 100); + + const durationBits = Math.round(safeDuration / 0.5) & 0x3f; + const dutyBits = Math.round((safeDuty / 100) * 1023) & 0x3ff; + + const service = (durationBits << 10) | dutyBits; + + const topic = new ROSLIB.Topic({ + ros, + name: '/science/device_control', + messageType: 'sensor_msgs/msg/NavSatStatus', + }); + + topic.publish( + new ROSLIB.Message({ + status: motorID, + service, + }) + ); + + console.log('[SCIENCE CMD]', { + status: motorID, + service, + durationBits, + dutyBits, + }); + }; + + return ( +
+

Science Control

+ +
+ {motors.map((motor) => + isDCMotor(motor) ? ( + + ) : ( + + ) + )} +
+ + +
+ ); +}; + +// -------------------- +// DC Motor Component +// -------------------- +const DCMotor: React.FC = ({ + motor, + sendCommand, + disabled, +}) => { + const [time, setTime] = useState(motor.defaultTime); + const [duty, setDuty] = useState(motor.defaultDuty); + const [remaining, setRemaining] = useState(null); + const [startTime, setStartTime] = useState(motor.defaultTime); + + const clamp = (val: number, min: number, max: number) => + Math.min(Math.max(val, min), max); + + useEffect(() => { + if (remaining === null) return; + + if (remaining <= 0) { + setRemaining(null); + return; + } + + const interval = window.setInterval(() => { + setRemaining((prev) => { + if (prev === null) return null; + + const next = prev - 0.1; + return next <= 0 ? null : next; + }); + }, 100); + + return () => window.clearInterval(interval); + }, [remaining]); + + const handleGo = () => { + const safeTime = clamp(time, 0, 999); + const safeDuty = clamp(duty, 0, 100); + sendCommand(motor.id, safeDuty, safeTime); + + setStartTime(safeTime); + setRemaining(safeTime); + }; + + const handleStop = () => { + sendCommand(motor.id, 0, 0); + setRemaining(null); + }; + + const progressPercent = + remaining !== null && startTime > 0 + ? Math.max(0, Math.min(100, (remaining / startTime) * 100)) + : 0; + + return ( +
+

{motor.name}

+ + + + + + {remaining !== null && ( + <> +
{remaining.toFixed(1)}s remaining
+ +
+
+
+ + )} + +
+ + + + + +
+
+ ); +}; + +const ServoMotor: React.FC = ({ + motor, + sendCommand, + disabled, +}) => { + const [position, setPosition] = useState(motor.defaultPosition); + const [remaining, setRemaining] = useState(null); + + const clamp = (val: number, min: number, max: number) => + Math.min(Math.max(val, min), max); + + useEffect(() => { + if (remaining === null) return; + + if (remaining <= 0) { + setRemaining(null); + return; + } + + const interval = window.setInterval(() => { + setRemaining((prev) => { + if (prev === null) return null; + + const next = prev - 0.1; + return next <= 0 ? null : next; + }); + }, 100); + + return () => window.clearInterval(interval); + }, [remaining]); + + const handleGo = () => { + const safePos = clamp(position, 0, motor.maxDegrees); + + const pulseUs = + motor.minPulseUs + + (safePos / motor.maxDegrees) * (motor.maxPulseUs - motor.minPulseUs); + + const dutyPercent = (pulseUs / motor.periodUs) * 100; + + sendCommand(motor.id, dutyPercent, 0.5); + setRemaining(0.5); + }; + + return ( +
+

{motor.name}

+ + + + {remaining !== null && ( + <> +
{remaining.toFixed(1)}s remaining
+ +
+
+
+ + )} + +
+ + + +
+
+ ); +}; + +export default ScienceControlPanel; \ No newline at end of file diff --git a/src/components/panels/ScienceGraphPanels.tsx b/src/components/panels/ScienceGraphPanels.tsx new file mode 100644 index 0000000..5911bed --- /dev/null +++ b/src/components/panels/ScienceGraphPanels.tsx @@ -0,0 +1,23 @@ +// telemetryPanels.ts +import TelemetryGraph from "../TelemetryGraph"; + + +export const CO2Graph: React.FC = () => { + return ( + + ); +}; + +export const MethaneGraph: React.FC = () => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/panels/VideoControls.tsx b/src/components/panels/VideoControls.tsx index 84ddb24..1bbe953 100644 --- a/src/components/panels/VideoControls.tsx +++ b/src/components/panels/VideoControls.tsx @@ -62,34 +62,54 @@ const VideoControls: React.FC = () => { topic.publish(new ROSLIB.Message({})); console.log("Stream restart triggered"); }; - const onSnapshot = () => {}; - const onPanoramic = () => {}; - - const setFec = (fecPercent: number) => { + const callVideoCaptureService = (serviceName: string, filename: string = "") => { if (!ros || rosStatus !== "connected") return; - const setParamsClient = new ROSLIB.Service({ + const client = new ROSLIB.Service({ ros, - name: "/rtp_node/set_parameters", - serviceType: "rcl_interfaces/srv/SetParameters", + name: serviceName, + serviceType: "interfaces/srv/VideoCapture", }); const request = new ROSLIB.ServiceRequest({ - parameters: [ - { - name: "fec_percent", - value: { - type: 2, - integer_value: fecPercent, - }, - }, - ], + filename, }); - setParamsClient.callService(request, (result) => { - console.log("Set parameters response:", result); + client.callService(request, (response: any) => { + if (!response.success) { + console.error(`Service ${serviceName} failed`); + return; + } + + const imageData = response.image?.data; + + if (!imageData) { + console.error("No image data returned"); + return; + } + + const bytes = + typeof imageData === "string" + ? Uint8Array.from(atob(imageData), (c) => c.charCodeAt(0)) + : new Uint8Array(imageData); + + const blob = new Blob([bytes], { type: "image/jpeg" }); + const url = URL.createObjectURL(blob); + + const win = window.open(); + if (win) { + win.document.write(``); + } + + setTimeout(() => URL.revokeObjectURL(url), 10000); }); }; + const onSnapshot = () => { + callVideoCaptureService("/capture_frame"); + }; + const onPanoramic = () => { + callVideoCaptureService("/capture_panoramic"); + }; const setFramerate = (framerate: number) => { if (!ros || rosStatus !== "connected") return; @@ -195,20 +215,6 @@ const VideoControls: React.FC = () => {
-
- FEC: - - - -