Parsing responses to BLE commands in Swift using the example of GoPro

Doubletapp
11 min readNov 13, 2023

Hello, my name is Polina, and I work at Doubletapp as an iOS developer. Today, I want to share our experience working with the GoPro API, specifically parsing responses to BLE commands described in this API.

BLE devices working principle

Working with Bluetooth devices involves sending commands and receiving responses from the device. In our project, we needed to establish communication between an iPad and a GoPro camera using BLE. Therefore, all further discussions and conclusions will be based on the example of the GoPro camera, although they can be applied to any other device that provides a BLE API.

The task was to parse the camera’s responses via Bluetooth, which was complicated by the fact that the responses were received in multiple packets. In this article, I will explain how we managed to parse such responses and more.

To connect the camera to the device (in this case, an iPad) via BLE, we first need to discover the camera and establish a connection with it (these steps are beyond the scope of this article). Then, we need to write to one of the characteristics and wait for a corresponding characteristic’s notification as a response.

Services and characteristics of BLE devices

What are these characteristics? Bluetooth devices have a specification called the Generic Attribute Profile (GATT) as part of the BLE protocol stack. It defines the structure for data exchange between two devices and how attributes (ATT) are grouped into sets to form services. The services of your device should be described in the API.

In our case, the camera provides three services: GoPro WiFi Access Point, GoPro Camera Management, and Control & Query. Each service manages a specific set of characteristics (all data is taken from the open GoPro API).

Note for the table:

GP-XXXX is an abbreviation for the 128-bit GoPro UUID:
b5f9XXXX-aa8d-11e3–9046–0002a5d5c51b

For convenience in interacting with the characteristics, corresponding enumerations GPWiFiAccessService and GPControlAndQueryService are used in the project.

In the example of GPControlAndQueryService, you can see that each characteristic is initialized with an object of the corresponding type. For example, if we create a command characteristic (commandCharacteristic) that only has “write” permissions, its initializer will be WriteBLECharacteristicSubject. The WriteBLECharacteristicSubject structure conforms to the WriteBLECharacteristic protocol.

enum GPControlAndQueryService {
static let serviceIdentifier = ServiceIdentifier(uuid: "FEA6")
static let commandCharacteristic = WriteBLECharacteristicSubject(
id: CharacteristicIdentifier(
uuid: CBUUID(string: "B5F90072-AA8D-11E3-9046-0002A5D5C51B"),
service: serviceIdentifier
)
)
...
}

The WriteBLECharacteristic protocol is used in the “write” method to prevent passing a characteristic with different permissions by mistake.

func write<S: Sendable>(
_ characteristic: WriteBLECharacteristic,
value: S,
completion: @escaping (WriteResult) -> Void
) {
if store.state.connectedPeripheral != nil {
bluejay.write(
to: characteristic.id,
value: value,
completion: completion
)
}
}

Now that we have covered characteristics, let’s move on to the process of sending commands to the camera.

Process of sending commands and receiving responses via BLE

Let’s say we want to subscribe to camera status updates. We check the table to see which service manages this characteristic and what interactions are available to us.

The service is Control & Query, as we are working with a query. The actions are to send a query command to port GP-0076 (write) and receive a response on port GP-0077 (notify). The “listen” and “write” methods are based on the corresponding methods provided by the Bluejay framework. It is important to note that to receive a response from the camera, we need to subscribe to notifications first by calling “listen”, and only then call the “write” command to write to the characteristic.

Subscribe to receive notifications from the camera:

service.listen(
// 1
GPControlAndQueryService.queryResponseCharacteristic,
multipleListenOption: .trap
// 2
) { (result: ReadResult<BTQueryResponse>) in
switch result {
case let .success(response):
// 3
...
case .failure:
// 4
...
}
}
  1. Specify the characteristic type as a response to the query.
  2. ReadResult indicates a successful, canceled, or unsuccessful data read. If successful, we can work with BTQueryResponse.
  3. Upon success, we receive camera status data every time it is updated.
  4. If unsuccessful, we cannot subscribe to status updates, so we handle the error.

Write to the characteristic:

service.write(
// 1
GPControlAndQueryService.queryCharacteristic,
// 2
value: RegisterForCommandDTO()
) { result in
switch result {
case .success:
// 3
break
case .failure:
// 4
...
}
}
  1. Specify the characteristic type as a query.
  2. Create an instance of the command to be sent.
  3. If the command is successfully sent, no further action is required.
  4. If the command fails to send, we handle the error.

The BLE protocol limits the message size to 20 bytes per packet. This means that messages sent to and received from the camera are divided into parts or packets of 20 bytes.

Responses can be either simple or complex.

Simple responses

A simple response consists of 3 bytes. The first byte represents the message length (usually 2, as the first byte is not counted), the second byte represents the command ID sent to the camera, and the third byte represents the result of the command execution. The third byte is the main response to the command and can have values of 0 (success), 1 (error), or 2 (invalid parameter).

Examples of commands with simple responses used in the application include SetShutterOn, EnableWiFi, SetShutterOff, PutCameraToSleep, SetVideoResolution, SetVideoFPS, and SetVideoFOV.

Complex responses

Some commands return additional information besides the status, known as complex responses. The main challenge when working with complex responses is that the response can be received in multiple packets (although it is not mandatory; the data can fit in a single packet). Therefore, it is necessary to combine the information from multiple packets into a single data set and work with it. The algorithm for handling multiple packets will be described below.

Packet formation

A packet consists of a header and a payload. The header is the initial part of the packet that contains control information, such as the message length and packet type (start or continuation). The payload is the body of the packet, which contains the transmitted data or useful information.

Since a packet is limited to 20 bytes, the GoPro header format looks as follows. All lengths are specified in bytes.

The messages received from the camera always have a header with the minimum possible message length. For example, a three-byte response will use a 5-bit standard header instead of 13-bit or 16-bit extended headers.

The messages sent to the camera can use either a 5-bit standard header or a 13-bit extended header.

What does the header information tell us? From it, we can determine whether the packet received is the first one or a continuation of previously received data packets, as well as the packet number in sequence.

Parsing complex responses

Let’s consider the algorithm for obtaining camera statuses.

struct RegisterForCommandDTO: Sendable {
func toBluetoothData() -> Data {
let commandsArray: [UInt8] = [
0x53, 0x01, 0x02, 0x21, 0x23, 0x44, 0x46, 0xD
]
let commandsLength = UInt8(commandsArray.count)
return Data([commandsLength] + commandsArray)
}
}

The toBluetoothData method forms a command request to retrieve camera statuses as an array of hexadecimal numbers:

0x08 — the length of the message being sent to the camera.

0x53 — the requestID, in this case, the request is to subscribe to receive values upon their update.

Next, there is a list of the statuses for which we want to receive notifications:

0x01 — battery presence in the camera.

0x02 — battery level (1, 2, or 3).

0x21 — SD card status.

0x23 — remaining video recording time (the response to this command does not always come correctly).

0x44 — GPS status.

0x46 — battery level in percentage.

0x0D — video recording timer.

After sending this command and receiving a response, we proceed to process it. The BTQueryResponse structure is responsible for processing the response:

struct BTQueryResponse: Receivable {
// 1
var statusDict = [Int: Int]()
// 2
private static var bytesRemaining = 0
// 3
private static var bytes = [String]()

init(bluetoothData: Data) throws {
// 4
if !bluetoothData.isEmpty {
// 5
makeSinglePacketIfNeeded(from: bluetoothData)
// 6
if isReceived() {
// 7
statusDict = try BTQueryResponse.parseBytesToDict(
BTQueryResponse.bytes
)
}
}
}

private func isReceived() -> Bool {
!BTQueryResponse.bytes.isEmpty && BTQueryResponse.bytesRemaining == 0
}
...
  1. A dictionary to store the command IDs as keys and values.
  2. The total number of useful bytes (excluding the message length and header) that need to be processed.
  3. A static variable that contains all the bytes in decimal format as strings.
  4. Check if data has been received.
  5. Check if we need to create a combined packet and do so; otherwise, work with a single packet.
  6. Check if another packet has been received.
  7. Parse the data into a dictionary.

The makeSinglePacketIfNeeded method works as follows:

...  
private func makeSinglePacketIfNeeded(from data: Data) {
let continuationMask = 0b1000_0000
let headerMask = 0b0110_0000
let generalLengthMask = 0b0001_1111
let extended13Mask = 0b0001_1111

enum Header: Int {
case general = 0b00
case extended13 = 0b01
case extended16 = 0b10
case reserved = 0b11
}

var bufferArray = [String]()
data.forEach { byte in
// 1
bufferArray.append(String(byte, radix: 16))
}
// 2
if (bufferArray[0].hexToDecimal & continuationMask) != 0 {
// 3
bufferArray.removeFirst()
} else {
// 4
BTQueryResponse.bytes = []
// 5
let header = Header(rawValue: (
bufferArray[0].hexToDecimal & headerMask
) >> 5)
// 6
switch header {
case .general:
// 7
BTQueryResponse.bytesRemaining = bufferArray[0].hexToDecimal &
generalLengthMask
// 8
bufferArray = Array(bufferArray[1...])
case .extended13:
// 9
BTQueryResponse.bytesRemaining = (
(bufferArray[0].hexToDecimal & extended13Mask) << 8
) + bufferArray[1].hexToDecimal
// 10
bufferArray = Array(bufferArray[2...])
case .extended16:
// 11
BTQueryResponse.bytesRemaining = (
bufferArray[1].hexToDecimal << 8
) + bufferArray[2].hexToDecimal
// 12
bufferArray = Array(bufferArray[3...])
default:
break
}
}
// 13
BTQueryResponse.bytes.append(contentsOf: bufferArray)
// 14
BTQueryResponse.bytesRemaining -= bufferArray.count
}
...
  1. Fill a buffer array with the received bytes as strings.
  2. Convert the bytes from hexadecimal to decimal numbers and check if it is a continuation packet.
  3. Remove the first byte (which represents the continuation).
  4. Reset the static variable that contains all the bytes as strings.
  5. Determine the header type: multiply the first byte by the headerMask, as the 2nd and 3rd bits in this byte indicate the positions of the bit value for the message length, and shift it bitwise to the right by 5 points to determine the header type.
  6. Depending on the header type, choose the starting element for the useful data.
  7. Determine the number of useful bytes remaining in the packets.
  8. Slice off the first element of the array to get the correct indexing.
  9. Determine the number of useful bytes remaining in the packets.
  10. Slice off the first two elements of the array, as they only contain the message length.
  11. Determine the number of useful bytes remaining in the packets.
  12. Slice off the first three elements of the array, as they only contain the message length.
  13. Add the message bytes to the array.
  14. Decrease the count of remaining useful bytes to be added to the array from subsequent packets.

This is a description of the parseBytesToDict method, which allows parsing an array of bytes into a dictionary:

... 
private static func parseBytesToDict(_ bytes: [String]) throws -> [Int: Int] {
// 1
var stateValueLength: Int
// 2
var resultDict = [Int: Int]()
// 3
var bufferArray = Array(bytes[2...])
// 4
var stateId = 0
// 5
var valueArray = [String]()
// 6
while !bufferArray.isEmpty {
// 7
stateId = bufferArray[0].hexToDecimal
// 8
guard let valueLength = Int(bufferArray[1]) else {
throw NSError(
domain: "Error fetching status value length", code: -3
)
}
stateValueLength = valueLength
// 9
bufferArray = Array(bufferArray[2...])
// 10
valueArray = Array(bufferArray[..<stateValueLength])
// 11
bufferArray = Array(bufferArray[valueLength...])
// 12
let valueStringHex = valueArray.joined()
// 13
let resultValueInt = valueStringHex.hexToDecimal
// 14
resultDict[stateId] = resultValueInt
}
return resultDict
}
}
  1. The number of bytes needed to store the values of the current status.
  2. A dictionary to store the status ID and its values.
  3. A buffer array to store all the bytes, excluding the total message length.
  4. A variable to store the current status ID.
  5. An array to store all the elements of the current status.
  6. While the buffer array is not empty, iterate through the statuses in it.
  7. Assign the “stateId” as the current status ID in decimal format.
  8. Check if the current status has a length.
  9. Slice off the first two elements of the array, as they contain the ID and length of the status.
  10. Slice a portion of the array with the size of the status length and store it in valueArray.
  11. Remove the values of the current status from the buffer array.
  12. Join all the elements of the value array.
  13. Convert the obtained value to decimal format.
  14. Store the status value in the dictionary with the status ID as the key.

As a result, when we successfully receive a response to the “listen” command in the registerForStatusUpdates method, we get a dictionary with the camera statuses and their keys for the first time when we make the request, and then every time any of the statuses change.

General algorithm for working with a BLE response:

To summarize the above algorithm for working with a response from any Bluetooth device, we can follow these steps:

  1. Receive a response from the Bluetooth device.
  2. If the response is simple, extract the status and use the result or search for errors. If the response is complex, check how many packets it contains — one or more.
  3. For a single packet, save the useful data in the desired format.
  4. For a composite packet, determine the number of useful bytes that should be received in response to a specific command, the starting byte position, and save them.
  5. Save the useful bytes until their count matches the required count from step 4.
  6. Once the complete list of bytes, composed of multiple packets, is obtained, parse it into the desired data structure, such as a dictionary.
  7. Since the response is in TLV format, separate the overall response into individual responses for each command. Iterate through the byte list and separate the useful data based on the ID and response length of the current setting or status. If the response length is more than one byte, combine these bytes into the overall response and store the obtained value in the dictionary with the setting or status ID as the key.
  8. The output will be a ready-to-use dictionary with the setting or status ID as the key and the current state of that setting or status as the value.

If you have any questions or anything to add, feel free to write your comments.

--

--