refactor(web): split useConnections into focused modules

useConnections went from 661 lines down to 264 by extracting three
single-responsibility helpers under packages/web/src/core/connections/.
The hook is now pure orchestration; transport / heartbeat / SDK-client
/ status-probe concerns live in their own files.

New modules:
- core/connections/heartbeat.ts (45 LOC): startConfigHeartbeat (5s),
  startMaintenanceHeartbeat (5min), stopHeartbeat. Owns the heartbeats
  Map; replaces the inline interval bookkeeping. Both helpers stop the
  prior heartbeat first so callers don't have to.
- core/connections/sdkClient.ts (61 LOC): buildMeshDevice(connId,
  deviceId, transport) opens the OPFS DB, builds the four sqlocal
  repositories (chat / draft / nodes / telemetry), and constructs the
  MeshDevice with the canonical retention defaults (chat 90d / 1k per
  bucket; telemetry 30d / 500 per node). Falls back to in-memory when
  sqlocal is unavailable.
- core/connections/transports.ts (236 LOC): per-transport openTransport
  factory (HTTP reachability check, BT permission re-acquisition with
  optional prompt, Serial port lookup with close-then-reopen),
  probeConnection for refreshStatuses, closeTransport for cleanup.
  Discriminates over conn.type so useConnections doesn't carry the
  switch logic.

useConnections (now 264 LOC):
- Owns the cachedTransports + configSubscriptions maps and the
  Zustand selectors.
- teardown(id, conn) consolidates the heartbeat + config-sub +
  meshDevice.disconnect + transport-close cleanup that used to be
  duplicated across removeConnection / disconnect.
- connect calls openTransport and forwards the resulting transport +
  cached BT/Serial handle into setupMeshDevice. The BT
  gattserverdisconnected listener is wired here (it needs the connId).
- refreshStatuses simplifies to a filter + Promise.all over
  probeConnection.
- syncConnectionStatuses unchanged.

Behavior is unchanged. No new tests — the existing web suite (236)
still passes; build clean.
This commit is contained in:
Dan Ditomaso
2026-04-26 10:54:11 -04:00
parent eef69dbacb
commit d4cd649b6b
4 changed files with 430 additions and 485 deletions

View File

@@ -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<ConnectionId, ReturnType<typeof setInterval>>();
/**
* 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);
}

View File

@@ -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<ReturnType<typeof TransportHTTP.create>>
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>;
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<MeshDevice> {
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,
});
}

View File

@@ -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<OpenTransportResult> {
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<OpenTransportResult> {
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<OpenTransportResult> {
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<BluetoothDevice[]>;
};
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<OpenTransportResult> {
if (!("serial" in navigator)) {
throw new Error("Web Serial not supported");
}
const serial = (
navigator as Navigator & {
serial: {
getPorts: () => Promise<SerialPort[]>;
requestPort: (options: Record<string, unknown>) => Promise<SerialPort>;
};
}
).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<void>;
};
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<BluetoothDevice[]>;
}
).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<SerialPort[]> };
}
).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<void> };
if (port.close) {
try {
port.close();
} catch {}
}
}

View File

@@ -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<ConnectionId, BluetoothDevice | SerialPort>();
const heartbeats = new Map<ConnectionId, ReturnType<typeof setInterval>>();
// 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<ConnectionId, BluetoothDevice | SerialPort>();
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);
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<Connection> = {
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<void> };
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<ReturnType<typeof TransportHTTP.create>>
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>,
btDevice?: BluetoothDevice,
transport: Awaited<ReturnType<typeof openTransport>>["transport"],
bluetoothDevice?: BluetoothDevice,
serialPort?: SerialPort,
): Promise<number> => {
// 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<BluetoothDevice[]>;
}
).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<SerialPort[]> };
}
).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<string, unknown>) => Promise<SerialPort>;
};
}
).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<void>;
};
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<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
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<BluetoothDevice[]>;
}
).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<SerialPort[]> };
}
).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]);