diff --git a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx index 848e802f..01492e02 100644 --- a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx +++ b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx @@ -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(); }); }); diff --git a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx index a261a025..e171c8ba 100644 --- a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx +++ b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx @@ -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 ( diff --git a/packages/web/src/core/services/dev-overrides.ts b/packages/web/src/core/services/dev-overrides.ts index 928c43a5..b4db6ff3 100644 --- a/packages/web/src/core/services/dev-overrides.ts +++ b/packages/web/src/core/services/dev-overrides.ts @@ -7,5 +7,7 @@ if (isDev) { featureFlags.setOverrides({ persistNodeDB: true, persistMessages: true, + persistDevices: true, + persistApp: true, }); } diff --git a/packages/web/src/core/services/featureFlags.ts b/packages/web/src/core/services/featureFlags.ts index 645861b6..48e72e6a 100644 --- a/packages/web/src/core/services/featureFlags.ts +++ b/packages/web/src/core/services/featureFlags.ts @@ -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; diff --git a/packages/web/src/core/stores/appStore/appStore.test.ts b/packages/web/src/core/stores/appStore/appStore.test.ts new file mode 100644 index 00000000..7ebe8ca0 --- /dev/null +++ b/packages/web/src/core/stores/appStore/appStore.test.ts @@ -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(); +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): 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 + } + }); +}); diff --git a/packages/web/src/core/stores/appStore/index.ts b/packages/web/src/core/stores/appStore/index.ts index 1f342c29..72034911 100644 --- a/packages/web/src/core/stores/appStore/index.ts +++ b/packages/web/src/core/stores/appStore/index.ts @@ -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()((set, _get) => ({ +export const deviceStoreInitializer: StateCreator = (set, _get) => ({ selectedDeviceId: 0, - devices: [], - currentPage: "messages", rasterSources: [], commandPaletteOpen: false, connectDialogOpen: false, @@ -67,14 +68,6 @@ export const useAppStore = create()((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((draft) => { @@ -93,9 +86,35 @@ export const useAppStore = create()((set, _get) => ({ }), ); }, - setNodeNumDetails: (nodeNum) => set(() => ({ nodeNumDetails: nodeNum, })), -})); +}); + +const persistOptions: PersistOptions = { + name: IDB_KEY_NAME, + storage: createStorage(), + 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)); diff --git a/packages/web/src/core/stores/appStore/types.ts b/packages/web/src/core/stores/appStore/types.ts new file mode 100644 index 00000000..3b673c99 --- /dev/null +++ b/packages/web/src/core/stores/appStore/types.ts @@ -0,0 +1,6 @@ +export interface RasterSource { + enabled: boolean; + title: string; + tiles: string; + tileSize: number; +} diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts index 06040336..d3fd2255 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts @@ -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(), }; diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.test.ts b/packages/web/src/core/stores/deviceStore/deviceStore.test.ts new file mode 100644 index 00000000..8348b2b0 --- /dev/null +++ b/packages/web/src/core/stores/deviceStore/deviceStore.test.ts @@ -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(); +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; +} +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) { + return create(Protobuf.Config.ConfigSchema, { + payloadVariant: { + case: "device", + value: create(Protobuf.Config.Config_DeviceConfigSchema, fields), + }, + }); +} +function makeModuleConfig(fields: Record) { + return create(Protobuf.ModuleConfig.ModuleConfigSchema, { + payloadVariant: { + case: "mqtt", + value: create( + Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, + fields, + ), + }, + }); +} +function makeAdminMessage(fields: Record) { + 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); + }); +}); diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index e8354706..f57f7f92 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -1,38 +1,43 @@ 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 { produce } from "immer"; -import { create as createStore } from "zustand"; - -export type Page = "messages" | "map" | "config" | "channels" | "nodes"; - -export interface ProcessPacketParams { - from: number; - 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 { +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"; + +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; + +type DeviceData = { + // Persisted data id: number; + myNodeNum: number | undefined; + traceroutes: Map< + number, + Types.PacketMetadata[] + >; + waypoints: WaypointWithMetadata[]; + neighborInfo: Map; +}; +export interface Device extends DeviceData { + // Ephemeral state (not persisted) status: Types.DeviceStatusEnum; channels: Map; config: Protobuf.LocalOnly.LocalConfig; @@ -42,36 +47,12 @@ export interface Device { workingChannelConfig: Protobuf.Channel.Channel[]; hardware: Protobuf.Mesh.MyNodeInfo; metadata: Map; - traceroutes: Map< - number, - Types.PacketMetadata[] - >; connection?: MeshDevice; activeNode: number; - waypoints: WaypointWithMetadata[]; - neighborInfo: Map; pendingSettingsChanges: boolean; messageDraft: string; unreadCounts: Map; - dialog: { - 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; - }; + dialog: Dialogs; clientNotifications: Protobuf.Mesh.ClientNotification[]; setStatus: (status: Types.DeviceStatusEnum) => void; @@ -79,19 +60,12 @@ export interface Device { setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setWorkingConfig: (config: Protobuf.Config.Config) => void; setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; - getWorkingConfig: ( - payloadVariant: ValidConfigType, - ) => - | Protobuf.LocalOnly.LocalConfig[Exclude] - | undefined; - getWorkingModuleConfig: ( - payloadVariant: ValidModuleConfigType, - ) => - | Protobuf.LocalOnly.LocalModuleConfig[Exclude< - ValidModuleConfigType, - undefined - >] - | undefined; + getWorkingConfig( + payloadVariant: K, + ): Protobuf.LocalOnly.LocalConfig[K] | undefined; + getWorkingModuleConfig( + payloadVariant: K, + ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined; removeWorkingConfig: (payloadVariant?: ValidConfigType) => void; removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void; getEffectiveConfig( @@ -115,6 +89,8 @@ export interface Device { from: number, rxTime: Date, ) => void; + removeWaypoint: (waypointId: number, toMesh: boolean) => Promise; + getWaypoint: (waypointId: number) => WaypointWithMetadata | undefined; addConnection: (connection: MeshDevice) => void; addTraceRoute: ( traceroute: Types.PacketMetadata, @@ -142,659 +118,845 @@ export interface Device { getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined; } -export interface DeviceState { +export interface deviceState { addDevice: (id: number) => Device; removeDevice: (id: number) => void; getDevices: () => Device[]; getDevice: (id: number) => Device | undefined; } -interface PrivateDeviceState extends DeviceState { +interface PrivateDeviceState extends deviceState { devices: Map; - remoteDevices: Map; } -export const useDeviceStore = createStore((set, get) => ({ - devices: new Map(), - remoteDevices: new Map(), - - addDevice: (id: number) => { - set( - produce((draft) => { - draft.devices.set(id, { - id, - status: Types.DeviceStatusEnum.DeviceDisconnected, - channels: new Map(), - config: create(Protobuf.LocalOnly.LocalConfigSchema), - moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema), - workingConfig: [], - workingModuleConfig: [], - workingChannelConfig: [], - hardware: create(Protobuf.Mesh.MyNodeInfoSchema), - metadata: new Map(), - traceroutes: new Map(), - connection: undefined, - activeNode: 0, - waypoints: [], - neighborInfo: new Map(), - dialog: { - import: false, - QR: false, - shutdown: false, - reboot: false, - deviceName: false, - nodeRemoval: false, - pkiBackup: false, - nodeDetails: false, - unsafeRoles: false, - refreshKeys: false, - deleteMessages: false, - managedMode: false, - clientNotification: false, - resetNodeDb: false, - clearAllStores: false, - factoryResetDevice: false, - factoryResetConfig: false, - }, - pendingSettingsChanges: false, - messageDraft: "", - unreadCounts: new Map(), - clientNotifications: [], - - setStatus: (status: Types.DeviceStatusEnum) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.status = status; - } - }), - ); - }, - setConfig: (config: Protobuf.Config.Config) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - switch (config.payloadVariant.case) { - case "device": { - device.config.device = config.payloadVariant.value; - break; - } - case "position": { - device.config.position = config.payloadVariant.value; - break; - } - case "power": { - device.config.power = config.payloadVariant.value; - break; - } - case "network": { - device.config.network = config.payloadVariant.value; - break; - } - case "display": { - device.config.display = config.payloadVariant.value; - break; - } - case "lora": { - device.config.lora = config.payloadVariant.value; - break; - } - case "bluetooth": { - device.config.bluetooth = config.payloadVariant.value; - break; - } - case "security": { - device.config.security = config.payloadVariant.value; - } - } - } - }), - ); - }, - setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - switch (config.payloadVariant.case) { - case "mqtt": { - device.moduleConfig.mqtt = config.payloadVariant.value; - break; - } - case "serial": { - device.moduleConfig.serial = config.payloadVariant.value; - break; - } - case "externalNotification": { - device.moduleConfig.externalNotification = - config.payloadVariant.value; - break; - } - case "storeForward": { - device.moduleConfig.storeForward = - config.payloadVariant.value; - break; - } - case "rangeTest": { - device.moduleConfig.rangeTest = - config.payloadVariant.value; - break; - } - case "telemetry": { - device.moduleConfig.telemetry = - config.payloadVariant.value; - break; - } - case "cannedMessage": { - device.moduleConfig.cannedMessage = - config.payloadVariant.value; - break; - } - case "audio": { - device.moduleConfig.audio = config.payloadVariant.value; - break; - } - case "neighborInfo": { - device.moduleConfig.neighborInfo = - config.payloadVariant.value; - break; - } - case "ambientLighting": { - device.moduleConfig.ambientLighting = - config.payloadVariant.value; - break; - } - case "detectionSensor": { - device.moduleConfig.detectionSensor = - config.payloadVariant.value; - break; - } - case "paxcounter": { - device.moduleConfig.paxcounter = - config.payloadVariant.value; - break; - } - } - } - }), - ); - }, - setWorkingConfig: (config: Protobuf.Config.Config) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const index = device.workingConfig.findIndex( - (wc) => wc.payloadVariant.case === config.payloadVariant.case, - ); - - if (index !== -1) { - device.workingConfig[index] = config; - } else { - device.workingConfig.push(config); - } - }), - ); - }, - setWorkingModuleConfig: ( - moduleConfig: Protobuf.ModuleConfig.ModuleConfig, - ) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const index = device.workingModuleConfig.findIndex( - (wmc) => - wmc.payloadVariant.case === - moduleConfig.payloadVariant.case, - ); - - if (index !== -1) { - device.workingModuleConfig[index] = moduleConfig; - } else { - device.workingModuleConfig.push(moduleConfig); - } - }), - ); - }, +type DevicePersisted = { + devices: Map; +}; - getWorkingConfig: (payloadVariant: ValidConfigType) => { - const device = get().devices.get(id); - if (!device) { - return; +function deviceFactory( + id: number, + get: () => PrivateDeviceState, + set: typeof useDeviceStore.setState, + data?: Partial, +): Device { + const myNodeNum = data?.myNodeNum; + const traceroutes = + data?.traceroutes ?? + new Map[]>(); + const waypoints = data?.waypoints ?? []; + const neighborInfo = + data?.neighborInfo ?? new Map(); + return { + id, + myNodeNum, + traceroutes, + waypoints, + neighborInfo, + + status: Types.DeviceStatusEnum.DeviceDisconnected, + channels: new Map(), + config: create(Protobuf.LocalOnly.LocalConfigSchema), + moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema), + workingConfig: [], + workingModuleConfig: [], + workingChannelConfig: [], + hardware: create(Protobuf.Mesh.MyNodeInfoSchema), + metadata: new Map(), + connection: undefined, + activeNode: 0, + dialog: { + import: false, + QR: false, + shutdown: false, + reboot: false, + deviceName: false, + nodeRemoval: false, + pkiBackup: false, + nodeDetails: false, + unsafeRoles: false, + refreshKeys: false, + deleteMessages: false, + managedMode: false, + clientNotification: false, + resetNodeDb: false, + clearAllStores: false, + factoryResetDevice: false, + factoryResetConfig: false, + }, + pendingSettingsChanges: false, + messageDraft: "", + unreadCounts: new Map(), + clientNotifications: [], + + setStatus: (status: Types.DeviceStatusEnum) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + device.status = status; + } + }), + ); + }, + setConfig: (config: Protobuf.Config.Config) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + switch (config.payloadVariant.case) { + case "device": { + device.config.device = config.payloadVariant.value; + break; + } + case "position": { + device.config.position = config.payloadVariant.value; + break; + } + case "power": { + device.config.power = config.payloadVariant.value; + break; + } + case "network": { + device.config.network = config.payloadVariant.value; + break; + } + case "display": { + device.config.display = config.payloadVariant.value; + break; + } + case "lora": { + device.config.lora = config.payloadVariant.value; + break; + } + case "bluetooth": { + device.config.bluetooth = config.payloadVariant.value; + break; + } + case "security": { + device.config.security = config.payloadVariant.value; + } } - - const workingConfig = device.workingConfig.find( - (c) => c.payloadVariant.case === payloadVariant, - ); - - if ( - workingConfig?.payloadVariant.case === "deviceUi" || - workingConfig?.payloadVariant.case === "sessionkey" - ) { - return; + } + }), + ); + }, + setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + switch (config.payloadVariant.case) { + case "mqtt": { + device.moduleConfig.mqtt = config.payloadVariant.value; + break; + } + case "serial": { + device.moduleConfig.serial = config.payloadVariant.value; + break; + } + case "externalNotification": { + device.moduleConfig.externalNotification = + config.payloadVariant.value; + break; + } + case "storeForward": { + device.moduleConfig.storeForward = config.payloadVariant.value; + break; + } + case "rangeTest": { + device.moduleConfig.rangeTest = config.payloadVariant.value; + break; + } + case "telemetry": { + device.moduleConfig.telemetry = config.payloadVariant.value; + break; + } + case "cannedMessage": { + device.moduleConfig.cannedMessage = config.payloadVariant.value; + break; + } + case "audio": { + device.moduleConfig.audio = config.payloadVariant.value; + break; + } + case "neighborInfo": { + device.moduleConfig.neighborInfo = config.payloadVariant.value; + break; + } + case "ambientLighting": { + device.moduleConfig.ambientLighting = + config.payloadVariant.value; + break; + } + case "detectionSensor": { + device.moduleConfig.detectionSensor = + config.payloadVariant.value; + break; + } + case "paxcounter": { + device.moduleConfig.paxcounter = config.payloadVariant.value; + break; + } } - - return workingConfig?.payloadVariant.value; - }, - getWorkingModuleConfig: (payloadVariant: ValidModuleConfigType) => { - const device = get().devices.get(id); - if (!device) { - return; + } + }), + ); + }, + setWorkingConfig: (config: Protobuf.Config.Config) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + const index = device.workingConfig.findIndex( + (wc) => wc.payloadVariant.case === config.payloadVariant.case, + ); + + if (index !== -1) { + device.workingConfig[index] = config; + } else { + device.workingConfig.push(config); + } + }), + ); + }, + setWorkingModuleConfig: ( + moduleConfig: Protobuf.ModuleConfig.ModuleConfig, + ) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + const index = device.workingModuleConfig.findIndex( + (wmc) => + wmc.payloadVariant.case === moduleConfig.payloadVariant.case, + ); + + if (index !== -1) { + device.workingModuleConfig[index] = moduleConfig; + } else { + device.workingModuleConfig.push(moduleConfig); + } + }), + ); + }, + + getWorkingConfig(payloadVariant: K) { + const device = get().devices.get(id); + if (!device) { + return; + } + + const workingConfig = device.workingConfig.find( + (c) => c.payloadVariant.case === payloadVariant, + ); + + if ( + workingConfig?.payloadVariant.case === "deviceUi" || + workingConfig?.payloadVariant.case === "sessionkey" + ) { + return; + } + + return workingConfig?.payloadVariant + .value as Protobuf.LocalOnly.LocalConfig[K]; + }, + getWorkingModuleConfig( + payloadVariant: K, + ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined { + const device = get().devices.get(id); + if (!device) { + return; + } + + return device.workingModuleConfig.find( + (c) => c.payloadVariant.case === payloadVariant, + )?.payloadVariant.value as Protobuf.LocalOnly.LocalModuleConfig[K]; + }, + + removeWorkingConfig: (payloadVariant?: ValidConfigType) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + + if (!payloadVariant) { + device.workingConfig = []; + return; + } + + const index = device.workingConfig.findIndex( + (wc: Protobuf.Config.Config) => + wc.payloadVariant.case === payloadVariant, + ); + + if (index !== -1) { + device.workingConfig.splice(index, 1); + } + }), + ); + }, + removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + + if (!payloadVariant) { + device.workingModuleConfig = []; + return; + } + + const index = device.workingModuleConfig.findIndex( + (wc: Protobuf.ModuleConfig.ModuleConfig) => + wc.payloadVariant.case === payloadVariant, + ); + + if (index !== -1) { + device.workingModuleConfig.splice(index, 1); + } + }), + ); + }, + + getEffectiveConfig( + payloadVariant: K, + ): Protobuf.LocalOnly.LocalConfig[K] | undefined { + if (!payloadVariant) { + return; + } + const device = get().devices.get(id); + if (!device) { + return; + } + + return { + ...device.config[payloadVariant], + ...device.workingConfig.find( + (c) => c.payloadVariant.case === payloadVariant, + )?.payloadVariant.value, + }; + }, + getEffectiveModuleConfig( + payloadVariant: K, + ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined { + const device = get().devices.get(id); + if (!device) { + return; + } + + return { + ...device.moduleConfig[payloadVariant], + ...device.workingModuleConfig.find( + (c) => c.payloadVariant.case === payloadVariant, + )?.payloadVariant.value, + }; + }, + + setWorkingChannelConfig: (config: Protobuf.Channel.Channel) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + const index = device.workingChannelConfig.findIndex( + (wcc) => wcc.index === config.index, + ); + + if (index !== -1) { + device.workingChannelConfig[index] = config; + } else { + device.workingChannelConfig.push(config); + } + }), + ); + }, + getWorkingChannelConfig: (channelNum: Types.ChannelNumber) => { + const device = get().devices.get(id); + if (!device) { + return; + } + + const workingChannelConfig = device.workingChannelConfig.find( + (c) => c.index === channelNum, + ); + + return workingChannelConfig; + }, + removeWorkingChannelConfig: (channelNum?: Types.ChannelNumber) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + + if (channelNum === undefined) { + device.workingChannelConfig = []; + return; + } + + const index = device.workingChannelConfig.findIndex( + (wcc: Protobuf.Channel.Channel) => wcc.index === channelNum, + ); + + if (index !== -1) { + device.workingChannelConfig.splice(index, 1); + } + }), + ); + }, + + setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => { + set( + produce((draft) => { + const newDevice = draft.devices.get(id); + if (!newDevice) { + 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; - return device.workingModuleConfig.find( - (c) => c.payloadVariant.case === payloadVariant, - )?.payloadVariant.value; - }, - - removeWorkingConfig: (payloadVariant?: ValidConfigType) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - - if (!payloadVariant) { - device.workingConfig = []; - return; - } - - const index = device.workingConfig.findIndex( - (wc: Protobuf.Config.Config) => - wc.payloadVariant.case === payloadVariant, - ); - - if (index !== -1) { - device.workingConfig.splice(index, 1); - } - }), + // Take this opportunity to remove stale waypoints + newDevice.waypoints = oldStore.waypoints.filter( + (waypoint) => !waypoint?.expire || waypoint.expire > Date.now(), ); - }, - removeWorkingModuleConfig: ( - payloadVariant?: ValidModuleConfigType, - ) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - - if (!payloadVariant) { - device.workingModuleConfig = []; - return; - } - - const index = device.workingModuleConfig.findIndex( - (wc: Protobuf.ModuleConfig.ModuleConfig) => - wc.payloadVariant.case === payloadVariant, - ); - - if (index !== -1) { - device.workingModuleConfig.splice(index, 1); - } - }), - ); - }, - - getEffectiveConfig( - payloadVariant: K, - ): Protobuf.LocalOnly.LocalConfig[K] | undefined { - if (!payloadVariant) { - return; - } - const device = get().devices.get(id); - if (!device) { - return; - } - return { - ...device.config[payloadVariant], - ...device.workingConfig.find( - (c) => c.payloadVariant.case === payloadVariant, - )?.payloadVariant.value, + // Drop old device + draft.devices.delete(otherId); + } + + newDevice.hardware = hardware; // Always replace hardware with latest + }), + ); + }, + setPendingSettingsChanges: (state) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + device.pendingSettingsChanges = state; + } + }), + ); + }, + addChannel: (channel: Protobuf.Channel.Channel) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + device.channels.set(channel.index, channel); + } + }), + ); + }, + addWaypoint: (waypoint, channel, from, rxTime) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return undefined; + } + + const index = device.waypoints.findIndex( + (wp) => wp.id === waypoint.id, + ); + + if (index !== -1) { + const created = + device.waypoints[index]?.metadata.created ?? new Date(); + const updatedWaypoint = { + ...waypoint, + metadata: { created, updated: rxTime, from, channel }, }; - }, - getEffectiveModuleConfig( - payloadVariant: K, - ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined { - const device = get().devices.get(id); - if (!device) { - return; - } - return { - ...device.moduleConfig[payloadVariant], - ...device.workingModuleConfig.find( - (c) => c.payloadVariant.case === payloadVariant, - )?.payloadVariant.value, - }; - }, - - setWorkingChannelConfig: (config: Protobuf.Channel.Channel) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const index = device.workingChannelConfig.findIndex( - (wcc) => wcc.index === config.index, - ); - - if (index !== -1) { - device.workingChannelConfig[index] = config; - } else { - device.workingChannelConfig.push(config); - } - }), - ); - }, - getWorkingChannelConfig: (channelNum: Types.ChannelNumber) => { - const device = get().devices.get(id); - if (!device) { - return; - } + // Remove existing waypoint + device.waypoints.splice(index, 1); - const workingChannelConfig = device.workingChannelConfig.find( - (c) => c.index === channelNum, - ); - - return workingChannelConfig; - }, - removeWorkingChannelConfig: (channelNum?: Types.ChannelNumber) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - - if (channelNum === undefined) { - device.workingChannelConfig = []; - return; - } - - const index = device.workingChannelConfig.findIndex( - (wcc: Protobuf.Channel.Channel) => wcc.index === channelNum, - ); - - if (index !== -1) { - device.workingChannelConfig.splice(index, 1); - } - }), - ); - }, - - setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.hardware = hardware; - } - }), - ); - }, - setPendingSettingsChanges: (state) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.pendingSettingsChanges = state; - } - }), - ); - }, - addChannel: (channel: Protobuf.Channel.Channel) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.channels.set(channel.index, channel); - } - }), - ); - }, - addWaypoint: (waypoint, channel, from, rxTime) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - const index = device.waypoints.findIndex( - (wp) => wp.id === waypoint.id, - ); - if (index !== -1) { - const created = - device.waypoints[index]?.metadata.created ?? new Date(); - const updatedWaypoint = { - ...waypoint, - metadata: { created, updated: rxTime, from, channel }, - }; - - device.waypoints[index] = updatedWaypoint; - } else { - device.waypoints.push({ - ...waypoint, - metadata: { created: rxTime, from, channel }, - }); - } - } - }), - ); - }, - setActiveNode: (node) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.activeNode = node; - } - }), - ); - }, - addConnection: (connection) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.connection = connection; - } - }), - ); - }, - addMetadata: (from, metadata) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.metadata.set(from, metadata); - } - }), - ); - }, - addTraceRoute: (traceroute) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const routes = device.traceroutes.get(traceroute.from) ?? []; - routes.push(traceroute); - device.traceroutes.set(traceroute.from, routes); - }), - ); - }, - setDialogOpen: (dialog: DialogVariant, open: boolean) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.dialog[dialog] = open; - } - }), - ); - }, - getDialogOpen: (dialog: DialogVariant) => { - const device = get().devices.get(id); - if (!device) { - throw new Error(`Device ${id} not found`); - } - return device.dialog[dialog]; - }, - - setMessageDraft: (message: string) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.messageDraft = message; - } - }), - ); - }, - incrementUnread: (nodeNum: number) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const currentCount = device.unreadCounts.get(nodeNum) ?? 0; - device.unreadCounts.set(nodeNum, currentCount + 1); - }), - ); - }, - getUnreadCount: (nodeNum: number): number => { - const device = get().devices.get(id); - if (!device) { - return 0; - } - return device.unreadCounts.get(nodeNum) ?? 0; - }, - getAllUnreadCount: (): number => { - const device = get().devices.get(id); - if (!device) { - return 0; + // Push new if no expiry or not expired + if (waypoint.expire === 0 || waypoint.expire > Date.now()) { + device.waypoints.push(updatedWaypoint); } - let totalUnread = 0; - device.unreadCounts.forEach((count) => { - totalUnread += count; + } 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({ + ...waypoint, + metadata: { created: rxTime, from, channel }, }); - return totalUnread; - }, - resetUnread: (nodeNum: number) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - device.unreadCounts.set(nodeNum, 0); - if (device.unreadCounts.get(nodeNum) === 0) { - device.unreadCounts.delete(nodeNum); - } - }), - ); - }, + } + + // 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, + }); - sendAdminMessage(message: Protobuf.Admin.AdminMessage) { - const device = get().devices.get(id); - if (!device) { - return; - } + await device.connection.sendWaypoint( + waypointToBroadcast, + "broadcast", + waypoint.metadata.channel, + ); + } + + // Remove from store + set( + produce((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) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + device.activeNode = node; + } + }), + ); + }, + addConnection: (connection) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + device.connection = connection; + } + }), + ); + }, + addMetadata: (from, metadata) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + device.metadata.set(from, metadata); + } + }), + ); + }, + addTraceRoute: (traceroute) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + const routes = device.traceroutes.get(traceroute.from) ?? []; + routes.push(traceroute); + 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, + ); + }), + ); + }, + setDialogOpen: (dialog: DialogVariant, open: boolean) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + device.dialog[dialog] = open; + } + }), + ); + }, + getDialogOpen: (dialog: DialogVariant) => { + const device = get().devices.get(id); + if (!device) { + throw new Error(`Device ${id} not found`); + } + return device.dialog[dialog]; + }, + + setMessageDraft: (message: string) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + device.messageDraft = message; + } + }), + ); + }, + incrementUnread: (nodeNum: number) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + const currentCount = device.unreadCounts.get(nodeNum) ?? 0; + device.unreadCounts.set(nodeNum, currentCount + 1); + }), + ); + }, + getUnreadCount: (nodeNum: number): number => { + const device = get().devices.get(id); + if (!device) { + return 0; + } + return device.unreadCounts.get(nodeNum) ?? 0; + }, + getAllUnreadCount: (): number => { + const device = get().devices.get(id); + if (!device) { + return 0; + } + let totalUnread = 0; + device.unreadCounts.forEach((count) => { + totalUnread += count; + }); + return totalUnread; + }, + resetUnread: (nodeNum: number) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + device.unreadCounts.set(nodeNum, 0); + if (device.unreadCounts.get(nodeNum) === 0) { + device.unreadCounts.delete(nodeNum); + } + }), + ); + }, + + sendAdminMessage(message: Protobuf.Admin.AdminMessage) { + const device = get().devices.get(id); + if (!device) { + return; + } + + device.connection?.sendPacket( + toBinary(Protobuf.Admin.AdminMessageSchema, message), + Protobuf.Portnums.PortNum.ADMIN_APP, + "self", + ); + }, + + addClientNotification: ( + clientNotificationPacket: Protobuf.Mesh.ClientNotification, + ) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + device.clientNotifications.push(clientNotificationPacket); + }), + ); + }, + removeClientNotification: (index: number) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + device.clientNotifications.splice(index, 1); + }), + ); + }, + getClientNotification: (index: number) => { + const device = get().devices.get(id); + if (!device) { + return; + } + return device.clientNotifications[index]; + }, + addNeighborInfo: ( + nodeId: number, + neighborInfo: Protobuf.Mesh.NeighborInfo, + ) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + + // Replace any existing neighbor info for this nodeId + device.neighborInfo.set(nodeId, neighborInfo); + }), + ); + }, + + getNeighborInfo: (nodeNum: number) => { + const device = get().devices.get(id); + if (!device) { + return; + } + return device.neighborInfo.get(nodeNum); + }, + }; +} - device.connection?.sendPacket( - toBinary(Protobuf.Admin.AdminMessageSchema, message), - Protobuf.Portnums.PortNum.ADMIN_APP, - "self", - ); - }, - - addClientNotification: ( - clientNotificationPacket: Protobuf.Mesh.ClientNotification, - ) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - device.clientNotifications.push(clientNotificationPacket); - }), - ); - }, - removeClientNotification: (index: number) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - device.clientNotifications.splice(index, 1); - }), - ); - }, - getClientNotification: (index: number) => { - const device = get().devices.get(id); - if (!device) { - return; - } - return device.clientNotifications[index]; - }, - addNeighborInfo: ( - nodeId: number, - neighborInfo: Protobuf.Mesh.NeighborInfo, - ) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - - // Replace any existing neighbor info for this nodeId - device.neighborInfo.set(nodeId, neighborInfo); - }), - ); - }, +export const deviceStoreInitializer: StateCreator = ( + set, + get, +) => ({ + devices: new Map(), - getNeighborInfo: (nodeNum: number) => { - const device = get().devices.get(id); - if (!device) { - return; - } - return device.neighborInfo.get(nodeNum); - }, - }); + addDevice: (id) => { + const existing = get().devices.get(id); + if (existing) { + return existing; + } + + const device = deviceFactory(id, get, set); + set( + produce((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; }, - removeDevice: (id) => { set( produce((draft) => { - draft.devices.delete(id); + const updated = new Map(draft.devices); + updated.delete(id); + draft.devices = updated; }), ); }, - getDevices: () => Array.from(get().devices.values()), - getDevice: (id) => get().devices.get(id), -})); +}); + +const persistOptions: PersistOptions = { + name: IDB_KEY_NAME, + storage: createStorage(), + 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((draft) => { + const rebuilt = new Map(); + for (const [id, data] of ( + draft.devices as unknown as Map + ).entries()) { + if (data.myNodeNum !== undefined) { + // Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate + rebuilt.set( + id, + 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)); diff --git a/packages/web/src/core/stores/deviceStore/types.ts b/packages/web/src/core/stores/deviceStore/types.ts new file mode 100644 index 00000000..08726372 --- /dev/null +++ b/packages/web/src/core/stores/deviceStore/types.ts @@ -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, +}; diff --git a/packages/web/src/core/stores/index.ts b/packages/web/src/core/stores/index.ts index 4cf74c94..fa9b06f9 100644 --- a/packages/web/src/core/stores/index.ts +++ b/packages/web/src/core/stores/index.ts @@ -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"; diff --git a/packages/web/src/core/stores/messageStore/index.ts b/packages/web/src/core/stores/messageStore/index.ts index 3c86ac40..ffc17671 100644 --- a/packages/web/src/core/stores/messageStore/index.ts +++ b/packages/web/src/core/stores/messageStore/index.ts @@ -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; broadcast: Map; } -export interface MessageStore { + +type MessageStoreData = { + // Persisted data id: number; myNodeNum: number | undefined; - messages: MessageBuckets; drafts: Map; +}; - // 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; } -type MessageStoreData = { - id: number; - myNodeNum: number | undefined; - - messages: MessageBuckets; - drafts: Map; -}; - type MessageStorePersisted = { messageStores: Map; }; @@ -393,7 +389,7 @@ const persistOptions: PersistOptions< PrivateMessageStoreState, MessageStorePersisted > = { - name: "meshtastic-message-store", + name: IDB_KEY_NAME, storage: createStorage(), version: CURRENT_STORE_VERSION, partialize: (s): MessageStorePersisted => ({ diff --git a/packages/web/src/core/stores/nodeDBStore/index.ts b/packages/web/src/core/stores/nodeDBStore/index.ts index 14bc40a6..e68fdaf6 100644 --- a/packages/web/src/core/stores/nodeDBStore/index.ts +++ b/packages/web/src/core/stores/nodeDBStore/index.ts @@ -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; nodeErrors: Map; +}; +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; } -type NodeDBData = { - id: number; - myNodeNum: number | undefined; - nodeMap: Map; - nodeErrors: Map; -}; - type NodeDBPersisted = { nodeDBs: Map; }; @@ -442,7 +440,7 @@ export const nodeDBInitializer: StateCreator = ( }); const persistOptions: PersistOptions = { - name: "meshtastic-nodedb-store", + name: IDB_KEY_NAME, storage: createStorage(), version: CURRENT_STORE_VERSION, partialize: (s): NodeDBPersisted => ({ diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts index 031ee84d..0ffb71ca 100644 --- a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts +++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts @@ -27,4 +27,6 @@ export const mockNodeDBStore: NodeDB = { updateFavorite: vi.fn(), updateIgnore: vi.fn(), setNodeNum: vi.fn(), + removeAllNodeErrors: vi.fn(), + removeAllNodes: vi.fn(), }; diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx index a5482711..ffc91185 100644 --- a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx +++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx @@ -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); }); }); diff --git a/packages/web/src/core/stores/utils/evictOldestEntries.ts b/packages/web/src/core/stores/utils/evictOldestEntries.ts index 500726de..2e4631f4 100644 --- a/packages/web/src/core/stores/utils/evictOldestEntries.ts +++ b/packages/web/src/core/stores/utils/evictOldestEntries.ts @@ -1,14 +1,24 @@ -export function evictOldestEntries( - map: Map, +export function evictOldestEntries(arr: T[], maxSize: number): void; +export function evictOldestEntries(map: Map, maxSize: number): void; + +export function evictOldestEntries( + collection: T[] | Map, 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; + } } } } diff --git a/packages/web/src/pages/Dashboard/index.tsx b/packages/web/src/pages/Dashboard/index.tsx index abf069f7..e075452b 100644 --- a/packages/web/src/pages/Dashboard/index.tsx +++ b/packages/web/src/pages/Dashboard/index.tsx @@ -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 (
@@ -29,7 +32,8 @@ export const Dashboard = () => {
- {devices.length ? ( + { + /*devices.length ? (
    {devices.map((device) => { const nodeDB = getNodeDB(device.id); @@ -69,8 +73,7 @@ export const Dashboard = () => { ); })}
- ) : ( -
+ ) : */
{t("dashboard.noDevicesTitle")} {t("dashboard.noDevicesDescription")} @@ -83,7 +86,7 @@ export const Dashboard = () => { {t("dashboard.button_newConnection")}
- )} + }
);