diff --git a/gcs/electron/main.ts b/gcs/electron/main.ts
index 762cb3b81..977a43354 100644
--- a/gcs/electron/main.ts
+++ b/gcs/electron/main.ts
@@ -5,7 +5,7 @@ import path from 'node:path'
import packageInfo from '../package.json'
// @ts-expect-error - no types available
-import openFile, { getRecentFiles, clearRecentFiles } from './fla'
+import openFile, { clearRecentFiles, getRecentFiles } from './fla'
// The built directory structure
//
// ├─┬─┬ dist
@@ -448,6 +448,16 @@ app.whenReady().then(() => {
// Clear recent logs
ipcMain.handle('fla:clear-recent-logs', clearRecentFiles)
+ // Save mission file
+ ipcMain.handle('missions:get-save-mission-file-path', async (event, options) => {
+ const window = BrowserWindow.fromWebContents(event.sender);
+ if (!window) {
+ throw new Error('No active window found')
+ }
+ const result = await dialog.showSaveDialog(window, options);
+ return result;
+ })
+
ipcMain.handle('app:get-node-env', () =>
app.isPackaged ? 'production' : 'development',
)
diff --git a/gcs/electron/preload.js b/gcs/electron/preload.js
index c52ab7d36..e7149140f 100644
--- a/gcs/electron/preload.js
+++ b/gcs/electron/preload.js
@@ -1,18 +1,22 @@
-import { contextBridge, ipcRenderer } from 'electron'
+import { contextBridge, ipcRenderer } from "electron"
// --------- Expose some API to the Renderer process ---------
-contextBridge.exposeInMainWorld('ipcRenderer', {
+contextBridge.exposeInMainWorld("ipcRenderer", {
...withPrototype(ipcRenderer),
- loadFile: (data) => ipcRenderer.invoke('fla:open-file', data),
- getRecentLogs: () => ipcRenderer.invoke('fla:get-recent-logs'),
- clearRecentLogs: () => ipcRenderer.invoke('fla:clear-recent-logs'),
- getNodeEnv: () => ipcRenderer.invoke('app:get-node-env'),
- getVersion: () => ipcRenderer.invoke('app:get-version'),
- getSettings: () => ipcRenderer.invoke('getSettings'),
- saveSettings: (settings) => ipcRenderer.invoke('setSettings', settings),
- openWebcamWindow: (id, name, aspect) => ipcRenderer.invoke("openWebcamWindow", id, name, aspect),
- closeWebcamWindow: () => ipcRenderer.invoke('closeWebcamWindow'),
- onCameraWindowClose: (callback) => ipcRenderer.on("webcam-closed", () => callback())
+ loadFile: (data) => ipcRenderer.invoke("fla:open-file", data),
+ getRecentLogs: () => ipcRenderer.invoke("fla:get-recent-logs"),
+ clearRecentLogs: () => ipcRenderer.invoke("fla:clear-recent-logs"),
+ getSaveMissionFilePath: (options) =>
+ ipcRenderer.invoke("missions:get-save-mission-file-path", options),
+ getNodeEnv: () => ipcRenderer.invoke("app:get-node-env"),
+ getVersion: () => ipcRenderer.invoke("app:get-version"),
+ getSettings: () => ipcRenderer.invoke("getSettings"),
+ saveSettings: (settings) => ipcRenderer.invoke("setSettings", settings),
+ openWebcamWindow: (id, name, aspect) =>
+ ipcRenderer.invoke("openWebcamWindow", id, name, aspect),
+ closeWebcamWindow: () => ipcRenderer.invoke("closeWebcamWindow"),
+ onCameraWindowClose: (callback) =>
+ ipcRenderer.on("webcam-closed", () => callback()),
})
// `exposeInMainWorld` can't detect attributes and methods of `prototype`, manually patching it.
@@ -22,7 +26,7 @@ function withPrototype(obj) {
for (const [key, value] of Object.entries(protos)) {
if (Object.prototype.hasOwnProperty.call(obj, key)) continue
- if (typeof value === 'function') {
+ if (typeof value === "function") {
// Some native APIs, like `NodeJS.EventEmitter['on']`, don't work in the Renderer process. Wrapping them into a function.
obj[key] = function (...args) {
return value.call(obj, ...args)
@@ -35,12 +39,12 @@ function withPrototype(obj) {
}
// --------- Preload scripts loading ---------
-function domReady(condition = ['complete', 'interactive']) {
+function domReady(condition = ["complete", "interactive"]) {
return new Promise((resolve) => {
if (condition.includes(document.readyState)) {
resolve(true)
} else {
- document.addEventListener('readystatechange', () => {
+ document.addEventListener("readystatechange", () => {
if (condition.includes(document.readyState)) {
resolve(true)
}
@@ -97,12 +101,12 @@ function useLoading() {
z-index: 9;
}
`
- const oStyle = document.createElement('style')
- const oDiv = document.createElement('div')
+ const oStyle = document.createElement("style")
+ const oDiv = document.createElement("div")
- oStyle.id = 'app-loading-style'
+ oStyle.id = "app-loading-style"
oStyle.innerHTML = styleContent
- oDiv.className = 'app-loading-wrap'
+ oDiv.className = "app-loading-wrap"
oDiv.innerHTML = `
`
return {
@@ -124,7 +128,7 @@ const { appendLoading, removeLoading } = useLoading()
domReady().then(appendLoading)
window.onmessage = (ev) => {
- ev.data.payload === 'removeLoading' && removeLoading()
+ ev.data.payload === "removeLoading" && removeLoading()
}
setTimeout(removeLoading, 4999)
diff --git a/gcs/src/missions.jsx b/gcs/src/missions.jsx
index 89284af7a..573478131 100644
--- a/gcs/src/missions.jsx
+++ b/gcs/src/missions.jsx
@@ -135,6 +135,7 @@ export default function Missions() {
for (let missionItem of data.items) {
missionItemsWithIds.push(addIdToItem(missionItem))
}
+ updateHomePositionBasedOnWaypoints(missionItemsWithIds)
setMissionItems(missionItemsWithIds)
} else if (data.mission_type === "fence") {
setFenceItems(data.items)
@@ -164,6 +165,9 @@ export default function Missions() {
for (let missionItem of data.items) {
missionItemsWithIds.push(addIdToItem(missionItem))
}
+
+ updateHomePositionBasedOnWaypoints(missionItemsWithIds)
+
setMissionItems(missionItemsWithIds)
} else if (data.mission_type === "fence") {
setFenceItems(data.items)
@@ -180,6 +184,14 @@ export default function Missions() {
}
})
+ socket.on("export_mission_result", (data) => {
+ if (data.success) {
+ showSuccessNotification(data.message)
+ } else {
+ showErrorNotification(data.message)
+ }
+ })
+
return () => {
socket.off("incoming_msg")
socket.off("home_position_result")
@@ -187,6 +199,7 @@ export default function Missions() {
socket.off("current_mission")
socket.off("write_mission_result")
socket.off("import_mission_result")
+ socket.off("export_mission_result")
}
}, [connected])
@@ -196,6 +209,33 @@ export default function Missions() {
}
}, [importFile])
+ function isGlobalFrameHomeCommand(waypoint) {
+ const globalFrameValue = parseInt(
+ Object.keys(MAV_FRAME_LIST).find(
+ (key) => MAV_FRAME_LIST[key] === "MAV_FRAME_GLOBAL",
+ ),
+ )
+ return (
+ waypoint.frame === globalFrameValue &&
+ waypoint.x !== 0 &&
+ waypoint.y !== 0 &&
+ waypoint.command === 16
+ )
+ }
+
+ function updateHomePositionBasedOnWaypoints(waypoints) {
+ if (waypoints.length > 0) {
+ const potentialHomeLocation = waypoints[0]
+ if (isGlobalFrameHomeCommand(potentialHomeLocation)) {
+ setHomePosition({
+ lat: potentialHomeLocation.x,
+ lon: potentialHomeLocation.y,
+ alt: potentialHomeLocation.z,
+ })
+ }
+ }
+ }
+
function getFlightMode() {
if (aircraftType === 1) {
return PLANE_MODES_FLIGHT_MODE_MAP[heartbeatData.custom_mode]
@@ -333,8 +373,34 @@ export default function Missions() {
importFileResetRef.current?.()
}
- function saveMissionToFile() {
- return
+ async function saveMissionToFile() {
+ // The options for the save dialog
+ const options = {
+ title: "Save the mission to a file",
+ filters: [
+ { name: "Waypoint Files", extensions: ["waypoints"] },
+ { name: "All Files", extensions: ["*"] },
+ ],
+ }
+
+ const result = await window.ipcRenderer.getSaveMissionFilePath(options)
+
+ if (!result.canceled) {
+ let items = []
+ if (activeTab === "mission") {
+ items = missionItems
+ } else if (activeTab === "fence") {
+ items = fenceItems
+ } else if (activeTab === "rally") {
+ items = rallyItems
+ }
+
+ socket.emit("export_mission_to_file", {
+ type: activeTab,
+ file_path: result.filePath,
+ items: items,
+ })
+ }
}
return (
diff --git a/radio/app/controllers/missionController.py b/radio/app/controllers/missionController.py
index 724b7e600..4061bfb74 100644
--- a/radio/app/controllers/missionController.py
+++ b/radio/app/controllers/missionController.py
@@ -4,7 +4,7 @@
from typing import TYPE_CHECKING, Any, List
import serial
-from app.customTypes import Response
+from app.customTypes import Number, Response
from app.utils import commandAccepted
from pymavlink import mavutil, mavwp
@@ -28,6 +28,9 @@ def __init__(self, drone: Drone) -> None:
self.drone = drone
+ # Loaders are only used to manage the mission items that are currently loaded in the drone.
+ # Importing and exporting mission items to/from files do not use loaders as these waypoints
+ # are not then loaded into the drone's mission items.
self.missionLoader = mavwp.MAVWPLoader(
target_system=drone.target_system, target_component=drone.target_component
)
@@ -46,6 +49,18 @@ def _checkMissionType(self, mission_type: int) -> Response:
}
return {"success": True}
+ def _convertCoordinate(self, coordinate) -> Number:
+ gps_coordinate_scale = 1e7
+
+ if isinstance(coordinate, float):
+ return int(coordinate * gps_coordinate_scale)
+ elif isinstance(coordinate, int):
+ return coordinate / gps_coordinate_scale
+
+ raise ValueError(
+ f"Invalid coordinate type {type(coordinate)}. Must be int or float."
+ )
+
def getCurrentMission(self, mission_type: int) -> Response:
"""
Get the current mission of a specific type from the drone.
@@ -464,6 +479,9 @@ def _parseWaypointsListIntoLoader(
)
raise ValueError(f"Invalid waypoint type {type(wp)} in waypoints list")
+ self.drone.logger.debug(
+ f"Parsed {loader.count()} waypoints into loader for mission type {mission_type}"
+ )
return loader
def uploadMission(self, mission_type: int, waypoints: List[dict]) -> Response:
@@ -570,7 +588,7 @@ def uploadMission(self, mission_type: int, waypoints: List[dict]) -> Response:
def importMissionFromFile(self, mission_type: int, file_path: str) -> Response:
"""
- Imports a mission from a file into the drone's mission loader, return the waypoints loaded.
+ Imports a mission from a file, return the waypoints loaded.
Args:
mission_type (int): The type of mission to import. 0=Mission,1=Fence,2=Rally.
@@ -592,11 +610,20 @@ def importMissionFromFile(self, mission_type: int, file_path: str) -> Response:
)
if mission_type == TYPE_MISSION:
- loader = self.missionLoader
+ loader = mavwp.MAVWPLoader(
+ target_system=self.drone.target_system,
+ target_component=self.drone.target_component,
+ )
elif mission_type == TYPE_FENCE:
- loader = self.fenceLoader
+ loader = mavwp.MissionItemProtocol_Fence(
+ target_system=self.drone.target_system,
+ target_component=self.drone.target_component,
+ )
else:
- loader = self.rallyLoader
+ loader = mavwp.MissionItemProtocol_Rally(
+ target_system=self.drone.target_system,
+ target_component=self.drone.target_component,
+ )
try:
loader.load(file_path)
@@ -622,10 +649,8 @@ def importMissionFromFile(self, mission_type: int, file_path: str) -> Response:
for wp in loader.wpoints:
if hasattr(wp, "x") and hasattr(wp, "y"):
- if isinstance(wp.x, float):
- wp.x = int(wp.x * 1e7)
- if isinstance(wp.y, float):
- wp.y = int(wp.y * 1e7)
+ wp.x = self._convertCoordinate(wp.x)
+ wp.y = self._convertCoordinate(wp.y)
self.drone.logger.info(
f"Loaded waypoint file with {loader.count()} points successfully"
@@ -635,3 +660,52 @@ def importMissionFromFile(self, mission_type: int, file_path: str) -> Response:
"message": f"Waypoint file loaded {loader.count()} points successfully",
"data": [wp.to_dict() for wp in loader.wpoints],
}
+
+ def exportMissionToFile(
+ self, mission_type: int, file_path: str, waypoints: List[dict]
+ ) -> Response:
+ """
+ Exports a mission to a file from a given list of waypoints.
+
+ Args:
+ mission_type (int): The type of mission to export. 0=Mission,1=Fence,2=Rally.
+ file_path (str): The path to the waypoint file to export.
+ waypoints (List[dict]): The list of waypoints to upload. Each waypoint should be a dict with the required fields.
+ """
+ mission_type_check = self._checkMissionType(mission_type)
+ if not mission_type_check.get("success"):
+ return mission_type_check
+
+ loader = self._parseWaypointsListIntoLoader(waypoints, mission_type)
+
+ for wp in loader.wpoints:
+ if hasattr(wp, "x") and hasattr(wp, "y"):
+ wp.x = self._convertCoordinate(wp.x)
+ wp.y = self._convertCoordinate(wp.y)
+
+ if loader.count() == 0:
+ return {
+ "success": False,
+ "message": f"No waypoints loaded for the mission type of {mission_type}",
+ }
+
+ self.drone.logger.debug(
+ f"Exporting waypoint file to {file_path} for mission type {mission_type}"
+ )
+
+ try:
+ loader.save(file_path)
+ except Exception as e:
+ self.drone.logger.error(f"Failed to save waypoint file: {e}")
+ return {
+ "success": False,
+ "message": f"Failed to save waypoint file: {e}",
+ }
+
+ self.drone.logger.info(
+ f"Saved waypoint file with {loader.count()} points successfully to {file_path}"
+ )
+ return {
+ "success": True,
+ "message": f"Waypoint file saved {loader.count()} points successfully to {file_path}",
+ }
diff --git a/radio/app/endpoints/mission.py b/radio/app/endpoints/mission.py
index ef02d2300..a54b87f95 100644
--- a/radio/app/endpoints/mission.py
+++ b/radio/app/endpoints/mission.py
@@ -14,11 +14,17 @@ class WriteCurrentMissionType(TypedDict):
items: list[dict]
-class ImportMissionFromFileType(TypedDict):
+class ImportMissionFileType(TypedDict):
type: str
file_path: str
+class ExportMissionFileType(TypedDict):
+ type: str
+ file_path: str
+ items: list[dict]
+
+
class ControlMissionType(TypedDict):
action: str
@@ -146,7 +152,7 @@ def writeCurrentMission(data: WriteCurrentMissionType) -> None:
@socketio.on("import_mission_from_file")
-def importMissionFromFile(data: ImportMissionFromFileType) -> None:
+def importMissionFromFile(data: ImportMissionFileType) -> None:
if droneStatus.state != "missions":
socketio.emit(
"params_error",
@@ -165,7 +171,7 @@ def importMissionFromFile(data: ImportMissionFromFileType) -> None:
if mission_type not in mission_type_array:
socketio.emit(
- "write_mission_result",
+ "import_mission_result",
{
"success": False,
"message": f"Invalid mission type. Must be 'mission', 'fence', or 'rally', got {mission_type}.",
@@ -197,6 +203,50 @@ def importMissionFromFile(data: ImportMissionFromFileType) -> None:
)
+@socketio.on("export_mission_to_file")
+def exportMissionToFile(data: ExportMissionFileType) -> None:
+ if droneStatus.state != "missions":
+ socketio.emit(
+ "params_error",
+ {
+ "message": "You must be on the missions screen to export a mission to a file."
+ },
+ )
+ logger.debug(f"Current state: {droneStatus.state}")
+ return
+
+ if not droneStatus.drone:
+ return notConnectedError(action="export mission to file")
+
+ mission_type = data.get("type")
+ mission_type_array = ["mission", "fence", "rally"]
+
+ if mission_type not in mission_type_array:
+ socketio.emit(
+ "export_mission_result",
+ {
+ "success": False,
+ "message": f"Invalid mission type. Must be 'mission', 'fence', or 'rally', got {mission_type}.",
+ },
+ )
+ logger.error(
+ f"Invalid mission type: {mission_type}. Must be 'mission', 'fence', or 'rally'."
+ )
+ return
+
+ file_path = data.get("file_path", "")
+ items = data.get("items", [])
+
+ result = droneStatus.drone.missionController.exportMissionToFile(
+ mission_type_array.index(mission_type), file_path, items
+ )
+
+ if not result.get("success"):
+ logger.error(result.get("message"))
+
+ socketio.emit("export_mission_result", result)
+
+
@socketio.on("control_mission")
def controlMission(data: ControlMissionType) -> None:
"""