Parsing responses to BLE commands in the Swift language on the example of GoPro

doubletapp As an iOS developer, today I want to talk about our experience with the GoPro API, and specifically with parsing responses to BLE commands that are described in this API.

Content:

How to work with BLE devices

Working with bluetooth devices involves sending commands and receiving responses to these commands from the device. In our project, it was necessary to implement the iPad-GoPro interaction, so all further reasoning and inferences are considered using the GoPro camera as an example, although they can be applied to any other device that provides the BLE API.

The task consisted in parsing responses from the camera via bluetooth and was complicated by the fact that the responses came in several packets. In the article I will tell you how we managed to implement the parsing of such answers and not only.

To pair a camera with a device (read: iPad) via BLE, you must first discover the camera and connect to it (these points are beyond the scope of this article), and then write to one of the characteristics and wait for a response notification from the corresponding characteristic.

Services and characteristics of BLE devices

What are these characteristics? Bluetooth devices have a specification for the BLE protocol stack – Generic Attribute Profile (GATT). It defines the structure in which data is exchanged between two devices and how attributes (ATTs) are grouped into sets to form services. Your device services must 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 certain set of characteristics (all data is taken from an open GoPro API).

Note for the table:

GP-XXXX is shorthand for 128-bit GoPro UUIDs:
b5f9XXXX-aa8d-11e3-9046-0002a5d5c51b

For the convenience of interacting with characteristics from the corresponding blocks, the project has enumerations GPWiFiAccessService and GPControlAndQueryService.

Using the GPControlAndQueryService example, you can see that each characteristic is initialized with an object of the corresponding type, that is, if we start a command characteristic (commandCharacteristic), which has write access only, then its initializer is WriteBLECharacteristicSubject. The WriteBLECharacteristicSubject structure is closed by 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 avoid passing a characteristic with distinct accesses to it 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
        )
    }
}

We figured out the characteristics, now let’s go directly to the process of sending commands to the camera.

The process of sending commands and receiving responses via BLE

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

The service is Control & Query, since we are working with a request, the actions are to send a request command to the GP-0076 port (write) and receive a response to the GP-0077 port (notify). The listen and write methods work based on the methods of the same name provided by the Bluejay framework. It is important to note that in order to receive a response from the camera, you must first subscribe to notifications, that is, call listen, and only after that call the command to write the characteristic (write).

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 type of characteristic – the response to the request.

  2. ReadResult indicates successful, canceled or unsuccessful data reading, if successful, we can work with BTQueryResponse.

  3. If successful, we get data about the camera statuses every time they are updated.

  4. If it fails, it is not possible to subscribe to the status update, we process the error.

We write the characteristic:

service.write(
    // 1
    GPControlAndQueryService.queryCharacteristic,
    // 2
    value: RegisterForCommandDTO()
) { result in
    switch result {
    case .success:
        // 3
        break
    case .failure:
        // 4
        ...
    }
}
  1. Specify the type of characteristic – request.

  2. We create an instance of the command to be sent.

  3. The command was sent successfully, in this case nothing needs to be done.

  4. Failed to send the command, we are processing the error.

The BLE protocol limits the size of messages to 20 bytes per packet. That is, messages that can be sent to and received from the camera are divided into parts or packets of 20 bytes.

Answers can be both simple and complex.

Simple Answers

A simple response is understood as a response consisting of 3 bytes. The first byte is responsible for the length of the message (usually 2, because the first byte is not taken into account), the second is for the id of the command that was sent to the camera, and the third is for the result of the command, i.e. the third byte is the main response to the command. The third byte can be 0 (success), 1 (error), 2 (invalid parameter).

An example of commands with simple responses that are used in the application: SetShutterOn, EnableWiFi, SetShutterOff, PutCameraToSleep, SetVideoResolution, SetVideoFPS, SetVideoFOV.

Comprehensive answers

Some commands, in addition to the status, return additional information – the so-called complex responses. The main difficulty when working with them is that the answer can come in the form of several packets (but this is not necessary, the data can fit in one packet), so it becomes necessary to add information from several packets into one common data set and work with it. The algorithm for working with multiple packages will be described below.

Packet formation

The package consists of a header and a payload. The header is the initial part of the packet, containing control information, in our case, the length of the message and the type of packet, start or duration. The payload is the body of the packet, the transmitted data, the payload.

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

Messages received from the camera always have a header with the shortest possible message length. For example, a 3-byte response would use a 5-bit standard header, not 13-bit or 16-bit extended headers.

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

What does information about the package header give us? From it we can find out which packet came to us: the first or is it a continuation of the data packet already received earlier, also the packet number in order.

Parsing complex responses

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 generates a command-request for receiving camera statuses in the form of an array of hexadecimal numbers:

0x08 – length of the message sent to the camera

0x53 – request id, in this case, the request is a subscription to receive values ​​when they are updated

The following is a list of those statuses, notifications about the update of which we want to receive:

0x01 – the presence of a battery in the camera

0x02 – battery level (1, 2 or 3)

0x21 – SD card status

0x23 – remaining time to record video (response to this command does not always come correctly)

0x44 – GPS status

0x46 – battery level in percent

0xD – 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 id as a key and value.

  2. The total number of useful bytes (not message length or header) to be processed.

  3. Static variable containing all bytes in decimal CC as strings.

  4. We check that the data has arrived.

  5. We check if we need to make a general package and do it, otherwise we work with a single package.

  6. Verify that another packet has been received.

  7. Parse the data into a dictionary.

The makeSinglePacketIfNeeded method works like this:

...  
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. We fill the buffer array with the received bytes as strings.

  2. Convert bytes from hexadecimal to decimal and check if the packet is long.

  3. We delete the first byte (just he is responsible for the duration).

  4. We reset the static variable containing all the bytes as strings.

  5. Determine the header type: multiply the first byte by headerMask, because Bits 2 and 3 in this byte are responsible for where the bit value of the message length is located, and we shift bitwise to the right by 5 points to determine the type of header.

  6. Depending on the type of header, we choose which element the useful data begins with.

  7. Determine how many useful bytes are left in the packets.

  8. We cut off the first element of the array using a slice and cast it to the array to get the correct indexing.

  9. Determine how many useful bytes are left in the packets.

  10. We cut off the first two elements of the array, because they only contain the length of the message.

  11. Determine how many useful bytes are left in the packets.

  12. We cut off the first three elements of the array, because they only contain the length of the message.

  13. Add the message bytes to the array.

  14. Decrease the number of useful bytes left to add to the byte array from subsequent packets.

Description of the parseBytesToDict method, which allows you to parse 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 to store the current status values.

  2. A dictionary to record the status id and its value.

  3. Buffer array to store all bytes except the length of the entire message.

  4. Variable to record the id of the current status.

  5. An array to store all elements of the current status.

  6. Until the buffer array is empty, we will go through the statuses in it.

  7. Assign stateId the current status id in decimal SS.

  8. Check if the current status has a length.

  9. We cut off the first two elements of the array, because they contain the id and length of the status.

  10. We cut off a slice from the array of values ​​the size of the status length and put it in valueArray.

  11. Delete the current status values ​​from the buffer array.

  12. We connect all elements of the array of values.

  13. We translate the resulting value into decimal SS.

  14. Write the status value to the dictionary with the status id key.

As a result, upon successful receipt of a response to listen in the registerForStatusUpdates method, we get a dictionary with the camera statuses and their keys at the output for the first time when requested and then every time any of the statuses has changed.

General algorithm for working with BLE response

Thus, if we generalize the above algorithm to work with a response from any bluetooth device, we get the following:

  1. We receive a response from the Bluetooth device.

  2. If the answer is simple, then we get the status from it and are satisfied with the result or look for an error.
    If the answer is complex, then we check how many packages it contains – one or more.

  3. With a single package, we save payload data in a format that suits us.

  4. With a composite packet, we determine by mask how many useful bytes should come in response to this command, which byte they start with, and save them.

  5. We save useful bytes each time until their number is equal to the one needed from point 4.

  6. When a complete list of bytes, made up of several packets, is received, we proceed to parsing it into the data structure we need, for example, a dictionary.

  7. Since the response comes in TLV format, you need to split the overall response into separate responses for each command. We go through the entire list of bytes and separate the useful data by id and length of the response to the current setting (or status): if the length of the response is more than one byte, then add these bytes into a common response and write the resulting value to the dictionary by the setting or status id key.

  8. At the output, we get a ready-made dictionary with the id of the setting (or status) as a key and the current state of this setting (or status) as a value.

If you have any questions or have anything to add, feel free to leave a comment.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *