Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion gcs/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
)
Expand Down
44 changes: 24 additions & 20 deletions gcs/electron/preload.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 = `<div class="${className}"><div></div></div>`

return {
Expand All @@ -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)
70 changes: 68 additions & 2 deletions gcs/src/missions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -180,13 +184,22 @@ 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")
socket.off("target_info")
socket.off("current_mission")
socket.off("write_mission_result")
socket.off("import_mission_result")
socket.off("export_mission_result")
}
}, [connected])

Expand All @@ -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]
Expand Down Expand Up @@ -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 (
Expand Down
92 changes: 83 additions & 9 deletions radio/app/controllers/missionController.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
)
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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"
Expand All @@ -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}",
}
Loading
Loading