From b262795bc6c32c9442c37565f063cb52ec52c737 Mon Sep 17 00:00:00 2001 From: jz_ Date: Fri, 3 May 2024 11:04:42 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20=20(core):=20Fix=20typ?= =?UTF-8?q?os?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/src/api/command/os/GetOsVersionCommand.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/api/command/os/GetOsVersionCommand.ts b/packages/core/src/api/command/os/GetOsVersionCommand.ts index 05cdc3d54..0566e2761 100644 --- a/packages/core/src/api/command/os/GetOsVersionCommand.ts +++ b/packages/core/src/api/command/os/GetOsVersionCommand.ts @@ -1,5 +1,5 @@ import { Apdu } from "@api/apdu/model/Apdu"; -import { ApduBuilder } from "@api/apdu/utils/ApduBuilder"; +import { ApduBuilder, ApduBuilderArgs } from "@api/apdu/utils/ApduBuilder"; import { ApduParser } from "@api/apdu/utils/ApduParser"; import { Command } from "@api/command/Command"; import { InvalidStatusWordError } from "@api/command/Errors"; @@ -64,13 +64,15 @@ export type GetOsVersionResponse = { * Command to get information about the device firmware. */ export class GetOsVersionCommand implements Command { - getApdu = (): Apdu => - new ApduBuilder({ + getApdu(): Apdu { + const getOsVersionApduArgs: ApduBuilderArgs = { cla: 0xe0, ins: 0x01, p1: 0x00, p2: 0x00, - }).build(); + } as const; + return new ApduBuilder(getOsVersionApduArgs).build(); + } parseResponse(responseApdu: ApduResponse, deviceModelId: DeviceModelId) { const parser = new ApduParser(responseApdu); From ddf1654674484e420a5de240033e2c9437061841 Mon Sep 17 00:00:00 2001 From: jz_ Date: Fri, 3 May 2024 15:50:55 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20polling=20to=20?= =?UTF-8?q?update=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/device-session/DeviceSessionState.ts | 56 +++++++++++++ .../device-session/model/DeviceSession.ts | 8 ++ .../device-session/model/SessionRefresher.ts | 78 +++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 packages/core/src/internal/device-session/model/SessionRefresher.ts diff --git a/packages/core/src/api/device-session/DeviceSessionState.ts b/packages/core/src/api/device-session/DeviceSessionState.ts index e2cd7caff..f0a325ab6 100644 --- a/packages/core/src/api/device-session/DeviceSessionState.ts +++ b/packages/core/src/api/device-session/DeviceSessionState.ts @@ -6,6 +6,25 @@ export type SessionStateConstructorArgs = { deviceStatus: DeviceStatus; }; +export type ConnectedStateConstructorArgs = Pick< + SessionStateConstructorArgs, + "sessionId" +> & { + batteryStatus?: BatteryStatus; + firmwareVersion?: FirmwareVersion; + currentApp?: string; +}; + +export type BatteryStatus = { + level: number; +}; + +export type FirmwareVersion = { + mcu: string; // Microcontroller Unit version + bootloader: string; // Bootloader version + os: string; // Operating System version +}; + export class DeviceSessionState { public readonly sessionId: DeviceSessionId; public readonly deviceStatus: DeviceStatus; @@ -15,3 +34,40 @@ export class DeviceSessionState { this.deviceStatus = deviceStatus; } } + +export class ConnectedState extends DeviceSessionState { + // private readonly _deviceName: string; // GetDeviceNameResponse +} + +export class ReadyWithoutSecureChannelState extends ConnectedState { + private readonly _batteryStatus: BatteryStatus | null = null; // GetBatteryStatusResponse + private readonly _firmwareVersion: FirmwareVersion | null = null; // GetOsVersionResponse + private readonly _currentApp: string | null = null; // GetAppVersionResponse + // private readonly _deviceName: string; // GetDeviceNameResponse + + constructor({ + sessionId, + currentApp, + batteryStatus, + firmwareVersion, + }: ConnectedStateConstructorArgs) { + super({ sessionId, deviceStatus: DeviceStatus.CONNECTED }); + this._currentApp = currentApp ? currentApp : null; + this._batteryStatus = batteryStatus ? batteryStatus : null; + this._firmwareVersion = firmwareVersion ? firmwareVersion : null; + } + + public get batteryStatus() { + return this._batteryStatus; + } + + public get firmwareVersion() { + return this._firmwareVersion; + } + + public get currentApp() { + return this._currentApp; + } +} + +export class ReadyWithSecureChannelState extends ReadyWithoutSecureChannelState {} diff --git a/packages/core/src/internal/device-session/model/DeviceSession.ts b/packages/core/src/internal/device-session/model/DeviceSession.ts index b49275a45..a0a62a5db 100644 --- a/packages/core/src/internal/device-session/model/DeviceSession.ts +++ b/packages/core/src/internal/device-session/model/DeviceSession.ts @@ -7,6 +7,7 @@ import { DeviceModelId } from "@api/device/DeviceModel"; import { DeviceStatus } from "@api/device/DeviceStatus"; import { DeviceSessionState } from "@api/device-session/DeviceSessionState"; import { DeviceSessionId } from "@api/device-session/types"; +import { SeesionRefresher } from "@internal/device-session/model/SessionRefresher"; import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice"; export type SessionConstructorArgs = { @@ -21,6 +22,7 @@ export class DeviceSession { private readonly _id: DeviceSessionId; private readonly _connectedDevice: InternalConnectedDevice; private readonly _deviceState: BehaviorSubject; + private readonly _refresher: SeesionRefresher; constructor({ connectedDevice, id = uuidv4() }: SessionConstructorArgs) { this._id = id; @@ -31,6 +33,8 @@ export class DeviceSession { deviceStatus: DeviceStatus.CONNECTED, }), ); + this._refresher = new SeesionRefresher(this, 1000); + this._refresher.start(); } public get id() { @@ -45,6 +49,10 @@ export class DeviceSession { return this._deviceState.asObservable(); } + public setState(state: DeviceSessionState) { + this._deviceState.next(state); + } + private updateDeviceStatus(deviceStatus: DeviceStatus) { const sessionState = this._deviceState.getValue(); this._deviceState.next( diff --git a/packages/core/src/internal/device-session/model/SessionRefresher.ts b/packages/core/src/internal/device-session/model/SessionRefresher.ts new file mode 100644 index 000000000..54d538cec --- /dev/null +++ b/packages/core/src/internal/device-session/model/SessionRefresher.ts @@ -0,0 +1,78 @@ +import { audit, interval, Subscription, switchMap, takeWhile } from "rxjs"; + +import { + GetAppAndVersionCommand, + GetAppAndVersionResponse, +} from "@api/command/os/GetAppAndVersionCommand"; +import { DeviceStatus } from "@api/device/DeviceStatus"; +import { ReadyWithoutSecureChannelState } from "@api/session/SessionDeviceState"; + +import { Session } from "./Session"; + +export const DEVICE_OS_NAME = "BOLOS"; + +export class SeesionRefresher { + private _subscription: Subscription | null = null; + private readonly command: GetAppAndVersionCommand; + + constructor( + private readonly session: Session, + private readonly refreshInterval: number, + ) { + this.command = new GetAppAndVersionCommand(); + } + + start(): void { + this._subscription = this.session.state + .pipe( + audit(() => interval(this.refreshInterval)), + takeWhile((state) => state.deviceStatus === DeviceStatus.CONNECTED), + switchMap(() => { + const rawApdu = this.command.getApdu().getRawApdu(); + return this.session.connectedDevice.sendApdu(rawApdu); + }), + ) + .subscribe({ + next: (response) => { + response + .ifRight((data) => { + const { name }: GetAppAndVersionResponse = + this.command.parseResponse(data); + if (name === DEVICE_OS_NAME) { + // await this.session.connectedDevice.sendApdu( + // new GetOsVersionCommand().getApdu().getRawApdu(), + // ); + } else { + this.session.setState( + new ReadyWithoutSecureChannelState({ + sessionId: this.session.id, + currentApp: name, + }), + ); + } + }) + .ifLeft(() => { + console.log("Error in response"); + }); + }, + error: (error) => { + this.restart(); + console.error("Error", error); + }, + complete: () => { + console.log("Complete"); + }, + }); + } + + stop(): void { + if (this._subscription) { + this._subscription.unsubscribe(); + } + } + + restart(): void { + this.stop(); + this.start(); + } +} From 8bef03ffe3348b5f660ea3f180bf6ece93dd3f92 Mon Sep 17 00:00:00 2001 From: jz_ Date: Fri, 3 May 2024 16:15:43 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=93=9D=20(core):=20Add=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fresh-eels-sip.md | 5 +++ .../api/device-session/DeviceSessionState.ts | 12 +++++-- .../device-session/model/DeviceSession.ts | 16 +++++---- .../device-session/model/SessionRefresher.ts | 36 ++++++++++--------- 4 files changed, 44 insertions(+), 25 deletions(-) create mode 100644 .changeset/fresh-eels-sip.md diff --git a/.changeset/fresh-eels-sip.md b/.changeset/fresh-eels-sip.md new file mode 100644 index 000000000..72fe29a23 --- /dev/null +++ b/.changeset/fresh-eels-sip.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-sdk-core": minor +--- + +Polling on conncected device to get device status. diff --git a/packages/core/src/api/device-session/DeviceSessionState.ts b/packages/core/src/api/device-session/DeviceSessionState.ts index f0a325ab6..03a807f25 100644 --- a/packages/core/src/api/device-session/DeviceSessionState.ts +++ b/packages/core/src/api/device-session/DeviceSessionState.ts @@ -9,6 +9,11 @@ export type SessionStateConstructorArgs = { export type ConnectedStateConstructorArgs = Pick< SessionStateConstructorArgs, "sessionId" +>; // & {}; + +export type ReadyWithoutSecureChannelStateConstructorArgs = Pick< + ConnectedStateConstructorArgs, + "sessionId" > & { batteryStatus?: BatteryStatus; firmwareVersion?: FirmwareVersion; @@ -37,6 +42,9 @@ export class DeviceSessionState { export class ConnectedState extends DeviceSessionState { // private readonly _deviceName: string; // GetDeviceNameResponse + constructor({ sessionId }: ConnectedStateConstructorArgs) { + super({ sessionId, deviceStatus: DeviceStatus.CONNECTED }); + } } export class ReadyWithoutSecureChannelState extends ConnectedState { @@ -50,8 +58,8 @@ export class ReadyWithoutSecureChannelState extends ConnectedState { currentApp, batteryStatus, firmwareVersion, - }: ConnectedStateConstructorArgs) { - super({ sessionId, deviceStatus: DeviceStatus.CONNECTED }); + }: ReadyWithoutSecureChannelStateConstructorArgs) { + super({ sessionId }); this._currentApp = currentApp ? currentApp : null; this._batteryStatus = batteryStatus ? batteryStatus : null; this._firmwareVersion = firmwareVersion ? firmwareVersion : null; diff --git a/packages/core/src/internal/device-session/model/DeviceSession.ts b/packages/core/src/internal/device-session/model/DeviceSession.ts index a0a62a5db..708310a94 100644 --- a/packages/core/src/internal/device-session/model/DeviceSession.ts +++ b/packages/core/src/internal/device-session/model/DeviceSession.ts @@ -1,3 +1,4 @@ +import { Left, Right } from "purify-ts"; import { BehaviorSubject } from "rxjs"; import { v4 as uuidv4 } from "uuid"; @@ -68,13 +69,14 @@ export class DeviceSession { const errorOrResponse = await this._connectedDevice.sendApdu(rawApdu); - return errorOrResponse.map((response) => { - this.updateDeviceStatus( - CommandUtils.isLockedDeviceResponse(response) - ? DeviceStatus.LOCKED - : DeviceStatus.CONNECTED, - ); - return response; + return errorOrResponse.chain((response) => { + if (CommandUtils.isLockedDeviceResponse(response)) { + this.updateDeviceStatus(DeviceStatus.LOCKED); + return Left(new Error("Device is locked")); + } else { + this.updateDeviceStatus(DeviceStatus.CONNECTED); + return Right(response); + } }); } diff --git a/packages/core/src/internal/device-session/model/SessionRefresher.ts b/packages/core/src/internal/device-session/model/SessionRefresher.ts index 54d538cec..e70785975 100644 --- a/packages/core/src/internal/device-session/model/SessionRefresher.ts +++ b/packages/core/src/internal/device-session/model/SessionRefresher.ts @@ -1,4 +1,4 @@ -import { audit, interval, Subscription, switchMap, takeWhile } from "rxjs"; +import { audit, interval, skipWhile, Subscription, switchMap } from "rxjs"; import { GetAppAndVersionCommand, @@ -26,29 +26,33 @@ export class SeesionRefresher { this._subscription = this.session.state .pipe( audit(() => interval(this.refreshInterval)), - takeWhile((state) => state.deviceStatus === DeviceStatus.CONNECTED), + skipWhile((state) => state.deviceStatus === DeviceStatus.BUSY), switchMap(() => { const rawApdu = this.command.getApdu().getRawApdu(); - return this.session.connectedDevice.sendApdu(rawApdu); + return this.session.sendApdu(rawApdu); }), ) .subscribe({ next: (response) => { response .ifRight((data) => { - const { name }: GetAppAndVersionResponse = - this.command.parseResponse(data); - if (name === DEVICE_OS_NAME) { - // await this.session.connectedDevice.sendApdu( - // new GetOsVersionCommand().getApdu().getRawApdu(), - // ); - } else { - this.session.setState( - new ReadyWithoutSecureChannelState({ - sessionId: this.session.id, - currentApp: name, - }), - ); + try { + const { name }: GetAppAndVersionResponse = + this.command.parseResponse(data); + if (name === DEVICE_OS_NAME) { + // await this.session.connectedDevice.sendApdu( + // new GetOsVersionCommand().getApdu().getRawApdu(), + // ); + } else { + this.session.setState( + new ReadyWithoutSecureChannelState({ + sessionId: this.session.id, + currentApp: name, + }), + ); + } + } catch (error) { + console.error("Error in response", error); } }) .ifLeft(() => { From fa11c16630f48b5bb906209eda14ef57c5f79a36 Mon Sep 17 00:00:00 2001 From: jz_ Date: Mon, 13 May 2024 17:02:55 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=8E=A8=20(core):=20Feedbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fresh-eels-sip.md | 2 +- .../api/command/os/GetBatteryStatusCommand.ts | 2 +- .../use-case/SendCommandUseCase.test.ts | 7 +- .../api/device-session/DeviceSessionState.ts | 162 +++++++++++------- packages/core/src/api/index.ts | 2 +- .../device-model/model/DeviceModel.ts | 2 +- .../model/DeviceSession.stub.ts | 15 +- .../device-session/model/DeviceSession.ts | 55 +++--- .../model/DeviceSessionRefresher.test.ts | 100 +++++++++++ .../model/DeviceSessionRefresher.ts | 133 ++++++++++++++ .../internal/device-session/model/Errors.ts | 10 ++ .../device-session/model/SessionRefresher.ts | 82 --------- .../service/DefaultApduSenderService.ts | 1 - .../DefaultDeviceSessionService.test.ts | 9 +- .../GetDeviceSessionStateUseCase.test.ts | 6 +- .../discovery/use-case/ConnectUseCase.ts | 7 +- .../use-case/DisconnectUseCase.test.ts | 13 +- .../send/use-case/SendApduUseCase.test.ts | 7 +- .../GetConnectedDeviceUseCase.test.ts | 10 +- 19 files changed, 429 insertions(+), 196 deletions(-) create mode 100644 packages/core/src/internal/device-session/model/DeviceSessionRefresher.test.ts create mode 100644 packages/core/src/internal/device-session/model/DeviceSessionRefresher.ts delete mode 100644 packages/core/src/internal/device-session/model/SessionRefresher.ts diff --git a/.changeset/fresh-eels-sip.md b/.changeset/fresh-eels-sip.md index 72fe29a23..98d83ee0b 100644 --- a/.changeset/fresh-eels-sip.md +++ b/.changeset/fresh-eels-sip.md @@ -2,4 +2,4 @@ "@ledgerhq/device-sdk-core": minor --- -Polling on conncected device to get device status. +Polling on connected device to get device status. diff --git a/packages/core/src/api/command/os/GetBatteryStatusCommand.ts b/packages/core/src/api/command/os/GetBatteryStatusCommand.ts index 71e8b3e17..c9f0462e3 100644 --- a/packages/core/src/api/command/os/GetBatteryStatusCommand.ts +++ b/packages/core/src/api/command/os/GetBatteryStatusCommand.ts @@ -53,7 +53,7 @@ enum FlagMasks { ISSUE_TEMPERATURE = 0x00000020, } -type BatteryStatusFlags = { +export type BatteryStatusFlags = { charging: ChargingMode; issueCharging: boolean; issueTemperature: boolean; diff --git a/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts b/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts index 74c2441f6..713932625 100644 --- a/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts +++ b/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts @@ -7,10 +7,7 @@ import { DeviceSessionService } from "@internal/device-session/service/DeviceSes import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; -import { - SendCommandUseCase, - // SendCommandUseCaseArgs, -} from "./SendCommandUseCase"; +import { SendCommandUseCase } from "./SendCommandUseCase"; let logger: LoggerPublisherService; let sessionService: DeviceSessionService; @@ -32,7 +29,7 @@ describe("SendCommandUseCase", () => { }); it("should send a command to a connected device", async () => { - const deviceSession = deviceSessionStubBuilder(); + const deviceSession = deviceSessionStubBuilder({}, () => logger); sessionService.addDeviceSession(deviceSession); const useCase = new SendCommandUseCase(sessionService, () => logger); diff --git a/packages/core/src/api/device-session/DeviceSessionState.ts b/packages/core/src/api/device-session/DeviceSessionState.ts index 03a807f25..8e67e1c73 100644 --- a/packages/core/src/api/device-session/DeviceSessionState.ts +++ b/packages/core/src/api/device-session/DeviceSessionState.ts @@ -1,81 +1,115 @@ +import { BatteryStatusFlags } from "@api/command/os/GetBatteryStatusCommand"; import { DeviceStatus } from "@api/device/DeviceStatus"; -import { DeviceSessionId } from "@api/device-session/types"; - -export type SessionStateConstructorArgs = { - sessionId: DeviceSessionId; - deviceStatus: DeviceStatus; -}; - -export type ConnectedStateConstructorArgs = Pick< - SessionStateConstructorArgs, - "sessionId" ->; // & {}; - -export type ReadyWithoutSecureChannelStateConstructorArgs = Pick< - ConnectedStateConstructorArgs, - "sessionId" -> & { - batteryStatus?: BatteryStatus; - firmwareVersion?: FirmwareVersion; - currentApp?: string; -}; +/** + * The battery status of a device. + */ export type BatteryStatus = { level: number; + voltage: number; + temperature: number; + current: number; + status: BatteryStatusFlags; }; +/** + * The firmware version of a device. + */ export type FirmwareVersion = { - mcu: string; // Microcontroller Unit version - bootloader: string; // Bootloader version - os: string; // Operating System version -}; + /** + * Microcontroller Unit version + */ + mcu: string; -export class DeviceSessionState { - public readonly sessionId: DeviceSessionId; - public readonly deviceStatus: DeviceStatus; + /** + * Bootloader version + */ + bootloader: string; - constructor({ sessionId, deviceStatus }: SessionStateConstructorArgs) { - this.sessionId = sessionId; - this.deviceStatus = deviceStatus; - } -} + /** + * Operating System version + */ + os: string; +}; -export class ConnectedState extends DeviceSessionState { - // private readonly _deviceName: string; // GetDeviceNameResponse - constructor({ sessionId }: ConnectedStateConstructorArgs) { - super({ sessionId, deviceStatus: DeviceStatus.CONNECTED }); - } +/** + * The state types of a device session. + */ +export enum DeviceSessionStateType { + Connected, + ReadyWithoutSecureChannel, + ReadyWithSecureChannel, } -export class ReadyWithoutSecureChannelState extends ConnectedState { - private readonly _batteryStatus: BatteryStatus | null = null; // GetBatteryStatusResponse - private readonly _firmwareVersion: FirmwareVersion | null = null; // GetOsVersionResponse - private readonly _currentApp: string | null = null; // GetAppVersionResponse - // private readonly _deviceName: string; // GetDeviceNameResponse +type DeviceSessionBaseState = { + readonly sessionStateType: DeviceSessionStateType; - constructor({ - sessionId, - currentApp, - batteryStatus, - firmwareVersion, - }: ReadyWithoutSecureChannelStateConstructorArgs) { - super({ sessionId }); - this._currentApp = currentApp ? currentApp : null; - this._batteryStatus = batteryStatus ? batteryStatus : null; - this._firmwareVersion = firmwareVersion ? firmwareVersion : null; - } + /** + * The status of the device. + */ + deviceStatus: DeviceStatus; + + /** + * The name of the device. + */ + deviceName?: string; +}; - public get batteryStatus() { - return this._batteryStatus; - } +type DeviceSessionReadyState = { + /** + * The battery status of the device. + * TODO: This should not be optional, but it is not in the current implementation. + */ + batteryStatus?: BatteryStatus; - public get firmwareVersion() { - return this._firmwareVersion; - } + /** + * The firmware version of the device. + * TODO: This should not be optional, but it is not in the current implementation. + */ + firmwareVersion?: FirmwareVersion; - public get currentApp() { - return this._currentApp; - } -} + /** + * The current application running on the device. + */ + currentApp: string; +}; + +/** + * The state of a connected device session. + */ +export type ConnectedState = DeviceSessionBaseState & { + /** + * The type of the device session state. + */ + readonly sessionStateType: DeviceSessionStateType.Connected; +}; + +/** + * The state of a device session when it is ready without a secure channel. + */ +export type ReadyWithoutSecureChannelState = DeviceSessionBaseState & + DeviceSessionReadyState & { + /** + * The type of the device session state. + */ + readonly sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel; + }; + +/** + * The state of a device session when it is ready with a secure channel. + */ +export type ReadyWithSecureChannelState = DeviceSessionBaseState & + DeviceSessionReadyState & { + /** + * The type of the device session state. + */ + readonly sessionStateType: DeviceSessionStateType.ReadyWithSecureChannel; + }; -export class ReadyWithSecureChannelState extends ReadyWithoutSecureChannelState {} +/** + * The state of a device session. + */ +export type DeviceSessionState = + | ConnectedState + | ReadyWithoutSecureChannelState + | ReadyWithSecureChannelState; diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 89cd2ca28..1931c8add 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -9,4 +9,4 @@ export { LogLevel } from "./logger-subscriber/model/LogLevel"; export { ConsoleLogger } from "./logger-subscriber/service/ConsoleLogger"; export * from "./types"; export { ConnectedDevice } from "./usb/model/ConnectedDevice"; -export { DeviceSessionState } from "@api/device-session/DeviceSessionState"; +export { type DeviceSessionState } from "@api/device-session/DeviceSessionState"; diff --git a/packages/core/src/internal/device-model/model/DeviceModel.ts b/packages/core/src/internal/device-model/model/DeviceModel.ts index 1b8f6c8b5..96dc07cc0 100644 --- a/packages/core/src/internal/device-model/model/DeviceModel.ts +++ b/packages/core/src/internal/device-model/model/DeviceModel.ts @@ -3,7 +3,7 @@ import semver from "semver"; import { DeviceModelId } from "@api/device/DeviceModel"; /** - * Represents the info of a device model + * The info of a device model */ export class InternalDeviceModel { id: DeviceModelId; diff --git a/packages/core/src/internal/device-session/model/DeviceSession.stub.ts b/packages/core/src/internal/device-session/model/DeviceSession.stub.ts index a4f9dcf76..bc9f111a9 100644 --- a/packages/core/src/internal/device-session/model/DeviceSession.stub.ts +++ b/packages/core/src/internal/device-session/model/DeviceSession.stub.ts @@ -2,13 +2,18 @@ import { DeviceSession, SessionConstructorArgs, } from "@internal/device-session/model/DeviceSession"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; export const deviceSessionStubBuilder = ( props: Partial = {}, + loggerFactory: (tag: string) => LoggerPublisherService, ) => - new DeviceSession({ - connectedDevice: connectedDeviceStubBuilder(), - id: "fakeSessionId", - ...props, - }); + new DeviceSession( + { + connectedDevice: connectedDeviceStubBuilder(), + id: "fakeSessionId", + ...props, + }, + loggerFactory, + ); diff --git a/packages/core/src/internal/device-session/model/DeviceSession.ts b/packages/core/src/internal/device-session/model/DeviceSession.ts index 708310a94..08ed98eda 100644 --- a/packages/core/src/internal/device-session/model/DeviceSession.ts +++ b/packages/core/src/internal/device-session/model/DeviceSession.ts @@ -1,4 +1,4 @@ -import { Left, Right } from "purify-ts"; +import { inject } from "inversify"; import { BehaviorSubject } from "rxjs"; import { v4 as uuidv4 } from "uuid"; @@ -6,11 +6,17 @@ import { Command } from "@api/command/Command"; import { CommandUtils } from "@api/command/utils/CommandUtils"; import { DeviceModelId } from "@api/device/DeviceModel"; import { DeviceStatus } from "@api/device/DeviceStatus"; -import { DeviceSessionState } from "@api/device-session/DeviceSessionState"; +import { + DeviceSessionState, + DeviceSessionStateType, +} from "@api/device-session/DeviceSessionState"; import { DeviceSessionId } from "@api/device-session/types"; -import { SeesionRefresher } from "@internal/device-session/model/SessionRefresher"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice"; +import { DeviceSessionRefresher } from "./DeviceSessionRefresher"; + export type SessionConstructorArgs = { connectedDevice: InternalConnectedDevice; id?: DeviceSessionId; @@ -23,19 +29,29 @@ export class DeviceSession { private readonly _id: DeviceSessionId; private readonly _connectedDevice: InternalConnectedDevice; private readonly _deviceState: BehaviorSubject; - private readonly _refresher: SeesionRefresher; + private readonly _refresher: DeviceSessionRefresher; - constructor({ connectedDevice, id = uuidv4() }: SessionConstructorArgs) { + constructor( + { connectedDevice, id = uuidv4() }: SessionConstructorArgs, + @inject(loggerTypes.LoggerPublisherServiceFactory) + loggerModuleFactory: (tag: string) => LoggerPublisherService, + ) { this._id = id; this._connectedDevice = connectedDevice; - this._deviceState = new BehaviorSubject( - new DeviceSessionState({ - sessionId: this._id, + this._deviceState = new BehaviorSubject({ + sessionStateType: DeviceSessionStateType.Connected, + deviceStatus: DeviceStatus.CONNECTED, + }); + this._refresher = new DeviceSessionRefresher( + { + refreshInterval: 1000, deviceStatus: DeviceStatus.CONNECTED, - }), + sendApduFn: (rawApdu: Uint8Array) => this.sendApdu(rawApdu), + updateStateFn: (state: DeviceSessionState) => + this.setDeviceSessionState(state), + }, + loggerModuleFactory("device-session-refresher"), ); - this._refresher = new SeesionRefresher(this, 1000); - this._refresher.start(); } public get id() { @@ -50,18 +66,17 @@ export class DeviceSession { return this._deviceState.asObservable(); } - public setState(state: DeviceSessionState) { + public setDeviceSessionState(state: DeviceSessionState) { this._deviceState.next(state); } private updateDeviceStatus(deviceStatus: DeviceStatus) { const sessionState = this._deviceState.getValue(); - this._deviceState.next( - new DeviceSessionState({ - ...sessionState, - deviceStatus, - }), - ); + this._refresher.setDeviceStatus(deviceStatus); + this._deviceState.next({ + ...sessionState, + deviceStatus, + }); } async sendApdu(rawApdu: Uint8Array) { @@ -69,13 +84,11 @@ export class DeviceSession { const errorOrResponse = await this._connectedDevice.sendApdu(rawApdu); - return errorOrResponse.chain((response) => { + return errorOrResponse.ifRight((response) => { if (CommandUtils.isLockedDeviceResponse(response)) { this.updateDeviceStatus(DeviceStatus.LOCKED); - return Left(new Error("Device is locked")); } else { this.updateDeviceStatus(DeviceStatus.CONNECTED); - return Right(response); } }); } diff --git a/packages/core/src/internal/device-session/model/DeviceSessionRefresher.test.ts b/packages/core/src/internal/device-session/model/DeviceSessionRefresher.test.ts new file mode 100644 index 000000000..4564e475c --- /dev/null +++ b/packages/core/src/internal/device-session/model/DeviceSessionRefresher.test.ts @@ -0,0 +1,100 @@ +import { Left, Right } from "purify-ts"; + +import { + GetAppAndVersionCommand, + GetAppAndVersionResponse, +} from "@api/command/os/GetAppAndVersionCommand"; +import { ApduResponse, DeviceStatus } from "@api/index"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +import { DeviceSessionRefresher } from "./DeviceSessionRefresher"; + +const mockSendApduFn = jest.fn().mockResolvedValue(Right({} as ApduResponse)); +const mockUpdateStateFn = jest.fn().mockImplementation(() => void 0); + +jest.useFakeTimers(); + +describe("DeviceSessionRefresher", () => { + let deviceSessionRefresher: DeviceSessionRefresher; + let logger: LoggerPublisherService; + + beforeEach(() => { + jest + .spyOn(GetAppAndVersionCommand.prototype, "parseResponse") + .mockReturnValueOnce({ + name: "testAppName", + } as GetAppAndVersionResponse); + logger = new DefaultLoggerPublisherService( + [], + "DeviceSessionRefresherTest", + ); + deviceSessionRefresher = new DeviceSessionRefresher( + { + refreshInterval: 1000, + deviceStatus: DeviceStatus.CONNECTED, + sendApduFn: mockSendApduFn, + updateStateFn: mockUpdateStateFn, + }, + logger, + ); + }); + + afterEach(() => { + deviceSessionRefresher.stop(); + jest.clearAllMocks(); + }); + + it("should poll by calling sendApduFn", () => { + jest.advanceTimersByTime(1000); + expect(mockSendApduFn).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1000); + expect(mockSendApduFn).toHaveBeenCalledTimes(2); + }); + + it("should not poll when device is busy", () => { + deviceSessionRefresher.setDeviceStatus(DeviceStatus.BUSY); + + jest.advanceTimersByTime(1000); + + expect(mockSendApduFn).not.toHaveBeenCalled(); + }); + + it("should not poll when device is disconnected", () => { + deviceSessionRefresher.setDeviceStatus(DeviceStatus.NOT_CONNECTED); + + jest.advanceTimersByTime(1000); + + expect(mockSendApduFn).not.toHaveBeenCalled(); + }); + + it("should update device session state by calling updateStateFn", async () => { + jest.advanceTimersByTime(1000); + + expect(await mockSendApduFn()).toEqual(Right({})); + expect(mockUpdateStateFn).toHaveBeenCalled(); + }); + + it("should not update device session state with failed polling response", async () => { + mockSendApduFn.mockResolvedValueOnce(Left("error")); + const spy = jest.spyOn(logger, "error"); + + jest.advanceTimersByTime(1000); + await mockSendApduFn(); + + expect(mockUpdateStateFn).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); + }); + + it("should stop the refresher when device is disconnected", () => { + const spy = jest.spyOn(deviceSessionRefresher, "stop"); + deviceSessionRefresher.setDeviceStatus(DeviceStatus.NOT_CONNECTED); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("should not throw error if stop is called on a stopped refresher", () => { + deviceSessionRefresher.stop(); + expect(() => deviceSessionRefresher.stop()).not.toThrow(); + }); +}); diff --git a/packages/core/src/internal/device-session/model/DeviceSessionRefresher.ts b/packages/core/src/internal/device-session/model/DeviceSessionRefresher.ts new file mode 100644 index 000000000..58147a971 --- /dev/null +++ b/packages/core/src/internal/device-session/model/DeviceSessionRefresher.ts @@ -0,0 +1,133 @@ +import { injectable } from "inversify"; +import { Either } from "purify-ts"; +import { filter, interval, map, Subscription, switchMap } from "rxjs"; + +import { + GetAppAndVersionCommand, + GetAppAndVersionResponse, +} from "@api/command/os/GetAppAndVersionCommand"; +import { + DeviceSessionState, + DeviceSessionStateType, +} from "@api/device-session/DeviceSessionState"; +import { SdkError } from "@api/Error"; +import { ApduResponse, DeviceStatus } from "@api/index"; +import { type LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +import { DeviceSessionRefresherError } from "./Errors"; + +/** + * The arguments for the DeviceSessionRefresher. + */ +export type DeviceSessionRefresherArgs = { + /** + * The refresh interval in milliseconds. + */ + refreshInterval: number; + + /** + * The current device status when the refresher is created. + */ + deviceStatus: Exclude; + + /** + * The function used to send APDU commands to the device. + */ + sendApduFn: (rawApdu: Uint8Array) => Promise>; + + /** + * Callback that updates the state of the device session with + * polling response. + * @param state - The new state to update to. + */ + updateStateFn(state: DeviceSessionState): void; +}; + +/** + * The session refresher that periodically sends a command to refresh the session. + */ +@injectable() +export class DeviceSessionRefresher { + private readonly _logger: LoggerPublisherService; + private readonly _getAppAndVersionCommand = new GetAppAndVersionCommand(); + private _deviceStatus: DeviceStatus; + private _subscription: Subscription; + + constructor( + { + refreshInterval, + deviceStatus, + sendApduFn, + updateStateFn, + }: DeviceSessionRefresherArgs, + logger: LoggerPublisherService, + ) { + this._deviceStatus = deviceStatus; + this._logger = logger; + this._subscription = interval(refreshInterval) + .pipe( + filter( + () => + ![DeviceStatus.BUSY, DeviceStatus.NOT_CONNECTED].includes( + this._deviceStatus, + ), + ), + switchMap(() => { + const rawApdu = this._getAppAndVersionCommand.getApdu().getRawApdu(); + return sendApduFn(rawApdu); + }), + map((resp) => + resp.caseOf({ + Left: (error) => { + this._logger.error("Error in sending APDU when polling", { + data: { error }, + }); + throw error; + }, + Right: (data: ApduResponse) => { + return this._getAppAndVersionCommand.parseResponse(data); + }, + }), + ), + ) + .subscribe({ + next: (parsedResponse: GetAppAndVersionResponse) => { + // batteryStatus and firmwareVersion are not available in the polling response. + updateStateFn({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: this._deviceStatus, + currentApp: parsedResponse.name, + }); + }, + error: (error: Error) => { + const pollingError = new DeviceSessionRefresherError(error); + this._logger.error("Error in updating state when polling", { + data: { error: pollingError }, + }); + }, + }); + } + + /** + * Maintain a device status to prevent sending APDU when the device is busy. + * + * @param {DeviceStatus} deviceStatus - The new device status. + */ + setDeviceStatus(deviceStatus: DeviceStatus) { + if (deviceStatus === DeviceStatus.NOT_CONNECTED) { + this.stop(); + } + this._deviceStatus = deviceStatus; + } + + /** + * Stops the session refresher. + * The refresher will no longer send commands to refresh the session. + */ + stop() { + if (!this._subscription || this._subscription.closed) { + return; + } + this._subscription.unsubscribe(); + } +} diff --git a/packages/core/src/internal/device-session/model/Errors.ts b/packages/core/src/internal/device-session/model/Errors.ts index 835ac143c..651ec412d 100644 --- a/packages/core/src/internal/device-session/model/Errors.ts +++ b/packages/core/src/internal/device-session/model/Errors.ts @@ -36,3 +36,13 @@ export class DeviceSessionNotFound implements SdkError { this.originalError = originalError ?? new Error("Device session not found"); } } + +export class DeviceSessionRefresherError implements SdkError { + readonly _tag = "DeviceSessionRefresherError"; + originalError?: Error; + + constructor(originalError?: Error) { + this.originalError = + originalError ?? new Error("Device session refresher error"); + } +} diff --git a/packages/core/src/internal/device-session/model/SessionRefresher.ts b/packages/core/src/internal/device-session/model/SessionRefresher.ts deleted file mode 100644 index e70785975..000000000 --- a/packages/core/src/internal/device-session/model/SessionRefresher.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { audit, interval, skipWhile, Subscription, switchMap } from "rxjs"; - -import { - GetAppAndVersionCommand, - GetAppAndVersionResponse, -} from "@api/command/os/GetAppAndVersionCommand"; -import { DeviceStatus } from "@api/device/DeviceStatus"; -import { ReadyWithoutSecureChannelState } from "@api/session/SessionDeviceState"; - -import { Session } from "./Session"; - -export const DEVICE_OS_NAME = "BOLOS"; - -export class SeesionRefresher { - private _subscription: Subscription | null = null; - private readonly command: GetAppAndVersionCommand; - - constructor( - private readonly session: Session, - private readonly refreshInterval: number, - ) { - this.command = new GetAppAndVersionCommand(); - } - - start(): void { - this._subscription = this.session.state - .pipe( - audit(() => interval(this.refreshInterval)), - skipWhile((state) => state.deviceStatus === DeviceStatus.BUSY), - switchMap(() => { - const rawApdu = this.command.getApdu().getRawApdu(); - return this.session.sendApdu(rawApdu); - }), - ) - .subscribe({ - next: (response) => { - response - .ifRight((data) => { - try { - const { name }: GetAppAndVersionResponse = - this.command.parseResponse(data); - if (name === DEVICE_OS_NAME) { - // await this.session.connectedDevice.sendApdu( - // new GetOsVersionCommand().getApdu().getRawApdu(), - // ); - } else { - this.session.setState( - new ReadyWithoutSecureChannelState({ - sessionId: this.session.id, - currentApp: name, - }), - ); - } - } catch (error) { - console.error("Error in response", error); - } - }) - .ifLeft(() => { - console.log("Error in response"); - }); - }, - error: (error) => { - this.restart(); - console.error("Error", error); - }, - complete: () => { - console.log("Complete"); - }, - }); - } - - stop(): void { - if (this._subscription) { - this._subscription.unsubscribe(); - } - } - - restart(): void { - this.stop(); - this.start(); - } -} diff --git a/packages/core/src/internal/device-session/service/DefaultApduSenderService.ts b/packages/core/src/internal/device-session/service/DefaultApduSenderService.ts index 959fd55cc..b9e3a68b8 100644 --- a/packages/core/src/internal/device-session/service/DefaultApduSenderService.ts +++ b/packages/core/src/internal/device-session/service/DefaultApduSenderService.ts @@ -54,7 +54,6 @@ export class DefaultApduSenderService implements ApduSenderService { channel = Maybe.zero(), padding = false, }: DefaultApduSenderServiceConstructorArgs, - @inject(loggerTypes.LoggerPublisherServiceFactory) loggerServiceFactory: (tag: string) => LoggerPublisherService, ) { diff --git a/packages/core/src/internal/device-session/service/DefaultDeviceSessionService.test.ts b/packages/core/src/internal/device-session/service/DefaultDeviceSessionService.test.ts index 72e00258b..d3b1b380d 100644 --- a/packages/core/src/internal/device-session/service/DefaultDeviceSessionService.test.ts +++ b/packages/core/src/internal/device-session/service/DefaultDeviceSessionService.test.ts @@ -15,11 +15,14 @@ let deviceSession: DeviceSession; describe("DefaultDeviceSessionService", () => { beforeEach(() => { jest.restoreAllMocks(); - deviceSession = new DeviceSession({ - connectedDevice: connectedDeviceStubBuilder(), - }); loggerService = new DefaultLoggerPublisherService([], "deviceSession"); sessionService = new DefaultDeviceSessionService(() => loggerService); + deviceSession = new DeviceSession( + { + connectedDevice: connectedDeviceStubBuilder(), + }, + () => loggerService, + ); }); it("should have an empty sessions list", () => { diff --git a/packages/core/src/internal/device-session/use-case/GetDeviceSessionStateUseCase.test.ts b/packages/core/src/internal/device-session/use-case/GetDeviceSessionStateUseCase.test.ts index 54cb73aea..bbaa1423e 100644 --- a/packages/core/src/internal/device-session/use-case/GetDeviceSessionStateUseCase.test.ts +++ b/packages/core/src/internal/device-session/use-case/GetDeviceSessionStateUseCase.test.ts @@ -19,9 +19,13 @@ describe("GetDeviceSessionStateUseCase", () => { ); sessionService = new DefaultDeviceSessionService(() => logger); }); + it("should retrieve deviceSession device state", () => { // given - const deviceSession = deviceSessionStubBuilder({ id: fakeSessionId }); + const deviceSession = deviceSessionStubBuilder( + { id: fakeSessionId }, + () => logger, + ); sessionService.addDeviceSession(deviceSession); const useCase = new GetDeviceSessionStateUseCase( sessionService, diff --git a/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts b/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts index 21bb70438..6f7a0d175 100644 --- a/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts +++ b/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts @@ -25,6 +25,7 @@ export type ConnectUseCaseArgs = { export class ConnectUseCase { private readonly _usbHidTransport: UsbHidTransport; private readonly _sessionService: DeviceSessionService; + private readonly _loggerFactory: (tag: string) => LoggerPublisherService; private readonly _logger: LoggerPublisherService; constructor( @@ -37,6 +38,7 @@ export class ConnectUseCase { ) { this._sessionService = sessionService; this._usbHidTransport = usbHidTransport; + this._loggerFactory = loggerFactory; this._logger = loggerFactory("ConnectUseCase"); } @@ -62,7 +64,10 @@ export class ConnectUseCase { throw error; }, Right: (connectedDevice) => { - const deviceSession = new DeviceSession({ connectedDevice }); + const deviceSession = new DeviceSession( + { connectedDevice }, + this._loggerFactory, + ); this._sessionService.addDeviceSession(deviceSession); return deviceSession.id; }, diff --git a/packages/core/src/internal/discovery/use-case/DisconnectUseCase.test.ts b/packages/core/src/internal/discovery/use-case/DisconnectUseCase.test.ts index 6b7c1ccad..42e3aceda 100644 --- a/packages/core/src/internal/discovery/use-case/DisconnectUseCase.test.ts +++ b/packages/core/src/internal/discovery/use-case/DisconnectUseCase.test.ts @@ -35,10 +35,13 @@ describe("DisconnectUseCase", () => { it("should disconnect from a device", async () => { // Given const connectedDevice = connectedDeviceStubBuilder(); - const deviceSession = deviceSessionStubBuilder({ - id: sessionId, - connectedDevice, - }); + const deviceSession = deviceSessionStubBuilder( + { + id: sessionId, + connectedDevice, + }, + loggerFactory, + ); jest .spyOn(sessionService, "getDeviceSessionById") .mockImplementation(() => Right(deviceSession)); @@ -84,7 +87,7 @@ describe("DisconnectUseCase", () => { jest .spyOn(sessionService, "getDeviceSessionById") .mockImplementation(() => - Right(deviceSessionStubBuilder({ id: sessionId })), + Right(deviceSessionStubBuilder({ id: sessionId }, loggerFactory)), ); jest .spyOn(usbHidTransport, "disconnect") diff --git a/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts b/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts index cc194127f..b819fb50e 100644 --- a/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts +++ b/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts @@ -24,7 +24,7 @@ describe("SendApduUseCase", () => { it("should send an APDU to a connected device", async () => { // given - const deviceSession = deviceSessionStubBuilder(); + const deviceSession = deviceSessionStubBuilder({}, () => logger); sessionService.addDeviceSession(deviceSession); const useCase = new SendApduUseCase(sessionService, () => logger); @@ -60,7 +60,10 @@ describe("SendApduUseCase", () => { Promise.resolve(Left(new ReceiverApduError())), ), }); - const deviceSession = deviceSessionStubBuilder({ connectedDevice }); + const deviceSession = deviceSessionStubBuilder( + { connectedDevice }, + () => logger, + ); sessionService.addDeviceSession(deviceSession); const useCase = new SendApduUseCase(sessionService, () => logger); diff --git a/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts b/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts index 776843d76..939d3bf85 100644 --- a/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts +++ b/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts @@ -22,7 +22,10 @@ describe("GetConnectedDevice", () => { it("should retrieve an instance of ConnectedDevice", () => { // given - const deviceSession = deviceSessionStubBuilder({ id: fakeSessionId }); + const deviceSession = deviceSessionStubBuilder( + { id: fakeSessionId }, + () => logger, + ); sessionService.addDeviceSession(deviceSession); const useCase = new GetConnectedDeviceUseCase(sessionService, () => logger); @@ -37,7 +40,10 @@ describe("GetConnectedDevice", () => { it("should retrieve correct device from session", () => { // given - const deviceSession = deviceSessionStubBuilder({ id: fakeSessionId }); + const deviceSession = deviceSessionStubBuilder( + { id: fakeSessionId }, + () => logger, + ); sessionService.addDeviceSession(deviceSession); const useCase = new GetConnectedDeviceUseCase(sessionService, () => logger);