From 978398462b14cfcf2209be1b7aab91114624594f Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 30 Aug 2024 18:02:49 +0200 Subject: [PATCH 01/16] :heavy_plus_sign: (core): Add typings for web-ble --- packages/core/package.json | 1 + pnpm-lock.yaml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/core/package.json b/packages/core/package.json index f40236141..8e765dd0f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -55,6 +55,7 @@ "@types/semver": "^7.5.8", "@types/uuid": "^10.0.0", "@types/w3c-web-hid": "^1.0.6", + "@types/web-bluetooth": "^0.0.20", "ts-node": "^10.9.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c16e7e9cd..d56bccee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,6 +259,9 @@ importers: '@types/w3c-web-hid': specifier: ^1.0.6 version: 1.0.6 + '@types/web-bluetooth': + specifier: ^0.0.20 + version: 0.0.20 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.7.5)(typescript@5.6.3) @@ -2857,6 +2860,9 @@ packages: '@types/w3c-web-hid@1.0.6': resolution: {integrity: sha512-IWyssXmRDo6K7s31dxf+U+x/XUWuVsl9qUIYbJmpUHPcTv/COfBCKw/F0smI45+gPV34brjyP30BFcIsHgYWLA==} + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -10673,6 +10679,8 @@ snapshots: '@types/w3c-web-hid@1.0.6': {} + '@types/web-bluetooth@0.0.20': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@15.0.19': From 5cf96438d5ed6cd08f9176730b8d9791a155bf01 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 30 Aug 2024 18:18:47 +0200 Subject: [PATCH 02/16] :recycle: (core): Add bluetooth models to device model data source --- .../data/DeviceModelDataSource.ts | 5 +++ .../data/StaticDeviceModelDataSource.ts | 38 +++++++++++++++++++ .../transport/ble/model/BleDeviceInfos.ts | 9 +++++ 3 files changed, 52 insertions(+) create mode 100644 packages/core/src/internal/transport/ble/model/BleDeviceInfos.ts diff --git a/packages/core/src/internal/device-model/data/DeviceModelDataSource.ts b/packages/core/src/internal/device-model/data/DeviceModelDataSource.ts index fb56a8f46..5a41271d2 100644 --- a/packages/core/src/internal/device-model/data/DeviceModelDataSource.ts +++ b/packages/core/src/internal/device-model/data/DeviceModelDataSource.ts @@ -1,5 +1,6 @@ import { DeviceModelId } from "@api/device/DeviceModel"; import { InternalDeviceModel } from "@internal/device-model/model/DeviceModel"; +import { BleDeviceInfos } from "@internal/transport/ble/model/BleDeviceInfos"; /** * Source of truth for the device models @@ -12,4 +13,8 @@ export interface DeviceModelDataSource { filterDeviceModels( params: Partial, ): InternalDeviceModel[]; + + getBluetoothServicesInfos(): Record; + + getBluetoothServices(): string[]; } diff --git a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts index ae148a06e..6d900f393 100644 --- a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts +++ b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts @@ -2,6 +2,7 @@ import { injectable } from "inversify"; import { DeviceModelId } from "@api/device/DeviceModel"; import { InternalDeviceModel } from "@internal/device-model/model/DeviceModel"; +import { BleDeviceInfos } from "@internal/transport/ble/model/BleDeviceInfos"; import { DeviceModelDataSource } from "./DeviceModelDataSource"; @@ -104,4 +105,41 @@ export class StaticDeviceModelDataSource implements DeviceModelDataSource { }); }); } + + getBluetoothServicesInfos(): Record { + return Object.values(StaticDeviceModelDataSource.deviceModelByIds).reduce< + Record + >((acc, deviceModel) => { + const { bluetoothSpec } = deviceModel; + if (bluetoothSpec) { + return { + ...acc, + ...bluetoothSpec.reduce>( + (serviceToModel, bleSpec) => ({ + ...serviceToModel, + [bleSpec.serviceUuid]: { + deviceModel, + ...bleSpec, + }, + }), + {}, + ), + }; + } + return acc; + }, {}); + } + + getBluetoothServices(): string[] { + return Object.values(StaticDeviceModelDataSource.deviceModelByIds).reduce< + string[] + >((acc, deviceModel) => { + const { bluetoothSpec } = deviceModel; + + if (bluetoothSpec) { + return acc.concat(bluetoothSpec.map((spec) => spec.serviceUuid)); + } + return acc; + }, []); + } } diff --git a/packages/core/src/internal/transport/ble/model/BleDeviceInfos.ts b/packages/core/src/internal/transport/ble/model/BleDeviceInfos.ts new file mode 100644 index 000000000..16c4a8edc --- /dev/null +++ b/packages/core/src/internal/transport/ble/model/BleDeviceInfos.ts @@ -0,0 +1,9 @@ +import { InternalDeviceModel } from "@internal/device-model/model/DeviceModel"; + +export interface BleDeviceInfos { + deviceModel: InternalDeviceModel; + serviceUuid: string; + writeUuid: string; + writeCmdUuid: string; + notifyUuid: string; +} From da52f3d3ddf70b8a1237ccddb45fde87dc84ed6a Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 30 Aug 2024 18:20:02 +0200 Subject: [PATCH 03/16] :sparkles: (core): Create ble transport module --- .../transport/model/TransportIdentifier.ts | 1 + packages/core/src/di.ts | 2 + .../device-session/di/deviceSessionModule.ts | 2 +- .../internal/transport/ble/di/bleDiTypes.ts | 3 + .../transport/ble/di/bleModule.test.ts | 26 ++ .../internal/transport/ble/di/bleModule.ts | 10 + .../transport/ble/model/BleDevice.stub.ts | 62 +++ .../BleDeviceConnectionFactory.stub.ts | 13 + .../ble/service/BleDeviceConnectionFactory.ts | 38 ++ .../ble/transport/BleDeviceConnection.test.ts | 138 +++++++ .../ble/transport/BleDeviceConnection.ts | 196 ++++++++++ .../ble/transport/WebBleTransport.test.ts | 315 +++++++++++++++ .../ble/transport/WebBleTransport.ts | 362 ++++++++++++++++++ .../transport/__mocks__/WebBleTransport.ts | 25 ++ .../transport/data/TransportDataSource.ts | 2 + .../src/internal/transport/model/Errors.ts | 22 ++ 16 files changed, 1216 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/internal/transport/ble/di/bleDiTypes.ts create mode 100644 packages/core/src/internal/transport/ble/di/bleModule.test.ts create mode 100644 packages/core/src/internal/transport/ble/di/bleModule.ts create mode 100644 packages/core/src/internal/transport/ble/model/BleDevice.stub.ts create mode 100644 packages/core/src/internal/transport/ble/service/BleDeviceConnectionFactory.stub.ts create mode 100644 packages/core/src/internal/transport/ble/service/BleDeviceConnectionFactory.ts create mode 100644 packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts create mode 100644 packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts create mode 100644 packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts create mode 100644 packages/core/src/internal/transport/ble/transport/WebBleTransport.ts create mode 100644 packages/core/src/internal/transport/ble/transport/__mocks__/WebBleTransport.ts diff --git a/packages/core/src/api/transport/model/TransportIdentifier.ts b/packages/core/src/api/transport/model/TransportIdentifier.ts index c939e9509..597d93e86 100644 --- a/packages/core/src/api/transport/model/TransportIdentifier.ts +++ b/packages/core/src/api/transport/model/TransportIdentifier.ts @@ -2,5 +2,6 @@ export type TransportIdentifier = string; export enum BuiltinTransports { USB = "USB", + BLE = "BLE", MOCK_SERVER = "MOCK_SERVER", } diff --git a/packages/core/src/di.ts b/packages/core/src/di.ts index 31fe8f45a..75909c0f4 100644 --- a/packages/core/src/di.ts +++ b/packages/core/src/di.ts @@ -22,6 +22,7 @@ import { } from "@internal/manager-api/model/Const"; import { sendModuleFactory } from "@internal/send/di/sendModule"; import { transportModuleFactory } from "@internal/transport//di/transportModule"; +import { bleModuleFactory } from "@internal/transport/ble/di/bleModule"; import { usbModuleFactory } from "@internal/transport/usb/di/usbModule"; // Uncomment this line to enable the logger middleware @@ -62,6 +63,7 @@ export const makeContainer = ({ sendModuleFactory({ stub }), commandModuleFactory({ stub }), deviceActionModuleFactory({ stub }), + bleModuleFactory(), // modules go here ); diff --git a/packages/core/src/internal/device-session/di/deviceSessionModule.ts b/packages/core/src/internal/device-session/di/deviceSessionModule.ts index b34ec4a52..90bd75b1b 100644 --- a/packages/core/src/internal/device-session/di/deviceSessionModule.ts +++ b/packages/core/src/internal/device-session/di/deviceSessionModule.ts @@ -56,7 +56,7 @@ export const deviceSessionModuleFactory = ( (name: string) => LoggerPublisherService >(loggerTypes.LoggerPublisherServiceFactory); - return (args: DefaultApduReceiverConstructorArgs) => { + return (args: DefaultApduReceiverConstructorArgs = {}) => { return new DefaultApduReceiverService(args, logger); }; }); diff --git a/packages/core/src/internal/transport/ble/di/bleDiTypes.ts b/packages/core/src/internal/transport/ble/di/bleDiTypes.ts new file mode 100644 index 000000000..1b25bc98e --- /dev/null +++ b/packages/core/src/internal/transport/ble/di/bleDiTypes.ts @@ -0,0 +1,3 @@ +export const bleDiTypes = { + BleDeviceConnectionFactory: Symbol.for("BleDeviceConnectionFactory"), +}; diff --git a/packages/core/src/internal/transport/ble/di/bleModule.test.ts b/packages/core/src/internal/transport/ble/di/bleModule.test.ts new file mode 100644 index 000000000..f55f1f483 --- /dev/null +++ b/packages/core/src/internal/transport/ble/di/bleModule.test.ts @@ -0,0 +1,26 @@ +import { Container } from "inversify"; + +import { deviceModelModuleFactory } from "@internal/device-model/di/deviceModelModule"; +import { deviceSessionModuleFactory } from "@internal/device-session/di/deviceSessionModule"; +import { loggerModuleFactory } from "@internal/logger-publisher/di/loggerModule"; + +import { bleModuleFactory } from "./bleModule"; + +describe("bleModuleFactory", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = bleModuleFactory(); + container = new Container(); + container.load(loggerModuleFactory()); + container.load( + mod, + deviceModelModuleFactory({ stub: false }), + deviceSessionModuleFactory(), + ); + }); + + it("should return the usb module", () => { + expect(mod).toBeDefined(); + }); +}); diff --git a/packages/core/src/internal/transport/ble/di/bleModule.ts b/packages/core/src/internal/transport/ble/di/bleModule.ts new file mode 100644 index 000000000..ae18d5100 --- /dev/null +++ b/packages/core/src/internal/transport/ble/di/bleModule.ts @@ -0,0 +1,10 @@ +import { ContainerModule } from "inversify"; + +import { BleDeviceConnectionFactory } from "@internal/transport/ble/service/BleDeviceConnectionFactory"; + +import { bleDiTypes } from "./bleDiTypes"; + +export const bleModuleFactory = () => + new ContainerModule((bind, _unbind, _isBound, _rebind) => { + bind(bleDiTypes.BleDeviceConnectionFactory).to(BleDeviceConnectionFactory); + }); diff --git a/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts b/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts new file mode 100644 index 000000000..004ce3f60 --- /dev/null +++ b/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts @@ -0,0 +1,62 @@ +const bleDeviceWithoutGatt: BluetoothDevice = { + name: "Ledger Nano X", + id: "42", + forget: jest.fn(), + watchAdvertisements: jest.fn(), + dispatchEvent: jest.fn(), + watchingAdvertisements: false, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + onadvertisementreceived: jest.fn(), + ongattserverdisconnected: jest.fn(), + oncharacteristicvaluechanged: jest.fn(), + onserviceadded: jest.fn(), + onservicechanged: jest.fn(), + onserviceremoved: jest.fn(), +}; + +const bluetoothGattPrimaryService: BluetoothRemoteGATTService = { + device: bleDeviceWithoutGatt, + uuid: "13d63400-2c97-0004-0000-4c6564676572", + isPrimary: true, + getCharacteristic: jest.fn(() => + Promise.resolve(bleCharacteristicStubBuilder()), + ), + getCharacteristics: jest.fn(), + getIncludedService: jest.fn(), + getIncludedServices: jest.fn(), + addEventListener: jest.fn(), + dispatchEvent: jest.fn(), + removeEventListener: jest.fn(), + oncharacteristicvaluechanged: jest.fn(), + onserviceadded: jest.fn(), + onservicechanged: jest.fn(), + onserviceremoved: jest.fn(), +}; + +export const bleCharacteristicStubBuilder = ( + props: Partial = {}, +): BluetoothRemoteGATTCharacteristic => + ({ + ...props, + addEventListener: jest.fn(), + startNotifications: jest.fn(), + writeValueWithResponse: jest.fn(), + }) as BluetoothRemoteGATTCharacteristic; + +export const bleDeviceStubBuilder = ( + props: Partial = {}, +): BluetoothDevice => ({ + ...bleDeviceWithoutGatt, + gatt: { + device: bleDeviceWithoutGatt, + connected: true, + connect: jest.fn(), + disconnect: jest.fn(), + getPrimaryService: jest.fn(), + getPrimaryServices: jest.fn(() => + Promise.resolve([bluetoothGattPrimaryService]), + ), + }, + ...props, +}); diff --git a/packages/core/src/internal/transport/ble/service/BleDeviceConnectionFactory.stub.ts b/packages/core/src/internal/transport/ble/service/BleDeviceConnectionFactory.stub.ts new file mode 100644 index 000000000..aed2fa665 --- /dev/null +++ b/packages/core/src/internal/transport/ble/service/BleDeviceConnectionFactory.stub.ts @@ -0,0 +1,13 @@ +import { defaultApduReceiverServiceStubBuilder } from "@internal/device-session/service/DefaultApduReceiverService.stub"; +import { defaultApduSenderServiceStubBuilder } from "@internal/device-session/service/DefaultApduSenderService.stub"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/__mocks__/DefaultLoggerService"; +import { BleDeviceConnectionFactory } from "@internal/transport/ble/service/BleDeviceConnectionFactory"; + +const loggerFactory = () => new DefaultLoggerPublisherService(); + +export const bleDeviceConnectionFactoryStubBuilder = () => + new BleDeviceConnectionFactory( + () => defaultApduSenderServiceStubBuilder({}, loggerFactory), + () => defaultApduReceiverServiceStubBuilder({}, loggerFactory), + loggerFactory, + ); diff --git a/packages/core/src/internal/transport/ble/service/BleDeviceConnectionFactory.ts b/packages/core/src/internal/transport/ble/service/BleDeviceConnectionFactory.ts new file mode 100644 index 000000000..44f79726c --- /dev/null +++ b/packages/core/src/internal/transport/ble/service/BleDeviceConnectionFactory.ts @@ -0,0 +1,38 @@ +import { inject, injectable } from "inversify"; + +import { deviceSessionTypes } from "@internal/device-session/di/deviceSessionTypes"; +import { ApduReceiverService } from "@internal/device-session/service/ApduReceiverService"; +import { ApduSenderService } from "@internal/device-session/service/ApduSenderService"; +import { DefaultApduSenderServiceConstructorArgs } from "@internal/device-session/service/DefaultApduSenderService"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { BleDeviceConnection } from "@internal/transport/ble/transport/BleDeviceConnection"; + +@injectable() +export class BleDeviceConnectionFactory { + constructor( + @inject(deviceSessionTypes.ApduSenderServiceFactory) + private readonly apduSenderFactory: ( + args: DefaultApduSenderServiceConstructorArgs, + ) => ApduSenderService, + @inject(deviceSessionTypes.ApduReceiverServiceFactory) + private readonly apduReceiverFactory: () => ApduReceiverService, + @inject(loggerTypes.LoggerPublisherServiceFactory) + private readonly loggerFactory: (name: string) => LoggerPublisherService, + ) {} + + public create( + writeCharacteristic: BluetoothRemoteGATTCharacteristic, + notifyCharacteristic: BluetoothRemoteGATTCharacteristic, + ): BleDeviceConnection { + return new BleDeviceConnection( + { + writeCharacteristic, + notifyCharacteristic, + apduSenderFactory: this.apduSenderFactory, + apduReceiverFactory: this.apduReceiverFactory, + }, + this.loggerFactory, + ); + } +} diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts new file mode 100644 index 000000000..1afb945d0 --- /dev/null +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts @@ -0,0 +1,138 @@ +import { Left, Right } from "purify-ts"; + +import { ApduReceiverService } from "@internal/device-session/service/ApduReceiverService"; +import { ApduSenderService } from "@internal/device-session/service/ApduSenderService"; +import { defaultApduReceiverServiceStubBuilder } from "@internal/device-session/service/DefaultApduReceiverService.stub"; +import { defaultApduSenderServiceStubBuilder } from "@internal/device-session/service/DefaultApduSenderService.stub"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { bleCharacteristicStubBuilder } from "@internal/transport/ble/model/BleDevice.stub"; +import { DeviceNotInitializedError } from "@internal/transport/model/Errors"; +import { ApduResponse } from "@root/src"; + +import { BleDeviceConnection, DataViewEvent } from "./BleDeviceConnection"; + +const GET_MTU_APDU = new Uint8Array([0x08, 0x00, 0x00, 0x00, 0x00]); +const GET_MTU_APDU_RESPONSE = new Uint8Array([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x42, +]); +const EMPTY_APDU_RESPONSE = Uint8Array.from([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); + +describe("BleDeviceConnection", () => { + let writeCharacteristic: BluetoothRemoteGATTCharacteristic; + let notifyCharacteristic: BluetoothRemoteGATTCharacteristic; + let apduSenderFactory: () => ApduSenderService; + let apduReceiverFactory: () => ApduReceiverService; + const logger = (tag: string) => new DefaultLoggerPublisherService([], tag); + + beforeEach(() => { + writeCharacteristic = bleCharacteristicStubBuilder(); + notifyCharacteristic = bleCharacteristicStubBuilder(); + apduSenderFactory = jest.fn(() => + defaultApduSenderServiceStubBuilder(undefined, logger), + ); + apduReceiverFactory = jest.fn(() => + defaultApduReceiverServiceStubBuilder(undefined, logger), + ); + }); + + function receiveApdu( + connection: BleDeviceConnection, + buffer: Uint8Array = Uint8Array.from([]), + ) { + // @ts-expect-error private function call to mock web ble response + connection.onNotifyCharacteristicValueChanged({ + target: { + value: { + buffer, + }, + }, + } as DataViewEvent); + } + + describe("sendApdu", () => { + it("should return an error if the device isn't setup", async () => { + // given + const connection = new BleDeviceConnection( + { + writeCharacteristic, + notifyCharacteristic, + apduSenderFactory, + apduReceiverFactory, + }, + logger, + ); + // when + const errorOrApduResponse = await connection.sendApdu( + Uint8Array.from([]), + ); + // then + expect(errorOrApduResponse).toStrictEqual( + Left(new DeviceNotInitializedError()), + ); + }); + + it("should call writeValueWithResponse if device is setup", async () => { + // given + const connection = new BleDeviceConnection( + { + writeCharacteristic, + notifyCharacteristic, + apduSenderFactory, + apduReceiverFactory, + }, + logger, + ); + // when + receiveApdu(connection, GET_MTU_APDU_RESPONSE); + const response = connection.sendApdu(new Uint8Array([])); + receiveApdu(connection, EMPTY_APDU_RESPONSE); + // then + expect(await response).toStrictEqual( + Right( + new ApduResponse({ + statusCode: Uint8Array.from([]), + data: Uint8Array.from([]), + }), + ), + ); + }); + }); + describe("setup", () => { + it("should send the apdu 0x0800000000 to get mtu size", async () => { + // given + const connection = new BleDeviceConnection( + { + writeCharacteristic, + notifyCharacteristic, + apduSenderFactory, + apduReceiverFactory, + }, + logger, + ); + // when + await connection.setup(); + // then + expect(writeCharacteristic.writeValueWithResponse).toHaveBeenCalledWith( + new Uint8Array(GET_MTU_APDU), + ); + }); + it("should setup apduSender with the correct mtu size", () => { + // given + const connection = new BleDeviceConnection( + { + writeCharacteristic, + notifyCharacteristic, + apduSenderFactory, + apduReceiverFactory, + }, + logger, + ); + // when + receiveApdu(connection, GET_MTU_APDU_RESPONSE); + // then + expect(apduSenderFactory).toHaveBeenCalledWith({ frameSize: 0x42 }); + }); + }); +}); diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts new file mode 100644 index 000000000..b6905f34d --- /dev/null +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts @@ -0,0 +1,196 @@ +import { Either, Left, Maybe, Nothing, Right } from "purify-ts"; +import { Subject } from "rxjs"; + +import { ApduResponse } from "@api/device-session/ApduResponse"; +import { SdkError } from "@api/Error"; +import { ApduReceiverService } from "@internal/device-session/service/ApduReceiverService"; +import { ApduSenderService } from "@internal/device-session/service/ApduSenderService"; +import { DefaultApduSenderServiceConstructorArgs } from "@internal/device-session/service/DefaultApduSenderService"; +import type { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { DeviceConnection } from "@internal/transport/model/DeviceConnection"; +import { DeviceNotInitializedError } from "@internal/transport/model/Errors"; + +type BleDeviceConnectionConstructorArgs = { + writeCharacteristic: BluetoothRemoteGATTCharacteristic; + notifyCharacteristic: BluetoothRemoteGATTCharacteristic; + apduSenderFactory: ( + args: DefaultApduSenderServiceConstructorArgs, + ) => ApduSenderService; + apduReceiverFactory: () => ApduReceiverService; +}; + +export type DataViewEvent = Event & { + target: { + value: DataView; + }; +}; + +export class BleDeviceConnection implements DeviceConnection { + private readonly _writeCharacteristic: BluetoothRemoteGATTCharacteristic; + private readonly _notifyCharacteristic: BluetoothRemoteGATTCharacteristic; + private readonly _logger: LoggerPublisherService; + private _apduSender: Maybe; + private readonly _apduSenderFactory: ( + args: DefaultApduSenderServiceConstructorArgs, + ) => ApduSenderService; + private readonly _apduReceiver: ApduReceiverService; + private _isDeviceReady: boolean; + private _sendApduSubject: Subject; + + constructor( + { + writeCharacteristic, + notifyCharacteristic, + apduSenderFactory, + apduReceiverFactory, + }: BleDeviceConnectionConstructorArgs, + loggerServiceFactory: (tag: string) => LoggerPublisherService, + ) { + this._apduSenderFactory = apduSenderFactory; + this._apduSender = Nothing; + this._apduReceiver = apduReceiverFactory(); + this._logger = loggerServiceFactory("BleDeviceConnection"); + this._writeCharacteristic = writeCharacteristic; + this._notifyCharacteristic = notifyCharacteristic; + this._isDeviceReady = false; + this._notifyCharacteristic.addEventListener( + "characteristicvaluechanged", + this.onNotifyCharacteristicValueChanged, + ); + this._sendApduSubject = new Subject(); + } + + /** + * Event handler to setup the mtu size in response of 0x0800000000 APDU + * @param value + * @private + */ + private onReceiveSetupApduResponse(value: ArrayBuffer) { + const mtuResponse = new Uint8Array(value); + // the mtu is the 5th byte of the response + const [frameSize] = mtuResponse.slice(5); + if (frameSize) { + this._apduSender = Maybe.of(this._apduSenderFactory({ frameSize })); + this._isDeviceReady = true; + this._logger.debug("new frame size value change", { + data: { frameSize }, + }); + } + } + + /** + * Main event handler for BLE notify characteristic + * Call _onReceiveSetupApduResponse if device mtu is not set + * Call receiveApdu otherwise + * @param event + */ + private onNotifyCharacteristicValueChanged = (event: Event) => { + if (!this.isDataViewEvent(event)) { + return; + } + const { + target: { + value: { buffer }, + }, + } = event; + if (!this._isDeviceReady) { + return this.onReceiveSetupApduResponse(buffer); + } + return this.receiveApdu(buffer); + }; + + /** + * Setup BleDeviceConnection + * + * The device is considered as ready once the mtu had been set + * APDU 0x0800000000 is used to get this mtu size + */ + public async setup() { + const apdu = Uint8Array.from([0x08, 0x00, 0x00, 0x00, 0x00]); + + await this._notifyCharacteristic.startNotifications(); + await this._writeCharacteristic.writeValueWithResponse(apdu); + } + + /** + * Receive APDU response + * Complete sendApdu subject once the framer receives all the frames of the response + * @param data + */ + async receiveApdu(data: ArrayBuffer) { + const response = this._apduReceiver.handleFrame(new Uint8Array(data)); + + response.caseOf({ + Right: (maybeApduResponse) => { + maybeApduResponse.map((apduResponse) => { + this._logger.debug("Received APDU Response", { + data: { response: apduResponse }, + }); + this._sendApduSubject.next(apduResponse); + this._sendApduSubject.complete(); + }); + }, + Left: (error) => this._sendApduSubject.error(error), + }); + } + + /** + * Send apdu if the mtu had been set + * + * Get all frames for a given APDU + * Subscribe to a Subject that would be complete once the response had been received + * @param apdu + */ + async sendApdu(apdu: Uint8Array): Promise> { + if (!this._isDeviceReady) { + return Left(new DeviceNotInitializedError()); + } + this._sendApduSubject = new Subject(); + + const resultPromise = new Promise>( + (resolve) => { + this._sendApduSubject.subscribe({ + next: async (response) => { + resolve(Right(response)); + }, + error: (err) => resolve(Left(err)), + }); + }, + ); + const frames = this._apduSender.caseOf({ + Just: (apduSender) => apduSender.getFrames(apdu), + Nothing: () => [], + }); + for (const frame of frames) { + try { + await this._writeCharacteristic.writeValueWithResponse( + frame.getRawData(), + ); + } catch (error) { + this._logger.error("Error sending frame", { data: { error } }); + } + } + return resultPromise; + } + + /** + * Typeguard to check if an event contains target value of type DataView + * + * @param event + * @private + */ + private isDataViewEvent(event: Event): event is DataViewEvent { + return ( + typeof event.target === "object" && + event.target !== null && + "value" in event.target && + typeof event.target.value === "object" && + event.target.value !== null && + "buffer" in event.target.value && + typeof event.target.value.buffer === "object" && + event.target.value.buffer !== null && + "byteLength" in event.target.value.buffer && + typeof event.target.value.buffer.byteLength === "number" + ); + } +} diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts new file mode 100644 index 000000000..1bb3f260e --- /dev/null +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts @@ -0,0 +1,315 @@ +import { Left } from "purify-ts"; + +import { DeviceModel } from "@api/device/DeviceModel"; +import { StaticDeviceModelDataSource } from "@internal/device-model/data/StaticDeviceModelDataSource"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { bleDeviceStubBuilder } from "@internal/transport/ble/model/BleDevice.stub"; +import { bleDeviceConnectionFactoryStubBuilder } from "@internal/transport/ble/service/BleDeviceConnectionFactory.stub"; +import { + BleDeviceGattServerError, + BleTransportNotSupportedError, + NoAccessibleDeviceError, + OpeningConnectionError, + UnknownDeviceError, +} from "@internal/transport/model/Errors"; +import { InternalDiscoveredDevice } from "@internal/transport/model/InternalDiscoveredDevice"; + +import { WebBleTransport } from "./WebBleTransport"; + +jest.mock("@internal/logger-publisher/service/LoggerPublisherService"); + +// Our StaticDeviceModelDataSource can directly be used in our unit tests +const bleDeviceModelDataSource = new StaticDeviceModelDataSource(); +const logger = new DefaultLoggerPublisherService([], "web-ble"); + +const stubDevice: BluetoothDevice = bleDeviceStubBuilder(); + +describe("WebBleTransport", () => { + let transport: WebBleTransport; + + beforeEach(() => { + transport = new WebBleTransport( + bleDeviceModelDataSource, + () => logger, + bleDeviceConnectionFactoryStubBuilder(), + ); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const discoverDevice = ( + onSuccess: (discoveredDevice: InternalDiscoveredDevice) => void, + onError?: (error: unknown) => void, + ) => { + transport.startDiscovering().subscribe({ + next: onSuccess, + error: onError, + }); + }; + + describe("When Web bluetooth API is not supported", () => { + it("should not support the transport", () => { + expect(transport.isSupported()).toBe(false); + }); + + it("should emit a startDiscovering error", (done) => { + discoverDevice( + () => { + done("Should not emit any value"); + }, + (error) => { + expect(error).toBeInstanceOf(BleTransportNotSupportedError); + done(); + }, + ); + }); + }); + + describe("When Web Bluetooth API is supported", () => { + const mockedRequestDevice = jest.fn(); + + beforeAll(() => { + global.navigator = { + bluetooth: { + requestDevice: mockedRequestDevice, + }, + } as unknown as Navigator; + }); + + afterAll(() => { + jest.restoreAllMocks(); + global.navigator = undefined as unknown as Navigator; + }); + + it("should support the transport", () => { + expect(transport.isSupported()).toBe(true); + }); + + describe("startDiscovering", () => { + it("should emit device if one new grant access", (done) => { + mockedRequestDevice.mockResolvedValueOnce(stubDevice); + + discoverDevice( + (discoveredDevice) => { + try { + expect(discoveredDevice).toEqual( + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: "nanoX", + productName: "Ledger Nano X", + }) as DeviceModel, + }), + ); + + done(); + } catch (expectError) { + done(expectError); + } + }, + (error) => { + done(error); + }, + ); + }); + + it("should throw DeviceNotRecognizedError if the device is not recognized", (done) => { + mockedRequestDevice.mockResolvedValueOnce({ + ...stubDevice, + gatt: { + ...stubDevice.gatt, + getPrimaryServices: jest.fn(() => Promise.resolve([])), + }, + productId: 0x4242, + }); + + discoverDevice( + () => { + done("should not return a device"); + }, + (error) => { + expect(error).toBeInstanceOf(BleDeviceGattServerError); + done(); + }, + ); + }); + + it("should emit an error if the request device is in error", (done) => { + const message = "request device error"; + mockedRequestDevice.mockImplementationOnce(() => { + throw new Error(message); + }); + + discoverDevice( + () => { + done("should not return a device"); + }, + (error) => { + expect(error).toBeInstanceOf(NoAccessibleDeviceError); + expect(error).toStrictEqual( + new NoAccessibleDeviceError(new Error(message)), + ); + done(); + }, + ); + }); + + it("should emit an error if the user did not grant us access to a device (clicking on cancel on the browser popup for ex)", (done) => { + mockedRequestDevice.mockResolvedValueOnce({}); + + discoverDevice( + (discoveredDevice) => { + done( + `Should not emit any value, but emitted ${JSON.stringify( + discoveredDevice, + )}`, + ); + }, + (error) => { + try { + expect(error).toBeInstanceOf(BleDeviceGattServerError); + done(); + } catch (expectError) { + done(expectError); + } + }, + ); + }); + }); + + describe("stopDiscovering", () => { + it("should stop monitoring connections if the discovery process is halted", () => { + const abortSpy = jest.spyOn(AbortController.prototype, "abort"); + + transport.stopDiscovering(); + + expect(abortSpy).toHaveBeenCalled(); + }); + }); + + describe("connect", () => { + it("should throw UnknownDeviceError if no internal device", async () => { + const connectParams = { + deviceId: "fake", + onDisconnect: jest.fn(), + }; + + const connect = await transport.connect(connectParams); + + expect(connect).toStrictEqual( + Left(new UnknownDeviceError("Unknown device fake")), + ); + }); + + it("should throw OpeningConnectionError if the device is already opened", async () => { + const device = { + deviceId: "fake", + onDisconnect: jest.fn(), + }; + + const connect = await transport.connect(device); + + expect(connect).toStrictEqual( + Left(new UnknownDeviceError("Unknown device fake")), + ); + }); + + it("should throw OpeningConnectionError if the device cannot be opened", (done) => { + const message = "cannot be opened"; + mockedRequestDevice.mockResolvedValueOnce({ + ...stubDevice, + gatt: { + connect: () => { + throw new Error(message); + }, + }, + }); + + discoverDevice( + () => { + done(); + }, + (error) => { + expect(error).toBeInstanceOf(OpeningConnectionError); + done(); + }, + ); + }); + + it("should return the opened device", (done) => { + mockedRequestDevice.mockResolvedValueOnce({ + ...stubDevice, + gatt: { + ...stubDevice.gatt, + connected: true, + connect: () => { + throw new DOMException("already opened", "InvalidStateError"); + }, + }, + }); + + discoverDevice( + (discoveredDevice) => { + transport + .connect({ + deviceId: discoveredDevice.id, + onDisconnect: jest.fn(), + }) + .then((connectedDevice) => { + connectedDevice + .ifRight((device) => { + expect(device).toEqual( + expect.objectContaining({ id: discoveredDevice.id }), + ); + done(); + }) + .ifLeft(() => { + done(connectedDevice); + }); + }) + .catch((error) => { + done(error); + }); + }, + (error) => { + done(error); + }, + ); + }); + + it("should return a device if available", (done) => { + mockedRequestDevice.mockResolvedValueOnce(stubDevice); + + discoverDevice( + (discoveredDevice) => { + transport + .connect({ + deviceId: discoveredDevice.id, + onDisconnect: jest.fn(), + }) + .then((connectedDevice) => { + connectedDevice + .ifRight((device) => { + expect(device).toEqual( + expect.objectContaining({ id: discoveredDevice.id }), + ); + done(); + }) + .ifLeft(() => { + done(connectedDevice); + }); + }) + .catch((error) => { + done(error); + }); + }, + (error) => { + done(error); + }, + ); + }); + }); + }); +}); diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts new file mode 100644 index 000000000..a3e0d9e35 --- /dev/null +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -0,0 +1,362 @@ +import { inject, injectable } from "inversify"; +import { Either, EitherAsync, Left, Right } from "purify-ts"; +import { from, Observable, switchMap } from "rxjs"; +import { v4 as uuid } from "uuid"; + +import { DeviceId } from "@api/device/DeviceModel"; +import { ConnectionType } from "@api/discovery/ConnectionType"; +import { SdkError } from "@api/Error"; +import { Transport } from "@api/transport/model/Transport"; +import { + BuiltinTransports, + TransportIdentifier, +} from "@api/transport/model/TransportIdentifier"; +import type { DeviceModelDataSource } from "@internal/device-model/data/DeviceModelDataSource"; +import { deviceModelTypes } from "@internal/device-model/di/deviceModelTypes"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import type { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { bleDiTypes } from "@internal/transport/ble/di/bleDiTypes"; +import { BleDeviceInfos } from "@internal/transport/ble/model/BleDeviceInfos"; +import { BleDeviceConnectionFactory } from "@internal/transport/ble/service/BleDeviceConnectionFactory"; +import { BleDeviceConnection } from "@internal/transport/ble/transport/BleDeviceConnection"; +import { DisconnectHandler } from "@internal/transport/model/DeviceConnection"; +import { + BleDeviceGattServerError, + BleTransportNotSupportedError, + ConnectError, + DeviceNotRecognizedError, + NoAccessibleDeviceError, + OpeningConnectionError, + type PromptDeviceAccessError, + UnknownDeviceError, +} from "@internal/transport/model/Errors"; +import { InternalConnectedDevice } from "@internal/transport/model/InternalConnectedDevice"; +import { InternalDiscoveredDevice } from "@internal/transport/model/InternalDiscoveredDevice"; + +// An attempt to manage the state of several devices with one transport. Not final. +type WebBleInternalDevice = { + id: DeviceId; + bleDevice: BluetoothDevice; + bleDeviceInfos: BleDeviceInfos; + bleGattService: BluetoothRemoteGATTService; + discoveredDevice: InternalDiscoveredDevice; +}; + +@injectable() +export class WebBleTransport implements Transport { + // Maps uncoupled DiscoveredDevice and WebHID's HIDDevice WebHID + private _internalDevicesById: Map; + private _deviceConnectionById: Map; + private _disconnectionHandlersById: Map; + private _connectionListenersAbortController: AbortController; + private _logger: LoggerPublisherService; + private readonly connectionType: ConnectionType = "BLE"; + private readonly identifier: TransportIdentifier = BuiltinTransports.BLE; + + constructor( + @inject(deviceModelTypes.DeviceModelDataSource) + private _deviceModelDataSource: DeviceModelDataSource, + @inject(loggerTypes.LoggerPublisherServiceFactory) + loggerServiceFactory: (tag: string) => LoggerPublisherService, + @inject(bleDiTypes.BleDeviceConnectionFactory) + private _bleDeviceConnectionFactory: BleDeviceConnectionFactory, + ) { + this._internalDevicesById = new Map(); + this._deviceConnectionById = new Map(); + this._disconnectionHandlersById = new Map(); + this._connectionListenersAbortController = new AbortController(); + this._logger = loggerServiceFactory("WebUsbHidTransport"); + } + + /** + * Get the Bluetooth API if supported or error + * @returns `Either` + */ + private get bluetoothApi(): Either { + if (this.isSupported()) { + return Right(navigator.bluetooth); + } + + return Left(new BleTransportNotSupportedError("WebHID not supported")); + } + + isSupported() { + try { + const result = !!navigator?.bluetooth; + this._logger.debug(`isSupported: ${result}`); + return result; + } catch (error) { + this._logger.error(`isSupported: error`, { data: { error } }); + return false; + } + } + + getIdentifier(): TransportIdentifier { + return this.identifier; + } + + /** + * Get Bluetooth GATT Primary service that is used to get writeCharacteristic and notifyCharacteristic + * @param bleDevice + * @private + */ + private async getBleGattService( + bleDevice: BluetoothDevice, + ): Promise> { + if (!bleDevice.gatt) { + return Left(new BleDeviceGattServerError("Device gatt not found")); + } + const [bleGattService] = await bleDevice.gatt.getPrimaryServices(); + + if (!bleGattService) { + return Left(new BleDeviceGattServerError("bluetooth service not found")); + } + return Right(bleGattService); + } + + /** + * BleDeviceInfos to map primary service uuid to device model & characteristics uuid + * @param bleGattService + * @private + */ + private getBleDeviceInfos( + bleGattService: BluetoothRemoteGATTService, + ): Either { + const serviceToBleInfos = + this._deviceModelDataSource.getBluetoothServicesInfos(); + const bleDeviceInfos = serviceToBleInfos[bleGattService.uuid]; + + if (!bleDeviceInfos) { + this._logger.error( + `Device not recognized: ${bleGattService.device.name}`, + ); + return Left( + new DeviceNotRecognizedError( + `Device not recognized: ${bleGattService.device.name}`, + ), + ); + } + return Right(bleDeviceInfos); + } + + /** + * Prompt device selection in navigator + * + * @private + */ + private async promptDeviceAccess(): Promise< + Either + > { + return EitherAsync.liftEither(this.bluetoothApi) + .map(async (bluetoothApi) => { + let bleDevice: BluetoothDevice; + + try { + bleDevice = await bluetoothApi.requestDevice({ + filters: this._deviceModelDataSource + .getBluetoothServices() + .map((serviceUuid) => ({ + services: [serviceUuid], + })), + }); + } catch (error) { + const deviceError = new NoAccessibleDeviceError(error); + this._logger.error(`promptDeviceAccess: error requesting device`, { + data: { error }, + }); + throw deviceError; + } + + this._logger.debug(`promptDeviceAccess: bleDevice found`); + return bleDevice; + }) + .run(); + } + + /** + * Generate a discovered device from BluetoothDevice, BleGATT primary service and BLE device infos + * @param bleDevice + * @param bleGattService + * @param bleDeviceInfos + * @private + */ + private getDiscoveredDeviceFrom( + bleDevice: BluetoothDevice, + bleGattService: BluetoothRemoteGATTService, + bleDeviceInfos: BleDeviceInfos, + ) { + const id = uuid(); + + const discoveredDevice = { + id, + deviceModel: bleDeviceInfos.deviceModel, + transport: this.identifier, + }; + + const internalDevice: WebBleInternalDevice = { + id, + bleDevice, + bleGattService, + bleDeviceInfos, + discoveredDevice, + }; + + this._logger.debug( + `Discovered device ${id} ${discoveredDevice.deviceModel.productName}`, + ); + this._internalDevicesById.set(id, internalDevice); + + return discoveredDevice; + } + + /** + * Main method to get a device from a button click handler + * The GATT connection is done here in order to populate InternalDiscoveredDevice with deviceModel + */ + startDiscovering(): Observable { + this._logger.debug("startDiscovering"); + + this.startListeningToConnectionEvents(); + + this._internalDevicesById.clear(); + + return from(this.promptDeviceAccess()).pipe( + switchMap(async (errorOrBleDevice) => + errorOrBleDevice.caseOf({ + Right: async (bleDevice) => { + // ble connect here as gatt server needs to be opened to fetch gatt service + if (bleDevice.gatt && !bleDevice.gatt.connected) { + try { + await bleDevice.gatt.connect(); + } catch (error) { + throw new OpeningConnectionError(error); + } + } + const errorOrBleGattService = + await this.getBleGattService(bleDevice); + return errorOrBleGattService.caseOf({ + Right: (bleGattService) => { + const errorOrBleDeviceInfos = + this.getBleDeviceInfos(bleGattService); + return errorOrBleDeviceInfos.caseOf({ + Right: (bleDeviceInfos) => + this.getDiscoveredDeviceFrom( + bleDevice, + bleGattService, + bleDeviceInfos, + ), + Left: (error) => { + throw error; + }, + }); + }, + Left: (error) => { + throw error; + }, + }); + }, + Left: (error) => { + this._logger.error("Error while getting accessible device", { + data: { error }, + }); + throw error; + }, + }), + ), + ); + } + + stopDiscovering(): void { + this._logger.debug("stopDiscovering"); + + this.stopListeningToConnectionEvents(); + } + + /** + * Logs `connect` and `disconnect` events for already accessible devices + */ + private startListeningToConnectionEvents(): void { + this._logger.debug("startListeningToConnectionEvents"); + } + + private stopListeningToConnectionEvents(): void { + this._logger.debug("stopListeningToConnectionEvents"); + this._connectionListenersAbortController.abort(); + } + + /** + * Connect to a BLE device and update the internal state of the associated device + */ + async connect({ + deviceId, + onDisconnect, + }: { + deviceId: DeviceId; + onDisconnect: DisconnectHandler; + }): Promise> { + this._logger.debug("connect", { data: { deviceId } }); + + const internalDevice = this._internalDevicesById.get(deviceId); + + if (!internalDevice) { + this._logger.error(`Unknown device ${deviceId}`); + return Left(new UnknownDeviceError(`Unknown device ${deviceId}`)); + } + + const { + discoveredDevice: { deviceModel }, + } = internalDevice; + + const [writeCharacteristic, notifyCharacteristic] = await Promise.all([ + internalDevice.bleGattService.getCharacteristic( + internalDevice.bleDeviceInfos.writeUuid, + ), + internalDevice.bleGattService.getCharacteristic( + internalDevice.bleDeviceInfos.notifyUuid, + ), + ]); + + const deviceConnection = this._bleDeviceConnectionFactory.create( + writeCharacteristic, + notifyCharacteristic, + ); + this._logger.debug("Device connection", { data: { deviceConnection } }); + await deviceConnection.setup(); + this._deviceConnectionById.set( + internalDevice.bleDevice.id, + deviceConnection, + ); + const connectedDevice = new InternalConnectedDevice({ + sendApdu: (apdu) => deviceConnection.sendApdu(apdu), + deviceModel, + id: deviceId, + type: this.connectionType, + transport: this.identifier, + }); + this._disconnectionHandlersById.set(internalDevice.bleDevice.id, () => { + this.disconnect({ connectedDevice }).then(() => onDisconnect(deviceId)); + }); + return Right(connectedDevice); + } + + /** + * Disconnect from a BLE device and delete its handlers + * TODO + */ + async disconnect(params: { + connectedDevice: InternalConnectedDevice; + }): Promise> { + this._logger.debug("disconnect", { data: { connectedDevice: params } }); + const internalDevice = this._internalDevicesById.get( + params.connectedDevice.id, + ); + + if (!internalDevice) { + this._logger.error(`Unknown device ${params.connectedDevice.id}`); + return Left( + new UnknownDeviceError(`Unknown device ${params.connectedDevice.id}`), + ); + } + return Right(void 0); + } +} diff --git a/packages/core/src/internal/transport/ble/transport/__mocks__/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/__mocks__/WebBleTransport.ts new file mode 100644 index 000000000..ead23b3bd --- /dev/null +++ b/packages/core/src/internal/transport/ble/transport/__mocks__/WebBleTransport.ts @@ -0,0 +1,25 @@ +import { Transport } from "@api/transport/model/Transport"; + +export class WebBleTransport implements Transport { + isSupported = jest.fn(); + getIdentifier = jest.fn(); + connect = jest.fn(); + startDiscovering = jest.fn(); + stopDiscovering = jest.fn(); + + disconnect = jest.fn(); +} + +export function usbHidTransportMockBuilder( + props: Partial = {}, +): Transport { + return { + isSupported: jest.fn(), + getIdentifier: jest.fn(), + startDiscovering: jest.fn(), + stopDiscovering: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + ...props, + }; +} diff --git a/packages/core/src/internal/transport/data/TransportDataSource.ts b/packages/core/src/internal/transport/data/TransportDataSource.ts index 091dec279..bb9eaee25 100644 --- a/packages/core/src/internal/transport/data/TransportDataSource.ts +++ b/packages/core/src/internal/transport/data/TransportDataSource.ts @@ -2,6 +2,7 @@ import { interfaces } from "inversify"; import { Transport } from "@api/transport/model/Transport"; import { BuiltinTransports } from "@api/transport/model/TransportIdentifier"; +import { WebBleTransport } from "@internal/transport/ble/transport/WebBleTransport"; import { MockTransport } from "@internal/transport/mockserver/MockserverTransport"; import { WebUsbHidTransport } from "@internal/transport/usb/transport/WebUsbHidTransport"; @@ -11,6 +12,7 @@ export class TransportDataSource { } = { [BuiltinTransports.USB]: WebUsbHidTransport, [BuiltinTransports.MOCK_SERVER]: MockTransport, + [BuiltinTransports.BLE]: WebBleTransport, }; static get(transport: BuiltinTransports): interfaces.Newable { diff --git a/packages/core/src/internal/transport/model/Errors.ts b/packages/core/src/internal/transport/model/Errors.ts index a699329fa..3503370bd 100644 --- a/packages/core/src/internal/transport/model/Errors.ts +++ b/packages/core/src/internal/transport/model/Errors.ts @@ -2,6 +2,7 @@ import { SdkError } from "@api/Error"; export type PromptDeviceAccessError = | UsbHidTransportNotSupportedError + | BleTransportNotSupportedError | NoAccessibleDeviceError; export type ConnectError = UnknownDeviceError | OpeningConnectionError; @@ -53,6 +54,13 @@ export class TransportNotSupportedError extends GeneralSdkError { } } +export class BleTransportNotSupportedError extends GeneralSdkError { + override readonly _tag = "BleTransportNotSupportedError"; + constructor(readonly err?: unknown) { + super(err); + } +} + export class UsbHidTransportNotSupportedError extends GeneralSdkError { override readonly _tag = "UsbHidTransportNotSupportedError"; constructor(readonly err?: unknown) { @@ -87,3 +95,17 @@ export class HidSendReportError extends GeneralSdkError { super(err); } } + +export class DeviceNotInitializedError extends GeneralSdkError { + override readonly _tag = "DeviceNotInitializedError"; + constructor(readonly err?: unknown) { + super(err); + } +} + +export class BleDeviceGattServerError extends GeneralSdkError { + override readonly _tag = "BleDeviceGattServerError"; + constructor(readonly err?: unknown) { + super(err); + } +} From 6f2ed0291cf76e69f34e2d7f622be875ed28c17a Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Mon, 23 Sep 2024 19:15:33 +0200 Subject: [PATCH 04/16] :lipstick: (smpl): SdkConfig reducer instead of mockServer, header switch transport --- apps/sample/next.config.js | 6 +- apps/sample/src/app/client-layout.tsx | 6 +- .../src/components/CommandsView/Command.tsx | 7 ++ apps/sample/src/components/Header/index.tsx | 116 +++++++++++++----- apps/sample/src/components/MainView/index.tsx | 9 +- apps/sample/src/components/Menu/index.tsx | 10 +- apps/sample/src/components/MockView/index.tsx | 8 +- apps/sample/src/components/Sidebar/index.tsx | 11 +- .../src/providers/DeviceSdkProvider/index.tsx | 20 +-- .../providers/MockServerProvider/index.tsx | 42 ------- apps/sample/src/providers/SdkConfig/index.tsx | 32 +++++ apps/sample/src/reducers/mockServer.ts | 61 --------- apps/sample/src/reducers/sdkConfig.ts | 51 ++++++++ apps/sample/src/utils/constants.ts | 4 - 14 files changed, 210 insertions(+), 173 deletions(-) delete mode 100644 apps/sample/src/providers/MockServerProvider/index.tsx create mode 100644 apps/sample/src/providers/SdkConfig/index.tsx delete mode 100644 apps/sample/src/reducers/mockServer.ts create mode 100644 apps/sample/src/reducers/sdkConfig.ts delete mode 100644 apps/sample/src/utils/constants.ts diff --git a/apps/sample/next.config.js b/apps/sample/next.config.js index 2ceeb14c7..f1d5bb18e 100644 --- a/apps/sample/next.config.js +++ b/apps/sample/next.config.js @@ -9,8 +9,10 @@ const nextConfig = { styledComponents: true, }, env: { - MOCK_SERVER_DEFAULT_ENABLED: - process.env.npm_lifecycle_event === "dev:default-mock" ? "true" : "false", + SDK_CONFIG_TRANSPORT: + process.env.npm_lifecycle_event === "dev:default-mock" + ? "MOCK_SERVER" + : "", }, }; diff --git a/apps/sample/src/app/client-layout.tsx b/apps/sample/src/app/client-layout.tsx index dbfc3614f..46d5b4805 100644 --- a/apps/sample/src/app/client-layout.tsx +++ b/apps/sample/src/app/client-layout.tsx @@ -17,7 +17,7 @@ import { Header } from "@/components/Header"; import { Sidebar } from "@/components/Sidebar"; import { SdkProvider } from "@/providers/DeviceSdkProvider"; import { DeviceSessionsProvider } from "@/providers/DeviceSessionsProvider"; -import { MockServerProvider } from "@/providers/MockServerProvider"; +import { SdkConfigProvider } from "@/providers/SdkConfig"; import { GlobalStyle } from "@/styles/globalstyles"; const Root = styled(Flex)` @@ -37,7 +37,7 @@ const PageContainer = styled(Flex)` const ClientRootLayout: React.FC = ({ children }) => { return ( - + @@ -54,7 +54,7 @@ const ClientRootLayout: React.FC = ({ children }) => { - + ); }; diff --git a/apps/sample/src/components/CommandsView/Command.tsx b/apps/sample/src/components/CommandsView/Command.tsx index 33c4985fc..3446f74a8 100644 --- a/apps/sample/src/components/CommandsView/Command.tsx +++ b/apps/sample/src/components/CommandsView/Command.tsx @@ -79,6 +79,13 @@ export function Command< ]; }); }) + .catch((error) => { + setLoading(false); + setResponses((prev) => [ + ...prev.slice(0, -1), + { args: values, date: new Date(), loading: false, response: error }, + ]); + }) .finally(() => { setLoading(false); }); diff --git a/apps/sample/src/components/Header/index.tsx b/apps/sample/src/components/Header/index.tsx index 3c03a916a..8265b538a 100644 --- a/apps/sample/src/components/Header/index.tsx +++ b/apps/sample/src/components/Header/index.tsx @@ -1,15 +1,15 @@ import React, { useCallback, useState } from "react"; import { Button, + Dropdown, DropdownGeneric, Flex, Icons, Input, - Switch, } from "@ledgerhq/react-ui"; import styled, { DefaultTheme } from "styled-components"; - -import { useMockServerContext } from "@/providers/MockServerProvider"; +import { useSdkConfigContext } from "../../providers/SdkConfig"; +import { BuiltinTransports } from "@ledgerhq/device-management-kit"; const Root = styled(Flex).attrs({ py: 3, px: 10, gridGap: 8 })` color: ${({ theme }: { theme: DefaultTheme }) => theme.colors.neutral.c90}; @@ -33,21 +33,56 @@ const UrlInput = styled(Input)` align-items: center; `; +type DropdownOption = { + label: string; + value: BuiltinTransports; +}; + +const DropdownValues: DropdownOption[] = [ + { + label: "USB", + value: BuiltinTransports.USB, + }, + { + label: "BLE", + value: BuiltinTransports.BLE, + }, + { + label: "Mock server", + value: BuiltinTransports.MOCK_SERVER, + }, +]; + export const Header = () => { const { dispatch, - state: { enabled, url }, - } = useMockServerContext(); - const onToggleMockServer = useCallback(() => { - dispatch({ type: enabled ? "disable_mock_server" : "enable_mock_server" }); - }, [dispatch, enabled]); - const [mockServerStateUrl, setMockServerStateUrl] = useState(url); + state: { transport, mockServerUrl }, + } = useSdkConfigContext(); + const onChangeTransport = useCallback( + (selectedValue: DropdownOption | null) => { + if (selectedValue) { + dispatch({ + type: "set_transport", + payload: { transport: selectedValue.value }, + }); + } + }, + [], + ); + const [mockServerStateUrl, setMockServerStateUrl] = + useState(mockServerUrl); + + const getDropdownValue = useCallback( + (transport: BuiltinTransports): DropdownOption | undefined => + DropdownValues.find((option) => option.value === transport), + [], + ); const validateServerUrl = useCallback( () => dispatch({ type: "set_mock_server_url", - payload: { url: mockServerStateUrl }, + payload: { mockServerUrl: mockServerStateUrl }, }), [mockServerStateUrl], ); @@ -63,29 +98,48 @@ export const Header = () => {
- -
- + + {transport === BuiltinTransports.MOCK_SERVER && ( + setMockServerStateUrl(url)} + renderRight={() => ( + + + + )} /> -
+ )}
- {enabled && ( - setMockServerStateUrl(url)} - renderRight={() => ( - - - - )} - /> - )}
diff --git a/apps/sample/src/components/MainView/index.tsx b/apps/sample/src/components/MainView/index.tsx index 3a5efbcde..7a7a93d51 100644 --- a/apps/sample/src/components/MainView/index.tsx +++ b/apps/sample/src/components/MainView/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback } from "react"; import { Button, Flex, Text } from "@ledgerhq/react-ui"; import Image from "next/image"; import styled, { DefaultTheme } from "styled-components"; @@ -53,13 +53,6 @@ export const MainView: React.FC = () => { }); }, [sdk, dispatch]); - useEffect(() => { - return () => { - // Example cleaning up the discovery - sdk.stopDiscovering(); - }; - }, [sdk]); - return ( { const router = useRouter(); const { - state: { enabled: mockServerEnabled }, - } = useMockServerContext(); + state: { transport }, + } = useSdkConfigContext(); return ( <> @@ -71,7 +71,7 @@ export const Menu: React.FC = () => { router.push("/cal")}>Crypto Assets - {mockServerEnabled && ( + {transport === BuiltinTransports.MOCK_SERVER && ( { const [currentResponse, setCurrentResponse] = useState("6700"); const { - state: { url }, - } = useMockServerContext(); + state: { mockServerUrl }, + } = useSdkConfigContext(); - const client = useMockClient(url); + const client = useMockClient(mockServerUrl); const fetchSessions = async () => { try { diff --git a/apps/sample/src/components/Sidebar/index.tsx b/apps/sample/src/components/Sidebar/index.tsx index 8a025cd04..9212ac2ae 100644 --- a/apps/sample/src/components/Sidebar/index.tsx +++ b/apps/sample/src/components/Sidebar/index.tsx @@ -8,7 +8,8 @@ import { Device } from "@/components/Device"; import { Menu } from "@/components/Menu"; import { useExportLogsCallback, useSdk } from "@/providers/DeviceSdkProvider"; import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; -import { useMockServerContext } from "@/providers/MockServerProvider"; +import { useSdkConfigContext } from "../../providers/SdkConfig"; +import { BuiltinTransports } from "@ledgerhq/device-management-kit"; const Root = styled(Flex).attrs({ py: 8, px: 6 })` flex-direction: column; @@ -51,8 +52,8 @@ export const Sidebar: React.FC = () => { dispatch, } = useDeviceSessionsContext(); const { - state: { enabled: mockServerEnabled }, - } = useMockServerContext(); + state: { transport }, + } = useSdkConfigContext(); useEffect(() => { sdk @@ -77,7 +78,7 @@ export const Sidebar: React.FC = () => { const router = useRouter(); return ( - + router.push("/")} mb={8} @@ -87,7 +88,7 @@ export const Sidebar: React.FC = () => { }} > Ledger Device Management Kit - {mockServerEnabled && (MOCKED)} + {transport === BuiltinTransports.MOCK_SERVER && (MOCKED)} SDK Version: {version ? version : "Loading..."} diff --git a/apps/sample/src/providers/DeviceSdkProvider/index.tsx b/apps/sample/src/providers/DeviceSdkProvider/index.tsx index 89be90309..c290bc288 100644 --- a/apps/sample/src/providers/DeviceSdkProvider/index.tsx +++ b/apps/sample/src/providers/DeviceSdkProvider/index.tsx @@ -7,8 +7,7 @@ import { DeviceSdkBuilder, WebLogsExporterLogger, } from "@ledgerhq/device-management-kit"; - -import { useMockServerContext } from "@/providers/MockServerProvider"; +import { useSdkConfigContext } from "../SdkConfig"; const webLogsExporterLogger = new WebLogsExporterLogger(); @@ -22,24 +21,29 @@ const SdkContext = createContext(defaultSdk); export const SdkProvider: React.FC = ({ children }) => { const { - state: { enabled: mockServerEnabled, url }, - } = useMockServerContext(); + state: { transport, mockServerUrl }, + } = useSdkConfigContext(); const [sdk, setSdk] = useState(defaultSdk); useEffect(() => { - if (mockServerEnabled) { + if (transport === BuiltinTransports.MOCK_SERVER) { sdk.close(); setSdk( new DeviceSdkBuilder() .addLogger(new ConsoleLogger()) .addTransport(BuiltinTransports.MOCK_SERVER) - .addConfig({ mockUrl: url }) + .addConfig({ mockUrl: mockServerUrl }) .build(), ); } else { sdk.close(); - setSdk(defaultSdk); + setSdk( + new DeviceSdkBuilder() + .addLogger(new ConsoleLogger()) + .addTransport(transport) + .build(), + ); } - }, [mockServerEnabled, url]); + }, [transport, mockServerUrl]); return {children}; }; diff --git a/apps/sample/src/providers/MockServerProvider/index.tsx b/apps/sample/src/providers/MockServerProvider/index.tsx deleted file mode 100644 index f85661192..000000000 --- a/apps/sample/src/providers/MockServerProvider/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react"; -import { createContext, useContext, useReducer } from "react"; - -import { - MockServerAction, - MockServerInitialState, - mockServerReducer, - MockServerState, -} from "@/reducers/mockServer"; -import { IsDefaultMockEnabled } from "@/utils/constants"; - -type MockServerContextType = { - state: MockServerState; - dispatch: (value: MockServerAction) => void; -}; - -const MockServerContext = createContext({ - state: MockServerInitialState( - process.env.MOCK_SERVER_DEFAULT_ENABLED as IsDefaultMockEnabled, - ), - dispatch: () => null, -}); - -export const MockServerProvider: React.FC = ({ - children, -}) => { - const [state, dispatch] = useReducer( - mockServerReducer, - MockServerInitialState( - process.env.MOCK_SERVER_DEFAULT_ENABLED as IsDefaultMockEnabled, - ), - ); - - return ( - - {children} - - ); -}; - -export const useMockServerContext = () => - useContext(MockServerContext); diff --git a/apps/sample/src/providers/SdkConfig/index.tsx b/apps/sample/src/providers/SdkConfig/index.tsx new file mode 100644 index 000000000..8403dba7a --- /dev/null +++ b/apps/sample/src/providers/SdkConfig/index.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useReducer } from "react"; +import { + SdkConfigState, + sdkConfigReducer, + SdkConfigInitialState, + SdkConfigAction, +} from "@/reducers/sdkConfig"; + +type SdkConfigContextType = { + state: SdkConfigState; + dispatch: (value: SdkConfigAction) => void; +}; + +const SdkConfigContext = createContext({ + state: SdkConfigInitialState, + dispatch: () => null, +}); + +export const SdkConfigProvider: React.FC = ({ + children, +}) => { + const [state, dispatch] = useReducer(sdkConfigReducer, SdkConfigInitialState); + + return ( + + {children} + + ); +}; + +export const useSdkConfigContext = () => + useContext(SdkConfigContext); diff --git a/apps/sample/src/reducers/mockServer.ts b/apps/sample/src/reducers/mockServer.ts deleted file mode 100644 index 29aaae22d..000000000 --- a/apps/sample/src/reducers/mockServer.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Reducer } from "react"; - -import { IsDefaultMockEnabled } from "@/utils/constants"; - -export type MockServerState = { - enabled: boolean; - url: string; -}; - -type EnableMockServerAction = { - type: "enable_mock_server"; -}; - -type DisableMockServerAction = { - type: "disable_mock_server"; -}; - -type SetMockServerUrlAction = { - type: "set_mock_server_url"; - payload: { - url: string; - }; -}; - -export type MockServerAction = - | EnableMockServerAction - | DisableMockServerAction - | SetMockServerUrlAction; - -export const MockServerInitialState = ( - isDefaultMockEnabled: IsDefaultMockEnabled, -): MockServerState => ({ - url: "http://127.0.0.1:8080/", - enabled: isDefaultMockEnabled === IsDefaultMockEnabled.TRUE, -}); - -export const mockServerReducer: Reducer = ( - state, - action, -) => { - switch (action.type) { - case "enable_mock_server": - return { - ...state, - enabled: true, - }; - case "disable_mock_server": - return { - ...state, - enabled: false, - }; - case "set_mock_server_url": - return { - ...state, - url: action.payload.url, - }; - - default: - return state; - } -}; diff --git a/apps/sample/src/reducers/sdkConfig.ts b/apps/sample/src/reducers/sdkConfig.ts new file mode 100644 index 000000000..c2499aa1d --- /dev/null +++ b/apps/sample/src/reducers/sdkConfig.ts @@ -0,0 +1,51 @@ +import { Reducer } from "react"; +import { BuiltinTransports } from "@ledgerhq/device-management-kit"; + +export type SdkConfigState = { + mockServerUrl: string; + transport: BuiltinTransports; +}; + +type SetTransportAction = { + type: "set_transport"; + payload: { + transport: BuiltinTransports; + }; +}; + +type SetMockServerUrlAction = { + type: "set_mock_server_url"; + payload: { + mockServerUrl: string; + }; +}; + +export type SdkConfigAction = SetTransportAction | SetMockServerUrlAction; + +export const SdkConfigInitialState: SdkConfigState = { + mockServerUrl: "http://127.0.0.1:8080/", + transport: + (process.env.SDK_CONFIG_TRANSPORT as BuiltinTransports) || + BuiltinTransports.USB, +}; + +export const sdkConfigReducer: Reducer = ( + state, + action, +) => { + switch (action.type) { + case "set_transport": + return { + ...state, + transport: action.payload.transport, + }; + case "set_mock_server_url": + return { + ...state, + mockServerUrl: action.payload.mockServerUrl, + }; + + default: + return state; + } +}; diff --git a/apps/sample/src/utils/constants.ts b/apps/sample/src/utils/constants.ts deleted file mode 100644 index 4ac7ee740..000000000 --- a/apps/sample/src/utils/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum IsDefaultMockEnabled { - TRUE = "true", - FALSE = "false", -} From ca0953a11c25a21b236bd1f4fcb551d5641ddfc2 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Mon, 23 Sep 2024 19:16:55 +0200 Subject: [PATCH 05/16] :sparkles: (core): Ble transport disconnect/reconnect --- .../transport/ble/model/BleDevice.stub.ts | 1 + .../ble/transport/BleDeviceConnection.test.ts | 2 +- .../ble/transport/BleDeviceConnection.ts | 125 ++++++++++++++--- .../ble/transport/WebBleTransport.test.ts | 104 ++++++++++++-- .../ble/transport/WebBleTransport.ts | 130 +++++++++++------- 5 files changed, 276 insertions(+), 86 deletions(-) diff --git a/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts b/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts index 004ce3f60..e53c15949 100644 --- a/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts +++ b/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts @@ -40,6 +40,7 @@ export const bleCharacteristicStubBuilder = ( ({ ...props, addEventListener: jest.fn(), + removeEventListener: jest.fn(), startNotifications: jest.fn(), writeValueWithResponse: jest.fn(), }) as BluetoothRemoteGATTCharacteristic; diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts index 1afb945d0..75b9c76ea 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts @@ -69,7 +69,7 @@ describe("BleDeviceConnection", () => { ); // then expect(errorOrApduResponse).toStrictEqual( - Left(new DeviceNotInitializedError()), + Left(new DeviceNotInitializedError("Unknown MTU")), ); }); diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts index b6905f34d..f57e9d684 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts @@ -1,6 +1,7 @@ import { Either, Left, Maybe, Nothing, Right } from "purify-ts"; import { Subject } from "rxjs"; +import { CommandUtils } from "@api/command/utils/CommandUtils"; import { ApduResponse } from "@api/device-session/ApduResponse"; import { SdkError } from "@api/Error"; import { ApduReceiverService } from "@internal/device-session/service/ApduReceiverService"; @@ -8,7 +9,10 @@ import { ApduSenderService } from "@internal/device-session/service/ApduSenderSe import { DefaultApduSenderServiceConstructorArgs } from "@internal/device-session/service/DefaultApduSenderService"; import type { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; import { DeviceConnection } from "@internal/transport/model/DeviceConnection"; -import { DeviceNotInitializedError } from "@internal/transport/model/Errors"; +import { + DeviceNotInitializedError, + ReconnectionFailedError, +} from "@internal/transport/model/Errors"; type BleDeviceConnectionConstructorArgs = { writeCharacteristic: BluetoothRemoteGATTCharacteristic; @@ -26,8 +30,8 @@ export type DataViewEvent = Event & { }; export class BleDeviceConnection implements DeviceConnection { - private readonly _writeCharacteristic: BluetoothRemoteGATTCharacteristic; - private readonly _notifyCharacteristic: BluetoothRemoteGATTCharacteristic; + private _writeCharacteristic: BluetoothRemoteGATTCharacteristic; + private _notifyCharacteristic: BluetoothRemoteGATTCharacteristic; private readonly _logger: LoggerPublisherService; private _apduSender: Maybe; private readonly _apduSenderFactory: ( @@ -36,6 +40,10 @@ export class BleDeviceConnection implements DeviceConnection { private readonly _apduReceiver: ApduReceiverService; private _isDeviceReady: boolean; private _sendApduSubject: Subject; + private _settleReconnectionPromise: Maybe<{ + resolve(): void; + reject(err: SdkError): void; + }> = Maybe.zero(); constructor( { @@ -52,14 +60,30 @@ export class BleDeviceConnection implements DeviceConnection { this._logger = loggerServiceFactory("BleDeviceConnection"); this._writeCharacteristic = writeCharacteristic; this._notifyCharacteristic = notifyCharacteristic; - this._isDeviceReady = false; this._notifyCharacteristic.addEventListener( "characteristicvaluechanged", this.onNotifyCharacteristicValueChanged, ); + this._isDeviceReady = false; this._sendApduSubject = new Subject(); } + public set notifyCharacteristic( + notifyCharacteristic: BluetoothRemoteGATTCharacteristic, + ) { + this._notifyCharacteristic = notifyCharacteristic; + this._notifyCharacteristic.addEventListener( + "characteristicvaluechanged", + this.onNotifyCharacteristicValueChanged, + ); + } + + public set writeCharacteristic( + writeCharacteristic: BluetoothRemoteGATTCharacteristic, + ) { + this._writeCharacteristic = writeCharacteristic; + } + /** * Event handler to setup the mtu size in response of 0x0800000000 APDU * @param value @@ -71,10 +95,11 @@ export class BleDeviceConnection implements DeviceConnection { const [frameSize] = mtuResponse.slice(5); if (frameSize) { this._apduSender = Maybe.of(this._apduSenderFactory({ frameSize })); - this._isDeviceReady = true; - this._logger.debug("new frame size value change", { - data: { frameSize }, + this._settleReconnectionPromise.ifJust((promise) => { + promise.resolve(); + this._settleReconnectionPromise = Maybe.zero(); }); + this._isDeviceReady = true; } } @@ -94,9 +119,10 @@ export class BleDeviceConnection implements DeviceConnection { }, } = event; if (!this._isDeviceReady) { - return this.onReceiveSetupApduResponse(buffer); + this.onReceiveSetupApduResponse(buffer); + } else { + this.receiveApdu(buffer); } - return this.receiveApdu(buffer); }; /** @@ -117,15 +143,12 @@ export class BleDeviceConnection implements DeviceConnection { * Complete sendApdu subject once the framer receives all the frames of the response * @param data */ - async receiveApdu(data: ArrayBuffer) { + receiveApdu(data: ArrayBuffer) { const response = this._apduReceiver.handleFrame(new Uint8Array(data)); response.caseOf({ Right: (maybeApduResponse) => { maybeApduResponse.map((apduResponse) => { - this._logger.debug("Received APDU Response", { - data: { response: apduResponse }, - }); this._sendApduSubject.next(apduResponse); this._sendApduSubject.complete(); }); @@ -141,17 +164,34 @@ export class BleDeviceConnection implements DeviceConnection { * Subscribe to a Subject that would be complete once the response had been received * @param apdu */ - async sendApdu(apdu: Uint8Array): Promise> { - if (!this._isDeviceReady) { - return Left(new DeviceNotInitializedError()); - } + async sendApdu( + apdu: Uint8Array, + triggersDisconnection?: boolean, + ): Promise> { this._sendApduSubject = new Subject(); + if (!this._isDeviceReady) { + return Promise.resolve( + Left(new DeviceNotInitializedError("Unknown MTU")), + ); + } + // Create a promise that would be resolved once the response had been received const resultPromise = new Promise>( (resolve) => { this._sendApduSubject.subscribe({ next: async (response) => { - resolve(Right(response)); + if ( + triggersDisconnection && + CommandUtils.isSuccessResponse(response) + ) { + const reconnectionRes = await this.setupWaitForReconnection(); + reconnectionRes.caseOf({ + Left: (err) => resolve(Left(err)), + Right: () => resolve(Right(response)), + }); + } else { + resolve(Right(response)); + } }, error: (err) => resolve(Left(err)), }); @@ -193,4 +233,53 @@ export class BleDeviceConnection implements DeviceConnection { typeof event.target.value.buffer.byteLength === "number" ); } + + /** + * Setup a promise that would be resolved once the device is reconnected + * + * @private + */ + private setupWaitForReconnection(): Promise> { + return new Promise>((resolve) => { + this._settleReconnectionPromise = Maybe.of({ + resolve: () => resolve(Right(undefined)), + reject: (error: SdkError) => resolve(Left(error)), + }); + }); + } + + /** + * Reconnect to the device by resetting new ble characteristics + * @param writeCharacteristic + * @param notifyCharacteristic + */ + public async reconnect( + writeCharacteristic: BluetoothRemoteGATTCharacteristic, + notifyCharacteristic: BluetoothRemoteGATTCharacteristic, + ) { + this._notifyCharacteristic.removeEventListener( + "characteristicvaluechanged", + this.onNotifyCharacteristicValueChanged, + ); + this._isDeviceReady = false; + this.notifyCharacteristic = notifyCharacteristic; + this.writeCharacteristic = writeCharacteristic; + await this.setup(); + } + + /** + * Disconnect from the device + */ + public async disconnect() { + // if a reconnection promise is pending, reject it + this._settleReconnectionPromise.ifJust((promise) => { + promise.reject(new ReconnectionFailedError()); + this._settleReconnectionPromise = Maybe.zero(); + }); + this._notifyCharacteristic.removeEventListener( + "characteristicvaluechanged", + this.onNotifyCharacteristicValueChanged, + ); + this._isDeviceReady = false; + } } diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts index 1bb3f260e..c6e190589 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts @@ -1,4 +1,4 @@ -import { Left } from "purify-ts"; +import { Left, Right } from "purify-ts"; import { DeviceModel } from "@api/device/DeviceModel"; import { StaticDeviceModelDataSource } from "@internal/device-model/data/StaticDeviceModelDataSource"; @@ -13,6 +13,7 @@ import { UnknownDeviceError, } from "@internal/transport/model/Errors"; import { InternalDiscoveredDevice } from "@internal/transport/model/InternalDiscoveredDevice"; +import { RECONNECT_DEVICE_TIMEOUT } from "@internal/transport/usb/data/UsbHidConfig"; import { WebBleTransport } from "./WebBleTransport"; @@ -179,16 +180,6 @@ describe("WebBleTransport", () => { }); }); - describe("stopDiscovering", () => { - it("should stop monitoring connections if the discovery process is halted", () => { - const abortSpy = jest.spyOn(AbortController.prototype, "abort"); - - transport.stopDiscovering(); - - expect(abortSpy).toHaveBeenCalled(); - }); - }); - describe("connect", () => { it("should throw UnknownDeviceError if no internal device", async () => { const connectParams = { @@ -244,9 +235,6 @@ describe("WebBleTransport", () => { gatt: { ...stubDevice.gatt, connected: true, - connect: () => { - throw new DOMException("already opened", "InvalidStateError"); - }, }, }); @@ -311,5 +299,93 @@ describe("WebBleTransport", () => { ); }); }); + + describe("disconnect", () => { + it("should disconnect the device", (done) => { + mockedRequestDevice.mockResolvedValueOnce(stubDevice); + + const onDisconnect = jest.fn(); + + discoverDevice( + (discoveredDevice) => { + transport + .connect({ + deviceId: discoveredDevice.id, + onDisconnect, + }) + .then((connectedDevice) => { + connectedDevice.ifRight((device) => { + transport + .disconnect({ connectedDevice: device }) + .then((value) => { + expect(value).toStrictEqual(Right(void 0)); + done(); + }) + .catch((error) => { + done(error); + }); + }); + }); + }, + (error) => { + done(error); + }, + ); + }); + it("should call disconnect handler if device is hardware disconnected", (done) => { + const onDisconnect = jest.fn(); + const disconnectSpy = jest.spyOn(transport, "disconnect"); + mockedRequestDevice.mockResolvedValueOnce(stubDevice); + + discoverDevice( + (discoveredDevice) => { + transport + .connect({ + deviceId: discoveredDevice.id, + onDisconnect, + }) + .then(() => { + stubDevice.ongattserverdisconnected(new Event("")); + jest.advanceTimersByTime(RECONNECT_DEVICE_TIMEOUT); + expect(disconnectSpy).toHaveBeenCalled(); + done(); + }); + }, + (error) => { + done(error); + }, + ); + }); + }); + + describe("reconnect", () => { + it("should not call disconnection if reconnection happen", (done) => { + // given + const onDisconnect = jest.fn(); + const disconnectSpy = jest.spyOn(transport, "disconnect"); + mockedRequestDevice.mockResolvedValueOnce(stubDevice); + + // when + discoverDevice((discoveredDevice) => { + transport + .connect({ + deviceId: discoveredDevice.id, + onDisconnect, + }) + .then(() => { + stubDevice.ongattserverdisconnected(new Event("")); + + jest.advanceTimersByTime(RECONNECT_DEVICE_TIMEOUT / 3); + + // then + expect(disconnectSpy).toHaveBeenCalledTimes(0); + done(); + }) + .catch((error) => { + done(error); + }); + }); + }); + }); }); }); diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts index a3e0d9e35..a9b1e83fa 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -1,6 +1,6 @@ import { inject, injectable } from "inversify"; -import { Either, EitherAsync, Left, Right } from "purify-ts"; -import { from, Observable, switchMap } from "rxjs"; +import { Either, EitherAsync, Left, Maybe, Right } from "purify-ts"; +import { from, Observable, switchMap, timer } from "rxjs"; import { v4 as uuid } from "uuid"; import { DeviceId } from "@api/device/DeviceModel"; @@ -32,6 +32,7 @@ import { } from "@internal/transport/model/Errors"; import { InternalConnectedDevice } from "@internal/transport/model/InternalConnectedDevice"; import { InternalDiscoveredDevice } from "@internal/transport/model/InternalDiscoveredDevice"; +import { RECONNECT_DEVICE_TIMEOUT } from "@internal/transport/usb/data/UsbHidConfig"; // An attempt to manage the state of several devices with one transport. Not final. type WebBleInternalDevice = { @@ -44,11 +45,9 @@ type WebBleInternalDevice = { @injectable() export class WebBleTransport implements Transport { - // Maps uncoupled DiscoveredDevice and WebHID's HIDDevice WebHID private _internalDevicesById: Map; private _deviceConnectionById: Map; - private _disconnectionHandlersById: Map; - private _connectionListenersAbortController: AbortController; + private _disconnectionHandlersById: Map void>; private _logger: LoggerPublisherService; private readonly connectionType: ConnectionType = "BLE"; private readonly identifier: TransportIdentifier = BuiltinTransports.BLE; @@ -64,29 +63,26 @@ export class WebBleTransport implements Transport { this._internalDevicesById = new Map(); this._deviceConnectionById = new Map(); this._disconnectionHandlersById = new Map(); - this._connectionListenersAbortController = new AbortController(); - this._logger = loggerServiceFactory("WebUsbHidTransport"); + this._logger = loggerServiceFactory("WebBleTransport"); } /** * Get the Bluetooth API if supported or error * @returns `Either` */ - private get bluetoothApi(): Either { + private getBluetoothApi(): Either { if (this.isSupported()) { return Right(navigator.bluetooth); } - return Left(new BleTransportNotSupportedError("WebHID not supported")); + return Left(new BleTransportNotSupportedError("WebBle not supported")); } isSupported() { try { const result = !!navigator?.bluetooth; - this._logger.debug(`isSupported: ${result}`); return result; - } catch (error) { - this._logger.error(`isSupported: error`, { data: { error } }); + } catch { return false; } } @@ -106,12 +102,17 @@ export class WebBleTransport implements Transport { if (!bleDevice.gatt) { return Left(new BleDeviceGattServerError("Device gatt not found")); } - const [bleGattService] = await bleDevice.gatt.getPrimaryServices(); - - if (!bleGattService) { - return Left(new BleDeviceGattServerError("bluetooth service not found")); + try { + const [bleGattService] = await bleDevice.gatt.getPrimaryServices(); + if (!bleGattService) { + return Left( + new BleDeviceGattServerError("bluetooth service not found"), + ); + } + return Right(bleGattService); + } catch (e) { + return Left(new BleDeviceGattServerError(e)); } - return Right(bleGattService); } /** @@ -147,7 +148,7 @@ export class WebBleTransport implements Transport { private async promptDeviceAccess(): Promise< Either > { - return EitherAsync.liftEither(this.bluetoothApi) + return EitherAsync.liftEither(this.getBluetoothApi()) .map(async (bluetoothApi) => { let bleDevice: BluetoothDevice; @@ -161,13 +162,9 @@ export class WebBleTransport implements Transport { }); } catch (error) { const deviceError = new NoAccessibleDeviceError(error); - this._logger.error(`promptDeviceAccess: error requesting device`, { - data: { error }, - }); throw deviceError; } - this._logger.debug(`promptDeviceAccess: bleDevice found`); return bleDevice; }) .run(); @@ -216,8 +213,6 @@ export class WebBleTransport implements Transport { startDiscovering(): Observable { this._logger.debug("startDiscovering"); - this.startListeningToConnectionEvents(); - this._internalDevicesById.clear(); return from(this.promptDeviceAccess()).pipe( @@ -225,7 +220,7 @@ export class WebBleTransport implements Transport { errorOrBleDevice.caseOf({ Right: async (bleDevice) => { // ble connect here as gatt server needs to be opened to fetch gatt service - if (bleDevice.gatt && !bleDevice.gatt.connected) { + if (bleDevice.gatt) { try { await bleDevice.gatt.connect(); } catch (error) { @@ -268,24 +263,11 @@ export class WebBleTransport implements Transport { stopDiscovering(): void { this._logger.debug("stopDiscovering"); - - this.stopListeningToConnectionEvents(); - } - - /** - * Logs `connect` and `disconnect` events for already accessible devices - */ - private startListeningToConnectionEvents(): void { - this._logger.debug("startListeningToConnectionEvents"); - } - - private stopListeningToConnectionEvents(): void { - this._logger.debug("stopListeningToConnectionEvents"); - this._connectionListenersAbortController.abort(); } /** * Connect to a BLE device and update the internal state of the associated device + * Handle ondisconnect event on the device in order to try a reconnection */ async connect({ deviceId, @@ -294,8 +276,6 @@ export class WebBleTransport implements Transport { deviceId: DeviceId; onDisconnect: DisconnectHandler; }): Promise> { - this._logger.debug("connect", { data: { deviceId } }); - const internalDevice = this._internalDevicesById.get(deviceId); if (!internalDevice) { @@ -320,43 +300,87 @@ export class WebBleTransport implements Transport { writeCharacteristic, notifyCharacteristic, ); - this._logger.debug("Device connection", { data: { deviceConnection } }); await deviceConnection.setup(); - this._deviceConnectionById.set( - internalDevice.bleDevice.id, - deviceConnection, - ); + this._deviceConnectionById.set(internalDevice.id, deviceConnection); const connectedDevice = new InternalConnectedDevice({ - sendApdu: (apdu) => deviceConnection.sendApdu(apdu), + sendApdu: (apdu, triggersDisconnection) => + deviceConnection.sendApdu(apdu, triggersDisconnection), deviceModel, id: deviceId, type: this.connectionType, transport: this.identifier, }); - this._disconnectionHandlersById.set(internalDevice.bleDevice.id, () => { + internalDevice.bleDevice.ongattserverdisconnected = + this._getDeviceDisconnectedHandler(internalDevice, deviceConnection); + this._disconnectionHandlersById.set(internalDevice.id, () => { this.disconnect({ connectedDevice }).then(() => onDisconnect(deviceId)); }); return Right(connectedDevice); } + private _getDeviceDisconnectedHandler( + internalDevice: WebBleInternalDevice, + deviceConnection: BleDeviceConnection, + ) { + return async () => { + const disconnectObserver = timer(RECONNECT_DEVICE_TIMEOUT).subscribe( + () => { + const disconnectHandler = Maybe.fromNullable( + this._disconnectionHandlersById.get(internalDevice.id), + ); + disconnectHandler.map((handler) => { + this._logger.info("timer over, disconnect device"); + handler(); + }); + }, + ); + await internalDevice.bleDevice.gatt?.connect(); + const service = await this.getBleGattService(internalDevice.bleDevice); + if (service.isRight()) { + const [writeC, notifyC] = await Promise.all([ + service + .extract() + .getCharacteristic(internalDevice.bleDeviceInfos.writeUuid), + service + .extract() + .getCharacteristic(internalDevice.bleDeviceInfos.notifyUuid), + ]); + await deviceConnection.reconnect(writeC, notifyC); + disconnectObserver.unsubscribe(); + } + }; + } + /** * Disconnect from a BLE device and delete its handlers - * TODO + * + * @param connectedDevice InternalConnectedDevice */ async disconnect(params: { connectedDevice: InternalConnectedDevice; }): Promise> { - this._logger.debug("disconnect", { data: { connectedDevice: params } }); - const internalDevice = this._internalDevicesById.get( - params.connectedDevice.id, + const maybeInternalDevice = Maybe.fromNullable( + this._internalDevicesById.get(params.connectedDevice.id), ); - if (!internalDevice) { + if (maybeInternalDevice.isNothing()) { this._logger.error(`Unknown device ${params.connectedDevice.id}`); return Left( new UnknownDeviceError(`Unknown device ${params.connectedDevice.id}`), ); } + maybeInternalDevice.map((device) => { + const { bleDevice } = device; + const maybeDeviceConnection = Maybe.fromNullable( + this._deviceConnectionById.get(device.id), + ); + maybeDeviceConnection.map((dConnection) => dConnection.disconnect()); + bleDevice.gatt?.disconnect(); + this._internalDevicesById.delete(device.id); + this._deviceConnectionById.delete(device.id); + this._disconnectionHandlersById.delete(device.id); + }); + return Right(void 0); } } From 55d62f2dfe9cd979c99fbc8f8aeed7909c653807 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Tue, 24 Sep 2024 10:42:24 +0200 Subject: [PATCH 06/16] :bookmark: (core): Changeset --- .changeset/nasty-horses-wash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nasty-horses-wash.md diff --git a/.changeset/nasty-horses-wash.md b/.changeset/nasty-horses-wash.md new file mode 100644 index 000000000..13c1c81e5 --- /dev/null +++ b/.changeset/nasty-horses-wash.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-sdk-core": patch +--- + +Add BLE support From 7d5d8706095a5182a213e5a1a50f0ed1c1533c39 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Mon, 30 Sep 2024 16:38:43 +0200 Subject: [PATCH 07/16] :recycle: (core): Reviews --- .../data/StaticDeviceModelDataSource.ts | 9 +++++--- .../transport/ble/model/BleDeviceInfos.ts | 14 +++++++----- .../ble/transport/BleDeviceConnection.ts | 4 ++-- .../ble/transport/WebBleTransport.ts | 22 ++++++++++++++----- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts index 6d900f393..c50a2cb33 100644 --- a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts +++ b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts @@ -117,10 +117,13 @@ export class StaticDeviceModelDataSource implements DeviceModelDataSource { ...bluetoothSpec.reduce>( (serviceToModel, bleSpec) => ({ ...serviceToModel, - [bleSpec.serviceUuid]: { + [bleSpec.serviceUuid]: new BleDeviceInfos( deviceModel, - ...bleSpec, - }, + bleSpec.serviceUuid, + bleSpec.writeUuid, + bleSpec.writeCmdUuid, + bleSpec.notifyUuid, + ), }), {}, ), diff --git a/packages/core/src/internal/transport/ble/model/BleDeviceInfos.ts b/packages/core/src/internal/transport/ble/model/BleDeviceInfos.ts index 16c4a8edc..e33563ba1 100644 --- a/packages/core/src/internal/transport/ble/model/BleDeviceInfos.ts +++ b/packages/core/src/internal/transport/ble/model/BleDeviceInfos.ts @@ -1,9 +1,11 @@ import { InternalDeviceModel } from "@internal/device-model/model/DeviceModel"; -export interface BleDeviceInfos { - deviceModel: InternalDeviceModel; - serviceUuid: string; - writeUuid: string; - writeCmdUuid: string; - notifyUuid: string; +export class BleDeviceInfos { + constructor( + public deviceModel: InternalDeviceModel, + public serviceUuid: string, + public writeUuid: string, + public writeCmdUuid: string, + public notifyUuid: string, + ) {} } diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts index f57e9d684..e0397f20b 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts @@ -68,7 +68,7 @@ export class BleDeviceConnection implements DeviceConnection { this._sendApduSubject = new Subject(); } - public set notifyCharacteristic( + private set notifyCharacteristic( notifyCharacteristic: BluetoothRemoteGATTCharacteristic, ) { this._notifyCharacteristic = notifyCharacteristic; @@ -78,7 +78,7 @@ export class BleDeviceConnection implements DeviceConnection { ); } - public set writeCharacteristic( + private set writeCharacteristic( writeCharacteristic: BluetoothRemoteGATTCharacteristic, ) { this._writeCharacteristic = writeCharacteristic; diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts index a9b1e83fa..527fec202 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -318,23 +318,31 @@ export class WebBleTransport implements Transport { return Right(connectedDevice); } + /** + * Get the device disconnected handler + * @param internalDevice WebBleInternalDevice + * @param deviceConnection BleDeviceConnection + * @returns async () => void + * @private + */ private _getDeviceDisconnectedHandler( internalDevice: WebBleInternalDevice, deviceConnection: BleDeviceConnection, ) { return async () => { + // start a timer to disconnect the device if it does not reconnect const disconnectObserver = timer(RECONNECT_DEVICE_TIMEOUT).subscribe( () => { + // retrieve the disconnect handler and call it const disconnectHandler = Maybe.fromNullable( this._disconnectionHandlersById.get(internalDevice.id), ); - disconnectHandler.map((handler) => { - this._logger.info("timer over, disconnect device"); - handler(); - }); + disconnectHandler.map((handler) => handler()); }, ); + // connect to the navigator device await internalDevice.bleDevice.gatt?.connect(); + // retrieve new ble characteristics const service = await this.getBleGattService(internalDevice.bleDevice); if (service.isRight()) { const [writeC, notifyC] = await Promise.all([ @@ -345,7 +353,9 @@ export class WebBleTransport implements Transport { .extract() .getCharacteristic(internalDevice.bleDeviceInfos.notifyUuid), ]); + // reconnect device connection await deviceConnection.reconnect(writeC, notifyC); + // cancel disconnection timeout disconnectObserver.unsubscribe(); } }; @@ -354,11 +364,12 @@ export class WebBleTransport implements Transport { /** * Disconnect from a BLE device and delete its handlers * - * @param connectedDevice InternalConnectedDevice + * @param params { connectedDevice: InternalConnectedDevice } */ async disconnect(params: { connectedDevice: InternalConnectedDevice; }): Promise> { + // retrieve internal device from connected device const maybeInternalDevice = Maybe.fromNullable( this._internalDevicesById.get(params.connectedDevice.id), ); @@ -371,6 +382,7 @@ export class WebBleTransport implements Transport { } maybeInternalDevice.map((device) => { const { bleDevice } = device; + // retrieve device connection and disconnect it const maybeDeviceConnection = Maybe.fromNullable( this._deviceConnectionById.get(device.id), ); From cdf631f4ac578aa14195bb376b59896518ff5f65 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Tue, 1 Oct 2024 17:27:59 +0200 Subject: [PATCH 08/16] :bug: (core): Block multiple sessions for a same device --- .../ble/transport/WebBleTransport.ts | 80 +++++++++++-------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts index 527fec202..e1c65d5b3 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -45,9 +45,10 @@ type WebBleInternalDevice = { @injectable() export class WebBleTransport implements Transport { + private _connectedDevices: Array; private _internalDevicesById: Map; - private _deviceConnectionById: Map; - private _disconnectionHandlersById: Map void>; + private _deviceConnectionById: Map; + private _disconnectionHandlersById: Map void>; private _logger: LoggerPublisherService; private readonly connectionType: ConnectionType = "BLE"; private readonly identifier: TransportIdentifier = BuiltinTransports.BLE; @@ -60,6 +61,7 @@ export class WebBleTransport implements Transport { @inject(bleDiTypes.BleDeviceConnectionFactory) private _bleDeviceConnectionFactory: BleDeviceConnectionFactory, ) { + this._connectedDevices = []; this._internalDevicesById = new Map(); this._deviceConnectionById = new Map(); this._disconnectionHandlersById = new Map(); @@ -181,10 +183,14 @@ export class WebBleTransport implements Transport { bleDevice: BluetoothDevice, bleGattService: BluetoothRemoteGATTService, bleDeviceInfos: BleDeviceInfos, - ) { + ): InternalDiscoveredDevice { + if (this._connectedDevices.includes(bleDevice)) { + this._logger.debug("Device already discovered"); + throw new Error("Device already discovered"); + } const id = uuid(); - const discoveredDevice = { + const discoveredDevice: InternalDiscoveredDevice = { id, deviceModel: bleDeviceInfos.deviceModel, transport: this.identifier, @@ -202,7 +208,6 @@ export class WebBleTransport implements Transport { `Discovered device ${id} ${discoveredDevice.deviceModel.productName}`, ); this._internalDevicesById.set(id, internalDevice); - return discoveredDevice; } @@ -287,35 +292,42 @@ export class WebBleTransport implements Transport { discoveredDevice: { deviceModel }, } = internalDevice; - const [writeCharacteristic, notifyCharacteristic] = await Promise.all([ - internalDevice.bleGattService.getCharacteristic( - internalDevice.bleDeviceInfos.writeUuid, - ), - internalDevice.bleGattService.getCharacteristic( - internalDevice.bleDeviceInfos.notifyUuid, - ), - ]); - - const deviceConnection = this._bleDeviceConnectionFactory.create( - writeCharacteristic, - notifyCharacteristic, - ); - await deviceConnection.setup(); - this._deviceConnectionById.set(internalDevice.id, deviceConnection); - const connectedDevice = new InternalConnectedDevice({ - sendApdu: (apdu, triggersDisconnection) => - deviceConnection.sendApdu(apdu, triggersDisconnection), - deviceModel, - id: deviceId, - type: this.connectionType, - transport: this.identifier, - }); - internalDevice.bleDevice.ongattserverdisconnected = - this._getDeviceDisconnectedHandler(internalDevice, deviceConnection); - this._disconnectionHandlersById.set(internalDevice.id, () => { - this.disconnect({ connectedDevice }).then(() => onDisconnect(deviceId)); - }); - return Right(connectedDevice); + try { + const [writeCharacteristic, notifyCharacteristic] = await Promise.all([ + internalDevice.bleGattService.getCharacteristic( + internalDevice.bleDeviceInfos.writeUuid, + ), + internalDevice.bleGattService.getCharacteristic( + internalDevice.bleDeviceInfos.notifyUuid, + ), + ]); + const deviceConnection = this._bleDeviceConnectionFactory.create( + writeCharacteristic, + notifyCharacteristic, + ); + this._deviceConnectionById.set(internalDevice.id, deviceConnection); + await deviceConnection.setup(); + const connectedDevice = new InternalConnectedDevice({ + sendApdu: (apdu, triggersDisconnection) => + deviceConnection.sendApdu(apdu, triggersDisconnection), + deviceModel, + id: deviceId, + type: this.connectionType, + transport: this.identifier, + }); + internalDevice.bleDevice.ongattserverdisconnected = + this._getDeviceDisconnectedHandler(internalDevice, deviceConnection); + this._disconnectionHandlersById.set(internalDevice.id, () => { + this.disconnect({ connectedDevice }).then(() => onDisconnect(deviceId)); + }); + this._connectedDevices.push(internalDevice.bleDevice); + return Right(connectedDevice); + } catch (error) { + this._logger.error("Error while getting characteristics", { + data: { error }, + }); + return Left(new OpeningConnectionError(error)); + } } /** From f590ad90c3c2b58f8cd5b475b090e47b740e84dd Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Wed, 2 Oct 2024 18:43:29 +0200 Subject: [PATCH 09/16] :art: (core): Improve WebBleTransport errors --- .../ble/transport/BleDeviceConnection.ts | 30 +++---- .../ble/transport/WebBleTransport.ts | 85 ++++++++++++------- .../src/internal/transport/model/Errors.ts | 79 ++++++++++++++++- 3 files changed, 145 insertions(+), 49 deletions(-) diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts index e0397f20b..a0eef7f58 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts @@ -60,10 +60,8 @@ export class BleDeviceConnection implements DeviceConnection { this._logger = loggerServiceFactory("BleDeviceConnection"); this._writeCharacteristic = writeCharacteristic; this._notifyCharacteristic = notifyCharacteristic; - this._notifyCharacteristic.addEventListener( - "characteristicvaluechanged", - this.onNotifyCharacteristicValueChanged, - ); + this._notifyCharacteristic.oncharacteristicvaluechanged = (event) => + this.onNotifyCharacteristicValueChanged(event); this._isDeviceReady = false; this._sendApduSubject = new Subject(); } @@ -72,10 +70,8 @@ export class BleDeviceConnection implements DeviceConnection { notifyCharacteristic: BluetoothRemoteGATTCharacteristic, ) { this._notifyCharacteristic = notifyCharacteristic; - this._notifyCharacteristic.addEventListener( - "characteristicvaluechanged", - this.onNotifyCharacteristicValueChanged, - ); + this._notifyCharacteristic.oncharacteristicvaluechanged = (event) => + this.onNotifyCharacteristicValueChanged(event); } private set writeCharacteristic( @@ -109,7 +105,7 @@ export class BleDeviceConnection implements DeviceConnection { * Call receiveApdu otherwise * @param event */ - private onNotifyCharacteristicValueChanged = (event: Event) => { + private onNotifyCharacteristicValueChanged(event: Event) { if (!this.isDataViewEvent(event)) { return; } @@ -123,7 +119,7 @@ export class BleDeviceConnection implements DeviceConnection { } else { this.receiveApdu(buffer); } - }; + } /** * Setup BleDeviceConnection @@ -150,6 +146,9 @@ export class BleDeviceConnection implements DeviceConnection { Right: (maybeApduResponse) => { maybeApduResponse.map((apduResponse) => { this._sendApduSubject.next(apduResponse); + this._logger.debug("Received APDU Response", { + data: { response: apduResponse }, + }); this._sendApduSubject.complete(); }); }, @@ -203,6 +202,9 @@ export class BleDeviceConnection implements DeviceConnection { }); for (const frame of frames) { try { + this._logger.debug("Sending Frame", { + data: { frame: frame.getRawData() }, + }); await this._writeCharacteristic.writeValueWithResponse( frame.getRawData(), ); @@ -257,10 +259,6 @@ export class BleDeviceConnection implements DeviceConnection { writeCharacteristic: BluetoothRemoteGATTCharacteristic, notifyCharacteristic: BluetoothRemoteGATTCharacteristic, ) { - this._notifyCharacteristic.removeEventListener( - "characteristicvaluechanged", - this.onNotifyCharacteristicValueChanged, - ); this._isDeviceReady = false; this.notifyCharacteristic = notifyCharacteristic; this.writeCharacteristic = writeCharacteristic; @@ -276,10 +274,6 @@ export class BleDeviceConnection implements DeviceConnection { promise.reject(new ReconnectionFailedError()); this._settleReconnectionPromise = Maybe.zero(); }); - this._notifyCharacteristic.removeEventListener( - "characteristicvaluechanged", - this.onNotifyCharacteristicValueChanged, - ); this._isDeviceReady = false; } } diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts index e1c65d5b3..4d7ddf7aa 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -24,6 +24,7 @@ import { BleDeviceGattServerError, BleTransportNotSupportedError, ConnectError, + DeviceAlreadyConnectedError, DeviceNotRecognizedError, NoAccessibleDeviceError, OpeningConnectionError, @@ -163,8 +164,7 @@ export class WebBleTransport implements Transport { })), }); } catch (error) { - const deviceError = new NoAccessibleDeviceError(error); - throw deviceError; + throw new NoAccessibleDeviceError(error); } return bleDevice; @@ -174,30 +174,35 @@ export class WebBleTransport implements Transport { /** * Generate a discovered device from BluetoothDevice, BleGATT primary service and BLE device infos - * @param bleDevice - * @param bleGattService * @param bleDeviceInfos * @private */ private getDiscoveredDeviceFrom( - bleDevice: BluetoothDevice, - bleGattService: BluetoothRemoteGATTService, bleDeviceInfos: BleDeviceInfos, ): InternalDiscoveredDevice { - if (this._connectedDevices.includes(bleDevice)) { - this._logger.debug("Device already discovered"); - throw new Error("Device already discovered"); - } - const id = uuid(); - - const discoveredDevice: InternalDiscoveredDevice = { - id, + return { + id: uuid(), deviceModel: bleDeviceInfos.deviceModel, transport: this.identifier, }; + } + /** + * Generate an InternalDevice from a unique id, a BluetoothDevice, BleGATT primary service and BLE device infos + * @param discoveredDevice + * @param bleDevice + * @param bleDeviceInfos + * @param bleGattService + * @private + */ + private setInternalDeviceFrom( + discoveredDevice: InternalDiscoveredDevice, + bleDevice: BluetoothDevice, + bleDeviceInfos: BleDeviceInfos, + bleGattService: BluetoothRemoteGATTService, + ) { const internalDevice: WebBleInternalDevice = { - id, + id: discoveredDevice.id, bleDevice, bleGattService, bleDeviceInfos, @@ -205,10 +210,9 @@ export class WebBleTransport implements Transport { }; this._logger.debug( - `Discovered device ${id} ${discoveredDevice.deviceModel.productName}`, + `Discovered device ${internalDevice.id} ${discoveredDevice.deviceModel.productName}`, ); - this._internalDevicesById.set(id, internalDevice); - return discoveredDevice; + this._internalDevicesById.set(internalDevice.id, internalDevice); } /** @@ -218,8 +222,6 @@ export class WebBleTransport implements Transport { startDiscovering(): Observable { this._logger.debug("startDiscovering"); - this._internalDevicesById.clear(); - return from(this.promptDeviceAccess()).pipe( switchMap(async (errorOrBleDevice) => errorOrBleDevice.caseOf({ @@ -239,18 +241,26 @@ export class WebBleTransport implements Transport { const errorOrBleDeviceInfos = this.getBleDeviceInfos(bleGattService); return errorOrBleDeviceInfos.caseOf({ - Right: (bleDeviceInfos) => - this.getDiscoveredDeviceFrom( + Right: (bleDeviceInfos) => { + const discoveredDevice = + this.getDiscoveredDeviceFrom(bleDeviceInfos); + this.setInternalDeviceFrom( + discoveredDevice, bleDevice, - bleGattService, bleDeviceInfos, - ), + bleGattService, + ); + return discoveredDevice; + }, + Left: (error) => { + bleDevice.gatt?.disconnect(); throw error; }, }); }, Left: (error) => { + bleDevice.gatt?.disconnect(); throw error; }, }); @@ -284,9 +294,18 @@ export class WebBleTransport implements Transport { const internalDevice = this._internalDevicesById.get(deviceId); if (!internalDevice) { - this._logger.error(`Unknown device ${deviceId}`); + this._logger.error(`Unknown device ${deviceId}`, { + data: { internalDevices: this._internalDevicesById }, + }); + this._logger.debug("Available devices", { + data: { devices: this._internalDevicesById }, + }); return Left(new UnknownDeviceError(`Unknown device ${deviceId}`)); } + if (this._connectedDevices.includes(internalDevice.bleDevice)) { + this._internalDevicesById.delete(deviceId); + return Left(new DeviceAlreadyConnectedError("Device already connected")); + } const { discoveredDevice: { deviceModel }, @@ -305,7 +324,6 @@ export class WebBleTransport implements Transport { writeCharacteristic, notifyCharacteristic, ); - this._deviceConnectionById.set(internalDevice.id, deviceConnection); await deviceConnection.setup(); const connectedDevice = new InternalConnectedDevice({ sendApdu: (apdu, triggersDisconnection) => @@ -317,12 +335,14 @@ export class WebBleTransport implements Transport { }); internalDevice.bleDevice.ongattserverdisconnected = this._getDeviceDisconnectedHandler(internalDevice, deviceConnection); + this._deviceConnectionById.set(internalDevice.id, deviceConnection); this._disconnectionHandlersById.set(internalDevice.id, () => { this.disconnect({ connectedDevice }).then(() => onDisconnect(deviceId)); }); this._connectedDevices.push(internalDevice.bleDevice); return Right(connectedDevice); } catch (error) { + this._internalDevicesById.delete(deviceId); this._logger.error("Error while getting characteristics", { data: { error }, }); @@ -345,6 +365,7 @@ export class WebBleTransport implements Transport { // start a timer to disconnect the device if it does not reconnect const disconnectObserver = timer(RECONNECT_DEVICE_TIMEOUT).subscribe( () => { + this._logger.debug("disconnection timer over"); // retrieve the disconnect handler and call it const disconnectHandler = Maybe.fromNullable( this._disconnectionHandlersById.get(internalDevice.id), @@ -354,6 +375,8 @@ export class WebBleTransport implements Transport { ); // connect to the navigator device await internalDevice.bleDevice.gatt?.connect(); + // cancel disconnection timeout + disconnectObserver.unsubscribe(); // retrieve new ble characteristics const service = await this.getBleGattService(internalDevice.bleDevice); if (service.isRight()) { @@ -367,8 +390,6 @@ export class WebBleTransport implements Transport { ]); // reconnect device connection await deviceConnection.reconnect(writeC, notifyC); - // cancel disconnection timeout - disconnectObserver.unsubscribe(); } }; } @@ -381,10 +402,13 @@ export class WebBleTransport implements Transport { async disconnect(params: { connectedDevice: InternalConnectedDevice; }): Promise> { - // retrieve internal device from connected device + // retrieve internal device const maybeInternalDevice = Maybe.fromNullable( this._internalDevicesById.get(params.connectedDevice.id), ); + this._logger.debug("disconnect device", { + data: { connectedDevice: params.connectedDevice }, + }); if (maybeInternalDevice.isNothing()) { this._logger.error(`Unknown device ${params.connectedDevice.id}`); @@ -399,10 +423,13 @@ export class WebBleTransport implements Transport { this._deviceConnectionById.get(device.id), ); maybeDeviceConnection.map((dConnection) => dConnection.disconnect()); + // disconnect device gatt server bleDevice.gatt?.disconnect(); + // clean up objects this._internalDevicesById.delete(device.id); this._deviceConnectionById.delete(device.id); this._disconnectionHandlersById.delete(device.id); + delete this._connectedDevices[this._connectedDevices.indexOf(bleDevice)]; }); return Right(void 0); diff --git a/packages/core/src/internal/transport/model/Errors.ts b/packages/core/src/internal/transport/model/Errors.ts index 3503370bd..eb4dc5b87 100644 --- a/packages/core/src/internal/transport/model/Errors.ts +++ b/packages/core/src/internal/transport/model/Errors.ts @@ -5,7 +5,10 @@ export type PromptDeviceAccessError = | BleTransportNotSupportedError | NoAccessibleDeviceError; -export type ConnectError = UnknownDeviceError | OpeningConnectionError; +export type ConnectError = + | UnknownDeviceError + | OpeningConnectionError + | DeviceAlreadyConnectedError; class GeneralSdkError implements SdkError { _tag = "GeneralSdkError"; @@ -13,7 +16,7 @@ class GeneralSdkError implements SdkError { constructor(err?: unknown) { if (err instanceof Error) { this.originalError = err; - } else { + } else if (err !== undefined) { this.originalError = new Error(String(err)); } } @@ -23,6 +26,11 @@ export class DeviceNotRecognizedError extends GeneralSdkError { override readonly _tag = "DeviceNotRecognizedError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -30,6 +38,11 @@ export class NoAccessibleDeviceError extends GeneralSdkError { override readonly _tag = "NoAccessibleDeviceError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -37,6 +50,11 @@ export class OpeningConnectionError extends GeneralSdkError { override readonly _tag = "ConnectionOpeningError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -44,6 +62,11 @@ export class UnknownDeviceError extends GeneralSdkError { override readonly _tag = "UnknownDeviceError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -51,6 +74,11 @@ export class TransportNotSupportedError extends GeneralSdkError { override readonly _tag = "TransportNotSupportedError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -58,6 +86,11 @@ export class BleTransportNotSupportedError extends GeneralSdkError { override readonly _tag = "BleTransportNotSupportedError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -65,6 +98,11 @@ export class UsbHidTransportNotSupportedError extends GeneralSdkError { override readonly _tag = "UsbHidTransportNotSupportedError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -72,6 +110,11 @@ export class SendApduConcurrencyError extends GeneralSdkError { override readonly _tag = "SendApduConcurrencyError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -79,6 +122,11 @@ export class DisconnectError extends GeneralSdkError { override readonly _tag = "DisconnectError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -86,6 +134,11 @@ export class ReconnectionFailedError extends GeneralSdkError { override readonly _tag = "ReconnectionFailedError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -100,6 +153,11 @@ export class DeviceNotInitializedError extends GeneralSdkError { override readonly _tag = "DeviceNotInitializedError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } @@ -107,5 +165,22 @@ export class BleDeviceGattServerError extends GeneralSdkError { override readonly _tag = "BleDeviceGattServerError"; constructor(readonly err?: unknown) { super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } + } +} + +export class DeviceAlreadyConnectedError extends GeneralSdkError { + override readonly _tag = "DeviceAlreadyDiscoveredError"; + constructor(readonly err?: unknown) { + super(err); + if (err instanceof Error) { + this.originalError = err; + } else if (err !== undefined) { + this.originalError = new Error(String(err)); + } } } From 9c628791d04b8f40abbf4d21525faa3250292b36 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Wed, 2 Oct 2024 18:45:03 +0200 Subject: [PATCH 10/16] :lipstick: (smpl): Rm transport selection, allow to select devices with different ones --- apps/sample/src/components/Device/index.tsx | 23 ++-- .../DeviceActionsView/AllDeviceActions.tsx | 4 - apps/sample/src/components/Header/index.tsx | 114 ++++++------------ .../MainView/ConnectDeviceActions.tsx | 91 ++++++++++++++ apps/sample/src/components/MainView/index.tsx | 83 +++++++------ .../src/components/SessionIdWrapper.tsx | 2 +- apps/sample/src/components/Sidebar/index.tsx | 5 +- apps/sample/src/hooks/usePrevious.ts | 9 ++ .../src/providers/DeviceSdkProvider/index.tsx | 10 +- 9 files changed, 197 insertions(+), 144 deletions(-) create mode 100644 apps/sample/src/components/MainView/ConnectDeviceActions.tsx create mode 100644 apps/sample/src/hooks/usePrevious.ts diff --git a/apps/sample/src/components/Device/index.tsx b/apps/sample/src/components/Device/index.tsx index c6a6fc871..53862ae12 100644 --- a/apps/sample/src/components/Device/index.tsx +++ b/apps/sample/src/components/Device/index.tsx @@ -16,6 +16,10 @@ const Root = styled(Flex).attrs({ p: 5, mb: 8, borderRadius: 2 })` background: ${({ theme }: { theme: DefaultTheme }) => theme.colors.neutral.c30}; align-items: center; + border: ${({ active, theme }: { theme: DefaultTheme; active: boolean }) => + `1px solid ${active ? theme.colors.success.c40 : "transparent"}`}; + cursor: ${({ active }: { active: boolean }) => + active ? "normal" : "pointer"}; `; const IconContainer = styled(Flex).attrs({ p: 4, mr: 3, borderRadius: 100 })` @@ -42,6 +46,7 @@ type DeviceProps = { sessionId: DeviceSessionId; model: DeviceModelId; onDisconnect: () => Promise; + onSelect: () => void; }; function getIconComponent(model: DeviceModelId) { @@ -62,16 +67,17 @@ export const Device: React.FC = ({ type, model, onDisconnect, + onSelect, sessionId, }) => { const sessionState = useDeviceSessionState(sessionId); const { - state: { deviceById, selectedId }, - dispatch, + state: { selectedId }, } = useDeviceSessionsContext(); const IconComponent = getIconComponent(model); + const isActive = selectedId === sessionId; return ( - + @@ -98,17 +104,6 @@ export const Device: React.FC = ({
- {Object.values(deviceById).length > 1 && selectedId !== sessionId && ( - - dispatch({ type: "select_session", payload: { sessionId } }) - } - > - - Select - - - )} Disconnect diff --git a/apps/sample/src/components/DeviceActionsView/AllDeviceActions.tsx b/apps/sample/src/components/DeviceActionsView/AllDeviceActions.tsx index e13598413..962b791f4 100644 --- a/apps/sample/src/components/DeviceActionsView/AllDeviceActions.tsx +++ b/apps/sample/src/components/DeviceActionsView/AllDeviceActions.tsx @@ -41,10 +41,6 @@ export const AllDeviceActions: React.FC<{ sessionId: string }> = ({ const deviceModelId = sdk.getConnectedDevice({ sessionId, }).modelId; - console.log( - "sdk get connected device::", - sdk.getConnectedDevice({ sessionId }), - ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const deviceActions: DeviceActionProps[] = useMemo( diff --git a/apps/sample/src/components/Header/index.tsx b/apps/sample/src/components/Header/index.tsx index 8265b538a..a8941ca9e 100644 --- a/apps/sample/src/components/Header/index.tsx +++ b/apps/sample/src/components/Header/index.tsx @@ -1,14 +1,14 @@ import React, { useCallback, useState } from "react"; import { Button, - Dropdown, DropdownGeneric, Flex, Icons, Input, + Switch, } from "@ledgerhq/react-ui"; import styled, { DefaultTheme } from "styled-components"; -import { useSdkConfigContext } from "../../providers/SdkConfig"; +import { useSdkConfigContext } from "@/providers/SdkConfig"; import { BuiltinTransports } from "@ledgerhq/device-management-kit"; const Root = styled(Flex).attrs({ py: 3, px: 10, gridGap: 8 })` @@ -33,50 +33,25 @@ const UrlInput = styled(Input)` align-items: center; `; -type DropdownOption = { - label: string; - value: BuiltinTransports; -}; - -const DropdownValues: DropdownOption[] = [ - { - label: "USB", - value: BuiltinTransports.USB, - }, - { - label: "BLE", - value: BuiltinTransports.BLE, - }, - { - label: "Mock server", - value: BuiltinTransports.MOCK_SERVER, - }, -]; - export const Header = () => { const { dispatch, state: { transport, mockServerUrl }, } = useSdkConfigContext(); - const onChangeTransport = useCallback( - (selectedValue: DropdownOption | null) => { - if (selectedValue) { - dispatch({ - type: "set_transport", - payload: { transport: selectedValue.value }, - }); - } - }, - [], - ); + const onToggleMockServer = useCallback(() => { + dispatch({ + type: "set_transport", + payload: { + transport: + transport === BuiltinTransports.MOCK_SERVER + ? BuiltinTransports.USB + : BuiltinTransports.MOCK_SERVER, + }, + }); + }, [transport]); const [mockServerStateUrl, setMockServerStateUrl] = useState(mockServerUrl); - - const getDropdownValue = useCallback( - (transport: BuiltinTransports): DropdownOption | undefined => - DropdownValues.find((option) => option.value === transport), - [], - ); + const mockServerEnabled = transport === BuiltinTransports.MOCK_SERVER; const validateServerUrl = useCallback( () => @@ -98,48 +73,29 @@ export const Header = () => {
- - - {transport === BuiltinTransports.MOCK_SERVER && ( - setMockServerStateUrl(url)} - renderRight={() => ( - - - - )} + +
+ - )} +
+ {mockServerEnabled && ( + setMockServerStateUrl(url)} + renderRight={() => ( + + + + )} + /> + )}
diff --git a/apps/sample/src/components/MainView/ConnectDeviceActions.tsx b/apps/sample/src/components/MainView/ConnectDeviceActions.tsx new file mode 100644 index 000000000..7f899d6a0 --- /dev/null +++ b/apps/sample/src/components/MainView/ConnectDeviceActions.tsx @@ -0,0 +1,91 @@ +import { Button, Flex } from "@ledgerhq/react-ui"; +import { BuiltinTransports, SdkError } from "@ledgerhq/device-management-kit"; +import React, { useCallback } from "react"; +import { useSdkConfigContext } from "@/providers/SdkConfig"; +import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; + +type ConnectDeviceActionsProps = { + onError: (error: SdkError | null) => void; +}; + +export const ConnectDeviceActions = ({ + onError, +}: ConnectDeviceActionsProps) => { + const { + dispatch: dispatchSdkConfig, + state: { transport }, + } = useSdkConfigContext(); + const { dispatch: dispatchDeviceSession } = useDeviceSessionsContext(); + const sdk = useSdk(); + + const onSelectDeviceClicked = useCallback( + (selectedTransport: BuiltinTransports) => { + onError(null); + dispatchSdkConfig({ + type: "set_transport", + payload: { transport: selectedTransport }, + }); + sdk.startDiscovering({ transport: selectedTransport }).subscribe({ + next: (device) => { + sdk + .connect({ device }) + .then((sessionId) => { + console.log( + `🦖 Response from connect: ${JSON.stringify(sessionId)} 🎉`, + ); + dispatchDeviceSession({ + type: "add_session", + payload: { + sessionId, + connectedDevice: sdk.getConnectedDevice({ sessionId }), + }, + }); + }) + .catch((error) => { + onError(error); + console.error(`Error from connection or get-version`, error); + }); + }, + error: (error) => { + console.error(error); + }, + }); + }, + [sdk, transport], + ); + + return transport === BuiltinTransports.MOCK_SERVER ? ( + + ) : ( + + + + + ); +}; diff --git a/apps/sample/src/components/MainView/index.tsx b/apps/sample/src/components/MainView/index.tsx index 7a7a93d51..166326acf 100644 --- a/apps/sample/src/components/MainView/index.tsx +++ b/apps/sample/src/components/MainView/index.tsx @@ -1,10 +1,10 @@ -import React, { useCallback } from "react"; -import { Button, Flex, Text } from "@ledgerhq/react-ui"; +import React, { useEffect, useState } from "react"; +import { Badge, Flex, Icon, Text, Notification } from "@ledgerhq/react-ui"; import Image from "next/image"; import styled, { DefaultTheme } from "styled-components"; -import { useSdk } from "@/providers/DeviceSdkProvider"; -import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; +import { SdkError } from "@ledgerhq/device-management-kit"; +import { ConnectDeviceActions } from "./ConnectDeviceActions"; const Root = styled(Flex)` flex: 1; @@ -12,6 +12,11 @@ const Root = styled(Flex)` align-items: center; flex-direction: column; `; +const ErrorNotification = styled(Notification)` + position: absolute; + bottom: 10px; + width: 70%; +`; const Description = styled(Text).attrs({ my: 6 })` color: ${({ theme }: { theme: DefaultTheme }) => theme.colors.neutral.c70}; @@ -22,36 +27,21 @@ const NanoLogo = styled(Image).attrs({ mb: 8 })` `; export const MainView: React.FC = () => { - const sdk = useSdk(); - const { dispatch } = useDeviceSessionsContext(); + const [connectionError, setConnectionError] = useState(null); - // Example starting the discovery on a user action - const onSelectDeviceClicked = useCallback(() => { - sdk.startDiscovering({}).subscribe({ - next: (device) => { - sdk - .connect({ device }) - .then((sessionId) => { - console.log( - `🦖 Response from connect: ${JSON.stringify(sessionId)} 🎉`, - ); - dispatch({ - type: "add_session", - payload: { - sessionId, - connectedDevice: sdk.getConnectedDevice({ sessionId }), - }, - }); - }) - .catch((error) => { - console.error(`Error from connection or get-version`, error); - }); - }, - error: (error) => { - console.error(error); - }, - }); - }, [sdk, dispatch]); + useEffect(() => { + let timeoutId: NodeJS.Timeout; + if (connectionError) { + timeoutId = setTimeout(() => { + setConnectionError(null); + }, 3000); + } + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [connectionError]); return ( @@ -68,15 +58,24 @@ export const MainView: React.FC = () => { Use this application to test Ledger hardware device features. - + + {connectionError && ( + } + /> + } + hasBackground + title="Error" + description={ + connectionError.message || + (connectionError.originalError as Error | undefined)?.message + } + /> + )} ); }; diff --git a/apps/sample/src/components/SessionIdWrapper.tsx b/apps/sample/src/components/SessionIdWrapper.tsx index 21e913713..7a0514711 100644 --- a/apps/sample/src/components/SessionIdWrapper.tsx +++ b/apps/sample/src/components/SessionIdWrapper.tsx @@ -40,5 +40,5 @@ export const SessionIdWrapper: React.FC<{ ); } - return ; + return ; }; diff --git a/apps/sample/src/components/Sidebar/index.tsx b/apps/sample/src/components/Sidebar/index.tsx index 9212ac2ae..00bd56bf6 100644 --- a/apps/sample/src/components/Sidebar/index.tsx +++ b/apps/sample/src/components/Sidebar/index.tsx @@ -103,7 +103,10 @@ export const Sidebar: React.FC = () => { name={device.name} model={device.modelId} type={device.type} - onDisconnect={async () => onDeviceDisconnect(sessionId)} + onSelect={() => + dispatch({ type: "select_session", payload: { sessionId } }) + } + onDisconnect={() => onDeviceDisconnect(sessionId)} /> ))}
diff --git a/apps/sample/src/hooks/usePrevious.ts b/apps/sample/src/hooks/usePrevious.ts new file mode 100644 index 000000000..d6d1f2155 --- /dev/null +++ b/apps/sample/src/hooks/usePrevious.ts @@ -0,0 +1,9 @@ +import { useEffect, useRef } from "react"; + +export function usePrevious(value: T) { + const ref = useRef(); + useEffect(() => { + ref.current = value; //assign the value of ref to the argument + }, [value]); //this code will run when the value of 'value' changes + return ref.current; //in the end, return the current ref value. +} diff --git a/apps/sample/src/providers/DeviceSdkProvider/index.tsx b/apps/sample/src/providers/DeviceSdkProvider/index.tsx index c290bc288..24e7a721f 100644 --- a/apps/sample/src/providers/DeviceSdkProvider/index.tsx +++ b/apps/sample/src/providers/DeviceSdkProvider/index.tsx @@ -8,11 +8,13 @@ import { WebLogsExporterLogger, } from "@ledgerhq/device-management-kit"; import { useSdkConfigContext } from "../SdkConfig"; +import { usePrevious } from "@/hooks/usePrevious"; const webLogsExporterLogger = new WebLogsExporterLogger(); const defaultSdk = new DeviceSdkBuilder() .addLogger(new ConsoleLogger()) + .addTransport(BuiltinTransports.BLE) .addTransport(BuiltinTransports.USB) .addLogger(webLogsExporterLogger) .build(); @@ -23,6 +25,7 @@ export const SdkProvider: React.FC = ({ children }) => { const { state: { transport, mockServerUrl }, } = useSdkConfigContext(); + const previousTransport = usePrevious(transport); const [sdk, setSdk] = useState(defaultSdk); useEffect(() => { if (transport === BuiltinTransports.MOCK_SERVER) { @@ -34,16 +37,17 @@ export const SdkProvider: React.FC = ({ children }) => { .addConfig({ mockUrl: mockServerUrl }) .build(), ); - } else { + } else if (previousTransport === BuiltinTransports.MOCK_SERVER) { sdk.close(); setSdk( new DeviceSdkBuilder() .addLogger(new ConsoleLogger()) - .addTransport(transport) + .addTransport(BuiltinTransports.BLE) + .addTransport(BuiltinTransports.USB) .build(), ); } - }, [transport, mockServerUrl]); + }, [transport, mockServerUrl, previousTransport]); return {children}; }; From 22bc09df2a3248750970deb0ecf7cbc99f2665c2 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Thu, 3 Oct 2024 14:32:22 +0200 Subject: [PATCH 11/16] :art: (core): BleConnection use sendapdu promise instead of rxjs subject --- .../ble/transport/BleDeviceConnection.test.ts | 4 +- .../ble/transport/BleDeviceConnection.ts | 120 ++++++++++-------- .../ble/transport/WebBleTransport.test.ts | 2 +- .../ble/transport/WebBleTransport.ts | 18 ++- 4 files changed, 80 insertions(+), 64 deletions(-) diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts index 75b9c76ea..3aeb2f621 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts @@ -44,9 +44,7 @@ describe("BleDeviceConnection", () => { // @ts-expect-error private function call to mock web ble response connection.onNotifyCharacteristicValueChanged({ target: { - value: { - buffer, - }, + value: new DataView(buffer.buffer), }, } as DataViewEvent); } diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts index a0eef7f58..5b64c6b78 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts @@ -1,5 +1,4 @@ import { Either, Left, Maybe, Nothing, Right } from "purify-ts"; -import { Subject } from "rxjs"; import { CommandUtils } from "@api/command/utils/CommandUtils"; import { ApduResponse } from "@api/device-session/ApduResponse"; @@ -39,11 +38,13 @@ export class BleDeviceConnection implements DeviceConnection { ) => ApduSenderService; private readonly _apduReceiver: ApduReceiverService; private _isDeviceReady: boolean; - private _sendApduSubject: Subject; - private _settleReconnectionPromise: Maybe<{ + private _sendApduPromiseResolver: Maybe<{ + resolve(value: Either): void; + }>; + private _settleReconnectionPromiseResolvers: Maybe<{ resolve(): void; reject(err: SdkError): void; - }> = Maybe.zero(); + }>; constructor( { @@ -60,20 +61,32 @@ export class BleDeviceConnection implements DeviceConnection { this._logger = loggerServiceFactory("BleDeviceConnection"); this._writeCharacteristic = writeCharacteristic; this._notifyCharacteristic = notifyCharacteristic; - this._notifyCharacteristic.oncharacteristicvaluechanged = (event) => - this.onNotifyCharacteristicValueChanged(event); + this._notifyCharacteristic.oncharacteristicvaluechanged = + this.onNotifyCharacteristicValueChanged; this._isDeviceReady = false; - this._sendApduSubject = new Subject(); + this._sendApduPromiseResolver = Maybe.zero(); + this._settleReconnectionPromiseResolvers = Maybe.zero(); } + /** + * NotifyCharacteristic setter + * Register a listener on characteristic value change + * @param notifyCharacteristic + * @private + */ private set notifyCharacteristic( notifyCharacteristic: BluetoothRemoteGATTCharacteristic, ) { this._notifyCharacteristic = notifyCharacteristic; - this._notifyCharacteristic.oncharacteristicvaluechanged = (event) => - this.onNotifyCharacteristicValueChanged(event); + this._notifyCharacteristic.oncharacteristicvaluechanged = + this.onNotifyCharacteristicValueChanged; } + /** + * WriteCharacteristic setter + * @param writeCharacteristic + * @private + */ private set writeCharacteristic( writeCharacteristic: BluetoothRemoteGATTCharacteristic, ) { @@ -91,9 +104,9 @@ export class BleDeviceConnection implements DeviceConnection { const [frameSize] = mtuResponse.slice(5); if (frameSize) { this._apduSender = Maybe.of(this._apduSenderFactory({ frameSize })); - this._settleReconnectionPromise.ifJust((promise) => { + this._settleReconnectionPromiseResolvers.ifJust((promise) => { promise.resolve(); - this._settleReconnectionPromise = Maybe.zero(); + this._settleReconnectionPromiseResolvers = Maybe.zero(); }); this._isDeviceReady = true; } @@ -105,7 +118,7 @@ export class BleDeviceConnection implements DeviceConnection { * Call receiveApdu otherwise * @param event */ - private onNotifyCharacteristicValueChanged(event: Event) { + private onNotifyCharacteristicValueChanged = (event: Event) => { if (!this.isDataViewEvent(event)) { return; } @@ -119,7 +132,7 @@ export class BleDeviceConnection implements DeviceConnection { } else { this.receiveApdu(buffer); } - } + }; /** * Setup BleDeviceConnection @@ -128,47 +141,50 @@ export class BleDeviceConnection implements DeviceConnection { * APDU 0x0800000000 is used to get this mtu size */ public async setup() { - const apdu = Uint8Array.from([0x08, 0x00, 0x00, 0x00, 0x00]); + const requestMtuApdu = Uint8Array.from([0x08, 0x00, 0x00, 0x00, 0x00]); await this._notifyCharacteristic.startNotifications(); - await this._writeCharacteristic.writeValueWithResponse(apdu); + await this._writeCharacteristic.writeValueWithResponse(requestMtuApdu); } /** * Receive APDU response - * Complete sendApdu subject once the framer receives all the frames of the response + * Resolve sendApdu promise once the framer receives all the frames of the response * @param data */ receiveApdu(data: ArrayBuffer) { const response = this._apduReceiver.handleFrame(new Uint8Array(data)); - response.caseOf({ - Right: (maybeApduResponse) => { + response + .map((maybeApduResponse) => { maybeApduResponse.map((apduResponse) => { - this._sendApduSubject.next(apduResponse); this._logger.debug("Received APDU Response", { data: { response: apduResponse }, }); - this._sendApduSubject.complete(); + this._sendApduPromiseResolver.map(({ resolve }) => + resolve(Right(apduResponse)), + ); }); - }, - Left: (error) => this._sendApduSubject.error(error), - }); + }) + .mapLeft((error) => { + this._sendApduPromiseResolver.map(({ resolve }) => + resolve(Left(error)), + ); + }); } /** * Send apdu if the mtu had been set * * Get all frames for a given APDU - * Subscribe to a Subject that would be complete once the response had been received + * Save a promise that would be completed once the response had been received * @param apdu + * @param triggersDisconnection */ async sendApdu( apdu: Uint8Array, triggersDisconnection?: boolean, ): Promise> { - this._sendApduSubject = new Subject(); - if (!this._isDeviceReady) { return Promise.resolve( Left(new DeviceNotInitializedError("Unknown MTU")), @@ -177,22 +193,8 @@ export class BleDeviceConnection implements DeviceConnection { // Create a promise that would be resolved once the response had been received const resultPromise = new Promise>( (resolve) => { - this._sendApduSubject.subscribe({ - next: async (response) => { - if ( - triggersDisconnection && - CommandUtils.isSuccessResponse(response) - ) { - const reconnectionRes = await this.setupWaitForReconnection(); - reconnectionRes.caseOf({ - Left: (err) => resolve(Left(err)), - Right: () => resolve(Right(response)), - }); - } else { - resolve(Right(response)); - } - }, - error: (err) => resolve(Left(err)), + this._sendApduPromiseResolver = Maybe.of({ + resolve, }); }, ); @@ -212,7 +214,22 @@ export class BleDeviceConnection implements DeviceConnection { this._logger.error("Error sending frame", { data: { error } }); } } - return resultPromise; + const response = await resultPromise; + this._sendApduPromiseResolver = Maybe.zero(); + return response.caseOf({ + Right: async (apduResponse) => { + if ( + triggersDisconnection && + CommandUtils.isSuccessResponse(apduResponse) + ) { + const reconnectionRes = await this.setupWaitForReconnection(); + return reconnectionRes.map(() => apduResponse); + } else { + return Right(apduResponse); + } + }, + Left: async (error) => Promise.resolve(Left(error)), + }); } /** @@ -223,16 +240,9 @@ export class BleDeviceConnection implements DeviceConnection { */ private isDataViewEvent(event: Event): event is DataViewEvent { return ( - typeof event.target === "object" && event.target !== null && "value" in event.target && - typeof event.target.value === "object" && - event.target.value !== null && - "buffer" in event.target.value && - typeof event.target.value.buffer === "object" && - event.target.value.buffer !== null && - "byteLength" in event.target.value.buffer && - typeof event.target.value.buffer.byteLength === "number" + event.target.value instanceof DataView ); } @@ -243,7 +253,7 @@ export class BleDeviceConnection implements DeviceConnection { */ private setupWaitForReconnection(): Promise> { return new Promise>((resolve) => { - this._settleReconnectionPromise = Maybe.of({ + this._settleReconnectionPromiseResolvers = Maybe.of({ resolve: () => resolve(Right(undefined)), reject: (error: SdkError) => resolve(Left(error)), }); @@ -268,11 +278,11 @@ export class BleDeviceConnection implements DeviceConnection { /** * Disconnect from the device */ - public async disconnect() { + public disconnect() { // if a reconnection promise is pending, reject it - this._settleReconnectionPromise.ifJust((promise) => { + this._settleReconnectionPromiseResolvers.ifJust((promise) => { promise.reject(new ReconnectionFailedError()); - this._settleReconnectionPromise = Maybe.zero(); + this._settleReconnectionPromiseResolvers = Maybe.zero(); }); this._isDeviceReady = false; } diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts index c6e190589..1ba4ded59 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.test.ts @@ -158,7 +158,7 @@ describe("WebBleTransport", () => { }); it("should emit an error if the user did not grant us access to a device (clicking on cancel on the browser popup for ex)", (done) => { - mockedRequestDevice.mockResolvedValueOnce({}); + mockedRequestDevice.mockResolvedValueOnce({ forget: jest.fn() }); discoverDevice( (discoveredDevice) => { diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts index 4d7ddf7aa..7c08ed007 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -81,7 +81,7 @@ export class WebBleTransport implements Transport { return Left(new BleTransportNotSupportedError("WebBle not supported")); } - isSupported() { + isSupported(): boolean { try { const result = !!navigator?.bluetooth; return result; @@ -254,13 +254,13 @@ export class WebBleTransport implements Transport { }, Left: (error) => { - bleDevice.gatt?.disconnect(); + bleDevice.forget(); throw error; }, }); }, Left: (error) => { - bleDevice.gatt?.disconnect(); + bleDevice.forget(); throw error; }, }); @@ -302,6 +302,7 @@ export class WebBleTransport implements Transport { }); return Left(new UnknownDeviceError(`Unknown device ${deviceId}`)); } + // if device already connected, remove device id from internal state and remove error if (this._connectedDevices.includes(internalDevice.bleDevice)) { this._internalDevicesById.delete(deviceId); return Left(new DeviceAlreadyConnectedError("Device already connected")); @@ -342,6 +343,7 @@ export class WebBleTransport implements Transport { this._connectedDevices.push(internalDevice.bleDevice); return Right(connectedDevice); } catch (error) { + await internalDevice.bleDevice.forget(); this._internalDevicesById.delete(deviceId); this._logger.error("Error while getting characteristics", { data: { error }, @@ -424,12 +426,18 @@ export class WebBleTransport implements Transport { ); maybeDeviceConnection.map((dConnection) => dConnection.disconnect()); // disconnect device gatt server - bleDevice.gatt?.disconnect(); + if (bleDevice.gatt?.connected) { + bleDevice.gatt.disconnect(); + } // clean up objects this._internalDevicesById.delete(device.id); this._deviceConnectionById.delete(device.id); this._disconnectionHandlersById.delete(device.id); - delete this._connectedDevices[this._connectedDevices.indexOf(bleDevice)]; + if (this._connectedDevices.includes(bleDevice)) { + delete this._connectedDevices[ + this._connectedDevices.indexOf(bleDevice) + ]; + } }); return Right(void 0); From d381c9a4b2ed5c6223eb0f8a54cbe1f319fd7644 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Thu, 10 Oct 2024 16:47:16 +0200 Subject: [PATCH 12/16] :recycle: (chore): Use EitherAsync instead of multiple caseOf in bleTransport --- .../ble/transport/BleDeviceConnection.ts | 8 +-- .../ble/transport/WebBleTransport.ts | 70 ++++++++----------- 2 files changed, 34 insertions(+), 44 deletions(-) diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts index 5b64c6b78..6deebb3a5 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts @@ -198,10 +198,10 @@ export class BleDeviceConnection implements DeviceConnection { }); }, ); - const frames = this._apduSender.caseOf({ - Just: (apduSender) => apduSender.getFrames(apdu), - Nothing: () => [], - }); + const frames = this._apduSender.mapOrDefault( + (apduSender) => apduSender.getFrames(apdu), + [], + ); for (const frame of frames) { try { this._logger.debug("Sending Frame", { diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts index 7c08ed007..33611668c 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -224,47 +224,37 @@ export class WebBleTransport implements Transport { return from(this.promptDeviceAccess()).pipe( switchMap(async (errorOrBleDevice) => - errorOrBleDevice.caseOf({ - Right: async (bleDevice) => { - // ble connect here as gatt server needs to be opened to fetch gatt service - if (bleDevice.gatt) { - try { - await bleDevice.gatt.connect(); - } catch (error) { - throw new OpeningConnectionError(error); - } + EitherAsync(async ({ liftEither, fromPromise }) => { + const bleDevice = await liftEither(errorOrBleDevice); + if (bleDevice.gatt) { + try { + await bleDevice.gatt.connect(); + } catch (error) { + throw new OpeningConnectionError(error); } - const errorOrBleGattService = - await this.getBleGattService(bleDevice); - return errorOrBleGattService.caseOf({ - Right: (bleGattService) => { - const errorOrBleDeviceInfos = - this.getBleDeviceInfos(bleGattService); - return errorOrBleDeviceInfos.caseOf({ - Right: (bleDeviceInfos) => { - const discoveredDevice = - this.getDiscoveredDeviceFrom(bleDeviceInfos); - this.setInternalDeviceFrom( - discoveredDevice, - bleDevice, - bleDeviceInfos, - bleGattService, - ); - return discoveredDevice; - }, - - Left: (error) => { - bleDevice.forget(); - throw error; - }, - }); - }, - Left: (error) => { - bleDevice.forget(); - throw error; - }, - }); - }, + } + try { + const bleGattService = await fromPromise( + this.getBleGattService(bleDevice), + ); + const bleDeviceInfos = await liftEither( + this.getBleDeviceInfos(bleGattService), + ); + const discoveredDevice = + this.getDiscoveredDeviceFrom(bleDeviceInfos); + this.setInternalDeviceFrom( + discoveredDevice, + bleDevice, + bleDeviceInfos, + bleGattService, + ); + return discoveredDevice; + } catch (error) { + await bleDevice.forget(); + throw error; + } + }).caseOf({ + Right: (discoveredDevice) => discoveredDevice, Left: (error) => { this._logger.error("Error while getting accessible device", { data: { error }, From 42b63079449920429c4ea5f36efaf19f1b81492d Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Mon, 14 Oct 2024 17:15:49 +0200 Subject: [PATCH 13/16] :recycle: (core): Rebase --- apps/sample/src/components/Header/index.tsx | 3 ++- .../MainView/ConnectDeviceActions.tsx | 25 ++++++++++--------- apps/sample/src/components/MainView/index.tsx | 4 +-- apps/sample/src/components/Menu/index.tsx | 5 ++-- apps/sample/src/components/MockView/index.tsx | 2 +- apps/sample/src/components/Sidebar/index.tsx | 4 +-- .../src/providers/DeviceSdkProvider/index.tsx | 3 ++- apps/sample/src/providers/SdkConfig/index.tsx | 8 +++--- 8 files changed, 30 insertions(+), 24 deletions(-) diff --git a/apps/sample/src/components/Header/index.tsx b/apps/sample/src/components/Header/index.tsx index a8941ca9e..1a2d9d796 100644 --- a/apps/sample/src/components/Header/index.tsx +++ b/apps/sample/src/components/Header/index.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useState } from "react"; +import { BuiltinTransports } from "@ledgerhq/device-management-kit"; import { Button, DropdownGeneric, @@ -8,8 +9,8 @@ import { Switch, } from "@ledgerhq/react-ui"; import styled, { DefaultTheme } from "styled-components"; + import { useSdkConfigContext } from "@/providers/SdkConfig"; -import { BuiltinTransports } from "@ledgerhq/device-management-kit"; const Root = styled(Flex).attrs({ py: 3, px: 10, gridGap: 8 })` color: ${({ theme }: { theme: DefaultTheme }) => theme.colors.neutral.c90}; diff --git a/apps/sample/src/components/MainView/ConnectDeviceActions.tsx b/apps/sample/src/components/MainView/ConnectDeviceActions.tsx index 7f899d6a0..948e1f60e 100644 --- a/apps/sample/src/components/MainView/ConnectDeviceActions.tsx +++ b/apps/sample/src/components/MainView/ConnectDeviceActions.tsx @@ -1,14 +1,18 @@ -import { Button, Flex } from "@ledgerhq/react-ui"; -import { BuiltinTransports, SdkError } from "@ledgerhq/device-management-kit"; import React, { useCallback } from "react"; -import { useSdkConfigContext } from "@/providers/SdkConfig"; +import { BuiltinTransports, SdkError } from "@ledgerhq/device-management-kit"; +import { Button, Flex } from "@ledgerhq/react-ui"; +import styled from "styled-components"; + import { useSdk } from "@/providers/DeviceSdkProvider"; import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; +import { useSdkConfigContext } from "@/providers/SdkConfig"; type ConnectDeviceActionsProps = { onError: (error: SdkError | null) => void; }; +const ConnectButton = styled(Button).attrs({ mx: 3 })``; + export const ConnectDeviceActions = ({ onError, }: ConnectDeviceActionsProps) => { @@ -56,8 +60,7 @@ export const ConnectDeviceActions = ({ ); return transport === BuiltinTransports.MOCK_SERVER ? ( - + ) : ( - - + ); }; diff --git a/apps/sample/src/components/MainView/index.tsx b/apps/sample/src/components/MainView/index.tsx index 166326acf..bce6cd792 100644 --- a/apps/sample/src/components/MainView/index.tsx +++ b/apps/sample/src/components/MainView/index.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react"; -import { Badge, Flex, Icon, Text, Notification } from "@ledgerhq/react-ui"; +import { SdkError } from "@ledgerhq/device-management-kit"; +import { Badge, Flex, Icon, Notification, Text } from "@ledgerhq/react-ui"; import Image from "next/image"; import styled, { DefaultTheme } from "styled-components"; -import { SdkError } from "@ledgerhq/device-management-kit"; import { ConnectDeviceActions } from "./ConnectDeviceActions"; const Root = styled(Flex)` diff --git a/apps/sample/src/components/Menu/index.tsx b/apps/sample/src/components/Menu/index.tsx index e86a75608..bbc598816 100644 --- a/apps/sample/src/components/Menu/index.tsx +++ b/apps/sample/src/components/Menu/index.tsx @@ -1,9 +1,10 @@ import React from "react"; +import { BuiltinTransports } from "@ledgerhq/device-management-kit"; import { Flex, Icons, Link } from "@ledgerhq/react-ui"; import { useRouter } from "next/navigation"; import styled from "styled-components"; -import { useSdkConfigContext } from "../../providers/SdkConfig"; -import { BuiltinTransports } from "@ledgerhq/device-management-kit"; + +import { useSdkConfigContext } from "@/providers/SdkConfig"; const MenuItem = styled(Flex).attrs({ p: 3, pl: 5 })` align-items: center; diff --git a/apps/sample/src/components/MockView/index.tsx b/apps/sample/src/components/MockView/index.tsx index 2b0ba5d02..4965641af 100644 --- a/apps/sample/src/components/MockView/index.tsx +++ b/apps/sample/src/components/MockView/index.tsx @@ -4,7 +4,7 @@ import { Button, Divider, Flex, Input, Link, Text } from "@ledgerhq/react-ui"; import styled, { DefaultTheme } from "styled-components"; import { useMockClient } from "@/hooks/useMockClient"; -import { useSdkConfigContext } from "../../providers/SdkConfig"; +import { useSdkConfigContext } from "@/providers/SdkConfig"; const Root = styled(Flex).attrs({ mx: 15, mt: 10, mb: 5 })` flex-direction: column; diff --git a/apps/sample/src/components/Sidebar/index.tsx b/apps/sample/src/components/Sidebar/index.tsx index 00bd56bf6..041359d68 100644 --- a/apps/sample/src/components/Sidebar/index.tsx +++ b/apps/sample/src/components/Sidebar/index.tsx @@ -1,5 +1,6 @@ "use client"; import React, { useCallback, useEffect, useState } from "react"; +import { BuiltinTransports } from "@ledgerhq/device-management-kit"; import { Box, Flex, IconsLegacy, Link, Text } from "@ledgerhq/react-ui"; import { useRouter } from "next/navigation"; import styled, { DefaultTheme } from "styled-components"; @@ -8,8 +9,7 @@ import { Device } from "@/components/Device"; import { Menu } from "@/components/Menu"; import { useExportLogsCallback, useSdk } from "@/providers/DeviceSdkProvider"; import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; -import { useSdkConfigContext } from "../../providers/SdkConfig"; -import { BuiltinTransports } from "@ledgerhq/device-management-kit"; +import { useSdkConfigContext } from "@/providers/SdkConfig"; const Root = styled(Flex).attrs({ py: 8, px: 6 })` flex-direction: column; diff --git a/apps/sample/src/providers/DeviceSdkProvider/index.tsx b/apps/sample/src/providers/DeviceSdkProvider/index.tsx index 24e7a721f..fef278748 100644 --- a/apps/sample/src/providers/DeviceSdkProvider/index.tsx +++ b/apps/sample/src/providers/DeviceSdkProvider/index.tsx @@ -7,8 +7,9 @@ import { DeviceSdkBuilder, WebLogsExporterLogger, } from "@ledgerhq/device-management-kit"; -import { useSdkConfigContext } from "../SdkConfig"; + import { usePrevious } from "@/hooks/usePrevious"; +import { useSdkConfigContext } from "@/providers/SdkConfig"; const webLogsExporterLogger = new WebLogsExporterLogger(); diff --git a/apps/sample/src/providers/SdkConfig/index.tsx b/apps/sample/src/providers/SdkConfig/index.tsx index 8403dba7a..9ae5336b1 100644 --- a/apps/sample/src/providers/SdkConfig/index.tsx +++ b/apps/sample/src/providers/SdkConfig/index.tsx @@ -1,9 +1,11 @@ +import React from "react"; import { createContext, useContext, useReducer } from "react"; + import { - SdkConfigState, - sdkConfigReducer, - SdkConfigInitialState, SdkConfigAction, + SdkConfigInitialState, + sdkConfigReducer, + SdkConfigState, } from "@/reducers/sdkConfig"; type SdkConfigContextType = { From 90a4f1eeeb1177771a056b561dce7e429bda05ea Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Tue, 15 Oct 2024 11:24:47 +0200 Subject: [PATCH 14/16] :zap: (core): Use writeValueWithoutResponse for ble perf --- .../src/internal/transport/ble/model/BleDevice.stub.ts | 1 + .../transport/ble/transport/BleDeviceConnection.test.ts | 8 ++++---- .../transport/ble/transport/BleDeviceConnection.ts | 4 ++-- .../internal/transport/ble/transport/WebBleTransport.ts | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts b/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts index e53c15949..ad58e5b41 100644 --- a/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts +++ b/packages/core/src/internal/transport/ble/model/BleDevice.stub.ts @@ -43,6 +43,7 @@ export const bleCharacteristicStubBuilder = ( removeEventListener: jest.fn(), startNotifications: jest.fn(), writeValueWithResponse: jest.fn(), + writeValueWithoutResponse: jest.fn(), }) as BluetoothRemoteGATTCharacteristic; export const bleDeviceStubBuilder = ( diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts index 3aeb2f621..ebae70769 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts @@ -71,7 +71,7 @@ describe("BleDeviceConnection", () => { ); }); - it("should call writeValueWithResponse if device is setup", async () => { + it("should send apdu without error if device is setup", async () => { // given const connection = new BleDeviceConnection( { @@ -112,9 +112,9 @@ describe("BleDeviceConnection", () => { // when await connection.setup(); // then - expect(writeCharacteristic.writeValueWithResponse).toHaveBeenCalledWith( - new Uint8Array(GET_MTU_APDU), - ); + expect( + writeCharacteristic.writeValueWithoutResponse, + ).toHaveBeenCalledWith(new Uint8Array(GET_MTU_APDU)); }); it("should setup apduSender with the correct mtu size", () => { // given diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts index 6deebb3a5..9d13b504c 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.ts @@ -144,7 +144,7 @@ export class BleDeviceConnection implements DeviceConnection { const requestMtuApdu = Uint8Array.from([0x08, 0x00, 0x00, 0x00, 0x00]); await this._notifyCharacteristic.startNotifications(); - await this._writeCharacteristic.writeValueWithResponse(requestMtuApdu); + await this._writeCharacteristic.writeValueWithoutResponse(requestMtuApdu); } /** @@ -207,7 +207,7 @@ export class BleDeviceConnection implements DeviceConnection { this._logger.debug("Sending Frame", { data: { frame: frame.getRawData() }, }); - await this._writeCharacteristic.writeValueWithResponse( + await this._writeCharacteristic.writeValueWithoutResponse( frame.getRawData(), ); } catch (error) { diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts index 33611668c..3899fab48 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -305,7 +305,7 @@ export class WebBleTransport implements Transport { try { const [writeCharacteristic, notifyCharacteristic] = await Promise.all([ internalDevice.bleGattService.getCharacteristic( - internalDevice.bleDeviceInfos.writeUuid, + internalDevice.bleDeviceInfos.writeCmdUuid, ), internalDevice.bleGattService.getCharacteristic( internalDevice.bleDeviceInfos.notifyUuid, @@ -375,7 +375,7 @@ export class WebBleTransport implements Transport { const [writeC, notifyC] = await Promise.all([ service .extract() - .getCharacteristic(internalDevice.bleDeviceInfos.writeUuid), + .getCharacteristic(internalDevice.bleDeviceInfos.writeCmdUuid), service .extract() .getCharacteristic(internalDevice.bleDeviceInfos.notifyUuid), From 82460bf0e28f590f37084ef259861577a594960d Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Wed, 16 Oct 2024 15:37:29 +0200 Subject: [PATCH 15/16] :art: (core): Reviews ble transport --- .../ble/transport/WebBleTransport.ts | 42 ++++++------ .../src/internal/transport/model/Errors.ts | 65 ------------------- 2 files changed, 21 insertions(+), 86 deletions(-) diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts index 3899fab48..b89366f87 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -46,8 +46,8 @@ type WebBleInternalDevice = { @injectable() export class WebBleTransport implements Transport { - private _connectedDevices: Array; - private _internalDevicesById: Map; + private readonly _connectedDevices: Array; + private readonly _internalDevicesById: Map; private _deviceConnectionById: Map; private _disconnectionHandlersById: Map void>; private _logger: LoggerPublisherService; @@ -148,28 +148,28 @@ export class WebBleTransport implements Transport { * * @private */ - private async promptDeviceAccess(): Promise< - Either + private promptDeviceAccess(): EitherAsync< + PromptDeviceAccessError, + BluetoothDevice > { - return EitherAsync.liftEither(this.getBluetoothApi()) - .map(async (bluetoothApi) => { - let bleDevice: BluetoothDevice; + return EitherAsync(async ({ liftEither, throwE }) => { + const bluetoothApi = await liftEither(this.getBluetoothApi()); + let bleDevice: BluetoothDevice; - try { - bleDevice = await bluetoothApi.requestDevice({ - filters: this._deviceModelDataSource - .getBluetoothServices() - .map((serviceUuid) => ({ - services: [serviceUuid], - })), - }); - } catch (error) { - throw new NoAccessibleDeviceError(error); - } + try { + bleDevice = await bluetoothApi.requestDevice({ + filters: this._deviceModelDataSource + .getBluetoothServices() + .map((serviceUuid) => ({ + services: [serviceUuid], + })), + }); + } catch (error) { + return throwE(new NoAccessibleDeviceError(error)); + } - return bleDevice; - }) - .run(); + return bleDevice; + }); } /** diff --git a/packages/core/src/internal/transport/model/Errors.ts b/packages/core/src/internal/transport/model/Errors.ts index eb4dc5b87..7d8376f1e 100644 --- a/packages/core/src/internal/transport/model/Errors.ts +++ b/packages/core/src/internal/transport/model/Errors.ts @@ -26,11 +26,6 @@ export class DeviceNotRecognizedError extends GeneralSdkError { override readonly _tag = "DeviceNotRecognizedError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -38,11 +33,6 @@ export class NoAccessibleDeviceError extends GeneralSdkError { override readonly _tag = "NoAccessibleDeviceError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -50,11 +40,6 @@ export class OpeningConnectionError extends GeneralSdkError { override readonly _tag = "ConnectionOpeningError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -62,11 +47,6 @@ export class UnknownDeviceError extends GeneralSdkError { override readonly _tag = "UnknownDeviceError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -74,11 +54,6 @@ export class TransportNotSupportedError extends GeneralSdkError { override readonly _tag = "TransportNotSupportedError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -86,11 +61,6 @@ export class BleTransportNotSupportedError extends GeneralSdkError { override readonly _tag = "BleTransportNotSupportedError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -98,11 +68,6 @@ export class UsbHidTransportNotSupportedError extends GeneralSdkError { override readonly _tag = "UsbHidTransportNotSupportedError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -110,11 +75,6 @@ export class SendApduConcurrencyError extends GeneralSdkError { override readonly _tag = "SendApduConcurrencyError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -122,11 +82,6 @@ export class DisconnectError extends GeneralSdkError { override readonly _tag = "DisconnectError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -134,11 +89,6 @@ export class ReconnectionFailedError extends GeneralSdkError { override readonly _tag = "ReconnectionFailedError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -153,11 +103,6 @@ export class DeviceNotInitializedError extends GeneralSdkError { override readonly _tag = "DeviceNotInitializedError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -165,11 +110,6 @@ export class BleDeviceGattServerError extends GeneralSdkError { override readonly _tag = "BleDeviceGattServerError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } @@ -177,10 +117,5 @@ export class DeviceAlreadyConnectedError extends GeneralSdkError { override readonly _tag = "DeviceAlreadyDiscoveredError"; constructor(readonly err?: unknown) { super(err); - if (err instanceof Error) { - this.originalError = err; - } else if (err !== undefined) { - this.originalError = new Error(String(err)); - } } } From dd61a4e4ab3278c700ff03a2eaa6ff5847be363d Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Thu, 17 Oct 2024 12:01:09 +0200 Subject: [PATCH 16/16] :white_check_mark: (core): Improve coverage --- .../data/StaticDeviceModelDataSource.test.ts | 47 +++++++++++++++++ .../data/StaticDeviceModelDataSource.ts | 16 +++--- .../use-case/CloseSessionsUseState.test.ts | 51 +++++++++++++++++++ .../ble/transport/BleDeviceConnection.test.ts | 3 ++ .../mockserver/MockserverTransport.ts | 5 +- 5 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/internal/device-session/use-case/CloseSessionsUseState.test.ts diff --git a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts index 360c7cf36..b450b1fd5 100644 --- a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts +++ b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts @@ -1,4 +1,5 @@ import { DeviceModelId } from "@api/device/DeviceModel"; +import { BleDeviceInfos } from "@internal/transport/ble/model/BleDeviceInfos"; import { StaticDeviceModelDataSource } from "./StaticDeviceModelDataSource"; @@ -149,4 +150,50 @@ describe("StaticDeviceModelDataSource", () => { expect(deviceModels3.length).toEqual(0); }); }); + describe("getBluetoothServicesInfos", () => { + it("should return the ble service infos record", () => { + // given + const dataSource = new StaticDeviceModelDataSource(); + // when + const bleServiceInfos = dataSource.getBluetoothServicesInfos(); + // then + expect(bleServiceInfos).toStrictEqual({ + "13d63400-2c97-0004-0000-4c6564676572": new BleDeviceInfos( + dataSource.getDeviceModel({ id: DeviceModelId.NANO_X }), + "13d63400-2c97-0004-0000-4c6564676572", + "13d63400-2c97-0004-0002-4c6564676572", + "13d63400-2c97-0004-0003-4c6564676572", + "13d63400-2c97-0004-0001-4c6564676572", + ), + "13d63400-2c97-6004-0000-4c6564676572": new BleDeviceInfos( + dataSource.getDeviceModel({ id: DeviceModelId.STAX }), + "13d63400-2c97-6004-0000-4c6564676572", + "13d63400-2c97-6004-0002-4c6564676572", + "13d63400-2c97-6004-0003-4c6564676572", + "13d63400-2c97-6004-0001-4c6564676572", + ), + "13d63400-2c97-3004-0000-4c6564676572": new BleDeviceInfos( + dataSource.getDeviceModel({ id: DeviceModelId.FLEX }), + "13d63400-2c97-3004-0000-4c6564676572", + "13d63400-2c97-3004-0002-4c6564676572", + "13d63400-2c97-3004-0003-4c6564676572", + "13d63400-2c97-3004-0001-4c6564676572", + ), + }); + }); + }); + describe("getBluetoothServices", () => { + it("should return the bluetooth services", () => { + // given + const dataSource = new StaticDeviceModelDataSource(); + // when + const bleServices = dataSource.getBluetoothServices(); + // then + expect(bleServices).toStrictEqual([ + "13d63400-2c97-0004-0000-4c6564676572", + "13d63400-2c97-6004-0000-4c6564676572", + "13d63400-2c97-3004-0000-4c6564676572", + ]); + }); + }); }); diff --git a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts index c50a2cb33..57b7a9d86 100644 --- a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts +++ b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts @@ -134,15 +134,11 @@ export class StaticDeviceModelDataSource implements DeviceModelDataSource { } getBluetoothServices(): string[] { - return Object.values(StaticDeviceModelDataSource.deviceModelByIds).reduce< - string[] - >((acc, deviceModel) => { - const { bluetoothSpec } = deviceModel; - - if (bluetoothSpec) { - return acc.concat(bluetoothSpec.map((spec) => spec.serviceUuid)); - } - return acc; - }, []); + return Object.values(StaticDeviceModelDataSource.deviceModelByIds) + .map((deviceModel) => + (deviceModel.bluetoothSpec || []).map((spec) => spec.serviceUuid), + ) + .flat() + .filter((uuid) => !!uuid); } } diff --git a/packages/core/src/internal/device-session/use-case/CloseSessionsUseState.test.ts b/packages/core/src/internal/device-session/use-case/CloseSessionsUseState.test.ts new file mode 100644 index 000000000..238a69f92 --- /dev/null +++ b/packages/core/src/internal/device-session/use-case/CloseSessionsUseState.test.ts @@ -0,0 +1,51 @@ +import { deviceSessionStubBuilder } from "@internal/device-session/model/DeviceSession.stub"; +import { DefaultDeviceSessionService } from "@internal/device-session/service/DefaultDeviceSessionService"; +import { DeviceSessionService } from "@internal/device-session/service/DeviceSessionService"; +import { CloseSessionsUseCase } from "@internal/device-session/use-case/CloseSessionsUseCase"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; + +let logger: LoggerPublisherService; +let managerApiDataSource: ManagerApiDataSource; +let managerApi: ManagerApiService; +let sessionService: DeviceSessionService; + +describe("CloseSessionsUseState", () => { + beforeEach(() => { + logger = new DefaultLoggerPublisherService( + [], + "close-sessions-use-case-test", + ); + managerApiDataSource = new AxiosManagerApiDataSource({ + managerApiUrl: "http://fake.url", + mockUrl: "http://fake-mock.url", + }); + managerApi = new DefaultManagerApiService(managerApiDataSource); + sessionService = new DefaultDeviceSessionService(() => logger); + }); + + it("should be able to close every session", () => { + //given + const sessions = [...Array(10).keys()].map((id) => { + const session = deviceSessionStubBuilder( + { id: id.toString() }, + () => logger, + managerApi, + ); + jest.spyOn(session, "close"); + return session; + }); + sessions.forEach((session) => sessionService.addDeviceSession(session)); + const useCase = new CloseSessionsUseCase(sessionService); + //when + useCase.execute(); + //then + sessions.forEach((session) => { + expect(session.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts index ebae70769..d60b8122d 100644 --- a/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts +++ b/packages/core/src/internal/transport/ble/transport/BleDeviceConnection.test.ts @@ -87,6 +87,9 @@ describe("BleDeviceConnection", () => { const response = connection.sendApdu(new Uint8Array([])); receiveApdu(connection, EMPTY_APDU_RESPONSE); // then + expect( + writeCharacteristic.writeValueWithoutResponse, + ).toHaveBeenCalledTimes(1); expect(await response).toStrictEqual( Right( new ApduResponse({ diff --git a/packages/core/src/internal/transport/mockserver/MockserverTransport.ts b/packages/core/src/internal/transport/mockserver/MockserverTransport.ts index 1016455ac..4e8ac8319 100644 --- a/packages/core/src/internal/transport/mockserver/MockserverTransport.ts +++ b/packages/core/src/internal/transport/mockserver/MockserverTransport.ts @@ -1,3 +1,5 @@ +/* istanbul ignore file */ +// pragma to ignore this file from coverage import { CommandResponse, Device, @@ -94,9 +96,6 @@ export class MockTransport implements Transport { const sessionId: string = params.deviceId; try { const session: Session = await this.mockClient.connect(sessionId); - this.logger.debug("connected device model id::", { - data: { session, sessionId }, - }); const connectedDevice = { sendApdu: (apdu) => { return this.sendApdu(