diff --git a/gcs/src/components/mapComponents/missionItems.jsx b/gcs/src/components/mapComponents/missionItems.jsx
index b0a5e212..e0e1899a 100644
--- a/gcs/src/components/mapComponents/missionItems.jsx
+++ b/gcs/src/components/mapComponents/missionItems.jsx
@@ -27,13 +27,15 @@ import MarkerPin from "./markerPin"
import MidpointInsertButton from "./midpointInsertButton"
// Tailwind styling
-import { midpoint, point } from "@turf/turf"
+import { circle, midpoint, point } from "@turf/turf"
+import { Layer, Source } from "react-map-gl"
import resolveConfig from "tailwindcss/resolveConfig"
import tailwindConfig from "../../../tailwind.config"
+import { selectWaypointRadius } from "../../redux/slices/paramsSlice"
const tailwindColors = resolveConfig(tailwindConfig).theme.colors
-export default function MissionItems({ missionItems }) {
+export default function MissionItems({ missionItems, waypointRadius = null }) {
const dispatch = useDispatch()
const currentPage = useSelector(selectCurrentPage)
const editable =
@@ -210,6 +212,36 @@ export default function MissionItems({ missionItems }) {
).geometry.coordinates
}
+ const waypointRadiusFromStore = useSelector(selectWaypointRadius)
+
+ const waypointRadiusMeters = useMemo(() => {
+ const valueToUse =
+ waypointRadius !== null && waypointRadius !== undefined
+ ? waypointRadius
+ : waypointRadiusFromStore
+ const parsedValue = Number(valueToUse)
+ const radiusMeters =
+ Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : 2
+ return radiusMeters
+ }, [waypointRadius, waypointRadiusFromStore])
+
+ const waypointRadiusGeoJson = useMemo(() => {
+ if (displayedMissionItems.length === 0) return null
+
+ return {
+ type: "FeatureCollection",
+ features: displayedMissionItems.map((item) =>
+ circle([intToCoord(item.y), intToCoord(item.x)], waypointRadiusMeters, {
+ steps: 64,
+ units: "meters",
+ properties: {
+ stroke: "#ffffff",
+ },
+ }),
+ ),
+ }
+ }, [displayedMissionItems, waypointRadiusMeters])
+
return (
<>
{/* Show mission item LABELS */}
@@ -228,6 +260,20 @@ export default function MissionItems({ missionItems }) {
)
})}
+ {waypointRadiusGeoJson && (
+
+
+
+ )}
+
{insertionMidpoints.map((midpointItem) => (
-
+
diff --git a/gcs/src/missions.jsx b/gcs/src/missions.jsx
index d2949741..aac749bd 100644
--- a/gcs/src/missions.jsx
+++ b/gcs/src/missions.jsx
@@ -6,6 +6,7 @@
import { useCallback, useEffect, useRef, useState } from "react"
// 3rd Party Imports
+import { useDebouncedCallback } from "@mantine/hooks"
import { ResizableBox } from "react-resizable"
import { v4 as uuidv4 } from "uuid"
@@ -67,6 +68,11 @@ import {
setMissionProgressModal,
setPlannedHomePosition,
} from "./redux/slices/missionSlice"
+import {
+ emitGetWaypointRadius,
+ emitSetWaypointRadius,
+ selectWaypointRadius,
+} from "./redux/slices/paramsSlice"
const tailwindColors = resolveConfig(tailwindConfig).theme.colors
const coordsFractionDigits = 7
@@ -108,6 +114,15 @@ export default function Missions() {
const unwrittenChanges = useSelector(selectUnwrittenChanges)
const missionProgressModalOpened = useSelector(selectMissionProgressModal)
const missionProgressModalData = useSelector(selectMissionProgressData)
+ const waypointRadius = useSelector(selectWaypointRadius)
+ const [waypointRadiusInput, setWaypointRadiusInput] = useState(
+ waypointRadius ?? 2,
+ )
+
+ const debouncedUpdateWaypointRadius = useDebouncedCallback((value) => {
+ if (!connected || isInvalidInputNumber(value)) return
+ dispatch(emitSetWaypointRadius(value))
+ }, 500)
// Need to keep a reference to the active tab to avoid stale closures
const activeTabRef = useRef(activeTab)
@@ -140,9 +155,16 @@ export default function Missions() {
}
}, [tabsListRef.current])
+ useEffect(() => {
+ if (waypointRadius !== null && waypointRadius !== undefined) {
+ setWaypointRadiusInput(waypointRadius)
+ }
+ }, [waypointRadius])
+
// Send some messages when file is loaded
useEffect(() => {
dispatch(emitGetTargetInfo())
+ dispatch(emitGetWaypointRadius())
}, [currentPage])
useEffect(() => {
@@ -540,6 +562,26 @@ export default function Missions() {
Mission statistics
+
+
+
+
+ {
+ setWaypointRadiusInput(val)
+ debouncedUpdateWaypointRadius(val)
+ }}
+ onBlur={() => {
+ if (isInvalidInputNumber(waypointRadiusInput)) {
+ setWaypointRadiusInput(waypointRadius ?? 2)
+ }
+ }}
+ min={1}
+ hideControls
+ />
+
@@ -552,6 +594,7 @@ export default function Missions() {
missionItems={missionItems}
fenceItems={fenceItems}
rallyItems={rallyItems}
+ waypointRadius={waypointRadiusInput}
onOpenElevationGraph={openElevationGraph}
/>
diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js
index 53e525d2..93762ec5 100644
--- a/gcs/src/redux/middleware/emitters.js
+++ b/gcs/src/redux/middleware/emitters.js
@@ -68,9 +68,11 @@ import {
import {
emitExportParamsToFile,
emitGetParams,
+ emitGetWaypointRadius,
emitRebootAutopilot,
emitRefreshParams,
emitSetMultipleParams,
+ emitSetWaypointRadius,
setParamsWriteProgressModalOpen,
} from "../slices/paramsSlice"
import {
@@ -547,6 +549,18 @@ export function handleEmitters(socket, store, action) {
store.dispatch(setReadFileProgress(null)) // Reset progress when starting new download
},
},
+ {
+ emitter: emitGetWaypointRadius,
+ callback: () => socket.socket.emit("get_waypoint_radius"),
+ },
+ {
+ emitter: emitSetWaypointRadius,
+ callback: () => {
+ socket.socket.emit("set_waypoint_radius", {
+ value: action.payload,
+ })
+ },
+ },
]
for (const { emitter, callback } of emitHandlers) {
diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js
index 8593e215..517e2627 100644
--- a/gcs/src/redux/middleware/socketMiddleware.js
+++ b/gcs/src/redux/middleware/socketMiddleware.js
@@ -131,6 +131,7 @@ import {
emitRefreshParams,
resetParamsWriteProgressData,
setAutoPilotRebootModalOpen,
+ setWaypointRadius,
setFetchingParam,
setFetchingVars,
setFetchingVarsProgress,
@@ -171,6 +172,8 @@ const DroneSpecificSocketEvents = Object.freeze({
onNavRepositionResult: "nav_reposition_result",
onGetLoiterRadiusResult: "nav_get_loiter_radius_result",
onSetLoiterRadiusResult: "nav_set_loiter_radius_result",
+ onGetWaypointRadiusResult: "get_waypoint_radius_result",
+ onSetWaypointRadiusResult: "set_waypoint_radius_result",
})
const ParamSpecificSocketEvents = Object.freeze({
@@ -1085,6 +1088,30 @@ const socketMiddleware = (store) => {
},
)
+ socket.socket.on(
+ DroneSpecificSocketEvents.onGetWaypointRadiusResult,
+ (msg) => {
+ if (msg.success) {
+ store.dispatch(setWaypointRadius(msg.data))
+ } else {
+ showErrorNotification(msg.message)
+ }
+ },
+ )
+
+ socket.socket.on(
+ DroneSpecificSocketEvents.onSetWaypointRadiusResult,
+ (msg) => {
+ if (msg.success) {
+ showSuccessNotification(msg.message)
+ store.dispatch(setWaypointRadius(msg.data.param_value))
+ store.dispatch(updateParamValue(msg.data))
+ } else {
+ showErrorNotification(msg.message)
+ }
+ },
+ )
+
/*
Missions
*/
diff --git a/gcs/src/redux/slices/paramsSlice.js b/gcs/src/redux/slices/paramsSlice.js
index fc3b12d3..b3608b7f 100644
--- a/gcs/src/redux/slices/paramsSlice.js
+++ b/gcs/src/redux/slices/paramsSlice.js
@@ -28,6 +28,7 @@ const paramsSlice = createSlice({
fetchParamsWarningModalOpen: false,
rebootPromptModalOpen: false,
pendingFetchAction: null,
+ waypointRadius: null,
},
reducers: {
setRebootData: (state, action) => {
@@ -133,6 +134,7 @@ const paramsSlice = createSlice({
state.rebootData = {}
state.searchValue = ""
state.rebootPromptModalOpen = false
+ state.waypointRadius = null
},
setHasFetchedOnce: (state, action) => {
state.hasFetchedOnce = action.payload
@@ -175,11 +177,17 @@ const paramsSlice = createSlice({
state.pendingFetchAction = action.payload
},
+ setWaypointRadius: (state, action) => {
+ state.waypointRadius = action.payload
+ },
+
// Emitters (empty objects to be captured in the middleware)
emitRebootAutopilot: () => {},
emitGetParams: () => {},
emitRefreshParams: () => {},
emitSetMultipleParams: () => {},
+ emitGetWaypointRadius: () => {},
+ emitSetWaypointRadius: () => {},
emitExportParamsToFile: () => {},
},
selectors: {
@@ -210,6 +218,7 @@ const paramsSlice = createSlice({
state.fetchParamsWarningModalOpen,
selectRebootPromptModalOpen: (state) => state.rebootPromptModalOpen,
selectPendingFetchAction: (state) => state.pendingFetchAction,
+ selectWaypointRadius: (state) => state.waypointRadius,
},
})
@@ -241,10 +250,13 @@ export const {
setFetchParamsWarningModalOpen,
setRebootPromptModalOpen,
setPendingFetchAction,
+ setWaypointRadius,
emitRebootAutopilot,
emitGetParams,
emitRefreshParams,
emitSetMultipleParams,
+ emitGetWaypointRadius,
+ emitSetWaypointRadius,
emitExportParamsToFile,
} = paramsSlice.actions
export const {
@@ -270,6 +282,7 @@ export const {
selectFetchParamsWarningModalOpen,
selectRebootPromptModalOpen,
selectPendingFetchAction,
+ selectWaypointRadius,
} = paramsSlice.selectors
export default paramsSlice
diff --git a/radio/app/controllers/navController.py b/radio/app/controllers/navController.py
index 1cfaa549..f5e2284b 100644
--- a/radio/app/controllers/navController.py
+++ b/radio/app/controllers/navController.py
@@ -5,7 +5,7 @@
from typing import TYPE_CHECKING
import serial
-from app.customTypes import Response
+from app.customTypes import Number, Response, VehicleType
from app.utils import commandAccepted, sendingCommandLock
from pymavlink import mavutil
@@ -379,3 +379,64 @@ def setLoiterRadius(self, radius: float) -> Response:
"success": False,
"message": f"Failed to set loiter radius set to {radius}m",
}
+
+ def _getWpRadiusParamName(self) -> str:
+ """
+ Determine the correct waypoint radius parameter name based on aircraft type and firmware version.
+ """
+ if self.drone.aircraft_type == VehicleType.FIXED_WING.value:
+ return "WP_RADIUS"
+
+ if self.drone.aircraft_type == VehicleType.MULTIROTOR.value:
+ version = getattr(self.drone, "flight_sw_version", None)
+ if not version or len(version) < 2 or version[0] != 4 or version[1] < 7:
+ return "WPNAV_RADIUS"
+ return "WP_RADIUS_M"
+
+ return "WP_RADIUS"
+
+ def getWpRadius(self) -> Response:
+ """
+ Get the waypoint radius of the drone from the cached parameters.
+
+ Returns:
+ Response: The response from the get waypoint radius command
+ """
+
+ wp_radius_data = self.drone.paramsController.getSingleParam(
+ self._getWpRadiusParamName()
+ )
+
+ if wp_radius_data.get("param_value") is None:
+ self.drone.logger.warning("Waypoint radius parameter not found in cache")
+ return {
+ "success": False,
+ "message": "Waypoint radius parameter not found in cache",
+ }
+
+ return {
+ "success": True,
+ "data": wp_radius_data.get("param_value", 2), # Default to 2m
+ }
+
+ def setWpRadius(self, value: Number) -> Response:
+ """
+ Set the waypoint radius of the drone.
+ """
+ param_name = self._getWpRadiusParamName()
+ param_set_success = self.drone.paramsController.setParam(
+ param_name, value, mavutil.mavlink.MAV_PARAM_TYPE_FLOAT
+ )
+
+ if param_set_success:
+ self.drone.logger.info(f"Waypoint radius set to {value}m")
+ return {
+ "success": True,
+ "message": f"Waypoint radius set to {value}m",
+ "data": {"param_id": param_name, "param_value": value},
+ }
+ else:
+ return {
+ "success": False,
+ "message": f"Failed to set waypoint radius to {value}m",
+ }
diff --git a/radio/app/endpoints/nav.py b/radio/app/endpoints/nav.py
index b4cc97b4..80f02e70 100644
--- a/radio/app/endpoints/nav.py
+++ b/radio/app/endpoints/nav.py
@@ -19,6 +19,10 @@ class LoiterRadiusDataType(TypedDict):
radius: float
+class WaypointRadiusDataType(TypedDict):
+ value: float
+
+
@socketio.on("get_home_position")
def getHomePosition() -> None:
"""
@@ -183,3 +187,57 @@ def setLoiterRadius(data: LoiterRadiusDataType) -> None:
result = droneStatus.drone.navController.setLoiterRadius(radius)
socketio.emit("nav_set_loiter_radius_result", result)
+
+
+@socketio.on("set_waypoint_radius")
+def setWaypointRadius(data: WaypointRadiusDataType) -> None:
+ """
+ Sets the waypoint radius parameter on the drone from the missions or dashboard page.
+ """
+ if droneStatus.state not in ["dashboard", "missions"]:
+ socketio.emit(
+ "params_error",
+ {
+ "message": "You must be on the dashboard or missions screen to set the waypoint radius."
+ },
+ )
+ logger.debug(f"Current state: {droneStatus.state}")
+ return
+
+ if not droneStatus.drone:
+ return notConnectedError(action="set waypoint radius")
+
+ value = data.get("value", None)
+ if value is None or not isinstance(value, (int, float)) or value <= 0:
+ socketio.emit(
+ "params_error",
+ {"message": f"Waypoint radius must be a positive number, got {value}."},
+ )
+ return
+
+ result = droneStatus.drone.navController.setWpRadius(value)
+
+ socketio.emit("set_waypoint_radius_result", result)
+
+
+@socketio.on("get_waypoint_radius")
+def getWaypointRadius() -> None:
+ """
+ Gets the waypoint radius of the drone, only works when the dashboard or missions page is loaded.
+ """
+ if droneStatus.state not in ["dashboard", "missions"]:
+ socketio.emit(
+ "params_error",
+ {
+ "message": "You must be on the dashboard or missions screen to get the waypoint radius."
+ },
+ )
+ logger.debug(f"Current state: {droneStatus.state}")
+ return
+
+ if not droneStatus.drone:
+ return notConnectedError(action="get waypoint radius")
+
+ result = droneStatus.drone.navController.getWpRadius()
+
+ socketio.emit("get_waypoint_radius_result", result)
diff --git a/radio/tests/test_nav.py b/radio/tests/test_nav.py
new file mode 100644
index 00000000..e0adff36
--- /dev/null
+++ b/radio/tests/test_nav.py
@@ -0,0 +1,60 @@
+import app.droneStatus as droneStatus
+import pytest
+from flask_socketio.test_client import SocketIOTestClient
+
+
+@pytest.fixture(autouse=True)
+def restore_state():
+ original_state = droneStatus.state
+ yield
+ droneStatus.state = original_state
+
+
+@pytest.fixture()
+def restore_wp_radius():
+ drone = droneStatus.drone
+ assert drone is not None
+ original = drone.navController.getWpRadius().get("data")
+ yield
+ if original is not None:
+ drone.navController.setWpRadius(original)
+
+
+def test_set_waypoint_radius_success(
+ socketio_client: SocketIOTestClient, restore_wp_radius
+):
+ drone = droneStatus.drone
+ assert drone is not None
+ droneStatus.state = "missions"
+ expected_param = drone.navController._getWpRadiusParamName()
+
+ socketio_client.emit("set_waypoint_radius", {"value": 5})
+ response = socketio_client.get_received()[0]
+
+ assert response["name"] == "set_waypoint_radius_result"
+ assert response["args"][0]["success"] is True
+ assert response["args"][0]["data"]["param_id"] == expected_param
+ assert response["args"][0]["data"]["param_value"] == 5
+
+
+def test_get_waypoint_radius_success(socketio_client: SocketIOTestClient):
+ droneStatus.state = "missions"
+
+ socketio_client.emit("get_waypoint_radius")
+ response = socketio_client.get_received()[0]
+
+ assert response["name"] == "get_waypoint_radius_result"
+ assert response["args"][0]["success"] is True
+ assert isinstance(response["args"][0]["data"], (int, float))
+
+
+def test_set_waypoint_radius_returns_error_when_invalid_value(
+ socketio_client: SocketIOTestClient,
+):
+ droneStatus.state = "missions"
+
+ socketio_client.emit("set_waypoint_radius", {"value": -1})
+ response = socketio_client.get_received()[0]
+
+ assert response["name"] == "params_error"
+ assert "Waypoint radius must be a positive number" in response["args"][0]["message"]