Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
57 changes: 8 additions & 49 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();
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 @@ -233,47 +217,22 @@ export class BluetoothDeviceWrapper implements Logging {
this.logging.log(
`Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}`,
);
this.duringExplicitConnectDisconnect++;
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.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
71 changes: 20 additions & 51 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,9 +35,8 @@ export interface MicrobitRadioBridgeConnectionOptions {

interface ConnectCallbacks {
onConnecting: () => void;
onReconnecting: () => void;
onRestartConnection: () => void;
onFail: () => void;
onFailPreDispose: () => void;
onFailPostDispose: () => 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 @@ -173,24 +170,18 @@ class MicrobitRadioBridgeConnectionImpl
this.delegate,
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);
}
onConnecting: () => {
this.setStatus(ConnectionStatus.CONNECTING);
this.serialSessionOpen = true;
},
onRestartConnection: () => {
// So that serial session does not get repetitively disposed in
// delegate status listener when delegate is disconnected for restarting connection
this.ignoreDelegateStatus = true;
onFailPreDispose: () => {
this.ignoreDelegateStatus = false;
this.serialSessionOpen = false;
},
onFail: () => {
onFailPostDispose: () => {
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 +251,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 +330,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 +358,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,12 +371,12 @@ 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) {
this.callbacks.onFail();
this.callbacks.onFailPreDispose();
await this.dispose();
this.callbacks.onFailPostDispose();
}
}

Expand Down Expand Up @@ -447,33 +433,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.onFailPreDispose();
await this.dispose(true);
this.callbacks.onFailPostDispose();
}
}, 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