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