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
parent 7f21b3b531
commit 648a9c3640
17 changed files with 662 additions and 213 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ __screenshots__*
npm/
.idea
**/LICENSE
.DS_Store
packages/protobufs/packages/ts/dist

View File

@@ -387,4 +387,12 @@ export class EventSystem {
*/
public readonly onQueueStatus: SimpleEventDispatcher<Protobuf.Mesh.QueueStatus> =
new SimpleEventDispatcher<Protobuf.Mesh.QueueStatus>();
/**
* Fires when a configCompleteId message is received from the device
*
* @event onConfigComplete
*/
public readonly onConfigComplete: SimpleEventDispatcher<number> =
new SimpleEventDispatcher<number>();
}

View File

@@ -135,21 +135,27 @@ export const decodePacket = (device: MeshDevice) =>
}
case "configCompleteId": {
if (decodedMessage.payloadVariant.value !== device.configId) {
device.log.error(
Types.Emitter[Types.Emitter.HandleFromRadio],
`❌ Invalid config id received from device, expected ${device.configId} but received ${decodedMessage.payloadVariant.value}`,
);
}
device.log.info(
Types.Emitter[Types.Emitter.HandleFromRadio],
`⚙️ Valid config id received from device: ${device.configId}`,
`⚙️ Received config complete id: ${decodedMessage.payloadVariant.value}`,
);
device.updateDeviceStatus(
Types.DeviceStatusEnum.DeviceConfigured,
// Emit the configCompleteId event for MeshService to handle two-stage flow
device.events.onConfigComplete.dispatch(
decodedMessage.payloadVariant.value,
);
// For backward compatibility: if configId matches, update device status
// MeshService will override this behavior for two-stage flow
if (decodedMessage.payloadVariant.value === device.configId) {
device.log.info(
Types.Emitter[Types.Emitter.HandleFromRadio],
`⚙️ Config id matches device.configId: ${device.configId}`,
);
device.updateDeviceStatus(
Types.DeviceStatusEnum.DeviceConfigured,
);
}
break;
}

View File

@@ -26,7 +26,7 @@ interface DeviceInfoPanelProps {
isCollapsed: boolean;
deviceMetrics: DeviceMetrics;
firmwareVersion: string;
user: Protobuf.Mesh.User;
user?: Protobuf.Mesh.User;
setDialogOpen: () => void;
setCommandPaletteOpen: () => void;
disableHover?: boolean;
@@ -70,8 +70,12 @@ export const DeviceInfoPanel = ({
}
switch (status) {
case "connected":
case "configured":
case "online":
return "bg-emerald-500";
case "connecting":
case "configuring":
case "disconnecting":
return "bg-amber-500";
case "error":
return "bg-red-500";
@@ -84,6 +88,10 @@ export const DeviceInfoPanel = ({
if (!status) {
return t("unknown.notAvailable", "N/A");
}
// Show "connected" for configured state
if (status === "configured") {
return t("toasts.connected", { ns: "connections" });
}
return status;
};
@@ -135,28 +143,30 @@ export const DeviceInfoPanel = ({
return (
<>
<div
className={cn(
"flex items-center gap-3 p-1 flex-shrink-0",
isCollapsed && "justify-center",
)}
>
<Avatar
nodeNum={parseInt(user.id.slice(1), 16)}
className={cn("flex-shrink-0", isCollapsed && "")}
size="sm"
/>
{!isCollapsed && (
<p
className={cn(
"text-sm font-medium text-gray-800 dark:text-gray-200",
"transition-opacity duration-300 ease-in-out truncate",
)}
>
{user.longName}
</p>
)}
</div>
{user && (
<div
className={cn(
"flex items-center gap-3 p-1 flex-shrink-0",
isCollapsed && "justify-center",
)}
>
<Avatar
nodeNum={parseInt(user.id.slice(1), 16)}
className={cn("flex-shrink-0", isCollapsed && "")}
size="sm"
/>
{!isCollapsed && (
<p
className={cn(
"text-sm font-medium text-gray-800 dark:text-gray-200",
"transition-opacity duration-300 ease-in-out truncate",
)}
>
{user.longName}
</p>
)}
</div>
)}
{connectionStatus && (
<button

View File

@@ -1,6 +1,9 @@
import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx";
import { Switch } from "@app/components/UI/Switch.tsx";
import type { NewConnection } from "@app/core/stores/deviceStore/types.ts";
import type {
ConnectionType,
NewConnection,
} from "@app/core/stores/deviceStore/types.ts";
import { testHttpReachable } from "@app/pages/Connections/utils";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
@@ -34,7 +37,7 @@ import { Trans, useTranslation } from "react-i18next";
import { DialogWrapper } from "../DialogWrapper.tsx";
import { urlOrIpv4Schema } from "./validation.ts";
type TabKey = "http" | "bluetooth" | "serial";
type TabKey = ConnectionType;
type TestingStatus = "idle" | "testing" | "success" | "failure";
type DialogState = {
tab: TabKey;
@@ -390,12 +393,6 @@ export default function AddConnectionDialog({
const reachable = await testHttpReachable(validatedURL.data);
if (reachable) {
dispatch({ type: "SET_TEST_STATUS", payload: "success" });
toast({
title: t("addConnection.httpConnection.connectionTest.success.title"),
description: t(
"addConnection.httpConnection.connectionTest.success.description",
),
});
} else {
dispatch({ type: "SET_TEST_STATUS", payload: "failure" });
toast({

View File

@@ -7,12 +7,16 @@ export function ConnectionStatusBadge({
status: Connection["status"];
}) {
let color = "";
let displayStatus = status;
switch (status) {
case "connected":
case "configured":
color = "bg-emerald-500";
displayStatus = "connected";
break;
case "connecting":
case "configuring":
color = "bg-amber-500";
break;
case "online":
@@ -31,7 +35,7 @@ export function ConnectionStatusBadge({
aria-hidden="true"
/>
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">
{status}
{displayStatus}
</span>
</Button>
);

View File

@@ -16,7 +16,6 @@ export const NodeMarker = memo(function NodeMarker({
id,
lng,
lat,
label,
longLabel,
tooltipLabel,
hasError,

View File

@@ -1,12 +1,14 @@
import { useFirstSavedConnection } from "@app/core/stores/deviceStore/selectors.ts";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import {
type Page,
useActiveConnection,
useAppStore,
useDefaultConnection,
useDevice,
useDeviceStore,
useNodeDB,
useSidebar,
} from "@core/stores";
@@ -71,17 +73,18 @@ export const Sidebar = ({ children }: SidebarProps) => {
const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
const { getNode, getNodesLength } = useNodeDB();
const { setCommandPaletteOpen } = useAppStore();
const savedConnections = useDeviceStore((s) => s.savedConnections);
const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar();
const { t } = useTranslation("ui");
const navigate = useNavigate({ from: "/" });
// Get the active connection (connected > default > first)
// Get the active connection from selector (connected > default > first)
const activeConnection =
savedConnections.find((c) => c.status === "connected") ||
savedConnections.find((c) => c.isDefault) ||
savedConnections[0];
useActiveConnection() ||
// biome-ignore lint/correctness/useHookAtTopLevel: not a react hook
useDefaultConnection() ||
// biome-ignore lint/correctness/useHookAtTopLevel: not a hook
useFirstSavedConnection();
const pathname = useLocation({
select: (location) => location.pathname.replace(/^\//, ""),

View File

@@ -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> = {

View File

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

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,

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) => {

View File

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

View File

@@ -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

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) => {

View File

@@ -1,4 +1,5 @@
import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
import { TimeAgo } from "@app/components/generic/TimeAgo";
import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
import type { Connection } from "@app/core/stores/deviceStore/types";
import { useConnections } from "@app/pages/Connections/useConnections";
@@ -39,8 +40,8 @@ import {
ArrowLeft,
LinkIcon,
MoreHorizontal,
PlugZap,
RotateCw,
RouterIcon,
Star,
StarOff,
Trash2,
@@ -71,7 +72,6 @@ export const Connections = () => {
syncConnectionStatuses();
refreshStatuses();
}, []);
const sorted = useMemo(() => {
const copy = [...connections];
return copy.sort((a, b) => {
@@ -81,7 +81,9 @@ export const Connections = () => {
if (!a.isDefault && b.isDefault) {
return 1;
}
if (a.status === "connected" && b.status !== "connected") {
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);
@@ -111,7 +113,7 @@ export const Connections = () => {
</div>
<div className="flex items-center ml-2 gap-2">
<Button onClick={() => setAddOpen(true)} className="gap-2">
<PlugZap className="size-4" />
<RouterIcon className="size-5" />
{t("button.addConnection")}
</Button>
</div>
@@ -131,13 +133,13 @@ export const Connections = () => {
</CardContent>
<CardFooter>
<Button onClick={() => setAddOpen(true)} className="gap-2">
<PlugZap className="size-4" />
<RouterIcon className="size-5" />
{t("button.addConnection")}
</Button>
</CardFooter>
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{sorted.map((c) => (
<ConnectionCard
key={c.id}
@@ -223,7 +225,10 @@ export const Connections = () => {
interpolation: { escapeValue: false },
}),
});
if (created.status === "connected") {
if (
created.status === "connected" ||
created.status === "configured"
) {
navigate({ to: "/" });
}
} else {
@@ -268,8 +273,10 @@ function ConnectionCard({
const { t } = useTranslation("connections");
const Icon = connectionTypeIcon(connection.type);
const isBusy = connection.status === "connecting";
const isConnected = connection.status === "connected";
const isBusy =
connection.status === "connecting" || connection.status === "configuring";
const isConnected =
connection.status === "connected" || connection.status === "configured";
const isError = connection.status === "error";
return (
@@ -367,10 +374,11 @@ function ConnectionCard({
</p>
) : connection.lastConnectedAt ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{t("lastConnectedAt", {
date: new Date(connection.lastConnectedAt),
})}
:{new Date(connection.lastConnectedAt).toLocaleString()}
{t("lastConnectedAt", { date: "" })}{" "}
<TimeAgo
timestamp={connection.lastConnectedAt}
className="text-sm text-slate-500 dark:text-slate-400"
/>
</p>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">

View File

@@ -20,13 +20,15 @@ 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, useRef } from "react";
import { useCallback } from "react";
type LiveRefs = {
bt: Map<ConnectionId, BluetoothDevice>;
serial: Map<ConnectionId, SerialPort>;
meshDevices: Map<ConnectionId, MeshDevice>;
};
// Local storage for cleanup only (not in Zustand)
const transports = new Map<ConnectionId, BluetoothDevice | SerialPort>();
const heartbeats = new Map<ConnectionId, ReturnType<typeof setInterval>>();
const configSubscriptions = new Map<ConnectionId, () => 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);
@@ -37,11 +39,9 @@ export function useConnections() {
(s) => s.removeSavedConnection,
);
const live = useRef<LiveRefs>({
bt: new Map(),
serial: new Map(),
meshDevices: new Map(),
});
// DeviceStore methods
const setActiveConnectionId = useDeviceStore((s) => s.setActiveConnectionId);
const { addDevice } = useDeviceStore();
const { addNodeDB } = useNodeDBStore();
const { addMessageStore } = useMessageStore();
@@ -62,37 +62,67 @@ export function useConnections() {
const removeConnection = useCallback(
(id: ConnectionId) => {
// Disconnect MeshDevice first
const meshDevice = live.current.meshDevices.get(id);
if (meshDevice) {
try {
meshDevice.disconnect();
} catch {}
live.current.meshDevices.delete(id);
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}`);
}
// Close live refs if open
const bt = live.current.bt.get(id);
if (bt?.gatt?.connected) {
try {
bt.gatt.disconnect();
} catch {
// Ignore errors
}
// 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}`,
);
}
const sp = live.current.serial.get(id);
if (sp && "close" in sp) {
try {
(sp as SerialPort & { close: () => Promise<void> }).close();
} catch {
// Ignore errors
// 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<void> };
if (sp.close) {
try {
sp.close();
} catch {}
}
transports.delete(id);
}
// Clean up orphaned Device
try {
removeDevice(conn.meshDeviceId);
} catch {}
}
live.current.bt.delete(id);
live.current.serial.delete(id);
removeSavedConnectionFromStore(id);
},
[removeSavedConnectionFromStore],
[connections, removeSavedConnectionFromStore],
);
const setDefaultConnection = useCallback(
@@ -115,36 +145,114 @@ export function useConnections() {
| Awaited<ReturnType<typeof TransportHTTP.create>>
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>,
options?: {
setHeartbeat?: boolean;
onDisconnect?: () => void;
},
btDevice?: BluetoothDevice,
serialPort?: SerialPort,
): number => {
const deviceId = randId();
// 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);
meshDevice.configure();
setSelectedDevice(deviceId);
device.addConnection(meshDevice);
subscribeAll(device, meshDevice, messageStore, nodeDB);
live.current.meshDevices.set(id, meshDevice);
if (options?.setHeartbeat) {
const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
meshDevice.setHeartbeatInterval(HEARTBEAT_INTERVAL);
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,
],
);
@@ -154,7 +262,7 @@ export function useConnections() {
if (!conn) {
return false;
}
if (conn.status === "connected") {
if (conn.status === "configured" || conn.status === "connected") {
return true;
}
@@ -175,7 +283,7 @@ export function useConnections() {
const isTLS = url.protocol === "https:";
const transport = await TransportHTTP.create(url.host, isTLS);
setupMeshDevice(id, transport);
updateStatus(id, "connected");
// Status will be set to "configured" by onConfigComplete event
return true;
}
@@ -183,7 +291,7 @@ export function useConnections() {
if (!("bluetooth" in navigator)) {
throw new Error("Web Bluetooth not supported");
}
let bleDevice = live.current.bt.get(id);
let bleDevice = transports.get(id) as BluetoothDevice | undefined;
if (!bleDevice) {
// Try to recover permitted devices
const getDevices = (
@@ -198,10 +306,6 @@ export function useConnections() {
bleDevice = known.find(
(d: BluetoothDevice) => d.id === conn.deviceId,
);
// If found, store it for future use
if (bleDevice) {
live.current.bt.set(id, bleDevice);
}
}
}
}
@@ -222,17 +326,16 @@ export function useConnections() {
"Bluetooth device not available. Re-select the device.",
);
}
live.current.bt.set(id, bleDevice);
const transport =
await TransportWebBluetooth.createFromDevice(bleDevice);
setupMeshDevice(id, transport, { setHeartbeat: true });
setupMeshDevice(id, transport, bleDevice);
bleDevice.addEventListener("gattserverdisconnected", () => {
updateStatus(id, "disconnected");
});
updateStatus(id, "connected");
// Status will be set to "configured" by onConfigComplete event
return true;
}
@@ -240,7 +343,7 @@ export function useConnections() {
if (!("serial" in navigator)) {
throw new Error("Web Serial not supported");
}
let port = live.current.serial.get(id);
let port = transports.get(id) as SerialPort | undefined;
if (!port) {
// Find a previously granted port by vendor/product
const ports: SerialPort[] = await (
@@ -296,11 +399,9 @@ export function useConnections() {
}
}
live.current.serial.set(id, port);
const transport = await TransportWebSerial.createFromPort(port);
setupMeshDevice(id, transport, { setHeartbeat: true });
updateStatus(id, "connected");
setupMeshDevice(id, transport, undefined, port);
// Status will be set to "configured" by onConfigComplete event
return true;
}
} catch (err: unknown) {
@@ -320,39 +421,68 @@ export function useConnections() {
return;
}
try {
// Disconnect MeshDevice first
const meshDevice = live.current.meshDevices.get(id);
if (meshDevice) {
try {
meshDevice.disconnect();
} catch {
// Ignore errors
}
live.current.meshDevices.delete(id);
// Stop heartbeat
const heartbeatId = heartbeats.get(id);
if (heartbeatId) {
clearInterval(heartbeatId);
heartbeats.delete(id);
console.log(
`[useConnections] Heartbeat stopped for connection ${id}`,
);
}
if (conn.type === "bluetooth") {
const dev = live.current.bt.get(id);
if (dev?.gatt?.connected) {
dev.gatt.disconnect();
}
// 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}`,
);
}
if (conn.type === "serial") {
const port = live.current.serial.get(id);
if (port) {
// 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 {
const portWithClose = port as SerialPort & {
close: () => Promise<void>;
readable: ReadableStream | null;
};
// Only close if the port is open (has readable stream)
if (portWithClose.readable) {
await portWithClose.close();
}
} catch (err) {
console.warn("Error closing serial port:", err);
device.connection.disconnect();
} catch {
// Ignore errors
}
live.current.serial.delete(id);
}
// 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<void>;
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 {
@@ -379,7 +509,7 @@ export function useConnections() {
const conn = addConnection(input);
// If a Bluetooth device was provided, store it to avoid re-prompting
if (btDevice && conn.type === "bluetooth") {
live.current.bt.set(conn.id, btDevice);
transports.set(conn.id, btDevice);
}
await connect(conn.id, { allowPrompt: true });
// Get updated connection from store after connect
@@ -395,11 +525,14 @@ export function useConnections() {
// HTTP: test endpoint reachability
// Bluetooth/Serial: check permission grants
// HTTP connections: test reachability if not already connected
// 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.type === "http" &&
c.status !== "connected" &&
c.status !== "configured" &&
c.status !== "configuring",
)
.map(async (c) => {
const ok = await testHttpReachable(c.url);
@@ -412,7 +545,10 @@ export function useConnections() {
const btChecks = connections
.filter(
(c): c is Connection & { type: "bluetooth"; deviceId?: string } =>
c.type === "bluetooth" && c.status !== "connected",
c.type === "bluetooth" &&
c.status !== "connected" &&
c.status !== "configured" &&
c.status !== "configuring",
)
.map(async (c) => {
if (!("bluetooth" in navigator)) {
@@ -445,7 +581,11 @@ export function useConnections() {
type: "serial";
usbVendorId?: number;
usbProductId?: number;
} => c.type === "serial" && c.status !== "connected",
} =>
c.type === "serial" &&
c.status !== "connected" &&
c.status !== "configured" &&
c.status !== "configuring",
)
.map(async (c) => {
if (!("serial" in navigator)) {
@@ -493,13 +633,16 @@ export function useConnections() {
// 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 && conn.status !== "connected") {
updateSavedConnection(conn.id, { status: "connected" });
} else if (!shouldBeConnected && conn.status === "connected") {
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]);