From 5deb5a82779057162c37c1692319c887da72bb55 Mon Sep 17 00:00:00 2001 From: jz_ Date: Mon, 29 Apr 2024 15:28:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20GetBatteryStatu?= =?UTF-8?q?s=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/gold-chairs-divide.md | 5 + packages/core/src/api/DeviceSdk.ts | 2 +- .../core/src/api/apdu/utils/ApduParser.ts | 8 +- packages/core/src/api/command/Command.ts | 9 +- .../os/GetBatteryStatusCommand.test.ts | 123 ++++++++++++++++++ .../api/command/os/GetBatteryStatusCommand.ts | 121 +++++++++++++++++ .../command/os/GetOsVersionCommand.test.ts | 2 +- .../src/api/command/os/GetOsVersionCommand.ts | 4 +- .../use-case/SendCommandUseCase.test.ts | 8 +- .../command/use-case/SendCommandUseCase.ts | 12 +- .../internal/device-session/model/Session.ts | 8 +- 11 files changed, 277 insertions(+), 25 deletions(-) create mode 100644 .changeset/gold-chairs-divide.md create mode 100644 packages/core/src/api/command/os/GetBatteryStatusCommand.test.ts create mode 100644 packages/core/src/api/command/os/GetBatteryStatusCommand.ts diff --git a/.changeset/gold-chairs-divide.md b/.changeset/gold-chairs-divide.md new file mode 100644 index 000000000..47384c41a --- /dev/null +++ b/.changeset/gold-chairs-divide.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-sdk-core": minor +--- + +Add `GetBatteryStatus` command. diff --git a/packages/core/src/api/DeviceSdk.ts b/packages/core/src/api/DeviceSdk.ts index a54d0a5b0..8cd4fe02a 100644 --- a/packages/core/src/api/DeviceSdk.ts +++ b/packages/core/src/api/DeviceSdk.ts @@ -84,7 +84,7 @@ export class DeviceSdk { .execute(args); } - sendCommand(args: SendCommandUseCaseArgs): Promise { + sendCommand(args: SendCommandUseCaseArgs): Promise { return this.container .get(commandTypes.SendCommandUseCase) .execute(args); diff --git a/packages/core/src/api/apdu/utils/ApduParser.ts b/packages/core/src/api/apdu/utils/ApduParser.ts index 88e24032d..7c5cb2236 100644 --- a/packages/core/src/api/apdu/utils/ApduParser.ts +++ b/packages/core/src/api/apdu/utils/ApduParser.ts @@ -43,9 +43,9 @@ export class ApduParser { extract16BitUInt(): number | undefined { if (this._outOfRange(2)) return; let msb = this.extract8BitUint(); - if (!msb) return; + if (msb === undefined) return; const lsb = this.extract8BitUint(); - if (!lsb) return; + if (lsb === undefined) return; msb *= 0x100; return msb + lsb; } @@ -57,9 +57,9 @@ export class ApduParser { extract32BitUInt(): number | undefined { if (this._outOfRange(4)) return; let msw = this.extract16BitUInt(); - if (!msw) return; + if (msw === undefined) return; const lsw = this.extract16BitUInt(); - if (!lsw) return; + if (lsw === undefined) return; msw *= 0x10000; return msw + lsw; } diff --git a/packages/core/src/api/command/Command.ts b/packages/core/src/api/command/Command.ts index d46509147..8d9795fde 100644 --- a/packages/core/src/api/command/Command.ts +++ b/packages/core/src/api/command/Command.ts @@ -2,7 +2,10 @@ import { Apdu } from "@api/apdu/model/Apdu"; import { DeviceModelId } from "@api/device/DeviceModel"; import { ApduResponse } from "@internal/device-session/model/ApduResponse"; -export interface Command { - getApdu(params?: Params): Apdu; - parseResponse(apduResponse: ApduResponse, deviceModelId?: DeviceModelId): T; +export interface Command { + getApdu(args?: U): Apdu; + parseResponse( + apduResponse: ApduResponse, + deviceModelId: DeviceModelId | void, + ): T; } diff --git a/packages/core/src/api/command/os/GetBatteryStatusCommand.test.ts b/packages/core/src/api/command/os/GetBatteryStatusCommand.test.ts new file mode 100644 index 000000000..843b35d01 --- /dev/null +++ b/packages/core/src/api/command/os/GetBatteryStatusCommand.test.ts @@ -0,0 +1,123 @@ +import { Command } from "@api/command/Command"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; + +import { + BatteryStatusType, + ChargingMode, + GetBatteryStatusCommand, + GetBatteryStatusResponse, +} from "./GetBatteryStatusCommand"; + +const GET_BATTERY_STATUS_APDU_PERCENTAGE = Uint8Array.from([ + 0xe0, 0x10, 0x00, 0x00, 0x00, +]); +const GET_BATTERY_STATUS_APDU_VOLTAGE = Uint8Array.from([ + 0xe0, 0x10, 0x00, 0x01, 0x00, +]); +const GET_BATTERY_STATUS_APDU_TEMPERATURE = Uint8Array.from([ + 0xe0, 0x10, 0x00, 0x02, 0x00, +]); +const GET_BATTERY_STATUS_APDU_CURRENT = Uint8Array.from([ + 0xe0, 0x10, 0x00, 0x03, 0x00, +]); +const GET_BATTERY_STATUS_APDU_FLAGS = Uint8Array.from([ + 0xe0, 0x10, 0x00, 0x04, 0x00, +]); + +const PERCENTAGE_RESPONSE_HEX = Uint8Array.from([0x37, 0x90, 0x00]); +const VOLTAGE_RESPONSE_HEX = Uint8Array.from([0x0f, 0xff, 0x90, 0x00]); +const TEMPERATURE_RESPONSE_HEX = Uint8Array.from([0x10, 0x90, 0x00]); +const FLAGS_RESPONSE_HEX = Uint8Array.from([ + 0x00, 0x00, 0x00, 0x0f, 0x90, 0x00, +]); +const FAILED_RESPONSE_HEX = Uint8Array.from([0x67, 0x00]); + +describe("GetBatteryStatus", () => { + let command: Command; + + beforeEach(() => { + command = new GetBatteryStatusCommand(); + }); + + describe("getApdu", () => { + it("should return the GetBatteryStatus APUD", () => { + expect( + command.getApdu(BatteryStatusType.BATTERY_PERCENTAGE).getRawApdu(), + ).toStrictEqual(GET_BATTERY_STATUS_APDU_PERCENTAGE); + expect( + command.getApdu(BatteryStatusType.BATTERY_VOLTAGE).getRawApdu(), + ).toStrictEqual(GET_BATTERY_STATUS_APDU_VOLTAGE); + expect( + command.getApdu(BatteryStatusType.BATTERY_TEMPERATURE).getRawApdu(), + ).toStrictEqual(GET_BATTERY_STATUS_APDU_TEMPERATURE); + expect( + command.getApdu(BatteryStatusType.BATTERY_CURRENT).getRawApdu(), + ).toStrictEqual(GET_BATTERY_STATUS_APDU_CURRENT); + expect( + command.getApdu(BatteryStatusType.BATTERY_FLAGS).getRawApdu(), + ).toStrictEqual(GET_BATTERY_STATUS_APDU_FLAGS); + }); + }); + describe("parseResponse", () => { + it("should parse the response when querying percentage", () => { + const PERCENTAGE_RESPONSE = new ApduResponse({ + statusCode: PERCENTAGE_RESPONSE_HEX.slice(-2), + data: PERCENTAGE_RESPONSE_HEX.slice(0, -2), + }); + command.getApdu(BatteryStatusType.BATTERY_PERCENTAGE); + const parsed = command.parseResponse(PERCENTAGE_RESPONSE); + expect(parsed).toStrictEqual(55); + }); + it("should parse the response when querying voltage", () => { + const VOLTAGE_RESPONSE = new ApduResponse({ + statusCode: VOLTAGE_RESPONSE_HEX.slice(-2), + data: VOLTAGE_RESPONSE_HEX.slice(0, -2), + }); + command.getApdu(BatteryStatusType.BATTERY_VOLTAGE); + const parsed = command.parseResponse(VOLTAGE_RESPONSE); + expect(parsed).toStrictEqual(4095); + }); + it("should parse the response when querying temperature", () => { + const TEMPERATURE_RESPONSE = new ApduResponse({ + statusCode: TEMPERATURE_RESPONSE_HEX.slice(-2), + data: TEMPERATURE_RESPONSE_HEX.slice(0, -2), + }); + command.getApdu(BatteryStatusType.BATTERY_TEMPERATURE); + const parsed = command.parseResponse(TEMPERATURE_RESPONSE); + expect(parsed).toStrictEqual(16); + }); + it("should parse the response when querying flags", () => { + const FLAGS_RESPONSE = new ApduResponse({ + statusCode: FLAGS_RESPONSE_HEX.slice(-2), + data: FLAGS_RESPONSE_HEX.slice(0, -2), + }); + command.getApdu(BatteryStatusType.BATTERY_FLAGS); + const parsed = command.parseResponse(FLAGS_RESPONSE); + expect(parsed).toStrictEqual({ + charging: ChargingMode.USB, + issueCharging: false, + issueTemperature: false, + issueBattery: false, + }); + }); + it("should not parse the response when getApdu not called", () => { + const PERCENTAGE_RESPONSE = new ApduResponse({ + statusCode: PERCENTAGE_RESPONSE_HEX.slice(-2), + data: PERCENTAGE_RESPONSE_HEX.slice(0, -2), + }); + expect(() => command.parseResponse(PERCENTAGE_RESPONSE)).toThrow( + "Call getApdu to initialise battery status type.", + ); + }); + it("should throw an error if the response returned unsupported format", () => { + const FAILED_RESPONSE = new ApduResponse({ + statusCode: FAILED_RESPONSE_HEX.slice(-2), + data: FAILED_RESPONSE_HEX.slice(0, -2), + }); + command.getApdu(BatteryStatusType.BATTERY_PERCENTAGE); + expect(() => command.parseResponse(FAILED_RESPONSE)).toThrow( + "Unexpected status word: 6700", + ); + }); + }); +}); diff --git a/packages/core/src/api/command/os/GetBatteryStatusCommand.ts b/packages/core/src/api/command/os/GetBatteryStatusCommand.ts new file mode 100644 index 000000000..38d1ad396 --- /dev/null +++ b/packages/core/src/api/command/os/GetBatteryStatusCommand.ts @@ -0,0 +1,121 @@ +import { Apdu } from "@api/apdu/model/Apdu"; +import { ApduBuilder, ApduBuilderArgs } from "@api/apdu/utils/ApduBuilder"; +import { ApduParser } from "@api/apdu/utils/ApduParser"; +import { Command } from "@api/command/Command"; +import { CommandUtils } from "@api/command/utils/CommandUtils"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; + +export enum BatteryStatusType { + BATTERY_PERCENTAGE = 0x00, + BATTERY_VOLTAGE = 0x01, + BATTERY_TEMPERATURE = 0x02, + BATTERY_CURRENT = 0x03, + BATTERY_FLAGS = 0x04, +} + +export enum ChargingMode { + NONE = 0x00, + USB = 0x01, + QI = 0x02, +} + +enum FlagMasks { + CHARGING = 0x00000001, + USB = 0x00000002, + USB_POWERED = 0x00000008, + BLE = 0x00000004, + ISSUE_BATTERY = 0x00000080, + ISSUE_CHARGING = 0x00000010, + ISSUE_TEMPERATURE = 0x00000020, +} + +type BatteryStatusFlags = { + charging: ChargingMode; + issueCharging: boolean; + issueTemperature: boolean; + issueBattery: boolean; +}; + +export type GetBatteryStatusResponse = number | BatteryStatusFlags; + +export class GetBatteryStatusCommand + implements Command +{ + private _statusType: BatteryStatusType | undefined = undefined; + + getApdu(statusType: BatteryStatusType): Apdu { + this._statusType = statusType; + const getBatteryStatusArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x10, + p1: 0x00, + p2: statusType, + } as const; + return new ApduBuilder(getBatteryStatusArgs).build(); + } + + parseResponse(apduResponse: ApduResponse): GetBatteryStatusResponse { + if (this._statusType === undefined) { + throw new Error("Call getApdu to initialise battery status type."); + } + + const parser = new ApduParser(apduResponse); + // [SHOULD] Implement new error treatment logic + if (!CommandUtils.isSuccessResponse(apduResponse)) { + throw new Error( + `Unexpected status word: ${parser.encodeToHexaString( + apduResponse.statusCode, + )}`, + ); + } + + switch (this._statusType) { + case BatteryStatusType.BATTERY_PERCENTAGE: { + const percentage = parser.extract8BitUint(); + if (!percentage) { + throw new Error("Cannot parse APDU response"); + } + return percentage > 100 ? -1 : percentage; + } + case BatteryStatusType.BATTERY_VOLTAGE: { + const data = parser.extract16BitUInt(); + if (!data) { + throw new Error("Cannot parse APDU response"); + } + return data; + } + case BatteryStatusType.BATTERY_TEMPERATURE: + case BatteryStatusType.BATTERY_CURRENT: { + const data = parser.extract8BitUint(); + if (!data) { + throw new Error("Cannot parse APDU response"); + } + return (data << 24) >> 24; + } + case BatteryStatusType.BATTERY_FLAGS: { + const flags = parser.extract32BitUInt(); + if (!flags) { + throw new Error("Cannot parse APDU response"); + } + const chargingUSB = !!(flags & FlagMasks.USB_POWERED); + const chargingQi = !chargingUSB && !!(flags & FlagMasks.CHARGING); + return { + charging: chargingQi + ? ChargingMode.QI + : chargingUSB + ? ChargingMode.USB + : ChargingMode.NONE, + issueCharging: !!(flags & FlagMasks.ISSUE_CHARGING), + issueTemperature: !!(flags & FlagMasks.ISSUE_TEMPERATURE), + issueBattery: !!(flags & FlagMasks.ISSUE_BATTERY), + }; + } + default: + this._exhaustiveMatchingGuard(this._statusType); + } + } + + private _exhaustiveMatchingGuard(_: never): never { + throw new Error("One or some case(s) not covered"); + } +} diff --git a/packages/core/src/api/command/os/GetOsVersionCommand.test.ts b/packages/core/src/api/command/os/GetOsVersionCommand.test.ts index d954e133f..8cc2e0713 100644 --- a/packages/core/src/api/command/os/GetOsVersionCommand.test.ts +++ b/packages/core/src/api/command/os/GetOsVersionCommand.test.ts @@ -40,7 +40,7 @@ const STAX_RESPONSE_GOOD = new ApduResponse({ }); describe("GetOsVersionCommand", () => { - let command: Command; + let command: Command; beforeEach(() => { command = new GetOsVersionCommand(); diff --git a/packages/core/src/api/command/os/GetOsVersionCommand.ts b/packages/core/src/api/command/os/GetOsVersionCommand.ts index 9567b4612..2fb15c6d9 100644 --- a/packages/core/src/api/command/os/GetOsVersionCommand.ts +++ b/packages/core/src/api/command/os/GetOsVersionCommand.ts @@ -17,9 +17,7 @@ export type GetOsVersionResponse = { recoverState: string; }; -export class GetOsVersionCommand - implements Command -{ +export class GetOsVersionCommand implements Command { getApdu = (): Apdu => new ApduBuilder({ cla: 0xe0, 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 8ee94dbaf..470c5face 100644 --- a/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts +++ b/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts @@ -15,7 +15,7 @@ import { let logger: LoggerPublisherService; let sessionService: SessionService; const fakeSessionId = "fakeSessionId"; -let command: Command; +let command: Command<{ status: string }>; describe("SendCommandUseCase", () => { beforeEach(() => { @@ -40,9 +40,10 @@ describe("SendCommandUseCase", () => { .spyOn(session, "getCommand") .mockReturnValue(async () => Promise.resolve({ status: "success" })); - const response = await useCase.execute({ + const response = await useCase.execute<{ status: string }>({ sessionId: fakeSessionId, command, + params: undefined, }); expect(response).toStrictEqual({ status: "success" }); @@ -54,9 +55,10 @@ describe("SendCommandUseCase", () => { .spyOn(sessionService, "getSessionById") .mockReturnValue(Left({ _tag: "DeviceSessionNotFound" })); - const res = useCase.execute({ + const res = useCase.execute<{ status: string }>({ sessionId: fakeSessionId, command, + params: undefined, }); await expect(res).rejects.toMatchObject({ _tag: "DeviceSessionNotFound" }); diff --git a/packages/core/src/api/command/use-case/SendCommandUseCase.ts b/packages/core/src/api/command/use-case/SendCommandUseCase.ts index 686424350..f9ff13c43 100644 --- a/packages/core/src/api/command/use-case/SendCommandUseCase.ts +++ b/packages/core/src/api/command/use-case/SendCommandUseCase.ts @@ -6,10 +6,10 @@ import type { SessionService } from "@internal/device-session/service/SessionSer import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; -export type SendCommandUseCaseArgs = { +export type SendCommandUseCaseArgs = { sessionId: string; - command: Command; - params?: Params; + command: Command; + params: U; }; /** @@ -28,18 +28,18 @@ export class SendCommandUseCase { this._logger = loggerFactory("SendCommandUseCase"); } - async execute({ + async execute({ sessionId, command, params, - }: SendCommandUseCaseArgs): Promise { + }: SendCommandUseCaseArgs): Promise { const deviceSession = this._sessionService.getSessionById(sessionId); return deviceSession.caseOf({ // Case device session found Right: async (session) => { const deviceModelId = session.connectedDevice.deviceModel.id; - const action = session.getCommand(command); + const action = session.getCommand(command); const response = await action(deviceModelId, params); return response; }, diff --git a/packages/core/src/internal/device-session/model/Session.ts b/packages/core/src/internal/device-session/model/Session.ts index bb66fe95f..6ffc16287 100644 --- a/packages/core/src/internal/device-session/model/Session.ts +++ b/packages/core/src/internal/device-session/model/Session.ts @@ -70,16 +70,16 @@ export class Session { }); } - getCommand(command: Command) { - return async (deviceModel: DeviceModelId, params?: Params): Promise => { - const apdu = command.getApdu(params); + getCommand(command: Command) { + return async (deviceModelId: DeviceModelId, getApduArgs: U): Promise => { + const apdu = command.getApdu(getApduArgs); const response = await this.sendApdu(apdu.getRawApdu()); return response.caseOf({ Left: (err) => { throw err; }, - Right: (r) => command.parseResponse(r, deviceModel), + Right: (r) => command.parseResponse(r, deviceModelId), }); }; } From b87c57ca564d4da8d85b95c9e4c99497ca346cd1 Mon Sep 17 00:00:00 2001 From: jz_ Date: Fri, 3 May 2024 10:36:54 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=80=20(core):=20Rebase=20on=20deve?= =?UTF-8?q?lop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/api/command/os/GetAppAndVersionCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/api/command/os/GetAppAndVersionCommand.ts b/packages/core/src/api/command/os/GetAppAndVersionCommand.ts index 39c949819..312e23f16 100644 --- a/packages/core/src/api/command/os/GetAppAndVersionCommand.ts +++ b/packages/core/src/api/command/os/GetAppAndVersionCommand.ts @@ -12,7 +12,7 @@ export type GetAppAndVersionResponse = { }; export class GetAppAndVersionCommand - implements Command + implements Command { getApdu(): Apdu { const getAppAndVersionApduArgs: ApduBuilderArgs = {