diff --git a/src/composables/settingsSyncer.ts b/src/composables/settingsSyncer.ts index 08c4f19a6..96e073ca8 100644 --- a/src/composables/settingsSyncer.ts +++ b/src/composables/settingsSyncer.ts @@ -15,12 +15,61 @@ 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('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 => { + 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 => { + const url = `settings/${username}/epochs/${key}` + await setKeyDataOnCockpitVehicleStorage(vehicleAddress, url, epoch) +} + +const getSettingsValueOnVehicle = async (vehicleAddress: string, username: string, key: string): Promise => { + 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 => { + const url = `settings/${username}/${key}` + await setKeyDataOnCockpitVehicleStorage(vehicleAddress, url, value) +} + const getVehicleAddress = async (): Promise => { const vehicleStore = useMainVehicleStore() @@ -83,12 +132,12 @@ export function useBlueOsStorage(key: string, defaultValue: MaybeRef): Rem return vehicleStore.currentlyConnectedVehicleId } - const getLastConnectedVehicleId = async (): Promise => { + const getLastConnectedVehicleId = (): string | undefined => { const vehicleStore = useMainVehicleStore() return vehicleStore.lastConnectedVehicleId } - const getLastConnectedUser = async (): Promise => { + const getLastConnectedUser = (): string | undefined => { const missoinStore = useMissionStore() return missoinStore.lastConnectedUser } @@ -123,7 +172,7 @@ export function useBlueOsStorage(key: string, defaultValue: MaybeRef): Rem return useBlueOsValue } - const updateValueOnBlueOS = async (newValue: T): Promise => { + const updateValueOnBlueOS = async (newValue: T, updateEpoch: number): Promise => { const vehicleAddress = await getVehicleAddress() const username = await getUsername() @@ -134,7 +183,10 @@ export function useBlueOsStorage(key: string, defaultValue: MaybeRef): 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) @@ -157,8 +209,8 @@ export function useBlueOsStorage(key: string, defaultValue: MaybeRef): Rem const vehicleAddress = await getVehicleAddress() const username = await getUsername() const currentVehicleId = await getCurrentVehicleId() - const lastConnectedVehicleId = await getLastConnectedVehicleId() - const lastConnectedUser = await getLastConnectedUser() + const lastConnectedVehicleId = getLastConnectedVehicleId() + const lastConnectedUser = getLastConnectedUser() // Clear initial sync routine if there's one left, as we are going to start a new one clearTimeout(initialSyncTimeout) @@ -167,40 +219,52 @@ export function useBlueOsStorage(key: string, defaultValue: MaybeRef): Rem const valueOnBlueOS = await getKeyDataFromCockpitVehicleStorage(vehicleAddress, `settings/${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) + + // Only ask user if epochs are equal and values are different + if (remoteEpoch === localEpoch && !isEqual(currentValue.value, valueOnBlueOS)) { + if (resetJustMade.value) { + useBlueOsValue = false + } else if (lastConnectedUser === username && lastConnectedVehicleId === currentVehicleId) { + console.debug(`Conflict with BlueOS for key '${key}' and equal epochs. Asking user what to do.`) + useBlueOsValue = await askIfUserWantsToUseBlueOsValue() + } } 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, @@ -208,7 +272,16 @@ export function useBlueOsStorage(key: string, defaultValue: MaybeRef): Rem 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' }) } @@ -221,7 +294,10 @@ export function useBlueOsStorage(key: string, defaultValue: MaybeRef): 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) { @@ -256,7 +332,7 @@ export function useBlueOsStorage(key: string, defaultValue: MaybeRef): Rem let valueBeforeDebouncedChange = structuredClone(toRaw(currentValue.value)) let valueUpdateMethodTimeout: ReturnType | undefined = undefined - const maybeUpdateValueOnBlueOs = async (newValue: T, oldValue: T): Promise => { + const maybeUpdateValueOnBlueOs = async (newValue: T, oldValue: T, epoch: number): Promise => { 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 @@ -275,14 +351,30 @@ export function useBlueOsStorage(key: string, defaultValue: MaybeRef): 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) => { clearTimeout(valueUpdateMethodTimeout) - valueUpdateMethodTimeout = setTimeout(() => maybeUpdateValueOnBlueOs(newValue, valueBeforeDebouncedChange), 1000) + valueUpdateMethodTimeout = setTimeout(() => { + const epoch = Date.now() + updateEpochLocally(epoch) + maybeUpdateValueOnBlueOs(newValue, valueBeforeDebouncedChange, epoch) + }, 1000) }, { deep: true } )