[Перевод] Android Bluetooth Low Energy (BLE) — готовим правильно, часть #2 (connecting/disconnecting)
Содержание
Часть #2 (connecting/disconnecting), вы здесь.
В предыдущей статье мы подробно рассмотрели сканирование устройств. Эта статья – о подключении, отключении и обнаружении сервисов (discovering services).
Подключение к устройству
После удачного сканирования, вы должны подключиться к устройству, вызывая метод connectGatt()
. В результате мы получаем объект – BluetoothGatt
, который будет использоваться для всех GATT операций, такие как чтение и запись характеристик. Однако будьте внимательны, есть две версии метода connectGatt()
. Поздние версии Android имеют еще несколько вариантов, но нам нужна совместимость с Android-6 поэтому мы рассматриваем только эти две:
BluetoothGatt connectGatt(Context context, boolean autoConnect,
BluetoothGattCallback callback)
BluetoothGatt connectGatt(Context context, boolean autoConnect,
BluetoothGattCallback callback, int transport)
Внутренняя реализация первой версии – это фактически вызов второй версии с аргументом transport = TRANSPORT_AUTO
. Для подключения BLE устройств такой вариант не подходит. TRANSPORT_AUTO
используется для устройств с поддержкой и BLE и классического Bluetooth протоколов. Это значит, что Android будет сам выбирать протокол подключения. Этот момент практически нигде не описан и может привести к непредсказуемым результатам, много людей сталкивались с такой проблемой. Вот почему вы должны использовать вторую версию connectGatt()
с transport = TRANSPORT_LE
:
BluetoothGatt gatt = device.connectGatt(context, false,
bluetoothGattCallback, TRANSPORT_LE);
Первый аргумент – context
приложения. Второй аргумент – флаг autoconnect
, говорит подключаться немедленно (false
) или нет (true
). При немедленном подключении (false
) Android будет пытаться соединиться в течение 30 секунд (на большинстве смартфонов), по истечении этого времени придет статус соединения status_code = 133
. Это не официальная ошибка для таймаута соединения. В исходниках Android код фигурирует как GATT_ERROR
. К сожалению, эта ошибка появляется и в других случаях. Имейте ввиду, с autoconnect = false
Android делает соединение только с одним устройством в одно и то же время (это значит если у вас несколько устройств – подключайте их последовательно, а не паралелльно). Третий аргумент – функция обратного вызова BluetoothGattCallback
(callback) для конкретного устройства. Этот колбек используется для всех связанных с устройством операциях, такие как чтение и запись. Мы рассмотрим это более детально в следующей статье.
Autoconnect = true
Если вы установите autoconnect = true
, Android будет подключаться самостоятельно к устройству всякий раз, когда оно будет обнаружено. Внутри это работает так: Bluetooth стек сканирует сохраненные устройства и когда увидит одно из них – подключается к нему. Это довольно удобно, если вы хотите подключиться к конкретному устройству, когда оно становится доступным. Фактически, это предпочтительный способ для переподключения. Вы просто создаете BluetoothDevice
объект и вызываете connectGatt
с autoconnect = true
.
BluetoothDevice device =
bluetoothAdapter.getRemoteDevice("12:34:56:AA:BB:CC");
BluetoothGatt gatt =
device.connectGatt(context, true, bluetoothGattCallback, TRANSPORT_LE);
Обратите внимание, этот подход работает только, если устройство есть в Bluetooth кеше или устройство было уже сопряжено (bonding). Посмотрите мою предыдущую статью, где подробно объясняется работа с Bluetooth кешем. При перезагрузке смартфона или выключении/включении Bluetooth (а также Airplane режима) – кеш очистится, это надо проверять перед подключением с autoconnect = true
, что действительно раздражает.
Autoconnect работает только с закешированными и сопряженными (bonded) устройствами!
Для того, чтобы узнать, закешировано устройство или нет, можно использовать небольшой трюк. После создания объекта BluetoothDevice
, вызовите у него getType
, если результат – TYPE_UNKNOWN
, значит устройство не закешировано. В этом случае, необходимо просканировать устройство с этим мак-адресом (используя не агрессивный метод сканирования) и после этого можно использовать автоподключение снова.
Android-6 и ниже имеет известный баг, в котором возникает гонка состояний и автоматическое подключение становится обычным (autoconnect = false
). К счастью, умные ребята из Polidea нашли решение для этого. Настоятельно рекомендуется использовать его, если думаете использовать автоподключение.
Преимущества:
-
работает достаточно хорошо на современных версиях Android (прим. переводчика – от Android-8 и выше).
-
возможность подключаться к нескольким устройствам одновременно;
Недостатки:
-
работает медленнее, если сравнивать сканирование в агрессивном режиме + подключение с
autoconnect = false
. Потому что Android в этом случае сканирует в режимеSCAN_MODE_LOW_POWER
, экономя энергию.
Изменения статуса подключения
После вызова connectGatt()
, Bluetooth стек присылает результат в колбек onConnectionStateChange
, он вызывается при любом изменении соединения.
Работа с этим колбеком – достаточно нетривиальная вещь. Большинство простых примеров из сети выглядит так (не обольщайтесь):
public void onConnectionStateChange(final BluetoothGatt gatt,
final int status,
final int newState) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.discoverServices();
} else {
gatt.close();
}
}
Этот код обрабатывает только аргумент newState
и полностью игнорирует status
. В многих случаях это работает и кажется безошибочным. Действительно, после подключения, следующее что нужно сделать – это вызвать discoverServices()
. А в случае отключения – необходимо сделать вызов close()
, чтобы Android освободил все связанные ресурсы в стеке Bluetooth. Эти два момента очень важные для стабильной работы BLE под Android, давайте их обсудим прямо сейчас!
При вызове connectGatt()
, Bluetooth стек регистрирует внутри себя интерфейс для нового клиента (client interface: clientIf
).
Возможно вы заметили такие логи в LogCat:
D/BluetoothGatt: connect() - device: B0:49:5F:01:20:XX, auto: false
D/BluetoothGatt: registerApp()
D/BluetoothGatt: registerApp() — UUID=0e47c0cf-ef13–4afb-9f54–8cf3e9e808d5
D/BluetoothGatt: onClientRegistered() — status=0 clientIf=6
Здесь видно, что клиент 6
был зарегистрирован после вызова connectGatt()
. Максимальное количество клиентов (подключения) у Android равно 30 (константа GATT_MAX_APPS
в исходниках), при достижении которого – Android не будет подключаться к устройствам вообще и вы будете получать постоянно ошибку подключения. Достаточно странно, но сразу после загрузки Android уже имеет 5 или 6 таких подключенных клиентов, предполагаю, что Android использует их для внутренних нужд. Таким образом, если вы не вызываете метод close()
, то счетчик клиентов увеличивается каждый раз при вызове connectGatt()
. Когда вы вызываете close()
, Bluetooth стек удаляет ваш колбек, счетчик клиентов уменьшается на единицу и освобождает ресурсы клиента.
D/BluetoothGatt: close()
D/BluetoothGatt: unregisterApp() — mClientIf=6
Важно всегда вызывать close()
после отключения! А сейчас обсудим основные случаи дисконнекта устройств.
Состояние подключения (newState)
Переменная newState
содержит новое состояние подключения и может иметь 4 значения:
-
STATE_CONNECTED
-
STATE_DISCONNECTED
-
STATE_CONNECTING
-
STATE_DISCONNECTING
Значения говорят сами за себя. Хотя состояния STATE_CONNECTING
, STATE_DISCONNECTING
есть в документации, на практике я их не встречал. Так что, в принципе, можно не обрабатывать их, но для уверенности, я предлагаю их явно учитывать (прим. переводчика – и это лучше, чем не обрабатывать их), вызывая close()
только в том случае если устройство действительно отключено.
Статус подключения (status)
В примере выше, переменная статуса status
полностью игнорировалась, но в действительности обрабатывать ее важно. Эта переменная, по сути, является кодом ошибки. Вы можете получить GATT_SUCCESS
в результате как подключения, так и контролируемого отключения. Таким образом, мы можем по-разному обрабатывать контролируемое или внезапное отключение устройства. Если вы получили значение отличное от GATT_SUCCESS
, значит «что-то пошло не так» и в status
будет указана причина. К сожалению, объект BluetoothGatt
дает очень мало кодов ошибок, все они описаны здесь. Чаще всего вы будете встречаться с кодом 133 (GATT_ERROR
). Который не имеет точного описания, и просто говорит – «произошла какая-то ошибка». Не очень информативно, подробнее об GATT_ERROR
позже.
Теперь мы знаем, что обозначают переменные newState
и status
, давайте улучшим наш колбек onConnectionStateChange
:
public void onConnectionStateChange(final BluetoothGatt gatt,
final int status,
final int newState) {
if(status == GATT_SUCCESS) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
// Мы подключились, можно запускать обнаружение сервисов
gatt.discoverServices();
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
// Мы успешно отключились (контролируемое отключение)
gatt.close();
} else {
// мы или подключаемся или отключаемся, просто игнорируем эти статусы
}
} else {
// Произошла ошибка... разбираемся, что случилось!
...
gatt.close();
}
Это не последний вариант, мы еще улучшим колбек в этой статье. В любом случае, теперь у нас есть обработка ошибок и успешных операций.
Состояние bonding (bondState)
Последний параметр, который необходимо учитывать в колбеке onConnectionStateChange
– это bondState
, состояние сопряжения (bonding) с устройством. Мы получаем этот параметр так:
int bondstate = device.getBondState();
Состояние bonding может иметь одно из трех значений BOND_NONE
, BOND_BONDING
or BOND_BONDED
. Каждое из них влияет на то, как обрабатывать подключение.
-
BOND_NONE
, нет проблем, можно вызыватьdiscoverServices()
; -
BOND_BONDING
, устройство в процессе сопряжения, нельзя вызыватьdiscoverServices()
, так как Bluetooth стек в работе и запускdiscoverServices()
может прервать сопряжение и вызвать ошибку соединения.discoverServices()
вызываем только после того, как пройдет сопряжение (bonding); -
BOND_BONDED
, для Android-8 и выше, можно запускатьdiscoverServices()
без задержки. Для версий 7 и ниже может потребоваться задержка перед вызовом. Если ваше устройство имеет Service Changed Characteristic, то Bluetooth стек в этот момент еще обрабатывает их и запускdiscoverServices()
без задержки может вызвать ошибку соединения. Добавьте 1000-1500мс задержки, конкретное значение зависит от количества характеристик на устройстве. Используйте задержку всегда, если вы не знаете сколько Service Changed Characteristic имеет устройство.
Теперь мы можем учитывать состояние bondState
вместе с status
и newState
:
if (status == GATT_SUCCESS) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
int bondstate = device.getBondState();
// Обрабатываем bondState
if(bondstate == BOND_NONE || bondstate == BOND_BONDED) {
// Подключились к устройству, вызываем discoverServices с задержкой
int delayWhenBonded = 0;
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
delayWhenBonded = 1000;
}
final int delay = bondstate == BOND_BONDED ? delayWhenBonded : 0;
discoverServicesRunnable = new Runnable() {
@Override
public void run() {
Log.d(TAG, String.format(Locale.ENGLISH, "discovering services of '%s' with delay of %d ms", getName(), delay));
boolean result = gatt.discoverServices();
if (!result) {
Log.e(TAG, "discoverServices failed to start");
}
discoverServicesRunnable = null;
}
};
bleHandler.postDelayed(discoverServicesRunnable, delay);
} else if (bondstate == BOND_BONDING) {
// Bonding в процессе, ждем когда закончится
Log.i(TAG, "waiting for bonding to complete");
}
....
Обработка ошибок
После того как мы разобрались с успешными операциями, давайте взглянем на ошибки. Есть ряд ситуаций, которые на самом деле «нормальные», но выдают себя за ошибки.
-
Устройство отключилось намеренно. Например, все данные были переданы и больше ему нечего делать. Вы получите статус – 19 (
GATT_CONN_TERMINATE_PEER_USER
); -
Истекло время ожидания соединения и устройство отключилось само. В этом случае придет статус – 8 (
GATT_CONN_TIMEOUT
); -
Низкоуровневая ошибка соединения, которая привела к отключению. Обычно это статус – 133 (
GATT_ERROR
) или более конкретный код, если повезет; -
Bluetooth стек не смог подключится ни разу. Здесь также получим статус – 133 (
GATT_ERROR
); -
Соединение было потеряно в процессе
bonding
илиdiscoverServices
. Необходимо выяснить причину и возможно повторить попытку подключения.
Первые два случая абсолютно нормальные явления и все что нужно сделать – это вызывать close()
и подчистить ссылки на объект BluetoothGatt
, если необходимо. В остальных случаях, либо ваш код, либо устройство, что-то делает не так. Вы возможно захотите уведомить UI или другие части приложения о проблеме, повторить подключение или еще каким-то образом отреагировать на ситуацию. Взгляните как я сделал это в моей библиотеке.
Статус 133 при подключении (connecting)
Статус – 133 часто встречается при попытках подключиться к устройству, особенно во время разработки. Этот статус может иметь множество причин, некоторые из них можно контролировать:
-
Убедитесь, что вы всегда вызываете
close()
при отключении. Если этого не сделать, в следующий раз при подключении вы точно получитеstatus=133
; -
Всегда используйте
TRANSPORT_LE
в вызовеconnectGatt()
; -
Перезагрузите смартфон. Возможно Bluetooth стек выбрал лимит по клиентским подключениям или есть внутренняя проблема. (Прим. переводчика: я сначала выключал/включал Bluetooth, потом Airplane режим и если не помогало – перезагружал);
-
Проверьте что устройство посылает advertising пакеты. Вызов
connectGatt()
сautoconnect = false
имеет таймаут 30 секунд, после чего присылает ошибкуstatus=133
; -
Замените/зарядите батарею на устройстве. Обычно устройства работают нестабильно при низком заряде;
Если вы попробовали все способы выше и все еще получаете статус 133, необходимо просто повторить подключение! Это одна из Android ошибок, которую мне так и не удалось понять или решить. Иногда вы получаете 133 при подключении к устройству, но если вызывать close()
и переподключиться, то все работает без проблем! Есть подозрение, что проблема в кеше Android и вызов close()
сбрасывает его состояние для конкретного устройства. Если кто-нибудь поймет, как решить эту проблему – дайте мне знать!
Отключение по запросу (disconnect)
Для отключения устройства вам необходимо сделать шаги:
-
вызвать
disconnect()
; -
подождать обновления статуса в
onConnectionStateChange
; -
вызвать
close()
; -
освободить связанные с объектом gatt ресурсы;
Команда disconnect()
фактически разрывает соединение с устройством и обновляет внутреннее состояние Bluetooth стека. Затем вызывается колбек onConnectionStateChange
с новым состоянием «disconnected».
Вызов close()
удаляет ваш BluetoothGattCallback
и освобождает клиента в Bluetooth стеке.
Наконец, удаление BluetoothGatt
освободит все связанные с подключением ресурсы.
Отключение «неправильно»
В примерах из сети можно увидеть, разные примеры отключения, например:
-
вызвать
disconnect()
-
сразу вызвать
close()
Это будет работать более-менее. Да устройство отключится, но вы никогда не получите вызов колбека с состоянием «disconnected». Дело в том, что disconnect()
операция асинхронная (не блокирует поток и имеет свое время выполнения), а close()
немедленно удаляет коллбек! Получается, когда Android будет готов вызвать колбек, его уже не будет.
Иногда в примерах не вызывают disconnect()
, а только close()
. Это приведет к отключению устройства, но это неправильный способ, поскольку disconnect()
отключает активное соединение и отменяет ожидающее автоматическое подключение (вызов с autoconnect = true
). Поэтому, если вы вызываете только close()
, любое ожидающее автоподключение может привести к новому подключению.
Отмена попытки подключения
Если вы хотите отменить подключение после connectGatt()
, вам нужно вызвать disconnect()
. Так как в этому моменту вы еще не подключены, колбек onConnectionStateChange
не сработает! Просто подождите некоторое время после disconnect()
и после этого вызывайте close()
(прим. переводчика: обычно это 50-100мс).
При удачной отмене вы увидите примерно такое в логах:
D/BluetoothGatt: cancelOpen() — device: CF:A9:BA:D9:62:9E
Скорее всего, вы никогда не отмените соединение, для параметра autoconnect = false
. Часто это делается для подключений с autoconnect = true
. Например, когда приложение на переднем плане – вы подключаетесь к вашим устройствам и отключаетесь от них, если приложение переходит в фон.
Прим. переводчика: но это не значит что для autoconnect = false
не надо проводить такую отмену!
Обнаружение сервисов (discovering services)
Как только вы подключились к устройству, необходимо запустить обнаружение его сервисов вызовом discoverServices()
. Bluetooth стек запустит серию низкоуровневых команд для получения сервисов, характеристик и дескрипторов. Это занимает обычно около одной секунды в зависимости от того сколько таких служб, характеристик, дескрипторов имеет ваше устройство. В результате будет вызыван колбек onServicesDiscovered.
Первым делом проверим, есть ли какие ошибки после обнаружения сервисов:
// Проверяем есть ли ошибки? Если да - отключаемся
if (status == GATT_INTERNAL_ERROR) {
Log.e(TAG, "Service discovery failed");
disconnect();
return;
}
Если есть ошибки (обычно это GATT_INTERNAL_ERROR
со значением 129), делаем отключение устройства, что-то исправить здесь невозможно (нет специальных технических способов для этого). Вы просто отключаете устройство и повторно пробуете подключиться.
Если все прошло удачно, вы получите список сервисов:
final List<BluetoothGattService> services = gatt.getServices();
Log.i(TAG, String.format(Locale.ENGLISH,"discovered %d services for '%s'", services.size(), getName()));
// Работа со списком сервисов (если требуется)
...
Кеширование сервисов.
Bluetooth стек кеширует найденные на устройстве сервисы, характеристики и дескрипторы. Первое подключение вызывает реальное обнаружение сервисов, все последующие – возвращаются кешированные версии. Это соответствует стандарту Bluetooth. Обычно это нормально и сокращает время соединения с устройством. Однако в некоторых случаях, может потребоваться очистить кеш, чтобы снова обнаружить их с устройства при следующем соединении. Типичный сценарий: обновление прошивки, в которой изменяется набор сервисов, характеристик, дескрипторов. Есть скрытый метод очистки кеша и добраться до него нам поможет механизм рефлексии Java:
private boolean clearServicesCache() {
boolean result = false;
try {
Method refreshMethod = bluetoothGatt.getClass().getMethod("refresh");
if(refreshMethod != null) {
result = (boolean) refreshMethod.invoke(bluetoothGatt);
}
} catch (Exception e) {
Log.e(TAG, "ERROR: Could not invoke refresh method");
}
return result;
}
Этот метод асинхронный, дайте ему некоторое время для завершения!
Странные штуки в подключении/отключении
Хотя операции подключения и отключения выглядят просто, есть некоторые особенности, которые нужно знать.
-
Случайная ошибка 133 при подключении, выше мы разобрались как с ней работать;
-
Периодическое зависание подключения, не срабатывает таймаут и не вызывается колбек
onConnectionStateChange
. Это случается не часто, но я видел такие случае при низком уровне батареи или когда устройство находится на границе доступности по расстоянию Bluetooth. Скорее всего общение с устройством происходит, но затем прерывается и зависает. Мой обходной путь – использовать свой таймер подключения и в случае таймаута – закрывать соединение и отключаться; -
Некоторые смартфоны имеют проблему с подключением во время сканирования. Например, Huawei P8 Lite один из таких. Останавливаем сканнер перед любым подключением (Прим. переводчика: это правило соблюдаем строго!);
-
Все вызовы подключения/отключения асинхронные. То есть неблокирующие, но при этом им нужно время, чтобы выполнится до конца. Избегайте быстрый запуск их друг за другом (Прим. переводчика: я обычно использую задержку 50-100мс между вызовами).
Следующая статья: чтение и запись характеристик.
Теперь мы разобрались с подключением/отключением и обнаружением сервисов, следующая статья – о том, как работать с характеристиками.
Не терпится поработать с BLE? Попробуйте мою библиотеку Blessed for Android. Она использует все подходы из этой серии статей и упрощает работу с BLE в вашем приложении.