Persistent nodedb (#780)
* Refactor and consolitdate store imports - Created a new index file in the core stores directory to export all stores from a single module. - Updated imports to use consolidated store exports. * Remove unnecessary import * Update imports * First steps to persist nodeDB * Use named exports * Change store import after merge * Persistent nodeDB initial work * Key mishmatch warning, new serialization handler * Minor copilot changes * Add NODEDB_RETENTION_NUM * Updated tests * Refactor PKI mismatch logic * Clear persisted db on reset * Only persist on featureFlag * Mock featureFlag in tests --------- Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
co-authored by
philon-
parent
e33463b371
commit
68ec7ee5d8
@@ -13,15 +13,13 @@ import { Outlet } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { MapProvider } from "react-map-gl/maplibre";
|
||||
// Import feature flags and dev overrides
|
||||
import "@core/services/dev-overrides.ts";
|
||||
|
||||
export function App() {
|
||||
const { getDevice } = useDeviceStore();
|
||||
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
|
||||
const { selectedDeviceId, setConnectDialogOpen, connectDialogOpen } =
|
||||
useAppStore();
|
||||
|
||||
const device = getDevice(selectedDevice);
|
||||
const device = getDevice(selectedDeviceId);
|
||||
|
||||
// Sets up light/dark mode based on user preferences or system settings
|
||||
useTheme();
|
||||
@@ -36,7 +34,7 @@ export function App() {
|
||||
/>
|
||||
<Toaster />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
<DeviceWrapper device={device}>
|
||||
<DeviceWrapper deviceId={selectedDeviceId}>
|
||||
<div
|
||||
className="flex h-screen flex-col bg-background-primary text-text-primary"
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { type Device, DeviceContext } from "@core/stores";
|
||||
import { CurrentDeviceContext } from "@core/stores";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface DeviceWrapperProps {
|
||||
children: ReactNode;
|
||||
device?: Device;
|
||||
deviceId: number;
|
||||
}
|
||||
|
||||
export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => {
|
||||
export const DeviceWrapper = ({ children, deviceId }: DeviceWrapperProps) => {
|
||||
return (
|
||||
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
|
||||
<CurrentDeviceContext.Provider value={{ deviceId }}>
|
||||
{children}
|
||||
</CurrentDeviceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(<RebootDialog open onOpenChange={() => {}} />);
|
||||
|
||||
// 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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<DeviceContext.Provider value={currentDeviceState}>
|
||||
<CurrentDeviceContext.Provider value={{ deviceId }}>
|
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||
</DeviceContext.Provider>,
|
||||
</CurrentDeviceContext.Provider>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BluetoothDevice[]>([]);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<SerialPort[]>([]);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<AppState>()((set, get) => ({
|
||||
selectedDevice: 0,
|
||||
selectedDeviceId: 0,
|
||||
devices: [],
|
||||
currentPage: "messages",
|
||||
rasterSources: [],
|
||||
@@ -81,7 +81,7 @@ export const useAppStore = create<AppState>()((set, get) => ({
|
||||
},
|
||||
setSelectedDevice: (deviceId) =>
|
||||
set(() => ({
|
||||
selectedDevice: deviceId,
|
||||
selectedDeviceId: deviceId,
|
||||
})),
|
||||
addDevice: (device) =>
|
||||
set((state) => ({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Protobuf.Mesh.RouteDiscovery>[]
|
||||
>;
|
||||
nodeErrors: Map<number, NodeError>;
|
||||
connection?: MeshDevice;
|
||||
activeNode: number;
|
||||
waypoints: Protobuf.Mesh.Waypoint[];
|
||||
pendingSettingsChanges: boolean;
|
||||
messageDraft: string;
|
||||
unreadCounts: Map<number, number>;
|
||||
nodesMap: Map<number, Protobuf.Mesh.NodeInfo>; // 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<Protobuf.Mesh.User>) => void;
|
||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||
addConnection: (connection: MeshDevice) => void;
|
||||
addTraceRoute: (
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
||||
) => 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<PrivateDeviceState>((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<PrivateDeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
addNodeInfo: (nodeInfo) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
device.nodesMap.set(nodeInfo.num, nodeInfo);
|
||||
}),
|
||||
);
|
||||
},
|
||||
setActiveNode: (node) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
@@ -538,38 +500,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
addUser: (user) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((draft) => {
|
||||
@@ -603,17 +533,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
removeNode: (nodeNum: number) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
device.nodesMap.delete(nodeNum);
|
||||
}),
|
||||
);
|
||||
},
|
||||
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
@@ -631,31 +550,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
}
|
||||
return device.dialog[dialog];
|
||||
},
|
||||
processPacket(data: ProcessPacketParams) {
|
||||
set(
|
||||
produce<PrivateDeviceState>((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<PrivateDeviceState>((draft) => {
|
||||
@@ -666,40 +561,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
setNodeError: (nodeNum: number, error: string) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
clearNodeError: (nodeNum: number) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((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<PrivateDeviceState>((draft) => {
|
||||
@@ -744,48 +605,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((set, get) => ({
|
||||
|
||||
getDevice: (id) => get().devices.get(id),
|
||||
}));
|
||||
|
||||
export const DeviceContext = createContext<Device | undefined>(undefined);
|
||||
|
||||
export const useDevice = (): Device => {
|
||||
const context = useContext(DeviceContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDevice must be used within a DeviceProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<number, Protobuf.Mesh.NodeInfo>;
|
||||
nodeErrors: Map<number, NodeError>;
|
||||
|
||||
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
||||
removeNode: (nodeNum: number) => void;
|
||||
removeAllNodes: (keepMyNode?: boolean) => void;
|
||||
processPacket: (data: ProcessPacketParams) => void;
|
||||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => 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<number, NodeDB>;
|
||||
}
|
||||
|
||||
type NodeDBData = {
|
||||
id: number;
|
||||
myNodeNum: number | undefined;
|
||||
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
|
||||
nodeErrors: Map<number, NodeError>;
|
||||
};
|
||||
|
||||
type NodeDBPersisted = {
|
||||
nodeDBs: Map<number, NodeDBData>;
|
||||
};
|
||||
|
||||
function nodeDBFactory(
|
||||
id: number,
|
||||
get: () => PrivateNodeDBState,
|
||||
set: typeof useNodeDBStore.setState,
|
||||
data?: Partial<NodeDBData>,
|
||||
): NodeDB {
|
||||
const nodeMap = data?.nodeMap ?? new Map<number, Protobuf.Mesh.NodeInfo>();
|
||||
const nodeErrors = data?.nodeErrors ?? new Map<number, NodeError>();
|
||||
const myNodeNum = data?.myNodeNum;
|
||||
|
||||
return {
|
||||
id,
|
||||
myNodeNum,
|
||||
nodeMap,
|
||||
nodeErrors,
|
||||
|
||||
addNode: (node) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>();
|
||||
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<PrivateNodeDBState>((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<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
nodeDB.nodeErrors.delete(nodeNum);
|
||||
}),
|
||||
),
|
||||
|
||||
removeAllNodeErrors: () =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
nodeDB.nodeErrors = new Map<number, NodeError>();
|
||||
}),
|
||||
),
|
||||
|
||||
processPacket: (data) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState> = (
|
||||
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<PrivateNodeDBState>((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<PrivateNodeDBState>((draft) => {
|
||||
draft.nodeDBs.delete(id);
|
||||
}),
|
||||
);
|
||||
},
|
||||
getNodeDBs: () => Array.from(get().nodeDBs.values()),
|
||||
getNodeDB: (id) => get().nodeDBs.get(id),
|
||||
});
|
||||
|
||||
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
|
||||
name: "meshtastic-nodedb-store",
|
||||
storage: createStorage<NodeDBPersisted>(),
|
||||
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<PrivateNodeDBState>((draft) => {
|
||||
const rebuilt = new Map<number, NodeDB>();
|
||||
for (const [id, data] of (
|
||||
draft.nodeDBs as unknown as Map<number, NodeDBData>
|
||||
).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<PrivateNodeDBState, [["zustand/persist", NodeDBPersisted]]>(
|
||||
persist(nodeDBInitializer, persistOptions),
|
||||
)
|
||||
: createStore<PrivateNodeDBState>()(nodeDBInitializer);
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const idbMem = new Map<string, string>();
|
||||
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<string, any> = {}) {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as idb from "idb-keyval";
|
||||
import { createStorage } from "./indexDB";
|
||||
|
||||
type PersistStorage<T> = ReturnType<typeof createStorage<T>>;
|
||||
|
||||
describe("indexDB.ts persistence (steps 1–5)", () => {
|
||||
let store: PersistStorage<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
store = createStorage<any>();
|
||||
});
|
||||
|
||||
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<number,string> and preserves numeric key semantics", async () => {
|
||||
const m = new Map<number, string>([[1, "a"]]);
|
||||
const out = await roundTrip({ state: { m }, version: 0 });
|
||||
const m2 = out!.state.m as Map<number, string>;
|
||||
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<number, number>([[2, 3]])],
|
||||
obj: { inner: new Map<string, number>([["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<number, number>).get(2)).toBe(3);
|
||||
expect(out!.state.obj.inner instanceof Map).toBe(true);
|
||||
expect((out!.state.obj.inner as Map<string, number>).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<number, { key: Uint8Array }>([[42, inner]]);
|
||||
const out = await roundTrip({ state: { m }, version: 0 });
|
||||
const m2 = out!.state.m as Map<number, { key: Uint8Array }>;
|
||||
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<string, Uint8Array>([["k", new Uint8Array([9])]]),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
const out = await roundTrip(payload);
|
||||
const m2 = out!.state.arr[0].obj.m as Map<string, Uint8Array>;
|
||||
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<number, string>;
|
||||
expect(m2 instanceof Map).toBe(true);
|
||||
expect(m2.get(1)).toBe("x");
|
||||
});
|
||||
});
|
||||
@@ -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<string, MessageLogMap>;
|
||||
broadcast: Map<ChannelId, MessageLogMap>;
|
||||
};
|
||||
nodeNum: number;
|
||||
};
|
||||
|
||||
export const zustandIndexDBStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
return (await get(name)) || null;
|
||||
@@ -29,73 +17,118 @@ export const zustandIndexDBStorage: StateStorage = {
|
||||
},
|
||||
};
|
||||
|
||||
type SerializedMap<K = unknown, V = unknown> = {
|
||||
__dataType: "Map";
|
||||
value: Array<[K, V]>;
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
type Envelope<Tag extends string = string, V = unknown> = {
|
||||
__datatype: Tag;
|
||||
value: V;
|
||||
};
|
||||
|
||||
type JsonReplacer = (key: string, value: unknown) => unknown;
|
||||
const replacer: JsonReplacer = (_, value) => {
|
||||
if (value instanceof Map) {
|
||||
const map = value as Map<unknown, unknown>;
|
||||
const serialized: SerializedMap = {
|
||||
__dataType: "Map",
|
||||
value: Array.from(map.entries()),
|
||||
};
|
||||
return serialized;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
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<SerializedMap>;
|
||||
return potentialMap.__dataType === "Map" && Array.isArray(potentialMap.value);
|
||||
interface Handler<T, Tag extends string, Encoded> {
|
||||
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<K = unknown, V = unknown> = Array<[K, V]>;
|
||||
const mapHandler: Handler<Map<unknown, unknown>, "Map", SerializedMap> = {
|
||||
tag: "Map",
|
||||
test: (val): val is Map<unknown, unknown> => val instanceof Map,
|
||||
serialize: (map) => Array.from(map.entries()),
|
||||
revive: (pairs) => new Map(pairs),
|
||||
};
|
||||
|
||||
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
|
||||
getItem: async (
|
||||
name,
|
||||
): Promise<StorageValue<PersistedMessageState> | null> => {
|
||||
const str = await zustandIndexDBStorage.getItem(name);
|
||||
if (!str) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
str,
|
||||
reviver,
|
||||
) as StorageValue<PersistedMessageState>;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing persisted state (${name}):`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: async (
|
||||
name,
|
||||
newValue: StorageValue<PersistedMessageState>,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const str = JSON.stringify(newValue, replacer);
|
||||
await zustandIndexDBStorage.setItem(name, str);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error stringifying or setting persisted state (${name}):`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
removeItem: async (name): Promise<void> => {
|
||||
await zustandIndexDBStorage.removeItem(name);
|
||||
},
|
||||
// Uint8Array handler
|
||||
const uint8ArrayHandler: Handler<Uint8Array, "Uint8Array", number[]> = {
|
||||
tag: "Uint8Array",
|
||||
test: (val): val is Uint8Array => val instanceof Uint8Array,
|
||||
serialize: (uint8array) => Array.from(uint8array),
|
||||
revive: (arr) => new Uint8Array(arr),
|
||||
};
|
||||
|
||||
const defaultHandlers = [mapHandler, uint8ArrayHandler] as const;
|
||||
|
||||
function makeJson<H extends Handler<unknown, string, unknown>>(
|
||||
handlers: readonly H[],
|
||||
) {
|
||||
const byTag = new Map<H["tag"], H>();
|
||||
for (const handler of handlers) {
|
||||
byTag.set(handler.tag, handler);
|
||||
}
|
||||
|
||||
const replacer = (_: string, value: unknown): unknown => {
|
||||
for (const handler of handlers) {
|
||||
if (handler.test(value)) {
|
||||
const encoded = handler.serialize(value as never);
|
||||
const envelope: Envelope<typeof handler.tag, typeof encoded> = {
|
||||
__datatype: handler.tag,
|
||||
value: encoded,
|
||||
};
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const reviver = (_: string, value: unknown): unknown => {
|
||||
if (isEnvelope(value)) {
|
||||
const handler = byTag.get(value.__datatype);
|
||||
if (handler) {
|
||||
return handler.revive(
|
||||
(value as Envelope<H["tag"], unknown>).value as never,
|
||||
);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return { replacer, reviver };
|
||||
}
|
||||
|
||||
export function createStorage<
|
||||
T,
|
||||
H extends Handler<unknown, string, unknown> = never,
|
||||
>(extraHandlers: readonly H[] = [] as const): PersistStorage<T> {
|
||||
const { replacer, reviver } = makeJson([
|
||||
...defaultHandlers,
|
||||
...extraHandlers,
|
||||
]);
|
||||
return {
|
||||
getItem: async (name): Promise<StorageValue<T> | null> => {
|
||||
const str = await zustandIndexDBStorage.getItem(name);
|
||||
if (!str) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(str, reviver) as StorageValue<T>;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing persisted state (${name}):`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: async (name, newValue: StorageValue<T>): Promise<void> => {
|
||||
try {
|
||||
const str = JSON.stringify(newValue, replacer);
|
||||
await zustandIndexDBStorage.setItem(name, str);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error stringifying or setting persisted state (${name}):`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
removeItem: async (name): Promise<void> => {
|
||||
await zustandIndexDBStorage.removeItem(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type DeviceContext = {
|
||||
deviceId: number; // Unique identifier for the device, not nodeNum
|
||||
};
|
||||
|
||||
export const CurrentDeviceContext = createContext<DeviceContext | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function useDeviceContext(): DeviceContext {
|
||||
const ctx = useContext(CurrentDeviceContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useDeviceContext must be used within CurrentDeviceContext provider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import { ensureDefaultUser } from "@core/dto/NodeNumToNodeInfoDTO.ts";
|
||||
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||
import { type Device, type MessageStore, MessageType } from "@core/stores";
|
||||
import {
|
||||
type Device,
|
||||
type MessageStore,
|
||||
MessageType,
|
||||
type NodeDB,
|
||||
} from "@core/stores";
|
||||
import { type MeshDevice, Protobuf } from "@meshtastic/core";
|
||||
import { fromByteArray } from "base64-js";
|
||||
|
||||
export const subscribeAll = (
|
||||
device: Device,
|
||||
connection: MeshDevice,
|
||||
messageStore: MessageStore,
|
||||
nodeDB: NodeDB,
|
||||
) => {
|
||||
let myNodeNum = 0;
|
||||
|
||||
@@ -52,20 +59,40 @@ export const subscribeAll = (
|
||||
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
||||
device.setHardware(nodeInfo);
|
||||
messageStore.setNodeNum(nodeInfo.myNodeNum);
|
||||
nodeDB.setNodeNum(nodeInfo.myNodeNum);
|
||||
myNodeNum = nodeInfo.myNodeNum;
|
||||
});
|
||||
|
||||
connection.events.onUserPacket.subscribe((user) => {
|
||||
device.addUser(user);
|
||||
nodeDB.addUser(user);
|
||||
});
|
||||
|
||||
connection.events.onPositionPacket.subscribe((position) => {
|
||||
device.addPosition(position);
|
||||
nodeDB.addPosition(position);
|
||||
});
|
||||
|
||||
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
|
||||
const nodeWithUser = ensureDefaultUser(nodeInfo);
|
||||
device.addNodeInfo(nodeWithUser);
|
||||
|
||||
if (nodeWithUser.num !== myNodeNum && nodeDB.getNode(nodeWithUser.num)) {
|
||||
const oldPublicKey = fromByteArray(
|
||||
nodeDB.getNode(nodeWithUser.num)?.user?.publicKey ?? new Uint8Array(),
|
||||
);
|
||||
const newPublicKey = fromByteArray(
|
||||
nodeWithUser.user?.publicKey ?? new Uint8Array(),
|
||||
);
|
||||
|
||||
if (oldPublicKey !== newPublicKey) {
|
||||
console.warn(
|
||||
`Node ${nodeWithUser.user?.longName} (${nodeWithUser.num}) has a different public key than expected: Expected ${oldPublicKey} but got ${newPublicKey}`,
|
||||
);
|
||||
nodeDB.setNodeError(nodeWithUser.num, "MISMATCH_PKI");
|
||||
|
||||
// TODO: Handle this error case properly (refactor PKI dialog?)
|
||||
}
|
||||
}
|
||||
|
||||
nodeDB.addNode(nodeWithUser);
|
||||
});
|
||||
|
||||
connection.events.onChannelPacket.subscribe((channel) => {
|
||||
@@ -106,7 +133,7 @@ export const subscribeAll = (
|
||||
});
|
||||
|
||||
connection.events.onMeshPacket.subscribe((meshPacket) => {
|
||||
device.processPacket({
|
||||
nodeDB.processPacket({
|
||||
from: meshPacket.from,
|
||||
snr: meshPacket.rxSnr,
|
||||
time: meshPacket.rxTime,
|
||||
@@ -128,17 +155,17 @@ export const subscribeAll = (
|
||||
break;
|
||||
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
|
||||
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
device.setNodeError(
|
||||
nodeDB.setNodeError(
|
||||
routingPacket.from,
|
||||
Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value],
|
||||
routingPacket?.data?.variant?.value,
|
||||
);
|
||||
device.setDialogOpen("refreshKeys", true);
|
||||
break;
|
||||
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
|
||||
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
device.setNodeError(
|
||||
nodeDB.setNodeError(
|
||||
routingPacket.from,
|
||||
Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value],
|
||||
routingPacket?.data?.variant?.value,
|
||||
);
|
||||
device.setDialogOpen("refreshKeys", true);
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from "react";
|
||||
import "@app/index.css";
|
||||
|
||||
// Import feature flags and dev overrides
|
||||
import "@core/services/dev-overrides.ts";
|
||||
import { enableMapSet } from "immer";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { Suspense } from "react";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from "@components/UI/Button.tsx";
|
||||
import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { useAppStore, useDeviceStore } from "@core/stores";
|
||||
import { useAppStore, useDeviceStore, useNodeDBStore } from "@core/stores";
|
||||
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,6 +12,7 @@ export const Dashboard = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { getNodeDB } = useNodeDBStore();
|
||||
|
||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||
|
||||
@@ -31,6 +32,11 @@ export const Dashboard = () => {
|
||||
{devices.length ? (
|
||||
<ul className="grow divide-y divide-slate-200">
|
||||
{devices.map((device) => {
|
||||
const nodeDB = getNodeDB(device.id);
|
||||
if (!nodeDB) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<button
|
||||
@@ -42,7 +48,7 @@ export const Dashboard = () => {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.getNode(device.hardware.myNodeNum)?.user
|
||||
{nodeDB.getNode(device.hardware.myNodeNum)?.user
|
||||
?.longName ?? t("unknown.shortName")}
|
||||
</p>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
@@ -52,9 +58,9 @@ export const Dashboard = () => {
|
||||
className="text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.getNodesLength() === 0
|
||||
{nodeDB.getNodesLength() === 0
|
||||
? 0
|
||||
: device.getNodesLength() - 1}
|
||||
: nodeDB.getNodesLength() - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user