diff --git a/gcs/data/default_settings.json b/gcs/data/default_settings.json index db0c6ce60..fbc68b971 100644 --- a/gcs/data/default_settings.json +++ b/gcs/data/default_settings.json @@ -31,6 +31,12 @@ "default": false, "type": "boolean", "display": "Speech Announcements" + }, + "experimentalDeveloperFeatures": { + "default": false, + "type": "boolean", + "display": "Experimental Developer Features", + "description": "These may be unstable and can cause unexpected bugs or crashes." } }, "Dashboard": { diff --git a/gcs/src/components/connectionProgress.jsx b/gcs/src/components/connectionProgress.jsx new file mode 100644 index 000000000..9c902d0ea --- /dev/null +++ b/gcs/src/components/connectionProgress.jsx @@ -0,0 +1,22 @@ +import { Progress } from "@mantine/core" + +export default function ConnectionProgress({ connecting, status }) { + return ( + <> + {connecting && + status.message !== null && + typeof status.progress === "number" && ( + <> +

{status.message}

+ + + )} + + ) +} diff --git a/gcs/src/components/navbar.jsx b/gcs/src/components/navbar.jsx index 88b234b6a..2b5fe7090 100644 --- a/gcs/src/components/navbar.jsx +++ b/gcs/src/components/navbar.jsx @@ -16,7 +16,6 @@ import { Group, LoadingOverlay, Modal, - Progress, SegmentedControl, Select, Tabs, @@ -78,6 +77,10 @@ import { useEffect } from "react" import { twMerge } from "tailwind-merge" import { showErrorNotification } from "../helpers/notification.js" +// Modals +import SimulationModal from "./toolbar/simulationModal.jsx" +import ConnectionProgress from "./connectionProgress.jsx" + export default function Navbar() { // Redux const dispatch = useDispatch() @@ -320,22 +323,10 @@ export default function Navbar() { - {connecting && - droneConnectionStatus.message !== null && - typeof droneConnectionStatus.progress === "number" && ( - <> -

- {droneConnectionStatus.message} -

- - - )} + + +
{/* Navigation */} diff --git a/gcs/src/components/toolbar/menus/advanced.jsx b/gcs/src/components/toolbar/menus/advanced.jsx index 7606ddccc..3e491307a 100644 --- a/gcs/src/components/toolbar/menus/advanced.jsx +++ b/gcs/src/components/toolbar/menus/advanced.jsx @@ -5,11 +5,14 @@ // Local Imports import { useDispatch } from "react-redux" import { setForwardingAddressModalOpened } from "../../../redux/slices/droneConnectionSlice" +import { setSimulationModalOpened } from "../../../redux/slices/simulationParamsSlice" import MenuItem from "./menuItem" import MenuTemplate from "./menuTemplate" +import { useSettings } from "../../../helpers/settings" export default function AdvancedMenu(props) { const dispatch = useDispatch() + const { getSetting } = useSettings() return ( + {getSetting("General.experimentalDeveloperFeatures") && ( + { + dispatch(setSimulationModalOpened(true)) + }} + /> + )} ) } diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx new file mode 100644 index 000000000..faee795e2 --- /dev/null +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -0,0 +1,305 @@ +import { useEffect, useState } from "react" +import { useDispatch, useSelector } from "react-redux" +import { + Modal, + Text, + Button, + Select, + NumberInput, + Checkbox, + Tooltip, + Group, + ActionIcon, +} from "@mantine/core" +import { IconAlertCircle, IconInfoCircle, IconTrash } from "@tabler/icons-react" +import { + SimulationStatus, + addSimulationPort, + emitStartSimulation, + emitStopSimulation, + removeSimulationPortById, + selectIsSimulationRunning, + selectSimulationConnectAfterStart, + selectSimulationModalOpened, + selectSimulationPorts, + selectSimulationStatus, + selectSimulationVehicleType, + setSimulationConnectAfterStart, + setSimulationModalOpened, + setSimulationVehicleType, + updateSimulationPortById, +} from "../../redux/slices/simulationParamsSlice" +import { selectIsConnectedToSocket } from "../../redux/slices/socketSlice" +import { showNotification } from "../../helpers/notification" +import { + emitDisconnectFromDrone, + selectConnectedToDrone, + selectConnectedToSimulator, + selectConnecting, + selectConnectionStatus, +} from "../../redux/slices/droneConnectionSlice" +import ConnectionProgress from "../connectionProgress" + +const normalizePort = (val) => { + if (val === null || val === "" || val === undefined || isNaN(val)) { + return undefined + } + return Number(val) +} + +const getDuplicates = (values) => { + const counts = new Map() + values.forEach((v) => { + if (v === null || v === undefined) return + const current = counts.get(v) || 0 + counts.set(v, current + 1) + }) + return new Set( + Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([value]) => value), + ) +} + +export default function SimulationModal() { + const dispatch = useDispatch() + const modalOpen = useSelector(selectSimulationModalOpened) + const isSimulationRunning = useSelector(selectIsSimulationRunning) + const simulationStatus = useSelector(selectSimulationStatus) + const ports = useSelector(selectSimulationPorts) + const vehicleType = useSelector(selectSimulationVehicleType) + const connectAfterStart = useSelector(selectSimulationConnectAfterStart) + const connectedToSocket = useSelector(selectIsConnectedToSocket) + const droneConnectionStatus = useSelector(selectConnectionStatus) + const connecting = useSelector(selectConnecting) + const connectedToDrone = useSelector(selectConnectedToDrone) + const connectedToSimulator = useSelector(selectConnectedToSimulator) + + const [pendingStopAfterDisconnect, setPendingStopAfterDisconnect] = + useState(false) + + // Wait for disconnect before stopping simulation + useEffect(() => { + if (!pendingStopAfterDisconnect) return + if (connectedToDrone) return + if (!isSimulationRunning) { + setPendingStopAfterDisconnect(false) + return + } + + dispatch(emitStopSimulation()) + setPendingStopAfterDisconnect(false) + }, [ + pendingStopAfterDisconnect, + connectedToDrone, + isSimulationRunning, + dispatch, + ]) + + // Make connect after start false if already connected to a drone + useEffect(() => { + if (connectedToDrone && !connectedToSimulator && connectAfterStart) { + dispatch(setSimulationConnectAfterStart(false)) + } + }, [connectedToDrone, connectedToSimulator, connectAfterStart, dispatch]) + + const duplicateHostPorts = getDuplicates(ports.map((p) => p.hostPort)) + const duplicateContainerPorts = getDuplicates( + ports.map((p) => p.containerPort), + ) + const hasEmptyPort = ports.some( + (p) => + normalizePort(p.hostPort) === undefined || + normalizePort(p.containerPort) === undefined, + ) + + return ( + { + dispatch(setSimulationModalOpened(false)) + if (simulationStatus === SimulationStatus.Starting) { + showNotification( + "Simulation still starting", + "The simulator is still starting and will continue in the background", + ) + } else if (simulationStatus === SimulationStatus.Stopping) { + showNotification( + "Simulation still stopping", + "The simulator is still stopping and will continue in the background", + ) + } + }} + title="SITL Simulator" + centered + overlayProps={{ + backgroundOpacity: 0.55, + blur: 3, + }} + styles={{ + content: { + borderRadius: "0.5rem", + }, + }} + > + {ports.map((port) => ( + + + dispatch( + updateSimulationPortById({ + id: port.id, + key: "hostPort", + value: normalizePort(val), + }), + ) + } + /> + + + dispatch( + updateSimulationPortById({ + id: port.id, + key: "containerPort", + value: normalizePort(val), + }), + ) + } + /> + + {ports.length > 1 && ( + + dispatch(removeSimulationPortById(port.id))} + > + + + + )} + + ))} + + {(duplicateHostPorts.size > 0 || duplicateContainerPorts.size > 0) && ( + + + + Duplicated ports + + + )} + + + +