diff --git a/apps/web/src/core/connections/transports.ts b/apps/web/src/core/connections/transports.ts index be009f56..962bd1bf 100644 --- a/apps/web/src/core/connections/transports.ts +++ b/apps/web/src/core/connections/transports.ts @@ -188,7 +188,11 @@ export async function probeConnection( } ).getDevices?.(); const hasPermission = known?.some((d: BluetoothDevice) => d.id === conn.deviceId); - return hasPermission ? "configured" : "disconnected"; + // Permission granted ≠ device configured. The card surfaces "online" + // (i.e. "available, click to connect") so the user explicitly opts + // into the configure handshake. "configured" is reserved for when + // the firmware has actually replied with config-complete. + return hasPermission ? "online" : "disconnected"; } catch { return "disconnected"; } @@ -210,7 +214,7 @@ export async function probeConnection( ).getInfo?.() ?? {}; return info.usbVendorId === conn.usbVendorId && info.usbProductId === conn.usbProductId; }); - return hasPermission ? "configured" : "disconnected"; + return hasPermission ? "online" : "disconnected"; } catch { return "disconnected"; } diff --git a/apps/web/src/pages/Connections/useConnections.ts b/apps/web/src/pages/Connections/useConnections.ts index 8c6b9a8d..24beb4c0 100644 --- a/apps/web/src/pages/Connections/useConnections.ts +++ b/apps/web/src/pages/Connections/useConnections.ts @@ -49,7 +49,7 @@ export function useConnections() { [updateSavedConnection], ); - const teardown = useCallback((id: ConnectionId, conn?: Connection) => { + const teardown = useCallback(async (id: ConnectionId, conn?: Connection) => { stopHeartbeat(id); configSubscriptions.get(id)?.(); configSubscriptions.delete(id); @@ -57,7 +57,10 @@ export function useConnections() { if (conn?.meshDeviceId) { const device = useDeviceStore.getState().getDevice(conn.meshDeviceId); try { - device?.connection?.disconnect(); + // Await the underlying transport's close so the port is fully + // released before the user clicks reconnect — otherwise port.open() + // can race the close and throw "port already open". + await device?.connection?.disconnect(); } catch {} } closeTransport(cachedTransports.get(id)); @@ -65,9 +68,9 @@ export function useConnections() { }, []); const removeConnection = useCallback( - (id: ConnectionId) => { + async (id: ConnectionId) => { const conn = connections.find((c) => c.id === id); - teardown(id, conn); + await teardown(id, conn); if (conn?.meshDeviceId) { try { useDeviceStore.getState().removeDevice(conn.meshDeviceId); @@ -104,6 +107,14 @@ export function useConnections() { deviceId = deviceId ?? randId(); const device = addDevice(deviceId); + + // Flip status to "configuring" up front. buildMeshDevice awaits the + // OPFS DB open which can take a beat (or stall under multi-tab + // contention); we don't want the UI looking stuck at "connecting" + // while persistence is spinning up. + device.setConnectionPhase("configuring"); + updateStatus(id, "configuring"); + const meshDevice = await buildMeshDevice(id, deviceId, transport); if (!meshRegistry.has(id)) { @@ -128,9 +139,6 @@ export function useConnections() { }); configSubscriptions.set(id, unsubConfigComplete); - device.setConnectionPhase("configuring"); - updateStatus(id, "configuring"); - meshDevice .configure() .then(() => meshDevice.heartbeat().then(() => startConfigHeartbeat(id, meshDevice))) @@ -175,6 +183,7 @@ export function useConnections() { }); return true; } catch (err) { + console.error("[useConnections] connect failed:", err); const message = err instanceof Error ? err.message : String(err); updateStatus(id, "error", message); return false; @@ -188,7 +197,7 @@ export function useConnections() { const conn = connections.find((c) => c.id === id); if (!conn) return; try { - teardown(id, conn); + await teardown(id, conn); if (conn.meshDeviceId) { const device = useDeviceStore.getState().getDevice(conn.meshDeviceId); if (device) {