Разработать Bluetooth LE устройство на Arduino не так уж сложно. А вот на стороне клиента организовать нормальный обмен командами и ответами — тут начинается настоящая боль. Хочется просто отправить команду и сразу получить ответ. Или чтобы устройство само отправляло координаты, пока едет машинка. Без погружения в GATT и без асинхронных танцев с бубном.
Я подготовил две библиотеки — для Arduino и для .NET (MAUI, WPF, WinForms), которые скрывают все сложности BLE за простым текстовым протоколом.
Возможности:
Текстовый протокол: удобно логировать и отлаживать.
Простота использования: не нужно разбираться в тонкостях GATT, характеристиках, дескрипторах, буферизации.
Кроссплатформенность: единый API для MAUI (Android/iOS), WPF и WinForms.
Надежность: автоматическая сборка фрагментированных пакетов, ожидание ответа и таймауты.
Расширенные сценарии: удобная работа с BLE-устройством с доступом к нативным объектам.
Код для Arduino: скетч за минуту
Загружаем библиотеку BleCommands и пишем:
#include <BLECommands.h> BLECommandsServer server; void setup() { Serial.begin(9600); while (!Serial); if (!server.begin("BLECommands Device")) { Serial.println("BLECommands server failed to start"); while (true); } server .onCommand("PING", [](const String& command, const String& args) -> String { return "PONG"; }) .onCommand("ECHO", [](const String& command, const String& args) -> String { return args.isEmpty() ? command : command + " " + args; }) .onCommand("GET", [](const String& command, const String& args) -> String { if (args == "MAC") return BLE.address(); if (args == "NAME") return "BLECommands device"; return "WRONG ARGS"; }); } void loop() { server.poll(); }
Первый пробел в строке — разделитель команды и аргументов. Это позволяет удобно обрабатывать такие команды, как GET MAC и GET NAME.
Можно также переопределить обработчик неизвестных команд:
server.setFallbackHandler([](const String& input) -> String { return "UNKNOWN: '" + input + "'"; });
Код для клиента C#: ещё минута
using var transport = await ArduinoClient.CreateTransportAsync(deviceName); if (transport == null) { Console.WriteLine($"Failed to connect to device '{deviceName}'"); return; } await transport.StartAsync(); var reply = await transport.SendCommandAsync("PING"); Console.WriteLine(reply); // "PONG"
Объект BleTransport содержит событие Disconnected — на случай разрыва связи.
А теперь более подробно.
Обмен текстовыми сообщениями
Когда-то, экспериментируя с Bluetooth-модулем HM-10, подключённым к Arduino через UART, я столкнулся с проблемой разрыва сообщений. Даже короткий текст мог быть разорван: сообщение «POS 100» передавалось как две строки «POS 1» и «00». Чтобы решить эту проблему, пришлось разделять сообщения неотображаемым символом (например, ‘\n’) и собирать сообщения воедино в обработчике события обновления характеристики.
Такой же подход реализован и в библиотеке. На платах со встроенным Bluetooth проблема разрыва сообщений уже не так актуальна. Тем не менее, реализованный в библиотеке подход позволяет удобно передавать длинные строки.
Что делать, если нужно передать из устройства длинный текст, например, JSON с настройками? Библиотека Arduino автоматически разбивает текст на чанки, последовательно отправляет их и в конце отправляет финальный разделитель. На стороне клиента в дело вступает класс TokenAggregator, собирающий отдельные куски и инициирующий событие TokenReceived, когда получен разделитель.
Кроме того, можно отправлять не только ASCII, но и UTF-8. Вы можете отправлять русский текст, не заботясь о его длине, и на стороне клиента он будет правильно собран.
Запрос — ответ
В чём сложность реализации столь очевидной и знакомой парадигмы в стеке Bluetooth LE? И можно ли обойтись без неё? В принципе, если мы имеем дело с какими-нибудь датчиками — то да, можно. Тогда характеристика просто поддерживает состояние, на это и заточен GATT.
Но можно ли таким образом управлять сложным устройством? Ведь приложение должно знать, как устройство реагирует на команды. Без такой возможности реализовать сложную логику будет весьма затруднительно, а скорее — и невозможно.
Представьте, что ваше устройство — это кофемашина с 20 параметрами:
Температура воды, давление, помол, режим подготовки, таймеры, блокировки
Переходы между состояниями имеют предусловия («нельзя включить нагрев без воды»).
Если вы попытаетесь смоделировать это через одну характеристику состояния, вы столкнётесь с большими проблемами. Клиент пишет новое состояние — устройство должно проверить, возможен ли переход и ответить ошибкой, если нет. Но GATT Write не возвращает ошибку с текстом! Максимум — стандартный ATT error code без возможности передать произвольный текст ошибки. Никакого тебе «недостаточно давления в насосе».
Итак, нам нужно иметь функцию, отправляющую команду на устройство и возвращающую ответ.
Решение — две характеристики:
Write — для отправки команд;
Notify — для получения ответов.
Клиент пишет команду в характеристику со свойством Write, подписывается на Notify и ждёт ответа с таймаутом. Не дождался — null. Всё просто.
Task<string?> SendCommandAsync(string command, CancellationToken token = default);
Есть и третья характеристика для передачи периодических сообщений от устройства. Но об этом чуть позже.
Соединение
Прежде чем обмениваться информацией, с устройством нужно соединиться. Соединение с Bluetooth LE устройством в разных ОС организовано примерно одинаково:
Windows: достаточно получить устройство путём вызова одного из двух статических методов
BluetoothLEDevice.FromBluetoothAddressAsync()илиFromIdAsync(). Правда, это не означает, что соединение установилось. Тем не менее, после этого можно сразу вытаскивать сервисы из устройства — тогда соединение точно будет установлено. Нужно отметить, что если устройство не было сопряжено и не находится в системном кэше, эти функции вернутnull. То есть если устройство открывается в первый раз, связаться с ним можно только через сканирование. Есть также классGattSession, в котором можно определить время жизни соединения. Практика показывает, что после создания сессии соединение с устройством происходит почти мгновенно.В Android для установки соединения используется синхронная функция
device.connectGatt(). После этого, как и в Windows, процесс соединения происходит в фоне, и пользователь может получать сведения о текущем статусе в обратных вызовах.В iOS соединение устанавливается классом
CBCentralManagerв функцииconnect(). И подобно Android и Windows, процесс соединения можно отслеживать и обрабатывать ошибки соединения.
Таким образом, на всех трёх платформах соединение происходит в фоновом режиме. Поэтому функция Device.ConnectAsync() не возвращает результат, а свойство Device.IsConnected меняется динамически.
Объекты Device, Service и Characteristic
Я стремился к максимальной простоте, поэтому интерфейсы содержат только самое необходимое. Например, в ICharacteristic нет дескрипторов. Это сознательное решение. Получить доступ к нативному объекту (и через него — к дескрипторам) всегда можно.
Device порождает Services, Service порождает Characteristics. Всё это disposable-объекты. Если освобождается Device, то все дочерние объекты перестают функционировать. Чтобы избавить пользователя от необходимости освобождать все объекты в иерархии, Device и Service, как наследники интерфейса IChildDisposer, сохраняют всех своих детей, полученных пользователем. Таким образом, для освобождения системных ресурсов достаточно вызвать Device.Dispose().
Windows не содержит явного метода Disconnect. Для закрытия связи с устройством достаточно вызвать Dispose у устройства. Такой же философии придерживается и библиотека. Главное — не забыть это сделать.
Прослушивание
Не всегда нужен только запрос-ответ. Представьте: вы отправили команду START, и устройство должно начать отправку своих текущих координат каждые 100 мс. Как это сделать? Для этого служит режим Listening. Для прослушивания используется третья характеристика Notify.
Устройство, получив команду, начинает передавать клиенту сообщения при помощи метода server.send(). На стороне клиента нужно подписаться на сообщение BleTransport.ListeningTokenReceived и вызвать функцию StartListening, задав таймаут между сообщениями. Если по каким-то причинам в течение таймаута от устройства ничего не пришло, срабатывает событие BleTransport.ListeningTimeoutElapsed.
При разработке своего протокола обмена стоит предусмотреть возможность сообщения клиенту о том, что прослушивание нужно прекратить. Например, когда устройство останавливается, следует передать последним сообщением какой-нибудь признак окончания движения — END или FINISH.
В примере WpfSample можно увидеть, как это работает на практике. В скетче Messaging команда START запускает отправку сообщений «POS <n>» от 1 до 100 с периодичностью в полсекунды. В конце отправки скетч отправляет сообщение END, что сигнализирует о конце передачи.

Характеристики
Таким образом, есть три характеристики с предопределёнными UUID:
Характеристика |
UUID |
Свойства |
Назначение |
Command |
DB341FB3-8977-4C2D-AC6C-74540BD8B902 |
Write | WriteWithoutResponse |
Команды от клиента |
Response |
DB341FB3-8977-4C2D-AC6C-74540BD8B903 |
Notify | Indicate |
Ответы на команды |
Listening |
DB341FB3-8977-4C2D-AC6C-74540BD8B904 |
Notify | Indicate |
Периодические данные |
Идентификаторы (вместе с UUID сервиса DB341FB3-8977-4C2D-AC6C-74540BD8B901) едины для Arduino и клиента.
Заметьте: характеристики позволяют отправлять данные как с подтверждением, так и без (свойства Write | WriteWithoutResponse и Notify | Indicate). Это даёт клиенту свободу выбора. Библиотека отправляет команды на устройство с подтверждением, а для Response и Listening-характеристик используется режим Notify.

Кроссплатформенность без сюрпризов
Библиотека существует в двух версиях:
BleCommands.Windows — для WPF, WinForms, Console (нативные объекты WinRT).
BleCommands.Maui — для Android/iOS через популярный пакет Plugin.BLE.
BleCommands.Core содержит общие абстракции и базовые реализации.
Каждый Device, Service, Characteristic хранит нативный объект. При необходимости вы можете опуститься на уровень ниже и использовать всю мощь родного API.
На стороне Arduino библиотека BleCommands использует многоплатформенную библиотеку ArduinoBLE.
Расширенные варианты использования
Поскольку библиотека предоставляет доступ к нативным объектам, её использование не ограничивается только Arduino. В репозитории есть пример MauiSample — приложение, которое:
подключается к любому устройству с активным Advertising;
показывает сервисы и характеристики;
позволяет писать в характеристику текст и наблюдать за изменениями на Notify-характеристике.
Из двух дней разработки этого примера большая часть ушла на UI, а не на BLE-логику.

В примере используется класс BleScanner для поиска устройства по имени и соединения с ним и наследники классов Service и Characteristic.
Ссылки, чтобы не искать
NuGet-пакеты:
BleCommands.Core
BleCommands.Maui
BleCommands.Windows
Arduino library: BleCommands
Дисклеймер
Для разработки библиотеки Arduino я использовал ESP32, на других платах пока не тестировал. Для работы с Bluetooth используется стандартная библиотека ArduinoBLE, рассчитанная на применение со многими платами. Допускаю, что текущая реализация библиотеки BleCommands в Arduino может оказаться слишком прожорливой по памяти для некоторых плат из-за использования std::map и std::function.
В экспериментах по отсылке длинной строки мне удалось отправить клиенту строку длиной в 65520 символов. String длиннее создать не удаётся: видимо, это максимальный размер, который способен выделить realloc в String.
Не пишите BLE-велосипед.
Берите библиотеки, подключайтесь, посылайте команды. Вся GATT-асинхронщина, фрагментация, таймауты и управление жизненным циклом объектов — уже внутри.
Форкните, ставьте звёздочку, пишите issue и pull request.
Автор на связи.
NutsUnderline
и не удивляйтесь почему все тормозит и вообще не работает.
bluetooth вообще содержит большое количество оберток и инкапсуляций протоколов. Регулярно вижу как реализуют "своими силами" то что, предусмотрено стандартом, используя bluetooth как uart через gatt. HM10 это просто чудесно "упрощает" введением еще одной прослойки с at-командами (и своей прошивкой, а в ней могут быть и глюки) А особенно это кучеряво на фоне того что низкоуровневые hci команды исторически через uart, плюс там еще L2. Т.е. пятикратная инкапсуляция, огромный оверхет.
И при этом это как бы и стандарт, а каждое устройство говорит на своем "языке", и только со своим софтом, почти нельзя взять и просто использовать другой/альтернативный софт.
Так что очень есть резоны углубляться, изучать протокол и использовать имеющие стандартные возможности по максимуму
AndreyRodin Автор
Нельзя не согласиться.
Но должен заметить, что HM-10 здесь ни при чём. Базовая библиотека ArduinoBLE не будет работать с этим модулем.