Перевод статьи Making Android BLE work — part 2.

внимание: в цикле статей используется минимальная версия - Android 6

В предыдущей статье мы подробно рассмотрели сканирование устройств. Эта статья - о подключении, отключении и обнаружении сервисов (discovering services).

devices

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

После удачного сканирования, вы должны подключиться к устройству, вызывая метод 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 объект и вызываете connectGattwith с 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 и выше);
  • возможность подключаться к нескольким устройствам одновременно;

Недостатки:

  • работает медленнее (Android в этом случае сканирует в режиме SCAN_MODE_LOW_POWER, экономя энергию), если сравнивать сканирование в агрессивном режиме + подключение с autoconnect = false;

Изменения статуса подключения

После вызова 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. Обычно это нормально и сокращает время соединения с устройством. Однако в некоторых случаях, может потребоваться очистить кеш, чтобы снова обнаружить их с устройства при следующем соединении. Типичный сценарий: обновление прошивки, в которой изменяется набор сервисов, характеристик, дескрипторов. Есть скрытый метод очистки кеша и добраться до него нам поможет рефлексия:

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 в вашем приложении.