Persists device and app stores across sessions (#860)
* Persistence for device and app data * esphemeral -> ephemeral Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * devices -> app Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Additional waypoint methods, update mock, update tests --------- Co-authored-by: philon- <philon-@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
philon-
parent
fc1e327b74
commit
cdad811295
+38
-33
@@ -1,29 +1,41 @@
|
|||||||
// FactoryResetDeviceDialog.test.tsx
|
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx";
|
import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx";
|
||||||
|
|
||||||
const mockFactoryResetDevice = vi.fn();
|
const mockFactoryResetDevice = vi.fn();
|
||||||
const mockDeleteAllMessages = vi.fn();
|
const mockRemoveDevice = vi.fn();
|
||||||
const mockRemoveAllNodeErrors = vi.fn();
|
const mockRemoveMessageStore = vi.fn();
|
||||||
const mockRemoveAllNodes = vi.fn();
|
const mockRemoveNodeDB = vi.fn();
|
||||||
|
const mockToast = vi.fn();
|
||||||
|
|
||||||
vi.mock("@core/stores", () => ({
|
vi.mock("@core/stores", () => {
|
||||||
|
// Make each store a callable fn (like a Zustand hook), and attach .getState()
|
||||||
|
const useDeviceStore = Object.assign(vi.fn(), {
|
||||||
|
getState: () => ({ removeDevice: mockRemoveDevice }),
|
||||||
|
});
|
||||||
|
const useMessageStore = Object.assign(vi.fn(), {
|
||||||
|
getState: () => ({ removeMessageStore: mockRemoveMessageStore }),
|
||||||
|
});
|
||||||
|
const useNodeDBStore = Object.assign(vi.fn(), {
|
||||||
|
getState: () => ({ removeNodeDB: mockRemoveNodeDB }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
CurrentDeviceContext: {
|
CurrentDeviceContext: {
|
||||||
_currentValue: { deviceId: 1234 },
|
_currentValue: { deviceId: 1234 },
|
||||||
},
|
},
|
||||||
useDevice: () => ({
|
useDevice: () => ({
|
||||||
connection: {
|
id: 42,
|
||||||
factoryResetDevice: mockFactoryResetDevice,
|
connection: { factoryResetDevice: mockFactoryResetDevice },
|
||||||
},
|
|
||||||
}),
|
|
||||||
useMessages: () => ({
|
|
||||||
deleteAllMessages: mockDeleteAllMessages,
|
|
||||||
}),
|
|
||||||
useNodeDB: () => ({
|
|
||||||
removeAllNodeErrors: mockRemoveAllNodeErrors,
|
|
||||||
removeAllNodes: mockRemoveAllNodes,
|
|
||||||
}),
|
}),
|
||||||
|
useDeviceStore,
|
||||||
|
useMessageStore,
|
||||||
|
useNodeDBStore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@core/hooks/useToast.ts", () => ({
|
||||||
|
toast: (...args: unknown[]) => mockToast(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("FactoryResetDeviceDialog", () => {
|
describe("FactoryResetDeviceDialog", () => {
|
||||||
@@ -31,10 +43,11 @@ describe("FactoryResetDeviceDialog", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockOnOpenChange.mockClear();
|
mockOnOpenChange.mockClear();
|
||||||
mockFactoryResetDevice.mockClear();
|
mockFactoryResetDevice.mockReset();
|
||||||
mockDeleteAllMessages.mockClear();
|
mockRemoveDevice.mockClear();
|
||||||
mockRemoveAllNodeErrors.mockClear();
|
mockRemoveMessageStore.mockClear();
|
||||||
mockRemoveAllNodes.mockClear();
|
mockRemoveNodeDB.mockClear();
|
||||||
|
mockToast.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => {
|
it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => {
|
||||||
@@ -61,20 +74,12 @@ describe("FactoryResetDeviceDialog", () => {
|
|||||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nothing else should have happened yet (the promise hasn't resolved)
|
|
||||||
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
|
|
||||||
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
|
|
||||||
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Resolve the reset
|
// Resolve the reset
|
||||||
resolveReset?.();
|
resolveReset?.();
|
||||||
|
|
||||||
// Now the .then() chain should fire
|
expect(mockRemoveDevice).toHaveBeenCalledTimes(1);
|
||||||
await waitFor(() => {
|
expect(mockRemoveMessageStore).toHaveBeenCalledTimes(1);
|
||||||
expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1);
|
expect(mockRemoveNodeDB).toHaveBeenCalledTimes(1);
|
||||||
expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => {
|
it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => {
|
||||||
@@ -87,8 +92,8 @@ describe("FactoryResetDeviceDialog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockFactoryResetDevice).not.toHaveBeenCalled();
|
expect(mockFactoryResetDevice).not.toHaveBeenCalled();
|
||||||
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
|
expect(mockRemoveDevice).not.toHaveBeenCalled();
|
||||||
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
|
expect(mockRemoveMessageStore).not.toHaveBeenCalled();
|
||||||
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
|
expect(mockRemoveNodeDB).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+17
-12
@@ -1,5 +1,10 @@
|
|||||||
import { toast } from "@core/hooks/useToast.ts";
|
import { toast } from "@core/hooks/useToast.ts";
|
||||||
import { useDevice, useMessages, useNodeDB } from "@core/stores";
|
import {
|
||||||
|
useDevice,
|
||||||
|
useDeviceStore,
|
||||||
|
useMessageStore,
|
||||||
|
useNodeDBStore,
|
||||||
|
} from "@core/stores";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||||
|
|
||||||
@@ -13,24 +18,24 @@ export const FactoryResetDeviceDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: FactoryResetDeviceDialogProps) => {
|
}: FactoryResetDeviceDialogProps) => {
|
||||||
const { t } = useTranslation("dialog");
|
const { t } = useTranslation("dialog");
|
||||||
const { connection } = useDevice();
|
const { connection, id } = useDevice();
|
||||||
const { removeAllNodeErrors, removeAllNodes } = useNodeDB();
|
|
||||||
const { deleteAllMessages } = useMessages();
|
|
||||||
|
|
||||||
const handleFactoryResetDevice = () => {
|
const handleFactoryResetDevice = () => {
|
||||||
connection
|
connection?.factoryResetDevice().catch((error) => {
|
||||||
?.factoryResetDevice()
|
|
||||||
.then(() => {
|
|
||||||
deleteAllMessages();
|
|
||||||
removeAllNodeErrors();
|
|
||||||
removeAllNodes();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast({
|
toast({
|
||||||
title: t("factoryResetDevice.failedTitle"),
|
title: t("factoryResetDevice.failedTitle"),
|
||||||
});
|
});
|
||||||
console.error("Failed to factory reset device:", error);
|
console.error("Failed to factory reset device:", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The device will be wiped and disconnected without resolving the promise
|
||||||
|
// so we proceed to clear all data associated with the device immediately
|
||||||
|
useDeviceStore.getState().removeDevice(id);
|
||||||
|
useMessageStore.getState().removeMessageStore(id);
|
||||||
|
useNodeDBStore.getState().removeNodeDB(id);
|
||||||
|
|
||||||
|
// Reload the app to ensure all ephemeral state is cleared
|
||||||
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ if (isDev) {
|
|||||||
featureFlags.setOverrides({
|
featureFlags.setOverrides({
|
||||||
persistNodeDB: true,
|
persistNodeDB: true,
|
||||||
persistMessages: true,
|
persistMessages: true,
|
||||||
|
persistDevices: true,
|
||||||
|
persistApp: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { z } from "zod";
|
|||||||
export const FLAG_ENV = {
|
export const FLAG_ENV = {
|
||||||
persistNodeDB: "VITE_PERSIST_NODE_DB",
|
persistNodeDB: "VITE_PERSIST_NODE_DB",
|
||||||
persistMessages: "VITE_PERSIST_MESSAGES",
|
persistMessages: "VITE_PERSIST_MESSAGES",
|
||||||
|
persistDevices: "VITE_PERSIST_DEVICES",
|
||||||
|
persistApp: "VITE_PERSIST_APP",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FlagKey = keyof typeof FLAG_ENV;
|
export type FlagKey = keyof typeof FLAG_ENV;
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { RasterSource } from "@core/stores/appStore/types.ts";
|
||||||
|
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((key: string) => {
|
||||||
|
idbMem.delete(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function freshStore(persistApp = false) {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||||
|
|
||||||
|
vi.doMock("@core/services/featureFlags.ts", () => ({
|
||||||
|
featureFlags: {
|
||||||
|
get: vi.fn((key: string) => (key === "persistApp" ? persistApp : false)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const storeMod = await import("./index.ts");
|
||||||
|
return storeMod as typeof import("./index.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRaster(fields: Record<string, any>): RasterSource {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
title: "default",
|
||||||
|
tiles: `https://default.com/default.json`,
|
||||||
|
tileSize: 256,
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AppStore – basic state & actions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
idbMem.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setters flip UI flags and numeric fields", async () => {
|
||||||
|
const { useAppStore } = await freshStore(false);
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
|
||||||
|
state.setSelectedDevice(42);
|
||||||
|
expect(useAppStore.getState().selectedDeviceId).toBe(42);
|
||||||
|
|
||||||
|
state.setCommandPaletteOpen(true);
|
||||||
|
expect(useAppStore.getState().commandPaletteOpen).toBe(true);
|
||||||
|
|
||||||
|
state.setConnectDialogOpen(true);
|
||||||
|
expect(useAppStore.getState().connectDialogOpen).toBe(true);
|
||||||
|
|
||||||
|
state.setNodeNumToBeRemoved(123);
|
||||||
|
expect(useAppStore.getState().nodeNumToBeRemoved).toBe(123);
|
||||||
|
|
||||||
|
state.setNodeNumDetails(777);
|
||||||
|
expect(useAppStore.getState().nodeNumDetails).toBe(777);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setRasterSources replaces; addRasterSource appends; removeRasterSource splices by index", async () => {
|
||||||
|
const { useAppStore } = await freshStore(false);
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
|
||||||
|
const a = makeRaster({ title: "a" });
|
||||||
|
const b = makeRaster({ title: "b" });
|
||||||
|
const c = makeRaster({ title: "c" });
|
||||||
|
|
||||||
|
state.setRasterSources([a, b]);
|
||||||
|
expect(
|
||||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title),
|
||||||
|
).toEqual(["a", "b"]);
|
||||||
|
|
||||||
|
state.addRasterSource(c);
|
||||||
|
expect(
|
||||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title),
|
||||||
|
).toEqual(["a", "b", "c"]);
|
||||||
|
|
||||||
|
// "b"
|
||||||
|
state.removeRasterSource(1);
|
||||||
|
expect(
|
||||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title),
|
||||||
|
).toEqual(["a", "c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppStore – persistence: partialize + rehydrate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
idbMem.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists only rasterSources; methods still work after rehydrate", async () => {
|
||||||
|
// Write data
|
||||||
|
{
|
||||||
|
const { useAppStore } = await freshStore(true);
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
|
||||||
|
state.setRasterSources([
|
||||||
|
makeRaster({ title: "x" }),
|
||||||
|
makeRaster({ title: "y" }),
|
||||||
|
]);
|
||||||
|
state.setSelectedDevice(99);
|
||||||
|
state.setCommandPaletteOpen(true);
|
||||||
|
// Only rasterSources should persist by partialize
|
||||||
|
expect(useAppStore.getState().rasterSources.length).toBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rehydrate from idbMem
|
||||||
|
{
|
||||||
|
const { useAppStore } = await freshStore(true);
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
|
||||||
|
// persisted slice:
|
||||||
|
expect(state.rasterSources.map((raster) => raster.title)).toEqual([
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ephemeral fields reset to defaults:
|
||||||
|
expect(state.selectedDeviceId).toBe(0);
|
||||||
|
expect(state.commandPaletteOpen).toBe(false);
|
||||||
|
expect(state.connectDialogOpen).toBe(false);
|
||||||
|
expect(state.nodeNumToBeRemoved).toBe(0);
|
||||||
|
expect(state.nodeNumDetails).toBe(0);
|
||||||
|
|
||||||
|
// methods still work post-rehydrate:
|
||||||
|
state.addRasterSource(makeRaster({ title: "z" }));
|
||||||
|
expect(
|
||||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title),
|
||||||
|
).toEqual(["x", "y", "z"]);
|
||||||
|
state.removeRasterSource(0);
|
||||||
|
expect(
|
||||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title),
|
||||||
|
).toEqual(["y", "z"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removing and resetting sources persists across reload", async () => {
|
||||||
|
{
|
||||||
|
const { useAppStore } = await freshStore(true);
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
state.setRasterSources([
|
||||||
|
makeRaster({ title: "keep" }),
|
||||||
|
makeRaster({ title: "drop" }),
|
||||||
|
]);
|
||||||
|
state.removeRasterSource(1); // drop "drop"
|
||||||
|
expect(
|
||||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title),
|
||||||
|
).toEqual(["keep"]);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { useAppStore } = await freshStore(true);
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
expect(state.rasterSources.map((raster) => raster.title)).toEqual([
|
||||||
|
"keep",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Now replace entirely
|
||||||
|
state.setRasterSources([]);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { useAppStore } = await freshStore(true);
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
expect(state.rasterSources).toEqual([]); // stayed cleared
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +1,42 @@
|
|||||||
|
import { featureFlags } from "@core/services/featureFlags.ts";
|
||||||
|
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import { create } from "zustand";
|
import { create as createStore, type StateCreator } from "zustand";
|
||||||
|
import {
|
||||||
|
type PersistOptions,
|
||||||
|
persist,
|
||||||
|
subscribeWithSelector,
|
||||||
|
} from "zustand/middleware";
|
||||||
|
import type { RasterSource } from "./types.ts";
|
||||||
|
|
||||||
export interface RasterSource {
|
const IDB_KEY_NAME = "meshtastic-app-store";
|
||||||
enabled: boolean;
|
const CURRENT_STORE_VERSION = 0;
|
||||||
title: string;
|
|
||||||
tiles: string;
|
|
||||||
tileSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppState {
|
type AppData = {
|
||||||
selectedDeviceId: number;
|
// Persisted data
|
||||||
devices: {
|
|
||||||
id: number;
|
|
||||||
num: number;
|
|
||||||
}[];
|
|
||||||
rasterSources: RasterSource[];
|
rasterSources: RasterSource[];
|
||||||
commandPaletteOpen: boolean;
|
};
|
||||||
|
|
||||||
|
export interface AppState extends AppData {
|
||||||
|
// Ephemeral state (not persisted)
|
||||||
|
selectedDeviceId: number;
|
||||||
nodeNumToBeRemoved: number;
|
nodeNumToBeRemoved: number;
|
||||||
connectDialogOpen: boolean;
|
connectDialogOpen: boolean;
|
||||||
nodeNumDetails: number;
|
nodeNumDetails: number;
|
||||||
|
commandPaletteOpen: boolean;
|
||||||
|
|
||||||
setRasterSources: (sources: RasterSource[]) => void;
|
setRasterSources: (sources: RasterSource[]) => void;
|
||||||
addRasterSource: (source: RasterSource) => void;
|
addRasterSource: (source: RasterSource) => void;
|
||||||
removeRasterSource: (index: number) => void;
|
removeRasterSource: (index: number) => void;
|
||||||
setSelectedDevice: (deviceId: number) => void;
|
setSelectedDevice: (deviceId: number) => void;
|
||||||
addDevice: (device: { id: number; num: number }) => void;
|
|
||||||
removeDevice: (deviceId: number) => void;
|
|
||||||
setCommandPaletteOpen: (open: boolean) => void;
|
setCommandPaletteOpen: (open: boolean) => void;
|
||||||
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
||||||
setConnectDialogOpen: (open: boolean) => void;
|
setConnectDialogOpen: (open: boolean) => void;
|
||||||
setNodeNumDetails: (nodeNum: number) => void;
|
setNodeNumDetails: (nodeNum: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>()((set, _get) => ({
|
export const deviceStoreInitializer: StateCreator<AppState> = (set, _get) => ({
|
||||||
selectedDeviceId: 0,
|
selectedDeviceId: 0,
|
||||||
devices: [],
|
|
||||||
currentPage: "messages",
|
|
||||||
rasterSources: [],
|
rasterSources: [],
|
||||||
commandPaletteOpen: false,
|
commandPaletteOpen: false,
|
||||||
connectDialogOpen: false,
|
connectDialogOpen: false,
|
||||||
@@ -67,14 +68,6 @@ export const useAppStore = create<AppState>()((set, _get) => ({
|
|||||||
set(() => ({
|
set(() => ({
|
||||||
selectedDeviceId: deviceId,
|
selectedDeviceId: deviceId,
|
||||||
})),
|
})),
|
||||||
addDevice: (device) =>
|
|
||||||
set((state) => ({
|
|
||||||
devices: [...state.devices, device],
|
|
||||||
})),
|
|
||||||
removeDevice: (deviceId) =>
|
|
||||||
set((state) => ({
|
|
||||||
devices: state.devices.filter((device) => device.id !== deviceId),
|
|
||||||
})),
|
|
||||||
setCommandPaletteOpen: (open: boolean) => {
|
setCommandPaletteOpen: (open: boolean) => {
|
||||||
set(
|
set(
|
||||||
produce<AppState>((draft) => {
|
produce<AppState>((draft) => {
|
||||||
@@ -93,9 +86,35 @@ export const useAppStore = create<AppState>()((set, _get) => ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
setNodeNumDetails: (nodeNum) =>
|
setNodeNumDetails: (nodeNum) =>
|
||||||
set(() => ({
|
set(() => ({
|
||||||
nodeNumDetails: nodeNum,
|
nodeNumDetails: nodeNum,
|
||||||
})),
|
})),
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
const persistOptions: PersistOptions<AppState, AppData> = {
|
||||||
|
name: IDB_KEY_NAME,
|
||||||
|
storage: createStorage<AppData>(),
|
||||||
|
version: CURRENT_STORE_VERSION,
|
||||||
|
partialize: (s): AppData => ({
|
||||||
|
rasterSources: s.rasterSources,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug("AppStore: Rehydrating state", state);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add persist middleware on the store if the feature flag is enabled
|
||||||
|
const persistApps = featureFlags.get("persistApp");
|
||||||
|
console.debug(
|
||||||
|
`AppStore: Persisting app is ${persistApps ? "enabled" : "disabled"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useAppStore = persistApps
|
||||||
|
? createStore(
|
||||||
|
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
|
||||||
|
)
|
||||||
|
: createStore(subscribeWithSelector(deviceStoreInitializer));
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface RasterSource {
|
||||||
|
enabled: boolean;
|
||||||
|
title: string;
|
||||||
|
tiles: string;
|
||||||
|
tileSize: number;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import type { Device } from "./index.ts";
|
|||||||
*/
|
*/
|
||||||
export const mockDeviceStore: Device = {
|
export const mockDeviceStore: Device = {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
myNodeNum: 123456,
|
||||||
status: 5 as const,
|
status: 5 as const,
|
||||||
channels: new Map(),
|
channels: new Map(),
|
||||||
config: {} as Protobuf.LocalOnly.LocalConfig,
|
config: {} as Protobuf.LocalOnly.LocalConfig,
|
||||||
@@ -44,8 +45,13 @@ export const mockDeviceStore: Device = {
|
|||||||
deleteMessages: false,
|
deleteMessages: false,
|
||||||
managedMode: false,
|
managedMode: false,
|
||||||
clientNotification: false,
|
clientNotification: false,
|
||||||
|
resetNodeDb: false,
|
||||||
|
clearAllStores: false,
|
||||||
|
factoryResetConfig: false,
|
||||||
|
factoryResetDevice: false,
|
||||||
},
|
},
|
||||||
clientNotifications: [],
|
clientNotifications: [],
|
||||||
|
neighborInfo: new Map(),
|
||||||
|
|
||||||
setStatus: vi.fn(),
|
setStatus: vi.fn(),
|
||||||
setConfig: vi.fn(),
|
setConfig: vi.fn(),
|
||||||
@@ -66,6 +72,8 @@ export const mockDeviceStore: Device = {
|
|||||||
setPendingSettingsChanges: vi.fn(),
|
setPendingSettingsChanges: vi.fn(),
|
||||||
addChannel: vi.fn(),
|
addChannel: vi.fn(),
|
||||||
addWaypoint: vi.fn(),
|
addWaypoint: vi.fn(),
|
||||||
|
removeWaypoint: vi.fn(),
|
||||||
|
getWaypoint: vi.fn(),
|
||||||
addConnection: vi.fn(),
|
addConnection: vi.fn(),
|
||||||
addTraceRoute: vi.fn(),
|
addTraceRoute: vi.fn(),
|
||||||
addMetadata: vi.fn(),
|
addMetadata: vi.fn(),
|
||||||
@@ -80,4 +88,6 @@ export const mockDeviceStore: Device = {
|
|||||||
getClientNotification: vi.fn(),
|
getClientNotification: vi.fn(),
|
||||||
getAllUnreadCount: vi.fn().mockReturnValue(0),
|
getAllUnreadCount: vi.fn().mockReturnValue(0),
|
||||||
getUnreadCount: vi.fn().mockReturnValue(0),
|
getUnreadCount: vi.fn().mockReturnValue(0),
|
||||||
|
getNeighborInfo: vi.fn(),
|
||||||
|
addNeighborInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import { create, toBinary } from "@bufbuild/protobuf";
|
||||||
|
import { Protobuf, type Types } from "@meshtastic/core";
|
||||||
|
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();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to load a fresh copy of the store with persist flag on/off
|
||||||
|
async function freshStore(persist = false) {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
// suppress console output from the store during tests (for github actions)
|
||||||
|
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||||
|
|
||||||
|
vi.doMock("@core/services/featureFlags", () => ({
|
||||||
|
featureFlags: {
|
||||||
|
get: vi.fn((key: string) => (key === "persistDevices" ? persist : false)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const storeMod = await import("./index.ts");
|
||||||
|
const { useNodeDB } = await import("../index.ts");
|
||||||
|
return { ...storeMod, useNodeDB };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHardware(myNodeNum: number) {
|
||||||
|
return create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum });
|
||||||
|
}
|
||||||
|
function makeRoute(from: number, time = Date.now() / 1000) {
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
rxTime: time,
|
||||||
|
portnum: Protobuf.Portnums.PortNum.ROUTING_APP,
|
||||||
|
data: create(Protobuf.Mesh.RouteDiscoverySchema, {}),
|
||||||
|
} as unknown as Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>;
|
||||||
|
}
|
||||||
|
function makeChannel(index: number) {
|
||||||
|
return create(Protobuf.Channel.ChannelSchema, { index });
|
||||||
|
}
|
||||||
|
function makeWaypoint(id: number, expire?: number) {
|
||||||
|
return create(Protobuf.Mesh.WaypointSchema, { id, expire });
|
||||||
|
}
|
||||||
|
function makeConfig(fields: Record<string, any>) {
|
||||||
|
return create(Protobuf.Config.ConfigSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: "device",
|
||||||
|
value: create(Protobuf.Config.Config_DeviceConfigSchema, fields),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function makeModuleConfig(fields: Record<string, any>) {
|
||||||
|
return create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: "mqtt",
|
||||||
|
value: create(
|
||||||
|
Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema,
|
||||||
|
fields,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function makeAdminMessage(fields: Record<string, any>) {
|
||||||
|
return create(Protobuf.Admin.AdminMessageSchema, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DeviceStore – basic map ops & retention", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
idbMem.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addDevice returns same instance on repeated calls; getDevice(s) works; retention evicts oldest after cap", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
|
||||||
|
const a = state.addDevice(1);
|
||||||
|
const b = state.addDevice(1);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(state.getDevice(1)).toBe(a);
|
||||||
|
expect(state.getDevices().length).toBe(1);
|
||||||
|
|
||||||
|
// DEVICESTORE_RETENTION_NUM = 10; create 11 to evict #1
|
||||||
|
for (let i = 2; i <= 11; i++) {
|
||||||
|
state.addDevice(i);
|
||||||
|
}
|
||||||
|
expect(state.getDevice(1)).toBeUndefined();
|
||||||
|
expect(state.getDevice(11)).toBeDefined();
|
||||||
|
expect(state.getDevices().length).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeDevice deletes only that entry", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
state.addDevice(10);
|
||||||
|
state.addDevice(11);
|
||||||
|
expect(state.getDevices().length).toBe(2);
|
||||||
|
|
||||||
|
state.removeDevice(10);
|
||||||
|
expect(state.getDevice(10)).toBeUndefined();
|
||||||
|
expect(state.getDevice(11)).toBeDefined();
|
||||||
|
expect(state.getDevices().length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DeviceStore – working/effective config API", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
idbMem.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setWorkingConfig/getWorkingConfig replaces by variant and getEffectiveConfig merges base + working", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
const device = state.addDevice(42);
|
||||||
|
|
||||||
|
// config deviceConfig.role = CLIENT
|
||||||
|
device.setConfig(
|
||||||
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: "device",
|
||||||
|
value: create(Protobuf.Config.Config_DeviceConfigSchema, {
|
||||||
|
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// working deviceConfig.role = ROUTER
|
||||||
|
device.setWorkingConfig(
|
||||||
|
makeConfig({
|
||||||
|
role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// expect working deviceConfig.role = ROUTER
|
||||||
|
const working = device.getWorkingConfig("device");
|
||||||
|
expect(working?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER);
|
||||||
|
|
||||||
|
// expect effective deviceConfig.role = ROUTER
|
||||||
|
const effective = device.getEffectiveConfig("device");
|
||||||
|
expect(effective?.role).toBe(
|
||||||
|
Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
|
||||||
|
);
|
||||||
|
|
||||||
|
// remove working, effective should equal base
|
||||||
|
device.removeWorkingConfig("device");
|
||||||
|
expect(device.getWorkingConfig("device")).toBeUndefined();
|
||||||
|
expect(device.getEffectiveConfig("device")?.role).toBe(
|
||||||
|
Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// add multiple, then clear all
|
||||||
|
device.setWorkingConfig(makeConfig({}));
|
||||||
|
device.setWorkingConfig(
|
||||||
|
makeConfig({
|
||||||
|
deviceRole: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
device.removeWorkingConfig(); // clears all
|
||||||
|
expect(device.getWorkingConfig("device")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setWorkingModuleConfig/getWorkingModuleConfig and getEffectiveModuleConfig", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
const device = state.addDevice(7);
|
||||||
|
|
||||||
|
// base moduleConfig.mqtt empty; add working mqtt host
|
||||||
|
device.setModuleConfig(
|
||||||
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: "mqtt",
|
||||||
|
value: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, {
|
||||||
|
address: "mqtt://base",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
device.setWorkingModuleConfig(
|
||||||
|
makeModuleConfig({ address: "mqtt://working" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mqtt = device.getWorkingModuleConfig("mqtt");
|
||||||
|
expect(mqtt?.address).toBe("mqtt://working");
|
||||||
|
expect(mqtt?.address).toBe("mqtt://working");
|
||||||
|
|
||||||
|
device.removeWorkingModuleConfig("mqtt");
|
||||||
|
expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined();
|
||||||
|
expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe(
|
||||||
|
"mqtt://base",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
device.setWorkingModuleConfig(makeModuleConfig({ address: "x" }));
|
||||||
|
device.setWorkingModuleConfig(makeModuleConfig({ address: "y" }));
|
||||||
|
device.removeWorkingModuleConfig();
|
||||||
|
expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("channel working config add/update/remove/get", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
const device = state.addDevice(9);
|
||||||
|
|
||||||
|
device.setWorkingChannelConfig(makeChannel(0));
|
||||||
|
device.setWorkingChannelConfig(
|
||||||
|
create(Protobuf.Channel.ChannelSchema, {
|
||||||
|
index: 1,
|
||||||
|
settings: { name: "one" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(device.getWorkingChannelConfig(0)?.index).toBe(0);
|
||||||
|
expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("one");
|
||||||
|
|
||||||
|
// update channel 1
|
||||||
|
device.setWorkingChannelConfig(
|
||||||
|
create(Protobuf.Channel.ChannelSchema, {
|
||||||
|
index: 1,
|
||||||
|
settings: { name: "uno" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("uno");
|
||||||
|
|
||||||
|
// remove specific
|
||||||
|
device.removeWorkingChannelConfig(1);
|
||||||
|
expect(device.getWorkingChannelConfig(1)).toBeUndefined();
|
||||||
|
|
||||||
|
// remove all
|
||||||
|
device.removeWorkingChannelConfig();
|
||||||
|
expect(device.getWorkingChannelConfig(0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DeviceStore – metadata, dialogs, unread counts, message draft", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
idbMem.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addMetadata stores by node id", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
const device = state.addDevice(1);
|
||||||
|
|
||||||
|
const metadata = create(Protobuf.Mesh.DeviceMetadataSchema, {
|
||||||
|
firmwareVersion: "1.2.3",
|
||||||
|
});
|
||||||
|
device.addMetadata(123, metadata);
|
||||||
|
|
||||||
|
expect(useDeviceStore.getState().devices.get(1)?.metadata.get(123)).toEqual(
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dialogs set/get work and throw if device missing", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
const device = state.addDevice(5);
|
||||||
|
|
||||||
|
device.setDialogOpen("reboot", true);
|
||||||
|
expect(device.getDialogOpen("reboot")).toBe(true);
|
||||||
|
device.setDialogOpen("reboot", false);
|
||||||
|
expect(device.getDialogOpen("reboot")).toBe(false);
|
||||||
|
|
||||||
|
// getDialogOpen uses getDevice or throws if device missing
|
||||||
|
state.removeDevice(5);
|
||||||
|
expect(() => device.getDialogOpen("reboot")).toThrow(/Device 5 not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unread counts: increment/get/getAll/reset", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
const device = state.addDevice(2);
|
||||||
|
|
||||||
|
expect(device.getUnreadCount(10)).toBe(0);
|
||||||
|
device.incrementUnread(10);
|
||||||
|
device.incrementUnread(10);
|
||||||
|
device.incrementUnread(11);
|
||||||
|
expect(device.getUnreadCount(10)).toBe(2);
|
||||||
|
expect(device.getUnreadCount(11)).toBe(1);
|
||||||
|
expect(device.getAllUnreadCount()).toBe(3);
|
||||||
|
|
||||||
|
device.resetUnread(10);
|
||||||
|
expect(device.getUnreadCount(10)).toBe(0);
|
||||||
|
expect(device.getAllUnreadCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setMessageDraft stores the text", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const device = useDeviceStore.getState().addDevice(3);
|
||||||
|
device.setMessageDraft("hello");
|
||||||
|
|
||||||
|
expect(useDeviceStore.getState().devices.get(3)?.messageDraft).toBe(
|
||||||
|
"hello",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DeviceStore – traceroutes & waypoints retention + merge on setHardware", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
idbMem.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addTraceRoute appends and enforces per-target and target caps", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
const device = state.addDevice(100);
|
||||||
|
|
||||||
|
// Per target: cap = 100; push 101 for from=7
|
||||||
|
for (let i = 0; i < 101; i++) {
|
||||||
|
device.addTraceRoute(makeRoute(7, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
const routesFor7 = useDeviceStore
|
||||||
|
.getState()
|
||||||
|
.devices.get(100)
|
||||||
|
?.traceroutes.get(7)!;
|
||||||
|
expect(routesFor7.length).toBe(100);
|
||||||
|
expect(routesFor7[0]?.rxTime).toBe(1); // first (0) evicted
|
||||||
|
|
||||||
|
// Target map cap: 100 keys, add 101 unique "from"
|
||||||
|
for (let from = 0; from <= 100; from++) {
|
||||||
|
device.addTraceRoute(makeRoute(1000 + from));
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Array.from(
|
||||||
|
useDeviceStore.getState().devices.get(100)!.traceroutes.keys(),
|
||||||
|
);
|
||||||
|
expect(keys.length).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addWaypoint upserts by id and enforces retention; setHardware moves traceroutes + prunes expired waypoints", async () => {
|
||||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
|
||||||
|
// Old device with myNodeNum=777 and some waypoints (one expired)
|
||||||
|
const oldDevice = state.addDevice(1);
|
||||||
|
oldDevice.connection = { sendWaypoint: vi.fn() } as any;
|
||||||
|
|
||||||
|
oldDevice.setHardware(makeHardware(777));
|
||||||
|
oldDevice.addWaypoint(
|
||||||
|
makeWaypoint(1, Date.parse("2024-12-31T23:59:59Z")), // This is expired, will not be added
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
new Date(),
|
||||||
|
); // expired
|
||||||
|
oldDevice.addWaypoint(makeWaypoint(2, 0), 0, 0, new Date()); // no expire
|
||||||
|
oldDevice.addWaypoint(
|
||||||
|
makeWaypoint(3, Date.parse("2026-01-01T00:00:00Z")),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
new Date(),
|
||||||
|
); // ok
|
||||||
|
oldDevice.addTraceRoute(makeRoute(55));
|
||||||
|
oldDevice.addTraceRoute(makeRoute(56));
|
||||||
|
|
||||||
|
// Upsert waypoint by id
|
||||||
|
oldDevice.addWaypoint(
|
||||||
|
makeWaypoint(2, Date.parse("2027-01-01T00:00:00Z")),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const wps = useDeviceStore.getState().devices.get(1)!.waypoints;
|
||||||
|
expect(wps.length).toBe(2);
|
||||||
|
expect(wps.find((w) => w.id === 2)?.expire).toBe(
|
||||||
|
Date.parse("2027-01-01T00:00:00Z"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retention: push 102 total waypoints -> capped at 100. Oldest evicted
|
||||||
|
for (let i = 3; i <= 102; i++) {
|
||||||
|
oldDevice.addWaypoint(makeWaypoint(i), 0, 0, new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove waypoint
|
||||||
|
oldDevice.removeWaypoint(102, false);
|
||||||
|
expect(oldDevice.connection?.sendWaypoint).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await oldDevice.removeWaypoint(101, true); // toMesh=true
|
||||||
|
expect(oldDevice.connection?.sendWaypoint).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(98);
|
||||||
|
|
||||||
|
// New device shares myNodeNum; setHardware should:
|
||||||
|
// - move traceroutes from old device
|
||||||
|
// - copy waypoints minus expired
|
||||||
|
// - delete old device entry
|
||||||
|
const newDevice = state.addDevice(2);
|
||||||
|
newDevice.setHardware(makeHardware(777));
|
||||||
|
|
||||||
|
expect(state.getDevice(1)).toBeUndefined();
|
||||||
|
expect(state.getDevice(2)).toBeDefined();
|
||||||
|
|
||||||
|
// traceroutes moved:
|
||||||
|
expect(state.getDevice(2)!.traceroutes.size).toBe(2);
|
||||||
|
|
||||||
|
// Getter for waypoint by id works
|
||||||
|
expect(newDevice.getWaypoint(1)).toBeUndefined();
|
||||||
|
expect(newDevice.getWaypoint(2)).toBeUndefined();
|
||||||
|
expect(newDevice.getWaypoint(3)).toBeTruthy();
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DeviceStore – persistence partialize & rehydrate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
idbMem.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("partialize stores only DeviceData; onRehydrateStorage rebuilds only devices with myNodeNum set (orphans dropped)", async () => {
|
||||||
|
// First run: persist=true
|
||||||
|
{
|
||||||
|
const { useDeviceStore } = await freshStore(true);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
|
||||||
|
const orphan = state.addDevice(500); // no myNodeNum -> should be dropped
|
||||||
|
orphan.addWaypoint(makeWaypoint(123), 0, 0, new Date());
|
||||||
|
|
||||||
|
const good = state.addDevice(501);
|
||||||
|
good.setHardware(makeHardware(42)); // sets myNodeNum
|
||||||
|
good.addTraceRoute(makeRoute(77));
|
||||||
|
good.addWaypoint(makeWaypoint(1), 0, 0, new Date());
|
||||||
|
// ensure some ephemeral fields differ so we can verify methods work after rehydrate
|
||||||
|
good.setMessageDraft("draft");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload: persist=true -> rehydrate from idbMem
|
||||||
|
{
|
||||||
|
const { useDeviceStore } = await freshStore(true);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
|
||||||
|
expect(state.getDevice(500)).toBeUndefined(); // orphan dropped
|
||||||
|
const device = state.getDevice(501)!;
|
||||||
|
expect(device).toBeDefined();
|
||||||
|
|
||||||
|
// methods should work
|
||||||
|
device.addWaypoint(makeWaypoint(2), 0, 0, new Date());
|
||||||
|
expect(
|
||||||
|
useDeviceStore.getState().devices.get(501)!.waypoints.length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// traceroutes survived
|
||||||
|
expect(
|
||||||
|
useDeviceStore.getState().devices.get(501)!.traceroutes.size,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removing a device persists across reload", async () => {
|
||||||
|
{
|
||||||
|
const { useDeviceStore } = await freshStore(true);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
const device = state.addDevice(900);
|
||||||
|
device.setHardware(makeHardware(9)); // ensure it will be rehydrated
|
||||||
|
expect(state.getDevice(900)).toBeDefined();
|
||||||
|
state.removeDevice(900);
|
||||||
|
expect(state.getDevice(900)).toBeUndefined();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { useDeviceStore } = await freshStore(true);
|
||||||
|
expect(useDeviceStore.getState().getDevice(900)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DeviceStore – connection & sendAdminMessage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
idbMem.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendAdminMessage calls through to connection.sendPacket with correct args", async () => {
|
||||||
|
const { useDeviceStore } = await freshStore(false);
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
const device = state.addDevice(77);
|
||||||
|
|
||||||
|
const sendPacket = vi.fn();
|
||||||
|
device.addConnection({ sendPacket } as any);
|
||||||
|
|
||||||
|
const message = makeAdminMessage({ logVerbosity: 1 });
|
||||||
|
device.sendAdminMessage(message);
|
||||||
|
|
||||||
|
expect(sendPacket).toHaveBeenCalledTimes(1);
|
||||||
|
const [bytes, port, dest] = sendPacket.mock.calls[0]!;
|
||||||
|
expect(port).toBe(Protobuf.Portnums.PortNum.ADMIN_APP);
|
||||||
|
expect(dest).toBe("self");
|
||||||
|
|
||||||
|
// sanity: encoded bytes match toBinary on the same schema
|
||||||
|
const expected = toBinary(Protobuf.Admin.AdminMessageSchema, message);
|
||||||
|
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||||
|
|
||||||
|
// compare content length as minimal assertion (exact byte-for-byte is fine too)
|
||||||
|
expect((bytes as Uint8Array).length).toBe(expected.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,38 +1,43 @@
|
|||||||
import { create, toBinary } from "@bufbuild/protobuf";
|
import { create, toBinary } from "@bufbuild/protobuf";
|
||||||
|
import { featureFlags } from "@core/services/featureFlags";
|
||||||
|
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
|
||||||
|
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||||
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
|
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import { create as createStore } from "zustand";
|
import { create as createStore, type StateCreator } from "zustand";
|
||||||
|
import {
|
||||||
|
type PersistOptions,
|
||||||
|
persist,
|
||||||
|
subscribeWithSelector,
|
||||||
|
} from "zustand/middleware";
|
||||||
|
import type {
|
||||||
|
Dialogs,
|
||||||
|
DialogVariant,
|
||||||
|
ValidConfigType,
|
||||||
|
ValidModuleConfigType,
|
||||||
|
WaypointWithMetadata,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
export type Page = "messages" | "map" | "config" | "channels" | "nodes";
|
const IDB_KEY_NAME = "meshtastic-device-store";
|
||||||
|
const CURRENT_STORE_VERSION = 0;
|
||||||
|
const DEVICESTORE_RETENTION_NUM = 10;
|
||||||
|
const TRACEROUTE_TARGET_RETENTION_NUM = 100; // Number of traceroutes targets to keep
|
||||||
|
const TRACEROUTE_ROUTE_RETENTION_NUM = 100; // Number of traceroutes to keep per target
|
||||||
|
const WAYPOINT_RETENTION_NUM = 100;
|
||||||
|
|
||||||
export interface ProcessPacketParams {
|
type DeviceData = {
|
||||||
from: number;
|
// Persisted data
|
||||||
snr: number;
|
|
||||||
time: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DialogVariant = keyof Device["dialog"];
|
|
||||||
|
|
||||||
export type ValidConfigType = Exclude<
|
|
||||||
Protobuf.Config.Config["payloadVariant"]["case"],
|
|
||||||
"deviceUi" | "sessionkey" | undefined
|
|
||||||
>;
|
|
||||||
export type ValidModuleConfigType = Exclude<
|
|
||||||
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
|
|
||||||
undefined
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
|
|
||||||
metadata: {
|
|
||||||
channel: number; // Channel on which the waypoint was received
|
|
||||||
created: Date; // Timestamp when the waypoint was received
|
|
||||||
updated?: Date; // Timestamp when the waypoint was last updated
|
|
||||||
from: number; // Node number of the device that sent the waypoint
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface Device {
|
|
||||||
id: number;
|
id: number;
|
||||||
|
myNodeNum: number | undefined;
|
||||||
|
traceroutes: Map<
|
||||||
|
number,
|
||||||
|
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
||||||
|
>;
|
||||||
|
waypoints: WaypointWithMetadata[];
|
||||||
|
neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
|
||||||
|
};
|
||||||
|
export interface Device extends DeviceData {
|
||||||
|
// Ephemeral state (not persisted)
|
||||||
status: Types.DeviceStatusEnum;
|
status: Types.DeviceStatusEnum;
|
||||||
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
|
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
|
||||||
config: Protobuf.LocalOnly.LocalConfig;
|
config: Protobuf.LocalOnly.LocalConfig;
|
||||||
@@ -42,36 +47,12 @@ export interface Device {
|
|||||||
workingChannelConfig: Protobuf.Channel.Channel[];
|
workingChannelConfig: Protobuf.Channel.Channel[];
|
||||||
hardware: Protobuf.Mesh.MyNodeInfo;
|
hardware: Protobuf.Mesh.MyNodeInfo;
|
||||||
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
|
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
|
||||||
traceroutes: Map<
|
|
||||||
number,
|
|
||||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
|
||||||
>;
|
|
||||||
connection?: MeshDevice;
|
connection?: MeshDevice;
|
||||||
activeNode: number;
|
activeNode: number;
|
||||||
waypoints: WaypointWithMetadata[];
|
|
||||||
neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
|
|
||||||
pendingSettingsChanges: boolean;
|
pendingSettingsChanges: boolean;
|
||||||
messageDraft: string;
|
messageDraft: string;
|
||||||
unreadCounts: Map<number, number>;
|
unreadCounts: Map<number, number>;
|
||||||
dialog: {
|
dialog: Dialogs;
|
||||||
import: boolean;
|
|
||||||
QR: boolean;
|
|
||||||
shutdown: boolean;
|
|
||||||
reboot: boolean;
|
|
||||||
deviceName: boolean;
|
|
||||||
nodeRemoval: boolean;
|
|
||||||
pkiBackup: boolean;
|
|
||||||
nodeDetails: boolean;
|
|
||||||
unsafeRoles: boolean;
|
|
||||||
refreshKeys: boolean;
|
|
||||||
deleteMessages: boolean;
|
|
||||||
managedMode: boolean;
|
|
||||||
clientNotification: boolean;
|
|
||||||
resetNodeDb: boolean;
|
|
||||||
clearAllStores: boolean;
|
|
||||||
factoryResetDevice: boolean;
|
|
||||||
factoryResetConfig: boolean;
|
|
||||||
};
|
|
||||||
clientNotifications: Protobuf.Mesh.ClientNotification[];
|
clientNotifications: Protobuf.Mesh.ClientNotification[];
|
||||||
|
|
||||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||||
@@ -79,19 +60,12 @@ export interface Device {
|
|||||||
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||||
setWorkingConfig: (config: Protobuf.Config.Config) => void;
|
setWorkingConfig: (config: Protobuf.Config.Config) => void;
|
||||||
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||||
getWorkingConfig: (
|
getWorkingConfig<K extends ValidConfigType>(
|
||||||
payloadVariant: ValidConfigType,
|
payloadVariant: K,
|
||||||
) =>
|
): Protobuf.LocalOnly.LocalConfig[K] | undefined;
|
||||||
| Protobuf.LocalOnly.LocalConfig[Exclude<ValidConfigType, undefined>]
|
getWorkingModuleConfig<K extends ValidModuleConfigType>(
|
||||||
| undefined;
|
payloadVariant: K,
|
||||||
getWorkingModuleConfig: (
|
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
|
||||||
payloadVariant: ValidModuleConfigType,
|
|
||||||
) =>
|
|
||||||
| Protobuf.LocalOnly.LocalModuleConfig[Exclude<
|
|
||||||
ValidModuleConfigType,
|
|
||||||
undefined
|
|
||||||
>]
|
|
||||||
| undefined;
|
|
||||||
removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
|
removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
|
||||||
removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
|
removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
|
||||||
getEffectiveConfig<K extends ValidConfigType>(
|
getEffectiveConfig<K extends ValidConfigType>(
|
||||||
@@ -115,6 +89,8 @@ export interface Device {
|
|||||||
from: number,
|
from: number,
|
||||||
rxTime: Date,
|
rxTime: Date,
|
||||||
) => void;
|
) => void;
|
||||||
|
removeWaypoint: (waypointId: number, toMesh: boolean) => Promise<void>;
|
||||||
|
getWaypoint: (waypointId: number) => WaypointWithMetadata | undefined;
|
||||||
addConnection: (connection: MeshDevice) => void;
|
addConnection: (connection: MeshDevice) => void;
|
||||||
addTraceRoute: (
|
addTraceRoute: (
|
||||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
||||||
@@ -142,27 +118,41 @@ export interface Device {
|
|||||||
getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined;
|
getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceState {
|
export interface deviceState {
|
||||||
addDevice: (id: number) => Device;
|
addDevice: (id: number) => Device;
|
||||||
removeDevice: (id: number) => void;
|
removeDevice: (id: number) => void;
|
||||||
getDevices: () => Device[];
|
getDevices: () => Device[];
|
||||||
getDevice: (id: number) => Device | undefined;
|
getDevice: (id: number) => Device | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PrivateDeviceState extends DeviceState {
|
interface PrivateDeviceState extends deviceState {
|
||||||
devices: Map<number, Device>;
|
devices: Map<number, Device>;
|
||||||
remoteDevices: Map<number, undefined>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
type DevicePersisted = {
|
||||||
devices: new Map(),
|
devices: Map<number, DeviceData>;
|
||||||
remoteDevices: new Map(),
|
};
|
||||||
|
|
||||||
addDevice: (id: number) => {
|
function deviceFactory(
|
||||||
set(
|
id: number,
|
||||||
produce<PrivateDeviceState>((draft) => {
|
get: () => PrivateDeviceState,
|
||||||
draft.devices.set(id, {
|
set: typeof useDeviceStore.setState,
|
||||||
|
data?: Partial<DeviceData>,
|
||||||
|
): Device {
|
||||||
|
const myNodeNum = data?.myNodeNum;
|
||||||
|
const traceroutes =
|
||||||
|
data?.traceroutes ??
|
||||||
|
new Map<number, Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]>();
|
||||||
|
const waypoints = data?.waypoints ?? [];
|
||||||
|
const neighborInfo =
|
||||||
|
data?.neighborInfo ?? new Map<number, Protobuf.Mesh.NeighborInfo>();
|
||||||
|
return {
|
||||||
id,
|
id,
|
||||||
|
myNodeNum,
|
||||||
|
traceroutes,
|
||||||
|
waypoints,
|
||||||
|
neighborInfo,
|
||||||
|
|
||||||
status: Types.DeviceStatusEnum.DeviceDisconnected,
|
status: Types.DeviceStatusEnum.DeviceDisconnected,
|
||||||
channels: new Map(),
|
channels: new Map(),
|
||||||
config: create(Protobuf.LocalOnly.LocalConfigSchema),
|
config: create(Protobuf.LocalOnly.LocalConfigSchema),
|
||||||
@@ -172,11 +162,8 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
workingChannelConfig: [],
|
workingChannelConfig: [],
|
||||||
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
||||||
metadata: new Map(),
|
metadata: new Map(),
|
||||||
traceroutes: new Map(),
|
|
||||||
connection: undefined,
|
connection: undefined,
|
||||||
activeNode: 0,
|
activeNode: 0,
|
||||||
waypoints: [],
|
|
||||||
neighborInfo: new Map(),
|
|
||||||
dialog: {
|
dialog: {
|
||||||
import: false,
|
import: false,
|
||||||
QR: false,
|
QR: false,
|
||||||
@@ -273,23 +260,19 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "storeForward": {
|
case "storeForward": {
|
||||||
device.moduleConfig.storeForward =
|
device.moduleConfig.storeForward = config.payloadVariant.value;
|
||||||
config.payloadVariant.value;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "rangeTest": {
|
case "rangeTest": {
|
||||||
device.moduleConfig.rangeTest =
|
device.moduleConfig.rangeTest = config.payloadVariant.value;
|
||||||
config.payloadVariant.value;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "telemetry": {
|
case "telemetry": {
|
||||||
device.moduleConfig.telemetry =
|
device.moduleConfig.telemetry = config.payloadVariant.value;
|
||||||
config.payloadVariant.value;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "cannedMessage": {
|
case "cannedMessage": {
|
||||||
device.moduleConfig.cannedMessage =
|
device.moduleConfig.cannedMessage = config.payloadVariant.value;
|
||||||
config.payloadVariant.value;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "audio": {
|
case "audio": {
|
||||||
@@ -297,8 +280,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "neighborInfo": {
|
case "neighborInfo": {
|
||||||
device.moduleConfig.neighborInfo =
|
device.moduleConfig.neighborInfo = config.payloadVariant.value;
|
||||||
config.payloadVariant.value;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "ambientLighting": {
|
case "ambientLighting": {
|
||||||
@@ -312,8 +294,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "paxcounter": {
|
case "paxcounter": {
|
||||||
device.moduleConfig.paxcounter =
|
device.moduleConfig.paxcounter = config.payloadVariant.value;
|
||||||
config.payloadVariant.value;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,8 +332,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
const index = device.workingModuleConfig.findIndex(
|
const index = device.workingModuleConfig.findIndex(
|
||||||
(wmc) =>
|
(wmc) =>
|
||||||
wmc.payloadVariant.case ===
|
wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
|
||||||
moduleConfig.payloadVariant.case,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -364,7 +344,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getWorkingConfig: (payloadVariant: ValidConfigType) => {
|
getWorkingConfig<K extends ValidConfigType>(payloadVariant: K) {
|
||||||
const device = get().devices.get(id);
|
const device = get().devices.get(id);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return;
|
return;
|
||||||
@@ -381,9 +361,12 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return workingConfig?.payloadVariant.value;
|
return workingConfig?.payloadVariant
|
||||||
|
.value as Protobuf.LocalOnly.LocalConfig[K];
|
||||||
},
|
},
|
||||||
getWorkingModuleConfig: (payloadVariant: ValidModuleConfigType) => {
|
getWorkingModuleConfig<K extends ValidModuleConfigType>(
|
||||||
|
payloadVariant: K,
|
||||||
|
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined {
|
||||||
const device = get().devices.get(id);
|
const device = get().devices.get(id);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return;
|
return;
|
||||||
@@ -391,7 +374,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
|
|
||||||
return device.workingModuleConfig.find(
|
return device.workingModuleConfig.find(
|
||||||
(c) => c.payloadVariant.case === payloadVariant,
|
(c) => c.payloadVariant.case === payloadVariant,
|
||||||
)?.payloadVariant.value;
|
)?.payloadVariant.value as Protobuf.LocalOnly.LocalModuleConfig[K];
|
||||||
},
|
},
|
||||||
|
|
||||||
removeWorkingConfig: (payloadVariant?: ValidConfigType) => {
|
removeWorkingConfig: (payloadVariant?: ValidConfigType) => {
|
||||||
@@ -418,9 +401,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
removeWorkingModuleConfig: (
|
removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => {
|
||||||
payloadVariant?: ValidModuleConfigType,
|
|
||||||
) => {
|
|
||||||
set(
|
set(
|
||||||
produce<PrivateDeviceState>((draft) => {
|
produce<PrivateDeviceState>((draft) => {
|
||||||
const device = draft.devices.get(id);
|
const device = draft.devices.get(id);
|
||||||
@@ -537,10 +518,29 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
|
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
|
||||||
set(
|
set(
|
||||||
produce<PrivateDeviceState>((draft) => {
|
produce<PrivateDeviceState>((draft) => {
|
||||||
const device = draft.devices.get(id);
|
const newDevice = draft.devices.get(id);
|
||||||
if (device) {
|
if (!newDevice) {
|
||||||
device.hardware = hardware;
|
throw new Error(`No DeviceStore found for id: ${id}`);
|
||||||
}
|
}
|
||||||
|
newDevice.myNodeNum = hardware.myNodeNum;
|
||||||
|
|
||||||
|
for (const [otherId, oldStore] of draft.devices) {
|
||||||
|
if (otherId === id || oldStore.myNodeNum !== hardware.myNodeNum) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newDevice.traceroutes = oldStore.traceroutes;
|
||||||
|
newDevice.neighborInfo = oldStore.neighborInfo;
|
||||||
|
|
||||||
|
// Take this opportunity to remove stale waypoints
|
||||||
|
newDevice.waypoints = oldStore.waypoints.filter(
|
||||||
|
(waypoint) => !waypoint?.expire || waypoint.expire > Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop old device
|
||||||
|
draft.devices.delete(otherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
newDevice.hardware = hardware; // Always replace hardware with latest
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -568,10 +568,14 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
set(
|
set(
|
||||||
produce<PrivateDeviceState>((draft) => {
|
produce<PrivateDeviceState>((draft) => {
|
||||||
const device = draft.devices.get(id);
|
const device = draft.devices.get(id);
|
||||||
if (device) {
|
if (!device) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const index = device.waypoints.findIndex(
|
const index = device.waypoints.findIndex(
|
||||||
(wp) => wp.id === waypoint.id,
|
(wp) => wp.id === waypoint.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const created =
|
const created =
|
||||||
device.waypoints[index]?.metadata.created ?? new Date();
|
device.waypoints[index]?.metadata.created ?? new Date();
|
||||||
@@ -580,17 +584,86 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
metadata: { created, updated: rxTime, from, channel },
|
metadata: { created, updated: rxTime, from, channel },
|
||||||
};
|
};
|
||||||
|
|
||||||
device.waypoints[index] = updatedWaypoint;
|
// Remove existing waypoint
|
||||||
} else {
|
device.waypoints.splice(index, 1);
|
||||||
|
|
||||||
|
// Push new if no expiry or not expired
|
||||||
|
if (waypoint.expire === 0 || waypoint.expire > Date.now()) {
|
||||||
|
device.waypoints.push(updatedWaypoint);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
// only add if set to never expire or not already expired
|
||||||
|
waypoint.expire === 0 ||
|
||||||
|
(waypoint.expire !== 0 && waypoint.expire < Date.now())
|
||||||
|
) {
|
||||||
device.waypoints.push({
|
device.waypoints.push({
|
||||||
...waypoint,
|
...waypoint,
|
||||||
metadata: { created: rxTime, from, channel },
|
metadata: { created: rxTime, from, channel },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce retention limit
|
||||||
|
evictOldestEntries(device.waypoints, WAYPOINT_RETENTION_NUM);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
removeWaypoint: async (waypointId: number, toMesh: boolean) => {
|
||||||
|
const device = get().devices.get(id);
|
||||||
|
if (!device) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waypoint = device.waypoints.find((wp) => wp.id === waypointId);
|
||||||
|
if (!waypoint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toMesh) {
|
||||||
|
if (!device.connection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waypointToBroadcast = create(Protobuf.Mesh.WaypointSchema, {
|
||||||
|
id: waypoint.id, // Bare minimum to delete a waypoint
|
||||||
|
lockedTo: 0,
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
icon: 0,
|
||||||
|
expire: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await device.connection.sendWaypoint(
|
||||||
|
waypointToBroadcast,
|
||||||
|
"broadcast",
|
||||||
|
waypoint.metadata.channel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from store
|
||||||
|
set(
|
||||||
|
produce<PrivateDeviceState>((draft) => {
|
||||||
|
const device = draft.devices.get(id);
|
||||||
|
if (!device) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = device.waypoints.findIndex(
|
||||||
|
(waypoint) => waypoint.id === waypointId,
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
device.waypoints.splice(idx, 1);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getWaypoint: (waypointId: number) => {
|
||||||
|
const device = get().devices.get(id);
|
||||||
|
if (!device) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return device.waypoints.find((waypoint) => waypoint.id === waypointId);
|
||||||
|
},
|
||||||
setActiveNode: (node) => {
|
setActiveNode: (node) => {
|
||||||
set(
|
set(
|
||||||
produce<PrivateDeviceState>((draft) => {
|
produce<PrivateDeviceState>((draft) => {
|
||||||
@@ -631,6 +704,13 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
const routes = device.traceroutes.get(traceroute.from) ?? [];
|
const routes = device.traceroutes.get(traceroute.from) ?? [];
|
||||||
routes.push(traceroute);
|
routes.push(traceroute);
|
||||||
device.traceroutes.set(traceroute.from, routes);
|
device.traceroutes.set(traceroute.from, routes);
|
||||||
|
|
||||||
|
// Enforce retention limit, both in terms of targets (device.traceroutes) and routes per target (routes)
|
||||||
|
evictOldestEntries(routes, TRACEROUTE_ROUTE_RETENTION_NUM);
|
||||||
|
evictOldestEntries(
|
||||||
|
device.traceroutes,
|
||||||
|
TRACEROUTE_TARGET_RETENTION_NUM,
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -775,26 +855,108 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return device.neighborInfo.get(nodeNum);
|
return device.neighborInfo.get(nodeNum);
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
|
||||||
|
set,
|
||||||
|
get,
|
||||||
|
) => ({
|
||||||
|
devices: new Map(),
|
||||||
|
|
||||||
|
addDevice: (id) => {
|
||||||
|
const existing = get().devices.get(id);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = deviceFactory(id, get, set);
|
||||||
|
set(
|
||||||
|
produce<PrivateDeviceState>((draft) => {
|
||||||
|
draft.devices = new Map(draft.devices).set(id, device);
|
||||||
|
|
||||||
|
// Enforce retention limit
|
||||||
|
evictOldestEntries(draft.devices, DEVICESTORE_RETENTION_NUM);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const device = get().devices.get(id);
|
|
||||||
if (!device) {
|
|
||||||
throw new Error(`Failed to create or retrieve device with ID ${id}`);
|
|
||||||
}
|
|
||||||
return device;
|
return device;
|
||||||
},
|
},
|
||||||
|
|
||||||
removeDevice: (id) => {
|
removeDevice: (id) => {
|
||||||
set(
|
set(
|
||||||
produce<PrivateDeviceState>((draft) => {
|
produce<PrivateDeviceState>((draft) => {
|
||||||
draft.devices.delete(id);
|
const updated = new Map(draft.devices);
|
||||||
|
updated.delete(id);
|
||||||
|
draft.devices = updated;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getDevices: () => Array.from(get().devices.values()),
|
getDevices: () => Array.from(get().devices.values()),
|
||||||
|
|
||||||
getDevice: (id) => get().devices.get(id),
|
getDevice: (id) => get().devices.get(id),
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
||||||
|
name: IDB_KEY_NAME,
|
||||||
|
storage: createStorage<DevicePersisted>(),
|
||||||
|
version: CURRENT_STORE_VERSION,
|
||||||
|
partialize: (s): DevicePersisted => ({
|
||||||
|
devices: new Map(
|
||||||
|
Array.from(s.devices.entries()).map(([id, db]) => [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
id: db.id,
|
||||||
|
myNodeNum: db.myNodeNum,
|
||||||
|
traceroutes: db.traceroutes,
|
||||||
|
waypoints: db.waypoints,
|
||||||
|
neighborInfo: db.neighborInfo,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug(
|
||||||
|
"DeviceStore: Rehydrating state with ",
|
||||||
|
state.devices.size,
|
||||||
|
" devices -",
|
||||||
|
state.devices,
|
||||||
|
);
|
||||||
|
|
||||||
|
useDeviceStore.setState(
|
||||||
|
produce<PrivateDeviceState>((draft) => {
|
||||||
|
const rebuilt = new Map<number, Device>();
|
||||||
|
for (const [id, data] of (
|
||||||
|
draft.devices as unknown as Map<number, DeviceData>
|
||||||
|
).entries()) {
|
||||||
|
if (data.myNodeNum !== undefined) {
|
||||||
|
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
|
||||||
|
rebuilt.set(
|
||||||
|
id,
|
||||||
|
deviceFactory(
|
||||||
|
id,
|
||||||
|
useDeviceStore.getState,
|
||||||
|
useDeviceStore.setState,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draft.devices = rebuilt;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add persist middleware on the store if the feature flag is enabled
|
||||||
|
const persistDevices = featureFlags.get("persistDevices");
|
||||||
|
console.debug(
|
||||||
|
`DeviceStore: Persisting devices is ${persistDevices ? "enabled" : "disabled"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useDeviceStore = persistDevices
|
||||||
|
? createStore(
|
||||||
|
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
|
||||||
|
)
|
||||||
|
: createStore(subscribeWithSelector(deviceStoreInitializer));
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
|
interface Dialogs {
|
||||||
|
import: boolean;
|
||||||
|
QR: boolean;
|
||||||
|
shutdown: boolean;
|
||||||
|
reboot: boolean;
|
||||||
|
deviceName: boolean;
|
||||||
|
nodeRemoval: boolean;
|
||||||
|
pkiBackup: boolean;
|
||||||
|
nodeDetails: boolean;
|
||||||
|
unsafeRoles: boolean;
|
||||||
|
refreshKeys: boolean;
|
||||||
|
deleteMessages: boolean;
|
||||||
|
managedMode: boolean;
|
||||||
|
clientNotification: boolean;
|
||||||
|
resetNodeDb: boolean;
|
||||||
|
clearAllStores: boolean;
|
||||||
|
factoryResetDevice: boolean;
|
||||||
|
factoryResetConfig: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DialogVariant = keyof Dialogs;
|
||||||
|
|
||||||
|
type ValidConfigType = Exclude<
|
||||||
|
Protobuf.Config.Config["payloadVariant"]["case"],
|
||||||
|
"deviceUi" | "sessionkey" | undefined
|
||||||
|
>;
|
||||||
|
type ValidModuleConfigType = Exclude<
|
||||||
|
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
|
||||||
|
undefined
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Page = "messages" | "map" | "config" | "channels" | "nodes";
|
||||||
|
|
||||||
|
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
|
||||||
|
metadata: {
|
||||||
|
channel: number; // Channel on which the waypoint was received
|
||||||
|
created: Date; // Timestamp when the waypoint was received
|
||||||
|
updated?: Date; // Timestamp when the waypoint was last updated
|
||||||
|
from: number; // Node number of the device that sent the waypoint
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
Page,
|
||||||
|
Dialogs,
|
||||||
|
DialogVariant,
|
||||||
|
ValidConfigType,
|
||||||
|
ValidModuleConfigType,
|
||||||
|
WaypointWithMetadata,
|
||||||
|
};
|
||||||
@@ -1,35 +1,37 @@
|
|||||||
import { useDeviceContext } from "@core/hooks/useDeviceContext";
|
import { useDeviceContext } from "@core/hooks/useDeviceContext.ts";
|
||||||
import { type Device, useDeviceStore } from "@core/stores/deviceStore";
|
import { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
|
||||||
import { type MessageStore, useMessageStore } from "@core/stores/messageStore";
|
import {
|
||||||
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
type MessageStore,
|
||||||
import { bindStoreToDevice } from "@core/stores/utils/bindStoreToDevice";
|
useMessageStore,
|
||||||
|
} from "@core/stores/messageStore/index.ts";
|
||||||
|
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
|
||||||
|
import { bindStoreToDevice } from "@core/stores/utils/bindStoreToDevice.ts";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CurrentDeviceContext,
|
CurrentDeviceContext,
|
||||||
type DeviceContext,
|
type DeviceContext,
|
||||||
useDeviceContext,
|
useDeviceContext,
|
||||||
} from "@core/hooks/useDeviceContext";
|
} from "@core/hooks/useDeviceContext";
|
||||||
export { useAppStore } from "@core/stores/appStore";
|
export { useAppStore } from "@core/stores/appStore/index.ts";
|
||||||
export {
|
export { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
|
||||||
type Device,
|
export type {
|
||||||
type Page,
|
Page,
|
||||||
useDeviceStore,
|
ValidConfigType,
|
||||||
type ValidConfigType,
|
ValidModuleConfigType,
|
||||||
type ValidModuleConfigType,
|
WaypointWithMetadata,
|
||||||
type WaypointWithMetadata,
|
} from "@core/stores/deviceStore/types.ts";
|
||||||
} from "@core/stores/deviceStore";
|
|
||||||
export {
|
export {
|
||||||
MessageState,
|
MessageState,
|
||||||
type MessageStore,
|
type MessageStore,
|
||||||
MessageType,
|
MessageType,
|
||||||
useMessageStore,
|
useMessageStore,
|
||||||
} from "@core/stores/messageStore";
|
} from "@core/stores/messageStore";
|
||||||
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
|
||||||
export type { NodeErrorType } from "@core/stores/nodeDBStore/types";
|
export type { NodeErrorType } from "@core/stores/nodeDBStore/types.ts";
|
||||||
export {
|
export {
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
useSidebar, // TODO: Bring hook into this file
|
useSidebar, // TODO: Bring hook into this file
|
||||||
} from "@core/stores/sidebarStore";
|
} from "@core/stores/sidebarStore/index.tsx";
|
||||||
|
|
||||||
// Re-export idb-keyval functions for clearing all stores, expand this if we add more local storage types
|
// Re-export idb-keyval functions for clearing all stores, expand this if we add more local storage types
|
||||||
export { clear as clearAllStores } from "idb-keyval";
|
export { clear as clearAllStores } from "idb-keyval";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { produce } from "immer";
|
|||||||
import { create as createStore, type StateCreator } from "zustand";
|
import { create as createStore, type StateCreator } from "zustand";
|
||||||
import { type PersistOptions, persist } from "zustand/middleware";
|
import { type PersistOptions, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
const IDB_KEY_NAME = "meshtastic-message-store";
|
||||||
const CURRENT_STORE_VERSION = 0;
|
const CURRENT_STORE_VERSION = 0;
|
||||||
const MESSAGESTORE_RETENTION_NUM = 10;
|
const MESSAGESTORE_RETENTION_NUM = 10;
|
||||||
const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel
|
const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel
|
||||||
@@ -43,14 +44,17 @@ export interface MessageBuckets {
|
|||||||
direct: Map<ConversationId, MessageLogMap>;
|
direct: Map<ConversationId, MessageLogMap>;
|
||||||
broadcast: Map<ChannelId, MessageLogMap>;
|
broadcast: Map<ChannelId, MessageLogMap>;
|
||||||
}
|
}
|
||||||
export interface MessageStore {
|
|
||||||
|
type MessageStoreData = {
|
||||||
|
// Persisted data
|
||||||
id: number;
|
id: number;
|
||||||
myNodeNum: number | undefined;
|
myNodeNum: number | undefined;
|
||||||
|
|
||||||
messages: MessageBuckets;
|
messages: MessageBuckets;
|
||||||
drafts: Map<Types.Destination, string>;
|
drafts: Map<Types.Destination, string>;
|
||||||
|
};
|
||||||
|
|
||||||
// Ephemeral UI state (not persisted)
|
export interface MessageStore extends MessageStoreData {
|
||||||
|
// Ephemeral state (not persisted)
|
||||||
activeChat: number;
|
activeChat: number;
|
||||||
chatType: MessageType;
|
chatType: MessageType;
|
||||||
|
|
||||||
@@ -78,14 +82,6 @@ interface PrivateMessageStoreState extends MessageStoreState {
|
|||||||
messageStores: Map<number, MessageStore>;
|
messageStores: Map<number, MessageStore>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageStoreData = {
|
|
||||||
id: number;
|
|
||||||
myNodeNum: number | undefined;
|
|
||||||
|
|
||||||
messages: MessageBuckets;
|
|
||||||
drafts: Map<Types.Destination, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MessageStorePersisted = {
|
type MessageStorePersisted = {
|
||||||
messageStores: Map<number, MessageStoreData>;
|
messageStores: Map<number, MessageStoreData>;
|
||||||
};
|
};
|
||||||
@@ -393,7 +389,7 @@ const persistOptions: PersistOptions<
|
|||||||
PrivateMessageStoreState,
|
PrivateMessageStoreState,
|
||||||
MessageStorePersisted
|
MessageStorePersisted
|
||||||
> = {
|
> = {
|
||||||
name: "meshtastic-message-store",
|
name: IDB_KEY_NAME,
|
||||||
storage: createStorage<MessageStorePersisted>(),
|
storage: createStorage<MessageStorePersisted>(),
|
||||||
version: CURRENT_STORE_VERSION,
|
version: CURRENT_STORE_VERSION,
|
||||||
partialize: (s): MessageStorePersisted => ({
|
partialize: (s): MessageStorePersisted => ({
|
||||||
|
|||||||
@@ -13,15 +13,20 @@ import {
|
|||||||
} from "zustand/middleware";
|
} from "zustand/middleware";
|
||||||
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
|
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
|
||||||
|
|
||||||
|
const IDB_KEY_NAME = "meshtastic-nodedb-store";
|
||||||
const CURRENT_STORE_VERSION = 0;
|
const CURRENT_STORE_VERSION = 0;
|
||||||
const NODEDB_RETENTION_NUM = 10;
|
const NODEDB_RETENTION_NUM = 10;
|
||||||
|
|
||||||
export interface NodeDB {
|
type NodeDBData = {
|
||||||
|
// Persisted data
|
||||||
id: number;
|
id: number;
|
||||||
myNodeNum: number | undefined;
|
myNodeNum: number | undefined;
|
||||||
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
|
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
|
||||||
nodeErrors: Map<number, NodeError>;
|
nodeErrors: Map<number, NodeError>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NodeDB extends NodeDBData {
|
||||||
|
// Ephemeral state (not persisted)
|
||||||
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
||||||
removeNode: (nodeNum: number) => void;
|
removeNode: (nodeNum: number) => void;
|
||||||
removeAllNodes: (keepMyNode?: boolean) => void;
|
removeAllNodes: (keepMyNode?: boolean) => void;
|
||||||
@@ -58,13 +63,6 @@ interface PrivateNodeDBState extends nodeDBState {
|
|||||||
nodeDBs: Map<number, NodeDB>;
|
nodeDBs: Map<number, NodeDB>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeDBData = {
|
|
||||||
id: number;
|
|
||||||
myNodeNum: number | undefined;
|
|
||||||
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
|
|
||||||
nodeErrors: Map<number, NodeError>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NodeDBPersisted = {
|
type NodeDBPersisted = {
|
||||||
nodeDBs: Map<number, NodeDBData>;
|
nodeDBs: Map<number, NodeDBData>;
|
||||||
};
|
};
|
||||||
@@ -442,7 +440,7 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
|
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
|
||||||
name: "meshtastic-nodedb-store",
|
name: IDB_KEY_NAME,
|
||||||
storage: createStorage<NodeDBPersisted>(),
|
storage: createStorage<NodeDBPersisted>(),
|
||||||
version: CURRENT_STORE_VERSION,
|
version: CURRENT_STORE_VERSION,
|
||||||
partialize: (s): NodeDBPersisted => ({
|
partialize: (s): NodeDBPersisted => ({
|
||||||
|
|||||||
@@ -27,4 +27,6 @@ export const mockNodeDBStore: NodeDB = {
|
|||||||
updateFavorite: vi.fn(),
|
updateFavorite: vi.fn(),
|
||||||
updateIgnore: vi.fn(),
|
updateIgnore: vi.fn(),
|
||||||
setNodeNum: vi.fn(),
|
setNodeNum: vi.fn(),
|
||||||
|
removeAllNodeErrors: vi.fn(),
|
||||||
|
removeAllNodes: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ describe("NodeDB – merge semantics, PKI checks & extras", () => {
|
|||||||
const newDB = st.addNodeDB(1101);
|
const newDB = st.addNodeDB(1101);
|
||||||
newDB.setNodeNum(4242);
|
newDB.setNodeNum(4242);
|
||||||
|
|
||||||
expect(newDB.getMyNode().num).toBe(4242);
|
expect(newDB.getMyNode()?.num).toBe(4242);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
export function evictOldestEntries<K, V>(
|
export function evictOldestEntries<T>(arr: T[], maxSize: number): void;
|
||||||
map: Map<K, V>,
|
export function evictOldestEntries<K, V>(map: Map<K, V>, maxSize: number): void;
|
||||||
|
|
||||||
|
export function evictOldestEntries<T, K, V>(
|
||||||
|
collection: T[] | Map<K, V>,
|
||||||
maxSize: number,
|
maxSize: number,
|
||||||
): void {
|
): void {
|
||||||
// while loop in case maxSize is ever changed to be lower, to trim all the way down
|
if (Array.isArray(collection)) {
|
||||||
while (map.size > maxSize) {
|
// Trim array from the front (assuming oldest entries are at the start)
|
||||||
const firstKey = map.keys().next().value; // maps keep insertion order, so this is oldest
|
while (collection.length > maxSize) {
|
||||||
|
collection.shift();
|
||||||
|
}
|
||||||
|
} else if (collection instanceof Map) {
|
||||||
|
// Trim map by insertion order
|
||||||
|
while (collection.size > maxSize) {
|
||||||
|
const firstKey = collection.keys().next().value;
|
||||||
if (firstKey !== undefined) {
|
if (firstKey !== undefined) {
|
||||||
map.delete(firstKey);
|
collection.delete(firstKey);
|
||||||
} else {
|
} else {
|
||||||
break; // should not happen, but just in case
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,21 @@ import { Button } from "@components/UI/Button.tsx";
|
|||||||
import { Separator } from "@components/UI/Separator.tsx";
|
import { Separator } from "@components/UI/Separator.tsx";
|
||||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||||
import { useAppStore, useDeviceStore, useNodeDBStore } from "@core/stores";
|
import {
|
||||||
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
|
useAppStore /*, useDeviceStore, useNodeDBStore */,
|
||||||
import { useMemo } from "react";
|
} from "@core/stores";
|
||||||
|
import { ListPlusIcon, PlusIcon /*, UsersIcon */ } from "lucide-react";
|
||||||
|
/* import { useMemo } from "react"; */
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
|
const { setConnectDialogOpen /*, setSelectedDevice*/ } = useAppStore();
|
||||||
|
/*
|
||||||
const { getDevices } = useDeviceStore();
|
const { getDevices } = useDeviceStore();
|
||||||
const { getNodeDB } = useNodeDBStore();
|
const { getNodeDB } = useNodeDBStore();
|
||||||
|
|
||||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||||
|
*/
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 p-3 px-8">
|
<div className="flex flex-col gap-3 p-3 px-8">
|
||||||
@@ -29,7 +32,8 @@ export const Dashboard = () => {
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||||
{devices.length ? (
|
{
|
||||||
|
/*devices.length ? (
|
||||||
<ul className="grow divide-y divide-slate-200">
|
<ul className="grow divide-y divide-slate-200">
|
||||||
{devices.map((device) => {
|
{devices.map((device) => {
|
||||||
const nodeDB = getNodeDB(device.id);
|
const nodeDB = getNodeDB(device.id);
|
||||||
@@ -69,8 +73,7 @@ export const Dashboard = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : */ <div className="m-auto flex flex-col gap-3 text-center">
|
||||||
<div className="m-auto flex flex-col gap-3 text-center">
|
|
||||||
<ListPlusIcon size={48} className="mx-auto text-text-secondary" />
|
<ListPlusIcon size={48} className="mx-auto text-text-secondary" />
|
||||||
<Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading>
|
<Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading>
|
||||||
<Subtle>{t("dashboard.noDevicesDescription")}</Subtle>
|
<Subtle>{t("dashboard.noDevicesDescription")}</Subtle>
|
||||||
@@ -83,7 +86,7 @@ export const Dashboard = () => {
|
|||||||
{t("dashboard.button_newConnection")}
|
{t("dashboard.button_newConnection")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user