惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Vercel News
Vercel News
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
雷峰网
雷峰网
有赞技术团队
有赞技术团队
罗磊的独立博客
博客园 - 叶小钗
Jina AI
Jina AI
博客园 - 司徒正美
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
Tailwind CSS Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
人人都是产品经理
人人都是产品经理
Apple Machine Learning Research
Apple Machine Learning Research
阮一峰的网络日志
阮一峰的网络日志
Microsoft Security Blog
Microsoft Security Blog
大猫的无限游戏
大猫的无限游戏
量子位
MyScale Blog
MyScale Blog
V
Visual Studio Blog
博客园 - 聂微东
The Cloudflare Blog
Engineering at Meta
Engineering at Meta
小众软件
小众软件
宝玉的分享
宝玉的分享

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
BLE для Arduino: пишем команды, а не дескрипторы
AndreyRodin · 2026-05-12 · via Все публикации подряд на Хабре

Время на прочтение7 мин

Охват и читатели1.4K

Разработать 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.
Автор на связи.