158. Bluetooth LE на ESP32: реклама, GATT-сервер на NimBLE, характеристики и уведомления
Bluetooth LE нужен прибору, у которого нет экрана, но есть владелец с телефоном: настроить, посмотреть показания, передать имя и пароль Wi-Fi. У ESP32 радио Bluetooth на том же кристалле, что и Wi-Fi, а в ESP-IDF есть два стека: большой Bluedroid и компактный NimBLE. Здесь — NimBLE.
Радио эмулятор не моделирует, поэтому раздел проверен сборкой (_verify/esp158/): программа —
GATT-сервер с рекламой и двумя характеристиками; размеры образа и памяти, настройки из sdkconfig
и значения из заголовков NimBLE — из собранного проекта. Как проверить на плате — в 158.7.
Как это называется в документации
Оригинал Перевод BLE (Bluetooth Low Energy) Bluetooth с низким потреблением GAP (Generic Access Profile) роли и реклама: кто рекламирует, кто подключается advertising, advertising interval реклама, интервал между рекламными пакетами peripheral, central периферийное устройство и центральное (телефон) GATT (Generic Attribute Profile) сервисы и характеристики поверх ATT service, characteristic сервис, характеристика ATT MTU наибольший размер пакета атрибутного протокола notification, indication уведомление без подтверждения и с подтверждением UUID идентификатор сервиса или характеристики bonding, pairing запоминание ключей, сопряжение
158.1 Роли и путь данных: GAP и GATT
Прибор — периферия: он рекламирует себя короткими пакетами и ждёт. Телефон — центральное устройство: сканирует эфир, находит рекламу и подключается. Это уровень GAP. После подключения данные идут по GATT: у прибора таблица сервисов, в каждом — характеристики со своими правами: чтение, запись, уведомление. Телефон читает и пишет характеристики, а прибор сам шлёт уведомления, когда значение изменилось.
158.2 Стек NimBLE и цена в памяти
Стек включается двумя строками sdkconfig.defaults, остальное — значения по умолчанию из
собранного sdkconfig:
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=3
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
CONFIG_BT_NIMBLE_SECURITY_ENABLE=y
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=256
CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y
Контроллер — только BLE (BTDM_CTRL_MODE_BLE_ONLY), без классического Bluetooth; до трёх
соединений; безопасность включена. Цена стека в памяти:
│ Flash Code │ 328702 │ 9.83 │ 3013602 │ 3342304 │
│ IRAM │ 97439 │ 74.34 │ 33633 │ 131072 │
│ DRAM │ 31544 │ 25.32 │ 93036 │ 124580 │
Total image size: 517996 bytes (.bin may be padded larger)
Образ — 517 996 байт, ОЗУ команд занято на 74,34 %: контроллер Bluetooth держит свой код в
IRAM, потому что обслуживает радио в прерываниях. В последнем столбце DRAM —
124 580 байт вместо 180 736 у пустой программы (раздел 150): 56 156 байт
внутреннего ОЗУ контроллер Bluetooth резервирует под себя ещё при сборке
(CONFIG_BTDM_RESERVE_DRAM = 0xDB5C). В разделе 156 образ с сетью, SNTP, HTTP и MQTT
занимал IRAM на 45 %. Wi-Fi и Bluetooth в одном приложении — это уже расчёт каждой функции с
IRAM_ATTR.
158.3 Реклама: имя в эфире и интервал
Рекламный пакет — не больше 31 байта данных (BLE_HCI_MAX_ADV_DATA_LEN): флаги, имя, иногда
UUID сервиса. Больше в рекламу не влезет. Ещё 31 байт даёт ответ на сканирование
(ble_gap_adv_rsp_set_fields): телефон запрашивает его сам, без подключения. Обычно
в рекламе — флаги и UUID, а имя — в ответе. Остальное телефон узнает после подключения.
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
struct ble_gap_adv_params adv = { .conn_mode = BLE_GAP_CONN_MODE_UND, .disc_mode = BLE_GAP_DISC_MODE_GEN,
.itvl_min = BLE_GAP_ADV_ITVL_MS(100), .itvl_max = BLE_GAP_ADV_ITVL_MS(150) };
Интервал рекламы задаётся в единицах радио, а не в миллисекундах:
#define BLE_HCI_ADV_ITVL (625) /* usecs */
#define BLE_HCI_ADV_ITVL_MAX (16384) /* units */
#define BLE_HCI_ADV_ITVL_MIN (32) /* units */
#define BLE_GAP_ADV_ITVL_MS(t) ((t) * 1000 / BLE_HCI_ADV_ITVL)
Единица — 625 мкс. 100 мс — это 160 единиц, 150 мс — 240. Допустимо от 32 единиц (20 мс) до 16 384 (10,24 с). Интервал — главный рычаг потребления: реклама каждые 20 мс — телефон находит прибор мгновенно, но радио просыпается в пятьдесят раз чаще, чем при рекламе раз в секунду.
158.4 Сервис и характеристики
Таблица сервисов в NimBLE — массив структур; у каждой характеристики UUID, права и обработчик:
{ .uuid = &temp_uuid.u, .access_cb = on_access, .val_handle = &temp_handle,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY },
{ .uuid = &led_uuid.u, .access_cb = on_access, .flags = BLE_GATT_CHR_F_WRITE },
Для своих сервисов берут 128-битные UUID: 16-битные закреплены за стандартными сервисами Bluetooth SIG. Обработчик отвечает на чтение и запись и сам проверяет, что прислали:
if (OS_MBUF_PKTLEN(ctxt->om) != 1)
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; /* ровно один байт */
Уведомление прибор отправляет сам, когда температура изменилась, — телефон ничего не
опрашивает. Для этого телефон сначала подписывается, записав дескриптор характеристики;
NimBLE добавляет этот дескриптор сам по флагу NOTIFY, а о записи сообщает событием
BLE_GAP_EVENT_SUBSCRIBE. Номер соединения, по которому слать, приходит раньше — в
BLE_GAP_EVENT_CONNECT:
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status != 0)
advertise();
else
conn_handle = event->connect.conn_handle;
break;
case BLE_GAP_EVENT_SUBSCRIBE:
if (event->subscribe.attr_handle == temp_handle)
notify_on = event->subscribe.cur_notify; /* 1 — телефон записал дескриптор подписки */
break;
Задача опроса датчика вызывает temp_update с новым значением, и оно уходит телефону:
void temp_update(int16_t centi)
{
temp_centi = centi;
if (notify_on && conn_handle != BLE_HS_CONN_HANDLE_NONE) {
struct os_mbuf *om = ble_hs_mbuf_from_flat(&temp_centi, sizeof temp_centi);
ble_gatts_notify_custom(conn_handle, temp_handle, om); /* om стек освободит сам */
}
}
Разослать всем подписанным сразу проще: ble_gatts_chr_updated(temp_handle) — значение стек
возьмёт сам, через обработчик чтения.
Сопряжение и шифрование. CONFIG_BT_NIMBLE_SECURITY_ENABLE только включает сопряжение
в стеке, а характеристику само по себе не защищает: без особых флагов её читает и пишет любой,
кто подключился. Характеристику с секретом — пароль Wi-Fi — помечают BLE_GATT_CHR_F_WRITE_ENC:
запись пройдёт только по зашифрованному каналу, и телефон, получив отказ, сам предложит
сопряжение. Способ сопряжения выбирают по возможностям прибора — поле ble_hs_cfg.sm_io_cap.
Без экрана и кнопок (BLE_SM_IO_CAP_NO_IO) остаётся «просто подключиться» (Just Works): канал
шифруется, но от посредника, вставшего между телефоном и прибором во время сопряжения, это не
защищает. С экраном прибор показывает шестизначный ключ (BLE_SM_IO_CAP_DISP_ONLY), владелец
вводит его на телефоне — это защищает, и флаг BLE_GATT_CHR_F_WRITE_AUTHEN требует именно
такого сопряжения. Чтобы ключи пережили перезагрузку, нужны ble_hs_cfg.sm_bonding = 1, вызов
ble_store_config_init() (в заголовках её объявления нет — примеры ESP-IDF объявляют её сами)
и CONFIG_BT_NIMBLE_NVS_PERSIST=y: по умолчанию он выключен, и ключи живут только в ОЗУ. Для
передачи пароля Wi-Fi в ESP-IDF есть готовый компонент wifi_provisioning со своим шифрованием.
158.5 MTU и размер данных
#define BLE_ATT_MTU_DFLT 23
#define BLE_ATT_MTU_MAX 527
До договорённости о размере пакет атрибутного протокола — 23 байта, и 3 из них занимает
заголовок: в одно уведомление влезает 20 байт. NimBLE в этой сборке предлагает MTU 256
(CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU), и если телефон согласится, в пакет войдёт 253 байта.
Больше 20 байт без обмена MTU — только кусками. Температура в два байта, как здесь, в любой
пакет влезает с запасом.
158.6 Wi-Fi и Bluetooth вместе
Радио у ESP32 одно: Wi-Fi и Bluetooth работают по очереди, и ESP-IDF делит время сам. Обоим
достаётся меньше: реклама реже, пропускная способность Wi-Fi ниже. К этому добавляется память:
IRAM этой сборки занят на 74,34 % одним Bluetooth. Для настройки Wi-Fi через телефон это нормально —
BLE включают на время настройки и выключают, освобождая память. При постоянной работе обоих
упираются в IRAM, и модуль с внешней памятью (PSRAM) тут не спасает: она подключена к шине
данных и разгружает только кучу, а код из неё ESP32 не выполняет; её обходной путь для кэша
ещё и кладёт часть библиотеки C в IRAM. Помогает другое: выключить IRAM-оптимизации Wi-Fi
(CONFIG_ESP_WIFI_IRAM_OPT — больше 10 КБ, CONFIG_ESP_WIFI_RX_IRAM_OPT — больше 17 КБ) ценой
скорости, не включать CONFIG_LWIP_IRAM_OPTIMIZATION (около 10 КБ), взять чип с общей памятью
команд и данных (ESP32-C3, ESP32-S3) или разделить задачи между двумя чипами.
158.7 Как проверить на плате
На телефоне — приложение-сканер BLE (из магазина приложений, «BLE scanner»). Прошивка из
_verify/esp158/ble рекламирует имя esp32-158: сканер показывает его, подключается и видит
сервис с двумя характеристиками. Чтение температуры возвращает два байта 0x66 0x08 — 2150
(21,50 °C в сотых) в порядке «младший байт первым»; запись одного байта в «светодиод» принимается,
двух — отвергается с ошибкой длины.
158.8 Грабли
| Симптом | Причина |
|---|---|
| прибор не виден в сканере | не вызвана реклама после синхронизации стека (sync_cb) или после разрыва |
| после отключения телефона прибор пропал из эфира | реклама не перезапускается по событию разрыва |
| длинная строка в рекламе обрезается | данные рекламы не больше 31 байта |
| уведомления не приходят | телефон не подписался на характеристику |
| пароль Wi-Fi может записать любой подключившийся | нет флага BLE_GATT_CHR_F_WRITE_ENC: SECURITY_ENABLE сам ничего не шифрует |
| после перезагрузки телефон снова просит сопряжение | CONFIG_BT_NIMBLE_NVS_PERSIST выключен — ключи были в ОЗУ |
| данные больше 20 байт приходят обрезанными | MTU по умолчанию 23: нужен обмен MTU или передача кусками |
| сборка с Wi-Fi и BLE не влезает в IRAM | стек Bluetooth держит код в IRAM — выключать ненужное |
| приложение падает при старте стека | не инициализирована NVS: там калибровка радио и ключи |
| интервал рекламы «100» дал 62,5 мс | интервал в единицах 625 мкс, а не в миллисекундах — BLE_GAP_ADV_ITVL_MS |
158.9 Итоги
- Прибор — периферия: GAP-реклама и GATT-сервер; телефон — центральное устройство.
- NimBLE:
CONFIG_BT_NIMBLE_ENABLED; до 3 соединений, MTU 256 по умолчанию в сборке. - Цена: образ 517 996 байт, IRAM 74,34 %.
- Реклама: до 31 байта; интервал в единицах 625 мкс, от 32 (20 мс) до 16 384 (10,24 с); 100 мс = 160 единиц.
- MTU по умолчанию 23 — в пакет 20 байт; максимум 527.
- Уведомление: номер соединения — из
BLE_GAP_EVENT_CONNECT, подписка —BLE_GAP_EVENT_SUBSCRIBE, отправка —ble_gatts_notify_customилиble_gatts_chr_updated. - Секрет — только в характеристике с
BLE_GATT_CHR_F_WRITE_ENC; Just Works от посредника не защищает, шестизначный ключ — защищает. - Wi-Fi и Bluetooth делят одно радио и память.
Полные исходники
main.c — сервер GATT на NimBLE: температура с уведомлениями и светодиод.
/* Раздел 158: Bluetooth LE на ESP32 — сервер GATT на стеке NimBLE.
* Имя в эфире «esp32-158», один сервис с характеристикой «температура» (чтение и уведомления: номер
* соединения — из BLE_GAP_EVENT_CONNECT, подписка — BLE_GAP_EVENT_SUBSCRIBE, отправка — temp_update)
* и «светодиод» (запись). Радио в QEMU нет: программа проверяется сборкой, размеры и настройки —
* из собранного образа и sdkconfig. */
#include <stdio.h>
#include <string.h>
#include "host/ble_hs.h"
#include "host/util/util.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "nvs_flash.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
static uint8_t own_addr_type;
static uint16_t temp_handle;
static int16_t temp_centi = 2150; /* 21,50 °C в сотых долях */
static uint8_t led;
static uint16_t conn_handle = BLE_HS_CONN_HANDLE_NONE; /* номер соединения — из BLE_GAP_EVENT_CONNECT */
static uint8_t notify_on; /* телефон подписался на уведомления */
/* UUID сервиса и характеристик — 128-битные, свои */
static const ble_uuid128_t svc_uuid = BLE_UUID128_INIT(0x58, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x58, 0x01, 0x00, 0x00);
static const ble_uuid128_t temp_uuid = BLE_UUID128_INIT(0x58, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x58, 0x02, 0x00, 0x00);
static const ble_uuid128_t led_uuid = BLE_UUID128_INIT(0x58, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x58, 0x03, 0x00, 0x00);
static int on_access(uint16_t conn, uint16_t attr, struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn;
(void)attr;
(void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR)
return os_mbuf_append(ctxt->om, &temp_centi, sizeof temp_centi) == 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES;
if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) {
if (OS_MBUF_PKTLEN(ctxt->om) != 1)
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; /* ровно один байт */
led = ctxt->om->om_data[0];
return 0;
}
return BLE_ATT_ERR_UNLIKELY;
}
static const struct ble_gatt_svc_def services[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &svc_uuid.u,
.characteristics = (struct ble_gatt_chr_def[]) {
{ .uuid = &temp_uuid.u, .access_cb = on_access, .val_handle = &temp_handle,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY },
{ .uuid = &led_uuid.u, .access_cb = on_access, .flags = BLE_GATT_CHR_F_WRITE },
{ 0 },
},
},
{ 0 },
};
static int on_gap(struct ble_gap_event *event, void *arg);
static void advertise(void)
{
struct ble_hs_adv_fields fields = { 0 };
const char *name = ble_svc_gap_device_name();
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
fields.name = (uint8_t *)name;
fields.name_len = strlen(name);
fields.name_is_complete = 1;
ble_gap_adv_set_fields(&fields);
struct ble_gap_adv_params adv = { .conn_mode = BLE_GAP_CONN_MODE_UND, .disc_mode = BLE_GAP_DISC_MODE_GEN,
.itvl_min = BLE_GAP_ADV_ITVL_MS(100), .itvl_max = BLE_GAP_ADV_ITVL_MS(150) };
ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER, &adv, on_gap, NULL);
}
static int on_gap(struct ble_gap_event *event, void *arg)
{
(void)arg;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status != 0)
advertise();
else
conn_handle = event->connect.conn_handle;
break;
case BLE_GAP_EVENT_SUBSCRIBE:
if (event->subscribe.attr_handle == temp_handle)
notify_on = event->subscribe.cur_notify; /* 1 — телефон записал дескриптор подписки */
break;
case BLE_GAP_EVENT_DISCONNECT:
conn_handle = BLE_HS_CONN_HANDLE_NONE;
notify_on = 0;
advertise(); /* после разрыва — снова в эфир */
break;
default:
break;
}
return 0;
}
/* Новая температура от задачи опроса датчика: запомнить и, если телефон подписан, отправить уведомление. */
void temp_update(int16_t centi);
void temp_update(int16_t centi)
{
temp_centi = centi;
if (notify_on && conn_handle != BLE_HS_CONN_HANDLE_NONE) {
struct os_mbuf *om = ble_hs_mbuf_from_flat(&temp_centi, sizeof temp_centi);
ble_gatts_notify_custom(conn_handle, temp_handle, om); /* om стек освободит сам */
}
}
static void on_sync(void)
{
ble_hs_id_infer_auto(0, &own_addr_type);
advertise();
}
static void host_task(void *param)
{
(void)param;
nimble_port_run(); /* не возвращается, пока стек работает */
nimble_port_freertos_deinit();
}
void app_main(void)
{
ESP_ERROR_CHECK(nvs_flash_init()); /* калибровка радио и ключи сопряжения — в NVS */
ESP_ERROR_CHECK(nimble_port_init());
ble_hs_cfg.sync_cb = on_sync;
ble_svc_gap_init();
ble_svc_gatt_init();
ble_gatts_count_cfg(services);
ble_gatts_add_svcs(services);
ble_svc_gap_device_name_set("esp32-158");
nimble_port_freertos_init(host_task);
printf("ATT MTU по умолчанию %d, интервал рекламы %d…%d мс\n", BLE_ATT_MTU_DFLT, 100, 150);
}
Дальше: раздел 159 — обновление по сети.