Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto-sync Cockpit settings with BlueOS #701

Merged
Merged
4 changes: 2 additions & 2 deletions src/assets/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const defaultProfileVehicleCorrespondency = {
}

export const defaultWidgetManagerVars = {
timesMounted: 0,
everMounted: false,
configMenuOpen: false,
allowMoving: false,
lastNonMaximizedX: 0.4,
Expand All @@ -24,7 +24,7 @@ export const defaultWidgetManagerVars = {
}

export const defaultMiniWidgetManagerVars = {
timesMounted: 0,
everMounted: false,
configMenuOpen: false,
highlighted: false,
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/WidgetHugger.vue
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,10 @@ const resizeWidgetToMinimalSize = (): void => {
}

onMounted(async () => {
if (managerVars.value.timesMounted === 0) {
if (managerVars.value.everMounted === false) {
resizeWidgetToMinimalSize()
}
managerVars.value.timesMounted += 1
managerVars.value.everMounted = true

if (widgetResizeHandles.value) {
for (let i = 0; i < widgetResizeHandles.value.length; i++) {
Expand Down
193 changes: 193 additions & 0 deletions src/composables/settingsSyncer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { type RemovableRef, useStorage, watchThrottled } from '@vueuse/core'
import { type MaybeRef, onMounted, ref, unref } from 'vue'

import {
getKeyDataFromCockpitVehicleStorage,
NoPathInBlueOsErrorName,
setKeyDataOnCockpitVehicleStorage,
} from '@/libs/blueos'
import { isEqual } from '@/libs/utils'
import { useAlertStore } from '@/stores/alert'
import { useMainVehicleStore } from '@/stores/mainVehicle'

import { useInteractionDialog } from './interactionDialog'

/**
* This composable will keep a setting in sync between the browser's local storage and BlueOS.
*
* When initialized, it will try to get the value from BlueOS. While BlueOS does not connect, it will use the local
* stored value and keep trying to communicate with BlueOS to get it's value.
*
* Once the connection is stablished, if BlueOS doesn't have a value, it will use the local stored one and update
* BlueOS with it. On the other hand, if BlueOS has a value, it will ask the user if they want to use the value from
* BlueOS or the local one. Depending on the user's choice, it will update the local value or BlueOS.
*
* Once everything is in sync, if the local value changes, it will update the value on BlueOS.
* In resume, the initial source of truth is decided by the user, and once everything is in sync, the source of truth
* is the local value.
* @param { string } key
* @param { T } defaultValue
* @returns { RemovableRef<T> }
*/
export function useBlueOsStorage<T>(key: string, defaultValue: MaybeRef<T>): RemovableRef<T> {
const { showDialog, closeDialog } = useInteractionDialog()

const primitiveDefaultValue = unref(defaultValue)
const currentValue = useStorage(key, primitiveDefaultValue)
const finishedInitialFetch = ref(false)
let initialSyncTimeout: ReturnType<typeof setTimeout> | undefined = undefined
let blueOsUpdateTimeout: ReturnType<typeof setTimeout> | undefined = undefined

const getVehicleAddress = async (): Promise<string> => {
const vehicleStore = useMainVehicleStore()

while (vehicleStore.globalAddress === undefined) {
console.debug('Waiting for vehicle global address on BlueOS sync routine.')
await new Promise((r) => setTimeout(r, 1000))
// Wait until we have a global address
}

return vehicleStore.globalAddress
}

const askIfUserWantsToUseBlueOsValue = async (): Promise<boolean> => {
let useBlueOsValue = true

const preferBlueOs = (): void => {
useBlueOsValue = true
}

const preferCockpit = (): void => {
useBlueOsValue = false
}

await showDialog({
title: 'Conflict with BlueOS',
message: `
The value for '${key}' that is currently used in Cockpit differs from the one stored in BlueOS. What do you
want to do?
`,
variant: 'warning',
actions: [
{ text: 'Use the value from BlueOS', action: preferBlueOs },
{ text: "Keep Cockpit's value", action: preferCockpit },
],
})

closeDialog()

return useBlueOsValue
}

const updateValueOnBlueOS = async (newValue: T): Promise<void> => {
const vehicleAddress = await getVehicleAddress()
const alertStore = useAlertStore()

alertStore.pushInfoAlert(`Updating '${key}' on BlueOS.`)

let timesTriedBlueOsUpdate = 0
const tryToUpdateBlueOsValue = async (): Promise<void> => {
// Clear update routine if there's one left, as we are going to start a new one
clearTimeout(blueOsUpdateTimeout)

timesTriedBlueOsUpdate++
try {
await setKeyDataOnCockpitVehicleStorage(vehicleAddress, key, newValue)
alertStore.pushSuccessAlert(`Success updating '${key}' on BlueOS.`)
} catch (fetchError) {
const errorMessage = `Failed updating '${key}' on BlueOS. Will keep trying.`
if (timesTriedBlueOsUpdate > 1) {
alertStore.pushErrorAlert(errorMessage)
} else {
console.error(errorMessage)
}
console.error(fetchError)

// If we can't update the value on BlueOS, try again in 10 seconds
blueOsUpdateTimeout = setTimeout(tryToUpdateBlueOsValue, 10000)
}
}

// Start BlueOS value update routine
tryToUpdateBlueOsValue()
}

onMounted(async () => {
const vehicleAddress = await getVehicleAddress()
const alertStore = useAlertStore()

alertStore.pushInfoAlert(`Started syncing '${key}' with BlueOS.`)

let timesTriedInitialSync = 0
const tryToDoInitialSync = async (): Promise<void> => {
// Clear initial sync routine if there's one left, as we are going to start a new one
clearTimeout(initialSyncTimeout)

timesTriedInitialSync++

try {
const valueOnBlueOS = await getKeyDataFromCockpitVehicleStorage(vehicleAddress, key)
console.log(`Success getting value of '${key}' from BlueOS:`, valueOnBlueOS)

// If the value on BlueOS is the same as the one we have locally, we don't need to bother the user
if (isEqual(currentValue.value, valueOnBlueOS)) {
console.debug(`Value for '${key}' on BlueOS is the same as the local one. No need to update.`)
finishedInitialFetch.value = true
return
}

// If Cockpit has a different value than BlueOS, ask the user if they want to use the value from BlueOS or
// if they want to update BlueOS with the value from Cockpit.

const useBlueOsValue = await askIfUserWantsToUseBlueOsValue()

if (useBlueOsValue) {
currentValue.value = valueOnBlueOS as T
} else {
updateValueOnBlueOS(currentValue.value)
}

alertStore.pushSuccessAlert(`Success syncing '${key}' with BlueOS.`)

finishedInitialFetch.value = true
} catch (initialSyncError) {
// If the initial sync fails because there's no value for the key on BlueOS, we can just use the current value
if ((initialSyncError as Error).name === NoPathInBlueOsErrorName) {
console.debug(`No value for '${key}' on BlueOS. Using current value.`)
updateValueOnBlueOS(currentValue.value)
finishedInitialFetch.value = true
return
}

// If the initial sync fails because we can't connect to BlueOS, try again in 10 seconds
initialSyncTimeout = setTimeout(tryToDoInitialSync, 10000)

const errorMessage = `Failed syncing '${key}' with BlueOS. Will keep trying.`
if (timesTriedInitialSync > 1) {
alertStore.pushErrorAlert(errorMessage)
} else {
console.error(errorMessage)
}
console.error(`Not able to get current value of '${key}' on BlueOS. ${initialSyncError}`)
}
}

// Start initial sync routine
tryToDoInitialSync()
})

// Update BlueOS value when local value changes.
// Throttle to avoid spamming BlueOS with requests while the user is updating the value.
watchThrottled(
currentValue,
async (newValue) => {
// Don't update the value on BlueOS if we haven't finished the initial fetch, so we don't overwrite the value there without user consent
if (!finishedInitialFetch.value) return

updateValueOnBlueOS(newValue)
},
{ throttle: 3000, deep: true }
)

return currentValue
}
52 changes: 16 additions & 36 deletions src/libs/blueos.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
import ky from 'ky'
import ky, { HTTPError } from 'ky'

export const NoPathInBlueOsErrorName = 'NoPathInBlueOS'

const defaultTimeout = 10000

/* eslint-disable @typescript-eslint/no-explicit-any */
export const getBagOfHoldingFromVehicle = async (
vehicleAddress: string,
bagName: string
): Promise<Record<string, any>> => {
bagPath: string
): Promise<Record<string, any> | any> => {
try {
return await ky.get(`http://${vehicleAddress}/bag/v1.0/get/${bagName}`, { timeout: defaultTimeout }).json()
const options = { timeout: defaultTimeout, retry: 0 }
return await ky.get(`http://${vehicleAddress}/bag/v1.0/get/${bagPath}`, options).json()
} catch (error) {
throw new Error(`Could not get bag of holdings for ${bagName}. ${error}`)
}
}

export const getCockpitStorageFromVehicle = async (vehicleAddress: string): Promise<Record<string, any>> => {
try {
return await getBagOfHoldingFromVehicle(vehicleAddress, 'cockpit')
} catch (error) {
throw new Error(`Could not get Cockpit's storage data from vehicle. ${error}`)
const errorBody = await (error as HTTPError).response.json()
if (errorBody.detail === 'Invalid path') {
const noPathError = new Error(`No data available in BlueOS storage for path '${bagPath}'.`)
noPathError.name = NoPathInBlueOsErrorName
throw noPathError
}
throw new Error(`Could not get bag of holdings for ${bagPath}. ${error}`)
}
}

export const getKeyDataFromCockpitVehicleStorage = async (
vehicleAddress: string,
storageKey: string
): Promise<Record<string, any> | undefined> => {
const cockpitVehicleStorage = await getCockpitStorageFromVehicle(vehicleAddress)
return cockpitVehicleStorage[storageKey]
return await getBagOfHoldingFromVehicle(vehicleAddress, `cockpit/${storageKey}`)
}

export const setBagOfHoldingOnVehicle = async (
Expand All @@ -42,32 +42,12 @@ export const setBagOfHoldingOnVehicle = async (
}
}

export const setCockpitStorageOnVehicle = async (
vehicleAddress: string,
storageData: Record<string, any> | any
): Promise<void> => {
try {
await setBagOfHoldingOnVehicle(vehicleAddress, 'cockpit', storageData)
} catch (error) {
throw new Error(`Could not set Cockpit's storage data on vehicle. ${error}`)
}
}

export const setKeyDataOnCockpitVehicleStorage = async (
vehicleAddress: string,
storageKey: string,
storageData: Record<string, any> | any
): Promise<void> => {
let previousVehicleStorage: Record<string, any> = {}
try {
previousVehicleStorage = await getCockpitStorageFromVehicle(vehicleAddress)
} catch (error) {
console.error(error)
}
const newVehicleStorage = previousVehicleStorage
newVehicleStorage[storageKey] = storageData

await setCockpitStorageOnVehicle(vehicleAddress, newVehicleStorage)
await setBagOfHoldingOnVehicle(vehicleAddress, `cockpit/${storageKey}`, storageData)
}

/* eslint-disable jsdoc/require-jsdoc */
Expand Down
12 changes: 6 additions & 6 deletions src/libs/sensors-logging.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useStorage } from '@vueuse/core'
import { differenceInMilliseconds, differenceInSeconds, format, intervalToDuration } from 'date-fns'
import localforage from 'localforage'
import Swal from 'sweetalert2'

import { useBlueOsStorage } from '@/composables/settingsSyncer'
import { useMainVehicleStore } from '@/stores/mainVehicle'
import { useMissionStore } from '@/stores/mission'

Expand Down Expand Up @@ -199,7 +199,7 @@ class DataLogger {
currentCockpitLog: CockpitStandardLog = []
variablesBeingUsed: DatalogVariable[] = []
veryGenericIndicators: VeryGenericData[] = []
telemetryDisplayData = useStorage<OverlayGrid>('cockpit-datalogger-overlay-grid', {
telemetryDisplayData = useBlueOsStorage<OverlayGrid>('cockpit-datalogger-overlay-grid', {
LeftTop: [],
CenterTop: [],
RightTop: [],
Expand All @@ -210,7 +210,7 @@ class DataLogger {
CenterBottom: [],
RightBottom: [],
})
telemetryDisplayOptions = useStorage<OverlayOptions>('cockpit-datalogger-overlay-options', {
telemetryDisplayOptions = useBlueOsStorage<OverlayOptions>('cockpit-datalogger-overlay-options', {
fontSize: 30,
fontColor: '#FFFFFFFF',
backgroundColor: '#000000FF',
Expand All @@ -223,7 +223,7 @@ class DataLogger {
fontUnderline: false,
fontStrikeout: false,
})
logInterval = useStorage<number>('cockpit-datalogger-log-interval', 1000)
logInterval = useBlueOsStorage<number>('cockpit-datalogger-log-interval', 1000)
cockpitLogsDB = localforage.createInstance({
driver: localforage.INDEXEDDB,
name: 'Cockpit - Sensor Logs',
Expand Down Expand Up @@ -476,7 +476,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`

return `&H${invertedAlpha}${blue}${green}${red}`
}

log.forEach((logPoint, index) => {
// Don't deal with the last log point, as it has no next point to compare to
if (index === log.length - 1) return
Expand Down Expand Up @@ -521,7 +521,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`
const millisNextPoint = differenceInMilliseconds(new Date(log[index + 1].epoch), new Date(videoStartEpoch))
const remainingMillisNextPoint = millisNextPoint - roundedMillisNextPoint
const remainingCentisNextPoint = Math.floor(remainingMillisNextPoint / 10).toString().padStart(2, '0')

const timeThis = `${durationHoursThisPoint}:${durationMinutesThisPoint}:${durationSecondsThisPoint}.${remainingCentisThisPoint}`
const timeNext = `${durationHoursNextPoint}:${durationMinutesNextPoint}:${durationSecondsNextPoint}.${remainingCentisNextPoint}`

Expand Down
Loading
Loading