feat(protobufs): sync to firmware-current and consume workspace package (#1097)

* feat(protobufs): sync to firmware-current and consume workspace package

Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*).

Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true.

* build(protobufs): vendor generated bindings for workspace consumers

The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen

* fix(protobufs): clean script removes the actual generated output dir

buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback.

* refactor: move web app packages/web -> apps/web

Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green.

* feat: config fields, module pages, key verification, telemetry capture

Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green.

* chore: drop #1062 (unsaved-change-detection) to match upstream revert

#1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green.

* fix(nodes): clean up SNR display in node table and map popup

SNR is a ratio measured in dB, not dBm (which is absolute power); the
node table and map popup both mislabeled it and crammed three values
together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same
heuristic ((snr+10)*5) shown twice — once clamped, once not.

Render SNR in dB rounded to one decimal, color-coded by a 0-100%
signal-quality heuristic (green/yellow/red), with the quality percentage
as a muted secondary. Drop the redundant raw value. Adds unit.db; this
matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
Ben Meadors
2026-06-15 13:23:28 -05:00
committed by GitHub
parent 7a8529b841
commit bbe9a0d5cd
695 changed files with 27038 additions and 786 deletions
+69
View File
@@ -0,0 +1,69 @@
# sdk-preview — a proof-of-direction slice
This folder is a small, self-contained demonstration of the architecture from
**[meshtastic/web#1050](https://github.com/meshtastic/web/pull/1050)** (Dan's
`@meshtastic/sdk` migration), built against the **firmware-current protobufs**
synced in this branch.
It is **not wired into the live app** and replaces nothing. The goal is to show
the *shape* of the SDK direction — domain-driven slices, signals-backed reactive
state, `Result`-returning use-cases, and ports/adapters — without pulling in the
full SDK + sqlocal/OPFS persistence stack.
## What it mirrors
| This slice | `@meshtastic/sdk` (PR #1050) |
| --- | --- |
| `core/signals` (`createStore`, `SignalMap`, `ReadonlySignal`) | `core/signals` |
| `core/errors` (`MeshError`, `toMeshError`) | `core/errors` |
| `core/client/MeshClientPort` (minimal port) | `core/client/MeshClient` |
| `features/config/domain` (`RadioConfig`, `ModuleConfig`, `ConfigEditor`) | same |
| `features/config/application/ConfigUseCases` (`Result<number, MeshError>`) | same |
| `features/config/infrastructure` (`ConfigMapper`, `configBuilders`) | same |
| `react/useSignal`, `react/useConfigEditor` | `@meshtastic/sdk-react` |
| `adapters/fromMeshDevice` | the SDK's transport/registry wiring |
## The pattern on display: `ConfigEditor`
`ConfigEditor` is the piece that replaces today's Zustand `changeRegistry`. It
keeps two reactive views per config section:
- **baseline** — device truth, updated automatically from `onConfigPacket` /
`onModuleConfigPacket`.
- **working** — the UI's in-flight edits.
A mid-edit packet from the device updates the baseline but **does not clobber a
section the user is editing**; `commit()` sends only the dirty sections inside a
`beginEdit → setConfig…/setModuleConfig… → commitEdit` envelope and returns a
`Result`; disconnect resets everything so a stale working copy can't leak across
reconnects. See `features/config/__tests__/ConfigEditor.test.ts`.
## Why this ties the branch together
`RadioConfigSection` / `ModuleConfigSection` are **derived from the protobufs**
(`Exclude<…payloadVariant["case"]>`), and `ConfigMapper` is fully generic. Because
this branch already synced the firmware-current protobufs, the new modules
(`trafficManagement`, `statusmessage`, `tak`, `remoteHardware`) and new config
fields flow through the domain, mapper, editor, and dirty-tracking **with zero
per-section code** — which is exactly the leverage the SDK migration is built on.
## Wiring it into a page (illustrative)
```tsx
import { useConfigEditor, useSignal } from "@app/sdk-preview";
function TrafficManagementPanel() {
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? /* empty signal */);
const isDirty = useSignal(editor?.isDirty ?? /* false signal */);
// editor.setModuleSection("trafficManagement", next); … editor.commit();
}
```
## Deliberately out of scope
- Channels / owner / queued-admin editing (need the channels + nodes slices).
- `@meshtastic/sdk-storage-sqlocal` (OPFS SQLite) persistence.
- The full `MeshClient` / `MeshRegistry` multi-device client.
- Live UI adoption — that's the slice-by-slice migration tracked in PR #1050's
follow-ups.
@@ -0,0 +1,42 @@
import type { MeshDevice } from "@meshtastic/core";
import type { MeshClientPort } from "../core/index.ts";
import { ConfigEditor } from "../features/config/index.ts";
/**
* Adapts the legacy `@meshtastic/core` `MeshDevice` to the slice's
* `MeshClientPort`.
*
* In the real SDK (PR #1050) the client owns explicit begin/commit admin
* messages. The legacy `MeshDevice.setConfig` already opens the edit
* transaction itself (guarded by its `pendingSettingsChanges` flag) and
* `commitEditSettings()` closes it — so `beginEditSettings` here is a no-op and
* the transaction is driven by the existing device methods.
*/
export function meshDeviceToPort(device: MeshDevice): MeshClientPort {
return {
events: {
onConfigPacket: device.events.onConfigPacket,
onModuleConfigPacket: device.events.onModuleConfigPacket,
onDeviceStatus: device.events.onDeviceStatus,
},
setConfig: (config) => device.setConfig(config),
setModuleConfig: (moduleConfig) => device.setModuleConfig(moduleConfig),
beginEditSettings: () => Promise.resolve(0),
commitEditSettings: () => device.commitEditSettings(),
};
}
const editors = new WeakMap<MeshDevice, ConfigEditor>();
/**
* Returns the `ConfigEditor` for a device connection, creating it lazily and
* caching one per connection (so every consumer binds to the same signals).
*/
export function getConfigEditor(device: MeshDevice): ConfigEditor {
let editor = editors.get(device);
if (!editor) {
editor = new ConfigEditor(meshDeviceToPort(device));
editors.set(device, editor);
}
return editor;
}
@@ -0,0 +1,34 @@
import type { Protobuf, Types } from "@meshtastic/core";
/**
* Minimal subscribe-only view of an event source. `ste-simple-events`'
* `SimpleEventDispatcher` (used by the legacy `MeshDevice`) satisfies this, as
* does a preact signal facade — so the slice doesn't care where events come
* from.
*/
export interface Subscribable<T> {
subscribe(listener: (value: T) => void): unknown;
}
/** The subset of device events the config slice subscribes to. */
export interface MeshClientEvents {
onConfigPacket: Subscribable<Protobuf.Config.Config>;
onModuleConfigPacket: Subscribable<Protobuf.ModuleConfig.ModuleConfig>;
onDeviceStatus: Subscribable<Types.DeviceStatusEnum>;
}
/**
* The port the config slice talks to.
*
* In the real SDK this is the full `MeshClient`; here it is the minimal surface
* `ConfigEditor` needs, so the slice stays decoupled and unit-testable with a
* fake. `adapters/fromMeshDevice.ts` builds one of these from the existing
* `@meshtastic/core` `MeshDevice`.
*/
export interface MeshClientPort {
readonly events: MeshClientEvents;
setConfig(config: Protobuf.Config.Config): Promise<number>;
setModuleConfig(moduleConfig: Protobuf.ModuleConfig.ModuleConfig): Promise<number>;
beginEditSettings(): Promise<number>;
commitEditSettings(): Promise<number>;
}
@@ -0,0 +1,30 @@
/**
* Typed error hierarchy mirroring `@meshtastic/sdk` (PR #1050). New application
* use-cases return `Result<T, MeshError>` instead of throwing; transport-level
* failures are represented as concrete subclasses so callers can branch on
* `instanceof` rather than string-matching messages.
*/
export class MeshError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "MeshError";
}
}
export class ConfigCommitError extends MeshError {
constructor(section: string, options?: ErrorOptions) {
super(`Failed to commit config section "${section}"`, options);
this.name = "ConfigCommitError";
}
}
/** Wraps an unknown thrown value into a MeshError without losing the cause. */
export function toMeshError(value: unknown): MeshError {
if (value instanceof MeshError) {
return value;
}
if (value instanceof Error) {
return new MeshError(value.message, { cause: value });
}
return new MeshError(String(value));
}
+3
View File
@@ -0,0 +1,3 @@
export type { MeshClientEvents, MeshClientPort, Subscribable } from "./client/MeshClientPort.ts";
export { ConfigCommitError, MeshError, toMeshError } from "./errors/MeshError.ts";
export { createStore, type ReadonlySignal, SignalMap, toReadonly } from "./signals/index.ts";
@@ -0,0 +1,104 @@
import {
type ReadonlySignal as PreactReadonlySignal,
type Signal,
signal,
} from "@preact/signals-core";
/**
* Reactive read-only view of a signal.
*
* Compatible with React's `useSyncExternalStore` contract: consumers subscribe
* for change notifications and call `value` / `peek()` to read.
*
* Mirrors `@meshtastic/sdk`'s core signals primitive from PR #1050. The point
* of this preview slice is to show the *shape* of the SDK direction inside the
* current app without pulling in the whole SDK + sqlocal stack.
*/
export interface ReadonlySignal<T> {
readonly value: T;
peek(): T;
subscribe(listener: (value: T) => void): () => void;
}
/**
* Wraps a preact signal into the SDK's `ReadonlySignal` interface. Returns both
* the writable signal (for slice-internal use) and the readable facade (for
* external consumption).
*/
export function createStore<T>(initial: T): {
write: Signal<T>;
read: ReadonlySignal<T>;
} {
const write = signal(initial);
return { write, read: toReadonly(write) };
}
export function toReadonly<T>(s: Signal<T> | PreactReadonlySignal<T>): ReadonlySignal<T> {
return {
get value() {
return s.value;
},
peek: () => s.peek(),
subscribe: (listener) => {
// Skip the immediate synchronous emit so `subscribe` only fires on change,
// matching the SDK contract (and avoiding a redundant first render).
let first = true;
return s.subscribe((v) => {
if (first) {
first = false;
return;
}
listener(v);
});
},
};
}
/**
* Keyed collection built on a single backing signal, emitting a new array
* snapshot on each mutation. Sufficient for nodes/messages where consumers
* subscribe to the whole list and filter locally.
*/
export class SignalMap<K, V> {
private readonly inner: Map<K, V> = new Map();
private readonly backing: Signal<ReadonlyArray<V>>;
public readonly read: ReadonlySignal<ReadonlyArray<V>>;
constructor() {
this.backing = signal<ReadonlyArray<V>>([]);
this.read = toReadonly(this.backing);
}
get(key: K): V | undefined {
return this.inner.get(key);
}
has(key: K): boolean {
return this.inner.has(key);
}
set(key: K, value: V): void {
this.inner.set(key, value);
this.backing.value = Array.from(this.inner.values());
}
delete(key: K): boolean {
const removed = this.inner.delete(key);
if (removed) {
this.backing.value = Array.from(this.inner.values());
}
return removed;
}
clear(): void {
if (this.inner.size === 0) {
return;
}
this.inner.clear();
this.backing.value = [];
}
get size(): number {
return this.inner.size;
}
}
@@ -0,0 +1 @@
export { createStore, type ReadonlySignal, SignalMap, toReadonly } from "./createStore.ts";
@@ -0,0 +1,157 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf, Types } from "@meshtastic/core";
import { Result } from "better-result";
import { describe, expect, it } from "vitest";
import type { MeshClientPort, Subscribable } from "../../../core/index.ts";
import { ConfigEditor } from "../domain/ConfigEditor.ts";
/** Controllable in-test emitter satisfying `Subscribable<T>`. */
function emitter<T>(): Subscribable<T> & { emit: (value: T) => void } {
const listeners = new Set<(value: T) => void>();
return {
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
emit(value) {
for (const listener of [...listeners]) {
listener(value);
}
},
};
}
function makePort() {
const onConfigPacket = emitter<Protobuf.Config.Config>();
const onModuleConfigPacket = emitter<Protobuf.ModuleConfig.ModuleConfig>();
const onDeviceStatus = emitter<Types.DeviceStatusEnum>();
const calls: string[] = [];
const port: MeshClientPort = {
events: { onConfigPacket, onModuleConfigPacket, onDeviceStatus },
setConfig: (config) => {
calls.push(`setConfig:${config.payloadVariant.case}`);
return Promise.resolve(1);
},
setModuleConfig: (moduleConfig) => {
calls.push(`setModuleConfig:${moduleConfig.payloadVariant.case}`);
return Promise.resolve(1);
},
beginEditSettings: () => {
calls.push("begin");
return Promise.resolve(1);
},
commitEditSettings: () => {
calls.push("commit");
return Promise.resolve(1);
},
};
return { port, onConfigPacket, onModuleConfigPacket, onDeviceStatus, calls };
}
const loraConfig = (hopLimit: number): Protobuf.Config.Config =>
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: create(Protobuf.Config.Config_LoRaConfigSchema, { hopLimit }),
},
});
const lora = (hopLimit: number) => create(Protobuf.Config.Config_LoRaConfigSchema, { hopLimit });
const trafficModuleConfig = (enabled: boolean): Protobuf.ModuleConfig.ModuleConfig =>
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "trafficManagement",
value: create(Protobuf.ModuleConfig.ModuleConfig_TrafficManagementConfigSchema, { enabled }),
},
});
describe("ConfigEditor (sdk-preview)", () => {
it("hydrates baseline + working from an inbound config packet without becoming dirty", () => {
const { port, onConfigPacket } = makePort();
const editor = new ConfigEditor(port);
onConfigPacket.emit(loraConfig(3));
expect(editor.radio.peek().lora?.hopLimit).toBe(3);
expect(editor.isDirty.peek()).toBe(false);
expect(editor.dirtyRadioSections.peek()).toEqual([]);
});
it("marks a section dirty when edited", () => {
const { port, onConfigPacket } = makePort();
const editor = new ConfigEditor(port);
onConfigPacket.emit(loraConfig(3));
editor.setRadioSection("lora", lora(5));
expect(editor.isDirty.peek()).toBe(true);
expect(editor.dirtyRadioSections.peek()).toEqual(["lora"]);
});
it("does not clobber a dirty section when a competing baseline packet arrives", () => {
const { port, onConfigPacket } = makePort();
const editor = new ConfigEditor(port);
onConfigPacket.emit(loraConfig(3));
editor.setRadioSection("lora", lora(5));
onConfigPacket.emit(loraConfig(7)); // device pushes a different value mid-edit
expect(editor.radio.peek().lora?.hopLimit).toBe(5); // user edit preserved
expect(editor.isDirty.peek()).toBe(true);
});
it("commits dirty sections inside a begin/…/commit envelope and clears dirty", async () => {
const { port, onConfigPacket, calls } = makePort();
const editor = new ConfigEditor(port);
onConfigPacket.emit(loraConfig(3));
editor.setRadioSection("lora", lora(5));
const result = await editor.commit();
expect(Result.isError(result)).toBe(false);
expect(calls).toEqual(["begin", "setConfig:lora", "commit"]);
expect(editor.isDirty.peek()).toBe(false);
expect(editor.radio.peek().lora?.hopLimit).toBe(5); // baseline now equals working
});
it("supports firmware-current module sections (trafficManagement) end to end", async () => {
const { port, onModuleConfigPacket, calls } = makePort();
const editor = new ConfigEditor(port);
onModuleConfigPacket.emit(trafficModuleConfig(false));
editor.setModuleSection(
"trafficManagement",
create(Protobuf.ModuleConfig.ModuleConfig_TrafficManagementConfigSchema, { enabled: true }),
);
expect(editor.dirtyModuleSections.peek()).toEqual(["trafficManagement"]);
const result = await editor.commit();
expect(Result.isError(result)).toBe(false);
expect(calls).toEqual(["begin", "setModuleConfig:trafficManagement", "commit"]);
});
it("reset() discards working edits back to baseline", () => {
const { port, onConfigPacket } = makePort();
const editor = new ConfigEditor(port);
onConfigPacket.emit(loraConfig(3));
editor.setRadioSection("lora", lora(9));
editor.reset();
expect(editor.radio.peek().lora?.hopLimit).toBe(3);
expect(editor.isDirty.peek()).toBe(false);
});
it("clears all state on disconnect", () => {
const { port, onConfigPacket, onDeviceStatus } = makePort();
const editor = new ConfigEditor(port);
onConfigPacket.emit(loraConfig(3));
editor.setRadioSection("lora", lora(5));
onDeviceStatus.emit(Types.DeviceStatusEnum.DeviceDisconnected);
expect(editor.radio.peek()).toEqual({});
expect(editor.isDirty.peek()).toBe(false);
});
});
@@ -0,0 +1,53 @@
import type { Protobuf } from "@meshtastic/core";
import { Result } from "better-result";
import type { ResultType } from "better-result";
import type { MeshClientPort } from "../../../core/client/MeshClientPort.ts";
import { type MeshError, toMeshError } from "../../../core/errors/MeshError.ts";
/**
* Application use-cases for the config slice. Each wraps a transport call in a
* `Result<number, MeshError>` instead of throwing — mirroring the SDK's
* use-case layer from PR #1050. The returned number is the sent packet id.
*/
export async function beginEditSettings(
client: MeshClientPort,
): Promise<ResultType<number, MeshError>> {
try {
return Result.ok(await client.beginEditSettings());
} catch (error) {
return Result.err(toMeshError(error));
}
}
export async function commitEditSettings(
client: MeshClientPort,
): Promise<ResultType<number, MeshError>> {
try {
return Result.ok(await client.commitEditSettings());
} catch (error) {
return Result.err(toMeshError(error));
}
}
export async function setConfig(
client: MeshClientPort,
config: Protobuf.Config.Config,
): Promise<ResultType<number, MeshError>> {
try {
return Result.ok(await client.setConfig(config));
} catch (error) {
return Result.err(toMeshError(error));
}
}
export async function setModuleConfig(
client: MeshClientPort,
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
): Promise<ResultType<number, MeshError>> {
try {
return Result.ok(await client.setModuleConfig(moduleConfig));
} catch (error) {
return Result.err(toMeshError(error));
}
}
@@ -0,0 +1,234 @@
import { Types } from "@meshtastic/core";
import { signal } from "@preact/signals-core";
import { Result } from "better-result";
import type { ResultType } from "better-result";
import type { MeshClientPort } from "../../../core/client/MeshClientPort.ts";
import type { MeshError } from "../../../core/errors/MeshError.ts";
import { type ReadonlySignal, toReadonly } from "../../../core/signals/index.ts";
import {
beginEditSettings,
commitEditSettings,
setConfig,
setModuleConfig,
} from "../application/ConfigUseCases.ts";
import { buildModuleConfig, buildRadioConfig } from "../infrastructure/configBuilders.ts";
import { ConfigMapper } from "../infrastructure/ConfigMapper.ts";
import type { ModuleConfig, ModuleConfigSection } from "./ModuleConfig.ts";
import type { RadioConfig, RadioConfigSection } from "./RadioConfig.ts";
/**
* Per-section editor for radio config + module config — a faithful, scoped port
* of `@meshtastic/sdk`'s `ConfigEditor` (PR #1050). Demonstrates the pattern
* that replaces the current Zustand `changeRegistry`:
*
* - **Baseline**: what the device most recently sent. Updated automatically from
* `onConfigPacket` / `onModuleConfigPacket`, and re-applied as post-commit truth.
* - **Working copy**: UI edits via `setRadioSection` / `setModuleSection`.
* Mid-edit baseline updates do NOT clobber a section the user is editing.
* - On disconnect both reset, so a stale working copy can't leak across reconnects.
*
* All state is exposed as `ReadonlySignal`s; React binds via `useSignal`.
*/
export class ConfigEditor {
private readonly client: MeshClientPort;
private readonly baselineRadio = signal<RadioConfig>({});
private readonly baselineModules = signal<ModuleConfig>({});
private readonly workingRadio = signal<RadioConfig>({});
private readonly workingModules = signal<ModuleConfig>({});
private readonly _dirtyRadioSections = signal<readonly RadioConfigSection[]>([]);
private readonly _dirtyModuleSections = signal<readonly ModuleConfigSection[]>([]);
private readonly _isDirty = signal<boolean>(false);
public readonly radio: ReadonlySignal<RadioConfig> = toReadonly(this.workingRadio);
public readonly modules: ReadonlySignal<ModuleConfig> = toReadonly(this.workingModules);
public readonly dirtyRadioSections: ReadonlySignal<readonly RadioConfigSection[]> = toReadonly(
this._dirtyRadioSections,
);
public readonly dirtyModuleSections: ReadonlySignal<readonly ModuleConfigSection[]> = toReadonly(
this._dirtyModuleSections,
);
public readonly isDirty: ReadonlySignal<boolean> = toReadonly(this._isDirty);
constructor(client: MeshClientPort) {
this.client = client;
client.events.onConfigPacket.subscribe((config) => {
const next = ConfigMapper.mergeRadio(this.baselineRadio.peek(), config);
this.baselineRadio.value = next;
const variant = config.payloadVariant.case;
if (variant) {
// Apply the baseline update to the working copy only if the user hasn't
// already edited this section — their in-flight edit wins.
if (!this._dirtyRadioSections.peek().includes(variant)) {
this.workingRadio.value = { ...this.workingRadio.peek(), [variant]: next[variant] };
}
}
this.recomputeDirty();
});
client.events.onModuleConfigPacket.subscribe((moduleConfig) => {
const next = ConfigMapper.mergeModule(this.baselineModules.peek(), moduleConfig);
this.baselineModules.value = next;
const variant = moduleConfig.payloadVariant.case;
if (variant) {
if (!this._dirtyModuleSections.peek().includes(variant)) {
this.workingModules.value = { ...this.workingModules.peek(), [variant]: next[variant] };
}
}
this.recomputeDirty();
});
client.events.onDeviceStatus.subscribe((status) => {
if (status === Types.DeviceStatusEnum.DeviceDisconnected) {
this.baselineRadio.value = {};
this.baselineModules.value = {};
this.workingRadio.value = {};
this.workingModules.value = {};
this._dirtyRadioSections.value = [];
this._dirtyModuleSections.value = [];
this._isDirty.value = false;
}
});
}
public setRadioSection<K extends RadioConfigSection & string>(
key: K,
value: RadioConfig[K],
): void {
this.workingRadio.value = { ...this.workingRadio.peek(), [key]: value };
this.recomputeDirty();
}
public setModuleSection<K extends ModuleConfigSection & string>(
key: K,
value: ModuleConfig[K],
): void {
this.workingModules.value = { ...this.workingModules.peek(), [key]: value };
this.recomputeDirty();
}
/** Discard all working edits, snapping back to the device's baseline. */
public reset(): void {
this.workingRadio.value = this.baselineRadio.peek();
this.workingModules.value = this.baselineModules.peek();
this.recomputeDirty();
}
/**
* Send every dirty section to the device inside a beginEdit/commitEdit pair.
* On success the baseline is replaced with the working copy (optimistic);
* inbound config packets after commit reconcile. Any failure aborts and
* returns the error — the baseline is left untouched.
*/
public async commit(): Promise<ResultType<void, MeshError>> {
if (!this._isDirty.peek()) {
return Result.ok(undefined);
}
const begin = await beginEditSettings(this.client);
if (Result.isError(begin)) {
return Result.err(begin.error);
}
const radio = this.workingRadio.peek();
for (const section of this._dirtyRadioSections.peek()) {
const value = radio[section];
if (value === undefined) {
continue;
}
const result = await setConfig(this.client, buildRadioConfig(section, value));
if (Result.isError(result)) {
return Result.err(result.error);
}
}
const modules = this.workingModules.peek();
for (const section of this._dirtyModuleSections.peek()) {
const value = modules[section];
if (value === undefined) {
continue;
}
const result = await setModuleConfig(this.client, buildModuleConfig(section, value));
if (Result.isError(result)) {
return Result.err(result.error);
}
}
const commit = await commitEditSettings(this.client);
if (Result.isError(commit)) {
return Result.err(commit.error);
}
this.baselineRadio.value = this.workingRadio.peek();
this.baselineModules.value = this.workingModules.peek();
this._dirtyRadioSections.value = [];
this._dirtyModuleSections.value = [];
this._isDirty.value = false;
return Result.ok(undefined);
}
private recomputeDirty(): void {
const radioBase = this.baselineRadio.peek();
const radioWorking = this.workingRadio.peek();
const radioDirty: RadioConfigSection[] = [];
for (const key of new Set<string>([...Object.keys(radioBase), ...Object.keys(radioWorking)])) {
const section = key as RadioConfigSection;
if (!shallowEqual(radioBase[section], radioWorking[section])) {
radioDirty.push(section);
}
}
const moduleBase = this.baselineModules.peek();
const moduleWorking = this.workingModules.peek();
const moduleDirty: ModuleConfigSection[] = [];
for (const key of new Set<string>([
...Object.keys(moduleBase),
...Object.keys(moduleWorking),
])) {
const section = key as ModuleConfigSection;
if (!shallowEqual(moduleBase[section], moduleWorking[section])) {
moduleDirty.push(section);
}
}
this._dirtyRadioSections.value = radioDirty;
this._dirtyModuleSections.value = moduleDirty;
this._isDirty.value = radioDirty.length > 0 || moduleDirty.length > 0;
}
}
/** Recursive value-equality used for dirty detection (matches the SDK helper). */
function shallowEqual(a: unknown, b: unknown): boolean {
if (a === b) {
return true;
}
if (a === undefined || b === undefined || a === null || b === null) {
return false;
}
if (typeof a !== "object" || typeof b !== "object") {
return false;
}
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const aKeys = Object.keys(ao);
const bKeys = Object.keys(bo);
if (aKeys.length !== bKeys.length) {
return false;
}
for (const k of aKeys) {
const av = ao[k];
const bv = bo[k];
if (av === bv) {
continue;
}
if (typeof av === "object" && typeof bv === "object") {
if (!shallowEqual(av, bv)) {
return false;
}
} else {
return false;
}
}
return true;
}
@@ -0,0 +1,20 @@
import type { Protobuf } from "@meshtastic/core";
/**
* The module-config sections keyed by their protobuf variant case. Because this
* is derived from the firmware-current protobufs, the new modules synced in
* this branch — `trafficManagement`, `statusmessage`, `tak`, `remoteHardware` —
* are already members with zero extra code.
*/
export type ModuleConfigSection = Exclude<
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
undefined
>;
/** Section -> proto-payload map, derived directly from the `ModuleConfig` message. */
export type ModuleConfig = {
readonly [V in ModuleConfigSection]?: Extract<
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"],
{ case: V }
>["value"];
};
@@ -0,0 +1,19 @@
import type { Protobuf } from "@meshtastic/core";
/**
* The radio-config sections keyed by their protobuf variant case (excluding the
* "no variant" undefined case). Derived directly from the `Config` message, so
* new firmware variants stay typed automatically — no per-section maintenance.
*/
export type RadioConfigSection = Exclude<
Protobuf.Config.Config["payloadVariant"]["case"],
undefined
>;
/** Section -> proto-payload map, derived directly from the `Config` message. */
export type RadioConfig = {
readonly [V in RadioConfigSection]?: Extract<
Protobuf.Config.Config["payloadVariant"],
{ case: V }
>["value"];
};
@@ -0,0 +1,4 @@
export { ConfigEditor } from "./domain/ConfigEditor.ts";
export type { ModuleConfig, ModuleConfigSection } from "./domain/ModuleConfig.ts";
export type { RadioConfig, RadioConfigSection } from "./domain/RadioConfig.ts";
export { ConfigMapper } from "./infrastructure/ConfigMapper.ts";
@@ -0,0 +1,26 @@
import type { Protobuf } from "@meshtastic/core";
import type { ModuleConfig } from "../domain/ModuleConfig.ts";
import type { RadioConfig } from "../domain/RadioConfig.ts";
/**
* Translates inbound protobuf `Config` / `ModuleConfig` packets into the slice's
* section-keyed domain shape. Fully generic — new firmware sections flow through
* with no code change, exactly like the SDK mapper in PR #1050.
*/
export const ConfigMapper = {
mergeRadio(existing: RadioConfig, incoming: Protobuf.Config.Config): RadioConfig {
const variant = incoming.payloadVariant;
if (!variant.case) {
return existing;
}
return { ...existing, [variant.case]: variant.value };
},
mergeModule(existing: ModuleConfig, incoming: Protobuf.ModuleConfig.ModuleConfig): ModuleConfig {
const variant = incoming.payloadVariant;
if (!variant.case) {
return existing;
}
return { ...existing, [variant.case]: variant.value };
},
};
@@ -0,0 +1,27 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/core";
import type { ModuleConfig, ModuleConfigSection } from "../domain/ModuleConfig.ts";
import type { RadioConfig, RadioConfigSection } from "../domain/RadioConfig.ts";
/** Wrap a single radio-config section value back into a `Config` message. */
export function buildRadioConfig<K extends RadioConfigSection>(
section: K,
value: NonNullable<RadioConfig[K]>,
): Protobuf.Config.Config {
return create(Protobuf.Config.ConfigSchema, {
payloadVariant: { case: section, value } as Protobuf.Config.Config["payloadVariant"],
});
}
/** Wrap a single module-config section value back into a `ModuleConfig` message. */
export function buildModuleConfig<K extends ModuleConfigSection>(
section: K,
value: NonNullable<ModuleConfig[K]>,
): Protobuf.ModuleConfig.ModuleConfig {
return create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: section,
value,
} as Protobuf.ModuleConfig.ModuleConfig["payloadVariant"],
});
}
+12
View File
@@ -0,0 +1,12 @@
// Public surface of the SDK-direction preview slice. See ./README.md.
export * from "./core/index.ts";
export { ConfigEditor } from "./features/config/index.ts";
export type {
ModuleConfig,
ModuleConfigSection,
RadioConfig,
RadioConfigSection,
} from "./features/config/index.ts";
export { getConfigEditor, meshDeviceToPort } from "./adapters/fromMeshDevice.ts";
export { useConfigEditor } from "./react/useConfigEditor.ts";
export { useSignal } from "./react/useSignal.ts";
@@ -0,0 +1,16 @@
import { useDevice } from "@core/stores";
import { getConfigEditor } from "../adapters/fromMeshDevice.ts";
import type { ConfigEditor } from "../features/config/index.ts";
/**
* Returns the active connection's `ConfigEditor`, or `undefined` when nothing is
* connected. The editor owns reactive signals — components bind to
* `editor.radio`, `editor.dirtyModuleSections`, `editor.isDirty`, etc. via
* `useSignal` to re-render on change. Mirrors `useConfigEditor` from
* `@meshtastic/sdk-react` (PR #1050), sourced here from the legacy device
* connection rather than a `MeshRegistry`.
*/
export function useConfigEditor(): ConfigEditor | undefined {
const { connection } = useDevice();
return connection ? getConfigEditor(connection) : undefined;
}
@@ -0,0 +1,16 @@
import { useSyncExternalStore } from "react";
import type { ReadonlySignal } from "../core/index.ts";
/**
* Subscribes a component to a `ReadonlySignal` and returns its current value.
*
* Uses `useSyncExternalStore` so concurrent-mode renders see a consistent
* snapshot. Mirrors `@meshtastic/sdk-react`'s `useSignal` (PR #1050).
*/
export function useSignal<T>(sig: ReadonlySignal<T>): T {
return useSyncExternalStore(
sig.subscribe,
() => sig.value,
() => sig.peek(),
);
}