feat(sdk,web): SDK NodesClient owns user/position/lastHeard/snr +

favourite-flag flips; legacy mirror retired

The SDK NodesClient now subscribes to onUserPacket / onPositionPacket /
onMeshPacket so user-record updates, GPS updates, and lastHeard / snr
refreshes flow into the signal-backed store + repository directly. The
favourite / ignored toggles also flip the local flag once the admin
message resolves successfully, so the UI no longer needs to mirror the
state into a parallel Zustand store.

packages/sdk
- NodesClient: new private patch(num, partial) helper that shallow-
  merges into the existing entry (or seeds a placeholder Node) and
  upserts to the repository in one shot. Used by:
  - onUserPacket → patch user
  - onPositionPacket → patch position
  - onMeshPacket → patch lastHeard / snr (replaces the legacy nodeDB
    processPacket flow)
  - favorite / unfavorite / ignore / unignore — flag flip on Result.ok
- NodesClient.reset({ keepMyNode? }) — preserves the local node entry
  when requested. Mirrors the previous removeAllNodes(true) semantics
  the ResetNodeDb dialog relied on.

packages/web
- core/subscriptions.ts: drops the onUserPacket / onPositionPacket /
  onNodeInfoPacket / onMeshPacket → legacy nodeDB write paths. The
  unused nodeDB parameter is renamed `_nodeDB` for callsite stability;
  it will disappear when the store itself is deleted in a follow-up.
- core/hooks/useFavoriteNode + useIgnoreNode: drop the legacy
  updateFavorite / updateIgnore mirror — the SDK flips the flag on
  success.
- components/Dialog/RemoveNodeDialog: now calls meshClient.nodes.remove
  exclusively; no legacy fall-through.
- components/Dialog/ResetNodeDbDialog: calls
  meshClient.nodes.reset({ keepMyNode: true }) and meshClient.chat.clearAll().
  No legacy nodeDB calls.
- Test mocks for useFavoriteNode / useIgnoreNode / ResetNodeDbDialog
  pruned to match.

Test counts: sdk 44 (unchanged), sdk-react 8, sdk-storage-sqlocal 24,
web 290. Production Vite build clean.

Remaining surface on the legacy nodeDB: addNode / addUser / addPosition /
processPacket / setNodeError-equivalent are now unused; the store retains
only updateFavorite / updateIgnore (no callers) and the per-device
plumbing required by the deviceContext hooks. Full deletion of
nodeDBStore queued in the plan-file follow-up.
This commit is contained in:
Dan Ditomaso
2026-04-25 22:15:24 -04:00
parent 9037ac78cf
commit 005d000047
9 changed files with 127 additions and 119 deletions

View File

@@ -1,3 +1,4 @@
import { create } from "@bufbuild/protobuf";
import * as Protobuf from "@meshtastic/protobufs";
import type { ResultType } from "better-result";
import type { MeshClient } from "../../core/client/MeshClient.ts";
@@ -41,16 +42,31 @@ export class NodesClient {
this.list = this.store.read;
this.errors = this.errorsStore.read;
client.events.onNodeInfoPacket.subscribe((info) => {
this.handleIncoming(info);
client.events.onNodeInfoPacket.subscribe((info) => this.handleIncoming(info));
client.events.onUserPacket.subscribe((packet) => {
this.patch(packet.from, { user: packet.data });
});
client.events.onPositionPacket.subscribe((packet) => {
this.patch(packet.from, { position: packet.data });
});
client.events.onMeshPacket.subscribe((packet) => {
// Every inbound mesh packet refreshes lastHeard / snr for the
// originating node — same semantics as the legacy nodeDB
// processPacket but routed through the SDK signal layer.
const nowSec = Math.floor(Date.now() / 1000);
this.patch(packet.from, {
lastHeard: packet.rxTime > 0 ? packet.rxTime : nowSec,
snr: packet.rxSnr,
});
});
client.events.onRoutingPacket.subscribe((packet) => {
if (packet.data.variant.case !== "errorReason") return;
const reason = packet.data.variant.value;
if (reason === Protobuf.Mesh.Routing_Error.NONE) return;
// Reasons that indicate a node-level fault. Anything else is just a
// transient packet failure and not worth surfacing per-node.
if (
reason === Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY ||
reason === Protobuf.Mesh.Routing_Error.PKI_FAILED ||
@@ -76,11 +92,31 @@ export class NodesClient {
const node = NodeMapper.fromProto(verdict.accepted);
this.store.set(node.num, node);
// A successful update implicitly clears any prior PKI/no-channel error.
if (this.errorsStore.has(node.num)) this.errorsStore.delete(node.num);
void this.repository.upsert(node).catch(() => {});
}
/**
* Shallow-merges a partial Node update over the existing entry. Creates a
* stub Node if none exists yet — matches the legacy nodeDB behaviour
* where a stray UserPacket / PositionPacket / mesh packet would seed a
* placeholder NodeInfo.
*/
private patch(nodeNum: number, partial: Partial<Node>): void {
if (nodeNum === 0) return;
const existing = this.store.get(nodeNum);
const next: Node = existing
? { ...existing, ...partial }
: {
num: nodeNum,
isFavorite: false,
isIgnored: false,
...partial,
};
this.store.set(nodeNum, next);
void this.repository.upsert(next).catch(() => {});
}
private async hydrate(): Promise<void> {
if (this.hydrated) return;
this.hydrated = true;
@@ -118,20 +154,28 @@ export class NodesClient {
this.errorsStore.clear();
}
public favorite(nodeNum: number): Promise<ResultType<number, Error>> {
return favoriteNode(this.client, nodeNum);
public async favorite(nodeNum: number): Promise<ResultType<number, Error>> {
const result = await favoriteNode(this.client, nodeNum);
if (result.status === "ok") this.patch(nodeNum, { isFavorite: true });
return result;
}
public unfavorite(nodeNum: number): Promise<ResultType<number, Error>> {
return removeFavoriteNode(this.client, nodeNum);
public async unfavorite(nodeNum: number): Promise<ResultType<number, Error>> {
const result = await removeFavoriteNode(this.client, nodeNum);
if (result.status === "ok") this.patch(nodeNum, { isFavorite: false });
return result;
}
public ignore(nodeNum: number): Promise<ResultType<number, Error>> {
return ignoreNode(this.client, nodeNum);
public async ignore(nodeNum: number): Promise<ResultType<number, Error>> {
const result = await ignoreNode(this.client, nodeNum);
if (result.status === "ok") this.patch(nodeNum, { isIgnored: true });
return result;
}
public unignore(nodeNum: number): Promise<ResultType<number, Error>> {
return removeIgnoredNode(this.client, nodeNum);
public async unignore(nodeNum: number): Promise<ResultType<number, Error>> {
const result = await removeIgnoredNode(this.client, nodeNum);
if (result.status === "ok") this.patch(nodeNum, { isIgnored: false });
return result;
}
public remove(nodeNum: number): Promise<ResultType<number, Error>> {
@@ -141,10 +185,40 @@ export class NodesClient {
return removeNodeByNum(this.client, nodeNum);
}
public reset(): Promise<ResultType<number, Error>> {
void this.repository.clear().catch(() => {});
this.store.clear();
this.errorsStore.clear();
/**
* Wipes every node except (optionally) the local "my node" entry, then
* sends the device-side reset admin message. Mirrors the legacy
* removeAllNodes(true) + resetNodes flow that the ResetNodeDb dialog
* relied on.
*/
public async reset(options: { keepMyNode?: boolean } = {}): Promise<ResultType<number, Error>> {
const myNodeNum = this.client.device.myNodeNum.value;
if (options.keepMyNode && myNodeNum !== undefined) {
const me = this.store.get(myNodeNum);
this.store.clear();
this.errorsStore.clear();
if (me) this.store.set(myNodeNum, me);
try {
await this.repository.clear();
if (me) await this.repository.upsert(me);
} catch {
// ok
}
} else {
this.store.clear();
this.errorsStore.clear();
try {
await this.repository.clear();
} catch {
// ok
}
}
return resetNodes(this.client);
}
/** Drives the legacy `Protobuf.Mesh.NodeInfoSchema` create flow if a
* consumer needs a placeholder for a not-yet-known node. */
public createPlaceholder(nodeNum: number): Protobuf.Mesh.NodeInfo {
return create(Protobuf.Mesh.NodeInfoSchema, { num: nodeNum });
}
}

View File

@@ -1,6 +1,7 @@
import { Label } from "@components/UI/Label.tsx";
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
import { useAppStore } from "@core/stores";
import { useActiveClient } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
import { DialogWrapper } from "./DialogWrapper.tsx";
@@ -11,14 +12,12 @@ export interface RemoveNodeDialogProps {
export const RemoveNodeDialog = ({ open, onOpenChange }: RemoveNodeDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const { removeNode } = useNodeDB();
const meshClient = useActiveClient();
const { nodeNumToBeRemoved } = useAppStore();
const node = useNodeAsProto(nodeNumToBeRemoved);
const handleConfirm = () => {
connection?.removeNodeByNum(nodeNumToBeRemoved);
removeNode(nodeNumToBeRemoved);
void meshClient?.nodes.remove(nodeNumToBeRemoved);
};
return (

View File

@@ -4,8 +4,6 @@ import { ResetNodeDbDialog } from "./ResetNodeDbDialog.tsx";
const mockResetNodes = vi.fn();
const mockClearAll = vi.fn();
const mockClearAllErrors = vi.fn();
const mockRemoveAllNodes = vi.fn();
const { mockUseActiveClient } = vi.hoisted(() => ({
mockUseActiveClient: vi.fn(),
@@ -15,27 +13,18 @@ vi.mock("@meshtastic/sdk-react", () => ({
useActiveClient: mockUseActiveClient,
}));
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useNodeDB: () => ({
removeAllNodes: mockRemoveAllNodes,
}),
}));
describe("ResetNodeDbDialog", () => {
const mockOnOpenChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockUseActiveClient.mockReturnValue({
nodes: { reset: mockResetNodes, clearAllErrors: mockClearAllErrors },
nodes: { reset: mockResetNodes },
chat: { clearAll: mockClearAll },
});
});
it("calls reset(), clears SDK errors + chat + legacy nodes after resolve", async () => {
it("calls reset({ keepMyNode: true }) then clears chat after resolve", async () => {
let resolveReset: ((value: { status: "ok"; value: number }) => void) | undefined;
mockResetNodes.mockImplementation(
() =>
@@ -48,22 +37,18 @@ describe("ResetNodeDbDialog", () => {
render(<ResetNodeDbDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Reset Node Database" }));
expect(mockResetNodes).toHaveBeenCalledTimes(1);
expect(mockResetNodes).toHaveBeenCalledWith({ keepMyNode: true });
await waitFor(() => {
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
expect(mockClearAll).not.toHaveBeenCalled();
expect(mockClearAllErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
resolveReset?.({ status: "ok", value: 1 });
await waitFor(() => {
expect(mockClearAllErrors).toHaveBeenCalledTimes(1);
expect(mockClearAll).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodes).toHaveBeenCalledWith(true);
});
});
@@ -77,7 +62,5 @@ describe("ResetNodeDbDialog", () => {
expect(mockResetNodes).not.toHaveBeenCalled();
expect(mockClearAll).not.toHaveBeenCalled();
expect(mockClearAllErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,4 @@
import { toast } from "@core/hooks/useToast.ts";
import { useNodeDB } from "@core/stores";
import { useActiveClient } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
import { DialogWrapper } from "../DialogWrapper.tsx";
@@ -12,22 +11,15 @@ export interface ResetNodeDbDialogProps {
export const ResetNodeDbDialog = ({ open, onOpenChange }: ResetNodeDbDialogProps) => {
const { t } = useTranslation("dialog");
const meshClient = useActiveClient();
// The legacy nodeDB still holds an in-memory snapshot for unmigrated
// consumers; sweep it alongside the SDK clear.
const { removeAllNodes } = useNodeDB();
const handleResetNodeDb = () => {
if (!meshClient) return;
meshClient.nodes
.reset()
.reset({ keepMyNode: true })
.then((result) => {
if (result.status === "error") throw result.error;
meshClient.nodes.clearAllErrors();
return meshClient.chat.clearAll();
})
.then(() => {
removeAllNodes(true);
})
.catch((error) => {
toast({ title: t("resetNodeDb.failedTitle") });
console.error("Failed to reset Node DB:", error);

View File

@@ -2,7 +2,6 @@ import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useFavoriteNode } from "./useFavoriteNode.ts";
const mockUpdateFavorite = vi.fn();
const mockToast = vi.fn();
const mockSdkFavorite = vi.fn();
const mockSdkUnfavorite = vi.fn();
@@ -15,15 +14,6 @@ vi.mock("@meshtastic/sdk-react", () => ({
useActiveClient: mockUseActiveClient,
}));
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useNodeDB: () => ({
updateFavorite: mockUpdateFavorite,
}),
}));
vi.mock("@core/hooks/useToast.ts", () => ({
useToast: () => ({
toast: mockToast,
@@ -47,7 +37,7 @@ describe("useFavoriteNode hook", () => {
});
});
it("calls updateFavorite and shows correct toast", () => {
it("calls SDK favorite and shows correct toast", () => {
const { result } = renderHook(() => useFavoriteNode());
act(() => {
@@ -55,7 +45,6 @@ describe("useFavoriteNode hook", () => {
});
expect(mockSdkFavorite).toHaveBeenCalledWith(1234);
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, true);
expect(mockToast).toHaveBeenCalledWith({
title: "Added Test Node to favorites.",
});
@@ -69,7 +58,6 @@ describe("useFavoriteNode hook", () => {
});
expect(mockSdkUnfavorite).toHaveBeenCalledWith(1234);
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, false);
expect(mockToast).toHaveBeenCalledWith({
title: "Removed Test Node from favorites.",
});
@@ -98,7 +86,8 @@ describe("useFavoriteNode hook", () => {
result.current.updateFavorite({ nodeNum: 9999, isFavorite: false });
});
expect(mockUpdateFavorite).not.toHaveBeenCalled();
expect(mockSdkFavorite).not.toHaveBeenCalled();
expect(mockSdkUnfavorite).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,4 @@
import { useToast } from "@core/hooks/useToast.ts";
import { useNodeDB } from "@core/stores";
import { useActiveClient } from "@meshtastic/sdk-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -9,11 +8,12 @@ interface FavoriteNodeOptions {
isFavorite: boolean;
}
/**
* Toggles the favorite flag on a node. Drives the SDK NodesClient which
* sends the matching admin message and flips the local flag on success.
*/
export function useFavoriteNode() {
const meshClient = useActiveClient();
// Mirror to the legacy nodeDB until the favorite/ignore-flag projection on
// the SDK Node entity drives the UI directly.
const { updateFavorite } = useNodeDB();
const { t } = useTranslation();
const { toast } = useToast();
@@ -25,9 +25,6 @@ export function useFavoriteNode() {
void (isFavorite ? meshClient.nodes.favorite(nodeNum) : meshClient.nodes.unfavorite(nodeNum));
// TODO: drive store mutation off the admin-message ack instead of optimistic.
updateFavorite(nodeNum, isFavorite);
toast({
title: t("toast.favoriteNode.title", {
action: isFavorite
@@ -40,7 +37,7 @@ export function useFavoriteNode() {
}),
});
},
[meshClient, updateFavorite, t, toast],
[meshClient, t, toast],
);
return { updateFavorite: updateFavoriteCB };

View File

@@ -2,7 +2,6 @@ import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useIgnoreNode } from "./useIgnoreNode.ts";
const mockUpdateIgnore = vi.fn();
const mockToast = vi.fn();
const mockSdkIgnore = vi.fn();
const mockSdkUnignore = vi.fn();
@@ -15,15 +14,6 @@ vi.mock("@meshtastic/sdk-react", () => ({
useActiveClient: mockUseActiveClient,
}));
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useNodeDB: () => ({
updateIgnore: mockUpdateIgnore,
}),
}));
vi.mock("@core/hooks/useToast.ts", () => ({
useToast: () => ({
toast: mockToast,
@@ -47,7 +37,7 @@ describe("useIgnoreNode hook", () => {
});
});
it("calls updateIgnored and shows correct toast", () => {
it("calls SDK ignore and shows correct toast", () => {
const { result } = renderHook(() => useIgnoreNode());
act(() => {
@@ -55,7 +45,6 @@ describe("useIgnoreNode hook", () => {
});
expect(mockSdkIgnore).toHaveBeenCalledWith(1234);
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, true);
expect(mockToast).toHaveBeenCalledWith({
title: "Added Test Node to ignore list",
});
@@ -69,7 +58,6 @@ describe("useIgnoreNode hook", () => {
});
expect(mockSdkUnignore).toHaveBeenCalledWith(1234);
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, false);
expect(mockToast).toHaveBeenCalledWith({
title: "Removed Test Node from ignore list",
});
@@ -98,7 +86,8 @@ describe("useIgnoreNode hook", () => {
result.current.updateIgnored({ nodeNum: 9999, isIgnored: false });
});
expect(mockUpdateIgnore).not.toHaveBeenCalled();
expect(mockSdkIgnore).not.toHaveBeenCalled();
expect(mockSdkUnignore).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,4 @@
import { useToast } from "@core/hooks/useToast.ts";
import { useNodeDB } from "@core/stores";
import { useActiveClient } from "@meshtastic/sdk-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -9,9 +8,12 @@ interface IgnoreNodeOptions {
isIgnored: boolean;
}
/**
* Toggles the ignored flag on a node. Drives the SDK NodesClient which
* sends the matching admin message and flips the local flag on success.
*/
export function useIgnoreNode() {
const meshClient = useActiveClient();
const { updateIgnore } = useNodeDB();
const { t } = useTranslation();
const { toast } = useToast();
@@ -23,8 +25,6 @@ export function useIgnoreNode() {
void (isIgnored ? meshClient.nodes.ignore(nodeNum) : meshClient.nodes.unignore(nodeNum));
updateIgnore(nodeNum, isIgnored);
toast({
title: t("toast.ignoreNode.title", {
nodeName: node.user?.longName ?? "node",
@@ -37,7 +37,7 @@ export function useIgnoreNode() {
}),
});
},
[meshClient, updateIgnore, t, toast],
[meshClient, t, toast],
);
return { updateIgnored: updateIgnoredCB };

View File

@@ -5,18 +5,20 @@ import { type MeshDevice, Protobuf } from "@meshtastic/sdk";
/**
* Wires up the legacy MeshDevice event stream into the web's Zustand stores.
*
* Note: the SDK chat slice already persists messages via the configured
* SqlocalMessageRepository, so this function no longer copies messages into
* the legacy messageStore. Unread-count increments stay here because that
* logic still lives on the device store; it migrates to the SDK in a
* follow-up "unread" cross-cutting commit.
* Note: the SDK now owns chat persistence (via SqlocalMessageRepository) and
* the entire NodesClient surface — node info, user, position, lastHeard /
* snr, favourite / ignored flags, and PKI-error tracking. This handler no
* longer mirrors any of that into the legacy stores; what remains is
* device-store-only state (waypoints, traceroutes, neighbour info, dialog
* open triggers, unread counts).
*/
export const subscribeAll = (
device: Device,
connection: MeshDevice,
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for callsite stability while messageStore is being retired
_messageStore: MessageStore,
nodeDB: NodeDB,
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for callsite stability while nodeDB is being retired
_nodeDB: NodeDB,
) => {
let myNodeNum = 0;
@@ -62,20 +64,8 @@ export const subscribeAll = (
myNodeNum = nodeInfo.myNodeNum;
});
connection.events.onUserPacket.subscribe((user) => {
nodeDB.addUser(user);
});
connection.events.onPositionPacket.subscribe((position) => {
nodeDB.addPosition(position);
});
// NOTE: Node handling is managed by the nodeDB
// Nodes are added via subscriptions.ts and stored in nodeDB
// Configuration is handled directly by meshDevice.configure() in useConnections
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
nodeDB.addNode(nodeInfo);
});
// onUserPacket / onPositionPacket / onNodeInfoPacket are handled by the
// SDK NodesClient (see packages/sdk/src/features/nodes/NodesClient.ts).
connection.events.onChannelPacket.subscribe((channel) => {
device.addChannel(channel);
@@ -111,13 +101,8 @@ export const subscribeAll = (
device.setPendingSettingsChanges(state);
});
connection.events.onMeshPacket.subscribe((meshPacket) => {
nodeDB.processPacket({
from: meshPacket.from,
snr: meshPacket.rxSnr,
time: meshPacket.rxTime,
});
});
// onMeshPacket → lastHeard / snr per-node updates are handled by the SDK
// NodesClient.
connection.events.onClientNotificationPacket.subscribe((clientNotificationPacket) => {
device.addClientNotification(clientNotificationPacket);