На даче роутера нет и не предвидится, а температуру в теплице хотелось бы видеть на телефоне, который лежит в кармане в десяти метрах от грядки. Первая мысль была про WiFi — и умерла там же, где и роутер: сети нет, соединяться не с чем, а точку доступа на каждый приезд поднимать лень. Зато в ESP32, кроме WiFi, сидит второй радиомодуль — Bluetooth, тот самый, из названия платы. Я решил, что дело на пять минут: спарю плату с телефоном, как наушники, и буду читать цифры хоть из теплицы, хоть из дома.
Плата честно появилась в списке Bluetooth на Android, телефон отрапортовал о сопряжении — а данных не было нигде: ни в настройках, ни в каком-либо файловом менеджере, ни в виджетах. Полчаса я тыкал в пустой экран, пока не дошёл до простого факта: BLE — не про «подключил и слушает», как с колонкой. Данных в телефоне не появится, пока я сам не превращу плату в сервер с сервисом и характеристикой, а на телефоне мне нужен не файловый менеджер, а GATT-клиент — приложение-сканер вроде nRF Connect. Этот урок — про то, как собрать такой сервер по-настоящему, понимая каждую строку, а не копируя пример целиком.
Роли и этажи: GAP, GATT и кто есть кто
В Bluetooth Low Energy два этажа, и путать их — самая частая причина ситуации «плату видно, а данных нет». Нижний этаж зовётся GAP: это реклама и связи. Плата-периферийник раз в сто миллисекунд кричит в эфир короткий пакет: «я здесь, меня зовут так-то» (интервал настраивается; стандарт разрешает от 20 мс до 10,24 с). Телефон-центр слушает эфир, рисует список устройств и решает, подключаться ли. Верхний этаж — GATT: то, что открывается уже после подключения. Внутри — дерево из трёх уровней: сервис (логическая группа функций), внутри характеристики (ячейки данных, которые можно читать и писать), внутри дескрипторов (служебные подписи к ячейкам).
Стороны здесь поменялись местами относительно привычного, и на этом я спотыкался дважды. В BLE сервер — это обычно плата с датчиками: она хранит данные и отдаёт их по запросу. Клиент — телефон: он инициирует подключение, читает характеристики и пишет свои. То есть подключается к серверу именно телефон, хотя слово «сервер» у многих ассоциируется с чем-то большим и удалённым. Критерий простой: сервер — тот, кто держит данные, а не тот, кто начал связь.
У каждого сервиса и характеристики есть идентификатор — UUID. Стандартные, выданные организацией Bluetooth SIG, короткие, 16-битные: 0x180F — служба батареи, 0x2A19 — уровень заряда внутри неё, 0x180D — служба пульса у фитнес-браслетов. Свои сервисы делают на 128-битных UUID — это 16 байт, которые в тексте выглядят как 36 символов с дефисами: 4fafc201-1fb5-459e-8fcc-c5c9c331914b — ровно такой сервис лежит в официальном примере BLE_notify из пакета плат ESP32. Сочинять UUID руками не стоит: возьмите генератор (в nRF Connect он встроен), потому что самодельные «красивые» последовательности вроде 12345678-... некоторые приложения-сканеры отказываются показывать.
Одна деталь про размер значений, чтобы вы не удивлялись потом: характеристика может хранить до 512 байт, но телефон вытянет длинное значение только частями — процедурой long read, за несколько обменов вместо одного. Для показаний я держу короткие значения: прочитались за один запрос — и хорошо. А ещё у одной характеристики бывает несколько свойств сразу: READ вместе с WRITE, а в следующем уроке добавим и NOTIFY — флаги просто складываются через побитовое ИЛИ.
Сервер в четырнадцати строках
#include <BLEDevice.h>#include <BLEServer.h> #define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" void setup() { Serial.begin(115200); BLEDevice::init("ESP32-Meteo"); // имя, которое увидит сканер BLEServer *server = BLEDevice::createServer(); BLEService *service = server->createService(SERVICE_UUID); BLECharacteristic *ch = service->createCharacteristic( CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE ); ch->setValue("hello"); // что прочитает телефон service->start(); // без этого сервер внутри пустой BLEAdvertising *adv = BLEDevice::getAdvertising(); adv->addServiceUUID(SERVICE_UUID); // иначе сканер не покажет сервис BLEDevice::startAdvertising(); // пошли кричать в эфир} void loop() { // сервер работает в фоне, здесь пока пусто}Разбор по этажам. BLEDevice::init поднимает контроллер и даёт плате имя — оно уезжает в рекламный пакет, а у того жёсткий бюджет: 31 байт полезной нагрузки в классическом пакете. Имя, сервисный UUID и служебные поля делят эти 31 байт между собой, поэтому длинные имена вроде «ESP32 Теплица Северная» откусывают место у данных, а телефон ещё и показывает их с задержкой: имя часто живёт в scan response — отдельном пакете, который плата отдаёт по запросу сканера. Я держу имена до 15-16 символов и получаю честную картину в списке. Дальше createServer, createService и createCharacteristic строят дерево GATT; PROPERTY_READ разрешает телефону читать значение, PROPERTY_WRITE — писать своё, и флаги можно комбинировать через побитовое ИЛИ.
service->start() и startAdvertising() — две разные кнопки, и пропустить легко любую. Первая публикует сервис внутри стека, вторая включает GAP-рекламу в эфире. После загрузки открывайте на телефоне nRF Connect (Android и iOS) или LightBlue: в списке появится ESP32-Meteo, по кнопке Connect приложение вытянет дерево сервисов, и внутри окажется характеристика со значением hello. Если монитор порта вы ещё не обживали, самое время по уроку про первый запуск Serial-монитора — в логах BLE удобно оставлять метки этапов. Кнопка со стрелкой вниз перечитывает значение: перезальёте скетч с новой строкой — на телефоне появится новое.
И не пугайтесь, когда после подключения плата исчезнет из списка сканера на втором телефоне: это норма BLE, а не поломка. Периферийник, который уже в связи, эфир не рекламирует — advertising останавливается на время соединения и возобновляется после разрыва, если скетч об этом попросит. Про то, как попросить правильно и не потерять плату после первого же разрыва, — отдельная тема следующего урока.
| UUID | Что это | Где встречаю |
|---|---|---|
| 0x180F | Служба батареи | Наушники и трекеры: группа заряда |
| 0x2A19 | Уровень батареи, % | Характеристика внутри 0x180F, значение 0-100 |
| 0x180D | Служба пульса | Фитнес-браслеты, нагрудные датчики ритма |
| 0x2A6E | Температура, значение x 100 | Датчики окружения: 2345 = 23,45 °C |
Пингуем в симуляторе: что и как часто
Движок страницы WiFi умеет, а BLE-стек не поднимает — и я не буду делать вид, что можно честно эмулировать GAP на JavaScript в браузере. Зато половину работы сервера можно показать по-настоящему: какую строку и как часто скетч готовит для эфира. Ниже тот же датчик DHT, про подключение которого я писал в отдельном уроке про DHT11, таймер на millis() без delay из урока про планировщик и сборка пакета показаний — ровно той строки, которая в следующем уроке поедет в notify().
Запустите и покрутите ручку сенсора: строка pkt= обновляется раз в две секунды, счётчик n растёт, температура и влажность меняются на глазах. Счётчик — не украшение: когда дойдёте до реального канала, по нему будете замечать пропавшие пакеты — было n=34, потом n=36, значит один не доехал. Формат t=...;h=...;n=... выбран не случайно — он влезает в один notify-пакет, и о том, почему это важно, будет отдельный разговор. А пока запомните ритм: сборка данных по таймеру, одна строка на один отправляемый пакет.
- 1. Кто в архитектуре BLE хранит данные и отдаёт их по запросу?
- 2. Сколько байт полезной нагрузки у классического рекламного пакета BLE?
- 3. Забыли service->start() перед рекламой. Что увидит сканер после подключения?
- 4. 128-битный UUID — сколько символов в его текстовой записи?
- 5. Телефон подключился к плате. Что происходит с рекламой?
- BLE — это сервер (плата, держит данные) и клиент (телефон, читает и пишет); подключение инициирует всегда клиент
- Два этажа: GAP — реклама и связи, GATT — дерево сервис, характеристика, дескрипторы
- Минимальный сервер: init, createServer, createService, createCharacteristic, service->start(), startAdvertising()
- Рекламный пакет — 31 байт: имя короткое, сервисный UUID добавлен через addServiceUUID
- Сканеры: nRF Connect и LightBlue; после подключения плата уходит из рекламы — и это не поломка
Сервер, который молчит и ждёт, пока телефон сам жмёт кнопку «читать», — уже рабочий инструмент, но для метеостанции хочется наоборот: плата сама пинает телефон при каждом новом показании. Как устроена подписка — дескриптор CCCD, в который телефон пишет заветные 0x0001, — почему notify-пакет по умолчанию не длиннее 20 байт и как не потерять плату после первого разрыва связи, — в следующем уроке про BLE-уведомления.
А если роутер всё-таки есть, тот же датчик удобнее вести по WiFi: в уроке про первый выход платы в сеть я разбирал подключение по шагам, а в уроке про собственную точку доступа SoftAP — вариант вовсе без роутера, когда сеть поднимает сама плата. Bluetooth выбираю, когда телефон должен работать с платой напрямую, без инфраструктуры: теплица, гараж, съёмная дача.
Частые вопросы по теме урока
- Кто в архитектуре BLE хранит данные и отдаёт их по запросу?
- Сервер — тот, кто держит данные, а не тот, кто начал связь. Подключается к плате именно телефон, потому что клиент в BLE всегда инициирует соединение. Сервер — периферийник, обычно плата с датчиками
- Сколько байт полезной нагрузки у классического рекламного пакета BLE?
- Классический рекламный пакет несёт 31 байт полезной нагрузки: имя, UUID сервиса и служебные поля делят его между собой. Поэтому имя платы держат коротким. 31 байт
- Забыли service->start() перед рекламой. Что увидит сканер после подключения?
- startAdvertising() включает GAP-рекламу независимо от содержимого GATT. Без service->start() стек публикует пустой сервер — соединение есть, данных нет. Устройство есть, но список сервисов окажется пустым
- 128-битный UUID — сколько символов в его текстовой записи?
- 16 байт, записанных восемью-четырьмя-четырьмя-четырьмя-двенадцатью шестнадцатеричными цифрами с дефисами, — это 36 символов. Такой формат у UUID из официального примера BLE_notify. 36
- Телефон подключился к плате. Что происходит с рекламой?
- Это норма BLE: в соединении периферийник не рекламируется. Второй телефон не увидит плату до разрыва связи — а вернёт ли скетч рекламу после разрыва, зависит от вашего кода. Останавливается: периферийник в связи эфир не рекламирует