Android, работа с BLE - часть 3.
Перевод статьи Making Android BLE work — part 3.
внимание: в цикле статей используется минимальная версия - Android 6
В предыдущей статье мы подробно поговорили о подключении/отключении BLE устройств. Эта статья о чтении и записи характеристик, а также включение-выключение уведомлений.
Чтение и запись характеристик
Многие разработчики, которые начинают работать с BLE на Android, сталкиваются с проблемами чтения/записи BLE характеристик. На Stackoverflow полно людей, предлагающих просто использовать задержки… Большинство таких советов неверные.
Есть две основные причины проблем:
- Операции чтения/записи асинхронные. Это значит, что вызов метода вернется немедленно, но результат вызова вы получите немного позже – в соответствующих колбеках. Например
onCharacteristicRead()
илиonCharacteristicWrite()
. - Одновременно может быть запущена только одна операция. Нужно дождаться выполнения текущей операции, и затем, запускать следующую. В исходном коде
BluetoothGatt
есть блокирующая переменная, которая при запуске операции устанавливается и при вызове колбека сбрасывается. Google забыла про это упомянуть в документации… (Прим. переводчика: речь идет оmDeviceBusy
иmDeviceBusyLock
здесь).
Первая причина, на самом деле, не является проблемой, такова природа BLE. Асинхронное программирование это распространенная штука, используется, например, при сетевых вызовах. Однако вторая причина раздражает и требует специального подхода.
Ниже кусок кода BluetoothGatt.java
с блокировкой переменной mDeviceBusy
, перед чтением характеристики:
Когда приходит результат чтения/записи, переменная mDeviceBusy
сбрасывается в false снова:
Используем очередь
Выполнять чтение/запись по одной операции за раз неудобно, но любое сложное приложение должно это учитывать. Решение этой проблемы - использование очереди команд. Все BLE библиотеки, которые я ранее упоминал, так или иначе реализуют очередь. Это одна из лучших практик!
Идея простая – каждая команда сначала добавляется в очередь. Затем команда забирается из очереди на исполнение, после результата, команда помечается как «завершенная» и, удаляется из очереди. Запускать команды можно в любое время, но они выполняются точно в том порядке, в котором поступают в очередь. Это очень упрощает разработку под BLE. В iOS аналогично работает фреймворк CoreBluetooth
(Прим. переводчика: который намного удобнее, чем реализация Bluetooth стека в Android).
Очередь создается для каждого объекта BluetoothGatt
. К счастью, Android сможет обрабатывать очереди от нескольких объектов BluetoothGatt
, вам не нужно об этом беспокоиться (Прим. переводчика: у меня это не сработало, я использовал глобальную очередь команд для всех устройств). Есть много способов создать очередь, мы будем использовать простую очередь Queue
с Runnable
для каждой команды и переменной commandQueueBusy
для отслеживания работы команды:
Мы добавляем новый экземпляр Runnable
в очередь при выполнении команды. Ниже пример чтения характеристики (readCharacteristic):
В этом методе, сначала проверяем все ли готово для выполнения (наличие и тип характеристики) и логгируем ошибки, если они есть. Внутри Runnable
, фактически вызывается метод readCharacteristic()
, который выдает команду на устройство. Мы также отслеживаем сколько было попыток, чтобы сделать повтор в случае ошибки (Прим. переводчика: это лучшая тактика, чтобы добиться стабильной работы с устройством). Если чтение характеристики возвращает false
, мы логгируем ошибку, «завершаем» команду, чтобы можно было запустить следующую. Наконец вызывается nextCommand()
, чтобы запустить следующую команду из очереди:
Обратите внимание, мы используем метод peek()
для получения объекта Runnable
из очереди, чтобы можно было повторить запуск позже. Этот метод не удаляет объект из очереди.
Результат чтения будет отправлен в ваш колбек:
Мы завершаем команду completedCommand()
после обработки нового значения. Это помогает избежать одновременный вызов другой команды и состояния гонки.
Теперь мы готовы завершить команду, убираем Runnable
из очереди через вызов poll()
и запускаем следующую из очереди:
В некоторых случаях (ошибка, неожиданное значение), вам нужно будет повторить команду. Сделать это просто, так как объект Runnable
остается в очереди до вызова completedCommand()
. Чтобы не уйти в бесконечное повторение – проверяем лимит на повторы:
Запись характеристик
Чтение характеристики достаточно простая операция, а запись требует дополнительных пояснений. Для выполнения записи нужно предоставить характеристику, массив байтов и тип записи. Существует несколько типов записи, важные для нас это:
WRITE_TYPE_DEFAULT
(вы получите ответ от устройства, например, код завершения);WRITE_TYPE_NO_RESPONSE
(никакого ответа от устройства не будет).
Использовать тот или иной тип зависит от вашего устройства и характеристики (иногда она поддерживает оба типа записи, иногда только один конкретный тип).
В Android каждая характеристика имеет дефолтный тип записи, который определяется при ее создании. Ниже фрагмент кода из исходников Android, где определяется тип:
Как вы видите, это работает нормально, если характеристика поддерживает только один их двух типов записи. Если характеристика поддерживает оба типа, то значение по умолчанию будет WRITE_TYPE_NO_RESPONSE
. Имейте это ввиду!
Перед записью можно проверить характеристику, поддерживает ли она нужный тип записи:
Я рекомендую всегда явно указывать тип записи и не полагаться на дефолтные настройки выбранные Android!
Итак, запись массива байтов bytesToWrite
в характеристику выглядит так:
Включение/выключение уведомлений
Кроме самостоятельного чтения и записи характеристик, вы можете включить или отключить уведомления от устройств. При включении уведомления, устройство сообщит вам о появлении новых данных и отправит их автоматически.
Для включения уведомлений нужно сделать две вещи в Android:
- вызвать
setCharacteristicNotification
. Bluetooth стек будет ожидать уведомления для этой характеристики. - записать 1 или 2 как
unsigned int16
в дескриптор конфигурации характеристик (Client Characteristic Configuration, сокращенно - ССС). Дескриптор CCC имеет короткий UUID 2902.
Почему 1 или 2? Потому что «под капотом» Bluetooth стека есть Уведомление и Индикация. Полученное Уведомление не подтверждаются стеком Bluetooth, а Индикация наоборот – подтверждается стеком. При использовании Индикации, устройство будет точно знать, что данные получены и может их, например, удалить из локального хранилища. С точки зрения Android приложения нет разницы: в обоих случаях вы просто получите массив байтов и Bluetooth стек уведомит устройство об этом, если вы используете Индикацию. Итак, 1 включает уведомления, 2 – индикацию. Чтобы выключить их, записываем 0. Вы должны самостоятельно определить, что записать в дескриптор CCC.
В iOS метод setNotify()
делает всю работу за вас. Ниже пример, как сделать тоже самое на Android, там сначала идут проверки входных параметров, определяется что записать в дескриптор и, наконец команда отправляется в очередь:
Результат записи в CCC дескриптор обрабатывается в колбеке onDescriptorWrite
. Здесь вы должны отличить запись в CCC от записей в другие дескрипторы. Во время обработки колбека, мы также должны хранить, какие в данный момент характеристики уведомляются.
Чтобы узнать из какой характеристики пришло уведомление – используйте метод isNotifying()
:
Лимиты на установку уведомлений
К сожалению, нельзя включить столько уведомлений, сколько хочешь. Начиная с Android-5 лимит равен 15. В более старых версиях он был равен 7 или даже 4. Большинство смартфонов поддерживают 15 уведомлений. Не забывайте отключать их, если они вам больше не нужны, чтобы не исчерпать лимит.
Проблемы с потоками
Итак, мы научились читать/писать характеристики, включать/выключать уведомления, а значит готовы использовать это в реальном проекте. Я думаю, что устройства BLE можно разделить на две категории:
- Простые устройства. Например, термометр, который использует официальный Bluetooth Health Thermometer сервис. Такие устройства легко использовать, вы просто включаете уведомления и данные начинают поступать. Здесь мы используем только операции чтения характеристики, запись не нужна;
- Сложные устройства. Это может быть любое устройство, но обычно все они используют свой внутренний протокол обмена данными. Часто эти протоколы не спроектированы под BLE, а просто транслируют внутренний последовательный протокол в BLE, где одна характеристика используется для отправки данных, а другая для приема. Сложность в том, что вам требуется знать большое количество команд для работы с устройством: авторизация, обновление пользовательских параметров, параметров самого устройства, получение сохраненных данных и т.д.
Простые устройства обычно не создают проблем с потоками, для сложных – следует работать внимательно. Чтение, запись и уведомления в этом случае будут чередоваться и могут мешать друг другу, особенно если у вас устройство с высокой частотой передачи данных (30Hz или около).
Типичная проблема с потоками выглядит так:
- приходит уведомление
- вы отправляете событие в свою собственную очередь для обработки
- запускается обработку полученных данных
- в это время приходит новое уведомление и перезаписывает предыдущее значение в
BluetoothGattCharacteristic
- если ваша обработка данных медленная, вы потенциально теряете значение из первого уведомления.
Причины такого поведения:
- как только сообщение доставлено, Android будет отправлять следующее (если оно есть). Посколько обработка данных отправляется в другой поток, текущий освобождается и Android продолжит доставку уведомлений;
- Android переиспользует BluetoothGattCharacteristic объекты внутри. Они создаются в время обнаружения сервисов (services discovering) и после этого переспользуются многократно. Таким образом, когда приходит уведомления Android сохраняет значение в объект
BluetoothGattCharacteristic
. Если характеристика в этот момент обрабатывается в другом потоке мы получим гонку состояний (race condition) и результат будет непредсказуемым.
Очевидно, что нужно всегда работать с копией массива байтов. Получили данные, сразу же делаем копию и работаем с ней.
Ниже пример, который использует такую тактику:
Другие рекомендации по работе с потоками
Есть несколько дополнительных рекомендаций по работе с BLE на Android. Поскольку стек BLE в основном асинхронный, у нас есть мульти-поточная обработка задач.
Android использует потоки:
- При сканировании (результаты приходят в
main
поток); - Вызове колбеков
BluetoothGattCallback
(выполняются в потокахBinder
);
Обработка результатов сканирования на main потоке не будет проблемой. Но с потоками Binder все немного сложнее. При вызове колбека на потоке Binder, Android не будет отправлять новые данные пока не закончится обработка текущих, то есть поток Binder блокируется пока ваш код не завершится. Следует избегать тяжелых операций в колбеках, никаких sleep()
или что-то подобное. Кроме того, никаких новых вызовов в объекте BluetoothGatt
, пока вы находитесь в потоке Binder, хотя большинство методов асинхронные.
Я рекомендую следующее:
- Всегда выполняйте вызовы
BluetoothGattCallback
в отдельном потоке, возможно даже из потока пользовательского интерфейса (Прим. переводчика: работать на main потоке - плохая идея, если у вас есть активный обмен с устройством, обязательно будут залипания UI, не делайте так); - Освобождайте потоки
Binder
как можно быстрее и никогда не блокируйте их;
Самый простой способ выполнить рекомендации выше – создать выделенный Handler
и использовать его для обработки данных и выдачи новых команд. Обратите внимание, я уже использовал Handler
на примере кода для колбека onCharacteristicUpdate
.
Объявление объекта:
Если хотите запустить Handler
на main
потоке:
Прокрутите назад и взгляните на наш метод nextCommand()
, каждый Runnable
выполняется в нашем собственном Handler
, следовательно, мы гарантируем, что все команды выполняются вне потока Binder
.
Следующая статья: сопряжение (bonding)
В этой статье мы разобрались с чтением и записью характеристик, включением и выключением уведомлений/нотификаций. В следующей статье, мы детально изучим процесс спряжения с устройством (bonding).
Не терпится поработать с BLE? Попробуйте мою библиотеку Blessed for Android. Она использует все подходы из этой серии статей и упрощает работу с BLE в вашем приложении.