From 959a7f58bcb6ef6900abfdaf85969df3b3773698 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:05:56 -0400 Subject: [PATCH 01/15] fix(sdk): keep config fields a form doesn't render when staging a section setRadioSection/setModuleSection replaced the whole section with the form's zod output, which strips keys the schema doesn't declare. Saving a section therefore sent protobuf defaults for every field without a form control (e.g. LoRa paFanDisabled/ignoreIncoming, Power lsSecs, RangeTest clearOnReboot). Merge the staged value over the current working/baseline section instead. --- .../src/features/config/ConfigEditor.test.ts | 68 +++++++++++++++++++ .../features/config/domain/ConfigEditor.ts | 20 +++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/features/config/ConfigEditor.test.ts b/packages/sdk/src/features/config/ConfigEditor.test.ts index fbf7d7cef..18bddabd4 100644 --- a/packages/sdk/src/features/config/ConfigEditor.test.ts +++ b/packages/sdk/src/features/config/ConfigEditor.test.ts @@ -111,6 +111,74 @@ describe("ConfigEditor", () => { expect(editor.dirtyRadioSections.value).toEqual(["lora"]); }); + it("keeps radio fields a partial update leaves out", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const editor = client.config.editor; + + client.events.onConfigPacket.dispatch( + create(Protobuf.Config.ConfigSchema, { + payloadVariant: { + case: "lora", + value: create(Protobuf.Config.Config_LoRaConfigSchema, { + region: 4, + paFanDisabled: true, + ignoreIncoming: [1234], + }), + }, + }), + ); + + // Form data after zod validation: only the fields the form renders. + editor.setRadioSection("lora", { + region: 7, + } as unknown as Protobuf.Config.Config_LoRaConfig); + + const lora = editor.radio.value.lora; + expect(lora?.region).toBe(7); + expect(lora?.paFanDisabled).toBe(true); + expect(lora?.ignoreIncoming).toEqual([1234]); + expect(editor.dirtyRadioSections.value).toEqual(["lora"]); + }); + + it("keeps module fields a partial update leaves out", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const editor = client.config.editor; + + client.events.onModuleConfigPacket.dispatch( + create(Protobuf.ModuleConfig.ModuleConfigSchema, { + payloadVariant: { + case: "rangeTest", + value: create( + Protobuf.ModuleConfig.ModuleConfig_RangeTestConfigSchema, + { enabled: false, clearOnReboot: true }, + ), + }, + }), + ); + + editor.setModuleSection("rangeTest", { + enabled: true, + } as unknown as Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig); + + expect(editor.modules.value.rangeTest?.enabled).toBe(true); + expect(editor.modules.value.rangeTest?.clearOnReboot).toBe(true); + }); + + it("a partial update equal to the baseline is not dirty", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const editor = client.config.editor; + + client.events.onConfigPacket.dispatch(loraPacket(4)); + editor.setRadioSection("lora", { + region: 4, + } as unknown as Protobuf.Config.Config_LoRaConfig); + + expect(editor.isDirty.value).toBe(false); + }); + it("disconnect drops baseline and working", () => { const { transport } = createFakeTransport(); const client = new MeshClient({ transport }); diff --git a/packages/sdk/src/features/config/domain/ConfigEditor.ts b/packages/sdk/src/features/config/domain/ConfigEditor.ts index f6b9b9815..a0a5a67a2 100644 --- a/packages/sdk/src/features/config/domain/ConfigEditor.ts +++ b/packages/sdk/src/features/config/domain/ConfigEditor.ts @@ -199,19 +199,35 @@ export class ConfigEditor { this.recomputeDirty(); } + /** + * Stage a radio config section. `value` is merged over the current working + * (or baseline) section, so fields a form doesn't render keep the device's + * value instead of being sent as protobuf defaults. + */ public setRadioSection( key: K, value: RadioConfig[K], ): void { - this.workingRadio.value = { ...this.workingRadio.peek(), [key]: value }; + const working = this.workingRadio.peek(); + const current = working[key] ?? this.baselineRadio.peek()[key]; + this.workingRadio.value = { + ...working, + [key]: current && value ? { ...current, ...value } : value, + }; this.recomputeDirty(); } + /** Stage a module config section, merged like {@link setRadioSection}. */ public setModuleSection( key: K, value: ModuleConfig[K], ): void { - this.workingModules.value = { ...this.workingModules.peek(), [key]: value }; + const working = this.workingModules.peek(); + const current = working[key] ?? this.baselineModules.peek()[key]; + this.workingModules.value = { + ...working, + [key]: current && value ? { ...current, ...value } : value, + }; this.recomputeDirty(); } From 151fadd50f95aedb20a527c533a5915ba73e475e Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:10:12 -0400 Subject: [PATCH 02/15] feat(web): restructure Settings navigation to match the Android app Settings now follows Android's SettingsScreen: a Configuration group with User, LoRa, Channels and Security as their own pages, Device and Module configuration as tabbed pages, and Administration; then Backup & Restore, Advanced, App and Information groups (pages added in follow-up commits). - One route per page; Device/Module tabs live in ?tab= keyed by the section id instead of the translated label, so they can be linked. - Module tabs follow Android's order. Status Message moves to User. - Network and Bluetooth tabs hide on hardware without them. - Sidebar unsaved-change counts now match their section (they were shifted by one section and never counted the owner). - /settings/radio redirects to /settings/lora; the command palette's "Go to config" pointed at a non-existent /config route. - The app sidebar keeps Settings highlighted on its sub-pages. --- apps/web/public/i18n/locales/en/config.json | 23 +- .../src/components/CommandPalette/index.tsx | 2 +- .../src/components/Dialog/ImportDialog.tsx | 2 +- .../src/components/RegionSetupReminder.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 4 +- apps/web/src/pages/Settings/About.tsx | 3 + .../web/src/pages/Settings/Administration.tsx | 3 + apps/web/src/pages/Settings/AppSettings.tsx | 3 + apps/web/src/pages/Settings/BackupRestore.tsx | 3 + apps/web/src/pages/Settings/CleanNodeDb.tsx | 3 + apps/web/src/pages/Settings/DebugPanel.tsx | 3 + apps/web/src/pages/Settings/DeviceConfig.tsx | 128 +++---- apps/web/src/pages/Settings/ModuleConfig.tsx | 214 +++++------- apps/web/src/pages/Settings/RadioConfig.tsx | 111 ------- apps/web/src/pages/Settings/SettingsTabs.tsx | 80 +++++ apps/web/src/pages/Settings/index.tsx | 311 ++++++++++++++---- apps/web/src/routes.tsx | 71 ++-- 17 files changed, 543 insertions(+), 423 deletions(-) create mode 100644 apps/web/src/pages/Settings/About.tsx create mode 100644 apps/web/src/pages/Settings/Administration.tsx create mode 100644 apps/web/src/pages/Settings/AppSettings.tsx create mode 100644 apps/web/src/pages/Settings/BackupRestore.tsx create mode 100644 apps/web/src/pages/Settings/CleanNodeDb.tsx create mode 100644 apps/web/src/pages/Settings/DebugPanel.tsx delete mode 100644 apps/web/src/pages/Settings/RadioConfig.tsx create mode 100644 apps/web/src/pages/Settings/SettingsTabs.tsx diff --git a/apps/web/public/i18n/locales/en/config.json b/apps/web/public/i18n/locales/en/config.json index 9cb03c4ab..ca8b94339 100644 --- a/apps/web/public/i18n/locales/en/config.json +++ b/apps/web/public/i18n/locales/en/config.json @@ -13,7 +13,28 @@ "tabSecurity": "Security" }, "sidebar": { - "label": "Configuration" + "label": "Configuration", + "configuration": "Configuration", + "backupRestore": "Backup & Restore", + "advanced": "Advanced", + "app": "App", + "information": "Information" + }, + "navigation": { + "user": "User", + "lora": "LoRa", + "channels": "Channels", + "security": "Security", + "deviceConfiguration": "Device configuration", + "moduleConfiguration": "Module configuration", + "administration": "Administration", + "deviceProfile": "Import / export configuration", + "firmwareUpdate": "Firmware update", + "cleanNodeDb": "Clean node database", + "debugPanel": "Debug panel", + "appSettings": "App settings", + "about": "About", + "help": "Help & documentation" }, "device": { "title": "Device", diff --git a/apps/web/src/components/CommandPalette/index.tsx b/apps/web/src/components/CommandPalette/index.tsx index 75590d020..628c7998f 100644 --- a/apps/web/src/components/CommandPalette/index.tsx +++ b/apps/web/src/components/CommandPalette/index.tsx @@ -98,7 +98,7 @@ export const CommandPalette = () => { label: t("goto.command.config"), icon: SettingsIcon, action() { - navigate({ to: "/config" }); + navigate({ to: "/settings" }); }, tags: ["settings"], }, diff --git a/apps/web/src/components/Dialog/ImportDialog.tsx b/apps/web/src/components/Dialog/ImportDialog.tsx index 51d0d2fbd..835a3a598 100644 --- a/apps/web/src/components/Dialog/ImportDialog.tsx +++ b/apps/web/src/components/Dialog/ImportDialog.tsx @@ -121,7 +121,7 @@ export const ImportDialog = ({ } handleOpenChange(false); // Apply only stages the change; take a beacon join to where it's saved. - if (joining) navigate({ to: "/settings/radio" }); + if (joining) navigate({ to: "/settings/lora" }); }; const onSelectChange = (value: string, index: number) => { diff --git a/apps/web/src/components/RegionSetupReminder.tsx b/apps/web/src/components/RegionSetupReminder.tsx index 593ea47f5..35952c079 100644 --- a/apps/web/src/components/RegionSetupReminder.tsx +++ b/apps/web/src/components/RegionSetupReminder.tsx @@ -45,7 +45,7 @@ export const RegionSetupReminder = (): null => { className="w-full" onClick={() => { dismiss(); - navigate({ to: "/settings/radio" }); + navigate({ to: "/settings/lora" }); }} > {t("regionSetup.cta", { defaultValue: "Set region" })} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 908bf518a..c5c8cbf03 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -191,7 +191,9 @@ export const Sidebar = ({ children }: SidebarProps) => { navigate({ to: `/${link.page}` }); } }} - active={link.page === pathname} + active={ + pathname === link.page || pathname.startsWith(`${link.page}/`) + } disabled={myNode === undefined} /> ); diff --git a/apps/web/src/pages/Settings/About.tsx b/apps/web/src/pages/Settings/About.tsx new file mode 100644 index 000000000..3316982f9 --- /dev/null +++ b/apps/web/src/pages/Settings/About.tsx @@ -0,0 +1,3 @@ +import type { ConfigProps } from "./SettingsTabs.tsx"; + +export const About = (_props: ConfigProps) => null; diff --git a/apps/web/src/pages/Settings/Administration.tsx b/apps/web/src/pages/Settings/Administration.tsx new file mode 100644 index 000000000..ac7bf4bb8 --- /dev/null +++ b/apps/web/src/pages/Settings/Administration.tsx @@ -0,0 +1,3 @@ +import type { ConfigProps } from "./SettingsTabs.tsx"; + +export const Administration = (_props: ConfigProps) => null; diff --git a/apps/web/src/pages/Settings/AppSettings.tsx b/apps/web/src/pages/Settings/AppSettings.tsx new file mode 100644 index 000000000..fbfca2b3a --- /dev/null +++ b/apps/web/src/pages/Settings/AppSettings.tsx @@ -0,0 +1,3 @@ +import type { ConfigProps } from "./SettingsTabs.tsx"; + +export const AppSettings = (_props: ConfigProps) => null; diff --git a/apps/web/src/pages/Settings/BackupRestore.tsx b/apps/web/src/pages/Settings/BackupRestore.tsx new file mode 100644 index 000000000..f6325ea63 --- /dev/null +++ b/apps/web/src/pages/Settings/BackupRestore.tsx @@ -0,0 +1,3 @@ +import type { ConfigProps } from "./SettingsTabs.tsx"; + +export const BackupRestore = (_props: ConfigProps) => null; diff --git a/apps/web/src/pages/Settings/CleanNodeDb.tsx b/apps/web/src/pages/Settings/CleanNodeDb.tsx new file mode 100644 index 000000000..1baad81a3 --- /dev/null +++ b/apps/web/src/pages/Settings/CleanNodeDb.tsx @@ -0,0 +1,3 @@ +import type { ConfigProps } from "./SettingsTabs.tsx"; + +export const CleanNodeDb = (_props: ConfigProps) => null; diff --git a/apps/web/src/pages/Settings/DebugPanel.tsx b/apps/web/src/pages/Settings/DebugPanel.tsx new file mode 100644 index 000000000..3e4103d94 --- /dev/null +++ b/apps/web/src/pages/Settings/DebugPanel.tsx @@ -0,0 +1,3 @@ +import type { ConfigProps } from "./SettingsTabs.tsx"; + +export const DebugPanel = (_props: ConfigProps) => null; diff --git a/apps/web/src/pages/Settings/DeviceConfig.tsx b/apps/web/src/pages/Settings/DeviceConfig.tsx index cf1b57b1c..83d103b7f 100644 --- a/apps/web/src/pages/Settings/DeviceConfig.tsx +++ b/apps/web/src/pages/Settings/DeviceConfig.tsx @@ -4,30 +4,16 @@ import { Display } from "@components/PageComponents/Settings/Display.tsx"; import { Network } from "@components/PageComponents/Settings/Network/index.tsx"; import { Position } from "@components/PageComponents/Settings/Position.tsx"; import { Power } from "@components/PageComponents/Settings/Power.tsx"; -import { User } from "@components/PageComponents/Settings/User.tsx"; -import { Spinner } from "@components/UI/Spinner.tsx"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@components/UI/Tabs.tsx"; +import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import type { ValidConfigType } from "@core/stores"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; -import { type ComponentType, Suspense, useMemo } from "react"; -import type { UseFormReturn } from "react-hook-form"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; - -interface ConfigProps { - onFormInit: (methods: UseFormReturn) => void; -} - -type TabItem = { - case: ValidConfigType | "user"; - label: string; - element: ComponentType; - count?: number; -}; +import { + type ConfigProps, + type SettingsTab, + SettingsTabs, +} from "./SettingsTabs.tsx"; const EMPTY_DIRTY_RADIO_SIGNAL = { value: [] as readonly string[], @@ -35,101 +21,63 @@ const EMPTY_DIRTY_RADIO_SIGNAL = { subscribe: () => () => {}, } as const; -const EMPTY_OWNER_DIRTY_SIGNAL = { - value: false, - peek: () => false, - subscribe: () => () => {}, -} as const; +type DeviceTab = SettingsTab & { key: ValidConfigType; shown: boolean }; +/** Device configuration, in the order of Android's deviceConfigRoutes. */ export const DeviceConfig = ({ onFormInit }: ConfigProps) => { const editor = useConfigEditor(); const dirtyRadio = useSignal( editor?.dirtyRadioSections ?? EMPTY_DIRTY_RADIO_SIGNAL, ); - const isOwnerDirty = useSignal( - editor?.isOwnerDirty ?? EMPTY_OWNER_DIRTY_SIGNAL, - ); + const { metadata } = useDeviceCapabilities(); const { t } = useTranslation("config"); - const tabs: TabItem[] = useMemo( - () => [ - { - case: "user", - label: t("page.tabUser"), - element: User, - }, + + const tabs: SettingsTab[] = useMemo(() => { + // Until metadata arrives, show every tab, as Android does. + const hasNetwork = !metadata || metadata.hasWifi || metadata.hasEthernet; + const hasBluetooth = !metadata || metadata.hasBluetooth; + const all: DeviceTab[] = [ { - case: "device", + key: "device", label: t("page.tabDevice"), element: Device, + shown: true, }, { - case: "position", + key: "position", label: t("page.tabPosition"), element: Position, + shown: true, }, + { key: "power", label: t("page.tabPower"), element: Power, shown: true }, { - case: "power", - label: t("page.tabPower"), - element: Power, - }, - { - case: "network", + key: "network", label: t("page.tabNetwork"), element: Network, + shown: hasNetwork, }, { - case: "display", + key: "display", label: t("page.tabDisplay"), element: Display, + shown: true, }, { - case: "bluetooth", + key: "bluetooth", label: t("page.tabBluetooth"), element: Bluetooth, + shown: hasBluetooth, }, - ], - [t], - ); - - const flags = useMemo( - () => - new Map( - tabs.map((tab) => [ - tab.case, - tab.case === "user" - ? isOwnerDirty - : dirtyRadio.includes(tab.case as ValidConfigType), - ]), - ), - [tabs, dirtyRadio, isOwnerDirty], - ); + ]; + return all + .filter((tab) => tab.shown) + .map((tab) => ({ + key: tab.key, + label: tab.label, + element: tab.element, + isDirty: dirtyRadio.includes(tab.key), + })); + }, [t, metadata, dirtyRadio]); - return ( - - - {tabs.map((tab) => ( - - {tab.label} - {flags.get(tab.case) && ( - - - - - )} - - ))} - - {tabs.map((tab) => ( - - }> - - - - ))} - - ); + return ; }; diff --git a/apps/web/src/pages/Settings/ModuleConfig.tsx b/apps/web/src/pages/Settings/ModuleConfig.tsx index 4d88c02b6..ee05ad1c1 100644 --- a/apps/web/src/pages/Settings/ModuleConfig.tsx +++ b/apps/web/src/pages/Settings/ModuleConfig.tsx @@ -10,35 +10,20 @@ import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.t import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.tsx"; import { RemoteHardware } from "@components/PageComponents/ModuleConfig/RemoteHardware.tsx"; import { Serial } from "@components/PageComponents/ModuleConfig/Serial.tsx"; -import { StatusMessage } from "@components/PageComponents/ModuleConfig/StatusMessage.tsx"; import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.tsx"; import { Tak } from "@components/PageComponents/ModuleConfig/Tak.tsx"; import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx"; -import { Spinner } from "@components/UI/Spinner.tsx"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@components/UI/Tabs.tsx"; import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import type { ValidModuleConfigType } from "@core/stores"; import { isModuleExcluded } from "@core/utils/excludedModules.ts"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; -import { type ComponentType, Suspense, useMemo } from "react"; -import type { UseFormReturn } from "react-hook-form"; +import { type ComponentType, useMemo } from "react"; import { useTranslation } from "react-i18next"; - -interface ConfigProps { - onFormInit: (methods: UseFormReturn) => void; -} - -type TabItem = { - case: ValidModuleConfigType; - label: string; - element: ComponentType; - count?: number; -}; +import { + type ConfigProps, + type SettingsTab, + SettingsTabs, +} from "./SettingsTabs.tsx"; const EMPTY_DIRTY_MODULE_SIGNAL = { value: [] as readonly string[], @@ -46,6 +31,62 @@ const EMPTY_DIRTY_MODULE_SIGNAL = { subscribe: () => () => {}, } as const; +type ModuleTab = { + key: ValidModuleConfigType; + labelKey: string; + element: ComponentType; +}; + +/** + * Module configuration, in the order of Android's ModuleRoute. Status + * Message is not a tab: like Android, its one field lives on the User page. + */ +const MODULE_TABS: ModuleTab[] = [ + { key: "mqtt", labelKey: "page.tabMqtt", element: MQTT }, + { key: "serial", labelKey: "page.tabSerial", element: Serial }, + { + key: "externalNotification", + labelKey: "page.tabExternalNotification", + element: ExternalNotification, + }, + { + key: "storeForward", + labelKey: "page.tabStoreAndForward", + element: StoreForward, + }, + { key: "rangeTest", labelKey: "page.tabRangeTest", element: RangeTest }, + { key: "telemetry", labelKey: "page.tabTelemetry", element: Telemetry }, + { + key: "cannedMessage", + labelKey: "page.tabCannedMessage", + element: CannedMessage, + }, + { key: "audio", labelKey: "page.tabAudio", element: Audio }, + { + key: "remoteHardware", + labelKey: "page.tabRemoteHardware", + element: RemoteHardware, + }, + { + key: "neighborInfo", + labelKey: "page.tabNeighborInfo", + element: NeighborInfo, + }, + { + key: "ambientLighting", + labelKey: "page.tabAmbientLighting", + element: AmbientLighting, + }, + { + key: "detectionSensor", + labelKey: "page.tabDetectionSensor", + element: DetectionSensor, + }, + { key: "paxcounter", labelKey: "page.tabPaxcounter", element: Paxcounter }, + { key: "tak", labelKey: "page.tabTak", element: Tak }, + { key: "meshBeacon", labelKey: "page.tabMeshBeacon", element: MeshBeacon }, +]; + export const ModuleConfig = ({ onFormInit }: ConfigProps) => { const editor = useConfigEditor(); const dirtyModule = useSignal( @@ -53,127 +94,20 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => { ); const { t } = useTranslation("moduleConfig"); const { excludedModules } = useDeviceCapabilities(); - const allTabs: TabItem[] = useMemo( - () => [ - { - case: "mqtt", - label: t("page.tabMqtt"), - element: MQTT, - }, - { - case: "serial", - label: t("page.tabSerial"), - element: Serial, - }, - { - case: "externalNotification", - label: t("page.tabExternalNotification"), - element: ExternalNotification, - }, - { - case: "storeForward", - label: t("page.tabStoreAndForward"), - element: StoreForward, - }, - { - case: "rangeTest", - label: t("page.tabRangeTest"), - element: RangeTest, - }, - { - case: "telemetry", - label: t("page.tabTelemetry"), - element: Telemetry, - }, - { - case: "cannedMessage", - label: t("page.tabCannedMessage"), - element: CannedMessage, - }, - { - case: "audio", - label: t("page.tabAudio"), - element: Audio, - }, - { - case: "neighborInfo", - label: t("page.tabNeighborInfo"), - element: NeighborInfo, - }, - { - case: "ambientLighting", - label: t("page.tabAmbientLighting"), - element: AmbientLighting, - }, - { - case: "detectionSensor", - label: t("page.tabDetectionSensor"), - element: DetectionSensor, - }, - { - case: "paxcounter", - label: t("page.tabPaxcounter"), - element: Paxcounter, - }, - { - case: "remoteHardware", - label: t("page.tabRemoteHardware"), - element: RemoteHardware, - }, - { - case: "statusmessage", - label: t("page.tabStatusMessage"), - element: StatusMessage, - }, - { case: "tak", label: t("page.tabTak"), element: Tak }, - { - case: "meshBeacon", - label: t("page.tabMeshBeacon"), - element: MeshBeacon, - }, - ], - [t], - ); - // Hide the modules this firmware build was compiled without. - const tabs = useMemo( - () => allTabs.filter((tab) => !isModuleExcluded(excludedModules, tab.case)), - [allTabs, excludedModules], - ); - const flags = useMemo( + // Hide the modules this firmware build was compiled without. + const tabs: SettingsTab[] = useMemo( () => - new Map(tabs.map((tab) => [tab.case, dirtyModule.includes(tab.case)])), - [tabs, dirtyModule], + MODULE_TABS.filter( + (tab) => !isModuleExcluded(excludedModules, tab.key), + ).map((tab) => ({ + key: tab.key, + label: t(tab.labelKey), + element: tab.element, + isDirty: dirtyModule.includes(tab.key), + })), + [t, excludedModules, dirtyModule], ); - return ( - // Keyed on the first tab so the default moves on when metadata arriving - // later hides it. - - - {tabs.map((tab) => ( - - {tab.label} - {flags.get(tab.case) && ( - - - - - )} - - ))} - - {tabs.map((tab) => ( - - }> - - - - ))} - - ); + return ; }; diff --git a/apps/web/src/pages/Settings/RadioConfig.tsx b/apps/web/src/pages/Settings/RadioConfig.tsx deleted file mode 100644 index 5fe88f68c..000000000 --- a/apps/web/src/pages/Settings/RadioConfig.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Channels } from "@app/components/PageComponents/Channels/Channels"; -import { LoRa } from "@components/PageComponents/Settings/LoRa.tsx"; -import { Security } from "@components/PageComponents/Settings/Security/Security.tsx"; -import { Spinner } from "@components/UI/Spinner.tsx"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@components/UI/Tabs.tsx"; -import type { ValidConfigType } from "@core/stores"; -import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; -import { type ComponentType, Suspense, useMemo } from "react"; -import type { UseFormReturn } from "react-hook-form"; -import { useTranslation } from "react-i18next"; - -interface ConfigProps { - onFormInit: (methods: UseFormReturn) => void; -} - -type TabItem = { - case: ValidConfigType | "channels"; - label: string; - element: ComponentType; - count?: number; -}; - -const EMPTY_DIRTY_RADIO_SIGNAL = { - value: [] as readonly string[], - peek: () => [] as readonly string[], - subscribe: () => () => {}, -} as const; - -const EMPTY_DIRTY_CHANNELS_SIGNAL = { - value: [] as readonly number[], - peek: () => [] as readonly number[], - subscribe: () => () => {}, -} as const; - -export const RadioConfig = ({ onFormInit }: ConfigProps) => { - const editor = useConfigEditor(); - const dirtyRadio = useSignal( - editor?.dirtyRadioSections ?? EMPTY_DIRTY_RADIO_SIGNAL, - ); - const dirtyChannels = useSignal( - editor?.dirtyChannels ?? EMPTY_DIRTY_CHANNELS_SIGNAL, - ); - const { t } = useTranslation("config"); - const tabs: TabItem[] = useMemo( - () => [ - { - case: "lora", - label: t("page.tabLora"), - element: LoRa, - }, - { - case: "channels", - label: t("page.tabChannels"), - element: Channels, - }, - { - case: "security", - label: t("page.tabSecurity"), - element: Security, - }, - ], - [t], - ); - - const flags = useMemo( - () => - new Map( - tabs.map((tab) => [ - tab.case, - tab.case === "channels" - ? dirtyChannels.length > 0 - : dirtyRadio.includes(tab.case), - ]), - ), - [tabs, dirtyRadio, dirtyChannels], - ); - - return ( - - - {tabs.map((tab) => ( - - {tab.label} - {flags.get(tab.case) && ( - - - - - )} - - ))} - - {tabs.map((tab) => ( - - }> - - - - ))} - - ); -}; diff --git a/apps/web/src/pages/Settings/SettingsTabs.tsx b/apps/web/src/pages/Settings/SettingsTabs.tsx new file mode 100644 index 000000000..be652d591 --- /dev/null +++ b/apps/web/src/pages/Settings/SettingsTabs.tsx @@ -0,0 +1,80 @@ +import { Spinner } from "@components/UI/Spinner.tsx"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@components/UI/Tabs.tsx"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { type ComponentType, type ReactNode, Suspense } from "react"; +import type { UseFormReturn } from "react-hook-form"; + +export interface ConfigProps { + onFormInit: (methods: UseFormReturn) => void; +} + +export interface SettingsTab { + /** Stable id, used in the `?tab=` search param. */ + key: string; + label: string; + element: ComponentType; + isDirty?: boolean; +} + +interface SettingsTabsProps extends ConfigProps { + tabs: SettingsTab[]; +} + +export const DirtyDot = () => ( + + + + +); + +/** Tabs whose selection lives in the URL (`?tab=`), so it can be linked. */ +export const SettingsTabs = ({ tabs, onFormInit }: SettingsTabsProps) => { + const navigate = useNavigate(); + const search = useSearch({ strict: false }) as { tab?: string }; + const active = tabs.find((tab) => tab.key === search.tab) ?? tabs[0]; + + if (!active) return null; + + return ( + + navigate({ to: ".", search: { tab }, replace: true }) + } + > + + {tabs.map((tab) => ( + + {tab.label} + {tab.isDirty && } + + ))} + + {tabs.map((tab) => ( + + }> + + + + ))} + + ); +}; + +/** Bordered card matching a tab panel, for pages that have no tabs. */ +export const SettingsCard = ({ children }: { children: ReactNode }) => ( +
+ }> + {children} + +
+); diff --git a/apps/web/src/pages/Settings/index.tsx b/apps/web/src/pages/Settings/index.tsx index 9a2081e09..03c183ec1 100644 --- a/apps/web/src/pages/Settings/index.tsx +++ b/apps/web/src/pages/Settings/index.tsx @@ -1,4 +1,7 @@ -import { deviceRoute, moduleRoute, radioRoute } from "@app/routes"; +import { Channels } from "@components/PageComponents/Channels/Channels.tsx"; +import { LoRa } from "@components/PageComponents/Settings/LoRa.tsx"; +import { Security } from "@components/PageComponents/Settings/Security/Security.tsx"; +import { User } from "@components/PageComponents/Settings/User.tsx"; import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; @@ -6,21 +9,46 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { useToast } from "@core/hooks/useToast.ts"; import { cn } from "@core/utils/cn.ts"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { About } from "@pages/Settings/About.tsx"; +import { Administration } from "@pages/Settings/Administration.tsx"; +import { AppSettings } from "@pages/Settings/AppSettings.tsx"; +import { BackupRestore } from "@pages/Settings/BackupRestore.tsx"; +import { CleanNodeDb } from "@pages/Settings/CleanNodeDb.tsx"; +import { DebugPanel } from "@pages/Settings/DebugPanel.tsx"; import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx"; import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { + ArchiveRestoreIcon, + BookOpenIcon, + BugIcon, + EraserIcon, + HardDriveUploadIcon, + InfoIcon, LayersIcon, + ListIcon, + type LucideIcon, + PaletteIcon, RadioTowerIcon, RefreshCwIcon, RouterIcon, SaveIcon, SaveOff, + ShieldAlertIcon, + ShieldIcon, + UserIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ComponentType, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import type { FieldValues, UseFormReturn } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { RadioConfig } from "./RadioConfig.tsx"; +import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx"; const EMPTY_DIRTY_STRING_SIGNAL = { value: [] as readonly string[], @@ -32,16 +60,59 @@ const EMPTY_DIRTY_NUMBER_SIGNAL = { peek: () => [] as readonly number[], subscribe: () => () => {}, } as const; +const EMPTY_BOOLEAN_SIGNAL = { + value: false, + peek: () => false, + subscribe: () => () => {}, +} as const; + +const DEVICE_SECTIONS = [ + "device", + "position", + "power", + "network", + "display", + "bluetooth", +]; + +const FIRMWARE_FLASHER_URL = "https://flasher.meshtastic.org"; +const DOCS_URL = "https://meshtastic.org/docs/"; + +const carded = + (Form: ComponentType): ComponentType => + ({ onFormInit }) => ( + +
+ + ); + +const UserPage = carded(User); +const LoRaPage = carded(LoRa); +const ChannelsPage = carded(Channels); +const SecurityPage = carded(Security); + +type SettingsSection = { + /** Path segment under /settings. */ + key: string; + label: string; + icon: LucideIcon; + /** Opens in a new tab instead of rendering a page. */ + href?: string; + component?: ComponentType; + /** Shows Save / Reset; true for pages that edit device config. */ + edits?: boolean; + changeCount?: number; +}; + +type SettingsGroup = { + key: string; + label: string; + sections: SettingsSection[]; +}; const ConfigPage = () => { const editor = useConfigEditor(); - const editorIsDirty = useSignal( - editor?.isDirty ?? { - value: false, - peek: () => false, - subscribe: () => () => {}, - }, - ); + const editorIsDirty = useSignal(editor?.isDirty ?? EMPTY_BOOLEAN_SIGNAL); const dirtyRadio = useSignal( editor?.dirtyRadioSections ?? EMPTY_DIRTY_STRING_SIGNAL, ); @@ -51,6 +122,7 @@ const ConfigPage = () => { const dirtyChannels = useSignal( editor?.dirtyChannels ?? EMPTY_DIRTY_NUMBER_SIGNAL, ); + const isOwnerDirty = useSignal(editor?.isOwnerDirty ?? EMPTY_BOOLEAN_SIGNAL); const [isSaving, setIsSaving] = useState(false); const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true }); @@ -58,54 +130,161 @@ const ConfigPage = () => { const [formMethods, setFormMethods] = useState(null); const { toast } = useToast(); const navigate = useNavigate(); - const routerState = useRouterState(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); const { t } = useTranslation("config"); - const configChangeCount = dirtyRadio.length; - const moduleConfigChangeCount = dirtyModule.length; - const channelChangeCount = dirtyChannels.length; - - const sections = useMemo( - () => [ + // Grouped like Android's Settings screen. + const groups: SettingsGroup[] = useMemo(() => { + const radioCount = (key: string) => (dirtyRadio.includes(key) ? 1 : 0); + // The status message is a module field that lives on the User page. + const statusDirty = dirtyModule.includes("statusmessage") ? 1 : 0; + return [ { - key: "radio", - route: radioRoute, - label: t("navigation.radioConfig"), - icon: RadioTowerIcon, - changeCount: configChangeCount, - component: RadioConfig, + key: "configuration", + label: t("sidebar.configuration"), + sections: [ + { + key: "user", + label: t("navigation.user"), + icon: UserIcon, + component: UserPage, + edits: true, + changeCount: (isOwnerDirty ? 1 : 0) + statusDirty, + }, + { + key: "lora", + label: t("navigation.lora"), + icon: RadioTowerIcon, + component: LoRaPage, + edits: true, + changeCount: radioCount("lora"), + }, + { + key: "channels", + label: t("navigation.channels"), + icon: ListIcon, + component: ChannelsPage, + edits: true, + changeCount: dirtyChannels.length, + }, + { + key: "security", + label: t("navigation.security"), + icon: ShieldIcon, + component: SecurityPage, + edits: true, + changeCount: radioCount("security"), + }, + { + key: "device", + label: t("navigation.deviceConfiguration"), + icon: RouterIcon, + component: DeviceConfig, + edits: true, + changeCount: dirtyRadio.filter((key) => + DEVICE_SECTIONS.includes(key), + ).length, + }, + { + key: "module", + label: t("navigation.moduleConfiguration"), + icon: LayersIcon, + component: ModuleConfig, + edits: true, + changeCount: dirtyModule.length - statusDirty, + }, + { + key: "administration", + label: t("navigation.administration"), + icon: ShieldAlertIcon, + component: Administration, + }, + ], }, { - key: "device", - route: deviceRoute, - label: t("navigation.deviceConfig"), - icon: RouterIcon, - changeCount: moduleConfigChangeCount, - component: DeviceConfig, + key: "backupRestore", + label: t("sidebar.backupRestore"), + sections: [ + { + key: "backup", + label: t("navigation.deviceProfile"), + icon: ArchiveRestoreIcon, + component: BackupRestore, + }, + ], }, { - key: "module", - route: moduleRoute, - label: t("navigation.moduleConfig"), - icon: LayersIcon, - changeCount: channelChangeCount, - component: ModuleConfig, + key: "advanced", + label: t("sidebar.advanced"), + sections: [ + { + key: "firmware", + label: t("navigation.firmwareUpdate"), + icon: HardDriveUploadIcon, + href: FIRMWARE_FLASHER_URL, + }, + { + key: "clean-nodes", + label: t("navigation.cleanNodeDb"), + icon: EraserIcon, + component: CleanNodeDb, + }, + { + key: "debug", + label: t("navigation.debugPanel"), + icon: BugIcon, + component: DebugPanel, + }, + ], }, - ], - [t, configChangeCount, moduleConfigChangeCount, channelChangeCount], - ); + { + key: "app", + label: t("sidebar.app"), + sections: [ + { + key: "app", + label: t("navigation.appSettings"), + icon: PaletteIcon, + component: AppSettings, + }, + ], + }, + { + key: "information", + label: t("sidebar.information"), + sections: [ + { + key: "about", + label: t("navigation.about"), + icon: InfoIcon, + component: About, + }, + { + key: "help", + label: t("navigation.help"), + icon: BookOpenIcon, + href: DOCS_URL, + }, + ], + }, + ]; + }, [t, dirtyRadio, dirtyModule, dirtyChannels, isOwnerDirty]); + const sections = useMemo( + () => groups.flatMap((group) => group.sections), + [groups], + ); + const segment = pathname.split("/")[2] ?? ""; const activeSection = - sections.find((section) => - routerState.location.pathname.includes(`/settings/${section.key}`), - ) ?? sections[0]; + sections.find((section) => section.component && section.key === segment) ?? + sections[0]; const onFormInit = useCallback( (methods: UseFormReturn) => { setFormMethods(methods as UseFormReturn); setRhfState({ - // Assume defailt on init, changes will be caught by subscription + // Assume default on init, changes will be caught by subscription isDirty: false, isValid: true, }); @@ -169,25 +348,42 @@ const ConfigPage = () => { editor?.reset(); }, [formMethods, editor]); + const openSection = useCallback( + (section: SettingsSection) => { + if (section.href) { + window.open(section.href, "_blank", "noopener,noreferrer"); + return; + } + navigate({ to: `/settings/${section.key}` }); + }, + [navigate], + ); + const leftSidebar = useMemo( () => ( - - {sections.map((section) => ( - navigate({ to: section.route.to })} - Icon={section.icon} - isDirty={section.changeCount > 0} - count={section.changeCount} - /> - ))} - + {groups.map((group) => ( + + {group.sections.map((section) => ( + openSection(section)} + Icon={section.icon} + isDirty={(section.changeCount ?? 0) > 0} + count={section.changeCount} + /> + ))} + + ))} ), - [sections, activeSection?.key, navigate, t], + [groups, activeSection?.key, openSection], ); const hasDrafts = editorIsDirty; @@ -256,7 +452,8 @@ const ConfigPage = () => { contentClassName="overflow-auto" leftBar={leftSidebar} label={activeSection?.label ?? ""} - actions={actions} + // Pending edits stay visible from any page so they aren't forgotten. + actions={activeSection?.edits || hasPending ? actions : []} > {ActiveComponent && } diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 565f83f42..927aa1dae 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -112,30 +112,47 @@ export const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/settings", component: ConfigPage, - // beforeLoad: () => { - // throw redirect({ - // to: "/settings/radio", - // replace: true, - // }); - // }, }); -export const radioRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "radio", - component: ConfigPage, -}); - -export const deviceRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "device", - component: ConfigPage, +const settingsTabSearchSchema = z.object({ + tab: z.string().optional(), }); -export const moduleRoute = createRoute({ +const settingsPage = (path: string) => + createRoute({ + getParentRoute: () => settingsRoute, + path, + component: ConfigPage, + }); + +const settingsTabbedPage = (path: string) => + createRoute({ + getParentRoute: () => settingsRoute, + path, + component: ConfigPage, + validateSearch: (search) => settingsTabSearchSchema.parse(search), + }); + +export const userRoute = settingsPage("user"); +export const loraRoute = settingsPage("lora"); +export const channelsRoute = settingsPage("channels"); +export const securityRoute = settingsPage("security"); +export const deviceRoute = settingsTabbedPage("device"); +export const moduleRoute = settingsTabbedPage("module"); +export const administrationRoute = settingsPage("administration"); +export const backupRestoreRoute = settingsPage("backup"); +export const cleanNodeDbRoute = settingsPage("clean-nodes"); +export const debugRoute = settingsPage("debug"); +export const appSettingsRoute = settingsPage("app"); +export const aboutRoute = settingsPage("about"); + +// Radio Config used to group LoRa, Channels and Security. +const legacyRadioRoute = createRoute({ getParentRoute: () => settingsRoute, - path: "module", - component: ConfigPage, + path: "radio", + beforeLoad: () => { + throw redirect({ to: "/settings/lora", replace: true }); + }, }); const nodesRoute = createRoute({ @@ -162,7 +179,21 @@ export const routeTree = rootRoute.addChildren([ messagesWithParamsRoute, mapRoute, mapWithParamsRoute, - settingsRoute.addChildren([radioRoute, deviceRoute, moduleRoute]), + settingsRoute.addChildren([ + userRoute, + loraRoute, + channelsRoute, + securityRoute, + deviceRoute, + moduleRoute, + administrationRoute, + backupRestoreRoute, + cleanNodeDbRoute, + debugRoute, + appSettingsRoute, + aboutRoute, + legacyRadioRoute, + ]), nodesRoute, dialogWithParamsRoute, connectionsRoute, From 49ce480ecee216094044d5180bad1f3fb5f26c2f Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:25:38 -0400 Subject: [PATCH 03/15] feat(web): Android-style Channels page Replace the tab-per-channel form with Android's channel list: cards with location/uplink/downlink/lock icons, a Primary/Secondary legend, add and delete, and an edit dialog (name, PSK, MQTT uplink/downlink, position and precision). Edits are staged through the config editor and keep fields the dialog doesn't show (useAead, id, isMuted). Drag-to-reorder is not included: chat history is keyed by channel index, so reordering would attach messages to the wrong channel. --- apps/web/public/i18n/locales/en/channels.json | 100 +++-- .../PageComponents/Channels/Channel.tsx | 296 -------------- .../PageComponents/Channels/ChannelCard.tsx | 165 ++++++++ .../PageComponents/Channels/ChannelLegend.tsx | 151 +++++++ .../PageComponents/Channels/Channels.tsx | 260 ++++++++---- .../Channels/EditChannelDialog.test.tsx | 121 ++++++ .../Channels/EditChannelDialog.tsx | 311 +++++++++++++++ .../Channels/channelStaging.test.ts | 369 ++++++++++++++++++ .../PageComponents/Channels/channelStaging.ts | 324 +++++++++++++++ apps/web/src/validation/channel.test.ts | 162 +++----- apps/web/src/validation/channel.ts | 65 +-- 11 files changed, 1783 insertions(+), 541 deletions(-) delete mode 100644 apps/web/src/components/PageComponents/Channels/Channel.tsx create mode 100644 apps/web/src/components/PageComponents/Channels/ChannelCard.tsx create mode 100644 apps/web/src/components/PageComponents/Channels/ChannelLegend.tsx create mode 100644 apps/web/src/components/PageComponents/Channels/EditChannelDialog.test.tsx create mode 100644 apps/web/src/components/PageComponents/Channels/EditChannelDialog.tsx create mode 100644 apps/web/src/components/PageComponents/Channels/channelStaging.test.ts create mode 100644 apps/web/src/components/PageComponents/Channels/channelStaging.ts diff --git a/apps/web/public/i18n/locales/en/channels.json b/apps/web/public/i18n/locales/en/channels.json index d3ca7e029..b1a0f5985 100644 --- a/apps/web/public/i18n/locales/en/channels.json +++ b/apps/web/public/i18n/locales/en/channels.json @@ -7,49 +7,97 @@ "import": "Import", "export": "Export" }, - "validation": { - "pskInvalid": "Please enter a valid {{bits}} bit PSK." + "header": { + "frequency": "Freq: {{frequency}} MHz", + "slot": "Slot: {{slot}}", + "slotAuto": "Slot: Auto" }, - "settings": { - "label": "Channel Settings", - "description": "Crypto, MQTT & misc settings" + "list": { + "add": "Add channel", + "edit": "Edit {{name}}", + "delete": "Delete {{name}}" }, - "role": { - "label": "Role", - "description": "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed", - "options": { - "primary": "PRIMARY", - "disabled": "DISABLED", - "secondary": "SECONDARY" + "legend": { + "primary": "Primary", + "secondary": "Secondary", + "dialog": { + "title": "Channel Features", + "description": "What the primary and secondary channels do and what the channel icons mean.", + "primaryFeature": "Periodic position and telemetry broadcast", + "secondaryNoTelemetry": "No periodic telemetry broadcast", + "secondaryPositionFeature": "Disabling position on the primary channel allows periodic position broadcasts on the first secondary channel with the position enabled, otherwise manual position request required.", + "manualPositionRequest": "Manual position request required", + "iconMeanings": "Icon Meanings" + } + }, + "icons": { + "location": { + "label": "Location Sharing", + "description": "Periodic position broadcast" + }, + "uplink": { + "label": "MQTT Uplink Enabled", + "description": "Messages from the mesh will be sent to the public internet through any node's configured gateway." + }, + "downlink": { + "label": "MQTT Downlink Enabled", + "description": "Messages from a public internet gateway are forwarded to the local mesh. Due to the zero-hop policy, traffic from the default MQTT server will not propagate further than this device." + } + }, + "security": { + "label": "Security Status", + "secure": { + "label": "Secure", + "description": "A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." + }, + "insecureNoPrecise": { + "label": "Insecure Channel, Not Precise", + "description": "A yellow open lock means the channel is not securely encrypted, is not used for precise location data, and uses either no key at all or a 1 byte known key." + }, + "insecurePreciseOnly": { + "label": "Insecure Channel, Precise Location", + "description": "A red open lock means the channel is not securely encrypted, is used for precise location data, and uses either no key at all or a 1 byte known key." + }, + "insecurePreciseMqtt": { + "label": "Warning: Insecure, Precise Location & MQTT Uplink", + "description": "A red open lock with a warning means the channel is not securely encrypted, is used for precise location data which is being uplinked to the internet via MQTT, and uses either no key at all or a 1 byte known key." } }, + "editDialog": { + "addTitle": "Add channel", + "editTitle": "Edit channel {{index}}", + "description": "Changes are sent to the radio when you save the settings." + }, + "validation": { + "pskInvalid": "Please enter a valid {{bits}} bit PSK." + }, + "name": { + "label": "Channel Name", + "description": "Up to 11 bytes. Leave blank to use the default name." + }, "psk": { - "label": "Pre-Shared Key", + "label": "PSK", "description": "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)", "generate": "Generate" }, - "name": { - "label": "Name", - "description": "A unique name for the channel <12 bytes, leave blank for default" - }, "uplinkEnabled": { - "label": "Uplink Enabled", + "label": "MQTT Uplink Enabled", "description": "Send messages from the local mesh to MQTT" }, "downlinkEnabled": { - "label": "Downlink Enabled", + "label": "MQTT Downlink Enabled", "description": "Send messages from MQTT to the local mesh" }, - "useAead": { - "label": "Authenticated Encryption (Experimental)", - "description": "Encrypt with AES-CCM, which stops forged and tampered messages. Every node on this channel must turn this on: the channel then rejects standard AES-CTR packets, so nodes without it can no longer talk here." + "positionEnabled": { + "label": "Position enabled", + "description": "Share this node's position on this channel." + }, + "preciseLocation": { + "label": "Precise location" }, "positionPrecision": { - "label": "Location", - "description": "The precision of the location to share with the channel. Choose Position sharing off to share none.", + "label": "Location precision", "options": { - "none": "Position sharing off", - "precise": "Precise Location", "metric_km23": "Within 23 kilometers", "metric_km12": "Within 12 kilometers", "metric_km5_8": "Within 5.8 kilometers", diff --git a/apps/web/src/components/PageComponents/Channels/Channel.tsx b/apps/web/src/components/PageComponents/Channels/Channel.tsx deleted file mode 100644 index 862a7d9ce..000000000 --- a/apps/web/src/components/PageComponents/Channels/Channel.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import { - type ChannelValidation, - makeChannelSchema, -} from "@app/validation/channel.ts"; -import { create } from "@bufbuild/protobuf"; -import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; -import { createZodResolver } from "@components/Form/createZodResolver.ts"; -import { - DynamicForm, - type DynamicFormFormInit, -} from "@components/Form/DynamicForm.tsx"; -import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; -import { useDevice } from "@core/stores"; -import { Protobuf } from "@meshtastic/sdk"; -import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; -import { fromByteArray, toByteArray } from "base64-js"; -import cryptoRandomString from "crypto-random-string"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { type DefaultValues, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; - -export interface SettingsPanelProps { - onFormInit: DynamicFormFormInit; - channel: Protobuf.Channel.Channel; -} - -const EMPTY_CHANNELS_SIGNAL = { - value: new Map() as ReadonlyMap< - number, - Protobuf.Channel.Channel - >, - peek: () => - new Map() as ReadonlyMap< - number, - Protobuf.Channel.Channel - >, - subscribe: () => () => {}, -} as const; - -export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { - const { config } = useDevice(); - const editor = useConfigEditor(); - const editorChannels = useSignal(editor?.channels ?? EMPTY_CHANNELS_SIGNAL); - const { t } = useTranslation(["channels", "ui", "dialog"]); - const { supportsFirmware28 } = useDeviceCapabilities(); - - const defaultConfig = channel; - const defaultValues = { - ...defaultConfig, - ...{ - settings: { - ...defaultConfig?.settings, - psk: fromByteArray(defaultConfig?.settings?.psk ?? new Uint8Array(0)), - moduleSettings: { - ...defaultConfig?.settings?.moduleSettings, - positionPrecision: - defaultConfig?.settings?.moduleSettings?.positionPrecision === - undefined - ? 10 - : defaultConfig?.settings?.moduleSettings?.positionPrecision, - }, - }, - }, - }; - - const workingChannel = editorChannels.get(channel.index); - const effectiveConfig = workingChannel ?? channel; - const formValues = { - ...effectiveConfig, - ...{ - settings: { - ...effectiveConfig?.settings, - psk: fromByteArray(effectiveConfig?.settings?.psk ?? new Uint8Array(0)), - moduleSettings: { - ...effectiveConfig?.settings?.moduleSettings, - positionPrecision: - effectiveConfig?.settings?.moduleSettings?.positionPrecision === - undefined - ? 10 - : effectiveConfig?.settings?.moduleSettings?.positionPrecision, - }, - }, - }, - }; - - const [preSharedDialogOpen, setPreSharedDialogOpen] = - useState(false); - const [byteCount, setBytes] = useState( - effectiveConfig?.settings?.psk.length ?? 16, - ); - const ChannelValidationSchema = useMemo(() => { - return makeChannelSchema(byteCount); - }, [byteCount]); - - const formMethods = useForm({ - mode: "onChange", - defaultValues: defaultValues as DefaultValues, - resolver: createZodResolver(ChannelValidationSchema), - shouldFocusError: false, - resetOptions: { keepDefaultValues: true }, - values: formValues as ChannelValidation, - }); - const { setValue, trigger, handleSubmit, formState } = formMethods; - - useEffect(() => { - onFormInit?.(formMethods); - }, [onFormInit, formMethods]); - - // Since byteCount is an independent state, we need to use the effective value - // from the channel config to ensure the form updates when the setting changes - const effectiveByteCount = effectiveConfig.settings?.psk.length ?? 16; - const lastEffectiveRef = useRef(effectiveByteCount); - useEffect(() => { - if (effectiveByteCount !== lastEffectiveRef.current) { - lastEffectiveRef.current = effectiveByteCount; - - setBytes(effectiveByteCount); - trigger("settings.psk"); - } - }, [effectiveByteCount, trigger]); - - const onSubmit = (data: ChannelValidation) => { - if (!formState.isReady || !editor) { - return; - } - - const payload = create(Protobuf.Channel.ChannelSchema, { - ...data, - settings: { - ...data.settings, - psk: toByteArray(data.settings.psk), - moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, { - ...data.settings.moduleSettings, - positionPrecision: data.settings.moduleSettings.positionPrecision, - }), - }, - }); - - editor.setChannel(payload); - }; - - const preSharedKeyRegenerate = async () => { - const newPsk = btoa( - cryptoRandomString({ - length: byteCount ?? 16, - type: "alphanumeric", - }), - ); - setValue("settings.psk", newPsk, { shouldDirty: true }); - setPreSharedDialogOpen(false); - - const valid = await trigger("settings.psk"); - if (valid) { - handleSubmit(onSubmit)(); // manually invoke form submit - } - }; - - const selectChangeEvent = (e: string) => { - const count = Number.parseInt(e, 10); - if (!Number.isNaN(count)) { - setBytes(count); - trigger("settings.psk"); - } - }; - - return ( - <> - - propMethods={formMethods} - onSubmit={onSubmit} - fieldGroups={[ - { - label: t("settings.label"), - description: t("settings.description"), - fields: [ - { - type: "select", - name: "role", - label: t("role.label"), - disabled: channel.index === 0, - description: t("role.description"), - properties: { - enumValue: - channel.index === 0 - ? { [t("role.options.primary")]: 1 } - : { - [t("role.options.disabled")]: 0, - [t("role.options.secondary")]: 2, - }, - }, - }, - { - type: "passwordGenerator", - name: "settings.psk", - id: "channel-psk", - label: t("psk.label"), - description: t("psk.description"), - devicePSKBitCount: byteCount ?? 16, - selectChange: selectChangeEvent, - actionButtons: [ - { - text: t("psk.generate"), - variant: "success", - onClick: () => setPreSharedDialogOpen(true), - }, - ], - hide: true, - properties: { - showPasswordToggle: true, - showCopyButton: true, - }, - }, - { - type: "text", - name: "settings.name", - label: t("name.label"), - description: t("name.description"), - }, - { - type: "toggle", - name: "settings.uplinkEnabled", - label: t("uplinkEnabled.label"), - description: t("uplinkEnabled.description"), - }, - { - type: "toggle", - name: "settings.downlinkEnabled", - label: t("downlinkEnabled.label"), - description: t("downlinkEnabled.description"), - }, - // Firmware before 2.8 ignores use_aead. - ...(supportsFirmware28 - ? [ - { - type: "toggle" as const, - name: "settings.useAead" as const, - label: t("useAead.label"), - description: t("useAead.description"), - }, - ] - : []), - { - type: "select", - name: "settings.moduleSettings.positionPrecision", - label: t("positionPrecision.label"), - description: t("positionPrecision.description"), - properties: { - enumValue: - config.display?.units === 0 - ? { - [t("positionPrecision.options.none")]: 0, - [t("positionPrecision.options.metric_km23")]: 10, - [t("positionPrecision.options.metric_km12")]: 11, - [t("positionPrecision.options.metric_km5_8")]: 12, - [t("positionPrecision.options.metric_km2_9")]: 13, - [t("positionPrecision.options.metric_km1_5")]: 14, - [t("positionPrecision.options.metric_m700")]: 15, - [t("positionPrecision.options.metric_m350")]: 16, - [t("positionPrecision.options.metric_m200")]: 17, - [t("positionPrecision.options.metric_m90")]: 18, - [t("positionPrecision.options.metric_m50")]: 19, - [t("positionPrecision.options.precise")]: 32, - } - : { - [t("positionPrecision.options.none")]: 0, - [t("positionPrecision.options.imperial_mi15")]: 10, - [t("positionPrecision.options.imperial_mi7_3")]: 11, - [t("positionPrecision.options.imperial_mi3_6")]: 12, - [t("positionPrecision.options.imperial_mi1_8")]: 13, - [t("positionPrecision.options.imperial_mi0_9")]: 14, - [t("positionPrecision.options.imperial_mi0_5")]: 15, - [t("positionPrecision.options.imperial_mi0_2")]: 16, - [t("positionPrecision.options.imperial_ft600")]: 17, - [t("positionPrecision.options.imperial_ft300")]: 18, - [t("positionPrecision.options.imperial_ft150")]: 19, - [t("positionPrecision.options.precise")]: 32, - }, - }, - }, - ], - }, - ]} - /> - setPreSharedDialogOpen(false)} - onSubmit={() => preSharedKeyRegenerate()} - /> - - ); -}; diff --git a/apps/web/src/components/PageComponents/Channels/ChannelCard.tsx b/apps/web/src/components/PageComponents/Channels/ChannelCard.tsx new file mode 100644 index 000000000..7669df7f1 --- /dev/null +++ b/apps/web/src/components/PageComponents/Channels/ChannelCard.tsx @@ -0,0 +1,165 @@ +import { cn } from "@core/utils/cn.ts"; +import type { Protobuf } from "@meshtastic/sdk"; +import { + CloudDownloadIcon, + CloudUploadIcon, + LockIcon, + LockOpenIcon, + type LucideIcon, + MapPinIcon, + TriangleAlertIcon, + XIcon, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { type SecurityState, securityState } from "./channelStaging.ts"; + +const SECURITY_STYLE: Record< + SecurityState, + { icon: LucideIcon; className: string; badge?: boolean } +> = { + secure: { icon: LockIcon, className: "text-green-600 dark:text-green-500" }, + insecureNoPrecise: { + icon: LockOpenIcon, + className: "text-yellow-500 dark:text-yellow-400", + }, + insecurePreciseOnly: { + icon: LockOpenIcon, + className: "text-red-600 dark:text-red-500", + }, + insecurePreciseMqtt: { + icon: LockOpenIcon, + className: "text-red-600 dark:text-red-500", + badge: true, + }, +}; + +/** Lock icon for a channel's encryption, as on Android's SecurityIcon. */ +export function SecurityIcon({ + state, + className, +}: { + state: SecurityState; + className?: string; +}) { + const { t } = useTranslation("channels"); + const { icon: Icon, className: color, badge } = SECURITY_STYLE[state]; + const label = `${t("security.label")}: ${t(`security.${state}.label`)}`; + return ( + + + {badge && ( + + )} + + ); +} + +function FeatureIcon({ + icon: Icon, + label, +}: { + icon: LucideIcon; + label: string; +}) { + return ( + + + + ); +} + +export interface ChannelCardProps { + channel: Pick; + title: string; + sharesLocation: boolean; + /** The channel has staged changes that are not saved yet. */ + dirty: boolean; + onEdit: () => void; + /** Omitted for the primary channel, which can't be deleted. */ + onDelete?: () => void; +} + +/** One channel in the list (Android's ChannelCard). */ +export function ChannelCard({ + channel, + title, + sharesLocation, + dirty, + onEdit, + onDelete, +}: ChannelCardProps) { + const { t } = useTranslation("channels"); + const isPrimary = channel.index === 0; + const settings = channel.settings; + + return ( +
  • + + {onDelete ? ( + + ) : ( + + )} +
  • + ); +} diff --git a/apps/web/src/components/PageComponents/Channels/ChannelLegend.tsx b/apps/web/src/components/PageComponents/Channels/ChannelLegend.tsx new file mode 100644 index 000000000..b8bad1050 --- /dev/null +++ b/apps/web/src/components/PageComponents/Channels/ChannelLegend.tsx @@ -0,0 +1,151 @@ +import { Button } from "@components/UI/Button.tsx"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { + CloudDownloadIcon, + CloudUploadIcon, + InfoIcon, + type LucideIcon, + MapPinIcon, +} from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SecurityIcon } from "./ChannelCard.tsx"; +import type { SecurityState } from "./channelStaging.ts"; + +const FEATURE_ICONS: { key: string; icon: LucideIcon }[] = [ + { key: "location", icon: MapPinIcon }, + { key: "uplink", icon: CloudUploadIcon }, + { key: "downlink", icon: CloudDownloadIcon }, +]; + +const SECURITY_STATES: SecurityState[] = [ + "secure", + "insecureNoPrecise", + "insecurePreciseOnly", + "insecurePreciseMqtt", +]; + +function Meaning({ + icon, + title, + text, +}: { + icon: React.ReactNode; + title: string; + text: string; +}) { + return ( +
  • + + {icon} + + + {title} + + {text} + + +
  • + ); +} + +/** + * "Primary / Secondary" legend above the channel list. Clicking it explains + * the roles and the card icons (Android's ChannelLegend + ChannelLegendDialog). + */ +export function ChannelLegend({ + supportsSecondaryChannelLocation, +}: { + supportsSecondaryChannelLocation: boolean; +}) { + const { t } = useTranslation("channels"); + const [open, setOpen] = useState(false); + + return ( + <> + + + + + + + {t("legend.dialog.title")} + + {t("legend.dialog.description")} + + + +
    +

    + {t("legend.primary")} +

    +

    + - {t("legend.dialog.primaryFeature")} +

    +
    +
    +

    {t("legend.secondary")}

    +

    + - {t("legend.dialog.secondaryNoTelemetry")} +

    +

    + -{" "} + {supportsSecondaryChannelLocation + ? t("legend.dialog.secondaryPositionFeature") + : t("legend.dialog.manualPositionRequest")} +

    +
    + +
    +

    + {t("legend.dialog.iconMeanings")} +

    +
      + {FEATURE_ICONS.map(({ key, icon: Icon }) => ( + } + title={t(`icons.${key}.label`)} + text={t(`icons.${key}.description`)} + /> + ))} + {SECURITY_STATES.map((state) => ( + } + title={t(`security.${state}.label`)} + text={t(`security.${state}.description`)} + /> + ))} +
    +
    + + + + +
    +
    + + ); +} diff --git a/apps/web/src/components/PageComponents/Channels/Channels.tsx b/apps/web/src/components/PageComponents/Channels/Channels.tsx index 9702ea0bb..befef0f81 100644 --- a/apps/web/src/components/PageComponents/Channels/Channels.tsx +++ b/apps/web/src/components/PageComponents/Channels/Channels.tsx @@ -1,22 +1,29 @@ import { BeaconOffers } from "@app/components/PageComponents/Channels/BeaconOffers.tsx"; -import { Channel } from "@app/components/PageComponents/Channels/Channel"; -import { create } from "@bufbuild/protobuf"; import { Button } from "@components/UI/Button.tsx"; -import { Spinner } from "@components/UI/Spinner.tsx"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@components/UI/Tabs.tsx"; +import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import { useDevice } from "@core/stores"; -import { Protobuf } from "@meshtastic/sdk"; +import { firmwareAtLeast, type Protobuf } from "@meshtastic/sdk"; import { useChannels, useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import i18next from "i18next"; -import { QrCodeIcon, UploadIcon } from "lucide-react"; -import { Suspense, useMemo } from "react"; -import type { UseFormReturn } from "react-hook-form"; +import { PlusIcon, QrCodeIcon, UploadIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { type UseFormReturn, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { ChannelCard } from "./ChannelCard.tsx"; +import { ChannelLegend } from "./ChannelLegend.tsx"; +import { + buildDisabledChannel, + buildNewChannel, + EXISTING_CHANNEL_PSK_STATE, + enabledChannels, + firstFreeIndex, + generatePsk, + locationSharingIndex, + MAX_CHANNELS, + NEW_CHANNEL_PSK_STATE, + type PskEditState, +} from "./channelStaging.ts"; +import { EditChannelDialog } from "./EditChannelDialog.tsx"; interface ConfigProps { onFormInit: (methods: UseFormReturn) => void; @@ -29,98 +36,201 @@ export const getChannelName = (channel: { return channel.settings?.name?.length ? channel.settings.name : channel.index === 0 - ? i18next.t("page.broadcastLabel") + ? i18next.t("page.broadcastLabel", { ns: "channels" }) : i18next.t("page.channelIndex", { ns: "channels", index: channel.index, }); }; +type ChannelMap = ReadonlyMap; + +const EMPTY_CHANNELS_SIGNAL = { + value: new Map() as ChannelMap, + peek: () => new Map() as ChannelMap, + subscribe: () => () => {}, +} as const; + const EMPTY_DIRTY_CHANNELS_SIGNAL = { value: [] as readonly number[], peek: () => [] as readonly number[], subscribe: () => () => {}, } as const; +const EMPTY_RADIO_SIGNAL = { + value: {} as { lora?: Protobuf.Config.Config_LoRaConfig }, + peek: () => ({}) as { lora?: Protobuf.Config.Config_LoRaConfig }, + subscribe: () => () => {}, +} as const; + +/** Firmware that can broadcast position on a secondary channel. */ +const SECONDARY_CHANNEL_LOCATION_FIRMWARE = "2.6.10"; + +interface Editing { + channel: Protobuf.Channel.Channel; + isNew: boolean; + pskState: PskEditState; +} + +/** + * The Channels settings page, laid out like Android's ChannelConfigScreen: + * a header, a Primary/Secondary legend, one card per enabled channel and an + * "Add channel" button. Edits are staged in the config editor by channel + * index and sent with the page's Save. + * + * Channels can't be reordered: chat history is keyed by channel index, so + * moving a channel would attach its messages to another one. + */ export const Channels = ({ onFormInit }: ConfigProps) => { - const { setDialogOpen } = useDevice(); + const { t } = useTranslation("channels"); + const { setDialogOpen, config } = useDevice(); + const { metadata } = useDeviceCapabilities(); const editor = useConfigEditor(); - const channels = useChannels(); + const deviceChannels = useChannels(); + const workingChannels = useSignal(editor?.channels ?? EMPTY_CHANNELS_SIGNAL); const dirtyChannels = useSignal( editor?.dirtyChannels ?? EMPTY_DIRTY_CHANNELS_SIGNAL, ); - const { t } = useTranslation("channels"); + const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL); + const lora = radio.lora ?? config.lora; + const [editing, setEditing] = useState(); - const allChannels = useMemo( - () => - channels.map((c) => - create(Protobuf.Channel.ChannelSchema, { - index: c.index, - role: c.role, - settings: c.settings, - }), - ), - [channels], + // The page has no form of its own: edits are staged from the dialog. Give + // Settings a stable empty form so Save only depends on the editor's state. + const pageForm = useForm(); + useEffect(() => { + onFormInit(pageForm); + }, [onFormInit, pageForm]); + + // The editor's working copy, falling back to the device's channels before + // the editor has seen them. + const channels: ChannelMap = useMemo(() => { + if (workingChannels.size > 0) return workingChannels; + return new Map( + deviceChannels.map((c) => [ + c.index, + { ...c, $typeName: "meshtastic.Channel" } as Protobuf.Channel.Channel, + ]), + ); + }, [workingChannels, deviceChannels]); + + const listed = useMemo(() => enabledChannels(channels.values()), [channels]); + const supportsSecondaryChannelLocation = firmwareAtLeast( + metadata?.firmwareVersion ?? "", + SECONDARY_CHANNEL_LOCATION_FIRMWARE, ); - const flags = useMemo( - () => - new Map( - allChannels.map((channel) => [ - channel.index, - dirtyChannels.includes(channel.index), - ]), - ), - [allChannels, dirtyChannels], + const sharingIndex = locationSharingIndex( + listed, + supportsSecondaryChannelLocation, ); + const freeIndex = firstFreeIndex(channels); + const canAdd = + editor !== undefined && + listed.length < MAX_CHANNELS && + freeIndex !== undefined; + const metric = config.display?.units === 0; + + const slot = lora?.channelNum ?? 0; + const overrideFrequency = lora?.overrideFrequency ?? 0; + + const addChannel = () => { + if (freeIndex === undefined) return; + setEditing({ + channel: buildNewChannel(freeIndex, generatePsk()), + isNew: true, + pskState: NEW_CHANNEL_PSK_STATE, + }); + }; return ( - <> - - - - {allChannels.map((channel) => ( - - {getChannelName(channel)} - {flags.get(channel.index) && ( - - - - - )} - - ))} +
    +
    +
    +

    {t("page.sectionLabel")}

    +
    + {overrideFrequency > 0 && ( +

    {t("header.frequency", { frequency: overrideFrequency })}

    + )} +

    + {slot > 0 ? t("header.slot", { slot }) : t("header.slotAuto")} +

    +
    +
    +
    - - - {allChannels.map((channel) => ( - - }> - - - - ))} - - +
    +
    + + + + + +
      + {listed.map((channel) => { + const title = getChannelName(channel); + return ( + + setEditing({ + channel, + isNew: false, + pskState: EXISTING_CHANNEL_PSK_STATE, + }) + } + onDelete={ + channel.index === 0 || !editor + ? undefined + : () => editor.setChannel(buildDisabledChannel(channel.index)) + } + /> + ); + })} +
    + + {canAdd && ( + + )} + + {editing && editor && ( + setEditing(undefined)} + onSave={(channel) => { + editor.setChannel(channel); + setEditing(undefined); + }} + /> + )} +
    ); }; diff --git a/apps/web/src/components/PageComponents/Channels/EditChannelDialog.test.tsx b/apps/web/src/components/PageComponents/Channels/EditChannelDialog.test.tsx new file mode 100644 index 000000000..62bec41a2 --- /dev/null +++ b/apps/web/src/components/PageComponents/Channels/EditChannelDialog.test.tsx @@ -0,0 +1,121 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + EXISTING_CHANNEL_PSK_STATE, + NEW_CHANNEL_PSK_STATE, +} from "./channelStaging.ts"; +import { EditChannelDialog } from "./EditChannelDialog.tsx"; + +const channel = create(Protobuf.Channel.ChannelSchema, { + index: 1, + role: Protobuf.Channel.Channel_Role.SECONDARY, + settings: { + ...create(Protobuf.Channel.ChannelSettingsSchema), + name: "Friends", + psk: new Uint8Array(16).fill(3), + useAead: true, + id: 42, + moduleSettings: { + ...create(Protobuf.Channel.ModuleSettingsSchema), + positionPrecision: 32, + isMuted: true, + }, + }, +}); + +function renderDialog( + props: Partial[0]> = {}, +) { + const onSave = vi.fn(); + const onClose = vi.fn(); + render( + , + ); + return { onSave, onClose }; +} + +describe("EditChannelDialog", () => { + it("stages the edit on OK and keeps fields it doesn't show", () => { + const { onSave } = renderDialog(); + fireEvent.change(screen.getByLabelText("Channel Name"), { + target: { value: "Family" }, + }); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + + expect(onSave).toHaveBeenCalledTimes(1); + const saved = onSave.mock.calls[0]?.[0] as Protobuf.Channel.Channel; + expect(saved.settings?.name).toBe("Family"); + expect(saved.settings?.useAead).toBe(true); + expect(saved.settings?.id).toBe(42); + expect(saved.settings?.moduleSettings?.isMuted).toBe(true); + expect(saved.settings?.psk).toEqual(new Uint8Array(16).fill(3)); + }); + + it("discards edits on Cancel", () => { + const { onSave, onClose } = renderDialog(); + fireEvent.change(screen.getByLabelText("Channel Name"), { + target: { value: "Family" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onClose).toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("blocks OK while the name is longer than 11 bytes", () => { + const { onSave } = renderDialog(); + fireEvent.change(screen.getByLabelText("Channel Name"), { + target: { value: "a".repeat(12) }, + }); + const ok = screen.getByRole("button", { name: "OK" }); + expect(ok).toBeDisabled(); + fireEvent.click(ok); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("does not offer Authenticated Encryption", () => { + renderDialog(); + expect(screen.queryByText(/Authenticated Encryption/)).toBeNull(); + }); + + it("shows the precision slider only when precise location is off", () => { + renderDialog(); + expect(screen.queryByText("Location precision")).toBeNull(); + fireEvent.click(screen.getByLabelText("Precise location")); + expect(screen.getByText("Location precision")).toBeInTheDocument(); + expect(screen.getByText("Within 2.9 kilometers")).toBeInTheDocument(); + }); + + it("generates a key when a new channel with the default key is named", () => { + const fresh = create(Protobuf.Channel.ChannelSchema, { + index: 2, + role: Protobuf.Channel.Channel_Role.SECONDARY, + settings: { + ...create(Protobuf.Channel.ChannelSettingsSchema), + psk: new Uint8Array([1]), + }, + }); + const { onSave } = renderDialog({ + channel: fresh, + isNew: true, + initialPskState: NEW_CHANNEL_PSK_STATE, + }); + fireEvent.change(screen.getByLabelText("Channel Name"), { + target: { value: "Hiking" }, + }); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + const saved = onSave.mock.calls[0]?.[0] as Protobuf.Channel.Channel; + expect(saved.settings?.psk.length).toBe(32); + expect(saved.role).toBe(Protobuf.Channel.Channel_Role.SECONDARY); + }); +}); diff --git a/apps/web/src/components/PageComponents/Channels/EditChannelDialog.tsx b/apps/web/src/components/PageComponents/Channels/EditChannelDialog.tsx new file mode 100644 index 000000000..72478a7b7 --- /dev/null +++ b/apps/web/src/components/PageComponents/Channels/EditChannelDialog.tsx @@ -0,0 +1,311 @@ +import { + makeChannelSchema, + POSITION_PRECISION_MAX, + POSITION_PRECISION_MIN, + POSITION_PRECISION_OFF, + POSITION_PRECISION_PRECISE, +} from "@app/validation/channel.ts"; +import { Button } from "@components/UI/Button.tsx"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { Generator } from "@components/UI/Generator.tsx"; +import { Input } from "@components/UI/Input.tsx"; +import { Label } from "@components/UI/Label.tsx"; +import { Slider } from "@components/UI/Slider.tsx"; +import { Switch } from "@components/UI/Switch.tsx"; +import type { Protobuf } from "@meshtastic/sdk"; +import { type ReactNode, useId, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + applyChannelNameEdit, + buildEditedChannel, + type ChannelDraft, + draftFromChannel, + generatePsk, + markPskEdited, + POSITION_PRECISION_DEFAULT, + type PskEditState, + precisionLabelKey, + pskByteLength, +} from "./channelStaging.ts"; + +const PSK_BYTE_LENGTHS = [0, 1, 16, 32]; + +export interface EditChannelDialogProps { + /** The channel being edited, or the new channel being added. */ + channel: Protobuf.Channel.Channel; + isNew: boolean; + initialPskState: PskEditState; + /** Shown in the name field while it's empty. */ + defaultName: string; + /** Show distances in metric units. */ + metric: boolean; + /** Stage the channel. */ + onSave: (channel: Protobuf.Channel.Channel) => void; + onClose: () => void; +} + +function initialByteCount(psk: string): number { + const length = pskByteLength(psk); + return length !== undefined && PSK_BYTE_LENGTHS.includes(length) + ? length + : 32; +} + +function SwitchRow({ + id, + label, + description, + checked, + onCheckedChange, +}: { + id: string; + label: string; + description?: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( +
    +
    + + {description && ( +

    + {description} +

    + )} +
    + +
    + ); +} + +function FieldError({ children }: { children?: ReactNode }) { + if (!children) return null; + return

    {children}

    ; +} + +/** + * Edit one channel (Android's EditChannelDialog). OK stages the channel in the + * config editor; nothing is sent until the page's Save. + */ +export function EditChannelDialog({ + channel, + isNew, + initialPskState, + defaultName, + metric, + onSave, + onClose, +}: EditChannelDialogProps) { + const { t } = useTranslation("channels"); + const id = useId(); + const [draft, setDraft] = useState(() => + draftFromChannel(channel), + ); + const [pskState, setPskState] = useState(initialPskState); + const [byteCount, setByteCount] = useState(() => + initialByteCount(draftFromChannel(channel).psk), + ); + + const errors = useMemo(() => { + const result = makeChannelSchema(byteCount).safeParse(draft); + const byField: Partial> = {}; + if (!result.success) { + for (const issue of result.error.issues) { + const field = issue.path[0] as keyof ChannelDraft; + byField[field] ??= t(issue.message, { + ns: "common", + params: (issue as { params?: unknown }).params, + }); + } + } + return byField; + }, [draft, byteCount, t]); + const isValid = Object.keys(errors).length === 0; + + const update = (patch: Partial) => + setDraft((current) => ({ ...current, ...patch })); + + const setPsk = (psk: string) => { + update({ psk }); + setPskState(markPskEdited); + const length = pskByteLength(psk); + if (length !== undefined && PSK_BYTE_LENGTHS.includes(length)) { + setByteCount(length); + } + }; + + const onNameChange = (name: string) => { + const next = applyChannelNameEdit(draft, name, pskState); + setDraft(next.draft); + setPskState(next.state); + if (next.draft.psk !== draft.psk) { + setByteCount(initialByteCount(next.draft.psk)); + } + }; + + const onGenerate = () => { + const bytes = byteCount >= 16 ? byteCount : 32; + setPsk(generatePsk(bytes)); + }; + + const precision = draft.positionPrecision; + const positionEnabled = precision !== POSITION_PRECISION_OFF; + const precise = precision === POSITION_PRECISION_PRECISE; + + const save = () => { + if (!isValid) return; + onSave(buildEditedChannel(channel, draft)); + }; + + return ( + !open && onClose()}> + + + + + {isNew + ? t("editDialog.addTitle") + : t("editDialog.editTitle", { index: channel.index })} + + {t("editDialog.description")} + + + { + e.preventDefault(); + save(); + }} + > +
    + + onNameChange(e.target.value)} + /> +

    + {t("name.description")} +

    + {errors.name} +
    + +
    + +
    + { + const count = Number.parseInt(value, 10); + if (!Number.isNaN(count)) setByteCount(count); + }} + inputChange={(e) => setPsk(e.target.value)} + actionButtons={[ + { + text: t("psk.generate"), + variant: "success", + onClick: onGenerate, + }, + ]} + showPasswordToggle + showCopyButton + /> +
    +

    + {t("psk.description")} +

    + {errors.psk} +
    + + update({ uplinkEnabled })} + /> + update({ downlinkEnabled })} + /> + + + update({ + positionPrecision: on + ? POSITION_PRECISION_PRECISE + : POSITION_PRECISION_OFF, + }) + } + /> + {positionEnabled && ( + + update({ + positionPrecision: on + ? POSITION_PRECISION_PRECISE + : POSITION_PRECISION_DEFAULT, + }) + } + /> + )} + {positionEnabled && !precise && ( +
    + + { + if (value !== undefined) update({ positionPrecision: value }); + }} + /> +

    + {t(precisionLabelKey(precision, metric))} +

    + {errors.positionPrecision} +
    + )} + + + + + + +
    +
    + ); +} diff --git a/apps/web/src/components/PageComponents/Channels/channelStaging.test.ts b/apps/web/src/components/PageComponents/Channels/channelStaging.test.ts new file mode 100644 index 000000000..078036904 --- /dev/null +++ b/apps/web/src/components/PageComponents/Channels/channelStaging.test.ts @@ -0,0 +1,369 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { fromByteArray, toByteArray } from "base64-js"; +import { describe, expect, it } from "vitest"; +import { + applyChannelNameEdit, + buildDisabledChannel, + buildEditedChannel, + buildNewChannel, + DEFAULT_PSK, + draftFromChannel, + EXISTING_CHANNEL_PSK_STATE, + enabledChannels, + firstFreeIndex, + generatePsk, + locationSharingIndex, + markPskEdited, + NEW_CHANNEL_PSK_STATE, + normalizePositionPrecision, + precisionLabelKey, + securityState, +} from "./channelStaging.ts"; + +const Role = Protobuf.Channel.Channel_Role; + +function makeChannel( + index: number, + role: Protobuf.Channel.Channel_Role, + settings: Partial = {}, + moduleSettings?: Partial, +): Protobuf.Channel.Channel { + return create(Protobuf.Channel.ChannelSchema, { + index, + role, + settings: { + ...create(Protobuf.Channel.ChannelSettingsSchema), + ...settings, + ...(moduleSettings && { + moduleSettings: { + ...create(Protobuf.Channel.ModuleSettingsSchema), + ...moduleSettings, + }, + }), + }, + }); +} + +const key16 = new Uint8Array(16).fill(7); + +describe("buildEditedChannel", () => { + const existing = makeChannel( + 2, + Role.SECONDARY, + { + name: "Friends", + psk: key16, + id: 1234, + channelNum: 5, + useAead: true, + uplinkEnabled: false, + downlinkEnabled: true, + }, + { positionPrecision: 13, isMuted: true }, + ); + + it("is unchanged when nothing was edited", () => { + const edited = buildEditedChannel(existing, draftFromChannel(existing)); + expect(edited).toEqual(existing); + }); + + it("is unchanged for a channel without module settings", () => { + const bare = makeChannel(1, Role.SECONDARY, { name: "x", psk: key16 }); + const edited = buildEditedChannel(bare, draftFromChannel(bare)); + expect(edited).toEqual(bare); + expect(Object.keys(edited.settings ?? {})).toEqual( + Object.keys(bare.settings ?? {}), + ); + }); + + it("applies the edited fields and keeps the ones the dialog hides", () => { + const newKey = new Uint8Array(32).fill(9); + const edited = buildEditedChannel(existing, { + name: " Family ", + psk: fromByteArray(newKey), + uplinkEnabled: true, + downlinkEnabled: false, + positionPrecision: 32, + }); + + expect(edited.index).toBe(2); + expect(edited.role).toBe(Role.SECONDARY); + expect(edited.settings?.name).toBe("Family"); + expect(edited.settings?.psk).toEqual(newKey); + expect(edited.settings?.uplinkEnabled).toBe(true); + expect(edited.settings?.downlinkEnabled).toBe(false); + expect(edited.settings?.moduleSettings?.positionPrecision).toBe(32); + // Not rendered by the dialog: must survive. + expect(edited.settings?.useAead).toBe(true); + expect(edited.settings?.id).toBe(1234); + expect(edited.settings?.channelNum).toBe(5); + expect(edited.settings?.moduleSettings?.isMuted).toBe(true); + }); + + it("does not modify the existing channel", () => { + buildEditedChannel(existing, { + ...draftFromChannel(existing), + name: "Other", + }); + expect(existing.settings?.name).toBe("Friends"); + }); + + it("keeps the primary role on index 0", () => { + const primary = makeChannel(0, Role.PRIMARY, { psk: key16 }); + const edited = buildEditedChannel(primary, { + ...draftFromChannel(primary), + name: "Main", + }); + expect(edited.role).toBe(Role.PRIMARY); + }); +}); + +describe("add and delete", () => { + it("adds a secondary with the given key and position off", () => { + const psk = generatePsk(); + const channel = buildNewChannel(3, psk); + expect(channel.index).toBe(3); + expect(channel.role).toBe(Role.SECONDARY); + expect(fromByteArray(channel.settings?.psk ?? new Uint8Array())).toBe(psk); + expect(channel.settings?.moduleSettings?.positionPrecision).toBe(0); + expect(channel.settings?.name).toBe(""); + }); + + it("stages the dialog's edits on top of a new channel", () => { + const added = buildEditedChannel(buildNewChannel(1, generatePsk()), { + name: "New", + psk: fromByteArray(key16), + uplinkEnabled: true, + downlinkEnabled: true, + positionPrecision: 15, + }); + expect(added.role).toBe(Role.SECONDARY); + expect(added.settings?.name).toBe("New"); + expect(added.settings?.moduleSettings?.positionPrecision).toBe(15); + }); + + it("deletes by disabling the slot and clearing its settings", () => { + const channel = buildDisabledChannel(4); + expect(channel.index).toBe(4); + expect(channel.role).toBe(Role.DISABLED); + expect(channel.settings?.name).toBe(""); + expect(channel.settings?.psk.length).toBe(0); + expect(channel.settings?.uplinkEnabled).toBe(false); + }); + + it("generates 32 random bytes by default", () => { + const a = generatePsk(); + expect(toByteArray(a).length).toBe(32); + expect(generatePsk()).not.toBe(a); + expect(toByteArray(generatePsk(16)).length).toBe(16); + }); +}); + +describe("firstFreeIndex and enabledChannels", () => { + const map = (channels: Protobuf.Channel.Channel[]) => + new Map(channels.map((c) => [c.index, c])); + + it("returns the first disabled secondary slot", () => { + const channels = map([ + makeChannel(0, Role.PRIMARY), + makeChannel(1, Role.SECONDARY), + makeChannel(2, Role.DISABLED), + makeChannel(3, Role.SECONDARY), + ]); + expect(firstFreeIndex(channels)).toBe(2); + }); + + it("treats unknown slots as free and never returns 0", () => { + expect(firstFreeIndex(map([]))).toBe(1); + expect(firstFreeIndex(map([makeChannel(0, Role.DISABLED)]))).toBe(1); + }); + + it("returns undefined when all 8 are in use", () => { + const all = map( + Array.from({ length: 8 }, (_, i) => + makeChannel(i, i === 0 ? Role.PRIMARY : Role.SECONDARY), + ), + ); + expect(firstFreeIndex(all)).toBeUndefined(); + }); + + it("lists enabled channels by index", () => { + const list = enabledChannels([ + makeChannel(3, Role.SECONDARY), + makeChannel(2, Role.DISABLED), + makeChannel(0, Role.PRIMARY), + ]); + expect(list.map((c) => c.index)).toEqual([0, 3]); + }); +}); + +describe("locationSharingIndex", () => { + const channels = [ + makeChannel(0, Role.PRIMARY, {}, { positionPrecision: 0 }), + makeChannel(1, Role.DISABLED, {}, { positionPrecision: 32 }), + makeChannel(2, Role.SECONDARY, {}, { positionPrecision: 13 }), + ]; + + it("uses the first enabled channel with position on (2.6.10+)", () => { + expect(locationSharingIndex(channels, true)).toBe(2); + }); + + it("only uses the primary on older firmware", () => { + expect(locationSharingIndex(channels, false)).toBe(-1); + const primaryOn = [ + makeChannel(0, Role.PRIMARY, {}, { positionPrecision: 32 }), + ...channels.slice(1), + ]; + expect(locationSharingIndex(primaryOn, false)).toBe(0); + }); +}); + +describe("securityState", () => { + const settings = ( + psk: Uint8Array, + precision: number, + uplinkEnabled = false, + ) => + makeChannel( + 0, + Role.PRIMARY, + { psk, uplinkEnabled }, + { + positionPrecision: precision, + }, + ).settings; + + it("is secure with a 128 or 256 bit key", () => { + expect(securityState(settings(key16, 32, true))).toBe("secure"); + }); + + it("grades low entropy keys by precise location and MQTT", () => { + const one = new Uint8Array([1]); + expect(securityState(settings(one, 13))).toBe("insecureNoPrecise"); + expect(securityState(settings(new Uint8Array(), 32))).toBe( + "insecurePreciseOnly", + ); + expect(securityState(settings(one, 32, true))).toBe("insecurePreciseMqtt"); + }); +}); + +describe("applyChannelNameEdit", () => { + const fixedKey = () => "GENERATED"; + const draft = { + name: "", + psk: DEFAULT_PSK, + uplinkEnabled: false, + downlinkEnabled: false, + positionPrecision: 0, + }; + + it("generates a key when a new channel with the default key is named", () => { + const result = applyChannelNameEdit( + draft, + "Hiking", + NEW_CHANNEL_PSK_STATE, + fixedKey, + ); + expect(result.draft.psk).toBe("GENERATED"); + expect(result.state.generatedPskForName).toBe(true); + }); + + it("also generates for an empty key", () => { + const result = applyChannelNameEdit( + { ...draft, psk: "" }, + "Hiking", + NEW_CHANNEL_PSK_STATE, + fixedKey, + ); + expect(result.draft.psk).toBe("GENERATED"); + }); + + it("restores the old key when the name is cleared again", () => { + const named = applyChannelNameEdit( + draft, + "H", + NEW_CHANNEL_PSK_STATE, + fixedKey, + ); + const cleared = applyChannelNameEdit( + named.draft, + "", + named.state, + fixedKey, + ); + expect(cleared.draft.psk).toBe(DEFAULT_PSK); + expect(cleared.state.generatedPskForName).toBe(false); + }); + + it("keeps the generated key while the name keeps changing", () => { + const first = applyChannelNameEdit( + draft, + "H", + NEW_CHANNEL_PSK_STATE, + () => "FIRST", + ); + const second = applyChannelNameEdit( + first.draft, + "Hi", + first.state, + () => "SECOND", + ); + expect(second.draft.psk).toBe("FIRST"); + }); + + it("never generates for an existing channel", () => { + const result = applyChannelNameEdit( + draft, + "LongFast", + EXISTING_CHANNEL_PSK_STATE, + fixedKey, + ); + expect(result.draft.psk).toBe(DEFAULT_PSK); + expect(result.draft.name).toBe("LongFast"); + }); + + it("does not touch a key the user set", () => { + const state = markPskEdited(NEW_CHANNEL_PSK_STATE); + const named = applyChannelNameEdit(draft, "Hiking", state, fixedKey); + expect(named.draft.psk).toBe(DEFAULT_PSK); + const cleared = applyChannelNameEdit( + { ...draft, name: "Hiking", psk: "MINE" }, + "", + state, + fixedKey, + ); + expect(cleared.draft.psk).toBe("MINE"); + }); + + it("does not generate when the key is already strong", () => { + const strong = fromByteArray(key16); + const result = applyChannelNameEdit( + { ...draft, psk: strong }, + "Hiking", + NEW_CHANNEL_PSK_STATE, + fixedKey, + ); + expect(result.draft.psk).toBe(strong); + }); +}); + +describe("position precision helpers", () => { + it("normalizes device values into 0, 10-19 or 32", () => { + expect(normalizePositionPrecision(undefined)).toBe(0); + expect(normalizePositionPrecision(0)).toBe(0); + expect(normalizePositionPrecision(5)).toBe(10); + expect(normalizePositionPrecision(14)).toBe(14); + expect(normalizePositionPrecision(25)).toBe(19); + expect(normalizePositionPrecision(32)).toBe(32); + }); + + it("maps 10-19 to the distance labels", () => { + expect(precisionLabelKey(10, true)).toBe( + "positionPrecision.options.metric_km23", + ); + expect(precisionLabelKey(19, false)).toBe( + "positionPrecision.options.imperial_ft150", + ); + }); +}); diff --git a/apps/web/src/components/PageComponents/Channels/channelStaging.ts b/apps/web/src/components/PageComponents/Channels/channelStaging.ts new file mode 100644 index 000000000..32a902fd9 --- /dev/null +++ b/apps/web/src/components/PageComponents/Channels/channelStaging.ts @@ -0,0 +1,324 @@ +import { + POSITION_PRECISION_MAX, + POSITION_PRECISION_MIN, + POSITION_PRECISION_OFF, + POSITION_PRECISION_PRECISE, +} from "@app/validation/channel.ts"; +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { fromByteArray, toByteArray } from "base64-js"; + +/** The firmware supports 8 channels: index 0 (primary) and 1-7. */ +export const MAX_CHANNELS = 8; + +/** Precision used when "Precise location" is switched off (as on Android). */ +export const POSITION_PRECISION_DEFAULT = 13; + +/** The well-known 1 byte default key (0x01), as base64. */ +export const DEFAULT_PSK = "AQ=="; + +const Role = Protobuf.Channel.Channel_Role; + +type ChannelLike = Pick & { + settings?: Protobuf.Channel.ChannelSettings; +}; + +/** What the edit dialog lets the user change. */ +export interface ChannelDraft { + /** As typed; trimmed when the channel is built. */ + name: string; + /** Base64. */ + psk: string; + uplinkEnabled: boolean; + downlinkEnabled: boolean; + positionPrecision: number; +} + +/** Android's ChannelPskEditState: when naming a channel may generate a PSK. */ +export interface PskEditState { + /** Only set for channels added with "Add channel". */ + canGeneratePskForName: boolean; + /** The current PSK was generated because the channel was named. */ + generatedPskForName: boolean; + /** The user typed or generated a PSK themselves. */ + pskExplicitlyEdited: boolean; + /** The PSK to restore if the generated one is undone. */ + pskBeforeGenerated?: string; +} + +export const EXISTING_CHANNEL_PSK_STATE: PskEditState = { + canGeneratePskForName: false, + generatedPskForName: false, + pskExplicitlyEdited: false, +}; + +export const NEW_CHANNEL_PSK_STATE: PskEditState = { + ...EXISTING_CHANNEL_PSK_STATE, + canGeneratePskForName: true, +}; + +/** A random 256 bit key, as base64. */ +export function generatePsk(bytes = 32): string { + return fromByteArray(crypto.getRandomValues(new Uint8Array(bytes))); +} + +/** + * Bring a device value into what the dialog can show: 0 (off), 10-19 or 32 + * (precise). + */ +export function normalizePositionPrecision(value: number | undefined): number { + if (!value || value <= POSITION_PRECISION_OFF) return POSITION_PRECISION_OFF; + if (value >= POSITION_PRECISION_PRECISE) return POSITION_PRECISION_PRECISE; + return Math.min( + POSITION_PRECISION_MAX, + Math.max(POSITION_PRECISION_MIN, value), + ); +} + +export function draftFromChannel(channel: ChannelLike): ChannelDraft { + const settings = channel.settings; + return { + name: settings?.name ?? "", + psk: fromByteArray(settings?.psk ?? new Uint8Array(0)), + uplinkEnabled: settings?.uplinkEnabled ?? false, + downlinkEnabled: settings?.downlinkEnabled ?? false, + positionPrecision: normalizePositionPrecision( + settings?.moduleSettings?.positionPrecision, + ), + }; +} + +/** + * The channel to stage after the dialog's OK. Starts from the existing channel + * and settings so fields the dialog doesn't render (role, id, channelNum, + * useAead, moduleSettings.isMuted, ...) keep their values. Saving without + * changes gives a channel equal to the original. + */ +export function buildEditedChannel( + existing: ChannelLike, + draft: ChannelDraft, +): Protobuf.Channel.Channel { + const settings = existing.settings; + const moduleSettings = settings?.moduleSettings; + const precision = draft.positionPrecision; + const keepModuleSettings = + moduleSettings !== undefined || precision !== POSITION_PRECISION_OFF; + + const base: Protobuf.Channel.ChannelSettings = + settings ?? create(Protobuf.Channel.ChannelSettingsSchema); + // Spread the existing messages: create() returns a message as it is, so + // every field the dialog doesn't touch (and any unknown field) is kept. + const nextSettings: Protobuf.Channel.ChannelSettings = { + ...base, + name: draft.name.trim(), + psk: toByteArray(draft.psk), + uplinkEnabled: draft.uplinkEnabled, + downlinkEnabled: draft.downlinkEnabled, + // Only add the key when needed: the editor counts keys when it checks + // whether a channel changed. + ...(keepModuleSettings && { + moduleSettings: { + ...(moduleSettings ?? create(Protobuf.Channel.ModuleSettingsSchema)), + positionPrecision: precision, + }, + }), + }; + + return create(Protobuf.Channel.ChannelSchema, { + index: existing.index, + role: existing.role, + settings: nextSettings, + }); +} + +/** What "Delete" stages: the slot is disabled and its settings cleared. */ +export function buildDisabledChannel(index: number): Protobuf.Channel.Channel { + return create(Protobuf.Channel.ChannelSchema, { + index, + role: Role.DISABLED, + settings: create(Protobuf.Channel.ChannelSettingsSchema, {}), + }); +} + +/** A new secondary channel for a free slot, with the given base64 PSK. */ +export function buildNewChannel( + index: number, + psk: string, +): Protobuf.Channel.Channel { + return create(Protobuf.Channel.ChannelSchema, { + index, + role: Role.SECONDARY, + settings: create(Protobuf.Channel.ChannelSettingsSchema, { + psk: toByteArray(psk), + moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, { + positionPrecision: POSITION_PRECISION_OFF, + }), + }), + }); +} + +/** Channels that are in use (not DISABLED), by index. */ +export function enabledChannels( + channels: Iterable, +): T[] { + return [...channels] + .filter((c) => c.role !== Role.DISABLED) + .sort((a, b) => a.index - b.index); +} + +/** + * The slot "Add channel" uses: the first secondary index (1-7) that is + * DISABLED or not known yet. Undefined when all 8 are in use. + */ +export function firstFreeIndex( + channels: ReadonlyMap, +): number | undefined { + for (let index = 1; index < MAX_CHANNELS; index++) { + const channel = channels.get(index); + if (!channel || channel.role === Role.DISABLED) return index; + } + return undefined; +} + +/** + * The index of the channel that broadcasts position periodically, or -1. + * Firmware 2.6.10+ uses the first enabled channel with position on; older + * firmware only the primary. + */ +export function locationSharingIndex( + channels: Iterable, + supportsSecondaryChannelLocation: boolean, +): number { + const enabled = enabledChannels(channels); + const sharing = (c: ChannelLike) => + (c.settings?.moduleSettings?.positionPrecision ?? 0) > 0; + if (supportsSecondaryChannelLocation) { + return enabled.find(sharing)?.index ?? -1; + } + const primary = enabled.find((c) => c.index === 0); + return primary && sharing(primary) ? 0 : -1; +} + +export type SecurityState = + | "secure" + | "insecureNoPrecise" + | "insecurePreciseOnly" + | "insecurePreciseMqtt"; + +/** Android's SecurityIcon: a PSK of 0 or 1 byte is not secure. */ +export function securityState( + settings: Protobuf.Channel.ChannelSettings | undefined, +): SecurityState { + const lowEntropy = (settings?.psk.length ?? 0) <= 1; + if (!lowEntropy) return "secure"; + const precise = + settings?.moduleSettings?.positionPrecision === POSITION_PRECISION_PRECISE; + if (precise && settings?.uplinkEnabled) return "insecurePreciseMqtt"; + if (precise) return "insecurePreciseOnly"; + return "insecureNoPrecise"; +} + +/** + * Port of Android's applyChannelNameEdit. Naming a newly added channel that + * still has the default (or an empty) PSK generates a key, unless the user + * set the PSK themselves. Clearing the name again restores the old key. + */ +export function applyChannelNameEdit( + draft: ChannelDraft, + nameInput: string, + state: PskEditState, + randomKey: () => string = generatePsk, +): { draft: ChannelDraft; state: PskEditState } { + const name = nameInput.trim(); + const clearGenerated = + name === "" && state.generatedPskForName && !state.pskExplicitlyEdited; + const generateForName = + name !== "" && + (draft.psk === DEFAULT_PSK || draft.psk === "") && + state.canGeneratePskForName && + !state.pskExplicitlyEdited; + + if (clearGenerated) { + return { + draft: { + ...draft, + name: nameInput, + psk: state.pskBeforeGenerated ?? DEFAULT_PSK, + }, + state: { + ...state, + generatedPskForName: false, + pskBeforeGenerated: undefined, + }, + }; + } + if (generateForName) { + return { + draft: { ...draft, name: nameInput, psk: randomKey() }, + state: { + ...state, + generatedPskForName: true, + pskBeforeGenerated: draft.psk, + }, + }; + } + return { draft: { ...draft, name: nameInput }, state }; +} + +/** The user typed or generated a PSK: stop generating one for the name. */ +export function markPskEdited(state: PskEditState): PskEditState { + return { + ...state, + generatedPskForName: false, + pskExplicitlyEdited: true, + pskBeforeGenerated: undefined, + }; +} + +/** Byte length of a base64 PSK, or undefined if it isn't valid base64. */ +export function pskByteLength(psk: string): number | undefined { + try { + return toByteArray(psk).byteLength; + } catch { + return undefined; + } +} + +const METRIC_PRECISION_KEYS = [ + "metric_km23", + "metric_km12", + "metric_km5_8", + "metric_km2_9", + "metric_km1_5", + "metric_m700", + "metric_m350", + "metric_m200", + "metric_m90", + "metric_m50", +] as const; + +const IMPERIAL_PRECISION_KEYS = [ + "imperial_mi15", + "imperial_mi7_3", + "imperial_mi3_6", + "imperial_mi1_8", + "imperial_mi0_9", + "imperial_mi0_5", + "imperial_mi0_2", + "imperial_ft600", + "imperial_ft300", + "imperial_ft150", +] as const; + +/** + * The i18n key (channels namespace) describing how close a position with this + * precision (10-19) is, e.g. "Within 2.9 kilometers". + */ +export function precisionLabelKey(precision: number, metric: boolean): string { + const keys = metric ? METRIC_PRECISION_KEYS : IMPERIAL_PRECISION_KEYS; + const clamped = Math.min( + POSITION_PRECISION_MAX, + Math.max(POSITION_PRECISION_MIN, precision), + ); + return `positionPrecision.options.${keys[clamped - POSITION_PRECISION_MIN]}`; +} diff --git a/apps/web/src/validation/channel.test.ts b/apps/web/src/validation/channel.test.ts index 3b99c0ea9..941a64db5 100644 --- a/apps/web/src/validation/channel.test.ts +++ b/apps/web/src/validation/channel.test.ts @@ -2,8 +2,6 @@ import { fromByteArray } from "base64-js"; import { describe, expect, it } from "vitest"; import { makeChannelSchema } from "./channel.ts"; -const mockRole = 0; - function makeBase64OfLength(len: number): string { return fromByteArray(new Uint8Array(len)); } @@ -12,146 +10,76 @@ describe("makeChannelSchema", () => { const allowedBytes = 16; const schema = makeChannelSchema(allowedBytes); - const validBase64 = makeBase64OfLength(allowedBytes); - - const validSettings = { - channelNum: 3, - psk: validBase64, + const valid = { name: "TestName", - id: 3, + psk: makeBase64OfLength(allowedBytes), uplinkEnabled: true, downlinkEnabled: false, - moduleSettings: { positionPrecision: 10 }, + positionPrecision: 10, }; - it("accepts valid channel object", () => { - const result = schema.safeParse({ - index: 0, - settings: validSettings, - role: mockRole, - }); - expect(result.success).toBe(true); - }); + const issuePaths = (input: unknown) => { + const result = schema.safeParse(input); + return result.success ? [] : result.error.issues.map((i) => i.path[0]); + }; - it("keeps use_aead through validation", () => { - const on = schema.safeParse({ - index: 0, - settings: { ...validSettings, useAead: true }, - role: mockRole, - }); - expect(on.success && on.data.settings.useAead).toBe(true); + it("accepts the dialog's fields", () => { + expect(schema.safeParse(valid).success).toBe(true); + }); - const absent = schema.safeParse({ - index: 0, - settings: validSettings, - role: mockRole, - }); - expect(absent.success && absent.data.settings.useAead).toBe(false); + it("does not require fields the dialog does not render", () => { + // role, id, channelNum and useAead are copied from the existing channel. + const result = schema.safeParse(valid); + expect(result.success && Object.keys(result.data).sort()).toEqual([ + "downlinkEnabled", + "name", + "positionPrecision", + "psk", + "uplinkEnabled", + ]); }); it("rejects invalid base64 psk", () => { - const result = schema.safeParse({ - index: 0, - settings: { ...validSettings, psk: "not_base64!" }, - role: mockRole, - }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some( - (i) => i.path.includes("settings") && i.path.includes("psk"), - ), - ).toBe(true); - } + expect(issuePaths({ ...valid, psk: "not_base64!" })).toContain("psk"); }); - it("rejects psk of wrong length", () => { - const wrongLength = makeBase64OfLength(8); - const result = schema.safeParse({ - index: 0, - settings: { ...validSettings, psk: wrongLength }, - role: mockRole, - }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some( - (i) => i.path.includes("settings") && i.path.includes("psk"), - ), - ).toBe(true); - } + it("rejects psk of the wrong length", () => { + expect(issuePaths({ ...valid, psk: makeBase64OfLength(8) })).toContain( + "psk", + ); }); - it("rejects name longer than 12 bytes", () => { - const longName = "a".repeat(13); - const result = schema.safeParse({ - index: 0, - settings: { ...validSettings, name: longName }, - role: mockRole, - }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some( - (i) => i.path.includes("settings") && i.path.includes("name"), - ), - ).toBe(true); - } + it("accepts an empty psk when 0 bytes are allowed", () => { + expect(makeChannelSchema(0).safeParse({ ...valid, psk: "" }).success).toBe( + true, + ); }); - it("rejects channelNum out of range", () => { - const result = schema.safeParse({ - index: 0, - settings: { ...validSettings, channelNum: 10 }, - role: mockRole, - }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some( - (i) => i.path.includes("settings") && i.path.includes("channelNum"), - ), - ).toBe(true); - } + it("accepts a name of 11 bytes and rejects 12", () => { + expect(schema.safeParse({ ...valid, name: "a".repeat(11) }).success).toBe( + true, + ); + expect(issuePaths({ ...valid, name: "a".repeat(12) })).toContain("name"); }); - it("rejects missing required fields", () => { - const result = schema.safeParse({ - index: 0, - settings: {}, - role: mockRole, - }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues.length).toBeGreaterThan(0); - } + it("counts bytes, not characters, in the name", () => { + // "é" is 2 bytes in UTF-8: 6 of them are 12 bytes. + expect(issuePaths({ ...valid, name: "é".repeat(6) })).toContain("name"); }); - it("accepts moduleSettings.positionPrecision as 0, 10-19, or 32", () => { + it("accepts positionPrecision 0, 10-19 or 32", () => { for (const val of [0, 10, 15, 19, 32]) { - const result = schema.safeParse({ - index: 0, - settings: { - ...validSettings, - moduleSettings: { positionPrecision: val }, - }, - role: mockRole, - }); - expect(result.success).toBe(true); + expect( + schema.safeParse({ ...valid, positionPrecision: val }).success, + ).toBe(true); } }); - it("rejects moduleSettings.positionPrecision out of range", () => { + it("rejects positionPrecision out of range", () => { for (const val of [9, 20, 31, 33]) { - const result = schema.safeParse({ - index: 0, - settings: { - ...validSettings, - moduleSettings: { positionPrecision: val }, - }, - role: mockRole, - }); - expect(result.success).toBe(false); + expect( + schema.safeParse({ ...valid, positionPrecision: val }).success, + ).toBe(false); } }); }); diff --git a/apps/web/src/validation/channel.ts b/apps/web/src/validation/channel.ts index 1f96f1424..dde3dce22 100644 --- a/apps/web/src/validation/channel.ts +++ b/apps/web/src/validation/channel.ts @@ -1,43 +1,54 @@ import { validateMaxByteLength } from "@core/utils/string.ts"; -import { Protobuf } from "@meshtastic/sdk"; import { z } from "zod/v4"; import { makePskHelpers } from "./pskSchema.ts"; -const RoleEnum = z.enum(Protobuf.Channel.Channel_Role); +/** + * Longest channel name in UTF-8 bytes. The protobuf allows 12 bytes, and one + * of those is the string terminator, so 11 are usable (as on Android). + */ +export const CHANNEL_NAME_MAX_BYTES = 11; -const moduleSettingsSchema = z.object({ - positionPrecision: z.union([ - z.literal(0), - z.coerce.number().int().min(10).max(19), - z.literal(32), - ]), -}); +export const POSITION_PRECISION_OFF = 0; +export const POSITION_PRECISION_PRECISE = 32; +export const POSITION_PRECISION_MIN = 10; +export const POSITION_PRECISION_MAX = 19; +const positionPrecisionSchema = z.union([ + z.literal(POSITION_PRECISION_OFF), + z.coerce + .number() + .int() + .min(POSITION_PRECISION_MIN) + .max(POSITION_PRECISION_MAX), + z.literal(POSITION_PRECISION_PRECISE), +]); + +/** + * The fields the edit channel dialog renders. Everything else on the channel + * (role, id, useAead, moduleSettings.isMuted, ...) is copied from the existing + * channel when the edit is staged, so it is not validated here. + * + * @param allowedBytes the PSK length in bytes the user picked (0, 1, 16 or 32) + */ export function makeChannelSchema(allowedBytes: number) { const { stringSchema } = makePskHelpers([allowedBytes]); - const ChannelSettingsSchema = z.object({ - channelNum: z.coerce.number().int().min(0).max(7), + return z.object({ + name: z + .string() + .refine( + (s) => validateMaxByteLength(s.trim(), CHANNEL_NAME_MAX_BYTES).isValid, + { + message: "formValidation.tooBig.bytes", + params: { maximum: CHANNEL_NAME_MAX_BYTES }, + }, + ), psk: stringSchema(false), - name: z.string().refine((s) => validateMaxByteLength(s, 12).isValid, { - message: "formValidation.tooBig.bytes", - params: { maximum: 12 }, - }), - id: z.coerce.number().int(), uplinkEnabled: z.boolean(), downlinkEnabled: z.boolean(), - // Experimental AES-CCM (firmware 2.8). Kept in the schema so saving the - // form does not drop a value set elsewhere. - useAead: z.boolean().default(false), - moduleSettings: moduleSettingsSchema, - }); - - return z.object({ - index: z.coerce.number(), - settings: ChannelSettingsSchema, - role: RoleEnum, + positionPrecision: positionPrecisionSchema, }); } -const ChannelValidationSchema = makeChannelSchema(0); // generate a schema that doesn't validate PSK length, just structure, for type purposes +const ChannelValidationSchema = makeChannelSchema(0); // structure only, for the type export type ChannelValidation = z.infer; From 88ba0e4aedddc99d77630c49d139bd7d592a1b4f Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:32:30 -0400 Subject: [PATCH 04/15] feat(web): align radio config pages with Android's groups and fields - User: Status Message (80 bytes, fw 2.7.20+) after Short Name, saved with the owner in one form. - Security: Packet authenticity first (disabled unless the radio reports XEdDSA), then Direct Message Key, Admin Keys, Logs, Administration with lockdown. Legacy Admin channel removed. - LoRa: Coding Rate Override (presets, fw 2.7.18+) and PA Fan Disabled on boards with a PA fan; presets on/off show only the relevant fields. Frequency Offset, FEM/LNA Mode and Serial HAL Only removed. - Power: ADC multiplier override switch with the value shown when on. - Network: UDP broadcasting switch (only the UDP bit); Ethernet group only on hardware with Ethernet. - Display: Message Bubbles removed. - Device: Triple Click Ad Hoc Ping / LED Heartbeat as positive switches. Removed fields keep their device values via the config editor merge. --- apps/web/public/i18n/locales/en/config.json | 88 +++---- .../PageComponents/Settings/Device/index.tsx | 20 +- .../PageComponents/Settings/Display.tsx | 6 - .../PageComponents/Settings/LoRa.test.tsx | 112 ++++++++ .../PageComponents/Settings/LoRa.tsx | 247 ++++++++++++------ .../Settings/Network/Network.test.tsx | 74 ++++++ .../PageComponents/Settings/Network/index.tsx | 56 ++-- .../PageComponents/Settings/Power.test.tsx | 63 +++++ .../PageComponents/Settings/Power.tsx | 67 ++++- .../Settings/Security/LockdownSection.tsx | 112 ++++---- .../Settings/Security/Security.test.tsx | 88 +++++++ .../Settings/Security/Security.tsx | 76 +++--- .../PageComponents/Settings/User.test.tsx | 122 +++++++++ .../PageComponents/Settings/User.tsx | 64 ++++- apps/web/src/validation/config/device.test.ts | 32 +++ apps/web/src/validation/config/device.ts | 30 ++- apps/web/src/validation/config/display.ts | 1 - apps/web/src/validation/config/lora.test.ts | 106 ++++++++ apps/web/src/validation/config/lora.ts | 110 +++++++- .../web/src/validation/config/network.test.ts | 23 ++ apps/web/src/validation/config/network.ts | 25 +- apps/web/src/validation/config/power.test.ts | 27 ++ apps/web/src/validation/config/power.ts | 27 ++ .../src/validation/config/security.test.ts | 23 +- apps/web/src/validation/config/security.ts | 1 - apps/web/src/validation/config/user.test.ts | 34 ++- apps/web/src/validation/config/user.ts | 20 ++ 27 files changed, 1357 insertions(+), 297 deletions(-) create mode 100644 apps/web/src/components/PageComponents/Settings/LoRa.test.tsx create mode 100644 apps/web/src/components/PageComponents/Settings/Network/Network.test.tsx create mode 100644 apps/web/src/components/PageComponents/Settings/Power.test.tsx create mode 100644 apps/web/src/components/PageComponents/Settings/Security/Security.test.tsx create mode 100644 apps/web/src/components/PageComponents/Settings/User.test.tsx create mode 100644 apps/web/src/validation/config/device.test.ts create mode 100644 apps/web/src/validation/config/network.test.ts create mode 100644 apps/web/src/validation/config/power.test.ts diff --git a/apps/web/public/i18n/locales/en/config.json b/apps/web/public/i18n/locales/en/config.json index ca8b94339..dc3f32fee 100644 --- a/apps/web/public/i18n/locales/en/config.json +++ b/apps/web/public/i18n/locales/en/config.json @@ -47,17 +47,17 @@ "description": "Buzzer pin override", "label": "Buzzer GPIO" }, - "disableTripleClick": { - "description": "Disable triple click", - "label": "Disable Triple Click" + "tripleClickAdHocPing": { + "label": "Triple Click Ad Hoc Ping", + "description": "Send a position on the primary channel when the user button is triple clicked." }, "doubleTapAsButtonPress": { "description": "Treat double tap as button press", - "label": "Double Tap as Button Press" + "label": "Double Tap as Button" }, - "ledHeartbeatDisabled": { - "description": "Disable default blinking LED", - "label": "LED Heartbeat Disabled" + "ledHeartbeat": { + "label": "LED Heartbeat", + "description": "Controls the blinking LED on the device. For most devices this controls one of up to 4 LEDs; the charger and GPS LEDs are not controllable." }, "nodeInfoBroadcastInterval": { "description": "How often to broadcast node info", @@ -157,15 +157,11 @@ }, "twelveHourClock": { "description": "Display time in 12h format", - "label": "Use 12h clock format" + "label": "12 Hour Clock" }, "wakeOnTapOrMotion": { "description": "Wake the device on tap or motion", - "label": "Wake on tap or motion" - }, - "enableMessageBubbles": { - "label": "Message Bubbles", - "description": "Display incoming messages as bubbles on the device screen" + "label": "Wake screen on tap or motion" }, "deviceDisplay": { "label": "Device Display", @@ -195,9 +191,12 @@ "description": "The denominator of the coding rate", "label": "Coding Rate" }, - "frequencyOffset": { - "description": "Frequency offset to correct for crystal calibration errors", - "label": "Frequency Offset" + "codingRateOverride": { + "label": "Coding Rate Override", + "description": "Adds error correction on top of the preset. A higher coding rate makes every packet longer on air and uses more of the duty cycle and channel utilization budget.", + "maxDescription": "This preset already uses the highest coding rate.", + "presetDefault": "Preset default ({{rate}})", + "fraction": "4/{{rate}}" }, "frequencySlot": { "description": "Your node's operating frequency is calculated based on the region, modem preset, and this field. When 0, the slot is automatically calculated based on the primary channel name and will change from the default public slot.", @@ -205,7 +204,7 @@ }, "hopLimit": { "description": "Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. 0 hop broadcast messages will not get ACKs.", - "label": "Number of Hops" + "label": "Hop Limit" }, "ignoreMqtt": { "description": "Don't forward MQTT messages over the mesh", @@ -229,6 +228,10 @@ "description": "Override frequency", "label": "Frequency Override" }, + "paFanDisabled": { + "label": "PA Fan Disabled", + "description": "Turn off the power amplifier's cooling fan" + }, "region": { "description": "The region where you will be using your radios.", "licensedOnlySuffix": "(licensed operators only)", @@ -262,14 +265,6 @@ "label": "Radio Settings", "description": "Settings for the LoRa radio" }, - "femLnaMode": { - "label": "FEM/LNA Mode", - "description": "Control an external RF front-end module / low-noise amplifier (only on boards that have one)" - }, - "serialHalOnly": { - "label": "Serial HAL Only", - "description": "Communicate with the radio exclusively over the serial hardware abstraction layer" - }, "optionsCard": { "label": "Options", "description": "LoRa core options" @@ -285,7 +280,7 @@ "note": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.", "addressMode": { "description": "Address assignment selection", - "label": "IPv4 mode" + "label": "Address mode" }, "dns": { "description": "DNS Server", @@ -319,9 +314,9 @@ "description": "Enable or disable the WiFi radio", "label": "WiFi enabled" }, - "meshViaUdp": { - "label": "Enabled", - "description": "Forward mesh packets over UDP" + "udpBroadcast": { + "label": "UDP broadcasting", + "description": "Enable broadcasting packets via UDP over the local network." }, "ntpServer": { "label": "NTP server" @@ -391,11 +386,11 @@ }, "gpsMode": { "description": "Configure whether device GPS is Enabled, Disabled, or Not Present", - "label": "GPS Mode (Physical Hardware)" + "label": "GPS Mode" }, "gpsUpdateInterval": { "description": "How often should we try to get a GPS position (<10sec keeps GPS on).", - "label": "GPS Polling Interval" + "label": "Update Interval" }, "positionFlags": { "description": "Optional fields to include when assembling position messages. The more fields are included, the larger the message will be — leading to longer airtime and a higher risk of packet loss.", @@ -411,11 +406,11 @@ }, "smartPositionMinDistance": { "description": "The minimum distance change in meters to be considered for a smart position broadcast.", - "label": "Smart Distance" + "label": "Minimum Distance" }, "smartPositionMinInterval": { "description": "The fastest that position updates will be sent if the minimum distance has been satisfied.", - "label": "Smart Interval" + "label": "Minimum Interval" }, "transmitPin": { "description": "GPS transmit GPIO pin override", @@ -460,7 +455,11 @@ "power": { "adcMultiplierOverride": { "description": "Used for tweaking battery voltage reading", - "label": "ADC multiplier override ratio" + "label": "ADC Override" + }, + "adcOverrideEnabled": { + "label": "ADC multiplier override", + "description": "Override the battery voltage reading's ADC multiplier" }, "ina219Address": { "description": "Address of the INA219 battery monitor", @@ -480,7 +479,7 @@ }, "powerSavingEnabled": { "description": "Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.", - "label": "Enable power saving mode" + "label": "Power saving" }, "shutdownOnBatteryDelay": { "description": "Automatically shutdown node after this long when on battery, 0 for indefinite", @@ -506,10 +505,6 @@ "security": { "description": "Settings for the Security configuration", "title": "Security", - "adminChannelEnabled": { - "description": "Allow incoming device control over the insecure legacy admin channel", - "label": "Legacy Admin channel" - }, "enableDebugLogApi": { "description": "Output live debug logging over serial, view and export position-redacted device logs over Bluetooth", "label": "Debug log API enabled" @@ -564,21 +559,22 @@ }, "administration": { "label": "Administration", - "description": "Managed-mode and legacy admin channel" + "description": "Managed mode and lockdown" }, "packetSignaturesCard": { - "label": "Packet Signatures", + "label": "Packet authenticity", "description": "How the device treats signed and unsigned packets it receives (firmware 2.8+)" }, "packetSignaturePolicy": { - "label": "Packet Signature Policy", + "label": "Protection level", "description": "Compatible accepts unsigned packets but drops any with a wrong signature. Balanced also rejects unsigned broadcasts from nodes that have signed before. Strict accepts only packets with a verified signature or successful PKI decryption, so packets from older nodes may be ignored.", "compatible": "Compatible - Accept Unsigned", "balanced": "Balanced - Prefer Authenticated", - "strict": "Strict - Require Authentication" + "strict": "Strict - Require Authentication", + "unsupported": "This device does not support packet signature verification." }, "lockdown": { - "label": "Lockdown", + "label": "Lockdown mode", "description": "Encrypts the radio's stored settings and withholds them from clients until they unlock it with a passphrase.", "status": "Status", "state": { @@ -634,6 +630,10 @@ "hardwareModel": { "label": "Hardware model", "description": "Read-only — reported by the device" + }, + "statusMessage": { + "label": "Status Message", + "description": "A public status message that is broadcast to the mesh on change and every 12 hours. Up to 80 bytes." } } } diff --git a/apps/web/src/components/PageComponents/Settings/Device/index.tsx b/apps/web/src/components/PageComponents/Settings/Device/index.tsx index d4ea77061..f17fe0ef4 100644 --- a/apps/web/src/components/PageComponents/Settings/Device/index.tsx +++ b/apps/web/src/components/PageComponents/Settings/Device/index.tsx @@ -2,6 +2,8 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig"; import { type DeviceValidation, DeviceValidationSchema, + toDeviceConfig, + toDeviceFormValues, } from "@app/validation/config/device.ts"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { @@ -42,7 +44,7 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => { if (!editor) return; editor.setRadioSection( "device", - data as unknown as Protobuf.Config.Config_DeviceConfig, + toDeviceConfig(data) as unknown as Protobuf.Config.Config_DeviceConfig, ); }; @@ -51,8 +53,8 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={DeviceValidationSchema} - defaultValues={config.device} - values={effective} + defaultValues={toDeviceFormValues(config.device)} + values={toDeviceFormValues(effective)} fieldGroups={[ { label: t("device.options.label"), @@ -100,15 +102,15 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => { }, { type: "toggle", - name: "disableTripleClick", - label: t("device.disableTripleClick.label"), - description: t("device.disableTripleClick.description"), + name: "tripleClickAdHocPing", + label: t("device.tripleClickAdHocPing.label"), + description: t("device.tripleClickAdHocPing.description"), }, { type: "toggle", - name: "ledHeartbeatDisabled", - label: t("device.ledHeartbeatDisabled.label"), - description: t("device.ledHeartbeatDisabled.description"), + name: "ledHeartbeat", + label: t("device.ledHeartbeat.label"), + description: t("device.ledHeartbeat.description"), }, ], }, diff --git a/apps/web/src/components/PageComponents/Settings/Display.tsx b/apps/web/src/components/PageComponents/Settings/Display.tsx index 43f0b25ab..e7eaf81a3 100644 --- a/apps/web/src/components/PageComponents/Settings/Display.tsx +++ b/apps/web/src/components/PageComponents/Settings/Display.tsx @@ -145,12 +145,6 @@ export const Display = ({ onFormInit }: DisplayConfigProps) => { formatEnumName: true, }, }, - { - type: "toggle", - name: "enableMessageBubbles", - label: t("display.enableMessageBubbles.label"), - description: t("display.enableMessageBubbles.description"), - }, ], }, ]} diff --git a/apps/web/src/components/PageComponents/Settings/LoRa.test.tsx b/apps/web/src/components/PageComponents/Settings/LoRa.test.tsx new file mode 100644 index 000000000..7bbb1e82d --- /dev/null +++ b/apps/web/src/components/PageComponents/Settings/LoRa.test.tsx @@ -0,0 +1,112 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LoRa } from "./LoRa.tsx"; + +const Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset; +const Hw = Protobuf.Mesh.HardwareModel; + +let lora: Protobuf.Config.Config_LoRaConfig; +let firmwareVersion: string; +let hwModel: Protobuf.Mesh.HardwareModel | undefined; + +vi.mock("@app/core/hooks/useWaitForConfig", () => ({ + useWaitForConfig: () => {}, +})); + +vi.mock("@core/stores", () => ({ + useDevice: () => ({ + config: { lora }, + getEffectiveConfig: () => lora, + }), +})); + +vi.mock("@core/hooks/useDeviceCapabilities.ts", () => ({ + useDeviceCapabilities: () => ({ + metadata: { firmwareVersion }, + regionPresets: undefined, + supportsFirmware28: false, + excludedModules: 0, + }), +})); + +vi.mock("@core/hooks/useNodesAsProto.ts", () => ({ + useMyNodeAsProto: () => + hwModel === undefined ? undefined : { user: { hwModel } }, +})); + +vi.mock("@meshtastic/sdk-react", () => ({ + useConfigEditor: () => ({ + radio: { value: {} }, + setRadioSection: vi.fn(), + }), + useSignal: (signal: { value: unknown }) => signal.value, +})); + +const renderLoRa = (fields: { usePreset?: boolean; codingRate?: number }) => { + lora = create(Protobuf.Config.Config_LoRaConfigSchema, { + region: Protobuf.Config.Config_LoRaConfig_RegionCode.US, + usePreset: true, + modemPreset: Preset.LONG_FAST, + hopLimit: 3, + ...fields, + }); + render(); +}; + +describe("LoRa", () => { + beforeEach(() => { + firmwareVersion = "2.7.18"; + hwModel = Hw.HELTEC_V3; + }); + + it("offers a coding rate override on a preset from firmware 2.7.18", () => { + renderLoRa({ codingRate: 7 }); + expect(screen.getByText("Coding Rate Override")).toBeInTheDocument(); + expect(screen.getByText("Hop Limit")).toBeInTheDocument(); + expect( + screen.getByRole("combobox", { name: "Coding Rate Override" }), + ).toHaveTextContent("4/7"); + // The manual modem settings only show with presets off. + expect(screen.queryByText("Spreading Factor")).toBeNull(); + }); + + it("shows a rate the preset already uses as the preset default", () => { + renderLoRa({ codingRate: 5 }); + expect( + screen.getByRole("combobox", { name: "Coding Rate Override" }), + ).toHaveTextContent("Preset default (4/5)"); + }); + + it("has no coding rate override before firmware 2.7.18", () => { + firmwareVersion = "2.7.17"; + renderLoRa({}); + expect(screen.queryByText("Coding Rate Override")).toBeNull(); + }); + + it("has no coding rate override with manual modem settings", () => { + renderLoRa({ usePreset: false }); + expect(screen.queryByText("Coding Rate Override")).toBeNull(); + expect(screen.queryByText("Presets")).toBeNull(); + expect(screen.getByText("Coding Rate")).toBeInTheDocument(); + }); + + it("no longer shows Frequency Offset, FEM/LNA Mode or Serial HAL Only", () => { + renderLoRa({}); + expect(screen.queryByText("Frequency Offset")).toBeNull(); + expect(screen.queryByText("FEM/LNA Mode")).toBeNull(); + expect(screen.queryByText("Serial HAL Only")).toBeNull(); + }); + + it("shows PA Fan Disabled only for boards with a PA fan", () => { + renderLoRa({}); + expect(screen.queryByText("PA Fan Disabled")).toBeNull(); + }); + + it("shows PA Fan Disabled for a PA fan board", () => { + hwModel = Hw.RADIOMASTER_900_BANDIT; + renderLoRa({}); + expect(screen.getByText("PA Fan Disabled")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/PageComponents/Settings/LoRa.tsx b/apps/web/src/components/PageComponents/Settings/LoRa.tsx index 56e75e535..bd6f9f852 100644 --- a/apps/web/src/components/PageComponents/Settings/LoRa.tsx +++ b/apps/web/src/components/PageComponents/Settings/LoRa.tsx @@ -1,13 +1,21 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig"; import { + codingRateOverride, + codingRateOverrides, + hasPaFan, type LoRaValidation, makeLoRaValidationSchema, + normalizeCodingRate, + presetCodingRate, } from "@app/validation/config/lora.ts"; +import { createZodResolver } from "@components/Form/createZodResolver.ts"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; +import type { FieldProps } from "@components/Form/DynamicFormField.tsx"; import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; +import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts"; import { useDevice } from "@core/stores"; import { getAllowedPresets, @@ -16,10 +24,10 @@ import { getLicensedOnlyRegions, pickEnumValues, } from "@core/utils/loraPresets.ts"; -import { Protobuf } from "@meshtastic/sdk"; +import { firmwareAtLeast, Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; -import { useCallback, useMemo, useRef, useState } from "react"; -import type { UseFormReturn } from "react-hook-form"; +import { useEffect, useMemo, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; interface LoRaConfigProps { @@ -47,7 +55,8 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { const { t } = useTranslation("config"); - const { regionPresets, supportsFirmware28 } = useDeviceCapabilities(); + const { regionPresets, supportsFirmware28, metadata } = + useDeviceCapabilities(); const caps = useMemo( () => ({ regionPresets, supportsFirmware28 }), [regionPresets, supportsFirmware28], @@ -56,11 +65,52 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { () => makeLoRaValidationSchema(caps), [caps], ); + // Firmware 2.7.18+ applies a coding rate above the preset's own. + const supportsCodingRateOverride = + supportsFirmware28 || + firmwareAtLeast(metadata?.firmwareVersion ?? "", "2.7.18"); + const showPaFan = hasPaFan(useMyNodeAsProto()?.user?.hwModel); + + const toFormValues = (cfg: Protobuf.Config.Config_LoRaConfig | undefined) => + (cfg && supportsCodingRateOverride + ? normalizeCodingRate(cfg) + : cfg) as unknown as LoRaValidation; + + // The form is created here, not in DynamicForm, so the coding rate + // override can follow Use Preset and the selected preset. + const formMethods = useForm({ + mode: "onChange", + defaultValues: toFormValues(config.lora), + resolver: createZodResolver(validationSchema), + shouldFocusError: false, + resetOptions: { keepDefaultValues: true }, + values: toFormValues(effectiveLora), + }); + const { control, getValues, setValue } = formMethods; + const [usePreset, modemPreset] = useWatch({ + control, + name: ["usePreset", "modemPreset"], + }); + + useEffect(() => { + onFormInit(formMethods); + }, [onFormInit, formMethods]); + + // Keep an override only while it still raises the newly chosen preset. + const onPresetChange = ( + next: Protobuf.Config.Config_LoRaConfig_ModemPreset, + ) => { + if (!supportsCodingRateOverride || !getValues("usePreset")) return; + const current = Number(getValues("codingRate")); + const kept = codingRateOverride(next, current); + if (kept !== current) { + setValue("codingRate", kept, { shouldDirty: true }); + } + }; // The region picked in the form, which may not be saved yet. A pick only // holds while the effective config still has the region it was made // against; once it is saved or discarded the effective config wins. - const formRef = useRef | undefined>(undefined); const effectiveRegion = effectiveLora?.region; const [picked, setPicked] = useState<{ region: Protobuf.Config.Config_LoRaConfig_RegionCode; @@ -69,14 +119,6 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { const region = picked && picked.over === effectiveRegion ? picked.region : effectiveRegion; - const handleFormInit = useCallback>( - (methods) => { - formRef.current = methods; - onFormInit(methods); - }, - [onFormInit], - ); - const onRegionChange = (value: string) => { const next = Number.parseInt( value, @@ -87,9 +129,8 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { // switching regions. const defaultPreset = getDefaultPreset(regionPresets, next); if (defaultPreset !== undefined) { - formRef.current?.setValue("modemPreset", defaultPreset, { - shouldDirty: true, - }); + setValue("modemPreset", defaultPreset, { shouldDirty: true }); + onPresetChange(defaultPreset); } }; @@ -120,17 +161,116 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { if (!editor) return; editor.setRadioSection( "lora", - data as unknown as Protobuf.Config.Config_LoRaConfig, + (supportsCodingRateOverride + ? normalizeCodingRate(data) + : data) as unknown as Protobuf.Config.Config_LoRaConfig, ); }; + // Coding Rate Override: shown with presets on, like Android. Offers + // "Preset default" (stored as 0) and the rates above the preset's own. + const presetRate = + modemPreset === undefined ? undefined : presetCodingRate(modemPreset); + const codingRateOverrideFields: FieldProps[] = []; + if ( + supportsCodingRateOverride && + usePreset && + modemPreset !== undefined && + presetRate !== undefined + ) { + const overrides = codingRateOverrides(modemPreset); + const fraction = (rate: number) => + t("lora.codingRateOverride.fraction", { rate }); + codingRateOverrideFields.push({ + type: "select", + name: "codingRate", + label: t("lora.codingRateOverride.label"), + description: + overrides.length === 0 + ? t("lora.codingRateOverride.maxDescription") + : t("lora.codingRateOverride.description"), + disabled: overrides.length === 0, + properties: { + enumValue: Object.fromEntries([ + [ + t("lora.codingRateOverride.presetDefault", { + rate: fraction(presetRate), + // Plain text in a select option; keep the "/" as is. + interpolation: { escapeValue: false }, + }), + 0, + ], + ...overrides.map((rate) => [fraction(rate), rate]), + ]), + }, + }); + } + + // As on Android, presets on shows the preset (and its coding rate + // override); presets off shows the manual modem settings instead. + const modemFields: FieldProps[] = usePreset + ? [ + { + type: "select", + name: "modemPreset", + label: t("lora.modemPreset.label"), + description: + defaultPreset === undefined + ? t("lora.modemPreset.description") + : t("lora.modemPreset.descriptionForRegion"), + selectChange: (value) => + onPresetChange( + Number.parseInt( + value, + 10, + ) as Protobuf.Config.Config_LoRaConfig_ModemPreset, + ), + properties: { + enumValue: presetOptions, + formatEnumName: true, + optionSuffixes: presetSuffixes, + }, + }, + ...codingRateOverrideFields, + ] + : [ + { + type: "number", + name: "bandwidth", + label: t("lora.bandwidth.label"), + description: t("lora.bandwidth.description"), + properties: { suffix: t("unit.kilohertz") }, + }, + { + type: "number", + name: "spreadFactor", + label: t("lora.spreadingFactor.label"), + description: t("lora.spreadingFactor.description"), + properties: { suffix: t("unit.cps") }, + }, + { + type: "number", + name: "codingRate", + label: t("lora.codingRate.label"), + description: t("lora.codingRate.description"), + }, + ]; + + const paFanFields: FieldProps[] = showPaFan + ? [ + { + type: "toggle", + name: "paFanDisabled", + label: t("lora.paFanDisabled.label"), + description: t("lora.paFanDisabled.description"), + }, + ] + : []; + return ( + propMethods={formMethods} onSubmit={onSubmit} - onFormInit={handleFormInit} - validationSchema={validationSchema} - defaultValues={config.lora} - values={effectiveLora} fieldGroups={[ { label: t("lora.optionsCard.label"), @@ -153,44 +293,7 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { label: t("lora.usePreset.label"), description: t("lora.usePreset.description"), }, - { - type: "select", - name: "modemPreset", - label: t("lora.modemPreset.label"), - description: - defaultPreset === undefined - ? t("lora.modemPreset.description") - : t("lora.modemPreset.descriptionForRegion"), - disabledBy: [{ fieldName: "usePreset" }], - properties: { - enumValue: presetOptions, - formatEnumName: true, - optionSuffixes: presetSuffixes, - }, - }, - { - type: "number", - name: "bandwidth", - label: t("lora.bandwidth.label"), - description: t("lora.bandwidth.description"), - disabledBy: [{ fieldName: "usePreset", invert: true }], - properties: { suffix: t("unit.kilohertz") }, - }, - { - type: "number", - name: "spreadFactor", - label: t("lora.spreadingFactor.label"), - description: t("lora.spreadingFactor.description"), - disabledBy: [{ fieldName: "usePreset", invert: true }], - properties: { suffix: t("unit.cps") }, - }, - { - type: "number", - name: "codingRate", - label: t("lora.codingRate.label"), - description: t("lora.codingRate.description"), - disabledBy: [{ fieldName: "usePreset", invert: true }], - }, + ...modemFields, ], }, { @@ -236,15 +339,6 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { label: t("lora.frequencySlot.label"), description: t("lora.frequencySlot.description"), }, - { - type: "number", - name: "frequencyOffset", - label: t("lora.frequencyOffset.label"), - description: t("lora.frequencyOffset.description"), - properties: { - suffix: t("unit.hertz"), - }, - }, { type: "toggle", name: "sx126xRxBoostedGain", @@ -268,22 +362,7 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { description: t("lora.transmitPower.description"), properties: { suffix: t("unit.dbm") }, }, - { - type: "select", - name: "femLnaMode", - label: t("lora.femLnaMode.label"), - description: t("lora.femLnaMode.description"), - properties: { - enumValue: Protobuf.Config.Config_LoRaConfig_FEM_LNA_Mode, - formatEnumName: true, - }, - }, - { - type: "toggle", - name: "serialHalOnly", - label: t("lora.serialHalOnly.label"), - description: t("lora.serialHalOnly.description"), - }, + ...paFanFields, ], }, ]} diff --git a/apps/web/src/components/PageComponents/Settings/Network/Network.test.tsx b/apps/web/src/components/PageComponents/Settings/Network/Network.test.tsx new file mode 100644 index 000000000..2e0948f4b --- /dev/null +++ b/apps/web/src/components/PageComponents/Settings/Network/Network.test.tsx @@ -0,0 +1,74 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Network } from "./index.tsx"; + +const UDP = Protobuf.Config.Config_NetworkConfig_ProtocolFlags.UDP_BROADCAST; + +let network: Protobuf.Config.Config_NetworkConfig; +let metadata: { hasEthernet: boolean } | undefined; +const setRadioSection = vi.fn(); + +vi.mock("@app/core/hooks/useWaitForConfig", () => ({ + useWaitForConfig: () => {}, +})); + +vi.mock("@core/stores", () => ({ + useDevice: () => ({ + config: { network }, + getEffectiveConfig: () => network, + }), +})); + +vi.mock("@core/hooks/useDeviceCapabilities.ts", () => ({ + useDeviceCapabilities: () => ({ metadata }), +})); + +vi.mock("@meshtastic/sdk-react", () => ({ + useConfigEditor: () => ({ radio: { value: {} }, setRadioSection }), + useSignal: (signal: { value: unknown }) => signal.value, +})); + +const renderNetwork = (enabledProtocols = 0) => { + network = create(Protobuf.Config.Config_NetworkConfigSchema, { + enabledProtocols, + ipv4Config: create(Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema), + }); + render(); +}; + +describe("Network", () => { + beforeEach(() => { + vi.clearAllMocks(); + metadata = { hasEthernet: true }; + }); + + it("shows Ethernet only when the radio has it or is not known yet", () => { + renderNetwork(); + expect(screen.getByText("Ethernet Options")).toBeInTheDocument(); + }); + + it("hides Ethernet when the radio has none", () => { + metadata = { hasEthernet: false }; + renderNetwork(); + expect(screen.queryByText("Ethernet Options")).toBeNull(); + }); + + it("shows Ethernet before the radio's metadata arrives", () => { + metadata = undefined; + renderNetwork(); + expect(screen.getByText("Ethernet Options")).toBeInTheDocument(); + }); + + it("switches the UDP_BROADCAST bit, keeping the other bits", async () => { + renderNetwork(0b100 | UDP); + const udp = screen.getByRole("switch", { name: "UDP broadcasting" }); + expect(udp).toBeChecked(); + fireEvent.click(udp); + await waitFor(() => expect(setRadioSection).toHaveBeenCalled()); + const [, staged] = setRadioSection.mock.lastCall ?? []; + expect(staged.enabledProtocols).toBe(0b100); + expect(staged).not.toHaveProperty("udpBroadcast"); + }); +}); diff --git a/apps/web/src/components/PageComponents/Settings/Network/index.tsx b/apps/web/src/components/PageComponents/Settings/Network/index.tsx index 5726d31cf..3fd0c6520 100644 --- a/apps/web/src/components/PageComponents/Settings/Network/index.tsx +++ b/apps/web/src/components/PageComponents/Settings/Network/index.tsx @@ -1,13 +1,16 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig"; import { + hasUdpBroadcast, type NetworkValidation, NetworkValidationSchema, + withUdpBroadcast, } from "@app/validation/config/network.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; +import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import { useDevice } from "@core/stores"; import { convertIntToIpAddress, @@ -37,9 +40,7 @@ const toFormShape = ( subnet: convertIntToIpAddress(cfg?.ipv4Config?.subnet ?? 0), dns: convertIntToIpAddress(cfg?.ipv4Config?.dns ?? 0), }, - enabledProtocols: - cfg?.enabledProtocols ?? - Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, + udpBroadcast: hasUdpBroadcast(cfg?.enabledProtocols), }); export const Network = ({ onFormInit }: NetworkConfigProps) => { @@ -55,11 +56,18 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => { | undefined); const { t } = useTranslation("config"); + const { metadata } = useDeviceCapabilities(); + // Until metadata arrives, show Ethernet, as the Device tabs do. + const hasEthernet = !metadata || metadata.hasEthernet; - const onSubmit = (data: NetworkValidation) => { + const onSubmit = ({ udpBroadcast, ...data }: NetworkValidation) => { if (!editor) return; const payload = { ...data, + enabledProtocols: withUdpBroadcast( + effective?.enabledProtocols, + udpBroadcast, + ), ipv4Config: create( Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema, { @@ -111,18 +119,22 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => { }, ], }, - { - label: t("network.ethernetOptionsCard.label"), - description: t("network.ethernetOptionsCard.description"), - fields: [ - { - type: "toggle", - name: "ethEnabled", - label: t("network.ethernetEnabled.label"), - description: t("network.ethernetEnabled.description"), - }, - ], - }, + ...(hasEthernet + ? [ + { + label: t("network.ethernetOptionsCard.label"), + description: t("network.ethernetOptionsCard.description"), + fields: [ + { + type: "toggle" as const, + name: "ethEnabled" as const, + label: t("network.ethernetEnabled.label"), + description: t("network.ethernetEnabled.description"), + }, + ], + }, + ] + : []), { label: t("network.advancedCard.label"), description: t("network.advancedCard.description"), @@ -138,14 +150,10 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => { label: t("network.rsyslogServer.label"), }, { - type: "select", - name: "enabledProtocols", - label: t("network.meshViaUdp.label"), - description: t("network.meshViaUdp.description"), - properties: { - enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags, - formatEnumName: true, - }, + type: "toggle", + name: "udpBroadcast", + label: t("network.udpBroadcast.label"), + description: t("network.udpBroadcast.description"), }, { type: "select", diff --git a/apps/web/src/components/PageComponents/Settings/Power.test.tsx b/apps/web/src/components/PageComponents/Settings/Power.test.tsx new file mode 100644 index 000000000..01d721398 --- /dev/null +++ b/apps/web/src/components/PageComponents/Settings/Power.test.tsx @@ -0,0 +1,63 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Power } from "./Power.tsx"; + +let power: Protobuf.Config.Config_PowerConfig; + +vi.mock("@app/core/hooks/useWaitForConfig", () => ({ + useWaitForConfig: () => {}, +})); + +vi.mock("@core/stores", () => ({ + useDevice: () => ({ + config: { power }, + getEffectiveConfig: () => power, + }), +})); + +vi.mock("@meshtastic/sdk-react", () => ({ + useConfigEditor: () => ({ + radio: { value: {} }, + setRadioSection: vi.fn(), + }), + useSignal: (signal: { value: unknown }) => signal.value, +})); + +const renderPower = (adcMultiplierOverride: number) => { + power = create(Protobuf.Config.Config_PowerConfigSchema, { + adcMultiplierOverride, + }); + render(); +}; + +describe("Power ADC multiplier override", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hides the ratio while the override is off", () => { + renderPower(0); + expect( + screen.getByRole("switch", { name: "ADC multiplier override" }), + ).not.toBeChecked(); + expect(screen.queryByLabelText("ADC Override")).toBeNull(); + }); + + it("shows the ratio while the override is on", () => { + renderPower(2.5); + expect( + screen.getByRole("switch", { name: "ADC multiplier override" }), + ).toBeChecked(); + expect(screen.getByLabelText("ADC Override")).toHaveValue(2.5); + }); + + it("starts from a ratio of 1 when switched on", async () => { + renderPower(0); + fireEvent.click( + screen.getByRole("switch", { name: "ADC multiplier override" }), + ); + expect(await screen.findByLabelText("ADC Override")).toHaveValue(1); + }); +}); diff --git a/apps/web/src/components/PageComponents/Settings/Power.tsx b/apps/web/src/components/PageComponents/Settings/Power.tsx index 038e8c6e7..486f6c254 100644 --- a/apps/web/src/components/PageComponents/Settings/Power.tsx +++ b/apps/web/src/components/PageComponents/Settings/Power.tsx @@ -2,14 +2,20 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig"; import { type PowerValidation, PowerValidationSchema, + toPowerConfig, + toPowerFormValues, } from "@app/validation/config/power.ts"; +import { createZodResolver } from "@components/Form/createZodResolver.ts"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; +import type { FieldProps } from "@components/Form/DynamicFormField.tsx"; import { useDevice } from "@core/stores"; -import { Protobuf } from "@meshtastic/sdk"; +import type { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { useEffect } from "react"; +import { useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; interface PowerConfigProps { @@ -36,21 +42,62 @@ export const Power = ({ onFormInit }: PowerConfigProps) => { const { t } = useTranslation("config"); + // The form is created here, not in DynamicForm, so the ADC ratio field can + // follow the override switch. + const formMethods = useForm({ + mode: "onChange", + defaultValues: toPowerFormValues(config.power), + resolver: createZodResolver(PowerValidationSchema), + shouldFocusError: false, + resetOptions: { keepDefaultValues: true }, + values: toPowerFormValues(effective), + }); + const { control, getValues, setValue } = formMethods; + const adcOverrideEnabled = useWatch({ control, name: "adcOverrideEnabled" }); + + useEffect(() => { + onFormInit(formMethods); + }, [onFormInit, formMethods]); + const onSubmit = (data: PowerValidation) => { if (!editor) return; editor.setRadioSection( "power", - data as unknown as Protobuf.Config.Config_PowerConfig, + toPowerConfig(data) as unknown as Protobuf.Config.Config_PowerConfig, ); }; + const adcFields: FieldProps[] = [ + { + type: "toggle", + name: "adcOverrideEnabled", + label: t("power.adcOverrideEnabled.label"), + description: t("power.adcOverrideEnabled.description"), + // As on Android: switching on starts from a ratio of 1, off clears it. + inputChange: (checked) => { + const ratio = getValues("adcMultiplierOverride"); + if (checked && !(Number(ratio) > 0)) { + setValue("adcMultiplierOverride", 1, { shouldDirty: true }); + } else if (!checked) { + setValue("adcMultiplierOverride", 0, { shouldDirty: true }); + } + }, + }, + ]; + if (adcOverrideEnabled) { + adcFields.push({ + type: "number", + name: "adcMultiplierOverride", + label: t("power.adcMultiplierOverride.label"), + description: t("power.adcMultiplierOverride.description"), + properties: { step: 0.0001 }, + }); + } + return ( + propMethods={formMethods} onSubmit={onSubmit} - onFormInit={onFormInit} - validationSchema={PowerValidationSchema} - defaultValues={config.power} - values={effective} fieldGroups={[ { label: t("power.powerConfig.label"), @@ -69,13 +116,7 @@ export const Power = ({ onFormInit }: PowerConfigProps) => { description: t("power.shutdownOnBatteryDelay.description"), properties: { suffix: t("unit.second.plural") }, }, - { - type: "number", - name: "adcMultiplierOverride", - label: t("power.adcMultiplierOverride.label"), - description: t("power.adcMultiplierOverride.description"), - properties: { step: 0.0001 }, - }, + ...adcFields, { type: "number", name: "waitBluetoothSecs", diff --git a/apps/web/src/components/PageComponents/Settings/Security/LockdownSection.tsx b/apps/web/src/components/PageComponents/Settings/Security/LockdownSection.tsx index 70e6ce134..72ae88a05 100644 --- a/apps/web/src/components/PageComponents/Settings/Security/LockdownSection.tsx +++ b/apps/web/src/components/PageComponents/Settings/Security/LockdownSection.tsx @@ -1,6 +1,6 @@ import { DisableLockdownDialog } from "@components/Dialog/LockdownDialog/DisableLockdownDialog.tsx"; +import { FieldWrapper } from "@components/Form/FormWrapper.tsx"; import { Button } from "@components/UI/Button.tsx"; -import { Heading } from "@components/UI/Typography/Heading.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { useLockdown } from "@core/hooks/useLockdown.ts"; import { useToast } from "@core/hooks/useToast.ts"; @@ -20,8 +20,9 @@ const STATE_KEYS: Partial> = }; /** - * Lockdown controls on the Security page. Only rendered for firmware that - * reports a lockdown status, i.e. lockdown-capable builds. + * Lockdown controls, shown as the last row of the Security page's + * Administration group (after Managed Mode), as on Android. Only rendered for + * firmware that reports a lockdown status, i.e. lockdown-capable builds. */ export const LockdownSection = () => { const { t } = useTranslation("config"); @@ -46,61 +47,62 @@ export const LockdownSection = () => { }; return ( -
    -
    - - {t("security.lockdown.label")} - - {t("security.lockdown.description")} -
    - -
    -

    - {t("security.lockdown.status")}: - - {t(`security.lockdown.state.${stateKey}`)} - -

    - {unlocked && status.bootsRemaining > 0 && ( -

    - {t("security.lockdown.bootsRemaining", { - count: status.bootsRemaining, - })} -

    - )} - {unlocked && status.validUntilEpoch > 0 && ( + +
    +

    - {t("security.lockdown.expires", { - date: new Date(status.validUntilEpoch * 1000).toLocaleString(), - })} + + {t("security.lockdown.status")}:{" "} + + + {t(`security.lockdown.state.${stateKey}`)} +

    - )} -
    + {unlocked && status.bootsRemaining > 0 && ( +

    + {t("security.lockdown.bootsRemaining", { + count: status.bootsRemaining, + })} +

    + )} + {unlocked && status.validUntilEpoch > 0 && ( +

    + {t("security.lockdown.expires", { + date: new Date(status.validUntilEpoch * 1000).toLocaleString(), + })} +

    + )} +
    - {unlocked && ( -
    -
    - - {t("security.lockdown.lockNowDescription")} -
    -
    - - {t("security.lockdown.disableDescription")} + {unlocked && ( +
    +
    + + {t("security.lockdown.lockNowDescription")} +
    +
    + + {t("security.lockdown.disableDescription")} +
    -
    - )} + )} +
    { report(client.device.disableLockdown(passphrase)) } /> -
    + ); }; diff --git a/apps/web/src/components/PageComponents/Settings/Security/Security.test.tsx b/apps/web/src/components/PageComponents/Settings/Security/Security.test.tsx new file mode 100644 index 000000000..0c130c83b --- /dev/null +++ b/apps/web/src/components/PageComponents/Settings/Security/Security.test.tsx @@ -0,0 +1,88 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Security } from "./Security.tsx"; + +const security = create(Protobuf.Config.Config_SecurityConfigSchema, { + privateKey: new Uint8Array(32), + publicKey: new Uint8Array(32), + adminChannelEnabled: true, +}); +let metadata: { hasXeddsa: boolean } | undefined; + +vi.mock("@app/core/hooks/useWaitForConfig", () => ({ + useWaitForConfig: () => {}, +})); + +vi.mock("@core/stores", () => ({ + useDevice: () => ({ + config: { security }, + getEffectiveConfig: () => security, + }), +})); + +vi.mock("@core/hooks/useDeviceCapabilities.ts", () => ({ + useDeviceCapabilities: () => ({ metadata }), +})); + +vi.mock("@core/hooks/useLockdown.ts", () => ({ + useLockdown: () => ({ client: undefined, status: undefined }), +})); + +vi.mock("@meshtastic/sdk-react", () => ({ + useConfigEditor: () => ({ + radio: { value: {} }, + setRadioSection: vi.fn(), + }), + useSignal: (signal: { value: unknown }) => signal.value, +})); + +describe("Security", () => { + beforeEach(() => { + metadata = { hasXeddsa: true }; + }); + + it("orders the groups as Android does, without the legacy admin channel", () => { + render(); + const headings = screen + .getAllByRole("heading", { level: 4 }) + .map((h) => h.textContent); + expect(headings).toEqual([ + "Packet authenticity", + "Direct Message Key", + "Admin Keys", + "Logs", + "Administration", + ]); + expect(screen.queryByText("Legacy Admin channel")).toBeNull(); + }); + + it("enables the protection level when the radio reports XEdDSA", () => { + render(); + expect( + screen.getByRole("combobox", { name: "Protection level" }), + ).toBeEnabled(); + }); + + it("disables it when the radio does not support it", () => { + metadata = { hasXeddsa: false }; + render(); + expect( + screen.getByRole("combobox", { name: "Protection level" }), + ).toBeDisabled(); + expect( + screen.getByText( + "This device does not support packet signature verification.", + ), + ).toBeInTheDocument(); + }); + + it("disables it until the radio's metadata arrives", () => { + metadata = undefined; + render(); + expect( + screen.getByRole("combobox", { name: "Protection level" }), + ).toBeDisabled(); + }); +}); diff --git a/apps/web/src/components/PageComponents/Settings/Security/Security.tsx b/apps/web/src/components/PageComponents/Settings/Security/Security.tsx index 7f47313f9..a70834262 100644 --- a/apps/web/src/components/PageComponents/Settings/Security/Security.tsx +++ b/apps/web/src/components/PageComponents/Settings/Security/Security.tsx @@ -11,8 +11,8 @@ import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; +import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import { useDevice } from "@core/stores"; -import { LockdownSection } from "./LockdownSection.tsx"; import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts"; import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; @@ -20,6 +20,7 @@ import { fromByteArray, toByteArray } from "base64-js"; import { useEffect, useState } from "react"; import { type DefaultValues, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { LockdownSection } from "./LockdownSection.tsx"; interface SecurityConfigProps { onFormInit: DynamicFormFormInit; @@ -57,6 +58,7 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { | undefined); const { t } = useTranslation("config"); + const { metadata } = useDeviceCapabilities(); const formMethods = useForm({ mode: "onChange", @@ -145,10 +147,40 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { propMethods={formMethods} onSubmit={onSubmit} fieldGroups={[ + { + label: t("security.packetSignaturesCard.label"), + description: t("security.packetSignaturesCard.description"), + fields: [ + { + type: "select", + name: "packetSignaturePolicy", + label: t("security.packetSignaturePolicy.label"), + description: + metadata?.hasXeddsa === false + ? t("security.packetSignaturePolicy.unsupported") + : t("security.packetSignaturePolicy.description"), + // Only a radio that reports XEdDSA signing can apply the policy. + disabled: metadata?.hasXeddsa !== true, + properties: { + enumValue: packetSignaturePolicyOptions, + }, + }, + ], + }, { label: t("security.directMessageKey.label"), description: t("security.directMessageKey.description"), fields: [ + { + type: "text", + name: "publicKey", + label: t("security.publicKey.label"), + disabled: true, + description: t("security.publicKey.description"), + properties: { + showCopyButton: true, + }, + }, { type: "passwordGenerator", id: "pskInput", @@ -173,16 +205,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { showPasswordToggle: true, }, }, - { - type: "text", - name: "publicKey", - label: t("security.publicKey.label"), - disabled: true, - description: t("security.publicKey.description"), - properties: { - showCopyButton: true, - }, - }, ], }, { @@ -198,9 +220,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { bits, devicePSKBitCount: 32, actionButtons: [], - disabledBy: [ - { fieldName: "adminChannelEnabled", invert: true }, - ], properties: { showCopyButton: true }, }, { @@ -212,9 +231,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { bits, devicePSKBitCount: 32, actionButtons: [], - disabledBy: [ - { fieldName: "adminChannelEnabled", invert: true }, - ], properties: { showCopyButton: true }, }, { @@ -226,28 +242,10 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { bits, devicePSKBitCount: 32, actionButtons: [], - disabledBy: [ - { fieldName: "adminChannelEnabled", invert: true }, - ], properties: { showCopyButton: true }, }, ], }, - { - label: t("security.packetSignaturesCard.label"), - description: t("security.packetSignaturesCard.description"), - fields: [ - { - type: "select", - name: "packetSignaturePolicy", - label: t("security.packetSignaturePolicy.label"), - description: t("security.packetSignaturePolicy.description"), - properties: { - enumValue: packetSignaturePolicyOptions, - }, - }, - ], - }, { label: t("security.logsCard.label"), description: t("security.logsCard.description"), @@ -282,17 +280,11 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { setValue("isManaged", false); }, }, - { - type: "toggle", - name: "adminChannelEnabled", - label: t("security.adminChannelEnabled.label"), - description: t("security.adminChannelEnabled.description"), - }, ], + footer: , }, ]} /> - ({ + useMyNodeAsProto: () => ({ user }), +})); + +vi.mock("@core/stores", () => ({ + useDevice: () => ({ + moduleConfig: { statusmessage }, + getEffectiveModuleConfig: () => statusmessage, + }), +})); + +vi.mock("@core/hooks/useDeviceCapabilities.ts", () => ({ + useDeviceCapabilities: () => ({ + metadata: { firmwareVersion }, + supportsFirmware28: false, + excludedModules, + }), +})); + +vi.mock("@meshtastic/sdk-react", () => ({ + useConfigEditor: () => ({ + owner: { value: undefined }, + modules: { value: {} }, + setBaselineOwner: vi.fn(), + setOwner, + setModuleSection, + }), + useSignal: (signal: { value: unknown }) => signal.value, +})); + +describe("User", () => { + beforeEach(() => { + vi.clearAllMocks(); + firmwareVersion = "2.7.20"; + excludedModules = 0; + statusmessage = create( + Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfigSchema, + { nodeStatus: "On the hill" }, + ); + }); + + it("shows the status message after Short Name from firmware 2.7.20", () => { + render(); + const labels = screen + .getAllByText( + /^(Node ID|Long Name|Short Name|Status Message|Hardware model|Unmessageable|Licensed amateur radio \(Ham\))$/, + ) + .map((el) => el.textContent); + expect(labels).toEqual([ + "Node ID", + "Long Name", + "Short Name", + "Status Message", + "Hardware model", + "Unmessageable", + "Licensed amateur radio (Ham)", + ]); + expect(screen.getByLabelText("Status Message")).toHaveValue("On the hill"); + }); + + it("hides the status message on older firmware", () => { + firmwareVersion = "2.7.19"; + render(); + expect(screen.queryByText("Status Message")).toBeNull(); + }); + + it("hides the status message when the build leaves the module out", () => { + excludedModules = Excluded.STATUSMESSAGE_CONFIG; + render(); + expect(screen.queryByText("Status Message")).toBeNull(); + }); + + it("waits for the module's config before showing the status message", () => { + statusmessage = undefined; + render(); + expect(screen.queryByText("Status Message")).toBeNull(); + }); + + it("stages the owner, keeping its other fields, and the status", async () => { + render(); + fireEvent.change(screen.getByLabelText("Status Message"), { + target: { value: "Back at base" }, + }); + + await waitFor(() => expect(setModuleSection).toHaveBeenCalled()); + expect(setModuleSection).toHaveBeenLastCalledWith( + "statusmessage", + expect.objectContaining({ nodeStatus: "Back at base" }), + ); + expect(setOwner).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: "!abcd1234", + longName: "Base Station", + role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER, + }), + ); + }); +}); diff --git a/apps/web/src/components/PageComponents/Settings/User.tsx b/apps/web/src/components/PageComponents/Settings/User.tsx index 1f03e8a17..7a8a2f1d2 100644 --- a/apps/web/src/components/PageComponents/Settings/User.tsx +++ b/apps/web/src/components/PageComponents/Settings/User.tsx @@ -1,4 +1,5 @@ import { + NODE_STATUS_MAX_BYTES, type UserValidation, UserValidationSchema, } from "@app/validation/config/user.ts"; @@ -7,8 +8,11 @@ import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; +import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts"; -import { Protobuf } from "@meshtastic/sdk"; +import { useDevice } from "@core/stores"; +import { isModuleExcluded } from "@core/utils/excludedModules.ts"; +import { firmwareAtLeast, Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; @@ -23,11 +27,41 @@ const EMPTY_OWNER_SIGNAL = { subscribe: () => () => {}, } as const; +const EMPTY_MODULES_SIGNAL = { + value: {} as { + statusmessage?: Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfig; + }, + peek: () => + ({}) as { + statusmessage?: Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfig; + }, + subscribe: () => () => {}, +} as const; + export const User = ({ onFormInit }: UserConfigProps) => { const { t } = useTranslation("config"); const editor = useConfigEditor(); const myNode = useMyNodeAsProto(); const owner = useSignal(editor?.owner ?? EMPTY_OWNER_SIGNAL); + const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL); + const { moduleConfig, getEffectiveModuleConfig } = useDevice(); + const { metadata, supportsFirmware28, excludedModules } = + useDeviceCapabilities(); + + // The Status Message module's one field lives here, as on Android: from + // firmware 2.7.20, unless the build leaves the module out. It also waits + // for the module's config, so an edit is never shown but not staged. + const supportsStatusMessage = + (supportsFirmware28 || + firmwareAtLeast(metadata?.firmwareVersion ?? "", "2.7.20")) && + !isModuleExcluded(excludedModules, "statusmessage"); + const showStatusMessage = + supportsStatusMessage && moduleConfig.statusmessage !== undefined; + const baselineStatus = moduleConfig.statusmessage?.nodeStatus ?? ""; + const effectiveStatus = + modules.statusmessage?.nodeStatus ?? + getEffectiveModuleConfig("statusmessage")?.nodeStatus ?? + baselineStatus; // Seed the editor's baseline when the device's user info first arrives. // setBaselineOwner is a no-op when the working copy is dirty, so user @@ -59,6 +93,16 @@ export const User = ({ onFormInit }: UserConfigProps) => { isLicensed: data.isLicensed, }); editor.setOwner(merged); + // Only stage the status once the device has sent the module's config, + // so a node without it never gets a new, dirty section. + if (showStatusMessage) { + editor.setModuleSection( + "statusmessage", + create(Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfigSchema, { + nodeStatus: data.nodeStatus, + }), + ); + } }; const hwName = @@ -77,6 +121,7 @@ export const User = ({ onFormInit }: UserConfigProps) => { shortName: baselineUser.shortName, isLicensed: baselineUser.isLicensed, isUnmessageable: baselineUser.isUnmessagable ?? false, + nodeStatus: baselineStatus, }} values={{ nodeId: effectiveUser.id, @@ -85,6 +130,7 @@ export const User = ({ onFormInit }: UserConfigProps) => { shortName: effectiveUser.shortName, isLicensed: effectiveUser.isLicensed, isUnmessageable: effectiveUser.isUnmessagable ?? false, + nodeStatus: effectiveStatus, }} fieldGroups={[ { @@ -119,6 +165,22 @@ export const User = ({ onFormInit }: UserConfigProps) => { fieldLength: { min: 1, max: 4, showCharacterCount: true }, }, }, + ...(showStatusMessage + ? [ + { + type: "text" as const, + name: "nodeStatus" as const, + label: t("user.statusMessage.label"), + description: t("user.statusMessage.description"), + properties: { + fieldLength: { + max: NODE_STATUS_MAX_BYTES, + showCharacterCount: true, + }, + }, + }, + ] + : []), { type: "text", name: "hardwareModel", diff --git a/apps/web/src/validation/config/device.test.ts b/apps/web/src/validation/config/device.test.ts new file mode 100644 index 000000000..c1b0bf6e6 --- /dev/null +++ b/apps/web/src/validation/config/device.test.ts @@ -0,0 +1,32 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { toDeviceConfig, toDeviceFormValues } from "./device.ts"; + +describe("Device toggles shown as on Android", () => { + it("shows the inverse of disableTripleClick and ledHeartbeatDisabled", () => { + const values = toDeviceFormValues( + create(Protobuf.Config.Config_DeviceConfigSchema, { + disableTripleClick: true, + ledHeartbeatDisabled: false, + }), + ); + expect(values.tripleClickAdHocPing).toBe(false); + expect(values.ledHeartbeat).toBe(true); + }); + + it("writes the proto fields back, without the form-only names", () => { + const values = toDeviceFormValues( + create(Protobuf.Config.Config_DeviceConfigSchema), + ); + const cfg = toDeviceConfig({ + ...values, + tripleClickAdHocPing: false, + ledHeartbeat: false, + }); + expect(cfg.disableTripleClick).toBe(true); + expect(cfg.ledHeartbeatDisabled).toBe(true); + expect(cfg).not.toHaveProperty("tripleClickAdHocPing"); + expect(cfg).not.toHaveProperty("ledHeartbeat"); + }); +}); diff --git a/apps/web/src/validation/config/device.ts b/apps/web/src/validation/config/device.ts index 4428916f3..2ecade3cb 100644 --- a/apps/web/src/validation/config/device.ts +++ b/apps/web/src/validation/config/device.ts @@ -15,9 +15,35 @@ export const DeviceValidationSchema = z.object({ nodeInfoBroadcastSecs: z.coerce.number().int().min(0), doubleTapAsButtonPress: z.boolean(), isManaged: z.boolean(), - disableTripleClick: z.boolean(), - ledHeartbeatDisabled: z.boolean(), + // Shown the way Android shows them, as the inverse of the proto fields + // disableTripleClick and ledHeartbeatDisabled. + tripleClickAdHocPing: z.boolean(), + ledHeartbeat: z.boolean(), tzdef: z.string().max(65), }); export type DeviceValidation = z.infer; + +/** The form's values for a DeviceConfig, with the inverted toggles. */ +export function toDeviceFormValues( + cfg: Protobuf.Config.Config_DeviceConfig | undefined, +): DeviceValidation { + return { + ...cfg, + tripleClickAdHocPing: !cfg?.disableTripleClick, + ledHeartbeat: !cfg?.ledHeartbeatDisabled, + } as DeviceValidation; +} + +/** The DeviceConfig fields to stage from the form. */ +export function toDeviceConfig({ + tripleClickAdHocPing, + ledHeartbeat, + ...rest +}: DeviceValidation) { + return { + ...rest, + disableTripleClick: !tripleClickAdHocPing, + ledHeartbeatDisabled: !ledHeartbeat, + }; +} diff --git a/apps/web/src/validation/config/display.ts b/apps/web/src/validation/config/display.ts index f28ece343..2dfb5a821 100644 --- a/apps/web/src/validation/config/display.ts +++ b/apps/web/src/validation/config/display.ts @@ -28,7 +28,6 @@ export const DisplayValidationSchema = z.object({ wakeOnTapOrMotion: z.boolean(), compassOrientation: CompassOrientationEnum, use12hClock: z.boolean(), - enableMessageBubbles: z.boolean(), }); export type DisplayValidation = z.infer; diff --git a/apps/web/src/validation/config/lora.test.ts b/apps/web/src/validation/config/lora.test.ts index 153e42b20..68da04a1e 100644 --- a/apps/web/src/validation/config/lora.test.ts +++ b/apps/web/src/validation/config/lora.test.ts @@ -1,5 +1,12 @@ import { Protobuf } from "@meshtastic/sdk"; import { describe, expect, it } from "vitest"; +import { + codingRateOverride, + codingRateOverrides, + hasPaFan, + LoRaValidationSchema, + normalizeCodingRate, +} from "./lora.ts"; describe("LoRa modem presets", () => { it("includes LONG_TURBO modem preset", () => { @@ -11,3 +18,102 @@ describe("LoRa modem presets", () => { ).toBe(true); }); }); + +const Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset; +const Hw = Protobuf.Mesh.HardwareModel; + +describe("coding rate override", () => { + it("offers only the rates above the preset's own", () => { + expect(codingRateOverrides(Preset.LONG_FAST)).toEqual([6, 7, 8]); + expect(codingRateOverrides(Preset.NARROW_FAST)).toEqual([7, 8]); + expect(codingRateOverrides(Preset.LONG_SLOW)).toEqual([]); + }); + + it("reads a stored rate as firmware applies it", () => { + expect(codingRateOverride(Preset.LONG_FAST, 7)).toBe(7); + // At or below the preset's own rate firmware ignores it. + expect(codingRateOverride(Preset.LONG_FAST, 5)).toBe(0); + expect(codingRateOverride(Preset.LONG_SLOW, 8)).toBe(0); + expect(codingRateOverride(Preset.MEDIUM_FAST, 0)).toBe(0); + }); + + it("normalizes the coding rate only while presets are on", () => { + expect( + normalizeCodingRate({ + usePreset: true, + modemPreset: Preset.MEDIUM_FAST, + codingRate: 7, + }).codingRate, + ).toBe(7); + expect( + normalizeCodingRate({ + usePreset: true, + modemPreset: Preset.LONG_FAST, + codingRate: 5, + }).codingRate, + ).toBe(0); + const manual = { + usePreset: false, + modemPreset: Preset.LONG_FAST, + codingRate: 5, + }; + expect(normalizeCodingRate(manual)).toBe(manual); + }); +}); + +describe("hasPaFan", () => { + it("shows the PA fan switch for boards with a fan or an unknown model", () => { + expect(hasPaFan(undefined)).toBe(true); + expect(hasPaFan(Hw.UNSET)).toBe(true); + expect(hasPaFan(Hw.BETAFPV_2400_TX)).toBe(true); + expect(hasPaFan(Hw.RADIOMASTER_900_BANDIT_NANO)).toBe(true); + expect(hasPaFan(Hw.RADIOMASTER_900_BANDIT)).toBe(true); + }); + + it("hides it for other boards", () => { + expect(hasPaFan(Hw.HELTEC_V3)).toBe(false); + expect(hasPaFan(Hw.TBEAM)).toBe(false); + }); +}); + +describe("LoRaValidationSchema", () => { + const base = { + usePreset: true, + modemPreset: Preset.LONG_FAST, + bandwidth: 0, + spreadFactor: 0, + codingRate: 0, + region: Protobuf.Config.Config_LoRaConfig_RegionCode.US, + hopLimit: 3, + txEnabled: true, + txPower: 0, + channelNum: 0, + overrideDutyCycle: false, + sx126xRxBoostedGain: false, + overrideFrequency: 0, + ignoreIncoming: [], + ignoreMqtt: false, + configOkToMqtt: false, + }; + + it("drops the fields the page no longer shows, keeping the device's values", () => { + const result = LoRaValidationSchema.safeParse({ + ...base, + frequencyOffset: 12, + femLnaMode: 1, + serialHalOnly: true, + }); + expect(result.success).toBe(true); + expect(result.data).not.toHaveProperty("frequencyOffset"); + expect(result.data).not.toHaveProperty("femLnaMode"); + expect(result.data).not.toHaveProperty("serialHalOnly"); + }); + + it("keeps paFanDisabled, defaulting to false", () => { + expect(LoRaValidationSchema.parse(base).paFanDisabled).toBe(false); + expect( + LoRaValidationSchema.parse({ ...base, paFanDisabled: true }) + .paFanDisabled, + ).toBe(true); + }); +}); diff --git a/apps/web/src/validation/config/lora.ts b/apps/web/src/validation/config/lora.ts index b64cbf4eb..43407a3aa 100644 --- a/apps/web/src/validation/config/lora.ts +++ b/apps/web/src/validation/config/lora.ts @@ -8,7 +8,6 @@ import { z } from "zod/v4"; const ModemPresetEnum = z.enum(Protobuf.Config.Config_LoRaConfig_ModemPreset); const RegionCodeEnum = z.enum(Protobuf.Config.Config_LoRaConfig_RegionCode); -const FemLnaModeEnum = z.enum(Protobuf.Config.Config_LoRaConfig_FEM_LNA_Mode); export const LoRaValidationSchema = z.object({ usePreset: z.boolean(), @@ -16,7 +15,6 @@ export const LoRaValidationSchema = z.object({ bandwidth: z.coerce.number().int(), spreadFactor: z.coerce.number().int().max(12), codingRate: z.coerce.number().int().min(0).max(10), - frequencyOffset: z.coerce.number().int(), region: RegionCodeEnum, hopLimit: z.coerce.number().int().min(0).max(7), txEnabled: z.boolean(), @@ -33,12 +31,116 @@ export const LoRaValidationSchema = z.object({ ignoreIncoming: z.coerce.number().array(), ignoreMqtt: z.boolean(), configOkToMqtt: z.boolean(), - femLnaMode: FemLnaModeEnum, - serialHalOnly: z.boolean(), + paFanDisabled: z.boolean().default(false), }); export type LoRaValidation = z.infer; +const Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset; +const HardwareModel = Protobuf.Mesh.HardwareModel; + +/** The highest LoRa coding rate, 4/8. */ +export const MAX_CODING_RATE = 8; + +/** + * The coding-rate denominator each modem preset uses (5 to 8 for 4/5 to + * 4/8), from the firmware's modemPresetToParams as Android lists it. + */ +const PRESET_CODING_RATES: Partial< + Record +> = { + [Preset.VERY_LONG_SLOW]: 8, + [Preset.LONG_TURBO]: 8, + [Preset.LONG_FAST]: 5, + [Preset.LONG_MODERATE]: 8, + [Preset.LONG_SLOW]: 8, + [Preset.MEDIUM_FAST]: 5, + [Preset.MEDIUM_SLOW]: 5, + [Preset.MEDIUM_TURBO]: 5, + [Preset.SHORT_FAST]: 5, + [Preset.SHORT_SLOW]: 5, + [Preset.SHORT_TURBO]: 5, + [Preset.LITE_FAST]: 5, + [Preset.LITE_SLOW]: 5, + [Preset.NARROW_FAST]: 6, + [Preset.NARROW_SLOW]: 6, + [Preset.TINY_FAST]: 5, + [Preset.TINY_SLOW]: 6, +}; + +/** The coding rate a preset uses on its own, or undefined if unknown. */ +export function presetCodingRate( + preset: Protobuf.Config.Config_LoRaConfig_ModemPreset, +): number | undefined { + return PRESET_CODING_RATES[preset]; +} + +/** + * The coding rates `codingRate` can raise a preset to while usePreset is on + * (firmware 2.7.18+). Firmware only applies a rate above the preset's own, so + * only those are offered, and a preset already at 4/8 has none. + */ +export function codingRateOverrides( + preset: Protobuf.Config.Config_LoRaConfig_ModemPreset, +): number[] { + const own = presetCodingRate(preset); + if (own === undefined) return []; + const rates: number[] = []; + for (let rate = own + 1; rate <= MAX_CODING_RATE; rate++) rates.push(rate); + return rates; +} + +/** + * The stored codingRate as firmware applies it over a preset: the override + * while it raises the preset's rate, otherwise 0 ("preset default"). + */ +export function codingRateOverride( + preset: Protobuf.Config.Config_LoRaConfig_ModemPreset, + stored: number, +): number { + return codingRateOverrides(preset).includes(stored) ? stored : 0; +} + +/** + * With usePreset on, keeps codingRate only while it raises the preset's own + * rate and stores 0 otherwise, so a value firmware ignores never looks like + * it applies. Manual settings and unknown presets are returned unchanged. + */ +export function normalizeCodingRate< + T extends { + usePreset?: boolean; + modemPreset?: Protobuf.Config.Config_LoRaConfig_ModemPreset; + codingRate?: number; + }, +>(cfg: T): T { + if ( + !cfg.usePreset || + cfg.modemPreset === undefined || + presetCodingRate(cfg.modemPreset) === undefined + ) { + return cfg; + } + const codingRate = codingRateOverride(cfg.modemPreset, cfg.codingRate ?? 0); + return codingRate === cfg.codingRate ? cfg : { ...cfg, codingRate }; +} + +/** + * Boards with a PA fan that paFanDisabled controls. Like Android, the switch + * is also shown while the hardware model is unknown. + */ +const PA_FAN_MODELS = new Set([ + HardwareModel.UNSET, + HardwareModel.BETAFPV_2400_TX, + HardwareModel.RADIOMASTER_900_BANDIT_NANO, + HardwareModel.RADIOMASTER_900_BANDIT, +]); + +export function hasPaFan( + hwModel: Protobuf.Mesh.HardwareModel | undefined, +): boolean { + return hwModel === undefined || PA_FAN_MODELS.has(hwModel); +} + /** * LoRaValidationSchema plus the device's own limits: the presets legal in the * selected region (firmware 2.8 region_presets) and the presets and regions diff --git a/apps/web/src/validation/config/network.test.ts b/apps/web/src/validation/config/network.test.ts new file mode 100644 index 000000000..e2cc750c1 --- /dev/null +++ b/apps/web/src/validation/config/network.test.ts @@ -0,0 +1,23 @@ +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { hasUdpBroadcast, withUdpBroadcast } from "./network.ts"; + +const UDP = Protobuf.Config.Config_NetworkConfig_ProtocolFlags.UDP_BROADCAST; + +describe("UDP broadcasting switch", () => { + it("reads the UDP_BROADCAST bit", () => { + expect(hasUdpBroadcast(undefined)).toBe(false); + expect(hasUdpBroadcast(0)).toBe(false); + expect(hasUdpBroadcast(UDP)).toBe(true); + expect(hasUdpBroadcast(UDP | 0b100)).toBe(true); + }); + + it("sets and clears only that bit", () => { + expect(withUdpBroadcast(0, true)).toBe(UDP); + expect(withUdpBroadcast(undefined, true)).toBe(UDP); + expect(withUdpBroadcast(UDP, false)).toBe(0); + // Other protocol bits are kept. + expect(withUdpBroadcast(0b100, true)).toBe(0b100 | UDP); + expect(withUdpBroadcast(0b100 | UDP, false)).toBe(0b100); + }); +}); diff --git a/apps/web/src/validation/config/network.ts b/apps/web/src/validation/config/network.ts index 0f280f544..61a9a0859 100644 --- a/apps/web/src/validation/config/network.ts +++ b/apps/web/src/validation/config/network.ts @@ -4,9 +4,8 @@ import { z } from "zod/v4"; const AddressModeEnum = z.enum( Protobuf.Config.Config_NetworkConfig_AddressMode, ); -const ProtocolFlagsEnum = z.enum( - Protobuf.Config.Config_NetworkConfig_ProtocolFlags, -); +const UDP_BROADCAST = + Protobuf.Config.Config_NetworkConfig_ProtocolFlags.UDP_BROADCAST; export const NetworkValidationIpV4ConfigSchema = z.object({ ip: z.ipv4(), @@ -23,8 +22,26 @@ export const NetworkValidationSchema = z.object({ ethEnabled: z.boolean(), addressMode: AddressModeEnum, ipv4Config: NetworkValidationIpV4ConfigSchema, - enabledProtocols: ProtocolFlagsEnum, + /** Form only: the UDP_BROADCAST bit of enabledProtocols, as a switch. */ + udpBroadcast: z.boolean(), rsyslogServer: z.string().max(33), }); export type NetworkValidation = z.infer; + +/** Whether the enabledProtocols bitmask has UDP broadcasting on. */ +export function hasUdpBroadcast(enabledProtocols: number | undefined): boolean { + return ((enabledProtocols ?? 0) & UDP_BROADCAST) !== 0; +} + +/** + * enabledProtocols with the UDP_BROADCAST bit set or cleared, keeping any + * other protocol bits the device has. + */ +export function withUdpBroadcast( + enabledProtocols: number | undefined, + enabled: boolean, +): number { + const flags = enabledProtocols ?? 0; + return enabled ? flags | UDP_BROADCAST : flags & ~UDP_BROADCAST; +} diff --git a/apps/web/src/validation/config/power.test.ts b/apps/web/src/validation/config/power.test.ts new file mode 100644 index 000000000..1edd3ea01 --- /dev/null +++ b/apps/web/src/validation/config/power.test.ts @@ -0,0 +1,27 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { + type PowerValidation, + toPowerConfig, + toPowerFormValues, +} from "./power.ts"; + +const power = (adcMultiplierOverride: number) => + create(Protobuf.Config.Config_PowerConfigSchema, { adcMultiplierOverride }); + +describe("ADC multiplier override switch", () => { + it("is on while the ratio is above 0", () => { + expect(toPowerFormValues(power(0)).adcOverrideEnabled).toBe(false); + expect(toPowerFormValues(power(2.5)).adcOverrideEnabled).toBe(true); + expect(toPowerFormValues(undefined).adcOverrideEnabled).toBe(false); + }); + + it("writes the ratio while on and 0 when off, without the switch", () => { + const values = toPowerFormValues(power(2.5)); + expect(toPowerConfig(values)).not.toHaveProperty("adcOverrideEnabled"); + expect(toPowerConfig(values).adcMultiplierOverride).toBe(2.5); + const off: PowerValidation = { ...values, adcOverrideEnabled: false }; + expect(toPowerConfig(off).adcMultiplierOverride).toBe(0); + }); +}); diff --git a/apps/web/src/validation/config/power.ts b/apps/web/src/validation/config/power.ts index 4c99fd76d..f180a8235 100644 --- a/apps/web/src/validation/config/power.ts +++ b/apps/web/src/validation/config/power.ts @@ -1,8 +1,11 @@ +import type { Protobuf } from "@meshtastic/sdk"; import { z } from "zod/v4"; export const PowerValidationSchema = z.object({ isPowerSaving: z.boolean(), onBatteryShutdownAfterSecs: z.coerce.number().int().min(0), + /** Form only: the "ADC multiplier override" switch, on while the ratio is above 0. */ + adcOverrideEnabled: z.boolean(), adcMultiplierOverride: z.coerce.number().min(0).max(4), waitBluetoothSecs: z.coerce.number().int().min(0), sdsSecs: z.coerce.number().int().min(0), @@ -12,3 +15,27 @@ export const PowerValidationSchema = z.object({ }); export type PowerValidation = z.infer; + +/** The form's values for a PowerConfig: the override switch is derived from the ratio. */ +export function toPowerFormValues( + cfg: Protobuf.Config.Config_PowerConfig | undefined, +): PowerValidation { + return { + ...cfg, + adcOverrideEnabled: (cfg?.adcMultiplierOverride ?? 0) > 0, + } as PowerValidation; +} + +/** + * The PowerConfig fields to stage from the form. The switch is not a proto + * field; turning it off writes a ratio of 0, which disables the override. + */ +export function toPowerConfig({ + adcOverrideEnabled, + ...rest +}: PowerValidation): Omit { + return { + ...rest, + adcMultiplierOverride: adcOverrideEnabled ? rest.adcMultiplierOverride : 0, + }; +} diff --git a/apps/web/src/validation/config/security.test.ts b/apps/web/src/validation/config/security.test.ts index 4e84be16d..5ec3e1290 100644 --- a/apps/web/src/validation/config/security.test.ts +++ b/apps/web/src/validation/config/security.test.ts @@ -16,7 +16,6 @@ describe("RawSecuritySchema", () => { it("accepts valid security config", () => { const result = RawSecuritySchema.safeParse({ isManaged: false, - adminChannelEnabled: true, debugLogApiEnabled: false, serialEnabled: true, packetSignaturePolicy: COMPATIBLE, @@ -30,7 +29,6 @@ describe("RawSecuritySchema", () => { it("rejects if privateKey is invalid", () => { const result = RawSecuritySchema.safeParse({ isManaged: false, - adminChannelEnabled: true, debugLogApiEnabled: false, serialEnabled: true, packetSignaturePolicy: COMPATIBLE, @@ -49,7 +47,6 @@ describe("RawSecuritySchema", () => { it("requires at least one adminKey if isManaged", () => { const result = RawSecuritySchema.safeParse({ isManaged: true, - adminChannelEnabled: true, debugLogApiEnabled: false, serialEnabled: true, packetSignaturePolicy: COMPATIBLE, @@ -70,6 +67,22 @@ describe("RawSecuritySchema", () => { it("accepts if at least one adminKey is valid when isManaged", () => { const result = RawSecuritySchema.safeParse({ isManaged: true, + debugLogApiEnabled: false, + serialEnabled: true, + packetSignaturePolicy: COMPATIBLE, + privateKey: validKey, + publicKey: validKey, + adminKey: [validKey, "", ""], + }); + expect(result.success).toBe(true); + }); +}); + +describe("adminChannelEnabled", () => { + it("is not part of the form, so a save keeps the device's value", () => { + const validKey = makeBase64OfLength(32); + const result = RawSecuritySchema.safeParse({ + isManaged: false, adminChannelEnabled: true, debugLogApiEnabled: false, serialEnabled: true, @@ -79,13 +92,13 @@ describe("RawSecuritySchema", () => { adminKey: [validKey, "", ""], }); expect(result.success).toBe(true); + expect(result.data).not.toHaveProperty("adminChannelEnabled"); }); }); describe("packetSignaturePolicy", () => { const base = { isManaged: false, - adminChannelEnabled: false, debugLogApiEnabled: false, serialEnabled: true, privateKey: makeBase64OfLength(32), @@ -119,7 +132,6 @@ describe("ParsedSecuritySchema", () => { it("accepts valid parsed security config", () => { const result = ParsedSecuritySchema.safeParse({ isManaged: false, - adminChannelEnabled: true, debugLogApiEnabled: false, serialEnabled: true, packetSignaturePolicy: COMPATIBLE, @@ -133,7 +145,6 @@ describe("ParsedSecuritySchema", () => { it("requires at least one adminKey if isManaged", () => { const result = ParsedSecuritySchema.safeParse({ isManaged: true, - adminChannelEnabled: true, debugLogApiEnabled: false, serialEnabled: true, packetSignaturePolicy: COMPATIBLE, diff --git a/apps/web/src/validation/config/security.ts b/apps/web/src/validation/config/security.ts index 6310e0f49..0a0a02aad 100644 --- a/apps/web/src/validation/config/security.ts +++ b/apps/web/src/validation/config/security.ts @@ -16,7 +16,6 @@ function makeSecuritySchema( return z .object({ isManaged: z.boolean(), - adminChannelEnabled: z.boolean(), debugLogApiEnabled: z.boolean(), serialEnabled: z.boolean(), packetSignaturePolicy: PacketSignaturePolicyEnum, diff --git a/apps/web/src/validation/config/user.test.ts b/apps/web/src/validation/config/user.test.ts index fcede8c41..359302c5a 100644 --- a/apps/web/src/validation/config/user.test.ts +++ b/apps/web/src/validation/config/user.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { LONG_NAME_MAX_BYTES, UserValidationSchema } from "./user.ts"; +import { + LONG_NAME_MAX_BYTES, + NODE_STATUS_MAX_BYTES, + UserValidationSchema, +} from "./user.ts"; const parse = (longName: string) => UserValidationSchema.safeParse({ @@ -44,3 +48,31 @@ describe("UserValidationSchema longName", () => { expect(parse("").success).toBe(false); }); }); + +describe("UserValidationSchema nodeStatus", () => { + const parseStatus = (nodeStatus?: string) => + UserValidationSchema.safeParse({ + longName: "Node", + shortName: "ABCD", + isUnmessageable: false, + isLicensed: false, + nodeStatus, + }); + + it("limits the status message to 80 bytes", () => { + expect(NODE_STATUS_MAX_BYTES).toBe(80); + expect(parseStatus("a".repeat(80)).success).toBe(true); + expect(parseStatus("a".repeat(81)).success).toBe(false); + // 21 emoji are 84 bytes. + const result = parseStatus("📡".repeat(21)); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.path[0] === "nodeStatus"); + expect(issue?.message).toBe("formValidation.tooBig.bytes"); + }); + + it("defaults to an empty status when the radio has none", () => { + const result = parseStatus(undefined); + expect(result.success).toBe(true); + expect(result.data?.nodeStatus).toBe(""); + }); +}); diff --git a/apps/web/src/validation/config/user.ts b/apps/web/src/validation/config/user.ts index 49169b579..8b7335ab0 100644 --- a/apps/web/src/validation/config/user.ts +++ b/apps/web/src/validation/config/user.ts @@ -9,6 +9,12 @@ import { z } from "zod/v4"; */ export const LONG_NAME_MAX_BYTES = 24; +/** + * ModuleConfig.statusmessage.node_status, shown on the User page from + * firmware 2.7.20. Android limits it to 80 bytes of UTF-8. + */ +export const NODE_STATUS_MAX_BYTES = 80; + export const UserValidationSchema = z.object({ // Read-only display fields (preserved on submit). nodeId: z.string().optional(), @@ -33,6 +39,20 @@ export const UserValidationSchema = z.object({ .string() .min(1, t("user.shortName.validation.min")) .max(4, t("user.shortName.validation.max")), + // Status Message module's node_status; not part of the owner. + nodeStatus: z + .string() + .superRefine((value, ctx) => { + const { isValid } = validateMaxByteLength(value, NODE_STATUS_MAX_BYTES); + if (!isValid) { + ctx.addIssue({ + code: "custom", + message: "formValidation.tooBig.bytes", + params: { maximum: NODE_STATUS_MAX_BYTES }, + }); + } + }) + .default(""), isUnmessageable: z.boolean().default(false), isLicensed: z.boolean().default(false), }); From 632802afccfef44abb45a60c7ede29d26016a211 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:39:07 -0400 Subject: [PATCH 05/15] feat(web): align module config pages with Android's groups and fields - Canned Message: Messages (read from and written to the device via the canned message admin get/set). - External Notification: Ringtone (RTTTL, admin get/set); Active and Use PWM only shown when their output pin is set. - Remote Hardware: Available pins list editor. - Mesh Beacon: Android's three groups (Mesh Beacon, Offered channel, Broadcast targets) with target add/remove, and Android's save rules. - Ambient Lighting: colour picker, numeric RGB behind Advanced. - TAK: team colour swatches; tab only for TAK roles on firmware 2.8+. - Range Test: locked on a public primary channel, as on Android. - Labels follow Android; Telemetry drops the web-only Air Quality Screen field (its value is kept on save). SDK: canned message / ringtone use-cases, ConfigClient signals and sdk-react hooks; ConfigEditor.stageAdminMessage() stages one admin message per key that is sent on commit and cleared by reset. --- .../public/i18n/locales/en/moduleConfig.json | 243 ++++++------ .../ModuleConfig/AmbientLighting.tsx | 56 ++- .../ModuleConfig/AmbientLightingColor.test.ts | 19 + .../ModuleConfig/AmbientLightingColor.tsx | 92 +++++ .../ModuleConfig/CannedMessage.tsx | 104 +++++- .../ModuleConfig/ExternalNotification.tsx | 87 ++++- .../PageComponents/ModuleConfig/MQTT.tsx | 24 +- .../ModuleConfig/MeshBeacon.tsx | 221 ++++++----- .../ModuleConfig/MeshBeaconTargets.tsx | 231 ++++++++++++ .../PageComponents/ModuleConfig/RangeTest.tsx | 22 +- .../ModuleConfig/RemoteHardware.tsx | 13 +- .../ModuleConfig/RemoteHardwarePins.tsx | 153 ++++++++ .../PageComponents/ModuleConfig/Tak.tsx | 2 + .../ModuleConfig/TakTeamSwatches.tsx | 81 ++++ .../PageComponents/ModuleConfig/Telemetry.tsx | 6 - .../ModuleConfig/useStagedAdminString.ts | 64 ++++ apps/web/src/pages/Settings/ModuleConfig.tsx | 35 +- .../validation/moduleConfig/cannedMessage.ts | 13 +- .../moduleConfig/externalNotification.ts | 9 + .../moduleConfig/meshBeacon.test.ts | 347 +++++++++++++----- .../src/validation/moduleConfig/meshBeacon.ts | 253 ++++++++++--- .../moduleConfig/moduleMessages.test.ts | 67 ++++ .../moduleConfig/remoteHardware.test.ts | 73 ++++ .../validation/moduleConfig/remoteHardware.ts | 58 ++- packages/sdk-react/mod.ts | 5 + .../sdk-react/src/hooks/useModuleMessages.ts | 134 +++++++ .../tests/useModuleMessages.test.tsx | 88 +++++ packages/sdk/mod.ts | 7 +- .../sdk/src/core/packet-codec/decodePacket.ts | 8 + .../sdk/src/features/config/ConfigClient.ts | 83 ++++- .../features/config/ModuleMessages.test.ts | 227 ++++++++++++ .../application/ModuleMessagesUseCases.ts | 91 +++++ .../features/config/domain/ConfigEditor.ts | 72 +++- packages/sdk/src/features/config/index.ts | 4 + .../src/features/config/state/configStore.ts | 2 + 35 files changed, 2583 insertions(+), 411 deletions(-) create mode 100644 apps/web/src/components/PageComponents/ModuleConfig/AmbientLightingColor.test.ts create mode 100644 apps/web/src/components/PageComponents/ModuleConfig/AmbientLightingColor.tsx create mode 100644 apps/web/src/components/PageComponents/ModuleConfig/MeshBeaconTargets.tsx create mode 100644 apps/web/src/components/PageComponents/ModuleConfig/RemoteHardwarePins.tsx create mode 100644 apps/web/src/components/PageComponents/ModuleConfig/TakTeamSwatches.tsx create mode 100644 apps/web/src/components/PageComponents/ModuleConfig/useStagedAdminString.ts create mode 100644 apps/web/src/validation/moduleConfig/moduleMessages.test.ts create mode 100644 apps/web/src/validation/moduleConfig/remoteHardware.test.ts create mode 100644 packages/sdk-react/src/hooks/useModuleMessages.ts create mode 100644 packages/sdk-react/tests/useModuleMessages.test.tsx create mode 100644 packages/sdk/src/features/config/ModuleMessages.test.ts create mode 100644 packages/sdk/src/features/config/application/ModuleMessagesUseCases.ts diff --git a/apps/web/public/i18n/locales/en/moduleConfig.json b/apps/web/public/i18n/locales/en/moduleConfig.json index 4d511c00a..70ccb1fe7 100644 --- a/apps/web/public/i18n/locales/en/moduleConfig.json +++ b/apps/web/public/i18n/locales/en/moduleConfig.json @@ -2,15 +2,15 @@ "page": { "tabAmbientLighting": "Ambient Lighting", "tabAudio": "Audio", - "tabCannedMessage": "Canned", + "tabCannedMessage": "Canned Message", "tabDetectionSensor": "Detection Sensor", - "tabExternalNotification": "Ext Notif", + "tabExternalNotification": "External Notification", "tabMqtt": "MQTT", "tabNeighborInfo": "Neighbor Info", "tabPaxcounter": "Paxcounter", "tabRangeTest": "Range Test", "tabSerial": "Serial", - "tabStoreAndForward": "S&F", + "tabStoreAndForward": "Store & Forward", "tabTelemetry": "Telemetry", "tabRemoteHardware": "Remote Hardware", "tabStatusMessage": "Status Message", @@ -43,13 +43,21 @@ "ambientLightingConfig": { "label": "Ambient Lighting Config", "description": "Ambient Lighting configuration" + }, + "color": { + "label": "Color", + "description": "LED color" + }, + "advanced": { + "label": "Advanced", + "description": "Red, green and blue levels, 0-255" } }, "audio": { "title": "Audio Settings", "description": "Settings for the Audio module", "codec2Enabled": { - "label": "Codec 2 Enabled", + "label": "Codec2 Enabled", "description": "Enable Codec 2 audio encoding" }, "pttPin": { @@ -61,19 +69,19 @@ "description": "Bitrate to use for audio encoding" }, "i2sWs": { - "label": "i2S WS", + "label": "I2S WS", "description": "GPIO pin to use for i2S WS" }, "i2sSd": { - "label": "i2S SD", + "label": "I2S SD", "description": "GPIO pin to use for i2S SD" }, "i2sDin": { - "label": "i2S DIN", + "label": "I2S DIN", "description": "GPIO pin to use for i2S DIN" }, "i2sSck": { - "label": "i2S SCK", + "label": "I2S SCK", "description": "GPIO pin to use for i2S SCK" }, "audioConfig": { @@ -89,39 +97,39 @@ "description": "Enable Canned Message" }, "rotary1Enabled": { - "label": "Rotary Encoder #1 Enabled", + "label": "Rotary 1", "description": "Enable the rotary encoder" }, "inputbrokerPinA": { - "label": "Encoder Pin A", + "label": "Pin A", "description": "GPIO Pin Value (1-39) For encoder port A" }, "inputbrokerPinB": { - "label": "Encoder Pin B", + "label": "Pin B", "description": "GPIO Pin Value (1-39) For encoder port B" }, "inputbrokerPinPress": { - "label": "Encoder Pin Press", + "label": "Press Pin", "description": "GPIO Pin Value (1-39) For encoder Press" }, "inputbrokerEventCw": { - "label": "Clockwise event", + "label": "Clockwise Rotary Event", "description": "Select input event." }, "inputbrokerEventCcw": { - "label": "Counter Clockwise event", + "label": "Counter Clockwise Rotary Event", "description": "Select input event." }, "inputbrokerEventPress": { - "label": "Press event", + "label": "Encoder Press Event", "description": "Select input event" }, "updown1Enabled": { - "label": "Up Down enabled", + "label": "Up Down 1", "description": "Enable the up / down encoder" }, "allowInputSource": { - "label": "Allow Input Source", + "label": "Allow input source", "description": "Select from: '_any', 'rotEnc1', 'upDownEnc1', 'cardkb'" }, "sendBell": { @@ -135,21 +143,27 @@ "enabled": { "label": "Canned message enabled", "description": "Enable Canned Message" + }, + "messages": { + "label": "Messages", + "description": "The messages to choose from on the device, separated by |. Up to 200 bytes", + "loading": "Reading the messages from the device…", + "unavailable": "Couldn't read the messages from the device" } }, "detectionSensor": { "title": "Detection Sensor Settings", "description": "Settings for the Detection Sensor module", "enabled": { - "label": "Enabled", + "label": "Detection Sensor Enabled", "description": "Enable or disable Detection Sensor Module" }, "minimumBroadcastSecs": { - "label": "Minimum Broadcast Seconds", + "label": "Minimum time between detection broadcasts", "description": "The interval in seconds of how often we can send a message to the mesh when a state change is detected" }, "stateBroadcastSecs": { - "label": "State Broadcast Seconds", + "label": "State Broadcast Interval", "description": "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes" }, "sendBell": { @@ -157,19 +171,19 @@ "description": "Send ASCII bell with alert message" }, "name": { - "label": "Friendly Name", + "label": "Name", "description": "Used to format the message sent to mesh, max 20 Characters" }, "monitorPin": { - "label": "Monitor Pin", + "label": "GPIO Pin to monitor", "description": "The GPIO pin to monitor for state changes" }, "detectionTriggerType": { - "label": "Detection Triggered Type", + "label": "Trigger Type", "description": "The type of trigger event to be used" }, "usePullup": { - "label": "Use Pullup", + "label": "Uses pullup resistor", "description": "Whether or not use INPUT_PULLUP mode for GPIO pin" }, "detectionSensorConfig": { @@ -181,23 +195,23 @@ "title": "External Notification Settings", "description": "Configure the external notification module", "enabled": { - "label": "Module Enabled", + "label": "External Notification Enabled", "description": "Enable External Notification" }, "outputMs": { - "label": "Output MS", + "label": "GPIO Output Duration", "description": "Output MS" }, "output": { - "label": "Output", + "label": "Output pin GPIO", "description": "Output" }, "outputVibra": { - "label": "Output Vibrate", + "label": "Output pin vibra GPIO", "description": "Output Vibrate" }, "outputBuzzer": { - "label": "Output Buzzer", + "label": "Output pin buzzer GPIO", "description": "Output Buzzer" }, "active": { @@ -205,31 +219,31 @@ "description": "Active" }, "alertMessage": { - "label": "Alert Message", + "label": "Alert when receiving a message", "description": "Alert Message" }, "alertMessageVibra": { - "label": "Alert Message Vibrate", + "label": "Vibra Motor Alert", "description": "Alert Message Vibrate" }, "alertMessageBuzzer": { - "label": "Alert Message Buzzer", + "label": "Alert GPIO buzzer when receiving a message", "description": "Alert Message Buzzer" }, "alertBell": { - "label": "Alert Bell", + "label": "Alert when receiving a bell", "description": "Should an alert be triggered when receiving an incoming bell?" }, "alertBellVibra": { - "label": "Alert Bell Vibrate", + "label": "Alert GPIO vibra motor when receiving a bell", "description": "Alert Bell Vibrate" }, "alertBellBuzzer": { - "label": "Alert Bell Buzzer", + "label": "Alert GPIO buzzer when receiving a bell", "description": "Alert Bell Buzzer" }, "usePwm": { - "label": "Use PWM", + "label": "Use PWM Buzzer", "description": "Use PWM" }, "nagTimeout": { @@ -237,7 +251,7 @@ "description": "Nag Timeout" }, "useI2sAsBuzzer": { - "label": "Use I²S Pin as Buzzer", + "label": "Use I²S As Buzzer", "description": "Designate I²S Pin as Buzzer Output" }, "externalNotificationConfig": { @@ -255,25 +269,31 @@ "advanced": { "label": "Advanced", "description": "Pin assignments and timing" + }, + "ringtone": { + "label": "Ringtone", + "description": "Tune played on the buzzer, in RTTTL format. Up to 230 bytes", + "loading": "Reading the ringtone from the device…", + "unavailable": "Couldn't read the ringtone from the device" } }, "mqtt": { "title": "MQTT Settings", "description": "Settings for the MQTT module", "enabled": { - "label": "Enabled", + "label": "MQTT Enabled", "description": "Enable or disable MQTT" }, "address": { - "label": "MQTT Server Address", + "label": "Address", "description": "MQTT server address to use for default/custom servers" }, "username": { - "label": "MQTT Username", + "label": "Username", "description": "MQTT username to use for default/custom servers" }, "password": { - "label": "MQTT Password", + "label": "Password", "description": "MQTT password to use for default/custom servers" }, "encryptionEnabled": { @@ -281,7 +301,7 @@ "description": "Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data." }, "jsonEnabled": { - "label": "JSON Enabled", + "label": "JSON output enabled", "description": "Whether to send/consume JSON packets on MQTT" }, "tlsEnabled": { @@ -289,15 +309,15 @@ "description": "Enable or disable TLS" }, "root": { - "label": "Root topic", + "label": "Root Topic", "description": "MQTT root topic to use for default/custom servers" }, "proxyToClientEnabled": { - "label": "MQTT Client Proxy Enabled", + "label": "MQTT Client Proxy", "description": "Utilizes the network connection to proxy MQTT messages to the client." }, "mapReportingEnabled": { - "label": "Map Reporting Enabled", + "label": "Map Reporting", "description": "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, short and long name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name." }, "mapReportSettings": { @@ -343,7 +363,7 @@ "description": "MQTT broker connection" }, "mapReportingCard": { - "label": "Map reporting", + "label": "Map Reporting", "description": "Periodic unencrypted map report" } }, @@ -351,7 +371,7 @@ "title": "Neighbor Info Settings", "description": "Settings for the Neighbor Info module", "enabled": { - "label": "Enabled", + "label": "Neighbor Info Enabled", "description": "Enable or disable Neighbor Info Module" }, "updateInterval": { @@ -371,19 +391,19 @@ "title": "Paxcounter Settings", "description": "Settings for the Paxcounter module", "enabled": { - "label": "Module Enabled", + "label": "PAX Counter Enabled", "description": "Enable Paxcounter" }, "paxcounterUpdateInterval": { - "label": "Update Interval (seconds)", + "label": "Update Interval", "description": "How long to wait between sending paxcounter packets" }, "wifiThreshold": { - "label": "WiFi RSSI Threshold", + "label": "WiFi Threshold", "description": "At what WiFi RSSI level should the counter increase. Defaults to -80." }, "bleThreshold": { - "label": "BLE RSSI Threshold", + "label": "BLE Threshold", "description": "At what BLE RSSI level should the counter increase. Defaults to -80." }, "paxcounterConfig": { @@ -395,27 +415,28 @@ "title": "Range Test Settings", "description": "Settings for the Range Test module", "enabled": { - "label": "Module Enabled", + "label": "Range Test Enabled", "description": "Enable Range Test" }, "sender": { - "label": "Message Interval", + "label": "Sender Interval", "description": "How long to wait between sending test packets" }, "save": { - "label": "Save CSV to storage", + "label": "Save", "description": "ESP32 Only" }, "rangeTestConfig": { "label": "Range Test Config", "description": "Range Test configuration" - } + }, + "publicChannel": "Range test can't run while the primary channel uses the default key or no key, so it is turned off when you save." }, "serial": { "title": "Serial Settings", "description": "Settings for the Serial module", "enabled": { - "label": "Module Enabled", + "label": "Serial Enabled", "description": "Enable Serial output" }, "echo": { @@ -423,15 +444,15 @@ "description": "Any packets you send will be echoed back to your device" }, "rxd": { - "label": "Receive Pin", + "label": "Receive data (rxd) GPIO pin", "description": "Set the GPIO pin to the RXD pin you have set up." }, "txd": { - "label": "Transmit Pin", + "label": "Transmit data (txd) GPIO pin", "description": "Set the GPIO pin to the TXD pin you have set up." }, "baud": { - "label": "Baud Rate", + "label": "Baud", "description": "The serial baud rate" }, "timeout": { @@ -443,7 +464,7 @@ "description": "Select Mode" }, "overrideConsoleSerialPort": { - "label": "Override Console Serial Port", + "label": "Override console serial port", "description": "If you have a serial port connected to the console, this will override it." }, "serialConfig": { @@ -455,11 +476,11 @@ "title": "Store & Forward Settings", "description": "Settings for the Store & Forward module", "enabled": { - "label": "Module Enabled", + "label": "Store and Forward Enabled", "description": "Enable Store & Forward" }, "heartbeat": { - "label": "Heartbeat Enabled", + "label": "Send Heartbeat", "description": "Enable Store & Forward heartbeat" }, "records": { @@ -467,11 +488,11 @@ "description": "Number of records to store" }, "historyReturnMax": { - "label": "History return max", + "label": "History Return Max", "description": "Max number of records to return" }, "historyReturnWindow": { - "label": "History return window", + "label": "History Return Window", "description": "Return records from this time window (minutes)" }, "storeForwardConfig": { @@ -487,19 +508,19 @@ "title": "Telemetry Settings", "description": "Settings for the Telemetry module", "deviceUpdateInterval": { - "label": "Device Metrics", - "description": "Device metrics update interval (seconds)" + "label": "Device Metrics Interval", + "description": "How often to send device metrics over the mesh" }, "environmentUpdateInterval": { - "label": "Environment metrics update interval (seconds)", - "description": "" + "label": "Environment Metrics Interval", + "description": "How often to send environment metrics over the mesh" }, "environmentMeasurementEnabled": { - "label": "Module Enabled", + "label": "Environment Metrics Enabled", "description": "Enable the Environment Telemetry" }, "environmentScreenEnabled": { - "label": "Displayed on Screen", + "label": "Show on device screen", "description": "Show the Telemetry Module on the OLED" }, "environmentDisplayFahrenheit": { @@ -507,11 +528,11 @@ "description": "Display temp in Fahrenheit" }, "airQualityEnabled": { - "label": "Air Quality Enabled", + "label": "Air Quality Metrics Enabled", "description": "Enable the Air Quality Telemetry" }, "airQualityInterval": { - "label": "Air Quality Update Interval", + "label": "Air Quality Metrics Interval", "description": "How often to send Air Quality data over the mesh" }, "powerMeasurementEnabled": { @@ -519,23 +540,19 @@ "description": "Enable the Power Measurement Telemetry" }, "powerUpdateInterval": { - "label": "Power Update Interval", + "label": "Power Metrics Interval", "description": "How often to send Power data over the mesh" }, "powerScreenEnabled": { - "label": "Power Screen Enabled", + "label": "Power Screen", "description": "Enable the Power Telemetry Screen" }, - "airQualityScreenEnabled": { - "label": "Air Quality Screen Enabled", - "description": "Show the Air Quality telemetry on the device screen" - }, "telemetryConfig": { "label": "Telemetry Config", "description": "Telemetry configuration" }, "deviceTelemetryEnabled": { - "label": "Send Device Telemetry", + "label": "Broadcast Device Metrics", "description": "Enable/Disable the device telemetry module to send metrics to the mesh." } }, @@ -543,12 +560,25 @@ "title": "Remote Hardware Settings", "description": "Settings for the Remote Hardware module", "enabled": { - "label": "Module Enabled", + "label": "Remote Hardware enabled", "description": "Enable the Remote Hardware module" }, "allowUndefinedPinAccess": { - "label": "Allow Undefined Pin Access", + "label": "Allow undefined pin access", "description": "Allow reading/writing GPIO pins not defined in the available pins list" + }, + "availablePins": { + "label": "Available pins", + "description": "GPIO pins other nodes may read or write through this module, up to four", + "gpioPin": "GPIO pin", + "name": "Name", + "type": "Type", + "types": { + "1": "Digital read", + "2": "Digital write" + }, + "add": "Add", + "remove": "Delete" } }, "statusMessage": { @@ -569,49 +599,54 @@ "role": { "label": "Role", "description": "Member role reported to ATAK" + }, + "teamColor": { + "label": "Team color", + "description": "Pick a team by its color" } }, "meshBeacon": { - "title": "Mesh Beacon Settings", + "title": "Mesh Beacon", "description": "Hear and send invitations that let nearby radios join a channel", + "regionRequired": "Set your radio's region before setting up a beacon.", + "requiresPreset": "Beacon broadcasting advertises a modem preset for others to join. This radio uses custom LoRa settings instead of a standard preset, so a beacon could invite people onto settings they cannot hear. Broadcasting can be turned off here, but not on, until the radio uses a standard modem preset.", + "region": { + "label": "Region", + "description": "The beacon always advertises this radio's region and preset" + }, "listenEnabled": { - "label": "Listen for Beacons", + "label": "Listen for beacons", "description": "Receive beacons from other nodes. Their text goes to your messages, and their channel offers appear on the Channels page" }, "broadcastEnabled": { - "label": "Broadcast Beacons", - "description": "Periodically send a beacon from this node" - }, - "broadcast": { - "label": "Broadcast", - "description": "What this node's beacon says and offers" + "label": "Broadcast a beacon", + "description": "Periodically advertise this mesh to nearby nodes" }, "broadcastMessage": { "label": "Message", "description": "Text sent with each beacon, up to 100 bytes" }, "broadcastIntervalSecs": { - "label": "Interval", + "label": "Broadcast interval", "description": "How often to send a beacon. The minimum is one hour" }, - "legacySplit": { - "label": "Send Text Separately", - "description": "Send the message as a regular text message too, so radios on older firmware can read it" - }, "offerChannel": { - "label": "Offered Channel", + "groupLabel": "Offered channel", + "groupDescription": "The channel the beacon invites nearby radios to join", + "label": "Offered channel", "description": "Channel whose name and key the beacon offers to listeners", - "none": "None", "keep": "Current: {{name}}" }, - "broadcastOfferPreset": { - "label": "Offered Preset", - "description": "Modem preset the offered mesh uses", - "unset": "Not advertised" - }, - "broadcastOfferRegion": { - "label": "Offered Region", - "description": "Region the offered mesh uses. UNSET leaves it out" + "targets": { + "label": "Broadcast targets", + "description": "Where each beacon is sent. Default uses the radio's running preset and channel", + "target": "Target {{number}}", + "channel": "Channel", + "preset": "Transmit preset", + "default": "Default", + "channelNumber": "Channel {{index}}", + "add": "Add target", + "remove": "Remove target" } } } diff --git a/apps/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx b/apps/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx index dbfd52e75..e7b892828 100644 --- a/apps/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx +++ b/apps/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx @@ -10,7 +10,9 @@ import { import { useDevice } from "@core/stores"; import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { AmbientLightingColor } from "./AmbientLightingColor.tsx"; interface AmbientLightingModuleConfigProps { onFormInit: DynamicFormFormInit; @@ -42,6 +44,7 @@ export const AmbientLighting = ({ | undefined); const { t } = useTranslation("moduleConfig"); + const [showAdvanced, setShowAdvanced] = useState(false); const onSubmit = (data: AmbientLightingValidation) => { if (!editor) return; @@ -75,26 +78,43 @@ export const AmbientLighting = ({ label: t("ambientLighting.current.label"), description: t("ambientLighting.current.description"), }, - { - type: "number", - name: "red", - label: t("ambientLighting.red.label"), - description: t("ambientLighting.red.description"), - }, - { - type: "number", - name: "green", - label: t("ambientLighting.green.label"), - description: t("ambientLighting.green.description"), - }, - { - type: "number", - name: "blue", - label: t("ambientLighting.blue.label"), - description: t("ambientLighting.blue.description"), - }, ], + footer: ( + + ), }, + // Like Android, the separate channels sit behind "Advanced". + ...(showAdvanced + ? [ + { + label: t("ambientLighting.advanced.label"), + description: t("ambientLighting.advanced.description"), + fields: [ + { + type: "number" as const, + name: "red" as const, + label: t("ambientLighting.red.label"), + description: t("ambientLighting.red.description"), + }, + { + type: "number" as const, + name: "green" as const, + label: t("ambientLighting.green.label"), + description: t("ambientLighting.green.description"), + }, + { + type: "number" as const, + name: "blue" as const, + label: t("ambientLighting.blue.label"), + description: t("ambientLighting.blue.description"), + }, + ], + }, + ] + : []), ]} /> ); diff --git a/apps/web/src/components/PageComponents/ModuleConfig/AmbientLightingColor.test.ts b/apps/web/src/components/PageComponents/ModuleConfig/AmbientLightingColor.test.ts new file mode 100644 index 000000000..1a5903822 --- /dev/null +++ b/apps/web/src/components/PageComponents/ModuleConfig/AmbientLightingColor.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { hexToRgb, rgbToHex } from "./AmbientLightingColor.tsx"; + +describe("ambient lighting colour", () => { + it("formats red, green and blue as a colour input value", () => { + expect(rgbToHex(255, 128, 0)).toBe("#ff8000"); + expect(rgbToHex(0, 0, 0)).toBe("#000000"); + }); + + it("clamps and coerces values the fields may hold mid-edit", () => { + expect(rgbToHex("12", 300, -5)).toBe("#0cff00"); + expect(rgbToHex("", undefined, "x")).toBe("#000000"); + }); + + it("reads a colour input value back", () => { + expect(hexToRgb("#ff8000")).toEqual({ red: 255, green: 128, blue: 0 }); + expect(hexToRgb("nope")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/PageComponents/ModuleConfig/AmbientLightingColor.tsx b/apps/web/src/components/PageComponents/ModuleConfig/AmbientLightingColor.tsx new file mode 100644 index 000000000..3a00cd656 --- /dev/null +++ b/apps/web/src/components/PageComponents/ModuleConfig/AmbientLightingColor.tsx @@ -0,0 +1,92 @@ +import type { AmbientLightingValidation } from "@app/validation/moduleConfig/ambientLighting.ts"; +import { FieldWrapper } from "@components/Form/FormWrapper.tsx"; +import { Label } from "@components/UI/Label.tsx"; +import { Switch } from "@components/UI/Switch.tsx"; +import { useFormContext, useWatch } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +const clampByte = (value: unknown): number => { + const n = Math.round(Number(value)); + if (!Number.isFinite(n)) return 0; + return Math.min(255, Math.max(0, n)); +}; + +const toHex = (value: unknown) => + clampByte(value).toString(16).padStart(2, "0"); + +export const rgbToHex = (red: unknown, green: unknown, blue: unknown) => + `#${toHex(red)}${toHex(green)}${toHex(blue)}`; + +export const hexToRgb = (hex: string) => { + const match = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + if (!match) return undefined; + const [, r = "0", g = "0", b = "0"] = match; + return { + red: Number.parseInt(r, 16), + green: Number.parseInt(g, 16), + blue: Number.parseInt(b, 16), + }; +}; + +interface AmbientLightingColorProps { + showAdvanced: boolean; + onShowAdvancedChange: (show: boolean) => void; +} + +/** + * Android's RGB colour picker for the LED, plus its "Advanced" toggle that + * reveals the separate Red, Green and Blue fields. The picker writes all + * three; its change event bubbles up to the form, which stages them. + */ +export const AmbientLightingColor = ({ + showAdvanced, + onShowAdvancedChange, +}: AmbientLightingColorProps) => { + const { t } = useTranslation("moduleConfig"); + const { control, setValue } = useFormContext(); + const [red, green, blue] = useWatch({ + control, + name: ["red", "green", "blue"], + }); + const hex = rgbToHex(red, green, blue); + + return ( + <> + +
    + { + const rgb = hexToRgb(e.target.value); + if (!rgb) return; + const options = { shouldDirty: true, shouldValidate: true }; + setValue("red", rgb.red, options); + setValue("green", rgb.green, options); + setValue("blue", rgb.blue, options); + }} + className="h-10 w-16 cursor-pointer rounded-md border border-slate-300 bg-transparent p-1 dark:border-slate-500" + /> + + {hex.toUpperCase()} + +
    +
    +
    + + +
    + + ); +}; diff --git a/apps/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx b/apps/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx index 1072a2bd4..dfc30bc8b 100644 --- a/apps/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx +++ b/apps/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx @@ -7,10 +7,17 @@ import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; +import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import { useDevice } from "@core/stores"; -import { Protobuf } from "@meshtastic/sdk"; -import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { firmwareAtLeast, Protobuf } from "@meshtastic/sdk"; +import { + useCannedMessages, + useConfigEditor, + useSignal, +} from "@meshtastic/sdk-react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { useStagedAdminString } from "./useStagedAdminString.ts"; interface CannedMessageModuleConfigProps { onFormInit: DynamicFormFormInit; @@ -43,32 +50,82 @@ export const CannedMessage = ({ const { t } = useTranslation("moduleConfig"); + // Both fields are deprecated from firmware 2.7.0. Like Android, offer + // them there only while the radio still has them set. + const { metadata } = useDeviceCapabilities(); + const deprecatedFieldsRetired = firmwareAtLeast( + metadata?.firmwareVersion ?? "", + "2.7.0", + ); + const showEnabled = + !deprecatedFieldsRetired || moduleConfig.cannedMessage?.enabled === true; + const showAllowInputSource = + !deprecatedFieldsRetired || + (moduleConfig.cannedMessage?.allowInputSource ?? "") !== ""; + + // The message list isn't module config; it has its own admin get/set. + const device = useCannedMessages(); + const messages = useStagedAdminString("cannedMessages", device.value); + + const defaultValues = useMemo( + () => + ({ + ...moduleConfig.cannedMessage, + messages: device.value ?? "", + }) as CannedMessageValidation, + [moduleConfig.cannedMessage, device.value], + ); + const values = useMemo( + () => + ({ + ...effective, + messages: messages.value ?? "", + }) as CannedMessageValidation, + [effective, messages.value], + ); + const onSubmit = (data: CannedMessageValidation) => { if (!editor) return; + const { messages: messageList, ...config } = data; editor.setModuleSection( "cannedMessage", - data as unknown as Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig, + config as unknown as Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig, ); + messages.stage(messageList); }; + const messagesDescription = + device.value !== undefined + ? t("cannedMessage.messages.description") + : device.isLoading + ? t("cannedMessage.messages.loading") + : t("cannedMessage.messages.unavailable"); + return ( + // Remount once the list arrives so it becomes the form's default + // value instead of showing up as an unsaved edit. + key={device.value === undefined ? "loading" : "loaded"} onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={CannedMessageValidationSchema} - defaultValues={moduleConfig.cannedMessage} - values={effective} + defaultValues={defaultValues} + values={values} fieldGroups={[ { label: t("cannedMessage.cannedMessageConfig.label"), description: t("cannedMessage.cannedMessageConfig.description"), fields: [ - { - type: "toggle", - name: "enabled", - label: t("cannedMessage.enabled.label"), - description: t("cannedMessage.enabled.description"), - }, + ...(showEnabled + ? [ + { + type: "toggle" as const, + name: "enabled" as const, + label: t("cannedMessage.enabled.label"), + description: t("cannedMessage.enabled.description"), + }, + ] + : []), { type: "toggle", name: "rotary1Enabled", @@ -132,18 +189,31 @@ export const CannedMessage = ({ label: t("cannedMessage.updown1Enabled.label"), description: t("cannedMessage.updown1Enabled.description"), }, - { - type: "text", - name: "allowInputSource", - label: t("cannedMessage.allowInputSource.label"), - description: t("cannedMessage.allowInputSource.description"), - }, + ...(showAllowInputSource + ? [ + { + type: "text" as const, + name: "allowInputSource" as const, + label: t("cannedMessage.allowInputSource.label"), + description: t( + "cannedMessage.allowInputSource.description", + ), + }, + ] + : []), { type: "toggle", name: "sendBell", label: t("cannedMessage.sendBell.label"), description: t("cannedMessage.sendBell.description"), }, + { + type: "text", + name: "messages", + label: t("cannedMessage.messages.label"), + description: messagesDescription, + disabled: device.value === undefined, + }, ], }, ]} diff --git a/apps/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx b/apps/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx index 572914acd..4b269ccc4 100644 --- a/apps/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx +++ b/apps/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx @@ -9,8 +9,10 @@ import { } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { Protobuf } from "@meshtastic/sdk"; -import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { useConfigEditor, useRingtone, useSignal } from "@meshtastic/sdk-react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { useStagedAdminString } from "./useStagedAdminString.ts"; interface ExternalNotificationModuleConfigProps { onFormInit: DynamicFormFormInit; @@ -43,21 +45,59 @@ export const ExternalNotification = ({ const { t } = useTranslation("moduleConfig"); + // The ringtone isn't module config; it has its own admin get/set. + const device = useRingtone(); + const ringtone = useStagedAdminString("ringtone", device.value); + + const defaultValues = useMemo( + () => + ({ + ...moduleConfig.externalNotification, + ringtone: device.value ?? "", + }) as ExternalNotificationValidation, + [moduleConfig.externalNotification, device.value], + ); + const values = useMemo( + () => + ({ + ...effective, + ringtone: ringtone.value ?? "", + }) as ExternalNotificationValidation, + [effective, ringtone.value], + ); + const onSubmit = (data: ExternalNotificationValidation) => { if (!editor) return; + const { ringtone: rtttl, ...config } = data; editor.setModuleSection( "externalNotification", - data as unknown as Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig, + config as unknown as Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig, ); + ringtone.stage(rtttl); }; + const ringtoneDescription = + device.value !== undefined + ? t("externalNotification.ringtone.description") + : device.isLoading + ? t("externalNotification.ringtone.loading") + : t("externalNotification.ringtone.unavailable"); + + // Like Android, the active-high and PWM options only apply once their + // pin is set. + const hasOutput = (effective?.output ?? 0) !== 0; + const hasBuzzer = (effective?.outputBuzzer ?? 0) !== 0; + return ( + // Remount once the ringtone arrives so it becomes the form's default + // value instead of showing up as an unsaved edit. + key={device.value === undefined ? "loading" : "loaded"} onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={ExternalNotificationValidationSchema} - defaultValues={moduleConfig.externalNotification} - values={effective} + defaultValues={defaultValues} + values={values} fieldGroups={[ { label: t("externalNotification.externalNotificationConfig.label"), @@ -141,24 +181,32 @@ export const ExternalNotification = ({ label: t("externalNotification.output.label"), description: t("externalNotification.output.description"), }, - { - type: "toggle", - name: "active", - label: t("externalNotification.active.label"), - description: t("externalNotification.active.description"), - }, + ...(hasOutput + ? [ + { + type: "toggle" as const, + name: "active" as const, + label: t("externalNotification.active.label"), + description: t("externalNotification.active.description"), + }, + ] + : []), { type: "number", name: "outputBuzzer", label: t("externalNotification.outputBuzzer.label"), description: t("externalNotification.outputBuzzer.description"), }, - { - type: "toggle", - name: "usePwm", - label: t("externalNotification.usePwm.label"), - description: t("externalNotification.usePwm.description"), - }, + ...(hasBuzzer + ? [ + { + type: "toggle" as const, + name: "usePwm" as const, + label: t("externalNotification.usePwm.label"), + description: t("externalNotification.usePwm.description"), + }, + ] + : []), { type: "number", name: "outputVibra", @@ -179,6 +227,13 @@ export const ExternalNotification = ({ description: t("externalNotification.nagTimeout.description"), properties: { suffix: t("unit.second.plural") }, }, + { + type: "text", + name: "ringtone", + label: t("externalNotification.ringtone.label"), + description: ringtoneDescription, + disabled: device.value === undefined, + }, { type: "toggle", name: "useI2sAsBuzzer", diff --git a/apps/web/src/components/PageComponents/ModuleConfig/MQTT.tsx b/apps/web/src/components/PageComponents/ModuleConfig/MQTT.tsx index c9c291087..e6315ca51 100644 --- a/apps/web/src/components/PageComponents/ModuleConfig/MQTT.tsx +++ b/apps/web/src/components/PageComponents/ModuleConfig/MQTT.tsx @@ -8,6 +8,7 @@ import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; +import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import { useDevice } from "@core/stores"; import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; @@ -51,6 +52,11 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => { | undefined); const { t } = useTranslation("moduleConfig"); + const { supportsFirmware28 } = useDeviceCapabilities(); + // JSON output is deprecated from firmware 2.8.0. Like Android, offer it + // there only while the radio still has it on. + const showJson = + !supportsFirmware28 || moduleConfig.mqtt?.jsonEnabled === true; const onSubmit = (data: MqttValidation) => { if (!editor) return; @@ -149,13 +155,17 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => { description: t("mqtt.encryptionEnabled.description"), disabledBy: [{ fieldName: "enabled" }], }, - { - type: "toggle", - name: "jsonEnabled", - label: t("mqtt.jsonEnabled.label"), - description: t("mqtt.jsonEnabled.description"), - disabledBy: [{ fieldName: "enabled" }], - }, + ...(showJson + ? [ + { + type: "toggle" as const, + name: "jsonEnabled" as const, + label: t("mqtt.jsonEnabled.label"), + description: t("mqtt.jsonEnabled.description"), + disabledBy: [{ fieldName: "enabled" as const }], + }, + ] + : []), { type: "toggle", name: "tlsEnabled", diff --git a/apps/web/src/components/PageComponents/ModuleConfig/MeshBeacon.tsx b/apps/web/src/components/PageComponents/ModuleConfig/MeshBeacon.tsx index 83fb3068e..f7d3efcb4 100644 --- a/apps/web/src/components/PageComponents/ModuleConfig/MeshBeacon.tsx +++ b/apps/web/src/components/PageComponents/ModuleConfig/MeshBeacon.tsx @@ -1,11 +1,11 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig"; import { + beaconBroadcastGate, + CONSERVATIVE_BEACON_PRESETS, fromMeshBeaconForm, type MeshBeaconValidation, MeshBeaconValidationSchema, OFFER_CHANNEL_KEEP, - OFFER_CHANNEL_NONE, - OFFER_PRESET_UNSET, toMeshBeaconForm, } from "@app/validation/moduleConfig/meshBeacon.ts"; import { getChannelName } from "@components/PageComponents/Channels/Channels.tsx"; @@ -13,15 +13,21 @@ import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; +import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import { useDevice } from "@core/stores"; import { channelSettingsByIndex } from "@core/utils/beaconJoin.ts"; +import { getRegionPresetGroup } from "@core/utils/loraPresets.ts"; import { Protobuf } from "@meshtastic/sdk"; import { useChannels, useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { MeshBeaconTargets } from "./MeshBeaconTargets.tsx"; type MeshBeaconConfig = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig; +const Flags = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_Flags; +const Region = Protobuf.Config.Config_LoRaConfig_RegionCode; + interface MeshBeaconModuleConfigProps { onFormInit: DynamicFormFormInit; } @@ -33,39 +39,66 @@ const EMPTY_MODULES_SIGNAL = { } as const; /** - * Mesh Beacon settings, limited to what firmware 2.8.1 understands. The - * frequency slot fields in the vendored protobufs came later and are left - * out. Broadcast targets have no editor here and are kept as they are. + * Mesh Beacon settings in Android's three cards: the beacon itself, the + * channel it offers, and the targets it is sent on. The region and the + * offered preset are the radio's own, stamped in on save. See + * fromMeshBeaconForm for the save rules and what firmware 2.8.1 needs. */ export const MeshBeacon = ({ onFormInit }: MeshBeaconModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "meshBeacon" }); - const { moduleConfig, getEffectiveModuleConfig } = useDevice(); + const { moduleConfig, getEffectiveModuleConfig, getEffectiveConfig } = + useDevice(); const editor = useConfigEditor(); const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL); const effective = modules.meshBeacon ?? (getEffectiveModuleConfig("meshBeacon") as MeshBeaconConfig | undefined); + const lora = getEffectiveConfig("lora") as + | Protobuf.Config.Config_LoRaConfig + | undefined; const channels = useChannels(); + const { regionPresets } = useDeviceCapabilities(); const { t } = useTranslation("moduleConfig"); + const regionUnset = !lora || lora.region === Region.UNSET; + const usePreset = lora?.usePreset ?? false; + // Gated on what the radio has stored, so turning broadcast off in this + // session can still be undone. + const storedBroadcast = + ((moduleConfig.meshBeacon?.flags ?? 0) & Flags.FLAG_BROADCAST_ENABLED) !== + 0; + const gate = beaconBroadcastGate(usePreset, storedBroadcast); + const channelsByIndex = useMemo( () => channelSettingsByIndex(channels), [channels], ); const defaultValues = useMemo( - () => toMeshBeaconForm(moduleConfig.meshBeacon, channelsByIndex), - [moduleConfig.meshBeacon, channelsByIndex], + () => toMeshBeaconForm(moduleConfig.meshBeacon, channelsByIndex, lora), + [moduleConfig.meshBeacon, channelsByIndex, lora], ); const values = useMemo( - () => toMeshBeaconForm(effective, channelsByIndex), - [effective, channelsByIndex], + () => toMeshBeaconForm(effective, channelsByIndex, lora), + [effective, channelsByIndex, lora], + ); + + // The radio's channels; the primary always, even unnamed. + const channelOptions = useMemo( + () => + channelsByIndex.flatMap((settings, index) => + settings + ? [{ value: index, label: getChannelName({ index, settings }) }] + : [], + ), + [channelsByIndex], ); const offerChannelOptions = useMemo(() => { - const options: Record = { - [t("meshBeacon.offerChannel.none")]: OFFER_CHANNEL_NONE, - }; + const options: Record = {}; + for (const { value, label } of channelOptions) { + options[`${value}: ${label}`] = value; + } if (values.offerChannel === OFFER_CHANNEL_KEEP) { options[ t("meshBeacon.offerChannel.keep", { @@ -73,31 +106,47 @@ export const MeshBeacon = ({ onFormInit }: MeshBeaconModuleConfigProps) => { }) ] = OFFER_CHANNEL_KEEP; } - channelsByIndex.forEach((settings, index) => { - if (!settings) return; - const name = getChannelName({ index, settings }); - options[`${index}: ${name}`] = index; - }); return options; - }, [t, values.offerChannel, effective, channelsByIndex]); + }, [t, values.offerChannel, effective, channelOptions]); - const offerPresetOptions = useMemo( - () => ({ - [t("meshBeacon.broadcastOfferPreset.unset")]: OFFER_PRESET_UNSET, - ...Protobuf.Config.Config_LoRaConfig_ModemPreset, - }), - [t], + const targetPresets = useMemo( + () => + getRegionPresetGroup(regionPresets, lora?.region)?.presets ?? + CONSERVATIVE_BEACON_PRESETS, + [regionPresets, lora?.region], ); const onSubmit = (data: MeshBeaconValidation) => { if (!editor) return; editor.setModuleSection( "meshBeacon", - fromMeshBeaconForm(data, effective, channelsByIndex), + fromMeshBeaconForm( + data, + effective, + channelsByIndex, + lora, + moduleConfig.meshBeacon, + ), ); }; - const broadcastOff = [{ fieldName: "broadcastEnabled" as const }]; + if (regionUnset) { + return ( + + onSubmit={() => {}} + onFormInit={onFormInit} + fieldGroups={[ + { + label: t("meshBeacon.title"), + description: t("meshBeacon.regionRequired"), + fields: [], + }, + ]} + /> + ); + } + + const sectionsDisabled = !gate.sectionsEnabled; return ( @@ -110,7 +159,18 @@ export const MeshBeacon = ({ onFormInit }: MeshBeaconModuleConfigProps) => { { label: t("meshBeacon.title"), description: t("meshBeacon.description"), + notes: usePreset ? undefined : t("meshBeacon.requiresPreset"), fields: [ + { + type: "select", + name: "region", + label: t("meshBeacon.region.label"), + description: t("meshBeacon.region.description"), + disabled: true, + properties: { + enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode, + }, + }, { type: "toggle", name: "listenEnabled", @@ -122,66 +182,63 @@ export const MeshBeacon = ({ onFormInit }: MeshBeaconModuleConfigProps) => { name: "broadcastEnabled", label: t("meshBeacon.broadcastEnabled.label"), description: t("meshBeacon.broadcastEnabled.description"), + disabled: !gate.toggleEnabled, }, + ...(gate.sectionsVisible + ? [ + { + type: "text" as const, + name: "broadcastMessage" as const, + label: t("meshBeacon.broadcastMessage.label"), + description: t("meshBeacon.broadcastMessage.description"), + disabled: sectionsDisabled, + }, + { + type: "number" as const, + name: "broadcastIntervalSecs" as const, + label: t("meshBeacon.broadcastIntervalSecs.label"), + description: t( + "meshBeacon.broadcastIntervalSecs.description", + ), + disabled: sectionsDisabled, + properties: { suffix: t("unit.second.plural") }, + }, + ] + : []), ], }, - { - label: t("meshBeacon.broadcast.label"), - description: t("meshBeacon.broadcast.description"), - fields: [ - { - type: "text", - name: "broadcastMessage", - label: t("meshBeacon.broadcastMessage.label"), - description: t("meshBeacon.broadcastMessage.description"), - disabledBy: broadcastOff, - }, - { - type: "number", - name: "broadcastIntervalSecs", - label: t("meshBeacon.broadcastIntervalSecs.label"), - description: t("meshBeacon.broadcastIntervalSecs.description"), - disabledBy: broadcastOff, - properties: { suffix: t("unit.second.plural") }, - }, - { - type: "toggle", - name: "legacySplit", - label: t("meshBeacon.legacySplit.label"), - description: t("meshBeacon.legacySplit.description"), - disabledBy: broadcastOff, - }, - { - type: "select", - name: "offerChannel", - label: t("meshBeacon.offerChannel.label"), - description: t("meshBeacon.offerChannel.description"), - disabledBy: broadcastOff, - properties: { enumValue: offerChannelOptions }, - }, - { - type: "select", - name: "broadcastOfferPreset", - label: t("meshBeacon.broadcastOfferPreset.label"), - description: t("meshBeacon.broadcastOfferPreset.description"), - disabledBy: broadcastOff, - properties: { - enumValue: offerPresetOptions, - formatEnumName: true, + ...(gate.sectionsVisible + ? [ + { + label: t("meshBeacon.offerChannel.groupLabel"), + description: t("meshBeacon.offerChannel.groupDescription"), + fields: [ + { + type: "select" as const, + name: "offerChannel" as const, + label: t("meshBeacon.offerChannel.label"), + description: t("meshBeacon.offerChannel.description"), + disabled: sectionsDisabled, + properties: { enumValue: offerChannelOptions }, + }, + ], }, - }, - { - type: "select", - name: "broadcastOfferRegion", - label: t("meshBeacon.broadcastOfferRegion.label"), - description: t("meshBeacon.broadcastOfferRegion.description"), - disabledBy: broadcastOff, - properties: { - enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode, + { + label: t("meshBeacon.targets.label"), + description: t("meshBeacon.targets.description"), + fields: [], + footer: ( + + ), }, - }, - ], - }, + ] + : []), ]} /> ); diff --git a/apps/web/src/components/PageComponents/ModuleConfig/MeshBeaconTargets.tsx b/apps/web/src/components/PageComponents/ModuleConfig/MeshBeaconTargets.tsx new file mode 100644 index 000000000..7042692d3 --- /dev/null +++ b/apps/web/src/components/PageComponents/ModuleConfig/MeshBeaconTargets.tsx @@ -0,0 +1,231 @@ +import { + DEFAULT_TARGET, + type MeshBeaconTargetValidation, + type MeshBeaconValidation, + selectTargetChannel, + TARGET_DEFAULT, +} from "@app/validation/moduleConfig/meshBeacon.ts"; +import { Button } from "@components/UI/Button.tsx"; +import { Label } from "@components/UI/Label.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/UI/Select.tsx"; +import { Protobuf } from "@meshtastic/sdk"; +import { PlusIcon } from "lucide-react"; +import { + type SubmitHandler, + useFieldArray, + useFormContext, + useWatch, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +type ModemPreset = Protobuf.Config.Config_LoRaConfig_ModemPreset; + +const presetName = (preset: number) => + (Protobuf.Config.Config_LoRaConfig_ModemPreset[preset] ?? String(preset)) + .toLowerCase() + .split("_") + .map((s) => s.charAt(0).toUpperCase() + s.substring(1)) + .join(" "); + +interface Option { + value: number; + label: string; + disabled?: boolean; +} + +interface MeshBeaconTargetsProps { + onSubmit: SubmitHandler; + /** The radio's channels a target may use, by channel index. */ + channelOptions: Option[]; + /** Presets legal in the radio's region. */ + presets: readonly ModemPreset[]; + /** The radio's own preset, preselected when a row first gets a channel. */ + currentPreset: ModemPreset | undefined; + disabled?: boolean; +} + +/** + * Editor for MeshBeaconConfig.broadcast_targets, after Android's + * BroadcastTargetsCard: one row per target with a channel and a transmit + * preset, each with a "Default" choice, a Remove button, and Add target. + * The list never drops below one row; removing the last one resets it. + */ +export const MeshBeaconTargets = ({ + onSubmit, + channelOptions, + presets, + currentPreset, + disabled, +}: MeshBeaconTargetsProps) => { + const { t } = useTranslation("moduleConfig"); + const { control, handleSubmit } = useFormContext(); + const { fields, append, remove, update } = useFieldArray({ + control, + name: "broadcastTargets", + }); + const targets = useWatch({ control, name: "broadcastTargets" }) ?? []; + + // Selects and buttons don't raise a change event on the form, so stage + // their edits here. + const stage = () => { + void handleSubmit(onSubmit)(); + }; + + const defaultOption: Option = { + value: TARGET_DEFAULT, + label: t("meshBeacon.targets.default"), + }; + + return ( +
    + {fields.map((field, index) => { + const target = targets[index] ?? DEFAULT_TARGET; + // Keep a stored value the radio no longer offers visible, but not + // pickable, so a select never renders blank. + const channels: Option[] = [defaultOption, ...channelOptions]; + if (!channels.some((o) => o.value === target.channelIndex)) { + channels.push({ + value: target.channelIndex, + label: t("meshBeacon.targets.channelNumber", { + index: target.channelIndex, + }), + disabled: true, + }); + } + const presetOptions: Option[] = [ + defaultOption, + ...presets.map((p) => ({ value: p, label: presetName(p) })), + ]; + if (!presetOptions.some((o) => o.value === target.preset)) { + presetOptions.push({ + value: target.preset, + label: presetName(target.preset), + disabled: true, + }); + } + const channelId = `broadcastTargets.${index}.channelIndex`; + const presetId = `broadcastTargets.${index}.preset`; + + return ( +
    +

    + {t("meshBeacon.targets.target", { number: index + 1 })} +

    +
    +
    + + { + update( + index, + selectTargetChannel(target, channelIndex, currentPreset), + ); + stage(); + }} + /> +
    +
    + + { + update(index, { + ...target, + preset: preset as MeshBeaconTargetValidation["preset"], + }); + stage(); + }} + /> +
    +
    + +
    + ); + })} + +
    + ); +}; + +interface TargetSelectProps { + id: string; + value: number; + options: Option[]; + disabled?: boolean; + onChange: (value: number) => void; +} + +const TargetSelect = ({ + id, + value, + options, + disabled, + onChange, +}: TargetSelectProps) => ( + +); diff --git a/apps/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx b/apps/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx index 0bb0a3ba6..692305b90 100644 --- a/apps/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx +++ b/apps/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx @@ -9,7 +9,7 @@ import { } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { Protobuf } from "@meshtastic/sdk"; -import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { useChannel, useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { useTranslation } from "react-i18next"; interface RangeTestModuleConfigProps { @@ -39,12 +39,19 @@ export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => { const { t } = useTranslation("moduleConfig"); + // Like Android, range test is off limits on a public primary channel (a + // key under two bytes is the default or no key): it would flood a shared + // mesh. Wait for the primary channel before deciding. + const primary = useChannel(0); + const isPublicPrimary = + primary?.settings !== undefined && primary.settings.psk.length < 2; + const onSubmit = (data: RangeTestValidation) => { if (!editor) return; - editor.setModuleSection( - "rangeTest", - data as unknown as Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig, - ); + editor.setModuleSection("rangeTest", { + ...data, + enabled: isPublicPrimary ? false : data.enabled, + } as unknown as Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig); }; return ( @@ -58,12 +65,15 @@ export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => { { label: t("rangeTest.rangeTestConfig.label"), description: t("rangeTest.rangeTestConfig.description"), + notes: isPublicPrimary ? t("rangeTest.publicChannel") : undefined, fields: [ { type: "toggle", name: "enabled", label: t("rangeTest.enabled.label"), description: t("rangeTest.enabled.description"), + // Still lets a radio that has it on turn it off. + disabled: isPublicPrimary && !effective?.enabled, }, { type: "number", @@ -71,12 +81,14 @@ export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => { label: t("rangeTest.sender.label"), description: t("rangeTest.sender.description"), properties: { suffix: t("unit.second.plural") }, + disabled: isPublicPrimary, }, { type: "toggle", name: "save", label: t("rangeTest.save.label"), description: t("rangeTest.save.description"), + disabled: isPublicPrimary, }, ], }, diff --git a/apps/web/src/components/PageComponents/ModuleConfig/RemoteHardware.tsx b/apps/web/src/components/PageComponents/ModuleConfig/RemoteHardware.tsx index 29db1a0a6..3023f3e0a 100644 --- a/apps/web/src/components/PageComponents/ModuleConfig/RemoteHardware.tsx +++ b/apps/web/src/components/PageComponents/ModuleConfig/RemoteHardware.tsx @@ -2,6 +2,7 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig"; import { type RemoteHardwareValidation, RemoteHardwareValidationSchema, + toRemoteHardwareConfig, } from "@app/validation/moduleConfig/remoteHardware.ts"; import { DynamicForm, @@ -11,6 +12,7 @@ import { useDevice } from "@core/stores"; import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { useTranslation } from "react-i18next"; +import { RemoteHardwarePins } from "./RemoteHardwarePins.tsx"; interface RemoteHardwareModuleConfigProps { onFormInit: DynamicFormFormInit; @@ -46,7 +48,9 @@ export const RemoteHardware = ({ if (!editor) return; editor.setModuleSection( "remoteHardware", - data as unknown as Protobuf.ModuleConfig.ModuleConfig_RemoteHardwareConfig, + toRemoteHardwareConfig( + data, + ) as Protobuf.ModuleConfig.ModuleConfig_RemoteHardwareConfig, ); }; @@ -55,8 +59,10 @@ export const RemoteHardware = ({ onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={RemoteHardwareValidationSchema} - defaultValues={moduleConfig.remoteHardware} - values={effective} + defaultValues={ + moduleConfig.remoteHardware as RemoteHardwareValidation | undefined + } + values={effective as RemoteHardwareValidation | undefined} fieldGroups={[ { label: t("remoteHardware.title"), @@ -77,6 +83,7 @@ export const RemoteHardware = ({ ), }, ], + footer: , }, ]} /> diff --git a/apps/web/src/components/PageComponents/ModuleConfig/RemoteHardwarePins.tsx b/apps/web/src/components/PageComponents/ModuleConfig/RemoteHardwarePins.tsx new file mode 100644 index 000000000..8cd72b35f --- /dev/null +++ b/apps/web/src/components/PageComponents/ModuleConfig/RemoteHardwarePins.tsx @@ -0,0 +1,153 @@ +import { + REMOTE_HARDWARE_MAX_PINS, + type RemoteHardwareValidation, +} from "@app/validation/moduleConfig/remoteHardware.ts"; +import { FieldWrapper } from "@components/Form/FormWrapper.tsx"; +import { Button } from "@components/UI/Button.tsx"; +import { Input } from "@components/UI/Input.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/UI/Select.tsx"; +import { Protobuf } from "@meshtastic/sdk"; +import { PlusIcon, TrashIcon } from "lucide-react"; +import { + Controller, + get, + type SubmitHandler, + useFieldArray, + useFormContext, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +const PinType = Protobuf.ModuleConfig.RemoteHardwarePinType; + +/** Android offers only the two usable types; UNKNOWN is the proto default. */ +const PIN_TYPES = [PinType.DIGITAL_READ, PinType.DIGITAL_WRITE] as const; + +interface RemoteHardwarePinsProps { + onSubmit: SubmitHandler; + disabled?: boolean; +} + +/** + * List editor for RemoteHardwareConfig.available_pins, like Android's + * EditListPreference: one row per pin with its GPIO number, name and type, + * a delete button, and Add while there are fewer than four. + */ +export const RemoteHardwarePins = ({ + onSubmit, + disabled, +}: RemoteHardwarePinsProps) => { + const { t } = useTranslation("moduleConfig"); + const { control, register, handleSubmit, formState } = + useFormContext(); + const { fields, append, remove } = useFieldArray({ + control, + name: "availablePins", + }); + + // Typing bubbles a change event up to the form, which stages it. Adding, + // removing and picking from a Select don't, so stage those here. + const stage = () => { + void handleSubmit(onSubmit)(); + }; + + const invalid = (index: number, key: string) => + get(formState.errors, `availablePins.${index}.${key}`) !== undefined; + + return ( + +
    + {fields.map((field, index) => ( +
    + + + ( + + )} + /> + +
    + ))} +
    + +
    +
    +
    + ); +}; diff --git a/apps/web/src/components/PageComponents/ModuleConfig/Tak.tsx b/apps/web/src/components/PageComponents/ModuleConfig/Tak.tsx index b6ca6e95c..f31ea2dc2 100644 --- a/apps/web/src/components/PageComponents/ModuleConfig/Tak.tsx +++ b/apps/web/src/components/PageComponents/ModuleConfig/Tak.tsx @@ -11,6 +11,7 @@ import { useDevice } from "@core/stores"; import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { useTranslation } from "react-i18next"; +import { TakTeamSwatches } from "./TakTeamSwatches.tsx"; interface TakModuleConfigProps { onFormInit: DynamicFormFormInit; @@ -76,6 +77,7 @@ export const Tak = ({ onFormInit }: TakModuleConfigProps) => { }, }, ], + footer: , }, ]} /> diff --git a/apps/web/src/components/PageComponents/ModuleConfig/TakTeamSwatches.tsx b/apps/web/src/components/PageComponents/ModuleConfig/TakTeamSwatches.tsx new file mode 100644 index 000000000..5e14417db --- /dev/null +++ b/apps/web/src/components/PageComponents/ModuleConfig/TakTeamSwatches.tsx @@ -0,0 +1,81 @@ +import type { TakValidation } from "@app/validation/moduleConfig/tak.ts"; +import { FieldWrapper } from "@components/Form/FormWrapper.tsx"; +import { cn } from "@core/utils/cn.ts"; +import { Protobuf } from "@meshtastic/sdk"; +import { type SubmitHandler, useFormContext, useWatch } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +const Team = Protobuf.ATAK.Team; + +/** Android's getColorFrom(Team) (core/model TAK.kt). Unspecified is cyan. */ +export const TAK_TEAM_COLORS: Record = { + [Team.Unspecifed_Color]: "#00FFFF", + [Team.White]: "#FFFFFF", + [Team.Yellow]: "#FFFF00", + [Team.Orange]: "#FFA500", + [Team.Magenta]: "#FF00FF", + [Team.Red]: "#FF0000", + [Team.Maroon]: "#800000", + [Team.Purple]: "#800080", + [Team.Dark_Blue]: "#00008B", + [Team.Blue]: "#0000FF", + [Team.Cyan]: "#00FFFF", + [Team.Teal]: "#008080", + [Team.Green]: "#00FF00", + [Team.Dark_Green]: "#006400", + [Team.Brown]: "#A52A2A", +}; + +const SWATCH_TEAMS = ( + Object.values(Team).filter( + (v) => typeof v === "number", + ) as Protobuf.ATAK.Team[] +).filter((team) => team !== Team.Unspecifed_Color); + +const teamName = (team: Protobuf.ATAK.Team) => + (Team[team] ?? "").replace(/_/g, " "); + +interface TakTeamSwatchesProps { + onSubmit: SubmitHandler; +} + +/** + * Team colours as swatches, as Android colours its Team dropdown. Picking a + * swatch sets the team. + */ +export const TakTeamSwatches = ({ onSubmit }: TakTeamSwatchesProps) => { + const { t } = useTranslation("moduleConfig"); + const { control, setValue, handleSubmit } = useFormContext(); + const team = useWatch({ control, name: "team" }); + + return ( + +
    + {SWATCH_TEAMS.map((option) => ( +
    +
    + ); +}; diff --git a/apps/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx b/apps/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx index e5a7e7e5b..c15f1c3b6 100644 --- a/apps/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx +++ b/apps/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx @@ -152,12 +152,6 @@ export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => { description: t("telemetry.airQualityInterval.description"), properties: { suffix: t("unit.second.plural") }, }, - { - type: "toggle", - name: "airQualityScreenEnabled", - label: t("telemetry.airQualityScreenEnabled.label"), - description: t("telemetry.airQualityScreenEnabled.description"), - }, { type: "toggle", name: "powerMeasurementEnabled", diff --git a/apps/web/src/components/PageComponents/ModuleConfig/useStagedAdminString.ts b/apps/web/src/components/PageComponents/ModuleConfig/useStagedAdminString.ts new file mode 100644 index 000000000..53fe0cf86 --- /dev/null +++ b/apps/web/src/components/PageComponents/ModuleConfig/useStagedAdminString.ts @@ -0,0 +1,64 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { useCallback } from "react"; + +/** + * Settings the device keeps outside ModuleConfig, each read and written + * through its own admin message. + */ +export type AdminStringKey = "cannedMessages" | "ringtone"; + +type SetCase = "setCannedMessageModuleMessages" | "setRingtoneMessage"; + +const SET_CASE: Record = { + cannedMessages: "setCannedMessageModuleMessages", + ringtone: "setRingtoneMessage", +}; + +const EMPTY_STAGED_SIGNAL = { + value: new Map() as ReadonlyMap, + peek: () => new Map() as ReadonlyMap, + subscribe: () => () => {}, +} as const; + +function setMessage(key: AdminStringKey, value: string) { + const payloadVariant: Protobuf.Admin.AdminMessage["payloadVariant"] = + key === "cannedMessages" + ? { case: "setCannedMessageModuleMessages", value } + : { case: "setRingtoneMessage", value }; + return create(Protobuf.Admin.AdminMessageSchema, { payloadVariant }); +} + +/** + * The value a form should show for one of these settings, and a function + * that stages an edit to it. The edit is sent with the page's Save, inside + * the same begin/commit edit as the module config. Staging the device's own + * value drops the edit, so the page isn't left dirty. + * + * Nothing is staged until the device has answered, so an untouched field + * can never overwrite a value we haven't read. + */ +export function useStagedAdminString( + key: AdminStringKey, + deviceValue: string | undefined, +): { value: string | undefined; stage: (value: string) => void } { + const editor = useConfigEditor(); + const staged = useSignal(editor?.stagedAdminMessages ?? EMPTY_STAGED_SIGNAL); + const variant = staged.get(key)?.payloadVariant; + const stagedValue = + variant?.case === SET_CASE[key] ? (variant.value as string) : undefined; + + const stage = useCallback( + (value: string) => { + if (!editor || deviceValue === undefined) return; + editor.stageAdminMessage( + key, + value === deviceValue ? undefined : setMessage(key, value), + ); + }, + [editor, key, deviceValue], + ); + + return { value: stagedValue ?? deviceValue, stage }; +} diff --git a/apps/web/src/pages/Settings/ModuleConfig.tsx b/apps/web/src/pages/Settings/ModuleConfig.tsx index ee05ad1c1..fc9cedd6b 100644 --- a/apps/web/src/pages/Settings/ModuleConfig.tsx +++ b/apps/web/src/pages/Settings/ModuleConfig.tsx @@ -14,8 +14,9 @@ import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForwa import { Tak } from "@components/PageComponents/ModuleConfig/Tak.tsx"; import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx"; import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; -import type { ValidModuleConfigType } from "@core/stores"; +import { useDevice, type ValidModuleConfigType } from "@core/stores"; import { isModuleExcluded } from "@core/utils/excludedModules.ts"; +import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { type ComponentType, useMemo } from "react"; import { useTranslation } from "react-i18next"; @@ -31,6 +32,12 @@ const EMPTY_DIRTY_MODULE_SIGNAL = { subscribe: () => () => {}, } as const; +const EMPTY_RADIO_SIGNAL = { + value: {} as { device?: Protobuf.Config.Config_DeviceConfig }, + peek: () => ({}) as { device?: Protobuf.Config.Config_DeviceConfig }, + subscribe: () => () => {}, +} as const; + type ModuleTab = { key: ValidModuleConfigType; labelKey: string; @@ -87,26 +94,46 @@ const MODULE_TABS: ModuleTab[] = [ { key: "meshBeacon", labelKey: "page.tabMeshBeacon", element: MeshBeacon }, ]; +const TAK_ROLES: Protobuf.Config.Config_DeviceConfig_Role[] = [ + Protobuf.Config.Config_DeviceConfig_Role.TAK, + Protobuf.Config.Config_DeviceConfig_Role.TAK_TRACKER, +]; + export const ModuleConfig = ({ onFormInit }: ConfigProps) => { const editor = useConfigEditor(); const dirtyModule = useSignal( editor?.dirtyModuleSections ?? EMPTY_DIRTY_MODULE_SIGNAL, ); + const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL); + const { getEffectiveConfig } = useDevice(); const { t } = useTranslation("moduleConfig"); - const { excludedModules } = useDeviceCapabilities(); + const { excludedModules, supportsFirmware28 } = useDeviceCapabilities(); + + // Staged role first, so the TAK tab appears as soon as the role is set. + const role = ( + radio.device ?? + (getEffectiveConfig("device") as + | Protobuf.Config.Config_DeviceConfig + | undefined) + )?.role; + // Like Android, TAK needs firmware 2.8 and a TAK role. + const showTak = + supportsFirmware28 && role !== undefined && TAK_ROLES.includes(role); // Hide the modules this firmware build was compiled without. const tabs: SettingsTab[] = useMemo( () => MODULE_TABS.filter( - (tab) => !isModuleExcluded(excludedModules, tab.key), + (tab) => + !isModuleExcluded(excludedModules, tab.key) && + (tab.key !== "tak" || showTak), ).map((tab) => ({ key: tab.key, label: t(tab.labelKey), element: tab.element, isDirty: dirtyModule.includes(tab.key), })), - [t, excludedModules, dirtyModule], + [t, excludedModules, dirtyModule, showTak], ); return ; diff --git a/apps/web/src/validation/moduleConfig/cannedMessage.ts b/apps/web/src/validation/moduleConfig/cannedMessage.ts index d68c1beb4..697509d1d 100644 --- a/apps/web/src/validation/moduleConfig/cannedMessage.ts +++ b/apps/web/src/validation/moduleConfig/cannedMessage.ts @@ -1,4 +1,5 @@ -import { Protobuf } from "@meshtastic/sdk"; +import { validateMaxByteLength } from "@core/utils/string.ts"; +import { CANNED_MESSAGES_MAX_BYTES, Protobuf } from "@meshtastic/sdk"; import { z } from "zod/v4"; const InputEventCharEnum = z.enum( @@ -17,6 +18,16 @@ export const CannedMessageValidationSchema = z.object({ enabled: z.boolean(), allowInputSource: z.string().max(30), sendBell: z.boolean(), + // Not part of the module config: sent with its own admin message. + messages: z + .string() + .refine( + (s) => validateMaxByteLength(s, CANNED_MESSAGES_MAX_BYTES).isValid, + { + message: "formValidation.tooBig.bytes", + params: { maximum: CANNED_MESSAGES_MAX_BYTES }, + }, + ), }); export type CannedMessageValidation = z.infer< diff --git a/apps/web/src/validation/moduleConfig/externalNotification.ts b/apps/web/src/validation/moduleConfig/externalNotification.ts index b8769f0ea..0c2d7a503 100644 --- a/apps/web/src/validation/moduleConfig/externalNotification.ts +++ b/apps/web/src/validation/moduleConfig/externalNotification.ts @@ -1,3 +1,5 @@ +import { validateMaxByteLength } from "@core/utils/string.ts"; +import { RINGTONE_MAX_BYTES } from "@meshtastic/sdk"; import { z } from "zod/v4"; export const ExternalNotificationValidationSchema = z.object({ @@ -16,6 +18,13 @@ export const ExternalNotificationValidationSchema = z.object({ usePwm: z.boolean(), nagTimeout: z.coerce.number().int().min(0), useI2sAsBuzzer: z.boolean(), + // Not part of the module config: sent with its own admin message. + ringtone: z + .string() + .refine((s) => validateMaxByteLength(s, RINGTONE_MAX_BYTES).isValid, { + message: "formValidation.tooBig.bytes", + params: { maximum: RINGTONE_MAX_BYTES }, + }), }); export type ExternalNotificationValidation = z.infer< diff --git a/apps/web/src/validation/moduleConfig/meshBeacon.test.ts b/apps/web/src/validation/moduleConfig/meshBeacon.test.ts index 8cd07b26a..71e994f87 100644 --- a/apps/web/src/validation/moduleConfig/meshBeacon.test.ts +++ b/apps/web/src/validation/moduleConfig/meshBeacon.test.ts @@ -2,29 +2,33 @@ import { create } from "@bufbuild/protobuf"; import { Protobuf } from "@meshtastic/sdk"; import { describe, expect, it } from "vitest"; import { + beaconBroadcastGate, + DEFAULT_TARGET, fromMeshBeaconForm, MESH_BEACON_MESSAGE_MAX_BYTES, type MeshBeaconValidation, MeshBeaconValidationSchema, OFFER_CHANNEL_KEEP, - OFFER_CHANNEL_NONE, - OFFER_PRESET_UNSET, + selectTargetChannel, + TARGET_DEFAULT, toMeshBeaconForm, } from "./meshBeacon.ts"; const Flags = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_Flags; const Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset; const Region = Protobuf.Config.Config_LoRaConfig_RegionCode; +const BeaconSchema = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema; +const TargetSchema = + Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_BroadcastTargetSchema; const valid: MeshBeaconValidation = { + region: Region.US, listenEnabled: true, broadcastEnabled: true, - legacySplit: false, broadcastMessage: "Join us", broadcastIntervalSecs: 3600, offerChannel: 0, - broadcastOfferRegion: Region.US, - broadcastOfferPreset: OFFER_PRESET_UNSET, + broadcastTargets: [{ ...DEFAULT_TARGET }], }; const channel = (name: string, psk: number[]) => @@ -40,6 +44,17 @@ const club = channel( ); const channels = [primary, club, undefined, undefined]; +const lora = create(Protobuf.Config.Config_LoRaConfigSchema, { + region: Region.US, + usePreset: true, + modemPreset: Preset.MEDIUM_FAST, +}); +const customLora = create(Protobuf.Config.Config_LoRaConfigSchema, { + region: Region.US, + usePreset: false, + modemPreset: Preset.LONG_FAST, +}); + describe("MeshBeaconValidationSchema", () => { it("accepts a valid config", () => { expect(MeshBeaconValidationSchema.safeParse(valid).success).toBe(true); @@ -81,159 +96,311 @@ describe("MeshBeaconValidationSchema", () => { ).toBe(false); }); - it("accepts an unset preset or a real one, nothing else", () => { - const parse = (broadcastOfferPreset: number) => - MeshBeaconValidationSchema.safeParse({ ...valid, broadcastOfferPreset }) - .success; - expect(parse(OFFER_PRESET_UNSET)).toBe(true); - expect(parse(Preset.MEDIUM_FAST)).toBe(true); - expect(parse(999)).toBe(false); - }); - - it("limits the offered channel to the eight slots and the two sentinels", () => { + it("limits the offered channel to the eight slots and the keep sentinel", () => { const parse = (offerChannel: number) => MeshBeaconValidationSchema.safeParse({ ...valid, offerChannel }).success; expect(parse(OFFER_CHANNEL_KEEP)).toBe(true); - expect(parse(OFFER_CHANNEL_NONE)).toBe(true); + expect(parse(0)).toBe(true); expect(parse(7)).toBe(true); + expect(parse(-1)).toBe(false); expect(parse(8)).toBe(false); - expect(parse(-3)).toBe(false); + }); + + it("needs at least one target, each Default or a real channel and preset", () => { + const parse = (broadcastTargets: unknown) => + MeshBeaconValidationSchema.safeParse({ ...valid, broadcastTargets }) + .success; + expect(parse([])).toBe(false); + expect(parse([{ channelIndex: 3, preset: Preset.SHORT_FAST }])).toBe(true); + expect(parse([{ channelIndex: 8, preset: TARGET_DEFAULT }])).toBe(false); + expect(parse([{ channelIndex: TARGET_DEFAULT, preset: 999 }])).toBe(false); }); }); describe("toMeshBeaconForm", () => { - it("unpacks the flag bits", () => { + it("unpacks the flag bits and shows the radio's region", () => { const form = toMeshBeaconForm( - create(Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema, { - flags: Flags.FLAG_LISTEN_ENABLED | Flags.FLAG_LEGACY_SPLIT, - }), + create(BeaconSchema, { flags: Flags.FLAG_LISTEN_ENABLED }), channels, + lora, ); expect(form.listenEnabled).toBe(true); expect(form.broadcastEnabled).toBe(false); - expect(form.legacySplit).toBe(true); + expect(form.region).toBe(Region.US); }); it("shows the one hour default for an interval never set", () => { - expect(toMeshBeaconForm(undefined, channels).broadcastIntervalSecs).toBe( - 3600, - ); + expect( + toMeshBeaconForm(undefined, channels, lora).broadcastIntervalSecs, + ).toBe(3600); }); it("maps the offered channel back to the local slot it came from", () => { - const config = create( - Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema, - { broadcastOfferChannel: channel("Club", Array.from(club.psk)) }, - ); - expect(toMeshBeaconForm(config, channels).offerChannel).toBe(1); + const config = create(BeaconSchema, { + broadcastOfferChannel: channel("Club", Array.from(club.psk)), + }); + expect(toMeshBeaconForm(config, channels, lora).offerChannel).toBe(1); }); it("keeps an offered channel that isn't in the channel table", () => { - const config = create( - Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema, - { broadcastOfferChannel: channel("Elsewhere", [9]) }, - ); - expect(toMeshBeaconForm(config, channels).offerChannel).toBe( + const config = create(BeaconSchema, { + broadcastOfferChannel: channel("Elsewhere", [9]), + }); + expect(toMeshBeaconForm(config, channels, lora).offerChannel).toBe( OFFER_CHANNEL_KEEP, ); }); - it("reports no offered channel and no preset when unset", () => { - const form = toMeshBeaconForm(undefined, channels); - expect(form.offerChannel).toBe(OFFER_CHANNEL_NONE); - expect(form.broadcastOfferPreset).toBe(OFFER_PRESET_UNSET); + it("shows the primary for an unset offered channel, as a save fills in", () => { + expect(toMeshBeaconForm(undefined, channels, lora).offerChannel).toBe(0); }); -}); -describe("fromMeshBeaconForm", () => { - const base = create( - Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema, - { - // A flag bit this client doesn't know about. - flags: Flags.FLAG_BROADCAST_ENABLED | 64, - broadcastOfferChannel: channel("Elsewhere", [9]), + it("seeds one Default target row for an empty list", () => { + expect( + toMeshBeaconForm(undefined, channels, lora).broadcastTargets, + ).toEqual([DEFAULT_TARGET]); + }); + + it("shows stored targets with Default for unset fields", () => { + const config = create(BeaconSchema, { broadcastTargets: [ - create( - Protobuf.ModuleConfig - .ModuleConfig_MeshBeaconConfig_BroadcastTargetSchema, - { channelIndex: 1 }, - ), + create(TargetSchema, { channelIndex: 1 }), + create(TargetSchema, { preset: Preset.SHORT_FAST }), ], - }, - ); + }); + expect(toMeshBeaconForm(config, channels, lora).broadcastTargets).toEqual([ + { channelIndex: 1, preset: TARGET_DEFAULT }, + { channelIndex: TARGET_DEFAULT, preset: Preset.SHORT_FAST }, + ]); + }); +}); + +describe("fromMeshBeaconForm", () => { + const base = create(BeaconSchema, { + // The legacy-split bit and one this client doesn't know about. + flags: Flags.FLAG_BROADCAST_ENABLED | Flags.FLAG_LEGACY_SPLIT | 64, + broadcastMessage: "Stored", + broadcastIntervalSecs: 7200, + broadcastOfferChannel: channel("Elsewhere", [9]), + broadcastOfferRegion: Region.EU_868, + broadcastOfferPreset: Preset.LONG_SLOW, + broadcastTargets: [create(TargetSchema, { channelIndex: 1 })], + }); it("packs the flags and keeps bits it doesn't own", () => { const config = fromMeshBeaconForm( - { ...valid, broadcastEnabled: false, legacySplit: true }, + { ...valid, broadcastEnabled: false }, base, channels, + lora, ); expect(config.flags).toBe( Flags.FLAG_LISTEN_ENABLED | Flags.FLAG_LEGACY_SPLIT | 64, ); }); - it("keeps broadcast targets, which the form doesn't edit", () => { - const config = fromMeshBeaconForm(valid, base, channels); - expect(config.broadcastTargets).toHaveLength(1); - expect(config.broadcastTargets[0]?.channelIndex).toBe(1); + it("stamps the radio's region and preset over stored values", () => { + const config = fromMeshBeaconForm(valid, base, channels, lora); + expect(config.broadcastOfferRegion).toBe(Region.US); + expect(config.broadcastOfferPreset).toBe(Preset.MEDIUM_FAST); + expect(config.broadcastMessage).toBe("Join us"); + expect(config.broadcastIntervalSecs).toBe(3600); }); it("copies only the name and key of the chosen channel", () => { - const config = fromMeshBeaconForm({ ...valid, offerChannel: 1 }, base, [ - primary, - create(Protobuf.Channel.ChannelSettingsSchema, { - ...club, - uplinkEnabled: true, - }), - ]); + const config = fromMeshBeaconForm( + { ...valid, offerChannel: 1 }, + base, + [ + primary, + create(Protobuf.Channel.ChannelSettingsSchema, { + ...club, + uplinkEnabled: true, + }), + ], + lora, + ); expect(config.broadcastOfferChannel?.name).toBe("Club"); expect(config.broadcastOfferChannel?.psk).toEqual(club.psk); expect(config.broadcastOfferChannel?.uplinkEnabled).toBe(false); }); - it("keeps or clears the offered channel", () => { + it("keeps an offered channel the radio doesn't have", () => { expect( fromMeshBeaconForm( { ...valid, offerChannel: OFFER_CHANNEL_KEEP }, base, channels, + lora, ).broadcastOfferChannel?.name, ).toBe("Elsewhere"); - expect( - fromMeshBeaconForm( - { ...valid, offerChannel: OFFER_CHANNEL_NONE }, - base, - channels, - ).broadcastOfferChannel, - ).toBeUndefined(); }); - it("leaves the preset unset or sets it", () => { + it("falls back to the primary when the chosen slot is empty", () => { + const config = fromMeshBeaconForm( + { ...valid, offerChannel: 3 }, + undefined, + channels, + lora, + ); + expect(config.broadcastOfferChannel?.psk).toEqual(primary.psk); + }); + + it("keeps an empty list empty when its Default row is untouched", () => { + const empty = create(BeaconSchema, { flags: Flags.FLAG_BROADCAST_ENABLED }); expect( - fromMeshBeaconForm(valid, base, channels).broadcastOfferPreset, - ).toBeUndefined(); + fromMeshBeaconForm(valid, empty, channels, lora).broadcastTargets, + ).toEqual([]); + }); + + it("keeps a stored Default target when another field changes", () => { + const withDefault = create(BeaconSchema, { + broadcastTargets: [create(TargetSchema, { region: Region.US })], + }); + const form = toMeshBeaconForm(withDefault, channels, lora); + const config = fromMeshBeaconForm( + { ...form, listenEnabled: !form.listenEnabled }, + withDefault, + channels, + lora, + ); + expect(config.broadcastTargets).toHaveLength(1); + expect(config.broadcastTargets[0]?.channelIndex).toBeUndefined(); + }); + + it("keeps fields it doesn't show on a target the user didn't change", () => { + const withSlot = create(BeaconSchema, { + broadcastTargets: [ + create(TargetSchema, { + channelIndex: 1, + region: Region.EU_868, + frequencySlot: 3, + }), + ], + }); + const form = toMeshBeaconForm(withSlot, channels, lora); + const [kept] = fromMeshBeaconForm( + form, + withSlot, + channels, + lora, + ).broadcastTargets; + expect(kept?.frequencySlot).toBe(3); + expect(kept?.region).toBe(Region.US); + }); + + it("saves targets with the radio's region and unset Defaults", () => { + const config = fromMeshBeaconForm( + { + ...valid, + broadcastTargets: [ + { channelIndex: 1, preset: Preset.SHORT_FAST }, + { ...DEFAULT_TARGET }, + ], + }, + base, + channels, + lora, + ); expect( - fromMeshBeaconForm( - { ...valid, broadcastOfferPreset: Preset.SHORT_FAST }, - base, - channels, - ).broadcastOfferPreset, - ).toBe(Preset.SHORT_FAST); + config.broadcastTargets.map((t) => ({ + region: t.region, + channelIndex: t.channelIndex, + preset: t.preset, + })), + ).toEqual([ + { region: Region.US, channelIndex: 1, preset: Preset.SHORT_FAST }, + { region: Region.US, channelIndex: undefined, preset: undefined }, + ]); }); - it("never sends the frequency slot firmware 2.8.1 doesn't know", () => { - const withSlot = create( - Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema, - { broadcastOfferFrequencySlot: 5 }, + it("keeps every broadcast field on custom LoRa settings", () => { + const config = fromMeshBeaconForm( + { + ...valid, + broadcastEnabled: false, + broadcastMessage: "Changed", + offerChannel: 1, + }, + base, + channels, + customLora, ); + expect(config.flags & Flags.FLAG_BROADCAST_ENABLED).toBe(0); + expect(config.broadcastMessage).toBe("Stored"); + expect(config.broadcastIntervalSecs).toBe(7200); + expect(config.broadcastOfferChannel?.name).toBe("Elsewhere"); + expect(config.broadcastOfferRegion).toBe(Region.EU_868); + expect(config.broadcastOfferPreset).toBe(Preset.LONG_SLOW); + expect(config.broadcastTargets[0]?.channelIndex).toBe(1); + }); + + it("never sends the frequency slot firmware 2.8.1 doesn't know", () => { + const withSlot = create(BeaconSchema, { broadcastOfferFrequencySlot: 5 }); expect( - fromMeshBeaconForm(valid, withSlot, channels).broadcastOfferFrequencySlot, + fromMeshBeaconForm(valid, withSlot, channels, lora) + .broadcastOfferFrequencySlot, ).toBeUndefined(); }); it("round-trips through the form", () => { - const config = fromMeshBeaconForm(valid, undefined, channels); - expect(toMeshBeaconForm(config, channels)).toEqual(valid); + const form = { + ...valid, + offerChannel: 1, + broadcastTargets: [{ channelIndex: 1, preset: Preset.SHORT_FAST }], + }; + const config = fromMeshBeaconForm(form, undefined, channels, lora); + expect(toMeshBeaconForm(config, channels, lora)).toEqual(form); + }); +}); + +describe("selectTargetChannel", () => { + it("preselects the radio's preset the first time a row gets a channel", () => { + expect( + selectTargetChannel({ ...DEFAULT_TARGET }, 1, Preset.MEDIUM_FAST), + ).toEqual({ channelIndex: 1, preset: Preset.MEDIUM_FAST }); + }); + + it("keeps a preset the user chose", () => { + expect( + selectTargetChannel( + { channelIndex: 1, preset: Preset.SHORT_FAST }, + 2, + Preset.MEDIUM_FAST, + ), + ).toEqual({ channelIndex: 2, preset: Preset.SHORT_FAST }); + }); + + it("leaves the preset alone when Default is picked", () => { + expect( + selectTargetChannel( + { channelIndex: 1, preset: TARGET_DEFAULT }, + TARGET_DEFAULT, + Preset.MEDIUM_FAST, + ), + ).toEqual(DEFAULT_TARGET); + }); +}); + +describe("beaconBroadcastGate", () => { + it("shows and enables everything on a standard preset", () => { + expect(beaconBroadcastGate(true, false)).toEqual({ + sectionsVisible: true, + sectionsEnabled: true, + toggleEnabled: true, + }); + }); + + it("on custom settings, only lets a broadcasting radio turn it off", () => { + expect(beaconBroadcastGate(false, true)).toEqual({ + sectionsVisible: true, + sectionsEnabled: false, + toggleEnabled: true, + }); + expect(beaconBroadcastGate(false, false)).toEqual({ + sectionsVisible: false, + sectionsEnabled: false, + toggleEnabled: false, + }); }); }); diff --git a/apps/web/src/validation/moduleConfig/meshBeacon.ts b/apps/web/src/validation/moduleConfig/meshBeacon.ts index b1f5cfcfd..47ad6b1dc 100644 --- a/apps/web/src/validation/moduleConfig/meshBeacon.ts +++ b/apps/web/src/validation/moduleConfig/meshBeacon.ts @@ -4,13 +4,23 @@ import { Protobuf } from "@meshtastic/sdk"; import { z } from "zod/v4"; type MeshBeaconConfig = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig; +type BroadcastTarget = + Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_BroadcastTarget; type ChannelSettings = Protobuf.Channel.ChannelSettings; +type LoRaConfig = Protobuf.Config.Config_LoRaConfig; +type ModemPreset = Protobuf.Config.Config_LoRaConfig_ModemPreset; +type RegionCode = Protobuf.Config.Config_LoRaConfig_RegionCode; const Flags = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_Flags; -const FLAG_MASK = - Flags.FLAG_LISTEN_ENABLED | - Flags.FLAG_BROADCAST_ENABLED | - Flags.FLAG_LEGACY_SPLIT; +const Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset; +const Region = Protobuf.Config.Config_LoRaConfig_RegionCode; + +/** + * The flag bits the form edits. Like Android, the legacy-split bit isn't + * shown, so it is kept as the radio has it, along with any bit this client + * doesn't know. + */ +const FLAG_MASK = Flags.FLAG_LISTEN_ENABLED | Flags.FLAG_BROADCAST_ENABLED; /** * Firmware 2.8.1 was built from protobufs 8f97d66, where nanopb holds @@ -20,21 +30,46 @@ const FLAG_MASK = export const MESH_BEACON_MESSAGE_MAX_BYTES = 100; /** The proto documents one hour as both the minimum and the default. */ export const MESH_BEACON_MIN_INTERVAL_SECS = 3600; +const MAX_CHANNEL_INDEX = 7; -/** Form value for "advertise no channel". */ -export const OFFER_CHANNEL_NONE = -1; /** * Form value for "keep the advertised channel as it is": the radio * advertises a channel that isn't in its own channel table. */ export const OFFER_CHANNEL_KEEP = -2; -/** Form value for "no preset advertised", the proto's unset optional. */ -export const OFFER_PRESET_UNSET = -1; +/** + * Form value for a target's "Default" channel or preset, the proto's unset + * optional: firmware then uses its running preset, or the preset's default + * channel. + */ +export const TARGET_DEFAULT = -1; + +/** + * Presets a beacon may target when the radio sent no region preset map, as + * Android's CONSERVATIVE_BEACON_PRESETS: no newer-firmware presets and + * nothing wider than 250 kHz, so they fit any region's band. + */ +export const CONSERVATIVE_BEACON_PRESETS: readonly ModemPreset[] = [ + Preset.LONG_FAST, + Preset.LONG_MODERATE, + Preset.MEDIUM_SLOW, + Preset.MEDIUM_FAST, + Preset.SHORT_SLOW, + Preset.SHORT_FAST, +]; + +const channelIndex = z.coerce.number().int().min(0).max(MAX_CHANNEL_INDEX); + +const BroadcastTargetSchema = z.object({ + channelIndex: z.union([z.literal(TARGET_DEFAULT), channelIndex]), + preset: z.union([z.literal(TARGET_DEFAULT), z.enum(Preset)]), +}); export const MeshBeaconValidationSchema = z.object({ + /** Read-only: the beacon always uses the radio's own region. */ + region: z.enum(Region), listenEnabled: z.boolean(), broadcastEnabled: z.boolean(), - legacySplit: z.boolean(), broadcastMessage: z .string() .refine( @@ -48,28 +83,31 @@ export const MeshBeaconValidationSchema = z.object({ .number() .int() .min(MESH_BEACON_MIN_INTERVAL_SECS), - offerChannel: z.coerce.number().int().min(OFFER_CHANNEL_KEEP).max(7), - broadcastOfferRegion: z.enum(Protobuf.Config.Config_LoRaConfig_RegionCode), - broadcastOfferPreset: z.union([ - z.literal(OFFER_PRESET_UNSET), - z.enum(Protobuf.Config.Config_LoRaConfig_ModemPreset), - ]), + offerChannel: z.union([z.literal(OFFER_CHANNEL_KEEP), channelIndex]), + broadcastTargets: z.array(BroadcastTargetSchema).min(1), }); export type MeshBeaconValidation = z.infer; +export type MeshBeaconTargetValidation = z.infer; + +export const DEFAULT_TARGET: MeshBeaconTargetValidation = { + channelIndex: TARGET_DEFAULT, + preset: TARGET_DEFAULT, +}; const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((v, i) => v === b[i]); /** * The local channel the advertised channel was copied from, matched on - * name and key, or OFFER_CHANNEL_KEEP when it isn't one of ours. + * name and key. An unset offer shows the primary, which is what a save + * fills in. One that isn't ours is OFFER_CHANNEL_KEEP. */ function offerChannelIndex( offer: ChannelSettings | undefined, channels: ReadonlyArray, ): number { - if (!offer) return OFFER_CHANNEL_NONE; + if (!offer) return 0; const index = channels.findIndex( (c) => c !== undefined && c.name === offer.name && sameBytes(c.psk, offer.psk), @@ -80,69 +118,186 @@ function offerChannelIndex( /** * The form's view of the config. `channels` is indexed by channel index, * with undefined for disabled slots. + * + * Like Android, an empty target list shows as one "Default" row: firmware + * sends a single beacon on its running settings when the list is empty, and + * the row makes that visible. */ export function toMeshBeaconForm( config: MeshBeaconConfig | undefined, channels: ReadonlyArray, + lora: LoRaConfig | undefined, ): MeshBeaconValidation { const flags = config?.flags ?? 0; + const targets = config?.broadcastTargets ?? []; return { + region: lora?.region ?? Region.UNSET, listenEnabled: (flags & Flags.FLAG_LISTEN_ENABLED) !== 0, broadcastEnabled: (flags & Flags.FLAG_BROADCAST_ENABLED) !== 0, - legacySplit: (flags & Flags.FLAG_LEGACY_SPLIT) !== 0, broadcastMessage: config?.broadcastMessage ?? "", // 0 is the proto3 default for a radio that never set it; show the // documented default instead of a value the schema rejects. broadcastIntervalSecs: config?.broadcastIntervalSecs || MESH_BEACON_MIN_INTERVAL_SECS, offerChannel: offerChannelIndex(config?.broadcastOfferChannel, channels), - broadcastOfferRegion: - config?.broadcastOfferRegion ?? - Protobuf.Config.Config_LoRaConfig_RegionCode.UNSET, - broadcastOfferPreset: config?.broadcastOfferPreset ?? OFFER_PRESET_UNSET, + broadcastTargets: + targets.length > 0 + ? targets.map((target) => ({ + channelIndex: target.channelIndex ?? TARGET_DEFAULT, + preset: target.preset ?? TARGET_DEFAULT, + })) + : [{ ...DEFAULT_TARGET }], }; } +const isDefaultTarget = (target: MeshBeaconTargetValidation) => + target.channelIndex === TARGET_DEFAULT && target.preset === TARGET_DEFAULT; + /** - * Merge the form back onto the config. Fields the form doesn't show - * (broadcast targets, flag bits it doesn't know) are kept as they are. - * The advertised channel carries only the name and key a listener needs. + * Merge the form back onto the config, applying Android's save rules + * (MeshBeaconConfigPolicy.stampBeaconConfigForSave). + * + * On a standard modem preset, the beacon advertises the radio's own region + * and preset, never a user's choice, and every target uses the radio's + * region. An untouched offered channel becomes the primary; only its name + * and key are sent. A target row the user didn't change keeps the stored + * target, fields this form doesn't show included. The "Default" row shown + * for an empty list stays an empty list: firmware then sends one beacon on + * the primary channel, while a saved Default target would use the preset's + * default channel. + * + * On custom LoRa settings a preset means nothing, so only the listen and + * broadcast flags come from the form and every broadcast field stays as the + * radio has it. + * + * Unlike Android, the frequency slots are never sent: firmware 2.8.1 doesn't + * know them. Flag bits the form doesn't own are kept. + * + * `stored` is the radio's own config, which target rows are compared with; + * it defaults to `base`, the config being edited. */ export function fromMeshBeaconForm( form: MeshBeaconValidation, base: MeshBeaconConfig | undefined, channels: ReadonlyArray, + lora: LoRaConfig | undefined, + stored: MeshBeaconConfig | undefined = base, ): MeshBeaconConfig { - const bits = - (form.listenEnabled ? Flags.FLAG_LISTEN_ENABLED : 0) | - (form.broadcastEnabled ? Flags.FLAG_BROADCAST_ENABLED : 0) | - (form.legacySplit ? Flags.FLAG_LEGACY_SPLIT : 0); - - let broadcastOfferChannel: ChannelSettings | undefined; - if (form.offerChannel === OFFER_CHANNEL_KEEP) { - broadcastOfferChannel = base?.broadcastOfferChannel; - } else if (form.offerChannel !== OFFER_CHANNEL_NONE) { - const source = channels[form.offerChannel]; - broadcastOfferChannel = source - ? create(Protobuf.Channel.ChannelSettingsSchema, { - name: source.name, - psk: source.psk, - }) - : undefined; - } - const Schema = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema; const config = base ? clone(Schema, base) : create(Schema); + const bits = + (form.listenEnabled ? Flags.FLAG_LISTEN_ENABLED : 0) | + (form.broadcastEnabled ? Flags.FLAG_BROADCAST_ENABLED : 0); config.flags = (config.flags & ~FLAG_MASK) | bits; + + if (!lora?.usePreset) return config; + config.broadcastMessage = form.broadcastMessage; config.broadcastIntervalSecs = form.broadcastIntervalSecs; - config.broadcastOfferChannel = broadcastOfferChannel; - config.broadcastOfferRegion = form.broadcastOfferRegion; - config.broadcastOfferPreset = - form.broadcastOfferPreset === OFFER_PRESET_UNSET - ? undefined - : form.broadcastOfferPreset; + config.broadcastOfferChannel = offerChannel( + form.offerChannel, + base, + channels, + ); + config.broadcastOfferRegion = lora.region; + config.broadcastOfferPreset = lora.modemPreset; // Added to the schema after firmware 2.8.1, which doesn't know it. config.broadcastOfferFrequencySlot = undefined; + + const storedTargets = stored?.broadcastTargets ?? []; + const [only] = form.broadcastTargets; + config.broadcastTargets = + storedTargets.length === 0 && + form.broadcastTargets.length === 1 && + only && + isDefaultTarget(only) + ? [] + : form.broadcastTargets.map((target, index) => + toTarget(target, storedTargets[index], lora.region), + ); return config; } + +function offerChannel( + choice: number, + base: MeshBeaconConfig | undefined, + channels: ReadonlyArray, +): ChannelSettings | undefined { + if (choice === OFFER_CHANNEL_KEEP) return base?.broadcastOfferChannel; + const source = channels[choice] ?? channels[0]; + if (!source) return base?.broadcastOfferChannel; + return create(Protobuf.Channel.ChannelSettingsSchema, { + name: source.name, + psk: source.psk, + }); +} + +function toTarget( + target: MeshBeaconTargetValidation, + stored: BroadcastTarget | undefined, + region: RegionCode, +): BroadcastTarget { + const TargetSchema = + Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_BroadcastTargetSchema; + if ( + stored && + (stored.channelIndex ?? TARGET_DEFAULT) === target.channelIndex && + (stored.preset ?? TARGET_DEFAULT) === target.preset + ) { + const kept = clone(TargetSchema, stored); + kept.region = region; + return kept; + } + return create( + Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_BroadcastTargetSchema, + { + region, + channelIndex: + target.channelIndex === TARGET_DEFAULT + ? undefined + : target.channelIndex, + preset: target.preset === TARGET_DEFAULT ? undefined : target.preset, + }, + ); +} + +/** + * A channel pick on a target row (Android's selectBeaconTargetChannel): the + * first time a row gets a real channel, preselect the radio's preset, but + * never replace a preset the user chose. + */ +export function selectTargetChannel( + target: MeshBeaconTargetValidation, + channelIndex: number, + currentPreset: ModemPreset | undefined, +): MeshBeaconTargetValidation { + const preset = + channelIndex !== TARGET_DEFAULT && + target.preset === TARGET_DEFAULT && + currentPreset !== undefined + ? currentPreset + : target.preset; + return { channelIndex, preset }; +} + +/** + * Whether the broadcast half of the editor shows and can be edited + * (Android's beaconBroadcastGate). It shows on a standard preset, or while + * the radio is still broadcasting so it can be turned off, and is editable + * only on a standard preset. + */ +export function beaconBroadcastGate( + usePreset: boolean, + storedBroadcastEnabled: boolean, +): { + sectionsVisible: boolean; + sectionsEnabled: boolean; + toggleEnabled: boolean; +} { + const visible = usePreset || storedBroadcastEnabled; + return { + sectionsVisible: visible, + sectionsEnabled: usePreset, + toggleEnabled: visible, + }; +} diff --git a/apps/web/src/validation/moduleConfig/moduleMessages.test.ts b/apps/web/src/validation/moduleConfig/moduleMessages.test.ts new file mode 100644 index 000000000..fbb27286e --- /dev/null +++ b/apps/web/src/validation/moduleConfig/moduleMessages.test.ts @@ -0,0 +1,67 @@ +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { CannedMessageValidationSchema } from "./cannedMessage.ts"; +import { ExternalNotificationValidationSchema } from "./externalNotification.ts"; + +const InputEvent = + Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; + +const canned = { + rotary1Enabled: false, + inputbrokerPinA: 0, + inputbrokerPinB: 0, + inputbrokerPinPress: 0, + inputbrokerEventCw: InputEvent.NONE, + inputbrokerEventCcw: InputEvent.NONE, + inputbrokerEventPress: InputEvent.NONE, + updown1Enabled: false, + enabled: true, + allowInputSource: "", + sendBell: false, + messages: "Hi|Bye", +}; + +const notification = { + enabled: true, + outputMs: 0, + output: 0, + outputVibra: 0, + outputBuzzer: 0, + active: false, + alertMessage: false, + alertMessageVibra: false, + alertMessageBuzzer: false, + alertBell: false, + alertBellVibra: false, + alertBellBuzzer: false, + usePwm: false, + nagTimeout: 0, + useI2sAsBuzzer: false, + ringtone: "24:d=32,o=5,b=565:f6,p,f6", +}; + +describe("canned messages", () => { + it("allows 200 bytes of messages, counted in UTF-8", () => { + const parse = (messages: string) => + CannedMessageValidationSchema.safeParse({ ...canned, messages }); + expect(parse("a".repeat(200)).success).toBe(true); + const tooLong = parse("é".repeat(101)); + expect(tooLong.success).toBe(false); + expect(tooLong.error?.issues[0]?.message).toBe( + "formValidation.tooBig.bytes", + ); + }); +}); + +describe("ringtone", () => { + it("allows 230 bytes", () => { + const parse = (ringtone: string) => + ExternalNotificationValidationSchema.safeParse({ + ...notification, + ringtone, + }).success; + expect(parse(notification.ringtone)).toBe(true); + expect(parse("a".repeat(230))).toBe(true); + expect(parse("a".repeat(231))).toBe(false); + }); +}); diff --git a/apps/web/src/validation/moduleConfig/remoteHardware.test.ts b/apps/web/src/validation/moduleConfig/remoteHardware.test.ts new file mode 100644 index 000000000..a0000148d --- /dev/null +++ b/apps/web/src/validation/moduleConfig/remoteHardware.test.ts @@ -0,0 +1,73 @@ +import { isMessage } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { + REMOTE_HARDWARE_MAX_PINS, + type RemoteHardwareValidation, + RemoteHardwareValidationSchema, + toRemoteHardwareConfig, +} from "./remoteHardware.ts"; + +const PinType = Protobuf.ModuleConfig.RemoteHardwarePinType; + +const pin = (gpioPin: number, name = "Door") => ({ + gpioPin, + name, + type: PinType.DIGITAL_READ, +}); + +const valid: RemoteHardwareValidation = { + enabled: true, + allowUndefinedPinAccess: false, + availablePins: [pin(12)], +}; + +describe("RemoteHardwareValidationSchema", () => { + it("accepts a valid config", () => { + expect(RemoteHardwareValidationSchema.safeParse(valid).success).toBe(true); + }); + + it("allows at most four pins", () => { + const parse = (count: number) => + RemoteHardwareValidationSchema.safeParse({ + ...valid, + availablePins: Array.from({ length: count }, (_, i) => pin(i)), + }).success; + expect(parse(REMOTE_HARDWARE_MAX_PINS)).toBe(true); + expect(parse(REMOTE_HARDWARE_MAX_PINS + 1)).toBe(false); + }); + + it("limits a pin name to 14 bytes", () => { + const parse = (name: string) => + RemoteHardwareValidationSchema.safeParse({ + ...valid, + availablePins: [pin(1, name)], + }).success; + expect(parse("a".repeat(14))).toBe(true); + expect(parse("é".repeat(8))).toBe(false); + }); + + it("coerces a typed pin number and rejects a negative one", () => { + const parse = (gpioPin: unknown) => + RemoteHardwareValidationSchema.safeParse({ + ...valid, + availablePins: [{ ...pin(0), gpioPin }], + }); + expect(parse("7").data?.availablePins[0]?.gpioPin).toBe(7); + expect(parse(-1).success).toBe(false); + }); +}); + +describe("toRemoteHardwareConfig", () => { + it("builds each pin as a protobuf message", () => { + const config = toRemoteHardwareConfig(valid); + const [first] = config.availablePins ?? []; + expect( + first && isMessage(first, Protobuf.ModuleConfig.RemoteHardwarePinSchema), + ).toBe(true); + expect(first?.gpioPin).toBe(12); + expect(first?.name).toBe("Door"); + expect(first?.type).toBe(PinType.DIGITAL_READ); + expect(config.enabled).toBe(true); + }); +}); diff --git a/apps/web/src/validation/moduleConfig/remoteHardware.ts b/apps/web/src/validation/moduleConfig/remoteHardware.ts index 51fb4fe6e..0727d6431 100644 --- a/apps/web/src/validation/moduleConfig/remoteHardware.ts +++ b/apps/web/src/validation/moduleConfig/remoteHardware.ts @@ -1,13 +1,65 @@ +import { create } from "@bufbuild/protobuf"; +import { validateMaxByteLength } from "@core/utils/string.ts"; +import { Protobuf } from "@meshtastic/sdk"; import { z } from "zod/v4"; +/** nanopb max_count for available_pins. */ +export const REMOTE_HARDWARE_MAX_PINS = 4; +/** nanopb max_size 15 for a pin name, less the terminator. */ +export const REMOTE_HARDWARE_PIN_NAME_MAX_BYTES = 14; + +export const RemoteHardwarePinValidationSchema = z.object({ + gpioPin: z.coerce.number().int().min(0), + name: z + .string() + .refine( + (s) => + validateMaxByteLength(s, REMOTE_HARDWARE_PIN_NAME_MAX_BYTES).isValid, + { + message: "formValidation.tooBig.bytes", + params: { maximum: REMOTE_HARDWARE_PIN_NAME_MAX_BYTES }, + }, + ), + type: z.enum(Protobuf.ModuleConfig.RemoteHardwarePinType), +}); + export const RemoteHardwareValidationSchema = z.object({ enabled: z.boolean(), allowUndefinedPinAccess: z.boolean(), - // Preserved untouched: the form does not yet edit the available-pins list, - // so round-trip it to avoid clearing pins configured elsewhere. - availablePins: z.array(z.any()), + availablePins: z + .array(RemoteHardwarePinValidationSchema) + .max(REMOTE_HARDWARE_MAX_PINS), }); +export type RemoteHardwarePinValidation = z.infer< + typeof RemoteHardwarePinValidationSchema +>; + export type RemoteHardwareValidation = z.infer< typeof RemoteHardwareValidationSchema >; + +/** The config to stage, with each pin built as a proper protobuf message. */ +export function toRemoteHardwareConfig( + data: RemoteHardwareValidation, +): Partial { + return { + enabled: data.enabled, + allowUndefinedPinAccess: data.allowUndefinedPinAccess, + availablePins: data.availablePins.map((pin) => + createPin(pin.gpioPin, pin.name, pin.type), + ), + }; +} + +function createPin( + gpioPin: number, + name: string, + type: Protobuf.ModuleConfig.RemoteHardwarePinType, +): Protobuf.ModuleConfig.RemoteHardwarePin { + return create(Protobuf.ModuleConfig.RemoteHardwarePinSchema, { + gpioPin, + name, + type, + }); +} diff --git a/packages/sdk-react/mod.ts b/packages/sdk-react/mod.ts index 8aef97414..0d3d06ebf 100644 --- a/packages/sdk-react/mod.ts +++ b/packages/sdk-react/mod.ts @@ -39,6 +39,11 @@ export { useModuleConfig, } from "./src/hooks/useConfig.ts"; export { useConfigEditor } from "./src/hooks/useConfigEditor.ts"; +export { + useCannedMessages, + useRingtone, +} from "./src/hooks/useModuleMessages.ts"; +export type { UseModuleMessagesResult } from "./src/hooks/useModuleMessages.ts"; export { useConnectionProgress } from "./src/hooks/useConnectionProgress.ts"; export { useTotalUnread, diff --git a/packages/sdk-react/src/hooks/useModuleMessages.ts b/packages/sdk-react/src/hooks/useModuleMessages.ts new file mode 100644 index 000000000..96c7de8a7 --- /dev/null +++ b/packages/sdk-react/src/hooks/useModuleMessages.ts @@ -0,0 +1,134 @@ +import type { MeshClient, Protobuf, ReadonlySignal } from "@meshtastic/sdk"; +import { Result } from "better-result"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useOptionalMeshRegistry } from "../adapters/useMeshRegistry.ts"; +import { useSignal } from "../adapters/useSignal.ts"; +import { MeshContext } from "../provider/MeshContext.ts"; + +/** How long to wait for the device to answer before giving up. */ +export const MODULE_MESSAGES_TIMEOUT_MS = 15_000; + +export interface UseModuleMessagesResult { + /** The device's value, or undefined until it has answered once. */ + value: string | undefined; + /** A request is out and the device hasn't answered it yet. */ + isLoading: boolean; + /** The request couldn't be sent, or the device didn't answer in time. */ + error: Error | undefined; + /** Ask the device again. */ + refresh: () => void; +} + +const EMPTY_STRING_SIGNAL: ReadonlySignal = { + value: undefined, + peek: () => undefined, + subscribe: () => () => {}, +}; + +const EMPTY_CLIENT_SIGNAL: ReadonlySignal = { + value: undefined, + peek: () => undefined, + subscribe: () => () => {}, +}; + +type ResponseCase = Extract< + Protobuf.Admin.AdminMessage["payloadVariant"]["case"], + "getCannedMessageModuleMessagesResponse" | "getRingtoneResponse" +>; + +interface Source { + signal: (client: MeshClient) => ReadonlySignal; + request: (client: MeshClient) => Promise>; + responseCase: ResponseCase; +} + +/** The nearest MeshProvider client, else the registry's active one, if any. */ +function useOptionalClient(): MeshClient | undefined { + const direct = useContext(MeshContext); + const registry = useOptionalMeshRegistry(); + const active = useSignal(registry?.active ?? EMPTY_CLIENT_SIGNAL); + return direct ?? active; +} + +function useModuleMessages(source: Source): UseModuleMessagesResult { + const client = useOptionalClient(); + const value = useSignal(client ? source.signal(client) : EMPTY_STRING_SIGNAL); + // Loading from the first render, since the request goes out on mount. + const [isLoading, setIsLoading] = useState(client !== undefined); + const [error, setError] = useState(undefined); + const timer = useRef | undefined>(undefined); + const sourceRef = useRef(source); + + const stopWaiting = useCallback(() => { + clearTimeout(timer.current); + timer.current = undefined; + setIsLoading(false); + }, []); + + // The answer may repeat the cached value, so wait on the packet rather + // than on the signal changing. + useEffect(() => { + if (!client) return; + return client.events.onAdminPacket.subscribe((packet) => { + if (packet.from !== client.myNodeNum) return; + if (packet.data.payloadVariant.case !== sourceRef.current.responseCase) { + return; + } + setError(undefined); + stopWaiting(); + }); + }, [client, stopWaiting]); + + const refresh = useCallback(() => { + if (!client) return; + clearTimeout(timer.current); + setIsLoading(true); + setError(undefined); + timer.current = setTimeout(() => { + timer.current = undefined; + setIsLoading(false); + setError(new Error("The device did not answer")); + }, MODULE_MESSAGES_TIMEOUT_MS); + sourceRef.current.request(client).then((result) => { + if (Result.isError(result)) { + setError(result.error); + stopWaiting(); + } + }); + }, [client, stopWaiting]); + + useEffect(() => { + refresh(); + return () => clearTimeout(timer.current); + }, [refresh]); + + return { value, isLoading, error, refresh }; +} + +const CANNED_MESSAGES: Source = { + signal: (client) => client.config.cannedMessages, + request: (client) => client.config.requestCannedMessages(), + responseCase: "getCannedMessageModuleMessagesResponse", +}; + +const RINGTONE: Source = { + signal: (client) => client.config.ringtone, + request: (client) => client.config.requestRingtone(), + responseCase: "getRingtoneResponse", +}; + +/** + * The connected device's `|`-separated canned message list. Asks the device + * for it on mount. Returns an undefined value when no client is active. + */ +export function useCannedMessages(): UseModuleMessagesResult { + return useModuleMessages(CANNED_MESSAGES); +} + +/** + * The connected device's External Notification ringtone (RTTTL). Asks the + * device for it on mount. Returns an undefined value when no client is active. + */ +export function useRingtone(): UseModuleMessagesResult { + return useModuleMessages(RINGTONE); +} diff --git a/packages/sdk-react/tests/useModuleMessages.test.tsx b/packages/sdk-react/tests/useModuleMessages.test.tsx new file mode 100644 index 000000000..267da7710 --- /dev/null +++ b/packages/sdk-react/tests/useModuleMessages.test.tsx @@ -0,0 +1,88 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { MeshClient, Protobuf } from "@meshtastic/sdk"; +import { createFakeTransport } from "@meshtastic/sdk/testing"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MeshProvider, useCannedMessages, useRingtone } from "../mod.ts"; +import { MODULE_MESSAGES_TIMEOUT_MS } from "../src/hooks/useModuleMessages.ts"; + +function setup() { + const handle = createFakeTransport(); + const client = new MeshClient({ transport: handle.transport }); + const sendPacket = vi.spyOn(client, "sendPacket").mockResolvedValue(1); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return { client, sendPacket, wrapper }; +} + +function answer( + client: MeshClient, + payloadVariant: Protobuf.Admin.AdminMessage["payloadVariant"], +) { + client.events.onAdminPacket.dispatch({ + id: 2, + rxTime: new Date(), + type: "direct", + from: client.myNodeNum, + to: client.myNodeNum, + channel: 0, + // Only the fields the SDK reads; avoids a protobuf runtime dependency. + data: { + payloadVariant, + sessionPasskey: new Uint8Array(), + } as unknown as Protobuf.Admin.AdminMessage, + }); +} + +describe("useCannedMessages / useRingtone", () => { + afterEach(() => vi.useRealTimers()); + + it("asks the device on mount and shows its answer", async () => { + const { client, sendPacket, wrapper } = setup(); + const { result } = renderHook(() => useCannedMessages(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.value).toBeUndefined(); + expect(sendPacket).toHaveBeenCalledTimes(1); + + act(() => + answer(client, { + case: "getCannedMessageModuleMessagesResponse", + value: "Yes|No", + }), + ); + await waitFor(() => expect(result.current.value).toBe("Yes|No")); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeUndefined(); + }); + + it("stops loading when the answer repeats the cached value", async () => { + const { client, wrapper } = setup(); + answer(client, { case: "getRingtoneResponse", value: "tone" }); + const { result } = renderHook(() => useRingtone(), { wrapper }); + expect(result.current.value).toBe("tone"); + expect(result.current.isLoading).toBe(true); + + act(() => answer(client, { case: "getRingtoneResponse", value: "tone" })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + }); + + it("reports an error when the device doesn't answer", async () => { + vi.useFakeTimers(); + const { wrapper } = setup(); + const { result } = renderHook(() => useRingtone(), { wrapper }); + await act(async () => { + await vi.advanceTimersByTimeAsync(MODULE_MESSAGES_TIMEOUT_MS); + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeInstanceOf(Error); + }); + + it("reports an error when the request can't be sent", async () => { + const { sendPacket, wrapper } = setup(); + sendPacket.mockRejectedValue(new Error("offline")); + const { result } = renderHook(() => useRingtone(), { wrapper }); + await waitFor(() => expect(result.current.error?.message).toBe("offline")); + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/packages/sdk/mod.ts b/packages/sdk/mod.ts index 8c11db230..fefaeb9a9 100644 --- a/packages/sdk/mod.ts +++ b/packages/sdk/mod.ts @@ -136,7 +136,12 @@ export { isBeaconAlreadyJoined, } from "./src/features/channels/index.ts"; -export { ConfigClient, ConfigEditor } from "./src/features/config/index.ts"; +export { + CANNED_MESSAGES_MAX_BYTES, + ConfigClient, + ConfigEditor, + RINGTONE_MAX_BYTES, +} from "./src/features/config/index.ts"; export type { ModuleConfig, ModuleConfigSection, diff --git a/packages/sdk/src/core/packet-codec/decodePacket.ts b/packages/sdk/src/core/packet-codec/decodePacket.ts index 2317092eb..2e9903cb8 100644 --- a/packages/sdk/src/core/packet-codec/decodePacket.ts +++ b/packages/sdk/src/core/packet-codec/decodePacket.ts @@ -520,6 +520,14 @@ function handleDecodedPacket( }); break; } + case "getRingtoneResponse": { + // ConfigClient reads it from onAdminPacket above. + sink.log.debug( + Emitter[Emitter.HandleMeshPacket], + "🔔 Received ringtone response packet", + ); + break; + } default: { sink.log.error( Emitter[Emitter.HandleMeshPacket], diff --git a/packages/sdk/src/features/config/ConfigClient.ts b/packages/sdk/src/features/config/ConfigClient.ts index 37ea6572c..1b820e1b9 100644 --- a/packages/sdk/src/features/config/ConfigClient.ts +++ b/packages/sdk/src/features/config/ConfigClient.ts @@ -1,6 +1,6 @@ import * as Protobuf from "@meshtastic/protobufs"; import { computed } from "@preact/signals-core"; -import type { Result } from "better-result"; +import { Result } from "better-result"; import type { MeshClient } from "../../core/client/MeshClient.ts"; import { type ReadonlySignal, @@ -14,6 +14,13 @@ import { setConfig, setModuleConfig, } from "./application/ConfigUseCases.ts"; +import { + requestCannedMessages, + requestRingtone, + setCannedMessages, + setRingtone, +} from "./application/ModuleMessagesUseCases.ts"; +import { DeviceStatusEnum } from "../device/domain/DeviceStatus.ts"; import { ConfigEditor } from "./domain/ConfigEditor.ts"; import type { ModuleConfig } from "./domain/ModuleConfig.ts"; import type { RadioConfig } from "./domain/RadioConfig.ts"; @@ -36,13 +43,28 @@ export class ConfigClient { * flash a "set region" prompt during the connect handshake. */ public readonly isRegionUnset: ReadonlySignal; + /** + * The device's `|`-separated canned message list, or undefined until + * {@link requestCannedMessages} has been answered. + */ + public readonly cannedMessages: ReadonlySignal; + /** + * The External Notification ringtone (RTTTL), or undefined until + * {@link requestRingtone} has been answered. + */ + public readonly ringtone: ReadonlySignal; constructor(client: MeshClient) { this.client = client; this.store = createConfigStore(); this.radio = this.store.radio.read; this.modules = this.store.modules.read; - this.editor = new ConfigEditor(client); + this.cannedMessages = this.store.cannedMessages.read; + this.ringtone = this.store.ringtone.read; + this.editor = new ConfigEditor(client, { + onStagedAdminMessageCommitted: (message) => + this.applySentAdminMessage(message.payloadVariant), + }); this.isRegionUnset = toReadonly( computed(() => { @@ -66,6 +88,63 @@ export class ConfigClient { moduleConfig, ); }); + client.events.onAdminPacket.subscribe((packet) => { + // Only the local node's answers; a remote node's list is not ours. + if (packet.from !== client.myNodeNum) return; + const variant = packet.data.payloadVariant; + if (variant.case === "getCannedMessageModuleMessagesResponse") { + this.store.cannedMessages.write.value = variant.value; + } else if (variant.case === "getRingtoneResponse") { + this.store.ringtone.write.value = variant.value; + } + }); + client.events.onDeviceStatus.subscribe((status) => { + if (status === DeviceStatusEnum.DeviceDisconnected) { + this.store.cannedMessages.write.value = undefined; + this.store.ringtone.write.value = undefined; + } + }); + } + + /** Ask the device for its canned message list; see {@link cannedMessages}. */ + public requestCannedMessages(): Promise> { + return requestCannedMessages(this.client); + } + + /** Set the canned message list (`|`-separated, at most 200 bytes). */ + public async setCannedMessages( + messages: string, + ): Promise> { + const result = await setCannedMessages(this.client, messages); + if (Result.isOk(result)) { + this.store.cannedMessages.write.value = messages; + } + return result; + } + + /** Ask the device for its ringtone; see {@link ringtone}. */ + public requestRingtone(): Promise> { + return requestRingtone(this.client); + } + + /** Set the External Notification ringtone (RTTTL, at most 230 bytes). */ + public async setRingtone(ringtone: string): Promise> { + const result = await setRingtone(this.client, ringtone); + if (Result.isOk(result)) { + this.store.ringtone.write.value = ringtone; + } + return result; + } + + /** Keep the cached values in step with sets sent through the editor. */ + private applySentAdminMessage( + variant: Protobuf.Admin.AdminMessage["payloadVariant"], + ): void { + if (variant.case === "setCannedMessageModuleMessages") { + this.store.cannedMessages.write.value = variant.value; + } else if (variant.case === "setRingtoneMessage") { + this.store.ringtone.write.value = variant.value; + } } public beginEdit(): Promise> { diff --git a/packages/sdk/src/features/config/ModuleMessages.test.ts b/packages/sdk/src/features/config/ModuleMessages.test.ts new file mode 100644 index 000000000..c5ba09316 --- /dev/null +++ b/packages/sdk/src/features/config/ModuleMessages.test.ts @@ -0,0 +1,227 @@ +import { create, fromBinary } from "@bufbuild/protobuf"; +import * as Protobuf from "@meshtastic/protobufs"; +import { Result } from "better-result"; +import { describe, expect, it, vi } from "vitest"; +import { MeshClient } from "../../core/client/MeshClient.ts"; +import { createFakeTransport } from "../../core/testing/createFakeTransport.ts"; +import { DeviceStatusEnum } from "../../core/transport/Transport.ts"; +import { + CANNED_MESSAGES_MAX_BYTES, + RINGTONE_MAX_BYTES, + requestCannedMessages, + requestRingtone, + setCannedMessages, + setRingtone, +} from "./application/ModuleMessagesUseCases.ts"; + +function makeClient() { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const sendPacket = vi.spyOn(client, "sendPacket").mockResolvedValue(42); + const sentAdmin = () => + sendPacket.mock.calls.map((call) => + fromBinary(Protobuf.Admin.AdminMessageSchema, call[0] as Uint8Array), + ); + return { client, sendPacket, sentAdmin }; +} + +function adminPacket( + from: number, + payloadVariant: Protobuf.Admin.AdminMessage["payloadVariant"], +) { + return { + id: 1, + rxTime: new Date(), + type: "direct" as const, + from, + to: from, + channel: 0, + data: create(Protobuf.Admin.AdminMessageSchema, { payloadVariant }), + }; +} + +function admin(payloadVariant: Protobuf.Admin.AdminMessage["payloadVariant"]) { + return create(Protobuf.Admin.AdminMessageSchema, { payloadVariant }); +} + +describe("module message use-cases", () => { + it("requests the canned messages and the ringtone", async () => { + const { client, sentAdmin } = makeClient(); + expect(Result.isOk(await requestCannedMessages(client))).toBe(true); + expect(Result.isOk(await requestRingtone(client))).toBe(true); + expect(sentAdmin().map((m) => m.payloadVariant)).toEqual([ + { case: "getCannedMessageModuleMessagesRequest", value: true }, + { case: "getRingtoneRequest", value: true }, + ]); + }); + + it("sets the canned messages and the ringtone", async () => { + const { client, sentAdmin } = makeClient(); + const result = await setCannedMessages(client, "Hi|Bye"); + expect(Result.isOk(result) && result.value).toBe(42); + await setRingtone(client, "a:d=8,o=5,b=100:c"); + expect(sentAdmin().map((m) => m.payloadVariant)).toEqual([ + { case: "setCannedMessageModuleMessages", value: "Hi|Bye" }, + { case: "setRingtoneMessage", value: "a:d=8,o=5,b=100:c" }, + ]); + }); + + it("rejects values over the firmware byte limits without sending", async () => { + const { client, sendPacket } = makeClient(); + const messages = await setCannedMessages( + client, + "é".repeat(CANNED_MESSAGES_MAX_BYTES / 2 + 1), + ); + const ringtone = await setRingtone( + client, + "a".repeat(RINGTONE_MAX_BYTES + 1), + ); + expect(Result.isError(messages)).toBe(true); + expect(Result.isError(ringtone)).toBe(true); + expect(sendPacket).not.toHaveBeenCalled(); + }); + + it("returns an error when the send fails", async () => { + const { client, sendPacket } = makeClient(); + sendPacket.mockRejectedValueOnce(new Error("offline")); + const result = await requestRingtone(client); + expect(Result.isError(result) && result.error.message).toBe("offline"); + }); +}); + +describe("ConfigClient canned messages and ringtone", () => { + it("stays undefined until the local node answers", () => { + const { client } = makeClient(); + expect(client.config.cannedMessages.value).toBeUndefined(); + expect(client.config.ringtone.value).toBeUndefined(); + + client.events.onAdminPacket.dispatch( + adminPacket(client.myNodeNum, { + case: "getCannedMessageModuleMessagesResponse", + value: "Yes|No", + }), + ); + client.events.onAdminPacket.dispatch( + adminPacket(client.myNodeNum, { + case: "getRingtoneResponse", + value: "tone:d=4:c", + }), + ); + expect(client.config.cannedMessages.value).toBe("Yes|No"); + expect(client.config.ringtone.value).toBe("tone:d=4:c"); + }); + + it("ignores answers from other nodes", () => { + const { client } = makeClient(); + client.events.onAdminPacket.dispatch( + adminPacket(client.myNodeNum + 1, { + case: "getRingtoneResponse", + value: "remote", + }), + ); + expect(client.config.ringtone.value).toBeUndefined(); + }); + + it("forgets both on disconnect", () => { + const { client } = makeClient(); + client.events.onAdminPacket.dispatch( + adminPacket(client.myNodeNum, { + case: "getRingtoneResponse", + value: "tone", + }), + ); + client.events.onDeviceStatus.dispatch(DeviceStatusEnum.DeviceDisconnected); + expect(client.config.ringtone.value).toBeUndefined(); + }); + + it("caches a value it set", async () => { + const { client } = makeClient(); + await client.config.setCannedMessages("A|B"); + await client.config.setRingtone("r"); + expect(client.config.cannedMessages.value).toBe("A|B"); + expect(client.config.ringtone.value).toBe("r"); + }); +}); + +describe("ConfigEditor.stageAdminMessage", () => { + it("keeps one message per key and marks the editor dirty", () => { + const { client } = makeClient(); + const editor = client.config.editor; + editor.stageAdminMessage( + "cannedMessages", + admin({ case: "setCannedMessageModuleMessages", value: "a" }), + ); + editor.stageAdminMessage( + "cannedMessages", + admin({ case: "setCannedMessageModuleMessages", value: "ab" }), + ); + expect(editor.stagedAdminMessages.value.size).toBe(1); + expect( + editor.stagedAdminMessages.value.get("cannedMessages")?.payloadVariant + .value, + ).toBe("ab"); + expect(editor.isDirty.value).toBe(true); + + editor.stageAdminMessage("cannedMessages", undefined); + expect(editor.stagedAdminMessages.value.size).toBe(0); + expect(editor.isDirty.value).toBe(false); + }); + + it("drops staged messages on reset and disconnect", () => { + const { client } = makeClient(); + const editor = client.config.editor; + const ringtone = admin({ case: "setRingtoneMessage", value: "r" }); + + editor.stageAdminMessage("ringtone", ringtone); + editor.reset(); + expect(editor.stagedAdminMessages.value.size).toBe(0); + expect(editor.isDirty.value).toBe(false); + + editor.stageAdminMessage("ringtone", ringtone); + client.events.onDeviceStatus.dispatch(DeviceStatusEnum.DeviceDisconnected); + expect(editor.stagedAdminMessages.value.size).toBe(0); + }); + + it("sends staged messages inside the edit and caches them on commit", async () => { + const { client, sentAdmin } = makeClient(); + const editor = client.config.editor; + editor.stageAdminMessage( + "cannedMessages", + admin({ case: "setCannedMessageModuleMessages", value: "Hi|Bye" }), + ); + editor.stageAdminMessage( + "ringtone", + admin({ case: "setRingtoneMessage", value: "tone" }), + ); + + const result = await editor.commit(); + expect(Result.isOk(result)).toBe(true); + expect(sentAdmin().map((m) => m.payloadVariant.case)).toEqual([ + "beginEditSettings", + "setCannedMessageModuleMessages", + "setRingtoneMessage", + "commitEditSettings", + ]); + expect(editor.stagedAdminMessages.value.size).toBe(0); + expect(editor.isDirty.value).toBe(false); + expect(client.config.cannedMessages.value).toBe("Hi|Bye"); + expect(client.config.ringtone.value).toBe("tone"); + }); + + it("keeps staged messages when the commit fails", async () => { + const { client, sendPacket } = makeClient(); + const editor = client.config.editor; + editor.stageAdminMessage( + "ringtone", + admin({ case: "setRingtoneMessage", value: "tone" }), + ); + sendPacket + .mockResolvedValueOnce(1) + .mockRejectedValueOnce(new Error("no ack")); + + const result = await editor.commit(); + expect(Result.isError(result)).toBe(true); + expect(editor.stagedAdminMessages.value.size).toBe(1); + expect(client.config.ringtone.value).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/features/config/application/ModuleMessagesUseCases.ts b/packages/sdk/src/features/config/application/ModuleMessagesUseCases.ts new file mode 100644 index 000000000..e247d93a6 --- /dev/null +++ b/packages/sdk/src/features/config/application/ModuleMessagesUseCases.ts @@ -0,0 +1,91 @@ +import type * as Protobuf from "@meshtastic/protobufs"; +import { Result } from "better-result"; +import type { MeshClient } from "../../../core/client/MeshClient.ts"; +import { sendAdminMessage } from "../../device/infrastructure/AdminMessageSender.ts"; + +/** + * The canned message list and the External Notification ringtone are not part + * of ModuleConfig. Firmware keeps each one as a single string and reads and + * writes it through its own admin messages. + */ + +/** nanopb max_size 201 for the `|`-separated canned message list. */ +export const CANNED_MESSAGES_MAX_BYTES = 200; +/** nanopb max_size 231 for the RTTTL ringtone. */ +export const RINGTONE_MAX_BYTES = 230; + +const encoder = new TextEncoder(); + +async function send( + client: MeshClient, + payloadVariant: Protobuf.Admin.AdminMessage["payloadVariant"], +): Promise> { + try { + return Result.ok(await sendAdminMessage(client, payloadVariant)); + } catch (e) { + return Result.err(e instanceof Error ? e : new Error(String(e))); + } +} + +function tooLong(what: string, max: number): Error { + return new Error(`${what} is longer than ${max} bytes`); +} + +/** Admin message that sets the canned message list. */ +export function cannedMessagesAdminMessage( + messages: string, +): Protobuf.Admin.AdminMessage["payloadVariant"] { + return { case: "setCannedMessageModuleMessages", value: messages }; +} + +/** Admin message that sets the External Notification ringtone. */ +export function ringtoneAdminMessage( + ringtone: string, +): Protobuf.Admin.AdminMessage["payloadVariant"] { + return { case: "setRingtoneMessage", value: ringtone }; +} + +/** + * Ask the device for its canned message list. The answer arrives as a + * `getCannedMessageModuleMessagesResponse` admin message. + */ +export function requestCannedMessages( + client: MeshClient, +): Promise> { + return send(client, { + case: "getCannedMessageModuleMessagesRequest", + value: true, + }); +} + +export function setCannedMessages( + client: MeshClient, + messages: string, +): Promise> { + if (encoder.encode(messages).length > CANNED_MESSAGES_MAX_BYTES) { + return Promise.resolve( + Result.err(tooLong("Canned messages", CANNED_MESSAGES_MAX_BYTES)), + ); + } + return send(client, cannedMessagesAdminMessage(messages)); +} + +/** + * Ask the device for its ringtone. The answer arrives as a + * `getRingtoneResponse` admin message. + */ +export function requestRingtone( + client: MeshClient, +): Promise> { + return send(client, { case: "getRingtoneRequest", value: true }); +} + +export function setRingtone( + client: MeshClient, + ringtone: string, +): Promise> { + if (encoder.encode(ringtone).length > RINGTONE_MAX_BYTES) { + return Promise.resolve(Result.err(tooLong("Ringtone", RINGTONE_MAX_BYTES))); + } + return send(client, ringtoneAdminMessage(ringtone)); +} diff --git a/packages/sdk/src/features/config/domain/ConfigEditor.ts b/packages/sdk/src/features/config/domain/ConfigEditor.ts index a0a5a67a2..be6c68d9a 100644 --- a/packages/sdk/src/features/config/domain/ConfigEditor.ts +++ b/packages/sdk/src/features/config/domain/ConfigEditor.ts @@ -58,6 +58,9 @@ export class ConfigEditor { private readonly queuedAdminMessages = signal< readonly Protobuf.Admin.AdminMessage[] >([]); + private readonly _stagedAdminMessages = signal< + ReadonlyMap + >(new Map()); private readonly _dirtyRadioSections = signal( [], ); @@ -92,9 +95,29 @@ export class ConfigEditor { this._isOwnerDirty, ); public readonly isDirty: ReadonlySignal = toReadonly(this._isDirty); - - constructor(client: MeshClient) { + /** + * Admin messages staged with {@link stageAdminMessage}, by key. Sent on the + * next commit; cleared by commit, reset and disconnect. + */ + public readonly stagedAdminMessages: ReadonlySignal< + ReadonlyMap + > = toReadonly(this._stagedAdminMessages); + + private readonly onStagedAdminMessageCommitted?: ( + message: Protobuf.Admin.AdminMessage, + ) => void; + + constructor( + client: MeshClient, + options: { + /** Called for each staged admin message after a successful commit. */ + onStagedAdminMessageCommitted?: ( + message: Protobuf.Admin.AdminMessage, + ) => void; + } = {}, + ) { this.client = client; + this.onStagedAdminMessageCommitted = options.onStagedAdminMessageCommitted; client.events.onConfigPacket.subscribe((config) => { const next = ConfigMapper.mergeRadio(this.baselineRadio.peek(), config); @@ -158,6 +181,7 @@ export class ConfigEditor { this.workingChannels.value = new Map(); this.workingOwner.value = undefined; this.queuedAdminMessages.value = []; + this._stagedAdminMessages.value = new Map(); this._dirtyRadioSections.value = []; this._dirtyModuleSections.value = []; this._dirtyChannels.value = []; @@ -199,6 +223,29 @@ export class ConfigEditor { this.recomputeDirty(); } + /** + * Stage an admin message under `key`, replacing any message staged under + * the same key, or remove it with `undefined`. Unlike + * {@link queueAdminMessage}, a form can call this on every change and + * {@link reset} drops it. Used for settings that live outside Config and + * ModuleConfig, such as the canned message list and the ringtone. + */ + public stageAdminMessage( + key: string, + message: Protobuf.Admin.AdminMessage | undefined, + ): void { + const current = this._stagedAdminMessages.peek(); + if (message === undefined && !current.has(key)) return; + const next = new Map(current); + if (message === undefined) { + next.delete(key); + } else { + next.set(key, message); + } + this._stagedAdminMessages.value = next; + this.recomputeDirty(); + } + /** * Stage a radio config section. `value` is merged over the current working * (or baseline) section, so fields a form doesn't render keep the device's @@ -243,6 +290,7 @@ export class ConfigEditor { this.workingModules.value = this.baselineModules.peek(); this.workingChannels.value = new Map(this.baselineChannels.peek()); this.workingOwner.value = this.baselineOwner.peek(); + this._stagedAdminMessages.value = new Map(); this.recomputeDirty(); } @@ -301,6 +349,17 @@ export class ConfigEditor { } } + const staged = [...this._stagedAdminMessages.peek().values()]; + for (const message of staged) { + const variant = message.payloadVariant; + if (!variant.case) continue; + try { + await sendAdminMessage(this.client, variant); + } catch (e) { + return Result.err(e instanceof Error ? e : new Error(String(e))); + } + } + const commitResult = await commitEditSettings(this.client); if (Result.isError(commitResult)) return Result.err(commitResult.error); @@ -309,12 +368,17 @@ export class ConfigEditor { this.baselineChannels.value = new Map(this.workingChannels.peek()); this.baselineOwner.value = this.workingOwner.peek(); this.queuedAdminMessages.value = []; + this._stagedAdminMessages.value = new Map(); this._dirtyRadioSections.value = []; this._dirtyModuleSections.value = []; this._dirtyChannels.value = []; this._isOwnerDirty.value = false; this._isDirty.value = false; + for (const message of staged) { + this.onStagedAdminMessageCommitted?.(message); + } + return Result.ok(undefined); } @@ -372,7 +436,9 @@ export class ConfigEditor { this.baselineOwner.peek(), this.workingOwner.peek(), ); - const hasQueuedAdmin = this.queuedAdminMessages.peek().length > 0; + const hasQueuedAdmin = + this.queuedAdminMessages.peek().length > 0 || + this._stagedAdminMessages.peek().size > 0; this._dirtyRadioSections.value = radioDirty; this._dirtyModuleSections.value = moduleDirty; diff --git a/packages/sdk/src/features/config/index.ts b/packages/sdk/src/features/config/index.ts index 006af8537..8862dd546 100644 --- a/packages/sdk/src/features/config/index.ts +++ b/packages/sdk/src/features/config/index.ts @@ -1,5 +1,9 @@ export { ConfigClient } from "./ConfigClient.ts"; export { ConfigEditor } from "./domain/ConfigEditor.ts"; +export { + CANNED_MESSAGES_MAX_BYTES, + RINGTONE_MAX_BYTES, +} from "./application/ModuleMessagesUseCases.ts"; export type { RadioConfig, RadioConfigSection } from "./domain/RadioConfig.ts"; export type { ModuleConfig, diff --git a/packages/sdk/src/features/config/state/configStore.ts b/packages/sdk/src/features/config/state/configStore.ts index 02ec29603..e8ed65f3e 100644 --- a/packages/sdk/src/features/config/state/configStore.ts +++ b/packages/sdk/src/features/config/state/configStore.ts @@ -6,6 +6,8 @@ export function createConfigStore() { return { radio: createStore({}), modules: createStore({}), + cannedMessages: createStore(undefined), + ringtone: createStore(undefined), }; } From 552421134cf2fc9e6a80960fa4366640eda4727d Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:39:15 -0400 Subject: [PATCH 06/15] feat(web): add Administration, Backup & Restore, Advanced, App and About pages Mirrors the non-config sections of Android's Settings screen: - Administration: reboot, reboot into DFU, shutdown, factory reset (device / config) and NodeDB reset, using the existing confirm dialogs. - Backup & Restore: import/export a .cfg device profile, ported from #1229, with Android's choose-what-to-include dialog. The private key is left out unless explicitly chosen. Imports are staged in the editor and sent on Save. - Clean node database: remove nodes not heard for N days (or only unknown nodes), keeping our own node, favourites and ignored nodes. - Debug panel: device log records with filter, clear and export. - App: theme and language. About: version and project links. Co-authored-by: tylerpieper <54639597+tylerpieper@users.noreply.github.com> --- apps/web/public/i18n/locales/en/ui.json | 152 ++++++ .../SettingsPages/ProfileFieldsDialog.tsx | 119 +++++ .../SettingsPages/SettingsSection.tsx | 74 +++ .../SettingsPages/useDeviceConnected.ts | 7 + apps/web/src/core/utils/cleanNodes.test.ts | 56 +++ apps/web/src/core/utils/cleanNodes.ts | 43 ++ apps/web/src/core/utils/logRecords.test.ts | 47 ++ apps/web/src/core/utils/logRecords.ts | 35 ++ apps/web/src/core/utils/profileExport.test.ts | 273 ++++++++++ apps/web/src/core/utils/profileExport.ts | 470 ++++++++++++++++++ apps/web/src/pages/Settings/About.tsx | 98 +++- .../web/src/pages/Settings/Administration.tsx | 131 ++++- apps/web/src/pages/Settings/AppSettings.tsx | 42 +- apps/web/src/pages/Settings/BackupRestore.tsx | 284 ++++++++++- apps/web/src/pages/Settings/CleanNodeDb.tsx | 187 ++++++- apps/web/src/pages/Settings/DebugPanel.tsx | 155 +++++- 16 files changed, 2161 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx create mode 100644 apps/web/src/components/PageComponents/SettingsPages/SettingsSection.tsx create mode 100644 apps/web/src/components/PageComponents/SettingsPages/useDeviceConnected.ts create mode 100644 apps/web/src/core/utils/cleanNodes.test.ts create mode 100644 apps/web/src/core/utils/cleanNodes.ts create mode 100644 apps/web/src/core/utils/logRecords.test.ts create mode 100644 apps/web/src/core/utils/logRecords.ts create mode 100644 apps/web/src/core/utils/profileExport.test.ts create mode 100644 apps/web/src/core/utils/profileExport.ts diff --git a/apps/web/public/i18n/locales/en/ui.json b/apps/web/public/i18n/locales/en/ui.json index 721050dde..80d0f2edd 100644 --- a/apps/web/public/i18n/locales/en/ui.json +++ b/apps/web/public/i18n/locales/en/ui.json @@ -212,5 +212,157 @@ "footer": { "text": "Powered by <0>▲ Vercel | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information", "commitSha": "Commit SHA: {{sha}}" + }, + "settingsPages": { + "notConnected": "Connect a node to use these actions.", + "administration": { + "title": "Administration", + "description": "Power and reset actions run on the connected node straight away. The destructive ones ask for confirmation first.", + "reboot": { + "label": "Reboot", + "description": "Restart the node now or after a delay, optionally into OTA mode.", + "action": "Reboot…" + }, + "dfu": { + "label": "Reboot into DFU mode", + "description": "nRF52 boards only. The node stays in its bootloader and off the mesh until new firmware is flashed or it is reset.", + "action": "Enter DFU mode…", + "confirmTitle": "Reboot into DFU mode?", + "confirm": "Enter DFU mode", + "sent": "DFU request sent", + "failed": "Couldn't send the DFU request" + }, + "shutdown": { + "label": "Shutdown", + "description": "Turn the node off, now or after a delay.", + "action": "Shut down…" + }, + "factoryResetDevice": { + "label": "Factory reset", + "description": "Return all device state and configuration to factory defaults and clear Bluetooth pairings. Nodes and messages saved in this app are erased too.", + "action": "Factory reset…" + }, + "factoryResetConfig": { + "label": "Factory reset configuration", + "description": "Return all device state and configuration to factory defaults, keeping Bluetooth pairings.", + "action": "Reset configuration…" + }, + "resetNodeDb": { + "label": "NodeDB reset", + "description": "Clear the device's node database and the message history saved in this app.", + "action": "Reset NodeDB…" + } + }, + "backup": { + "title": "Device profile", + "description": "A profile (.cfg file) holds the node's names, channels, configuration and module configuration. The same format is used by the Android, Apple and command-line clients.", + "export": { + "label": "Export configuration", + "description": "Choose what to include, then save a .cfg file of this node's current settings.", + "action": "Export…", + "dialogTitle": "Export configuration", + "dialogDescription": "Choose which parts of this node's settings go into the file.", + "confirm": "Export", + "pendingChanges": "Save or reset your unsaved changes before exporting, so the file matches what's on the node.", + "failed": "Couldn't export the profile" + }, + "import": { + "label": "Import configuration", + "description": "Pick a .cfg file and choose which of its parts to apply. Nothing is sent to the node yet: the changes are staged like any other edit, so you can review them in the configuration pages and then press Save, or Reset to discard them.", + "action": "Choose file…", + "dialogTitle": "Import configuration", + "dialogDescription": "Only the parts this file contains can be selected.", + "confirm": "Stage changes", + "staged": "Profile staged", + "stagedDescription": "Review the changes, then press Save to write them to the node.", + "failed": "Import failed", + "invalidFile": "This file isn't a valid device profile.", + "invalidChannels": "The profile's channel URL is invalid; nothing was staged.", + "notConnected": "Connect a node before importing." + }, + "fields": { + "longName": "Long name", + "shortName": "Short name", + "channelUrl": "Channels (channel URL)", + "config": "Configuration", + "moduleConfig": "Module configuration", + "fixedPosition": "Fixed position", + "isUnmessagable": "Unmessageable", + "isLicensed": "Licensed amateur radio", + "privateKey": "Private key" + }, + "privateKeyWarning": "The private key is your node's identity. Anyone with this file could impersonate the node and read its direct messages. Include it only for your own backup, and keep the file safe.", + "privateKeyExcludedNote": "Without the private key, this app keeps the importing node's own keys, but other clients may give that node a new key pair when they install the security settings.", + "privateKeyImportWarning": "Importing a private key replaces this node's identity. Other nodes will see it as the node the file came from.", + "notApplied": "Not applied on import", + "skipped": { + "title": "Not applied:", + "owner": "names (the node's user info hasn't loaded)", + "isLicensed": "licensed flag (use the User page to change ham mode)", + "security": "security (the node's current keys haven't loaded)", + "trafficManagement": "traffic management", + "ringtone": "ringtone", + "cannedMessages": "canned messages" + } + }, + "cleanNodes": { + "title": "Clean node database", + "description": "Caution: this removes nodes from both the app and the node's database. Selections are additive.", + "olderThan": "Clean up nodes last seen older than {{count}} days", + "olderThanDescription": "Nodes never heard count as old. Your own node, favourites and ignored nodes are always kept.", + "onlyUnknown": "Clean up only unknown nodes", + "onlyUnknownDescription": "Unknown nodes are ones that never sent their user info (no long name).", + "queued": "{{count}} nodes queued for deletion", + "queued_one": "{{count}} node queued for deletion", + "queued_other": "{{count}} nodes queued for deletion", + "none": "No nodes match.", + "cleanNow": "Clean now", + "confirmTitle": "Clean node database?", + "confirmDescription": "Remove {{count}} nodes from the app and the node's database. This can't be undone.", + "confirmDescription_one": "Remove {{count}} node from the app and the node's database. This can't be undone.", + "confirmDescription_other": "Remove {{count}} nodes from the app and the node's database. This can't be undone.", + "done": "Removed {{count}} nodes", + "done_one": "Removed {{count}} node", + "done_other": "Removed {{count}} nodes", + "failed": "{{count}} could not be removed" + }, + "debug": { + "title": "Device logs", + "description": "Log records the node has sent since this page was opened. The node only sends them when \"Debug log API\" is enabled on the Security page; they aren't stored, so leaving this page clears them.", + "securityLink": "Open Security settings", + "search": "Filter logs", + "clear": "Clear", + "export": "Export", + "empty": "No log records yet.", + "noMatch": "No log records match the filter.", + "count": "{{shown}} of {{total}} records", + "capped": "Only the latest {{max}} records are kept." + }, + "app": { + "appearance": "Appearance", + "appearanceDescription": "These settings are stored in this browser only.", + "theme": "Theme", + "themeDescription": "Light, dark, or follow the system setting.", + "language": "Language", + "languageDescription": "The language of the app." + }, + "about": { + "whatIs": "What is Meshtastic?", + "whatIsDescription": "An open source, off-grid, decentralized mesh network that runs on affordable, low-power radios.", + "app": "Meshtastic Web", + "version": "App version", + "github": "GitHub repository", + "githubDescription": "Source code, issues and releases of this app.", + "project": "Project information", + "website": "Website", + "documentation": "Documentation", + "license": "License", + "licenseDescription": "GNU General Public License v3.0", + "needHardware": "Need hardware?", + "needHardwareDescription": "Meshtastic needs a compatible device. See the supported devices to find one.", + "devices": "Supported devices", + "copyright": "Meshtastic® Copyright Meshtastic LLC", + "licenseNotice": "Free software under the GNU General Public License v3, with no warranty. You may redistribute it under the same license." + } } } diff --git a/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx b/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx new file mode 100644 index 000000000..2733277e0 --- /dev/null +++ b/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx @@ -0,0 +1,119 @@ +import { DialogWrapper } from "@components/Dialog/DialogWrapper.tsx"; +import { Button } from "@components/UI/Button.tsx"; +import { Checkbox } from "@components/UI/Checkbox/index.tsx"; +import { + PROFILE_FIELDS, + type ProfileField, + type ProfileSelection, +} from "@core/utils/profileExport.ts"; +import { TriangleAlertIcon } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +export interface ProfileFieldsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "export" | "import"; + /** Which parts exist; the others are shown disabled. */ + available: ProfileSelection; + /** The starting selection; the private key should start unchecked. */ + initial: ProfileSelection; + onConfirm: (selection: ProfileSelection) => void; +} + +/** Fields that exist in the file but are never applied on import. */ +const NOT_IMPORTED: readonly ProfileField[] = ["isLicensed"]; + +/** + * Android's EditDeviceProfileDialog: pick which parts of a profile to export + * or import. The private key is opt-in and carries a warning. + */ +export const ProfileFieldsDialog = ({ + open, + onOpenChange, + mode, + available, + initial, + onConfirm, +}: ProfileFieldsDialogProps) => { + const { t } = useTranslation("ui"); + // Mount the dialog per profile: the selection starts from `initial` once. + const [selection, setSelection] = useState(initial); + + const toggle = (field: ProfileField, checked: boolean) => + setSelection((current) => ({ ...current, [field]: checked })); + + const anySelected = PROFILE_FIELDS.some( + (field) => selection[field] && available[field], + ); + + return ( + + + + + } + > +
    + {PROFILE_FIELDS.map((field) => { + const notApplied = mode === "import" && NOT_IMPORTED.includes(field); + // The key lives in the configuration's security section. + const needsConfig = field === "privateKey" && !selection.config; + const enabled = available[field] && !notApplied && !needsConfig; + return ( +
    + toggle(field, checked)} + > + {t(`settingsPages.backup.fields.${field}`)} + {notApplied && available[field] && ( + + ({t("settingsPages.backup.notApplied")}) + + )} + + {field === "privateKey" && available.privateKey && ( +

    + + {mode === "import" + ? t("settingsPages.backup.privateKeyImportWarning") + : t("settingsPages.backup.privateKeyWarning")} +

    + )} + {field === "privateKey" && + mode === "export" && + available.privateKey && + !(enabled && selection.privateKey) && ( +

    + {t("settingsPages.backup.privateKeyExcludedNote")} +

    + )} +
    + ); + })} +
    +
    + ); +}; diff --git a/apps/web/src/components/PageComponents/SettingsPages/SettingsSection.tsx b/apps/web/src/components/PageComponents/SettingsPages/SettingsSection.tsx new file mode 100644 index 000000000..dde7ad342 --- /dev/null +++ b/apps/web/src/components/PageComponents/SettingsPages/SettingsSection.tsx @@ -0,0 +1,74 @@ +import { Label } from "@components/UI/Label.tsx"; +import { Heading } from "@components/UI/Typography/Heading.tsx"; +import { Subtle } from "@components/UI/Typography/Subtle.tsx"; +import { cn } from "@core/utils/cn.ts"; +import type { ReactNode } from "react"; + +/** The stack of groups on a settings page, spaced like DynamicForm. */ +export const SettingsSections = ({ children }: { children: ReactNode }) => ( +
    {children}
    +); + +interface SettingsGroupProps { + title: string; + description?: ReactNode; + children?: ReactNode; +} + +/** A titled group of rows, matching a DynamicForm field group. */ +export const SettingsGroup = ({ + title, + description, + children, +}: SettingsGroupProps) => ( +
    +
    + + {title} + + {description && {description}} +
    + {children} +
    +); + +interface SettingsRowProps { + label: string; + /** Id of the control, so the label focuses it. */ + htmlFor?: string; + description?: ReactNode; + destructive?: boolean; + children?: ReactNode; +} + +/** A label, description and control, laid out like FieldWrapper. */ +export const SettingsRow = ({ + label, + htmlFor, + description, + destructive, + children, +}: SettingsRowProps) => ( +
    +
    + +
    + {description && ( +
    + {description} +
    + )} + {children && ( +
    + {children} +
    + )} +
    +
    +
    +); diff --git a/apps/web/src/components/PageComponents/SettingsPages/useDeviceConnected.ts b/apps/web/src/components/PageComponents/SettingsPages/useDeviceConnected.ts new file mode 100644 index 000000000..b4237d57c --- /dev/null +++ b/apps/web/src/components/PageComponents/SettingsPages/useDeviceConnected.ts @@ -0,0 +1,7 @@ +import { useDevice } from "@core/stores"; + +/** True once the active node is connected and configured. */ +export function useDeviceConnected(): boolean { + const { connection, connectionPhase } = useDevice(); + return connection !== undefined && connectionPhase === "configured"; +} diff --git a/apps/web/src/core/utils/cleanNodes.test.ts b/apps/web/src/core/utils/cleanNodes.test.ts new file mode 100644 index 000000000..c2b027e2d --- /dev/null +++ b/apps/web/src/core/utils/cleanNodes.test.ts @@ -0,0 +1,56 @@ +import { create } from "@bufbuild/protobuf"; +import { type Node, Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { minDays, nodesToClean } from "./cleanNodes.ts"; + +const DAY = 24 * 60 * 60; +const NOW = 1_800_000_000; + +const node = (num: number, overrides: Partial = {}): Node => ({ + num, + isFavorite: false, + isIgnored: false, + user: create(Protobuf.Mesh.UserSchema, { longName: `Node ${num}` }), + lastHeard: NOW - 30 * DAY, + ...overrides, +}); + +describe("nodesToClean", () => { + const criteria = { olderThanDays: 14, onlyUnknown: false, nowSeconds: NOW }; + + it("selects nodes last heard before the cut-off", () => { + const nodes = [ + node(1), + node(2, { lastHeard: NOW - 3 * DAY }), + node(3, { lastHeard: undefined }), + ]; + expect(nodesToClean(nodes, criteria).map((n) => n.num)).toEqual([1, 3]); + }); + + it("keeps our own node, favourites and ignored nodes", () => { + const nodes = [ + node(1), + node(2, { isFavorite: true }), + node(3, { isIgnored: true }), + ]; + expect( + nodesToClean(nodes, { ...criteria, myNodeNum: 1 }).map((n) => n.num), + ).toEqual([]); + }); + + it("can limit to unknown nodes", () => { + const nodes = [ + node(1), + node(2, { user: undefined }), + node(3, { user: create(Protobuf.Mesh.UserSchema, {}) }), + ]; + expect( + nodesToClean(nodes, { ...criteria, onlyUnknown: true }).map((n) => n.num), + ).toEqual([2, 3]); + }); + + it("lets unknown-only cleaning go down to 0 days", () => { + expect(minDays(true)).toBe(0); + expect(minDays(false)).toBe(7); + }); +}); diff --git a/apps/web/src/core/utils/cleanNodes.ts b/apps/web/src/core/utils/cleanNodes.ts new file mode 100644 index 000000000..cf62c35e4 --- /dev/null +++ b/apps/web/src/core/utils/cleanNodes.ts @@ -0,0 +1,43 @@ +import type { Node } from "@meshtastic/sdk"; + +export const MIN_DAYS_KNOWN = 7; +export const MIN_DAYS_UNKNOWN = 0; +export const MAX_DAYS = 365; + +const SECONDS_PER_DAY = 24 * 60 * 60; + +export interface CleanNodesCriteria { + olderThanDays: number; + /** Only nodes that never sent their user info (no long name). */ + onlyUnknown: boolean; + nowSeconds: number; + myNodeNum?: number; +} + +export const isUnknownNode = (node: Node): boolean => !node.user?.longName; + +/** + * The nodes Android's "Clean node database" would remove: last heard before + * the cut-off (never heard counts as old), optionally only unknown ones. Our + * own node, favourites and ignored nodes are always kept, since removing an + * ignored node would forget that it's ignored. + */ +export function nodesToClean( + nodes: Iterable, + criteria: CleanNodesCriteria, +): Node[] { + const cutoff = + criteria.nowSeconds - Math.floor(criteria.olderThanDays) * SECONDS_PER_DAY; + return [...nodes].filter( + (node) => + node.num !== criteria.myNodeNum && + !node.isFavorite && + !node.isIgnored && + (node.lastHeard ?? 0) < cutoff && + (!criteria.onlyUnknown || isUnknownNode(node)), + ); +} + +/** The slider's lower bound: unknown-only cleaning may go down to 0 days. */ +export const minDays = (onlyUnknown: boolean): number => + onlyUnknown ? MIN_DAYS_UNKNOWN : MIN_DAYS_KNOWN; diff --git a/apps/web/src/core/utils/logRecords.test.ts b/apps/web/src/core/utils/logRecords.test.ts new file mode 100644 index 000000000..c199bc1ce --- /dev/null +++ b/apps/web/src/core/utils/logRecords.test.ts @@ -0,0 +1,47 @@ +import { create, type MessageInitShape } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { + type DeviceLogEntry, + filterLogEntries, + formatLogEntry, +} from "./logRecords.ts"; + +const entry = ( + id: number, + init: MessageInitShape, +): DeviceLogEntry => ({ + id, + receivedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + record: create(Protobuf.Mesh.LogRecordSchema, init), +}); + +describe("log records", () => { + it("formats with the node's time when set", () => { + expect( + formatLogEntry( + entry(1, { + time: Date.UTC(2026, 0, 1) / 1000, + level: Protobuf.Mesh.LogRecord_Level.WARNING, + source: "Router", + message: "queue full", + }), + ), + ).toBe("2026-01-01T00:00:00.000Z [WARNING] Router: queue full"); + }); + + it("falls back to the arrival time", () => { + expect(formatLogEntry(entry(1, { message: "hi" }))).toBe( + "2026-01-02T03:04:05.000Z [UNSET] hi", + ); + }); + + it("filters case-insensitively", () => { + const entries = [ + entry(1, { message: "GPS lock" }), + entry(2, { message: "radio idle" }), + ]; + expect(filterLogEntries(entries, "gps").map((e) => e.id)).toEqual([1]); + expect(filterLogEntries(entries, " ")).toHaveLength(2); + }); +}); diff --git a/apps/web/src/core/utils/logRecords.ts b/apps/web/src/core/utils/logRecords.ts new file mode 100644 index 000000000..d97e7d658 --- /dev/null +++ b/apps/web/src/core/utils/logRecords.ts @@ -0,0 +1,35 @@ +import { Protobuf } from "@meshtastic/sdk"; + +/** A device log record as kept by the debug panel. */ +export interface DeviceLogEntry { + id: number; + /** When the record arrived, in ms; used when the node sent no time. */ + receivedAt: number; + record: Protobuf.Mesh.LogRecord; +} + +export const logLevelName = (level: Protobuf.Mesh.LogRecord_Level): string => + Protobuf.Mesh.LogRecord_Level[level] ?? String(level); + +/** The record's time: the node's clock if it set one, else arrival time. */ +export const logEntryTime = (entry: DeviceLogEntry): Date => + new Date(entry.record.time > 0 ? entry.record.time * 1000 : entry.receivedAt); + +/** One line of text, as shown and exported. */ +export function formatLogEntry(entry: DeviceLogEntry): string { + const { level, source, message } = entry.record; + const origin = source ? ` ${source}:` : ""; + return `${logEntryTime(entry).toISOString()} [${logLevelName(level)}]${origin} ${message}`; +} + +/** Case-insensitive match on the formatted line. */ +export function filterLogEntries( + entries: readonly DeviceLogEntry[], + query: string, +): DeviceLogEntry[] { + const needle = query.trim().toLowerCase(); + if (!needle) return [...entries]; + return entries.filter((entry) => + formatLogEntry(entry).toLowerCase().includes(needle), + ); +} diff --git a/apps/web/src/core/utils/profileExport.test.ts b/apps/web/src/core/utils/profileExport.test.ts new file mode 100644 index 000000000..4b2fec4d7 --- /dev/null +++ b/apps/web/src/core/utils/profileExport.test.ts @@ -0,0 +1,273 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { Result } from "better-result"; +import { describe, expect, it } from "vitest"; +import { + buildDeviceProfile, + decodeDeviceProfile, + defaultSelection, + deviceProfileFileName, + encodeDeviceProfile, + planProfileImport, + type ProfileSource, + profileContents, + sanitizeExportNameSegment, + selectProfileFields, +} from "./profileExport.ts"; + +const PRIVATE_KEY = new Uint8Array(32).fill(7); +const PUBLIC_KEY = new Uint8Array(32).fill(9); + +const makeSource = (overrides: Partial = {}): ProfileSource => ({ + owner: create(Protobuf.Mesh.UserSchema, { + id: "!abcd1234", + longName: "Roof Node", + shortName: "ROOF", + isLicensed: false, + }), + radio: { + device: create(Protobuf.Config.Config_DeviceConfigSchema, { + role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER, + }), + position: create(Protobuf.Config.Config_PositionConfigSchema, { + fixedPosition: true, + }), + lora: create(Protobuf.Config.Config_LoRaConfigSchema, { + region: Protobuf.Config.Config_LoRaConfig_RegionCode.EU_868, + }), + security: create(Protobuf.Config.Config_SecurityConfigSchema, { + privateKey: PRIVATE_KEY, + publicKey: PUBLIC_KEY, + serialEnabled: true, + }), + }, + modules: { + mqtt: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, { + enabled: true, + address: "mqtt.example.org", + }), + }, + channels: [ + create(Protobuf.Channel.ChannelSchema, { + index: 0, + role: Protobuf.Channel.Channel_Role.PRIMARY, + settings: create(Protobuf.Channel.ChannelSettingsSchema, { + name: "Main", + psk: new Uint8Array([1]), + }), + }), + create(Protobuf.Channel.ChannelSchema, { + index: 1, + role: Protobuf.Channel.Channel_Role.DISABLED, + }), + ], + ownPosition: create(Protobuf.Mesh.PositionSchema, { + latitudeI: 515000000, + longitudeI: -1000000, + altitude: 42, + }), + ...overrides, +}); + +describe("profile export", () => { + it("leaves the private and public key out by default", () => { + const profile = buildDeviceProfile(makeSource()); + const selection = defaultSelection(profile); + expect(selection.privateKey).toBe(false); + + const exported = selectProfileFields(profile, selection); + expect(exported.config?.security?.privateKey).toHaveLength(0); + expect(exported.config?.security?.publicKey).toHaveLength(0); + // The rest of the security section is kept. + expect(exported.config?.security?.serialEnabled).toBe(true); + }); + + it("keeps the private key only when chosen", () => { + const profile = buildDeviceProfile(makeSource()); + const exported = selectProfileFields(profile, { + ...defaultSelection(profile), + privateKey: true, + }); + expect(exported.config?.security?.privateKey).toEqual(PRIVATE_KEY); + }); + + it("round-trips through the binary format", () => { + const profile = buildDeviceProfile(makeSource()); + const exported = selectProfileFields(profile, defaultSelection(profile)); + const decoded = decodeDeviceProfile(encodeDeviceProfile(exported)); + expect(Result.isOk(decoded)).toBe(true); + if (!Result.isOk(decoded)) return; + const back = decoded.value; + expect(back.longName).toBe("Roof Node"); + expect(back.shortName).toBe("ROOF"); + expect(back.config?.device?.role).toBe( + Protobuf.Config.Config_DeviceConfig_Role.ROUTER, + ); + expect(back.moduleConfig?.mqtt?.address).toBe("mqtt.example.org"); + expect(back.fixedPosition?.latitudeI).toBe(515000000); + expect(back.channelUrl).toMatch(/^https:\/\/meshtastic\.org\/e\/#/); + expect(back.config?.security?.privateKey).toHaveLength(0); + expect(profileContents(back).privateKey).toBe(false); + }); + + it("drops parts that are unselected", () => { + const profile = buildDeviceProfile(makeSource()); + const exported = selectProfileFields(profile, { + ...defaultSelection(profile), + longName: false, + moduleConfig: false, + channelUrl: false, + }); + expect(exported.longName).toBeUndefined(); + expect(exported.moduleConfig).toBeUndefined(); + expect(exported.channelUrl).toBeUndefined(); + expect(exported.shortName).toBe("ROOF"); + }); + + it("exports no fixed position when it is off", () => { + const source = makeSource(); + const profile = buildDeviceProfile({ + ...source, + radio: { + ...source.radio, + position: create(Protobuf.Config.Config_PositionConfigSchema, {}), + }, + }); + expect(profile.fixedPosition).toBeUndefined(); + }); + + it("rejects bytes that aren't a profile", () => { + expect( + Result.isError(decodeDeviceProfile(new Uint8Array([0xff, 0xff, 0xff]))), + ).toBe(true); + expect(Result.isError(decodeDeviceProfile(new Uint8Array()))).toBe(true); + }); +}); + +describe("export file name", () => { + const date = new Date(2026, 9, 7); + + it("follows Android's pattern", () => { + expect(deviceProfileFileName("Roof // Node", "ROOF", date)).toBe( + "Meshtastic_Roof_Node_20261007_nodeConfig.cfg", + ); + }); + + it("falls back to the short name, then to node", () => { + expect(deviceProfileFileName("📡📡", "AB", date)).toBe( + "Meshtastic_AB_20261007_nodeConfig.cfg", + ); + expect(deviceProfileFileName(undefined, "🙂", date)).toBe( + "Meshtastic_node_20261007_nodeConfig.cfg", + ); + }); + + it("keeps letters of any script", () => { + expect(sanitizeExportNameSegment("Küche")).toBe("Küche"); + expect(sanitizeExportNameSegment("東京")).toBe("東京"); + expect(sanitizeExportNameSegment(" a/b\\c ")).toBe("a_b_c"); + expect(sanitizeExportNameSegment("x".repeat(60))).toHaveLength(48); + }); +}); + +describe("profile import", () => { + const currentSecurity = create(Protobuf.Config.Config_SecurityConfigSchema, { + privateKey: new Uint8Array(32).fill(1), + publicKey: new Uint8Array(32).fill(2), + }); + const owner = create(Protobuf.Mesh.UserSchema, { + id: "!00000001", + longName: "Old", + shortName: "OLD", + hwModel: Protobuf.Mesh.HardwareModel.RAK4631, + }); + + const exportedProfile = () => { + const profile = buildDeviceProfile(makeSource()); + return selectProfileFields(profile, defaultSelection(profile)); + }; + + it("keeps the node's own keys when the file has none", () => { + const plan = planProfileImport(exportedProfile(), { + owner, + security: currentSecurity, + activeChannelIndexes: [0], + }); + expect(Result.isOk(plan)).toBe(true); + if (!Result.isOk(plan)) return; + expect(plan.value.radio.security?.privateKey).toEqual( + currentSecurity.privateKey, + ); + expect(plan.value.radio.security?.publicKey).toEqual( + currentSecurity.publicKey, + ); + expect(plan.value.radio.security?.serialEnabled).toBe(true); + }); + + it("skips security rather than send empty keys", () => { + const plan = planProfileImport(exportedProfile(), { + owner, + activeChannelIndexes: [], + }); + if (!Result.isOk(plan)) throw plan.error; + expect(plan.value.radio.security).toBeUndefined(); + expect(plan.value.skipped).toContain("security"); + }); + + it("merges names into the current owner", () => { + const plan = planProfileImport(exportedProfile(), { + owner, + activeChannelIndexes: [], + }); + if (!Result.isOk(plan)) throw plan.error; + expect(plan.value.owner?.longName).toBe("Roof Node"); + expect(plan.value.owner?.id).toBe("!00000001"); + expect(plan.value.owner?.hwModel).toBe(Protobuf.Mesh.HardwareModel.RAK4631); + expect(plan.value.skipped).toContain("isLicensed"); + }); + + it("replaces the channel table and disables leftover channels", () => { + const plan = planProfileImport(exportedProfile(), { + owner, + activeChannelIndexes: [0, 1, 3], + }); + if (!Result.isOk(plan)) throw plan.error; + const byIndex = new Map(plan.value.channels.map((ch) => [ch.index, ch])); + expect(byIndex.get(0)?.role).toBe(Protobuf.Channel.Channel_Role.PRIMARY); + expect(byIndex.get(0)?.settings?.name).toBe("Main"); + expect(byIndex.get(1)?.role).toBe(Protobuf.Channel.Channel_Role.DISABLED); + expect(byIndex.get(3)?.role).toBe(Protobuf.Channel.Channel_Role.DISABLED); + expect(byIndex.has(2)).toBe(false); + expect(plan.value.radio.lora?.region).toBe( + Protobuf.Config.Config_LoRaConfig_RegionCode.EU_868, + ); + }); + + it("stages modules and the fixed position, but not traffic management", () => { + const profile = exportedProfile(); + if (!profile.moduleConfig) throw new Error("expected module config"); + profile.moduleConfig.trafficManagement = create( + Protobuf.ModuleConfig.ModuleConfig_TrafficManagementConfigSchema, + {}, + ); + const plan = planProfileImport(profile, { + owner, + activeChannelIndexes: [], + }); + if (!Result.isOk(plan)) throw plan.error; + expect(plan.value.modules.mqtt?.enabled).toBe(true); + expect(plan.value.modules.trafficManagement).toBeUndefined(); + expect(plan.value.skipped).toContain("trafficManagement"); + expect(plan.value.fixedPosition?.altitude).toBe(42); + }); + + it("fails on a bad channel URL", () => { + const profile = exportedProfile(); + profile.channelUrl = "https://example.com/not-a-channel"; + expect( + Result.isError( + planProfileImport(profile, { owner, activeChannelIndexes: [] }), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/core/utils/profileExport.ts b/apps/web/src/core/utils/profileExport.ts new file mode 100644 index 000000000..1ac71a197 --- /dev/null +++ b/apps/web/src/core/utils/profileExport.ts @@ -0,0 +1,470 @@ +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { channelSetFromUrl, channelSetToUrl } from "@core/utils/channelUrl.ts"; +import { Protobuf } from "@meshtastic/sdk"; +import { Result } from "better-result"; + +/** + * Device profile (`.cfg`) import and export, the binary + * `ClientOnly.DeviceProfile` format shared with the Android, Apple and Python + * clients. Everything here is pure: the page reads the editor and applies the + * import plan, so this file needs no device to test. + */ + +type LocalConfig = Protobuf.LocalOnly.LocalConfig; +type LocalModuleConfig = Protobuf.LocalOnly.LocalModuleConfig; +type DeviceProfile = Protobuf.ClientOnly.DeviceProfile; + +/** The parts of a profile the user can include or leave out. */ +export const PROFILE_FIELDS = [ + "longName", + "shortName", + "channelUrl", + "config", + "moduleConfig", + "fixedPosition", + "isUnmessagable", + "isLicensed", + "privateKey", +] as const; + +export type ProfileField = (typeof PROFILE_FIELDS)[number]; +export type ProfileSelection = Record; + +/** LocalConfig sections, as named in both LocalConfig and the editor. */ +export const PROFILE_RADIO_SECTIONS = [ + "device", + "position", + "power", + "network", + "display", + "lora", + "bluetooth", + "security", +] as const satisfies readonly (keyof LocalConfig)[]; + +/** LocalModuleConfig sections, as named in both LocalModuleConfig and the editor. */ +export const PROFILE_MODULE_SECTIONS = [ + "mqtt", + "serial", + "externalNotification", + "storeForward", + "rangeTest", + "telemetry", + "cannedMessage", + "audio", + "remoteHardware", + "neighborInfo", + "ambientLighting", + "detectionSensor", + "paxcounter", + "statusmessage", + "trafficManagement", + "tak", + "meshBeacon", +] as const satisfies readonly (keyof LocalModuleConfig)[]; + +export type ProfileRadioSection = (typeof PROFILE_RADIO_SECTIONS)[number]; +export type ProfileModuleSection = (typeof PROFILE_MODULE_SECTIONS)[number]; + +/** + * Not installed on import, like Android: a node built without the module + * exports zeroes, which would turn off this node's position dedup. + */ +const MODULE_SECTIONS_NOT_INSTALLED: readonly ProfileModuleSection[] = [ + "trafficManagement", +]; + +const MAX_CHANNELS = 8; + +/** What the export reads from the connected node. */ +export interface ProfileSource { + owner?: Protobuf.Mesh.User; + radio: Partial>; + modules: Partial>; + /** Channels by index; disabled ones are left out of the URL. */ + channels: Iterable; + /** The node's own position; exported only when fixed position is on. */ + ownPosition?: Protobuf.Mesh.Position; +} + +/** The channel share URL for the enabled channels, primary first. */ +export function channelUrlFromChannels( + channels: Iterable, + loraConfig: Protobuf.Config.Config_LoRaConfig | undefined, +): string | undefined { + const settings = [...channels] + .filter((ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED) + .sort((a, b) => a.index - b.index) + .map((ch) => ch.settings) + .filter((s): s is Protobuf.Channel.ChannelSettings => s !== undefined); + if (settings.length === 0) return undefined; + const channelSet = create(Protobuf.AppOnly.ChannelSetSchema, { + settings, + loraConfig, + }); + return channelSetToUrl(channelSet); +} + +/** The full profile of the node, private key included. Filter before saving. */ +export function buildDeviceProfile(source: ProfileSource): DeviceProfile { + const config = create(Protobuf.LocalOnly.LocalConfigSchema, {}); + let hasConfig = false; + for (const key of PROFILE_RADIO_SECTIONS) { + const value = source.radio[key]; + if (value) { + (config as Record)[key] = value; + hasConfig = true; + } + } + + const moduleConfig = create(Protobuf.LocalOnly.LocalModuleConfigSchema, {}); + let hasModules = false; + for (const key of PROFILE_MODULE_SECTIONS) { + const value = source.modules[key]; + if (value) { + (moduleConfig as Record)[key] = value; + hasModules = true; + } + } + + const position = source.ownPosition; + const fixedPosition = + source.radio.position?.fixedPosition && + position?.latitudeI !== undefined && + position.longitudeI !== undefined && + (position.latitudeI !== 0 || position.longitudeI !== 0) + ? create(Protobuf.Mesh.PositionSchema, { + latitudeI: position.latitudeI, + longitudeI: position.longitudeI, + altitude: position.altitude, + }) + : undefined; + + const owner = source.owner; + return create(Protobuf.ClientOnly.DeviceProfileSchema, { + longName: owner?.longName || undefined, + shortName: owner?.shortName || undefined, + isUnmessagable: owner ? (owner.isUnmessagable ?? false) : undefined, + isLicensed: owner ? owner.isLicensed : undefined, + channelUrl: channelUrlFromChannels(source.channels, source.radio.lora), + config: hasConfig ? config : undefined, + moduleConfig: hasModules ? moduleConfig : undefined, + fixedPosition, + }); +} + +const hasPrivateKey = (profile: DeviceProfile): boolean => + (profile.config?.security?.privateKey.length ?? 0) > 0; + +/** Which parts the profile holds; the rest can't be selected. */ +export function profileContents(profile: DeviceProfile): ProfileSelection { + return { + longName: profile.longName !== undefined, + shortName: profile.shortName !== undefined, + channelUrl: profile.channelUrl !== undefined, + config: profile.config !== undefined, + moduleConfig: profile.moduleConfig !== undefined, + fixedPosition: profile.fixedPosition !== undefined, + isUnmessagable: profile.isUnmessagable !== undefined, + isLicensed: profile.isLicensed !== undefined, + privateKey: hasPrivateKey(profile), + }; +} + +/** Everything the profile holds, except the private key. */ +export function defaultSelection(profile: DeviceProfile): ProfileSelection { + return { ...profileContents(profile), privateKey: false }; +} + +/** + * A copy of the profile with only the selected parts. The private key, and + * the public key that pairs with it, are kept only when explicitly selected. + */ +export function selectProfileFields( + profile: DeviceProfile, + selection: ProfileSelection, +): DeviceProfile { + const copy = create(Protobuf.ClientOnly.DeviceProfileSchema, { + longName: selection.longName ? profile.longName : undefined, + shortName: selection.shortName ? profile.shortName : undefined, + channelUrl: selection.channelUrl ? profile.channelUrl : undefined, + moduleConfig: selection.moduleConfig ? profile.moduleConfig : undefined, + fixedPosition: selection.fixedPosition ? profile.fixedPosition : undefined, + isUnmessagable: selection.isUnmessagable + ? profile.isUnmessagable + : undefined, + isLicensed: selection.isLicensed ? profile.isLicensed : undefined, + ringtone: profile.ringtone, + cannedMessages: profile.cannedMessages, + }); + + if (selection.config && profile.config) { + const security = profile.config.security; + copy.config = create(Protobuf.LocalOnly.LocalConfigSchema, { + ...profile.config, + security: + security && !(selection.privateKey && hasPrivateKey(profile)) + ? create(Protobuf.Config.Config_SecurityConfigSchema, { + ...security, + privateKey: new Uint8Array(), + publicKey: new Uint8Array(), + }) + : security, + }); + } + return copy; +} + +export function encodeDeviceProfile(profile: DeviceProfile): Uint8Array { + return toBinary(Protobuf.ClientOnly.DeviceProfileSchema, profile); +} + +export class ProfileDecodeError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ProfileDecodeError"; + } +} + +/** Parses a `.cfg` file. Fails on bytes that aren't a non-empty profile. */ +export function decodeDeviceProfile( + bytes: Uint8Array, +): Result { + let profile: DeviceProfile; + try { + profile = fromBinary(Protobuf.ClientOnly.DeviceProfileSchema, bytes); + } catch (cause) { + return Result.err( + new ProfileDecodeError("Not a device profile", { cause }), + ); + } + const contents = profileContents(profile); + if (!Object.values(contents).some(Boolean)) { + return Result.err(new ProfileDecodeError("The profile is empty")); + } + return Result.ok(profile); +} + +const FALLBACK_NODE_NAME = "node"; +const MAX_NAME_LENGTH = 48; +const PICTOGRAPHIC_RANGES: readonly [number, number][] = [ + [0x1d000, 0x1d1ff], + [0x1f000, 0x1faff], + [0x1fb00, 0x1fbff], +]; + +const isNameChar = (char: string): boolean => { + const codePoint = char.codePointAt(0) ?? 0; + if ( + PICTOGRAPHIC_RANGES.some(([lo, hi]) => codePoint >= lo && codePoint <= hi) + ) + return false; + return /^[\p{L}\p{N}_-]$/u.test(char); +}; + +/** + * Reduces a node name to something safe in a file name, like Android: letters + * and digits of any script stay, each run of anything else becomes one `_`. + * Undefined when nothing usable is left (an all-emoji name, say). + */ +export function sanitizeExportNameSegment( + name: string | undefined, +): string | undefined { + if (!name) return undefined; + let out = ""; + for (const char of name) { + if (isNameChar(char)) out += char; + else if (out.length > 0 && !out.endsWith("_")) out += "_"; + } + const capped = Array.from(out).slice(0, MAX_NAME_LENGTH).join(""); + const trimmed = capped.replace(/_+$/, ""); + return trimmed.length > 0 ? trimmed : undefined; +} + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** `yyyyMMdd` in local time. */ +export const dateStamp = (date: Date): string => + `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`; + +/** + * Android's export name: `Meshtastic___nodeConfig.cfg`, where + * the name is the long name, else the short name, else `node`. + */ +export function deviceProfileFileName( + longName: string | undefined, + shortName: string | undefined, + date: Date, +): string { + const name = + sanitizeExportNameSegment(longName) ?? + sanitizeExportNameSegment(shortName) ?? + FALLBACK_NODE_NAME; + return `Meshtastic_${name}_${dateStamp(date)}_nodeConfig.cfg`; +} + +/** What the import reads from the connected node. */ +export interface ImportTarget { + owner?: Protobuf.Mesh.User; + /** The node's current security config, to keep its keys. */ + security?: Protobuf.Config.Config_SecurityConfig; + /** Indexes of the channels in use now. */ + activeChannelIndexes: readonly number[]; +} + +export type ImportSkip = + | "owner" + | "isLicensed" + | "security" + | "trafficManagement" + | "ringtone" + | "cannedMessages"; + +/** The edits an import stages; nothing is sent until the user saves. */ +export interface ImportPlan { + owner?: Protobuf.Mesh.User; + radio: { [K in ProfileRadioSection]?: NonNullable }; + modules: { [K in ProfileModuleSection]?: NonNullable }; + channels: Protobuf.Channel.Channel[]; + fixedPosition?: Protobuf.Mesh.Position; + /** Parts of the file that are deliberately not applied. */ + skipped: ImportSkip[]; +} + +export class ProfileImportError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ProfileImportError"; + } +} + +/** + * Turns an (already filtered) profile into editor changes, following + * Android's install: the channel URL replaces the channel table and its LoRa + * settings win over the config's; the licensed flag and traffic management + * are never installed. The node keeps its own key pair unless the profile + * carries a private key. + */ +export function planProfileImport( + profile: DeviceProfile, + target: ImportTarget, +): Result { + const plan: ImportPlan = { + radio: {}, + modules: {}, + channels: [], + skipped: [], + }; + + let channelSet: Protobuf.AppOnly.ChannelSet | undefined; + if (profile.channelUrl) { + try { + channelSet = channelSetFromUrl(profile.channelUrl); + } catch (cause) { + return Result.err( + new ProfileImportError("Invalid channel URL in profile", { cause }), + ); + } + if ( + channelSet.settings.length === 0 || + channelSet.settings.length > MAX_CHANNELS + ) { + return Result.err( + new ProfileImportError("Invalid channel set in profile"), + ); + } + } + + if ( + profile.longName !== undefined || + profile.shortName !== undefined || + profile.isUnmessagable !== undefined + ) { + if (target.owner) { + plan.owner = create(Protobuf.Mesh.UserSchema, { + ...target.owner, + longName: profile.longName ?? target.owner.longName, + shortName: profile.shortName ?? target.owner.shortName, + isUnmessagable: profile.isUnmessagable ?? target.owner.isUnmessagable, + }); + } else { + plan.skipped.push("owner"); + } + } + if (profile.isLicensed !== undefined) plan.skipped.push("isLicensed"); + + const config = profile.config; + if (config) { + for (const key of PROFILE_RADIO_SECTIONS) { + if (key === "security") continue; + const value = config[key]; + if (value) (plan.radio as Record)[key] = value; + } + const security = config.security; + if (security) { + if (security.privateKey.length > 0) { + plan.radio.security = security; + } else if (target.security) { + plan.radio.security = create( + Protobuf.Config.Config_SecurityConfigSchema, + { + ...security, + privateKey: target.security.privateKey, + publicKey: target.security.publicKey, + }, + ); + } else { + // Staging it now would send empty keys, and the node would make new ones. + plan.skipped.push("security"); + } + } + } + + const moduleConfig = profile.moduleConfig; + if (moduleConfig) { + for (const key of PROFILE_MODULE_SECTIONS) { + const value = moduleConfig[key]; + if (!value) continue; + if (MODULE_SECTIONS_NOT_INSTALLED.includes(key)) { + plan.skipped.push("trafficManagement"); + continue; + } + (plan.modules as Record)[key] = value; + } + } + + if (channelSet) { + channelSet.settings.forEach((settings, index) => { + plan.channels.push( + create(Protobuf.Channel.ChannelSchema, { + index, + role: + index === 0 + ? Protobuf.Channel.Channel_Role.PRIMARY + : Protobuf.Channel.Channel_Role.SECONDARY, + settings, + }), + ); + }); + for (const index of target.activeChannelIndexes) { + if (index >= channelSet.settings.length && index < MAX_CHANNELS) { + plan.channels.push( + create(Protobuf.Channel.ChannelSchema, { + index, + role: Protobuf.Channel.Channel_Role.DISABLED, + settings: create(Protobuf.Channel.ChannelSettingsSchema, {}), + }), + ); + } + } + if (channelSet.loraConfig) { + plan.radio.lora = channelSet.loraConfig; + } + } + + if (profile.fixedPosition) plan.fixedPosition = profile.fixedPosition; + if (profile.ringtone !== undefined) plan.skipped.push("ringtone"); + if (profile.cannedMessages !== undefined) plan.skipped.push("cannedMessages"); + + return Result.ok(plan); +} diff --git a/apps/web/src/pages/Settings/About.tsx b/apps/web/src/pages/Settings/About.tsx index 3316982f9..b22f61b7c 100644 --- a/apps/web/src/pages/Settings/About.tsx +++ b/apps/web/src/pages/Settings/About.tsx @@ -1,3 +1,97 @@ -import type { ConfigProps } from "./SettingsTabs.tsx"; +import { + SettingsGroup, + SettingsRow, + SettingsSections, +} from "@components/PageComponents/SettingsPages/SettingsSection.tsx"; +import { Subtle } from "@components/UI/Typography/Subtle.tsx"; +import { ExternalLinkIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx"; -export const About = (_props: ConfigProps) => null; +const GITHUB_URL = "https://github.com/meshtastic/web"; +const WEBSITE_URL = "https://meshtastic.org"; +const DOCS_URL = "https://meshtastic.org/docs/"; +const LICENSE_URL = "https://www.gnu.org/licenses/gpl-3.0.html"; +const HARDWARE_URL = "https://meshtastic.org/docs/hardware/devices/"; + +// Set at build time by vite.config.ts, as in the footer. +const VERSION = String(import.meta.env.VITE_VERSION ?? ""); +const COMMIT = String(import.meta.env.VITE_COMMIT_HASH ?? "").slice(0, 7); + +const ExternalLink = ({ + href, + children, +}: { + href: string; + children: ReactNode; +}) => ( + + {children} + + +); + +/** Android's About screen. */ +export const About = (_props: ConfigProps) => { + const { t } = useTranslation("ui"); + const version = [VERSION, COMMIT && `#${COMMIT}`].filter(Boolean).join(" "); + + return ( + + + + + + + + github.com/meshtastic/web + + + + {version || "-"} + + + + + + meshtastic.org + + + meshtastic.org/docs + + + + {t("settingsPages.about.licenseDescription")} + + + + + + + {t("settingsPages.about.devices")} + + + +
    + {t("settingsPages.about.copyright")} + {t("settingsPages.about.licenseNotice")} +
    +
    +
    + ); +}; diff --git a/apps/web/src/pages/Settings/Administration.tsx b/apps/web/src/pages/Settings/Administration.tsx index ac7bf4bb8..e8884f95a 100644 --- a/apps/web/src/pages/Settings/Administration.tsx +++ b/apps/web/src/pages/Settings/Administration.tsx @@ -1,3 +1,130 @@ -import type { ConfigProps } from "./SettingsTabs.tsx"; +import { DialogWrapper } from "@components/Dialog/DialogWrapper.tsx"; +import { + SettingsGroup, + SettingsRow, + SettingsSections, +} from "@components/PageComponents/SettingsPages/SettingsSection.tsx"; +import { useDeviceConnected } from "@components/PageComponents/SettingsPages/useDeviceConnected.ts"; +import { Button } from "@components/UI/Button.tsx"; +import { Subtle } from "@components/UI/Typography/Subtle.tsx"; +import { useToast } from "@core/hooks/useToast.ts"; +import { useDevice } from "@core/stores"; +import type { DialogVariant } from "@core/stores/deviceStore/types.ts"; +import { + FactoryIcon, + HardDriveUploadIcon, + type LucideIcon, + PowerIcon, + RefreshCwIcon, + TrashIcon, +} from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx"; -export const Administration = (_props: ConfigProps) => null; +type AdminAction = { + key: + | "reboot" + | "dfu" + | "shutdown" + | "factoryResetDevice" + | "factoryResetConfig" + | "resetNodeDb"; + icon: LucideIcon; + destructive?: boolean; + /** Opens one of the existing device dialogs. */ + dialog?: DialogVariant; +}; + +// Order follows Android. Set time is left out: the SDK sends it only during +// the connect handshake and has no public call for it. +const ACTIONS: AdminAction[] = [ + { key: "reboot", icon: RefreshCwIcon, dialog: "reboot" }, + // The web can't tell an nRF52 board apart (Android uses its hardware + // list), so DFU stays visible like in the command palette. + { key: "dfu", icon: HardDriveUploadIcon, destructive: true }, + { key: "shutdown", icon: PowerIcon, dialog: "shutdown" }, + { + key: "factoryResetDevice", + icon: FactoryIcon, + destructive: true, + dialog: "factoryResetDevice", + }, + { + key: "factoryResetConfig", + icon: FactoryIcon, + destructive: true, + dialog: "factoryResetConfig", + }, + { + key: "resetNodeDb", + icon: TrashIcon, + destructive: true, + dialog: "resetNodeDb", + }, +]; + +/** Android's Administration screen: power and reset actions for the node. */ +export const Administration = (_props: ConfigProps) => { + const { t } = useTranslation("ui"); + const { toast } = useToast(); + const { setDialogOpen, connection } = useDevice(); + const connected = useDeviceConnected(); + const [dfuConfirmOpen, setDfuConfirmOpen] = useState(false); + + const enterDfu = async () => { + try { + await connection?.enterDfuMode(); + toast({ title: t("settingsPages.administration.dfu.sent") }); + } catch { + toast({ title: t("settingsPages.administration.dfu.failed") }); + } + }; + + return ( + + + + {!connected && ( + + {t("settingsPages.notConnected")} + + )} + {ACTIONS.map(({ key, icon: Icon, destructive, dialog }) => ( + + + + ))} + + + + + + ); +}; diff --git a/apps/web/src/pages/Settings/AppSettings.tsx b/apps/web/src/pages/Settings/AppSettings.tsx index fbfca2b3a..6a2fa5fcb 100644 --- a/apps/web/src/pages/Settings/AppSettings.tsx +++ b/apps/web/src/pages/Settings/AppSettings.tsx @@ -1,3 +1,41 @@ -import type { ConfigProps } from "./SettingsTabs.tsx"; +import LanguageSwitcher from "@components/LanguageSwitcher.tsx"; +import { + SettingsGroup, + SettingsRow, + SettingsSections, +} from "@components/PageComponents/SettingsPages/SettingsSection.tsx"; +import ThemeSwitcher from "@components/ThemeSwitcher.tsx"; +import { useTranslation } from "react-i18next"; +import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx"; -export const AppSettings = (_props: ConfigProps) => null; +/** + * App preferences, stored in this browser. Android's Units setting is left + * out: the web has no shared distance/altitude formatter to apply it to. + */ +export const AppSettings = (_props: ConfigProps) => { + const { t } = useTranslation("ui"); + + return ( + + + + + + + + + + + + + ); +}; diff --git a/apps/web/src/pages/Settings/BackupRestore.tsx b/apps/web/src/pages/Settings/BackupRestore.tsx index f6325ea63..11c50e413 100644 --- a/apps/web/src/pages/Settings/BackupRestore.tsx +++ b/apps/web/src/pages/Settings/BackupRestore.tsx @@ -1,3 +1,283 @@ -import type { ConfigProps } from "./SettingsTabs.tsx"; +import { create } from "@bufbuild/protobuf"; +import { ProfileFieldsDialog } from "@components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx"; +import { + SettingsGroup, + SettingsRow, + SettingsSections, +} from "@components/PageComponents/SettingsPages/SettingsSection.tsx"; +import { useDeviceConnected } from "@components/PageComponents/SettingsPages/useDeviceConnected.ts"; +import { Button } from "@components/UI/Button.tsx"; +import { Subtle } from "@components/UI/Typography/Subtle.tsx"; +import { useToast } from "@core/hooks/useToast.ts"; +import { + buildDeviceProfile, + decodeDeviceProfile, + defaultSelection, + deviceProfileFileName, + encodeDeviceProfile, + type ImportPlan, + planProfileImport, + type ProfileSelection, + profileContents, + selectProfileFields, +} from "@core/utils/profileExport.ts"; +import { type ConfigEditor, Protobuf } from "@meshtastic/sdk"; +import { + useActiveClient, + useConfigEditor, + useSignal, +} from "@meshtastic/sdk-react"; +import { Result } from "better-result"; +import { DownloadIcon, UploadIcon } from "lucide-react"; +import { type ChangeEvent, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx"; -export const BackupRestore = (_props: ConfigProps) => null; +type DeviceProfile = Protobuf.ClientOnly.DeviceProfile; + +const EMPTY_BOOLEAN_SIGNAL = { + value: false, + peek: () => false, + subscribe: () => () => {}, +} as const; + +type PendingDialog = + | { mode: "export"; profile: DeviceProfile } + | { mode: "import"; profile: DeviceProfile }; + +const downloadBytes = (bytes: Uint8Array, fileName: string) => { + const blob = new Blob([bytes as Uint8Array], { + type: "application/octet-stream", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + // Revoke after the click has been handled. + setTimeout(() => URL.revokeObjectURL(url), 0); +}; + +const applyPlan = (editor: ConfigEditor, plan: ImportPlan) => { + if (plan.owner) editor.setOwner(plan.owner); + for (const [key, value] of Object.entries(plan.radio)) { + editor.setRadioSection( + key as keyof ImportPlan["radio"], + value as NonNullable, + ); + } + for (const [key, value] of Object.entries(plan.modules)) { + editor.setModuleSection( + key as keyof ImportPlan["modules"], + value as NonNullable, + ); + } + for (const channel of plan.channels) editor.setChannel(channel); + if (plan.fixedPosition) { + // Staged by key, so Reset drops it and a second import replaces it. + editor.stageAdminMessage( + "profileFixedPosition", + create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { case: "setFixedPosition", value: plan.fixedPosition }, + }), + ); + } +}; + +/** + * Android's Import / Export configuration: a `.cfg` device profile, with a + * dialog to choose what goes in or out. Imports are staged in the editor and + * written only when the user presses Save. + */ +export const BackupRestore = (_props: ConfigProps) => { + const { t } = useTranslation("ui"); + const { toast } = useToast(); + const client = useActiveClient(); + const editor = useConfigEditor(); + const isDirty = useSignal(editor?.isDirty ?? EMPTY_BOOLEAN_SIGNAL); + const connected = useDeviceConnected(); + const fileInputRef = useRef(null); + const [pending, setPending] = useState(); + + const myNode = () => { + const myNodeNum = client?.device.myNodeNum.peek(); + return myNodeNum === undefined ? undefined : client?.nodes.byNum(myNodeNum); + }; + + const startExport = () => { + if (!editor) return; + const profile = buildDeviceProfile({ + owner: editor.owner.peek() ?? myNode()?.user, + radio: editor.radio.peek(), + modules: editor.modules.peek(), + channels: editor.channels.peek().values(), + ownPosition: myNode()?.position, + }); + setPending({ mode: "export", profile }); + }; + + const finishExport = ( + profile: DeviceProfile, + selection: ProfileSelection, + ) => { + try { + const selected = selectProfileFields(profile, selection); + downloadBytes( + encodeDeviceProfile(selected), + deviceProfileFileName( + selected.longName, + selected.shortName, + new Date(), + ), + ); + } catch { + toast({ title: t("settingsPages.backup.export.failed") }); + } + }; + + const onFileChosen = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + const decoded = decodeDeviceProfile( + new Uint8Array(await file.arrayBuffer()), + ); + if (Result.isError(decoded)) { + toast({ + title: t("settingsPages.backup.import.failed"), + description: t("settingsPages.backup.import.invalidFile"), + }); + return; + } + setPending({ mode: "import", profile: decoded.value }); + }; + + const finishImport = ( + profile: DeviceProfile, + selection: ProfileSelection, + ) => { + if (!editor) { + toast({ title: t("settingsPages.backup.import.notConnected") }); + return; + } + // The User page loads the owner into the editor only when opened. + const nodeUser = myNode()?.user; + if (!editor.owner.peek() && nodeUser) editor.setBaselineOwner(nodeUser); + + const plan = planProfileImport(selectProfileFields(profile, selection), { + owner: editor.owner.peek(), + security: editor.radio.peek().security, + activeChannelIndexes: [...editor.channels.peek().values()] + .filter((ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED) + .map((ch) => ch.index), + }); + if (Result.isError(plan)) { + toast({ + title: t("settingsPages.backup.import.failed"), + description: t("settingsPages.backup.import.invalidChannels"), + }); + return; + } + + applyPlan(editor, plan.value); + const skipped = [...new Set(plan.value.skipped)].map((key) => + t(`settingsPages.backup.skipped.${key}`), + ); + toast({ + title: t("settingsPages.backup.import.staged"), + description: [ + t("settingsPages.backup.import.stagedDescription"), + skipped.length > 0 + ? `${t("settingsPages.backup.skipped.title")} ${skipped.join(", ")}` + : "", + ] + .filter(Boolean) + .join(" "), + }); + }; + + const available = useMemo( + () => (pending ? profileContents(pending.profile) : undefined), + [pending], + ); + const initial = useMemo( + () => (pending ? defaultSelection(pending.profile) : undefined), + [pending], + ); + + return ( + + + + {!connected && ( + + {t("settingsPages.notConnected")} + + )} + + + void onFileChosen(event)} + /> + + + {t("settingsPages.backup.export.description")} + {isDirty && ( + + {t("settingsPages.backup.export.pendingChanges")} + + )} + + } + > + + + + + + {pending && available && initial && ( + { + if (!open) setPending(undefined); + }} + mode={pending.mode} + available={available} + initial={initial} + onConfirm={(selection) => + pending.mode === "export" + ? finishExport(pending.profile, selection) + : finishImport(pending.profile, selection) + } + /> + )} + + ); +}; diff --git a/apps/web/src/pages/Settings/CleanNodeDb.tsx b/apps/web/src/pages/Settings/CleanNodeDb.tsx index 1baad81a3..3dc98f82f 100644 --- a/apps/web/src/pages/Settings/CleanNodeDb.tsx +++ b/apps/web/src/pages/Settings/CleanNodeDb.tsx @@ -1,3 +1,186 @@ -import type { ConfigProps } from "./SettingsTabs.tsx"; +import { DialogWrapper } from "@components/Dialog/DialogWrapper.tsx"; +import { + SettingsGroup, + SettingsRow, + SettingsSections, +} from "@components/PageComponents/SettingsPages/SettingsSection.tsx"; +import { useDeviceConnected } from "@components/PageComponents/SettingsPages/useDeviceConnected.ts"; +import { Button } from "@components/UI/Button.tsx"; +import { Slider } from "@components/UI/Slider.tsx"; +import { Switch } from "@components/UI/Switch.tsx"; +import { Subtle } from "@components/UI/Typography/Subtle.tsx"; +import { useToast } from "@core/hooks/useToast.ts"; +import { + MAX_DAYS, + MIN_DAYS_KNOWN, + minDays, + nodesToClean, +} from "@core/utils/cleanNodes.ts"; +import type { Node } from "@meshtastic/sdk"; +import { useActiveClient, useSignal } from "@meshtastic/sdk-react"; +import { EraserIcon } from "lucide-react"; +import { useId, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx"; -export const CleanNodeDb = (_props: ConfigProps) => null; +const EMPTY_NODES: ReadonlyArray = []; +const EMPTY_NODES_SIGNAL = { + value: EMPTY_NODES, + peek: () => EMPTY_NODES, + subscribe: () => () => {}, +} as const; +const EMPTY_NUM_SIGNAL = { + value: undefined as number | undefined, + peek: () => undefined as number | undefined, + subscribe: () => () => {}, +} as const; + +const nodeLabel = (node: Node) => + node.user?.longName || + node.user?.shortName || + `!${(node.num >>> 0).toString(16).padStart(8, "0")}`; + +/** Android's Clean node database screen. */ +export const CleanNodeDb = (_props: ConfigProps) => { + const { t } = useTranslation("ui"); + const { toast } = useToast(); + const client = useActiveClient(); + const nodes = useSignal(client?.nodes.list ?? EMPTY_NODES_SIGNAL); + const myNodeNum = useSignal(client?.device.myNodeNum ?? EMPTY_NUM_SIGNAL); + const connected = useDeviceConnected(); + const switchId = useId(); + const sliderId = useId(); + + const [olderThanDays, setOlderThanDays] = useState(30); + const [onlyUnknown, setOnlyUnknown] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [cleaning, setCleaning] = useState(false); + // The cut-off is relative to when the page was opened. + const [nowSeconds] = useState(() => Math.floor(Date.now() / 1000)); + + const queued = useMemo( + () => + nodesToClean(nodes, { + olderThanDays, + onlyUnknown, + nowSeconds, + myNodeNum, + }), + [nodes, olderThanDays, onlyUnknown, myNodeNum, nowSeconds], + ); + + const onOnlyUnknownChange = (checked: boolean) => { + setOnlyUnknown(checked); + if (!checked) setOlderThanDays((days) => Math.max(days, MIN_DAYS_KNOWN)); + }; + + const cleanNow = async () => { + if (!client) return; + setCleaning(true); + let removed = 0; + let failed = 0; + // One at a time: each removal is an admin message to the node. + for (const node of queued) { + const result = await client.nodes.remove(node.num); + if (result.isOk()) removed++; + else failed++; + } + setCleaning(false); + toast({ + title: t("settingsPages.cleanNodes.done", { count: removed }), + description: + failed > 0 + ? t("settingsPages.cleanNodes.failed", { count: failed }) + : undefined, + }); + }; + + return ( + + + + + { + if (days !== undefined) setOlderThanDays(days); + }} + /> + + {olderThanDays} + + + + + + + + + {queued.length === 0 ? ( + {t("settingsPages.cleanNodes.none")} + ) : ( +
      + {queued.map((node) => ( +
    • + {nodeLabel(node)} +
    • + ))} +
    + )} + {!connected && ( + + {t("settingsPages.notConnected")} + + )} + +
    +
    + + void cleanNow()} + /> +
    + ); +}; diff --git a/apps/web/src/pages/Settings/DebugPanel.tsx b/apps/web/src/pages/Settings/DebugPanel.tsx index 3e4103d94..793c7a27e 100644 --- a/apps/web/src/pages/Settings/DebugPanel.tsx +++ b/apps/web/src/pages/Settings/DebugPanel.tsx @@ -1,3 +1,154 @@ -import type { ConfigProps } from "./SettingsTabs.tsx"; +import { + SettingsGroup, + SettingsSections, +} from "@components/PageComponents/SettingsPages/SettingsSection.tsx"; +import { Button } from "@components/UI/Button.tsx"; +import { Input } from "@components/UI/Input.tsx"; +import { Subtle } from "@components/UI/Typography/Subtle.tsx"; +import { cn } from "@core/utils/cn.ts"; +import { + type DeviceLogEntry, + filterLogEntries, + formatLogEntry, +} from "@core/utils/logRecords.ts"; +import { Protobuf } from "@meshtastic/sdk"; +import { useActiveClient } from "@meshtastic/sdk-react"; +import { useNavigate } from "@tanstack/react-router"; +import { DownloadIcon, ShieldIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx"; -export const DebugPanel = (_props: ConfigProps) => null; +/** Records kept in memory; older ones are dropped. */ +const MAX_ENTRIES = 2000; + +const levelClass = (level: Protobuf.Mesh.LogRecord_Level) => { + const Level = Protobuf.Mesh.LogRecord_Level; + if (level >= Level.ERROR) return "text-red-600 dark:text-red-400"; + if (level === Level.WARNING) return "text-amber-600 dark:text-amber-400"; + if (level === Level.DEBUG || level === Level.TRACE) + return "text-slate-500 dark:text-slate-400"; + return ""; +}; + +/** + * Android's Debug panel, for the device log records the node streams (the + * SDK's onLogRecord event). The SDK doesn't keep them, so the page collects + * them from when it's opened. + */ +export const DebugPanel = (_props: ConfigProps) => { + const { t } = useTranslation("ui"); + const navigate = useNavigate(); + const client = useActiveClient(); + const [entries, setEntries] = useState([]); + const [query, setQuery] = useState(""); + const nextId = useRef(0); + + useEffect(() => { + if (!client) return; + return client.events.onLogRecord.subscribe((record) => { + const entry: DeviceLogEntry = { + id: nextId.current++, + receivedAt: Date.now(), + record, + }; + setEntries((current) => [...current, entry].slice(-MAX_ENTRIES)); + }); + }, [client]); + + const shown = useMemo( + () => filterLogEntries(entries, query), + [entries, query], + ); + + const exportLogs = () => { + const text = `${shown.map(formatLogEntry).join("\n")}\n`; + const url = URL.createObjectURL(new Blob([text], { type: "text/plain" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `Meshtastic_device_log_${new Date().toISOString().replace(/[:.]/g, "-")}.txt`; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }; + + return ( + + + +
    + +
    +
    + setQuery(event.target.value)} + /> + + +
    + + {t("settingsPages.debug.count", { + shown: shown.length, + total: entries.length, + })} + {entries.length >= MAX_ENTRIES && + ` ${t("settingsPages.debug.capped", { max: MAX_ENTRIES })}`} + +
    + {shown.length === 0 ? ( + + {entries.length === 0 + ? t("settingsPages.debug.empty") + : t("settingsPages.debug.noMatch")} + + ) : ( + shown.map((entry) => ( +
    + {formatLogEntry(entry)} +
    + )) + )} +
    +
    +
    +
    + ); +}; From b2d12e0604ee9932a61b4e23133bbd5e2c39733a Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:42:42 -0400 Subject: [PATCH 07/15] fix(web): stage the fixed position once and show staged edits as unsaved Position queued a new setFixedPosition admin message on every form change, and Reset didn't clear the queue, so a reset edit was still sent on the next Save. Stage it under one key instead (replaced on each change, dropped by Reset) and only when the coordinates differ from the node's position. ConfigEditor.reset() now also clears queued messages. Staged admin messages (canned messages, ringtone, fixed position) now count toward their tab's dot and the sidebar's unsaved-change count. --- .../PageComponents/Settings/Position.tsx | 38 ++++++++---- apps/web/src/pages/Settings/DeviceConfig.tsx | 13 +++- apps/web/src/pages/Settings/ModuleConfig.tsx | 16 ++++- .../src/pages/Settings/dirtySections.test.ts | 35 +++++++++++ apps/web/src/pages/Settings/dirtySections.ts | 61 +++++++++++++++++++ apps/web/src/pages/Settings/index.tsx | 24 +++----- .../src/features/config/ConfigEditor.test.ts | 19 ++++++ .../features/config/domain/ConfigEditor.ts | 1 + 8 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/pages/Settings/dirtySections.test.ts create mode 100644 apps/web/src/pages/Settings/dirtySections.ts diff --git a/apps/web/src/components/PageComponents/Settings/Position.tsx b/apps/web/src/components/PageComponents/Settings/Position.tsx index 26541bf12..8d1573521 100644 --- a/apps/web/src/components/PageComponents/Settings/Position.tsx +++ b/apps/web/src/components/PageComponents/Settings/Position.tsx @@ -94,6 +94,8 @@ function UseBrowserLocationButton({ isImperial }: { isImperial: boolean }) { ); } +const FIXED_POSITION_KEY = "fixedPosition"; + const EMPTY_RADIO_SIGNAL = { value: {} as { position?: Protobuf.Config.Config_PositionConfig }, peek: () => ({}) as { position?: Protobuf.Config.Config_PositionConfig }, @@ -157,28 +159,38 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { ); } + // One staged fixed position, replaced on each change and dropped by + // Reset. Unchanged coordinates stage nothing so Save stays idle. + let fixedPositionMessage: Protobuf.Admin.AdminMessage | undefined; if ( data.fixedPosition && data.latitude !== undefined && data.longitude !== undefined ) { + const latitudeI = Math.round(data.latitude * 1e7); + const longitudeI = Math.round(data.longitude * 1e7); const altitudeMeters = isImperial ? Math.round(feetToMeters(data.altitude ?? 0)) : Math.round(data.altitude ?? 0); - const message = create(Protobuf.Admin.AdminMessageSchema, { - payloadVariant: { - case: "setFixedPosition", - value: create(Protobuf.Mesh.PositionSchema, { - latitudeI: Math.round(data.latitude * 1e7), - longitudeI: Math.round(data.longitude * 1e7), - altitude: altitudeMeters, - time: Math.floor(Date.now() / 1000), - }), - }, - }); - - editor?.queueAdminMessage(message); + const unchanged = + latitudeI === (currentPosition?.latitudeI ?? 0) && + longitudeI === (currentPosition?.longitudeI ?? 0) && + altitudeMeters === (currentPosition?.altitude ?? 0); + if (!unchanged) { + fixedPositionMessage = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "setFixedPosition", + value: create(Protobuf.Mesh.PositionSchema, { + latitudeI, + longitudeI, + altitude: altitudeMeters, + time: Math.floor(Date.now() / 1000), + }), + }, + }); + } } + editor?.stageAdminMessage(FIXED_POSITION_KEY, fixedPositionMessage); }; const onPositonFlagChange = useCallback( diff --git a/apps/web/src/pages/Settings/DeviceConfig.tsx b/apps/web/src/pages/Settings/DeviceConfig.tsx index 83d103b7f..a133b28b7 100644 --- a/apps/web/src/pages/Settings/DeviceConfig.tsx +++ b/apps/web/src/pages/Settings/DeviceConfig.tsx @@ -9,6 +9,7 @@ import type { ValidConfigType } from "@core/stores"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { dirtyDeviceTabs } from "./dirtySections.ts"; import { type ConfigProps, type SettingsTab, @@ -21,6 +22,12 @@ const EMPTY_DIRTY_RADIO_SIGNAL = { subscribe: () => () => {}, } as const; +const EMPTY_STAGED_SIGNAL = { + value: new Map() as ReadonlyMap, + peek: () => new Map() as ReadonlyMap, + subscribe: () => () => {}, +} as const; + type DeviceTab = SettingsTab & { key: ValidConfigType; shown: boolean }; /** Device configuration, in the order of Android's deviceConfigRoutes. */ @@ -29,10 +36,12 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => { const dirtyRadio = useSignal( editor?.dirtyRadioSections ?? EMPTY_DIRTY_RADIO_SIGNAL, ); + const staged = useSignal(editor?.stagedAdminMessages ?? EMPTY_STAGED_SIGNAL); const { metadata } = useDeviceCapabilities(); const { t } = useTranslation("config"); const tabs: SettingsTab[] = useMemo(() => { + const dirty = dirtyDeviceTabs(dirtyRadio, staged.keys()); // Until metadata arrives, show every tab, as Android does. const hasNetwork = !metadata || metadata.hasWifi || metadata.hasEthernet; const hasBluetooth = !metadata || metadata.hasBluetooth; @@ -75,9 +84,9 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => { key: tab.key, label: tab.label, element: tab.element, - isDirty: dirtyRadio.includes(tab.key), + isDirty: dirty.has(tab.key), })); - }, [t, metadata, dirtyRadio]); + }, [t, metadata, dirtyRadio, staged]); return ; }; diff --git a/apps/web/src/pages/Settings/ModuleConfig.tsx b/apps/web/src/pages/Settings/ModuleConfig.tsx index fc9cedd6b..d910990ee 100644 --- a/apps/web/src/pages/Settings/ModuleConfig.tsx +++ b/apps/web/src/pages/Settings/ModuleConfig.tsx @@ -20,6 +20,7 @@ import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { type ComponentType, useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { dirtyModuleTabs } from "./dirtySections.ts"; import { type ConfigProps, type SettingsTab, @@ -32,6 +33,12 @@ const EMPTY_DIRTY_MODULE_SIGNAL = { subscribe: () => () => {}, } as const; +const EMPTY_STAGED_SIGNAL = { + value: new Map() as ReadonlyMap, + peek: () => new Map() as ReadonlyMap, + subscribe: () => () => {}, +} as const; + const EMPTY_RADIO_SIGNAL = { value: {} as { device?: Protobuf.Config.Config_DeviceConfig }, peek: () => ({}) as { device?: Protobuf.Config.Config_DeviceConfig }, @@ -104,8 +111,13 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => { const dirtyModule = useSignal( editor?.dirtyModuleSections ?? EMPTY_DIRTY_MODULE_SIGNAL, ); + const staged = useSignal(editor?.stagedAdminMessages ?? EMPTY_STAGED_SIGNAL); const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL); const { getEffectiveConfig } = useDevice(); + const dirtyTabs = useMemo( + () => dirtyModuleTabs(dirtyModule, staged.keys()), + [dirtyModule, staged], + ); const { t } = useTranslation("moduleConfig"); const { excludedModules, supportsFirmware28 } = useDeviceCapabilities(); @@ -131,9 +143,9 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => { key: tab.key, label: t(tab.labelKey), element: tab.element, - isDirty: dirtyModule.includes(tab.key), + isDirty: dirtyTabs.has(tab.key), })), - [t, excludedModules, dirtyModule, showTak], + [t, excludedModules, dirtyTabs, showTak], ); return ; diff --git a/apps/web/src/pages/Settings/dirtySections.test.ts b/apps/web/src/pages/Settings/dirtySections.test.ts new file mode 100644 index 000000000..e561a345d --- /dev/null +++ b/apps/web/src/pages/Settings/dirtySections.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { dirtyDeviceTabs, dirtyModuleTabs } from "./dirtySections.ts"; + +describe("dirtyModuleTabs", () => { + it("counts staged canned messages and ringtone under their modules", () => { + const dirty = dirtyModuleTabs(["mqtt"], ["cannedMessages", "ringtone"]); + expect([...dirty].sort()).toEqual([ + "cannedMessage", + "externalNotification", + "mqtt", + ]); + }); + + it("leaves the status message to the User page", () => { + expect(dirtyModuleTabs(["statusmessage"], []).size).toBe(0); + }); + + it("counts a module once when both its section and a message are staged", () => { + expect(dirtyModuleTabs(["cannedMessage"], ["cannedMessages"]).size).toBe(1); + }); +}); + +describe("dirtyDeviceTabs", () => { + it("keeps only device configuration sections", () => { + const dirty = dirtyDeviceTabs(["lora", "power", "security"], []); + expect([...dirty]).toEqual(["power"]); + }); + + it("counts a staged fixed position under Position", () => { + expect([...dirtyDeviceTabs([], ["fixedPosition"])]).toEqual(["position"]); + expect([...dirtyDeviceTabs([], ["profileFixedPosition"])]).toEqual([ + "position", + ]); + }); +}); diff --git a/apps/web/src/pages/Settings/dirtySections.ts b/apps/web/src/pages/Settings/dirtySections.ts new file mode 100644 index 000000000..cc199df5e --- /dev/null +++ b/apps/web/src/pages/Settings/dirtySections.ts @@ -0,0 +1,61 @@ +/** + * Admin messages staged in the config editor, by the settings tab they + * belong to. They are sent with Save like a config section, so they count + * as that tab's unsaved changes. + */ +const STAGED_KEY_MODULE: Record = { + cannedMessages: "cannedMessage", + ringtone: "externalNotification", +}; +const STAGED_KEY_DEVICE: Record = { + fixedPosition: "position", + profileFixedPosition: "position", +}; + +export const DEVICE_SECTIONS = [ + "device", + "position", + "power", + "network", + "display", + "bluetooth", +]; + +function withStaged( + dirty: Set, + stagedKeys: Iterable, + mapping: Record, +): Set { + for (const key of stagedKeys) { + const tab = mapping[key]; + if (tab) dirty.add(tab); + } + return dirty; +} + +/** + * Module tabs with unsaved changes. The status message is excluded because + * it is edited on the User page. + */ +export function dirtyModuleTabs( + dirtyModuleSections: readonly string[], + stagedKeys: Iterable, +): Set { + return withStaged( + new Set(dirtyModuleSections.filter((key) => key !== "statusmessage")), + stagedKeys, + STAGED_KEY_MODULE, + ); +} + +/** Device configuration tabs with unsaved changes. */ +export function dirtyDeviceTabs( + dirtyRadioSections: readonly string[], + stagedKeys: Iterable, +): Set { + return withStaged( + new Set(dirtyRadioSections.filter((key) => DEVICE_SECTIONS.includes(key))), + stagedKeys, + STAGED_KEY_DEVICE, + ); +} diff --git a/apps/web/src/pages/Settings/index.tsx b/apps/web/src/pages/Settings/index.tsx index 03c183ec1..a66be6552 100644 --- a/apps/web/src/pages/Settings/index.tsx +++ b/apps/web/src/pages/Settings/index.tsx @@ -48,6 +48,7 @@ import { } from "react"; import type { FieldValues, UseFormReturn } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { dirtyDeviceTabs, dirtyModuleTabs } from "./dirtySections.ts"; import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx"; const EMPTY_DIRTY_STRING_SIGNAL = { @@ -60,21 +61,17 @@ const EMPTY_DIRTY_NUMBER_SIGNAL = { peek: () => [] as readonly number[], subscribe: () => () => {}, } as const; +const EMPTY_STAGED_SIGNAL = { + value: new Map() as ReadonlyMap, + peek: () => new Map() as ReadonlyMap, + subscribe: () => () => {}, +} as const; const EMPTY_BOOLEAN_SIGNAL = { value: false, peek: () => false, subscribe: () => () => {}, } as const; -const DEVICE_SECTIONS = [ - "device", - "position", - "power", - "network", - "display", - "bluetooth", -]; - const FIRMWARE_FLASHER_URL = "https://flasher.meshtastic.org"; const DOCS_URL = "https://meshtastic.org/docs/"; @@ -123,6 +120,7 @@ const ConfigPage = () => { editor?.dirtyChannels ?? EMPTY_DIRTY_NUMBER_SIGNAL, ); const isOwnerDirty = useSignal(editor?.isOwnerDirty ?? EMPTY_BOOLEAN_SIGNAL); + const staged = useSignal(editor?.stagedAdminMessages ?? EMPTY_STAGED_SIGNAL); const [isSaving, setIsSaving] = useState(false); const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true }); @@ -181,9 +179,7 @@ const ConfigPage = () => { icon: RouterIcon, component: DeviceConfig, edits: true, - changeCount: dirtyRadio.filter((key) => - DEVICE_SECTIONS.includes(key), - ).length, + changeCount: dirtyDeviceTabs(dirtyRadio, staged.keys()).size, }, { key: "module", @@ -191,7 +187,7 @@ const ConfigPage = () => { icon: LayersIcon, component: ModuleConfig, edits: true, - changeCount: dirtyModule.length - statusDirty, + changeCount: dirtyModuleTabs(dirtyModule, staged.keys()).size, }, { key: "administration", @@ -268,7 +264,7 @@ const ConfigPage = () => { ], }, ]; - }, [t, dirtyRadio, dirtyModule, dirtyChannels, isOwnerDirty]); + }, [t, dirtyRadio, dirtyModule, dirtyChannels, isOwnerDirty, staged]); const sections = useMemo( () => groups.flatMap((group) => group.sections), diff --git a/packages/sdk/src/features/config/ConfigEditor.test.ts b/packages/sdk/src/features/config/ConfigEditor.test.ts index 18bddabd4..09d5a4901 100644 --- a/packages/sdk/src/features/config/ConfigEditor.test.ts +++ b/packages/sdk/src/features/config/ConfigEditor.test.ts @@ -91,6 +91,25 @@ describe("ConfigEditor", () => { expect(editor.radio.value.lora?.region).toBe(4); }); + it("reset() drops queued admin messages", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const editor = client.config.editor; + + editor.queueAdminMessage( + create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "setFixedPosition", + value: create(Protobuf.Mesh.PositionSchema, { latitudeI: 1 }), + }, + }), + ); + expect(editor.isDirty.value).toBe(true); + + editor.reset(); + expect(editor.isDirty.value).toBe(false); + }); + it("inbound baseline updates do not stomp pending working edits", () => { const { transport } = createFakeTransport(); const client = new MeshClient({ transport }); diff --git a/packages/sdk/src/features/config/domain/ConfigEditor.ts b/packages/sdk/src/features/config/domain/ConfigEditor.ts index be6c68d9a..38a0872b5 100644 --- a/packages/sdk/src/features/config/domain/ConfigEditor.ts +++ b/packages/sdk/src/features/config/domain/ConfigEditor.ts @@ -291,6 +291,7 @@ export class ConfigEditor { this.workingChannels.value = new Map(this.baselineChannels.peek()); this.workingOwner.value = this.baselineOwner.peek(); this._stagedAdminMessages.value = new Map(); + this.queuedAdminMessages.value = []; this.recomputeDirty(); } From 33818ca34d45df23df6001be19bfd6dbcc9875f4 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:42:43 -0400 Subject: [PATCH 08/15] fix(web): keep every theme control on the same preference Each useTheme instance kept its own state, so changing the theme in App settings left the sidebar panel's switcher (and vice versa) showing the old value until reload. --- apps/web/src/core/hooks/useTheme.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/core/hooks/useTheme.ts b/apps/web/src/core/hooks/useTheme.ts index 8fb37d4f0..fddaea192 100644 --- a/apps/web/src/core/hooks/useTheme.ts +++ b/apps/web/src/core/hooks/useTheme.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from "react"; type Theme = "light" | "dark" | "system"; +const THEME_CHANGE_EVENT = "meshtastic:theme-change"; + export function useTheme() { const getSystemTheme = () => globalThis.matchMedia("(prefers-color-scheme: dark)").matches @@ -35,9 +37,18 @@ export function useTheme() { return () => media.removeEventListener("change", updateTheme); }, [preference, getStoredPreference]); + // Several components use this hook (App, the sidebar panel, App + // settings); keep every instance on the same preference. + useEffect(() => { + const sync = () => setPreference(getStoredPreference()); + globalThis.addEventListener(THEME_CHANGE_EVENT, sync); + return () => globalThis.removeEventListener(THEME_CHANGE_EVENT, sync); + }, [getStoredPreference]); + const setPreferenceValue = (newPreference: Theme) => { localStorage.setItem("theme", newPreference); setPreference(newPreference); + globalThis.dispatchEvent(new Event(THEME_CHANGE_EVENT)); }; return { theme, preference, setPreference: setPreferenceValue }; From 316b1dc06a8fcaa68f98c3a9036b633983f846d1 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:42:44 -0400 Subject: [PATCH 09/15] chore(web): remove the Status Message module form Its only field is now edited on the User page, as on Android. --- .../public/i18n/locales/en/moduleConfig.json | 9 -- .../ModuleConfig/StatusMessage.tsx | 82 ------------------- .../validation/moduleConfig/statusMessage.ts | 9 -- 3 files changed, 100 deletions(-) delete mode 100644 apps/web/src/components/PageComponents/ModuleConfig/StatusMessage.tsx delete mode 100644 apps/web/src/validation/moduleConfig/statusMessage.ts diff --git a/apps/web/public/i18n/locales/en/moduleConfig.json b/apps/web/public/i18n/locales/en/moduleConfig.json index 70ccb1fe7..24f47f0d3 100644 --- a/apps/web/public/i18n/locales/en/moduleConfig.json +++ b/apps/web/public/i18n/locales/en/moduleConfig.json @@ -13,7 +13,6 @@ "tabStoreAndForward": "Store & Forward", "tabTelemetry": "Telemetry", "tabRemoteHardware": "Remote Hardware", - "tabStatusMessage": "Status Message", "tabTak": "TAK", "tabMeshBeacon": "Mesh Beacon" }, @@ -581,14 +580,6 @@ "remove": "Delete" } }, - "statusMessage": { - "title": "Status Message Settings", - "description": "Periodically rebroadcast a short status message for this node", - "nodeStatus": { - "label": "Status Message", - "description": "The status string this node broadcasts on change and on a timer" - } - }, "tak": { "title": "TAK Settings", "description": "Settings for the ATAK (Team Awareness Kit) plugin module", diff --git a/apps/web/src/components/PageComponents/ModuleConfig/StatusMessage.tsx b/apps/web/src/components/PageComponents/ModuleConfig/StatusMessage.tsx deleted file mode 100644 index 3b747e021..000000000 --- a/apps/web/src/components/PageComponents/ModuleConfig/StatusMessage.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig"; -import { - type StatusMessageValidation, - StatusMessageValidationSchema, -} from "@app/validation/moduleConfig/statusMessage.ts"; -import { - DynamicForm, - type DynamicFormFormInit, -} from "@components/Form/DynamicForm.tsx"; -import { useDevice } from "@core/stores"; -import { Protobuf } from "@meshtastic/sdk"; -import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; -import { useTranslation } from "react-i18next"; - -interface StatusMessageModuleConfigProps { - onFormInit: DynamicFormFormInit; -} - -const EMPTY_MODULES_SIGNAL = { - value: {} as { - statusmessage?: Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfig; - }, - peek: () => - ({}) as { - statusmessage?: Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfig; - }, - subscribe: () => () => {}, -} as const; - -export const StatusMessage = ({ - onFormInit, -}: StatusMessageModuleConfigProps) => { - useWaitForConfig({ moduleConfigCase: "statusmessage" }); - - const { moduleConfig, getEffectiveModuleConfig } = useDevice(); - const editor = useConfigEditor(); - const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL); - const effective = - modules.statusmessage ?? - (getEffectiveModuleConfig("statusmessage") as - | Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfig - | undefined); - const { t } = useTranslation("moduleConfig"); - - const onSubmit = (data: StatusMessageValidation) => { - if (!editor) return; - editor.setModuleSection( - "statusmessage", - data as unknown as Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfig, - ); - }; - - return ( - - onSubmit={onSubmit} - onFormInit={onFormInit} - validationSchema={StatusMessageValidationSchema} - defaultValues={moduleConfig.statusmessage} - values={effective} - fieldGroups={[ - { - label: t("statusMessage.title"), - description: t("statusMessage.description"), - fields: [ - { - type: "text", - name: "nodeStatus", - label: t("statusMessage.nodeStatus.label"), - description: t("statusMessage.nodeStatus.description"), - properties: { - fieldLength: { - max: 200, - showCharacterCount: true, - }, - }, - }, - ], - }, - ]} - /> - ); -}; diff --git a/apps/web/src/validation/moduleConfig/statusMessage.ts b/apps/web/src/validation/moduleConfig/statusMessage.ts deleted file mode 100644 index 1d6344147..000000000 --- a/apps/web/src/validation/moduleConfig/statusMessage.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from "zod/v4"; - -export const StatusMessageValidationSchema = z.object({ - nodeStatus: z.string().max(200), -}); - -export type StatusMessageValidation = z.infer< - typeof StatusMessageValidationSchema ->; From 7ae584d04a0a2163925c7cd7adfec70739160523 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 16:46:01 -0400 Subject: [PATCH 10/15] fix(web): don't drop a staged fixed position when the Position tab remounts The form read coordinates only from the node's position, so after switching tabs any further Position edit compared the old coordinates with the node's, found no change and un-staged the new fixed position. Show the staged position in the form instead, and stage it under one shared key so a profile import and the Position page can't both send a setFixedPosition. --- .../PageComponents/Settings/Position.test.tsx | 86 +++++++++++++++++++ .../PageComponents/Settings/Position.tsx | 30 +++++-- apps/web/src/core/utils/stagedAdminKeys.ts | 6 ++ apps/web/src/pages/Settings/BackupRestore.tsx | 6 +- .../src/pages/Settings/dirtySections.test.ts | 3 - apps/web/src/pages/Settings/dirtySections.ts | 5 +- 6 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/PageComponents/Settings/Position.test.tsx create mode 100644 apps/web/src/core/utils/stagedAdminKeys.ts diff --git a/apps/web/src/components/PageComponents/Settings/Position.test.tsx b/apps/web/src/components/PageComponents/Settings/Position.test.tsx new file mode 100644 index 000000000..12ec583de --- /dev/null +++ b/apps/web/src/components/PageComponents/Settings/Position.test.tsx @@ -0,0 +1,86 @@ +import { create } from "@bufbuild/protobuf"; +import { STAGED_FIXED_POSITION } from "@core/utils/stagedAdminKeys.ts"; +import { Protobuf } from "@meshtastic/sdk"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Position } from "./Position.tsx"; + +const position = create(Protobuf.Config.Config_PositionConfigSchema, { + fixedPosition: true, + positionBroadcastSecs: 900, +}); +const stageAdminMessage = vi.fn(); +let staged = new Map(); + +vi.mock("@app/core/hooks/useWaitForConfig", () => ({ + useWaitForConfig: () => {}, +})); + +vi.mock("@core/hooks/useNodesAsProto.ts", () => ({ + // The node's own position: 10, 20. + useMyNodeAsProto: () => ({ + position: { latitudeI: 100_000_000, longitudeI: 200_000_000, altitude: 0 }, + }), +})); + +vi.mock("@core/stores", () => ({ + useDevice: () => ({ + config: { position }, + getEffectiveConfig: (section: string) => + section === "position" ? position : undefined, + }), +})); + +vi.mock("@meshtastic/sdk-react", () => ({ + useConfigEditor: () => ({ + radio: { value: {} }, + stagedAdminMessages: { value: staged }, + setRadioSection: vi.fn(), + stageAdminMessage, + }), + useSignal: (signal: { value: unknown }) => signal.value, +})); + +const fixedPositionMessage = (latitude: number, longitude: number) => + create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: "setFixedPosition", + value: create(Protobuf.Mesh.PositionSchema, { + latitudeI: latitude * 1e7, + longitudeI: longitude * 1e7, + }), + }, + }); + +describe("Position fixed position", () => { + beforeEach(() => { + vi.clearAllMocks(); + staged = new Map(); + }); + + it("shows the node's position when nothing is staged", () => { + render(); + expect(screen.getByLabelText("Latitude")).toHaveValue(10); + }); + + it("shows a staged fixed position after the tab remounts", () => { + staged = new Map([[STAGED_FIXED_POSITION, fixedPositionMessage(30, 40)]]); + render(); + expect(screen.getByLabelText("Latitude")).toHaveValue(30); + expect(screen.getByLabelText("Longitude")).toHaveValue(40); + }); + + it("keeps a staged fixed position when another field changes", async () => { + staged = new Map([[STAGED_FIXED_POSITION, fixedPositionMessage(30, 40)]]); + render(); + + fireEvent.change(screen.getByLabelText("Broadcast Interval"), { + target: { value: "1800" }, + }); + + await waitFor(() => expect(stageAdminMessage).toHaveBeenCalled()); + const [key, message] = stageAdminMessage.mock.lastCall ?? []; + expect(key).toBe(STAGED_FIXED_POSITION); + expect(message?.payloadVariant.value.latitudeI).toBe(300_000_000); + }); +}); diff --git a/apps/web/src/components/PageComponents/Settings/Position.tsx b/apps/web/src/components/PageComponents/Settings/Position.tsx index 8d1573521..63f9d1d41 100644 --- a/apps/web/src/components/PageComponents/Settings/Position.tsx +++ b/apps/web/src/components/PageComponents/Settings/Position.tsx @@ -16,6 +16,7 @@ import { import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts"; import { useToast } from "@core/hooks/useToast.ts"; import { useDevice } from "@core/stores"; +import { STAGED_FIXED_POSITION } from "@core/utils/stagedAdminKeys.ts"; import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { LocateFixed } from "lucide-react"; @@ -94,7 +95,11 @@ function UseBrowserLocationButton({ isImperial }: { isImperial: boolean }) { ); } -const FIXED_POSITION_KEY = "fixedPosition"; +const EMPTY_STAGED_SIGNAL = { + value: new Map() as ReadonlyMap, + peek: () => new Map() as ReadonlyMap, + subscribe: () => () => {}, +} as const; const EMPTY_RADIO_SIGNAL = { value: {} as { position?: Protobuf.Config.Config_PositionConfig }, @@ -109,6 +114,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { const editor = useConfigEditor(); const myNode = useMyNodeAsProto(); const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL); + const staged = useSignal(editor?.stagedAdminMessages ?? EMPTY_STAGED_SIGNAL); const effectivePosition = radio.position ?? @@ -122,26 +128,34 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { const { t } = useTranslation("config"); const currentPosition = myNode?.position; + // A staged fixed position (from this form or a profile import) is what + // the form shows, so coming back to the tab doesn't lose it. + const stagedVariant = staged.get(STAGED_FIXED_POSITION)?.payloadVariant; + const stagedPosition = + stagedVariant?.case === "setFixedPosition" + ? stagedVariant.value + : undefined; + const shownPosition = stagedPosition ?? currentPosition; const displayUnits = getEffectiveConfig("display")?.units; const isImperial = displayUnits === Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL; const formValues = useMemo(() => { - const altitudeMeters = currentPosition?.altitude ?? 0; + const altitudeMeters = shownPosition?.altitude ?? 0; return { ...config.position, ...effectivePosition, - latitude: currentPosition?.latitudeI - ? currentPosition.latitudeI / 1e7 + latitude: shownPosition?.latitudeI + ? shownPosition.latitudeI / 1e7 : undefined, - longitude: currentPosition?.longitudeI - ? currentPosition.longitudeI / 1e7 + longitude: shownPosition?.longitudeI + ? shownPosition.longitudeI / 1e7 : undefined, altitude: isImperial ? Math.round(metersToFeet(altitudeMeters)) : altitudeMeters, } as PositionValidation; - }, [config.position, effectivePosition, currentPosition, isImperial]); + }, [config.position, effectivePosition, shownPosition, isImperial]); const onSubmit = (data: PositionValidation) => { const { @@ -190,7 +204,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { }); } } - editor?.stageAdminMessage(FIXED_POSITION_KEY, fixedPositionMessage); + editor?.stageAdminMessage(STAGED_FIXED_POSITION, fixedPositionMessage); }; const onPositonFlagChange = useCallback( diff --git a/apps/web/src/core/utils/stagedAdminKeys.ts b/apps/web/src/core/utils/stagedAdminKeys.ts new file mode 100644 index 000000000..b43429d56 --- /dev/null +++ b/apps/web/src/core/utils/stagedAdminKeys.ts @@ -0,0 +1,6 @@ +/** + * Keys for admin messages staged in the config editor + * (`editor.stageAdminMessage`). One message is kept per key, so every place + * that stages the same message must use the same key. + */ +export const STAGED_FIXED_POSITION = "fixedPosition"; diff --git a/apps/web/src/pages/Settings/BackupRestore.tsx b/apps/web/src/pages/Settings/BackupRestore.tsx index 11c50e413..041b7e976 100644 --- a/apps/web/src/pages/Settings/BackupRestore.tsx +++ b/apps/web/src/pages/Settings/BackupRestore.tsx @@ -8,6 +8,7 @@ import { import { useDeviceConnected } from "@components/PageComponents/SettingsPages/useDeviceConnected.ts"; import { Button } from "@components/UI/Button.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; +import { STAGED_FIXED_POSITION } from "@core/utils/stagedAdminKeys.ts"; import { useToast } from "@core/hooks/useToast.ts"; import { buildDeviceProfile, @@ -74,9 +75,10 @@ const applyPlan = (editor: ConfigEditor, plan: ImportPlan) => { } for (const channel of plan.channels) editor.setChannel(channel); if (plan.fixedPosition) { - // Staged by key, so Reset drops it and a second import replaces it. + // Same key as the Position page, so only one fixed position is sent; + // Reset drops it and a later edit or import replaces it. editor.stageAdminMessage( - "profileFixedPosition", + STAGED_FIXED_POSITION, create(Protobuf.Admin.AdminMessageSchema, { payloadVariant: { case: "setFixedPosition", value: plan.fixedPosition }, }), diff --git a/apps/web/src/pages/Settings/dirtySections.test.ts b/apps/web/src/pages/Settings/dirtySections.test.ts index e561a345d..c61df01db 100644 --- a/apps/web/src/pages/Settings/dirtySections.test.ts +++ b/apps/web/src/pages/Settings/dirtySections.test.ts @@ -28,8 +28,5 @@ describe("dirtyDeviceTabs", () => { it("counts a staged fixed position under Position", () => { expect([...dirtyDeviceTabs([], ["fixedPosition"])]).toEqual(["position"]); - expect([...dirtyDeviceTabs([], ["profileFixedPosition"])]).toEqual([ - "position", - ]); }); }); diff --git a/apps/web/src/pages/Settings/dirtySections.ts b/apps/web/src/pages/Settings/dirtySections.ts index cc199df5e..e446b0c07 100644 --- a/apps/web/src/pages/Settings/dirtySections.ts +++ b/apps/web/src/pages/Settings/dirtySections.ts @@ -1,3 +1,5 @@ +import { STAGED_FIXED_POSITION } from "@core/utils/stagedAdminKeys.ts"; + /** * Admin messages staged in the config editor, by the settings tab they * belong to. They are sent with Save like a config section, so they count @@ -8,8 +10,7 @@ const STAGED_KEY_MODULE: Record = { ringtone: "externalNotification", }; const STAGED_KEY_DEVICE: Record = { - fixedPosition: "position", - profileFixedPosition: "position", + [STAGED_FIXED_POSITION]: "position", }; export const DEVICE_SECTIONS = [ From 46fc42c1918e8c0ee80e45faf504eadbba2413e1 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 18:22:03 -0400 Subject: [PATCH 11/15] fix(web): keep the sidebar focus ring inside the button The shared Button draws a 2px ring offset 2px outside on :focus, so a clicked sidebar item's ring overlapped the next item (they sit 4px apart). Show the ring only for keyboard focus, inset. --- apps/web/src/components/UI/Sidebar/SidebarButton.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/UI/Sidebar/SidebarButton.tsx b/apps/web/src/components/UI/Sidebar/SidebarButton.tsx index ea1f8d1e9..2b0843357 100644 --- a/apps/web/src/components/UI/Sidebar/SidebarButton.tsx +++ b/apps/web/src/components/UI/Sidebar/SidebarButton.tsx @@ -37,6 +37,9 @@ export const SidebarButton = ({ size="sm" className={cn( "flex w-full items-center text-wrap", + // Buttons sit 4px apart, so an offset ring spills onto the next one. + // Ring only for keyboard focus, drawn inside the button. + "focus:ring-0 focus:ring-offset-0 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-slate-400", isButtonCollapsed ? "justify-center gap-0 px-2 h-9" : "justify-start gap-2 min-h-9", From 2be31268338d121e5472c0f26588ea39e6ec377b Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 18:26:01 -0400 Subject: [PATCH 12/15] feat(web): move theme and language out of the sidebar panel They now live in Settings > App settings, as on Android. The sidebar panel keeps the command menu. --- apps/web/src/components/DeviceInfoPanel.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/apps/web/src/components/DeviceInfoPanel.tsx b/apps/web/src/components/DeviceInfoPanel.tsx index ca517981b..7edc54ece 100644 --- a/apps/web/src/components/DeviceInfoPanel.tsx +++ b/apps/web/src/components/DeviceInfoPanel.tsx @@ -5,9 +5,7 @@ import { useNavigate } from "@tanstack/react-router"; import { ChevronRight, CpuIcon, - Languages, type LucideIcon, - Palette, Search as SearchIcon, ZapIcon, } from "lucide-react"; @@ -15,8 +13,6 @@ import type React from "react"; import { Fragment } from "react"; import { useTranslation } from "react-i18next"; import BatteryStatus from "./BatteryStatus.tsx"; -import LanguageSwitcher from "./LanguageSwitcher.tsx"; -import ThemeSwitcher from "./ThemeSwitcher.tsx"; import type { DeviceMetrics } from "./types.ts"; import { Avatar } from "./UI/Avatar.tsx"; import { Button } from "./UI/Button.tsx"; @@ -120,25 +116,12 @@ export const DeviceInfoPanel = ({ ]; const actionButtons: ActionButtonConfig[] = [ - { - id: "theme", - label: t("theme.changeTheme"), - icon: Palette, - render: () => , - }, { id: "commandMenu", label: t("page.title", { ns: "commandPalette" }), icon: SearchIcon, onClick: setCommandPaletteOpen, }, - - { - id: "language", - label: t("languagePicker.label"), - icon: Languages, - render: () => , - }, ]; return ( From f5548b6167504ae7e29337d5629994c28da83620 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 18:27:27 -0400 Subject: [PATCH 13/15] feat(web): list every Settings page and tab in the command menu Add a Settings group (after Goto) with User, LoRa, Channels, Security, Device and Module configuration, Administration, import/export, clean node database, debug panel, App settings and About. Each Device and Module configuration tab is a sub-item when searching and opens that tab via ?tab=. The tab lists come from the Settings pages themselves (hardware-hidden tabs and compiled-out modules are left out). --- .../i18n/locales/en/commandPalette.json | 3 + .../src/components/CommandPalette/index.tsx | 73 ++++++++++++++++ apps/web/src/pages/Settings/DeviceConfig.tsx | 85 +++++++++---------- apps/web/src/pages/Settings/ModuleConfig.tsx | 2 +- 4 files changed, 115 insertions(+), 48 deletions(-) diff --git a/apps/web/public/i18n/locales/en/commandPalette.json b/apps/web/public/i18n/locales/en/commandPalette.json index 665adbf39..74049109e 100644 --- a/apps/web/public/i18n/locales/en/commandPalette.json +++ b/apps/web/public/i18n/locales/en/commandPalette.json @@ -18,6 +18,9 @@ "nodes": "Nodes" } }, + "settings": { + "label": "Settings" + }, "manage": { "label": "Manage", "command": { diff --git a/apps/web/src/components/CommandPalette/index.tsx b/apps/web/src/components/CommandPalette/index.tsx index 628c7998f..87e75cf4b 100644 --- a/apps/web/src/components/CommandPalette/index.tsx +++ b/apps/web/src/components/CommandPalette/index.tsx @@ -7,16 +7,30 @@ import { CommandItem, CommandList, } from "@components/UI/Command.tsx"; +import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import { usePinnedItems } from "@core/hooks/usePinnedItems.ts"; import { useNodesAsProto } from "@core/hooks/useNodesAsProto.ts"; import { useAppStore, useDevice, useDeviceStore } from "@core/stores"; import { cn } from "@core/utils/cn.ts"; +import { isModuleExcluded } from "@core/utils/excludedModules.ts"; +import { visibleDeviceTabs } from "@pages/Settings/DeviceConfig.tsx"; +import { MODULE_TABS } from "@pages/Settings/ModuleConfig.tsx"; import { useNavigate } from "@tanstack/react-router"; import { useCommandState } from "cmdk"; import { + ArchiveRestoreIcon, ArrowLeftRightIcon, BoxSelectIcon, BugIcon, + InfoIcon, + LayersIcon, + ListIcon, + PaletteIcon, + RadioTowerIcon, + RouterIcon, + ShieldAlertIcon, + ShieldIcon, + UserIcon, CloudOff, EraserIcon, FactoryIcon, @@ -73,6 +87,63 @@ export const CommandPalette = () => { }); const { t } = useTranslation("commandPalette"); const navigate = useNavigate({ from: "/" }); + const { metadata, excludedModules } = useDeviceCapabilities(); + + // Every Settings page, and each Device / Module configuration tab as a + // sub-item (shown while searching), from the same lists the pages use. + const openSettings = (page: string, tab?: string) => { + navigate({ to: `/settings/${page}`, search: tab ? { tab } : {} }); + setCommandPaletteOpen(false); + }; + const settingsPage = ( + page: string, + labelKey: string, + icon: LucideIcon, + ): Command => ({ + label: t(labelKey, { ns: "config" }), + icon, + action: () => openSettings(page), + tags: ["settings", "config"], + }); + const settingsGroup: Group = { + id: "settingsGroup", + label: t("settings.label"), + icon: SettingsIcon, + commands: [ + settingsPage("user", "navigation.user", UserIcon), + settingsPage("lora", "navigation.lora", RadioTowerIcon), + settingsPage("channels", "navigation.channels", ListIcon), + settingsPage("security", "navigation.security", ShieldIcon), + { + ...settingsPage("device", "navigation.deviceConfiguration", RouterIcon), + subItems: visibleDeviceTabs(metadata).map((tab) => ({ + label: t(tab.labelKey, { ns: "config" }), + icon: , + action: () => openSettings("device", tab.key), + })), + }, + { + ...settingsPage("module", "navigation.moduleConfiguration", LayersIcon), + subItems: MODULE_TABS.filter( + (tab) => !isModuleExcluded(excludedModules, tab.key), + ).map((tab) => ({ + label: t(tab.labelKey, { ns: "moduleConfig" }), + icon: , + action: () => openSettings("module", tab.key), + })), + }, + settingsPage( + "administration", + "navigation.administration", + ShieldAlertIcon, + ), + settingsPage("backup", "navigation.deviceProfile", ArchiveRestoreIcon), + settingsPage("clean-nodes", "navigation.cleanNodeDb", EraserIcon), + settingsPage("debug", "navigation.debugPanel", BugIcon), + settingsPage("app", "navigation.appSettings", PaletteIcon), + settingsPage("about", "navigation.about", InfoIcon), + ], + }; const groups: Group[] = [ { @@ -245,6 +316,8 @@ export const CommandPalette = () => { ], }, ]; + // Right after "Goto". + groups.splice(1, 0, settingsGroup); const sortedGroups = [...groups].sort((a, b) => { const aPinned = pinnedItems.includes(a.id) ? 1 : 0; diff --git a/apps/web/src/pages/Settings/DeviceConfig.tsx b/apps/web/src/pages/Settings/DeviceConfig.tsx index a133b28b7..1e6f889a9 100644 --- a/apps/web/src/pages/Settings/DeviceConfig.tsx +++ b/apps/web/src/pages/Settings/DeviceConfig.tsx @@ -6,8 +6,9 @@ import { Position } from "@components/PageComponents/Settings/Position.tsx"; import { Power } from "@components/PageComponents/Settings/Power.tsx"; import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts"; import type { ValidConfigType } from "@core/stores"; +import type { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; -import { useMemo } from "react"; +import { type ComponentType, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { dirtyDeviceTabs } from "./dirtySections.ts"; import { @@ -28,9 +29,37 @@ const EMPTY_STAGED_SIGNAL = { subscribe: () => () => {}, } as const; -type DeviceTab = SettingsTab & { key: ValidConfigType; shown: boolean }; +type DeviceTab = { + key: ValidConfigType; + labelKey: string; + element: ComponentType; +}; + +/** Device configuration tabs, in the order of Android's deviceConfigRoutes. */ +export const DEVICE_TABS: DeviceTab[] = [ + { key: "device", labelKey: "page.tabDevice", element: Device }, + { key: "position", labelKey: "page.tabPosition", element: Position }, + { key: "power", labelKey: "page.tabPower", element: Power }, + { key: "network", labelKey: "page.tabNetwork", element: Network }, + { key: "display", labelKey: "page.tabDisplay", element: Display }, + { key: "bluetooth", labelKey: "page.tabBluetooth", element: Bluetooth }, +]; + +/** + * Tabs this hardware supports: Network needs Wi-Fi or Ethernet, Bluetooth + * needs Bluetooth. Until metadata arrives every tab shows, as on Android. + */ +export function visibleDeviceTabs( + metadata: Protobuf.Mesh.DeviceMetadata | undefined, +): DeviceTab[] { + return DEVICE_TABS.filter((tab) => { + if (!metadata) return true; + if (tab.key === "network") return metadata.hasWifi || metadata.hasEthernet; + if (tab.key === "bluetooth") return metadata.hasBluetooth; + return true; + }); +} -/** Device configuration, in the order of Android's deviceConfigRoutes. */ export const DeviceConfig = ({ onFormInit }: ConfigProps) => { const editor = useConfigEditor(); const dirtyRadio = useSignal( @@ -42,50 +71,12 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => { const tabs: SettingsTab[] = useMemo(() => { const dirty = dirtyDeviceTabs(dirtyRadio, staged.keys()); - // Until metadata arrives, show every tab, as Android does. - const hasNetwork = !metadata || metadata.hasWifi || metadata.hasEthernet; - const hasBluetooth = !metadata || metadata.hasBluetooth; - const all: DeviceTab[] = [ - { - key: "device", - label: t("page.tabDevice"), - element: Device, - shown: true, - }, - { - key: "position", - label: t("page.tabPosition"), - element: Position, - shown: true, - }, - { key: "power", label: t("page.tabPower"), element: Power, shown: true }, - { - key: "network", - label: t("page.tabNetwork"), - element: Network, - shown: hasNetwork, - }, - { - key: "display", - label: t("page.tabDisplay"), - element: Display, - shown: true, - }, - { - key: "bluetooth", - label: t("page.tabBluetooth"), - element: Bluetooth, - shown: hasBluetooth, - }, - ]; - return all - .filter((tab) => tab.shown) - .map((tab) => ({ - key: tab.key, - label: tab.label, - element: tab.element, - isDirty: dirty.has(tab.key), - })); + return visibleDeviceTabs(metadata).map((tab) => ({ + key: tab.key, + label: t(tab.labelKey), + element: tab.element, + isDirty: dirty.has(tab.key), + })); }, [t, metadata, dirtyRadio, staged]); return ; diff --git a/apps/web/src/pages/Settings/ModuleConfig.tsx b/apps/web/src/pages/Settings/ModuleConfig.tsx index d910990ee..13b65afbe 100644 --- a/apps/web/src/pages/Settings/ModuleConfig.tsx +++ b/apps/web/src/pages/Settings/ModuleConfig.tsx @@ -55,7 +55,7 @@ type ModuleTab = { * Module configuration, in the order of Android's ModuleRoute. Status * Message is not a tab: like Android, its one field lives on the User page. */ -const MODULE_TABS: ModuleTab[] = [ +export const MODULE_TABS: ModuleTab[] = [ { key: "mqtt", labelKey: "page.tabMqtt", element: MQTT }, { key: "serial", labelKey: "page.tabSerial", element: Serial }, { From 98e189b4255a6fa50c6ca2389147bae8e6463bde Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sat, 17 Oct 2026 22:24:13 -0400 Subject: [PATCH 14/15] fix(web): keep a channel's precision and avoid empty profile exports - The channel edit dialog rounds location precision into 10-19, so a value another client set (e.g. 24) was rewritten and the channel marked changed on a name-only edit. Keep the device's value unless the user changed it. - The profile dialog counted a ticked private key as a selection even after Configuration was cleared and the key disabled, allowing an empty export. Count only rows the user can select. --- .../Channels/channelStaging.test.ts | 19 +++++++++++++++++++ .../PageComponents/Channels/channelStaging.ts | 8 +++++++- .../SettingsPages/ProfileFieldsDialog.tsx | 12 +++++++++--- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/PageComponents/Channels/channelStaging.test.ts b/apps/web/src/components/PageComponents/Channels/channelStaging.test.ts index 078036904..fb8317b42 100644 --- a/apps/web/src/components/PageComponents/Channels/channelStaging.test.ts +++ b/apps/web/src/components/PageComponents/Channels/channelStaging.test.ts @@ -68,6 +68,25 @@ describe("buildEditedChannel", () => { expect(edited).toEqual(existing); }); + it.each([24, 5])( + "keeps an out-of-range precision (%i) set by another client", + (precision) => { + const other = makeChannel( + 3, + Role.SECONDARY, + { name: "cli", psk: key16 }, + { positionPrecision: precision }, + ); + const draft = draftFromChannel(other); + expect(buildEditedChannel(other, draft)).toEqual(other); + // Renaming alone still keeps the device's precision. + const renamed = buildEditedChannel(other, { ...draft, name: "renamed" }); + expect(renamed.settings?.moduleSettings?.positionPrecision).toBe( + precision, + ); + }, + ); + it("is unchanged for a channel without module settings", () => { const bare = makeChannel(1, Role.SECONDARY, { name: "x", psk: key16 }); const edited = buildEditedChannel(bare, draftFromChannel(bare)); diff --git a/apps/web/src/components/PageComponents/Channels/channelStaging.ts b/apps/web/src/components/PageComponents/Channels/channelStaging.ts index 32a902fd9..b2dfd8dba 100644 --- a/apps/web/src/components/PageComponents/Channels/channelStaging.ts +++ b/apps/web/src/components/PageComponents/Channels/channelStaging.ts @@ -100,7 +100,13 @@ export function buildEditedChannel( ): Protobuf.Channel.Channel { const settings = existing.settings; const moduleSettings = settings?.moduleSettings; - const precision = draft.positionPrecision; + const existingPrecision = moduleSettings?.positionPrecision ?? 0; + // The dialog rounds precision into its 10-19 range; keep a device value + // outside it (set by another client) unless the user changed it. + const precision = + draft.positionPrecision === normalizePositionPrecision(existingPrecision) + ? existingPrecision + : draft.positionPrecision; const keepModuleSettings = moduleSettings !== undefined || precision !== POSITION_PRECISION_OFF; diff --git a/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx b/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx index 2733277e0..287e5f60d 100644 --- a/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx +++ b/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx @@ -43,8 +43,15 @@ export const ProfileFieldsDialog = ({ const toggle = (field: ProfileField, checked: boolean) => setSelection((current) => ({ ...current, [field]: checked })); + // A row the user can't select (unavailable, not imported, or the private + // key without the configuration) doesn't count, even if still ticked. + const isEnabled = (field: ProfileField) => + available[field] && + !(mode === "import" && NOT_IMPORTED.includes(field)) && + !(field === "privateKey" && !selection.config); + const anySelected = PROFILE_FIELDS.some( - (field) => selection[field] && available[field], + (field) => selection[field] && isEnabled(field), ); return ( @@ -75,8 +82,7 @@ export const ProfileFieldsDialog = ({ {PROFILE_FIELDS.map((field) => { const notApplied = mode === "import" && NOT_IMPORTED.includes(field); // The key lives in the configuration's security section. - const needsConfig = field === "privateKey" && !selection.config; - const enabled = available[field] && !notApplied && !needsConfig; + const enabled = isEnabled(field); return (
    Date: Sat, 17 Oct 2026 22:28:22 -0400 Subject: [PATCH 15/15] fix(web): only require a six-digit Bluetooth PIN in fixed PIN mode Radios that never used fixed PIN pairing report a PIN of 0, which the schema rejected, so the Bluetooth page couldn't save. Add a test that every settings form accepts a factory-default radio's config. Co-authored-by: Matt <57228426+xtantaudio@users.noreply.github.com> --- .../src/validation/config/bluetooth.test.ts | 40 +++++ apps/web/src/validation/config/bluetooth.ts | 30 +++- .../src/validation/formSchemaCoverage.test.ts | 154 ++++++++++++++++++ 3 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/validation/config/bluetooth.test.ts create mode 100644 apps/web/src/validation/formSchemaCoverage.test.ts diff --git a/apps/web/src/validation/config/bluetooth.test.ts b/apps/web/src/validation/config/bluetooth.test.ts new file mode 100644 index 000000000..fdcba8551 --- /dev/null +++ b/apps/web/src/validation/config/bluetooth.test.ts @@ -0,0 +1,40 @@ +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { BluetoothValidationSchema } from "./bluetooth.ts"; + +const { FIXED_PIN, RANDOM_PIN, NO_PIN } = + Protobuf.Config.Config_BluetoothConfig_PairingMode; + +describe("BluetoothValidationSchema", () => { + it.each([RANDOM_PIN, NO_PIN])("accepts PIN 0 in mode %i", (mode) => { + expect( + BluetoothValidationSchema.safeParse({ enabled: true, mode, fixedPin: 0 }) + .success, + ).toBe(true); + }); + + it("accepts a six-digit fixed PIN", () => { + expect( + BluetoothValidationSchema.safeParse({ + enabled: true, + mode: FIXED_PIN, + fixedPin: 123456, + }).success, + ).toBe(true); + }); + + it("rejects a short fixed PIN on the PIN field", () => { + const result = BluetoothValidationSchema.safeParse({ + enabled: true, + mode: FIXED_PIN, + fixedPin: 0, + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0]).toMatchObject({ + code: "too_small", + origin: "number", + minimum: 100000, + path: ["fixedPin"], + }); + }); +}); diff --git a/apps/web/src/validation/config/bluetooth.ts b/apps/web/src/validation/config/bluetooth.ts index 7ae02b236..5ce2b5eff 100644 --- a/apps/web/src/validation/config/bluetooth.ts +++ b/apps/web/src/validation/config/bluetooth.ts @@ -5,10 +5,30 @@ const PairingModeEnum = z.enum( Protobuf.Config.Config_BluetoothConfig_PairingMode, ); -export const BluetoothValidationSchema = z.object({ - enabled: z.boolean(), - mode: PairingModeEnum, - fixedPin: z.coerce.number().int().min(100000).max(999999), -}); +const MIN_FIXED_PIN = 100000; + +export const BluetoothValidationSchema = z + .object({ + enabled: z.boolean(), + mode: PairingModeEnum, + fixedPin: z.coerce.number().int().min(0).max(999999), + }) + .superRefine((config, ctx) => { + // Radios that never used a fixed PIN report 0. + if ( + config.mode === + Protobuf.Config.Config_BluetoothConfig_PairingMode.FIXED_PIN && + config.fixedPin < MIN_FIXED_PIN + ) { + ctx.addIssue({ + code: "too_small", + origin: "number", + minimum: MIN_FIXED_PIN, + inclusive: true, + input: config.fixedPin, + path: ["fixedPin"], + }); + } + }); export type BluetoothValidation = z.infer; diff --git a/apps/web/src/validation/formSchemaCoverage.test.ts b/apps/web/src/validation/formSchemaCoverage.test.ts new file mode 100644 index 000000000..8fd758873 --- /dev/null +++ b/apps/web/src/validation/formSchemaCoverage.test.ts @@ -0,0 +1,154 @@ +import { create, type DescMessage } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { BluetoothValidationSchema } from "./config/bluetooth.ts"; +import { DeviceValidationSchema, toDeviceFormValues } from "./config/device.ts"; +import { DisplayValidationSchema } from "./config/display.ts"; +import { LoRaValidationSchema } from "./config/lora.ts"; +import { NetworkValidationSchema } from "./config/network.ts"; +import { PositionValidationSchema } from "./config/position.ts"; +import { PowerValidationSchema, toPowerFormValues } from "./config/power.ts"; +import { AmbientLightingValidationSchema } from "./moduleConfig/ambientLighting.ts"; +import { AudioValidationSchema } from "./moduleConfig/audio.ts"; +import { CannedMessageValidationSchema } from "./moduleConfig/cannedMessage.ts"; +import { DetectionSensorValidationSchema } from "./moduleConfig/detectionSensor.ts"; +import { ExternalNotificationValidationSchema } from "./moduleConfig/externalNotification.ts"; +import { MqttValidationSchema } from "./moduleConfig/mqtt.ts"; +import { NeighborInfoValidationSchema } from "./moduleConfig/neighborInfo.ts"; +import { PaxcounterValidationSchema } from "./moduleConfig/paxcounter.ts"; +import { RangeTestValidationSchema } from "./moduleConfig/rangeTest.ts"; +import { RemoteHardwareValidationSchema } from "./moduleConfig/remoteHardware.ts"; +import { SerialValidationSchema } from "./moduleConfig/serial.ts"; +import { StoreForwardValidationSchema } from "./moduleConfig/storeForward.ts"; +import { TakValidationSchema } from "./moduleConfig/tak.ts"; +import { TelemetryValidationSchema } from "./moduleConfig/telemetry.ts"; + +type Schema = { + safeParse(value: unknown): { + success: boolean; + error?: { issues: { path: PropertyKey[]; message: string }[] }; + }; +}; + +const C = Protobuf.Config; +const M = Protobuf.ModuleConfig; +const fresh = (desc: DescMessage) => create(desc); + +/** + * A form auto-saves through its resolver, so a schema that rejects the + * radio's own config blocks every change on that page. Each case is what + * the page passes to its form for a factory-default radio. + */ +const cases: [string, Schema, unknown][] = [ + [ + "bluetooth", + BluetoothValidationSchema, + fresh(C.Config_BluetoothConfigSchema), + ], + [ + "device", + DeviceValidationSchema, + toDeviceFormValues(create(C.Config_DeviceConfigSchema)), + ], + ["display", DisplayValidationSchema, fresh(C.Config_DisplayConfigSchema)], + ["lora", LoRaValidationSchema, fresh(C.Config_LoRaConfigSchema)], + [ + "network", + NetworkValidationSchema, + { + ...fresh(C.Config_NetworkConfigSchema), + ipv4Config: { + ip: "0.0.0.0", + gateway: "0.0.0.0", + subnet: "0.0.0.0", + dns: "0.0.0.0", + }, + udpBroadcast: false, + }, + ], + [ + "position", + PositionValidationSchema, + { ...fresh(C.Config_PositionConfigSchema), altitude: 0 }, + ], + [ + "power", + PowerValidationSchema, + toPowerFormValues(create(C.Config_PowerConfigSchema)), + ], + [ + "ambientLighting", + AmbientLightingValidationSchema, + fresh(M.ModuleConfig_AmbientLightingConfigSchema), + ], + ["audio", AudioValidationSchema, fresh(M.ModuleConfig_AudioConfigSchema)], + [ + "cannedMessage", + CannedMessageValidationSchema, + { ...fresh(M.ModuleConfig_CannedMessageConfigSchema), messages: "" }, + ], + [ + "detectionSensor", + DetectionSensorValidationSchema, + fresh(M.ModuleConfig_DetectionSensorConfigSchema), + ], + [ + "externalNotification", + ExternalNotificationValidationSchema, + { ...fresh(M.ModuleConfig_ExternalNotificationConfigSchema), ringtone: "" }, + ], + [ + "mqtt", + MqttValidationSchema, + { + ...fresh(M.ModuleConfig_MQTTConfigSchema), + mapReportSettings: { + publishIntervalSecs: 3600, + positionPrecision: 13, + shouldReportLocation: false, + }, + }, + ], + [ + "neighborInfo", + NeighborInfoValidationSchema, + fresh(M.ModuleConfig_NeighborInfoConfigSchema), + ], + [ + "paxcounter", + PaxcounterValidationSchema, + fresh(M.ModuleConfig_PaxcounterConfigSchema), + ], + [ + "rangeTest", + RangeTestValidationSchema, + fresh(M.ModuleConfig_RangeTestConfigSchema), + ], + [ + "remoteHardware", + RemoteHardwareValidationSchema, + fresh(M.ModuleConfig_RemoteHardwareConfigSchema), + ], + ["serial", SerialValidationSchema, fresh(M.ModuleConfig_SerialConfigSchema)], + [ + "storeForward", + StoreForwardValidationSchema, + fresh(M.ModuleConfig_StoreForwardConfigSchema), + ], + ["tak", TakValidationSchema, fresh(M.ModuleConfig_TAKConfigSchema)], + [ + "telemetry", + TelemetryValidationSchema, + fresh(M.ModuleConfig_TelemetryConfigSchema), + ], +]; + +describe("settings forms accept a factory-default radio", () => { + it.each(cases)("%s", (_, schema, value) => { + const result = schema.safeParse(value); + const issues = result.error?.issues.map( + (issue) => `${issue.path.join(".")}: ${issue.message}`, + ); + expect(issues ?? []).toEqual([]); + }); +});