Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 13 additions & 55 deletions lib/bluetooth-device-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ export interface Service {

interface ConnectCallbacks {
onConnecting: () => void;
onReconnecting: () => void;
onFail: () => void;
onDisconnect: () => void;
onSuccess: () => void;
}

Expand All @@ -84,12 +83,7 @@ interface ConnectCallbacks {
// > this out after 30 seconds and reload the page

export class BluetoothDeviceWrapper implements Logging {
// Used to avoid automatic reconnection during user triggered connect/disconnect
// or reconnection itself.
private duringExplicitConnectDisconnect: number = 0;

connected = false;
private isReconnect = false;

// Only updated after the full connection flow completes not during bond handling.
private serviceIds: Set<string> = new Set();
Expand Down Expand Up @@ -145,16 +139,10 @@ export class BluetoothDeviceWrapper implements Logging {
async connect(options?: ConnectOptions): Promise<void> {
const progress = options?.progress ?? (() => {});
this.logging.event({
type: this.isReconnect ? "Reconnect" : "Connect",
type: "Connect",
message: "Bluetooth connect start",
});
if (this.isReconnect) {
this.callbacks.onReconnecting();
} else {
this.callbacks.onConnecting();
}

this.duringExplicitConnectDisconnect++;
this.callbacks.onConnecting();

try {
if (Capacitor.isNativePlatform()) {
Expand All @@ -174,18 +162,18 @@ export class BluetoothDeviceWrapper implements Logging {
events.forEach((e) => this.startNotifications(e as TypedServiceEvent));

this.logging.event({
type: this.isReconnect ? "Reconnect" : "Connect",
type: "Connect",
message: "Bluetooth connect success",
});
this.callbacks.onSuccess();
} catch (e) {
this.logging.error("Bluetooth connect error", e);
this.logging.event({
type: this.isReconnect ? "Reconnect" : "Connect",
type: "Connect",
message: "Bluetooth connect failed",
});
await this.disconnectInternal(false);
this.callbacks.onFail();
await this.disconnectInternal();
this.callbacks.onDisconnect();

if (e instanceof DeviceError) {
throw e;
Expand All @@ -210,10 +198,6 @@ export class BluetoothDeviceWrapper implements Logging {
code: "bluetooth-connection-failed",
message: e instanceof Error ? e.message : String(e),
});
} finally {
this.duringExplicitConnectDisconnect--;
// Reset isReconnect for next time
this.isReconnect = false;
}
}

Expand All @@ -226,54 +210,28 @@ export class BluetoothDeviceWrapper implements Logging {
}

async disconnect(): Promise<void> {
return this.disconnectInternal(true);
return this.disconnectInternal();
}

private async disconnectInternal(userTriggered: boolean): Promise<void> {
this.logging.log(
`Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}`,
);
this.duringExplicitConnectDisconnect++;
private async disconnectInternal(): Promise<void> {
this.logging.log("Bluetooth disconnect");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we can collapse these now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found that knowing whether the disconnect is occurring programatically vs user triggered is quite useful. Not sure why I removed that in the first place. Have recovered it here -> 713edeb

try {
if (this.connected) {
await BleClient.disconnect(this.device.deviceId);
}
} catch (e) {
this.logging.error("Bluetooth GATT disconnect error (ignored)", e);
// We might have already lost the connection.
} finally {
this.duringExplicitConnectDisconnect--;
}
}

async reconnect(): Promise<void> {
this.logging.log("Bluetooth reconnect");
this.isReconnect = true;
await this.connect();
}

handleDisconnectEvent = async (): Promise<void> => {
handleDisconnectEvent = (): void => {
this.waitingForDisconnectEventCallbacks.forEach((cb) => cb());
this.waitingForDisconnectEventCallbacks.length = 0;

this.connected = false;
try {
if (!this.duringExplicitConnectDisconnect) {
this.logging.log(
"Bluetooth disconnected... automatically trying reconnect",
);
await this.reconnect();
} else {
this.logging.log(
"Bluetooth disconnect ignored during explicit disconnect",
);
}
} catch (e) {
this.logging.error(
"Bluetooth connect triggered by disconnect listener failed",
e,
);
}
this.logging.log("Bluetooth disconnect");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like we should log something different from the disconnectInternal logging to help distinguish.

Copy link
Contributor Author

@microbit-grace microbit-grace Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your original thought that it is an redundant extra logging during our discussion was somewhat correct. It does log twice (duplicating above log) when we disconnect explicitly by user/programmatically, but only logs once when the disconnect is implicit (error-related).

I have removed this logging to avoid duplication (e141be7). If there is an error-related disconnect, nothing will get logged as it is unexpected

this.callbacks.onDisconnect();
};

async getBoardVersion(): Promise<BoardVersion> {
Expand Down
9 changes: 2 additions & 7 deletions lib/bluetooth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ class MicrobitWebBluetoothConnectionImpl
progress(ProgressStage.Initializing);
throwIfUnavailable(await this.checkAvailability());

if (!this.connection) {
if (!this.device || !this.connection) {
progress(ProgressStage.FindingDevice);
const device = await this.requestDevice(options?.signal);
this.connection = new BluetoothDeviceWrapper(
Expand All @@ -310,12 +310,8 @@ class MicrobitWebBluetoothConnectionImpl
() => this.getActiveEvents() as Array<keyof ServiceConnectionEventMap>,
{
onConnecting: () => this.setStatus(ConnectionStatus.CONNECTING),
onReconnecting: () => this.setStatus(ConnectionStatus.RECONNECTING),
onSuccess: () => this.setStatus(ConnectionStatus.CONNECTED),
onFail: () => {
this.setStatus(ConnectionStatus.DISCONNECTED);
this.connection = undefined;
},
onDisconnect: () => this.setStatus(ConnectionStatus.DISCONNECTED),
},
);
}
Expand All @@ -334,7 +330,6 @@ class MicrobitWebBluetoothConnectionImpl
message: "error-disconnecting",
});
} finally {
this.connection = undefined;
this.setStatus(ConnectionStatus.DISCONNECTED);
this.logging.event({
type: "Bluetooth-info",
Expand Down
5 changes: 0 additions & 5 deletions lib/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,6 @@ export enum ConnectionStatus {
* Connecting.
*/
CONNECTING = "CONNECTING",
/**
* Reconnecting. When there is unexpected disruption in the connection,
* a reconnection is attempted.
*/
RECONNECTING = "RECONNECTING",
/**
* Paused due to tab visibility. The connection was temporarily suspended
* because the browser tab became hidden. Reconnection will be attempted
Expand Down
61 changes: 13 additions & 48 deletions lib/usb-radio-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import * as protocol from "./usb-serial-protocol.js";
import { MicrobitWebUSBConnection } from "./usb.js";

const connectTimeoutDuration: number = 10000;
const connectTimeoutDuration: number = 10_000;

class BridgeError extends Error {}
class RemoteError extends Error {}
Expand All @@ -35,8 +35,7 @@ export interface MicrobitRadioBridgeConnectionOptions {

interface ConnectCallbacks {
onConnecting: () => void;
onReconnecting: () => void;
onRestartConnection: () => void;
onBeforeConnectionLostDispose: () => void;
onFail: () => void;
onSuccess: () => void;
}
Expand Down Expand Up @@ -96,12 +95,10 @@ class MicrobitRadioBridgeConnectionImpl
}
} else {
this.status = ConnectionStatus.DISCONNECTED;
// Reconnect the serial session if we were previously disconnected or paused.
// Reconnect the serial session if we were previously paused.
// PAUSED means the USB connection was temporarily suspended due to tab
// visibility, and now the tab is visible again so we should reconnect.
const shouldReconnect =
currentStatus === ConnectionStatus.DISCONNECTED ||
currentStatus === ConnectionStatus.PAUSED;
const shouldReconnect = currentStatus === ConnectionStatus.PAUSED;
if (shouldReconnect && this.serialSessionOpen) {
this.serialSession?.connect();
}
Expand Down Expand Up @@ -174,23 +171,14 @@ class MicrobitRadioBridgeConnectionImpl
this.dispatchTypedEvent.bind(this),
{
onConnecting: () => this.setStatus(ConnectionStatus.CONNECTING),
onReconnecting: () => {
// Leave serial connection running in case the remote device comes back.
if (this.status !== ConnectionStatus.RECONNECTING) {
this.setStatus(ConnectionStatus.RECONNECTING);
}
},
onRestartConnection: () => {
// So that serial session does not get repetitively disposed in
// delegate status listener when delegate is disconnected for restarting connection
this.ignoreDelegateStatus = true;
onBeforeConnectionLostDispose: () => {
this.ignoreDelegateStatus = false;
this.serialSessionOpen = false;
},
onFail: () => {
if (this.status !== ConnectionStatus.DISCONNECTED) {
this.setStatus(ConnectionStatus.DISCONNECTED);
}
this.ignoreDelegateStatus = false;
this.serialSessionOpen = false;
},
onSuccess: () => {
if (this.status !== ConnectionStatus.CONNECTED) {
Expand Down Expand Up @@ -260,7 +248,6 @@ class RadioBridgeSerialSession {
private onPeriodicMessageReceived: (() => void) | undefined;
private lastReceivedMessageTimestamp: number | undefined;
private connectionCheckIntervalId: ReturnType<typeof setInterval> | undefined;
private isRestartingConnection: boolean = false;

private serialErrorListener = (event: SerialErrorEvent) => {
this.logging.error("Serial error", event.error);
Expand Down Expand Up @@ -340,11 +327,7 @@ class RadioBridgeSerialSession {
this.delegate.addEventListener("serialdata", this.serialDataListener);
this.delegate.addEventListener("serialerror", this.serialErrorListener);
try {
if (this.isRestartingConnection) {
this.callbacks.onReconnecting();
} else {
this.callbacks.onConnecting();
}
this.callbacks.onConnecting();
await this.handshake();

this.logging.log(`Serial: using remote device id ${this.remoteDeviceId}`);
Expand Down Expand Up @@ -372,7 +355,7 @@ class RadioBridgeSerialSession {
setTimeout(() => {
this.onPeriodicMessageReceived = undefined;
reject(new Error("Failed to receive data from remote micro:bit"));
}, 500);
}, connectTimeoutDuration);
Copy link
Contributor Author

@microbit-grace microbit-grace Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

});

const startCmdResponse = await this.sendCmdWaitResponse(startCmd);
Expand All @@ -385,7 +368,6 @@ class RadioBridgeSerialSession {
// TODO: in the first-time connection case we used to move the error/disconnect to the background here, why? timing?
await periodicMessagePromise;

this.isRestartingConnection = false;
await this.startConnectionCheck();
this.callbacks.onSuccess();
} catch (e) {
Expand Down Expand Up @@ -447,33 +429,16 @@ class RadioBridgeSerialSession {
) {
this.logging.event({
type: "Serial",
message: "Serial connection lost...attempt to reconnect",
message: "Serial connection lost",
});
this.callbacks.onReconnecting();
}
if (
this.lastReceivedMessageTimestamp &&
Date.now() - this.lastReceivedMessageTimestamp >
connectTimeoutDuration
) {
await this.restartConnection();
this.callbacks.onBeforeConnectionLostDispose();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed please just ponder whether this could have a clearer name.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed here --> 8bdf2a3

await this.dispose(true);
this.callbacks.onFail();
}
}, 1000);
}
}

private async restartConnection() {
this.isRestartingConnection = true;
this.logging.event({
type: "Serial",
message: "Serial connection lost...restart connection",
});
this.callbacks.onRestartConnection();
await this.dispose(true);
await this.delegate.connect();
await this.connect();
}

private stopConnectionCheck() {
clearInterval(this.connectionCheckIntervalId);
this.connectionCheckIntervalId = undefined;
Expand Down