Embedded HandbookПриложения
Все разделы › XVIII. ESP32: второй микроконтроллер
ESP32 · уровень средний · 14 мин чтения
Открыть в интерактивном справочнике

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

Bluetooth LE на ESP32: прибор-периферия рекламирует имя, телефон подключается и работает с 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 — только кусками. Температура в два байта, как здесь, в любой пакет влезает с запасом.

Сколько байт несёт пакет BLE: реклама до подключения — 31 байт; пакет ATT — 3 байта заголовка и полезная нагрузка: 20 байт при MTU 23 по умолчанию, 253 после обмена на 256, 524 при пределе стека 527


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 Итоги

Полные исходники

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 — обновление по сети.

Открыть в интерактивном справочнике