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:
Jeremy Gallant
2025-10-23 23:27:41 +02:00
committed by GitHub
parent fc1e327b74
commit cdad811295
18 changed files with 1803 additions and 836 deletions

View File

@@ -1,29 +1,41 @@
// FactoryResetDeviceDialog.test.tsx
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx";
const mockFactoryResetDevice = vi.fn();
const mockDeleteAllMessages = vi.fn();
const mockRemoveAllNodeErrors = vi.fn();
const mockRemoveAllNodes = vi.fn();
const mockRemoveDevice = vi.fn();
const mockRemoveMessageStore = vi.fn();
const mockRemoveNodeDB = vi.fn();
const mockToast = vi.fn();
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useDevice: () => ({
connection: {
factoryResetDevice: mockFactoryResetDevice,
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: {
_currentValue: { deviceId: 1234 },
},
}),
useMessages: () => ({
deleteAllMessages: mockDeleteAllMessages,
}),
useNodeDB: () => ({
removeAllNodeErrors: mockRemoveAllNodeErrors,
removeAllNodes: mockRemoveAllNodes,
}),
useDevice: () => ({
id: 42,
connection: { factoryResetDevice: mockFactoryResetDevice },
}),
useDeviceStore,
useMessageStore,
useNodeDBStore,
};
});
vi.mock("@core/hooks/useToast.ts", () => ({
toast: (...args: unknown[]) => mockToast(...args),
}));
describe("FactoryResetDeviceDialog", () => {
@@ -31,10 +43,11 @@ describe("FactoryResetDeviceDialog", () => {
beforeEach(() => {
mockOnOpenChange.mockClear();
mockFactoryResetDevice.mockClear();
mockDeleteAllMessages.mockClear();
mockRemoveAllNodeErrors.mockClear();
mockRemoveAllNodes.mockClear();
mockFactoryResetDevice.mockReset();
mockRemoveDevice.mockClear();
mockRemoveMessageStore.mockClear();
mockRemoveNodeDB.mockClear();
mockToast.mockClear();
});
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);
});
// 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
resolveReset?.();
// Now the .then() chain should fire
await waitFor(() => {
expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1);
});
expect(mockRemoveDevice).toHaveBeenCalledTimes(1);
expect(mockRemoveMessageStore).toHaveBeenCalledTimes(1);
expect(mockRemoveNodeDB).toHaveBeenCalledTimes(1);
});
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(mockDeleteAllMessages).not.toHaveBeenCalled();
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
expect(mockRemoveDevice).not.toHaveBeenCalled();
expect(mockRemoveMessageStore).not.toHaveBeenCalled();
expect(mockRemoveNodeDB).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,10 @@
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 { DialogWrapper } from "../DialogWrapper.tsx";
@@ -13,24 +18,24 @@ export const FactoryResetDeviceDialog = ({
onOpenChange,
}: FactoryResetDeviceDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const { removeAllNodeErrors, removeAllNodes } = useNodeDB();
const { deleteAllMessages } = useMessages();
const { connection, id } = useDevice();
const handleFactoryResetDevice = () => {
connection
?.factoryResetDevice()
.then(() => {
deleteAllMessages();
removeAllNodeErrors();
removeAllNodes();
})
.catch((error) => {
toast({
title: t("factoryResetDevice.failedTitle"),
});
console.error("Failed to factory reset device:", error);
connection?.factoryResetDevice().catch((error) => {
toast({
title: t("factoryResetDevice.failedTitle"),
});
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 (

View File

@@ -7,5 +7,7 @@ if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
persistMessages: true,
persistDevices: true,
persistApp: true,
});
}

View File

@@ -4,6 +4,8 @@ import { z } from "zod";
export const FLAG_ENV = {
persistNodeDB: "VITE_PERSIST_NODE_DB",
persistMessages: "VITE_PERSIST_MESSAGES",
persistDevices: "VITE_PERSIST_DEVICES",
persistApp: "VITE_PERSIST_APP",
} as const;
export type FlagKey = keyof typeof FLAG_ENV;

View File

@@ -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
}
});
});

View File

@@ -1,41 +1,42 @@
import { featureFlags } from "@core/services/featureFlags.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
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 {
enabled: boolean;
title: string;
tiles: string;
tileSize: number;
}
const IDB_KEY_NAME = "meshtastic-app-store";
const CURRENT_STORE_VERSION = 0;
interface AppState {
selectedDeviceId: number;
devices: {
id: number;
num: number;
}[];
type AppData = {
// Persisted data
rasterSources: RasterSource[];
commandPaletteOpen: boolean;
};
export interface AppState extends AppData {
// Ephemeral state (not persisted)
selectedDeviceId: number;
nodeNumToBeRemoved: number;
connectDialogOpen: boolean;
nodeNumDetails: number;
commandPaletteOpen: boolean;
setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void;
removeRasterSource: (index: number) => void;
setSelectedDevice: (deviceId: number) => void;
addDevice: (device: { id: number; num: number }) => void;
removeDevice: (deviceId: number) => void;
setCommandPaletteOpen: (open: boolean) => void;
setNodeNumToBeRemoved: (nodeNum: number) => void;
setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
}
export const useAppStore = create<AppState>()((set, _get) => ({
export const deviceStoreInitializer: StateCreator<AppState> = (set, _get) => ({
selectedDeviceId: 0,
devices: [],
currentPage: "messages",
rasterSources: [],
commandPaletteOpen: false,
connectDialogOpen: false,
@@ -67,14 +68,6 @@ export const useAppStore = create<AppState>()((set, _get) => ({
set(() => ({
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) => {
set(
produce<AppState>((draft) => {
@@ -93,9 +86,35 @@ export const useAppStore = create<AppState>()((set, _get) => ({
}),
);
},
setNodeNumDetails: (nodeNum) =>
set(() => ({
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));

View File

@@ -0,0 +1,6 @@
export interface RasterSource {
enabled: boolean;
title: string;
tiles: string;
tileSize: number;
}

View File

@@ -14,6 +14,7 @@ import type { Device } from "./index.ts";
*/
export const mockDeviceStore: Device = {
id: 0,
myNodeNum: 123456,
status: 5 as const,
channels: new Map(),
config: {} as Protobuf.LocalOnly.LocalConfig,
@@ -44,8 +45,13 @@ export const mockDeviceStore: Device = {
deleteMessages: false,
managedMode: false,
clientNotification: false,
resetNodeDb: false,
clearAllStores: false,
factoryResetConfig: false,
factoryResetDevice: false,
},
clientNotifications: [],
neighborInfo: new Map(),
setStatus: vi.fn(),
setConfig: vi.fn(),
@@ -66,6 +72,8 @@ export const mockDeviceStore: Device = {
setPendingSettingsChanges: vi.fn(),
addChannel: vi.fn(),
addWaypoint: vi.fn(),
removeWaypoint: vi.fn(),
getWaypoint: vi.fn(),
addConnection: vi.fn(),
addTraceRoute: vi.fn(),
addMetadata: vi.fn(),
@@ -80,4 +88,6 @@ export const mockDeviceStore: Device = {
getClientNotification: vi.fn(),
getAllUnreadCount: vi.fn().mockReturnValue(0),
getUnreadCount: vi.fn().mockReturnValue(0),
getNeighborInfo: vi.fn(),
addNeighborInfo: vi.fn(),
};

View File

@@ -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);
});
});

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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,
};

View File

@@ -1,35 +1,37 @@
import { useDeviceContext } from "@core/hooks/useDeviceContext";
import { type Device, useDeviceStore } from "@core/stores/deviceStore";
import { type MessageStore, useMessageStore } from "@core/stores/messageStore";
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
import { bindStoreToDevice } from "@core/stores/utils/bindStoreToDevice";
import { useDeviceContext } from "@core/hooks/useDeviceContext.ts";
import { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
import {
type MessageStore,
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 {
CurrentDeviceContext,
type DeviceContext,
useDeviceContext,
} from "@core/hooks/useDeviceContext";
export { useAppStore } from "@core/stores/appStore";
export {
type Device,
type Page,
useDeviceStore,
type ValidConfigType,
type ValidModuleConfigType,
type WaypointWithMetadata,
} from "@core/stores/deviceStore";
export { useAppStore } from "@core/stores/appStore/index.ts";
export { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
export type {
Page,
ValidConfigType,
ValidModuleConfigType,
WaypointWithMetadata,
} from "@core/stores/deviceStore/types.ts";
export {
MessageState,
type MessageStore,
MessageType,
useMessageStore,
} from "@core/stores/messageStore";
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
export type { NodeErrorType } from "@core/stores/nodeDBStore/types";
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
export type { NodeErrorType } from "@core/stores/nodeDBStore/types.ts";
export {
SidebarProvider,
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
export { clear as clearAllStores } from "idb-keyval";

View File

@@ -17,6 +17,7 @@ import { produce } from "immer";
import { create as createStore, type StateCreator } from "zustand";
import { type PersistOptions, persist } from "zustand/middleware";
const IDB_KEY_NAME = "meshtastic-message-store";
const CURRENT_STORE_VERSION = 0;
const MESSAGESTORE_RETENTION_NUM = 10;
const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel
@@ -43,14 +44,17 @@ export interface MessageBuckets {
direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
}
export interface MessageStore {
type MessageStoreData = {
// Persisted data
id: number;
myNodeNum: number | undefined;
messages: MessageBuckets;
drafts: Map<Types.Destination, string>;
};
// Ephemeral UI state (not persisted)
export interface MessageStore extends MessageStoreData {
// Ephemeral state (not persisted)
activeChat: number;
chatType: MessageType;
@@ -78,14 +82,6 @@ interface PrivateMessageStoreState extends MessageStoreState {
messageStores: Map<number, MessageStore>;
}
type MessageStoreData = {
id: number;
myNodeNum: number | undefined;
messages: MessageBuckets;
drafts: Map<Types.Destination, string>;
};
type MessageStorePersisted = {
messageStores: Map<number, MessageStoreData>;
};
@@ -393,7 +389,7 @@ const persistOptions: PersistOptions<
PrivateMessageStoreState,
MessageStorePersisted
> = {
name: "meshtastic-message-store",
name: IDB_KEY_NAME,
storage: createStorage<MessageStorePersisted>(),
version: CURRENT_STORE_VERSION,
partialize: (s): MessageStorePersisted => ({

View File

@@ -13,15 +13,20 @@ import {
} from "zustand/middleware";
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
const IDB_KEY_NAME = "meshtastic-nodedb-store";
const CURRENT_STORE_VERSION = 0;
const NODEDB_RETENTION_NUM = 10;
export interface NodeDB {
type NodeDBData = {
// Persisted data
id: number;
myNodeNum: number | undefined;
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
nodeErrors: Map<number, NodeError>;
};
export interface NodeDB extends NodeDBData {
// Ephemeral state (not persisted)
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
removeNode: (nodeNum: number) => void;
removeAllNodes: (keepMyNode?: boolean) => void;
@@ -58,13 +63,6 @@ interface PrivateNodeDBState extends nodeDBState {
nodeDBs: Map<number, NodeDB>;
}
type NodeDBData = {
id: number;
myNodeNum: number | undefined;
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
nodeErrors: Map<number, NodeError>;
};
type NodeDBPersisted = {
nodeDBs: Map<number, NodeDBData>;
};
@@ -442,7 +440,7 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
});
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
name: "meshtastic-nodedb-store",
name: IDB_KEY_NAME,
storage: createStorage<NodeDBPersisted>(),
version: CURRENT_STORE_VERSION,
partialize: (s): NodeDBPersisted => ({

View File

@@ -27,4 +27,6 @@ export const mockNodeDBStore: NodeDB = {
updateFavorite: vi.fn(),
updateIgnore: vi.fn(),
setNodeNum: vi.fn(),
removeAllNodeErrors: vi.fn(),
removeAllNodes: vi.fn(),
};

View File

@@ -456,7 +456,7 @@ describe("NodeDB merge semantics, PKI checks & extras", () => {
const newDB = st.addNodeDB(1101);
newDB.setNodeNum(4242);
expect(newDB.getMyNode().num).toBe(4242);
expect(newDB.getMyNode()?.num).toBe(4242);
});
});

View File

@@ -1,14 +1,24 @@
export function evictOldestEntries<K, V>(
map: Map<K, V>,
export function evictOldestEntries<T>(arr: T[], maxSize: number): void;
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,
): void {
// while loop in case maxSize is ever changed to be lower, to trim all the way down
while (map.size > maxSize) {
const firstKey = map.keys().next().value; // maps keep insertion order, so this is oldest
if (firstKey !== undefined) {
map.delete(firstKey);
} else {
break; // should not happen, but just in case
if (Array.isArray(collection)) {
// Trim array from the front (assuming oldest entries are at the start)
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) {
collection.delete(firstKey);
} else {
break;
}
}
}
}

View File

@@ -3,18 +3,21 @@ import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Separator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore, useDeviceStore, useNodeDBStore } from "@core/stores";
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
import { useMemo } from "react";
import {
useAppStore /*, useDeviceStore, useNodeDBStore */,
} from "@core/stores";
import { ListPlusIcon, PlusIcon /*, UsersIcon */ } from "lucide-react";
/* import { useMemo } from "react"; */
import { useTranslation } from "react-i18next";
export const Dashboard = () => {
const { t } = useTranslation("dashboard");
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
const { setConnectDialogOpen /*, setSelectedDevice*/ } = useAppStore();
/*
const { getDevices } = useDeviceStore();
const { getNodeDB } = useNodeDBStore();
const devices = useMemo(() => getDevices(), [getDevices]);
*/
return (
<div className="flex flex-col gap-3 p-3 px-8">
@@ -29,7 +32,8 @@ export const Dashboard = () => {
<Separator />
<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">
{devices.map((device) => {
const nodeDB = getNodeDB(device.id);
@@ -69,8 +73,7 @@ export const Dashboard = () => {
);
})}
</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" />
<Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading>
<Subtle>{t("dashboard.noDevicesDescription")}</Subtle>
@@ -83,7 +86,7 @@ export const Dashboard = () => {
{t("dashboard.button_newConnection")}
</Button>
</div>
)}
}
</div>
</div>
);