Привет, Хабр! Меня зовут Полина, работаю в 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 ... } }
-
Указываем тип характеристики — ответ на запрос.
-
ReadResult указывает на успешное, отмененное или неудачное чтение данных, при успехе можем работать с BTQueryResponse.
-
При успехе получаем данные о статусах камеры каждый раз при их обновлении.
-
При неудаче не удается подписаться на обновление статусов, обрабатываем ошибку.
Записываем характеристику:
service.write( // 1 GPControlAndQueryService.queryCharacteristic, // 2 value: RegisterForCommandDTO() ) { result in switch result { case .success: // 3 break case .failure: // 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 } ...
-
Словарь для записи id команды в виде ключа и значения.
-
Общее количество полезных байтов (не длина сообщения и не хедер), которое нужно обработать.
-
Статическая переменная, содержащая все байты в десятичной СС в виде строк.
-
Проверяем, что данные пришли.
-
Проверяем, нужно ли нам сделать общий пакет и делаем, иначе работаем с одиночным пакетом.
-
Проверяем, что получен очередной пакет.
-
Парсим данные в словарь.
Метод 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 } ...
-
Заполняем буферный массив полученными байтами в виде строк.
-
Переводим байты из шестнадцатеричных в десятичные числа и проверяем, продолжительный ли пакет.
-
Удаляем первый байт (как раз он и отвечает за продолжительность).
-
Обнуляем статическую переменную, содержащую все байты в виде строк.
-
Определяем тип хедера: умножаем первый байт на headerMask, т.к. 2 и 3 бит в этом байте отвечают за то, на каких местах стоит битовое значение длины сообщения, и сдвигаем побитово вправо на 5 пунктов для определения типа хедера.
-
В зависимости от типа хедера выбираем, с какого элемента начинаются полезные данные.
-
Определяем, сколько полезных байтов осталось в пакетах.
-
Отрезаем первый элемент массива с помощью слайса и приводим к массиву, чтобы получить верную индексацию.
-
Определяем, сколько полезных байтов осталось в пакетах.
-
Отрезаем первые два элемента массива, т.к. они содержат только длину сообщения.
-
Определяем, сколько полезных байтов осталось в пакетах.
-
Отрезаем первые три элемента массива, т.к. они содержат только длину сообщения.
-
Добавляем байты сообщения в массив.
-
Уменьшаем количество полезных байтов, оставшихся для добавления в массив байтов из последующих пакетов.
Описание работы метода 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 } }
-
Количество байт для хранения значений текущего статуса.
-
Словарь для записи id статуса и его значения.
-
Буферный массив для хранения всех байт, кроме длины всего сообщения.
-
Переменная для записи id текущего статуса.
-
Массив для хранения всех элементов текущего статуса.
-
Пока буферный массив не опустеет, будем проходиться по статусам в нем.
-
Присваиваем stateId текущий id статуса в десятичной СС.
-
Проверяем наличие длины у текущего статуса.
-
Отрезаем первые два элемента массива, т.к. они содержат id и длину статуса.
-
Отрезаем слайс от массива значений размером с длину статуса и помещаем в valueArray.
-
Удаляем из буферного массива значения текущего статуса.
-
Соединяем все элементы массива значений.
-
Переводим полученное значение в десятичную СС.
-
Записываем значение статуса в словарь с ключом id статуса.
В результате при успешном получении ответа на listen в методе registerForStatusUpdates на выходе мы получаем словарь со статусами камеры и их ключами первый раз при запросе и затем каждый раз, когда какой‑либо из статусов изменился.
Общий алгоритм работы с BLE-ответом
Таким образом, если обобщить вышеперечисленный алгоритм для работы с ответом от любого bluetooth‑устройства, то получим следующее:
-
Получаем ответ от Bluetooth‑устройства.
-
Если ответ простой, то из него получаем статус и довольствуемся результатом или ищем ошибку.
Если ответ комплексный, то проверяем, сколько в нем пакетов — один или больше. -
При единичном пакете сохраняем полезные данные в подходящем нам формате.
-
При составном пакете по маске определяем, сколько полезных байт должно прийти в ответ на данную команду, с какого байта они начинаются, и сохраняем их.
-
Сохраняем полезные байты каждый раз, пока их количество не сравняется с нужным из пункта 4.
-
Когда получен полный список байтов, составленный из нескольких пакетов, приступаем к его парсингу в необходимую нам структуру данных, например, словарь.
-
Так как ответ приходит в TLV‑формате, то нужно разделить общий ответ на отдельные ответы на каждую команду. Проходимся по всему списку байтов и отделяем по id и длине ответа на текущую настройку (или статус) полезные данные: если длина ответа больше одного байта, то складываем эти байты в общий ответ и записываем полученное значение в словарь по ключу id настройки или статуса.
-
На выходе получаем готовый словарь с id настройки (или статуса) в виде ключа и текущим состоянием этой настройки (или статуса) в виде значения.
Если у вас остались какие‑либо вопросы или вам есть что добавить, не стесняйтесь писать комментарии.
ссылка на оригинал статьи https://habr.com/ru/articles/716162/
Добавить комментарий