diff --git a/packages/sdk/src/features/nodes/NodesClient.ts b/packages/sdk/src/features/nodes/NodesClient.ts index 0ab275e8..9ef7e5d3 100644 --- a/packages/sdk/src/features/nodes/NodesClient.ts +++ b/packages/sdk/src/features/nodes/NodesClient.ts @@ -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): 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 { if (this.hydrated) return; this.hydrated = true; @@ -118,20 +154,28 @@ export class NodesClient { this.errorsStore.clear(); } - public favorite(nodeNum: number): Promise> { - return favoriteNode(this.client, nodeNum); + public async favorite(nodeNum: number): Promise> { + const result = await favoriteNode(this.client, nodeNum); + if (result.status === "ok") this.patch(nodeNum, { isFavorite: true }); + return result; } - public unfavorite(nodeNum: number): Promise> { - return removeFavoriteNode(this.client, nodeNum); + public async unfavorite(nodeNum: number): Promise> { + const result = await removeFavoriteNode(this.client, nodeNum); + if (result.status === "ok") this.patch(nodeNum, { isFavorite: false }); + return result; } - public ignore(nodeNum: number): Promise> { - return ignoreNode(this.client, nodeNum); + public async ignore(nodeNum: number): Promise> { + const result = await ignoreNode(this.client, nodeNum); + if (result.status === "ok") this.patch(nodeNum, { isIgnored: true }); + return result; } - public unignore(nodeNum: number): Promise> { - return removeIgnoredNode(this.client, nodeNum); + public async unignore(nodeNum: number): Promise> { + const result = await removeIgnoredNode(this.client, nodeNum); + if (result.status === "ok") this.patch(nodeNum, { isIgnored: false }); + return result; } public remove(nodeNum: number): Promise> { @@ -141,10 +185,40 @@ export class NodesClient { return removeNodeByNum(this.client, nodeNum); } - public reset(): Promise> { - 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> { + 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 }); + } } diff --git a/packages/web/src/components/Dialog/RemoveNodeDialog.tsx b/packages/web/src/components/Dialog/RemoveNodeDialog.tsx index e4eac0bf..8aaafc61 100644 --- a/packages/web/src/components/Dialog/RemoveNodeDialog.tsx +++ b/packages/web/src/components/Dialog/RemoveNodeDialog.tsx @@ -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 ( diff --git a/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.test.tsx b/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.test.tsx index 04bc17bc..29397c0f 100644 --- a/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.test.tsx +++ b/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.test.tsx @@ -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(); 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(); }); }); diff --git a/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx b/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx index a737b908..7263ebfd 100644 --- a/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx +++ b/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx @@ -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); diff --git a/packages/web/src/core/hooks/useFavoriteNode.test.ts b/packages/web/src/core/hooks/useFavoriteNode.test.ts index f4cd317d..7e181af8 100644 --- a/packages/web/src/core/hooks/useFavoriteNode.test.ts +++ b/packages/web/src/core/hooks/useFavoriteNode.test.ts @@ -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(); }); }); diff --git a/packages/web/src/core/hooks/useFavoriteNode.ts b/packages/web/src/core/hooks/useFavoriteNode.ts index 6537c4d9..fbe01e87 100644 --- a/packages/web/src/core/hooks/useFavoriteNode.ts +++ b/packages/web/src/core/hooks/useFavoriteNode.ts @@ -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 }; diff --git a/packages/web/src/core/hooks/useIgnoreNode.test.ts b/packages/web/src/core/hooks/useIgnoreNode.test.ts index 682f3f43..0ddc0e24 100644 --- a/packages/web/src/core/hooks/useIgnoreNode.test.ts +++ b/packages/web/src/core/hooks/useIgnoreNode.test.ts @@ -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(); }); }); diff --git a/packages/web/src/core/hooks/useIgnoreNode.ts b/packages/web/src/core/hooks/useIgnoreNode.ts index 19a8e99a..01f3d0ba 100644 --- a/packages/web/src/core/hooks/useIgnoreNode.ts +++ b/packages/web/src/core/hooks/useIgnoreNode.ts @@ -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 }; diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 02e7927d..79b1697b 100644 --- a/packages/web/src/core/subscriptions.ts +++ b/packages/web/src/core/subscriptions.ts @@ -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);