Привет, Хабр! Меня зовут Полина, работаю в Doubletapp iOS‑разработчиком и сегодня хочу рассказать о нашем опыте работы с API GoPro, а конкретно с парсингом ответов на команды BLE, которые описаны в этом API.

Содержание:

Принцип работы с BLE-устройствами

Работа с bluetooth‑устройствами предполагает отправку команд и получение ответов на эти команды от устройства. В нашем проекте было необходимо реализовать взаимодействие iPad — GoPro, поэтому все дальнейшие рассуждения и умозаключения рассматриваются на примере камеры GoPro, хотя они могут быть применены к любому другому устройству, предоставляющему BLE API.

Задача состояла в парсинге ответов от камеры через bluetooth и усложнялась тем, что ответы приходили несколькими пакетами. В статье я расскажу, как нам удалось реализовать парсинг таких ответов и не только.

Чтобы связать камеру с устройством (читай: iPad) через BLE, нужно сначала обнаружить камеру и подключиться к ней (эти пункты выходят за рамки данной статьи), а затем произвести запись в одну из характеристик и ожидать ответного уведомления от соответствующей характеристики.

Сервисы и характеристики BLE-устройств

Что же это за характеристики? У Bluetooth‑устройств есть спецификация стека протоколов BLE — Generic Attribute Profile (GATT). Она определяет структуру, в которой осуществляется обмен данными между двумя устройствами, и то, как атрибуты (ATT) группируются в наборы для формирования сервисов. Сервисы вашего устройства должны быть описаны в API.

В нашем случае камера предоставляет три сервиса: GoPro WiFi Access Point, GoPro Camera Management и Control & Query. Каждый сервис управляет определенным набором характеристик (все данные взяты из открытой API GoPro).

Примечание для таблицы:

GP‑XXXX — это сокращение для 128-битных UUID GoPro:
b5f9XXXX‑aa8d-11e3–9046–0002a5d5c51b

Для удобства взаимодействия с характеристиками из соответствующих блоков в проекте есть перечисления GPWiFiAccessService и GPControlAndQueryService.

На примере GPControlAndQueryService можно заметить, что каждая характеристика инициализируется с объектом соответствующего типа, то есть, если мы заводим характеристику команды (commandCharacteristic), которая имеет права только на запись, то ее инициализатор — WriteBLECharacteristicSubject. Структура WriteBLECharacteristicSubject закрывается протоколом WriteBLECharacteristic.

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

Протокол WriteBLECharacteristic используется в методе write, чтобы по ошибке не передать в него характеристику с отличными доступами.

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
        )
    }
}

С характеристиками разобрались, теперь перейдем непосредственно к процессу отправки команд на камеру.

Процесс отправки команд и получения ответов через BLE

Допустим, мы хотим подписаться на обновление статусов камеры. Проверяем по таблице, какой сервис управляет этой характеристикой и какие взаимодействия с ней нам доступны.

Сервис — Control & Query, так как мы работаем с запросом, действия — отправить команду‑запрос на порт GP-0076 (write) и получить ответ на порт GP-0077 (notify). Методы listen и write работают на основе одноименных методов, предоставленных фреймворком Bluejay. Важно отметить, что для получения ответа от камеры необходимо сначала подписаться на уведомления, то есть вызвать listen, а уже после этого вызвать команду на запись характеристики (write).

Подписываемся на получение уведомлений от камеры:

service.listen(
    // 1
    GPControlAndQueryService.queryResponseCharacteristic,
    multipleListenOption: .trap
    // 2
) { (result: ReadResult<BTQueryResponse>) in
    switch result {
    case let .success(response):
        // 3
        ...
    case .failure:
        // 4
        ...
    }
}
  1. Указываем тип характеристики — ответ на запрос.

  2. ReadResult указывает на успешное, отмененное или неудачное чтение данных, при успехе можем работать с BTQueryResponse.

  3. При успехе получаем данные о статусах камеры каждый раз при их обновлении.

  4. При неудаче не удается подписаться на обновление статусов, обрабатываем ошибку.

Записываем характеристику:

service.write(
    // 1
    GPControlAndQueryService.queryCharacteristic,
    // 2
    value: RegisterForCommandDTO()
) { result in
    switch result {
    case .success:
        // 3
        break
    case .failure:
        // 4
        ...
    }
}
  1. Указываем тип характеристики — запрос.

  2. Создаем экземпляр отправляемой команды.

  3. Команда отправлена успешно, в этом случае ничего делать не надо.

  4. Не удалось отправить команду, обрабатываем ошибку.

Протокол BLE ограничивает объем сообщений 20 байтами на пакет. То есть сообщения, которые можно отправить на камеру и получить от нее, разбиваются на части или пакеты по 20 байт.

Ответы могут быть как простыми, так и комплексными.

Простые ответы

Под простым ответом понимается ответ, состоящий из 3 байт. Первый байт отвечает за длину сообщения (обычно 2, т.к. первый байт не учитывается), второй — за id команды, которая была отправлена на камеру, а третий — за результат выполнения команды, т. е. третий байт и является основным ответом на команду. Третий байт может иметь значения 0 (success), 1 (error), 2 (invalid parameter).

Пример команд с простыми ответами, которые используются в приложении: SetShutterOn, EnableWiFi, SetShutterOff, PutCameraToSleep, SetVideoResolution, SetVideoFPS, SetVideoFOV.

Комплексные ответы

Некоторые команды, помимо статуса, возвращают дополнительную информацию — так называемые комплексные ответы. Основная сложность при работе с ними заключается в том, что ответ может прийти в виде нескольких пакетов (но это необязательно, данные могут уместиться и в один пакет), поэтому возникает необходимость складывать информацию из нескольких пакетов в один общий набор данных и работать с ним. Ниже будет описан алгоритм работы с несколькими пакетами.

Формирование пакетов

Пакет состоит из хедера и пейлоада. Хедер — это начальная часть пакета, содержащая управляющую информацию, в нашем случае длину сообщения и тип пакета, стартовый или продолжительный. Пейлоад — это тело пакета, передаваемые данные, полезная информация.

Так как пакет ограничен 20 байтами, формат хедера GoPro выглядит следующим образом. Все длины указаны в байтах.

Сообщения, полученные с камеры, всегда имеют хедер с минимально возможной длиной сообщения. Например, в трехбайтовом ответе будет использоваться 5-битный стандартный хедер, а не 13-битные или 16-битные расширенные хедеры.

Сообщения, отправляемые на камеру, могут использовать либо 5-разрядный стандартный хедер, либо 13-разрядный расширенный хедер.

Что нам дает информация о хедере пакета? Из нее мы можем узнать, какой пакет нам пришел: первый или это продолжение уже полученного ранее пакета данных, также номер пакета по порядку.

Парсинг комплексных ответов

Рассмотрим алгоритм получения статусов камеры.

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)
    }
}

Метод toBluetoothData формирует команду-запрос на получение статусов камеры в виде массива из шестнадцатеричных чисел:

0x08 — длина сообщения, отправляемого на камеру.

0x53 — id запроса, в данном случае запрос — это подписка на получение значений при их обновлении.

Далее идет перечисление тех статусов, уведомления об обновлении которых мы хотим получать:

0x01 — наличие батареи в камере.

0x02 — уровень заряда батареи (1, 2 или 3).

0x21 — статус SD-карты.

0x23 — оставшееся время для записи видео (ответ на данную команду не всегда приходит корректно).

0x44 — статус GPS.

0x46 — уровень заряда батареи в процентах.

0xD — таймер записи видео.

Отправив данную команду и получив ответ, приступаем к его обработке. За обработку ответа отвечает структура BTQueryResponse:

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. Словарь для записи id команды в виде ключа и значения.

  2. Общее количество полезных байтов (не длина сообщения и не хедер), которое нужно обработать.

  3. Статическая переменная, содержащая все байты в десятичной СС в виде строк.

  4. Проверяем, что данные пришли.

  5. Проверяем, нужно ли нам сделать общий пакет и делаем, иначе работаем с одиночным пакетом.

  6. Проверяем, что получен очередной пакет.

  7. Парсим данные в словарь.

Метод makeSinglePacketIfNeeded работает следующим образом:

...  
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. Заполняем буферный массив полученными байтами в виде строк.

  2. Переводим байты из шестнадцатеричных в десятичные числа и проверяем, продолжительный ли пакет.

  3. Удаляем первый байт (как раз он и отвечает за продолжительность).

  4. Обнуляем статическую переменную, содержащую все байты в виде строк.

  5. Определяем тип хедера: умножаем первый байт на headerMask, т.к. 2 и 3 бит в этом байте отвечают за то, на каких местах стоит битовое значение длины сообщения, и сдвигаем побитово вправо на 5 пунктов для определения типа хедера.

  6. В зависимости от типа хедера выбираем, с какого элемента начинаются полезные данные.

  7. Определяем, сколько полезных байтов осталось в пакетах.

  8. Отрезаем первый элемент массива с помощью слайса и приводим к массиву, чтобы получить верную индексацию.

  9. Определяем, сколько полезных байтов осталось в пакетах.

  10. Отрезаем первые два элемента массива, т.к. они содержат только длину сообщения.

  11. Определяем, сколько полезных байтов осталось в пакетах.

  12. Отрезаем первые три элемента массива, т.к. они содержат только длину сообщения.

  13. Добавляем байты сообщения в массив.

  14. Уменьшаем количество полезных байтов, оставшихся для добавления в массив байтов из последующих пакетов.

Описание работы метода parseBytesToDict, который позволяет распарсить массив байтов в словарь:

... 
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. Количество байт для хранения значений текущего статуса.

  2. Словарь для записи id статуса и его значения.

  3. Буферный массив для хранения всех байт, кроме длины всего сообщения.

  4. Переменная для записи id текущего статуса.

  5. Массив для хранения всех элементов текущего статуса.

  6. Пока буферный массив не опустеет, будем проходиться по статусам в нем.

  7. Присваиваем stateId текущий id статуса в десятичной СС.

  8. Проверяем наличие длины у текущего статуса.

  9. Отрезаем первые два элемента массива, т.к. они содержат id и длину статуса.

  10. Отрезаем слайс от массива значений размером с длину статуса и помещаем в valueArray.

  11. Удаляем из буферного массива значения текущего статуса.

  12. Соединяем все элементы массива значений.

  13. Переводим полученное значение в десятичную СС.

  14. Записываем значение статуса в словарь с ключом id статуса.

В результате при успешном получении ответа на listen в методе registerForStatusUpdates на выходе мы получаем словарь со статусами камеры и их ключами первый раз при запросе и затем каждый раз, когда какой‑либо из статусов изменился.

Общий алгоритм работы с BLE-ответом 

Таким образом, если обобщить вышеперечисленный алгоритм для работы с ответом от любого bluetooth‑устройства, то получим следующее:

  1. Получаем ответ от Bluetooth‑устройства.

  2. Если ответ простой, то из него получаем статус и довольствуемся результатом или ищем ошибку.
    Если ответ комплексный, то проверяем, сколько в нем пакетов — один или больше.

  3. При единичном пакете сохраняем полезные данные в подходящем нам формате.

  4. При составном пакете по маске определяем, сколько полезных байт должно прийти в ответ на данную команду, с какого байта они начинаются, и сохраняем их.

  5. Сохраняем полезные байты каждый раз, пока их количество не сравняется с нужным из пункта 4.

  6. Когда получен полный список байтов, составленный из нескольких пакетов, приступаем к его парсингу в необходимую нам структуру данных, например, словарь.

  7. Так как ответ приходит в TLV‑формате, то нужно разделить общий ответ на отдельные ответы на каждую команду. Проходимся по всему списку байтов и отделяем по id и длине ответа на текущую настройку (или статус) полезные данные: если длина ответа больше одного байта, то складываем эти байты в общий ответ и записываем полученное значение в словарь по ключу id настройки или статуса.

  8. На выходе получаем готовый словарь с id настройки (или статуса) в виде ключа и текущим состоянием этой настройки (или статуса) в виде значения.

Если у вас остались какие‑либо вопросы или вам есть что добавить, не стесняйтесь писать комментарии.

Комментарии (0)