Русский
Русский
English
Статистика
Реклама

Перевод Android Bluetooth Energy (BLE) готовим правильно, часть 3 (readwrite)

Содержание

Часть #1 (scanning)

Часть #2 (connecting/disconnecting)

Часть #3 (read/write), вы здесь

Впредыдущей статьемы подробно поговорили о подключении/отключении BLE устройств. Эта статья очтенииизаписихарактеристик, а такжевключении-выключении уведомлений.

Чтение и запись характеристик

Многие разработчики, которые начинают работать с BLE на Android, сталкиваются с проблемами чтения/записи BLE характеристик. НаStackoverflowполно людей, предлагающих просто использовать задержки Большинство таких советов неверные.

Есть две основные причины проблем:

  • Операции чтения/записи асинхронные. Это значит, что вызов метода вернется немедленно, но результат вызова вы получите немного позже в соответствующих колбеках. НапримерonCharacteristicRead()илиonCharacteristicWrite().

  • Одновременно может быть запущена только одна операция. Нужно дождаться выполнения текущей операции, и затем, запускать следующую. В исходном кодеBluetoothGattесть блокирующая переменная, которая при запуске операции устанавливается и при вызове колбека сбрасывается. Google забыла про это упомянуть в документации (Прим. переводчика: речь идет оmDeviceBusyиmDeviceBusyLockздесь).

Первая причина, на самом деле, не является проблемой, такова природа BLE. Асинхронное программирование это распространенная штука, используется, например, при сетевых вызовах. Однако вторая причина раздражает и требует специального подхода.

Ниже кусок кодаBluetoothGatt.javaс блокировкой переменнойmDeviceBusy, перед чтением характеристики:

public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) {    if ((characteristic.getProperties()             & BluetoothGattCharacteristic.PROPERTY_READ) == 0) {        return false;    }    if (VDBG) Log.d(TAG, "readCharacteristic() - uuid: " + characteristic.getUuid());    if (mService == null || mClientIf == 0) return false;    BluetoothGattService service = characteristic.getService();    if (service == null) return false;    BluetoothDevice device = service.getDevice();    if (device == null) return false;    synchronized (mDeviceBusy) {        if (mDeviceBusy) return false;        mDeviceBusy = true;    }    try {        mService.readCharacteristic(mClientIf, device.getAddress(),                characteristic.getInstanceId(), AUTHENTICATION_NONE);    } catch (RemoteException e) {        Log.e(TAG, "", e);        mDeviceBusy = false;        return false;    }    return true;}

Когда приходит результат чтения/записи, переменнаяmDeviceBusyсбрасывается в false снова:

public void onCharacteristicRead(String address,                                 int status,                                 int handle,                                 byte[] value) {    if (VDBG) {        Log.d(TAG, "onCharacteristicRead() - Device=" + address                + " handle=" + handle + " Status=" + status);    }    if (!address.equals(mDevice.getAddress())) {        return;    }    synchronized (mDeviceBusy) {        mDeviceBusy = false;    }....

Используем очередь

Выполнять чтение/запись по одной операции за раз неудобно, но любое сложное приложение должно это учитывать. Решение этой проблемы - использованиеочереди команд. Все BLE библиотеки, которые я ранее упоминал, так или иначе реализуют очередь. Это одна из лучших практик! Идея простая каждая команда сначала добавляется в очередь. Затем команда забирается из очереди на исполнение, после результата, команда помечается как завершенная и, удаляется из очереди. Запускать команды можно в любое время, но они выполняются точно в том порядке, в котором поступают в очередь. Это очень упрощает разработку под BLE. В iOS аналогично работает фреймворкCoreBluetooth(Прим. переводчика: который намного удобнее, чем реализация Bluetooth стека в Android).

Очередь создается для каждого объектаBluetoothGatt. К счастью, Android сможет обрабатывать очереди от нескольких объектовBluetoothGatt, вам не нужно об этом беспокоиться (Прим. переводчика: у меня это не сработало, я использовал глобальную очередь команд для всех устройств). Есть много способов создать очередь, мы будем использовать простую очередьQueueсRunnableдля каждой команды и переменнойcommandQueueBusyдля отслеживания работы команды:

private Queue<Runnable> commandQueue;private boolean commandQueueBusy;

Мы добавляем новый экземплярRunnableв очередь при выполнении команды. Ниже пример чтения характеристики (readCharacteristic):

public boolean readCharacteristic(final BluetoothGattCharacteristic characteristic) {    if(bluetoothGatt == null) {        Log.e(TAG, "ERROR: Gatt is 'null', ignoring read request");        return false;    }    // Check if characteristic is valid    if(characteristic == null) {        Log.e(TAG, "ERROR: Characteristic is 'null', ignoring read request");        return false;    }    // Check if this characteristic actually has READ property    if((characteristic.getProperties() & PROPERTY_READ) == 0 ) {        Log.e(TAG, "ERROR: Characteristic cannot be read");        return false;    }    // Enqueue the read command now that all checks have been passed    boolean result = commandQueue.add(new Runnable() {        @Override        public void run() {            if(!bluetoothGatt.readCharacteristic(characteristic)) {                Log.e(TAG, String.format("ERROR: readCharacteristic failed for characteristic: %s", characteristic.getUuid()));                completedCommand();            } else {                Log.d(TAG, String.format("reading characteristic <%s>", characteristic.getUuid()));                nrTries++;            }        }    });    if(result) {        nextCommand();    } else {        Log.e(TAG, "ERROR: Could not enqueue read characteristic command");    }    return result;}

В этом методе, сначала проверяем все ли готово для выполнения (наличие и тип характеристики) и логгируем ошибки, если они есть. ВнутриRunnable, фактически вызывается методreadCharacteristic(), который выдает команду на устройство. Мы также отслеживаем сколько было попыток, чтобы сделать повтор в случае ошибки (Прим. переводчика: это лучшая тактика, чтобы добиться стабильной работы с устройством). Если чтение характеристики возвращаетfalse, мы логгируем ошибку, завершаем команду, чтобы можно было запустить следующую. Наконец вызываетсяnextCommand(), чтобы запустить следующую команду из очереди:

private void nextCommand() {    // If there is still a command being executed then bail out    if(commandQueueBusy) {        return;    }    // Check if we still have a valid gatt object    if (bluetoothGatt == null) {        Log.e(TAG, String.format("ERROR: GATT is 'null' for peripheral '%s', clearing command queue", getAddress()));        commandQueue.clear();        commandQueueBusy = false;        return;    }    // Execute the next command in the queue    if (commandQueue.size() > 0) {        final Runnable bluetoothCommand = commandQueue.peek();        commandQueueBusy = true;        nrTries = 0;        bleHandler.post(new Runnable() {            @Override            public void run() {                    try {                        bluetoothCommand.run();                    } catch (Exception ex) {                        Log.e(TAG, String.format("ERROR: Command exception for device '%s'", getName()), ex);                    }            }        });    }}

Обратите внимание, мы используем методpeek()для получения объектаRunnableиз очереди, чтобы можно было повторить запуск позже. Этот метод не удаляет объект из очереди.

Результат чтения будет отправлен в ваш колбек:

@Overridepublic void onCharacteristicRead(BluetoothGatt gatt,                                 final BluetoothGattCharacteristic characteristic,                                 int status) {    // Perform some checks on the status field    if (status != GATT_SUCCESS) {        Log.e(TAG, String.format(Locale.ENGLISH,"ERROR: Read failed for characteristic: %s, status %d", characteristic.getUuid(), status));        completedCommand();        return;    }    // Characteristic has been read so processes it       ...    // We done, complete the command    completedCommand();}

Мы завершаем командуcompletedCommand()после обработки нового значения. Это помогает избежать одновременный вызов другой команды и состояния гонки.

Теперь мы готовы завершить команду, убираемRunnableиз очереди через вызовpoll()и запускаем следующую из очереди:

private void completedCommand() {    commandQueueBusy = false;    isRetrying = false;    commandQueue.poll();    nextCommand();}

В некоторых случаях (ошибка, неожиданное значение), вам нужно будет повторить команду. Сделать это просто, так как объектRunnableостается в очереди до вызоваcompletedCommand(). Чтобы не уйти в бесконечное повторение проверяем лимит на повторы:

private void retryCommand() {    commandQueueBusy = false;    Runnable currentCommand = commandQueue.peek();    if(currentCommand != null) {        if (nrTries >= MAX_TRIES) {            // Max retries reached, give up on this one and proceed            Log.v(TAG, "Max number of tries reached");            commandQueue.poll();        } else {            isRetrying = true;        }    }    nextCommand();}

Запись характеристик

Чтение характеристики достаточно простая операция, а запись требует дополнительных пояснений. Для выполнения записи нужно предоставитьхарактеристику,массив байтовитип записи. Существует несколько типов записи, важные для нас это:

  • WRITE_TYPE_DEFAULT(вы получите ответ от устройства, например, код завершения);

  • WRITE_TYPE_NO_RESPONSE(никакого ответа от устройства не будет).

Использовать тот или иной тип зависит от вашего устройства и характеристики (иногда она поддерживает оба типа записи, иногда только один конкретный тип).

В Android каждая характеристика имеет дефолтный тип записи, который определяется при ее создании. Ниже фрагмент кода из исходников Android, где определяется тип:

...if ((mProperties & PROPERTY_WRITE_NO_RESPONSE) != 0) {    mWriteType = WRITE_TYPE_NO_RESPONSE;} else {    mWriteType = WRITE_TYPE_DEFAULT;}...

Как вы видите, это работает нормально, если характеристика поддерживает только один их двух типов записи. Если характеристика поддерживает оба типа, то значение по умолчанию будетWRITE_TYPE_NO_RESPONSE. Имейте это ввиду!

Перед записью можно проверить характеристику, поддерживает ли она нужный тип записи:

// Check if this characteristic actually supports this writeTypeint writeProperty;switch (writeType) {    case WRITE_TYPE_DEFAULT: writeProperty = PROPERTY_WRITE; break;    case WRITE_TYPE_NO_RESPONSE : writeProperty = PROPERTY_WRITE_NO_RESPONSE; break;    case WRITE_TYPE_SIGNED : writeProperty = PROPERTY_SIGNED_WRITE; break;    default: writeProperty = 0; break;}if((characteristic.getProperties() & writeProperty) == 0 ) {    Log.e(TAG, String.format(Locale.ENGLISH,"ERROR: Characteristic <%s> does not support writeType '%s'", characteristic.getUuid(), writeTypeToString(writeType)));    return false;}

Я рекомендую всегда явно указывать тип записи и не полагаться на дефолтные настройки выбранные Android!

Итак, запись массива байтовbytesToWriteв характеристику выглядит так:

characteristic.setValue(bytesToWrite);characteristic.setWriteType(writeType);if (!bluetoothGatt.writeCharacteristic(characteristic)) {    Log.e(TAG, String.format("ERROR: writeCharacteristic failed for characteristic: %s", characteristic.getUuid()));    completedCommand();} else {    Log.d(TAG, String.format("writing <%s> to characteristic <%s>", bytes2String(bytesToWrite), characteristic.getUuid()));    nrTries++;}

Включение/выключение уведомлений

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

Для включения уведомлений нужно сделать две вещи в Android:

  1. вызватьsetCharacteristicNotification. Bluetooth стек будет ожидать уведомления для этой характеристики.

  2. записать1или2какunsigned int16в дескриптор конфигурации характеристик (Client Characteristic Configuration, сокращенно - ССС). Дескриптор CCC имеет короткий UUID2902.

Почему1или2? Потому что под капотом Bluetooth стека есть Уведомление и Индикация. Полученное Уведомление не подтверждаются стеком Bluetooth, а Индикация наоборот подтверждается стеком. При использовании Индикации, устройство будет точно знать, что данные получены и может их, например, удалить из локального хранилища. С точки зрения Android приложения нет разницы: в обоих случаях вы просто получите массив байтов и Bluetooth стек уведомит устройство об этом, если вы используете Индикацию. Итак,1включает уведомления,2 индикацию. Чтобы выключить их, записываем0. Вы должны самостоятельно определить, что записать в дескриптор CCC.

В iOS методsetNotify()делает всю работу за вас. Ниже пример, как сделать тоже самое на Android, там сначала идут проверки входных параметров, определяется что записать в дескриптор и, наконец команда отправляется в очередь:

private final String CCC_DESCRIPTOR_UUID = "00002902-0000-1000-8000-00805f9b34fb";public boolean setNotify(BluetoothGattCharacteristic characteristic,                         final boolean enable) {    // Check if characteristic is valid    if(characteristic == null) {        Log.e(TAG, "ERROR: Characteristic is 'null', ignoring setNotify request");        return false;    }    // Get the CCC Descriptor for the characteristic    final BluetoothGattDescriptor descriptor = characteristic.getDescriptor(UUID.fromString(CCC_DESCRIPTOR_UUID));    if(descriptor == null) {        Log.e(TAG, String.format("ERROR: Could not get CCC descriptor for characteristic %s", characteristic.getUuid()));        return false;    }    // Check if characteristic has NOTIFY or INDICATE properties and set the correct byte value to be written    byte[] value;    int properties = characteristic.getProperties();    if ((properties & PROPERTY_NOTIFY) > 0) {        value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;    } else if ((properties & PROPERTY_INDICATE) > 0) {        value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;    } else {        Log.e(TAG, String.format("ERROR: Characteristic %s does not have notify or indicate property", characteristic.getUuid()));        return false;    }    final byte[] finalValue = enable ? value : BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE;    // Queue Runnable to turn on/off the notification now that all checks have been passed    boolean result = commandQueue.add(new Runnable() {        @Override        public void run() {            // First set notification for Gatt object  if(!bluetoothGatt.setCharacteristicNotification(descriptor.getCharacteristic(), enable)) {                Log.e(TAG, String.format("ERROR: setCharacteristicNotification failed for descriptor: %s", descriptor.getUuid()));            }            // Then write to descriptor            descriptor.setValue(finalValue);            boolean result;            result = bluetoothGatt.writeDescriptor(descriptor);            if(!result) {                Log.e(TAG, String.format("ERROR: writeDescriptor failed for descriptor: %s", descriptor.getUuid()));                completedCommand();            } else {                nrTries++;            }        }    });    if(result) {        nextCommand();    } else {        Log.e(TAG, "ERROR: Could not enqueue write command");    }    return result;}

Результат записи в CCC дескриптор обрабатывается в колбекеonDescriptorWrite. Здесь вы должны отличить запись в CCC от записей в другие дескрипторы. Во время обработки колбека, мы также должны хранить, какие в данный момент характеристики уведомляются.

@Overridepublic void onDescriptorWrite(BluetoothGatt gatt,                                 final BluetoothGattDescriptor descriptor,                                 final int status) {    // Do some checks first    final BluetoothGattCharacteristic parentCharacteristic = descriptor.getCharacteristic();    if(status!= GATT_SUCCESS) {        Log.e(TAG, String.format("ERROR: Write descriptor failed value <%s>, device: %s, characteristic: %s", bytes2String(currentWriteBytes), getAddress(), parentCharacteristic.getUuid()));    }    // Check if this was the Client Configuration Descriptor  if(descriptor.getUuid().equals(UUID.fromString(CCC_DESCRIPTOR_UUID))) {        if(status==GATT_SUCCESS) {            // Check if we were turning notify on or off            byte[] value = descriptor.getValue();            if (value != null) {                if (value[0] != 0) {                    // Notify set to on, add it to the set of notifying characteristics          notifyingCharacteristics.add(parentCharacteristic.getUuid());                    }                } else {                    // Notify was turned off, so remove it from the set of notifying characteristics               notifyingCharacteristics.remove(parentCharacteristic.getUuid());                }            }        }        // This was a setNotify operation        ....    } else {        // This was a normal descriptor write....        ...        });    }    completedCommand();}

Чтобы узнать из какой характеристики пришло уведомление используйте методisNotifying():

public boolean isNotifying(BluetoothGattCharacteristic characteristic) {    return notifyingCharacteristics.contains(characteristic.getUuid());}

Лимиты на установку уведомлений

К сожалению, нельзя включить столько уведомлений, сколько хочешь. Начиная с Android-5 лимит равен 15. В более старых версиях он был равен 7 или даже 4. Большинство смартфонов поддерживают 15 уведомлений. Не забывайте отключать их, если они вам больше не нужны, чтобы не исчерпать лимит.

Проблемы с потоками

Итак, мы научились читать/писать характеристики, включать/выключать уведомления, а значит готовы использовать это в реальном проекте. Я думаю, что устройства BLE можно разделить на две категории:

  • Простые устройства. Например, термометр, который использует официальный Bluetooth Health Thermometer сервис. Такие устройства легко использовать, вы просто включаете уведомления и данные начинают поступать. Здесь мы используем только операции чтения характеристики, запись не нужна;

  • Сложные устройства. Это может быть любое устройство, но обычно все они используют свой внутренний протокол обмена данными. Часто эти протоколы не спроектированы под BLE, а просто транслируют внутренний последовательный протокол в BLE, где одна характеристика используется для отправки данных, а другая для приема. Сложность в том, что вам требуется знать большое количество команд для работы с устройством: авторизация, обновление пользовательских параметров, параметров самого устройства, получение сохраненных данных и т.д.

Простые устройства обычно не создают проблем с потоками, для сложных следует работать внимательно. Чтение, запись и уведомления в этом случае будут чередоваться и могут мешать друг другу, особенно если у вас устройство с высокой частотой передачи данных (30Hz или около).

Типичная проблема с потоками выглядит так:

  • приходит уведомление

  • вы отправляете событие в свою собственную очередь для обработки

  • запускается обработку полученных данных

  • в это время приходит новое уведомление и перезаписывает предыдущее значение вBluetoothGattCharacteristic

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

Причины такого поведения:

  • как только сообщение доставлено, Android будет отправлять следующее(если оно есть). Посколько обработка данных отправляется в другой поток, текущий освобождается и Android продолжит доставку уведомлений;

  • Androidпереиспользует BluetoothGattCharacteristic объекты внутри. Они создаются в время обнаружения сервисов (services discovering) и после этогопереспользуютсямногократно. Таким образом, когда приходит уведомления Android сохраняет значение в объектBluetoothGattCharacteristic. Если характеристика в этот момент обрабатывается в другом потоке мы получим гонку состояний (race condition) и результат будет непредсказуемым.

Очевидно, чтонужно всегда работать с копией массива байтов. Получили данные, сразу же делаем копию и работаем с ней.

Ниже пример, который использует такую тактику:

@Overridepublic void onCharacteristicChanged(BluetoothGatt gatt,                                     final BluetoothGattCharacteristic characteristic) {    // Copy the byte array so we have a threadsafe copy    final byte[] value = new byte[characteristic.getValue().length];    System.arraycopy(        characteristic.getValue(),         0, value, 0,         characteristic.getValue().length);    // Characteristic has new value so pass it on for processing    bleHandler.post(new Runnable() {        @Override        public void run() {                     myProcessor.onCharacteristicUpdate(BluetoothPeripheral.this, value, characteristic);        }    });}

Другие рекомендации по работе с потоками

Есть несколько дополнительных рекомендаций по работе с BLE на Android. Поскольку стек BLE в основном асинхронный, у нас есть мульти-поточная обработка задач.

Android использует потоки:

  • При сканировании (результаты приходят вmainпоток);

  • Вызове колбековBluetoothGattCallback(выполняются в потокахBinder);

Обработка результатов сканирования на main потоке не будет проблемой. Но с потоками Binder все немного сложнее. При вызове колбека на потоке Binder, Android не будет отправлять новые данные пока не закончится обработка текущих, то есть поток Binder блокируется пока ваш код не завершится. Следует избегать тяжелых операций в колбеках, никакихsleep()или что-то подобное. Кроме того, никаких новых вызовов в объектеBluetoothGatt, пока вы находитесь в потоке Binder, хотя большинство методов асинхронные.

Я рекомендую следующее:

  • Всегда выполняйте вызовыBluetoothGattCallbackв отдельном потоке, возможно даже из потока пользовательского интерфейса (Прим. переводчика: работать на main потоке - плохая идея, если у вас есть активный обмен с устройством, обязательно будут залипания UI, не делайте так);

  • Освобождайте потокиBinderкак можно быстрее иникогдане блокируйте их;

Самый простой способ выполнить рекомендации выше создать выделенныйHandlerи использовать его для обработки данных и выдачи новых команд. Обратите внимание, я уже использовалHandlerна примере кода для колбекаonCharacteristicUpdate.

Объявление объекта:

Handler bleHandler = new Handler();

Если хотите запуститьHandlerнаmainпотоке:

Handler bleHandler = new Handler(Looper.getMainLooper());

Прокрутите назад и взгляните на наш методnextCommand(), каждыйRunnableвыполняется в нашем собственномHandler, следовательно, мы гарантируем, что все команды выполняются вне потокаBinder.

Следующая статья: сопряжение (bonding)

В этой статье мы разобрались с чтением и записью характеристик, включением и выключением уведомлений/нотификаций. В следующей статье, мы детально изучим процесс спряжения с устройством (bonding).

Не терпится поработать с BLE? Попробуйтемою библиотеку Blessed for Android. Она использует все подходы из этой серии статей и упрощает работу с BLE в вашем приложении.

Источник: habr.com
К списку статей
Опубликовано: 22.01.2021 20:15:37
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Java

Разработка мобильных приложений

Разработка под android

Android

Bluetooth

Ble

Low energy

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru