diff --git a/.gitignore b/.gitignore index 5c67a54a..19d31681 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __screenshots__* npm/ .idea **/LICENSE +.DS_Store packages/protobufs/packages/ts/dist diff --git a/packages/core/src/utils/eventSystem.ts b/packages/core/src/utils/eventSystem.ts index 81b24d34..f139c0d9 100644 --- a/packages/core/src/utils/eventSystem.ts +++ b/packages/core/src/utils/eventSystem.ts @@ -387,4 +387,12 @@ export class EventSystem { */ public readonly onQueueStatus: SimpleEventDispatcher = new SimpleEventDispatcher(); + + /** + * Fires when a configCompleteId message is received from the device + * + * @event onConfigComplete + */ + public readonly onConfigComplete: SimpleEventDispatcher = + new SimpleEventDispatcher(); } diff --git a/packages/core/src/utils/transform/decodePacket.ts b/packages/core/src/utils/transform/decodePacket.ts index 257ff0bf..06896403 100644 --- a/packages/core/src/utils/transform/decodePacket.ts +++ b/packages/core/src/utils/transform/decodePacket.ts @@ -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; } diff --git a/packages/web/src/components/DeviceInfoPanel.tsx b/packages/web/src/components/DeviceInfoPanel.tsx index 66d90e77..a6e4a987 100644 --- a/packages/web/src/components/DeviceInfoPanel.tsx +++ b/packages/web/src/components/DeviceInfoPanel.tsx @@ -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 ( <> -
- - {!isCollapsed && ( -

- {user.longName} -

- )} -
+ {user && ( +
+ + {!isCollapsed && ( +

+ {user.longName} +

+ )} +
+ )} {connectionStatus && ( ); diff --git a/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx b/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx index 5592ca90..1e358c8d 100644 --- a/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx +++ b/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx @@ -16,7 +16,6 @@ export const NodeMarker = memo(function NodeMarker({ id, lng, lat, - label, longLabel, tooltipLabel, hasError, diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx index 432f24e4..a5cf71a0 100644 --- a/packages/web/src/components/Sidebar.tsx +++ b/packages/web/src/components/Sidebar.tsx @@ -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(/^\//, ""), diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index c003d982..5b04caea 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -52,9 +52,17 @@ type DeviceData = { waypoints: WaypointWithMetadata[]; neighborInfo: Map; }; +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; 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( @@ -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((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) => { @@ -907,6 +949,7 @@ export const deviceStoreInitializer: StateCreator = ( ) => ({ devices: new Map(), savedConnections: [], + activeConnectionId: null, addDevice: (id) => { const existing = get().devices.get(id); @@ -972,6 +1015,33 @@ export const deviceStoreInitializer: StateCreator = ( ); }, 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 = { 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 00000000..7a785270 --- /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/index.ts b/packages/web/src/core/stores/index.ts index fa9b06f9..2d342840 100644 --- a/packages/web/src/core/stores/index.ts +++ b/packages/web/src/core/stores/index.ts @@ -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, diff --git a/packages/web/src/core/stores/nodeDBStore/index.ts b/packages/web/src/core/stores/nodeDBStore/index.ts index e68fdaf6..632cc757 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"; @@ -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) => void; addPosition: (position: Types.PacketMetadata) => 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((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) => { @@ -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 = ( 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 = ( 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) => { diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts index 0ffb71ca..f01daa99 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([]), @@ -27,6 +30,4 @@ export const mockNodeDBStore: NodeDB = { updateFavorite: vi.fn(), updateIgnore: vi.fn(), setNodeNum: vi.fn(), - removeAllNodeErrors: vi.fn(), - removeAllNodes: vi.fn(), }; diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx index ffc91185..fadb65d4 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 diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 78f0bd80..f20f5443 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/pages/Connections/index.tsx b/packages/web/src/pages/Connections/index.tsx index 1d5930ac..69686970 100644 --- a/packages/web/src/pages/Connections/index.tsx +++ b/packages/web/src/pages/Connections/index.tsx @@ -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 = () => {
@@ -131,13 +133,13 @@ export const Connections = () => { ) : ( -
+
{sorted.map((c) => ( { 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({

) : connection.lastConnectedAt ? (

- {t("lastConnectedAt", { - date: new Date(connection.lastConnectedAt), - })} - :{new Date(connection.lastConnectedAt).toLocaleString()} + {t("lastConnectedAt", { date: "" })}{" "} +

) : (

diff --git a/packages/web/src/pages/Connections/useConnections.ts b/packages/web/src/pages/Connections/useConnections.ts index b495c473..206e24ab 100644 --- a/packages/web/src/pages/Connections/useConnections.ts +++ b/packages/web/src/pages/Connections/useConnections.ts @@ -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; - serial: Map; - meshDevices: Map; -}; +// 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); @@ -37,11 +39,9 @@ export function useConnections() { (s) => s.removeSavedConnection, ); - const live = useRef({ - 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 }).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 }; + 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> | Awaited> | Awaited>, - 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; - 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; + 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]);