Skip to content

Commit

Permalink
settings-sync: Register timestamp of changes and always use the newes…
Browse files Browse the repository at this point in the history
…t one
  • Loading branch information
rafaellehmkuhl committed Feb 11, 2025
1 parent f2db9cf commit 9156204
Showing 1 changed file with 139 additions and 63 deletions.
202 changes: 139 additions & 63 deletions src/composables/settingsSyncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,79 @@ import { savedProfilesKey } from '@/stores/widgetManager'
import { useInteractionDialog } from './interactionDialog'
import { openSnackbar } from './snackbar'

/**
* Maps setting key to its last update timestamp, organized by user and vehicle ID
*/
export interface SettingsEpochTable {
[userId: string]: {
[vehicleId: string]: {
[key: string]: number
}
}
}

export const resetJustMadeKey = 'cockpit-reset-just-made'
const resetJustMade = useStorage(resetJustMadeKey, false)
setTimeout(() => {
resetJustMade.value = false
}, 10000)

// Store epochs for local settings
const localEpochTable = useStorage<SettingsEpochTable>('cockpit-settings-epochs', {})

// Helper function to get/set epoch for a specific user, vehicle, and key
const getLocalEpoch = (username: string, vehicleId: string, key: string): number | undefined => {
return localEpochTable.value[username]?.[vehicleId]?.[key] || undefined
}

const setLocalEpoch = (username: string, vehicleId: string, key: string, epoch: number): void => {
if (!localEpochTable.value[username]) {
localEpochTable.value[username] = {}
}
if (!localEpochTable.value[username][vehicleId]) {
localEpochTable.value[username][vehicleId] = {}
}
localEpochTable.value[username][vehicleId][key] = epoch
}

const getSettingsEpochOnVehicle = async (
vehicleAddress: string,
username: string,
key: string
): Promise<number | undefined> => {
const url = `settings/${username}/epochs/${key}`
return (await getKeyDataFromCockpitVehicleStorage(vehicleAddress, url).catch(() => undefined)) as number | undefined
}

const setSettingsEpochOnVehicle = async (
vehicleAddress: string,
username: string,
key: string,
epoch: number
): Promise<void> => {
const url = `settings/${username}/epochs/${key}`
await setKeyDataOnCockpitVehicleStorage(vehicleAddress, url, epoch)
}

const getSettingsValueOnVehicle = async (
vehicleAddress: string,
username: string,
key: string
): Promise<any | undefined> => {
const url = `settings/${username}/${key}`
return (await getKeyDataFromCockpitVehicleStorage(vehicleAddress, url).catch(() => undefined)) as any | undefined
}

const setSettingsValueOnVehicle = async (
vehicleAddress: string,
username: string,
key: string,
value: any
): Promise<void> => {
const url = `settings/${username}/${key}`
await setKeyDataOnCockpitVehicleStorage(vehicleAddress, url, value)
}

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

Expand All @@ -40,11 +107,11 @@ const getVehicleAddress = async (): Promise<string> => {
* 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.
* BlueOS with it. On the other hand, if BlueOS also has a value, it will use the most recent one, based on an epoch
* that stores the moment that value was last updated locally and on 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
* In resume, the initial source of truth is the most recent value, and once everything is in sync, the source of truth
* is the local value.
* @param { string } key
* @param { T } defaultValue
Expand Down Expand Up @@ -83,47 +150,17 @@ export function useBlueOsStorage<T>(key: string, defaultValue: MaybeRef<T>): Rem
return vehicleStore.currentlyConnectedVehicleId
}

const getLastConnectedVehicleId = async (): Promise<string | undefined> => {
const getLastConnectedVehicleId = (): string | undefined => {
const vehicleStore = useMainVehicleStore()
return vehicleStore.lastConnectedVehicleId
}

const getLastConnectedUser = async (): Promise<string | undefined> => {
const getLastConnectedUser = (): string | undefined => {
const missoinStore = useMissionStore()
return missoinStore.lastConnectedUser
}

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

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

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

await showDialog({
maxWidth: 600,
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 updateValueOnBlueOS = async (newValue: T, updateEpoch: number): Promise<void> => {
const vehicleAddress = await getVehicleAddress()
const username = await getUsername()

Expand All @@ -134,7 +171,10 @@ export function useBlueOsStorage<T>(key: string, defaultValue: MaybeRef<T>): Rem
clearTimeout(blueOsUpdateTimeout)

try {
await setKeyDataOnCockpitVehicleStorage(vehicleAddress, `settings/${username}/${key}`, newValue)
// Update the value of the key and its epoch on BlueOS
await setSettingsValueOnVehicle(vehicleAddress, username, key, newValue)
await setSettingsEpochOnVehicle(vehicleAddress, username, key, updateEpoch)

const message = `Success updating '${key}' on BlueOS.`
openSnackbar({ message, duration: 3000, variant: 'success', closeButton: true })
console.info(message)
Expand All @@ -157,58 +197,72 @@ export function useBlueOsStorage<T>(key: string, defaultValue: MaybeRef<T>): Rem
const vehicleAddress = await getVehicleAddress()
const username = await getUsername()
const currentVehicleId = await getCurrentVehicleId()
const lastConnectedVehicleId = await getLastConnectedVehicleId()
const lastConnectedUser = await getLastConnectedUser()

// Clear initial sync routine if there's one left, as we are going to start a new one
clearTimeout(initialSyncTimeout)

try {
const valueOnBlueOS = await getKeyDataFromCockpitVehicleStorage(vehicleAddress, `settings/${username}/${key}`)
const valueOnBlueOS = await getSettingsValueOnVehicle(vehicleAddress, username, key)
console.debug(`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 the value on BlueOS is the same as the one we have locally, we don't need to do anything
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
}

// By default, if there's a conflict, we use the value from BlueOS.
let useBlueOsValue = true

// If the connected vehicle is the same as the last connected vehicle, and the user is also the same, and there
// are conflicts, it means the user has made changes while offline, so we ask the user if they want to keep the
// local value or the one from BlueOS.
// If the connected vehicle is different from the last connected vehicle, we just use the value from BlueOS, as we
// don't want to overwrite the value on the new vehicle with the one from the previous vehicle.
if (resetJustMade.value) {
useBlueOsValue = false
} else if (lastConnectedUser === username && lastConnectedVehicleId === currentVehicleId) {
console.debug(`Conflict with BlueOS for key '${key}'. Asking user what to do.`)
useBlueOsValue = await askIfUserWantsToUseBlueOsValue()
// Get epochs for both local and remote values
const remoteEpoch = await getSettingsEpochOnVehicle(vehicleAddress, username, key)
const localEpoch = getLocalEpoch(username, currentVehicleId, key)

// By default, if there's a conflict, we use the value with the newest epoch
let useBlueOsValue = (remoteEpoch ?? 0) > (localEpoch ?? 0)

const msg = `Key: ${key} // Epochs: Remote: ${remoteEpoch}, Local: ${localEpoch} // Use BlueOS value: ${useBlueOsValue}`
console.debug(msg)

// If the epochs are equal and the values are different, we use the value from BlueOS
if (remoteEpoch === localEpoch && !isEqual(currentValue.value, valueOnBlueOS)) {
useBlueOsValue = true
}

if (useBlueOsValue) {
currentValue.value = valueOnBlueOS as T
const remoteEpochOrNow = remoteEpoch ?? Date.now()

// Update local epoch to match remote
setLocalEpoch(username, currentVehicleId, key, remoteEpochOrNow)

// Update epoch on BlueOS as well if it's not there yet
if (remoteEpoch === undefined) {
await setSettingsEpochOnVehicle(vehicleAddress, username, key, remoteEpochOrNow)
}

const message = `Fetched remote value of key ${key} from the vehicle.`
openSnackbar({ message, duration: 3000, variant: 'success' })

// TODO: This is a workaround to make the profiles work after an import.
// We need to find a better way to handle this, without reloading.
if (key === savedProfilesKey) {
await showDialog({
title: 'Widget profiles imported',
message: `The widget profiles have been imported from the vehicle. We need to reload the page to apply the
changes.`,
message: `The widget profiles have been imported from the vehicle. We need to reload the page to apply the changes.`,
variant: 'warning',
actions: [{ text: 'OK', action: closeDialog }],
timer: 3000,
})
reloadCockpit()
}
} else {
await updateValueOnBlueOS(currentValue.value)
// Update both value and epoch on BlueOS
const localEpochOrNow = localEpoch ?? Date.now()

await updateValueOnBlueOS(currentValue.value, localEpochOrNow)

// Update epoch locally if it's not there yet
if (localEpoch === undefined) {
setLocalEpoch(username, currentVehicleId, key, localEpochOrNow)
}

const message = `Pushed local value of key ${key} to the vehicle.`
openSnackbar({ message, duration: 3000, variant: 'success' })
}
Expand All @@ -221,7 +275,10 @@ export function useBlueOsStorage<T>(key: string, defaultValue: MaybeRef<T>): Rem
if ((initialSyncError as Error).name === NoPathInBlueOsErrorName) {
console.debug(`No value for '${key}' on BlueOS. Using current value. Will push it to BlueOS.`)
try {
await updateValueOnBlueOS(currentValue.value)
// Set initial epoch and push both value and epoch
const localEpochOrNow = getLocalEpoch(username, currentVehicleId, key) ?? Date.now()
setLocalEpoch(username, currentVehicleId, key, localEpochOrNow)
await updateValueOnBlueOS(currentValue.value, localEpochOrNow)
finishedInitialFetch.value = true
return
} catch (fetchError) {
Expand Down Expand Up @@ -256,7 +313,7 @@ export function useBlueOsStorage<T>(key: string, defaultValue: MaybeRef<T>): Rem
let valueBeforeDebouncedChange = structuredClone(toRaw(currentValue.value))
let valueUpdateMethodTimeout: ReturnType<typeof setTimeout> | undefined = undefined

const maybeUpdateValueOnBlueOs = async (newValue: T, oldValue: T): Promise<void> => {
const maybeUpdateValueOnBlueOs = async (newValue: T, oldValue: T, epoch: number): Promise<void> => {
console.debug(`Detected changes in the local value for key '${key}'. Updating BlueOS.`)

// 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
Expand All @@ -275,14 +332,33 @@ export function useBlueOsStorage<T>(key: string, defaultValue: MaybeRef<T>): Rem
const devStore = useDevelopmentStore()
if (!devStore.enableBlueOsSettingsSync) return

updateValueOnBlueOS(newValue)
updateValueOnBlueOS(newValue, epoch)
}

const updateEpochLocally = (epoch: number): void => {
const lastConnectedUser = getLastConnectedUser()
const lastConnectedVehicleId = getLastConnectedVehicleId()

if (lastConnectedUser === undefined || lastConnectedVehicleId === undefined) {
console.error('Not able to update epoch locally. Last connected user or vehicle ID not found.')
return
}

setLocalEpoch(lastConnectedUser, lastConnectedVehicleId, key, epoch)
}

watch(
currentValue,
async (newValue) => {
// Update the local epoch immediately
const epoch = Date.now()
updateEpochLocally(epoch)

// Throttle remote value updates to avoid spamming BlueOS with requests
clearTimeout(valueUpdateMethodTimeout)
valueUpdateMethodTimeout = setTimeout(() => maybeUpdateValueOnBlueOs(newValue, valueBeforeDebouncedChange), 1000)
valueUpdateMethodTimeout = setTimeout(() => {
maybeUpdateValueOnBlueOs(newValue, valueBeforeDebouncedChange, epoch)
}, 1000)
},
{ deep: true }
)
Expand Down

0 comments on commit 9156204

Please # to comment.