* refactor: device connection logic, added nonce to get config only on connect. * Update packages/web/src/core/services/MeshService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/pages/Connections/useConnections.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * code review fixes * fixes from code review * ui fixes * refactored meshService, moved code into deviceStore. Fixed some connnection issues * formatting fixes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
661 lines
22 KiB
TypeScript
661 lines
22 KiB
TypeScript
import type {
|
|
Connection,
|
|
ConnectionId,
|
|
ConnectionStatus,
|
|
NewConnection,
|
|
} from "@app/core/stores/deviceStore/types";
|
|
import {
|
|
createConnectionFromInput,
|
|
testHttpReachable,
|
|
} from "@app/pages/Connections/utils";
|
|
import {
|
|
useAppStore,
|
|
useDeviceStore,
|
|
useMessageStore,
|
|
useNodeDBStore,
|
|
} from "@core/stores";
|
|
import { subscribeAll } from "@core/subscriptions.ts";
|
|
import { randId } from "@core/utils/randId.ts";
|
|
import { MeshDevice } from "@meshtastic/core";
|
|
import { TransportHTTP } from "@meshtastic/transport-http";
|
|
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
|
|
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
|
import { useCallback } from "react";
|
|
|
|
// Local storage for cleanup only (not in Zustand)
|
|
const transports = new Map<ConnectionId, BluetoothDevice | SerialPort>();
|
|
const heartbeats = new Map<ConnectionId, ReturnType<typeof setInterval>>();
|
|
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 { addNodeDB } = useNodeDBStore();
|
|
const { addMessageStore } = useMessageStore();
|
|
const { setSelectedDevice } = useAppStore();
|
|
const selectedDeviceId = useAppStore((s) => s.selectedDeviceId);
|
|
|
|
const updateStatus = useCallback(
|
|
(id: ConnectionId, status: ConnectionStatus, error?: string) => {
|
|
const updates: Partial<Connection> = {
|
|
status,
|
|
error: error || undefined,
|
|
...(status === "disconnected" ? { lastConnectedAt: Date.now() } : {}),
|
|
};
|
|
updateSavedConnection(id, updates);
|
|
},
|
|
[updateSavedConnection],
|
|
);
|
|
|
|
const removeConnection = useCallback(
|
|
(id: ConnectionId) => {
|
|
const conn = connections.find((c) => c.id === id);
|
|
|
|
// Stop heartbeat
|
|
const heartbeatId = heartbeats.get(id);
|
|
if (heartbeatId) {
|
|
clearInterval(heartbeatId);
|
|
heartbeats.delete(id);
|
|
console.log(`[useConnections] Heartbeat stopped for connection ${id}`);
|
|
}
|
|
|
|
// Unsubscribe from config complete event
|
|
const unsubConfigComplete = configSubscriptions.get(id);
|
|
if (unsubConfigComplete) {
|
|
unsubConfigComplete();
|
|
configSubscriptions.delete(id);
|
|
console.log(
|
|
`[useConnections] Config subscription cleaned up for connection ${id}`,
|
|
);
|
|
}
|
|
|
|
// Get device and MeshDevice from Device.connection
|
|
if (conn?.meshDeviceId) {
|
|
const { getDevice, removeDevice } = useDeviceStore.getState();
|
|
const device = getDevice(conn.meshDeviceId);
|
|
|
|
if (device?.connection) {
|
|
// Disconnect MeshDevice
|
|
try {
|
|
device.connection.disconnect();
|
|
} catch {}
|
|
}
|
|
|
|
// Close transport if it's BT or Serial
|
|
const transport = transports.get(id);
|
|
if (transport) {
|
|
const bt = transport as BluetoothDevice;
|
|
if (bt.gatt?.connected) {
|
|
try {
|
|
bt.gatt.disconnect();
|
|
} catch {}
|
|
}
|
|
|
|
const sp = transport as SerialPort & { close?: () => Promise<void> };
|
|
if (sp.close) {
|
|
try {
|
|
sp.close();
|
|
} catch {}
|
|
}
|
|
|
|
transports.delete(id);
|
|
}
|
|
|
|
// Clean up orphaned Device
|
|
try {
|
|
removeDevice(conn.meshDeviceId);
|
|
} catch {}
|
|
}
|
|
|
|
removeSavedConnectionFromStore(id);
|
|
},
|
|
[connections, removeSavedConnectionFromStore],
|
|
);
|
|
|
|
const setDefaultConnection = useCallback(
|
|
(id: ConnectionId) => {
|
|
for (const connection of connections) {
|
|
if (connection.id === id) {
|
|
updateSavedConnection(connection.id, {
|
|
isDefault: !connection.isDefault,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
[connections, updateSavedConnection],
|
|
);
|
|
|
|
const setupMeshDevice = useCallback(
|
|
(
|
|
id: ConnectionId,
|
|
transport:
|
|
| Awaited<ReturnType<typeof TransportHTTP.create>>
|
|
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
|
|
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>,
|
|
btDevice?: BluetoothDevice,
|
|
serialPort?: SerialPort,
|
|
): number => {
|
|
// Reuse existing meshDeviceId if available to prevent duplicate nodeDBs,
|
|
// but only if the corresponding nodeDB still exists. Otherwise, generate a new ID.
|
|
const conn = connections.find((c) => c.id === id);
|
|
let deviceId = conn?.meshDeviceId;
|
|
if (deviceId && !useNodeDBStore.getState().getNodeDB(deviceId)) {
|
|
deviceId = undefined;
|
|
}
|
|
deviceId = deviceId ?? randId();
|
|
|
|
const device = addDevice(deviceId);
|
|
const nodeDB = addNodeDB(deviceId);
|
|
const messageStore = addMessageStore(deviceId);
|
|
const meshDevice = new MeshDevice(transport, deviceId);
|
|
|
|
setSelectedDevice(deviceId);
|
|
device.addConnection(meshDevice); // This stores meshDevice in Device.connection
|
|
subscribeAll(device, meshDevice, messageStore, nodeDB);
|
|
|
|
// Store transport locally for cleanup (BT/Serial only)
|
|
if (btDevice || serialPort) {
|
|
transports.set(id, btDevice || serialPort);
|
|
}
|
|
|
|
// Set active connection and link device bidirectionally
|
|
setActiveConnectionId(id);
|
|
device.setConnectionId(id);
|
|
|
|
// Listen for config complete event (with nonce/ID)
|
|
const unsubConfigComplete = meshDevice.events.onConfigComplete.subscribe(
|
|
(configCompleteId) => {
|
|
console.log(
|
|
`[useConnections] Configuration complete with ID: ${configCompleteId}`,
|
|
);
|
|
device.setConnectionPhase("configured");
|
|
updateStatus(id, "configured");
|
|
|
|
// Switch from fast config heartbeat to slow maintenance heartbeat
|
|
const oldHeartbeat = heartbeats.get(id);
|
|
if (oldHeartbeat) {
|
|
clearInterval(oldHeartbeat);
|
|
console.log(
|
|
`[useConnections] Switching to maintenance heartbeat (5 min interval)`,
|
|
);
|
|
}
|
|
|
|
const maintenanceHeartbeat = setInterval(() => {
|
|
meshDevice.heartbeat().catch((error) => {
|
|
console.warn("[useConnections] Heartbeat failed:", error);
|
|
});
|
|
}, HEARTBEAT_INTERVAL_MS);
|
|
heartbeats.set(id, maintenanceHeartbeat);
|
|
},
|
|
);
|
|
configSubscriptions.set(id, unsubConfigComplete);
|
|
|
|
// Start configuration
|
|
device.setConnectionPhase("configuring");
|
|
updateStatus(id, "configuring");
|
|
console.log("[useConnections] Starting configuration");
|
|
|
|
meshDevice
|
|
.configure()
|
|
.then(() => {
|
|
console.log(
|
|
"[useConnections] Configuration complete, starting heartbeat",
|
|
);
|
|
// Send initial heartbeat after configure completes
|
|
meshDevice
|
|
.heartbeat()
|
|
.then(() => {
|
|
// Start fast heartbeat after first successful heartbeat
|
|
const configHeartbeatId = setInterval(() => {
|
|
meshDevice.heartbeat().catch((error) => {
|
|
console.warn(
|
|
"[useConnections] Config heartbeat failed:",
|
|
error,
|
|
);
|
|
});
|
|
}, CONFIG_HEARTBEAT_INTERVAL_MS);
|
|
heartbeats.set(id, configHeartbeatId);
|
|
console.log(
|
|
`[useConnections] Heartbeat started for connection ${id} (5s interval during config)`,
|
|
);
|
|
})
|
|
.catch((error) => {
|
|
console.warn("[useConnections] Initial heartbeat failed:", error);
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
console.error(`[useConnections] Failed to configure:`, error);
|
|
updateStatus(id, "error", error.message);
|
|
});
|
|
|
|
updateSavedConnection(id, { meshDeviceId: deviceId });
|
|
return deviceId;
|
|
},
|
|
[
|
|
connections,
|
|
addDevice,
|
|
addNodeDB,
|
|
addMessageStore,
|
|
setSelectedDevice,
|
|
setActiveConnectionId,
|
|
updateSavedConnection,
|
|
updateStatus,
|
|
],
|
|
);
|
|
|
|
const connect = useCallback(
|
|
async (id: ConnectionId, opts?: { allowPrompt?: boolean }) => {
|
|
const conn = connections.find((c) => c.id === id);
|
|
if (!conn) {
|
|
return false;
|
|
}
|
|
if (conn.status === "configured" || conn.status === "connected") {
|
|
return true;
|
|
}
|
|
|
|
updateStatus(id, "connecting");
|
|
try {
|
|
if (conn.type === "http") {
|
|
const ok = await testHttpReachable(conn.url);
|
|
if (!ok) {
|
|
const url = new URL(conn.url);
|
|
const isHTTPS = url.protocol === "https:";
|
|
const message = isHTTPS
|
|
? `Cannot reach HTTPS endpoint. If using a self-signed certificate, open ${conn.url} in a new tab, accept the certificate warning, then try connecting again.`
|
|
: "HTTP endpoint not reachable (may be blocked by CORS)";
|
|
throw new Error(message);
|
|
}
|
|
|
|
const url = new URL(conn.url);
|
|
const isTLS = url.protocol === "https:";
|
|
const transport = await TransportHTTP.create(url.host, isTLS);
|
|
setupMeshDevice(id, transport);
|
|
// Status will be set to "configured" by onConfigComplete event
|
|
return true;
|
|
}
|
|
|
|
if (conn.type === "bluetooth") {
|
|
if (!("bluetooth" in navigator)) {
|
|
throw new Error("Web Bluetooth not supported");
|
|
}
|
|
let bleDevice = transports.get(id) as BluetoothDevice | undefined;
|
|
if (!bleDevice) {
|
|
// Try to recover permitted devices
|
|
const getDevices = (
|
|
navigator.bluetooth as Navigator["bluetooth"] & {
|
|
getDevices?: () => Promise<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);
|
|
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);
|
|
setupMeshDevice(id, transport, undefined, port);
|
|
// Status will be set to "configured" by onConfigComplete event
|
|
return true;
|
|
}
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
updateStatus(id, "error", message);
|
|
return false;
|
|
}
|
|
return false;
|
|
},
|
|
[connections, updateStatus, setupMeshDevice],
|
|
);
|
|
|
|
const disconnect = useCallback(
|
|
async (id: ConnectionId) => {
|
|
const conn = connections.find((c) => c.id === id);
|
|
if (!conn) {
|
|
return;
|
|
}
|
|
try {
|
|
// Stop heartbeat
|
|
const heartbeatId = heartbeats.get(id);
|
|
if (heartbeatId) {
|
|
clearInterval(heartbeatId);
|
|
heartbeats.delete(id);
|
|
console.log(
|
|
`[useConnections] Heartbeat stopped for connection ${id}`,
|
|
);
|
|
}
|
|
|
|
// Unsubscribe from config complete event
|
|
const unsubConfigComplete = configSubscriptions.get(id);
|
|
if (unsubConfigComplete) {
|
|
unsubConfigComplete();
|
|
configSubscriptions.delete(id);
|
|
console.log(
|
|
`[useConnections] Config subscription cleaned up for connection ${id}`,
|
|
);
|
|
}
|
|
|
|
// Get device and meshDevice from Device.connection
|
|
if (conn.meshDeviceId) {
|
|
const { getDevice } = useDeviceStore.getState();
|
|
const device = getDevice(conn.meshDeviceId);
|
|
|
|
if (device?.connection) {
|
|
// Disconnect MeshDevice
|
|
try {
|
|
device.connection.disconnect();
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
|
|
// Close transport connections
|
|
const transport = transports.get(id);
|
|
if (transport) {
|
|
if (conn.type === "bluetooth") {
|
|
const dev = transport as BluetoothDevice;
|
|
if (dev.gatt?.connected) {
|
|
dev.gatt.disconnect();
|
|
}
|
|
}
|
|
if (conn.type === "serial") {
|
|
const port = transport as SerialPort & {
|
|
close?: () => Promise<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
|
|
if (device) {
|
|
device.setConnectionId(null);
|
|
device.setConnectionPhase("disconnected");
|
|
}
|
|
}
|
|
} finally {
|
|
updateSavedConnection(id, {
|
|
status: "disconnected",
|
|
error: undefined,
|
|
});
|
|
}
|
|
},
|
|
[connections, updateSavedConnection],
|
|
);
|
|
|
|
const addConnection = useCallback(
|
|
(input: NewConnection) => {
|
|
const conn = createConnectionFromInput(input);
|
|
addSavedConnection(conn);
|
|
return conn;
|
|
},
|
|
[addSavedConnection],
|
|
);
|
|
|
|
const addConnectionAndConnect = useCallback(
|
|
async (input: NewConnection, btDevice?: BluetoothDevice) => {
|
|
const conn = addConnection(input);
|
|
// If a Bluetooth device was provided, store it to avoid re-prompting
|
|
if (btDevice && conn.type === "bluetooth") {
|
|
transports.set(conn.id, btDevice);
|
|
}
|
|
await connect(conn.id, { allowPrompt: true });
|
|
// Get updated connection from store after connect
|
|
if (conn.id) {
|
|
return conn;
|
|
}
|
|
},
|
|
[addConnection, connect],
|
|
);
|
|
|
|
const refreshStatuses = useCallback(async () => {
|
|
// Check reachability/availability without auto-connecting
|
|
// HTTP: test endpoint reachability
|
|
// Bluetooth/Serial: check permission grants
|
|
|
|
// HTTP connections: test reachability if not already connected/configured
|
|
const httpChecks = connections
|
|
.filter(
|
|
(c): c is Connection & { type: "http"; url: string } =>
|
|
c.type === "http" &&
|
|
c.status !== "connected" &&
|
|
c.status !== "configured" &&
|
|
c.status !== "configuring",
|
|
)
|
|
.map(async (c) => {
|
|
const ok = await testHttpReachable(c.url);
|
|
updateSavedConnection(c.id, {
|
|
status: ok ? "online" : "error",
|
|
});
|
|
});
|
|
|
|
// Bluetooth connections: check permission grants
|
|
const btChecks = connections
|
|
.filter(
|
|
(c): c is Connection & { type: "bluetooth"; deviceId?: string } =>
|
|
c.type === "bluetooth" &&
|
|
c.status !== "connected" &&
|
|
c.status !== "configured" &&
|
|
c.status !== "configuring",
|
|
)
|
|
.map(async (c) => {
|
|
if (!("bluetooth" in navigator)) {
|
|
return;
|
|
}
|
|
try {
|
|
const known = await (
|
|
navigator.bluetooth as Navigator["bluetooth"] & {
|
|
getDevices?: () => Promise<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]);
|
|
}, [connections, updateSavedConnection]);
|
|
|
|
const syncConnectionStatuses = useCallback(() => {
|
|
// Find which connection corresponds to the currently selected device
|
|
const activeConnection = connections.find(
|
|
(c) => c.meshDeviceId === selectedDeviceId,
|
|
);
|
|
|
|
// Update all connection statuses
|
|
connections.forEach((conn) => {
|
|
const shouldBeConnected = activeConnection?.id === conn.id;
|
|
const isConnectedState =
|
|
conn.status === "connected" ||
|
|
conn.status === "configured" ||
|
|
conn.status === "configuring";
|
|
|
|
// Update status if it doesn't match reality
|
|
if (!shouldBeConnected && isConnectedState) {
|
|
updateSavedConnection(conn.id, { status: "disconnected" });
|
|
}
|
|
// Don't force status to "connected" if shouldBeConnected - let the connection flow set the proper status
|
|
});
|
|
}, [connections, selectedDeviceId, updateSavedConnection]);
|
|
|
|
return {
|
|
connections,
|
|
addConnection,
|
|
addConnectionAndConnect,
|
|
connect,
|
|
disconnect,
|
|
removeConnection,
|
|
setDefaultConnection,
|
|
refreshStatuses,
|
|
syncConnectionStatuses,
|
|
};
|
|
}
|