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"]