+
+ {user.longName} +
+ )} +{helper}
+ ) : null} ++ {t("addConnection.httpConnection.connectionTest.description")} +
+
+
{children}
);
diff --git a/packages/web/src/core/hooks/useLRUList.ts b/packages/web/src/core/hooks/useLRUList.ts
new file mode 100644
index 000000000..d28fb61b2
--- /dev/null
+++ b/packages/web/src/core/hooks/useLRUList.ts
@@ -0,0 +1,232 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+/**
+ * A minimal serializable wrapper so we can store metadata alongside items.
+ */
+type PersistedPayload = {
+ v: 1; // schema version
+ capacity: number; // last used capacity (for info/migrations)
+ items: J[]; // serialized items (MRU -> LRU)
+};
+
+export type UseLRUListOptions = {
+ /** localStorage key */
+ key: string;
+ /** max number of items to keep (>=1) */
+ capacity: number;
+ /** optional initial items used when storage is empty/invalid */
+ initial?: T[];
+ /** equality to de-duplicate items; default: Object.is */
+ eq?: (a: T, b: T) => boolean;
+ /** convert T -> JSON-safe type; default: (x) => x as unknown as J */
+ toJSON?: (t: T) => J;
+ /** convert JSON-safe type -> T; default: (x) => x as unknown as T */
+ fromJSON?: (j: J) => T;
+ /** storage impl (for tests); default: window.localStorage */
+ storage?: Storage;
+ /** listen to storage events and live-sync across tabs/windows; default: true */
+ syncTabs?: boolean;
+};
+
+export type UseLRUListReturn = {
+ /** Items ordered MRU -> LRU */
+ items: T[];
+ /** Add or "touch" an item (move to MRU); inserts if missing */
+ add: (item: T) => void;
+ /** Remove a matching item (no-op if missing) */
+ remove: (item: T) => void;
+ /** Clear all items */
+ clear: () => void;
+ /** Whether a matching item exists */
+ has: (item: T) => boolean;
+ /** Replace the entire list (applies LRU trimming) */
+ replaceAll: (next: T[]) => void;
+ /** Current capacity (for information) */
+ capacity: number;
+};
+
+/**
+ * useLRUList – maintains a most-recently-used list and persists it to localStorage.
+ *
+ * MRU is index 0. Adding an existing item "touches" it (moves to front).
+ */
+export function useLRUList(
+ opts: UseLRUListOptions,
+): UseLRUListReturn {
+ const {
+ key,
+ capacity,
+ initial = [],
+ eq = Object.is,
+ toJSON = (x: T) => x as unknown as J,
+ fromJSON = (x: J) => x as unknown as T,
+ storage = typeof window !== "undefined" ? window.localStorage : undefined,
+ syncTabs = true,
+ } = opts;
+
+ if (capacity < 1) {
+ // Fail fast in dev; silently coerce in prod
+ if (process.env.NODE_ENV !== "production") {
+ throw new Error("useLRUList: capacity must be >= 1");
+ }
+ }
+
+ const effectiveCapacity = Math.max(1, capacity);
+
+ // Guard against SSR or no-storage environments
+ const canPersist = !!storage && typeof storage.getItem === "function";
+
+ const readPersisted = useCallback((): T[] | null => {
+ if (!canPersist) {
+ return null;
+ }
+ try {
+ const raw = storage.getItem(key);
+ if (!raw) {
+ return null;
+ }
+ const parsed = JSON.parse(raw) as PersistedPayload;
+ if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.items)) {
+ return null;
+ }
+ const deserialized = parsed.items.map(fromJSON);
+ return trimToCapacity(deserialized, effectiveCapacity);
+ } catch {
+ return null;
+ }
+ }, [canPersist, storage, key, fromJSON, effectiveCapacity]);
+
+ const writePersisted = useCallback(
+ (items: T[]) => {
+ if (!canPersist) {
+ return;
+ }
+ try {
+ const payload: PersistedPayload = {
+ v: 1,
+ capacity: effectiveCapacity,
+ items: items.map(toJSON),
+ };
+ storage.setItem(key, JSON.stringify(payload));
+ } catch {
+ // Swallow quota/serialization errors; keep in-memory state working
+ }
+ },
+ [canPersist, storage, key, toJSON, effectiveCapacity],
+ );
+
+ // Initialize from storage (or fallback to `initial`)
+ const [items, setItems] = useState(
+ () => readPersisted() ?? trimToCapacity([...initial], effectiveCapacity),
+ );
+
+ // Keep a ref to avoid feedback loops when applying remote (storage event) updates
+ const applyingExternal = useRef(false);
+
+ // Persist on changes
+ useEffect(() => {
+ if (applyingExternal.current) {
+ applyingExternal.current = false;
+ return;
+ }
+ writePersisted(items);
+ }, [items, writePersisted]);
+
+ // Cross-tab synchronization via storage events
+ useEffect(() => {
+ if (!syncTabs || !canPersist || typeof window === "undefined") {
+ return;
+ }
+ const onStorage = (e: StorageEvent) => {
+ if (e.storageArea !== storage || e.key !== key) {
+ return;
+ }
+ // Another tab changed it; re-read safely
+ const next = readPersisted();
+ if (!next) {
+ return;
+ }
+ applyingExternal.current = true;
+ setItems(next);
+ };
+ window.addEventListener("storage", onStorage);
+ return () => window.removeEventListener("storage", onStorage);
+ }, [syncTabs, canPersist, storage, key, readPersisted]);
+
+ // Helpers
+ const indexOf = useCallback(
+ (arr: T[], needle: T) => arr.findIndex((x) => eq(x, needle)),
+ [eq],
+ );
+
+ const add = useCallback(
+ (item: T) => {
+ setItems((prev) => {
+ const idx = indexOf(prev, item);
+ if (idx === 0) {
+ return prev; // already MRU
+ }
+ if (idx > 0) {
+ const next = [...prev];
+ next.splice(idx, 1);
+ next.unshift(item);
+ return next;
+ }
+ // Not present: insert at MRU and trim
+ const next = [item, ...prev];
+ return trimToCapacity(next, effectiveCapacity);
+ });
+ },
+ [indexOf, effectiveCapacity],
+ );
+
+ const remove = useCallback(
+ (item: T) => {
+ setItems((prev) => {
+ const idx = indexOf(prev, item);
+ if (idx === -1) {
+ return prev;
+ }
+ const next = [...prev];
+ next.splice(idx, 1);
+ return next;
+ });
+ },
+ [indexOf],
+ );
+
+ const clear = useCallback(() => setItems([]), []);
+
+ const has = useCallback(
+ (item: T) => indexOf(items, item) !== -1,
+ [items, indexOf],
+ );
+
+ const replaceAll = useCallback(
+ (next: T[]) => setItems(trimToCapacity([...next], effectiveCapacity)),
+ [effectiveCapacity],
+ );
+
+ // Stable API shape
+ return useMemo(
+ () => ({
+ items,
+ add,
+ remove,
+ clear,
+ has,
+ replaceAll,
+ capacity: effectiveCapacity,
+ }),
+ [items, add, remove, clear, has, replaceAll, effectiveCapacity],
+ );
+}
+
+// --- utils ---
+
+function trimToCapacity(arr: T[], capacity: number): T[] {
+ if (arr.length <= capacity) {
+ return arr;
+ }
+ return arr.slice(0, capacity);
+}
diff --git a/packages/web/src/core/hooks/useLang.ts b/packages/web/src/core/hooks/useLang.ts
index 6cc7f748d..30282eeb3 100644
--- a/packages/web/src/core/hooks/useLang.ts
+++ b/packages/web/src/core/hooks/useLang.ts
@@ -50,6 +50,11 @@ function useLang() {
[i18n.language, i18n.changeLanguage, setLanguageInStorage],
);
+ const getSupportedLangs = useMemo(
+ () => supportedLanguages.toSorted((a, b) => a.name.localeCompare(b.name)),
+ [],
+ );
+
const compare = useCallback(
(a: string, b: string) => {
return collator.compare(a, b);
@@ -57,7 +62,7 @@ function useLang() {
[collator],
);
- return { compare, set, currentLanguage };
+ return { compare, set, current: currentLanguage, getSupportedLangs };
}
export default useLang;
diff --git a/packages/web/src/core/services/dev-overrides.ts b/packages/web/src/core/services/dev-overrides.ts
index 928c43a59..59c060de9 100644
--- a/packages/web/src/core/services/dev-overrides.ts
+++ b/packages/web/src/core/services/dev-overrides.ts
@@ -7,5 +7,6 @@ if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
persistMessages: true,
+ persistApp: true,
});
}
diff --git a/packages/web/src/core/services/featureFlags.ts b/packages/web/src/core/services/featureFlags.ts
index 645861b64..48e72e6a0 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 000000000..7ebe8ca0e
--- /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 1f342c291..72034911d 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 000000000..3b673c994
--- /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/changeRegistry.ts b/packages/web/src/core/stores/deviceStore/changeRegistry.ts
new file mode 100644
index 000000000..844ed8db7
--- /dev/null
+++ b/packages/web/src/core/stores/deviceStore/changeRegistry.ts
@@ -0,0 +1,260 @@
+import type { Types } from "@meshtastic/core";
+
+// Config type discriminators
+export type ValidConfigType =
+ | "device"
+ | "position"
+ | "power"
+ | "network"
+ | "display"
+ | "lora"
+ | "bluetooth"
+ | "security";
+
+export type ValidModuleConfigType =
+ | "mqtt"
+ | "serial"
+ | "externalNotification"
+ | "storeForward"
+ | "rangeTest"
+ | "telemetry"
+ | "cannedMessage"
+ | "audio"
+ | "neighborInfo"
+ | "ambientLighting"
+ | "detectionSensor"
+ | "paxcounter";
+
+// Admin message types that can be queued
+export type ValidAdminMessageType = "setFixedPosition" | "other";
+
+// Unified config change key type
+export type ConfigChangeKey =
+ | { type: "config"; variant: ValidConfigType }
+ | { type: "moduleConfig"; variant: ValidModuleConfigType }
+ | { type: "channel"; index: Types.ChannelNumber }
+ | { type: "user" }
+ | { type: "adminMessage"; variant: ValidAdminMessageType; id: string };
+
+// Serialized key for Map storage
+export type ConfigChangeKeyString = string;
+
+// Registry entry
+export interface ChangeEntry {
+ key: ConfigChangeKey;
+ value: unknown;
+ timestamp: number;
+ originalValue?: unknown;
+}
+
+// The unified registry
+export interface ChangeRegistry {
+ changes: Map;
+}
+
+/**
+ * Convert structured key to string for Map lookup
+ */
+export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
+ switch (key.type) {
+ case "config":
+ return `config:${key.variant}`;
+ case "moduleConfig":
+ return `moduleConfig:${key.variant}`;
+ case "channel":
+ return `channel:${key.index}`;
+ case "user":
+ return "user";
+ case "adminMessage":
+ return `adminMessage:${key.variant}:${key.id}`;
+ }
+}
+
+/**
+ * Reverse operation for type-safe retrieval
+ */
+export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
+ const parts = keyStr.split(":");
+ const type = parts[0];
+
+ switch (type) {
+ case "config":
+ return { type: "config", variant: parts[1] as ValidConfigType };
+ case "moduleConfig":
+ return {
+ type: "moduleConfig",
+ variant: parts[1] as ValidModuleConfigType,
+ };
+ case "channel":
+ return {
+ type: "channel",
+ index: Number(parts[1]) as Types.ChannelNumber,
+ };
+ case "user":
+ return { type: "user" };
+ case "adminMessage":
+ return {
+ type: "adminMessage",
+ variant: parts[1] as ValidAdminMessageType,
+ id: parts[2] ?? "",
+ };
+ default:
+ throw new Error(`Unknown key type: ${type}`);
+ }
+}
+
+/**
+ * Create an empty change registry
+ */
+export function createChangeRegistry(): ChangeRegistry {
+ return {
+ changes: new Map(),
+ };
+}
+
+/**
+ * Check if a config variant has changes
+ */
+export function hasConfigChange(
+ registry: ChangeRegistry,
+ variant: ValidConfigType,
+): boolean {
+ return registry.changes.has(serializeKey({ type: "config", variant }));
+}
+
+/**
+ * Check if a module config variant has changes
+ */
+export function hasModuleConfigChange(
+ registry: ChangeRegistry,
+ variant: ValidModuleConfigType,
+): boolean {
+ return registry.changes.has(serializeKey({ type: "moduleConfig", variant }));
+}
+
+/**
+ * Check if a channel has changes
+ */
+export function hasChannelChange(
+ registry: ChangeRegistry,
+ index: Types.ChannelNumber,
+): boolean {
+ return registry.changes.has(serializeKey({ type: "channel", index }));
+}
+
+/**
+ * Check if user config has changes
+ */
+export function hasUserChange(registry: ChangeRegistry): boolean {
+ return registry.changes.has(serializeKey({ type: "user" }));
+}
+
+/**
+ * Get count of config changes
+ */
+export function getConfigChangeCount(registry: ChangeRegistry): number {
+ let count = 0;
+ for (const keyStr of registry.changes.keys()) {
+ const key = deserializeKey(keyStr);
+ if (key.type === "config") {
+ count++;
+ }
+ }
+ return count;
+}
+
+/**
+ * Get count of module config changes
+ */
+export function getModuleConfigChangeCount(registry: ChangeRegistry): number {
+ let count = 0;
+ for (const keyStr of registry.changes.keys()) {
+ const key = deserializeKey(keyStr);
+ if (key.type === "moduleConfig") {
+ count++;
+ }
+ }
+ return count;
+}
+
+/**
+ * Get count of channel changes
+ */
+export function getChannelChangeCount(registry: ChangeRegistry): number {
+ let count = 0;
+ for (const keyStr of registry.changes.keys()) {
+ const key = deserializeKey(keyStr);
+ if (key.type === "channel") {
+ count++;
+ }
+ }
+ return count;
+}
+
+/**
+ * Get all config changes as an array
+ */
+export function getAllConfigChanges(registry: ChangeRegistry): ChangeEntry[] {
+ const changes: ChangeEntry[] = [];
+ for (const entry of registry.changes.values()) {
+ if (entry.key.type === "config") {
+ changes.push(entry);
+ }
+ }
+ return changes;
+}
+
+/**
+ * Get all module config changes as an array
+ */
+export function getAllModuleConfigChanges(
+ registry: ChangeRegistry,
+): ChangeEntry[] {
+ const changes: ChangeEntry[] = [];
+ for (const entry of registry.changes.values()) {
+ if (entry.key.type === "moduleConfig") {
+ changes.push(entry);
+ }
+ }
+ return changes;
+}
+
+/**
+ * Get all channel changes as an array
+ */
+export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
+ const changes: ChangeEntry[] = [];
+ for (const entry of registry.changes.values()) {
+ if (entry.key.type === "channel") {
+ changes.push(entry);
+ }
+ }
+ return changes;
+}
+
+/**
+ * Get all admin message changes as an array
+ */
+export function getAllAdminMessages(registry: ChangeRegistry): ChangeEntry[] {
+ const changes: ChangeEntry[] = [];
+ for (const entry of registry.changes.values()) {
+ if (entry.key.type === "adminMessage") {
+ changes.push(entry);
+ }
+ }
+ return changes;
+}
+
+/**
+ * Get count of admin message changes
+ */
+export function getAdminMessageChangeCount(registry: ChangeRegistry): number {
+ let count = 0;
+ for (const keyStr of registry.changes.keys()) {
+ const key = deserializeKey(keyStr);
+ if (key.type === "adminMessage") {
+ count++;
+ }
+ }
+ return count;
+}
diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
index 06040336b..eb67f7bf1 100644
--- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
+++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
@@ -14,13 +14,12 @@ 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,
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
- workingConfig: [],
- workingModuleConfig: [],
- workingChannelConfig: [],
+ changeRegistry: { changes: new Map() },
hardware: {} as Protobuf.Mesh.MyNodeInfo,
metadata: new Map(),
traceroutes: new Map(),
@@ -44,28 +43,26 @@ 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(),
setModuleConfig: vi.fn(),
- setWorkingConfig: vi.fn(),
- setWorkingModuleConfig: vi.fn(),
- getWorkingConfig: vi.fn(),
- getWorkingModuleConfig: vi.fn(),
- removeWorkingConfig: vi.fn(),
- removeWorkingModuleConfig: vi.fn(),
getEffectiveConfig: vi.fn(),
getEffectiveModuleConfig: vi.fn(),
- setWorkingChannelConfig: vi.fn(),
- getWorkingChannelConfig: vi.fn(),
- removeWorkingChannelConfig: vi.fn(),
setHardware: vi.fn(),
setActiveNode: vi.fn(),
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 +77,23 @@ export const mockDeviceStore: Device = {
getClientNotification: vi.fn(),
getAllUnreadCount: vi.fn().mockReturnValue(0),
getUnreadCount: vi.fn().mockReturnValue(0),
+ getNeighborInfo: vi.fn(),
+ addNeighborInfo: vi.fn(),
+
+ // New unified change tracking methods
+ setChange: vi.fn(),
+ removeChange: vi.fn(),
+ hasChange: vi.fn().mockReturnValue(false),
+ getChange: vi.fn(),
+ clearAllChanges: vi.fn(),
+ hasConfigChange: vi.fn().mockReturnValue(false),
+ hasModuleConfigChange: vi.fn().mockReturnValue(false),
+ hasChannelChange: vi.fn().mockReturnValue(false),
+ hasUserChange: vi.fn().mockReturnValue(false),
+ getConfigChangeCount: vi.fn().mockReturnValue(0),
+ getModuleConfigChangeCount: vi.fn().mockReturnValue(0),
+ getChannelChangeCount: vi.fn().mockReturnValue(0),
+ getAllConfigChanges: vi.fn().mockReturnValue([]),
+ getAllModuleConfigChanges: vi.fn().mockReturnValue([]),
+ getAllChannelChanges: vi.fn().mockReturnValue([]),
};
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 000000000..f7644bdd4
--- /dev/null
+++ b/packages/web/src/core/stores/deviceStore/deviceStore.test.ts
@@ -0,0 +1,523 @@
+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 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 – change registry API", () => {
+ beforeEach(() => {
+ idbMem.clear();
+ vi.clearAllMocks();
+ });
+
+ it("setChange/hasChange/getChange for config 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
+ const routerConfig = create(Protobuf.Config.Config_DeviceConfigSchema, {
+ role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
+ });
+ device.setChange({ type: "config", variant: "device" }, routerConfig);
+
+ // expect change is tracked
+ expect(device.hasConfigChange("device")).toBe(true);
+ const working = device.getChange({
+ type: "config",
+ variant: "device",
+ }) as Protobuf.Config.Config_DeviceConfig;
+ 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 change, effective should equal base
+ device.removeChange({ type: "config", variant: "device" });
+ expect(device.hasConfigChange("device")).toBe(false);
+ expect(device.getEffectiveConfig("device")?.role).toBe(
+ Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
+ );
+
+ // add multiple, then clear all
+ device.setChange({ type: "config", variant: "device" }, routerConfig);
+ device.setChange({ type: "config", variant: "position" }, {});
+ device.clearAllChanges();
+ expect(device.hasConfigChange("device")).toBe(false);
+ expect(device.hasConfigChange("position")).toBe(false);
+ });
+
+ it("setChange/hasChange for moduleConfig and getEffectiveModuleConfig", async () => {
+ const { useDeviceStore } = await freshStore(false);
+ const state = useDeviceStore.getState();
+ const device = state.addDevice(7);
+
+ // base moduleConfig.mqtt with base address
+ device.setModuleConfig(
+ create(Protobuf.ModuleConfig.ModuleConfigSchema, {
+ payloadVariant: {
+ case: "mqtt",
+ value: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, {
+ address: "mqtt://base",
+ }),
+ },
+ }),
+ );
+
+ // working mqtt config
+ const workingMqtt = create(
+ Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema,
+ { address: "mqtt://working" },
+ );
+ device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt);
+
+ expect(device.hasModuleConfigChange("mqtt")).toBe(true);
+ const mqtt = device.getChange({
+ type: "moduleConfig",
+ variant: "mqtt",
+ }) as Protobuf.ModuleConfig.ModuleConfig_MQTTConfig;
+ expect(mqtt?.address).toBe("mqtt://working");
+
+ // effective should return working value
+ expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe(
+ "mqtt://working",
+ );
+
+ // remove change
+ device.removeChange({ type: "moduleConfig", variant: "mqtt" });
+ expect(device.hasModuleConfigChange("mqtt")).toBe(false);
+ expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe(
+ "mqtt://base",
+ );
+
+ // Clear all
+ device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt);
+ device.clearAllChanges();
+ expect(device.hasModuleConfigChange("mqtt")).toBe(false);
+ });
+
+ it("channel change tracking add/update/remove/get", async () => {
+ const { useDeviceStore } = await freshStore(false);
+ const state = useDeviceStore.getState();
+ const device = state.addDevice(9);
+
+ const channel0 = makeChannel(0);
+ const channel1 = create(Protobuf.Channel.ChannelSchema, {
+ index: 1,
+ settings: { name: "one" },
+ });
+
+ device.setChange({ type: "channel", index: 0 }, channel0);
+ device.setChange({ type: "channel", index: 1 }, channel1);
+
+ expect(device.hasChannelChange(0)).toBe(true);
+ expect(device.hasChannelChange(1)).toBe(true);
+ const ch0 = device.getChange({ type: "channel", index: 0 }) as
+ | Protobuf.Channel.Channel
+ | undefined;
+ expect(ch0?.index).toBe(0);
+ const ch1 = device.getChange({ type: "channel", index: 1 }) as
+ | Protobuf.Channel.Channel
+ | undefined;
+ expect(ch1?.settings?.name).toBe("one");
+
+ // update channel 1
+ const channel1Updated = create(Protobuf.Channel.ChannelSchema, {
+ index: 1,
+ settings: { name: "uno" },
+ });
+ device.setChange({ type: "channel", index: 1 }, channel1Updated);
+ const ch1Updated = device.getChange({ type: "channel", index: 1 }) as
+ | Protobuf.Channel.Channel
+ | undefined;
+ expect(ch1Updated?.settings?.name).toBe("uno");
+
+ // remove specific
+ device.removeChange({ type: "channel", index: 1 });
+ expect(device.hasChannelChange(1)).toBe(false);
+
+ // remove all
+ device.clearAllChanges();
+ expect(device.hasChannelChange(0)).toBe(false);
+ });
+});
+
+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);
+ const mockSendWaypoint = vi.fn();
+ oldDevice.addConnection({ sendWaypoint: mockSendWaypoint } 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(mockSendWaypoint).not.toHaveBeenCalled();
+
+ await oldDevice.removeWaypoint(101, true); // toMesh=true
+ expect(mockSendWaypoint).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 e83547067..789ecb2d0 100644
--- a/packages/web/src/core/stores/deviceStore/index.ts
+++ b/packages/web/src/core/stores/deviceStore/index.ts
@@ -1,110 +1,95 @@
import { create, toBinary } from "@bufbuild/protobuf";
+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";
+import { create as createStore, type StateCreator } from "zustand";
+import {
+ type PersistOptions,
+ persist,
+ subscribeWithSelector,
+} from "zustand/middleware";
+import type { ChangeRegistry, ConfigChangeKey } from "./changeRegistry.ts";
+import {
+ createChangeRegistry,
+ getAdminMessageChangeCount,
+ getAllAdminMessages,
+ getAllChannelChanges,
+ getAllConfigChanges,
+ getAllModuleConfigChanges,
+ getChannelChangeCount,
+ getConfigChangeCount,
+ getModuleConfigChangeCount,
+ hasChannelChange,
+ hasConfigChange,
+ hasModuleConfigChange,
+ hasUserChange,
+ serializeKey,
+} from "./changeRegistry.ts";
+import type {
+ Connection,
+ ConnectionId,
+ Dialogs,
+ DialogVariant,
+ ValidConfigType,
+ ValidModuleConfigType,
+ WaypointWithMetadata,
+} from "./types.ts";
-export type Page = "messages" | "map" | "config" | "channels" | "nodes";
+const IDB_KEY_NAME = "meshtastic-device-store";
+const CURRENT_STORE_VERSION = 0;
+const DEVICESTORE_RETENTION_NUM = 10;
+const TRACEROUTE_TARGET_RETENTION_NUM = 100; // Number of traceroutes targets to keep
+const TRACEROUTE_ROUTE_RETENTION_NUM = 100; // Number of traceroutes to keep per target
+const WAYPOINT_RETENTION_NUM = 100;
-export interface ProcessPacketParams {
- 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
- };
+type DeviceData = {
+ // Persisted data
+ id: number;
+ myNodeNum: number | undefined;
+ traceroutes: Map<
+ number,
+ Types.PacketMetadata[]
+ >;
+ waypoints: WaypointWithMetadata[];
+ neighborInfo: Map;
};
+export type ConnectionPhase =
+ | "disconnected"
+ | "connecting"
+ | "configuring"
+ | "configured";
-export interface Device {
- id: number;
+export interface Device extends DeviceData {
+ // Ephemeral state (not persisted)
status: Types.DeviceStatusEnum;
+ connectionPhase: ConnectionPhase;
+ connectionId: ConnectionId | null;
channels: Map;
config: Protobuf.LocalOnly.LocalConfig;
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
- workingConfig: Protobuf.Config.Config[];
- workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
- workingChannelConfig: Protobuf.Channel.Channel[];
+ changeRegistry: ChangeRegistry; // Unified change tracking
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;
+ setConnectionPhase: (phase: ConnectionPhase) => void;
+ setConnectionId: (id: ConnectionId | null) => void;
setConfig: (config: Protobuf.Config.Config) => void;
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;
- removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
- removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
getEffectiveConfig(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined;
getEffectiveModuleConfig(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
- setWorkingChannelConfig: (channelNum: Protobuf.Channel.Channel) => void;
- getWorkingChannelConfig: (
- index: Types.ChannelNumber,
- ) => Protobuf.Channel.Channel | undefined;
- removeWorkingChannelConfig: (channelNum?: Types.ChannelNumber) => void;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void;
@@ -115,6 +100,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,
@@ -140,661 +127,1036 @@ export interface Device {
neighborInfo: Protobuf.Mesh.NeighborInfo,
) => void;
getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined;
+
+ // New unified change tracking methods
+ setChange: (
+ key: ConfigChangeKey,
+ value: unknown,
+ originalValue?: unknown,
+ ) => void;
+ removeChange: (key: ConfigChangeKey) => void;
+ hasChange: (key: ConfigChangeKey) => boolean;
+ getChange: (key: ConfigChangeKey) => unknown | undefined;
+ clearAllChanges: () => void;
+ hasConfigChange: (variant: ValidConfigType) => boolean;
+ hasModuleConfigChange: (variant: ValidModuleConfigType) => boolean;
+ hasChannelChange: (index: Types.ChannelNumber) => boolean;
+ hasUserChange: () => boolean;
+ getConfigChangeCount: () => number;
+ getModuleConfigChangeCount: () => number;
+ getChannelChangeCount: () => number;
+ getAllConfigChanges: () => Protobuf.Config.Config[];
+ getAllModuleConfigChanges: () => Protobuf.ModuleConfig.ModuleConfig[];
+ getAllChannelChanges: () => Protobuf.Channel.Channel[];
+ queueAdminMessage: (message: Protobuf.Admin.AdminMessage) => void;
+ getAllQueuedAdminMessages: () => Protobuf.Admin.AdminMessage[];
+ getAdminMessageChangeCount: () => number;
}
-export interface DeviceState {
+export interface deviceState {
addDevice: (id: number) => Device;
removeDevice: (id: number) => void;
getDevices: () => Device[];
getDevice: (id: number) => Device | undefined;
+
+ // Saved connections management
+ savedConnections: Connection[];
+ addSavedConnection: (connection: Connection) => void;
+ updateSavedConnection: (
+ id: ConnectionId,
+ updates: Partial,
+ ) => void;
+ removeSavedConnection: (id: ConnectionId) => void;
+ getSavedConnections: () => Connection[];
+
+ // Active connection tracking
+ activeConnectionId: ConnectionId | null;
+ setActiveConnectionId: (id: ConnectionId | null) => void;
+ getActiveConnectionId: () => ConnectionId | null;
+
+ // Helper selectors for connection ↔ device relationships
+ getActiveConnection: () => Connection | undefined;
+ getDeviceForConnection: (id: ConnectionId) => Device | undefined;
+ getConnectionForDevice: (deviceId: number) => Connection | 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(),
+type DevicePersisted = {
+ devices: Map;
+ savedConnections: Connection[];
+};
- 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);
- }
- }),
- );
- },
+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,
- getWorkingConfig: (payloadVariant: ValidConfigType) => {
- const device = get().devices.get(id);
- if (!device) {
- return;
+ status: Types.DeviceStatusEnum.DeviceDisconnected,
+ connectionPhase: "disconnected",
+ connectionId: null,
+ channels: new Map(),
+ config: create(Protobuf.LocalOnly.LocalConfigSchema),
+ moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
+ changeRegistry: createChangeRegistry(),
+ 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;
+ }
+ }),
+ );
+ },
+ setConnectionPhase: (phase: ConnectionPhase) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (device) {
+ device.connectionPhase = phase;
+ }
+ }),
+ );
+ },
+ setConnectionId: (connectionId: ConnectionId | null) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (device) {
+ device.connectionId = connectionId;
+ }
+ }),
+ );
+ },
+ 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;
+ }
}
+ }
+ }),
+ );
+ },
+ getEffectiveConfig(
+ payloadVariant: K,
+ ): Protobuf.LocalOnly.LocalConfig[K] | undefined {
+ if (!payloadVariant) {
+ return;
+ }
+ const device = get().devices.get(id);
+ if (!device) {
+ return;
+ }
- const workingConfig = device.workingConfig.find(
- (c) => c.payloadVariant.case === payloadVariant,
- );
+ const workingValue = device.changeRegistry.changes.get(
+ serializeKey({ type: "config", variant: payloadVariant }),
+ )?.value as Protobuf.LocalOnly.LocalConfig[K] | undefined;
- if (
- workingConfig?.payloadVariant.case === "deviceUi" ||
- workingConfig?.payloadVariant.case === "sessionkey"
- ) {
- return;
- }
+ return {
+ ...device.config[payloadVariant],
+ ...workingValue,
+ };
+ },
+ getEffectiveModuleConfig(
+ payloadVariant: K,
+ ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined {
+ const device = get().devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ const workingValue = device.changeRegistry.changes.get(
+ serializeKey({ type: "moduleConfig", variant: payloadVariant }),
+ )?.value as Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
+
+ return {
+ ...device.moduleConfig[payloadVariant],
+ ...workingValue,
+ };
+ },
- return workingConfig?.payloadVariant.value;
- },
- getWorkingModuleConfig: (payloadVariant: ValidModuleConfigType) => {
- const device = get().devices.get(id);
- if (!device) {
- return;
+ 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);
- }
- }),
- );
- },
- 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);
- }
- }),
+ // Take this opportunity to remove stale waypoints
+ newDevice.waypoints = oldStore.waypoints.filter(
+ (waypoint) => !waypoint?.expire || waypoint.expire > Date.now(),
);
- },
- getEffectiveConfig(
- payloadVariant: K,
- ): Protobuf.LocalOnly.LocalConfig[K] | undefined {
- if (!payloadVariant) {
- return;
- }
- const device = get().devices.get(id);
- if (!device) {
- return;
- }
+ // Drop old device
+ draft.devices.delete(otherId);
+ }
- 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;
- }
+ 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,
+ );
- return {
- ...device.moduleConfig[payloadVariant],
- ...device.workingModuleConfig.find(
- (c) => c.payloadVariant.case === payloadVariant,
- )?.payloadVariant.value,
+ if (index !== -1) {
+ const created =
+ device.waypoints[index]?.metadata.created ?? new Date();
+ const updatedWaypoint = {
+ ...waypoint,
+ metadata: { created, updated: rxTime, from, channel },
};
- },
-
- 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,
- );
+ // Remove existing waypoint
+ device.waypoints.splice(index, 1);
- 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`);
+ // Push new if no expiry or not expired
+ if (waypoint.expire === 0 || waypoint.expire > Date.now()) {
+ device.waypoints.push(updatedWaypoint);
}
- 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;
+ } 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);
- }
- }),
- );
- },
+ }
- sendAdminMessage(message: Protobuf.Admin.AdminMessage) {
- const device = get().devices.get(id);
- if (!device) {
- return;
- }
+ // Enforce retention limit
+ evictOldestEntries(device.waypoints, WAYPOINT_RETENTION_NUM);
+ }),
+ );
+ },
+ removeWaypoint: async (waypointId: number, toMesh: boolean) => {
+ 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);
- }),
- );
- },
+ const waypoint = device.waypoints.find((wp) => wp.id === waypointId);
+ if (!waypoint) {
+ return;
+ }
- getNeighborInfo: (nodeNum: number) => {
- const device = get().devices.get(id);
- if (!device) {
- return;
- }
- return device.neighborInfo.get(nodeNum);
- },
+ if (toMesh) {
+ if (!device.connection) {
+ return;
+ }
+
+ const waypointToBroadcast = create(Protobuf.Mesh.WaypointSchema, {
+ id: waypoint.id, // Bare minimum to delete a waypoint
+ lockedTo: 0,
+ name: "",
+ description: "",
+ icon: 0,
+ expire: 1,
});
+
+ await device.connection.sendWaypoint(
+ waypointToBroadcast,
+ "broadcast",
+ waypoint.metadata.channel,
+ );
+ }
+
+ // Remove from store
+ set(
+ produce((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);
+ },
+
+ // New unified change tracking methods
+ setChange: (
+ key: ConfigChangeKey,
+ value: unknown,
+ originalValue?: unknown,
+ ) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ const keyStr = serializeKey(key);
+ device.changeRegistry.changes.set(keyStr, {
+ key,
+ value,
+ originalValue,
+ timestamp: Date.now(),
+ });
+ }),
+ );
+ },
+
+ removeChange: (key: ConfigChangeKey) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ device.changeRegistry.changes.delete(serializeKey(key));
+ }),
+ );
+ },
+
+ hasChange: (key: ConfigChangeKey) => {
+ const device = get().devices.get(id);
+ return device?.changeRegistry.changes.has(serializeKey(key)) ?? false;
+ },
+
+ getChange: (key: ConfigChangeKey) => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ return device.changeRegistry.changes.get(serializeKey(key))?.value;
+ },
+
+ clearAllChanges: () => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ device.changeRegistry.changes.clear();
+ }),
+ );
+ },
+
+ hasConfigChange: (variant: ValidConfigType) => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return false;
+ }
+
+ return hasConfigChange(device.changeRegistry, variant);
+ },
+
+ hasModuleConfigChange: (variant: ValidModuleConfigType) => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return false;
+ }
+
+ return hasModuleConfigChange(device.changeRegistry, variant);
+ },
+
+ hasChannelChange: (index: Types.ChannelNumber) => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return false;
+ }
+
+ return hasChannelChange(device.changeRegistry, index);
+ },
+
+ hasUserChange: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return false;
+ }
+
+ return hasUserChange(device.changeRegistry);
+ },
+
+ getConfigChangeCount: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return 0;
+ }
+
+ return getConfigChangeCount(device.changeRegistry);
+ },
+
+ getModuleConfigChangeCount: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return 0;
+ }
+
+ return getModuleConfigChangeCount(device.changeRegistry);
+ },
+
+ getChannelChangeCount: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return 0;
+ }
+
+ return getChannelChangeCount(device.changeRegistry);
+ },
+
+ getAllConfigChanges: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return [];
+ }
+
+ const changes = getAllConfigChanges(device.changeRegistry);
+ return changes
+ .map((entry) => {
+ if (entry.key.type !== "config") {
+ return null;
+ }
+ if (!entry.value) {
+ return null;
+ }
+ return create(Protobuf.Config.ConfigSchema, {
+ payloadVariant: {
+ case: entry.key.variant,
+ value: entry.value,
+ },
+ });
+ })
+ .filter((c): c is Protobuf.Config.Config => c !== null);
+ },
+
+ getAllModuleConfigChanges: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return [];
+ }
+
+ const changes = getAllModuleConfigChanges(device.changeRegistry);
+ return changes
+ .map((entry) => {
+ if (entry.key.type !== "moduleConfig") {
+ return null;
+ }
+ if (!entry.value) {
+ return null;
+ }
+ return create(Protobuf.ModuleConfig.ModuleConfigSchema, {
+ payloadVariant: {
+ case: entry.key.variant,
+ value: entry.value,
+ },
+ });
+ })
+ .filter((c): c is Protobuf.ModuleConfig.ModuleConfig => c !== null);
+ },
+
+ getAllChannelChanges: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return [];
+ }
+
+ const changes = getAllChannelChanges(device.changeRegistry);
+ return changes
+ .map((entry) => entry.value as Protobuf.Channel.Channel)
+ .filter((c): c is Protobuf.Channel.Channel => c !== undefined);
+ },
+
+ queueAdminMessage: (message: Protobuf.Admin.AdminMessage) => {
+ // Generate a unique ID for this admin message
+ const messageId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
+
+ // Determine the variant type
+ const variant =
+ message.payloadVariant.case === "setFixedPosition"
+ ? "setFixedPosition"
+ : "other";
+
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ const keyStr = serializeKey({
+ type: "adminMessage",
+ variant,
+ id: messageId,
+ });
+
+ device.changeRegistry.changes.set(keyStr, {
+ key: { type: "adminMessage", variant, id: messageId },
+ value: message,
+ timestamp: Date.now(),
+ });
+ }),
+ );
+ },
+
+ getAllQueuedAdminMessages: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return [];
+ }
+
+ const changes = getAllAdminMessages(device.changeRegistry);
+ return changes
+ .map((entry) => entry.value as Protobuf.Admin.AdminMessage)
+ .filter((m): m is Protobuf.Admin.AdminMessage => m !== undefined);
+ },
+
+ getAdminMessageChangeCount: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return 0;
+ }
+
+ return getAdminMessageChangeCount(device.changeRegistry);
+ },
+ };
+}
+
+export const deviceStoreInitializer: StateCreator = (
+ set,
+ get,
+) => ({
+ devices: new Map(),
+ savedConnections: [],
+ activeConnectionId: null,
+
+ 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),
-}));
+
+ addSavedConnection: (connection) => {
+ set(
+ produce((draft) => {
+ draft.savedConnections.push(connection);
+ }),
+ );
+ },
+ updateSavedConnection: (id, updates) => {
+ set(
+ produce((draft) => {
+ const conn = draft.savedConnections.find(
+ (c: Connection) => c.id === id,
+ );
+ if (conn) {
+ for (const key in updates) {
+ if (Object.hasOwn(updates, key)) {
+ (conn as Record)[key] =
+ updates[key as keyof typeof updates];
+ }
+ }
+ }
+ }),
+ );
+ },
+ removeSavedConnection: (id) => {
+ set(
+ produce((draft) => {
+ draft.savedConnections = draft.savedConnections.filter(
+ (c: Connection) => c.id !== id,
+ );
+ }),
+ );
+ },
+ getSavedConnections: () => get().savedConnections,
+
+ setActiveConnectionId: (id) => {
+ set(
+ produce((draft) => {
+ draft.activeConnectionId = id;
+ }),
+ );
+ },
+ getActiveConnectionId: () => get().activeConnectionId,
+
+ getActiveConnection: () => {
+ const activeId = get().activeConnectionId;
+ if (!activeId) {
+ return undefined;
+ }
+ return get().savedConnections.find((c) => c.id === activeId);
+ },
+ getDeviceForConnection: (id) => {
+ const connection = get().savedConnections.find((c) => c.id === id);
+ if (!connection?.meshDeviceId) {
+ return undefined;
+ }
+ return get().devices.get(connection.meshDeviceId);
+ },
+ getConnectionForDevice: (deviceId) => {
+ return get().savedConnections.find((c) => c.meshDeviceId === deviceId);
+ },
+});
+
+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,
+ },
+ ]),
+ ),
+ savedConnections: s.savedConnections,
+ }),
+ 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;
+ }),
+ );
+ },
+};
+
+export const useDeviceStore = createStore(
+ subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
+);
diff --git a/packages/web/src/core/stores/deviceStore/selectors.ts b/packages/web/src/core/stores/deviceStore/selectors.ts
new file mode 100644
index 000000000..7a7852700
--- /dev/null
+++ b/packages/web/src/core/stores/deviceStore/selectors.ts
@@ -0,0 +1,109 @@
+import type { Device } from "./index.ts";
+import { useDeviceStore } from "./index.ts";
+import type { Connection, ConnectionId } from "./types.ts";
+
+/**
+ * Hook to get the currently active connection
+ */
+export function useActiveConnection(): Connection | undefined {
+ return useDeviceStore((s) => s.getActiveConnection());
+}
+
+/**
+ * Hook to get the HTTP connection marked as default
+ */
+export function useDefaultConnection(): Connection | undefined {
+ return useDeviceStore((s) => s.savedConnections.find((c) => c.isDefault));
+}
+
+/**
+ * Hook to get the first saved connection
+ */
+export function useFirstSavedConnection(): Connection | undefined {
+ return useDeviceStore((s) => s.savedConnections.at(0));
+}
+
+export function useAddSavedConnection() {
+ return useDeviceStore((s) => s.addSavedConnection);
+}
+
+export function useUpdateSavedConnection() {
+ return useDeviceStore((s) => s.updateSavedConnection);
+}
+
+export function useRemoveSavedConnection() {
+ return useDeviceStore((s) => s.removeSavedConnection);
+}
+
+/**
+ * Hook to get the active connection ID
+ */
+export function useActiveConnectionId(): ConnectionId | null {
+ return useDeviceStore((s) => s.activeConnectionId);
+}
+
+export function useSetActiveConnectionId() {
+ return useDeviceStore((s) => s.setActiveConnectionId);
+}
+
+/**
+ * Hook to get a specific connection's status
+ */
+export function useConnectionStatus(id: ConnectionId): string | undefined {
+ return useDeviceStore(
+ (s) => s.savedConnections.find((c) => c.id === id)?.status,
+ );
+}
+
+/**
+ * Hook to get a device for a specific connection
+ */
+export function useDeviceForConnection(id: ConnectionId): Device | undefined {
+ return useDeviceStore((s) => s.getDeviceForConnection(id));
+}
+
+/**
+ * Hook to get a connection for a specific device
+ */
+export function useConnectionForDevice(
+ deviceId: number,
+): Connection | undefined {
+ return useDeviceStore((s) => s.getConnectionForDevice(deviceId));
+}
+
+/**
+ * Hook to check if any connection is currently connecting
+ */
+export function useIsConnecting(): boolean {
+ return useDeviceStore((s) =>
+ s.savedConnections.some(
+ (c) => c.status === "connecting" || c.status === "configuring",
+ ),
+ );
+}
+
+/**
+ * Hook to get error message for a specific connection
+ */
+export function useConnectionError(id: ConnectionId): string | null {
+ return useDeviceStore(
+ (s) => s.savedConnections.find((c) => c.id === id)?.error ?? null,
+ );
+}
+
+/**
+ * Hook to get all saved connections
+ */
+export function useSavedConnections(): Connection[] {
+ return useDeviceStore((s) => s.savedConnections);
+}
+
+/**
+ * Hook to check if a connection is connected
+ */
+export function useIsConnected(id: ConnectionId): boolean {
+ return useDeviceStore((s) => {
+ const status = s.savedConnections.find((c) => c.id === id)?.status;
+ return status === "connected" || status === "configured";
+ });
+}
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 000000000..484f3b315
--- /dev/null
+++ b/packages/web/src/core/stores/deviceStore/types.ts
@@ -0,0 +1,87 @@
+import type { Protobuf } from "@meshtastic/core";
+import type {
+ ValidConfigType,
+ ValidModuleConfigType,
+} from "./changeRegistry.ts";
+
+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 Page = "messages" | "map" | "settings" | "channels" | "nodes";
+
+export type ConnectionId = number;
+export type ConnectionType = "http" | "bluetooth" | "serial";
+export type ConnectionStatus =
+ | "connected"
+ | "connecting"
+ | "disconnected"
+ | "disconnecting"
+ | "configuring"
+ | "configured"
+ | "online"
+ | "error";
+
+export type Connection = {
+ id: ConnectionId;
+ type: ConnectionType;
+ name: string;
+ createdAt: number;
+ lastConnectedAt?: number;
+ isDefault?: boolean;
+ status: ConnectionStatus;
+ error?: string;
+ meshDeviceId?: number;
+} & NewConnection;
+
+export type NewConnection =
+ | { type: "http"; name: string; url: string }
+ | {
+ type: "bluetooth";
+ name: string;
+ deviceId?: string;
+ deviceName?: string;
+ gattServiceUUID?: string;
+ }
+ | {
+ type: "serial";
+ name: string;
+ usbVendorId?: number;
+ usbProductId?: number;
+ };
+
+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 4cf74c94f..2d3428409 100644
--- a/packages/web/src/core/stores/index.ts
+++ b/packages/web/src/core/stores/index.ts
@@ -1,35 +1,53 @@
-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 { useAppStore } from "@core/stores/appStore/index.ts";
+export { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
export {
- type Device,
- type Page,
- useDeviceStore,
- type ValidConfigType,
- type ValidModuleConfigType,
- type WaypointWithMetadata,
-} from "@core/stores/deviceStore";
+ useActiveConnection,
+ useActiveConnectionId,
+ useAddSavedConnection,
+ useConnectionError,
+ useConnectionForDevice,
+ useConnectionStatus,
+ useDefaultConnection,
+ useDeviceForConnection,
+ useFirstSavedConnection,
+ useIsConnected,
+ useIsConnecting,
+ useRemoveSavedConnection,
+ useSavedConnections,
+ useUpdateSavedConnection,
+} from "@core/stores/deviceStore/selectors.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 3c86ac40f..ffc176714 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 14bc40a6a..632cc7573 100644
--- a/packages/web/src/core/stores/nodeDBStore/index.ts
+++ b/packages/web/src/core/stores/nodeDBStore/index.ts
@@ -1,7 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { featureFlags } from "@core/services/featureFlags";
import { validateIncomingNode } from "@core/stores/nodeDBStore/nodeValidation";
-import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { Protobuf, type Types } from "@meshtastic/core";
import { produce } from "immer";
@@ -13,18 +12,24 @@ 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;
+const NODE_RETENTION_DAYS = 14; // Remove nodes not heard from in 14 days
-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;
+ pruneStaleNodes: () => number;
processPacket: (data: ProcessPacketParams) => void;
addUser: (user: Types.PacketMetadata) => void;
addPosition: (position: Types.PacketMetadata) => 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;
};
@@ -92,6 +90,11 @@ function nodeDBFactory(
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
+
+ // Check if node already exists
+ const existing = nodeDB.nodeMap.get(node.num);
+ const isNew = !existing;
+
// Use validation to check the new node before adding
const next = validateIncomingNode(
node,
@@ -107,7 +110,30 @@ function nodeDBFactory(
return;
}
- nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(node.num, next);
+ // Merge with existing node data if it exists
+ const merged = existing
+ ? {
+ ...existing,
+ ...next,
+ // Preserve existing fields if new node doesn't have them
+ user: next.user ?? existing.user,
+ position: next.position ?? existing.position,
+ deviceMetrics: next.deviceMetrics ?? existing.deviceMetrics,
+ }
+ : next;
+
+ // Use the validated node's num to ensure consistency
+ nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(merged.num, merged);
+
+ if (isNew) {
+ console.log(
+ `[NodeDB] Adding new node from NodeInfo packet: ${merged.num} (${merged.user?.longName || "unknown"})`,
+ );
+ } else {
+ console.log(
+ `[NodeDB] Updating existing node from NodeInfo packet: ${merged.num} (${merged.user?.longName || "unknown"})`,
+ );
+ }
}),
),
@@ -147,6 +173,56 @@ function nodeDBFactory(
}),
),
+ pruneStaleNodes: () => {
+ const nodeDB = get().nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+
+ const nowSec = Math.floor(Date.now() / 1000);
+ const cutoffSec = nowSec - NODE_RETENTION_DAYS * 24 * 60 * 60;
+ let prunedCount = 0;
+
+ set(
+ produce((draft) => {
+ const nodeDB = draft.nodeDBs.get(id);
+ if (!nodeDB) {
+ throw new Error(`No nodeDB found (id: ${id})`);
+ }
+
+ const newNodeMap = new Map();
+
+ for (const [nodeNum, node] of nodeDB.nodeMap) {
+ // Keep myNode regardless of lastHeard
+ // Keep nodes that have been heard recently
+ // Keep nodes without lastHeard (just in case)
+ if (
+ nodeNum === nodeDB.myNodeNum ||
+ !node.lastHeard ||
+ node.lastHeard >= cutoffSec
+ ) {
+ newNodeMap.set(nodeNum, node);
+ } else {
+ prunedCount++;
+ console.log(
+ `[NodeDB] Pruning stale node ${nodeNum} (last heard ${Math.floor((nowSec - node.lastHeard) / 86400)} days ago)`,
+ );
+ }
+ }
+
+ nodeDB.nodeMap = newNodeMap;
+ }),
+ );
+
+ if (prunedCount > 0) {
+ console.log(
+ `[NodeDB] Pruned ${prunedCount} stale node(s) older than ${NODE_RETENTION_DAYS} days`,
+ );
+ }
+
+ return prunedCount;
+ },
+
setNodeError: (nodeNum, error) =>
set(
produce((draft) => {
@@ -222,11 +298,20 @@ function nodeDBFactory(
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
- const current =
- nodeDB.nodeMap.get(user.from) ??
- create(Protobuf.Mesh.NodeInfoSchema);
- const updated = { ...current, user: user.data, num: user.from };
+ const current = nodeDB.nodeMap.get(user.from);
+ const isNew = !current;
+ const updated = {
+ ...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
+ user: user.data,
+ num: user.from,
+ };
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(user.from, updated);
+
+ if (isNew) {
+ console.log(
+ `[NodeDB] Adding new node from user packet: ${user.from} (${user.data.longName || "unknown"})`,
+ );
+ }
}),
),
@@ -237,15 +322,20 @@ function nodeDBFactory(
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
- const current =
- nodeDB.nodeMap.get(position.from) ??
- create(Protobuf.Mesh.NodeInfoSchema);
+ const current = nodeDB.nodeMap.get(position.from);
+ const isNew = !current;
const updated = {
- ...current,
+ ...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
position: position.data,
num: position.from,
};
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(position.from, updated);
+
+ if (isNew) {
+ console.log(
+ `[NodeDB] Adding new node from position packet: ${position.from}`,
+ );
+ }
}),
),
@@ -413,6 +503,8 @@ export const nodeDBInitializer: StateCreator = (
addNodeDB: (id) => {
const existing = get().nodeDBs.get(id);
if (existing) {
+ // Prune stale nodes when accessing existing nodeDB
+ existing.pruneStaleNodes();
return existing;
}
@@ -420,12 +512,12 @@ export const nodeDBInitializer: StateCreator = (
set(
produce((draft) => {
draft.nodeDBs = new Map(draft.nodeDBs).set(id, nodeDB);
-
- // Enforce retention limit
- evictOldestEntries(draft.nodeDBs, NODEDB_RETENTION_NUM);
}),
);
+ // Prune stale nodes on creation (useful when rehydrating from storage)
+ nodeDB.pruneStaleNodes();
+
return nodeDB;
},
removeNodeDB: (id) => {
@@ -442,7 +534,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 031ee84d1..f01daa990 100644
--- a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts
+++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts
@@ -15,9 +15,12 @@ export const mockNodeDBStore: NodeDB = {
addUser: vi.fn(),
addPosition: vi.fn(),
removeNode: vi.fn(),
+ removeAllNodes: vi.fn(),
+ pruneStaleNodes: vi.fn().mockReturnValue(0),
processPacket: vi.fn(),
setNodeError: vi.fn(),
clearNodeError: vi.fn(),
+ removeAllNodeErrors: vi.fn(),
getNodeError: vi.fn().mockReturnValue(undefined),
hasNodeError: vi.fn().mockReturnValue(false),
getNodes: vi.fn().mockReturnValue([]),
diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx
index a54827114..fadb65d41 100644
--- a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx
+++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx
@@ -66,10 +66,10 @@ describe("NodeDB store", () => {
const db1 = useNodeDBStore.getState().addNodeDB(123);
const db2 = useNodeDBStore.getState().addNodeDB(123);
- expect(db1).toBe(db2);
+ expect(db1).toStrictEqual(db2);
const got = useNodeDBStore.getState().getNodeDB(123);
- expect(got).toBe(db1);
+ expect(got).toStrictEqual(db1);
expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1);
});
@@ -204,15 +204,18 @@ describe("NodeDB store", () => {
expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11
});
- it("when exceeding cap, evicts earliest inserted, not the newly added", async () => {
+ it("will prune nodes after 14 days of inactivitiy", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
- for (let i = 1; i <= 10; i++) {
- st.addNodeDB(i);
- }
- st.addNodeDB(11);
- expect(st.getNodeDB(1)).toBeUndefined();
- expect(st.getNodeDB(11)).toBeDefined();
+ st.addNodeDB(1).addNode(
+ makeNode(1, { lastHeard: Date.now() / 1000 - 15 * 24 * 3600 }),
+ ); // 15 days ago
+ st.addNodeDB(1).addNode(
+ makeNode(2, { lastHeard: Date.now() / 1000 - 7 * 24 * 3600 }),
+ ); // 7 days ago
+
+ st.getNodeDB(1)!.pruneStaleNodes();
+ expect(st.getNodeDB(1)?.getNode(2)).toBeDefined();
});
it("removeNodeDB persists removal across reload", async () => {
@@ -401,28 +404,6 @@ describe("NodeDB – merge semantics, PKI checks & extras", () => {
expect(newDB.getNodeError(2)!.error).toBe("NEW_ERR"); // new added
});
- it("eviction still honors cap after merge", async () => {
- const { useNodeDBStore } = await freshStore();
- const st = useNodeDBStore.getState();
-
- for (let i = 1; i <= 10; i++) {
- st.addNodeDB(i);
- }
- const oldDB = st.addNodeDB(100);
- oldDB.setNodeNum(12345);
- oldDB.addNode(makeNode(2000));
-
- const newDB = st.addNodeDB(101);
- newDB.setNodeNum(12345); // merges + deletes 100
-
- // adding another to trigger eviction of earliest non-merged entry (which was 1)
- st.addNodeDB(102);
-
- expect(st.getNodeDB(1)).toBeUndefined(); // evicted
- expect(st.getNodeDB(101)).toBeDefined(); // merged entry exists
- expect(st.getNodeDB(101)!.getNode(2000)).toBeTruthy(); // carried over
- });
-
it("removeAllNodes (optionally keeping my node) and removeAllNodeErrors persist across reload", async () => {
{
const { useNodeDBStore } = await freshStore(true); // with persistence
@@ -456,7 +437,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 500726dea..2e4631f45 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/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts
index 78f0bd808..f20f5443d 100644
--- a/packages/web/src/core/subscriptions.ts
+++ b/packages/web/src/core/subscriptions.ts
@@ -1,4 +1,3 @@
-import { ensureDefaultUser } from "@core/dto/NodeNumToNodeInfoDTO.ts";
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
import { useNewNodeNum } from "@core/hooks/useNewNodeNum";
import {
@@ -68,11 +67,11 @@ export const subscribeAll = (
nodeDB.addPosition(position);
});
+ // NOTE: Node handling is managed by the nodeDB
+ // Nodes are added via subscriptions.ts and stored in nodeDB
+ // Configuration is handled directly by meshDevice.configure() in useConnections
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
- const nodeWithUser = ensureDefaultUser(nodeInfo);
-
- // PKI sanity check is handled inside nodeDB.addNode
- nodeDB.addNode(nodeWithUser);
+ nodeDB.addNode(nodeInfo);
});
connection.events.onChannelPacket.subscribe((channel) => {
diff --git a/packages/web/src/core/utils/color.test.ts b/packages/web/src/core/utils/color.test.ts
new file mode 100644
index 000000000..678a5135d
--- /dev/null
+++ b/packages/web/src/core/utils/color.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import {
+ getColorFromNodeNum,
+ hexToRgb,
+ isLightColor,
+ type RGBColor,
+ rgbToHex,
+} from "./color.ts";
+
+describe("hexToRgb", () => {
+ it.each([
+ [0x000000, { r: 0, g: 0, b: 0 }],
+ [0xffffff, { r: 255, g: 255, b: 255 }],
+ [0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
+ [0xff8000, { r: 255, g: 128, b: 0 }],
+ ])("parses 0x%s correctly", (hex, expected) => {
+ expect(hexToRgb(hex)).toEqual(expected);
+ });
+});
+
+describe("rgbToHex", () => {
+ it.each<[RGBColor, number]>([
+ [{ r: 0, g: 0, b: 0 }, 0x000000],
+ [{ r: 255, g: 255, b: 255 }, 0xffffff],
+ [{ r: 0x12, g: 0x34, b: 0x56 }, 0x123456],
+ [{ r: 255, g: 128, b: 0 }, 0xff8000],
+ ])("packs %j into 0x%s", (rgb, expected) => {
+ expect(rgbToHex(rgb)).toBe(expected);
+ });
+
+ it("rounds component values before packing", () => {
+ expect(rgbToHex({ r: 12.2, g: 12.8, b: 99.5 })).toBe(
+ (12 << 16) | (13 << 8) | 100,
+ );
+ });
+});
+
+describe("hexToRgb ⟷ rgbToHex round-trip", () => {
+ it("is identity for representative values (masked to 24-bit)", () => {
+ const samples = [0, 1, 0x7fffff, 0x800000, 0xffffff, 0x123456, 0x00ff00];
+ for (const hex of samples) {
+ const rgb = hexToRgb(hex);
+ expect(rgbToHex(rgb)).toBe(hex & 0xffffff);
+ }
+ });
+
+ it("holds for random 24-bit values", () => {
+ for (let i = 0; i < 100; i++) {
+ const hex = Math.floor(Math.random() * 0x1000000); // 0..0xFFFFFF
+ expect(rgbToHex(hexToRgb(hex))).toBe(hex);
+ }
+ });
+});
+
+describe("isLightColor", () => {
+ it("detects obvious extremes", () => {
+ expect(isLightColor({ r: 255, g: 255, b: 255 })).toBe(true); // white
+ expect(isLightColor({ r: 0, g: 0, b: 0 })).toBe(false); // black
+ });
+
+ it("respects the 127.5 threshold at boundary", () => {
+ // mid-gray 127 → false, 128 → true (given the formula and 127.5 threshold)
+ expect(isLightColor({ r: 127, g: 127, b: 127 })).toBe(false);
+ expect(isLightColor({ r: 128, g: 128, b: 128 })).toBe(true);
+ });
+});
+
+describe("getColorFromNodeNum", () => {
+ it.each([
+ [0x000000, { r: 0, g: 0, b: 0 }],
+ [0xffffff, { r: 255, g: 255, b: 255 }],
+ [0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
+ ])("extracts RGB from lower 24 bits of %s", (nodeNum, expected) => {
+ expect(getColorFromNodeNum(nodeNum)).toEqual(expected);
+ });
+
+ it("matches hexToRgb when masking to 24 bits", () => {
+ const nodeNums = [1127947528, 42, 999999, 0xfeef12, 0xfeedface, -123456];
+ for (const n of nodeNums) {
+ // JS bitwise ops use signed 32-bit, so mask the lower 24 bits for comparison.
+ const masked = n & 0xffffff;
+ expect(getColorFromNodeNum(n)).toEqual(hexToRgb(masked));
+ }
+ });
+
+ it("always yields components within 0..255", () => {
+ const color = getColorFromNodeNum(Math.floor(Math.random() * 2 ** 31));
+ for (const v of Object.values(color)) {
+ expect(v).toBeGreaterThanOrEqual(0);
+ expect(v).toBeLessThanOrEqual(255);
+ }
+ });
+});
diff --git a/packages/web/src/core/utils/color.ts b/packages/web/src/core/utils/color.ts
index 9abb7b301..cfbe286d0 100644
--- a/packages/web/src/core/utils/color.ts
+++ b/packages/web/src/core/utils/color.ts
@@ -2,39 +2,25 @@ export interface RGBColor {
r: number;
g: number;
b: number;
- a: number;
}
export const hexToRgb = (hex: number): RGBColor => ({
r: (hex & 0xff0000) >> 16,
g: (hex & 0x00ff00) >> 8,
b: hex & 0x0000ff,
- a: 255,
});
export const rgbToHex = (c: RGBColor): number =>
- (Math.round(c.a) << 24) |
- (Math.round(c.r) << 16) |
- (Math.round(c.g) << 8) |
- Math.round(c.b);
+ (Math.round(c.r) << 16) | (Math.round(c.g) << 8) | Math.round(c.b);
export const isLightColor = (c: RGBColor): boolean =>
(c.r * 299 + c.g * 587 + c.b * 114) / 1000 > 127.5;
-export const getColorFromText = (text: string): RGBColor => {
- if (!text) {
- return { r: 0, g: 0, b: 0, a: 255 };
- }
+export const getColorFromNodeNum = (nodeNum: number): RGBColor => {
+ // Extract RGB values directly from nodeNum (treated as hex color)
+ const r = (nodeNum & 0xff0000) >> 16;
+ const g = (nodeNum & 0x00ff00) >> 8;
+ const b = nodeNum & 0x0000ff;
- let hash = 0;
- for (let i = 0; i < text.length; i++) {
- hash = text.charCodeAt(i) + ((hash << 5) - hash);
- hash |= 0; // force 32‑bit
- }
- return {
- r: (hash & 0xff0000) >> 16,
- g: (hash & 0x00ff00) >> 8,
- b: hash & 0x0000ff,
- a: 255,
- };
+ return { r, g, b };
};
diff --git a/packages/web/src/i18n-config.ts b/packages/web/src/i18n-config.ts
index 08f50ba06..3c6f4637e 100644
--- a/packages/web/src/i18n-config.ts
+++ b/packages/web/src/i18n-config.ts
@@ -13,9 +13,10 @@ export type Lang = {
export type LangCode = Lang["code"];
export const supportedLanguages: Lang[] = [
+ { code: "fi", name: "Suomi", flag: "🇫🇮" },
{ code: "de", name: "Deutsch", flag: "🇩🇪" },
{ code: "en", name: "English", flag: "🇺🇸" },
- { code: "fi", name: "Suomi", flag: "🇫🇮" },
+ { code: "fr", name: "Français", flag: "🇫🇷" },
{ code: "sv", name: "Svenska", flag: "🇸🇪" },
];
@@ -41,6 +42,7 @@ i18next
fallbackLng: {
default: [FALLBACK_LANGUAGE_CODE],
fi: ["fi-FI", FALLBACK_LANGUAGE_CODE],
+ fr: ["fr-FR", FALLBACK_LANGUAGE_CODE],
sv: ["sv-SE", FALLBACK_LANGUAGE_CODE],
de: ["de-DE", FALLBACK_LANGUAGE_CODE],
},
@@ -48,11 +50,11 @@ i18next
debug: import.meta.env.MODE === "development",
ns: [
"channels",
+ "connections",
"commandPalette",
"common",
- "deviceConfig",
+ "config",
"moduleConfig",
- "dashboard",
"dialog",
"messages",
"nodes",
diff --git a/packages/web/src/index.css b/packages/web/src/index.css
index 5ab88c885..f9a80685d 100644
--- a/packages/web/src/index.css
+++ b/packages/web/src/index.css
@@ -1,14 +1,33 @@
-@import "tailwindcss";
+@font-face {
+ font-family: 'Inter var';
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url('/fonts/InterVariable.woff2') format('woff2');
+}
+@font-face {
+ font-family: 'Inter var';
+ font-style: italic;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url('/fonts/InterVariable-Italic.woff2') format('woff2');
+}
+
+@import "tailwindcss";
+/* @import '@meshtastic/ui/theme/default.css'; */
+/* @source "../node_modules/@meshtastic/ui"; */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@view-transition {
navigation: auto;
}
+
+
@theme {
--font-mono:
- Cascadia Code, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
--font-sans:
Inter var, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
@@ -76,7 +95,7 @@
.animate-fan-out {
transform: translate(var(--dx), var(--dy));
- transition: transform 200ms ease-out; /* expand AND collapse */
+ transition: transform 200ms ease-out;
}
@@ -91,12 +110,10 @@ body {
body {
font-family: var(--font-sans);
-}
-.app-container {
- height: 100%;
}
+
@layer base {
*,
@@ -106,6 +123,32 @@ body {
::file-selector-button {
border-color: var(--color-slate-200, currentColor);
}
+
+ ::-webkit-scrollbar {
+ @apply bg-transparent w-2;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ @apply bg-gray-400;
+ }
+
+ ::-webkit-scrollbar-corner {
+ @apply bg-slate-200;
+ }
+
+ .dark {
+ ::-webkit-scrollbar {
+ @apply bg-transparent w-2;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ @apply bg-slate-600 rounded-4xl;
+ }
+
+ ::-webkit-scrollbar-corner {
+ @apply bg-slate-900;
+ }
+ }
}
@layer components {
diff --git a/packages/web/src/pages/Connections/index.tsx b/packages/web/src/pages/Connections/index.tsx
new file mode 100644
index 000000000..27c413c30
--- /dev/null
+++ b/packages/web/src/pages/Connections/index.tsx
@@ -0,0 +1,423 @@
+import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
+import { TimeAgo } from "@app/components/generic/TimeAgo";
+import LanguageSwitcher from "@app/components/LanguageSwitcher";
+import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
+import type { Connection } from "@app/core/stores/deviceStore/types";
+import { useConnections } from "@app/pages/Connections/useConnections";
+import {
+ connectionTypeIcon,
+ formatConnectionSubtext,
+} from "@app/pages/Connections/utils";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@components/UI/AlertDialog.tsx";
+import { Badge } from "@components/UI/Badge.tsx";
+import { Button } from "@components/UI/Button.tsx";
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@components/UI/Card.tsx";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@components/UI/DropdownMenu.tsx";
+import { Separator } from "@components/UI/Separator.tsx";
+import { useToast } from "@core/hooks/useToast.ts";
+import { useNavigate } from "@tanstack/react-router";
+import {
+ ArrowLeft,
+ LinkIcon,
+ MoreHorizontal,
+ RotateCw,
+ RouterIcon,
+ Star,
+ StarOff,
+ Trash2,
+} from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export const Connections = () => {
+ const {
+ connections,
+ addConnectionAndConnect,
+ connect,
+ disconnect,
+ removeConnection,
+ setDefaultConnection,
+ refreshStatuses,
+ syncConnectionStatuses,
+ } = useConnections();
+ const { toast } = useToast();
+ const navigate = useNavigate({ from: "/" });
+ const [addOpen, setAddOpen] = useState(false);
+ const isURLHTTPS = useMemo(() => location.protocol === "https:", []);
+ const { t } = useTranslation("connections");
+
+ // On first mount, sync statuses and refresh
+ // biome-ignore lint/correctness/useExhaustiveDependencies: This can cause the icon to refresh too often
+ useEffect(() => {
+ syncConnectionStatuses();
+ refreshStatuses();
+ }, []);
+ const sorted = useMemo(() => {
+ const copy = [...connections];
+ return copy.sort((a, b) => {
+ if (a.isDefault && !b.isDefault) {
+ return -1;
+ }
+ if (!a.isDefault && b.isDefault) {
+ return 1;
+ }
+ const aConnected = a.status === "connected" || a.status === "configured";
+ const bConnected = b.status === "connected" || b.status === "configured";
+ if (aConnected && !bConnected) {
+ return -1;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ }, [connections]);
+
+ return (
+
+
+
+
+
+
+ {t("page.title")}
+
+
+ {t("page.description")}
+
+
+
+
+
+
+
+
+
+
+
+ {sorted.length === 0 ? (
+
+
+
+ {t("noConnections.title")}{" "}
+
+
+
+ {t("noConnections.description")}
+
+
+
+
+
+ ) : (
+
+ {sorted.map((c) => (
+ {
+ const ok = await connect(c.id, { allowPrompt: true });
+ toast({
+ title: ok ? t("toasts.connected") : t("toasts.failed"),
+ description: ok
+ ? t("toasts.nowConnected", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ })
+ : t("toasts.checkConnection"),
+ });
+ if (ok) {
+ navigate({ to: "/" });
+ }
+ }}
+ onDisconnect={async () => {
+ await disconnect(c.id);
+ toast({
+ title: t("toasts.disconnected"),
+ description: t("toasts.nowDisconnected", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ }),
+ });
+ }}
+ onSetDefault={() => {
+ setDefaultConnection(c.id);
+ toast({
+ title: t("toasts.defaultSet"),
+ description: t("toasts.defaultConnection", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ }),
+ });
+ }}
+ onDelete={async () => {
+ await disconnect(c.id);
+ removeConnection(c.id);
+ toast({
+ title: t("toasts.deleted"),
+ description: t("toasts.deletedByName", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ }),
+ });
+ }}
+ onRetry={async () => {
+ const ok = await connect(c.id, { allowPrompt: true });
+ toast({
+ title: ok ? t("toasts.connected") : t("toasts.failed"),
+ description: ok
+ ? t("toasts.nowConnected", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ })
+ : t("toasts.pickConnectionAgain"),
+ });
+ if (ok) {
+ navigate({ to: "/" });
+ }
+ }}
+ />
+ ))}
+
+ )}
+
+ {
+ const created = await addConnectionAndConnect(partial, btDevice);
+ if (created) {
+ setAddOpen(false);
+ toast({
+ title: t("toasts.added"),
+ description: t("toasts.savedByName", {
+ name: created.name,
+ interpolation: { escapeValue: false },
+ }),
+ });
+ if (
+ created.status === "connected" ||
+ created.status === "configured"
+ ) {
+ navigate({ to: "/" });
+ }
+ } else {
+ toast({
+ title: "Unable to connect",
+ description: "savedCantConnect",
+ });
+ }
+ }}
+ />
+
+ );
+};
+
+function TypeBadge({ type }: { type: Connection["type"] }) {
+ const Icon = connectionTypeIcon(type);
+ const label =
+ type === "http" ? "HTTP" : type === "bluetooth" ? "Bluetooth" : "Serial";
+ return (
+
+
+ {label}
+
+ );
+}
+
+function ConnectionCard({
+ connection,
+ onConnect,
+ onDisconnect,
+ onSetDefault,
+ onDelete,
+ onRetry,
+}: {
+ connection: Connection;
+ onConnect: () => Promise | Promise;
+ onDisconnect: () => Promise | Promise;
+ onSetDefault: () => void;
+ onDelete: () => void;
+ onRetry: () => Promise | Promise;
+}) {
+ const { t } = useTranslation("connections");
+
+ const Icon = connectionTypeIcon(connection.type);
+ const isBusy =
+ connection.status === "connecting" || connection.status === "configuring";
+ const isConnected =
+ connection.status === "connected" || connection.status === "configured";
+ const isError = connection.status === "error";
+
+ return (
+
+
+
+
+
+
+ {connection.name}
+ {connection.isDefault ? (
+
+
+ {t("default")}
+
+ ) : null}
+
+
+
+
+ {formatConnectionSubtext(connection)}
+
+
+
+
+
+
+
+
+
+
+
+ {connection.type === "http" && connection.isDefault && (
+ onSetDefault()}
+ >
+
+ {t("button.unsetDefault")}
+
+ )}
+ {connection.type === "http" && !connection.isDefault && (
+ onSetDefault()}
+ >
+
+ {t("button.setDefault")}
+
+ )}
+
+
+ e.preventDefault()}
+ >
+
+ {t("button.delete")}
+
+
+
+
+
+ {t("deleteConnection")}
+
+
+ {t("areYouSure", { name: connection.name })}
+
+
+
+
+ {t("button.cancel")}
+
+ onDelete()}
+ >
+ {t("button.delete")}
+
+
+
+
+
+
+
+
+
+
+ {connection.error ? (
+
+ {connection.error}
+
+ ) : connection.lastConnectedAt ? (
+
+ {t("lastConnectedAt", { date: "" })}{" "}
+
+
+ ) : (
+
+ {t("neverConnected")}
+
+ )}
+
+
+ {isConnected ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/packages/web/src/pages/Connections/useConnections.ts b/packages/web/src/pages/Connections/useConnections.ts
new file mode 100644
index 000000000..206e24ab0
--- /dev/null
+++ b/packages/web/src/pages/Connections/useConnections.ts
@@ -0,0 +1,660 @@
+import type {
+ Connection,
+ ConnectionId,
+ ConnectionStatus,
+ NewConnection,
+} from "@app/core/stores/deviceStore/types";
+import {
+ createConnectionFromInput,
+ testHttpReachable,
+} from "@app/pages/Connections/utils";
+import {
+ useAppStore,
+ useDeviceStore,
+ useMessageStore,
+ useNodeDBStore,
+} from "@core/stores";
+import { subscribeAll } from "@core/subscriptions.ts";
+import { randId } from "@core/utils/randId.ts";
+import { MeshDevice } from "@meshtastic/core";
+import { TransportHTTP } from "@meshtastic/transport-http";
+import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
+import { TransportWebSerial } from "@meshtastic/transport-web-serial";
+import { useCallback } from "react";
+
+// Local storage for cleanup only (not in Zustand)
+const transports = new Map();
+const heartbeats = new Map>();
+const configSubscriptions = new Map void>();
+
+const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
+const CONFIG_HEARTBEAT_INTERVAL_MS = 5000; // 5s during configuration
+
+export function useConnections() {
+ const connections = useDeviceStore((s) => s.savedConnections);
+
+ const addSavedConnection = useDeviceStore((s) => s.addSavedConnection);
+ const updateSavedConnection = useDeviceStore((s) => s.updateSavedConnection);
+ const removeSavedConnectionFromStore = useDeviceStore(
+ (s) => s.removeSavedConnection,
+ );
+
+ // DeviceStore methods
+ const setActiveConnectionId = useDeviceStore((s) => s.setActiveConnectionId);
+
+ const { addDevice } = useDeviceStore();
+ const { addNodeDB } = useNodeDBStore();
+ const { addMessageStore } = useMessageStore();
+ const { setSelectedDevice } = useAppStore();
+ const selectedDeviceId = useAppStore((s) => s.selectedDeviceId);
+
+ const updateStatus = useCallback(
+ (id: ConnectionId, status: ConnectionStatus, error?: string) => {
+ const updates: Partial = {
+ status,
+ error: error || undefined,
+ ...(status === "disconnected" ? { lastConnectedAt: Date.now() } : {}),
+ };
+ updateSavedConnection(id, updates);
+ },
+ [updateSavedConnection],
+ );
+
+ const removeConnection = useCallback(
+ (id: ConnectionId) => {
+ const conn = connections.find((c) => c.id === id);
+
+ // Stop heartbeat
+ const heartbeatId = heartbeats.get(id);
+ if (heartbeatId) {
+ clearInterval(heartbeatId);
+ heartbeats.delete(id);
+ console.log(`[useConnections] Heartbeat stopped for connection ${id}`);
+ }
+
+ // Unsubscribe from config complete event
+ const unsubConfigComplete = configSubscriptions.get(id);
+ if (unsubConfigComplete) {
+ unsubConfigComplete();
+ configSubscriptions.delete(id);
+ console.log(
+ `[useConnections] Config subscription cleaned up for connection ${id}`,
+ );
+ }
+
+ // Get device and MeshDevice from Device.connection
+ if (conn?.meshDeviceId) {
+ const { getDevice, removeDevice } = useDeviceStore.getState();
+ const device = getDevice(conn.meshDeviceId);
+
+ if (device?.connection) {
+ // Disconnect MeshDevice
+ try {
+ device.connection.disconnect();
+ } catch {}
+ }
+
+ // Close transport if it's BT or Serial
+ const transport = transports.get(id);
+ if (transport) {
+ const bt = transport as BluetoothDevice;
+ if (bt.gatt?.connected) {
+ try {
+ bt.gatt.disconnect();
+ } catch {}
+ }
+
+ const sp = transport as SerialPort & { close?: () => Promise };
+ if (sp.close) {
+ try {
+ sp.close();
+ } catch {}
+ }
+
+ transports.delete(id);
+ }
+
+ // Clean up orphaned Device
+ try {
+ removeDevice(conn.meshDeviceId);
+ } catch {}
+ }
+
+ removeSavedConnectionFromStore(id);
+ },
+ [connections, removeSavedConnectionFromStore],
+ );
+
+ const setDefaultConnection = useCallback(
+ (id: ConnectionId) => {
+ for (const connection of connections) {
+ if (connection.id === id) {
+ updateSavedConnection(connection.id, {
+ isDefault: !connection.isDefault,
+ });
+ }
+ }
+ },
+ [connections, updateSavedConnection],
+ );
+
+ const setupMeshDevice = useCallback(
+ (
+ id: ConnectionId,
+ transport:
+ | Awaited>
+ | Awaited>
+ | Awaited>,
+ btDevice?: BluetoothDevice,
+ serialPort?: SerialPort,
+ ): number => {
+ // Reuse existing meshDeviceId if available to prevent duplicate nodeDBs,
+ // but only if the corresponding nodeDB still exists. Otherwise, generate a new ID.
+ const conn = connections.find((c) => c.id === id);
+ let deviceId = conn?.meshDeviceId;
+ if (deviceId && !useNodeDBStore.getState().getNodeDB(deviceId)) {
+ deviceId = undefined;
+ }
+ deviceId = deviceId ?? randId();
+
+ const device = addDevice(deviceId);
+ const nodeDB = addNodeDB(deviceId);
+ const messageStore = addMessageStore(deviceId);
+ const meshDevice = new MeshDevice(transport, deviceId);
+
+ setSelectedDevice(deviceId);
+ device.addConnection(meshDevice); // This stores meshDevice in Device.connection
+ subscribeAll(device, meshDevice, messageStore, nodeDB);
+
+ // Store transport locally for cleanup (BT/Serial only)
+ if (btDevice || serialPort) {
+ transports.set(id, btDevice || serialPort);
+ }
+
+ // Set active connection and link device bidirectionally
+ setActiveConnectionId(id);
+ device.setConnectionId(id);
+
+ // Listen for config complete event (with nonce/ID)
+ const unsubConfigComplete = meshDevice.events.onConfigComplete.subscribe(
+ (configCompleteId) => {
+ console.log(
+ `[useConnections] Configuration complete with ID: ${configCompleteId}`,
+ );
+ device.setConnectionPhase("configured");
+ updateStatus(id, "configured");
+
+ // Switch from fast config heartbeat to slow maintenance heartbeat
+ const oldHeartbeat = heartbeats.get(id);
+ if (oldHeartbeat) {
+ clearInterval(oldHeartbeat);
+ console.log(
+ `[useConnections] Switching to maintenance heartbeat (5 min interval)`,
+ );
+ }
+
+ const maintenanceHeartbeat = setInterval(() => {
+ meshDevice.heartbeat().catch((error) => {
+ console.warn("[useConnections] Heartbeat failed:", error);
+ });
+ }, HEARTBEAT_INTERVAL_MS);
+ heartbeats.set(id, maintenanceHeartbeat);
+ },
+ );
+ configSubscriptions.set(id, unsubConfigComplete);
+
+ // Start configuration
+ device.setConnectionPhase("configuring");
+ updateStatus(id, "configuring");
+ console.log("[useConnections] Starting configuration");
+
+ meshDevice
+ .configure()
+ .then(() => {
+ console.log(
+ "[useConnections] Configuration complete, starting heartbeat",
+ );
+ // Send initial heartbeat after configure completes
+ meshDevice
+ .heartbeat()
+ .then(() => {
+ // Start fast heartbeat after first successful heartbeat
+ const configHeartbeatId = setInterval(() => {
+ meshDevice.heartbeat().catch((error) => {
+ console.warn(
+ "[useConnections] Config heartbeat failed:",
+ error,
+ );
+ });
+ }, CONFIG_HEARTBEAT_INTERVAL_MS);
+ heartbeats.set(id, configHeartbeatId);
+ console.log(
+ `[useConnections] Heartbeat started for connection ${id} (5s interval during config)`,
+ );
+ })
+ .catch((error) => {
+ console.warn("[useConnections] Initial heartbeat failed:", error);
+ });
+ })
+ .catch((error) => {
+ console.error(`[useConnections] Failed to configure:`, error);
+ updateStatus(id, "error", error.message);
+ });
+
+ updateSavedConnection(id, { meshDeviceId: deviceId });
+ return deviceId;
+ },
+ [
+ connections,
+ addDevice,
+ addNodeDB,
+ addMessageStore,
+ setSelectedDevice,
+ setActiveConnectionId,
+ updateSavedConnection,
+ updateStatus,
+ ],
+ );
+
+ const connect = useCallback(
+ async (id: ConnectionId, opts?: { allowPrompt?: boolean }) => {
+ const conn = connections.find((c) => c.id === id);
+ if (!conn) {
+ return false;
+ }
+ if (conn.status === "configured" || conn.status === "connected") {
+ return true;
+ }
+
+ updateStatus(id, "connecting");
+ try {
+ if (conn.type === "http") {
+ const ok = await testHttpReachable(conn.url);
+ if (!ok) {
+ const url = new URL(conn.url);
+ const isHTTPS = url.protocol === "https:";
+ const message = isHTTPS
+ ? `Cannot reach HTTPS endpoint. If using a self-signed certificate, open ${conn.url} in a new tab, accept the certificate warning, then try connecting again.`
+ : "HTTP endpoint not reachable (may be blocked by CORS)";
+ throw new Error(message);
+ }
+
+ const url = new URL(conn.url);
+ const isTLS = url.protocol === "https:";
+ const transport = await TransportHTTP.create(url.host, isTLS);
+ setupMeshDevice(id, transport);
+ // Status will be set to "configured" by onConfigComplete event
+ return true;
+ }
+
+ if (conn.type === "bluetooth") {
+ if (!("bluetooth" in navigator)) {
+ throw new Error("Web Bluetooth not supported");
+ }
+ let bleDevice = transports.get(id) as BluetoothDevice | undefined;
+ if (!bleDevice) {
+ // Try to recover permitted devices
+ const getDevices = (
+ navigator.bluetooth as Navigator["bluetooth"] & {
+ getDevices?: () => Promise;
+ }
+ ).getDevices;
+
+ if (getDevices) {
+ const known = await getDevices();
+ if (known && known.length > 0 && conn.deviceId) {
+ bleDevice = known.find(
+ (d: BluetoothDevice) => d.id === conn.deviceId,
+ );
+ }
+ }
+ }
+ if (!bleDevice && opts?.allowPrompt) {
+ // Prompt user to reselect (filter by optional service if provided)
+ bleDevice = await navigator.bluetooth.requestDevice({
+ acceptAllDevices: !conn.gattServiceUUID,
+ optionalServices: conn.gattServiceUUID
+ ? [conn.gattServiceUUID]
+ : undefined,
+ filters: conn.gattServiceUUID
+ ? [{ services: [conn.gattServiceUUID] }]
+ : undefined,
+ });
+ }
+ if (!bleDevice) {
+ throw new Error(
+ "Bluetooth device not available. Re-select the device.",
+ );
+ }
+
+ const transport =
+ await TransportWebBluetooth.createFromDevice(bleDevice);
+ setupMeshDevice(id, transport, bleDevice);
+
+ bleDevice.addEventListener("gattserverdisconnected", () => {
+ updateStatus(id, "disconnected");
+ });
+
+ // Status will be set to "configured" by onConfigComplete event
+ return true;
+ }
+
+ if (conn.type === "serial") {
+ if (!("serial" in navigator)) {
+ throw new Error("Web Serial not supported");
+ }
+ let port = transports.get(id) as SerialPort | undefined;
+ if (!port) {
+ // Find a previously granted port by vendor/product
+ const ports: SerialPort[] = await (
+ navigator as Navigator & {
+ serial: { getPorts: () => Promise };
+ }
+ ).serial.getPorts();
+ if (ports && conn.usbVendorId && conn.usbProductId) {
+ port = ports.find((p: SerialPort) => {
+ const info =
+ (
+ p as SerialPort & {
+ getInfo?: () => {
+ usbVendorId?: number;
+ usbProductId?: number;
+ };
+ }
+ ).getInfo?.() ?? {};
+ return (
+ info.usbVendorId === conn.usbVendorId &&
+ info.usbProductId === conn.usbProductId
+ );
+ });
+ }
+ }
+ if (!port && opts?.allowPrompt) {
+ port = await (
+ navigator as Navigator & {
+ serial: {
+ requestPort: (
+ options: Record,
+ ) => Promise;
+ };
+ }
+ ).serial.requestPort({});
+ }
+ if (!port) {
+ throw new Error("Serial port not available. Re-select the port.");
+ }
+
+ // Ensure the port is closed before opening it
+ const portWithStreams = port as SerialPort & {
+ readable: ReadableStream | null;
+ writable: WritableStream | null;
+ close: () => Promise;
+ };
+ if (portWithStreams.readable || portWithStreams.writable) {
+ try {
+ await portWithStreams.close();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ } catch (err) {
+ console.warn("Error closing port before reconnect:", err);
+ }
+ }
+
+ const transport = await TransportWebSerial.createFromPort(port);
+ setupMeshDevice(id, transport, undefined, port);
+ // Status will be set to "configured" by onConfigComplete event
+ return true;
+ }
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ updateStatus(id, "error", message);
+ return false;
+ }
+ return false;
+ },
+ [connections, updateStatus, setupMeshDevice],
+ );
+
+ const disconnect = useCallback(
+ async (id: ConnectionId) => {
+ const conn = connections.find((c) => c.id === id);
+ if (!conn) {
+ return;
+ }
+ try {
+ // Stop heartbeat
+ const heartbeatId = heartbeats.get(id);
+ if (heartbeatId) {
+ clearInterval(heartbeatId);
+ heartbeats.delete(id);
+ console.log(
+ `[useConnections] Heartbeat stopped for connection ${id}`,
+ );
+ }
+
+ // Unsubscribe from config complete event
+ const unsubConfigComplete = configSubscriptions.get(id);
+ if (unsubConfigComplete) {
+ unsubConfigComplete();
+ configSubscriptions.delete(id);
+ console.log(
+ `[useConnections] Config subscription cleaned up for connection ${id}`,
+ );
+ }
+
+ // Get device and meshDevice from Device.connection
+ if (conn.meshDeviceId) {
+ const { getDevice } = useDeviceStore.getState();
+ const device = getDevice(conn.meshDeviceId);
+
+ if (device?.connection) {
+ // Disconnect MeshDevice
+ try {
+ device.connection.disconnect();
+ } catch {
+ // Ignore errors
+ }
+ }
+
+ // Close transport connections
+ const transport = transports.get(id);
+ if (transport) {
+ if (conn.type === "bluetooth") {
+ const dev = transport as BluetoothDevice;
+ if (dev.gatt?.connected) {
+ dev.gatt.disconnect();
+ }
+ }
+ if (conn.type === "serial") {
+ const port = transport as SerialPort & {
+ close?: () => Promise;
+ readable?: ReadableStream | null;
+ };
+ if (port.close && port.readable) {
+ try {
+ await port.close();
+ } catch (err) {
+ console.warn("Error closing serial port:", err);
+ }
+ }
+ }
+ }
+
+ // Clear the device's connectionId link
+ if (device) {
+ device.setConnectionId(null);
+ device.setConnectionPhase("disconnected");
+ }
+ }
+ } finally {
+ updateSavedConnection(id, {
+ status: "disconnected",
+ error: undefined,
+ });
+ }
+ },
+ [connections, updateSavedConnection],
+ );
+
+ const addConnection = useCallback(
+ (input: NewConnection) => {
+ const conn = createConnectionFromInput(input);
+ addSavedConnection(conn);
+ return conn;
+ },
+ [addSavedConnection],
+ );
+
+ const addConnectionAndConnect = useCallback(
+ async (input: NewConnection, btDevice?: BluetoothDevice) => {
+ const conn = addConnection(input);
+ // If a Bluetooth device was provided, store it to avoid re-prompting
+ if (btDevice && conn.type === "bluetooth") {
+ transports.set(conn.id, btDevice);
+ }
+ await connect(conn.id, { allowPrompt: true });
+ // Get updated connection from store after connect
+ if (conn.id) {
+ return conn;
+ }
+ },
+ [addConnection, connect],
+ );
+
+ const refreshStatuses = useCallback(async () => {
+ // Check reachability/availability without auto-connecting
+ // HTTP: test endpoint reachability
+ // Bluetooth/Serial: check permission grants
+
+ // HTTP connections: test reachability if not already connected/configured
+ const httpChecks = connections
+ .filter(
+ (c): c is Connection & { type: "http"; url: string } =>
+ c.type === "http" &&
+ c.status !== "connected" &&
+ c.status !== "configured" &&
+ c.status !== "configuring",
+ )
+ .map(async (c) => {
+ const ok = await testHttpReachable(c.url);
+ updateSavedConnection(c.id, {
+ status: ok ? "online" : "error",
+ });
+ });
+
+ // Bluetooth connections: check permission grants
+ const btChecks = connections
+ .filter(
+ (c): c is Connection & { type: "bluetooth"; deviceId?: string } =>
+ c.type === "bluetooth" &&
+ c.status !== "connected" &&
+ c.status !== "configured" &&
+ c.status !== "configuring",
+ )
+ .map(async (c) => {
+ if (!("bluetooth" in navigator)) {
+ return;
+ }
+ try {
+ const known = await (
+ navigator.bluetooth as Navigator["bluetooth"] & {
+ getDevices?: () => Promise;
+ }
+ ).getDevices?.();
+ const hasPermission = known?.some(
+ (d: BluetoothDevice) => d.id === c.deviceId,
+ );
+ updateSavedConnection(c.id, {
+ status: hasPermission ? "configured" : "disconnected",
+ });
+ } catch {
+ // getDevices not supported or failed
+ updateSavedConnection(c.id, { status: "disconnected" });
+ }
+ });
+
+ // Serial connections: check permission grants
+ const serialChecks = connections
+ .filter(
+ (
+ c,
+ ): c is Connection & {
+ type: "serial";
+ usbVendorId?: number;
+ usbProductId?: number;
+ } =>
+ c.type === "serial" &&
+ c.status !== "connected" &&
+ c.status !== "configured" &&
+ c.status !== "configuring",
+ )
+ .map(async (c) => {
+ if (!("serial" in navigator)) {
+ return;
+ }
+ try {
+ const ports: SerialPort[] = await (
+ navigator as Navigator & {
+ serial: { getPorts: () => Promise };
+ }
+ ).serial.getPorts();
+ const hasPermission = ports.some((p: SerialPort) => {
+ const info =
+ (
+ p as SerialPort & {
+ getInfo?: () => {
+ usbVendorId?: number;
+ usbProductId?: number;
+ };
+ }
+ ).getInfo?.() ?? {};
+ return (
+ info.usbVendorId === c.usbVendorId &&
+ info.usbProductId === c.usbProductId
+ );
+ });
+ updateSavedConnection(c.id, {
+ status: hasPermission ? "configured" : "disconnected",
+ });
+ } catch {
+ // getPorts failed
+ updateSavedConnection(c.id, { status: "disconnected" });
+ }
+ });
+
+ await Promise.all([...httpChecks, ...btChecks, ...serialChecks]);
+ }, [connections, updateSavedConnection]);
+
+ const syncConnectionStatuses = useCallback(() => {
+ // Find which connection corresponds to the currently selected device
+ const activeConnection = connections.find(
+ (c) => c.meshDeviceId === selectedDeviceId,
+ );
+
+ // Update all connection statuses
+ connections.forEach((conn) => {
+ const shouldBeConnected = activeConnection?.id === conn.id;
+ const isConnectedState =
+ conn.status === "connected" ||
+ conn.status === "configured" ||
+ conn.status === "configuring";
+
+ // Update status if it doesn't match reality
+ if (!shouldBeConnected && isConnectedState) {
+ updateSavedConnection(conn.id, { status: "disconnected" });
+ }
+ // Don't force status to "connected" if shouldBeConnected - let the connection flow set the proper status
+ });
+ }, [connections, selectedDeviceId, updateSavedConnection]);
+
+ return {
+ connections,
+ addConnection,
+ addConnectionAndConnect,
+ connect,
+ disconnect,
+ removeConnection,
+ setDefaultConnection,
+ refreshStatuses,
+ syncConnectionStatuses,
+ };
+}
diff --git a/packages/web/src/pages/Connections/utils.ts b/packages/web/src/pages/Connections/utils.ts
new file mode 100644
index 000000000..d55d9fd2c
--- /dev/null
+++ b/packages/web/src/pages/Connections/utils.ts
@@ -0,0 +1,84 @@
+import type {
+ Connection,
+ ConnectionStatus,
+ ConnectionType,
+ NewConnection,
+} from "@app/core/stores/deviceStore/types";
+import { randId } from "@app/core/utils/randId";
+import { Bluetooth, Cable, Globe, type LucideIcon } from "lucide-react";
+
+export function createConnectionFromInput(input: NewConnection): Connection {
+ const base = {
+ id: randId(),
+ name: input.name,
+ createdAt: Date.now(),
+ status: "disconnected" as ConnectionStatus,
+ };
+ if (input.type === "http") {
+ return {
+ ...base,
+ type: "http",
+ url: input.url,
+ isDefault: false,
+ name: input.name.length === 0 ? input.url : input.name,
+ };
+ }
+ if (input.type === "bluetooth") {
+ return {
+ ...base,
+ type: "bluetooth",
+ deviceId: input.deviceId,
+ deviceName: input.deviceName,
+ gattServiceUUID: input.gattServiceUUID,
+ };
+ }
+ return {
+ ...base,
+ type: "serial",
+ usbVendorId: input.usbVendorId,
+ usbProductId: input.usbProductId,
+ };
+}
+
+export async function testHttpReachable(
+ url: string,
+ timeoutMs = 2500,
+): Promise