import type { Connection, ConnectionId, ConnectionStatus, NewConnection, } from "@app/core/stores/deviceStore/types"; import { createConnectionFromInput, testHttpReachable, } from "@app/pages/Connections/utils"; import { useAppStore, useDeviceStore, useMessageStore, useNodeDBStore, } from "@core/stores"; import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; import { MeshDevice } from "@meshtastic/core"; import { TransportHTTP } from "@meshtastic/transport-http"; import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { useCallback } from "react"; // Local storage for cleanup only (not in Zustand) const transports = new Map(); const heartbeats = new Map>(); const configSubscriptions = new Map void>(); const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes const CONFIG_HEARTBEAT_INTERVAL_MS = 5000; // 5s during configuration export function useConnections() { const connections = useDeviceStore((s) => s.savedConnections); const addSavedConnection = useDeviceStore((s) => s.addSavedConnection); const updateSavedConnection = useDeviceStore((s) => s.updateSavedConnection); const removeSavedConnectionFromStore = useDeviceStore( (s) => s.removeSavedConnection, ); // DeviceStore methods const setActiveConnectionId = useDeviceStore((s) => s.setActiveConnectionId); const { addDevice } = useDeviceStore(); const { addNodeDB } = useNodeDBStore(); const { addMessageStore } = useMessageStore(); const { setSelectedDevice } = useAppStore(); const selectedDeviceId = useAppStore((s) => s.selectedDeviceId); const updateStatus = useCallback( (id: ConnectionId, status: ConnectionStatus, error?: string) => { const updates: Partial = { status, error: error || undefined, ...(status === "disconnected" ? { lastConnectedAt: Date.now() } : {}), }; updateSavedConnection(id, updates); }, [updateSavedConnection], ); const removeConnection = useCallback( (id: ConnectionId) => { const conn = connections.find((c) => c.id === id); // Stop heartbeat const heartbeatId = heartbeats.get(id); if (heartbeatId) { clearInterval(heartbeatId); heartbeats.delete(id); console.log(`[useConnections] Heartbeat stopped for connection ${id}`); } // Unsubscribe from config complete event const unsubConfigComplete = configSubscriptions.get(id); if (unsubConfigComplete) { unsubConfigComplete(); configSubscriptions.delete(id); console.log( `[useConnections] Config subscription cleaned up for connection ${id}`, ); } // Get device and MeshDevice from Device.connection if (conn?.meshDeviceId) { const { getDevice, removeDevice } = useDeviceStore.getState(); const device = getDevice(conn.meshDeviceId); if (device?.connection) { // Disconnect MeshDevice try { device.connection.disconnect(); } catch {} } // Close transport if it's BT or Serial const transport = transports.get(id); if (transport) { const bt = transport as BluetoothDevice; if (bt.gatt?.connected) { try { bt.gatt.disconnect(); } catch {} } const sp = transport as SerialPort & { close?: () => Promise }; if (sp.close) { try { sp.close(); } catch {} } transports.delete(id); } // Clean up orphaned Device try { removeDevice(conn.meshDeviceId); } catch {} } removeSavedConnectionFromStore(id); }, [connections, removeSavedConnectionFromStore], ); const setDefaultConnection = useCallback( (id: ConnectionId) => { for (const connection of connections) { if (connection.id === id) { updateSavedConnection(connection.id, { isDefault: !connection.isDefault, }); } } }, [connections, updateSavedConnection], ); const setupMeshDevice = useCallback( ( id: ConnectionId, transport: | Awaited> | Awaited> | Awaited>, btDevice?: BluetoothDevice, serialPort?: SerialPort, ): number => { // Reuse existing meshDeviceId if available to prevent duplicate nodeDBs, // but only if the corresponding nodeDB still exists. Otherwise, generate a new ID. const conn = connections.find((c) => c.id === id); let deviceId = conn?.meshDeviceId; if (deviceId && !useNodeDBStore.getState().getNodeDB(deviceId)) { deviceId = undefined; } deviceId = deviceId ?? randId(); const device = addDevice(deviceId); const nodeDB = addNodeDB(deviceId); const messageStore = addMessageStore(deviceId); const meshDevice = new MeshDevice(transport, deviceId); setSelectedDevice(deviceId); device.addConnection(meshDevice); // This stores meshDevice in Device.connection subscribeAll(device, meshDevice, messageStore, nodeDB); // Store transport locally for cleanup (BT/Serial only) if (btDevice || serialPort) { transports.set(id, btDevice || serialPort); } // Set active connection and link device bidirectionally setActiveConnectionId(id); device.setConnectionId(id); // Listen for config complete event (with nonce/ID) const unsubConfigComplete = meshDevice.events.onConfigComplete.subscribe( (configCompleteId) => { console.log( `[useConnections] Configuration complete with ID: ${configCompleteId}`, ); device.setConnectionPhase("configured"); updateStatus(id, "configured"); // Switch from fast config heartbeat to slow maintenance heartbeat const oldHeartbeat = heartbeats.get(id); if (oldHeartbeat) { clearInterval(oldHeartbeat); console.log( `[useConnections] Switching to maintenance heartbeat (5 min interval)`, ); } const maintenanceHeartbeat = setInterval(() => { meshDevice.heartbeat().catch((error) => { console.warn("[useConnections] Heartbeat failed:", error); }); }, HEARTBEAT_INTERVAL_MS); heartbeats.set(id, maintenanceHeartbeat); }, ); configSubscriptions.set(id, unsubConfigComplete); // Start configuration device.setConnectionPhase("configuring"); updateStatus(id, "configuring"); console.log("[useConnections] Starting configuration"); meshDevice .configure() .then(() => { console.log( "[useConnections] Configuration complete, starting heartbeat", ); // Send initial heartbeat after configure completes meshDevice .heartbeat() .then(() => { // Start fast heartbeat after first successful heartbeat const configHeartbeatId = setInterval(() => { meshDevice.heartbeat().catch((error) => { console.warn( "[useConnections] Config heartbeat failed:", error, ); }); }, CONFIG_HEARTBEAT_INTERVAL_MS); heartbeats.set(id, configHeartbeatId); console.log( `[useConnections] Heartbeat started for connection ${id} (5s interval during config)`, ); }) .catch((error) => { console.warn("[useConnections] Initial heartbeat failed:", error); }); }) .catch((error) => { console.error(`[useConnections] Failed to configure:`, error); updateStatus(id, "error", error.message); }); updateSavedConnection(id, { meshDeviceId: deviceId }); return deviceId; }, [ connections, addDevice, addNodeDB, addMessageStore, setSelectedDevice, setActiveConnectionId, updateSavedConnection, updateStatus, ], ); const connect = useCallback( async (id: ConnectionId, opts?: { allowPrompt?: boolean }) => { const conn = connections.find((c) => c.id === id); if (!conn) { return false; } if (conn.status === "configured" || conn.status === "connected") { return true; } updateStatus(id, "connecting"); try { if (conn.type === "http") { const ok = await testHttpReachable(conn.url); if (!ok) { const url = new URL(conn.url); const isHTTPS = url.protocol === "https:"; const message = isHTTPS ? `Cannot reach HTTPS endpoint. If using a self-signed certificate, open ${conn.url} in a new tab, accept the certificate warning, then try connecting again.` : "HTTP endpoint not reachable (may be blocked by CORS)"; throw new Error(message); } const url = new URL(conn.url); const isTLS = url.protocol === "https:"; const transport = await TransportHTTP.create(url.host, isTLS); setupMeshDevice(id, transport); // Status will be set to "configured" by onConfigComplete event return true; } if (conn.type === "bluetooth") { if (!("bluetooth" in navigator)) { throw new Error("Web Bluetooth not supported"); } let bleDevice = transports.get(id) as BluetoothDevice | undefined; if (!bleDevice) { // Try to recover permitted devices const getDevices = ( navigator.bluetooth as Navigator["bluetooth"] & { getDevices?: () => Promise; } ).getDevices; if (getDevices) { const known = await getDevices(); if (known && known.length > 0 && conn.deviceId) { bleDevice = known.find( (d: BluetoothDevice) => d.id === conn.deviceId, ); } } } if (!bleDevice && opts?.allowPrompt) { // Prompt user to reselect (filter by optional service if provided) bleDevice = await navigator.bluetooth.requestDevice({ acceptAllDevices: !conn.gattServiceUUID, optionalServices: conn.gattServiceUUID ? [conn.gattServiceUUID] : undefined, filters: conn.gattServiceUUID ? [{ services: [conn.gattServiceUUID] }] : undefined, }); } if (!bleDevice) { throw new Error( "Bluetooth device not available. Re-select the device.", ); } const transport = await TransportWebBluetooth.createFromDevice(bleDevice); setupMeshDevice(id, transport, bleDevice); bleDevice.addEventListener("gattserverdisconnected", () => { updateStatus(id, "disconnected"); }); // Status will be set to "configured" by onConfigComplete event return true; } if (conn.type === "serial") { if (!("serial" in navigator)) { throw new Error("Web Serial not supported"); } let port = transports.get(id) as SerialPort | undefined; if (!port) { // Find a previously granted port by vendor/product const ports: SerialPort[] = await ( navigator as Navigator & { serial: { getPorts: () => Promise }; } ).serial.getPorts(); if (ports && conn.usbVendorId && conn.usbProductId) { port = ports.find((p: SerialPort) => { const info = ( p as SerialPort & { getInfo?: () => { usbVendorId?: number; usbProductId?: number; }; } ).getInfo?.() ?? {}; return ( info.usbVendorId === conn.usbVendorId && info.usbProductId === conn.usbProductId ); }); } } if (!port && opts?.allowPrompt) { port = await ( navigator as Navigator & { serial: { requestPort: ( options: Record, ) => Promise; }; } ).serial.requestPort({}); } if (!port) { throw new Error("Serial port not available. Re-select the port."); } // Ensure the port is closed before opening it const portWithStreams = port as SerialPort & { readable: ReadableStream | null; writable: WritableStream | null; close: () => Promise; }; if (portWithStreams.readable || portWithStreams.writable) { try { await portWithStreams.close(); await new Promise((resolve) => setTimeout(resolve, 100)); } catch (err) { console.warn("Error closing port before reconnect:", err); } } const transport = await TransportWebSerial.createFromPort(port); setupMeshDevice(id, transport, undefined, port); // Status will be set to "configured" by onConfigComplete event return true; } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); updateStatus(id, "error", message); return false; } return false; }, [connections, updateStatus, setupMeshDevice], ); const disconnect = useCallback( async (id: ConnectionId) => { const conn = connections.find((c) => c.id === id); if (!conn) { return; } try { // Stop heartbeat const heartbeatId = heartbeats.get(id); if (heartbeatId) { clearInterval(heartbeatId); heartbeats.delete(id); console.log( `[useConnections] Heartbeat stopped for connection ${id}`, ); } // Unsubscribe from config complete event const unsubConfigComplete = configSubscriptions.get(id); if (unsubConfigComplete) { unsubConfigComplete(); configSubscriptions.delete(id); console.log( `[useConnections] Config subscription cleaned up for connection ${id}`, ); } // Get device and meshDevice from Device.connection if (conn.meshDeviceId) { const { getDevice } = useDeviceStore.getState(); const device = getDevice(conn.meshDeviceId); if (device?.connection) { // Disconnect MeshDevice try { device.connection.disconnect(); } catch { // Ignore errors } } // Close transport connections const transport = transports.get(id); if (transport) { if (conn.type === "bluetooth") { const dev = transport as BluetoothDevice; if (dev.gatt?.connected) { dev.gatt.disconnect(); } } if (conn.type === "serial") { const port = transport as SerialPort & { close?: () => Promise; readable?: ReadableStream | null; }; if (port.close && port.readable) { try { await port.close(); } catch (err) { console.warn("Error closing serial port:", err); } } } } // Clear the device's connectionId link if (device) { device.setConnectionId(null); device.setConnectionPhase("disconnected"); } } } finally { updateSavedConnection(id, { status: "disconnected", error: undefined, }); } }, [connections, updateSavedConnection], ); const addConnection = useCallback( (input: NewConnection) => { const conn = createConnectionFromInput(input); addSavedConnection(conn); return conn; }, [addSavedConnection], ); const addConnectionAndConnect = useCallback( async (input: NewConnection, btDevice?: BluetoothDevice) => { const conn = addConnection(input); // If a Bluetooth device was provided, store it to avoid re-prompting if (btDevice && conn.type === "bluetooth") { transports.set(conn.id, btDevice); } await connect(conn.id, { allowPrompt: true }); // Get updated connection from store after connect if (conn.id) { return conn; } }, [addConnection, connect], ); const refreshStatuses = useCallback(async () => { // Check reachability/availability without auto-connecting // HTTP: test endpoint reachability // Bluetooth/Serial: check permission grants // HTTP connections: test reachability if not already connected/configured const httpChecks = connections .filter( (c): c is Connection & { type: "http"; url: string } => c.type === "http" && c.status !== "connected" && c.status !== "configured" && c.status !== "configuring", ) .map(async (c) => { const ok = await testHttpReachable(c.url); updateSavedConnection(c.id, { status: ok ? "online" : "error", }); }); // Bluetooth connections: check permission grants const btChecks = connections .filter( (c): c is Connection & { type: "bluetooth"; deviceId?: string } => c.type === "bluetooth" && c.status !== "connected" && c.status !== "configured" && c.status !== "configuring", ) .map(async (c) => { if (!("bluetooth" in navigator)) { return; } try { const known = await ( navigator.bluetooth as Navigator["bluetooth"] & { getDevices?: () => Promise; } ).getDevices?.(); const hasPermission = known?.some( (d: BluetoothDevice) => d.id === c.deviceId, ); updateSavedConnection(c.id, { status: hasPermission ? "configured" : "disconnected", }); } catch { // getDevices not supported or failed updateSavedConnection(c.id, { status: "disconnected" }); } }); // Serial connections: check permission grants const serialChecks = connections .filter( ( c, ): c is Connection & { type: "serial"; usbVendorId?: number; usbProductId?: number; } => c.type === "serial" && c.status !== "connected" && c.status !== "configured" && c.status !== "configuring", ) .map(async (c) => { if (!("serial" in navigator)) { return; } try { const ports: SerialPort[] = await ( navigator as Navigator & { serial: { getPorts: () => Promise }; } ).serial.getPorts(); const hasPermission = ports.some((p: SerialPort) => { const info = ( p as SerialPort & { getInfo?: () => { usbVendorId?: number; usbProductId?: number; }; } ).getInfo?.() ?? {}; return ( info.usbVendorId === c.usbVendorId && info.usbProductId === c.usbProductId ); }); updateSavedConnection(c.id, { status: hasPermission ? "configured" : "disconnected", }); } catch { // getPorts failed updateSavedConnection(c.id, { status: "disconnected" }); } }); await Promise.all([...httpChecks, ...btChecks, ...serialChecks]); }, [connections, updateSavedConnection]); const syncConnectionStatuses = useCallback(() => { // Find which connection corresponds to the currently selected device const activeConnection = connections.find( (c) => c.meshDeviceId === selectedDeviceId, ); // Update all connection statuses connections.forEach((conn) => { const shouldBeConnected = activeConnection?.id === conn.id; const isConnectedState = conn.status === "connected" || conn.status === "configured" || conn.status === "configuring"; // Update status if it doesn't match reality if (!shouldBeConnected && isConnectedState) { updateSavedConnection(conn.id, { status: "disconnected" }); } // Don't force status to "connected" if shouldBeConnected - let the connection flow set the proper status }); }, [connections, selectedDeviceId, updateSavedConnection]); return { connections, addConnection, addConnectionAndConnect, connect, disconnect, removeConnection, setDefaultConnection, refreshStatuses, syncConnectionStatuses, }; }