Designed by Freepik
В современных Android — приложениях для взаимодействия с другими устройствами чаще всего используются беспроводные протоколы передачи данных, как например Bluetooth. В годы, когда часть устройств имеют беспроводную зарядку, сложно представить себе связку Android устройства и периферийного модуля, в которой необходимо использование проводных интерфейсов. Однако когда такая необходимость возникает, на ум сразу же приходит USB.

Давайте разберем с вами гипотетический кейс. Представьте, что к вам приходит заказчик и говорит: “Мне нужно Android приложение для управления устройством сбора данных и вывода этих самых данных на экран. Есть одно НО — приложение надо написать на одноплатный компьютер с операционной системой Android, а периферийное устройство подключено по USB”

Звучит фантастически, но и такое иногда случается. И тут как нельзя кстати пригодится глубокое знание USB стека и его протоколов, но данная статья не об этом. В данной статье мы рассмотрим, как управлять периферийным устройством по протоколу USB Custom HID с Android устройства. Для простоты напишем Android-приложение (HOST), которое будет управлять светодиодом на периферийным устройством (DEVICE) и получать состояние кнопки (нажатия). Код для периферийной платы приводить не буду, кому интересно — пишите в комментариях.

Итак, приступим.

Теория. Максимально коротко


Для начала немного теории, максимально коротко. Это упрощенный минимум, достаточный для понимания кода, но для большего понимания советую ознакомиться с этим ресурсом.

Для общения по USB на периферийном устройстве необходимо реализовать интерфейс взаимодействия. Разные функции (например, USB HID, USB Mass Strorage или USB CDC) будут реализовывать свои интерфейсы, а некоторые будут иметь несколько интерфейсов. Каждый интерфейс содержит в себе конечные точки — специальные каналы связи, своего рода буферы обмена.

На моем периферийном устройстве реализован Custom HID с одним интерфейсом и с двумя конечными точками, одной для приёма, другой для передачи. Обычно информация с существующими на устройстве интерфейсами и конечными точками написана в спецификации на устройство, в противном случае определить их можно через специальные программы, к примеру USBlyzer.

Устройства в USB HID общаются через репорты. Что такое репорты? Так как данные передаются через конечные точки, то нам надо как-то идентифицировать, а также распарсить в соответствие с протоколом. Устройства не просто кидают друг другу байты данных, а обмениваются пакетами, имеющими четко определенную структуру, которая описывается на устройстве в специальном дескрипторе репорта. Таким образом, по дескриптору репорта, мы можем точно определить, какой идентификатор, структуру, размер и частоту передачи имеют те или иные данные. Идентификация пакета происходит по первому байту, который представляет из себя ID репорта. Например данные о состоянии кнопки, идут в репорта с ID = 1, а светодиодом мы управляем через репорт с ID = 2.

Подальше от железа, поближе к Android


В Android поддержка USB устройств появилась начиная с API версии 12 (Android 3.1) Для работы с периферийным устройством нам необходимо реализовать режим USB host. Работа с USB достаточно неплохо описана в документации.

Для начала необходимо идентифицировать ваше подключаемое устройство, среди всего разнообразия USB девайсов. USB девайсы идентифицируются по сочетанию vid (vendor id) и pid (product id). Создадим в папке xml файл device_filter.xml со следующим содержимым:

<resources>
    <usb-device vendor-id="1155" product-id="22352" />
</resources>

Теперь необходимо внести соответствующие разрешения и action (если вам они необходимы) в манифест приложения:

<uses-permission android:name="android.permission.USB_PERMISSION" />
<uses-feature android:name="android.hardware.usb.host" />

<activity android:name=".MainActivity">
            <intent-ilter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <meta-data
                android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
                android:resource="@xml/device_filter" />
</activity>

В android:resource мы указываем файл с необходимыми фильтрами для устройств. Также, как я уже говорил ранее, можно назначить intent фильтры, для запуска приложения, к примеру, в результате подключения вашего устройства.

Для начала необходимо получить UsbManager, найти устройство, интерфейс и конечные точки устройства. Это необходимо делать при каждом подключении устройства.

val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager

private var usbConnection: UsbDeviceConnection? = null
private var usbInterface: UsbInterface? = null
private var usbRequest: UsbRequest? = null
private var usbInEndpoint: UsbEndpoint? = null
private var usbOutEndpoint: UsbEndpoint? = null

fun enumerate(): Boolean {
  val deviceList = usbManager.deviceList
    for (device in deviceList.values) {

      /* Находим девайс девайс с нашими VID и PID */
      if ((device.vendorId == VENDOR_ID) and (device.productId == PRODUCT_ID)) {

        /* Получаем интерфейс по известному номер */
        usbInterface = device.getInterface(CUSTOM_HID_INTERFACE)

        /* Перебираем конечные точки интерфейса 
            и находим точки на прием и передачу */
        for (idx in 0..usbInterface!!.endpointCount) {
          if (usbInterface?.getEndpoint(idx)?.direction == USB_DIR_IN)
            usbInEndpoint = usbInterface?.getEndpoint(idx)
          else
            usbOutEndpoint = usbInterface?.getEndpoint(idx)
          }

           usbConnection = usbManager.openDevice(device)
           usbConnection?.claimInterface(usbInterface, true)

           usbRequest = UsbRequest()
           usbRequest?.initialize(usbConnection, usbInEndpoint)
        }
     }

  /* Возвращаем статус подключения */
  return usbConnection != null
}

Здесь мы видим те самые интерфейсы и конечные точки, речь о которых шла в прошлом разделе. Зная номер интерфейса, мы находим обе конечные точки, на прием и передачу, и инициируем usb соединение. На этом все, теперь можно читать данные.

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

    fun sendReport(data: ByteArray) {
        usbConnection?.bulkTransfer(usbOutEndpoint, data, data.size, 0)
    }

    fun getReport(): ByteArray {
        val buffer = ByteBuffer.allocate(REPORT_SIZE)
        val report = ByteArray(buffer.remaining())

        if (usbRequest.queue(buffer, REPORT_SIZE)) {
            usbConnection?.requestWait()

            buffer.rewind()
            buffer.get(report, 0, report.size)
            buffer.clear()
        }

        return report
    }

В метод sendReport мы передаем массив байт, в котором нулевым байтом является репорт ID, берем текущее USB подключение к устройству и выполняем передачу. В качестве параметров в метод BulkTransfer передаем номер конечной точки, данные, их размер и таймаут передачи. Стоит отметить, что класс UsbDeviceConnection имеет методы для реализации обмена данными с устройством USB — методы bulkTransfer и controlTransfer. Их использование зависит от типа передачи, который поддерживает та или иная конечная точка. В данном случае используем bulkTransfer, хотя для HID чаще всего характерно использование конечных точек с типом control. Но у нас Custom HID, так что делаем что хотим. Про тип передачи советую почитать отдельно, так как от него зависит объем и частота передаваемых данных.

Для получения данных необходимо знать размер получаемых данных, который можно, как знать заранее, так и получить из конечной точки.

Метод получения данных по USB HID является синхронным и блокирующим и выполнять его необходимо в другом потоке, кроме того, репорты от устройства могут приходить постоянно, либо в любое время, поэтому необходимо реализовать постоянный опрос репорта, чтобы не пропустить данные. Сделаем это при помощи RxJava:

fun receive() {
	Observable.fromCallable<ByteArray> { getReport() }
	                .subscribeOn(Schedulers.io())
	                .observeOn(Schedulers.computation())
	                .repeat()
			.subscribe({
				/* check it[0] (this is report id) and handle data */
			},{
				/* handle exeption */
			})
}

Получив массив байт, мы должны проверить нулевой байт, так как он является report ID и в соответствии с ним парсить полученные данные.

По завершении всех действий с USB нужно закрыть соединение. Можно выполнять это в onDestroy activity или в onCleared во ViewModel.

    fun close() {
        usbRequest?.close()
        usbConnection?.releaseInterface(usbInterface)
        usbConnection?.close()
    }

Заключение


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

X. Полезные материалы


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


  1. shkola
    07.10.2019 16:47
    +1

    :) это случайно не я ваш "заказчик"? прям про меня написано :)


    1. Foxek Автор
      07.10.2019 16:50

      Хах, насколько мне известно нет, проект был — проект успешно завершен. Но я уверен, что не Вы один сталкиваетесь с подобными задачами) Но я все таки считаю, что это не совсем правильное архитектурное решение. Вероятно использование Linux было бы более приемлемым решением. Хотя, опять же, зависит от конкретной задачи и конечной цели


  1. lingvo
    07.10.2019 17:07

    Два, точнее три вопроса:


    1. Насколько это решение применимо к телефонам/планшетам на Андроиде с заделом на будущее?
    2. Если да, то каковы шансы провести приложение в Google Play?
    3. А что с Apple?


    1. Foxek Автор
      07.10.2019 17:44

      Возможно не совсем правильно понимаю суть всех трех вопросов, но попробую ответить:
      1. Решение прекрасно ложиться на телефоны/планшеты на android, так как нет никаких видимых причин на изменение поведения. Работа с USB хорошо документирована, поддерживается (возникают новые методы, устаревают старые) гуглом и нет предпосылок к изменению этого в будущем.

      2. Опять же не вижу причин не попасть в Google Play. Политика гугл в этом отношении сейчас достаточно жесткая, но конкретно к работе с USB, каких-то особых требований я не видел. К тому же, как правило, все что Вам нужно сделать, это в манифесте задекларировать uses-feature, остальное Google Play сделает за Вас. Вероятно у Вас возникают сомнения, когда вы видите слово Custom. Но это напрямую относится к периферийному устройству, но даже с его сертификацией, проблем не возникнет (если вы приобрели свой VID и PID). С точки зрения Android, каких то отклонений от нормы нет.

      3. Не могу Вам ответить на этот вопрос, не смотрел. Но опять же повторю, протокол достаточно стандартизирован, и если у Apple есть возможность дотянуться до USB из вашего application, Вы с большой вероятностью сможете легко подружить его с Вашим периферийным устройством.

      Повторюсь, основная магия все равно идет в железе на периферийном устройстве, по причине того, что в данном случае Android используется, как host. Вероятно, если вы захотите использовать Android в качестве device, то Вам придется пережить все танцы с бубном, связанные с дескрипторами, но это уже немного другой уровень погружения в usb стек, хоть и тоже весьма поверхностный


      1. lingvo
        07.10.2019 18:22

        1. Тут вопрос в том, что не идет ли предпосылка в телефонах/планшетах к отказу от USB вообще? Т.е. в Apple, например, USB вообще нет, а всякие Lightning, что вынуждает делать отдельную версию устройства под них, да еще и с чипом. В андроидах сейчас идет Type C и может быть в конце-концов тоже уйдет, как и разъем для наушников, или придумают такую же фигню, как Apple с чипом "Certified for Android"?


        2. "если вы приобрели свой VID и PID" — вот это уже начинает напрягать. Т.е. прикинуться чайником (или FTDI каким-нибудь) может уже не получиться? Просто кастомизация все больше и больше не в почете у гигантов.



        1. Foxek Автор
          07.10.2019 19:42

          1. Мне на самом деле сложно предположить. Если говорить в контексте этой статьи, то как я уже говорил считаю использование USB HID, да в общем и остальных классов для взаимодействия с периферийным устройством с телефона/планшета архитектурнй ошибкой проекта, в котором все это используется. С однопланиками все понятно, там вероятно есть смысл, но в таком случае туда сам собой напрашивается Linux. Но опять же подчеркну, все зависит от задачи.

          Что касается использования USB в смартфонах в отрыве от статьи, прогнозировать сложно, но мне на данный момент сложно предположить подобное развитие событий именно для android смартфонов. USB протокол прост в реализации на уровне железа, программный стек достаточно хорошо подходит почти для всех задач и прекрасно отработан и документирован. Изменение типа разъема, спецификации и прочего это одно, а вот убрать разъем целиком, это совсем другое дело. Согласен, большую часть стандартных вещей можно и без USB провернуть, даже дебажить можно по Wi-Fi. Но боюсь закрыть все дыры таким образом не получится. К тому же я не по совсем понимаю, зачем его убирать. С разъемом наушников все понятно, а вот USB… Чип USB стоит немного, небольшой по размеру, если его убрать, места на плате не сильно уменьшится. К тому же текущая тенденция увеличения размера экрана, позволяет об этом не париться. Если речь идет о размере разъема, то если уже и он мешает, то я не знаю, насколько тонкие телефоны нужны человечеству)

          2. Кастомизация происходит на уровне периферийного устройства, к Android отношения не имеет, если речь идет о host. VID и PID придется приобрести, правда не во всех случаях)) FDTI — это не к USB. Сам протокол он не реализует, а является лишь преобразователем интерфейса. К слову о FTDI, USART на Android, насколько мне известно, тоже можно потрогать из Вашего Application


          1. 402d
            09.10.2019 10:20

            fun sendReport(data: ByteArray) {
            usbConnection?.bulkTransfer(usbOutEndpoint, data, data.size, 0)
            }

            собственно Вы уже привели тут единственный метод.
            FTDI, USART на чистой яве? это фантастика. Вам придется
            работать через оберку с двоичной либой.
            github.com/chzhong/serial-android


            1. Foxek Автор
              09.10.2019 11:23

              У UsbDeviceConnection есть еще метод controlTransfer, который тоже может использоваться, но насколько мне известно только для передачи управляющих команд через нулевую конечную точку, через которую идет энумерация. Насчёт USART, как я уже говорил, не уверен, возможно и через либу. Видел в сети варианты использования, так что в случае необходимости решение найти можно


      1. ginkage
        07.10.2019 23:02

        Кстати, в качестве Device устройства на Android можно использовать через Bluetooth, начиная с Android Pie для этого есть стандартный API (почему-то выключенный на телефонах Nokia и OnePlus, но тем не менее).


        1. Foxek Автор
          07.10.2019 23:11

          Я так понимаю Вы говорите именно про Bluetooth, безотносительно к USB.
          Насколько мне известно Android смартфон мог играть роль Device достаточно давно по крайней мере в Bluetooth Low Energy. В классическом Bluetooth это вероятно является нововведением, к сожалению не знаю, так как при разработке чаще пользовался BLE — стабильнее работает, по крайней мере со стороны embedded железки (точнее соотношение цена/стабильность получше)


          1. ginkage
            07.10.2019 23:50

            Да, про Bluetooth, но там, по сути, используются те же самые дескрипторы, меняется только физический транспорт, а протокол более-менее тот же.
            Играть роль Device (например, мышь) через BLE мне его стабильно заставить так и не удалось, что-то в стеке всё-таки реализовано странно, по крайней мере в последних версиях: где-то на Marshmallow-Nougat это ещё работало, но дальше практически перестало нормально работать. Ну а Device через Bluetooth Classic — это действительно нововведение.


            1. Foxek Автор
              08.10.2019 00:01

              Извините, но я с Вами не согласен. Да, с одной стороны характеристики и сервисы в bluetooth low energy действительно напоминают usb hid report, но только по структуре разве что. На этом сходства заканчиваются. Работают они абсолютно по разному. Я хотел даже привести здесь пример различий, но понял, что привести его достаточно сложно, потому что отличается все кроме этой самой структуры.


              1. ginkage
                09.10.2019 10:19

                Я говорил всё-таки о дескрипторах и репортах в Bluetooth Classic, а не BLE. :)
                BLE действительно не похож ни разу.


                1. Foxek Автор
                  09.10.2019 11:10

                  Если честно не понимаю о каких дескрипторах и репортах классического Bluetooth идет речь. Впервые слышу о подобных вещах.

                  Если речь идет про протокол HID натянутый поверх Bluetooth, то это просто протокол HID, который по идее спецификой USB не является. можно пользовать где угодно. Вот только отношения к Bluetooth я так и не просек)


                  1. ginkage
                    09.10.2019 17:34

                    Речь о стандартном профиле Bluetooth, таком же стандартном, как A2DP, см. википедию:

                    Bluetooth HID is a lightweight wrapper of the human interface device protocol defined for USB. The use of the HID protocol simplifies host implementation (ex: support by operating systems) by enabling the re-use of some of the existing support for USB HID to also support Bluetooth HID.

                    Все Bluetooth Classic устройства ввода (а это разнообразные мыши, клавиатуры, джойстики, и т.п.) по сути общаются по тому же самому USB HID протоколу, сообщая хосту те же самые дескрипторы и репорты.
                    Ну а BLE — это уже другой профиль, известный как GATT…


                    1. Foxek Автор
                      09.10.2019 17:44

                      Согласен, я не совсем корректно вас понял, поэтому никак не мог осознать о каких дескрипторах в классическом Bluetooth идет речь)


  1. roboter
    08.10.2019 16:52

    У STM есть VirualComPort тогда, по идее, не надо свой VID PID получать.


    1. Foxek Автор
      08.10.2019 17:02

      Если речь идет о USB классе VCP, то свои VID и PID там не нужны, можно использовать те, которые предоставляет STM для устройств на базе их контроллеров. STM также предоставляет драйвера под ОС для работы с подобными устройствами. Но если ваш проект планирует развиваться на рынке, VID и PID приобрести рано или поздно придется, а также написать свой драйвер для ОС.
      Для коммерческих продуктов стандартный драйвер не всегда хорошая идея. К примеру драйвер VCP под Windows просто ужасный. Позволяет все прекрасно запустить на малых скоростях и небольших объёмах данных, но шаг вправо, шаг влево и драйвер выдает удивительные вещи. Попробуйте предавать по VCP с STM большой объем данных и при этом щелкать мышкой на ПК (знаю, звучит глупо), на котором вы принимаете данные. Уверяю Вас, часть данных вы не получите.


  1. 402d
    09.10.2019 09:38

    На телефонах с андроидом работа с usb больше зависит от железа.
    например. флайчик. все программные тесты говорят юсб поддерживается, а железки нет.
    Поэтому простой тест. Берем мышку. Берем флешку. Втыкаем через отг кабель.

    Вместо конкретных vid&tid в хмл для фильтрации можно просто классы написать.
    Просто работать с устройствами, которые поддерживают булк на ендпоинтах.
    Эмуляция COM порта гиморой.


    1. Foxek Автор
      09.10.2019 09:47

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


      1. 402d
        09.10.2019 09:56

        что еще очень раздражает.
        Каждый раз после коннекта устройство считается новым и идет запрос пермишинов.
        Единственное юзабельное решение.
        Становится слушателем на все подключения. Тогда у пользователя появиться
        вариант «открывать ваше приложение при подключении всегда».
        Активити слушатель этого может быть пустышкой. Нужно только для автополучения
        разрешения. Ну а я в своей проге меняю настроки в этом случае так (транспорт-usb,vid,pid).
        Еще в андроиде не получиться подключить два устройства с одинаковыми vid&pid одновременно, так как смотри выше обход дерева требуется, чтобы найти устройство.
        А uri адрес устройства при каждом конекте новый.


  1. nmrulin
    09.10.2019 17:09

    А где можно найти расшифровку кодов ошибок. Например, если
    usbConnection?.bulkTransfer(usbOutEndpoint, data, data.size, 0), выполнилось неправильно.


    1. Foxek Автор
      09.10.2019 17:19

      Данный метод возвращает размер переданных данных либо ошибку (Возвращает -1). Ошибка не имеет различных кодов, есть только факт возникновения.