Глядя на обилие дешевых ESP32 модулей, захотелось мне сделать из них что нибудь полезное. Для работы мне нужен был BLE адаптер с последовательным интерфейсом пригодный для разных применений вроде организации беспроводного канала связи между железками или сбора телеметрии с нескольких устройств. Ну а для большей радости от процесса была выбрана платформа Ардуино. Эта статья - о том, что получилось.
Недостатки существующих решений
На али есть масса готовых решений вроде JDY-08 или HM-10, но они закрытые, не позволяют соединяться одновременно с несколькими устройствами и не предоставляют средств управления потоком данных. Еще об одном недостатке речь пойдет ниже.
Важность управления потоком данных
Любой канал связи имеет ограниченную пропускную способность. В случае BLE соединения она довольно ограничена и сильно зависит от условий приема. Что же произойдет при попытке передать больше данных, чем канал связи способен пропустить через себя? То же, что при попытке наливать в ванну больше, чем из нее вытекает - вода выльется на пол, а данные потеряются. Если они потеряются в канале связи, адаптер может хотя бы отследить, какие конкретно фрагменты потерялись. А если потеря происходит в последовательном канале связи, то как то контролировать масштаб этой потери просто невозможно. Чтобы этого не допускать, полезно использовать аппаратное управление потоком - сигналы RTS/CTS. Особенно важен RTS на стороне адаптера, поскольку он предотвращает переполнение его приемного буфера. Этот сигнал должен соединяться со входом CTS на другой стороне последовательного канала связи. В конфигурации адаптера сигнал RTS активен по умолчанию при использовании аппаратного порта. Вы можете не соединять его физически, если в нем нет необходимости.
Как это работает - передача данных посредством BLE
BLE устройство может работать в двух принципиально разных ролях, выполняя функцию периферийного или центрального устройства. Периферийное устройство имеет богатую внутреннюю структуру, показанную на рисунке ниже.

Периферийное устройство содержит набор сервисов. Сервис представляет собой коллекцию характеристик. Характеристика фактически является буфером данных размером до 512 байт. Характеристики могут иметь дескрипторы, которые описывают их свойства и тоже являются буфером данных, только меньшего размера (бритва Оккама скучает без дела). Центральное устройство лишено всей этой внутренней структуры, оно лишь может устанавливать соединение с периферийным устройством, записывать данные в его характеристики и подписываться на обновления. Адаптер имеет единственную характеристику, через нее и происходит обмен данными. При этом адаптер может работать как в роли центрального устройства, так и в роли периферийного, так и в двух ролях одновременно. В качестве центрального устройства он может устанавливать соединение с периферийными устройствами в количестве до 4 одновременно (это ограничение реализации BLE стэка). При использовании процессора первой версии ESP32 количество одновременно работающих соединений ограничено двумя.
Смысл соединения
Тут полезно задаться вопросом - в чем смысл соединения между центральным и периферийным устройством? Например, в классическом BT есть последовательный канал SPP, он гарантирует целостность потока данных и либо доставляет их в потоке, либо рвет соединение. Аналогичную семантику имеет TCP/IP соединение. Оказывается, что BLE соединение ничего не гарантирует вообще, оно нужно просто чтобы хранить контекст связи двух устройств. Но рваться по собственной инициативе оно тоже может. Обновления характеристик могут как угодно повреждаться, теряться и переупорядочиваться в процессе передачи.
Прозрачная передача против пакетной
И здесь мы подходим ко второму фундаментальному недостатку существующих решений - они ориентированы на прозрачную передачу потока данных. Это конечно удобно для пользователя, но правильно ли? Ведь BLE не может гарантировать целостность этого потока. Адаптер делит его на фрагменты, с которыми в канале связи может произойти все что угодно. В результате поток данных может быть произвольно модифицирован. Единственный известный человечеству способ обеспечить целостность данных при передаче по такому ненадежному каналу - это делить его на фрагменты и добавлять к ним средства проверки целостности (контрольные суммы). А если нужен поток с гарантией целостности, то добавлять средства контроля доставки в нужном порядке, подтверждение доставки с приемной стороны и повтор передачи на передающей стороне. Мы не пойдем настолько далеко в нашем адаптере. Но деление данных на пакеты в нем предусмотрено. Он получает их в виде пакетов на передающей стороне и доставляет пакеты с сохранением границ на приемную сторону. Так что пользователь лишен необходимости делить поток на пакеты самостоятельно. И последнее, но не менее важное обстоятельство, - с прозрачной передачей потоковых данных невозможно реализовать передачу данных в несколько соединений одновременно.
Протокол управления
Для управления адаптером и передачи данных он реализует простой асинхронный протокол, схематически показанный на следующем рисунке.

В качестве управляющей подсистемы может выступать сколь угодно простое устройство, имеющее последовательный порт. От него адаптер получает команды и данные для передачи. Ему он в свою очередь передает данные, полученные от подключенных устройств, нотификацию о своем состоянии и отладочные сообщения. Основных команд всего две - сброс и подключение списка устройств. Устройства идентифицируются по их адресу. Идентификация по имени не предусмотрена, поскольку она требует дополнительной процедуры поиска устройства, к тому же имена не являются уникальными. Чтобы узнать адрес устройства, можно, например, воспользоваться программой nRF Connect.
Реализация протокола на языке python содержится в файле python/ble_multi_adapter.py
Передача бинарных данных

Поскольку протокол использует определенные байты как маркеры начала и конца сообщения (белые и черные кружки на рисунке выше), наличие этих байт в передаваемых данных нарушает протокол обмена. Для передачи произвольных бинарных данных их необходимо закодировать в base64 перед отправкой адаптеру. Байт со значением 2 добавляется в начало блока данных в качестве маркера закодированных бинарных данных. Адаптер раскодирует данные, отправит их на приемную сторону, где они будут снова закодированы в base64.
Расширенный пакетный режим
Адаптер рассчитывает, что полученный им пакет данных можно записать в характеристику, что ограничивает его размер. Адаптер использует фрагменты данных до 244 байт, которые теоретически должны передаваться без дальнейшей фрагментации. Чтобы передавать пакеты данных большего размера адаптер реализует расширенный пакетный режим, в котором пакеты делятся на фрагменты, каждый из которых снабжен однобайтным заголовком и трехбайтовой контрольной суммой, как показано на рисунке ниже.

Использование расширенного пакетного режима совершенно прозрачно для пользователя и рекомендуется для всех случаев, кроме тех, когда нужно взаимодействие с другими BLE устройствами, которые не имеют возможности обрабатывать фрагментированные пакеты расширенного режима. В конфигурационном файле расширенный режим включен по умолчанию (EXT_FRAMES). По умолчанию максимальный размер пакета данных в расширенном режиме равен 2160 байт. При необходимости его можно увеличить, изменив параметр MAX_CHUNKS в файле конфигурации.
Обработка ошибок
Адаптер использует простую, но чрезвычайно эффективную стратегию обработки ошибок - он просто рестартует заново. В том числе, это происходит при разрыве соединения.
Упрощенный протокол
Во многих случаях протокол, описанный выше, является избыточным. Часто необходимо просто иметь возможность передавать пакеты между двумя устройствами, которые автоматически устанавливают соединение друг с другом. Определив макро SIMPLE_LINK в конфигурационном файле, мы получаем упрощенный вариант протокола, ориентированного исключительно на передачу данных. В таком варианте пакеты данных можно передавать адаптеру просто обрамляя их маркерами начала (если он используется) и конца пакета, как показано на рисунке ниже.В таком же виде они будут передаваться получателю на другой стороне соединения.

Соединение будет устанавливаться автоматически, нужно только определить AUTOCONNECT и задать PEER_ADDR в конфигурационном файле. В таком варианте пара адаптеров реализует практически прозрачную связь между устройствами при условии, что пакеты всегда завершаются маркером конца пакета и не превышают максимально допустимый размер. При необходимости маркер конца пакета можно задать в файле конфигурации (UART_END). В репозитории проекта есть пара примеров конфигурационных файлов для реализации такой связки config/simple_master.h и config/simple_slave.h.
Дополнительная разметка потока данных
Часто последовательный канал связи не имеет никаких средств управления потоком, например из-за нехватки свободных выводов управляющей подсистемы. А виртуальный порт USB CDC в ESP32 не имеет средств управления потоком в принципе. Поэтому, в протоколе предусмотрено дополнительное средство обнаружения потерянных в канале связи пакетов данных и команд. Оно включается, если в конфигурационном фале определить STREAM_TAGS. При этом адаптер добавляет дополнительные два байта к каждому сообщению. Открывающий тег добавляется после маркера начала сообщения (если он используется). Закрывающий тег добавляется перед маркером конца сообщения, как показано на следующем рисунке.

Смысл этих тегов в том, что они меняются от сообщения к сообщению и зависят от его длины. Поэтому потеря целого сообщения или его части будет с высокой вероятностью обнаружена получателем. Открывающий тег меняется по циклу от сообщения к сообщению, как показано на рисунке, где SN - порядковый номер сообщения. При вычислении закрывающего тега к порядковому номеру сообщения добавляется его длина.
Если в конфигурационном файле определен STREAM_TAGS, адаптер всегда добавляет теги к сообщениям, которые он передает управляющей подсистеме. Та, в свою очередь, может использовать их или нет, независимо от STREAM_TAGS, если используется полный вариант протокола, поскольку он позволяет определить их наличие в сообщении. В упрощенном же варианте протокола теги должны присутствовать во всех сообщениях, если в конфигурации задан STREAM_TAGS, и не должны присутствовать, если не задан.
Проблемы
В ходе работы над проектом было обнаружено немало проблем и даже багов в библиотеках и Ардуино классах на их основе.
Как уже отмечалось, использование управления потоком последовательного порта это хорошо и правильно. Но есть один нюанс. При использовании двухстороннего управления RTS/CTS возможна взаимоблокировка передатчика и приемника, когда они оба пытаются записать что то в последовательный канал, при том, что у обоих буфер приема переполнен. Взаимоблокировка возникает от того, что и передатчик и приемник могут либо передавать, либо принимать данные, но не могут это делать одновременно. Есть казалось бы простое и очевидное решение - не добавлять данные в буфер передачи, если в нем нет для них места. К несчастью библиотека ESP32 не позволяет достоверно узнать размер свободного места в буфере передатчика. Так что на данный момент рекомендация сводится к тому, чтобы использовать RTS, чтобы исключить переполнение приемного буфера адаптера, но не использовать CTS.
Следует иметь ввиду, что при работе адаптера через USB CDC нет никакого управления потоком вообще. Новые данные просто перетирают старые.
Неожиданностью стало то, что метод BLERemoteCharacteristic::writeValue, который зовется, чтобы записать данные в периферийное устройство, непригоден для использования вообще. По замыслу авторов он должен дожидаться завершения предыдущей записи. Для этого он берет семафор, но не отдает его в случае ошибки, так что следующий вызов повисает навсегда. Пришлось дублировать эту функциональность в коде адаптера.
Интересно, что запись без подтверждения, которая выполняется в библиотеке по умолчанию, приводит к нестабильности соединений в случае, если их больше одного. Запись с подтверждением не имеет такой проблемы. Она и используется в адаптере.
Как выяснилось, работа адаптера одновременно в двух ролях хотя и возможна, но может приводить к нестабильности соединения. Возможно, это даже фича, а не баг. Центральное устройство управляет периодами активности в эфире как для себя, так и для подключенных периферийных устройств. Значит, устройство с двумя ролями одновременно должно как само управлять периодами активности своего радио модуля, так и управляться другим центральным устройством. Возможно, это противоречие и приводит к проблемам при одновременном использовании двух ролей.
При использовании упрощенного протокола для передачи данных через тот же порт, что используется загрузчиком ESP32, возможны две проблемы. Во-первых, загрузчик сам передает информацию в порт во время загрузки. Ее нужно отличать от данных, которые передает адаптер. Использование маркеров начала и конца сообщения, а также дополнительной разметки потока данных, решает эту проблему, нужно только задать соответствующие настройки в файле конфигурации. Вторая проблема заключается в том, что данные, которые управляющая подсистема передает адаптеру, могут быть интерпретированы загрузчиком, например, как команда на перезапуск. При этом нормальная работа адаптера может быть полностью нарушена, и он войдет в бесконечный цикл перезагрузок. Избежать этой проблемы можно только при использовании полнофункциональной версии протокола, поскольку она позволяет контролировать текущее состояние адаптера. Интерфейсный класс на языке python MutliAdapter уже имеет встроенный механизм, позволяющий избежать этой проблемы.
Компиляция, настройки
Для компиляции не потребуется ничего, кроме Ардуино. В настройках (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 или ESP32C6 Dev Module в зависимости от того, какой процессор у вас на плате. С другими возможно тоже работает, я тестировал эти Разрешите в настройках платы USB CDC On Boot. Установите правильный COM порт, соответствующий подключенной плате, и плату можно прошивать. Имейте ввиду, что платы вообще без прошивки при подключении входят в цикл перезагрузки. Чтобы прошить такую плату, ее нужно перевести в режим загрузки. Для этого нажмите и удерживайте кнопку BOOT, потом нажмите и отпустите кнопку RST, затем отпустите BOOT. После прошивки нужно нажать RST, чтобы выйти из режима загрузки.
Проект имеет множество настроек времени компиляции, которые вынесены в заголовочный файл (по умолчанию config/default.h), который вы можете скопировать, переименовать и настроить по своему вкусу, после чего вставить ссылку на него в файл user_config.h. В настройках вы можете выбрать имя устройства, режим его работы, адрес устройства для автоматического соединения, если вам нужен канал связи, который устанавливается автоматически, и многое другое.
Производительность
Максимальная скорость передачи данных, полученная в эхо-тесте для одного соединения, составляет около 4kБ/сек в одну сторону (+ столько же в другую). Для четырех соединений в каждом из них скорость падает до 1.5kБ/сек, что видимо является ограничением последовательного порта. При желании его скорость можно увеличить в настройках (UART_BAUD_RATE).
Передача данных от периферийного устройства к центральному при перегрузке канала демонстрирует рост потерянных пакетов. Фактически доставляется лишь столько пакетов, сколько канал способен пропустить, что ожидаемо т.к. они используют полностью асинхронный механизм нотификации. Запись в обратном направлении от центрального устройства к периферийному более устойчива к перегрузке, т.к. она использует запись с подтверждением, и при перегрузке начинает тормозить прием данных из последовательного порта.
Энергопотребление
Результаты измерения энергопотребления адаптера в покое приведены в следующей таблице.
Процессор | Потребление на максимальной частоте | Потребление на пониженной частоте (80MHz) |
ESP32C3 | 56mA | 48mA |
ESP32C6 | 68mA | 60mA |
ESP32S3 | 93mA | 63mA |
ESP32H2 | 27mA |
Потребление под нагрузкой измерялось в следующем тесте. Центральное устройство устанавливало соединения к трем периферийным. Те передавали ему по 50 коротких сообщений в секунду каждое. В одном варианте теста центральное устройство никак их не обрабатывало, в другом посылало каждое полученное сообщение обратно периферийному устройству. Энергопотребление периферийного устройства было практически одинаковым в обоих тестах. Результаты приведены в следующей таблице.
Процессор | Потребление на максимальной частоте | Потребление на пониженной частоте (80MHz) |
ESP32C3 | 64mA | 56mA |
ESP32C6 | 76mA | 68mA |
ESP32H2 | 31mA |
Энергопотребление центрального устройства, принимающего 3x50 сообщений в секунду показано в следующей таблице.
Процессор | Потребление на максимальной частоте | Потребление на пониженной частоте (80MHz) |
ESP32C3 | 66mA | 58mA |
ESP32C6 | 78mA | 70mA |
ESP32S3 | 100mA | 70mA |
Результаты теста с передачей данных обратно отправителю для центрального устройства показаны в следующей таблице.
Процессор | Потребление на максимальной частоте | Потребление на пониженной частоте (80MHz) |
ESP32C3 | 77mA | 69mA |
ESP32C6 | 90mA | 81mA |
ESP32S3 | 115mA | 85mA |
Как видим, передача данных от центрального устройства к периферийному наиболее затратна для центрального устройства, но почти никак не сказывается на энергопотреблении периферийного. Видимо, BLE вообще ориентирован на минимизацию энергопотребления именно периферийных устройств.
ESP32H2 продемонстрировал в тестах самое низкое энергопотребление. К сожалению, для него пока мало готовых модулей, а удобных и дешевых среди них нет совсем. Ко мне в руки попал модуль ESP32H2 Super Mini, который имел целый букет проблем и сомнительных решений. Во-первых, он оснащен заряжателем батареи, который упорно мигает своим светодиодом, даже когда никакой батареи нет. Во-вторых, линии данных USB зачем то протянули через всю плату и вывели на контактные площадки в непосредственной близости от антенны, даже не прикрыв их земляным полигоном. В-третьих, в USB разъеме постоянно пропадал контакт. Вскрытие показало, что из всех его выводов были припаяны только два. Кроме этого, в тестах ему не удалось соединиться с более чем одним периферийным устройством. Пока непонятно, это баг или принципиальное ограничение возможностей этого процессора.
В целом из оставшихся ESP32С3 показывает наилучшие результаты по энергопотреблению, но есть один нюанс.
Проблема брака
Как показали тесты, покупая самые дешевые ESP32C3 модули вроде Super Mini вы с высокой вероятностью можете получить брак. Типичные случаи брака, с которыми столкнулся я, это неправильно запаянные светодиоды (самый безобидный), запаянный процессор без встроенной флеш памяти и неработающий радио модуль процессора. Был случай, когда вроде бы рабочий модуль перестал соединяться по BT через 10 минут после включения. Среди модулей, сделанных под брендом WeAct, брака обнаружено не было.
Дальность связи
Сильно зависит от антенны. Наихудший результат (20 метров) продемонстрировали модули ESP32C3 Super Mini с чип антенной в виде небольшого куска печатной платы, на котором сформирована спиральная антенна. О том, почему дальность связи с чип антенной оказалась столь мала, и как ее можно 'починить', есть отдельная публикация.
При желании чип антенну можно удалить и припаять вместо нее внешнюю, как показано на рисунке ниже.

При этом следует иметь ввиду, что нижняя на фото площадка, куда была припаяна чип антенна, не соединена ни с чем. Поэтому, припаивая внешнюю антенну, важно соединить оплетку кабеля с землей. На фото она соединена с земляной площадкой расположенного рядом кварцевого резонатора. Если этого не сделать, то весь кабель будет работать как одна большая антенна. Мощность излучаемого сигнала при этом может даже вырасти. Но вот что точно вырастет, так это энергопотребление радио модуля. Я наблюдал рост потребления в нагрузочных тестах до 140мА и более. Со временем в таких условиях постоянной перегрузки выход радио модуля может деградировать.
В варианте с внешней антенной дальность работы на открытой местности составляет 100м. В помещении возможна устойчивая работа между соседними этажами через железобетонное перекрытие.
Что интересно, рекорд по дальности из протестированных вариантов принадлежит не внешней антенне, а модулям WeAct с ESP32C3, оснащенным печатной антенной, разведенной на плате. Они продемонстрировали дальность 150 метров, да еще и без прямой видимости в условиях коттеджной застройки. Причина такой разницы видимо в наличии п-контура для согласования импеданса в модулях WeAct. Он позволяет отбирать максимально возможную мощность с выхода радиомодуля, чей импеданс около 36 ом, то есть заметно меньше, чем у антенны.
Столь впечатляющие результаты по дальности работы были бы невозможны без программного увеличения мощности передатчика. Эта опция включена по умолчанию в файле конфигурации (TX_BOOST). Так что все описанные здесь эксперименты проводились с увеличенной мощностью передатчика.
Совместимость
Код адаптера протестирован на процессорах ESP32, ESP32C3, ESP32S3, ESP32C6. RGB светодиоды (NeoPixel), присутствующие на некоторых платах, могут быть использованы для индикации состояния соединения. Для этого нужно определить NEO_PIXEL_PIN в файле конфигурации.
Адаптер может работать совместно с JDY-08 и им подобным адаптерам при условии, что вы передаете не более 20 байт данных за один раз. Он также полностью совместим с Web BLE приложениями, например с этим. Приложение по ссылке удобно использовать для тестирования.
Исходный код
Лежит тут https://github.com/olegv142/esp32-ble-uart-mx
Директория проекта для Ардуино ble_uart_mx