diff --git a/lib/accelerometer-service.ts b/lib/accelerometer-service.ts index f2ded91..be11b71 100644 --- a/lib/accelerometer-service.ts +++ b/lib/accelerometer-service.ts @@ -1,5 +1,7 @@ import { AccelerometerData, AccelerometerDataEvent } from "./accelerometer.js"; +import { GattOperation } from "./bluetooth-device-wrapper.js"; import { profile } from "./bluetooth-profile.js"; +import { createGattOperationPromise } from "./bluetooth-utils.js"; import { CharacteristicDataTarget, TypedServiceEventDispatcher, @@ -11,6 +13,7 @@ export class AccelerometerService { private accelerometerPeriodCharacteristic: BluetoothRemoteGATTCharacteristic, private dispatchTypedEvent: TypedServiceEventDispatcher, private isNotifying: boolean, + private queueGattOperation: (gattOperation: GattOperation) => void, ) { this.addDataEventListener(); if (this.isNotifying) { @@ -22,6 +25,7 @@ export class AccelerometerService { gattServer: BluetoothRemoteGATTServer, dispatcher: TypedServiceEventDispatcher, isNotifying: boolean, + queueGattOperation: (gattOperation: GattOperation) => void, ): Promise { const accelerometerService = await gattServer.getPrimaryService( profile.accelerometer.id, @@ -39,6 +43,7 @@ export class AccelerometerService { accelerometerPeriodCharacteristic, dispatcher, isNotifying, + queueGattOperation, ); } @@ -51,12 +56,22 @@ export class AccelerometerService { } async getData(): Promise { - const dataView = await this.accelerometerDataCharacteristic.readValue(); + const { callback, gattOperationPromise } = createGattOperationPromise(); + this.queueGattOperation({ + callback, + operation: () => this.accelerometerDataCharacteristic.readValue(), + }); + const dataView = (await gattOperationPromise) as DataView; return this.dataViewToData(dataView); } async getPeriod(): Promise { - const dataView = await this.accelerometerPeriodCharacteristic.readValue(); + const { callback, gattOperationPromise } = createGattOperationPromise(); + this.queueGattOperation({ + callback, + operation: () => this.accelerometerPeriodCharacteristic.readValue(), + }); + const dataView = (await gattOperationPromise) as DataView; return dataView.getUint16(0, true); } @@ -69,11 +84,16 @@ export class AccelerometerService { // Values passed are rounded up to the allowed values on device. // Documentation for allowed values looks wrong. // https://lancaster-university.github.io/microbit-docs/resources/bluetooth/bluetooth_profile.html + const { callback } = createGattOperationPromise(); const dataView = new DataView(new ArrayBuffer(2)); dataView.setUint16(0, value, true); - await this.accelerometerPeriodCharacteristic.writeValueWithoutResponse( - dataView, - ); + this.queueGattOperation({ + callback, + operation: () => + this.accelerometerPeriodCharacteristic.writeValueWithoutResponse( + dataView, + ), + }); } private addDataEventListener(): void { diff --git a/lib/bluetooth-device-wrapper.ts b/lib/bluetooth-device-wrapper.ts index 09cf89a..73ae7b7 100644 --- a/lib/bluetooth-device-wrapper.ts +++ b/lib/bluetooth-device-wrapper.ts @@ -6,10 +6,25 @@ import { AccelerometerService } from "./accelerometer-service.js"; import { profile } from "./bluetooth-profile.js"; -import { BoardVersion } from "./device.js"; +import { BoardVersion, DeviceError } from "./device.js"; import { Logging, NullLogging } from "./logging.js"; import { TypedServiceEventDispatcher } from "./service-events.js"; +export interface GattOperationCallback { + resolve: (result: DataView | void) => void; + reject: (error: DeviceError) => void; +} + +export interface GattOperation { + operation: () => Promise; + callback: GattOperationCallback; +} + +interface GattOperations { + busy: boolean; + queue: GattOperation[]; +} + const deviceIdToWrapper: Map = new Map(); const connectTimeoutDuration: number = 10000; @@ -55,6 +70,11 @@ export class BluetoothDeviceWrapper { }, }; + private gattOperations: GattOperations = { + busy: false, + queue: [], + }; + constructor( public readonly device: BluetoothDevice, private logging: Logging = new NullLogging(), @@ -212,8 +232,6 @@ export class BluetoothDeviceWrapper { } handleDisconnectEvent = async (): Promise => { - // this.outputWriteQueue = { busy: false, queue: [] }; - try { if (!this.duringExplicitConnectDisconnect) { this.logging.log( @@ -269,6 +287,57 @@ export class BluetoothDeviceWrapper { } } + private queueGattOperation(gattOperation: GattOperation) { + this.gattOperations.queue.push(gattOperation); + this.processGattOperationQueue(); + } + + private processGattOperationQueue = (): void => { + if (!this.device.gatt?.connected) { + // No longer connected. Drop queue. + this.clearGattQueueOnDisconnect(); + return; + } + if (this.gattOperations.busy) { + // We will finish processing the current operation, then + // pick up processing the queue in the finally block. + return; + } + const gattOperation = this.gattOperations.queue.shift(); + if (!gattOperation) { + return; + } + this.gattOperations.busy = true; + gattOperation + .operation() + .then((result) => { + gattOperation.callback.resolve(result); + }) + .catch((err) => { + gattOperation.callback.reject( + new DeviceError({ code: "background-comms-error", message: err }), + ); + this.logging.error("Error processing gatt operations queue", err); + }) + .finally(() => { + this.gattOperations.busy = false; + this.processGattOperationQueue(); + }); + }; + + private clearGattQueueOnDisconnect() { + this.gattOperations.queue.forEach((op) => { + op.callback.reject( + new DeviceError({ + code: "device-disconnected", + message: + "Error processing gatt operations queue - device disconnected", + }), + ); + }); + this.gattOperations = { busy: false, queue: [] }; + } + async getAccelerometerService(): Promise { if (!this.accelerometerService) { const gattServer = this.assertGattServer(); @@ -276,6 +345,7 @@ export class BluetoothDeviceWrapper { gattServer, this.dispatchTypedEvent, this.serviceListeners.accelerometerdatachanged.notifying, + this.queueGattOperation.bind(this), ); } return this.accelerometerService; @@ -283,6 +353,7 @@ export class BluetoothDeviceWrapper { private disposeServices() { this.accelerometerService = undefined; + this.clearGattQueueOnDisconnect(); } } diff --git a/lib/bluetooth-utils.ts b/lib/bluetooth-utils.ts new file mode 100644 index 0000000..d6057c1 --- /dev/null +++ b/lib/bluetooth-utils.ts @@ -0,0 +1,18 @@ +import { GattOperationCallback } from "./bluetooth-device-wrapper.js"; + +export const createGattOperationPromise = (): { + callback: GattOperationCallback; + gattOperationPromise: Promise; +} => { + let resolve: (result: DataView | void) => void; + let reject: () => void; + const gattOperationPromise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const callback = { + resolve: resolve!, + reject: reject!, + }; + return { callback, gattOperationPromise }; +}; diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts index e26d9db..36f4214 100644 --- a/lib/bluetooth.ts +++ b/lib/bluetooth.ts @@ -163,7 +163,7 @@ export class MicrobitWebBluetoothConnection this.connection = await createBluetoothDeviceWrapper( device, this.logging, - (type, event) => this.dispatchTypedEvent(type, event), + this.dispatchTypedEvent.bind(this), ); } // TODO: timeout unification? diff --git a/lib/device.ts b/lib/device.ts index ed99000..e91f9fe 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -35,7 +35,11 @@ export type DeviceErrorCode = /** * This is the fallback error case suggesting that the user reconnects their device. */ - | "reconnect-microbit"; + | "reconnect-microbit" + /** + * Error occured during serial or bluetooth communication. + */ + | "background-comms-error"; /** * Error type used for all interactions with this module.