refactor: device connection logic, added nonce to get config only (#946)

* refactor: device connection logic, added nonce to get config only on connect.

* Update packages/web/src/core/services/MeshService.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/src/pages/Connections/useConnections.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* code review fixes

* fixes from code review

* ui fixes

* refactored meshService, moved code into deviceStore. Fixed some connnection issues

* formatting fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dan Ditomaso
2025-11-11 20:56:22 -05:00
committed by GitHub
co-authored by Copilot
parent 7f21b3b531
commit 648a9c3640
17 changed files with 662 additions and 213 deletions
@@ -52,9 +52,17 @@ type DeviceData = {
waypoints: WaypointWithMetadata[];
neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
};
export type ConnectionPhase =
| "disconnected"
| "connecting"
| "configuring"
| "configured";
export interface Device extends DeviceData {
// Ephemeral state (not persisted)
status: Types.DeviceStatusEnum;
connectionPhase: ConnectionPhase;
connectionId: ConnectionId | null;
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
config: Protobuf.LocalOnly.LocalConfig;
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
@@ -70,6 +78,8 @@ export interface Device extends DeviceData {
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;
getEffectiveConfig<K extends ValidConfigType>(
@@ -153,6 +163,16 @@ export interface deviceState {
) => 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 {
@@ -185,6 +205,8 @@ function deviceFactory(
neighborInfo,
status: Types.DeviceStatusEnum.DeviceDisconnected,
connectionPhase: "disconnected",
connectionId: null,
channels: new Map(),
config: create(Protobuf.LocalOnly.LocalConfigSchema),
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
@@ -227,6 +249,26 @@ function deviceFactory(
}),
);
},
setConnectionPhase: (phase: ConnectionPhase) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.connectionPhase = phase;
}
}),
);
},
setConnectionId: (connectionId: ConnectionId | null) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.connectionId = connectionId;
}
}),
);
},
setConfig: (config: Protobuf.Config.Config) => {
set(
produce<PrivateDeviceState>((draft) => {
@@ -907,6 +949,7 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
) => ({
devices: new Map(),
savedConnections: [],
activeConnectionId: null,
addDevice: (id) => {
const existing = get().devices.get(id);
@@ -972,6 +1015,33 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
);
},
getSavedConnections: () => get().savedConnections,
setActiveConnectionId: (id) => {
set(
produce<PrivateDeviceState>((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<PrivateDeviceState, DevicePersisted> = {
@@ -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";
});
}
+16
View File
@@ -14,6 +14,22 @@ export {
} from "@core/hooks/useDeviceContext";
export { useAppStore } from "@core/stores/appStore/index.ts";
export { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
export {
useActiveConnection,
useActiveConnectionId,
useAddSavedConnection,
useConnectionError,
useConnectionForDevice,
useConnectionStatus,
useDefaultConnection,
useDeviceForConnection,
useFirstSavedConnection,
useIsConnected,
useIsConnecting,
useRemoveSavedConnection,
useSavedConnections,
useUpdateSavedConnection,
} from "@core/stores/deviceStore/selectors.ts";
export type {
Page,
ValidConfigType,
+108 -14
View File
@@ -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";
@@ -15,7 +14,7 @@ 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
type NodeDBData = {
// Persisted data
@@ -30,6 +29,7 @@ export interface NodeDB extends NodeDBData {
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
removeNode: (nodeNum: number) => void;
removeAllNodes: (keepMyNode?: boolean) => void;
pruneStaleNodes: () => number;
processPacket: (data: ProcessPacketParams) => void;
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
@@ -90,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,
@@ -105,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"})`,
);
}
}),
),
@@ -145,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<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>();
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<PrivateNodeDBState>((draft) => {
@@ -220,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"})`,
);
}
}),
),
@@ -235,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}`,
);
}
}),
),
@@ -411,6 +503,8 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
addNodeDB: (id) => {
const existing = get().nodeDBs.get(id);
if (existing) {
// Prune stale nodes when accessing existing nodeDB
existing.pruneStaleNodes();
return existing;
}
@@ -418,12 +512,12 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
set(
produce<PrivateNodeDBState>((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) => {
@@ -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([]),
@@ -27,6 +30,4 @@ export const mockNodeDBStore: NodeDB = {
updateFavorite: vi.fn(),
updateIgnore: vi.fn(),
setNodeNum: vi.fn(),
removeAllNodeErrors: vi.fn(),
removeAllNodes: vi.fn(),
};
@@ -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
+4 -5
View File
@@ -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) => {