From 3388d4c2df232d9f98072bce9f343eadeb0c17c1 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sun, 26 Apr 2026 10:03:43 -0400 Subject: [PATCH] feat(sdk,web): migrate User + Channel + ImportDialog off changeRegistry; ConfigEditor gains owner support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3c-1 of PR #9. The last writers/readers of changeRegistry are moved to the SDK ConfigEditor. (One sweep commit drops the changeRegistry plumbing from the deviceStore.) ConfigEditor (sdk/features/config/domain/ConfigEditor.ts): - New owner state: baselineOwner / workingOwner signals, isOwnerDirty, setBaselineOwner(user), setOwner(user). The editor doesn't subscribe to a SDK signal for the user (owner arrives via NodeInfo, not its own packet), so the web seeds baseline from useMyNodeAsProto via setter. - commit() now also runs setOwner (admin message) when isOwnerDirty, inside the same beginEdit/commitEdit window as radio/module/channels. - isDirty aggregates owner dirty too. reset() reverts working owner. - Disconnect clears baseline + working owner. User page (Settings/User.tsx): - Aligned to Android: single "User Config" card, fields in order: Node ID (read-only), Long Name, Short Name, Hardware model (read-only enum name), Unmessageable, Licensed amateur radio (Ham). - onSubmit calls editor.setOwner instead of connection.setOwner — edits queue with the rest of the editor's pending changes and ship on the global Save. - Preserves non-edited User fields (id, hwModel, role, publicKey, macaddr) by spreading the baseline before applying form deltas. - isUnmessageable now reads from the proto's isUnmessagable instead of defaulting to false (longstanding bug — the existing value was ignored). - shortName min loosened to 1 (was 2) per Android rule. Channel page (PageComponents/Channels/Channel.tsx): - Reads working channel from editor.channels via useSignal projection; drops getChange / setChange / removeChange / deepCompareConfig. - onSubmit -> editor.setChannel(payload). Channels page (PageComponents/Channels/Channels.tsx): - Reads channel list from useChannels() (SDK), maps each into a real proto Channel via create(ChannelSchema, ...) for the per-tab Channel component which still expects the proto shape. - Per-channel dirty indicator now reads editor.dirtyChannels instead of hasChannelChange. ImportDialog (Dialog/ImportDialog.tsx): - Apply path -> editor.setChannel + editor.setRadioSection("lora",...) instead of setChange. Channel reads via SDK. i18n: config.json user.* gains userConfig (card label), nodeId, hardwareModel keys; field labels reworded to match Android (e.g. "Licensed amateur radio (Ham)", "Unmessageable" with "Unmonitored or Infrastructure" description). UserValidationSchema gains optional nodeId / hardwareModel display fields. --- .../features/config/domain/ConfigEditor.ts | 45 +++++++- .../web/public/i18n/locales/en/config.json | 20 +++- .../src/components/Dialog/ImportDialog.tsx | 21 ++-- .../PageComponents/Channels/Channel.tsx | 30 ++--- .../PageComponents/Channels/Channels.tsx | 31 +++++- .../PageComponents/Settings/User.tsx | 103 ++++++++++++------ packages/web/src/validation/config/user.ts | 5 +- 7 files changed, 184 insertions(+), 71 deletions(-) diff --git a/packages/sdk/src/features/config/domain/ConfigEditor.ts b/packages/sdk/src/features/config/domain/ConfigEditor.ts index 47bb1186..61be3979 100644 --- a/packages/sdk/src/features/config/domain/ConfigEditor.ts +++ b/packages/sdk/src/features/config/domain/ConfigEditor.ts @@ -7,6 +7,7 @@ import type { MeshClient } from "../../../core/client/MeshClient.ts"; import { type ReadonlySignal, toReadonly } from "../../../core/signals/createStore.ts"; import { setChannel } from "../../channels/application/ChannelUseCases.ts"; import { DeviceStatusEnum } from "../../device/domain/DeviceStatus.ts"; +import { setOwner } from "../../nodes/application/SetOwnerUseCase.ts"; import { beginEditSettings, commitEditSettings, @@ -45,9 +46,12 @@ export class ConfigEditor { private readonly workingChannels = signal>( new Map(), ); + private readonly baselineOwner = signal(undefined); + private readonly workingOwner = signal(undefined); private readonly _dirtyRadioSections = signal([]); private readonly _dirtyModuleSections = signal([]); private readonly _dirtyChannels = signal([]); + private readonly _isOwnerDirty = signal(false); private readonly _isDirty = signal(false); public readonly radio: ReadonlySignal = toReadonly(this.workingRadio); @@ -63,6 +67,10 @@ export class ConfigEditor { public readonly dirtyChannels: ReadonlySignal = toReadonly( this._dirtyChannels, ); + public readonly owner: ReadonlySignal = toReadonly( + this.workingOwner, + ); + public readonly isOwnerDirty: ReadonlySignal = toReadonly(this._isOwnerDirty); public readonly isDirty: ReadonlySignal = toReadonly(this._isDirty); constructor(client: MeshClient) { @@ -118,17 +126,38 @@ export class ConfigEditor { this.baselineRadio.value = {}; this.baselineModules.value = {}; this.baselineChannels.value = new Map(); + this.baselineOwner.value = undefined; this.workingRadio.value = {}; this.workingModules.value = {}; this.workingChannels.value = new Map(); + this.workingOwner.value = undefined; this._dirtyRadioSections.value = []; this._dirtyModuleSections.value = []; this._dirtyChannels.value = []; + this._isOwnerDirty.value = false; this._isDirty.value = false; } }); } + /** + * Update the baseline owner (the device's current user). Web pulls this + * from `useMyNode().user` after the NodeInfo packet arrives, so we accept + * it through a setter rather than subscribing to a SDK signal. + */ + public setBaselineOwner(owner: Protobuf.Mesh.User | undefined): void { + this.baselineOwner.value = owner; + if (!this._isOwnerDirty.peek()) { + this.workingOwner.value = owner; + } + this.recomputeDirty(); + } + + public setOwner(owner: Protobuf.Mesh.User): void { + this.workingOwner.value = owner; + this.recomputeDirty(); + } + public setRadioSection( key: K, value: RadioConfig[K], @@ -156,6 +185,7 @@ export class ConfigEditor { this.workingRadio.value = this.baselineRadio.peek(); this.workingModules.value = this.baselineModules.peek(); this.workingChannels.value = new Map(this.baselineChannels.peek()); + this.workingOwner.value = this.baselineOwner.peek(); this.recomputeDirty(); } @@ -196,15 +226,25 @@ export class ConfigEditor { 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); + } + } + const commitResult = await commitEditSettings(this.client); if (Result.isError(commitResult)) return Result.err(commitResult.error); 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._dirtyRadioSections.value = []; this._dirtyModuleSections.value = []; this._dirtyChannels.value = []; + this._isOwnerDirty.value = false; this._isDirty.value = false; return Result.ok(undefined); @@ -248,11 +288,14 @@ export class ConfigEditor { } } + const ownerDirty = !shallowEqual(this.baselineOwner.peek(), this.workingOwner.peek()); + this._dirtyRadioSections.value = radioDirty; this._dirtyModuleSections.value = moduleDirty; this._dirtyChannels.value = channelDirty; + this._isOwnerDirty.value = ownerDirty; this._isDirty.value = - radioDirty.length > 0 || moduleDirty.length > 0 || channelDirty.length > 0; + radioDirty.length > 0 || moduleDirty.length > 0 || channelDirty.length > 0 || ownerDirty; } } diff --git a/packages/web/public/i18n/locales/en/config.json b/packages/web/public/i18n/locales/en/config.json index b428f0e2..60422733 100644 --- a/packages/web/public/i18n/locales/en/config.json +++ b/packages/web/public/i18n/locales/en/config.json @@ -528,7 +528,7 @@ } }, "user": { - "title": "User Settings", + "title": "User", "description": "Configure your device name and identity settings", "longName": { "label": "Long Name", @@ -548,11 +548,23 @@ }, "isUnmessageable": { "label": "Unmessageable", - "description": "Used to identify unmonitored or infrastructure nodes so that messaging is not available to nodes that will never respond." + "description": "Unmonitored or Infrastructure" }, "isLicensed": { - "label": "Licensed amateur radio (HAM)", - "description": "Enable if you are a licensed amateur radio operator, enabling this option disables encryption and is not compatible with the default Meshtastic network." + "label": "Licensed amateur radio (Ham)", + "description": "Enabling this option disables encryption and is not compatible with the default Meshtastic network." + }, + "userConfig": { + "label": "User Config", + "description": "Identity and licensing" + }, + "nodeId": { + "label": "Node ID", + "description": "Read-only — assigned by the device" + }, + "hardwareModel": { + "label": "Hardware model", + "description": "Read-only — reported by the device" } } } diff --git a/packages/web/src/components/Dialog/ImportDialog.tsx b/packages/web/src/components/Dialog/ImportDialog.tsx index 6e5a19a7..ea33a7ea 100644 --- a/packages/web/src/components/Dialog/ImportDialog.tsx +++ b/packages/web/src/components/Dialog/ImportDialog.tsx @@ -20,8 +20,8 @@ import { } from "@components/UI/Select.tsx"; import { Switch } from "@components/UI/Switch.tsx"; import { useDevice } from "@core/stores"; -import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { Protobuf } from "@meshtastic/sdk"; +import { useConfigEditor } from "@meshtastic/sdk-react"; import { toByteArray } from "base64-js"; import { useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; @@ -33,7 +33,8 @@ export interface ImportDialogProps { } export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { - const { setChange, channels, config } = useDevice(); + const { config } = useDevice(); + const editor = useConfigEditor(); const { t } = useTranslation("dialog"); const [importDialogInput, setImportDialogInput] = useState(""); const [channelSet, setChannelSet] = useState(); @@ -77,6 +78,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { }, [importDialogInput, t]); const apply = () => { + if (!editor) return; channelSet?.settings.forEach((ch: Protobuf.Channel.ChannelSettings, index: number) => { if (importIndex[index] === -1) { return; @@ -91,24 +93,15 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { settings: ch, }); - if (!deepCompareConfig(channels.get(importIndex[index] ?? 0), payload, true)) { - setChange( - { type: "channel", index: importIndex[index] ?? 0 }, - payload, - channels.get(importIndex[index] ?? 0), - ); - } + editor.setChannel(payload); }); if (channelSet?.loraConfig && updateConfig) { const payload = { ...config.lora, ...channelSet.loraConfig, - }; - - if (!deepCompareConfig(config.lora, payload, true)) { - setChange({ type: "config", variant: "lora" }, payload, config.lora); - } + } as Protobuf.Config.Config_LoRaConfig; + editor.setRadioSection("lora", payload); } // Reset state after import setImportDialogInput(""); diff --git a/packages/web/src/components/PageComponents/Channels/Channel.tsx b/packages/web/src/components/PageComponents/Channels/Channel.tsx index b740fc25..f1728b18 100644 --- a/packages/web/src/components/PageComponents/Channels/Channel.tsx +++ b/packages/web/src/components/PageComponents/Channels/Channel.tsx @@ -4,8 +4,8 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx" import { createZodResolver } from "@components/Form/createZodResolver.ts"; import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; -import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { 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"; @@ -17,8 +17,20 @@ export interface SettingsPanelProps { channel: Protobuf.Channel.Channel; } +const EMPTY_CHANNELS_SIGNAL = { + value: new Map() as ReadonlyMap< + number, + Protobuf.Channel.Channel + >, + peek: () => + new Map() as ReadonlyMap, + subscribe: () => () => {}, +} as const; + export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { - const { config, setChange, getChange, removeChange } = useDevice(); + const { config } = useDevice(); + const editor = useConfigEditor(); + const editorChannels = useSignal(editor?.channels ?? EMPTY_CHANNELS_SIGNAL); const { t } = useTranslation(["channels", "ui", "dialog"]); const defaultConfig = channel; @@ -39,10 +51,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { }, }; - const workingChannel = getChange({ - type: "channel", - index: channel.index, - }) as Protobuf.Channel.Channel | undefined; + const workingChannel = editorChannels.get(channel.index); const effectiveConfig = workingChannel ?? channel; const formValues = { ...effectiveConfig, @@ -95,7 +104,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { }, [effectiveByteCount, trigger]); const onSubmit = (data: ChannelValidation) => { - if (!formState.isReady) { + if (!formState.isReady || !editor) { return; } @@ -111,12 +120,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { }, }); - if (deepCompareConfig(channel, payload, true)) { - removeChange({ type: "channel", index: channel.index }); - return; - } - - setChange({ type: "channel", index: channel.index }, payload, channel); + editor.setChannel(payload); }; const preSharedKeyRegenerate = async () => { diff --git a/packages/web/src/components/PageComponents/Channels/Channels.tsx b/packages/web/src/components/PageComponents/Channels/Channels.tsx index 39fabb38..2045148b 100644 --- a/packages/web/src/components/PageComponents/Channels/Channels.tsx +++ b/packages/web/src/components/PageComponents/Channels/Channels.tsx @@ -1,8 +1,11 @@ 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 { useDevice } from "@core/stores"; +import { 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"; @@ -24,14 +27,34 @@ export const getChannelName = (channel: { index: number; settings?: { name?: str }); }; +const EMPTY_DIRTY_CHANNELS_SIGNAL = { + value: [] as readonly number[], + peek: () => [] as readonly number[], + subscribe: () => () => {}, +} as const; + export const Channels = ({ onFormInit }: ConfigProps) => { - const { channels, hasChannelChange, setDialogOpen } = useDevice(); + const { setDialogOpen } = useDevice(); + const editor = useConfigEditor(); + const channels = useChannels(); + const dirtyChannels = useSignal(editor?.dirtyChannels ?? EMPTY_DIRTY_CHANNELS_SIGNAL); const { t } = useTranslation("channels"); - const allChannels = Array.from(channels.values()); + const allChannels = useMemo( + () => + channels.map((c) => + create(Protobuf.Channel.ChannelSchema, { + index: c.index, + role: c.role, + settings: c.settings, + }), + ), + [channels], + ); const flags = useMemo( - () => new Map(allChannels.map((channel) => [channel.index, hasChannelChange(channel.index)])), - [allChannels, hasChannelChange], + () => + new Map(allChannels.map((channel) => [channel.index, dirtyChannels.includes(channel.index)])), + [allChannels, dirtyChannels], ); return ( diff --git a/packages/web/src/components/PageComponents/Settings/User.tsx b/packages/web/src/components/PageComponents/Settings/User.tsx index 37dce170..c7b0612a 100644 --- a/packages/web/src/components/PageComponents/Settings/User.tsx +++ b/packages/web/src/components/PageComponents/Settings/User.tsx @@ -2,72 +2,104 @@ import { type UserValidation, UserValidationSchema } from "@app/validation/confi import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx"; import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts"; -import { useDevice } from "@core/stores"; import { Protobuf } from "@meshtastic/sdk"; +import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; interface UserConfigProps { onFormInit: DynamicFormFormInit; } +const EMPTY_OWNER_SIGNAL = { + value: undefined as Protobuf.Mesh.User | undefined, + peek: () => undefined as Protobuf.Mesh.User | undefined, + subscribe: () => () => {}, +} as const; + export const User = ({ onFormInit }: UserConfigProps) => { - const { getChange, connection } = useDevice(); const { t } = useTranslation("config"); - + const editor = useConfigEditor(); const myNode = useMyNodeAsProto(); - const defaultUser = myNode?.user ?? { - id: "", - longName: "", - shortName: "", - isLicensed: false, - }; + const owner = useSignal(editor?.owner ?? EMPTY_OWNER_SIGNAL); - // Get working copy from change registry - const workingUser = getChange({ type: "user" }) as Protobuf.Mesh.User | undefined; + // 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 + // edits in flight are preserved across re-renders. + useEffect(() => { + if (editor && myNode?.user) { + editor.setBaselineOwner(myNode.user); + } + }, [editor, myNode?.user]); - const effectiveUser = workingUser ?? defaultUser; + const baselineUser = + myNode?.user ?? + create(Protobuf.Mesh.UserSchema, { + id: "", + longName: "", + shortName: "", + isLicensed: false, + }); + const effectiveUser = owner ?? baselineUser; const onSubmit = (data: UserValidation) => { - connection?.setOwner( - create(Protobuf.Mesh.UserSchema, { - ...data, - }), - ); + if (!editor) return; + // Preserve fields the form doesn't edit (id, hwModel, role, publicKey, etc.). + const merged = create(Protobuf.Mesh.UserSchema, { + ...baselineUser, + longName: data.longName, + shortName: data.shortName, + isUnmessagable: data.isUnmessageable, + isLicensed: data.isLicensed, + }); + editor.setOwner(merged); }; + const hwName = Protobuf.Mesh.HardwareModel[baselineUser.hwModel] ?? String(baselineUser.hwModel); + return ( onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={UserValidationSchema} defaultValues={{ - longName: defaultUser.longName, - shortName: defaultUser.shortName, - isLicensed: defaultUser.isLicensed, - isUnmessageable: false, + nodeId: baselineUser.id, + hardwareModel: hwName, + longName: baselineUser.longName, + shortName: baselineUser.shortName, + isLicensed: baselineUser.isLicensed, + isUnmessageable: baselineUser.isUnmessagable ?? false, }} values={{ + nodeId: effectiveUser.id, + hardwareModel: hwName, longName: effectiveUser.longName, shortName: effectiveUser.shortName, isLicensed: effectiveUser.isLicensed, - isUnmessageable: false, + isUnmessageable: effectiveUser.isUnmessagable ?? false, }} fieldGroups={[ { - label: t("user.title"), - description: t("user.description"), + label: t("user.userConfig.label"), + description: t("user.userConfig.description"), fields: [ + { + type: "text", + name: "nodeId", + label: t("user.nodeId.label"), + description: t("user.nodeId.description"), + disabled: true, + properties: { + showCopyButton: true, + }, + }, { type: "text", name: "longName", label: t("user.longName.label"), description: t("user.longName.description"), properties: { - fieldLength: { - min: 1, - max: 40, - showCharacterCount: true, - }, + fieldLength: { min: 1, max: 39, showCharacterCount: true }, }, }, { @@ -76,13 +108,16 @@ export const User = ({ onFormInit }: UserConfigProps) => { label: t("user.shortName.label"), description: t("user.shortName.description"), properties: { - fieldLength: { - min: 2, - max: 4, - showCharacterCount: true, - }, + fieldLength: { min: 1, max: 4, showCharacterCount: true }, }, }, + { + type: "text", + name: "hardwareModel", + label: t("user.hardwareModel.label"), + description: t("user.hardwareModel.description"), + disabled: true, + }, { type: "toggle", name: "isUnmessageable", diff --git a/packages/web/src/validation/config/user.ts b/packages/web/src/validation/config/user.ts index 59fac860..c41cf1ce 100644 --- a/packages/web/src/validation/config/user.ts +++ b/packages/web/src/validation/config/user.ts @@ -2,13 +2,16 @@ import { t } from "i18next"; import { z } from "zod/v4"; export const UserValidationSchema = z.object({ + // Read-only display fields (preserved on submit). + nodeId: z.string().optional(), + hardwareModel: z.string().optional(), longName: z .string() .min(1, t("user.longName.validation.min")) .max(40, t("user.longName.validation.max")), shortName: z .string() - .min(2, t("user.shortName.validation.min")) + .min(1, t("user.shortName.validation.min")) .max(4, t("user.shortName.validation.max")), isUnmessageable: z.boolean().default(false), isLicensed: z.boolean().default(false),