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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user