diff --git a/packages/web/src/components/PageComponents/Settings/Security/Security.tsx b/packages/web/src/components/PageComponents/Settings/Security/Security.tsx index b5fdf3f1..873941a6 100644 --- a/packages/web/src/components/PageComponents/Settings/Security/Security.tsx +++ b/packages/web/src/components/PageComponents/Settings/Security/Security.tsx @@ -9,7 +9,7 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx" import { createZodResolver } from "@components/Form/createZodResolver.ts"; import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; -import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; +import { deepCompareConfig, normalizeBytes } from "@core/utils/deepCompareConfig.ts"; import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts"; import { fromByteArray, toByteArray } from "base64-js"; import { useEffect, useState } from "react"; @@ -87,7 +87,20 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { ], }; - if (deepCompareConfig(config.security, payload, true)) { + // Normalize empty byte arrays -> undefined for comparison so + // empty base64 strings from the form match undefined/empty fields + // in the existing config and allow removeChange to work. + const normalizeSecurity = (s: ParsedSecurity | undefined) => { + if (!s) return s; + return { + ...s, + privateKey: normalizeBytes(s.privateKey), + publicKey: normalizeBytes(s.publicKey), + adminKey: s.adminKey?.map((b) => normalizeBytes(b)) as unknown, + } as ParsedSecurity; + }; + + if (deepCompareConfig(normalizeSecurity(config.security), normalizeSecurity(payload), true)) { removeChange({ type: "config", variant: "security" }); return; } diff --git a/packages/web/src/core/stores/deviceStore/changeRegistry.ts b/packages/web/src/core/stores/deviceStore/changeRegistry.ts index cdfcc0b6..2af79bb3 100644 --- a/packages/web/src/core/stores/deviceStore/changeRegistry.ts +++ b/packages/web/src/core/stores/deviceStore/changeRegistry.ts @@ -1,15 +1,17 @@ import type { Types } from "@meshtastic/core"; // Config type discriminators -export type ValidConfigType = +export type ValidRadioConfigType = "lora" | "security"; + +export type ValidDeviceConfigType = | "device" | "position" | "power" | "network" | "display" - | "lora" - | "bluetooth" - | "security"; + | "bluetooth"; + +export type ValidConfigType = ValidRadioConfigType | ValidDeviceConfigType; export type ValidModuleConfigType = | "mqtt" @@ -157,6 +159,47 @@ export function getConfigChangeCount(registry: ChangeRegistry): number { return count; } +/** + * Get count of radio config changes + */ +export function getRadioConfigChangeCount(registry: ChangeRegistry): number { + let count = 0; + for (const keyStr of registry.changes.keys()) { + const key = deserializeKey(keyStr); + if (key.type === "config" && (key.variant === "lora" || key.variant === "security")) { + count++; + } + } + // Channel is displayed under Radio section in UI, so include channel changes in the count + count += getChannelChangeCount(registry); + return count; +} + +/** + * Get count of device config changes + */ +export function getDeviceConfigChangeCount(registry: ChangeRegistry): number { + let count = 0; + for (const keyStr of registry.changes.keys()) { + const key = deserializeKey(keyStr); + if ( + key.type === "config" && + (key.variant === "device" || + key.variant === "position" || + key.variant === "power" || + key.variant === "network" || + key.variant === "display" || + key.variant === "bluetooth") + ) { + count++; + } + } + if (hasUserChange(registry)) { + count++; + } + return count; +} + /** * Get count of module config changes */ diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts index eb67f7bf..e36deafe 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts @@ -91,6 +91,8 @@ export const mockDeviceStore: Device = { hasChannelChange: vi.fn().mockReturnValue(false), hasUserChange: vi.fn().mockReturnValue(false), getConfigChangeCount: vi.fn().mockReturnValue(0), + getRadioConfigChangeCount: vi.fn().mockReturnValue(0), + getDeviceConfigChangeCount: vi.fn().mockReturnValue(0), getModuleConfigChangeCount: vi.fn().mockReturnValue(0), getChannelChangeCount: vi.fn().mockReturnValue(0), getAllConfigChanges: vi.fn().mockReturnValue([]), diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index a226d7af..d8f52c93 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -15,6 +15,8 @@ import { getAllModuleConfigChanges, getChannelChangeCount, getConfigChangeCount, + getRadioConfigChangeCount, + getDeviceConfigChangeCount, getModuleConfigChangeCount, hasChannelChange, hasConfigChange, @@ -119,6 +121,8 @@ export interface Device extends DeviceData { hasChannelChange: (index: Types.ChannelNumber) => boolean; hasUserChange: () => boolean; getConfigChangeCount: () => number; + getRadioConfigChangeCount: () => number; + getDeviceConfigChangeCount: () => number; getModuleConfigChangeCount: () => number; getChannelChangeCount: () => number; getAllConfigChanges: () => Protobuf.Config.Config[]; @@ -817,6 +821,24 @@ function deviceFactory( return getConfigChangeCount(device.changeRegistry); }, + getRadioConfigChangeCount: () => { + const device = get().devices.get(id); + if (!device) { + return 0; + } + + return getRadioConfigChangeCount(device.changeRegistry); + }, + + getDeviceConfigChangeCount: () => { + const device = get().devices.get(id); + if (!device) { + return 0; + } + + return getDeviceConfigChangeCount(device.changeRegistry); + }, + getModuleConfigChangeCount: () => { const device = get().devices.get(id); if (!device) { diff --git a/packages/web/src/core/utils/deepCompareConfig.ts b/packages/web/src/core/utils/deepCompareConfig.ts index cc9bcdb8..5e12c59d 100644 --- a/packages/web/src/core/utils/deepCompareConfig.ts +++ b/packages/web/src/core/utils/deepCompareConfig.ts @@ -6,6 +6,13 @@ function isUint8Array(v: unknown): v is Uint8Array { return v instanceof Uint8Array; } +export function normalizeBytes(value: unknown): unknown { + if (value instanceof Uint8Array && value.byteLength === 0) { + return undefined; + } + return value; +} + function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) { return false; diff --git a/packages/web/src/pages/Settings/index.tsx b/packages/web/src/pages/Settings/index.tsx index 2df577ed..1becf159 100644 --- a/packages/web/src/pages/Settings/index.tsx +++ b/packages/web/src/pages/Settings/index.tsx @@ -36,6 +36,8 @@ const ConfigPage = () => { setModuleConfig, addChannel, getConfigChangeCount, + getRadioConfigChangeCount, + getDeviceConfigChangeCount, getModuleConfigChangeCount, getChannelChangeCount, getAdminMessageChangeCount, @@ -50,9 +52,9 @@ const ConfigPage = () => { const routerState = useRouterState(); const { t } = useTranslation("config"); - const configChangeCount = getConfigChangeCount(); + const radioConfigChangeCount = getRadioConfigChangeCount(); + const deviceConfigChangeCount = getDeviceConfigChangeCount(); const moduleConfigChangeCount = getModuleConfigChangeCount(); - const channelChangeCount = getChannelChangeCount(); const adminMessageChangeCount = getAdminMessageChangeCount(); const sections = useMemo( @@ -62,7 +64,7 @@ const ConfigPage = () => { route: radioRoute, label: t("navigation.radioConfig"), icon: RadioTowerIcon, - changeCount: configChangeCount, + changeCount: radioConfigChangeCount, component: RadioConfig, }, { @@ -70,7 +72,7 @@ const ConfigPage = () => { route: deviceRoute, label: t("navigation.deviceConfig"), icon: RouterIcon, - changeCount: moduleConfigChangeCount, + changeCount: deviceConfigChangeCount, component: DeviceConfig, }, { @@ -78,11 +80,11 @@ const ConfigPage = () => { route: moduleRoute, label: t("navigation.moduleConfig"), icon: LayersIcon, - changeCount: channelChangeCount, + changeCount: moduleConfigChangeCount, component: ModuleConfig, }, ], - [t, configChangeCount, moduleConfigChangeCount, channelChangeCount], + [t, radioConfigChangeCount, deviceConfigChangeCount, moduleConfigChangeCount], ); const activeSection = @@ -263,7 +265,7 @@ const ConfigPage = () => { getModuleConfigChangeCount() > 0 || getChannelChangeCount() > 0 || adminMessageChangeCount > 0; - const hasPending = hasDrafts || rhfState.isDirty; + const hasPending = hasDrafts; const buttonOpacity = hasPending ? "opacity-100" : "opacity-0"; const saveDisabled = isSaving || !rhfState.isValid || !hasPending;