Сервер из прошлого урока я довёл до ума и тут же упёрся в неудобство: чтобы увидеть свежую температуру в теплице, надо было каждый раз брать телефон, открывать nRF Connect, жать Connect и тыкать кнопку «читать». Значение обновлялось ровно тогда, когда я об этом просил, — то есть никогда сам. Через неделю таких ручных сеансов я понял, что построил цифровой термометр с ручным заводом, и полез искать, как плата сообщает данные сама. Сам сервер переделывать не пришлось — структура сервис, характеристика, реклама осталась та же, меняется только способ доставки значения.
Ответ — механизм notify, и со второй попытки он заработал, а первая была показательным провалом: я вызвал notify() в setup(), ещё до того, как хоть один клиент подключился, — пакет улетел в пустоту, и никаких предупреждений ни компилятор, ни Serial мне не показали. Вторая грабля ждала на стороне телефона: подписка, которую делает приложение, оказалась отдельным действием — без него плата шлёт и шлёт, а телефон молчит. Разбираем обе темы по косточкам: что происходит на проводе при подписке и как правильно слать поток показаний.
Как телефон подписывается: CCCD и 0x0001
У характеристики, которой разрешён notify, появляется служебный дескриптор CCCD — Client Characteristic Configuration Descriptor, стандартный UUID 0x2902. Это два байта, и когда в приложении вы жмёте кнопку подписки, телефон пишет в них 0x0001: «присылай уведомления по этой характеристике». Хочешь delivery с подтверждением — телефон пишет 0x0002 и получает indicate: та же посылка, но плата ждёт квитанцию от клиента, поэтому медленнее. В nRF Connect это те три стрелочки рядом с характеристикой: одна читает, вторая подписывает на notify, третья — на indicate. Ключевая деталь: подписка живёт на стороне телефона и привязана к соединению — после разрыва CCCD обнуляется, и при новом подключении приложение подписывается заново.
Теперь про размер. По умолчанию MTU BLE-соединения — 23 байта, из которых три уезжают на заголовок протокола: на полезную нагрузку notify-пакета остаётся 20 байт. Телефон может попросить больше — nRF Connect на старте обычно поднимает MTU до 185-517, и ESP32 согласится, — но закладываться надо на худший случай: 20 байт на пакет. Моя строка показаний t=23.5;h=44;n=12 — это 16 символов, влезает с запасом. Если когда-нибудь упрётесь, переходите на компактный формат: температура как целое, умноженное на сто, — два байта вместо шести символов.
И про ритм: notify() не летит в эфир мгновенно — пакет ставится в очередь ближайшего connection event, а интервалы связи задаёт телефон. Стандарт разрешает от 7,5 мс до 4 секунд, телефоны на практике выбирают 30-100 мс. Отсюда вывод, который сэкономил мне вечер экспериментов: слать чаще, чем раз в несколько десятков миллисекунд, бессмысленно — пакеты просто сольются; раз в секунду, как в моём скетче, BLE даже не заметит нагрузки. Сам секундный таймер я собрал на millis() без единого delay по уроку про планировщик — тот же приём, что и в предыдущем уроке.
Отдельный вопрос — чем паковать значение. Текст вроде t=23.5;h=44 читается в любом приложении-сканере глазами, и для отладки это золото. Бинарный вариант компактнее: температура как целое, умноженное на сто, — два байта вместо шести символов; но в сканере такой пакет выглядит как hex-строка 09 30, и без таблицы формата вы не поймёте ни байта. Моё правило: показания — текстом, пока пакет не упрётся в 20 байт; сырые потоки данных, где каждый байт на счету, — уже бинарными полями, и тогда в документации к характеристике живёт таблица расшифровки.
Скетч: подписка, таймер, notify
#include <BLEDevice.h>#include <BLEServer.h>#include <BLE2902.h> // дескриптор подписки CCCD 0x2902 #define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" BLEServer *server = nullptr;BLECharacteristic *ch = nullptr;bool connected = false;unsigned long last = 0;int n = 0; class ServerCB : public BLEServerCallbacks { void onConnect(BLEServer *s) override { connected = true; } void onDisconnect(BLEServer *s) override { connected = false; BLEDevice::startAdvertising(); // без этого плату больше не найдут }}; void setup() { Serial.begin(115200); BLEDevice::init("ESP32-Meteo"); server = BLEDevice::createServer(); server->setCallbacks(new ServerCB()); BLEService *service = server->createService(SERVICE_UUID); ch = service->createCharacteristic( CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); ch->addDescriptor(new BLE2902()); // на ядрах 2.x без него подписки нет service->start(); BLEDevice::getAdvertising()->addServiceUUID(SERVICE_UUID); BLEDevice::startAdvertising();} void loop() { if (connected && millis() - last >= 1000) { last = millis(); n++; float t = 23.5 + (n % 5) * 0.1; // здесь будет dht.readTemperature() String pkt = "t="; pkt += t; pkt += ";h=44;n="; pkt += n; ch->setValue((uint8_t*)pkt.c_str(), pkt.length()); ch->notify(); // ушло всем подписавшимся }}Тут три новинки против прошлого урока, и каждая — по делу. Первая — колбэки: класс ServerCB перехватывает onConnect и onDisconnect, и флаг connected позволяет слать только тогда, когда есть кому. Вторая — addDescriptor(new BLE2902()): именно эта строка прицепляет дескриптор 0x2902, в который телефон пишет свою подписку; без него на ядрах Arduino-ESP32 2.x приложение даже не покажет кнопку подписки. К слову, с ядра 3.0 BLE-библиотека переехала на стек NimBLE, и там дескриптор добавляется автоматически — но и лишняя строка не мешает, код остаётся совместимым с обеими ветками. А бонусом NimBLE экономит сотни килобайт флеша и около 30 КБ RAM — на проектах рядом с WiFi это заметно.
Про строку с setValue — отдельно, потому что тут самая незаметная грабля. Метод принимает указатель на байты и длину, и длина у меня считается через pkt.length() — как в любом настоящем Arduino-коде со String. Лимит в 20 байт при дефолтном MTU никто проверять за вас не будет: длинная строка молча обрежется, и телефон начнёт показывать хвосты. В боевом скетче на этом месте стоит dht.readTemperature() — подключение датчика я разбирал в уроке про DHT11. А привычка печатать собранный пакет в Serial парой имя=значение, поставленная в уроке про отладку логики, ловит кривой формат ещё до телефона.
Третья — правильный разрыв связи. Когда телефон уходит, BLE-сервер молча останавливает рекламу, и плата выпадает из всех сканеров навсегда — я однажды гонял цикл «переподключиться» по двадцать минут, пока не догадался, что после первого же разрыва устройство больше не рекламируется. Строка BLEDevice::startAdvertising() внутри onDisconnect возвращает плату в эфир; на millis() она не тратит ни такта, пока разрыва нет. Формат пакета в setValue — тот же текст t=...;n=..., который мы репетировали в симуляторе прошлого урока: читать такие значения можно в любом приложении без расшифровки байтов, что при отладке в поле экономит больше времени, чем компактный бинарный формат.
| Свойство | Кто инициирует | Когда беру |
|---|---|---|
| READ | Телефон опрашивает | Редкие значения: версия прошивки, серийник |
| WRITE | Телефон посылает | Команды плате: включить, откалибровать |
| NOTIFY | Плата, без подтверждения | Поток показаний раз в секунду — мой выбор по умолчанию |
| INDICATE | Плата, ждёт квитанцию | Ценные события, потерять пакет нельзя |
В симуляторе ниже — та же механика, но с датчиком: раз в секунду скетч собирает строку показаний DHT и печатает её в Serial. BLE-стек движок страницы не поднимает, поэтому подписку CCCD здесь изображает флаг cccd=1, включённый с самого старта: считайте, что телефон уже ткнул стрелку подписки. Никакой магии в этом флаге нет — это честная заглушка вместо пакета, который телефон писал бы в дескриптор. Смотрите на ритм и формат — они один в один совпадают с тем, что вы увидите в nRF Connect на живой плате, включая счётчик пакетов.
- 1. Что пишет телефон в дескриптор CCCD, чтобы получать notify?
- 2. MTU не поднимали выше дефолтного. Сколько байт полезной нагрузки в одном notify-пакете?
- 3. Плата порвала связь с телефоном. Что случится с рекламой и подпиской?
- 4. С какой частотой шлёт notify() при connection interval 50 мс, если вызывать её каждые 5 мс?
- 5. Что даёт переезд BLE-стека на NimBLE в ядрах Arduino-ESP32 3.x?
- notify — это плат-инициатор: подписка через CCCD 0x2902, телефон пишет туда 0x0001 после подключения
- Минус BLE2902 на ядрах 2.x — нет подписки; с ядром 3.x стек NimBLE ставит дескриптор сам
- Дефолтный MTU 23 = 20 байт полезной нагрузки: держите пакет текстовым и коротким
- В onDisconnect зовите startAdvertising() — иначе после первого разрыва плату никто не найдёт
- notify() до подключения улетает молча в никуда: шлите только при флаге connected
Поток показаний на телефон — уже полноценный IoT на одном радиомодуле, без роутера и облака. Если такой же поток нужен не телефону, а серверу, сравните ощущение: в уроке про HTTP-запросы с ESP32 плата сама стучится в сеть за данными, и та же строка t=...;h=... без изменений уезжает в POST-запросе. Bluetooth и HTTP в моём хозяйстве делят так: телефон рядом — BLE, данные в интернет — WiFi.
А тем, кому нужен не поток данных, а терминал — печатать логи и вводить команды по воздуху, как в мониторе порта, — дальше по маршруту урок про Bluetooth SPP: там классический Bluetooth и SerialBT, знакомый Serial без единого провода. И да, про батарею: пик радио — порядка сотни миллиампер, BLE экономит не током, а скважностью, поэтому для батареечных устройств следующий шаг — глубокий сон между рассылками.
Частые вопросы по теме урока
- Что пишет телефон в дескриптор CCCD, чтобы получать notify?
- CCCD (UUID 0x2902) — два байта: 0x0001 включает notify, 0x0002 — indicate с подтверждением. Подписка живёт на телефоне и привязана к соединению. 0x0001 — два байта подписки
- MTU не поднимали выше дефолтного. Сколько байт полезной нагрузки в одном notify-пакете?
- MTU 23 минус 3 байта заголовка — 20 байт на значение. Телефон может попросить больше, но закладываться надо на 20: строка t=23.5;h=44;n=12 влезает с запасом. 20 байт
- Плата порвала связь с телефоном. Что случится с рекламой и подпиской?
- После разрыва сервер молчит в эфире, поэтому в onDisconnect зовут BLEDevice::startAdvertising(). CCCD обнуляется — приложение подписывается заново. Реклама стоит, подписка обнулилась — нужно restartAdvertising() и повторная подписка
- С какой частотой шлёт notify() при connection interval 50 мс, если вызывать её каждые 5 мс?
- notify() ставит пакет в очередь ближайшего connection event. Телефон выбирает интервал 30-100 мс, поэтому чаще слать бессмысленно. Пакеты сольются в очередь: реальный темп задан интервалом связи, быстрее телефона не будет
- Что даёт переезд BLE-стека на NimBLE в ядрах Arduino-ESP32 3.x?
- NimBLE заметно легче Bluedroid по памяти, и на связке BLE+WiFi это критично. Скорость канала при этом ограничена тем же радио, а Classic остаётся отдельной темой. Экономит сотни килобайт флеша и около 30 КБ RAM против Bluedroid