158. Bluetooth LE on the ESP32: advertising, a GATT server on NimBLE, characteristics and notifications
Bluetooth LE is for a device that has no screen but has an owner with a phone: to set it up, look at the readings, pass it the Wi-Fi name and password. On the ESP32 the Bluetooth radio is on the same chip as Wi-Fi, and ESP-IDF has two stacks: the large Bluedroid and the compact NimBLE. Here it is NimBLE.
The emulator does not model the radio, so the section is checked by a build (_verify/esp158/): the program is
a GATT server with advertising and two characteristics; the image and memory sizes, the settings from sdkconfig
and the values from the NimBLE headers come from the built project. How to check it on a board is in 158.7.
Terms used in the documentation
Term Meaning BLE (Bluetooth Low Energy) Bluetooth with low power consumption GAP (Generic Access Profile) roles and advertising: who advertises, who connects advertising, advertising interval advertising, the interval between advertising packets peripheral, central the peripheral device and the central one (the phone) GATT (Generic Attribute Profile) services and characteristics on top of ATT service, characteristic a service, a characteristic ATT MTU the largest packet size of the attribute protocol notification, indication an update without acknowledgement and with acknowledgement UUID the identifier of a service or characteristic bonding, pairing storing the keys, pairing
158.1 Roles and the data path: GAP and GATT
The device is the peripheral: it advertises itself with short packets and waits. The phone is the central device: it scans the air, finds the advertising and connects. This is the GAP level. After connection the data goes over GATT: the device has a table of services, and each has characteristics with their own permissions: read, write, notify. The phone reads and writes characteristics, and the device itself sends notifications when a value has changed.
158.2 The NimBLE stack and its memory cost
The stack is enabled by two lines of sdkconfig.defaults; the rest are default values from
the built 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
The controller is BLE only (BTDM_CTRL_MODE_BLE_ONLY), without Classic Bluetooth; up to three
connections; security is enabled. The memory cost of the stack:
│ 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)
The image is 517996 bytes, instruction RAM is 74.34 % full: the Bluetooth controller keeps its code in
IRAM because it services the radio in interrupts. In the last column, DRAM is
124580 bytes instead of 180736 for the empty program (section 150): the Bluetooth controller reserves 56156 bytes
of internal RAM for itself already at build time
(CONFIG_BTDM_RESERVE_DRAM = 0xDB5C). In section 156 the image with the network, SNTP, HTTP and MQTT
took 45 % of IRAM. Wi-Fi and Bluetooth in one application already means budgeting every function with
IRAM_ATTR.
158.3 Advertising: a name on the air and the interval
An advertising packet carries no more than 31 bytes of data (BLE_HCI_MAX_ADV_DATA_LEN): flags, the name, sometimes
a service UUID. Nothing more fits into advertising. Another 31 bytes come with the scan response
(ble_gap_adv_rsp_set_fields): the phone requests it by itself, without connecting. Usually
the advertising holds the flags and the UUID, and the name goes in the response. The phone learns the rest after connecting.
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) };
The advertising interval is set in radio units, not in milliseconds:
#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)
The unit is 625 µs. 100 ms is 160 units, 150 ms is 240. The allowed range is from 32 units (20 ms) to 16384 (10.24 s). The interval is the main lever on consumption: with advertising every 20 ms the phone finds the device instantly, but the radio wakes up fifty times more often than with advertising once a second.
158.4 The service and the characteristics
The service table in NimBLE is an array of structures; each characteristic has a UUID, permissions and a handler:
{ .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 },
For your own services you take 128-bit UUIDs: 16-bit ones are assigned to the standard services of the Bluetooth SIG. The handler answers reads and writes and checks itself what was sent:
if (OS_MBUF_PKTLEN(ctxt->om) != 1)
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; /* ровно один байт */
The device sends a notification by itself when the temperature has changed — the phone polls
nothing. For this the phone first subscribes by writing the characteristic's descriptor;
NimBLE adds this descriptor itself because of the NOTIFY flag, and reports the write with the
BLE_GAP_EVENT_SUBSCRIBE event. The connection handle to send on arrives earlier — in
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;
The sensor polling task calls temp_update with a new value, and it goes to the phone:
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 стек освободит сам */
}
}
Sending to all subscribers at once is simpler: ble_gatts_chr_updated(temp_handle) — the stack
takes the value itself, through the read handler.
Pairing and encryption. CONFIG_BT_NIMBLE_SECURITY_ENABLE only enables pairing
in the stack; it does not protect a characteristic by itself: without special flags anyone
who connects can read and write it. A characteristic with a secret — the Wi-Fi password — is marked BLE_GATT_CHR_F_WRITE_ENC:
the write goes through only over an encrypted link, and the phone, having got a refusal, offers
pairing by itself. The pairing method is chosen by the device's capabilities — the ble_hs_cfg.sm_io_cap field.
Without a screen and buttons (BLE_SM_IO_CAP_NO_IO) only "just connect" (Just Works) remains: the link
is encrypted, but it does not protect against a man in the middle who placed himself between the phone and the device during
pairing. With a screen the device shows a six-digit passkey (BLE_SM_IO_CAP_DISP_ONLY), the owner
enters it on the phone — this does protect, and the BLE_GATT_CHR_F_WRITE_AUTHEN flag requires exactly
this kind of pairing. For the keys to survive a reboot you need ble_hs_cfg.sm_bonding = 1, a call to
ble_store_config_init() (the headers do not declare it — the ESP-IDF examples declare it themselves)
and CONFIG_BT_NIMBLE_NVS_PERSIST=y: it is off by default, and the keys live only in RAM. For
passing the Wi-Fi password ESP-IDF has the ready-made wifi_provisioning component with its own encryption.
158.5 MTU and data size
#define BLE_ATT_MTU_DFLT 23
#define BLE_ATT_MTU_MAX 527
Before the size is negotiated, an attribute protocol packet is 23 bytes, and 3 of them are taken by
the header: one notification carries 20 bytes. NimBLE in this build proposes an MTU of 256
(CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU), and if the phone agrees, a packet carries 253 bytes.
More than 20 bytes without an MTU exchange — only in pieces. A two-byte temperature, as here, fits into any
packet with room to spare.
158.6 Wi-Fi and Bluetooth together
The ESP32 has one radio: Wi-Fi and Bluetooth work in turns, and ESP-IDF shares the time itself. Both
get less: advertising is rarer, Wi-Fi throughput is lower. On top of that comes memory:
the IRAM of this build is 74.34 % full with Bluetooth alone. For setting up Wi-Fi through a phone this is fine —
BLE is turned on for the setup and turned off afterwards, freeing memory. When both run all the time
you hit the IRAM limit, and a module with external memory (PSRAM) does not help here: it is connected to the data
bus and relieves only the heap, and the ESP32 does not execute code from it; its cache workaround
even puts part of the C library into IRAM. Other things help: switch off the Wi-Fi IRAM optimizations
(CONFIG_ESP_WIFI_IRAM_OPT — more than 10 KB, CONFIG_ESP_WIFI_RX_IRAM_OPT — more than 17 KB) at the cost of
speed, do not enable CONFIG_LWIP_IRAM_OPTIMIZATION (about 10 KB), take a chip with shared instruction
and data memory (ESP32-C3, ESP32-S3) or split the tasks between two chips.
158.7 How to check it on a board
On the phone — a BLE scanner app (from the app store, "BLE scanner"). The firmware from
_verify/esp158/ble advertises the name esp32-158: the scanner shows it, connects and sees
the service with two characteristics. Reading the temperature returns two bytes 0x66 0x08 — 2150
(21.50 °C in hundredths) in "least significant byte first" order; a one-byte write to "LED" is accepted,
a two-byte one is rejected with a length error.
158.8 Pitfalls
| Symptom | Cause |
|---|---|
| the device is not visible in the scanner | advertising is not started after stack synchronization (sync_cb) or after a disconnect |
| after the phone disconnects the device vanishes from the air | advertising is not restarted on the disconnect event |
| a long string in the advertising is cut off | advertising data is no more than 31 bytes |
| notifications do not arrive | the phone has not subscribed to the characteristic |
| anyone who connects can write the Wi-Fi password | no BLE_GATT_CHR_F_WRITE_ENC flag: SECURITY_ENABLE encrypts nothing by itself |
| after a reboot the phone asks for pairing again | CONFIG_BT_NIMBLE_NVS_PERSIST is off — the keys were in RAM |
| data longer than 20 bytes arrives truncated | the default MTU is 23: an MTU exchange or sending in pieces is needed |
| a build with Wi-Fi and BLE does not fit into IRAM | the Bluetooth stack keeps code in IRAM — switch off what you do not need |
| the application crashes when the stack starts | NVS is not initialized: it holds the radio calibration and the keys |
| an advertising interval of "100" gave 62.5 ms | the interval is in units of 625 µs, not in milliseconds — BLE_GAP_ADV_ITVL_MS |
158.9 Summary
- The device is the peripheral: GAP advertising and a GATT server; the phone is the central device.
- NimBLE:
CONFIG_BT_NIMBLE_ENABLED; up to 3 connections, MTU 256 by default in the build. - Cost: an image of 517996 bytes, IRAM 74.34 %.
- Advertising: up to 31 bytes; the interval in units of 625 µs, from 32 (20 ms) to 16384 (10.24 s); 100 ms = 160 units.
- The default MTU is 23 — 20 bytes per packet; the maximum is 527.
- Notification: the connection handle comes from
BLE_GAP_EVENT_CONNECT, the subscription fromBLE_GAP_EVENT_SUBSCRIBE, sending throughble_gatts_notify_customorble_gatts_chr_updated. - A secret goes only into a characteristic with
BLE_GATT_CHR_F_WRITE_ENC; Just Works does not protect against a man in the middle, a six-digit passkey does. - Wi-Fi and Bluetooth share one radio and the memory.
Full source code
main.c — a GATT server on NimBLE: temperature with notifications and an LED.
/* Раздел 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);
}
Next: section 159 — updates over the network.