diff --git a/gcs/electron/main.ts b/gcs/electron/main.ts
index 5c23e4a07..ebb17a4ac 100644
--- a/gcs/electron/main.ts
+++ b/gcs/electron/main.ts
@@ -477,18 +477,15 @@ app.whenReady().then(() => {
// Load Messages on demand
ipcMain.handle("fla:get-messages", retrieveMessages)
- // 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
- },
- )
+ // Open native save dialog
+ ipcMain.handle("app:get-save-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 6ebabd1db..cc796b6a3 100644
--- a/gcs/electron/preload.js
+++ b/gcs/electron/preload.js
@@ -7,7 +7,7 @@ const ALLOWED_INVOKE_CHANNELS = [
"fla:get-recent-logs",
"fla:clear-recent-logs",
"fla:get-messages",
- "missions:get-save-mission-file-path",
+ "app:get-save-file-path",
"app:get-node-env",
"app:get-version",
"app:is-mac",
diff --git a/gcs/src/components/params/paramsToolbar.jsx b/gcs/src/components/params/paramsToolbar.jsx
index b2384350d..5a9d600c8 100644
--- a/gcs/src/components/params/paramsToolbar.jsx
+++ b/gcs/src/components/params/paramsToolbar.jsx
@@ -8,6 +8,7 @@ rebooting the autopilot
// 3rd party imports
import { Button, TextInput, Tooltip } from "@mantine/core"
import {
+ IconDownload,
IconEye,
IconPencil,
IconPower,
@@ -23,6 +24,7 @@ const tailwindColors = resolveConfig(tailwindConfig).theme.colors
// Redux
import { useDispatch, useSelector } from "react-redux"
import {
+ emitExportParamsToFile,
emitRebootAutopilot,
emitRefreshParams,
emitSetMultipleParams,
@@ -59,6 +61,29 @@ export default function ParamsToolbar() {
dispatch(resetParamState())
}
+ async function saveParamsToFile() {
+ const options = {
+ title: "Save parameters to a file",
+ filters: [
+ { name: "Param File", extensions: ["param"] },
+ { name: "All Files", extensions: ["*"] },
+ ],
+ }
+
+ const result = await window.ipcRenderer.invoke(
+ "app:get-save-file-path",
+ options,
+ )
+
+ if (!result.canceled) {
+ dispatch(
+ emitExportParamsToFile({
+ filePath: result.filePath,
+ }),
+ )
+ }
+ }
+
return (
dispatch(toggleShowModifiedParams())}
color={tailwindColors.orange[600]}
>
- {" "}
- {showModifiedParams ? (
-
- ) : (
-
- )}{" "}
+ {showModifiedParams ? : }
@@ -95,8 +115,7 @@ export default function ParamsToolbar() {
onClick={() => dispatch(emitSetMultipleParams(modifiedParams))}
color={tailwindColors.green[600]}
>
- {" "}
- Save params{" "}
+ Write params
+
+ }
+ onClick={saveParamsToFile}
+ color={tailwindColors.blue[600]}
+ >
+ Save params to file
)
diff --git a/gcs/src/missions.jsx b/gcs/src/missions.jsx
index cee16ab03..6a09c14fb 100644
--- a/gcs/src/missions.jsx
+++ b/gcs/src/missions.jsx
@@ -260,7 +260,7 @@ export default function Missions() {
}
const result = await window.ipcRenderer.invoke(
- "missions:get-save-mission-file-path",
+ "app:get-save-file-path",
options,
)
diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js
index ac05ec2f2..169e6b5fb 100644
--- a/gcs/src/redux/middleware/emitters.js
+++ b/gcs/src/redux/middleware/emitters.js
@@ -46,6 +46,7 @@ import {
showDashboardMissionFetchingNotificationThunk,
} from "../slices/missionSlice"
import {
+ emitExportParamsToFile,
emitRebootAutopilot,
emitRefreshParams,
emitSetMultipleParams,
@@ -266,6 +267,14 @@ export function handleEmitters(socket, store, action) {
emitter: emitSetMultipleParams,
callback: () => socket.socket.emit("set_multiple_params", action.payload),
},
+ {
+ emitter: emitExportParamsToFile,
+ callback: () => {
+ socket.socket.emit("export_params_to_file", {
+ file_path: action.payload.filePath,
+ })
+ },
+ },
/*
==========
diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js
index 666b43be1..b5f206ae5 100644
--- a/gcs/src/redux/middleware/socketMiddleware.js
+++ b/gcs/src/redux/middleware/socketMiddleware.js
@@ -132,6 +132,7 @@ const ParamSpecificSocketEvents = Object.freeze({
onParamRequestUpdate: "param_request_update",
onParamSetSuccess: "param_set_success",
onParamError: "params_error",
+ onExportParamsResult: "export_params_result",
})
const MissionSpecificSocketEvents = Object.freeze({
@@ -456,6 +457,17 @@ const socketMiddleware = (store) => {
store.dispatch(setFetchingVars(false))
})
+ socket.socket.on(
+ ParamSpecificSocketEvents.onExportParamsResult,
+ (msg) => {
+ if (msg.success) {
+ showSuccessNotification(msg.message)
+ } else {
+ showErrorNotification(msg.message)
+ }
+ },
+ )
+
socket.socket.on(
DroneSpecificSocketEvents.onNavRepositionResult,
(msg) => {
diff --git a/gcs/src/redux/slices/paramsSlice.js b/gcs/src/redux/slices/paramsSlice.js
index eca4c5397..5196b2b86 100644
--- a/gcs/src/redux/slices/paramsSlice.js
+++ b/gcs/src/redux/slices/paramsSlice.js
@@ -101,6 +101,7 @@ const paramsSlice = createSlice({
emitRebootAutopilot: () => {},
emitRefreshParams: () => {},
emitSetMultipleParams: () => {},
+ emitExportParamsToFile: () => {},
},
selectors: {
selectRebootData: (state) => state.rebootData,
@@ -134,10 +135,11 @@ export const {
updateModifiedParamValue,
deleteModifiedParam,
resetParamState,
+ setHasFetchedOnce,
emitRebootAutopilot,
emitRefreshParams,
emitSetMultipleParams,
- setHasFetchedOnce,
+ emitExportParamsToFile,
} = paramsSlice.actions
export const {
selectRebootData,
diff --git a/radio/app/controllers/paramsController.py b/radio/app/controllers/paramsController.py
index 5b4b2597e..9e94db195 100644
--- a/radio/app/controllers/paramsController.py
+++ b/radio/app/controllers/paramsController.py
@@ -327,3 +327,30 @@ def getCachedParam(self, params: str) -> Union[CachedParam, dict]:
else:
self.drone.logger.error(f"Invalid params type, got {type(params)}")
return {}
+
+ def exportParamsToFile(self, file_path: str) -> Response:
+ """
+ Export all cached parameters to a file.
+
+ Args:
+ file_path (str): The path to the file to export to
+
+ Returns:
+ Response: The response from the export operation
+ """
+ try:
+ with open(file_path, "w") as f:
+ # order params alphabetically by param_id
+ ordered_params = sorted(self.params, key=lambda k: k["param_id"])
+ for param in ordered_params:
+ f.write(f"{param['param_id'].upper()},{param['param_value']}\n")
+ return {
+ "success": True,
+ "message": f"Parameters exported successfully to {file_path}",
+ }
+ except Exception as e:
+ self.drone.logger.error(f"Failed to export params to file: {e}")
+ return {
+ "success": False,
+ "message": f"Failed to export params to file: {e}",
+ }
diff --git a/radio/app/endpoints/params.py b/radio/app/endpoints/params.py
index 2ffbd81f5..a45df5ae6 100644
--- a/radio/app/endpoints/params.py
+++ b/radio/app/endpoints/params.py
@@ -1,8 +1,15 @@
import time
from typing import Any, List
+from typing_extensions import TypedDict
+
import app.droneStatus as droneStatus
from app import logger, socketio
+from app.utils import notConnectedError
+
+
+class ExportParamsFileType(TypedDict):
+ file_path: str
@socketio.on("set_multiple_params")
@@ -82,3 +89,37 @@ def refresh_params() -> None:
time.sleep(0.2)
socketio.emit("params", droneStatus.drone.paramsController.params)
+
+
+@socketio.on("export_params_to_file")
+def export_params_to_file(data: ExportParamsFileType) -> None:
+ """
+ Export parameters to a file.
+
+ Args:
+ data: The data from the client containing the file path.
+ """
+ if droneStatus.state != "params":
+ socketio.emit(
+ "params_error",
+ {"message": "You must be on the params screen to export parameters."},
+ )
+ logger.debug(f"Current state: {droneStatus.state}")
+ return
+
+ if not droneStatus.drone:
+ notConnectedError(action="export params to file")
+ return
+
+ file_path = data.get("file_path", None)
+ if not file_path:
+ socketio.emit(
+ "export_params_result",
+ {"success": False, "message": "No file path provided."},
+ )
+ logger.error("No file path provided for exporting parameters.")
+ return
+
+ result = droneStatus.drone.paramsController.exportParamsToFile(file_path)
+
+ socketio.emit("export_params_result", result)