mirror of
https://github.com/meshtastic/web.git
synced 2026-08-01 07:26:34 -04:00
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:
45
packages/web/src/core/connections/heartbeat.ts
Normal file
45
packages/web/src/core/connections/heartbeat.ts
Normal 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);
|
||||
}
|
||||
61
packages/web/src/core/connections/sdkClient.ts
Normal file
61
packages/web/src/core/connections/sdkClient.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
238
packages/web/src/core/connections/transports.ts
Normal file
238
packages/web/src/core/connections/transports.ts
Normal 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 {}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user