From 98bcdd6026e393fc6d18a025b4db21f7ebc7b95c Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:30:07 +0000 Subject: [PATCH 001/109] Add start simulation button --- gcs/src/components/toolbar/menus/advanced.jsx | 8 ++++++- gcs/src/redux/middleware/emitters.js | 7 ++++++ gcs/src/redux/middleware/socketMiddleware.js | 8 +++++++ gcs/src/redux/slices/droneConnectionSlice.js | 2 ++ radio/app/endpoints/__init__.py | 1 + radio/app/endpoints/simulation.py | 24 +++++++++++++++++++ radio/requirements.txt | 1 + 7 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 radio/app/endpoints/simulation.py diff --git a/gcs/src/components/toolbar/menus/advanced.jsx b/gcs/src/components/toolbar/menus/advanced.jsx index 7606ddccc..cd8d65592 100644 --- a/gcs/src/components/toolbar/menus/advanced.jsx +++ b/gcs/src/components/toolbar/menus/advanced.jsx @@ -4,7 +4,7 @@ // Local Imports import { useDispatch } from "react-redux" -import { setForwardingAddressModalOpened } from "../../../redux/slices/droneConnectionSlice" +import { emitStartSimulation, setForwardingAddressModalOpened } from "../../../redux/slices/droneConnectionSlice" import MenuItem from "./menuItem" import MenuTemplate from "./menuTemplate" @@ -28,6 +28,12 @@ export default function AdvancedMenu(props) { dispatch(setForwardingAddressModalOpened(true)) }} /> + { + dispatch(emitStartSimulation()) + }} + /> ) } diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js index d94461afc..42888db49 100644 --- a/gcs/src/redux/middleware/emitters.js +++ b/gcs/src/redux/middleware/emitters.js @@ -32,6 +32,7 @@ import { emitStartForwarding, emitStopForwarding, emitTakeoff, + emitStartSimulation, setCurrentPage, setIsForwarding, } from "../slices/droneConnectionSlice" @@ -194,6 +195,12 @@ export function handleEmitters(socket, store, action) { }), }, + { + emitter: emitStartSimulation, + callback: () => + socket.socket.emit("start_docker_simulation"), + }, + /* ============ = MISSIONS = diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 7ab2aa7bc..c188084e0 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -140,6 +140,7 @@ const DroneSpecificSocketEvents = Object.freeze({ onNavRepositionResult: "nav_reposition_result", onGetLoiterRadiusResult: "nav_get_loiter_radius_result", onSetLoiterRadiusResult: "nav_set_loiter_radius_result", + onSimulationError: "simulation_error", }) const ParamSpecificSocketEvents = Object.freeze({ @@ -699,6 +700,13 @@ const socketMiddleware = (store) => { }, ) + socket.socket.on( + DroneSpecificSocketEvents.onSimulationError, + (msg) => { + showErrorNotification(msg.message) + }, + ) + /* Missions */ diff --git a/gcs/src/redux/slices/droneConnectionSlice.js b/gcs/src/redux/slices/droneConnectionSlice.js index ff184e6e3..5d1082b20 100644 --- a/gcs/src/redux/slices/droneConnectionSlice.js +++ b/gcs/src/redux/slices/droneConnectionSlice.js @@ -168,6 +168,7 @@ const droneConnectionSlice = createSlice({ emitTakeoff: () => {}, emitLand: () => {}, emitSetCurrentFlightMode: () => {}, + emitStartSimulation: () => {}, }, selectors: { selectConnecting: (state) => state.connecting, @@ -238,6 +239,7 @@ export const { emitTakeoff, emitLand, emitSetCurrentFlightMode, + emitStartSimulation, } = droneConnectionSlice.actions export const { selectConnecting, diff --git a/radio/app/endpoints/__init__.py b/radio/app/endpoints/__init__.py index 76ed1417b..a8c7bd48a 100644 --- a/radio/app/endpoints/__init__.py +++ b/radio/app/endpoints/__init__.py @@ -13,6 +13,7 @@ from . import nav as nav from . import params as params from . import rc as rc +from . import simulation as simulation from . import states as states endpoints = Blueprint("endpoints", __name__) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py new file mode 100644 index 000000000..ad6a63f2e --- /dev/null +++ b/radio/app/endpoints/simulation.py @@ -0,0 +1,24 @@ +from app import socketio +import docker +from docker.errors import DockerException + +CONTAINER_NAME = "drone_sitl" + + +@socketio.on("start_docker_simulation") +def start_docker_simulation() -> None: + try: + client = docker.from_env() + + client.containers.run( + "kushmakkapati/ardupilot_sitl", + name=CONTAINER_NAME, + ports={"5760/udp": 5760}, + detach=True, + remove=True, + ) + except DockerException: + socketio.emit( + "simulation_error", + {"message": "Docker exception"}, # TODO: better error messages + ) diff --git a/radio/requirements.txt b/radio/requirements.txt index d297acc10..fb37c3619 100644 --- a/radio/requirements.txt +++ b/radio/requirements.txt @@ -80,3 +80,4 @@ Werkzeug==3.0.1 wsproto==1.2.0 yarl==1.9.4 zipp==3.17.0 +docker==7.1.0 From 1d5983e94e7f72ad08533f5b7857ab0011302300 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:36:41 +0000 Subject: [PATCH 002/109] Fix port --- radio/app/endpoints/simulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index ad6a63f2e..9370d1e7e 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -13,7 +13,7 @@ def start_docker_simulation() -> None: client.containers.run( "kushmakkapati/ardupilot_sitl", name=CONTAINER_NAME, - ports={"5760/udp": 5760}, + ports={"5760": 5760}, detach=True, remove=True, ) From 4f84a19a86e82c235ee8c410df4d649479333e4b Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Tue, 30 Dec 2025 23:04:51 +0000 Subject: [PATCH 003/109] Add stop simulation button --- gcs/src/components/toolbar/menus/advanced.jsx | 17 +++++++--- gcs/src/redux/middleware/emitters.js | 17 ++++++++-- gcs/src/redux/middleware/socketMiddleware.js | 8 +++-- gcs/src/redux/slices/droneConnectionSlice.js | 20 ++++++++++++ radio/app/endpoints/simulation.py | 32 ++++++++++++++++--- 5 files changed, 81 insertions(+), 13 deletions(-) diff --git a/gcs/src/components/toolbar/menus/advanced.jsx b/gcs/src/components/toolbar/menus/advanced.jsx index cd8d65592..552b27dff 100644 --- a/gcs/src/components/toolbar/menus/advanced.jsx +++ b/gcs/src/components/toolbar/menus/advanced.jsx @@ -3,13 +3,14 @@ */ // Local Imports -import { useDispatch } from "react-redux" -import { emitStartSimulation, setForwardingAddressModalOpened } from "../../../redux/slices/droneConnectionSlice" +import { useDispatch, useSelector } from "react-redux" +import { emitStartSimulation, emitStopSimulation, selectIsSimulationRunning, setForwardingAddressModalOpened } from "../../../redux/slices/droneConnectionSlice" import MenuItem from "./menuItem" import MenuTemplate from "./menuTemplate" export default function AdvancedMenu(props) { const dispatch = useDispatch() + const isSimulationRunning = useSelector(selectIsSimulationRunning) return ( { - dispatch(emitStartSimulation()) + dispatch( + isSimulationRunning + ? emitStopSimulation() + : emitStartSimulation() + ); }} /> diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js index 42888db49..3628b3d8f 100644 --- a/gcs/src/redux/middleware/emitters.js +++ b/gcs/src/redux/middleware/emitters.js @@ -33,8 +33,11 @@ import { emitStopForwarding, emitTakeoff, emitStartSimulation, + emitStopSimulation, setCurrentPage, setIsForwarding, + setSimulationStatus, + SimulationStatus, } from "../slices/droneConnectionSlice" import { emitListFiles, setLoadingListFiles } from "../slices/ftpSlice" import { @@ -197,8 +200,18 @@ export function handleEmitters(socket, store, action) { { emitter: emitStartSimulation, - callback: () => - socket.socket.emit("start_docker_simulation"), + callback: () => { + socket.socket.emit("start_docker_simulation") + store.dispatch(setSimulationStatus(SimulationStatus.Starting)) + } + }, + + { + emitter: emitStopSimulation, + callback: () => { + socket.socket.emit("stop_docker_simulation"), + store.dispatch(setSimulationStatus(SimulationStatus.Idle)) + } }, /* diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index c188084e0..18446d716 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -140,7 +140,7 @@ const DroneSpecificSocketEvents = Object.freeze({ onNavRepositionResult: "nav_reposition_result", onGetLoiterRadiusResult: "nav_get_loiter_radius_result", onSetLoiterRadiusResult: "nav_set_loiter_radius_result", - onSimulationError: "simulation_error", + onSimulationResult: "simulation_result", }) const ParamSpecificSocketEvents = Object.freeze({ @@ -701,9 +701,11 @@ const socketMiddleware = (store) => { ) socket.socket.on( - DroneSpecificSocketEvents.onSimulationError, + DroneSpecificSocketEvents.onSimulationResult, (msg) => { - showErrorNotification(msg.message) + msg.success + ? showSuccessNotification(msg.message) + : showErrorNotification(msg.message) }, ) diff --git a/gcs/src/redux/slices/droneConnectionSlice.js b/gcs/src/redux/slices/droneConnectionSlice.js index 5d1082b20..b33e2ca8b 100644 --- a/gcs/src/redux/slices/droneConnectionSlice.js +++ b/gcs/src/redux/slices/droneConnectionSlice.js @@ -5,6 +5,12 @@ export const ConnectionType = { Network: "network", } +export const SimulationStatus = { + Idle: "idle", + Starting: "starting", + Running: "running", +} + const initialState = { // drone connection status connecting: false, @@ -33,6 +39,8 @@ const initialState = { ip: "127.0.0.1", // local port: "5760", // local + simulation_status: SimulationStatus.Idle, + forwardingAddress: "", // local isForwarding: false, // local forwardingAddressModalOpened: false, @@ -148,6 +156,9 @@ const droneConnectionSlice = createSlice({ setForceDisarmModalOpened: (state, action) => { state.forceDisarmModalOpened = action.payload }, + setSimulationStatus: (state, action) => { + state.simulationStatus = action.payload + }, // Emits emitIsConnectedToDrone: () => {}, @@ -169,6 +180,7 @@ const droneConnectionSlice = createSlice({ emitLand: () => {}, emitSetCurrentFlightMode: () => {}, emitStartSimulation: () => {}, + emitStopSimulation: () => {}, }, selectors: { selectConnecting: (state) => state.connecting, @@ -194,6 +206,10 @@ const droneConnectionSlice = createSlice({ selectVideoMaximized: (state) => state.videoMaximized, selectVideoScale: (state) => state.videoScale, selectForceDisarmModalOpened: (state) => state.forceDisarmModalOpened, + selectSimulationStatus: (state) => state.simulationStatus, + selectIsSimulationRunning: (state) => + state.simulationStatus === SimulationStatus.Running || + state.simulationStatus === SimulationStatus.Starting, }, }) @@ -221,6 +237,7 @@ export const { setVideoMaximized, setVideoScale, setForceDisarmModalOpened, + setSimulationStatus, // Emitters emitIsConnectedToDrone, @@ -240,6 +257,7 @@ export const { emitLand, emitSetCurrentFlightMode, emitStartSimulation, + emitStopSimulation, } = droneConnectionSlice.actions export const { selectConnecting, @@ -264,6 +282,8 @@ export const { selectVideoMaximized, selectVideoScale, selectForceDisarmModalOpened, + selectSimulationStatus, + selectIsSimulationRunning, } = droneConnectionSlice.selectors export default droneConnectionSlice diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 9370d1e7e..680cd9ca4 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -5,11 +5,21 @@ CONTAINER_NAME = "drone_sitl" -@socketio.on("start_docker_simulation") -def start_docker_simulation() -> None: +def get_docker_client(): try: - client = docker.from_env() + return docker.from_env() + except DockerException: + return None + +@socketio.on("start_docker_simulation") +def start_docker_simulation(): + client = get_docker_client() + if client is None: + socketio.emit("simulation_result", {"message": "Docker is not running"}) + return + + try: client.containers.run( "kushmakkapati/ardupilot_sitl", name=CONTAINER_NAME, @@ -17,8 +27,22 @@ def start_docker_simulation() -> None: detach=True, remove=True, ) + socketio.emit( + "simulation_result", {"success": True, "message": "Simulation started"} + ) except DockerException: socketio.emit( - "simulation_error", + "simulation_result", {"message": "Docker exception"}, # TODO: better error messages ) + + +@socketio.on("stop_docker_simulation") +def stop_docker_simulation(): + client = get_docker_client() + if client is None: + socketio.emit("simulation_result", {"message": "Docker is not running"}) + return + + container = client.containers.get(CONTAINER_NAME) + container.stop() From c53ddfadddafbe35653ac3182f926a136087c03c Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:27:06 +0000 Subject: [PATCH 004/109] Working error messages (only if using string literal) --- gcs/src/redux/middleware/socketMiddleware.js | 21 ++++++++-------- radio/app/endpoints/simulation.py | 25 +++++++++++++------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 18446d716..e4b3ccfd0 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -140,7 +140,7 @@ const DroneSpecificSocketEvents = Object.freeze({ onNavRepositionResult: "nav_reposition_result", onGetLoiterRadiusResult: "nav_get_loiter_radius_result", onSetLoiterRadiusResult: "nav_set_loiter_radius_result", - onSimulationResult: "simulation_result", + // onSimResult: "sim_result", }) const ParamSpecificSocketEvents = Object.freeze({ @@ -412,6 +412,16 @@ const socketMiddleware = (store) => { store.dispatch(resetFiles()) }) + // Simulation messages + socket.socket.on( + "sim_result", + (msg) => { + msg.success + ? showSuccessNotification(msg.message) + : showErrorNotification(msg.message) + }, + ) + // Link stats socket.socket.on(SocketEvents.linkDebugStats, (msg) => { window.ipcRenderer.invoke("app:update-link-stats", msg) @@ -700,15 +710,6 @@ const socketMiddleware = (store) => { }, ) - socket.socket.on( - DroneSpecificSocketEvents.onSimulationResult, - (msg) => { - msg.success - ? showSuccessNotification(msg.message) - : showErrorNotification(msg.message) - }, - ) - /* Missions */ diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 680cd9ca4..3194773a3 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -1,4 +1,4 @@ -from app import socketio +from app import logger, socketio import docker from docker.errors import DockerException @@ -6,6 +6,8 @@ def get_docker_client(): + socketio.emit("bonjour") + try: return docker.from_env() except DockerException: @@ -16,7 +18,9 @@ def get_docker_client(): def start_docker_simulation(): client = get_docker_client() if client is None: - socketio.emit("simulation_result", {"message": "Docker is not running"}) + socketio.emit( + "sim_result", {"success": False, "message": "Docker is not running"} + ) return try: @@ -27,13 +31,16 @@ def start_docker_simulation(): detach=True, remove=True, ) - socketio.emit( - "simulation_result", {"success": True, "message": "Simulation started"} - ) + logger.debug("DOCKER STARTED SUCCESSFULLYB") + + socketio.emit("sim_result", {"success": True, "message": "Simulation started"}) except DockerException: socketio.emit( - "simulation_result", - {"message": "Docker exception"}, # TODO: better error messages + "sim_result", + { + "success": False, + "message": "Docker exception", + }, # TODO: better error messages ) @@ -41,7 +48,9 @@ def start_docker_simulation(): def stop_docker_simulation(): client = get_docker_client() if client is None: - socketio.emit("simulation_result", {"message": "Docker is not running"}) + socketio.emit( + "sim_result", {"success": False, "message": "Docker is not running"} + ) return container = client.containers.get(CONTAINER_NAME) From 32946524314824b5e9e1233da2d7e9e60b6108e1 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:59:02 +0000 Subject: [PATCH 005/109] Move onSimulationResult to different set of events --- gcs/src/redux/middleware/socketMiddleware.js | 4 ++-- radio/app/endpoints/simulation.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index e4b3ccfd0..42ed11716 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -127,6 +127,7 @@ const SocketEvents = Object.freeze({ isConnectedToDrone: "is_connected_to_drone", listComPorts: "list_com_ports", linkDebugStats: "link_debug_stats", + onSimulationResult: "simulation_result", }) const DroneSpecificSocketEvents = Object.freeze({ @@ -140,7 +141,6 @@ const DroneSpecificSocketEvents = Object.freeze({ onNavRepositionResult: "nav_reposition_result", onGetLoiterRadiusResult: "nav_get_loiter_radius_result", onSetLoiterRadiusResult: "nav_set_loiter_radius_result", - // onSimResult: "sim_result", }) const ParamSpecificSocketEvents = Object.freeze({ @@ -414,7 +414,7 @@ const socketMiddleware = (store) => { // Simulation messages socket.socket.on( - "sim_result", + SocketEvents.onSimulationResult, (msg) => { msg.success ? showSuccessNotification(msg.message) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 3194773a3..8563dbe96 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -6,8 +6,6 @@ def get_docker_client(): - socketio.emit("bonjour") - try: return docker.from_env() except DockerException: @@ -19,7 +17,7 @@ def start_docker_simulation(): client = get_docker_client() if client is None: socketio.emit( - "sim_result", {"success": False, "message": "Docker is not running"} + "simulation_result", {"success": False, "message": "Docker is not running"} ) return @@ -33,10 +31,12 @@ def start_docker_simulation(): ) logger.debug("DOCKER STARTED SUCCESSFULLYB") - socketio.emit("sim_result", {"success": True, "message": "Simulation started"}) + socketio.emit( + "simulation_result", {"success": True, "message": "Simulation started"} + ) except DockerException: socketio.emit( - "sim_result", + "simulation_result", { "success": False, "message": "Docker exception", @@ -49,7 +49,7 @@ def stop_docker_simulation(): client = get_docker_client() if client is None: socketio.emit( - "sim_result", {"success": False, "message": "Docker is not running"} + "simulation_result", {"success": False, "message": "Docker is not running"} ) return From 2bdbc24cb1b7c59aa40cbf6b48f4628f30b5286a Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:16:32 +0000 Subject: [PATCH 006/109] Add feedback of running state from backend --- gcs/src/redux/middleware/socketMiddleware.js | 8 ++++++++ radio/app/endpoints/simulation.py | 13 +++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 42ed11716..178dadd44 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -19,6 +19,8 @@ import { setFetchingComPorts, setForceDisarmModalOpened, setSelectedComPorts, + setSimulationStatus, + SimulationStatus, } from "../slices/droneConnectionSlice" // socket factory @@ -416,6 +418,12 @@ const socketMiddleware = (store) => { socket.socket.on( SocketEvents.onSimulationResult, (msg) => { + if (msg.running === true) { + store.dispatch(setSimulationStatus(SimulationStatus.Running)) + } else if (msg.running === false) { + store.dispatch(setSimulationStatus(SimulationStatus.Idle)) + } + msg.success ? showSuccessNotification(msg.message) : showErrorNotification(msg.message) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 8563dbe96..db195e53b 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -1,4 +1,4 @@ -from app import logger, socketio +from app import socketio import docker from docker.errors import DockerException @@ -17,7 +17,8 @@ def start_docker_simulation(): client = get_docker_client() if client is None: socketio.emit( - "simulation_result", {"success": False, "message": "Docker is not running"} + "simulation_result", + {"success": False, "running": False, "message": "Docker is not running"}, ) return @@ -29,10 +30,10 @@ def start_docker_simulation(): detach=True, remove=True, ) - logger.debug("DOCKER STARTED SUCCESSFULLYB") socketio.emit( - "simulation_result", {"success": True, "message": "Simulation started"} + "simulation_result", + {"success": True, "running": True, "message": "Simulation started"}, ) except DockerException: socketio.emit( @@ -55,3 +56,7 @@ def stop_docker_simulation(): container = client.containers.get(CONTAINER_NAME) container.stop() + socketio.emit( + "simulation_result", + {"success": True, "running": False, "message": "Simulation stopped"}, + ) From 257e6e387220d5f80d33966be5c9632dd0386838 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:50:36 +0000 Subject: [PATCH 007/109] Give feedback on first time use --- radio/app/endpoints/simulation.py | 37 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index db195e53b..2c6e0675b 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -2,7 +2,8 @@ import docker from docker.errors import DockerException -CONTAINER_NAME = "drone_sitl" +CONTAINER_NAME = "ardupilot_sitl" +IMAGE_NAME = "kushmakkapati/ardupilot_sitl" def get_docker_client(): @@ -12,6 +13,28 @@ def get_docker_client(): return None +def ensure_image(client): + try: + client.images.get(IMAGE_NAME) + return True + except docker.errors.ImageNotFound: + socketio.emit( + "simulation_result", + { + "message": "Image not found. Attempting to download.", + }, + ) + client.images.pull(IMAGE_NAME) + socketio.emit( + "simulation_result", + { + "success": True, + "message": "Simulation image downloaded", + }, + ) + return False + + @socketio.on("start_docker_simulation") def start_docker_simulation(): client = get_docker_client() @@ -23,10 +46,14 @@ def start_docker_simulation(): return try: + ensure_image(client) + client.containers.run( - "kushmakkapati/ardupilot_sitl", + IMAGE_NAME, name=CONTAINER_NAME, ports={"5760": 5760}, + stdin_open=True, + tty=True, detach=True, remove=True, ) @@ -35,13 +62,13 @@ def start_docker_simulation(): "simulation_result", {"success": True, "running": True, "message": "Simulation started"}, ) - except DockerException: + except DockerException as e: socketio.emit( "simulation_result", { "success": False, - "message": "Docker exception", - }, # TODO: better error messages + "message": str(e), + }, ) From 7e4fe81a43f573cb4a346310b2f05173924a15c9 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:59:30 +0000 Subject: [PATCH 008/109] Docker stubs --- radio/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/radio/requirements.txt b/radio/requirements.txt index fb37c3619..3f79119f0 100644 --- a/radio/requirements.txt +++ b/radio/requirements.txt @@ -81,3 +81,4 @@ wsproto==1.2.0 yarl==1.9.4 zipp==3.17.0 docker==7.1.0 +types-docker==7.1.0 From 93380f2233c844301e21ddaadb84ad9c304e66df Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:11:32 +0000 Subject: [PATCH 009/109] Resolve minor feedback --- gcs/src/components/toolbar/menus/advanced.jsx | 7 ++++++- gcs/src/redux/middleware/emitters.js | 3 +-- gcs/src/redux/slices/droneConnectionSlice.js | 2 +- radio/app/endpoints/simulation.py | 11 ++++++++--- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/gcs/src/components/toolbar/menus/advanced.jsx b/gcs/src/components/toolbar/menus/advanced.jsx index 552b27dff..c750efcb1 100644 --- a/gcs/src/components/toolbar/menus/advanced.jsx +++ b/gcs/src/components/toolbar/menus/advanced.jsx @@ -4,7 +4,12 @@ // Local Imports import { useDispatch, useSelector } from "react-redux" -import { emitStartSimulation, emitStopSimulation, selectIsSimulationRunning, setForwardingAddressModalOpened } from "../../../redux/slices/droneConnectionSlice" +import { + emitStartSimulation, + emitStopSimulation, + selectIsSimulationRunning, + setForwardingAddressModalOpened +} from "../../../redux/slices/droneConnectionSlice" import MenuItem from "./menuItem" import MenuTemplate from "./menuTemplate" diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js index 3628b3d8f..5ab46f72a 100644 --- a/gcs/src/redux/middleware/emitters.js +++ b/gcs/src/redux/middleware/emitters.js @@ -209,8 +209,7 @@ export function handleEmitters(socket, store, action) { { emitter: emitStopSimulation, callback: () => { - socket.socket.emit("stop_docker_simulation"), - store.dispatch(setSimulationStatus(SimulationStatus.Idle)) + socket.socket.emit("stop_docker_simulation") } }, diff --git a/gcs/src/redux/slices/droneConnectionSlice.js b/gcs/src/redux/slices/droneConnectionSlice.js index b33e2ca8b..c7d976d42 100644 --- a/gcs/src/redux/slices/droneConnectionSlice.js +++ b/gcs/src/redux/slices/droneConnectionSlice.js @@ -39,7 +39,7 @@ const initialState = { ip: "127.0.0.1", // local port: "5760", // local - simulation_status: SimulationStatus.Idle, + simulationStatus: SimulationStatus.Idle, forwardingAddress: "", // local isForwarding: false, // local diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 2c6e0675b..dcf094cf8 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -41,7 +41,11 @@ def start_docker_simulation(): if client is None: socketio.emit( "simulation_result", - {"success": False, "running": False, "message": "Docker is not running"}, + { + "success": False, + "running": False, + "message": "Unable to connect to Docker", + }, ) return @@ -51,7 +55,7 @@ def start_docker_simulation(): client.containers.run( IMAGE_NAME, name=CONTAINER_NAME, - ports={"5760": 5760}, + ports={5760: 5760}, stdin_open=True, tty=True, detach=True, @@ -77,7 +81,8 @@ def stop_docker_simulation(): client = get_docker_client() if client is None: socketio.emit( - "simulation_result", {"success": False, "message": "Docker is not running"} + "simulation_result", + {"success": False, "message": "Unable to connect to Docker"}, ) return From 37083db6a3db65e05f0fcc8b076a60c4cbfa9050 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Thu, 1 Jan 2026 10:24:22 +0000 Subject: [PATCH 010/109] Resolve more feedback --- gcs/src/components/toolbar/menus/advanced.jsx | 8 +++---- gcs/src/redux/middleware/emitters.js | 4 ++-- gcs/src/redux/middleware/socketMiddleware.js | 24 +++++++++---------- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/gcs/src/components/toolbar/menus/advanced.jsx b/gcs/src/components/toolbar/menus/advanced.jsx index c750efcb1..46c064da0 100644 --- a/gcs/src/components/toolbar/menus/advanced.jsx +++ b/gcs/src/components/toolbar/menus/advanced.jsx @@ -8,7 +8,7 @@ import { emitStartSimulation, emitStopSimulation, selectIsSimulationRunning, - setForwardingAddressModalOpened + setForwardingAddressModalOpened, } from "../../../redux/slices/droneConnectionSlice" import MenuItem from "./menuItem" import MenuTemplate from "./menuTemplate" @@ -42,10 +42,8 @@ export default function AdvancedMenu(props) { } onClick={() => { dispatch( - isSimulationRunning - ? emitStopSimulation() - : emitStartSimulation() - ); + isSimulationRunning ? emitStopSimulation() : emitStartSimulation(), + ) }} /> diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js index 5ab46f72a..0014d36f2 100644 --- a/gcs/src/redux/middleware/emitters.js +++ b/gcs/src/redux/middleware/emitters.js @@ -203,14 +203,14 @@ export function handleEmitters(socket, store, action) { callback: () => { socket.socket.emit("start_docker_simulation") store.dispatch(setSimulationStatus(SimulationStatus.Starting)) - } + }, }, { emitter: emitStopSimulation, callback: () => { socket.socket.emit("stop_docker_simulation") - } + }, }, /* diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 178dadd44..e3cd91d62 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -415,20 +415,18 @@ const socketMiddleware = (store) => { }) // Simulation messages - socket.socket.on( - SocketEvents.onSimulationResult, - (msg) => { - if (msg.running === true) { - store.dispatch(setSimulationStatus(SimulationStatus.Running)) - } else if (msg.running === false) { - store.dispatch(setSimulationStatus(SimulationStatus.Idle)) - } + socket.socket.on(SocketEvents.onSimulationResult, (msg) => { + if (msg.running === true) { + store.dispatch(setSimulationStatus(SimulationStatus.Running)) + } else if (msg.running === false) { + store.dispatch(setSimulationStatus(SimulationStatus.Idle)) + } + // Else assume status unchanged - msg.success - ? showSuccessNotification(msg.message) - : showErrorNotification(msg.message) - }, - ) + msg.success + ? showSuccessNotification(msg.message) + : showErrorNotification(msg.message) + }) // Link stats socket.socket.on(SocketEvents.linkDebugStats, (msg) => { From b1625cb0156d1b6508a0c4be209b84379c6d7e90 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Thu, 1 Jan 2026 10:25:09 +0000 Subject: [PATCH 011/109] Update requirements for stubs --- radio/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radio/requirements.txt b/radio/requirements.txt index 3f79119f0..a4a1eb85b 100644 --- a/radio/requirements.txt +++ b/radio/requirements.txt @@ -81,4 +81,4 @@ wsproto==1.2.0 yarl==1.9.4 zipp==3.17.0 docker==7.1.0 -types-docker==7.1.0 +types-docker From 1102d066eaf5dc908671ce914fe10583d89d95c0 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Thu, 1 Jan 2026 11:02:56 +0000 Subject: [PATCH 012/109] Exception handling for image pull --- radio/app/endpoints/simulation.py | 36 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index dcf094cf8..946394027 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -24,15 +24,27 @@ def ensure_image(client): "message": "Image not found. Attempting to download.", }, ) - client.images.pull(IMAGE_NAME) - socketio.emit( - "simulation_result", - { - "success": True, - "message": "Simulation image downloaded", - }, - ) - return False + + try: + client.images.pull(IMAGE_NAME) + + socketio.emit( + "simulation_result", + { + "success": True, + "message": "Simulation image downloaded", + }, + ) + return True + except DockerException: + socketio.emit( + "simulation_result", + { + "success": False, + "message": "Error downloading simulation image", + }, + ) + return False @socketio.on("start_docker_simulation") @@ -49,9 +61,11 @@ def start_docker_simulation(): ) return - try: - ensure_image(client) + image_result = ensure_image(client) + if image_result is False: + return # Error already given in function + try: client.containers.run( IMAGE_NAME, name=CONTAINER_NAME, From 94087a757bd44d0db6861127673d4348152cc04e Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Thu, 1 Jan 2026 11:12:05 +0000 Subject: [PATCH 013/109] Add docker to mypi ignore --- radio/mypy.ini | 2 ++ radio/requirements.txt | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/radio/mypy.ini b/radio/mypy.ini index 976a1bdc4..b99ebf7e3 100644 --- a/radio/mypy.ini +++ b/radio/mypy.ini @@ -11,3 +11,5 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True +[mypy-docker.*] +ignore_missing_imports = True diff --git a/radio/requirements.txt b/radio/requirements.txt index a4a1eb85b..fb37c3619 100644 --- a/radio/requirements.txt +++ b/radio/requirements.txt @@ -81,4 +81,3 @@ wsproto==1.2.0 yarl==1.9.4 zipp==3.17.0 docker==7.1.0 -types-docker From 671410ddeb8afc784ca7ff17c4116556ed36c4ef Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Thu, 1 Jan 2026 12:02:57 +0000 Subject: [PATCH 014/109] Add simulation modal --- gcs/src/components/navbar.jsx | 5 ++ gcs/src/components/toolbar/menus/advanced.jsx | 3 +- .../components/toolbar/simulationModal.jsx | 68 +++++++++++++++++++ gcs/src/redux/slices/droneConnectionSlice.js | 7 ++ 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 gcs/src/components/toolbar/simulationModal.jsx diff --git a/gcs/src/components/navbar.jsx b/gcs/src/components/navbar.jsx index 75ae52408..50620af91 100644 --- a/gcs/src/components/navbar.jsx +++ b/gcs/src/components/navbar.jsx @@ -78,6 +78,9 @@ import { useEffect } from "react" import { twMerge } from "tailwind-merge" import { showErrorNotification } from "../helpers/notification.js" +// Modals +import SimulationModal from "./toolbar/simulationModal.jsx" + export default function Navbar() { // Redux const dispatch = useDispatch() @@ -402,6 +405,8 @@ export default function Navbar() { )} + +
{/* Navigation */} diff --git a/gcs/src/components/toolbar/menus/advanced.jsx b/gcs/src/components/toolbar/menus/advanced.jsx index 46c064da0..eb6872d43 100644 --- a/gcs/src/components/toolbar/menus/advanced.jsx +++ b/gcs/src/components/toolbar/menus/advanced.jsx @@ -9,6 +9,7 @@ import { emitStopSimulation, selectIsSimulationRunning, setForwardingAddressModalOpened, + setSimulationModalOpened, } from "../../../redux/slices/droneConnectionSlice" import MenuItem from "./menuItem" import MenuTemplate from "./menuTemplate" @@ -42,7 +43,7 @@ export default function AdvancedMenu(props) { } onClick={() => { dispatch( - isSimulationRunning ? emitStopSimulation() : emitStartSimulation(), + setSimulationModalOpened(true) //isSimulationRunning ? emitStopSimulation() : emitStartSimulation(), ) }} /> diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx new file mode 100644 index 000000000..fbdac220d --- /dev/null +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -0,0 +1,68 @@ +import { useDispatch, useSelector } from "react-redux" +import { Modal, Text, Button } from "@mantine/core" +import { + setSimulationModalOpened, + selectSimulationModalOpened, + selectIsSimulationRunning, + emitStartSimulation, + emitStopSimulation, +} from "../../redux/slices/droneConnectionSlice" + +export default function SimulationModal() { + const dispatch = useDispatch() + const modalOpen = useSelector(selectSimulationModalOpened) + const isSimulationRunning = useSelector(selectIsSimulationRunning) + + return ( + { + dispatch(setSimulationModalOpened(false)) + }} + title="Simulation Modal" + centered + overlayProps={{ + backgroundOpacity: 0.55, + blur: 3, + }} + styles={{ + content: { + borderRadius: "0.5rem", + }, + }} + > + + Note: This is a note + + + {/* + dispatch(setForwardingAddress(event.currentTarget.value)) + } + data-autofocus + disabled={isForwarding} + /> */} + + + + ) +} diff --git a/gcs/src/redux/slices/droneConnectionSlice.js b/gcs/src/redux/slices/droneConnectionSlice.js index c7d976d42..978ba0974 100644 --- a/gcs/src/redux/slices/droneConnectionSlice.js +++ b/gcs/src/redux/slices/droneConnectionSlice.js @@ -53,6 +53,7 @@ const initialState = { videoScale: 1, forceDisarmModalOpened: false, + simulationModalOpened: false, } const droneConnectionSlice = createSlice({ @@ -156,6 +157,9 @@ const droneConnectionSlice = createSlice({ setForceDisarmModalOpened: (state, action) => { state.forceDisarmModalOpened = action.payload }, + setSimulationModalOpened: (state, action) => { + state.simulationModalOpened = action.payload + }, setSimulationStatus: (state, action) => { state.simulationStatus = action.payload }, @@ -210,6 +214,7 @@ const droneConnectionSlice = createSlice({ selectIsSimulationRunning: (state) => state.simulationStatus === SimulationStatus.Running || state.simulationStatus === SimulationStatus.Starting, + selectSimulationModalOpened: (state) => state.simulationModalOpened, }, }) @@ -238,6 +243,7 @@ export const { setVideoScale, setForceDisarmModalOpened, setSimulationStatus, + setSimulationModalOpened, // Emitters emitIsConnectedToDrone, @@ -284,6 +290,7 @@ export const { selectForceDisarmModalOpened, selectSimulationStatus, selectIsSimulationRunning, + selectSimulationModalOpened, } = droneConnectionSlice.selectors export default droneConnectionSlice From 3ce42cf7f91d5ef87dc5c5b13f118b7608424c7e Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:25:29 +0000 Subject: [PATCH 015/109] Add dropdown to select vehicle type --- .../components/toolbar/simulationModal.jsx | 18 +++++++++++++++++- gcs/src/redux/slices/droneConnectionSlice.js | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx index fbdac220d..186d076cb 100644 --- a/gcs/src/components/toolbar/simulationModal.jsx +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -1,17 +1,22 @@ import { useDispatch, useSelector } from "react-redux" -import { Modal, Text, Button } from "@mantine/core" +import { Modal, Text, Button, Select } from "@mantine/core" import { setSimulationModalOpened, + setSimulationParams, + setSimulationParam, selectSimulationModalOpened, + selectSimulationParams, selectIsSimulationRunning, emitStartSimulation, emitStopSimulation, + } from "../../redux/slices/droneConnectionSlice" export default function SimulationModal() { const dispatch = useDispatch() const modalOpen = useSelector(selectSimulationModalOpened) const isSimulationRunning = useSelector(selectIsSimulationRunning) + const simulationParams = useSelector(selectSimulationParams) return ( + - {/* - dispatch(setForwardingAddress(event.currentTarget.value)) - } - data-autofocus - disabled={isForwarding} - /> */} + + dispatch(setSimulationParam({ key: "lat", value: val }))} + /> + + dispatch(setSimulationParam({ key: "lon", value: val }))} + /> + + dispatch(setSimulationParam({ key: "alt", value: val }))} + /> + + dispatch(setSimulationParam({ key: "dir", value: val }))} + /> + + +
+ + setChecked(event.currentTarget.checked) + } + /> + + + +
+ + +
) } diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 430e66e07..93a7c2bf6 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -49,29 +49,23 @@ def ensure_image(client): @socketio.on("start_docker_simulation") def start_docker_simulation(data): - env = { - "VEHICLE": data.get("vehicleType"), - "LAT": data.get("lat"), - "LON": data.get("lon"), - "ALT": data.get("alt"), - "DIR": data.get("dir"), - } + logger.debug(f"Current data: {data}") # Get rid of any that are none - env = {k: str(v) for k, v in env.items() if v is not None} + data = {k: str(v) for k, v in data.items() if v is not None} cmd = [] - if "VEHICLE" in env: - cmd.append(f"VEHICLE={env['VEHICLE']}") - if "LAT" in env: - cmd.append(f"LAT={env['LAT']}") - if "LON" in env: - cmd.append(f"LON={env['LON']}") - if "ALT" in env: - cmd.append(f"ALT={env['ALT']}") - if "DIR" in env: - cmd.append(f"DIR={env['DIR']}") + if "vehicleType" in data: + cmd.append(f"VEHICLE={data['vehicleType']}") + if "lat" in data: + cmd.append(f"LAT={data['lat']}") + if "lon" in data: + cmd.append(f"LON={data['lon']}") + if "alt" in data: + cmd.append(f"ALT={data['alt']}") + if "dir" in data: + cmd.append(f"DIR={data['dir']}") client = get_docker_client() if client is None: @@ -90,7 +84,7 @@ def start_docker_simulation(data): return # Error already given in function try: - logger.debug(f"Current state: {env}") + logger.debug(f"Current state: {data}") logger.debug(f"Command: {cmd}") client.containers.run( From 8a70694c1570d570078156fbe59a8f2ff56972e6 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:50:45 +0000 Subject: [PATCH 022/109] Wait for YOU CAN CONNECT message --- gcs/src/components/toolbar/menus/advanced.jsx | 4 -- .../components/toolbar/simulationModal.jsx | 4 ++ gcs/src/redux/slices/droneConnectionSlice.js | 3 +- radio/app/endpoints/simulation.py | 60 ++++++++++++++++--- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/gcs/src/components/toolbar/menus/advanced.jsx b/gcs/src/components/toolbar/menus/advanced.jsx index b1ec5fc80..9477e0e9e 100644 --- a/gcs/src/components/toolbar/menus/advanced.jsx +++ b/gcs/src/components/toolbar/menus/advanced.jsx @@ -5,9 +5,6 @@ // Local Imports import { useDispatch, useSelector } from "react-redux" import { - emitStartSimulation, - emitStopSimulation, - selectIsSimulationRunning, setForwardingAddressModalOpened, setSimulationModalOpened, } from "../../../redux/slices/droneConnectionSlice" @@ -16,7 +13,6 @@ import MenuTemplate from "./menuTemplate" export default function AdvancedMenu(props) { const dispatch = useDispatch() - const isSimulationRunning = useSelector(selectIsSimulationRunning) return ( {isSimulationRunning ? "Stop Simulation" : "Start Simulation"} diff --git a/gcs/src/redux/slices/droneConnectionSlice.js b/gcs/src/redux/slices/droneConnectionSlice.js index 107c46382..273512c75 100644 --- a/gcs/src/redux/slices/droneConnectionSlice.js +++ b/gcs/src/redux/slices/droneConnectionSlice.js @@ -227,8 +227,7 @@ const droneConnectionSlice = createSlice({ selectForceDisarmModalOpened: (state) => state.forceDisarmModalOpened, selectSimulationStatus: (state) => state.simulationStatus, selectIsSimulationRunning: (state) => - state.simulationStatus === SimulationStatus.Running || - state.simulationStatus === SimulationStatus.Starting, + state.simulationStatus === SimulationStatus.Running, selectSimulationModalOpened: (state) => state.simulationModalOpened, selectSimulationParams: (state) => state.simParams, }, diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 93a7c2bf6..0bf0b9128 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -1,3 +1,4 @@ +import time from app import logger, socketio import docker from docker.errors import DockerException @@ -83,11 +84,29 @@ def start_docker_simulation(data): if image_result is False: return # Error already given in function + try: + existing = client.containers.get(CONTAINER_NAME) + if existing.status == "running": + socketio.emit( + "simulation_result", + { + "success": False, + "running": True, + "message": "Simulation already running", + }, + ) + return + else: + existing.remove(force=True) + + except docker.errors.NotFound: + pass # No container exists, so free to start one + try: logger.debug(f"Current state: {data}") logger.debug(f"Command: {cmd}") - client.containers.run( + container = client.containers.run( IMAGE_NAME, name=CONTAINER_NAME, ports={5760: 5760}, @@ -98,16 +117,43 @@ def start_docker_simulation(data): command=cmd, ) - socketio.emit( - "simulation_result", - {"success": True, "running": True, "message": "Simulation started"}, - ) - except DockerException as e: + timeout = 30 + start_time = time.time() + line_found = False + buffer = "" + + for line in container.logs(stream=True): + decoded = line.decode().strip() + buffer += decoded + + if "YOUCANNOWCONNECT" in buffer: + line_found = True + break + + if time.time() - start_time > timeout: + break + + if line_found: + socketio.emit( + "simulation_result", + {"success": True, "running": True, "message": "Simulation started"}, + ) + else: + socketio.emit( + "simulation_result", + { + "success": False, + "running": False, + "message": "Simulation failed to start in time", + }, + ) + except DockerException: socketio.emit( "simulation_result", { "success": False, - "message": str(e), + "running": False, + "message": "Simulation failed to start", }, ) From c32074809ca2978a3e2e3698f992dc9d107f4191 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:00:38 +0000 Subject: [PATCH 023/109] Disable if not connected to socket --- gcs/src/components/toolbar/simulationModal.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx index ef8c3b7e3..b05ae4a67 100644 --- a/gcs/src/components/toolbar/simulationModal.jsx +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -24,6 +24,7 @@ import { emitStopSimulation, selectSimulationStatus, } from "../../redux/slices/droneConnectionSlice" +import { selectIsConnectedToSocket } from "../../redux/slices/socketSlice" export default function SimulationModal() { const dispatch = useDispatch() @@ -31,6 +32,7 @@ export default function SimulationModal() { const isSimulationRunning = useSelector(selectIsSimulationRunning) const simulationStatus = useSelector(selectSimulationStatus) const simulationParams = useSelector(selectSimulationParams) + const connectedToSocket = useSelector(selectIsConnectedToSocket) const [checked, setChecked] = useState(false); return ( @@ -114,8 +116,13 @@ export default function SimulationModal() { onChange={(event) => setChecked(event.currentTarget.checked) } + disabled={!connectedToSocket} /> - +
From 2d36aba5ec61e35bbd3a742a548525b51efc9d8d Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 2 Jan 2026 17:35:19 +0000 Subject: [PATCH 024/109] Add connect after simulator start --- .../components/toolbar/simulationModal.jsx | 4 ++-- gcs/src/redux/middleware/socketMiddleware.js | 19 +++++++++++++++++++ gcs/src/redux/slices/droneConnectionSlice.js | 3 ++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx index b05ae4a67..f681a60e7 100644 --- a/gcs/src/components/toolbar/simulationModal.jsx +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -112,9 +112,9 @@ export default function SimulationModal() {
- setChecked(event.currentTarget.checked) + dispatch(setSimulationParam({ key: "connectAfterStart", value: event.currentTarget.checked })) } disabled={!connectedToSocket} /> diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index e3cd91d62..52803ba2a 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -9,8 +9,11 @@ import { // drone actions import { + ConnectionType, + emitConnectToDrone, emitGetComPorts, emitIsConnectedToDrone, + selectForwardingAddress, setComPorts, setConnected, setConnecting, @@ -118,6 +121,7 @@ import { } from "../slices/paramsSlice.js" import { pushMessage, resetMessages } from "../slices/statusTextSlice.js" import { handleEmitters } from "./emitters.js" +import { useSelector } from "react-redux" const SocketEvents = Object.freeze({ // socket.on events @@ -426,6 +430,21 @@ const socketMiddleware = (store) => { msg.success ? showSuccessNotification(msg.message) : showErrorNotification(msg.message) + + if (msg.success && msg.running) { + const storeState = store.getState() + if (storeState.droneConnection.simParams.connectAfterStart) { + store.dispatch( + emitConnectToDrone({ + port: 'tcp:127.0.0.1:5760', + baud: 115200, + wireless: true, + connectionType: ConnectionType.Network, + forwardingAddress: storeState.droneConnection.forwardingAddress, + }), + ) + } + } }) // Link stats diff --git a/gcs/src/redux/slices/droneConnectionSlice.js b/gcs/src/redux/slices/droneConnectionSlice.js index 273512c75..2c63f4816 100644 --- a/gcs/src/redux/slices/droneConnectionSlice.js +++ b/gcs/src/redux/slices/droneConnectionSlice.js @@ -53,14 +53,15 @@ const initialState = { videoScale: 1, forceDisarmModalOpened: false, - simulationModalOpened: false, + simulationModalOpened: false, simParams: { vehicleType: "ArduCopter", lat: null, lon: null, alt: null, dir: null, + connectAfterStart: false, }, } From 2da49270240aa9440643c799b190931a3cf3aa94 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 2 Jan 2026 17:40:18 +0000 Subject: [PATCH 025/109] linting --- gcs/src/components/toolbar/menus/advanced.jsx | 2 +- .../components/toolbar/simulationModal.jsx | 28 +++++++++++-------- gcs/src/redux/middleware/socketMiddleware.js | 7 ++--- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/gcs/src/components/toolbar/menus/advanced.jsx b/gcs/src/components/toolbar/menus/advanced.jsx index 9477e0e9e..f3056d840 100644 --- a/gcs/src/components/toolbar/menus/advanced.jsx +++ b/gcs/src/components/toolbar/menus/advanced.jsx @@ -3,7 +3,7 @@ */ // Local Imports -import { useDispatch, useSelector } from "react-redux" +import { useDispatch } from "react-redux" import { setForwardingAddressModalOpened, setSimulationModalOpened, diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx index f681a60e7..35ead6f7e 100644 --- a/gcs/src/components/toolbar/simulationModal.jsx +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -1,4 +1,3 @@ -import { useState } from "react" import { useDispatch, useSelector } from "react-redux" import { SimpleGrid, @@ -11,10 +10,9 @@ import { Tooltip, Group, } from "@mantine/core" -import { IconInfoCircle, IconRefresh } from "@tabler/icons-react" +import { IconInfoCircle } from "@tabler/icons-react" import { setSimulationModalOpened, - setSimulationParams, setSimulationParam, selectSimulationModalOpened, selectSimulationParams, @@ -33,7 +31,6 @@ export default function SimulationModal() { const simulationStatus = useSelector(selectSimulationStatus) const simulationParams = useSelector(selectSimulationParams) const connectedToSocket = useSelector(selectIsConnectedToSocket) - const [checked, setChecked] = useState(false); return ( - dispatch(setSimulationParam({ key: "connectAfterStart", value: event.currentTarget.checked })) + dispatch( + setSimulationParam({ + key: "connectAfterStart", + value: event.currentTarget.checked, + }), + ) } disabled={!connectedToSocket} /> - +
@@ -132,7 +136,9 @@ export default function SimulationModal() { color={isSimulationRunning ? "red" : "green"} onClick={() => { dispatch( - isSimulationRunning ? emitStopSimulation() : emitStartSimulation(), + isSimulationRunning + ? emitStopSimulation() + : emitStartSimulation(), ) }} loading={simulationStatus == SimulationStatus.Starting} diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 52803ba2a..40a1c9f91 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -13,7 +13,6 @@ import { emitConnectToDrone, emitGetComPorts, emitIsConnectedToDrone, - selectForwardingAddress, setComPorts, setConnected, setConnecting, @@ -121,7 +120,6 @@ import { } from "../slices/paramsSlice.js" import { pushMessage, resetMessages } from "../slices/statusTextSlice.js" import { handleEmitters } from "./emitters.js" -import { useSelector } from "react-redux" const SocketEvents = Object.freeze({ // socket.on events @@ -436,11 +434,12 @@ const socketMiddleware = (store) => { if (storeState.droneConnection.simParams.connectAfterStart) { store.dispatch( emitConnectToDrone({ - port: 'tcp:127.0.0.1:5760', + port: "tcp:127.0.0.1:5760", baud: 115200, wireless: true, connectionType: ConnectionType.Network, - forwardingAddress: storeState.droneConnection.forwardingAddress, + forwardingAddress: + storeState.droneConnection.forwardingAddress, }), ) } From bb9d008f56c0543fd70635aa22ef1799194b0335 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:29:36 +0000 Subject: [PATCH 026/109] Backend refactor and comments --- radio/app/endpoints/simulation.py | 166 +++++++++++++++++++----------- 1 file changed, 108 insertions(+), 58 deletions(-) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 0bf0b9128..10bdbc917 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -1,20 +1,33 @@ import time -from app import logger, socketio +from app import socketio import docker from docker.errors import DockerException -CONTAINER_NAME = "ardupilot_sitl" +CONTAINER_NAME = "fgcs_ardupilot_sitl" IMAGE_NAME = "kushmakkapati/ardupilot_sitl" def get_docker_client(): + """ + Returns a Docker client if available, otherwise None. + """ try: return docker.from_env() except DockerException: return None -def ensure_image(client): +def ensure_image(client) -> bool: + """ + Checks if the client contains the given image. + If not it attempts to download it. + + Args: + client: The docker client. + + Returns: + True if the image exists or is successfully downloaded. Else False. + """ try: client.images.get(IMAGE_NAME) return True @@ -48,13 +61,16 @@ def ensure_image(client): return False -@socketio.on("start_docker_simulation") -def start_docker_simulation(data): - logger.debug(f"Current data: {data}") +def build_command(data): + """ + Parses tbe socketio data into the form required for the docker command. - # Get rid of any that are none - data = {k: str(v) for k, v in data.items() if v is not None} + Args: + data: The parameters that the simulator should start with. + Returns: + The command containing the parameters in the correct format. + """ cmd = [] if "vehicleType" in data: @@ -68,24 +84,16 @@ def start_docker_simulation(data): if "dir" in data: cmd.append(f"DIR={data['dir']}") - client = get_docker_client() - if client is None: - socketio.emit( - "simulation_result", - { - "success": False, - "running": False, - "message": "Unable to connect to Docker", - }, - ) - return + return cmd - image_result = ensure_image(client) - if image_result is False: - return # Error already given in function +def container_already_running(client, container_name) -> bool: + """ + Checks if the client already has the given container running. + If it exists but is not running it will be removed. + """ try: - existing = client.containers.get(CONTAINER_NAME) + existing = client.containers.get(container_name) if existing.status == "running": socketio.emit( "simulation_result", @@ -95,17 +103,84 @@ def start_docker_simulation(data): "message": "Simulation already running", }, ) - return + return True else: existing.remove(force=True) + return False except docker.errors.NotFound: - pass # No container exists, so free to start one + return False - try: - logger.debug(f"Current state: {data}") - logger.debug(f"Command: {cmd}") +def wait_for_container_running_result(container, timeout=30): + """ + Waits for if the container runs successfully and thus prints: + "YOU CAN NOW CONNECT" + + Args: + container: The container to wait for. + timeout: The amount of time to wait before timing out. + """ + start_time = time.time() + line_found = False + buffer = "" + + for line in container.logs(stream=True): + decoded = line.decode().strip() + buffer += decoded + + if "YOUCANNOWCONNECT" in buffer: + line_found = True + break + + if time.time() - start_time > timeout: + break + + socketio.emit( + "simulation_result", + { + "success": line_found, + "running": line_found, + "message": "Simulation started" + if line_found + else "Simulation failed to start in time", + }, + ) + + +@socketio.on("start_docker_simulation") +def start_docker_simulation(data) -> None: + """ + Starts the container identified by CONTAINER_NAME. + + Args: + data: The parameters that the simulator should start with. + """ + # Get rid of any that are none + data = {k: str(v) for k, v in data.items() if v is not None} + + cmd = build_command(data) + + client = get_docker_client() + if client is None: + socketio.emit( + "simulation_result", + { + "success": False, + "running": False, + "message": "Unable to connect to Docker", + }, + ) + return + + image_result = ensure_image(client) + if image_result is False: + return # Error already given in function + + if container_already_running(client, CONTAINER_NAME): + return # Error already given in function + + try: container = client.containers.run( IMAGE_NAME, name=CONTAINER_NAME, @@ -117,36 +192,8 @@ def start_docker_simulation(data): command=cmd, ) - timeout = 30 - start_time = time.time() - line_found = False - buffer = "" - - for line in container.logs(stream=True): - decoded = line.decode().strip() - buffer += decoded + wait_for_container_running_result(container, timeout=30) - if "YOUCANNOWCONNECT" in buffer: - line_found = True - break - - if time.time() - start_time > timeout: - break - - if line_found: - socketio.emit( - "simulation_result", - {"success": True, "running": True, "message": "Simulation started"}, - ) - else: - socketio.emit( - "simulation_result", - { - "success": False, - "running": False, - "message": "Simulation failed to start in time", - }, - ) except DockerException: socketio.emit( "simulation_result", @@ -159,7 +206,10 @@ def start_docker_simulation(data): @socketio.on("stop_docker_simulation") -def stop_docker_simulation(): +def stop_docker_simulation() -> None: + """ + Stops the running Docker container identified by CONTAINER_NAME. + """ client = get_docker_client() if client is None: socketio.emit( From 8562cc8255c9a6e1139ad67aca97db49fcf3869c Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 2 Jan 2026 23:17:18 +0000 Subject: [PATCH 027/109] Add port option to also allow for tests --- gcs/src/components/toolbar/simulationModal.jsx | 17 +++++++++++++---- gcs/src/redux/middleware/emitters.js | 1 + gcs/src/redux/slices/droneConnectionSlice.js | 3 ++- radio/app/endpoints/simulation.py | 4 +++- radio/tests/test_simulation.py | 15 +++++++++++++++ 5 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 radio/tests/test_simulation.py diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx index 35ead6f7e..6831a949a 100644 --- a/gcs/src/components/toolbar/simulationModal.jsx +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -50,9 +50,14 @@ export default function SimulationModal() { }, }} > - - Note: Docker must be running to start the simulator. - + + dispatch(setSimulationParam({ key: "port", value: val })) + } + /> None: Args: data: The parameters that the simulator should start with. """ - if "port" in data: - port = data["port"] - else: - emit_error_message("Port is required") + host_port = data.get("hostPort") + if host_port is None: + emit_error_message("Host port is required") + return + if not (1025 <= host_port <= 65535): + emit_error_message("Host port must be between 1025 and 65535") return - if not (1 <= port <= 65535): - emit_error_message("Port must be between 1 and 65535") + container_port = data.get("containerPort", 5760) # default internal port + if not (1 <= container_port <= 65535): + emit_error_message("Container port must be between 1 and 65535") return connect = data["connect"] if "connect" in data else False @@ -228,7 +231,7 @@ def start_docker_simulation(data) -> None: container = client.containers.run( IMAGE_NAME, name=CONTAINER_NAME, - ports={port: port}, + ports={container_port: host_port}, detach=True, remove=True, command=cmd, @@ -238,7 +241,7 @@ def start_docker_simulation(data) -> None: wait_for_container_connection_msg, container, connect, - port, + host_port, CONTAINER_START_TIMEOUT, ) diff --git a/radio/tests/test_simulation.py b/radio/tests/test_simulation.py index 57c2176cb..efe79aa09 100644 --- a/radio/tests/test_simulation.py +++ b/radio/tests/test_simulation.py @@ -39,7 +39,7 @@ def test_start_docker_simulation_success(socketio_client: SocketIOTestClient): cleanup_container() # Emit the event - socketio_client.emit("start_docker_simulation", {"port": 5763}) + socketio_client.emit("start_docker_simulation", {"hostPort": 5763}) # Synchronize: Wait for the background task to emit the message start_time = time.time() @@ -69,7 +69,7 @@ def test_start_docker_simulation_with_connect(socketio_client: SocketIOTestClien cleanup_container() # Emit the event - socketio_client.emit("start_docker_simulation", {"port": 5763, "connect": True}) + socketio_client.emit("start_docker_simulation", {"hostPort": 5763, "connect": True}) # Synchronize: Wait for the background task to emit the message start_time = time.time() @@ -114,7 +114,7 @@ def test_container_already_running_error_handling(socketio_client: SocketIOTestC time.sleep(0.5) container.reload() - socketio_client.emit("start_docker_simulation", {"port": 5763}) + socketio_client.emit("start_docker_simulation", {"hostPort": 5763}) result = socketio_client.get_received()[-1] assert result["name"] == "simulation_result" @@ -368,12 +368,12 @@ def test_start_docker_simulation_invalid_port(socketio_client: SocketIOTestClien """ cleanup_container() - socketio_client.emit("start_docker_simulation", {"port": 70000}) + socketio_client.emit("start_docker_simulation", {"hostPort": 70000}) result = socketio_client.get_received()[-1] assert result["name"] == "simulation_result" assert result["args"][0]["success"] is False - assert "Port must be between 1 and 65535" in result["args"][0]["message"] + assert "Host port must be between 1025 and 65535" in result["args"][0]["message"] @falcon_test() @@ -384,7 +384,7 @@ def test_start_docker_simulation_no_docker(socketio_client: SocketIOTestClient): from unittest.mock import patch with patch("app.endpoints.simulation.get_docker_client", return_value=None): - socketio_client.emit("start_docker_simulation", {"port": 5763}) + socketio_client.emit("start_docker_simulation", {"hostPort": 5763}) result = socketio_client.get_received()[-1] assert result["name"] == "simulation_result" From f9ecf1b431709c6c7b0bbae51627789a9f6e2da4 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:34:59 +0000 Subject: [PATCH 063/109] Add more tests for failure routes --- radio/app/endpoints/simulation.py | 6 +- radio/tests/test_simulation.py | 177 +++++++++++++++++++++++++----- 2 files changed, 153 insertions(+), 30 deletions(-) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index cdab95032..2f2ca41ed 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -245,10 +245,10 @@ def start_docker_simulation(data) -> None: CONTAINER_START_TIMEOUT, ) - except APIError as e: - emit_error_message(f"Simulation failed to start: {e.explanation}") + except APIError: + emit_error_message("Simulation failed to start: Docker API error") except DockerException: - emit_error_message("Simulation failed to start") + emit_error_message("Simulation failed to start: Docker exception") @socketio.on("stop_docker_simulation") diff --git a/radio/tests/test_simulation.py b/radio/tests/test_simulation.py index efe79aa09..f5e124a84 100644 --- a/radio/tests/test_simulation.py +++ b/radio/tests/test_simulation.py @@ -1,7 +1,8 @@ from flask_socketio.test_client import SocketIOTestClient +from unittest.mock import Mock, MagicMock, patch import time import docker -from docker.errors import NotFound +from docker.errors import NotFound, DockerException, ImageNotFound from . import falcon_test client = docker.from_env() @@ -180,8 +181,7 @@ def test_stop_docker_simulation_no_container(socketio_client: SocketIOTestClient assert "Simulation could not be found" in result["args"][0]["message"] -@falcon_test() -def test_build_command(_socketio_client: SocketIOTestClient): +def test_build_command(): """ Test the build_command function directly. """ @@ -199,8 +199,7 @@ def test_build_command(_socketio_client: SocketIOTestClient): assert "VEHICLE=ArduCopter" in cmd -@falcon_test() -def test_ensure_image_exists(_socketio_client: SocketIOTestClient): +def test_ensure_image_exists(): """ Test the ensure_image_exists function directly. """ @@ -225,8 +224,7 @@ def test_ensure_image_exists(_socketio_client: SocketIOTestClient): assert False, "Image was not pulled successfully" -@falcon_test() -def test_ensure_image_exists_image_exists(_socketio_client: SocketIOTestClient): +def test_ensure_image_exists_image_exists(): """ Test the ensure_image_exists function when the image already exists. """ @@ -244,8 +242,39 @@ def test_ensure_image_exists_image_exists(_socketio_client: SocketIOTestClient): assert result is True -@falcon_test() -def test_container_already_running(_socketio_client: SocketIOTestClient): +def test_ensure_image_exists_pull_fails_emits_error(): + """Ensure ensure_image_exists handles DockerException during pull and emits error feedback.""" + from app.endpoints.simulation import ensure_image_exists + + # Build a fake docker client where the image is "missing" and pull fails + fake_client = Mock() + fake_client.images.get.side_effect = ImageNotFound("missing") + fake_client.images.pull.side_effect = DockerException("pull failed") + + with patch("app.endpoints.simulation.socketio.emit") as emit_mock: + result = ensure_image_exists(fake_client, "kushmakkapati/ardupilot_sitl") + + assert result is False + + # Should emit loading True (download start), then loading False, then simulation_result error + emitted_events = [call.args[0] for call in emit_mock.call_args_list if call.args] + assert "simulation_loading" in emitted_events + assert "simulation_result" in emitted_events + + simulation_result_calls = [ + call + for call in emit_mock.call_args_list + if call.args and call.args[0] == "simulation_result" + ] + assert simulation_result_calls, "Expected a simulation_result emit on pull failure" + assert simulation_result_calls[-1].args[1]["success"] is False + assert ( + "Error downloading simulation image" + in simulation_result_calls[-1].args[1]["message"] + ) + + +def test_container_already_running(): """ Test the container_already_running function directly. """ @@ -309,8 +338,7 @@ def test_emit_error_message_edge_cases(socketio_client: SocketIOTestClient): assert result["args"][0]["message"] is None -@falcon_test() -def test_get_docker_client(_socketio_client: SocketIOTestClient): +def test_get_docker_client(): """ Test the get_docker_client function directly. """ @@ -362,31 +390,126 @@ def test_wait_for_container_connection_msg(socketio_client: SocketIOTestClient): @falcon_test() -def test_start_docker_simulation_invalid_port(socketio_client: SocketIOTestClient): - """ - Test starting the simulation with an invalid port number. - """ - cleanup_container() +def test_start_docker_simulation_validation_failures( + socketio_client: SocketIOTestClient +): + """Covers the main input validation failure routes for start_docker_simulation.""" + # Missing hostPort + socketio_client.emit("start_docker_simulation", {}) + result = socketio_client.get_received()[-1] + assert result["name"] == "simulation_result" + assert result["args"][0]["success"] is False + assert "Host port is required" in result["args"][0]["message"] - socketio_client.emit("start_docker_simulation", {"hostPort": 70000}) + # hostPort too low + socketio_client.emit("start_docker_simulation", {"hostPort": 1024}) result = socketio_client.get_received()[-1] + assert result["args"][0]["success"] is False + assert "Host port must be between 1025 and 65535" in result["args"][0]["message"] - assert result["name"] == "simulation_result" + # hostPort too high + socketio_client.emit("start_docker_simulation", {"hostPort": 70000}) + result = socketio_client.get_received()[-1] assert result["args"][0]["success"] is False assert "Host port must be between 1025 and 65535" in result["args"][0]["message"] + # containerPort invalid + socketio_client.emit( + "start_docker_simulation", {"hostPort": 6000, "containerPort": 70000} + ) + result = socketio_client.get_received()[-1] + assert result["args"][0]["success"] is False + assert "Container port must be between 1 and 65535" in result["args"][0]["message"] + @falcon_test() -def test_start_docker_simulation_no_docker(socketio_client: SocketIOTestClient): - """ - Test starting the simulation when Docker is unavailable. - """ - from unittest.mock import patch +def test_start_docker_simulation_run_failures(socketio_client: SocketIOTestClient): + """Covers run() exception routes: APIError and generic DockerException.""" + from docker.errors import APIError as DockerAPIError + + fake_client = MagicMock() + fake_client.images.get.return_value = object() # image exists + fake_client.containers.get.side_effect = NotFound( + "missing" + ) # no existing container + + # 1) APIError includes explanation + fake_client.containers.run.side_effect = DockerAPIError( + "boom", explanation="bad params" + ) + + with patch("app.endpoints.simulation.get_docker_client", return_value=fake_client): + socketio_client.emit("start_docker_simulation", {"hostPort": 6000}) + result = socketio_client.get_received()[-1] + assert result["name"] == "simulation_result" + assert result["args"][0]["success"] is False + assert "Simulation failed to start" in result["args"][0]["message"] + assert "bad params" in result["args"][0]["message"] + + # 2) generic DockerException + fake_client.containers.run.side_effect = DockerException("nope") + + with patch("app.endpoints.simulation.get_docker_client", return_value=fake_client): + socketio_client.emit("start_docker_simulation", {"hostPort": 6000}) + result = socketio_client.get_received()[-1] + + assert result["name"] == "simulation_result" + assert result["args"][0]["success"] is False + assert result["args"][0]["message"] == "Simulation failed to start" + + +@falcon_test() +def test_stop_docker_simulation_failure_routes(socketio_client: SocketIOTestClient): + """Covers stop_docker_simulation Docker-unavailable and stop() exception routes.""" + # Docker unavailable with patch("app.endpoints.simulation.get_docker_client", return_value=None): - socketio_client.emit("start_docker_simulation", {"hostPort": 5763}) + socketio_client.emit("stop_docker_simulation") result = socketio_client.get_received()[-1] - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is False - assert "Unable to connect to Docker" in result["args"][0]["message"] + assert result["name"] == "simulation_result" + assert result["args"][0]["success"] is False + assert "Unable to connect to Docker" in result["args"][0]["message"] + + # container.stop raises DockerException + fake_container = MagicMock() + fake_container.stop.side_effect = DockerException("stop failed") + fake_client = MagicMock() + fake_client.containers.get.return_value = fake_container + + with patch("app.endpoints.simulation.get_docker_client", return_value=fake_client): + socketio_client.emit("stop_docker_simulation") + result = socketio_client.get_received()[-1] + + assert result["name"] == "simulation_result" + assert result["args"][0]["success"] is False + assert "Docker error while stopping simulation" in result["args"][0]["message"] + + +def test_container_already_running_not_running_container_removed(): + """Covers container_already_running branch where container exists but is not running.""" + from app.endpoints.simulation import container_already_running + + fake_existing = MagicMock() + fake_existing.status = "exited" + + fake_client = MagicMock() + fake_client.containers.get.return_value = fake_existing + + # Should remove and return False + assert container_already_running(fake_client, CONTAINER_NAME) is False + fake_existing.remove.assert_called_once() + + +def test_container_already_running_remove_race_notfound(): + """Covers NotFound race when removing a non-running container.""" + from app.endpoints.simulation import container_already_running + + fake_existing = MagicMock() + fake_existing.status = "exited" + fake_existing.remove.side_effect = NotFound("already removed") + + fake_client = MagicMock() + fake_client.containers.get.return_value = fake_existing + + assert container_already_running(fake_client, CONTAINER_NAME) is False From d57d3d47b64094aae61e6f982c1ee051b89a6101 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:48:21 +0000 Subject: [PATCH 064/109] Fix tests --- radio/tests/test_simulation.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/radio/tests/test_simulation.py b/radio/tests/test_simulation.py index f5e124a84..0f1971841 100644 --- a/radio/tests/test_simulation.py +++ b/radio/tests/test_simulation.py @@ -425,6 +425,8 @@ def test_start_docker_simulation_validation_failures( @falcon_test() def test_start_docker_simulation_run_failures(socketio_client: SocketIOTestClient): """Covers run() exception routes: APIError and generic DockerException.""" + from unittest.mock import MagicMock + from docker.errors import APIError as DockerAPIError fake_client = MagicMock() @@ -433,7 +435,7 @@ def test_start_docker_simulation_run_failures(socketio_client: SocketIOTestClien "missing" ) # no existing container - # 1) APIError includes explanation + # 1) APIError is intentionally sanitized for UI display fake_client.containers.run.side_effect = DockerAPIError( "boom", explanation="bad params" ) @@ -444,8 +446,9 @@ def test_start_docker_simulation_run_failures(socketio_client: SocketIOTestClien assert result["name"] == "simulation_result" assert result["args"][0]["success"] is False - assert "Simulation failed to start" in result["args"][0]["message"] - assert "bad params" in result["args"][0]["message"] + assert ( + result["args"][0]["message"] == "Simulation failed to start: Docker API error" + ) # 2) generic DockerException fake_client.containers.run.side_effect = DockerException("nope") @@ -456,7 +459,9 @@ def test_start_docker_simulation_run_failures(socketio_client: SocketIOTestClien assert result["name"] == "simulation_result" assert result["args"][0]["success"] is False - assert result["args"][0]["message"] == "Simulation failed to start" + assert ( + result["args"][0]["message"] == "Simulation failed to start: Docker exception" + ) @falcon_test() From 1544fc5bf346af19cc9c04514b9e65ed6be34233 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:21:29 +0000 Subject: [PATCH 065/109] Resolve copilot feedback --- radio/app/endpoints/simulation.py | 5 ++ radio/tests/test_simulation.py | 78 +++++++++++++++++-------------- 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 2f2ca41ed..506ff6775 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -60,6 +60,8 @@ def ensure_image_exists(client, image_name) -> bool: "simulation_loading", { "loading": False, + "title": "Docker Exception", + "message": "Error downloading simulation image", }, ) socketio.emit( @@ -271,6 +273,9 @@ def stop_docker_simulation() -> None: except NotFound: emit_error_message("Simulation could not be found") return + except DockerException: + emit_error_message("Docker exception while getting container") + return container.stop() socketio.emit( diff --git a/radio/tests/test_simulation.py b/radio/tests/test_simulation.py index 0f1971841..bc08d8658 100644 --- a/radio/tests/test_simulation.py +++ b/radio/tests/test_simulation.py @@ -3,18 +3,32 @@ import time import docker from docker.errors import NotFound, DockerException, ImageNotFound +import pytest from . import falcon_test -client = docker.from_env() CONTAINER_NAME = "fgcs_ardupilot_sitl" CLEANUP_CONTAINER_TRIES = 30 CONTAINER_START_WAIT_TIME = 60 +def get_docker_client_or_skip(): + """ + Return a docker client or skip the current test if Docker is unavailable. + """ + try: + c = docker.from_env() + # ping ensures daemon is reachable + c.ping() + return c + except Exception as e: + pytest.skip(f"Docker not available: {e}") + + def cleanup_container(): """ Helper function to remove the test container if it exists. """ + client = get_docker_client_or_skip() try: container = client.containers.get(CONTAINER_NAME) container.remove(force=True) @@ -37,6 +51,7 @@ def test_start_docker_simulation_success(socketio_client: SocketIOTestClient): """ Test successfully starting the simulation using Docker. """ + get_docker_client_or_skip() cleanup_container() # Emit the event @@ -67,6 +82,7 @@ def test_start_docker_simulation_with_connect(socketio_client: SocketIOTestClien """ Test starting the simulation with connect=True. """ + get_docker_client_or_skip() cleanup_container() # Emit the event @@ -98,6 +114,7 @@ def test_container_already_running_error_handling(socketio_client: SocketIOTestC """ Test behavior when the container is already running. """ + client = get_docker_client_or_skip() cleanup_container() # Start the container manually @@ -130,6 +147,7 @@ def test_stop_docker_simulation(socketio_client: SocketIOTestClient): """ Test stopping the Docker simulation. """ + client = get_docker_client_or_skip() cleanup_container() # Start the container manually @@ -200,46 +218,39 @@ def test_build_command(): def test_ensure_image_exists(): - """ - Test the ensure_image_exists function directly. - """ + """Test ensure_image_exists without touching real Docker images (safe for CI).""" from app.endpoints.simulation import ensure_image_exists - client = docker.from_env() - - # Remove the image if it exists - try: - client.images.remove("kushmakkapati/ardupilot_sitl", force=True) - except docker.errors.ImageNotFound: - pass # Does not exist so safe to continue + fake_client = Mock() + # Image already present + fake_client.images.get.return_value = object() - # Call the function to ensure the image - result = ensure_image_exists(client, "kushmakkapati/ardupilot_sitl") + with patch("app.endpoints.simulation.socketio.emit") as emit_mock: + result = ensure_image_exists(fake_client, "kushmakkapati/ardupilot_sitl") - # Verify the image is now present assert result is True - try: - client.images.get("kushmakkapati/ardupilot_sitl") - except docker.errors.ImageNotFound: - assert False, "Image was not pulled successfully" + fake_client.images.pull.assert_not_called() + # No loading/error emits expected when image exists + emitted_events = [call.args[0] for call in emit_mock.call_args_list if call.args] + assert "simulation_result" not in emitted_events -def test_ensure_image_exists_image_exists(): - """ - Test the ensure_image_exists function when the image already exists. - """ - from app.endpoints.simulation import ensure_image_exists - client = docker.from_env() +def test_ensure_image_exists_image_missing_pulls(): + """Test ensure_image_exists pulls when image is missing (mocked).""" + from app.endpoints.simulation import ensure_image_exists - # Ensure the image exists - client.images.pull("kushmakkapati/ardupilot_sitl") + fake_client = Mock() + fake_client.images.get.side_effect = ImageNotFound("missing") - # Call the function - result = ensure_image_exists(client, "kushmakkapati/ardupilot_sitl") + with patch("app.endpoints.simulation.socketio.emit") as emit_mock: + result = ensure_image_exists(fake_client, "kushmakkapati/ardupilot_sitl") - # Verify the function does not attempt to pull the image again assert result is True + fake_client.images.pull.assert_called_once_with("kushmakkapati/ardupilot_sitl") + + emitted_events = [call.args[0] for call in emit_mock.call_args_list if call.args] + assert "simulation_loading" in emitted_events def test_ensure_image_exists_pull_fails_emits_error(): @@ -280,7 +291,7 @@ def test_container_already_running(): """ from app.endpoints.simulation import container_already_running - client = docker.from_env() + client = get_docker_client_or_skip() # Ensure no container is running cleanup_container() @@ -358,13 +369,12 @@ def test_get_docker_client(): @falcon_test() def test_wait_for_container_connection_msg(socketio_client: SocketIOTestClient): - """ - Test the wait_for_container_connection_msg function directly. - """ + """Test the wait_for_container_connection_msg function directly.""" from app.endpoints.simulation import wait_for_container_connection_msg - # Start a container manually + client = get_docker_client_or_skip() cleanup_container() + # Start a container manually container = client.containers.run( "kushmakkapati/ardupilot_sitl", name=CONTAINER_NAME, From 1b4d101cf60a3b87db30eb60b858d8dc5eda0ca7 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:43:42 +0000 Subject: [PATCH 066/109] Just a couple more copilot comments --- radio/app/endpoints/simulation.py | 24 ++++++++++++--- radio/tests/test_simulation.py | 50 ++++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 506ff6775..ba0f488cf 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -72,6 +72,15 @@ def ensure_image_exists(client, image_name) -> bool: }, ) return False + except DockerException: + socketio.emit( + "simulation_result", + { + "success": False, + "message": "Unknown error getting simulation image", + }, + ) + return False def build_command(data): @@ -202,11 +211,21 @@ def start_docker_simulation(data) -> None: if host_port is None: emit_error_message("Host port is required") return + try: + host_port = int(host_port) + except (TypeError, ValueError): + emit_error_message("Host port must be an integer") + return if not (1025 <= host_port <= 65535): emit_error_message("Host port must be between 1025 and 65535") return container_port = data.get("containerPort", 5760) # default internal port + try: + container_port = int(container_port) + except (TypeError, ValueError): + emit_error_message("Container port must be an integer") + return if not (1 <= container_port <= 65535): emit_error_message("Container port must be between 1 and 65535") return @@ -260,10 +279,7 @@ def stop_docker_simulation() -> None: """ client = get_docker_client() if client is None: - socketio.emit( - "simulation_result", - {"success": False, "message": "Unable to connect to Docker"}, - ) + emit_error_message("Unable to connect to Docker") return try: diff --git a/radio/tests/test_simulation.py b/radio/tests/test_simulation.py index bc08d8658..c4efa3a5e 100644 --- a/radio/tests/test_simulation.py +++ b/radio/tests/test_simulation.py @@ -61,16 +61,24 @@ def test_start_docker_simulation_success(socketio_client: SocketIOTestClient): start_time = time.time() received_messages = [] + simulation_result = None while time.time() - start_time < CONTAINER_START_WAIT_TIME: - received_messages = socketio_client.get_received() - if received_messages: - break + new_messages = socketio_client.get_received() + if new_messages: + received_messages.extend(new_messages) + for message in new_messages: + if message.get("name") == "simulation_result": + simulation_result = message + break + if simulation_result is not None: + break time.sleep(0.1) # Avoid busy waiting - assert received_messages, "No messages were received after emitting the event. Check if the event handler is working." - - # Verify the last received message - result = received_messages[-1] + assert ( + simulation_result is not None + ), "Timed out waiting for 'simulation_result' event from simulation." + # Verify the simulation result message + result = simulation_result assert result["name"] == "simulation_result" assert result["args"][0]["success"] is True @@ -92,16 +100,24 @@ def test_start_docker_simulation_with_connect(socketio_client: SocketIOTestClien start_time = time.time() received_messages = [] + simulation_result = None while time.time() - start_time < CONTAINER_START_WAIT_TIME: - received_messages = socketio_client.get_received() - if received_messages: - break + new_messages = socketio_client.get_received() + if new_messages: + received_messages.extend(new_messages) + for message in new_messages: + if message.get("name") == "simulation_result": + simulation_result = message + break + if simulation_result is not None: + break time.sleep(0.1) # Avoid busy waiting - assert received_messages, "No messages were received after emitting the event. Check if the event handler is working." - - # Verify the last received message - result = received_messages[-1] + assert ( + simulation_result is not None + ), "Timed out waiting for 'simulation_result' event from simulation." + # Verify the simulation result message + result = simulation_result assert result["name"] == "simulation_result" assert result["args"][0]["success"] is True assert result["args"][0]["connect"] is True, "Connect flag not set correctly" @@ -128,7 +144,13 @@ def test_container_already_running_error_handling(socketio_client: SocketIOTestC # Wait for it to be running container.reload() + start_time = time.time() while container.status != "running": + if time.time() - start_time > CONTAINER_START_WAIT_TIME: + pytest.skip( + f"Container '{CONTAINER_NAME}' did not reach 'running' state " + f"within {CONTAINER_START_WAIT_TIME} seconds." + ) time.sleep(0.5) container.reload() From 2db66e9710f35266ffce5059c2c45ed796e0fd5b Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:10:14 +0000 Subject: [PATCH 067/109] Minor copilot feedback --- gcs/src/redux/middleware/socketMiddleware.js | 2 +- radio/app/endpoints/simulation.py | 31 ++++++++------------ 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 8f7d391da..2abad5f9d 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -440,7 +440,7 @@ const socketMiddleware = (store) => { msg.title, msg.message, ) - } else { + } else if (simulationLoadingId != null) { closeLoadingNotification( simulationLoadingId, msg.title, diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index ba0f488cf..c12a1ad6c 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -1,5 +1,5 @@ import time -from app import socketio +from app import logger, socketio import docker from docker.errors import DockerException, NotFound, ImageNotFound, APIError @@ -64,22 +64,10 @@ def ensure_image_exists(client, image_name) -> bool: "message": "Error downloading simulation image", }, ) - socketio.emit( - "simulation_result", - { - "success": False, - "message": "Error downloading simulation image", - }, - ) + emit_error_message("Error downloading simulation image") return False except DockerException: - socketio.emit( - "simulation_result", - { - "success": False, - "message": "Unknown error getting simulation image", - }, - ) + emit_error_message("Unknown error getting simulation image") return False @@ -95,10 +83,16 @@ def build_command(data): """ cmd = [] - if "vehicleType" in data: - cmd.append(f"VEHICLE={data['vehicleType']}") + ALLOWED_VEHICLE_TYPES = {"ArduCopter", "ArduPlane"} + + vehicle = data.get("vehicleType") + if vehicle: + if vehicle in ALLOWED_VEHICLE_TYPES: + cmd.append(f"VEHICLE={vehicle}") + else: + logger.debug("Ignoring unsupported vehicleType: %s", vehicle) - return cmd + return cmd if cmd else None # Docker start handles None better than empty lists def container_already_running(client, container_name) -> bool: @@ -108,6 +102,7 @@ def container_already_running(client, container_name) -> bool: """ try: existing = client.containers.get(container_name) + existing.reload() if existing.status == "running": socketio.emit( "simulation_result", From 5c20d8561e0ae97af01cf15ba57b270fd6a3357e Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Sat, 7 Feb 2026 22:23:02 +0000 Subject: [PATCH 068/109] Even more copilot feedback --- gcs/src/redux/middleware/socketMiddleware.js | 8 +++++++- radio/app/endpoints/simulation.py | 14 ++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 2abad5f9d..6ac2418f8 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -35,6 +35,7 @@ import { showLoadingNotification, showSuccessNotification, showWarningNotification, + redColor, } from "../../helpers/notification.js" import SocketFactory from "../../helpers/socket" import { @@ -434,7 +435,11 @@ const socketMiddleware = (store) => { socket.socket.on(SocketEvents.onSimulationLoading, (msg) => { if (msg.loading) { if (simulationLoadingId != null) { - closeLoadingNotification(simulationLoadingId) + closeLoadingNotification( + simulationLoadingId, + "Cancelled", + "Overwritten by new loading notification", + ) } simulationLoadingId = showLoadingNotification( msg.title, @@ -445,6 +450,7 @@ const socketMiddleware = (store) => { simulationLoadingId, msg.title, msg.message, + msg.success === false ? { color: redColor } : {}, ) simulationLoadingId = null } diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index c12a1ad6c..0dba0be7a 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -1,3 +1,4 @@ +import os import time from app import logger, socketio import docker @@ -5,7 +6,7 @@ CONTAINER_NAME = "fgcs_ardupilot_sitl" IMAGE_NAME = "kushmakkapati/ardupilot_sitl" -CONTAINER_START_TIMEOUT = 60 +CONTAINER_START_TIMEOUT = int(os.getenv("CONTAINER_START_TIMEOUT", 60)) def get_docker_client(): @@ -13,7 +14,9 @@ def get_docker_client(): Returns a Docker client if available, otherwise None. """ try: - return docker.from_env() + client = docker.from_env() + client.ping() # verify reachable + return client except DockerException: return None @@ -50,6 +53,7 @@ def ensure_image_exists(client, image_name) -> bool: "simulation_loading", { "loading": False, + "success": True, "title": "Downloaded Docker Image", "message": "Simulation image successfully downloaded", }, @@ -60,6 +64,7 @@ def ensure_image_exists(client, image_name) -> bool: "simulation_loading", { "loading": False, + "success": False, "title": "Docker Exception", "message": "Error downloading simulation image", }, @@ -137,12 +142,13 @@ def wait_for_container_connection_msg( Args: container: The container to wait for. connect: If the drone should attempt to connect on successful container start. + port: The host port to connect to. timeout: The amount of time to wait before timing out. """ start_time = time.time() line_found = False buffer = "" - poll_interval_s = 0.2 + poll_interval_s = 0.5 try: processed_len = 0 @@ -303,7 +309,7 @@ def emit_error_message(message): Emits the given message on "simulation_result" alongside false for success and running. Args: - message: The message to be included in the emit + message: The message to be included in the emit. """ socketio.emit( "simulation_result", From 18072a95143ffaff2c2d81647034d9a57c9eebbe Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:26:04 +0000 Subject: [PATCH 069/109] Rewrite waiting function to use since and tail --- radio/app/endpoints/simulation.py | 92 ++++++++++++++++--------------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 0dba0be7a..5aa4d11ed 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -19,6 +19,9 @@ def get_docker_client(): return client except DockerException: return None + except Exception: + logger.error("Unexpected error when creating and pinging Docker client") + return None def ensure_image_exists(client, image_name) -> bool: @@ -135,9 +138,9 @@ def wait_for_container_connection_msg( container, connect, port, timeout=CONTAINER_START_TIMEOUT ): """ - Waits to determine whether the container starts successfully by monitoring its logs - for the message "YOU CAN NOW CONNECT". During streaming the logs spaces are stripped - so actually searches for the string "YOUCANNOWCONNECT". + Waits for the container to emit "YOU CAN NOW CONNECT" in its logs. + Uses Docker's 'since' and 'tail' options for efficient polling. + Emits a simulation_result event on success or failure. Args: container: The container to wait for. @@ -145,59 +148,62 @@ def wait_for_container_connection_msg( port: The host port to connect to. timeout: The amount of time to wait before timing out. """ + POLL_INTERVAL_S = 0.5 + MAX_BUFFER_CHARS = 8192 + start_time = time.time() - line_found = False + deadline = start_time + timeout + since = int(start_time) - 1 # Small overlap to avoid missing boundary logs + buffer = "" - poll_interval_s = 0.5 + line_found = False + failure_reason = "Simulation failed to start in time" - try: - processed_len = 0 + while time.time() < deadline: + try: + container.reload() + except DockerException: + logger.exception("Docker error reloading container while awaiting start") + failure_reason = "Docker error while awaiting connection message" + break - while True: - if time.time() - start_time > timeout: - break + if getattr(container, "status", None) == "exited": + failure_reason = "Simulation container exited before it became ready" + break - try: - container.reload() - except DockerException: - break + try: + # Only fetch recent lines + logs_bytes = container.logs(stream=False, since=since, tail=200) + except DockerException: + logger.exception("Docker error reading container logs while awaiting start") + failure_reason = "Docker error while awaiting connection message" + break - try: - logs_bytes = container.logs(stream=False) - except DockerException: - break + # Change since to now with a little overlap to avoid missing logs + since = max(since, int(time.time()) - 1) - # Process the container logs - logs_text = logs_bytes.decode(errors="ignore") - if processed_len < len(logs_text): - new_text = logs_text[processed_len:] - processed_len = len(logs_text) - buffer += new_text.strip() + new_text = logs_bytes.decode(errors="ignore") + if new_text: + buffer += new_text + if len(buffer) > MAX_BUFFER_CHARS: + buffer = buffer[-MAX_BUFFER_CHARS:] if "YOUCANNOWCONNECT" in buffer.replace(" ", ""): line_found = True break - if getattr(container, "status", None) == "exited": - break - - time.sleep(poll_interval_s) - - socketio.emit( - "simulation_result", - { - "success": line_found, - "running": line_found, - "connect": connect and line_found, - "port": port, - "message": "Simulation started" - if line_found - else "Simulation failed to start in time", - }, - ) + time.sleep(POLL_INTERVAL_S) - except DockerException: - emit_error_message("Docker error while awaiting connection message") + socketio.emit( + "simulation_result", + { + "success": line_found, + "running": line_found, + "connect": bool(connect) and line_found, + "port": port, + "message": "Simulation started" if line_found else failure_reason, + }, + ) @socketio.on("start_docker_simulation") From 12e13c17f08322433998d646c3079e0ea0db719e Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:51:41 +0000 Subject: [PATCH 070/109] Frontend changes --- gcs/src/components/toolbar/simulationModal.jsx | 1 + gcs/src/redux/middleware/socketMiddleware.js | 1 + 2 files changed, 2 insertions(+) diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx index 12989f212..8612f3acc 100644 --- a/gcs/src/components/toolbar/simulationModal.jsx +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -133,6 +133,7 @@ export default function SimulationModal() { ) }} loading={simulationStatus === SimulationStatus.Starting} + disabled={!connectedToSocket && !isSimulationRunning} > {isSimulationRunning ? "Stop Simulation" : "Start Simulation"} diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 6ac2418f8..90f0ce924 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -471,6 +471,7 @@ const socketMiddleware = (store) => { if (msg.connect) { const storeState = store.getState() + store.dispatch(setConnecting(true)) store.dispatch( emitConnectToDrone({ port: `tcp:127.0.0.1:${msg.port}`, From 3293b6c47aa63a9456911e291f489d242078031f Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:07:32 +0000 Subject: [PATCH 071/109] Get rid of all the tests --- radio/tests/test_simulation.py | 552 --------------------------------- 1 file changed, 552 deletions(-) delete mode 100644 radio/tests/test_simulation.py diff --git a/radio/tests/test_simulation.py b/radio/tests/test_simulation.py deleted file mode 100644 index c4efa3a5e..000000000 --- a/radio/tests/test_simulation.py +++ /dev/null @@ -1,552 +0,0 @@ -from flask_socketio.test_client import SocketIOTestClient -from unittest.mock import Mock, MagicMock, patch -import time -import docker -from docker.errors import NotFound, DockerException, ImageNotFound -import pytest -from . import falcon_test - -CONTAINER_NAME = "fgcs_ardupilot_sitl" -CLEANUP_CONTAINER_TRIES = 30 -CONTAINER_START_WAIT_TIME = 60 - - -def get_docker_client_or_skip(): - """ - Return a docker client or skip the current test if Docker is unavailable. - """ - try: - c = docker.from_env() - # ping ensures daemon is reachable - c.ping() - return c - except Exception as e: - pytest.skip(f"Docker not available: {e}") - - -def cleanup_container(): - """ - Helper function to remove the test container if it exists. - """ - client = get_docker_client_or_skip() - try: - container = client.containers.get(CONTAINER_NAME) - container.remove(force=True) - except NotFound: - return - - # wait until Docker confirms it is gone - for _ in range(CLEANUP_CONTAINER_TRIES): - try: - client.containers.get(CONTAINER_NAME) - time.sleep(0.1) - except NotFound: - return - - raise RuntimeError("Container did not clean up in time") - - -@falcon_test() -def test_start_docker_simulation_success(socketio_client: SocketIOTestClient): - """ - Test successfully starting the simulation using Docker. - """ - get_docker_client_or_skip() - cleanup_container() - - # Emit the event - socketio_client.emit("start_docker_simulation", {"hostPort": 5763}) - - # Synchronize: Wait for the background task to emit the message - start_time = time.time() - received_messages = [] - - simulation_result = None - while time.time() - start_time < CONTAINER_START_WAIT_TIME: - new_messages = socketio_client.get_received() - if new_messages: - received_messages.extend(new_messages) - for message in new_messages: - if message.get("name") == "simulation_result": - simulation_result = message - break - if simulation_result is not None: - break - time.sleep(0.1) # Avoid busy waiting - assert received_messages, "No messages were received after emitting the event. Check if the event handler is working." - assert ( - simulation_result is not None - ), "Timed out waiting for 'simulation_result' event from simulation." - # Verify the simulation result message - result = simulation_result - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is True - - cleanup_container() - - -@falcon_test() -def test_start_docker_simulation_with_connect(socketio_client: SocketIOTestClient): - """ - Test starting the simulation with connect=True. - """ - get_docker_client_or_skip() - cleanup_container() - - # Emit the event - socketio_client.emit("start_docker_simulation", {"hostPort": 5763, "connect": True}) - - # Synchronize: Wait for the background task to emit the message - start_time = time.time() - received_messages = [] - - simulation_result = None - while time.time() - start_time < CONTAINER_START_WAIT_TIME: - new_messages = socketio_client.get_received() - if new_messages: - received_messages.extend(new_messages) - for message in new_messages: - if message.get("name") == "simulation_result": - simulation_result = message - break - if simulation_result is not None: - break - time.sleep(0.1) # Avoid busy waiting - assert received_messages, "No messages were received after emitting the event. Check if the event handler is working." - assert ( - simulation_result is not None - ), "Timed out waiting for 'simulation_result' event from simulation." - # Verify the simulation result message - result = simulation_result - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is True - assert result["args"][0]["connect"] is True, "Connect flag not set correctly" - - cleanup_container() - - -@falcon_test() -def test_container_already_running_error_handling(socketio_client: SocketIOTestClient): - """ - Test behavior when the container is already running. - """ - client = get_docker_client_or_skip() - cleanup_container() - - # Start the container manually - container = client.containers.run( - "kushmakkapati/ardupilot_sitl", - name=CONTAINER_NAME, - ports={5763: 5763}, - detach=True, - tty=True, - ) - - # Wait for it to be running - container.reload() - start_time = time.time() - while container.status != "running": - if time.time() - start_time > CONTAINER_START_WAIT_TIME: - pytest.skip( - f"Container '{CONTAINER_NAME}' did not reach 'running' state " - f"within {CONTAINER_START_WAIT_TIME} seconds." - ) - time.sleep(0.5) - container.reload() - - socketio_client.emit("start_docker_simulation", {"hostPort": 5763}) - result = socketio_client.get_received()[-1] - - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is False - assert "Simulation already running" in result["args"][0]["message"] - - cleanup_container() - - -@falcon_test() -def test_stop_docker_simulation(socketio_client: SocketIOTestClient): - """ - Test stopping the Docker simulation. - """ - client = get_docker_client_or_skip() - cleanup_container() - - # Start the container manually - client.containers.run( - "kushmakkapati/ardupilot_sitl", - name=CONTAINER_NAME, - ports={5763: 5763}, - detach=True, - tty=True, - ) - - socketio_client.emit("stop_docker_simulation") - result = socketio_client.get_received()[-1] - - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is True - assert "Simulation stopped" in result["args"][0]["message"] - - cleanup_container() - - -@falcon_test() -def test_stop_docker_simulation_no_container(socketio_client: SocketIOTestClient): - """ - Test stopping the Docker simulation when no container is running. - """ - cleanup_container() - - # Emit the stop event without any running container - socketio_client.emit("stop_docker_simulation") - - # Synchronize: Wait for the background task to emit the message - timeout = 10 # seconds - start_time = time.time() - received_messages = [] - - while time.time() - start_time < timeout: - received_messages = socketio_client.get_received() - if received_messages: - break - time.sleep(0.1) # Avoid busy waiting - - assert received_messages, "No messages were received after emitting the event. Check if the event handler is working." - - # Verify the last received message - result = received_messages[-1] - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is False - assert "Simulation could not be found" in result["args"][0]["message"] - - -def test_build_command(): - """ - Test the build_command function directly. - """ - from app.endpoints.simulation import build_command - - # Input data for the command - data = { - "vehicleType": "ArduCopter", - } - - # Call the function - cmd = build_command(data) - - # Verify the command output - assert "VEHICLE=ArduCopter" in cmd - - -def test_ensure_image_exists(): - """Test ensure_image_exists without touching real Docker images (safe for CI).""" - from app.endpoints.simulation import ensure_image_exists - - fake_client = Mock() - # Image already present - fake_client.images.get.return_value = object() - - with patch("app.endpoints.simulation.socketio.emit") as emit_mock: - result = ensure_image_exists(fake_client, "kushmakkapati/ardupilot_sitl") - - assert result is True - fake_client.images.pull.assert_not_called() - - # No loading/error emits expected when image exists - emitted_events = [call.args[0] for call in emit_mock.call_args_list if call.args] - assert "simulation_result" not in emitted_events - - -def test_ensure_image_exists_image_missing_pulls(): - """Test ensure_image_exists pulls when image is missing (mocked).""" - from app.endpoints.simulation import ensure_image_exists - - fake_client = Mock() - fake_client.images.get.side_effect = ImageNotFound("missing") - - with patch("app.endpoints.simulation.socketio.emit") as emit_mock: - result = ensure_image_exists(fake_client, "kushmakkapati/ardupilot_sitl") - - assert result is True - fake_client.images.pull.assert_called_once_with("kushmakkapati/ardupilot_sitl") - - emitted_events = [call.args[0] for call in emit_mock.call_args_list if call.args] - assert "simulation_loading" in emitted_events - - -def test_ensure_image_exists_pull_fails_emits_error(): - """Ensure ensure_image_exists handles DockerException during pull and emits error feedback.""" - from app.endpoints.simulation import ensure_image_exists - - # Build a fake docker client where the image is "missing" and pull fails - fake_client = Mock() - fake_client.images.get.side_effect = ImageNotFound("missing") - fake_client.images.pull.side_effect = DockerException("pull failed") - - with patch("app.endpoints.simulation.socketio.emit") as emit_mock: - result = ensure_image_exists(fake_client, "kushmakkapati/ardupilot_sitl") - - assert result is False - - # Should emit loading True (download start), then loading False, then simulation_result error - emitted_events = [call.args[0] for call in emit_mock.call_args_list if call.args] - assert "simulation_loading" in emitted_events - assert "simulation_result" in emitted_events - - simulation_result_calls = [ - call - for call in emit_mock.call_args_list - if call.args and call.args[0] == "simulation_result" - ] - assert simulation_result_calls, "Expected a simulation_result emit on pull failure" - assert simulation_result_calls[-1].args[1]["success"] is False - assert ( - "Error downloading simulation image" - in simulation_result_calls[-1].args[1]["message"] - ) - - -def test_container_already_running(): - """ - Test the container_already_running function directly. - """ - from app.endpoints.simulation import container_already_running - - client = get_docker_client_or_skip() - - # Ensure no container is running - cleanup_container() - - # Start a container manually - client.containers.run( - "kushmakkapati/ardupilot_sitl", - name=CONTAINER_NAME, - detach=True, - tty=True, - ) - - # Verify the function detects the running container - assert container_already_running(client, CONTAINER_NAME) is True - - # Stop and remove the container - cleanup_container() - - -@falcon_test() -def test_emit_error_message(socketio_client: SocketIOTestClient): - """ - Test the emit_error_message function directly. - """ - from app.endpoints.simulation import emit_error_message - - # Emit an error message - emit_error_message("Test error message") - - # Verify the emitted message - result = socketio_client.get_received()[-1] - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is False - assert result["args"][0]["running"] is False - assert result["args"][0]["message"] == "Test error message" - - -@falcon_test() -def test_emit_error_message_edge_cases(socketio_client: SocketIOTestClient): - """ - Test the emit_error_message function with edge cases. - """ - from app.endpoints.simulation import emit_error_message - - # Emit an empty message - emit_error_message("") - result = socketio_client.get_received()[-1] - assert result["name"] == "simulation_result" - assert result["args"][0]["message"] == "" - - # Emit a None message - emit_error_message(None) - result = socketio_client.get_received()[-1] - assert result["name"] == "simulation_result" - assert result["args"][0]["message"] is None - - -def test_get_docker_client(): - """ - Test the get_docker_client function directly. - """ - from app.endpoints.simulation import get_docker_client - - # Call the function - client = get_docker_client() - - # Verify the client is valid or None - if client is not None: - assert hasattr( - client, "containers" - ), "Docker client does not have containers attribute" - else: - assert client is None, "Expected None when Docker is unavailable" - - -@falcon_test() -def test_wait_for_container_connection_msg(socketio_client: SocketIOTestClient): - """Test the wait_for_container_connection_msg function directly.""" - from app.endpoints.simulation import wait_for_container_connection_msg - - client = get_docker_client_or_skip() - cleanup_container() - # Start a container manually - container = client.containers.run( - "kushmakkapati/ardupilot_sitl", - name=CONTAINER_NAME, - detach=True, - tty=True, - ) - - # Call the function to wait for the container to start - wait_for_container_connection_msg(container, connect=False, port=5760, timeout=5) - - # Verify the emitted message - result = socketio_client.get_received()[-1] - assert result["name"] == "simulation_result" - - # Verify the container logs were processed - result = client.containers.get(CONTAINER_NAME) - assert result.status in ( - "running", - "exited", - ), "Container did not reach expected status" - - cleanup_container() - - -@falcon_test() -def test_start_docker_simulation_validation_failures( - socketio_client: SocketIOTestClient -): - """Covers the main input validation failure routes for start_docker_simulation.""" - # Missing hostPort - socketio_client.emit("start_docker_simulation", {}) - result = socketio_client.get_received()[-1] - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is False - assert "Host port is required" in result["args"][0]["message"] - - # hostPort too low - socketio_client.emit("start_docker_simulation", {"hostPort": 1024}) - result = socketio_client.get_received()[-1] - assert result["args"][0]["success"] is False - assert "Host port must be between 1025 and 65535" in result["args"][0]["message"] - - # hostPort too high - socketio_client.emit("start_docker_simulation", {"hostPort": 70000}) - result = socketio_client.get_received()[-1] - assert result["args"][0]["success"] is False - assert "Host port must be between 1025 and 65535" in result["args"][0]["message"] - - # containerPort invalid - socketio_client.emit( - "start_docker_simulation", {"hostPort": 6000, "containerPort": 70000} - ) - result = socketio_client.get_received()[-1] - assert result["args"][0]["success"] is False - assert "Container port must be between 1 and 65535" in result["args"][0]["message"] - - -@falcon_test() -def test_start_docker_simulation_run_failures(socketio_client: SocketIOTestClient): - """Covers run() exception routes: APIError and generic DockerException.""" - from unittest.mock import MagicMock - - from docker.errors import APIError as DockerAPIError - - fake_client = MagicMock() - fake_client.images.get.return_value = object() # image exists - fake_client.containers.get.side_effect = NotFound( - "missing" - ) # no existing container - - # 1) APIError is intentionally sanitized for UI display - fake_client.containers.run.side_effect = DockerAPIError( - "boom", explanation="bad params" - ) - - with patch("app.endpoints.simulation.get_docker_client", return_value=fake_client): - socketio_client.emit("start_docker_simulation", {"hostPort": 6000}) - result = socketio_client.get_received()[-1] - - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is False - assert ( - result["args"][0]["message"] == "Simulation failed to start: Docker API error" - ) - - # 2) generic DockerException - fake_client.containers.run.side_effect = DockerException("nope") - - with patch("app.endpoints.simulation.get_docker_client", return_value=fake_client): - socketio_client.emit("start_docker_simulation", {"hostPort": 6000}) - result = socketio_client.get_received()[-1] - - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is False - assert ( - result["args"][0]["message"] == "Simulation failed to start: Docker exception" - ) - - -@falcon_test() -def test_stop_docker_simulation_failure_routes(socketio_client: SocketIOTestClient): - """Covers stop_docker_simulation Docker-unavailable and stop() exception routes.""" - # Docker unavailable - with patch("app.endpoints.simulation.get_docker_client", return_value=None): - socketio_client.emit("stop_docker_simulation") - result = socketio_client.get_received()[-1] - - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is False - assert "Unable to connect to Docker" in result["args"][0]["message"] - - # container.stop raises DockerException - fake_container = MagicMock() - fake_container.stop.side_effect = DockerException("stop failed") - fake_client = MagicMock() - fake_client.containers.get.return_value = fake_container - - with patch("app.endpoints.simulation.get_docker_client", return_value=fake_client): - socketio_client.emit("stop_docker_simulation") - result = socketio_client.get_received()[-1] - - assert result["name"] == "simulation_result" - assert result["args"][0]["success"] is False - assert "Docker error while stopping simulation" in result["args"][0]["message"] - - -def test_container_already_running_not_running_container_removed(): - """Covers container_already_running branch where container exists but is not running.""" - from app.endpoints.simulation import container_already_running - - fake_existing = MagicMock() - fake_existing.status = "exited" - - fake_client = MagicMock() - fake_client.containers.get.return_value = fake_existing - - # Should remove and return False - assert container_already_running(fake_client, CONTAINER_NAME) is False - fake_existing.remove.assert_called_once() - - -def test_container_already_running_remove_race_notfound(): - """Covers NotFound race when removing a non-running container.""" - from app.endpoints.simulation import container_already_running - - fake_existing = MagicMock() - fake_existing.status = "exited" - fake_existing.remove.side_effect = NotFound("already removed") - - fake_client = MagicMock() - fake_client.containers.get.return_value = fake_existing - - assert container_already_running(fake_client, CONTAINER_NAME) is False From 4cb4e337cd1765c62dfb42016828b9b6c0ba69d9 Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:26:28 +0000 Subject: [PATCH 072/109] Catch bubble up exception --- radio/app/endpoints/simulation.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 5aa4d11ed..7f863731a 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -5,7 +5,7 @@ from docker.errors import DockerException, NotFound, ImageNotFound, APIError CONTAINER_NAME = "fgcs_ardupilot_sitl" -IMAGE_NAME = "kushmakkapati/ardupilot_sitl" +IMAGE_NAME = "kushmakkapati/ardupilot_sitl:latest" CONTAINER_START_TIMEOUT = int(os.getenv("CONTAINER_START_TIMEOUT", 60)) @@ -132,6 +132,9 @@ def container_already_running(client, container_name) -> bool: except NotFound: return False + except DockerException: + logger.exception("Unexpected Docker exception in container_already_running") + return False def wait_for_container_connection_msg( From 4866e7e17f1da09b1f9c9149da7bdae701857f5e Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:53:41 +0000 Subject: [PATCH 073/109] Copy --- gcs/src/components/toolbar/simulationModal.jsx | 4 ++-- radio/app/endpoints/simulation.py | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx index 8612f3acc..591196856 100644 --- a/gcs/src/components/toolbar/simulationModal.jsx +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -99,7 +99,7 @@ export default function SimulationModal() {
dispatch( @@ -135,7 +135,7 @@ export default function SimulationModal() { loading={simulationStatus === SimulationStatus.Starting} disabled={!connectedToSocket && !isSimulationRunning} > - {isSimulationRunning ? "Stop Simulation" : "Start Simulation"} + {isSimulationRunning ? "Stop Simulator" : "Start Simulator"} diff --git a/radio/app/endpoints/simulation.py b/radio/app/endpoints/simulation.py index 7f863731a..c6d1b1853 100644 --- a/radio/app/endpoints/simulation.py +++ b/radio/app/endpoints/simulation.py @@ -20,7 +20,7 @@ def get_docker_client(): except DockerException: return None except Exception: - logger.error("Unexpected error when creating and pinging Docker client") + logger.exception("Unexpected error when creating and pinging Docker client") return None @@ -156,7 +156,6 @@ def wait_for_container_connection_msg( start_time = time.time() deadline = start_time + timeout - since = int(start_time) - 1 # Small overlap to avoid missing boundary logs buffer = "" line_found = False @@ -176,15 +175,12 @@ def wait_for_container_connection_msg( try: # Only fetch recent lines - logs_bytes = container.logs(stream=False, since=since, tail=200) + logs_bytes = container.logs(stream=False, tail=200) except DockerException: logger.exception("Docker error reading container logs while awaiting start") failure_reason = "Docker error while awaiting connection message" break - # Change since to now with a little overlap to avoid missing logs - since = max(since, int(time.time()) - 1) - new_text = logs_bytes.decode(errors="ignore") if new_text: buffer += new_text From 92edfe271199d03837605fa9dd763c7b8ea00fdd Mon Sep 17 00:00:00 2001 From: Ryan Turner <100864486+Turnlings@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:23:00 +0000 Subject: [PATCH 074/109] Option for multiple port mappings --- .../components/toolbar/simulationModal.jsx | 87 ++++++++++++++----- gcs/src/redux/middleware/emitters.js | 2 +- gcs/src/redux/slices/droneConnectionSlice.js | 16 +++- radio/app/endpoints/simulation.py | 70 ++++++++++----- 4 files changed, 127 insertions(+), 48 deletions(-) diff --git a/gcs/src/components/toolbar/simulationModal.jsx b/gcs/src/components/toolbar/simulationModal.jsx index 591196856..66e3e2e01 100644 --- a/gcs/src/components/toolbar/simulationModal.jsx +++ b/gcs/src/components/toolbar/simulationModal.jsx @@ -8,8 +8,9 @@ import { Checkbox, Tooltip, Group, + ActionIcon, } from "@mantine/core" -import { IconInfoCircle } from "@tabler/icons-react" +import { IconInfoCircle, IconX } from "@tabler/icons-react" import { setSimulationModalOpened, setSimulationParam, @@ -20,6 +21,9 @@ import { emitStartSimulation, emitStopSimulation, selectSimulationStatus, + addSimulationParamPort, + removeSimulationParamPort, + updateSimulationParamPort, } from "../../redux/slices/droneConnectionSlice" import { selectIsConnectedToSocket } from "../../redux/slices/socketSlice" import { showNotification } from "../../helpers/notification" @@ -56,28 +60,65 @@ export default function SimulationModal() { }, }} > - - - dispatch(setSimulationParam({ key: "hostPort", value: val })) - } - /> - - dispatch(setSimulationParam({ key: "containerPort", value: val })) - } - /> - + {simulationParams.ports.map((port, index) => ( + + + dispatch( + updateSimulationParamPort({ + index, + key: "hostPort", + value: val, + }), + ) + } + /> + + + dispatch( + updateSimulationParamPort({ + index, + key: "containerPort", + value: val, + }), + ) + } + /> + + {simulationParams.ports.length > 1 && ( + dispatch(removeSimulationParamPort(index))} + > + + + )} + + ))} + +