fix(web/connections): probe = "online" not "configured"; flip status to "configuring" before DB open; await transport disconnect

Three reconnect-flow bugs:

1. probeConnection returned "configured" for serial / bluetooth when
   the browser had stored permission for the device — but permission
   ≠ a configured Meshtastic device. The card showed "connected"
   and a Disconnect button before the user had ever clicked Connect.
   Probe now returns "online" (= "available, click to connect"),
   matching the HTTP path. "configured" is reserved for the
   onConfigComplete callback.

2. setupMeshDevice flipped status from "connecting" → "configuring"
   AFTER awaiting buildMeshDevice, which opens the OPFS DB. If the
   DB open stalled (multi-tab contention, slow first-time init), the
   card sat at "connecting" indefinitely. Move the status flip ahead
   of the persistence await — UI shows "configuring" while
   persistence is spinning up.

3. teardown's `device?.connection?.disconnect()` returned a Promise
   that was never awaited, so the underlying transport's port.close()
   could race the next port.open() on a fast disconnect → reconnect.
   Make teardown async, await disconnect, propagate via async
   removeConnection / disconnect callers.

Plus: connect() catch block logs the underlying error before turning
it into a status update, so the actual failure shows up in console
even when the toast text is generic.
This commit is contained in:
Dan Ditomaso
2026-04-26 18:44:16 -04:00
parent f899c97ac8
commit a7b1923ca2
2 changed files with 23 additions and 10 deletions

View File

@@ -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";
}

View File

@@ -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) {