diff --git a/packages/web/src/core/connections/heartbeat.ts b/packages/web/src/core/connections/heartbeat.ts new file mode 100644 index 00000000..f5534792 --- /dev/null +++ b/packages/web/src/core/connections/heartbeat.ts @@ -0,0 +1,45 @@ +import type { ConnectionId } from "@core/stores/deviceStore/types"; +import type { MeshDevice } from "@meshtastic/sdk"; + +const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes (post-config) +const CONFIG_HEARTBEAT_INTERVAL_MS = 5_000; // 5s (during initial config) + +const heartbeats = new Map>(); + +/** + * Stops + clears any active heartbeat for the connection. Safe to call when + * no heartbeat is running. + */ +export function stopHeartbeat(id: ConnectionId): void { + const h = heartbeats.get(id); + if (!h) return; + clearInterval(h); + heartbeats.delete(id); +} + +/** + * Fast-cadence heartbeat used while the device is in `configuring`. Replaced + * by the maintenance heartbeat once the device fires onConfigComplete. + */ +export function startConfigHeartbeat(id: ConnectionId, meshDevice: MeshDevice): void { + stopHeartbeat(id); + const intervalId = setInterval(() => { + meshDevice.heartbeat().catch((error) => { + console.warn("[heartbeat] config heartbeat failed:", error); + }); + }, CONFIG_HEARTBEAT_INTERVAL_MS); + heartbeats.set(id, intervalId); +} + +/** + * Slow-cadence keep-alive used after configuration completes. + */ +export function startMaintenanceHeartbeat(id: ConnectionId, meshDevice: MeshDevice): void { + stopHeartbeat(id); + const intervalId = setInterval(() => { + meshDevice.heartbeat().catch((error) => { + console.warn("[heartbeat] maintenance heartbeat failed:", error); + }); + }, HEARTBEAT_INTERVAL_MS); + heartbeats.set(id, intervalId); +} diff --git a/packages/web/src/core/connections/sdkClient.ts b/packages/web/src/core/connections/sdkClient.ts new file mode 100644 index 00000000..bf6439c5 --- /dev/null +++ b/packages/web/src/core/connections/sdkClient.ts @@ -0,0 +1,61 @@ +import { coordinator, getStorageDb } from "@core/sdkStorage.ts"; +import type { ConnectionId } from "@core/stores/deviceStore/types"; +import { MeshDevice } from "@meshtastic/sdk"; +import { + SqlocalDraftRepository, + SqlocalMessageRepository, +} from "@meshtastic/sdk-storage-sqlocal/chat"; +import { SqlocalNodesRepository } from "@meshtastic/sdk-storage-sqlocal/nodes"; +import { SqlocalTelemetryRepository } from "@meshtastic/sdk-storage-sqlocal/telemetry"; +import type { TransportHTTP } from "@meshtastic/transport-http"; +import type { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; +import type { TransportWebSerial } from "@meshtastic/transport-web-serial"; + +export type AnyTransport = + | Awaited> + | Awaited> + | Awaited>; + +const CHAT_RETENTION = { maxPerBucket: 1000, olderThanMs: 1000 * 60 * 60 * 24 * 90 } as const; +const TELEMETRY_RETENTION = { maxPerNode: 500, olderThanMs: 1000 * 60 * 60 * 24 * 30 } as const; + +/** + * Builds a MeshDevice wired with persistence repositories opened against the + * shared OPFS-backed SQLite DB. If sqlocal is unavailable the SDK falls back + * to its in-memory repositories transparently. + */ +export async function buildMeshDevice( + connectionId: ConnectionId, + deviceId: number, + transport: AnyTransport, +): Promise { + let chatRepository: SqlocalMessageRepository | undefined; + let draftRepository: SqlocalDraftRepository | undefined; + let nodesRepository: SqlocalNodesRepository | undefined; + let telemetryRepository: SqlocalTelemetryRepository | undefined; + try { + const db = await getStorageDb(); + chatRepository = new SqlocalMessageRepository(db, { deviceId: connectionId, coordinator }); + draftRepository = new SqlocalDraftRepository(db, { deviceId: connectionId }); + nodesRepository = new SqlocalNodesRepository(db, { deviceId: connectionId }); + telemetryRepository = new SqlocalTelemetryRepository(db, { deviceId: connectionId }); + } catch (err) { + console.warn("[sdkClient] sqlocal unavailable, falling back to in-memory:", err); + } + + return new MeshDevice(transport, { + configId: deviceId, + chat: + chatRepository || draftRepository + ? { + repository: chatRepository, + draftRepository, + retention: CHAT_RETENTION, + } + : undefined, + nodes: nodesRepository ? { repository: nodesRepository } : undefined, + telemetry: telemetryRepository + ? { repository: telemetryRepository, retention: TELEMETRY_RETENTION } + : undefined, + }); +} diff --git a/packages/web/src/core/connections/transports.ts b/packages/web/src/core/connections/transports.ts new file mode 100644 index 00000000..be009f56 --- /dev/null +++ b/packages/web/src/core/connections/transports.ts @@ -0,0 +1,238 @@ +import type { Connection } from "@core/stores/deviceStore/types"; +import { testHttpReachable } from "@pages/Connections/utils"; +import { TransportHTTP } from "@meshtastic/transport-http"; +import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; +import { TransportWebSerial } from "@meshtastic/transport-web-serial"; +import type { AnyTransport } from "./sdkClient.ts"; + +/** + * Per-transport-type factories. Each resolves a Transport from a saved + * Connection record + an optional cached BluetoothDevice/SerialPort the + * caller has held onto across reconnects. + */ + +export interface OpenTransportOptions { + /** Whether the user explicitly initiated this connection (allows BT/Serial pickers). */ + allowPrompt?: boolean; + /** Cached BT device from a prior connect, if any. */ + cachedBluetoothDevice?: BluetoothDevice; + /** Cached serial port from a prior connect, if any. */ + cachedSerialPort?: SerialPort; +} + +export interface OpenTransportResult { + transport: AnyTransport; + /** BT device (when type=bluetooth) so caller can cache for cleanup + reconnect. */ + bluetoothDevice?: BluetoothDevice; + /** Serial port (when type=serial) so caller can cache for cleanup + reconnect. */ + serialPort?: SerialPort; +} + +export async function openTransport( + conn: Connection, + opts: OpenTransportOptions = {}, +): Promise { + switch (conn.type) { + case "http": + return openHttp(conn); + case "bluetooth": + return openBluetooth(conn, opts); + case "serial": + return openSerial(conn, opts); + default: { + const _exhaustive: never = conn; + void _exhaustive; + throw new Error(`Unknown transport type: ${(conn as { type?: string }).type}`); + } + } +} + +async function openHttp( + conn: Connection & { type: "http"; url: string }, +): Promise { + const ok = await testHttpReachable(conn.url); + if (!ok) { + const url = new URL(conn.url); + const isHTTPS = url.protocol === "https:"; + throw new Error( + 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)", + ); + } + const url = new URL(conn.url); + const isTLS = url.protocol === "https:"; + const transport = await TransportHTTP.create(url.host, isTLS); + return { transport }; +} + +async function openBluetooth( + conn: Connection & { + type: "bluetooth"; + deviceId?: string; + gattServiceUUID?: string; + }, + opts: OpenTransportOptions, +): Promise { + if (!("bluetooth" in navigator)) { + throw new Error("Web Bluetooth not supported"); + } + + let device = opts.cachedBluetoothDevice; + if (!device) { + const bt = navigator.bluetooth as Navigator["bluetooth"] & { + getDevices?: () => Promise; + }; + if (bt.getDevices) { + const known = await bt.getDevices(); + if (known.length > 0 && conn.deviceId) { + device = known.find((d) => d.id === conn.deviceId); + } + } + } + if (!device && opts.allowPrompt) { + device = await navigator.bluetooth.requestDevice({ + acceptAllDevices: !conn.gattServiceUUID, + optionalServices: conn.gattServiceUUID ? [conn.gattServiceUUID] : undefined, + filters: conn.gattServiceUUID ? [{ services: [conn.gattServiceUUID] }] : undefined, + }); + } + if (!device) { + throw new Error("Bluetooth device not available. Re-select the device."); + } + + const transport = await TransportWebBluetooth.createFromDevice(device); + return { transport, bluetoothDevice: device }; +} + +async function openSerial( + conn: Connection & { + type: "serial"; + usbVendorId?: number; + usbProductId?: number; + }, + opts: OpenTransportOptions, +): Promise { + if (!("serial" in navigator)) { + throw new Error("Web Serial not supported"); + } + + const serial = ( + navigator as Navigator & { + serial: { + getPorts: () => Promise; + requestPort: (options: Record) => Promise; + }; + } + ).serial; + + let port = opts.cachedSerialPort; + if (!port) { + const ports = await 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 serial.requestPort({}); + } + if (!port) { + throw new Error("Serial port not available. Re-select the port."); + } + + // Close-then-reopen cycle in case a prior connection left streams open. + 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((r) => setTimeout(r, 100)); + } catch (err) { + console.warn("[transports] error closing serial port before reconnect:", err); + } + } + + const transport = await TransportWebSerial.createFromPort(port); + return { transport, serialPort: port }; +} + +/** + * Probes a saved connection for reachability/permission without opening it. + * Used by refreshStatuses to update the saved-connection status badges. + */ +export async function probeConnection( + conn: Connection, +): Promise<"online" | "configured" | "disconnected" | "error"> { + switch (conn.type) { + case "http": { + const ok = await testHttpReachable(conn.url); + return ok ? "online" : "error"; + } + case "bluetooth": { + if (!("bluetooth" in navigator)) return "disconnected"; + try { + const known = await ( + navigator.bluetooth as Navigator["bluetooth"] & { + getDevices?: () => Promise; + } + ).getDevices?.(); + const hasPermission = known?.some((d: BluetoothDevice) => d.id === conn.deviceId); + return hasPermission ? "configured" : "disconnected"; + } catch { + return "disconnected"; + } + } + case "serial": { + if (!("serial" in navigator)) return "disconnected"; + 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 === conn.usbVendorId && info.usbProductId === conn.usbProductId; + }); + return hasPermission ? "configured" : "disconnected"; + } catch { + return "disconnected"; + } + } + } +} + +/** + * Best-effort cleanup for a held-onto BT device or serial port. Safe on either. + */ +export function closeTransport(handle: BluetoothDevice | SerialPort | undefined): void { + if (!handle) return; + const bt = handle as BluetoothDevice; + if (bt.gatt?.connected) { + try { + bt.gatt.disconnect(); + } catch {} + } + const port = handle as SerialPort & { close?: () => Promise }; + if (port.close) { + try { + port.close(); + } catch {} + } +} diff --git a/packages/web/src/pages/Connections/useConnections.ts b/packages/web/src/pages/Connections/useConnections.ts index 803573c4..8c6b9a8d 100644 --- a/packages/web/src/pages/Connections/useConnections.ts +++ b/packages/web/src/pages/Connections/useConnections.ts @@ -1,134 +1,89 @@ +import { + startConfigHeartbeat, + startMaintenanceHeartbeat, + stopHeartbeat, +} from "@app/core/connections/heartbeat.ts"; +import { buildMeshDevice } from "@app/core/connections/sdkClient.ts"; +import { + closeTransport, + openTransport, + probeConnection, +} from "@app/core/connections/transports.ts"; import type { Connection, ConnectionId, ConnectionStatus, NewConnection, } from "@app/core/stores/deviceStore/types"; -import { createConnectionFromInput, testHttpReachable } from "@app/pages/Connections/utils"; +import { createConnectionFromInput } from "@app/pages/Connections/utils"; import { meshRegistry } from "@core/meshRegistry.ts"; -import { coordinator, getStorageDb } from "@core/sdkStorage.ts"; import { useAppStore, useDeviceStore } from "@core/stores"; import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; -import { MeshDevice } from "@meshtastic/sdk"; -import { - SqlocalDraftRepository, - SqlocalMessageRepository, -} from "@meshtastic/sdk-storage-sqlocal/chat"; -import { SqlocalNodesRepository } from "@meshtastic/sdk-storage-sqlocal/nodes"; -import { SqlocalTelemetryRepository } from "@meshtastic/sdk-storage-sqlocal/telemetry"; -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>(); +// Per-connection out-of-band handles tracked outside Zustand: +// - cachedTransports: held BT device / serial port for reconnect + cleanup. +// - configSubscriptions: onConfigComplete unsubscribers. +const cachedTransports = 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 { setSelectedDevice } = useAppStore(); const selectedDeviceId = useAppStore((s) => s.selectedDeviceId); const updateStatus = useCallback( (id: ConnectionId, status: ConnectionStatus, error?: string) => { - const updates: Partial = { + updateSavedConnection(id, { status, error: error || undefined, ...(status === "disconnected" ? { lastConnectedAt: Date.now() } : {}), - }; - updateSavedConnection(id, updates); + }); }, [updateSavedConnection], ); + const teardown = useCallback((id: ConnectionId, conn?: Connection) => { + stopHeartbeat(id); + configSubscriptions.get(id)?.(); + configSubscriptions.delete(id); + + if (conn?.meshDeviceId) { + const device = useDeviceStore.getState().getDevice(conn.meshDeviceId); + try { + device?.connection?.disconnect(); + } catch {} + } + closeTransport(cachedTransports.get(id)); + cachedTransports.delete(id); + }, []); + 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 + teardown(id, conn); 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); + useDeviceStore.getState().removeDevice(conn.meshDeviceId); } catch {} } - meshRegistry.unregister(id); removeSavedConnectionFromStore(id); }, - [connections, removeSavedConnectionFromStore], + [connections, removeSavedConnectionFromStore, teardown], ); const setDefaultConnection = useCallback( (id: ConnectionId) => { - for (const connection of connections) { - if (connection.id === id) { - updateSavedConnection(connection.id, { - isDefault: !connection.isDefault, - }); - } - } + const conn = connections.find((c) => c.id === id); + if (!conn) return; + updateSavedConnection(id, { isDefault: !conn.isDefault }); }, [connections, updateSavedConnection], ); @@ -136,15 +91,11 @@ export function useConnections() { const setupMeshDevice = useCallback( async ( id: ConnectionId, - transport: - | Awaited> - | Awaited> - | Awaited>, - btDevice?: BluetoothDevice, + transport: Awaited>["transport"], + bluetoothDevice?: BluetoothDevice, serialPort?: SerialPort, ): Promise => { - // Reuse existing meshDeviceId if available to prevent duplicate device - // entries. Otherwise, generate a new ID. + // Reuse existing meshDeviceId if the device entry still exists; otherwise mint a new one. const conn = connections.find((c) => c.id === id); let deviceId = conn?.meshDeviceId; if (deviceId && !useDeviceStore.getState().getDevice(deviceId)) { @@ -153,118 +104,39 @@ export function useConnections() { deviceId = deviceId ?? randId(); const device = addDevice(deviceId); + const meshDevice = await buildMeshDevice(id, deviceId, transport); - // Wire the SDK slices to the OPFS-backed SQLite repositories so the - // user keeps message + draft + node history across reloads. The DB is - // opened lazily on first connect; subsequent connections share the - // same DB instance. - let chatRepository: SqlocalMessageRepository | undefined; - let draftRepository: SqlocalDraftRepository | undefined; - let nodesRepository: SqlocalNodesRepository | undefined; - let telemetryRepository: SqlocalTelemetryRepository | undefined; - try { - const db = await getStorageDb(); - chatRepository = new SqlocalMessageRepository(db, { deviceId: id, coordinator }); - draftRepository = new SqlocalDraftRepository(db, { deviceId: id }); - nodesRepository = new SqlocalNodesRepository(db, { deviceId: id }); - telemetryRepository = new SqlocalTelemetryRepository(db, { deviceId: id }); - } catch (err) { - console.warn("[useConnections] sqlocal unavailable, falling back to in-memory:", err); - } - const meshDevice = new MeshDevice(transport, { - configId: deviceId, - chat: - chatRepository || draftRepository - ? { - repository: chatRepository, - draftRepository, - retention: { maxPerBucket: 1000, olderThanMs: 1000 * 60 * 60 * 24 * 90 }, - } - : undefined, - nodes: nodesRepository ? { repository: nodesRepository } : undefined, - telemetry: telemetryRepository - ? { - repository: telemetryRepository, - retention: { maxPerNode: 500, olderThanMs: 1000 * 60 * 60 * 24 * 30 }, - } - : undefined, - }); - - // Register the underlying MeshClient so sdk-react hooks - // (useMeshDevice, useChat, etc.) observe this connection. if (!meshRegistry.has(id)) { meshRegistry.register(id, meshDevice.meshClient); } meshRegistry.setActive(id); setSelectedDevice(deviceId); - device.addConnection(meshDevice); // This stores meshDevice in Device.connection + device.addConnection(meshDevice); subscribeAll(device, meshDevice); - // Store transport locally for cleanup (BT/Serial only) - if (btDevice || serialPort) { - transports.set(id, btDevice || serialPort); - } + const cached = bluetoothDevice ?? serialPort; + if (cached) cachedTransports.set(id, cached); - // 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); - }, - ); + const unsubConfigComplete = meshDevice.events.onConfigComplete.subscribe(() => { + device.setConnectionPhase("configured"); + updateStatus(id, "configured"); + startMaintenanceHeartbeat(id, meshDevice); + }); 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); - }); - }) + .then(() => meshDevice.heartbeat().then(() => startConfigHeartbeat(id, meshDevice))) .catch((error) => { - console.error(`[useConnections] Failed to configure:`, error); - updateStatus(id, "error", error.message); + console.error("[useConnections] configure failed:", error); + updateStatus(id, "error", error?.message ?? String(error)); }); updateSavedConnection(id, { meshDeviceId: deviceId }); @@ -283,145 +155,30 @@ export function useConnections() { 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; - } + 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 cached = cachedTransports.get(id); + const result = await openTransport(conn, { + allowPrompt: opts?.allowPrompt, + cachedBluetoothDevice: + conn.type === "bluetooth" ? (cached as BluetoothDevice | undefined) : undefined, + cachedSerialPort: conn.type === "serial" ? (cached as SerialPort | undefined) : undefined, + }); + await setupMeshDevice(id, result.transport, result.bluetoothDevice, result.serialPort); - const url = new URL(conn.url); - const isTLS = url.protocol === "https:"; - const transport = await TransportHTTP.create(url.host, isTLS); - await 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); - await 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); - await setupMeshDevice(id, transport, undefined, port); - // Status will be set to "configured" by onConfigComplete event - return true; - } - } catch (err: unknown) { + // BT-specific: catch device-side disconnect to flip status. + result.bluetoothDevice?.addEventListener("gattserverdisconnected", () => { + updateStatus(id, "disconnected"); + }); + return true; + } catch (err) { const message = err instanceof Error ? err.message : String(err); updateStatus(id, "error", message); return false; } - return false; }, [connections, updateStatus, setupMeshDevice], ); @@ -429,78 +186,21 @@ export function useConnections() { const disconnect = useCallback( async (id: ConnectionId) => { const conn = connections.find((c) => c.id === id); - if (!conn) { - return; - } + 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 + teardown(id, conn); 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 + const device = useDeviceStore.getState().getDevice(conn.meshDeviceId); if (device) { device.setConnectionId(null); device.setConnectionPhase("disconnected"); } } } finally { - updateSavedConnection(id, { - status: "disconnected", - error: undefined, - }); + updateSavedConnection(id, { status: "disconnected", error: undefined }); } }, - [connections, updateSavedConnection], + [connections, updateSavedConnection, teardown], ); const addConnection = useCallback( @@ -515,135 +215,36 @@ export function useConnections() { 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); - } + if (btDevice && conn.type === "bluetooth") cachedTransports.set(conn.id, btDevice); await connect(conn.id, { allowPrompt: true }); - // Get updated connection from store after connect - if (conn.id) { - return conn; - } + 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]); + const candidates = connections.filter( + (c) => c.status !== "connected" && c.status !== "configured" && c.status !== "configuring", + ); + await Promise.all( + candidates.map(async (c) => { + const status = await probeConnection(c); + updateSavedConnection(c.id, { status }); + }), + ); }, [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]);