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:
@@ -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));
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user