diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3c0354b..60e119de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed double event handling on the Logs panel of the UAV properties dialog.
+- RTK base station coordinates can now be restored from positions stored during
+ earlier surveys.
+
## [2.12.1] - 2025-12-15
### Fixed
diff --git a/src/features/rtk/AntennaPositionIndicator.jsx b/src/features/rtk/AntennaPositionIndicator.jsx
index 84d7f4d3..8b515b32 100644
--- a/src/features/rtk/AntennaPositionIndicator.jsx
+++ b/src/features/rtk/AntennaPositionIndicator.jsx
@@ -1,3 +1,5 @@
+import Restore from '@mui/icons-material/Restore';
+import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import PropTypes from 'prop-types';
@@ -15,15 +17,17 @@ import {
import { getAntennaInfoSummary } from './selectors';
const AntennaPositionIndicator = ({
+ hasSavedCoordinates,
onCopyAntennaPositionToClipboard,
onClick,
+ onShowSavedCoordinates,
position,
t,
}) => {
const hasAntennaPosition = Boolean(position);
return (
- <>
+
)}
- >
+ {onShowSavedCoordinates && (
+
+
+
+
+
+ )}
+
);
};
AntennaPositionIndicator.propTypes = {
// description: PropTypes.string,
+ hasSavedCoordinates: PropTypes.bool,
position: PropTypes.string,
onClick: PropTypes.func,
onCopyAntennaPositionToClipboard: PropTypes.func,
+ onShowSavedCoordinates: PropTypes.func,
t: PropTypes.func,
};
diff --git a/src/features/rtk/RTKCoordinateRestorationDialog.jsx b/src/features/rtk/RTKCoordinateRestorationDialog.jsx
new file mode 100644
index 00000000..0a5f4023
--- /dev/null
+++ b/src/features/rtk/RTKCoordinateRestorationDialog.jsx
@@ -0,0 +1,132 @@
+import formatDate from 'date-fns/format';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { withTranslation } from 'react-i18next';
+import { connect } from 'react-redux';
+
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import List from '@mui/material/List';
+import ListItem from '@mui/material/ListItem';
+import ListItemButton from '@mui/material/ListItemButton';
+import ListItemText from '@mui/material/ListItemText';
+
+import { BackgroundHint } from '@skybrush/mui-components';
+
+import { closeCoordinateRestorationDialog } from '~/features/rtk/slice';
+import {
+ getCoordinateRestorationDialogState,
+ getPreferredSavedRTKPositionFormatter,
+ getSavedCoordinatesForPreset,
+} from '~/features/rtk/selectors';
+import { useSavedCoordinateForPreset } from '~/features/rtk/actions';
+import Download from '~/icons/Download';
+import { formatDistance } from '~/utils/formatting';
+
+const SavedCoordinateItem = ({ coordinate, coordinateFormatter, onClick }) => {
+ const { accuracy, savedAt } = coordinate;
+ const savedDateTime = formatDate(new Date(savedAt), 'yyyy-MM-dd HH:mm:ss');
+
+ const formattedPosition = coordinateFormatter(coordinate);
+
+ return (
+
+ onClick(coordinate)}>
+
+
+
+
+
+
+ );
+};
+
+const RTKCoordinateRestorationDialog = ({
+ coordinateFormatter,
+ dialog,
+ onClose,
+ onUseSaved,
+ savedCoordinates,
+ t,
+}) => {
+ const { presetId } = dialog;
+
+ const handleUseSaved = (coordinate) => {
+ onClose(); // Close dialog first
+ void onUseSaved(presetId, coordinate); // Then start async operation
+ };
+
+ return (
+
+ );
+};
+
+RTKCoordinateRestorationDialog.propTypes = {
+ coordinateFormatter: PropTypes.func,
+ dialog: PropTypes.object,
+ onClose: PropTypes.func,
+ onUseSaved: PropTypes.func,
+ savedCoordinates: PropTypes.array,
+ t: PropTypes.func,
+};
+
+export default connect(
+ (state) => {
+ const dialog = getCoordinateRestorationDialogState(state);
+
+ const coordinateFormatter = getPreferredSavedRTKPositionFormatter(state);
+
+ return {
+ dialog,
+ savedCoordinates: dialog.presetId
+ ? getSavedCoordinatesForPreset(state, dialog.presetId)
+ : [],
+ coordinateFormatter,
+ };
+ },
+ {
+ onClose: closeCoordinateRestorationDialog,
+ onUseSaved: useSavedCoordinateForPreset,
+ }
+)(withTranslation()(RTKCoordinateRestorationDialog));
diff --git a/src/features/rtk/RTKCorrectionSourceSelector.jsx b/src/features/rtk/RTKCorrectionSourceSelector.jsx
index 827c4c9d..29a78fad 100644
--- a/src/features/rtk/RTKCorrectionSourceSelector.jsx
+++ b/src/features/rtk/RTKCorrectionSourceSelector.jsx
@@ -9,7 +9,10 @@ import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { useAsync, useAsyncFn } from 'react-use';
-import { resetRTKStatistics } from '~/features/rtk/slice';
+import {
+ resetRTKStatistics,
+ setCurrentRTKPresetId,
+} from '~/features/rtk/slice';
import messageHub from '~/message-hub';
const NULL_ID = '__null__';
@@ -19,7 +22,11 @@ const nullPreset = {
title: 'RTK disabled',
};
-const RTKCorrectionSourceSelector = ({ onSourceChanged, t }) => {
+const RTKCorrectionSourceSelector = ({
+ onSourceChanged,
+ setCurrentRTKPresetId,
+ t,
+}) => {
const [selectedByUser, setSelectedByUser] = useState();
const [selectionState, getSelectionFromServer] = useAsyncFn(async () =>
messageHub.query.getSelectedRTKPresetId()
@@ -31,9 +38,13 @@ const RTKCorrectionSourceSelector = ({ onSourceChanged, t }) => {
);
const loading = presetsState.loading || selectionState.loading;
- const hasError = presetsState.error || selectionState.error;
+ const hasError = presetsState.error;
- const presets = presetsState.value ? presetsState.value : [];
+ if (selectionState.error) {
+ console.warn('Failed to load RTK selection state:', selectionState.error);
+ }
+
+ const presets = presetsState.value ?? [];
const hasSelectionFromServer = selectionState.value !== undefined;
const selectedOnServer =
selectionState.value !== undefined
@@ -44,16 +55,27 @@ const RTKCorrectionSourceSelector = ({ onSourceChanged, t }) => {
const hasPresets = presets && presets.length > 0;
const handleChange = async (event) => {
+ const newPresetId = event.target.value;
+
// We assume that the request will succeed so we eagerly select the new
// value. If changing the RTK source fails, it will be changed back in the
// response handler triggered by the effect that we set up below.
- setSelectedByUser(event.target.value);
+ setSelectedByUser(newPresetId);
if (onSourceChanged) {
onSourceChanged();
}
};
+ // Update current preset ID in Redux
+ useEffect(() => {
+ const currentId = selectedByUser ?? selectedOnServer;
+ // Convert NULL_ID to undefined or keep as is? The selector uses 'undefined' for no selection usually.
+ // But presets have IDs.
+ const effectiveId = currentId === NULL_ID ? undefined : currentId;
+ setCurrentRTKPresetId(effectiveId);
+ }, [selectedByUser, selectedOnServer, setCurrentRTKPresetId]);
+
// If we have the preset list, but we don't have the current selection yet,
// load the current selection
useEffect(() => {
@@ -143,14 +165,15 @@ const RTKCorrectionSourceSelector = ({ onSourceChanged, t }) => {
RTKCorrectionSourceSelector.propTypes = {
onSourceChanged: PropTypes.func,
+ setCurrentRTKPresetId: PropTypes.func,
t: PropTypes.func,
};
export default connect(
- // mapStateToProps
null,
// mapDispatchToProps
{
onSourceChanged: resetRTKStatistics,
+ setCurrentRTKPresetId,
}
)(withTranslation()(RTKCorrectionSourceSelector));
diff --git a/src/features/rtk/RTKSetupDialog.jsx b/src/features/rtk/RTKSetupDialog.jsx
index 58c686dc..b442db14 100644
--- a/src/features/rtk/RTKSetupDialog.jsx
+++ b/src/features/rtk/RTKSetupDialog.jsx
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import RTKCorrectionSourceSelector from './RTKCorrectionSourceSelector';
+import RTKCoordinateRestorationDialog from './RTKCoordinateRestorationDialog';
import RTKMessageStatistics from './RTKMessageStatistics';
import RTKSetupDialogBottomPanel from './RTKSetupDialogBottomPanel';
import RTKStatusUpdater from './RTKStatusUpdater';
@@ -14,25 +15,28 @@ import { closeRTKSetupDialog } from './slice';
* monitor the RTK correction source for the UAVs.
*/
const RTKSetupDialog = ({ onClose, open }) => (
-
+
+
+ >
);
RTKSetupDialog.propTypes = {
diff --git a/src/features/rtk/RTKSetupDialogBottomPanel.jsx b/src/features/rtk/RTKSetupDialogBottomPanel.jsx
index 059c7fe5..aee12138 100644
--- a/src/features/rtk/RTKSetupDialogBottomPanel.jsx
+++ b/src/features/rtk/RTKSetupDialogBottomPanel.jsx
@@ -15,8 +15,16 @@ import { Tooltip } from '@skybrush/mui-components';
import FadeAndSlide from '~/components/transitions/FadeAndSlide';
-import { getSurveyStatus, shouldShowSurveySettings } from './selectors';
-import { toggleSurveySettingsPanel } from './slice';
+import {
+ getCurrentRTKPresetId,
+ getSurveyStatus,
+ hasSavedCoordinateForPreset,
+ shouldShowSurveySettings,
+} from './selectors';
+import {
+ showCoordinateRestorationDialog,
+ toggleSurveySettingsPanel,
+} from './slice';
import AntennaPositionIndicator from './AntennaPositionIndicator';
import RTKSatelliteObservations from './RTKSatelliteObservations';
@@ -51,7 +59,10 @@ const useStyles = makeStyles((theme) => ({
const RTKSetupDialogBottomPanel = ({
chartHeight = 160,
+ currentPresetId,
+ hasSavedCoordinates,
inset,
+ onShowSavedCoordinates,
onToggleSurveySettings,
surveySettingsVisible,
surveyStatus,
@@ -64,6 +75,12 @@ const RTKSetupDialogBottomPanel = ({
}
}, [onToggleSurveySettings, surveySettingsVisible, surveyStatus?.supported]);
+ const handleShowSavedCoordinates = () => {
+ if (currentPresetId) {
+ onShowSavedCoordinates?.(currentPresetId);
+ }
+ };
+
return (
-
+
@@ -114,7 +136,10 @@ const RTKSetupDialogBottomPanel = ({
RTKSetupDialogBottomPanel.propTypes = {
chartHeight: PropTypes.number,
+ currentPresetId: PropTypes.string,
+ hasSavedCoordinates: PropTypes.bool,
inset: PropTypes.bool,
+ onShowSavedCoordinates: PropTypes.func,
onToggleSurveySettings: PropTypes.func,
surveySettingsVisible: PropTypes.bool,
surveyStatus: PropTypes.object,
@@ -122,12 +147,20 @@ RTKSetupDialogBottomPanel.propTypes = {
export default connect(
// mapStateToProps
- (state) => ({
- surveyStatus: getSurveyStatus(state),
- surveySettingsVisible: shouldShowSurveySettings(state),
- }),
+ (state) => {
+ const currentPresetId = getCurrentRTKPresetId(state);
+ return {
+ currentPresetId,
+ hasSavedCoordinates:
+ Boolean(currentPresetId) &&
+ hasSavedCoordinateForPreset(state, currentPresetId),
+ surveyStatus: getSurveyStatus(state),
+ surveySettingsVisible: shouldShowSurveySettings(state),
+ };
+ },
// mapDispatchToProps
{
+ onShowSavedCoordinates: showCoordinateRestorationDialog,
onToggleSurveySettings: toggleSurveySettingsPanel,
}
)(RTKSetupDialogBottomPanel);
diff --git a/src/features/rtk/RTKStatusUpdater.jsx b/src/features/rtk/RTKStatusUpdater.jsx
index a3f167ed..354f6909 100644
--- a/src/features/rtk/RTKStatusUpdater.jsx
+++ b/src/features/rtk/RTKStatusUpdater.jsx
@@ -3,10 +3,13 @@ import isNil from 'lodash-es/isNil';
import mapValues from 'lodash-es/mapValues';
import PropTypes from 'prop-types';
import { useEffect } from 'react';
-import { connect } from 'react-redux';
+import { connect, useDispatch, useSelector } from 'react-redux';
import handleError from '~/error-handling';
+import { saveCurrentCoordinateForPreset } from '~/features/rtk/actions';
+import { getSavedCoordinates } from '~/features/rtk/selectors';
import { updateRTKStatistics } from '~/features/rtk/slice';
+import { hasValidFix, shouldSaveCoordinate } from '~/features/rtk/utils';
import useMessageHub from '~/hooks/useMessageHub';
/**
@@ -15,6 +18,8 @@ import useMessageHub from '~/hooks/useMessageHub';
*/
const RTKStatusUpdater = ({ onStatusChanged, period = 1000 }) => {
const messageHub = useMessageHub();
+ const dispatch = useDispatch();
+ const savedCoordinates = useSelector(getSavedCoordinates);
useEffect(() => {
const valueHolder = {
@@ -22,12 +27,31 @@ const RTKStatusUpdater = ({ onStatusChanged, period = 1000 }) => {
promise: null,
};
+ const checkAndAutosave = async (status) => {
+ // Autosave base station coordinate on first valid fix per preset
+ if (!hasValidFix(status)) {
+ return;
+ }
+
+ const selectedPresetId = await messageHub.query.getSelectedRTKPresetId();
+
+ if (
+ selectedPresetId &&
+ shouldSaveCoordinate(status, savedCoordinates, selectedPresetId)
+ ) {
+ dispatch(saveCurrentCoordinateForPreset(selectedPresetId));
+ }
+ };
+
const updateStatus = async () => {
while (!valueHolder.finished) {
try {
// eslint-disable-next-line no-await-in-loop
const status = await messageHub.query.getRTKStatus();
onStatusChanged(status);
+
+ // eslint-disable-next-line no-await-in-loop
+ await checkAndAutosave(status);
} catch (error) {
handleError(error, 'RTK status query');
}
@@ -43,7 +67,7 @@ const RTKStatusUpdater = ({ onStatusChanged, period = 1000 }) => {
valueHolder.finished = true;
valueHolder.promise = null;
};
- }, [messageHub, onStatusChanged, period]);
+ }, [dispatch, messageHub, onStatusChanged, period, savedCoordinates]);
return null;
};
diff --git a/src/features/rtk/actions.js b/src/features/rtk/actions.js
index 757c83ce..b06fe29a 100644
--- a/src/features/rtk/actions.js
+++ b/src/features/rtk/actions.js
@@ -1,4 +1,6 @@
import copy from 'copy-to-clipboard';
+import isEqual from 'lodash-es/isEqual';
+import { MessageSemantics } from '~/features/snackbar/types';
import { showError, showNotification } from '~/features/snackbar/actions';
import messageHub from '~/message-hub';
@@ -6,7 +8,11 @@ import {
getFormattedAntennaPosition,
isShowingAntennaPositionInECEF,
} from './selectors';
-import { closeSurveySettingsPanel, setAntennaPositionFormat } from './slice';
+import {
+ closeSurveySettingsPanel,
+ saveCoordinateForPreset,
+ setAntennaPositionFormat,
+} from './slice';
export const copyAntennaPositionToClipboard = () => (_dispatch, getState) => {
copy(getFormattedAntennaPosition(getState()));
@@ -31,3 +37,80 @@ export const toggleAntennaPositionFormat = () => (dispatch, getState) => {
)
);
};
+
+export const useSavedCoordinateForPreset =
+ (presetId, savedCoordinate) => async (dispatch) => {
+ try {
+ // Set the saved coordinate as the current antenna position
+ await messageHub.execute.setRTKAntennaPosition({
+ position: savedCoordinate.positionECEF,
+ accuracy: savedCoordinate.accuracy,
+ });
+
+ showNotification({
+ message: `Using saved coordinate for preset ${presetId}`,
+ semantics: MessageSemantics.SUCCESS,
+ });
+ } catch (error) {
+ console.warn('Failed to set saved coordinate:', error);
+
+ showNotification({
+ message: 'Failed to use saved coordinate.',
+ semantics: MessageSemantics.ERROR,
+ });
+ }
+ };
+
+export const saveCurrentCoordinateForPreset =
+ (presetId) => async (dispatch, getState) => {
+ const state = getState();
+ const { antenna, survey } = state.rtk.stats;
+
+ if (!antenna.position || !antenna.positionECEF || !survey.accuracy) {
+ showNotification({
+ message: 'No valid coordinate data available to save.',
+ semantics: MessageSemantics.ERROR,
+ });
+ return;
+ }
+
+ const savedCoordinate = {
+ position: antenna.position,
+ positionECEF: antenna.positionECEF,
+ accuracy: survey.accuracy,
+ savedAt: Date.now(),
+ };
+
+ // Check if this exact coordinate is already the latest saved one
+ const savedCoordinates = state.rtk.savedCoordinates[presetId] ?? [];
+ if (savedCoordinates.length > 0) {
+ const latest = savedCoordinates[0];
+ if (
+ latest &&
+ isEqual(latest.positionECEF, savedCoordinate.positionECEF)
+ ) {
+ // Coordinate is already saved as the latest, do nothing
+ return;
+ }
+ }
+
+ dispatch(
+ saveCoordinateForPreset({ presetId, coordinate: savedCoordinate })
+ );
+
+ let presetName = presetId;
+ try {
+ const presets = await messageHub.query.getRTKPresets();
+ const preset = presets.find((p) => p.id === presetId);
+ if (preset) {
+ presetName = preset.title;
+ }
+ } catch (error) {
+ console.warn('Failed to fetch RTK presets:', error);
+ }
+
+ showNotification({
+ message: `Coordinate saved for preset ${presetName}`,
+ semantics: MessageSemantics.SUCCESS,
+ });
+ };
diff --git a/src/features/rtk/selectors.ts b/src/features/rtk/selectors.ts
index f2615887..e49502ff 100644
--- a/src/features/rtk/selectors.ts
+++ b/src/features/rtk/selectors.ts
@@ -4,7 +4,14 @@ import { createSelector } from '@reduxjs/toolkit';
import { getPreferredCoordinateFormatter } from '~/selectors/formatting';
import type { RootState } from '~/store/reducers';
-import { RTKAntennaPositionFormat } from './types';
+import { RTKAntennaPositionFormat, type RTKSavedCoordinate } from './types';
+
+const formatPositionECEF = (
+ positionECEF?: RTKSavedCoordinate['positionECEF']
+): string | undefined =>
+ positionECEF && Array.isArray(positionECEF)
+ ? `[${positionECEF.map((c) => (c / 1e3).toFixed(3)).join(', ')}]`
+ : undefined;
/**
* Returns whether the antenna position should be shown in ECEF coordinates.
@@ -23,11 +30,7 @@ export const getFormattedAntennaPosition = createSelector(
(antennaInfo, formatter, isECEF) => {
if (isECEF) {
const { positionECEF } = antennaInfo || {};
- return positionECEF && Array.isArray(positionECEF)
- ? `[${(positionECEF[0] / 1e3).toFixed(3)}, ${(
- positionECEF[1] / 1e3
- ).toFixed(3)}, ${(positionECEF[2] / 1e3).toFixed(3)}]`
- : undefined;
+ return formatPositionECEF(positionECEF);
} else {
const { position } = antennaInfo || {};
return position ? formatter(position) : undefined;
@@ -159,3 +162,66 @@ export const getSurveyStatus = createSelector(
*/
export const shouldShowSurveySettings = (state: RootState): boolean =>
state.rtk.dialog.surveySettingsEditorVisible;
+
+/**
+ * Returns whether there is a saved coordinate for the given RTK preset ID.
+ */
+export const hasSavedCoordinateForPreset = (
+ state: RootState,
+ presetId: string
+): boolean => {
+ const coords = state.rtk.savedCoordinates[presetId];
+ return Boolean(coords) && coords.length > 0;
+};
+
+/**
+ * Returns the saved coordinates for the given RTK preset ID, or empty array if none exists.
+ */
+export const getSavedCoordinatesForPreset = (
+ state: RootState,
+ presetId: string
+): RTKSavedCoordinate[] => state.rtk.savedCoordinates[presetId] ?? [];
+
+/**
+ * Returns the full saved coordinates map keyed by preset ID.
+ */
+export const getSavedCoordinates = (
+ state: RootState
+): RootState['rtk']['savedCoordinates'] => state.rtk.savedCoordinates;
+
+/**
+ * Returns a formatter function that formats a saved coordinate position
+ * according to the current RTK display settings.
+ */
+export const getPreferredSavedRTKPositionFormatter = createSelector(
+ getPreferredCoordinateFormatter,
+ isShowingAntennaPositionInECEF,
+ (formatter, isECEF) =>
+ (savedCoordinate?: RTKSavedCoordinate): string | undefined => {
+ if (!savedCoordinate) {
+ return undefined;
+ }
+
+ if (isECEF) {
+ const { positionECEF } = savedCoordinate;
+ return formatPositionECEF(positionECEF);
+ } else {
+ const { position } = savedCoordinate;
+ return position ? formatter(position) : undefined;
+ }
+ }
+);
+
+/**
+ * Returns the current RTK preset ID.
+ */
+export const getCurrentRTKPresetId = (state: RootState): string | undefined =>
+ state.rtk.currentPresetId;
+
+/**
+ * Returns the coordinate restoration dialog state.
+ */
+export const getCoordinateRestorationDialogState = (
+ state: RootState
+): RootState['rtk']['dialog']['coordinateRestorationDialog'] =>
+ state.rtk.dialog.coordinateRestorationDialog;
diff --git a/src/features/rtk/slice.ts b/src/features/rtk/slice.ts
index f2798b2c..6bffdbcb 100644
--- a/src/features/rtk/slice.ts
+++ b/src/features/rtk/slice.ts
@@ -3,20 +3,35 @@
* selected RTK stream on the server.
*/
+import isEqual from 'lodash-es/isEqual';
import isNil from 'lodash-es/isNil';
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { noPayload } from '~/utils/redux';
-import { RTKAntennaPositionFormat, type RTKStatistics } from './types';
+import {
+ RTKAntennaPositionFormat,
+ type RTKSavedCoordinate,
+ type RTKStatistics,
+} from './types';
type RTKSliceState = {
stats: RTKStatistics;
+ /** Saved coordinates per RTK preset ID */
+ savedCoordinates: Record;
+
+ currentPresetId?: string;
+
dialog: {
open: boolean;
antennaPositionFormat: RTKAntennaPositionFormat;
surveySettingsEditorVisible: boolean;
+ /** Dialog for asking user if they want to use saved coordinates */
+ coordinateRestorationDialog: {
+ open: boolean;
+ presetId?: string;
+ };
};
};
@@ -39,10 +54,18 @@ const initialState: RTKSliceState = {
},
},
+ savedCoordinates: {},
+
+ currentPresetId: undefined,
+
dialog: {
open: false,
antennaPositionFormat: RTKAntennaPositionFormat.LON_LAT,
surveySettingsEditorVisible: false,
+ coordinateRestorationDialog: {
+ open: false,
+ presetId: undefined,
+ },
},
};
@@ -95,9 +118,7 @@ const { actions, reducer } = createSlice({
// gone
state.stats.satellites = satellites;
- if (state.stats.survey === undefined) {
- state.stats.survey = {};
- }
+ state.stats.survey ??= {};
if (!isNil(survey.accuracy)) {
state.stats.survey.accuracy = survey.accuracy;
@@ -107,14 +128,72 @@ const { actions, reducer } = createSlice({
state.stats.survey.flags = survey.flags;
}
},
+
+ // Saved coordinates management
+ saveCoordinateForPreset(
+ state,
+ action: PayloadAction<{
+ presetId: string;
+ coordinate: RTKSavedCoordinate;
+ }>
+ ) {
+ const { presetId, coordinate } = action.payload;
+
+ state.savedCoordinates[presetId] ??= [];
+
+ const existing = state.savedCoordinates[presetId];
+
+ const duplicateIndex = existing.findIndex((c) =>
+ isEqual(c.positionECEF, coordinate.positionECEF)
+ );
+
+ if (duplicateIndex !== -1) {
+ existing.splice(duplicateIndex, 1);
+ }
+
+ existing.unshift(coordinate);
+
+ if (existing.length > 5) {
+ existing.pop();
+ }
+ },
+
+ setCurrentRTKPresetId(state, action: PayloadAction) {
+ state.currentPresetId = action.payload;
+ },
+
+ clearAllSavedCoordinates(state) {
+ state.savedCoordinates = {};
+ },
+
+ // Coordinate restoration dialog management
+ showCoordinateRestorationDialog(state, action: PayloadAction) {
+ const presetId = action.payload;
+ state.dialog.coordinateRestorationDialog = {
+ open: true,
+ presetId,
+ };
+ },
+
+ closeCoordinateRestorationDialog: noPayload((state) => {
+ state.dialog.coordinateRestorationDialog = {
+ open: false,
+ presetId: undefined,
+ };
+ }),
},
});
export const {
+ clearAllSavedCoordinates,
+ closeCoordinateRestorationDialog,
closeRTKSetupDialog,
closeSurveySettingsPanel,
resetRTKStatistics,
+ saveCoordinateForPreset,
setAntennaPositionFormat,
+ setCurrentRTKPresetId,
+ showCoordinateRestorationDialog,
showRTKSetupDialog,
toggleSurveySettingsPanel,
updateRTKStatistics,
diff --git a/src/features/rtk/types.ts b/src/features/rtk/types.ts
index 4a4d8a61..b49b496e 100644
--- a/src/features/rtk/types.ts
+++ b/src/features/rtk/types.ts
@@ -6,6 +6,13 @@ export enum RTKAntennaPositionFormat {
ECEF = 'ecef',
}
+export type RTKSavedCoordinate = {
+ position: LonLat;
+ positionECEF: Coordinate3D;
+ accuracy: number;
+ savedAt: number;
+};
+
export type RTKStatistics = {
/**
* Timestamp when the statistics was updated the last time,
diff --git a/src/features/rtk/utils.ts b/src/features/rtk/utils.ts
index c97b4704..184bec85 100644
--- a/src/features/rtk/utils.ts
+++ b/src/features/rtk/utils.ts
@@ -1,5 +1,9 @@
+import isEqual from 'lodash-es/isEqual';
+
import { formatDistance } from '~/utils/formatting';
+import type { RTKSavedCoordinate, RTKStatistics } from './types';
+
const descriptions: Record = {
'rtcm2/1': 'Differential GPS Corrections',
'rtcm2/2': 'Delta Differential GPS Corrections',
@@ -119,3 +123,48 @@ export function formatSurveyAccuracy(value: number, { max = 20 } = {}): string {
const ceiled = Math.ceil(value * 1000) / 1000;
return formatDistance(ceiled, 1);
}
+
+/**
+ * Checks if the RTK status indicates a valid fix.
+ *
+ * @param status - The RTK statistics object.
+ * @returns True if a valid fix is present, false otherwise.
+ */
+export function hasValidFix(status: Partial): boolean {
+ const hasECEF = Array.isArray(status?.antenna?.positionECEF);
+ const accuracy = status?.survey?.accuracy;
+ const flags = status?.survey?.flags;
+ const surveyedCoordinateValid =
+ typeof flags === 'number' && (flags & 0b100) !== 0;
+
+ // Consider fix valid only with ECEF position, valid-coordinate flag, and numeric accuracy.
+ return hasECEF && surveyedCoordinateValid && typeof accuracy === 'number';
+}
+
+/**
+ * Determines whether the current coordinate should be saved for a given preset.
+ *
+ * @param status - The RTK statistics object containing the current antenna position.
+ * @param savedCoordinates - The record of saved coordinates keyed by preset ID.
+ * @param presetId - The ID of the preset to check against.
+ * @returns True if the coordinate is new and should be saved, false otherwise.
+ */
+export function shouldSaveCoordinate(
+ status: Partial,
+ savedCoordinates: Record,
+ presetId: string
+): boolean {
+ const incomingECEF = Array.isArray(status?.antenna?.positionECEF)
+ ? status.antenna?.positionECEF?.slice(0, 3).map((x) => Math.round(x))
+ : undefined;
+ const saved = savedCoordinates?.[presetId];
+ const savedECEF =
+ saved && saved.length > 0 && Array.isArray(saved[0]?.positionECEF)
+ ? saved[0]?.positionECEF.slice(0, 3)
+ : undefined;
+
+ const isSameECEF =
+ !!incomingECEF && !!savedECEF && isEqual(incomingECEF, savedECEF);
+
+ return !isSameECEF;
+}
diff --git a/src/flockwave/operations.ts b/src/flockwave/operations.ts
index 73d4e6e4..c3391d23 100644
--- a/src/flockwave/operations.ts
+++ b/src/flockwave/operations.ts
@@ -13,6 +13,7 @@ import type {
} from '@skybrush/flockwave-spec';
import { errorToString } from '~/error-handling';
+import type { Coordinate3D } from '~/utils/math';
import {
createBulkParameterUploadRequest,
@@ -274,6 +275,27 @@ export async function startRTKSurvey(
}
}
+/**
+ * Sets the RTK antenna position on the server by submitting explicit
+ * survey settings that contain a fixed position instead of starting a survey.
+ */
+export async function setRTKAntennaPosition(
+ hub: MessageHub,
+ { position, accuracy }: { position: Coordinate3D; accuracy: number }
+) {
+ const response = await hub.sendMessage({
+ type: 'X-RTK-SURVEY',
+ settings: {
+ position,
+ accuracy,
+ },
+ });
+
+ if (response.body.type !== 'ACK-ACK') {
+ throw new Error('Failed to set RTK antenna position on the server');
+ }
+}
+
/**
* Asks the server to suspend the currently running show.
*/
@@ -451,6 +473,7 @@ const _operations = {
sendDebugMessage,
setParameter,
setParameters,
+ setRTKAntennaPosition,
setRTKCorrectionsSource,
setShowConfiguration,
setShowLightConfiguration,
diff --git a/src/flockwave/queries.js b/src/flockwave/queries.js
index d28d0844..56490a9a 100644
--- a/src/flockwave/queries.js
+++ b/src/flockwave/queries.js
@@ -363,7 +363,12 @@ export async function getRTKStatus(hub) {
* Returns the currently selected RTK data source ID.
*/
export async function getSelectedRTKPresetId(hub) {
- const response = await hub.sendMessage({ type: 'X-RTK-SOURCE' });
+ const response = await hub.sendMessage(
+ { type: 'X-RTK-SOURCE' },
+ // Use a longer timeout as the server might be busy reconfiguring the RTK
+ // source when we ask for it.
+ { timeout: 15 }
+ );
if (response.body && response.body.type === 'X-RTK-SOURCE') {
return get(response, 'body.id');
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 3ac87220..e40ac8e8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -21,6 +21,14 @@
"passed": "Onboard preflight checks passed.",
"signOffOn": "Sign off on onboard preflight checks"
},
+ "RTKCoordinateRestorationDialog": {
+ "accuracyLabel": "Accuracy",
+ "dateLabel": "Saved",
+ "noSavedCoordinates": "No saved coordinates found.",
+ "positionLabel": "Position",
+ "title": "Choose a saved coordinate",
+ "useSaved": "Use coordinate"
+ },
"RTKCorrectionSourceSelector": {
"RTKCorrections": "RTK corrections",
"error": "Error while loading RTK sources from server",
@@ -67,7 +75,8 @@
"antennaPositionIndicator": {
"antennaPositionNotKnown": "Antenna position not known",
"clickToToggle": "Click to toggle between lon/lat and ECEF format",
- "copyToClipboard": "Copy to clipboard"
+ "copyToClipboard": "Copy to clipboard",
+ "useSavedCoordinate": "Use saved coordinate"
},
"augmentMappingButton": {
"assignSparesToEmptySlots": "Assign spares to empty slots"
diff --git a/src/i18n/hu.json b/src/i18n/hu.json
index 05aecf46..2a1eeaae 100644
--- a/src/i18n/hu.json
+++ b/src/i18n/hu.json
@@ -27,6 +27,14 @@
"noRTKData": "Nincsenek RTK adatforrások a szerveren",
"pleaseWait": "Kérem várjon, RTK források betöltése…"
},
+ "RTKCoordinateRestorationDialog": {
+ "title": "Válassz egy mentett koordinátát",
+ "positionLabel": "Pozíció",
+ "accuracyLabel": "Pontosság",
+ "dateLabel": "Mentve",
+ "noSavedCoordinates": "Nincsenek mentett koordináták.",
+ "useSaved": "Koordináta használata"
+ },
"RTKMessage": {
"noRTKMessagesYet": "Még nem érkezett RTK üzenet"
},
diff --git a/src/store/index.js b/src/store/index.js
index 200fccfc..18b71532 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -62,7 +62,6 @@ const persistConfig = {
'log',
'logDownload',
'messages',
- 'rtk',
'servers',
'session',
'snackbar',
@@ -113,6 +112,9 @@ const persistConfig = {
// Store only the persistent settings of the upload procedure
createFilter('upload', ['settings']),
+ // Store only saved coordinates from RTK
+ createFilter('rtk', ['savedCoordinates']),
+
// We do not wish to save 3D view tooltips, camera pose or the scene ID
createBlacklistFilter('threeD', ['camera', 'tooltip', 'sceneId']),
],