Skip to Content
📢 Breaking change: Applications using LedgerJS for transport implementation should migrate to the Device Management Kit (DMK). Learn more.
DocumentationDevice interactionMobile Native DMKHow to...Build a Custom Command

Build a Custom Command

You can build your own command by implementing the Command<T> interface and providing an APDU payload and a response parser.

You can then execute it with dmk.executeCommand(), exactly like any pre-defined command. This is strongly recommended over direct usage of sendApdu.

The Command Interface

public interface Command<out T> { val apduPayload: ApduPayload fun parseResponse(connectedDevice: ConnectedDevice, response: ByteArray): DeviceOperationResult<T> fun checkPreconditions(): DeviceOperationResult.Failure? }
MemberDescription
apduPayloadThe APDU to send to the device
parseResponseParses the raw response bytes into a typed result
checkPreconditionsValidates inputs before sending — return null if valid

Quick Example

Here is a complete custom command that retrieves a hypothetical device serial number:

import com.ledger.devicemanagement.api.DeviceOperationFailureReason import com.ledger.devicemanagement.api.DeviceOperationResult import com.ledger.devicemanagement.api.apdu.ApduParser import com.ledger.devicemanagement.api.apdu.ApduPayload import com.ledger.devicemanagement.api.apdu.apdu import com.ledger.devicemanagement.api.apdu.uniqueApduPayload import com.ledger.devicemanagement.api.command.Command import com.ledger.devicemanagement.api.connection.ConnectedDevice data class SerialNumber(val value: String) class GetSerialNumberCommand : Command<SerialNumber> { override val apduPayload: ApduPayload = uniqueApduPayload( apdu = apdu { classInstruction = 0xE0.toByte() instructionMethod = 0x02.toByte() parameter1 = 0x00.toByte() parameter2 = 0x00.toByte() }, ) override fun parseResponse( connectedDevice: ConnectedDevice, response: ByteArray, ): DeviceOperationResult<SerialNumber> { if (response.size < 2) { return DeviceOperationResult.Failure(DeviceOperationFailureReason.Unknown) } val parser = ApduParser(response) val length = parser.extract1BytesValue().toInt() val serial = parser.extractValueString(nbrBytes = length) return DeviceOperationResult.Success(SerialNumber(serial)) } override fun checkPreconditions(): DeviceOperationResult.Failure? = null }

Usage

Once your command is implemented, execute it with dmk.executeCommand():

val command = GetSerialNumberCommand() val result: DeviceOperationResult<SerialNumber> = dmk.executeCommand(deviceUid, command) when (result) { is DeviceOperationResult.Success -> println("Serial: ${result.value.serial}") is DeviceOperationResult.Failure -> println("Failed: ${result.reason}") }

Building the APDU

Use the apdu { } DSL to construct the APDU header and optional data payload, then wrap it with uniqueApduPayload:

override val apduPayload: ApduPayload = uniqueApduPayload( apdu = apdu { classInstruction = 0xE0.toByte() // CLA instructionMethod = 0xD8.toByte() // INS parameter1 = 0x00.toByte() // P1 parameter2 = 0x00.toByte() // P2 data = "Ethereum".encodeToByteArray() // optional data }, )
ℹ️

Use uniqueApduPayload for single-APDU commands. For commands whose data exceeds 255 bytes and must be split across multiple APDUs, use chunkApduPayload.

Parsing the Response

ApduParser reads the response bytes sequentially. Use the appropriate extraction method for each field:

val parser = ApduParser(response) val singleByte = parser.extractByteValue() // 1 byte as Byte val rawByte = parser.extract1BytesValue() // 1 byte as ByteArray val twoBytes = parser.extract2BytesValue() // 2 bytes val fourBytes = parser.extract4BytesValue() // 4 bytes val nBytes = parser.extractBytesValue(nbrBytes = 6) // N bytes val text = parser.extractValueString(nbrBytes = 10) // N bytes decoded as String val remaining = parser.extractRemainingBytesValue() // everything left

A typical pattern for length-prefixed strings:

val length = parser.extract1BytesValue().toInt() val value = parser.extractValueString(nbrBytes = length)

Custom Failure Reasons

For command-specific errors, extend DeviceOperationFailureReason:

sealed class GetSerialNumberFailureReason : DeviceOperationFailureReason() { data object NotSupported : GetSerialNumberFailureReason() data object InvalidResponse : GetSerialNumberFailureReason() }

Then return them in parseResponse:

private const val SW_NOT_SUPPORTED = "6D00" override fun parseResponse( connectedDevice: ConnectedDevice, response: ByteArray, ): DeviceOperationResult<SerialNumber> { if (response.toHexadecimalString() == SW_NOT_SUPPORTED) { return DeviceOperationResult.Failure(GetSerialNumberFailureReason.NotSupported) } // ... parse normally }

Precondition Checking

Use checkPreconditions to validate inputs before the APDU is sent — for example to reject empty strings or out-of-range values:

override fun checkPreconditions(): DeviceOperationResult.Failure? = if (name.isEmpty()) { DeviceOperationResult.Failure(GetSerialNumberFailureReason.InvalidResponse) } else { null }

Return null if all preconditions are met.

Last updated on
Ledger
Copyright © Ledger SAS. All rights reserved. Ledger, Ledger Stax, Ledger Flex, Ledger Nano, Ledger Nano S, Ledger OS, Ledger Wallet, [LEDGER] (logo), [L] (logo) are trademarks owned by Ledger SAS.