Глядя на обилие дешевых ESP32 модулей захотелось мне сделать из них что нибудь полезное. Для работы мне нужен был BLE адаптер с последовательным интерфейсом пригодный для разных применений вроде организации беспроводного канала связи между железками или сбора телеметрии с нескольких устройств. Ну а для большей радости от процесса была выбрана платформа Ардуино. Эта статья - о том, что получилось.
Недостатки существующих решений
На али есть масса готовых решений вроде JDY-08 или HM-10, но они закрытые, не позволяют соединяться одновременно с несколькими устройствами и не предоставляют средств управления потоком данных. Еще об одном недостатке речь пойдет ниже.
Важность управления потоком данных
Любой канал связи имеет ограниченную пропускную способность. В случае BLE соединения она довольно ограничена и сильно зависит от условий приема. Что же произойдет при попытке передать больше данных, чем канал связи способен пропустить через себя? То же, что при попытке наливать в ванну больше, чем из нее вытекает - вода выльется на пол, а данные потеряются. Если они потеряются в канале связи, адаптер может хотя бы отследить, какие конкретно фрагменты потерялись. А если потеря происходит в последовательном канале связи, то как то контролировать масштаб этой потери просто невозможно. Чтобы этого не допускать, полезно использовать аппаратное управление потоком - сигналы RTS/CTS. Особенно важен RTS на стороне адаптера, поскольку он предотвращает переполнение его приемного буфера. Этот сигнал должен соединяться со входом CTS на другой стороне последовательного канала связи. В конфигурации адаптера сигнал RTS активен по умолчанию при использовании аппаратного порта. Вы можете не соединять его физически, если в нем нет необходимости.
Как это работает - передача данных посредством BLE
BLE устройство может работать в двух принципиально разных ролях, выполняя функцию периферийного или центрального устройства. Периферийное устройство имеет богатую внутреннюю структуру, показанную на рисунке ниже.
Периферийное устройство содержит набор сервисов. Сервис представляет собой коллекцию характеристик. Характеристика фактически является буфером данных размером до 512 байт. Характеристики могут иметь дескрипторы, которые описывают их свойства и тоже являются буфером данных, только меньшего размера (бритва Оккама скучает без дела). Центральное устройство лишено всей этой внутренней структуры, оно лишь может устанавливать соединение с периферийным устройством, записывать данные в его характеристики и подписываться на обновления. Адаптер имеет единственную характеристику, через нее и происходит обмен данными. При этом адаптер может работать как в роли центрального устройства, так и в роли периферийного, так и в двух ролях одновременно. В качестве центрального устройства он может устанавливать соединение с периферийными устройствами в количестве до 4 одновременно (это ограничение реализации BLE стэка).
Смысл соединения
Тут полезно задаться вопросом - в чем смысл соединения между центральным и периферийным устройством? Например, в классическом BT есть последовательный канал SPP, он гарантирует целостность потока данных и либо доставляет их в потоке, либо рвет соединение. Аналогичную семантику имеет TCP/IP соединение. Оказывается, что BLE соединение ничего не гарантирует вообще, оно нужно просто чтобы хранить контекст связи двух устройств. Но рваться по собственной инициативе оно тоже может. Обновления характеристик могут как угодно повреждаться, теряться и переупорядочиваться в процессе передачи.
Прозрачная передача против пакетной
И здесь мы подходим ко второму фундаментальному недостатку существующих решений - они ориентированы на прозрачную передачу потока данных. Это конечно удобно для пользователя, но правильно ли? Ведь BLE не может гарантировать целостность этого потока. Адаптер делит его на фрагменты, с которыми в канале связи может произойти все что угодно. В результате поток данных может быть произвольно модифицирован. Единственный известный человечеству способ обеспечить целостность данных при передаче по такому ненадежному каналу - это делить его на фрагменты и добавлять к ним средства проверки целостности (контрольные суммы). А если нужен поток с гарантией целостности, то добавлять средства контроля доставки в нужном порядке, подтверждение доставки с приемной стороны и повтор передачи на передающей стороне. Мы не пойдем настолько далеко в нашем адаптере. Но деление данных на пакеты в нем предусмотрено. Он получает их в виде пакетов на передающей стороне и доставляет пакеты с сохранением границ на приемную сторону. Так что пользователь лишен необходимости делить поток на пакеты самостоятельно. И последнее, но не менее важное обстоятельство, - с прозрачной передачей потоковых данных невозможно реализовать передачу данных в несколько соединений одновременно.
Протокол управления
Для управления адаптером и передачи данных он реализует простой асинхронный протокол, схематически показанный на следующем рисунке.
В качестве управляющей подсистемы может выступать сколь угодно простое устройство, имеющее последовательный порт. От него адаптер получает команды и данные для передачи. Ему он в свою очередь передает данные, полученные от подключенных устройств, нотификацию о своем состоянии и отладочные сообщения. Команд всего две - сброс и подключение списка устройств. Устройства идентифицируются по их адресу. Идентификация по имени не предусмотрена, поскольку она требует дополнительной процедуры поиска устройства, к тому же имена не являются уникальными.
Реализация протокола на языке python содержится в файле python/ble_multi_adapter.py
Передача бинарных данных
Поскольку протокол использует определенные байты как маркеры начала и конца сообщения (белые и черные кружки на рисунке выше), наличие этих байт в передаваемых данных нарушает протокол обмена. Для передачи произвольных бинарных данных их необходимо закодировать в base64 перед отправкой адаптеру. Байт со значением 2 добавляется в начало блока данных в качестве маркера закодированных бинарных данных. Адаптер раскодирует данные, отправит их на приемную сторону, где они будут снова закодированы в base64.
Расширенный пакетный режим
Адаптер предполагает что полученный им пакет данных можно записать в характеристику, что ограничивает его размер. Адаптер использует фрагменты данных до 244 байт, которые теоретически должны передаваться без дальнейшей фрагментации. Чтобы передавать пакеты данных большего размера адаптер реализует расширенный пакетный режим, в котором пакеты делятся на фрагменты, каждый из которых снабжен однобайтным заголовком и трехбайтовой контрольной суммой, как показано на рисунке ниже.
Использование расширенного пакетного режима совершенно прозрачно для пользователя и рекомендуется для всех случаев, кроме тех, когда нужно взаимодействие с другими BLE устройствами, которые не имеют возможности обрабатывать фрагментированные пакеты расширенного режима. В конфигурационном файле расширенный режим включен по умолчанию (EXT_FRAMES). По умолчанию максимальный размер пакета данных в расширенном режиме равен 2160 байт. При необходимости его можно увеличить, изменив параметр MAX_CHUNKS в файле конфигурации.
Обработка ошибок
Адаптер использует простую, но чрезвычайно эффективную стратегию обработки ошибок - он просто рестартует заново. В том числе, это происходит при разрыве соединения.
Проблемы
В ходе работы над проектом было обнаружено немало проблем и даже багов в библиотеках и Ардуино классах на их основе.
Как уже отмечалось, использование управления потоком последовательного порта это хорошо и правильно. Но есть один нюанс. При использовании двухстороннего управления RTS/CTS возможна взаимоблокировка передатчика и приемника, когда они оба пытаются записать что то в последовательный канал, при том, что у обоих буфер приема переполнен. Взаимоблокировка возникает от того, что и передатчик и приемник могут либо передавать, либо принимать данные, но не могут это делать одновременно. Есть казалось бы простое и очевидное решение - не добавлять данные в буфер передачи, если в нем нет для них места. К несчастью библиотека ESP32 не позволяет достоверно узнать размер свободного места в буфере передатчика. Так что на данный момент рекомендация сводится к тому, чтобы использовать RTS, чтобы исключить переполнение приемного буфера адаптера, но не использовать CTS.
Следует иметь ввиду, что при работе адаптера через USB CDC нет никакого управления потоком вообще. Новые данные просто перетирают старые.
Неожиданностью стало то, что метод BLERemoteCharacteristic::writeValue, который зовется, чтобы записать данные в периферийное устройство, непригоден для использования вообще. По замыслу авторов он должен дожидаться завершения предыдущей записи. Для этого он берет семафор, но не отдает его в случае ошибки, так что следующий вызов повисает навсегда. Пришлось дублировать эту функциональность в коде адаптера.
Интересно, что запись без подтверждения, которая выполняется в библиотеке по умолчанию, приводит к нестабильности соединений в случае, если их больше одного. Запись с подтверждением не имеет такой проблемы. Она и используется в адаптере.
Как выяснилось, работа адаптера одновременно в двух ролях хотя и возможна, но может приводить к нестабильности соединения. Возможно, это даже фича, а не баг. Центральное устройство управляет периодами активности в эфире как для себя, так и для подключенных периферийных устройств. Значит, устройство с двумя ролями одновременно должно как само управлять периодами активности своего радио модуля, так и управляться другим центральным устройством. Возможно, это противоречие и приводит к проблемам при одновременном использовании двух ролей.
Компиляция, настройки
Для компиляции не потребуется ничего, кроме Ардуино. В настройках (Additional board manager URLs) добавьте ссылку на пакет поддержки ESP32:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
Загрузите пакет esp32 by Espressif Systems в менеджере плат. Выберите ESP32C3 Dev Module или ESP32S3 Dev Module в зависимости от того, какой процессор у вас на плате. С другими возможно тоже работает, я тестировал на этих двух. Разрешите в настройках платы USB CDC On Boot. Установите правильный COM порт, соответствующий подключенной плате, и плату можно прошивать. Имейте ввиду, что платы вообще без прошивки при подключении входят в цикл перезагрузки. Чтобы прошить такую плату, ее нужно перевести в режим загрузки. Для этого нажмите и удерживайте кнопку BOOT, потом нажмите и отпустите кнопку RST, затем отпустите BOOT. После прошивки нужно нажать RST, чтобы выйти из режима загрузки.
Проект имеет множество настроек времени компиляции, которые вынесены в заголовочный файл mx_config.h. Он включает другой заголовочный файл (по умолчанию default_config.h), который вы можете скопировать, переименовать и настроить по своему вкусу. В настройках вы можете выбрать имя устройства, режим его работы, адрес устройства для автоматического соединения, если вам нужен канал связи, который устанавливается автоматически, и многое другое.
Производительность
Максимальная скорость передачи данных, полученная в эхо-тесте для одного соединения, составляет около 4kБ/сек в одну сторону (+ столько же в другую). Для четырех соединений в каждом из них скорость падает до 1.5kБ/сек, что видимо является ограничением последовательного порта. При желании его скорость можно увеличить в настройках (UART_BAUD_RATE).
Передача данных от периферийного устройства к центральному при перегрузке канала демонстрирует рост потерянных пакетов. Фактически доставляется лишь столько пакетов, сколько канал способен пропустить, что ожидаемо т.к. они используют полностью асинхронный механизм нотификации. Запись в обратном направлении от центрального устройства к периферийному более устойчива к перегрузке, т.к. она использует запись с подтверждением, и при перегрузке начинает тормозить прием данных из последовательного порта.
Энергопотребление
Модуль ESP32C3 Super Mini в покое потребляет около 65мА. Под нагрузкой при приеме 50 коротких сообщений в секунду с 3 соединений потребление возрастает до 120мА. Модуль на ESP32S3 ожидаемо потребляет больше за счет двух процессорных ядер - около 90мА в покое. Под нагрузкой, однако, потребление возрастает не так сильно - до 115мА. В целом нормально, но не для батарейного питания.
Дальность связи
Сильно зависит от антенны. Наихудший вариант - чип антенна, вроде тех, что стоят на модулях ESP32C3 Super Mini. Она превращает электрическую энергию преимущественно в тепло. Не стоит ожидать от нее дальности больше 10 метров. Печатная антенна уже значительно лучше. Наилучшие результаты дают внешние антенны, даже самые простые. Если на плате стоит чип антенна, и нет разъема для внешней, - просто удалите чип антенну и припаяйте внешнюю, как показано на фото
Дополнительно увеличить дальность можно за счет программного увеличения мощности передатчика. Эта опция включена по умолчанию в файле конфигурации (TX_PW_BOOST). В таком варианте дальность работы на открытой местности составляет 100м. В помещении возможна устойчивая работа между соседними этажами через железобетонное перекрытие.
Совместимость
Адаптер может работать совместно с JDY-08 и им подобным адаптерам при условии, что вы передаете не более 20 байт данных за один раз. Он также полностью совместим с Web BLE приложениями, например с этим. Приложение по ссылке удобно использовать для тестирования.
Исходный код
Лежит тут https://github.com/olegv142/esp32-ble
Директория проекта для Ардуино ble_uart_mx
Автор: oleg_v