Перевод статьи Practical CoreBluetooth for Peripherals


Пару лет назад, впервые столкнувшись в рабочем проекте с Bluetooth, нашел эту статью, которая сильно помогла понять, как это работает, найти “отправную” точку. Надеюсь, что пригодится новичкам.

Об авторе: Йоав Шварц — ведущий iOS разработчик в Donkey Republic, системе байкшеринга в Копенгагене, стремящийся изменить отношение к велотранспорту. Далее речь пойдет от лица автора.

В этой статье я расскажу о практических приемах работы с CoreBluetooth. Сначала о Bluetooth Low Energy (BLE) потому, что не все знакомы с этой технологией, потом о CoreBluetooth, фреймворке от Apple, который даёт нам возможность взаимодействовать с устройствами BLE. Также я поведаю о некоторых приёмах в разработке, о которых сам узнал, пока занимался отладкой, плакал и рвал на голове волосы.

Bluetooth Low Energy


Для начала, что такое BLE? Это вроде как Bluetooth, который все мы используем в колонках, гарнитурах и т. п., но есть разница — этот протокол потребляет очень мало энергии. Обычно одного заряда батареи работающему с BLE устройству может хватить на месяцы или даже годы (в зависимости от того, конечно, как это устройство используется). Это позволяет нам делать вещи, ранее недоступные для “обычного” Bluetooth. Этот стандарт называется Bluetooth 4.0, началось всё с технологии под названием Smart Bluetooth, что и переросло позже в BLE. Есть 200-страничный мануал, можете почитать перед сном, захватывающее чтиво.

BLE очень экономичен в отношении потребления энергии, да и сам протокол не очень сложен. Итак, почему BLE? Как мы можем его использовать? Самый первый и распространенный пример — датчик ЧСС. Обычно это устройство измеряет и передаёт по протоколу ваш сердечный ритм. Ещё есть всякие сенсоры, к которым можно подключаться по BLE и считывать те данные, которые они собирают. Наконец, есть iBeacons, которые могут сообщать вам “близость” к какому-либо месту. В кавычках потому, что на Айфонах Apple блокирует возможность обнаружения iBeacons как обычных Bluetooth-устройств, поэтому нам остаётся работать с CoreLocation. В общем, это Интернет вещей: вы можете подключиться к телевизору или кондиционеру, и общаться с ним, используя данный протокол.

Как это работает?


У нас есть периферал — так называются устройства, работающие по протоколу Bluetooth. У каждого периферала есть сервисы, их может быть сколь угодно много, и каждый из них имеет характеристики. Можно рассматривать периферал как сервер. Со всеми вытекающими последствиями: иногда он отключается, иногда требуется время на передачу данных, a иногда эти данные и вовсе не приходят.

В целом, у нас есть сервис со множеством характеристик, каждая из которых содержит какое-нибудь значение, тип и так далее. Чтобы работать с CoreBluetooth — не нужно знать всего, самое главное — считывать данные. Вот что мы пытаемся получить, изменить или использовать в своих целях. Нам нужны эти данные и знание того, что мы можем с ними делать.

Вот такое краткое вступление в BLE потому, что есть тысячи ресурсов, которые объяснят технические особенности лучше меня.

Core Bluetooth


Core Bluetooth был представлен Apple достаточно давно, в iOS 5. Apple начала работу по внедрению BLE в свои устройства намного раньше Android и роста популярности технологии. Множество разработчиков используют этот фреймворк в своих приложениях, по большому счету — это всего лишь обертка, так как сами по себе протоколы BLE достаточно сложны. Не то чтобы очень, но поверьте, это не то, с чем хотелось бы работать каждый день. Так же, как и многие другие вещи, Apple завернула это в красивую и удобную упаковку, позволив пользоваться терминами, которые все мы, глупые разработчики сможем понять.

Теперь настала очередь рассказать то, что действительно нужно знать — о классах, вовлеченных в общение с фреймворком. Наш главный актер — CBCentralManager, создадим его:

manager = CBCentralManager(delegate:self, queue:nil, options: nil)

Выше мы создали новый менеджер, указав его делегат, иначе мы не сможем им пользоваться. Также укажем очередь, в нашем случае nil, значит, все общение с менеджером будет осуществляться на главной очереди.

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

Опции. Тут ничего особенно интересного, пожалуй, главная — когда вы создаёте менеджер и у пользователя выключен bluetooth — приложение скажет ему об этом, но почти все нажимают “ОК” (что на самом деле не включает bluetooth), из-за чего и этой опцией я не пользуюсь.

Первым делом после создания менеджера у его делегата вызывается метод:

func centralManagerDidUpdateState(_ central: CBCentralManager)

Так мы получим ответ от аппаратной части — включен у пользователя bluetоoth или нет.
Первый совет: менеджер бесполезен до тех пор, пока мы не получим ответ, что bluetоoth включен, его состояние — .PoweredOn. Остальные состояния можно использовать разве что для того, чтобы попросить пользователя включить bluetooth.

Поиск устройств


Теперь, когда наш менеджер работает должным образом, мы можем смотреть,
что же находится вокруг нас (после получения состояния .PoweredOn — вызываем функцию scanForPeripheralsWithServices:)

manager.scanForPeripheralsWithServices([CBUUID], options: nil)

Насчет сервисов — это массив CBUUID (класс, представляющий собой 128-битные универсальные уникальные идентификаторы атрибутов, используемых Bluetooth Low Energy прим. пер.), который мы используем в качестве фильтра, чтобы найти устройства только с этим набором UID, это обычная практика в CoreBluetooth.

Если передать в качестве аргумента nil — мы сможем увидеть все устройства вокруг. Для производительности, конечно, лучше указать массив нужных нам параметров, но в случае, когда вы их не знаете — не случится ничего страшного, если передать nil, никто не умрет.

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

manager.stopScan()

Каждый раз при обнаружении нового устройства у делегата менеджера будет вызвана функция didDiscoverPeripheral на той очереди, которую мы указали при его инициализации. Функция передает нам найденное устройство (peripheral), информацию о нем (advertisementData — что-то, что разработчики чипа решили показывать каждый раз) и относительный уровень сигнала RSSI в децибелах.

func centralManager(_ central: CBCentralManager, didDiscover peripheral:
CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber)

Второй совет: всегда держите сильную ссылку на обнаруженный нужный периферал. Если этого не сделать, система решит, что найденное устройство нам не нужно, и отбросит его. Она будет помнить о нем, но у нас к нему уже не будет доступа. Иначе работать с устройством мы не сможем.

Подключение к устройству


Мы нашли интересующее нас устройство — это как прийти на вечеринку и увидеть симпатичную девушку. Мы хотим подключиться, вызываем функцию connectPeripheral — предлагаем “купить выпить”. Таким образом, мы пробуем подключиться к нужному устройству (peripheral), и оно может сказать нам “да” или “нет”, но наш iPhone действительно хорош, поэтому мы услышим положительный ответ.

manager.connectPeripheral(peripheral, options: nil)

Здесь мы обратились к менеджеру, который отвечает за подключения, говорим ему, к какому конкретно устройству мы подключаемся, и опять в опции отдаем nil (если вам на самом деле очень интересно узнать об опциях — почитайте документацию, но обычно можно обойтись без них). Когда вы закончите работу с устройством, можно от него отключиться, ну вы знаете, утром — cancelPeripheralConnection:

//called to cancel and/or disconnect
manager.cancelPeripheralConnection(peripheral)

После того как мы подключимся или оборвем соединение, делегат сообщит нам об этом:

//didConnect
func centralManager(central: CBCentralManager!, didConnectPeripheral
peripheral: CBPeripheral!)

//didDisconnect
func centralManager(central: CBCentralManager!, didDisconnectPeripheral
peripheral: CBPeripheral!, error: NSError!)

Теперь, ещё два важных совета. Протокол Bluetooth предполагает таймаут на подключение, но Apple это не заботит. iOS будет снова и снова пытаться подключиться и не прекратит, пока вы не вызовете cancelPeripheralConnection. Этот процесс может слишком затянуться, так что необходимо ограничить его во времени, и если, в конце концов, мы не получаем сообщения об успешном подключении (didConnectPeripheral) — нужно информировать пользователя, что что-то пошло не так.

Если вы не держите сильную ссылку на периферал, iOS просто сбросит соединение. С её точки зрения это будет означать, что оно вам не нужно, а поддерживать его — довольно энергоёмкая задача для батареи, а нам известно, как Apple относится к расходу энергии.

Сделаем устройство полезным


И так, мы подключились к устройству, давайте с ним что-нибудь сделаем. Ранее я упоминал про сервисы и характеристики, про значения которые они содержат, вот что нам нужно. Теперь у нас есть устройство, оно подключено и мы можем получить его сервисы, вызвав peripheral.discoverServices.

peripheral.discoverServices(nil)

func peripheral(peripheral: CBPeripheral!, didDiscoverServices error:
NSError!)
peripheral.services

Сейчас прозвучит немного запутанно, но вызов делегата происходит на том потоке, который мы определили при создании менеджера, несмотря на то, что это делегат периферала. То есть система помнит, с каким потоком она работает, и всё наше Bluetooth-общение происходит на этом потоке. Важно не забыть вернуться на главный, если вы использовали не его.

Мы получили сервисы, но нам всё ещё не с чем работать. Далее нужно вызвать peripheral.discoverCharacteristics, делегат отдаст нам все доступные характеристики для полученных сервисов в didDiscoverCharacteristicsForService. Теперь мы можем прочитать значения,
которые там содержатся (readValueForCharacteristic) или попросить сообщать нам, как только там что-то изменится — setNotifyValue.

peripheral.discoverCharacteristics(nil, forService: (service as CBService))

func peripheral(peripheral: CBPeripheral!,
didDiscoverCharacteristicsForService service: CBService!, error: NSError!)

peripheral.readValueForCharacteristic(characteristic)

peripheral.setNotifyValue(true, forCharacteristic: characteristic)

func peripheral(peripheral: CBPeripheral!, didUpdateValueForCharacteristic
characteristic: CBCharacteristic!, error: NSError!)

В отличие от Android, Apple не различает чтение и уведомление. То есть мы не знаем, что происходит — мы считываем что-то с устройства или это устройство что-то говорит нам.

Запись на устройство


У нас есть устройство, мы читаем информацию с него, управляем им. А значит, можем записывать на него информацию, как правило, — обычную NSData. Только нужно выяснить, что это устройство ждет от нас и что будет им принято.

Большинство BLE-устройств поставляются со спецификацией, своего рода API, из которого понятно, как с ними “общаться”. Можно вытащить данные из характеристик, чтобы получить хотя бы примерное представление о том, что устройство ждет от нас.

Из спецификаций мы узнаем, в каких характеристиках какие свойства мы читаем, а в какие пишем, будем ли мы оповещены об изменениях (isNotifying). Чаще всего здесь мы найдем все, что потребуется для работы.

peripheral.writeValue(data: NSData!, forCharacteristic: CBCharacteristic!,
type: CBCharacteristicWriteType)

characteristic.properties - OptionSet type

characteristic.isNotifying

func peripheral(peripheral: CBPeripheral!, didWriteValueForCharacteristic
characteristic: CBCharacteristic!, error: NSError!)

В процессе записи делегат сообщит нам, что все прошло удачно (didWriteValueForCharacteristics), что нужное значение обновилось, а мы можем сказать об этом пользователю или использовать эту информацию иначе.

Мы рассматриваем тему в очень узком срезе, полагаясь на реализацию Apple, поэтому есть ряд проблем, с которыми придется столкнуться. Например, очень сильная зависимость от делегирования, так любимого Apple.

Наследование CBPeripheral? Если бы все было так легко


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

Выглядит как наследование: у нас есть частный случай чего-то общего. Из моего опыта могу сказать, что при использовании наследования что-то будет работать совсем не так, как ожидается, что-то совсем не будет работать, и вы не будете знать почему. В общем я бы предостерег вас от идеи наследования CBPeripheral. Что же делать?

Советую добавлять CBPeripheral в конструктор объекта, который будет им управлять. Это инкапсулирует его внутри этого класса. Используйте его для взаимодействия с устройством, удержания сильной ссылки на него, чтобы iOS не обрывала соединение. Но самое важное — этот класс будет использоваться в качестве делегата, иначе будет сложно управлять всеми устройствами в одном месте, это грозит кучей if else.

Подключение и работа с CBPeripheralDelegate


И вот мы подключаемся к устройству и хотим быть CBPeripheralDelegate. Есть ещё один нюанс: пока вы работаете с устройством, “опрашиваете” его сервисы и характеристики, читаете и пишете в них, практически все общение происходит с перифералом. Всё кроме подключения.

Естественно нам хотелось бы сосредоточить все общение в одном месте, но менеджер должен быть в курсе происходящего с устройством. И сложность в том, чтобы иметь один источник правды, сделать так, чтобы все были своевременно информированы о том, что творится с устройством. Для этого будем наблюдать за состоянием периферала — оно может меняться от отключено (disconnected), подключается (connecting) и подключено (connected). Оно всегда расскажет вам о текущей ситуации. Остаётся подписаться на изменение статуса в нашем управляющем объекте, о котором я говорил ранее, это даст возможность общаться с устройством из одного места.

Определение близости


Очень важный момент, поскольку найти нормальную документацию на эту тему сложно. В случае с Apple и их iBeacons все просто, они говорят нам, как близко мы находимся к bluetooth-устройству.
К сожалению, нам не дают такого простого способа для работы со сторонними устройствами. И не один раз случалось так, что была необходимость определить ближайшее устройство. Также сложно понять, находится устройство в доступном диапазоне или нет. Иногда при поиске устройств оно может дать знать о себе всего один раз и пропасть, тогда попытка подключения будет безуспешной.

Мы применяем следующий способ: сохраняем стек с метками даты и уровня сигнала (RSSI) каждого сообщения, полученного в discoverPeripheral. Если кто-то сталкивался с CoreLocation, наш способ похож на то, как там хранятся метки времени и соответствующие координаты. Обычно, чем выше сигнал (RSSI) — тем ближе устройство. Понять же, находится устройство в доступном диапазоне или нет, — сложнее, отчасти потому что само это понятие достаточно гибкое. Для этого я использую средневзвешенное значение сигнала. Имейте в виду, что у подключенного устройства уровень сигнала нужно запрашивать вручную каждый раз, как потребуется его узнать.

Что дальше?


К сожалению, эта статья не сделает вас экспертом, если вы прочитали её и вам
стало интересно — обратите внимание на Apple’s CoreBluetooth Programming Guide, руководство не очень большое, но очень полезное. Ещё есть пара трансляций с WWDC 2012 (basic и advanced) и одна с 2013, но не волнуйтесь, с тех пор мало что изменилось.

Также есть видео с Altconf 2015, размещенное на сайте Realm, в котором делится опытом Джон Шьер, отличный парень и специалист.

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


  1. dimakey
    10.10.2018 14:20

    Интересно было бы почитать о протоколах передачи данных — об ограничениях размера блока, записываемого в характеристику и что делать, если нужно передать данные, не влезающие в ограничения размера пакета. Как делить на «пролезающие» куски, как контролировать целостность, как потом собирать, в каком виде передавать данные.


    1. Dimitrow Автор
      10.10.2018 17:28

      В нашем случае ограничения описаны в API, в рамках которого мы работаем, поэтому делить данные на куски не приходится. Интересно было бы самому столкнуться с такой задачей на практике.


    1. Balldir
      10.10.2018 17:50
      +2

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

      Максимальный размер одного пакета ATT(Atribute protocol) для LE 23 байта (Vol3 Part G: 5.2.1). Считать характеристику можно за один пакет размером ATT_MTU-1. Записать размером ATT_MTU-3 (Vol3 Part F: 3.2.9). Есть возможность читать большую характеристику за но не больше 512 байт и bt стек спрячет все особенности этого. Есть возможность писать длинные характеристики, но за частую бт стек на стороне микроконтроллеров это не прячет (на стороне iOS не знаю). Длинная запись делается через «prepare write».

      > Как делить на «пролезающие» куски

      Самый простой вариант делить по ATT_MTU-3

      > как контролировать целостность, как потом собирать, в каком виде передавать данные

      ATT базируется на L2CAP. L2CAP гарантирует целостность и порядок(Vol3 Part A: 1.1.1), но на Anrdoid первый и последний пакеты могут потеряться.

      Если надо передавать что-то большое то рекомендуют использовать L2CAP channel. API для L2CAP — стрим с контролем целостности и порядка но без формата.


      1. dimakey
        10.10.2018 18:04

        Спасибо. Довольно детально. У нас были проблемы, когда нужно некий девайс подключить к Wi-Fi — девайс подключен, как периферия и возвращает список видимых им сетей. Поскольку большого опыта работы с BT нет решили заворачивать данные в JSON в формате, вроде:

        {
        «resp»:«OK»,
        «ssids»:[
        {
        «ssid»:«HP-Setup>4b-M277 LaserJet»,
        «rssi»:"-62",
        «security»:«Open»
        }

        ]
        }

        И если этих сетей много, то данные просто обрезаются.


        1. Balldir
          10.10.2018 18:14

          Мы в похожем случае делали workaround но на девайсе:
          1. На девайсе есть характеристика CMD в которую можно писать
          2. На девайсе есть характеристика RSP с notify property
          3. На запись команды в CMD девайс отвечает нотификейшенами по до 20 байт. В этом случае в приложении эти пакеты необходимо склеивать, но размер ответа не ограничен.

          В целом для такого советуют использовать L2CAP channel, но с этим существуют проблемы: не на всех SDK API доступно, в iOS поддержка появилась не так давно, в Android с этим бывают удивительные проблемы.


          1. dimakey
            10.10.2018 18:20

            Ну да — мы тоже конвертим JSON в строку штатными средствами, делим на куски по 20 байт и собираем потом. Делаем прям топорно — последний пакет содержит в себе условно-уникальный End of Message «символ», чтобы понять, что данные закончились. Собираемся навернуть поверх какой-никакой контроль целостности (хотябы CRC). С удивлением обнаружили, что готового решения для iOS/Android/Embedded нет — нужно свой велосипед городить.