Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
102 changes: 102 additions & 0 deletions lib/accelerometer-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { AccelerometerData, AccelerometerDataEvent } from "./accelerometer";
import { profile } from "./bluetooth-profile";
import {
CharacteristicDataTarget,
TypedServiceEventDispatcher,
} from "./service-events";

export class AccelerometerService {
constructor(
private accelerometerDataCharacteristic: BluetoothRemoteGATTCharacteristic,
private accelerometerPeriodCharacteristic: BluetoothRemoteGATTCharacteristic,
private dispatchTypedEvent: TypedServiceEventDispatcher,
private isNotifying: boolean,
) {
this.addDataEventListener();
if (this.isNotifying) {
this.startNotifications();
}
}

static async createService(
gattServer: BluetoothRemoteGATTServer,
dispatcher: TypedServiceEventDispatcher,
isNotifying: boolean,
): Promise<AccelerometerService> {
const accelerometerService = await gattServer.getPrimaryService(
profile.accelerometer.id,
);
const accelerometerDataCharacteristic =
await accelerometerService.getCharacteristic(
profile.accelerometer.characteristics.data.id,
);
const accelerometerPeriodCharacteristic =
await accelerometerService.getCharacteristic(
profile.accelerometer.characteristics.period.id,
);
return new AccelerometerService(
accelerometerDataCharacteristic,
accelerometerPeriodCharacteristic,
dispatcher,
isNotifying,
);
}

private dataViewToData(dataView: DataView): AccelerometerData {
return {
x: dataView.getInt16(0, true),
y: dataView.getInt16(2, true),
z: dataView.getInt16(4, true),
};
}

async getData(): Promise<AccelerometerData> {
const dataView = await this.accelerometerDataCharacteristic.readValue();
return this.dataViewToData(dataView);
}

async getPeriod(): Promise<number> {
const dataView = await this.accelerometerPeriodCharacteristic.readValue();
return dataView.getUint16(0, true);
}

async setPeriod(value: number): Promise<void> {
if (value === 0) {
// Writing 0 causes the device to crash.
Copy link
Contributor

Choose a reason for hiding this comment

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

We'll follow up on this one later.

return;
}
// Allowed values: 2, 5, 10, 20, 40, 100, 1000
// 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 dataView = new DataView(new ArrayBuffer(2));
dataView.setUint16(0, value, true);
await this.accelerometerPeriodCharacteristic.writeValueWithoutResponse(
dataView,
);
}

private addDataEventListener(): void {
this.accelerometerDataCharacteristic.addEventListener(
"characteristicvaluechanged",
(event: Event) => {
const target = event.target as CharacteristicDataTarget;
const data = this.dataViewToData(target.value);
this.dispatchTypedEvent(
"accelerometerdatachanged",
new AccelerometerDataEvent(data),
);
},
);
}

startNotifications(): void {
this.accelerometerDataCharacteristic.startNotifications();
this.isNotifying = true;
}

stopNotifications(): void {
this.isNotifying = false;
this.accelerometerDataCharacteristic.stopNotifications();
}
}
11 changes: 11 additions & 0 deletions lib/accelerometer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface AccelerometerData {
x: number;
y: number;
z: number;
}

export class AccelerometerDataEvent extends Event {
constructor(public readonly data: AccelerometerData) {
super("accelerometerdatachanged");
}
}
39 changes: 38 additions & 1 deletion lib/bluetooth-device-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
* SPDX-License-Identifier: MIT
*/

import { AccelerometerService } from "./accelerometer-service";
import { profile } from "./bluetooth-profile";
import { BoardVersion } from "./device";
import { Logging, NullLogging } from "./logging";
import { TypedServiceEventDispatcher } from "./service-events";

const deviceIdToWrapper: Map<string, BluetoothDeviceWrapper> = new Map();

Expand Down Expand Up @@ -43,12 +45,20 @@ export class BluetoothDeviceWrapper {
private connecting = false;
private isReconnect = false;
private reconnectReadyPromise: Promise<void> | undefined;
private accelerometerService: AccelerometerService | undefined;

boardVersion: BoardVersion | undefined;
serviceListeners = {
accelerometerdatachanged: {
notifying: false,
service: this.getAccelerometerService,
},
};

constructor(
public readonly device: BluetoothDevice,
private logging: Logging = new NullLogging(),
private dispatchTypedEvent: TypedServiceEventDispatcher,
) {
device.addEventListener(
"gattserverdisconnected",
Expand Down Expand Up @@ -138,6 +148,14 @@ export class BluetoothDeviceWrapper {
this.connecting = false;
}

// Restart notifications for services and characteristics
// the user has listened to.
for (const serviceListener of Object.values(this.serviceListeners)) {
if (serviceListener.notifying) {
serviceListener.service.call(this);
}
}

this.logging.event({
type: this.isReconnect ? "Reconnect" : "Connect",
message: "Bluetooth connect success",
Expand Down Expand Up @@ -172,6 +190,7 @@ export class BluetoothDeviceWrapper {
this.logging.error("Bluetooth GATT disconnect error (ignored)", e);
// We might have already lost the connection.
} finally {
this.disposeServices();
this.duringExplicitConnectDisconnect--;
}
this.reconnectReadyPromise = new Promise((resolve) =>
Expand Down Expand Up @@ -201,6 +220,7 @@ export class BluetoothDeviceWrapper {
"Bluetooth GATT disconnected... automatically trying reconnect",
);
// stateOnReconnectionAttempt();
this.disposeServices();
await this.reconnect();
} else {
this.logging.log(
Expand Down Expand Up @@ -248,18 +268,35 @@ export class BluetoothDeviceWrapper {
throw new Error("Could not read model number");
}
}

async getAccelerometerService(): Promise<AccelerometerService> {
if (!this.accelerometerService) {
const gattServer = this.assertGattServer();
this.accelerometerService = await AccelerometerService.createService(
gattServer,
this.dispatchTypedEvent,
this.serviceListeners.accelerometerdatachanged.notifying,
);
}
return this.accelerometerService;
}

private disposeServices() {
this.accelerometerService = undefined;
}
}

export const createBluetoothDeviceWrapper = async (
device: BluetoothDevice,
logging: Logging,
dispatchTypedEvent: TypedServiceEventDispatcher,
): Promise<BluetoothDeviceWrapper | undefined> => {
try {
// Reuse our connection objects for the same device as they
// track the GATT connect promise that never resolves.
const bluetooth =
deviceIdToWrapper.get(device.id) ??
new BluetoothDeviceWrapper(device, logging);
new BluetoothDeviceWrapper(device, logging, dispatchTypedEvent);
deviceIdToWrapper.set(device.id, bluetooth);
await bluetooth.connect();
return bluetooth;
Expand Down
87 changes: 79 additions & 8 deletions lib/bluetooth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,27 @@
*
* SPDX-License-Identifier: MIT
*/
import { AccelerometerData } from "./accelerometer";
import {
BluetoothDeviceWrapper,
createBluetoothDeviceWrapper,
} from "./bluetooth-device-wrapper";
import { profile } from "./bluetooth-profile";
import {
AfterRequestDevice,
BeforeRequestDevice,
BoardVersion,
ConnectOptions,
ConnectionStatus,
ConnectionStatusEvent,
DeviceConnection,
DeviceConnectionEventMap,
AfterRequestDevice,
FlashDataSource,
SerialResetEvent,
BeforeRequestDevice,
} from "./device";
import { TypedEventTarget } from "./events";
import { Logging, NullLogging } from "./logging";
import { ServiceConnectionEventMap, TypedServiceEvent } from "./service-events";

const requestDeviceTimeoutDuration: number = 30000;

Expand All @@ -33,7 +35,7 @@ export interface MicrobitWebBluetoothConnectionOptions {
* A Bluetooth connection to a micro:bit device.
*/
export class MicrobitWebBluetoothConnection
extends TypedEventTarget<DeviceConnectionEventMap>
extends TypedEventTarget<DeviceConnectionEventMap & ServiceConnectionEventMap>
implements DeviceConnection
{
// TODO: when do we call getAvailable() ?
Expand All @@ -50,9 +52,20 @@ export class MicrobitWebBluetoothConnection
private logging: Logging;
connection: BluetoothDeviceWrapper | undefined;

private _addEventListener = this.addEventListener;
private _removeEventListener = this.removeEventListener;

constructor(options: MicrobitWebBluetoothConnectionOptions = {}) {
super();
this.logging = options.logging || new NullLogging();
this.addEventListener = (type, ...args) => {
this._addEventListener(type, ...args);
this.startNotifications(type);
};
this.removeEventListener = (type, ...args) => {
this.stopNotifications(type);
this._removeEventListener(type, ...args);
};
}

private log(v: any) {
Expand Down Expand Up @@ -91,7 +104,7 @@ export class MicrobitWebBluetoothConnection
* A progress callback. Called with undefined when the process is complete or has failed.
*/
progress: (percentage: number | undefined) => void;
}
},
): Promise<void> {
throw new Error("Unsupported");
}
Expand Down Expand Up @@ -163,16 +176,18 @@ export class MicrobitWebBluetoothConnection
}
this.connection = await createBluetoothDeviceWrapper(
device,
this.logging
this.logging,
(type, event) => this.dispatchTypedEvent(type, event),
);
}
// TODO: timeout unification?
this.connection?.connect();
// Connection happens inside createBluetoothDeviceWrapper.
// await this.connection?.connect();
this.setStatus(ConnectionStatus.CONNECTED);
}

private async chooseDevice(
options: ConnectOptions
options: ConnectOptions,
): Promise<BluetoothDevice | undefined> {
if (this.device) {
return this.device;
Expand Down Expand Up @@ -204,7 +219,7 @@ export class MicrobitWebBluetoothConnection
],
}),
new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration)
setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration),
),
]);
if (result === "timeout") {
Expand All @@ -221,4 +236,60 @@ export class MicrobitWebBluetoothConnection
this.dispatchTypedEvent("afterrequestdevice", new AfterRequestDevice());
}
}

async getAccelerometerData(): Promise<AccelerometerData | undefined> {
const accelerometerService =
await this.connection?.getAccelerometerService();
return accelerometerService?.getData();
}

async getAccelerometerPeriod(): Promise<number | undefined> {
const accelerometerService =
await this.connection?.getAccelerometerService();
return accelerometerService?.getPeriod();
}

async setAccelerometerPeriod(value: number): Promise<void> {
const accelerometerService =
await this.connection?.getAccelerometerService();
accelerometerService?.setPeriod(value);
}

private async startAccelerometerNotifications() {
const accelerometerService =
await this.connection?.getAccelerometerService();
accelerometerService?.startNotifications();
if (this.connection) {
this.connection.serviceListeners.accelerometerdatachanged.notifying =
true;
}
}

private async stopAccelerometerNotifications() {
const accelerometerService =
await this.connection?.getAccelerometerService();
if (this.connection) {
this.connection.serviceListeners.accelerometerdatachanged.notifying =
false;
}
accelerometerService?.stopNotifications();
}

private async startNotifications(type: string) {
switch (type as TypedServiceEvent) {
case "accelerometerdatachanged": {
this.startAccelerometerNotifications();
break;
}
}
}

private async stopNotifications(type: string) {
switch (type as TypedServiceEvent) {
case "accelerometerdatachanged": {
this.stopAccelerometerNotifications();
break;
}
}
}
}
2 changes: 1 addition & 1 deletion lib/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export interface DeviceConnection
* The partial parameter reports the flash type currently in progress.
*/
progress: (percentage: number | undefined, partial: boolean) => void;
}
},
): Promise<void>;

/**
Expand Down
2 changes: 1 addition & 1 deletion lib/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class MockDeviceConnection
* A progress callback. Called with undefined when the process is complete or has failed.
*/
progress: (percentage: number | undefined) => void;
}
},
): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 100));
options.progress(0.5);
Expand Down
Loading