diff --git a/apps/web/public/i18n/locales/en/ui.json b/apps/web/public/i18n/locales/en/ui.json index 80d0f2edd..802a113e8 100644 --- a/apps/web/public/i18n/locales/en/ui.json +++ b/apps/web/public/i18n/locales/en/ui.json @@ -67,6 +67,10 @@ "title": "Saved", "description": "All configuration changes have been saved." }, + "savePending": { + "title": "Saved", + "description": "Changes made while saving haven't been sent yet. Save again to send them." + }, "favoriteNode": { "title": "{{action}} {{nodeName}} {{direction}} favorites.", "action": { 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..a51aacd1b --- /dev/null +++ b/apps/web/src/components/PageComponents/ModuleConfig/MQTT.test.tsx @@ -0,0 +1,87 @@ +import { create } from "@bufbuild/protobuf"; +import { renderSettingsForm } from "@app/tests/renderSettingsForm.tsx"; +import { Protobuf } from "@meshtastic/sdk"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { MQTT } from "./MQTT.tsx"; + +// Protobuf omits default booleans on the wire, so these tests decode what was +// actually sent rather than trusting the editor's state. + +const deviceMqtt = () => + create(Protobuf.ModuleConfig.ModuleConfigSchema, { + payloadVariant: { + case: "mqtt", + value: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, { + enabled: false, + address: "mqtt.example.org", + username: "meshdev", + password: "large4cats", + root: "msh", + }), + }, + }); + +const renderMqtt = () => + renderSettingsForm( {}} />, { + moduleConfig: [deviceMqtt()], + }); + +describe("MQTT module config", () => { + it("puts `enabled: true` on the wire when the toggle is switched on", async () => { + const { editor, radio } = renderMqtt(); + + await userEvent.click(await screen.findByLabelText("MQTT Enabled")); + await waitFor(() => + expect(editor.dirtyModuleSections.value).toContain("mqtt"), + ); + expect((await editor.commit()).status).toBe("ok"); + + const wire = radio.sentModuleConfig("mqtt"); + expect(wire?.enabled).toBe(true); + // Fields the user didn't touch 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("keeps the toggle pending when it was flipped during another save", async () => { + const { client, editor, radio } = renderMqtt(); + 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 }), + ); + + const held = radio.hold("commitEditSettings"); + const pending = editor.commit(); + await held.reached; + + await userEvent.click(await screen.findByLabelText("MQTT Enabled")); + await waitFor(() => + expect(editor.dirtyModuleSections.value).toContain("mqtt"), + ); + held.release(); + expect((await pending).status).toBe("ok"); + + expect(radio.cases()).toEqual([ + "beginEditSettings", + "setConfig", + "commitEditSettings", + ]); + expect(editor.dirtyModuleSections.value).toContain("mqtt"); + + expect((await editor.commit()).status).toBe("ok"); + expect(radio.sentModuleConfig("mqtt")?.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..c0c5b2f74 --- /dev/null +++ b/apps/web/src/components/PageComponents/Settings/DeviceConfig.test.tsx @@ -0,0 +1,128 @@ +import { create } from "@bufbuild/protobuf"; +import { renderSettingsForm } from "@app/tests/renderSettingsForm.tsx"; +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 { Protobuf } from "@meshtastic/sdk"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +// Protobuf omits default booleans on the wire, so these tests decode what was +// actually sent rather than trusting the editor's state. + +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, + }), + }, + }); + +const primaryChannel = (uplinkEnabled: boolean) => + create(Protobuf.Channel.ChannelSchema, { + index: 0, + role: Protobuf.Channel.Channel_Role.PRIMARY, + settings: create(Protobuf.Channel.ChannelSettingsSchema, { + psk: new Uint8Array([1]), + uplinkEnabled, + }), + }); + +const renderDevice = () => + renderSettingsForm( {}} />, { + config: [deviceConfig()], + }); + +describe("Device settings", () => { + it("puts a toggled device-config flag on the wire", async () => { + const { editor, radio } = renderDevice(); + + await userEvent.click(await screen.findByLabelText("Double Tap as Button")); + await waitFor(() => + expect(editor.dirtyRadioSections.value).toContain("device"), + ); + expect((await editor.commit()).status).toBe("ok"); + + const wire = radio.sentConfig("device"); + expect(wire?.doubleTapAsButtonPress).toBe(true); + 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("keeps the toggle pending when it was flipped during another save", async () => { + const { client, editor, radio } = renderDevice(); + client.events.onChannelPacket.dispatch(primaryChannel(false)); + editor.setChannel(primaryChannel(true)); + + const held = radio.hold("commitEditSettings"); + const pending = editor.commit(); + await held.reached; + + await userEvent.click(await screen.findByLabelText("Double Tap as Button")); + await waitFor(() => + expect(editor.dirtyRadioSections.value).toContain("device"), + ); + held.release(); + expect((await pending).status).toBe("ok"); + + expect(radio.cases()).toEqual([ + "beginEditSettings", + "setChannel", + "commitEditSettings", + ]); + expect(editor.dirtyRadioSections.value).toContain("device"); + + expect((await editor.commit()).status).toBe("ok"); + expect(radio.sentConfig("device")?.doubleTapAsButtonPress).toBe(true); + expect(editor.isDirty.value).toBe(false); + }); + + it("saves Bluetooth on a device that has no fixed PIN", async () => { + // Radios that never used FIXED_PIN pairing report a PIN of 0. + const { editor, radio } = renderSettingsForm( + {}} />, + { config: [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(radio.sentConfig("bluetooth")?.enabled).toBe(true); + }); + + it("still requires a six-digit PIN for FIXED_PIN pairing", () => { + const { FIXED_PIN, RANDOM_PIN } = + Protobuf.Config.Config_BluetoothConfig_PairingMode; + const parse = (mode: number, fixedPin: number) => + BluetoothValidationSchema.safeParse({ enabled: true, mode, fixedPin }) + .success; + + expect(parse(FIXED_PIN, 0)).toBe(false); + expect(parse(FIXED_PIN, 123456)).toBe(true); + expect(parse(RANDOM_PIN, 0)).toBe(true); + }); +}); diff --git a/apps/web/src/pages/Settings/index.tsx b/apps/web/src/pages/Settings/index.tsx index a66be6552..2ad84c168 100644 --- a/apps/web/src/pages/Settings/index.tsx +++ b/apps/web/src/pages/Settings/index.tsx @@ -313,6 +313,16 @@ const ConfigPage = () => { if (result.status === "error") { throw result.error; } + + // Edits made while the save was in flight are still pending. + if (editor.isDirty.value) { + toast({ + title: t("toast.savePending.title"), + description: t("toast.savePending.description"), + }); + return; + } + toast({ title: t("toast.saveAllSuccess.title"), description: t("toast.saveAllSuccess.description"), diff --git a/apps/web/src/tests/renderSettingsForm.tsx b/apps/web/src/tests/renderSettingsForm.tsx new file mode 100644 index 000000000..6efc689fd --- /dev/null +++ b/apps/web/src/tests/renderSettingsForm.tsx @@ -0,0 +1,64 @@ +import { CurrentDeviceContext, useDeviceStore } from "@core/stores"; +import { MeshClient, MeshRegistry, type Protobuf } from "@meshtastic/sdk"; +import { + createFakeTransport, + interceptAdminMessages, +} from "@meshtastic/sdk/testing"; +import { MeshRegistryProvider } from "@meshtastic/sdk-react"; +import { cleanup, render } from "@testing-library/react"; +import { type ReactElement, Suspense } from "react"; +import { afterEach } from "vitest"; + +let nextDeviceId = 1000; +const teardowns: (() => void)[] = []; + +afterEach(() => { + // Unmount before removing the device the form reads from. + cleanup(); + for (const teardown of teardowns.splice(0)) teardown(); +}); + +/** + * Render a settings form against a connected fake radio, with `device` as the + * config it has reported. Torn down after each test. + */ +export function renderSettingsForm( + ui: ReactElement, + device: { + config?: Protobuf.Config.Config[]; + moduleConfig?: Protobuf.ModuleConfig.ModuleConfig[]; + }, +) { + const { transport } = createFakeTransport(); + const registry = new MeshRegistry(); + const client = new MeshClient({ transport }); + const id = nextDeviceId++; + registry.register(id, client); + registry.setActive(id); + const radio = interceptAdminMessages(client); + + const stored = useDeviceStore.getState().addDevice(id); + for (const config of device.config ?? []) { + stored.setConfig(config); + client.events.onConfigPacket.dispatch(config); + } + for (const moduleConfig of device.moduleConfig ?? []) { + stored.setModuleConfig(moduleConfig); + client.events.onModuleConfigPacket.dispatch(moduleConfig); + } + + teardowns.push(() => { + registry.unregister(id); + useDeviceStore.getState().removeDevice(id); + }); + + render( + + + loading}>{ui} + + , + ); + + return { client, editor: client.config.editor, radio }; +} diff --git a/packages/sdk/src/core/testing/index.ts b/packages/sdk/src/core/testing/index.ts index 67af02a73..e6c482a73 100644 --- a/packages/sdk/src/core/testing/index.ts +++ b/packages/sdk/src/core/testing/index.ts @@ -1,4 +1,9 @@ export { createFakeTransport } from "./createFakeTransport.ts"; +export { + type AdminMessageInterceptor, + type HeldSend, + interceptAdminMessages, +} from "./interceptAdminMessages.ts"; export type { FakeResponder, FakeTransportHandle, diff --git a/packages/sdk/src/core/testing/interceptAdminMessages.ts b/packages/sdk/src/core/testing/interceptAdminMessages.ts new file mode 100644 index 000000000..81cea27f9 --- /dev/null +++ b/packages/sdk/src/core/testing/interceptAdminMessages.ts @@ -0,0 +1,115 @@ +import { fromBinary } from "@bufbuild/protobuf"; +import * as Protobuf from "@meshtastic/protobufs"; +import type { MeshClient } from "../client/MeshClient.ts"; + +type AdminCase = NonNullable< + Protobuf.Admin.AdminMessage["payloadVariant"]["case"] +>; +type ConfigVariant = Protobuf.Config.Config["payloadVariant"]; +type ModuleConfigVariant = Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]; +type ConfigCase = NonNullable; +type ModuleConfigCase = NonNullable; +type ConfigValue = Extract< + ConfigVariant, + { case: K } +>["value"]; +type ModuleConfigValue = Extract< + ModuleConfigVariant, + { case: K } +>["value"]; + +export interface HeldSend { + /** Resolves once the send is being held. */ + reached: Promise; + release(): void; +} + +export interface AdminMessageInterceptor { + /** Every admin message sent, decoded, in order. */ + sent: Protobuf.Admin.AdminMessage[]; + cases(): (AdminCase | undefined)[]; + /** Hold the next send of `adminCase` open until released. */ + hold(adminCase: AdminCase): HeldSend; + /** Make the next send of `adminCase` fail. */ + failNext(adminCase: AdminCase): void; + sentConfig(section: K): ConfigValue | undefined; + sentModuleConfig( + section: K, + ): ModuleConfigValue | undefined; +} + +/** + * Replace `client.sendPacket` so a test can see the admin messages a commit + * sends, and pause or fail individual sends. + */ +export function interceptAdminMessages( + client: MeshClient, +): AdminMessageInterceptor { + const sent: Protobuf.Admin.AdminMessage[] = []; + const holds: { + adminCase: AdminCase; + onReached: () => void; + released: Promise; + }[] = []; + const failures: AdminCase[] = []; + + client.sendPacket = (async (payload: Uint8Array) => { + const admin = fromBinary(Protobuf.Admin.AdminMessageSchema, payload); + sent.push(admin); + const adminCase = admin.payloadVariant.case; + + const failure = failures.indexOf(adminCase as AdminCase); + if (failure !== -1) { + failures.splice(failure, 1); + throw new Error(`send failed: ${adminCase}`); + } + + const index = holds.findIndex((h) => h.adminCase === adminCase); + const hold = index === -1 ? undefined : holds.splice(index, 1)[0]; + if (hold) { + hold.onReached(); + await hold.released; + } + return 1; + }) as MeshClient["sendPacket"]; + + return { + sent, + cases: () => sent.map((admin) => admin.payloadVariant.case), + hold(adminCase) { + let onReached!: () => void; + let release!: () => void; + const reached = new Promise((resolve) => { + onReached = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + holds.push({ adminCase, onReached, released }); + return { reached, release }; + }, + failNext(adminCase) { + failures.push(adminCase); + }, + sentConfig(section: K) { + 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 ConfigValue; + } + } + return undefined; + }, + sentModuleConfig(section: K) { + for (const admin of sent) { + if (admin.payloadVariant.case !== "setModuleConfig") continue; + const variant = admin.payloadVariant.value.payloadVariant; + if (variant.case === section) { + return variant.value as ModuleConfigValue; + } + } + return undefined; + }, + }; +} diff --git a/packages/sdk/src/features/config/ConfigEditor.test.ts b/packages/sdk/src/features/config/ConfigEditor.test.ts index 09d5a4901..b8ecbf0c1 100644 --- a/packages/sdk/src/features/config/ConfigEditor.test.ts +++ b/packages/sdk/src/features/config/ConfigEditor.test.ts @@ -3,6 +3,7 @@ import * as Protobuf from "@meshtastic/protobufs"; import { describe, expect, it } from "vitest"; import { MeshClient } from "../../core/client/MeshClient.ts"; import { createFakeTransport } from "../../core/testing/createFakeTransport.ts"; +import { interceptAdminMessages } from "../../core/testing/interceptAdminMessages.ts"; import { DeviceStatusEnum } from "../../core/transport/Transport.ts"; function loraPacket(region: number): Protobuf.Config.Config { @@ -198,6 +199,28 @@ describe("ConfigEditor", () => { expect(editor.isDirty.value).toBe(false); }); + it("an undefined section reverts to the baseline", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const editor = client.config.editor; + const radio = interceptAdminMessages(client); + + client.events.onConfigPacket.dispatch(loraPacket(4)); + client.events.onModuleConfigPacket.dispatch(mqttPacket(false)); + editor.setRadioSection( + "lora", + create(Protobuf.Config.Config_LoRaConfigSchema, { region: 7 }), + ); + editor.setRadioSection("lora", undefined); + editor.setModuleSection("mqtt", undefined); + editor.setRadioSection("position", undefined); + + expect(editor.radio.value.lora?.region).toBe(4); + expect(editor.isDirty.value).toBe(false); + expect((await editor.commit()).status).toBe("ok"); + expect(radio.sent).toEqual([]); + }); + it("disconnect drops baseline and working", () => { const { transport } = createFakeTransport(); const client = new MeshClient({ transport }); @@ -216,3 +239,161 @@ describe("ConfigEditor", () => { expect(editor.isDirty.value).toBe(false); }); }); + +const LORA = "lora" as const; + +/** A connected editor with lora edited from region 1 to 4. */ +function setupCommit() { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const editor = client.config.editor; + const radio = interceptAdminMessages(client); + + client.events.onConfigPacket.dispatch(loraPacket(1)); + client.events.onModuleConfigPacket.dispatch(mqttPacket(false)); + client.events.onChannelPacket.dispatch(channelPacket(1, "primary")); + editor.setRadioSection( + LORA, + create(Protobuf.Config.Config_LoRaConfigSchema, { region: 4 }), + ); + return { client, editor, radio }; +} + +const enableMqtt = () => + create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, { + enabled: true, + }); + +const user = (longName: string) => + create(Protobuf.Mesh.UserSchema, { id: "!00000001", longName }); + +describe("ConfigEditor.commit", () => { + it("keeps an edit made while a commit is in flight dirty", async () => { + const { editor, radio } = setupCommit(); + const held = radio.hold("commitEditSettings"); + + const pending = editor.commit(); + await held.reached; + editor.setModuleSection("mqtt", enableMqtt()); + held.release(); + expect((await pending).status).toBe("ok"); + + expect(editor.dirtyRadioSections.value).toEqual([]); + expect(editor.dirtyModuleSections.value).toEqual(["mqtt"]); + expect(radio.sentModuleConfig("mqtt")).toBeUndefined(); + + expect((await editor.commit()).status).toBe("ok"); + expect(radio.sentModuleConfig("mqtt")?.enabled).toBe(true); + expect(editor.isDirty.value).toBe(false); + }); + + it("keeps device packets for unsent sections that arrive during a commit", async () => { + const { client, editor, radio } = setupCommit(); + const held = radio.hold("commitEditSettings"); + + const pending = editor.commit(); + await held.reached; + client.events.onModuleConfigPacket.dispatch(mqttPacket(true)); + client.events.onChannelPacket.dispatch(channelPacket(1, "renamed")); + held.release(); + expect((await pending).status).toBe("ok"); + + expect(editor.isDirty.value).toBe(false); + expect(editor.modules.value.mqtt?.enabled).toBe(true); + expect(editor.channels.value.get(1)?.settings?.name).toBe("renamed"); + }); + + it("keeps an owner update that arrives during a commit", async () => { + const { editor, radio } = setupCommit(); + editor.setBaselineOwner(user("Before")); + const held = radio.hold("commitEditSettings"); + + const pending = editor.commit(); + await held.reached; + editor.setBaselineOwner(user("After")); + held.release(); + expect((await pending).status).toBe("ok"); + + expect(editor.isOwnerDirty.value).toBe(false); + expect(editor.owner.value?.longName).toBe("After"); + expect(radio.cases()).not.toContain("setOwner"); + }); + + it("leaves the editor empty when the device disconnects during a commit", async () => { + const { client, editor, radio } = setupCommit(); + const held = radio.hold("commitEditSettings"); + + const pending = editor.commit(); + await held.reached; + client.events.onDeviceStatus.dispatch(DeviceStatusEnum.DeviceDisconnected); + held.release(); + expect((await pending).status).toBe("error"); + + expect(editor.radio.value).toEqual({}); + expect(editor.isDirty.value).toBe(false); + client.events.onConfigPacket.dispatch(loraPacket(1)); + expect(editor.isDirty.value).toBe(false); + }); + + it("sends a message queued again during a commit", async () => { + const { editor, radio } = setupCommit(); + const message = create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { case: "removeFixedPosition", value: true }, + }); + editor.queueAdminMessage(message); + const held = radio.hold("commitEditSettings"); + + const pending = editor.commit(); + await held.reached; + editor.queueAdminMessage(message); + held.release(); + expect((await pending).status).toBe("ok"); + expect(editor.isDirty.value).toBe(true); + + expect((await editor.commit()).status).toBe("ok"); + expect( + radio.cases().filter((c) => c === "removeFixedPosition"), + ).toHaveLength(2); + expect(editor.isDirty.value).toBe(false); + }); + + it("runs a commit requested during another after it finishes", async () => { + const { editor, radio } = setupCommit(); + const held = radio.hold("commitEditSettings"); + + const first = editor.commit(); + await held.reached; + editor.setModuleSection("mqtt", enableMqtt()); + const second = editor.commit(); + held.release(); + + expect((await first).status).toBe("ok"); + expect((await second).status).toBe("ok"); + expect(radio.cases()).toEqual([ + "beginEditSettings", + "setConfig", + "commitEditSettings", + "beginEditSettings", + "setModuleConfig", + "commitEditSettings", + ]); + expect(editor.isDirty.value).toBe(false); + }); + + it("closes the transaction and stays dirty when a send fails", async () => { + const { editor, radio } = setupCommit(); + editor.setModuleSection("mqtt", enableMqtt()); + radio.failNext("setModuleConfig"); + + expect((await editor.commit()).status).toBe("error"); + + expect(radio.cases()).toEqual([ + "beginEditSettings", + "setConfig", + "setModuleConfig", + "commitEditSettings", + ]); + expect(editor.dirtyRadioSections.value).toEqual([LORA]); + expect(editor.dirtyModuleSections.value).toEqual(["mqtt"]); + }); +}); diff --git a/packages/sdk/src/features/config/domain/ConfigEditor.ts b/packages/sdk/src/features/config/domain/ConfigEditor.ts index 38a0872b5..b0f092892 100644 --- a/packages/sdk/src/features/config/domain/ConfigEditor.ts +++ b/packages/sdk/src/features/config/domain/ConfigEditor.ts @@ -107,6 +107,10 @@ export class ConfigEditor { message: Protobuf.Admin.AdminMessage, ) => void; + private commitQueue: Promise = Promise.resolve(); + /** Bumped on disconnect so an in-flight commit can tell. */ + private connectionGeneration = 0; + constructor( client: MeshClient, options: { @@ -172,6 +176,7 @@ export class ConfigEditor { client.events.onDeviceStatus.subscribe((status) => { if (status === DeviceStatusEnum.DeviceDisconnected) { + this.connectionGeneration++; this.baselineRadio.value = {}; this.baselineModules.value = {}; this.baselineChannels.value = new Map(); @@ -249,17 +254,24 @@ export class ConfigEditor { /** * 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. + * value instead of being sent as protobuf defaults. `undefined` reverts the + * section to the baseline. */ public setRadioSection( key: K, value: RadioConfig[K], ): void { const working = this.workingRadio.peek(); - const current = working[key] ?? this.baselineRadio.peek()[key]; + const baseline = this.baselineRadio.peek()[key]; + const current = working[key] ?? baseline; this.workingRadio.value = { ...working, - [key]: current && value ? { ...current, ...value } : value, + [key]: + value === undefined + ? baseline + : current + ? { ...current, ...value } + : value, }; this.recomputeDirty(); } @@ -270,10 +282,16 @@ export class ConfigEditor { value: ModuleConfig[K], ): void { const working = this.workingModules.peek(); - const current = working[key] ?? this.baselineModules.peek()[key]; + const baseline = this.baselineModules.peek()[key]; + const current = working[key] ?? baseline; this.workingModules.value = { ...working, - [key]: current && value ? { ...current, ...value } : value, + [key]: + value === undefined + ? baseline + : current + ? { ...current, ...value } + : value, }; this.recomputeDirty(); } @@ -297,50 +315,119 @@ export class ConfigEditor { /** * Send all dirty sections to the device wrapped in a beginEdit/commitEdit - * pair. On success the baseline is replaced with the working copy - * (optimistic update); inbound config packets after commit will reconcile. + * pair. Commits run one at a time; edits made during a commit stay dirty + * for the next one. */ - public async commit(): Promise> { + public commit(): Promise> { + const run = this.commitQueue.then(() => this.runCommit()); + this.commitQueue = run.catch(() => undefined); + return run; + } + + private async runCommit(): Promise> { if (!this._isDirty.peek()) return Result.ok(undefined); + // Taken before the first await. Setters replace the working signals, so + // later edits never reach it. + const payload = this.collectPayload(); + // An empty transaction makes the radio rewrite its config and report + // success for nothing. + if (isEmptyPayload(payload)) return Result.ok(undefined); + const generation = this.connectionGeneration; + const beginResult = await beginEditSettings(this.client); if (Result.isError(beginResult)) return Result.err(beginResult.error); + const sendResult = await this.sendPayload(payload); + if (Result.isError(sendResult)) { + // There is no abort message. Close the transaction rather than leave it + // open; everything stays dirty, so the next commit resends it. + if (generation === this.connectionGeneration) { + await commitEditSettings(this.client); + } + return Result.err(sendResult.error); + } + + const commitResult = await commitEditSettings(this.client); + if (Result.isError(commitResult)) return Result.err(commitResult.error); + + // Disconnect already cleared the editor; don't carry this payload into + // the next connection. + if (generation !== this.connectionGeneration) { + return Result.err( + new Error("The device disconnected before the commit finished"), + ); + } + + this.reconcileAfterCommit(payload); + for (const message of payload.staged.values()) { + this.onStagedAdminMessageCommitted?.(message); + } + return Result.ok(undefined); + } + + private collectPayload(): CommitPayload { const radio = this.workingRadio.peek(); + const radioPayload: CommitPayload["radio"] = []; for (const variant of this._dirtyRadioSections.peek()) { const value = radio[variant]; - if (value === undefined) continue; - const config = buildRadioConfig(variant, value); - const result = await setConfig(this.client, config); - if (Result.isError(result)) return Result.err(result.error); + if (value !== undefined) radioPayload.push([variant, value]); } const modules = this.workingModules.peek(); + const modulePayload: CommitPayload["modules"] = []; for (const variant of this._dirtyModuleSections.peek()) { const value = modules[variant]; - if (value === undefined) continue; - const moduleConfig = buildModuleConfig(variant, value); - const result = await setModuleConfig(this.client, moduleConfig); - if (Result.isError(result)) return Result.err(result.error); + if (value !== undefined) modulePayload.push([variant, value]); } const channels = this.workingChannels.peek(); + const channelPayload: CommitPayload["channels"] = []; for (const index of this._dirtyChannels.peek()) { const channel = channels.get(index); - if (!channel) continue; + if (channel) channelPayload.push([index, channel]); + } + + return { + radio: radioPayload, + modules: modulePayload, + channels: channelPayload, + owner: this._isOwnerDirty.peek() ? this.workingOwner.peek() : undefined, + queued: this.queuedAdminMessages.peek(), + staged: this._stagedAdminMessages.peek(), + }; + } + + private async sendPayload( + payload: CommitPayload, + ): Promise> { + for (const [variant, value] of payload.radio) { + const result = await setConfig( + this.client, + buildRadioConfig(variant, value), + ); + if (Result.isError(result)) return Result.err(result.error); + } + + for (const [variant, value] of payload.modules) { + const result = await setModuleConfig( + this.client, + buildModuleConfig(variant, value), + ); + if (Result.isError(result)) return Result.err(result.error); + } + + for (const [, channel] of payload.channels) { const result = await setChannel(this.client, channel); if (Result.isError(result)) return Result.err(result.error); } - if (this._isOwnerDirty.peek()) { - const owner = this.workingOwner.peek(); - if (owner) { - const result = await setOwner(this.client, owner); - if (Result.isError(result)) return Result.err(result.error); - } + if (payload.owner) { + const result = await setOwner(this.client, payload.owner); + if (Result.isError(result)) return Result.err(result.error); } - for (const message of this.queuedAdminMessages.peek()) { + for (const message of [...payload.queued, ...payload.staged.values()]) { const variant = message.payloadVariant; if (!variant.case) continue; try { @@ -350,37 +437,60 @@ 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))); + return Result.ok(undefined); + } + + /** + * Make only what was sent the new baseline. Other sections keep theirs, + * including device packets that arrived during the commit. + */ + private reconcileAfterCommit(payload: CommitPayload): void { + if (payload.radio.length > 0) { + const baseline: Record = { + ...this.baselineRadio.peek(), + }; + for (const [variant, value] of payload.radio) baseline[variant] = value; + this.baselineRadio.value = baseline as RadioConfig; + } + + if (payload.modules.length > 0) { + const baseline: Record = { + ...this.baselineModules.peek(), + }; + for (const [variant, value] of payload.modules) baseline[variant] = value; + this.baselineModules.value = baseline as ModuleConfig; + } + + if (payload.channels.length > 0) { + const baseline = new Map(this.baselineChannels.peek()); + for (const [index, channel] of payload.channels) { + baseline.set(index, channel); } + this.baselineChannels.value = baseline; } - const commitResult = await commitEditSettings(this.client); - if (Result.isError(commitResult)) return Result.err(commitResult.error); + if (payload.owner) this.baselineOwner.value = payload.owner; - this.baselineRadio.value = this.workingRadio.peek(); - this.baselineModules.value = this.workingModules.peek(); - 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; + // One entry per message sent: the same object may have been queued again + // during the commit, and that copy hasn't been sent. + if (payload.queued.length > 0) { + const remaining = [...this.queuedAdminMessages.peek()]; + for (const message of payload.queued) { + const index = remaining.indexOf(message); + if (index !== -1) remaining.splice(index, 1); + } + this.queuedAdminMessages.value = remaining; + } - for (const message of staged) { - this.onStagedAdminMessageCommitted?.(message); + if (payload.staged.size > 0) { + const unsent = new Map(this._stagedAdminMessages.peek()); + for (const [key, message] of payload.staged) { + if (unsent.get(key) === message) unsent.delete(key); + } + this._stagedAdminMessages.value = unsent; } - return Result.ok(undefined); + this.recomputeDirty(); } private recomputeDirty(): void { @@ -454,6 +564,30 @@ export class ConfigEditor { } } +interface CommitPayload { + radio: [RadioConfigSection, NonNullable][]; + modules: [ + ModuleConfigSection, + NonNullable, + ][]; + channels: [number, Protobuf.Channel.Channel][]; + /** Set only when the owner was dirty. */ + owner: Protobuf.Mesh.User | undefined; + queued: readonly Protobuf.Admin.AdminMessage[]; + staged: ReadonlyMap; +} + +function isEmptyPayload(payload: CommitPayload): boolean { + return ( + payload.radio.length === 0 && + payload.modules.length === 0 && + payload.channels.length === 0 && + payload.owner === undefined && + payload.queued.length === 0 && + payload.staged.size === 0 + ); +} + function buildRadioConfig( variant: RadioConfigSection, value: unknown,