{
+export const DeviceWrapper = ({ children, deviceId }: DeviceWrapperProps) => {
return (
-
{children}
+
+ {children}
+
);
};
diff --git a/packages/web/src/components/CommandPalette/index.tsx b/packages/web/src/components/CommandPalette/index.tsx
index a07ac46c..e3e2dc63 100644
--- a/packages/web/src/components/CommandPalette/index.tsx
+++ b/packages/web/src/components/CommandPalette/index.tsx
@@ -8,7 +8,12 @@ import {
CommandList,
} from "@components/UI/Command.tsx";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
-import { useAppStore, useDevice, useDeviceStore } from "@core/stores";
+import {
+ useAppStore,
+ useDevice,
+ useDeviceStore,
+ useNodeDB,
+} from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { useNavigate } from "@tanstack/react-router";
import { useCommandState } from "cmdk";
@@ -65,7 +70,8 @@ export const CommandPalette = () => {
setSelectedDevice,
} = useAppStore();
const { getDevices } = useDeviceStore();
- const { setDialogOpen, getNode, connection } = useDevice();
+ const { setDialogOpen, connection } = useDevice();
+ const { getNode, removeAllNodeErrors, removeAllNodes } = useNodeDB();
const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
@@ -201,6 +207,8 @@ export const CommandPalette = () => {
icon: TrashIcon,
action() {
connection?.resetNodes();
+ removeAllNodeErrors();
+ removeAllNodes(true);
},
},
{
@@ -217,6 +225,8 @@ export const CommandPalette = () => {
icon: FactoryIcon,
action() {
connection?.factoryResetDevice();
+ removeAllNodeErrors();
+ removeAllNodes();
},
},
{
diff --git a/packages/web/src/components/Dialog/DeviceNameDialog.tsx b/packages/web/src/components/Dialog/DeviceNameDialog.tsx
index ee8b15ba..055d11f8 100644
--- a/packages/web/src/components/Dialog/DeviceNameDialog.tsx
+++ b/packages/web/src/components/Dialog/DeviceNameDialog.tsx
@@ -10,7 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
-import { useDevice } from "@core/stores";
+import { useDevice, useNodeDB } from "@core/stores";
import { zodResolver } from "@hookform/resolvers/zod";
import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form";
@@ -33,7 +33,8 @@ export const DeviceNameDialog = ({
onOpenChange,
}: DeviceNameDialogProps) => {
const { t } = useTranslation("dialog");
- const { hardware, getNode, connection } = useDevice();
+ const { hardware, connection } = useDevice();
+ const { getNode } = useNodeDB();
const myNode = getNode(hardware.myNodeNum);
const defaultValues = {
diff --git a/packages/web/src/components/Dialog/LocationResponseDialog.tsx b/packages/web/src/components/Dialog/LocationResponseDialog.tsx
index e501da25..0fa9aff9 100644
--- a/packages/web/src/components/Dialog/LocationResponseDialog.tsx
+++ b/packages/web/src/components/Dialog/LocationResponseDialog.tsx
@@ -1,4 +1,4 @@
-import { useDevice } from "@core/stores";
+import { useNodeDB } from "@core/stores";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
@@ -23,7 +23,7 @@ export const LocationResponseDialog = ({
onOpenChange,
}: LocationResponseDialogProps) => {
const { t } = useTranslation("dialog");
- const { getNode } = useDevice();
+ const { getNode } = useNodeDB();
const from = getNode(location?.from ?? 0);
const longName =
diff --git a/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx b/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
index a6b9104e..a55e6794 100644
--- a/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
+++ b/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
@@ -27,7 +27,7 @@ import {
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
import { toast } from "@core/hooks/useToast.ts";
-import { useAppStore, useDevice } from "@core/stores";
+import { useAppStore, useDevice, useNodeDB } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
@@ -55,7 +55,8 @@ export const NodeDetailsDialog = ({
onOpenChange,
}: NodeDetailsDialogProps) => {
const { t } = useTranslation("dialog");
- const { setDialogOpen, connection, getNode } = useDevice();
+ const { setDialogOpen, connection } = useDevice();
+ const { getNode } = useNodeDB();
const navigate = useNavigate();
const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore();
const { updateFavorite } = useFavoriteNode();
diff --git a/packages/web/src/components/Dialog/PKIBackupDialog.tsx b/packages/web/src/components/Dialog/PKIBackupDialog.tsx
index a3f296af..df68b59d 100644
--- a/packages/web/src/components/Dialog/PKIBackupDialog.tsx
+++ b/packages/web/src/components/Dialog/PKIBackupDialog.tsx
@@ -1,3 +1,4 @@
+import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
@@ -7,12 +8,11 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
+import { useDevice, useNodeDB } from "@core/stores";
import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
-import { useDevice } from "../../core/stores";
-import { Button } from "../UI/Button.tsx";
export interface PkiBackupDialogProps {
open: boolean;
@@ -24,7 +24,8 @@ export const PkiBackupDialog = ({
onOpenChange,
}: PkiBackupDialogProps) => {
const { t } = useTranslation("dialog");
- const { config, setDialogOpen, getMyNode } = useDevice();
+ const { config, setDialogOpen } = useDevice();
+ const { getMyNode } = useNodeDB();
const privateKey = config.security?.privateKey;
const publicKey = config.security?.publicKey;
diff --git a/packages/web/src/components/Dialog/RebootDialog.test.tsx b/packages/web/src/components/Dialog/RebootDialog.test.tsx
index 85cf813c..bf88be9c 100644
--- a/packages/web/src/components/Dialog/RebootDialog.test.tsx
+++ b/packages/web/src/components/Dialog/RebootDialog.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, act } from "@testing-library/react";
import type {
ButtonHTMLAttributes,
ClassAttributes,
@@ -89,7 +89,9 @@ describe("RebootDialog", () => {
render(
{}} />);
// Schedule non-OTA reboot
- fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
+ act(() => {
+ fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
+ });
expect(rebootMock).toHaveBeenCalledWith(5);
expect(rebootOtaMock).not.toHaveBeenCalled();
@@ -97,7 +99,9 @@ describe("RebootDialog", () => {
rebootOtaMock.mockClear();
// Cancel scheduled
- fireEvent.click(screen.getByTestId("cancelRebootBtn"));
+ act(() => {
+ fireEvent.click(screen.getByTestId("cancelRebootBtn"));
+ });
expect(rebootMock).toHaveBeenCalledWith(-1);
expect(rebootOtaMock).not.toHaveBeenCalled();
@@ -105,8 +109,12 @@ describe("RebootDialog", () => {
rebootOtaMock.mockClear();
// Schedule OTA reboot
- fireEvent.click(screen.getByText(/reboot into ota mode/i));
- fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
+ act(() => {
+ fireEvent.click(screen.getByText(/reboot into ota mode/i));
+ });
+ act(() => {
+ fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
+ });
expect(rebootOtaMock).toHaveBeenCalledWith(5);
expect(rebootMock).not.toHaveBeenCalled();
});
@@ -115,17 +123,23 @@ describe("RebootDialog", () => {
const onOpenChangeMock = vi.fn();
render();
- fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
- target: { value: "3" },
+ act(() => {
+ fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
+ target: { value: "3" },
+ });
});
- fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
+ act(() => {
+ fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
+ });
expect(rebootMock).toHaveBeenCalledWith(3);
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
- vi.advanceTimersByTime(3000);
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
@@ -135,7 +149,9 @@ describe("RebootDialog", () => {
const onOpenChangeMock = vi.fn();
render();
- fireEvent.click(screen.getByRole("button", { name: /reboot now/i }));
+ act(() => {
+ fireEvent.click(screen.getByRole("button", { name: /reboot now/i }));
+ });
expect(rebootMock).toHaveBeenCalledWith(0);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
@@ -148,9 +164,13 @@ describe("RebootDialog", () => {
render();
- fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
+ act(() => {
+ fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
+ });
- vi.advanceTimersByTime(5000);
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
expect(rebootMock).not.toHaveBeenCalled();
expect(rebootOtaMock).not.toHaveBeenCalled();
@@ -162,13 +182,19 @@ describe("RebootDialog", () => {
const onOpenChangeMock = vi.fn();
render();
- fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
- target: { value: "4" },
+ act(() => {
+ fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
+ target: { value: "4" },
+ });
+ });
+ act(() => {
+ fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
});
- fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(4);
- fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
+ act(() => {
+ fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
+ });
expect(rebootMock).toHaveBeenCalledWith(-1);
expect(screen.queryByText(/reboot has been scheduled/i)).not.toBeInTheDocument();
});
diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
index 6a87ccc5..d9501fc6 100644
--- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
+++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
@@ -1,4 +1,4 @@
-import { DeviceContext, useDeviceStore, useMessageStore } from "@core/stores";
+import { CurrentDeviceContext, useDeviceStore, useMessageStore } from "@core/stores";
import { render } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
@@ -37,11 +37,6 @@ test("does not render dialog if no error exists for active chat", () => {
useDeviceStore.getState().addDevice(deviceId);
- const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
- if (!currentDeviceState) {
- throw new Error("Device not found");
- }
-
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: vi.fn(),
@@ -49,9 +44,9 @@ test("does not render dialog if no error exists for active chat", () => {
});
const { container } = render(
-
+
- ,
+ ,
);
expect(container.firstChild).toBeNull();
diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
index 388f6361..19a51e17 100644
--- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
+++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
@@ -6,7 +6,7 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
-import { useDevice, useMessageStore } from "@core/stores";
+import { useMessageStore, useNodeDB } from "@core/stores";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
@@ -22,7 +22,8 @@ export const RefreshKeysDialog = ({
}: RefreshKeysDialogProps) => {
const { t } = useTranslation("dialog");
const { activeChat } = useMessageStore();
- const { nodeErrors, getNode } = useDevice();
+ const { nodeErrors, getNode } = useNodeDB();
+
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
const nodeErrorNum = nodeErrors.get(activeChat);
diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts b/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
index 822abdca..aa7352cb 100644
--- a/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
+++ b/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
@@ -1,9 +1,9 @@
-import { useDevice, useMessageStore } from "@core/stores";
+import { useDevice, useMessageStore, useNodeDB } from "@core/stores";
import { useCallback } from "react";
export function useRefreshKeysDialog() {
- const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
- useDevice();
+ const { setDialogOpen } = useDevice();
+ const { removeNode, clearNodeError, getNodeError } = useNodeDB();
const { activeChat } = useMessageStore();
const handleCloseDialog = useCallback(() => {
diff --git a/packages/web/src/components/Dialog/RemoveNodeDialog.tsx b/packages/web/src/components/Dialog/RemoveNodeDialog.tsx
index 2bb7e181..9574fa16 100644
--- a/packages/web/src/components/Dialog/RemoveNodeDialog.tsx
+++ b/packages/web/src/components/Dialog/RemoveNodeDialog.tsx
@@ -9,7 +9,7 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Label } from "@components/UI/Label.tsx";
-import { useAppStore, useDevice } from "@core/stores";
+import { useAppStore, useDevice, useNodeDB } from "@core/stores";
import { useTranslation } from "react-i18next";
export interface RemoveNodeDialogProps {
@@ -22,7 +22,8 @@ export const RemoveNodeDialog = ({
onOpenChange,
}: RemoveNodeDialogProps) => {
const { t } = useTranslation("dialog");
- const { connection, getNode, removeNode } = useDevice();
+ const { connection } = useDevice();
+ const { getNode, removeNode } = useNodeDB();
const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => {
diff --git a/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx b/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx
index 4748e2d4..cf27fe2d 100644
--- a/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx
+++ b/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx
@@ -1,4 +1,4 @@
-import { useDevice } from "@core/stores";
+import { useNodeDB } from "@core/stores";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
@@ -25,7 +25,7 @@ export const TracerouteResponseDialog = ({
onOpenChange,
}: TracerouteResponseDialogProps) => {
const { t } = useTranslation("dialog");
- const { getNode } = useDevice();
+ const { getNode } = useNodeDB();
const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
diff --git a/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx b/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
index a2227763..cc69ef71 100644
--- a/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
+++ b/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
@@ -36,7 +36,10 @@ const mockDevice = {
setDialogOpen: vi.fn(),
};
-vi.mock("@core/stores/deviceStore", () => ({
+vi.mock("@core/stores", () => ({
+ CurrentDeviceContext: {
+ _currentValue: { deviceId: 123 },
+ },
useDevice: () => ({
setDialogOpen: mockDevice.setDialogOpen,
}),
@@ -85,7 +88,7 @@ describe("useUnsafeRolesDialog", () => {
const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(
- UNSAFE_ROLES[0],
+ UNSAFE_ROLES[0]!,
);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
@@ -97,7 +100,7 @@ describe("useUnsafeRolesDialog", () => {
expect.any(Function),
);
- const onHandler = (eventBus.on as Mock).mock.calls[0][1];
+ const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
onHandler({ action: "confirm" });
const validationResult = await validationPromise;
@@ -111,9 +114,9 @@ describe("useUnsafeRolesDialog", () => {
it("should resolve with false when user dismisses the dialog", async () => {
const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(
- UNSAFE_ROLES[0],
+ UNSAFE_ROLES[0]!,
);
- const onHandler = (eventBus.on as Mock).mock.calls[0][1];
+ const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
onHandler({ action: "dismiss" });
const validationResult = await validationPromise;
@@ -128,9 +131,9 @@ describe("useUnsafeRolesDialog", () => {
const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(
- UNSAFE_ROLES[1],
+ UNSAFE_ROLES[1]!,
);
- const onHandler = (eventBus.on as Mock).mock.calls[0][1];
+ const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
onHandler({ action: "confirm" });
await validationPromise;
@@ -157,7 +160,7 @@ describe("useUnsafeRolesDialog", () => {
true,
);
- const onHandler = (eventBus.on as Mock).mock.calls[0][1];
+ const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
onHandler({ action: "confirm" });
const validationResult = await validationPromise;
diff --git a/packages/web/src/components/PageComponents/Connect/BLE.tsx b/packages/web/src/components/PageComponents/Connect/BLE.tsx
index 40a5d307..9d50b64c 100644
--- a/packages/web/src/components/PageComponents/Connect/BLE.tsx
+++ b/packages/web/src/components/PageComponents/Connect/BLE.tsx
@@ -1,6 +1,11 @@
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
-import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores";
+import {
+ useAppStore,
+ useDeviceStore,
+ useMessageStore,
+ useNodeDBStore,
+} from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
@@ -12,7 +17,9 @@ import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
export const BLE = ({ closeDialog }: TabElementProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [bleDevices, setBleDevices] = useState([]);
+
const { addDevice } = useDeviceStore();
+ const { addNodeDB } = useNodeDBStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
@@ -29,11 +36,13 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
const id = randId();
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
const device = addDevice(id);
+ const nodeDB = addNodeDB(id);
+
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
- subscribeAll(device, connection, messageStore);
+ subscribeAll(device, connection, messageStore, nodeDB);
const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
connection.setHeartbeatInterval(HEARTBEAT_INTERVAL);
diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx
index c9bae27d..8c0733f1 100644
--- a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx
+++ b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx
@@ -9,7 +9,10 @@ vi.mock("@core/stores", () => ({
useDeviceStore: vi.fn(() => ({
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
})),
- useMessageStore: vi.fn()
+ useMessageStore: vi.fn(),
+ useNodeDBStore: vi.fn(() => ({
+ addNodeDB: vi.fn(),
+ })),
}));
vi.mock("@core/utils/randId.ts", () => ({
diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.tsx
index 566ef36d..b4ac3591 100644
--- a/packages/web/src/components/PageComponents/Connect/HTTP.tsx
+++ b/packages/web/src/components/PageComponents/Connect/HTTP.tsx
@@ -4,7 +4,12 @@ import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
-import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores";
+import {
+ useAppStore,
+ useDeviceStore,
+ useMessageStore,
+ useNodeDBStore,
+} from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
@@ -25,6 +30,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
+ const { addNodeDB } = useNodeDBStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
@@ -56,11 +62,13 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
const id = randId();
const transport = await TransportHTTP.create(data.ip, data.tls);
const device = addDevice(id);
+ const nodeDB = addNodeDB(id);
+
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
- subscribeAll(device, connection, messageStore);
+ subscribeAll(device, connection, messageStore, nodeDB);
closeDialog();
} catch (error) {
if (error instanceof Error) {
diff --git a/packages/web/src/components/PageComponents/Connect/Serial.tsx b/packages/web/src/components/PageComponents/Connect/Serial.tsx
index 2a671c7c..4015606a 100644
--- a/packages/web/src/components/PageComponents/Connect/Serial.tsx
+++ b/packages/web/src/components/PageComponents/Connect/Serial.tsx
@@ -1,6 +1,11 @@
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
-import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores";
+import {
+ useAppStore,
+ useDeviceStore,
+ useMessageStore,
+ useNodeDBStore,
+} from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
@@ -12,7 +17,9 @@ import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
export const Serial = ({ closeDialog }: TabElementProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [serialPorts, setSerialPorts] = useState([]);
+
const { addDevice } = useDeviceStore();
+ const { addNodeDB } = useNodeDBStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
@@ -34,12 +41,14 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
const onConnect = async (port: SerialPort) => {
const id = randId();
const device = addDevice(id);
+ const nodeDB = addNodeDB(id);
+
setSelectedDevice(id);
const transport = await TransportWebSerial.createFromPort(port);
const connection = new MeshDevice(transport, id);
connection.configure();
device.addConnection(connection);
- subscribeAll(device, connection, messageStore);
+ subscribeAll(device, connection, messageStore, nodeDB);
const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
connection.setHeartbeatInterval(HEARTBEAT_INTERVAL);
diff --git a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx
index 3a76ac55..4f75ccef 100644
--- a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx
+++ b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx
@@ -6,7 +6,12 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
-import { MessageState, useDevice, useMessageStore } from "@core/stores";
+import {
+ MessageState,
+ useDevice,
+ useMessageStore,
+ useNodeDB,
+} from "@core/stores";
import type { Message } from "@core/stores/messageStore/types.ts";
import { cn } from "@core/utils/cn.ts";
import { type Protobuf, Types } from "@meshtastic/core";
@@ -47,7 +52,8 @@ interface MessageItemProps {
}
export const MessageItem = ({ message }: MessageItemProps) => {
- const { config, getNode } = useDevice();
+ const { config } = useDevice();
+ const { getNode } = useNodeDB();
const { getMyNodeNum } = useMessageStore();
const { t, i18n } = useTranslation("messages");
diff --git a/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx b/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx
index 153b5b61..b3a40da7 100644
--- a/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx
+++ b/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx
@@ -1,6 +1,6 @@
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
-import { mockDeviceStore } from "@core/stores/deviceStore/deviceStore.mock.ts";
-import { useDevice } from "@core/stores";
+import { mockNodeDBStore } from "@core/stores/nodeDBStore/nodeDBStore.mock.ts";
+import { useNodeDB } from "@core/stores";
import { Protobuf } from "@meshtastic/core";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -64,9 +64,10 @@ describe("TraceRoute", () => {
]);
beforeEach(() => {
+
vi.resetAllMocks();
- vi.mocked(useDevice).mockReturnValue({
- ...mockDeviceStore,
+ vi.mocked(useNodeDB).mockReturnValue({
+ ...mockNodeDBStore,
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum);
},
diff --git a/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx b/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx
index 2449d291..a8f64498 100644
--- a/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx
+++ b/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx
@@ -1,4 +1,4 @@
-import { useDevice } from "@core/stores";
+import { useNodeDB } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
@@ -23,7 +23,7 @@ interface RoutePathProps {
}
const RoutePath = ({ title, from, to, path, snr }: RoutePathProps) => {
- const { getNode } = useDevice();
+ const { getNode } = useNodeDB();
const { t } = useTranslation();
return (
diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx
index f86de173..02d47357 100644
--- a/packages/web/src/components/Sidebar.tsx
+++ b/packages/web/src/components/Sidebar.tsx
@@ -2,7 +2,13 @@ import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
-import { type Page, useAppStore, useDevice, useSidebar } from "@core/stores";
+import {
+ type Page,
+ useAppStore,
+ useDevice,
+ useNodeDB,
+ useSidebar,
+} from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { useLocation, useNavigate } from "@tanstack/react-router";
import {
@@ -62,14 +68,8 @@ const CollapseToggleButton = () => {
};
export const Sidebar = ({ children }: SidebarProps) => {
- const {
- hardware,
- getNode,
- getNodesLength,
- metadata,
- unreadCounts,
- setDialogOpen,
- } = useDevice();
+ const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
+ const { getNode, getNodesLength } = useNodeDB();
const { setCommandPaletteOpen } = useAppStore();
const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar();
diff --git a/packages/web/src/core/hooks/useFavoriteNode.test.ts b/packages/web/src/core/hooks/useFavoriteNode.test.ts
index 6b2c0591..76063533 100644
--- a/packages/web/src/core/hooks/useFavoriteNode.test.ts
+++ b/packages/web/src/core/hooks/useFavoriteNode.test.ts
@@ -14,12 +14,19 @@ const mockNode = {
const mockUpdateFavorite = vi.fn();
const mockGetNode = vi.fn(() => mockNode);
const mockToast = vi.fn();
+const mockSendAdminMessage = vi.fn();
vi.mock("@core/stores", () => ({
- useDevice: () => ({
+ CurrentDeviceContext: {
+ _currentValue: { deviceId: 1234 },
+ },
+ useNodeDB: () => ({
updateFavorite: mockUpdateFavorite,
getNode: mockGetNode,
}),
+ useDevice: () => ({
+ sendAdminMessage: mockSendAdminMessage,
+ }),
}));
vi.mock("@core/hooks/useToast.ts", () => ({
diff --git a/packages/web/src/core/hooks/useFavoriteNode.ts b/packages/web/src/core/hooks/useFavoriteNode.ts
index d3358766..4c8faf08 100644
--- a/packages/web/src/core/hooks/useFavoriteNode.ts
+++ b/packages/web/src/core/hooks/useFavoriteNode.ts
@@ -1,5 +1,7 @@
+import { create } from "@bufbuild/protobuf";
import { useToast } from "@core/hooks/useToast.ts";
-import { useDevice } from "@core/stores";
+import { useDevice, useNodeDB } from "@core/stores";
+import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -9,7 +11,8 @@ interface FavoriteNodeOptions {
}
export function useFavoriteNode() {
- const { updateFavorite, getNode } = useDevice();
+ const { sendAdminMessage } = useDevice();
+ const { getNode, updateFavorite } = useNodeDB();
const { t } = useTranslation();
const { toast } = useToast();
@@ -20,6 +23,16 @@ export function useFavoriteNode() {
return;
}
+ sendAdminMessage(
+ create(Protobuf.Admin.AdminMessageSchema, {
+ payloadVariant: {
+ case: isFavorite ? "setFavoriteNode" : "removeFavoriteNode",
+ value: nodeNum,
+ },
+ }),
+ );
+
+ // TODO: Wait for response before changing the store
updateFavorite(nodeNum, isFavorite);
toast({
@@ -34,7 +47,7 @@ export function useFavoriteNode() {
}),
});
},
- [updateFavorite, getNode, t, toast],
+ [updateFavorite, sendAdminMessage, getNode, 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 5a7904c8..deb5b143 100644
--- a/packages/web/src/core/hooks/useIgnoreNode.test.ts
+++ b/packages/web/src/core/hooks/useIgnoreNode.test.ts
@@ -14,12 +14,19 @@ const mockNode = {
const mockUpdateIgnore = vi.fn();
const mockGetNode = vi.fn(() => mockNode);
const mockToast = vi.fn();
+const mockSendAdminMessage = vi.fn();
vi.mock("@core/stores", () => ({
- useDevice: () => ({
- updateIgnored: mockUpdateIgnore,
+ CurrentDeviceContext: {
+ _currentValue: { deviceId: 1234 },
+ },
+ useNodeDB: () => ({
+ updateIgnore: mockUpdateIgnore,
getNode: mockGetNode,
}),
+ useDevice: () => ({
+ sendAdminMessage: mockSendAdminMessage,
+ }),
}));
vi.mock("@core/hooks/useToast.ts", () => ({
diff --git a/packages/web/src/core/hooks/useIgnoreNode.ts b/packages/web/src/core/hooks/useIgnoreNode.ts
index 05e6cdc7..0a2dc099 100644
--- a/packages/web/src/core/hooks/useIgnoreNode.ts
+++ b/packages/web/src/core/hooks/useIgnoreNode.ts
@@ -1,5 +1,7 @@
+import { create } from "@bufbuild/protobuf";
import { useToast } from "@core/hooks/useToast.ts";
-import { useDevice } from "@core/stores";
+import { useDevice, useNodeDB } from "@core/stores";
+import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -9,7 +11,9 @@ interface IgnoreNodeOptions {
}
export function useIgnoreNode() {
- const { updateIgnored, getNode } = useDevice();
+ const { sendAdminMessage } = useDevice();
+ const { getNode, updateIgnore } = useNodeDB();
+
const { t } = useTranslation();
const { toast } = useToast();
@@ -21,7 +25,17 @@ export function useIgnoreNode() {
return;
}
- updateIgnored(nodeNum, isIgnored);
+ sendAdminMessage(
+ create(Protobuf.Admin.AdminMessageSchema, {
+ payloadVariant: {
+ case: isIgnored ? "setIgnoredNode" : "removeIgnoredNode",
+ value: nodeNum,
+ },
+ }),
+ );
+
+ // TODO: Wait for response before changing the store
+ updateIgnore(nodeNum, isIgnored);
toast({
title: t("toast.ignoreNode.title", {
@@ -35,7 +49,7 @@ export function useIgnoreNode() {
}),
});
},
- [updateIgnored, getNode, t, toast],
+ [sendAdminMessage, updateIgnore, getNode, t, toast],
);
return { updateIgnored: updateIgnoredCB };
diff --git a/packages/web/src/core/stores/appStore/index.ts b/packages/web/src/core/stores/appStore/index.ts
index bdf2d67f..1daa725c 100644
--- a/packages/web/src/core/stores/appStore/index.ts
+++ b/packages/web/src/core/stores/appStore/index.ts
@@ -14,7 +14,7 @@ interface ErrorState {
}
interface AppState {
- selectedDevice: number;
+ selectedDeviceId: number;
devices: {
id: number;
num: number;
@@ -48,7 +48,7 @@ interface AppState {
}
export const useAppStore = create()((set, get) => ({
- selectedDevice: 0,
+ selectedDeviceId: 0,
devices: [],
currentPage: "messages",
rasterSources: [],
@@ -81,7 +81,7 @@ export const useAppStore = create()((set, get) => ({
},
setSelectedDevice: (deviceId) =>
set(() => ({
- selectedDevice: deviceId,
+ selectedDeviceId: deviceId,
})),
addDevice: (device) =>
set((state) => ({
diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
index 63114812..b25e7884 100644
--- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
+++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
@@ -23,14 +23,12 @@ export const mockDeviceStore: Device = {
hardware: {} as Protobuf.Mesh.MyNodeInfo,
metadata: new Map(),
traceroutes: new Map(),
- nodeErrors: new Map(),
connection: undefined,
activeNode: 0,
waypoints: [],
pendingSettingsChanges: false,
messageDraft: "",
unreadCounts: new Map(),
- nodesMap: new Map(),
dialog: {
import: false,
QR: false,
@@ -64,30 +62,15 @@ export const mockDeviceStore: Device = {
setPendingSettingsChanges: vi.fn(),
addChannel: vi.fn(),
addWaypoint: vi.fn(),
- addNodeInfo: vi.fn(),
- addUser: vi.fn(),
- addPosition: vi.fn(),
addConnection: vi.fn(),
addTraceRoute: vi.fn(),
addMetadata: vi.fn(),
- removeNode: vi.fn(),
setDialogOpen: vi.fn(),
getDialogOpen: vi.fn().mockReturnValue(false),
- processPacket: vi.fn(),
setMessageDraft: vi.fn(),
- setNodeError: vi.fn(),
- clearNodeError: vi.fn(),
- getNodeError: vi.fn().mockReturnValue(undefined),
- hasNodeError: vi.fn().mockReturnValue(false),
incrementUnread: vi.fn(),
resetUnread: vi.fn(),
- getNodes: vi.fn().mockReturnValue([]),
- getNodesLength: vi.fn().mockReturnValue(0),
- getNode: vi.fn().mockReturnValue(undefined),
- getMyNode: vi.fn(),
sendAdminMessage: vi.fn(),
- updateFavorite: vi.fn(),
- updateIgnored: vi.fn(),
addClientNotification: vi.fn(),
removeClientNotification: vi.fn(),
getClientNotification: vi.fn(),
diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts
index 9ee04008..76db556c 100644
--- a/packages/web/src/core/stores/deviceStore/index.ts
+++ b/packages/web/src/core/stores/deviceStore/index.ts
@@ -1,7 +1,6 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
import { produce } from "immer";
-import { createContext, useContext } from "react";
import { create as createStore } from "zustand";
export type Page = "messages" | "map" | "config" | "channels" | "nodes";
@@ -14,10 +13,6 @@ export interface ProcessPacketParams {
export type DialogVariant = keyof Device["dialog"];
-type NodeError = {
- node: number;
- error: string;
-};
export type ValidConfigType = Exclude<
Protobuf.Config.Config["payloadVariant"]["case"],
"deviceUi" | "sessionkey" | undefined
@@ -41,14 +36,12 @@ export interface Device {
number,
Types.PacketMetadata[]
>;
- nodeErrors: Map;
connection?: MeshDevice;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
pendingSettingsChanges: boolean;
messageDraft: string;
unreadCounts: Map;
- nodesMap: Map; // dont access directly, use getNodes, or getNode
dialog: {
import: boolean;
QR: boolean;
@@ -97,36 +90,19 @@ export interface Device {
setPendingSettingsChanges: (state: boolean) => void;
addChannel: (channel: Protobuf.Channel.Channel) => void;
addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => void;
- addNodeInfo: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
- addUser: (user: Types.PacketMetadata) => void;
- addPosition: (position: Types.PacketMetadata) => void;
addConnection: (connection: MeshDevice) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata,
) => void;
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
- removeNode: (nodeNum: number) => void;
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
getDialogOpen: (dialog: DialogVariant) => boolean;
- processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => void;
- setNodeError: (nodeNum: number, error: string) => void;
- clearNodeError: (nodeNum: number) => void;
- getNodeError: (nodeNum: number) => NodeError | undefined;
- hasNodeError: (nodeNum: number) => boolean;
incrementUnread: (nodeNum: number) => void;
resetUnread: (nodeNum: number) => void;
getUnreadCount: (nodeNum: number) => number;
getAllUnreadCount: () => number;
- getNodes: (
- filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
- ) => Protobuf.Mesh.NodeInfo[];
- getNodesLength: () => number;
- getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
- getMyNode: () => Protobuf.Mesh.NodeInfo;
sendAdminMessage: (message: Protobuf.Admin.AdminMessage) => void;
- updateFavorite: (nodeNum: number, isFavorite: boolean) => void;
- updateIgnored: (nodeNum: number, isIgnored: boolean) => void;
addClientNotification: (
clientNotificationPacket: Protobuf.Mesh.ClientNotification,
) => void;
@@ -186,9 +162,7 @@ export const useDeviceStore = createStore((set, get) => ({
},
pendingSettingsChanges: false,
messageDraft: "",
- nodeErrors: new Map(),
unreadCounts: new Map(),
- nodesMap: new Map(),
clientNotifications: [],
setStatus: (status: Types.DeviceStatusEnum) => {
@@ -516,18 +490,6 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
- addNodeInfo: (nodeInfo) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
-
- if (!device) {
- return;
- }
- device.nodesMap.set(nodeInfo.num, nodeInfo);
- }),
- );
- },
setActiveNode: (node) => {
set(
produce((draft) => {
@@ -538,38 +500,6 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
- addUser: (user) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const currentNode =
- device.nodesMap.get(user.from) ??
- create(Protobuf.Mesh.NodeInfoSchema);
- currentNode.user = user.data;
- currentNode.num = user.from;
- device.nodesMap.set(user.from, currentNode);
- }),
- );
- },
- addPosition: (position) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const currentNode =
- device.nodesMap.get(position.from) ??
- create(Protobuf.Mesh.NodeInfoSchema);
- currentNode.position = position.data;
- currentNode.num = position.from;
- device.nodesMap.set(position.from, currentNode);
- }),
- );
- },
addConnection: (connection) => {
set(
produce((draft) => {
@@ -603,17 +533,6 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
- removeNode: (nodeNum: number) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- device.nodesMap.delete(nodeNum);
- }),
- );
- },
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set(
produce((draft) => {
@@ -631,31 +550,7 @@ export const useDeviceStore = createStore((set, get) => ({
}
return device.dialog[dialog];
},
- processPacket(data: ProcessPacketParams) {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const node = device.nodesMap.get(data.from);
- if (node) {
- node.lastHeard = data.time;
- node.snr = data.snr;
- device.nodesMap.set(data.from, node);
- } else {
- device.nodesMap.set(
- data.from,
- create(Protobuf.Mesh.NodeInfoSchema, {
- num: data.from,
- lastHeard: data.time,
- snr: data.snr,
- }),
- );
- }
- }),
- );
- },
+
setMessageDraft: (message: string) => {
set(
produce((draft) => {
@@ -666,40 +561,6 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
- setNodeError: (nodeNum: number, error: string) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (device) {
- device.nodeErrors.set(nodeNum, { node: nodeNum, error });
- }
- }),
- );
- },
- clearNodeError: (nodeNum: number) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (device) {
- device.nodeErrors.delete(nodeNum);
- }
- }),
- );
- },
- getNodeError: (nodeNum: number) => {
- const device = get().devices.get(id);
- if (!device) {
- throw new Error(`Device ${id} not found`);
- }
- return device.nodeErrors.get(nodeNum);
- },
- hasNodeError: (nodeNum: number) => {
- const device = get().devices.get(id);
- if (!device) {
- throw new Error(`Device ${id} not found`);
- }
- return device.nodeErrors.has(nodeNum);
- },
incrementUnread: (nodeNum: number) => {
set(
produce((draft) => {
@@ -744,48 +605,6 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
- getNodes: (
- filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
- ): Protobuf.Mesh.NodeInfo[] => {
- const device = get().devices.get(id);
- if (!device) {
- return [];
- }
- const allNodes = Array.from(device.nodesMap.values()).filter(
- (node) => node.num !== get().devices.get(id)?.hardware.myNodeNum,
- );
- if (filter) {
- return allNodes.filter(filter);
- }
- return allNodes;
- },
- getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
- const device = get().devices.get(id);
- if (!device) {
- return;
- }
- if (!device.nodesMap.has(nodeNum)) {
- return undefined;
- }
- return device.nodesMap.get(nodeNum);
- },
- getMyNode: (): Protobuf.Mesh.NodeInfo => {
- const device = get().devices.get(id);
- if (!device) {
- throw new Error(`Device ${id} not found`);
- }
- return (
- device.nodesMap.get(device.hardware.myNodeNum) ??
- create(Protobuf.Mesh.NodeInfoSchema)
- );
- },
- getNodesLength: () => {
- const device = get().devices.get(id);
- if (!device) {
- return 0;
- }
- return device.nodesMap.size;
- },
sendAdminMessage(message: Protobuf.Admin.AdminMessage) {
const device = get().devices.get(id);
@@ -800,64 +619,6 @@ export const useDeviceStore = createStore((set, get) => ({
);
},
- updateFavorite(nodeNum: number, isFavorite: boolean) {
- const device = get().devices.get(id);
- if (!device) {
- return;
- }
- const node = device?.nodesMap.get(nodeNum);
- if (!node) {
- return;
- }
-
- device.sendAdminMessage(
- create(Protobuf.Admin.AdminMessageSchema, {
- payloadVariant: {
- case: isFavorite ? "setFavoriteNode" : "removeFavoriteNode",
- value: nodeNum,
- },
- }),
- );
-
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- const node = device?.nodesMap.get(nodeNum);
- if (node) {
- node.isFavorite = isFavorite;
- }
- }),
- );
- },
- updateIgnored(nodeNum: number, isIgnored: boolean) {
- const device = get().devices.get(id);
- if (!device) {
- return;
- }
- const node = device?.nodesMap.get(nodeNum);
- if (!node) {
- return;
- }
-
- device.sendAdminMessage(
- create(Protobuf.Admin.AdminMessageSchema, {
- payloadVariant: {
- case: isIgnored ? "setIgnoredNode" : "removeIgnoredNode",
- value: nodeNum,
- },
- }),
- );
-
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- const node = device?.nodesMap.get(nodeNum);
- if (node) {
- node.isIgnored = isIgnored;
- }
- }),
- );
- },
addClientNotification: (
clientNotificationPacket: Protobuf.Mesh.ClientNotification,
) => {
@@ -912,13 +673,3 @@ export const useDeviceStore = createStore((set, get) => ({
getDevice: (id) => get().devices.get(id),
}));
-
-export const DeviceContext = createContext(undefined);
-
-export const useDevice = (): Device => {
- const context = useContext(DeviceContext);
- if (context === undefined) {
- throw new Error("useDevice must be used within a DeviceProvider");
- }
- return context;
-};
diff --git a/packages/web/src/core/stores/index.ts b/packages/web/src/core/stores/index.ts
index f10e6042..e6c73d19 100644
--- a/packages/web/src/core/stores/index.ts
+++ b/packages/web/src/core/stores/index.ts
@@ -1,22 +1,45 @@
-export { useAppStore } from "@core/stores/appStore";
+import { useDeviceContext } from "@app/core/stores/utils/useDeviceContext";
+import { type Device, useDeviceStore } from "@core/stores/deviceStore";
+import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
+export {
+ CurrentDeviceContext,
+ type DeviceContext,
+ useDeviceContext,
+} from "@app/core/stores/utils/useDeviceContext";
+export { useAppStore } from "@core/stores/appStore";
export {
type Device,
- DeviceContext,
- useDevice,
+ type Page,
useDeviceStore,
type ValidConfigType,
type ValidModuleConfigType,
} from "@core/stores/deviceStore";
-
export {
MessageState,
type MessageStore,
MessageType,
- useMessageStore,
+ useMessageStore, // TODO: Bring hook into this file
} from "@core/stores/messageStore";
-
+export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
export {
SidebarProvider,
- useSidebar,
+ useSidebar, // TODO: Bring hook into this file
} from "@core/stores/sidebarStore";
+
+// Define hooks to access the stores
+export const useNodeDB = (): NodeDB => {
+ const { deviceId } = useDeviceContext();
+ const nodeDB = useNodeDBStore(
+ (s) => s.getNodeDB(deviceId) ?? s.addNodeDB(deviceId),
+ );
+ return nodeDB;
+};
+export const useDevice = (): Device => {
+ const { deviceId } = useDeviceContext();
+
+ const device = useDeviceStore(
+ (s) => s.getDevice(deviceId) ?? s.addDevice(deviceId),
+ );
+ return device;
+};
diff --git a/packages/web/src/core/stores/nodeDBStore/index.ts b/packages/web/src/core/stores/nodeDBStore/index.ts
new file mode 100644
index 00000000..c5cdb0d2
--- /dev/null
+++ b/packages/web/src/core/stores/nodeDBStore/index.ts
@@ -0,0 +1,440 @@
+import { create } from "@bufbuild/protobuf";
+import { featureFlags } from "@core/services/featureFlags";
+import { createStorage } from "@core/stores/utils/indexDB.ts";
+import { Protobuf, type Types } from "@meshtastic/core";
+import { produce } from "immer";
+import { create as createStore, type StateCreator } from "zustand";
+import { type PersistOptions, persist } from "zustand/middleware";
+import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types";
+
+const CURRENT_STORE_VERSION = 0;
+const NODEDB_RETENTION_NUM = 10;
+
+export interface NodeDB {
+ id: number;
+ myNodeNum: number | undefined;
+ nodeMap: Map;
+ nodeErrors: Map;
+
+ addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
+ removeNode: (nodeNum: number) => void;
+ removeAllNodes: (keepMyNode?: boolean) => void;
+ processPacket: (data: ProcessPacketParams) => void;
+ addUser: (user: Types.PacketMetadata) => void;
+ addPosition: (position: Types.PacketMetadata) => void;
+ updateFavorite: (nodeNum: number, isFavorite: boolean) => void;
+ updateIgnore: (nodeNum: number, isIgnored: boolean) => void;
+ setNodeNum: (nodeNum: number) => void;
+ setNodeError: (nodeNum: number, error: NodeErrorType) => void;
+ clearNodeError: (nodeNum: number) => void;
+ removeAllNodeErrors: () => void;
+
+ getNodesLength: () => number;
+ getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
+ getNodes: (
+ filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
+ ) => Protobuf.Mesh.NodeInfo[];
+ getMyNode: () => Protobuf.Mesh.NodeInfo;
+
+ getNodeError: (nodeNum: number) => NodeError | undefined;
+ hasNodeError: (nodeNum: number) => boolean;
+}
+
+export interface nodeDBState {
+ addNodeDB: (id: number) => NodeDB;
+ removeNodeDB: (id: number) => void;
+ getNodeDBs: () => NodeDB[];
+ getNodeDB: (id: number) => NodeDB | undefined;
+}
+
+interface PrivateNodeDBState extends nodeDBState {
+ nodeDBs: Map;
+}
+
+type NodeDBData = {
+ id: number;
+ myNodeNum: number | undefined;
+ nodeMap: Map;
+ nodeErrors: Map;
+};
+
+type NodeDBPersisted = {
+ nodeDBs: Map;
+};
+
+function nodeDBFactory(
+ id: number,
+ get: () => PrivateNodeDBState,
+ set: typeof useNodeDBStore.setState,
+ data?: Partial,
+): NodeDB {
+ const nodeMap = data?.nodeMap ?? new Map();
+ const nodeErrors = data?.nodeErrors ?? new Map();
+ const myNodeNum = data?.myNodeNum;
+
+ return {
+ id,
+ myNodeNum,
+ nodeMap,
+ nodeErrors,
+
+ addNode: (node) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ nodeDB.nodeMap.set(node.num, node);
+ }),
+ ),
+
+ removeNode: (nodeNum) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ nodeDB.nodeMap.delete(nodeNum);
+ }),
+ ),
+
+ removeAllNodes: (keepMyNode) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ const newNodeMap = new Map();
+ if (
+ keepMyNode &&
+ nodeDB.myNodeNum !== undefined &&
+ nodeDB.nodeMap.has(nodeDB.myNodeNum)
+ ) {
+ newNodeMap.set(
+ nodeDB.myNodeNum,
+ nodeDB.nodeMap.get(nodeDB.myNodeNum) ??
+ create(Protobuf.Mesh.NodeInfoSchema),
+ );
+ }
+ nodeDB.nodeMap = newNodeMap;
+ }),
+ ),
+
+ setNodeError: (nodeNum, error) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ nodeDB.nodeErrors.set(nodeNum, { node: nodeNum, error });
+ }),
+ ),
+
+ clearNodeError: (nodeNum) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ nodeDB.nodeErrors.delete(nodeNum);
+ }),
+ ),
+
+ removeAllNodeErrors: () =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ nodeDB.nodeErrors = new Map();
+ }),
+ ),
+
+ processPacket: (data) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ const node = nodeDB.nodeMap.get(data.from);
+ if (node) {
+ node.lastHeard = data.time;
+ node.snr = data.snr;
+ nodeDB.nodeMap.set(data.from, node);
+ } else {
+ nodeDB.nodeMap.set(
+ data.from,
+ create(Protobuf.Mesh.NodeInfoSchema, {
+ num: data.from,
+ lastHeard: data.time,
+ snr: data.snr,
+ }),
+ );
+ }
+ }),
+ ),
+
+ addUser: (user) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ const current =
+ nodeDB.nodeMap.get(user.from) ??
+ create(Protobuf.Mesh.NodeInfoSchema);
+ current.user = user.data;
+ current.num = user.from;
+ nodeDB.nodeMap.set(user.from, current);
+ }),
+ ),
+
+ addPosition: (position) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ const current =
+ nodeDB.nodeMap.get(position.from) ??
+ create(Protobuf.Mesh.NodeInfoSchema);
+ current.position = position.data;
+ current.num = position.from;
+ nodeDB.nodeMap.set(position.from, current);
+ }),
+ ),
+
+ setNodeNum: (nodeNum) =>
+ set(
+ produce((draft) => {
+ const newDB = draft.nodeDBs.get(id);
+ if (!newDB) {
+ throw new Error(`No nodeDB found for id: ${id}`);
+ }
+
+ newDB.myNodeNum = nodeNum;
+
+ for (const [key, oldDB] of draft.nodeDBs) {
+ if (key === id) {
+ continue;
+ }
+ if (oldDB.myNodeNum === nodeNum) {
+ // The new DB is typically empty when nodenum is set, so we can safely copy over from the old DB
+ // otherwise, discard the old DB completely
+ if (newDB.nodeMap.size === 0) {
+ newDB.nodeMap = oldDB.nodeMap;
+ newDB.nodeErrors = oldDB.nodeErrors;
+ } else {
+ console.error(
+ `NodeDB with id: ${id} already has nodes, not merging with old DB`,
+ );
+ }
+
+ draft.nodeDBs.delete(key);
+ }
+ }
+ }),
+ ),
+
+ updateFavorite: (nodeNum, isFavorite) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+
+ const node = nodeDB.nodeMap.get(nodeNum);
+ if (node) {
+ node.isFavorite = isFavorite;
+ }
+ }),
+ ),
+
+ updateIgnore: (nodeNum, isIgnored) =>
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+
+ const node = nodeDB.nodeMap.get(nodeNum);
+ if (node) {
+ node.isIgnored = isIgnored;
+ }
+ }),
+ ),
+
+ getNodesLength: () => {
+ const nodeDB = get().nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ return nodeDB.nodeMap.size;
+ },
+
+ getNode: (nodeNum) => {
+ const nodeDB = get().nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ return nodeDB.nodeMap.get(nodeNum);
+ },
+
+ getNodes: (filter) => {
+ const nodeDB = get().nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ const all = Array.from(nodeDB.nodeMap.values()).filter(
+ (n) => n.num !== nodeDB.myNodeNum,
+ );
+ return filter ? all.filter(filter) : all;
+ },
+
+ getMyNode: () => {
+ const nodeDB = get().nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ if (!nodeDB.myNodeNum) {
+ throw new Error(`No myNodeNum set for nodeDB with id: ${id}`);
+ }
+ return (
+ nodeDB.nodeMap.get(nodeDB.myNodeNum) ??
+ create(Protobuf.Mesh.NodeInfoSchema)
+ );
+ },
+
+ getNodeError: (nodeNum) => {
+ const nodeDB = get().nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ return nodeDB.nodeErrors.get(nodeNum);
+ },
+
+ hasNodeError: (nodeNum) => {
+ const nodeDB = get().nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+ return nodeDB.nodeErrors.has(nodeNum);
+ },
+ };
+}
+
+export const nodeDBInitializer: StateCreator = (
+ set,
+ get,
+) => ({
+ nodeDBs: new Map(),
+
+ addNodeDB: (id) => {
+ const existing = get().nodeDBs.get(id);
+ if (existing) {
+ return existing;
+ }
+
+ const nodeDB = nodeDBFactory(id, get, set);
+ set(
+ produce((draft) => {
+ draft.nodeDBs.set(id, nodeDB);
+
+ // If over limit, remove oldest inserted. FIFO
+ if (draft.nodeDBs.size > NODEDB_RETENTION_NUM) {
+ const firstKey = draft.nodeDBs.keys().next().value;
+ if (firstKey !== undefined) {
+ draft.nodeDBs.delete(firstKey);
+ }
+ }
+ }),
+ );
+
+ return nodeDB;
+ },
+ removeNodeDB: (id) => {
+ set(
+ produce((draft) => {
+ draft.nodeDBs.delete(id);
+ }),
+ );
+ },
+ getNodeDBs: () => Array.from(get().nodeDBs.values()),
+ getNodeDB: (id) => get().nodeDBs.get(id),
+});
+
+const persistOptions: PersistOptions = {
+ name: "meshtastic-nodedb-store",
+ storage: createStorage(),
+ version: CURRENT_STORE_VERSION,
+ partialize: (s): NodeDBPersisted => ({
+ nodeDBs: new Map(
+ Array.from(s.nodeDBs.entries()).map(([id, db]) => [
+ id,
+ {
+ id: db.id,
+ myNodeNum: db.myNodeNum,
+ nodeMap: db.nodeMap,
+ nodeErrors: db.nodeErrors,
+ },
+ ]),
+ ),
+ }),
+ onRehydrateStorage: () => (state) => {
+ if (!state) {
+ return;
+ }
+ console.debug(
+ "NodeDBStore: Rehydrating state with ",
+ state.nodeDBs.size,
+ " nodeDBs -",
+ state.nodeDBs,
+ );
+
+ useNodeDBStore.setState(
+ produce((draft) => {
+ const rebuilt = new Map();
+ for (const [id, data] of (
+ draft.nodeDBs as unknown as Map
+ ).entries()) {
+ if (data.myNodeNum !== undefined) {
+ // Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
+ rebuilt.set(
+ id,
+ nodeDBFactory(
+ id,
+ useNodeDBStore.getState,
+ useNodeDBStore.setState,
+ data,
+ ),
+ );
+ }
+ }
+ draft.nodeDBs = rebuilt;
+ }),
+ );
+ },
+};
+
+// Add persist middleware on the store if the feature flag is enabled
+const persistNodes = featureFlags.get("persistNodeDB");
+console.debug(
+ `NodeDBStore: Persisting nodes is ${persistNodes ? "enabled" : "disabled"}`,
+);
+
+export const useNodeDBStore = persistNodes
+ ? createStore(
+ persist(nodeDBInitializer, persistOptions),
+ )
+ : createStore()(nodeDBInitializer);
diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts
new file mode 100644
index 00000000..031ee84d
--- /dev/null
+++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts
@@ -0,0 +1,30 @@
+import { vi } from "vitest";
+import type { NodeDB } from "./index.ts";
+
+/**
+ * You can spread this base mock in your tests and override only the
+ * properties relevant to a specific test case.
+ */
+export const mockNodeDBStore: NodeDB = {
+ id: 0,
+ myNodeNum: 0,
+ nodeErrors: new Map(),
+ nodeMap: new Map(),
+
+ addNode: vi.fn(),
+ addUser: vi.fn(),
+ addPosition: vi.fn(),
+ removeNode: vi.fn(),
+ processPacket: vi.fn(),
+ setNodeError: vi.fn(),
+ clearNodeError: vi.fn(),
+ getNodeError: vi.fn().mockReturnValue(undefined),
+ hasNodeError: vi.fn().mockReturnValue(false),
+ getNodes: vi.fn().mockReturnValue([]),
+ getNodesLength: vi.fn().mockReturnValue(0),
+ getNode: vi.fn().mockReturnValue(undefined),
+ getMyNode: vi.fn(),
+ updateFavorite: vi.fn(),
+ updateIgnore: vi.fn(),
+ setNodeNum: vi.fn(),
+};
diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts
new file mode 100644
index 00000000..f13e3e9c
--- /dev/null
+++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts
@@ -0,0 +1,258 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const idbMem = new Map();
+vi.mock("idb-keyval", () => ({
+ get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
+ set: vi.fn((key: string, val: string) => {
+ idbMem.set(key, val);
+ return Promise.resolve();
+ }),
+ del: vi.fn((k: string) => {
+ idbMem.delete(k);
+ return Promise.resolve();
+ }),
+}));
+
+// import a fresh copy of the store module (because the store is created at import time)
+async function freshStore() {
+ vi.resetModules();
+ const mod = await import("../nodeDBStore");
+ return mod;
+}
+
+vi.mock("@core/services/featureFlags", () => {
+ return {
+ featureFlags: {
+ get: vi.fn((key: string) => {
+ if (key === "persistNodeDB") return true;
+ return false;
+ }),
+ },
+ };
+});
+
+function makeNode(num: number, extras: Record = {}) {
+ return { num, ...extras } as any;
+}
+
+describe("NodeDB store", () => {
+ beforeEach(() => {
+ idbMem.clear();
+ vi.clearAllMocks();
+ });
+
+ it("addNodeDB returns same instance on repeated calls; getNodeDB works", async () => {
+ const { useNodeDBStore } = await freshStore();
+
+ const db1 = useNodeDBStore.getState().addNodeDB(123);
+ const db2 = useNodeDBStore.getState().addNodeDB(123);
+ expect(db1).toBe(db2);
+
+ const got = useNodeDBStore.getState().getNodeDB(123);
+ expect(got).toBe(db1);
+
+ expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1);
+ });
+
+ it("addNode, getNode(s), getNodesLength, removeNode", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const db = useNodeDBStore.getState().addNodeDB(1);
+
+ db.addNode(makeNode(10));
+ db.addNode(makeNode(11));
+ expect(db.getNodesLength()).toBe(2);
+ expect(db.getNode(10)?.num).toBe(10);
+
+ const all = db.getNodes();
+ expect(all.map(n => n.num).sort()).toEqual([10, 11]);
+
+ db.removeNode(10);
+ expect(db.getNodesLength()).toBe(1);
+ expect(db.getNode(10)).toBeUndefined();
+ });
+
+ it("processPacket creates or updates a node", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const db = useNodeDBStore.getState().addNodeDB(1);
+
+ db.processPacket({ from: 50, time: 1111, snr: 7 } as any);
+ expect(db.getNode(50)).toBeTruthy();
+ expect(db.getNode(50)?.lastHeard).toBe(1111);
+ expect(db.getNode(50)?.snr).toBe(7);
+
+ db.processPacket({ from: 50, time: 2222, snr: 9 } as any);
+ expect(db.getNode(50)?.lastHeard).toBe(2222);
+ expect(db.getNode(50)?.snr).toBe(9);
+ });
+
+ it("addUser and addPosition updates existing or creates new nodes", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const db = useNodeDBStore.getState().addNodeDB(1);
+
+ // addUser creates node if missing
+ db.addUser({ from: 77, data: { id: "u" } } as any);
+ expect(db.getNode(77)?.user).toEqual({ id: "u" });
+
+ // addPosition updates same node
+ db.addPosition({ from: 77, data: { lat: 1, lon: 2 } } as any);
+ expect(db.getNode(77)?.position).toEqual({ lat: 1, lon: 2 });
+ expect(db.getNode(77)?.num).toBe(77);
+ });
+
+ it("errors map: setNodeError, getNodeError, hasNodeError, clearNodeError", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const db = useNodeDBStore.getState().addNodeDB(1);
+
+ db.setNodeError(10, "BadFoo" as any);
+ expect(db.hasNodeError(10)).toBe(true);
+ expect(db.getNodeError(10)).toEqual({ node: 10, error: "BadFoo" });
+
+ db.clearNodeError(10);
+ expect(db.hasNodeError(10)).toBe(false);
+ expect(db.getNodeError(10)).toBeUndefined();
+ });
+
+ it("getMyNode throws before setNodeNum; works after", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const db = useNodeDBStore.getState().addNodeDB(1);
+ db.addNode(makeNode(123));
+
+ expect(() => db.getMyNode()).toThrow();
+ db.setNodeNum(123);
+
+ const me = db.getMyNode();
+ expect(me.num).toBe(123);
+ });
+
+ it("setNodeNum merges with existing DB with same myNodeNum", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+
+
+ const oldDB = st.addNodeDB(10);
+ oldDB.setNodeNum(999);
+ oldDB.addNode(makeNode(200));
+ oldDB.setNodeError(200, "ERROR" as any);
+
+ const newDB = st.addNodeDB(11);
+ // newDB currently empty; setting same myNodeNum should copy maps from oldDB and delete old
+ newDB.setNodeNum(999);
+
+ expect(st.getNodeDB(10)).toBeUndefined();
+ expect(st.getNodeDB(11)).toBeDefined();
+ expect(newDB.getNode(200)).toBeTruthy();
+ expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" });
+ });
+
+ it("setNodeNum does not merge when new DB already has nodes; old is removed", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+
+ const oldDB = st.addNodeDB(10);
+ oldDB.setNodeNum(999);
+ oldDB.addNode(makeNode(200)); // old has data
+
+ const newDB = st.addNodeDB(11);
+ newDB.addNode(makeNode(300)); // new has data -> no merge
+ newDB.setNodeNum(999);
+
+ expect(st.getNodeDB(10)).toBeUndefined(); // old removed
+ // new kept its own nodes; did not copy old's
+ expect(newDB.getNode(300)).toBeTruthy();
+ expect(newDB.getNode(200)).toBeUndefined();
+ });
+
+ it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => {
+ {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+ const db = st.addNodeDB(123);
+ db.setNodeNum(321);
+ db.addNode(makeNode(50));
+ db.setNodeError(50, "ERROR" as any);
+ }
+ {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+ const db = st.getNodeDB(123)!;
+
+ // methods should work after rehydrate
+ expect(db.getNode(50)?.num).toBe(50);
+ expect(db.getNodeError(50)).toEqual({ node: 50, error: "ERROR" });
+ db.addNode(makeNode(51));
+ expect(db.getNode(51)).toBeTruthy();
+ }
+ });
+
+ it("getNodes applies filter and excludes myNodeNum", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const db = useNodeDBStore.getState().addNodeDB(1);
+ db.setNodeNum(11);
+ db.addNode(makeNode(10));
+ db.addNode(makeNode(11));
+ db.addNode(makeNode(12));
+
+ const all = db.getNodes();
+ expect(all.map((n) => n.num).sort()).toEqual([10, 12]); // excludes my (11)
+
+ const filtered = db.getNodes((n) => n.num > 10);
+ expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11
+ });
+
+ it("when exceeding cap, evicts earliest inserted, not the newly added", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+ for (let i = 1; i <= 10; i++) st.addNodeDB(i);
+ st.addNodeDB(11);
+ expect(st.getNodeDB(1)).toBeUndefined();
+ expect(st.getNodeDB(11)).toBeDefined();
+ });
+
+ it("removeNodeDB persists removal across reload", async () => {
+ {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+ st.addNodeDB(99);
+ expect(st.getNodeDB(99)).toBeDefined();
+ st.removeNodeDB(99);
+ expect(st.getNodeDB(99)).toBeUndefined();
+ }
+ {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+ expect(st.getNodeDB(99)).toBeUndefined(); // still gone
+ }
+ });
+
+ it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => {
+ {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+
+ const orphan = st.addNodeDB(500); // no setNodeNum
+ orphan.addNode(makeNode(1));
+
+ const good = st.addNodeDB(501);
+ good.setNodeNum(42);
+ good.addNode(makeNode(2));
+ }
+ {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+ expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped
+ expect(st.getNodeDB(501)).toBeDefined(); // kept
+ expect(st.getNodeDB(501)!.getNode(2)).toBeTruthy();
+ }
+ });
+
+ it("methods throw after their DB is removed from the map", async () => {
+ const { useNodeDBStore } = await freshStore();
+ const st = useNodeDBStore.getState();
+ const db = st.addNodeDB(800);
+
+ st.removeNodeDB(800);
+
+ expect(() => db.getNodesLength()).toThrow(/No nodeDB found/);
+ expect(() => db.addNode(makeNode(1))).toThrow(/No nodeDB found/);
+ });
+});
diff --git a/packages/web/src/core/stores/nodeDBStore/types.ts b/packages/web/src/core/stores/nodeDBStore/types.ts
new file mode 100644
index 00000000..105ddbf0
--- /dev/null
+++ b/packages/web/src/core/stores/nodeDBStore/types.ts
@@ -0,0 +1,16 @@
+import type { Protobuf } from "@meshtastic/core";
+
+type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI";
+
+type NodeError = {
+ node: number;
+ error: NodeErrorType;
+};
+
+type ProcessPacketParams = {
+ from: number;
+ snr: number;
+ time: number;
+};
+
+export type { NodeError, ProcessPacketParams, NodeErrorType };
diff --git a/packages/web/src/core/stores/utils/indexDB.test.ts b/packages/web/src/core/stores/utils/indexDB.test.ts
new file mode 100644
index 00000000..14abe329
--- /dev/null
+++ b/packages/web/src/core/stores/utils/indexDB.test.ts
@@ -0,0 +1,158 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import * as idb from "idb-keyval";
+import { createStorage } from "./indexDB";
+
+type PersistStorage = ReturnType>;
+
+describe("indexDB.ts persistence (steps 1–5)", () => {
+ let store: PersistStorage;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ store = createStorage();
+ });
+
+ async function roundTrip(obj: any) {
+ const setSpy = vi.spyOn(idb, "set").mockResolvedValue();
+ const getSpy = vi.spyOn(idb, "get");
+ await store.setItem("rt", obj);
+ const storedStr = (setSpy.mock.calls[0] as any[])[1] as string;
+ getSpy.mockResolvedValue(storedStr);
+ return await store.getItem("rt");
+ }
+
+ // Basic methods
+ it("getItem returns null when idb-keyval.get yields undefined", async () => {
+ const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined);
+ const res = await store.getItem("missing-key");
+ expect(getSpy).toHaveBeenCalledWith("missing-key");
+ expect(res).toBeNull();
+ });
+
+ it("setItem writes a string via idb-keyval.set", async () => {
+ const setSpy = vi.spyOn(idb, "set").mockResolvedValue();
+ const payload = { state: { a: 1 }, version: 0 };
+ await store.setItem("k1", payload);
+ expect(setSpy).toHaveBeenCalledTimes(1);
+ const [, value] = setSpy.mock.calls[0]!;
+ expect(typeof value).toBe("string");
+ // sanity: it should be JSON
+ expect(() => JSON.parse(value as string)).not.toThrow();
+ });
+
+ it("removeItem calls idb-keyval.del and getItem returns null afterwards", async () => {
+ const delSpy = vi.spyOn(idb, "del").mockResolvedValue();
+ await store.removeItem("k2");
+ expect(delSpy).toHaveBeenCalledWith("k2");
+
+ const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined);
+ const res = await store.getItem("k2");
+ expect(getSpy).toHaveBeenCalledWith("k2");
+ expect(res).toBeNull();
+ });
+
+ // Map
+ it("round-trips an empty Map", async () => {
+ const out = await roundTrip({ state: { m: new Map() }, version: 0 });
+ expect(out?.state.m instanceof Map).toBe(true);
+ expect(out?.state.m.size).toBe(0);
+ });
+
+ it("round-trips a Map and preserves numeric key semantics", async () => {
+ const m = new Map([[1, "a"]]);
+ const out = await roundTrip({ state: { m }, version: 0 });
+ const m2 = out!.state.m as Map;
+ expect(m2 instanceof Map).toBe(true);
+ expect(m2.get(1)).toBe("a");
+ });
+
+ it("round-trips nested Map inside arrays/objects", async () => {
+ const payload = {
+ state: {
+ list: [new Map([[2, 3]])],
+ obj: { inner: new Map([["k", 7]]) },
+ },
+ version: 0,
+ };
+ const out = await roundTrip(payload);
+
+ expect(out!.state.list[0] instanceof Map).toBe(true);
+ expect((out!.state.list[0] as Map).get(2)).toBe(3);
+ expect(out!.state.obj.inner instanceof Map).toBe(true);
+ expect((out!.state.obj.inner as Map).get("k")).toBe(7);
+ });
+
+ // Uint8Array
+ it("round-trips a Uint8Array (simple)", async () => {
+ const u8 = new Uint8Array([1, 2, 255]);
+ const out = await roundTrip({ state: { u8 }, version: 0 });
+ const u2 = out!.state.u8 as Uint8Array;
+ expect(u2 instanceof Uint8Array).toBe(true);
+ expect(Array.from(u2)).toEqual([1, 2, 255]);
+ });
+
+ it("round-trips a Uint8Array view with non-zero byteOffset", async () => {
+ const buf = new Uint8Array([0, 9, 8, 7, 6, 5, 4, 3]).buffer;
+ const view = new Uint8Array(buf, 2, 4); // [8,7,6,5]
+ const out = await roundTrip({ state: { view }, version: 0 });
+ const v2 = out!.state.view as Uint8Array;
+ expect(v2 instanceof Uint8Array).toBe(true);
+ expect(Array.from(v2)).toEqual([8, 7, 6, 5]);
+ // ensure it's a standalone buffer now, without offset
+ expect(v2.byteOffset).toBe(0);
+ });
+
+ // Mixed & nested structures
+ it("round-trips Map values containing objects with nested Uint8Array", async () => {
+ const inner = { key: new Uint8Array([7, 8]) };
+ const m = new Map([[42, inner]]);
+ const out = await roundTrip({ state: { m }, version: 0 });
+ const m2 = out!.state.m as Map;
+ expect(m2 instanceof Map).toBe(true);
+
+ const got = m2.get(42)!;
+ expect(got.key instanceof Uint8Array).toBe(true);
+ expect(Array.from(got.key)).toEqual([7, 8]);
+ });
+
+ it("round-trips deep nesting (array → object → map → u8)", async () => {
+ const payload = {
+ state: {
+ arr: [
+ {
+ obj: {
+ m: new Map([["k", new Uint8Array([9])]]),
+ },
+ },
+ ],
+ },
+ version: 0,
+ };
+ const out = await roundTrip(payload);
+ const m2 = out!.state.arr[0].obj.m as Map;
+ expect(m2 instanceof Map).toBe(true);
+ const u = m2.get("k")!;
+ expect(u instanceof Uint8Array).toBe(true);
+ expect(Array.from(u)).toEqual([9]);
+ });
+
+ it("does not alter plain objects/arrays", async () => {
+ const payload = { state: { a: 1, b: [2, 3], c: { d: 4 } }, version: 0 };
+ const out = await roundTrip(payload);
+ expect(out).toEqual(payload);
+ });
+
+ it("revives envelope-looking objects", async () => {
+ // Current implementation will treat any {__datatype:"Map",value:[...]} as an envelope.
+ const forged = JSON.stringify({
+ state: { m: { __datatype: "Map", value: [[1, "x"]] } },
+ version: 0,
+ });
+ const getSpy = vi.spyOn(idb, "get").mockResolvedValue(forged);
+ const out = await store.getItem("forged");
+ expect(getSpy).toHaveBeenCalled();
+ const m2 = out!.state.m as Map;
+ expect(m2 instanceof Map).toBe(true);
+ expect(m2.get(1)).toBe("x");
+ });
+});
\ No newline at end of file
diff --git a/packages/web/src/core/stores/utils/indexDB.ts b/packages/web/src/core/stores/utils/indexDB.ts
index 26d219d3..216f52ab 100644
--- a/packages/web/src/core/stores/utils/indexDB.ts
+++ b/packages/web/src/core/stores/utils/indexDB.ts
@@ -1,7 +1,3 @@
-import type {
- ChannelId,
- MessageLogMap,
-} from "@core/stores/messageStore/types.ts";
import { del, get, set } from "idb-keyval";
import type {
PersistStorage,
@@ -9,14 +5,6 @@ import type {
StorageValue,
} from "zustand/middleware";
-type PersistedMessageState = {
- messages: {
- direct: Map;
- broadcast: Map;
- };
- nodeNum: number;
-};
-
export const zustandIndexDBStorage: StateStorage = {
getItem: async (name: string): Promise => {
return (await get(name)) || null;
@@ -29,73 +17,118 @@ export const zustandIndexDBStorage: StateStorage = {
},
};
-type SerializedMap = {
- __dataType: "Map";
- value: Array<[K, V]>;
-};
+type AnyRecord = Record;
-type JsonReplacer = (key: string, value: unknown) => unknown;
-const replacer: JsonReplacer = (_, value) => {
- if (value instanceof Map) {
- const map = value as Map;
- const serialized: SerializedMap = {
- __dataType: "Map",
- value: Array.from(map.entries()),
- };
- return serialized;
- }
- return value;
+type Envelope = {
+ __datatype: Tag;
+ value: V;
};
-type JsonReviver = (key: string, value: unknown) => unknown;
-function isSerializedMap(value: unknown): value is SerializedMap {
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
- return false;
- }
- const potentialMap = value as Partial;
- return potentialMap.__dataType === "Map" && Array.isArray(potentialMap.value);
+interface Handler {
+ tag: Tag;
+ test(val: unknown): val is T;
+ serialize(val: T): Encoded;
+ revive(encoded: Encoded): T;
}
-const reviver: JsonReviver = (_, value) => {
- if (isSerializedMap(value)) {
- return new Map(value.value);
- }
- return value;
+
+function isObject(x: unknown): x is AnyRecord {
+ return typeof x === "object" && x !== null;
+}
+function isEnvelope(x: unknown): x is Envelope {
+ return isObject(x) && typeof x.__datatype === "string" && "value" in x;
+}
+
+// Map handler
+type SerializedMap = Array<[K, V]>;
+const mapHandler: Handler
diff --git a/packages/web/src/pages/Map/index.tsx b/packages/web/src/pages/Map/index.tsx
index 39d23dc2..b7b2217a 100644
--- a/packages/web/src/pages/Map/index.tsx
+++ b/packages/web/src/pages/Map/index.tsx
@@ -6,7 +6,7 @@ import {
import { BaseMap } from "@components/Map.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
-import { useDevice } from "@core/stores";
+import { useDevice, useNodeDB } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import type { Protobuf } from "@meshtastic/core";
import { bbox, lineString } from "@turf/turf";
@@ -30,7 +30,8 @@ const convertToLatLng = (position?: {
});
const MapPage = () => {
- const { getNodes, waypoints, hasNodeError } = useDevice();
+ const { waypoints } = useDevice();
+ const { getNodes, hasNodeError } = useNodeDB();
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
const { default: map } = useMap();
@@ -79,7 +80,7 @@ const MapPage = () => {
return;
}
- if (validNodes.length === 1) {
+ if (validNodes.length === 1 && validNodes[0]) {
map.easeTo({
zoom: map.getZoom(),
center: [
diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx
index af0c0a37..288f28e4 100644
--- a/packages/web/src/pages/Messages.tsx
+++ b/packages/web/src/pages/Messages.tsx
@@ -13,6 +13,7 @@ import {
MessageType,
useDevice,
useMessageStore,
+ useNodeDB,
useSidebar,
} from "@core/stores";
import { cn } from "@core/utils/cn.ts";
@@ -42,15 +43,9 @@ function SelectMessageChat() {
}
export const MessagesPage = () => {
- const {
- channels,
- getNodes,
- getNode,
- hasNodeError,
- getUnreadCount,
- resetUnread,
- connection,
- } = useDevice();
+ const { channels, getUnreadCount, resetUnread, connection } = useDevice();
+ const { getNodes, getNode, hasNodeError } = useNodeDB();
+
const { getMyNodeNum, getMessages, setMessageState } = useMessageStore();
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
diff --git a/packages/web/src/pages/Nodes/index.tsx b/packages/web/src/pages/Nodes/index.tsx
index 6e06b024..7f3448ad 100644
--- a/packages/web/src/pages/Nodes/index.tsx
+++ b/packages/web/src/pages/Nodes/index.tsx
@@ -17,7 +17,7 @@ import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Input } from "@components/UI/Input.tsx";
import useLang from "@core/hooks/useLang.ts";
-import { useAppStore, useDevice } from "@core/stores";
+import { useAppStore, useDevice, useNodeDB } from "@core/stores";
import { Protobuf, type Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react";
@@ -40,8 +40,8 @@ export interface DeleteNoteDialogProps {
const NodesPage = (): JSX.Element => {
const { t } = useTranslation("nodes");
const { currentLanguage } = useLang();
- const { getNodes, hardware, connection, hasNodeError, setDialogOpen } =
- useDevice();
+ const { hardware, connection, setDialogOpen } = useDevice();
+ const { getNodes, hasNodeError } = useNodeDB();
const { setNodeNumDetails } = useAppStore();
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();