feat(sdk,web): migrate User + Channel + ImportDialog off changeRegistry; ConfigEditor gains owner support

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.
This commit is contained in:
Dan Ditomaso
2026-04-26 10:03:43 -04:00
parent e4d0b11292
commit 3388d4c2df
7 changed files with 184 additions and 71 deletions

View File

@@ -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<ReadonlyMap<number, Protobuf.Channel.Channel>>(
new Map(),
);
private readonly baselineOwner = signal<Protobuf.Mesh.User | undefined>(undefined);
private readonly workingOwner = signal<Protobuf.Mesh.User | undefined>(undefined);
private readonly _dirtyRadioSections = signal<readonly RadioConfigSection[]>([]);
private readonly _dirtyModuleSections = signal<readonly ModuleConfigSection[]>([]);
private readonly _dirtyChannels = signal<readonly number[]>([]);
private readonly _isOwnerDirty = signal<boolean>(false);
private readonly _isDirty = signal<boolean>(false);
public readonly radio: ReadonlySignal<RadioConfig> = toReadonly(this.workingRadio);
@@ -63,6 +67,10 @@ export class ConfigEditor {
public readonly dirtyChannels: ReadonlySignal<readonly number[]> = toReadonly(
this._dirtyChannels,
);
public readonly owner: ReadonlySignal<Protobuf.Mesh.User | undefined> = toReadonly(
this.workingOwner,
);
public readonly isOwnerDirty: ReadonlySignal<boolean> = toReadonly(this._isOwnerDirty);
public readonly isDirty: ReadonlySignal<boolean> = 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<K extends RadioConfigSection & string>(
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;
}
}

View File

@@ -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"
}
}
}

View File

@@ -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<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
@@ -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("");

View File

@@ -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<number, Protobuf.Channel.Channel>() as ReadonlyMap<
number,
Protobuf.Channel.Channel
>,
peek: () =>
new Map<number, Protobuf.Channel.Channel>() as ReadonlyMap<number, Protobuf.Channel.Channel>,
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 () => {

View File

@@ -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 (

View File

@@ -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<UserValidation>;
}
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 (
<DynamicForm<UserValidation>
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",

View File

@@ -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),