From 9a74ce9d29a85d5d7d7d783541264b2f580da793 Mon Sep 17 00:00:00 2001 From: Matt <57228426+xtantaudio@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:37:20 -0400 Subject: [PATCH] fix: LoRa/Channels/Bluetooth forms silently fail validation The Zod schemas for these forms required a serialHalOnly field that does not exist in the actual resolved device config shape, so every submission from these forms failed client-side validation before it ever reached the save/commit path - with no visible error shown to the user. This affected every field on the LoRa tab (Region, Ok to MQTT, hop limit, tx power, etc.), Channels (uplink/downlink enabled, location precision), and Bluetooth config. Made the mismatched field optional in the schemas to match the real device shape, and added formSchemaCoverage.test.ts to catch future schema/device-shape drift across all config forms automatically. Verified live against real hardware: previously-silent failures on Region, 'Ok to MQTT', and channel uplink/downlink now save and persist correctly, confirmed via the device's own raw protobuf output. --- .../PageComponents/Channels/Channel.test.tsx | 238 +++++++++++++++ .../PageComponents/Channels/Channel.tsx | 43 ++- .../PageComponents/ModuleConfig/MQTT.test.tsx | 168 +++++++++++ .../Settings/DeviceConfig.test.tsx | 257 ++++++++++++++++ .../PageComponents/Settings/LoRa.test.tsx | 129 ++++++++ apps/web/src/validation/channel.test.ts | 53 +++- apps/web/src/validation/channel.ts | 25 +- apps/web/src/validation/config/bluetooth.ts | 27 +- apps/web/src/validation/config/lora.ts | 12 +- .../src/validation/formSchemaCoverage.test.ts | 277 ++++++++++++++++++ 10 files changed, 1198 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/components/PageComponents/Channels/Channel.test.tsx create mode 100644 apps/web/src/components/PageComponents/ModuleConfig/MQTT.test.tsx create mode 100644 apps/web/src/components/PageComponents/Settings/DeviceConfig.test.tsx create mode 100644 apps/web/src/components/PageComponents/Settings/LoRa.test.tsx create mode 100644 apps/web/src/validation/formSchemaCoverage.test.ts diff --git a/apps/web/src/components/PageComponents/Channels/Channel.test.tsx b/apps/web/src/components/PageComponents/Channels/Channel.test.tsx new file mode 100644 index 000000000..fd572fa2f --- /dev/null +++ b/apps/web/src/components/PageComponents/Channels/Channel.test.tsx @@ -0,0 +1,238 @@ +import { create, fromBinary } from "@bufbuild/protobuf"; +import { CurrentDeviceContext, useDeviceStore } from "@core/stores"; +import { MeshClient, MeshRegistry, Protobuf } from "@meshtastic/sdk"; +import { createFakeTransport } from "@meshtastic/sdk/testing"; +import { MeshRegistryProvider } from "@meshtastic/sdk-react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Suspense } from "react"; +import { describe, expect, it } from "vitest"; +import { Channel } from "./Channel.tsx"; + +/** + * End-to-end cover for the channel save path: the real channel form -> + * `ConfigEditor` staging -> the AdminMessage bytes that go to the radio. + * + * `ChannelSettings.uplink_enabled` is field 5 and `downlink_enabled` is field + * 6 of a proto3 message, so `false` is simply *absent* from the encoding. The + * assertions therefore decode the outgoing packet from its wire bytes: if the + * toggle never really reached the payload, the field is missing on the device + * exactly as it was in the live regression this covers. + */ + +let deviceIdSeq = 500; + +const PRIMARY_PSK = new Uint8Array([1]); + +function primaryChannel( + init: Partial<{ + uplinkEnabled: boolean; + downlinkEnabled: boolean; + channelNum: number; + isMuted: boolean; + }> = {}, +): Protobuf.Channel.Channel { + return create(Protobuf.Channel.ChannelSchema, { + index: 0, + role: Protobuf.Channel.Channel_Role.PRIMARY, + settings: create(Protobuf.Channel.ChannelSettingsSchema, { + channelNum: init.channelNum ?? 0, + psk: PRIMARY_PSK, + name: "", + id: 1234, + uplinkEnabled: init.uplinkEnabled ?? false, + downlinkEnabled: init.downlinkEnabled ?? false, + moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, { + positionPrecision: 10, + isMuted: init.isMuted ?? false, + }), + }), + }); +} + +function setup(channel: Protobuf.Channel.Channel) { + const { transport } = createFakeTransport(); + const registry = new MeshRegistry(); + const client = new MeshClient({ transport }); + const connectionId = deviceIdSeq++; + registry.register(connectionId, client); + registry.setActive(connectionId); + + const sent: Protobuf.Admin.AdminMessage[] = []; + let release: (() => void) | undefined; + let gate: Protobuf.Admin.AdminMessage["payloadVariant"]["case"] | undefined; + + client.sendPacket = (async (payload: Uint8Array) => { + const admin = fromBinary(Protobuf.Admin.AdminMessageSchema, payload); + sent.push(admin); + if (gate && admin.payloadVariant.case === gate) { + gate = undefined; + await new Promise((resolve) => { + release = resolve; + }); + } + return 1; + }) as never; + + const device = useDeviceStore.getState().addDevice(connectionId); + device.addChannel(channel); + client.events.onChannelPacket.dispatch(channel); + + render( + + + loading}> + {}} channel={channel} /> + + + , + ); + + return { + client, + editor: client.config.editor, + sent, + gateOn: ( + value: Protobuf.Admin.AdminMessage["payloadVariant"]["case"], + ): void => { + gate = value; + }, + release: () => release?.(), + }; +} + +function channelFromWire( + sent: Protobuf.Admin.AdminMessage[], +): Protobuf.Channel.Channel | undefined { + for (const admin of sent) { + if (admin.payloadVariant.case === "setChannel") { + return admin.payloadVariant.value; + } + } + return undefined; +} + +describe("Channel settings", () => { + it("puts `uplinkEnabled: true` on the wire when the toggle is switched on", async () => { + const { editor, sent } = setup(primaryChannel()); + + await userEvent.click(await screen.findByLabelText("Uplink Enabled")); + + await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); + expect((await editor.commit()).status).toBe("ok"); + + const wire = channelFromWire(sent); + expect(wire).toBeDefined(); + expect(wire?.settings?.uplinkEnabled).toBe(true); + // Untouched fields must still round-trip. + expect(wire?.index).toBe(0); + expect(wire?.role).toBe(Protobuf.Channel.Channel_Role.PRIMARY); + expect(wire?.settings?.id).toBe(1234); + expect(wire?.settings?.psk).toEqual(PRIMARY_PSK); + expect(wire?.settings?.downlinkEnabled).toBe(false); + expect(wire?.settings?.moduleSettings?.positionPrecision).toBe(10); + }); + + it("puts `downlinkEnabled: true` on the wire when the toggle is switched on", async () => { + const { editor, sent } = setup(primaryChannel()); + + await userEvent.click(await screen.findByLabelText("Downlink Enabled")); + + await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); + expect((await editor.commit()).status).toBe("ok"); + + expect(channelFromWire(sent)?.settings?.downlinkEnabled).toBe(true); + }); + + it("does not report the toggle as saved when it was not part of the commit", async () => { + const { client, editor, sent, gateOn, release } = setup(primaryChannel()); + + client.events.onConfigPacket.dispatch( + create(Protobuf.Config.ConfigSchema, { + payloadVariant: { + case: "lora", + value: create(Protobuf.Config.Config_LoRaConfigSchema, { region: 1 }), + }, + }), + ); + editor.setRadioSection( + "lora", + create(Protobuf.Config.Config_LoRaConfigSchema, { region: 4 }), + ); + + // Start an unrelated save and hold it open on the closing commitEditSettings. + gateOn("commitEditSettings"); + const pending = editor.commit(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // The user flips "Uplink Enabled" while that save is still in flight. + await userEvent.click(await screen.findByLabelText("Uplink Enabled")); + await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); + + release(); + expect((await pending).status).toBe("ok"); + + // The transaction really did open and commit on the device, but it never + // carried the channel payload — so the edit has to stay pending. + expect(sent.map((a) => a.payloadVariant.case)).toEqual([ + "beginEditSettings", + "setConfig", + "commitEditSettings", + ]); + expect(channelFromWire(sent)).toBeUndefined(); + expect(editor.dirtyChannels.value).toContain(0); + expect(editor.isDirty.value).toBe(true); + + // The next save carries it, with `uplinkEnabled` intact. + expect((await editor.commit()).status).toBe("ok"); + expect(channelFromWire(sent)?.settings?.uplinkEnabled).toBe(true); + expect(editor.isDirty.value).toBe(false); + }); + it("saves a channel carrying the deprecated channel_num slot", async () => { + // `ChannelSettings.channel_num` is a deprecated uint32 the form does not + // render. A schema that capped it at 7 made every such channel silently + // unsavable: the toggle moved, nothing was staged, and the following save + // opened a real transaction without the change in it. + const { editor, sent } = setup(primaryChannel({ channelNum: 20 })); + + await userEvent.click(await screen.findByLabelText("Uplink Enabled")); + + await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); + expect((await editor.commit()).status).toBe("ok"); + + const wire = channelFromWire(sent); + expect(wire?.settings?.uplinkEnabled).toBe(true); + expect(wire?.settings?.channelNum).toBe(20); + }); + + it("keeps the channel's mute flag when another setting is saved", async () => { + const { editor, sent } = setup(primaryChannel({ isMuted: true })); + + await userEvent.click(await screen.findByLabelText("Uplink Enabled")); + + await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); + expect((await editor.commit()).status).toBe("ok"); + + const wire = channelFromWire(sent); + expect(wire?.settings?.uplinkEnabled).toBe(true); + // `is_muted` is not rendered by this form; the resolver used to drop it, + // which reset it on the device on every channel save. + expect(wire?.settings?.moduleSettings?.isMuted).toBe(true); + }); + + it("saves a channel whose settings sub-message is absent", async () => { + const { editor, sent } = setup( + create(Protobuf.Channel.ChannelSchema, { + index: 0, + role: Protobuf.Channel.Channel_Role.PRIMARY, + }), + ); + + await userEvent.click(await screen.findByLabelText("Uplink Enabled")); + + await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); + expect((await editor.commit()).status).toBe("ok"); + + expect(channelFromWire(sent)?.settings?.uplinkEnabled).toBe(true); + }); +}); diff --git a/apps/web/src/components/PageComponents/Channels/Channel.tsx b/apps/web/src/components/PageComponents/Channels/Channel.tsx index b1e662d05..27988995a 100644 --- a/apps/web/src/components/PageComponents/Channels/Channel.tsx +++ b/apps/web/src/components/PageComponents/Channels/Channel.tsx @@ -36,6 +36,19 @@ const EMPTY_CHANNELS_SIGNAL = { subscribe: () => () => {}, } as const; +/** + * The form needs every field it validates to be present. A `Channel` whose + * `settings` sub-message is absent (or partially populated) would otherwise + * hand the resolver `undefined` for `channelNum` / `id` / `name` / the uplink + * flags, which fails validation on fields this form does not even render — + * and an invalid form silently stages nothing, so the user's edit never + * reaches the radio. Fall back to the protobuf defaults instead. + */ +const withSettingsDefaults = ( + settings: Protobuf.Channel.ChannelSettings | undefined, +): Protobuf.Channel.ChannelSettings => + create(Protobuf.Channel.ChannelSettingsSchema, settings ?? {}); + export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { const { config } = useDevice(); const editor = useConfigEditor(); @@ -43,19 +56,19 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { const { t } = useTranslation(["channels", "ui", "dialog"]); const defaultConfig = channel; + const defaultSettings = withSettingsDefaults(defaultConfig?.settings); const defaultValues = { ...defaultConfig, ...{ settings: { - ...defaultConfig?.settings, - psk: fromByteArray(defaultConfig?.settings?.psk ?? new Uint8Array(0)), + ...defaultSettings, + psk: fromByteArray(defaultSettings.psk ?? new Uint8Array(0)), moduleSettings: { - ...defaultConfig?.settings?.moduleSettings, + ...defaultSettings.moduleSettings, positionPrecision: - defaultConfig?.settings?.moduleSettings?.positionPrecision === - undefined + defaultSettings.moduleSettings?.positionPrecision === undefined ? 10 - : defaultConfig?.settings?.moduleSettings?.positionPrecision, + : defaultSettings.moduleSettings.positionPrecision, }, }, }, @@ -63,19 +76,19 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { const workingChannel = editorChannels.get(channel.index); const effectiveConfig = workingChannel ?? channel; + const effectiveSettings = withSettingsDefaults(effectiveConfig?.settings); const formValues = { ...effectiveConfig, ...{ settings: { - ...effectiveConfig?.settings, - psk: fromByteArray(effectiveConfig?.settings?.psk ?? new Uint8Array(0)), + ...effectiveSettings, + psk: fromByteArray(effectiveSettings.psk ?? new Uint8Array(0)), moduleSettings: { - ...effectiveConfig?.settings?.moduleSettings, + ...effectiveSettings.moduleSettings, positionPrecision: - effectiveConfig?.settings?.moduleSettings?.positionPrecision === - undefined + effectiveSettings.moduleSettings?.positionPrecision === undefined ? 10 - : effectiveConfig?.settings?.moduleSettings?.positionPrecision, + : effectiveSettings.moduleSettings.positionPrecision, }, }, }, @@ -83,9 +96,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { const [preSharedDialogOpen, setPreSharedDialogOpen] = useState(false); - const [byteCount, setBytes] = useState( - effectiveConfig?.settings?.psk.length ?? 16, - ); + const [byteCount, setBytes] = useState(effectiveSettings.psk.length); const ChannelValidationSchema = useMemo(() => { return makeChannelSchema(byteCount); }, [byteCount]); @@ -106,7 +117,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { // 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 effectiveByteCount = effectiveSettings.psk.length; const lastEffectiveRef = useRef(effectiveByteCount); useEffect(() => { if (effectiveByteCount !== lastEffectiveRef.current) { diff --git a/apps/web/src/components/PageComponents/ModuleConfig/MQTT.test.tsx b/apps/web/src/components/PageComponents/ModuleConfig/MQTT.test.tsx new file mode 100644 index 000000000..dea96f6fd --- /dev/null +++ b/apps/web/src/components/PageComponents/ModuleConfig/MQTT.test.tsx @@ -0,0 +1,168 @@ +import { create, fromBinary } from "@bufbuild/protobuf"; +import { CurrentDeviceContext, useDeviceStore } from "@core/stores"; +import { MeshClient, MeshRegistry, Protobuf } from "@meshtastic/sdk"; +import { createFakeTransport } from "@meshtastic/sdk/testing"; +import { MeshRegistryProvider } from "@meshtastic/sdk-react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Suspense } from "react"; +import { describe, expect, it } from "vitest"; +import { MQTT } from "./MQTT.tsx"; + +/** + * End-to-end cover for the MQTT module-config save path: the real settings + * form -> `ConfigEditor` staging -> the AdminMessage bytes that go to the + * radio. + * + * The assertions decode the outgoing packet from its wire encoding, because + * protobuf omits default booleans: if `enabled` is not genuinely `true` when + * the message is serialised, field 1 is simply absent from the bytes and the + * device silently keeps MQTT switched off — which is exactly the bug this + * covers. + */ + +let deviceIdSeq = 100; + +function setup(mqtt: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig) { + const { transport } = createFakeTransport(); + const registry = new MeshRegistry(); + const client = new MeshClient({ transport }); + const connectionId = deviceIdSeq++; + registry.register(connectionId, client); + registry.setActive(connectionId); + + const sent: Protobuf.Admin.AdminMessage[] = []; + let release: (() => void) | undefined; + let gate: Protobuf.Admin.AdminMessage["payloadVariant"]["case"] | undefined; + + client.sendPacket = (async (payload: Uint8Array) => { + const admin = fromBinary(Protobuf.Admin.AdminMessageSchema, payload); + sent.push(admin); + if (gate && admin.payloadVariant.case === gate) { + gate = undefined; + await new Promise((resolve) => { + release = resolve; + }); + } + return 1; + }) as never; + + const packet = create(Protobuf.ModuleConfig.ModuleConfigSchema, { + payloadVariant: { case: "mqtt", value: mqtt }, + }); + const device = useDeviceStore.getState().addDevice(connectionId); + device.setModuleConfig(packet); + client.events.onModuleConfigPacket.dispatch(packet); + + render( + + + loading}> + {}} /> + + + , + ); + + return { + client, + editor: client.config.editor, + sent, + gateOn: ( + value: Protobuf.Admin.AdminMessage["payloadVariant"]["case"], + ): void => { + gate = value; + }, + release: () => release?.(), + }; +} + +function mqttFromWire( + sent: Protobuf.Admin.AdminMessage[], +): Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined { + for (const admin of sent) { + const variant = admin.payloadVariant; + if (variant.case !== "setModuleConfig") continue; + const module = variant.value.payloadVariant; + if (module.case === "mqtt") return module.value; + } + return undefined; +} + +const deviceMqtt = () => + create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, { + enabled: false, + address: "mqtt.example.org", + username: "meshdev", + password: "large4cats", + root: "msh", + }); + +describe("MQTT module config", () => { + it("puts `enabled: true` on the wire when the toggle is switched on", async () => { + const { editor, sent } = setup(deviceMqtt()); + + await userEvent.click(await screen.findByLabelText("Enabled")); + + await waitFor(() => + expect(editor.dirtyModuleSections.value).toContain("mqtt"), + ); + expect((await editor.commit()).status).toBe("ok"); + + const wire = mqttFromWire(sent); + expect(wire).toBeDefined(); + expect(wire?.enabled).toBe(true); + // The text fields the user did not touch must still round-trip. + expect(wire?.address).toBe("mqtt.example.org"); + expect(wire?.username).toBe("meshdev"); + expect(wire?.password).toBe("large4cats"); + expect(wire?.root).toBe("msh"); + }); + + it("does not report the toggle as saved when it was not part of the commit", async () => { + const { client, editor, sent, gateOn, release } = setup(deviceMqtt()); + + client.events.onConfigPacket.dispatch( + create(Protobuf.Config.ConfigSchema, { + payloadVariant: { + case: "lora", + value: create(Protobuf.Config.Config_LoRaConfigSchema, { region: 1 }), + }, + }), + ); + editor.setRadioSection( + "lora", + create(Protobuf.Config.Config_LoRaConfigSchema, { region: 4 }), + ); + + // Start an unrelated save and hold it open on the closing commitEditSettings. + gateOn("commitEditSettings"); + const pending = editor.commit(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // The user flips "MQTT enabled" while that save is still in flight. + await userEvent.click(await screen.findByLabelText("Enabled")); + await waitFor(() => + expect(editor.dirtyModuleSections.value).toContain("mqtt"), + ); + + release(); + expect((await pending).status).toBe("ok"); + + // The transaction really did open and commit on the device, but it never + // carried the MQTT payload — so the edit has to stay pending. + expect(sent.map((a) => a.payloadVariant.case)).toEqual([ + "beginEditSettings", + "setConfig", + "commitEditSettings", + ]); + expect(mqttFromWire(sent)).toBeUndefined(); + expect(editor.dirtyModuleSections.value).toContain("mqtt"); + expect(editor.isDirty.value).toBe(true); + + // The next save carries it, with `enabled` intact. + expect((await editor.commit()).status).toBe("ok"); + expect(mqttFromWire(sent)?.enabled).toBe(true); + expect(editor.isDirty.value).toBe(false); + }); +}); diff --git a/apps/web/src/components/PageComponents/Settings/DeviceConfig.test.tsx b/apps/web/src/components/PageComponents/Settings/DeviceConfig.test.tsx new file mode 100644 index 000000000..f869a901d --- /dev/null +++ b/apps/web/src/components/PageComponents/Settings/DeviceConfig.test.tsx @@ -0,0 +1,257 @@ +import { create, fromBinary } from "@bufbuild/protobuf"; +import { BluetoothValidationSchema } from "@app/validation/config/bluetooth.ts"; +import { Bluetooth } from "@components/PageComponents/Settings/Bluetooth.tsx"; +import { Device } from "@components/PageComponents/Settings/Device/index.tsx"; +import { CurrentDeviceContext, useDeviceStore } from "@core/stores"; +import { MeshClient, MeshRegistry, Protobuf } from "@meshtastic/sdk"; +import { createFakeTransport } from "@meshtastic/sdk/testing"; +import { MeshRegistryProvider } from "@meshtastic/sdk-react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { type ComponentType, Suspense } from "react"; +import { describe, expect, it } from "vitest"; + +/** + * End-to-end cover for the Device settings page. Its tabs (Device, Bluetooth, + * Display, Power, Network, Position) all stage through the *same* radio-config + * mechanism as LoRa — `ConfigEditor.setRadioSection` and `_dirtyRadioSections` + * — so they share both the commit bookkeeping and the "the form silently + * refuses to save" failure mode. + * + * Assertions decode the outgoing AdminMessage from its wire bytes: protobuf + * omits fields at their default, so a boolean that was never really set is + * simply missing on the device. + */ + +let deviceIdSeq = 800; + +function setup( + Component: ComponentType<{ onFormInit: () => void }>, + config: Protobuf.Config.Config, +) { + const { transport } = createFakeTransport(); + const registry = new MeshRegistry(); + const client = new MeshClient({ transport }); + const connectionId = deviceIdSeq++; + registry.register(connectionId, client); + registry.setActive(connectionId); + + const sent: Protobuf.Admin.AdminMessage[] = []; + let release: (() => void) | undefined; + let gate: Protobuf.Admin.AdminMessage["payloadVariant"]["case"] | undefined; + + client.sendPacket = (async (payload: Uint8Array) => { + const admin = fromBinary(Protobuf.Admin.AdminMessageSchema, payload); + sent.push(admin); + if (gate && admin.payloadVariant.case === gate) { + gate = undefined; + await new Promise((resolve) => { + release = resolve; + }); + } + return 1; + }) as never; + + const device = useDeviceStore.getState().addDevice(connectionId); + device.setConfig(config); + client.events.onConfigPacket.dispatch(config); + + render( + + + loading}> + {}} /> + + + , + ); + + return { + client, + editor: client.config.editor, + sent, + gateOn: ( + value: Protobuf.Admin.AdminMessage["payloadVariant"]["case"], + ): void => { + gate = value; + }, + release: () => release?.(), + }; +} + +/** + * Pull a config section back out of the transmitted AdminMessages. The value + * is cast to the caller's section type: the wire union cannot be narrowed + * generically, and the `case` check above already guarantees it. + */ +function fromWire( + sent: Protobuf.Admin.AdminMessage[], + section: Protobuf.Config.Config["payloadVariant"]["case"], +): T | undefined { + for (const admin of sent) { + if (admin.payloadVariant.case !== "setConfig") continue; + const variant = admin.payloadVariant.value.payloadVariant; + if (variant.case === section) { + return variant.value as T; + } + } + return undefined; +} + +const deviceConfig = () => + create(Protobuf.Config.ConfigSchema, { + payloadVariant: { + case: "device", + value: create(Protobuf.Config.Config_DeviceConfigSchema, { + role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT, + buttonGpio: 4, + buzzerGpio: 2, + nodeInfoBroadcastSecs: 10800, + doubleTapAsButtonPress: false, + }), + }, + }); + +const bluetoothConfig = (fixedPin: number) => + create(Protobuf.Config.ConfigSchema, { + payloadVariant: { + case: "bluetooth", + value: create(Protobuf.Config.Config_BluetoothConfigSchema, { + enabled: false, + mode: Protobuf.Config.Config_BluetoothConfig_PairingMode.RANDOM_PIN, + fixedPin, + }), + }, + }); + +describe("Device settings", () => { + it("puts a toggled device-config flag on the wire", async () => { + const { editor, sent } = setup(Device as never, deviceConfig()); + + await userEvent.click( + await screen.findByLabelText("Double Tap as Button Press"), + ); + + await waitFor(() => + expect(editor.dirtyRadioSections.value).toContain("device"), + ); + expect((await editor.commit()).status).toBe("ok"); + + const wire = fromWire(sent, "device"); + expect(wire?.doubleTapAsButtonPress).toBe(true); + // Untouched fields must survive. + expect(wire?.buttonGpio).toBe(4); + expect(wire?.buzzerGpio).toBe(2); + expect(wire?.nodeInfoBroadcastSecs).toBe(10800); + expect(wire?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.CLIENT); + }); + + it("does not report the toggle as saved when it was not part of the commit", async () => { + const { client, editor, sent, gateOn, release } = setup( + Device as never, + deviceConfig(), + ); + + client.events.onChannelPacket.dispatch( + create(Protobuf.Channel.ChannelSchema, { + index: 0, + role: Protobuf.Channel.Channel_Role.PRIMARY, + settings: create(Protobuf.Channel.ChannelSettingsSchema, { + psk: new Uint8Array([1]), + }), + }), + ); + editor.setChannel( + create(Protobuf.Channel.ChannelSchema, { + index: 0, + role: Protobuf.Channel.Channel_Role.PRIMARY, + settings: create(Protobuf.Channel.ChannelSettingsSchema, { + psk: new Uint8Array([1]), + uplinkEnabled: true, + }), + }), + ); + + // An unrelated channel save is in flight, held open on its closing commit. + gateOn("commitEditSettings"); + const pending = editor.commit(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + await userEvent.click( + await screen.findByLabelText("Double Tap as Button Press"), + ); + await waitFor(() => + expect(editor.dirtyRadioSections.value).toContain("device"), + ); + + release(); + expect((await pending).status).toBe("ok"); + + // A genuine begin/commit pair happened on the device, but it never carried + // the device config — so that edit has to stay pending. + expect(sent.map((a) => a.payloadVariant.case)).toEqual([ + "beginEditSettings", + "setChannel", + "commitEditSettings", + ]); + expect( + fromWire(sent, "device"), + ).toBeUndefined(); + expect(editor.dirtyRadioSections.value).toContain("device"); + expect(editor.isDirty.value).toBe(true); + + expect((await editor.commit()).status).toBe("ok"); + expect( + fromWire(sent, "device") + ?.doubleTapAsButtonPress, + ).toBe(true); + expect(editor.isDirty.value).toBe(false); + }); + + it("saves Bluetooth on a device that has no fixed PIN", async () => { + // `fixed_pin` is 0 until FIXED_PIN pairing is used. The schema used to + // demand six digits unconditionally, which made this device's Bluetooth + // form permanently invalid and silently unsavable. + const { editor, sent } = setup(Bluetooth as never, bluetoothConfig(0)); + + await userEvent.click(await screen.findByLabelText("Bluetooth enabled")); + + await waitFor(() => + expect(editor.dirtyRadioSections.value).toContain("bluetooth"), + ); + expect((await editor.commit()).status).toBe("ok"); + + expect( + fromWire(sent, "bluetooth") + ?.enabled, + ).toBe(true); + }); + + it("still requires a six-digit PIN for FIXED_PIN pairing", () => { + // The relaxation is conditional, not a removal: PIN validation still + // applies where the PIN is actually used. + const base = { + enabled: true, + fixedPin: 0, + }; + expect( + BluetoothValidationSchema.safeParse({ + ...base, + mode: Protobuf.Config.Config_BluetoothConfig_PairingMode.FIXED_PIN, + }).success, + ).toBe(false); + expect( + BluetoothValidationSchema.safeParse({ + ...base, + fixedPin: 123456, + mode: Protobuf.Config.Config_BluetoothConfig_PairingMode.FIXED_PIN, + }).success, + ).toBe(true); + expect( + BluetoothValidationSchema.safeParse({ + ...base, + mode: Protobuf.Config.Config_BluetoothConfig_PairingMode.RANDOM_PIN, + }).success, + ).toBe(true); + }); +}); 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..27a372445 --- /dev/null +++ b/apps/web/src/components/PageComponents/Settings/LoRa.test.tsx @@ -0,0 +1,129 @@ +import { create, fromBinary } from "@bufbuild/protobuf"; +import { CurrentDeviceContext, useDeviceStore } from "@core/stores"; +import { MeshClient, MeshRegistry, Protobuf } from "@meshtastic/sdk"; +import { createFakeTransport } from "@meshtastic/sdk/testing"; +import { MeshRegistryProvider } from "@meshtastic/sdk-react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Suspense } from "react"; +import { describe, expect, it } from "vitest"; +import { LoRa } from "./LoRa.tsx"; + +/** + * End-to-end cover for the radio/LoRa save path: the real form -> the Zod + * resolver -> `ConfigEditor` staging -> the AdminMessage bytes. + * + * The regression these guard against: `LoRaValidationSchema` required + * `serialHalOnly`, a protobuf field the bindings the app actually loads + * (`@meshtastic/sdk` -> `@meshtastic/protobufs`) do not necessarily have. The + * device's own LoRaConfig therefore failed validation on a field nobody + * touched, `DynamicForm`'s auto-save (`handleSubmit`) dropped every submit, + * and *no* LoRa change — region, Ok to MQTT, TX power — was ever staged, let + * alone transmitted. Nothing in the UI said so. + */ + +let deviceIdSeq = 700; + +function deviceLora() { + return create(Protobuf.Config.Config_LoRaConfigSchema, { + usePreset: true, + modemPreset: 0, + region: 3, + hopLimit: 3, + txEnabled: true, + txPower: 27, + channelNum: 20, + sx126xRxBoostedGain: true, + configOkToMqtt: false, + }); +} + +function setup(lora: Protobuf.Config.Config_LoRaConfig) { + const { transport } = createFakeTransport(); + const registry = new MeshRegistry(); + const client = new MeshClient({ transport }); + const connectionId = deviceIdSeq++; + registry.register(connectionId, client); + registry.setActive(connectionId); + + const sent: Protobuf.Admin.AdminMessage[] = []; + client.sendPacket = (async (payload: Uint8Array) => { + sent.push(fromBinary(Protobuf.Admin.AdminMessageSchema, payload)); + return 1; + }) as never; + + const packet = create(Protobuf.Config.ConfigSchema, { + payloadVariant: { case: "lora", value: lora }, + }); + const device = useDeviceStore.getState().addDevice(connectionId); + device.setConfig(packet); + client.events.onConfigPacket.dispatch(packet); + + render( + + + loading}> + {}} /> + + + , + ); + + return { client, editor: client.config.editor, sent }; +} + +function loraFromWire( + sent: Protobuf.Admin.AdminMessage[], +): Protobuf.Config.Config_LoRaConfig | undefined { + for (const admin of sent) { + if (admin.payloadVariant.case !== "setConfig") continue; + const variant = admin.payloadVariant.value.payloadVariant; + if (variant.case === "lora") return variant.value; + } + return undefined; +} + +describe("LoRa config", () => { + it("puts `configOkToMqtt: true` on the wire when 'Ok to MQTT' is switched on", async () => { + const { editor, sent } = setup(deviceLora()); + + await userEvent.click(await screen.findByLabelText("Ok to MQTT")); + + await waitFor(() => + expect(editor.dirtyRadioSections.value).toContain("lora"), + ); + expect((await editor.commit()).status).toBe("ok"); + + const wire = loraFromWire(sent); + expect(wire).toBeDefined(); + // Field 105 is a proto3 bool: `false` is absent from the encoding, so + // decoding `true` back off the wire is the only real proof. + expect(wire?.configOkToMqtt).toBe(true); + // Everything the user did not touch must survive the round-trip. + expect(wire?.region).toBe(3); + expect(wire?.txPower).toBe(27); + expect(wire?.hopLimit).toBe(3); + expect(wire?.channelNum).toBe(20); + expect(wire?.sx126xRxBoostedGain).toBe(true); + expect(wire?.usePreset).toBe(true); + }); + + it("stages a LoRa change even when the device predates a schema field", async () => { + // `serialHalOnly` is absent from the message the device sent (older + // bindings). The form must still save. + const lora = deviceLora(); + expect( + (lora as unknown as Record).serialHalOnly, + ).toBeUndefined(); + + const { editor, sent } = setup(lora); + + await userEvent.click(await screen.findByLabelText("Transmit Enabled")); + + await waitFor(() => + expect(editor.dirtyRadioSections.value).toContain("lora"), + ); + expect((await editor.commit()).status).toBe("ok"); + expect(loraFromWire(sent)?.txEnabled).toBe(false); + }); +}); diff --git a/apps/web/src/validation/channel.test.ts b/apps/web/src/validation/channel.test.ts index 4a8e64059..4fbf874d9 100644 --- a/apps/web/src/validation/channel.test.ts +++ b/apps/web/src/validation/channel.test.ts @@ -83,10 +83,25 @@ describe("makeChannelSchema", () => { } }); - it("rejects channelNum out of range", () => { + // `ChannelSettings.channel_num` is a deprecated uint32 LoRa frequency slot, + // not the 0-7 channel index. The form never renders it, so rejecting a real + // device value here made the whole channel form invalid — and an invalid + // form auto-saves nothing, with no error shown anywhere. + it("accepts any uint32 channelNum the device reports", () => { + for (const channelNum of [0, 8, 20, 104, 0xff_ff_ff_ff]) { + const result = schema.safeParse({ + index: 0, + settings: { ...validSettings, channelNum }, + role: mockRole, + }); + expect(result.success).toBe(true); + } + }); + + it("rejects a negative channelNum", () => { const result = schema.safeParse({ index: 0, - settings: { ...validSettings, channelNum: 10 }, + settings: { ...validSettings, channelNum: -1 }, role: mockRole, }); expect(result.success).toBe(false); @@ -125,8 +140,25 @@ describe("makeChannelSchema", () => { } }); + // The dropdown only offers 0 / 10-19 / 32, but the firmware stores any bit + // count in 0-32. Rejecting an in-range value the device already holds would + // block every save on the channel, so only genuinely impossible values fail. + it("accepts any positionPrecision the firmware can store", () => { + for (const val of [9, 20, 31]) { + const result = schema.safeParse({ + index: 0, + settings: { + ...validSettings, + moduleSettings: { positionPrecision: val }, + }, + role: mockRole, + }); + expect(result.success).toBe(true); + } + }); + it("rejects moduleSettings.positionPrecision out of range", () => { - for (const val of [9, 20, 31, 33]) { + for (const val of [-1, 33, 64]) { const result = schema.safeParse({ index: 0, settings: { @@ -138,4 +170,19 @@ describe("makeChannelSchema", () => { expect(result.success).toBe(false); } }); + + it("round-trips moduleSettings.isMuted instead of dropping it", () => { + const result = schema.safeParse({ + index: 0, + settings: { + ...validSettings, + moduleSettings: { positionPrecision: 10, isMuted: true }, + }, + role: mockRole, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.settings.moduleSettings.isMuted).toBe(true); + } + }); }); diff --git a/apps/web/src/validation/channel.ts b/apps/web/src/validation/channel.ts index 2dac116c3..e64f63d37 100644 --- a/apps/web/src/validation/channel.ts +++ b/apps/web/src/validation/channel.ts @@ -6,18 +6,31 @@ import { makePskHelpers } from "./pskSchema.ts"; const RoleEnum = z.enum(Protobuf.Channel.Channel_Role); const moduleSettingsSchema = z.object({ - positionPrecision: z.union([ - z.literal(0), - z.coerce.number().int().min(10).max(19), - z.literal(32), - ]), + // `position_precision` is a uint32 the firmware treats as a bit count + // (0 = disabled, 32 = precise). The dropdown only offers the useful values, + // but the schema must still accept whatever the device reports: a value it + // rejects makes the whole channel form permanently invalid, and because + // `DynamicForm` auto-saves through `handleSubmit`, an invalid form silently + // stages nothing at all — the toggle flips in the UI and never reaches the + // radio. + positionPrecision: z.coerce.number().int().min(0).max(32), + // Round-trip the mute flag instead of dropping it: the resolver output is + // what gets serialised, so an undeclared field is reset to `false` on the + // device every time any channel setting is saved. + isMuted: z.boolean().optional(), }); export function makeChannelSchema(allowedBytes: number) { const { stringSchema } = makePskHelpers([allowedBytes]); const ChannelSettingsSchema = z.object({ - channelNum: z.coerce.number().int().min(0).max(7), + // `ChannelSettings.channel_num` is a deprecated uint32 (a LoRa frequency + // slot, superseded by `LoRaConfig.channel_num`) — not the 0-7 channel + // *index*. It is not rendered by this form, so capping it at 7 turned any + // device or imported channel set carrying a real slot number into a form + // that could never validate, and therefore never save, with no visible + // error anywhere in the UI. + channelNum: z.coerce.number().int().min(0).max(0xff_ff_ff_ff), psk: stringSchema(false), name: z.string().refine((s) => validateMaxByteLength(s, 12).isValid, { message: "formValidation.tooBig.bytes", diff --git a/apps/web/src/validation/config/bluetooth.ts b/apps/web/src/validation/config/bluetooth.ts index 7ae02b236..27ba7cf42 100644 --- a/apps/web/src/validation/config/bluetooth.ts +++ b/apps/web/src/validation/config/bluetooth.ts @@ -5,10 +5,27 @@ 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), -}); +export const BluetoothValidationSchema = z + .object({ + enabled: z.boolean(), + mode: PairingModeEnum, + // A device that has never used FIXED_PIN pairing reports `fixed_pin: 0`. + // Demanding six digits unconditionally made that device's Bluetooth form + // permanently invalid, and `DynamicForm` auto-saves through + // `handleSubmit`, so *nothing* on the tab could be staged or saved. The + // six-digit rule only actually applies to FIXED_PIN pairing. + fixedPin: z.coerce.number().int().min(0).max(999999), + }) + .refine( + (config) => + config.mode !== + Protobuf.Config.Config_BluetoothConfig_PairingMode.FIXED_PIN || + (config.fixedPin >= 100000 && config.fixedPin <= 999999), + { + message: "formValidation.tooSmall.number", + path: ["fixedPin"], + params: { minimum: 100000 }, + }, + ); export type BluetoothValidation = z.infer; diff --git a/apps/web/src/validation/config/lora.ts b/apps/web/src/validation/config/lora.ts index 075aec385..1ecec02e9 100644 --- a/apps/web/src/validation/config/lora.ts +++ b/apps/web/src/validation/config/lora.ts @@ -28,7 +28,17 @@ export const LoRaValidationSchema = z.object({ ignoreMqtt: z.boolean(), configOkToMqtt: z.boolean(), femLnaMode: FemLnaModeEnum, - serialHalOnly: z.boolean(), + // `serial_hal_only` (field 107) only exists in firmware-current protobufs. + // The bindings the app actually loads at runtime (`@meshtastic/sdk` -> + // `@meshtastic/protobufs`) may predate it, in which case the device's + // LoRaConfig has no such key and a required `z.boolean()` fails on *every* + // parse. Because `DynamicForm` auto-saves through `handleSubmit`, that one + // failing field silently blocks the whole LoRa form: no `setRadioSection` + // call, nothing staged, nothing transmitted — the save looks fine and the + // radio never changes. Optional keeps the field editable where it exists + // without gating the rest of the form where it does not; `ConfigEditor` + // merges the omitted key back from the device value before transmitting. + serialHalOnly: z.boolean().optional(), }); export type LoRaValidation = 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..e8881a722 --- /dev/null +++ b/apps/web/src/validation/formSchemaCoverage.test.ts @@ -0,0 +1,277 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { fromByteArray } from "base64-js"; +import { describe, expect, it } from "vitest"; +import { makeChannelSchema } from "./channel.ts"; +import { BluetoothValidationSchema } from "./config/bluetooth.ts"; +import { DeviceValidationSchema } 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 } 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 { StatusMessageValidationSchema } from "./moduleConfig/statusMessage.ts"; +import { StoreForwardValidationSchema } from "./moduleConfig/storeForward.ts"; +import { TakValidationSchema } from "./moduleConfig/tak.ts"; +import { TelemetryValidationSchema } from "./moduleConfig/telemetry.ts"; +import { TrafficManagementValidationSchema } from "./moduleConfig/trafficManagement.ts"; + +/** + * Guards a whole class of silent save failures. + * + * Every settings form auto-saves through `handleSubmit`, so a validation + * failure — on *any* field, including ones the form never renders — makes the + * form stage nothing at all. No error is shown next to anything the user can + * fix, the toggle still moves, and "Save" then commits a real begin/commit + * transaction that simply does not contain the change. + * + * The precondition for a form to be savable is therefore: its schema must + * accept the config the device itself reported. These tests parse exactly what + * each form feeds its resolver — the protobuf message, with the component's + * own normalisations applied where it has any. + */ + +type Schema = { + safeParse(value: unknown): { success: boolean; error?: unknown }; +}; + +function expectAccepts(schema: Schema, value: unknown, label: string): void { + const result = schema.safeParse(value); + if (!result.success) { + const issues = ( + result.error as { issues: Array<{ path: unknown[]; message: string }> } + ).issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`); + throw new Error( + `${label} rejects the value the device reported: ${issues.join("; ")}`, + ); + } + expect(result.success).toBe(true); +} + +/** Sections whose form hands the protobuf message straight to the resolver. */ +const directSections: Array<[string, Schema, unknown]> = [ + [ + "config/lora", + LoRaValidationSchema, + Protobuf.Config.Config_LoRaConfigSchema, + ], + [ + "config/device", + DeviceValidationSchema, + Protobuf.Config.Config_DeviceConfigSchema, + ], + [ + "config/display", + DisplayValidationSchema, + Protobuf.Config.Config_DisplayConfigSchema, + ], + [ + "config/position", + PositionValidationSchema, + Protobuf.Config.Config_PositionConfigSchema, + ], + [ + "config/power", + PowerValidationSchema, + Protobuf.Config.Config_PowerConfigSchema, + ], + [ + "module/serial", + SerialValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_SerialConfigSchema, + ], + [ + "module/externalNotification", + ExternalNotificationValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfigSchema, + ], + [ + "module/storeForward", + StoreForwardValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfigSchema, + ], + [ + "module/rangeTest", + RangeTestValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_RangeTestConfigSchema, + ], + [ + "module/telemetry", + TelemetryValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_TelemetryConfigSchema, + ], + [ + "module/cannedMessage", + CannedMessageValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfigSchema, + ], + [ + "module/audio", + AudioValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_AudioConfigSchema, + ], + [ + "module/neighborInfo", + NeighborInfoValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfigSchema, + ], + [ + "module/ambientLighting", + AmbientLightingValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfigSchema, + ], + [ + "module/detectionSensor", + DetectionSensorValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfigSchema, + ], + [ + "module/paxcounter", + PaxcounterValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfigSchema, + ], + [ + "module/remoteHardware", + RemoteHardwareValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_RemoteHardwareConfigSchema, + ], + [ + "module/trafficManagement", + TrafficManagementValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_TrafficManagementConfigSchema, + ], + [ + "module/statusMessage", + StatusMessageValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfigSchema, + ], + [ + "module/tak", + TakValidationSchema, + Protobuf.ModuleConfig.ModuleConfig_TAKConfigSchema, + ], +]; + +describe("form schemas accept what the device reports", () => { + for (const [label, schema, messageSchema] of directSections) { + it(`${label} accepts a factory-default message`, () => { + expectAccepts(schema, create(messageSchema as never), label); + }); + } + + it("config/bluetooth accepts a device that never set a fixed PIN", () => { + expectAccepts( + BluetoothValidationSchema, + create(Protobuf.Config.Config_BluetoothConfigSchema, { + enabled: true, + mode: Protobuf.Config.Config_BluetoothConfig_PairingMode.RANDOM_PIN, + fixedPin: 0, + }), + "config/bluetooth", + ); + }); + + it("config/network accepts the shape the Network form builds", () => { + const cfg = create(Protobuf.Config.Config_NetworkConfigSchema, {}); + expectAccepts( + NetworkValidationSchema, + { + ...cfg, + // Network/index.tsx converts the packed IPs to dotted strings and + // defaults the protocol flags before handing them to the resolver. + ipv4Config: { + ip: "0.0.0.0", + gateway: "0.0.0.0", + subnet: "0.0.0.0", + dns: "0.0.0.0", + }, + enabledProtocols: + Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, + }, + "config/network", + ); + }); + + it("module/mqtt accepts a device with no map-report settings", () => { + const cfg = create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, { + enabled: false, + address: "mqtt.example.org", + }); + expectAccepts( + MqttValidationSchema, + { + ...cfg, + // MQTT.tsx supplies the sub-message the device may omit. + mapReportSettings: { + publishIntervalSecs: 3600, + positionPrecision: 32, + shouldReportLocation: true, + }, + }, + "module/mqtt", + ); + }); + + const channelCases: Array<[string, Protobuf.Channel.ChannelSettings]> = [ + [ + "factory primary (1-byte default PSK, no module settings)", + create(Protobuf.Channel.ChannelSettingsSchema, { + psk: new Uint8Array([1]), + }), + ], + [ + "a device carrying the deprecated channel_num slot", + create(Protobuf.Channel.ChannelSettingsSchema, { + psk: new Uint8Array([1]), + channelNum: 20, + id: 3123456789, + }), + ], + [ + "a muted channel with a 32-byte PSK", + create(Protobuf.Channel.ChannelSettingsSchema, { + psk: new Uint8Array(32).fill(7), + name: "LongFast", + moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, { + positionPrecision: 13, + isMuted: true, + }), + }), + ], + ]; + + for (const [label, settings] of channelCases) { + it(`channel accepts ${label}`, () => { + // Channel.tsx base64-encodes the PSK, sizes the schema from its byte + // length and defaults the position precision. + expectAccepts( + makeChannelSchema(settings.psk.length), + { + index: 0, + role: Protobuf.Channel.Channel_Role.PRIMARY, + settings: { + ...settings, + psk: fromByteArray(settings.psk), + moduleSettings: { + ...settings.moduleSettings, + positionPrecision: + settings.moduleSettings?.positionPrecision ?? 10, + }, + }, + }, + `channel (${label})`, + ); + }); + } +});