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 ( + + {t('RTKCoordinateRestorationDialog.title')} + + + {savedCoordinates.length === 0 ? ( + + ) : ( + + {savedCoordinates.map((coordinate) => ( + + ))} + + )} + + + + + + + ); +}; + +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']), ],