64 KiB
ESP-BLE-UART Porting & API Guide
Naming convention: Use ESP-BLE-UART for Espressif-owned product names (Bridge, Console, Daemon, Echo Server, the
ble_uartcomponent, and theble_uart_serviceexample). Use BLE UART for the generic GATT service convention, transport layer, and compatible third-party devices. This follows the same pattern as ESP-BLE-MESH.
This document lives in examples/bluetooth/common/ble_uart/ next to the
ble_uart component sources (ble_uart.h, backend .c files).
Reference application: use the examples/bluetooth/ble_uart_service
example as the working template. Its root CMakeLists.txt appends this
directory to EXTRA_COMPONENT_DIRS so main can REQUIRES ble_uart;
main/main.c initializes NVS, calls ble_uart_install() /
ble_uart_open() with the Kconfig-supplied GAP name and the default
encrypted UART-over-BLE echo path, and the tree ships
sdkconfig.defaults plus the Bluedroid overlay
(sdkconfig.bluedroid). Clone or diff that project when adapting to a new
target or host stack.
A complete guide to integrating ble_uart into any ESP-IDF project.
Either EXTRA_COMPONENT_DIRS pointing at this component or a few
copied source files plus the glue steps below are enough to bring an
encrypted BLE serial peripheral up in a fresh project — the same
ble_uart.h API works on top of either NimBLE or Bluedroid; pick the
host with a Kconfig knob.
This guide uses NimBLE as the running example because it is the default on every ESP32 family target. The Bluedroid path is identical from the application's point of view; the only differences are the sdkconfig knobs called out in §4.3 and a few stack-specific notes flagged inline.
1. What ble_uart Provides
| Capability | Description |
|---|---|
| Widely used BLE UART-over-GATT (RX/TX) | Interoperates with every generic BLE-serial tool (mobile GATT clients, Web Bluetooth, custom scripts) |
| LE Secure Connections + Bonding pairing | Single switch; when enabled, a fresh 6-digit passkey is printed to UART |
| Auto-reconnect | After a bonded central disconnects, advertising restarts immediately and the LTK is reused — no passkey prompt |
| Raw byte pass-through | RX is delivered via a callback; TX is exposed as ble_uart_tx |
| Auto-fragmentation | TX is sliced according to the negotiated ATT MTU |
| Fully wrapped | The user's app_main only calls two functions: install + open |
ble_uart is agnostic of any application-layer protocol (no JSON, no
line framing). It only delivers bytes — what you do with those bytes
is entirely up to you.
2. Prerequisites
| Requirement | Notes |
|---|---|
| ESP-IDF v5.0+ | v5.x or v6.x recommended |
| BT controller | Must support BLE (ESP32 / C2 / C3 / C5 / C6 / C61 / H2 / S3 / …) |
| Host stack | Exactly one of CONFIG_BT_NIMBLE_ENABLED=y (default, smaller) or CONFIG_BT_BLUEDROID_ENABLED=y in sdkconfig (covered in detail below) |
| Flash size | At least 2 MB (the default partition table is plenty) |
3. File Inventory
Canonical sources live under $IDF_PATH/examples/bluetooth/common/ble_uart/
(component name ble_uart): ble_uart.h, ble_uart_nimble.c,
ble_uart_bluedroid.c, CMakeLists.txt, and Kconfig (device name + RX scratch;
menuconfig → Component configuration → ESP-BLE-UART library). When reusing
outside this tree, copy the whole common/ble_uart/ directory or at least
merge Kconfig into your component so the same CONFIG_BLE_UART_* symbols
exist.
Option A — depend on the in-tree component (no copy): add the component
directory to EXTRA_COMPONENT_DIRS in the project root CMakeLists.txt
before include($ENV{IDF_PATH}/tools/cmake/project.cmake) / project(),
then use REQUIRES ble_uart from main/CMakeLists.txt (see
examples/bluetooth/ble_uart_service/CMakeLists.txt). This ensures the
ble_uart target exists when CMake expands main's requirements.
Kconfig options appear under
menuconfig → Component configuration → ESP-BLE-UART library.
A
main/idf_component.ymlpath dependency alone is not sufficient ifmain/CMakeLists.txtlistsREQUIRES ble_uart: the early requirement scan runs before the component manager injects that dependency, so CMake fails with unknown componentble_uart. PreferEXTRA_COMPONENT_DIRS(as in the reference example) or copy the sources into a normal project component.
Option B — copy into your project: pick the backend you want and
copy that pair plus the public header (or copy both backends; each .c
gates on its Kconfig symbol):
your_project/main/
├── ble_uart.h ← copy from .../common/ble_uart/
├── ble_uart_nimble.c ← if you'll set CONFIG_BT_NIMBLE_ENABLED=y
└── ble_uart_bluedroid.c ← if you'll set CONFIG_BT_BLUEDROID_ENABLED=y
Optional: copy Kconfig from common/ble_uart/ into your component (or merge
its symbols into your own Kconfig) if you want BLE_UART_* in menuconfig;
otherwise hard-code the device name and rely on the 1024-byte fallback for RX
scratch.
4. Step-by-Step Integration
Assume you already have an ESP-IDF project (my_project/).
4.1 Copy the files
cd my_project/main
BLE_UART_SRC="$IDF_PATH/examples/bluetooth/common/ble_uart"
# Stack-agnostic public header — always.
cp "$BLE_UART_SRC/ble_uart.h" .
# Pick one (or copy both — the inactive one compiles to nothing).
cp "$BLE_UART_SRC/ble_uart_nimble.c" .
cp "$BLE_UART_SRC/ble_uart_bluedroid.c" .
4.2 Edit main/CMakeLists.txt
# List both backends; each .c file is gated on its matching Kconfig
# symbol, so only the active one contributes code.
idf_component_register(SRCS "main.c"
"ble_uart_nimble.c"
"ble_uart_bluedroid.c"
INCLUDE_DIRS "."
REQUIRES bt nvs_flash)
4.3 Edit sdkconfig.defaults (the 7 critical lines)
NimBLE backend (default, smaller footprint):
# Enable NimBLE
CONFIG_BT_ENABLED=y
CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y # only needed on classic ESP32; C3/S3/C6/... will warn "unknown" — safe to ignore
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_ENABLED=y
# Encryption + persistent bonds
CONFIG_BT_NIMBLE_SM_SC=y # LE Secure Connections
CONFIG_BT_NIMBLE_NVS_PERSIST=y # persist LTKs in NVS — passkey-free reconnects
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU is optional; the default (256) is
fine. Bumping it to 512 lets TX push larger chunks per notification, but
the central must support it.
Bluedroid backend (drop-in alternative):
Use the examples/bluetooth/ble_uart_service/sdkconfig.bluedroid file as
the authoritative Kconfig overlay: it enables the host stack, SMP, GATTS
(service-table API), and the BLE-only advertising knobs that
ble_uart_bluedroid.c expects. Either merge those lines into your own
sdkconfig.defaults, or pass them as a second defaults file:
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bluedroid" reconfigure
(Paths are relative to the example project root; copy sdkconfig.bluedroid
into your tree if you are not starting from ble_uart_service.)
A minimal inline sketch (may drift from IDF defaults — diff against
sdkconfig.bluedroid after each IDF upgrade):
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=n
CONFIG_BT_BLUEDROID_ENABLED=y
# LE Secure Connections + bonding (Bluedroid persists LTKs by default)
CONFIG_BT_BLE_SMP_ENABLE=y
# Optional: bigger MTU (when supported by your IDF target / menuconfig)
# CONFIG_BT_GATT_MAX_MTU_SIZE=512
# BLE-only feature set (saves flash on classic-BT-capable parts)
CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y
CONFIG_BT_BLE_42_ADV_EN=y
4.4 Write app_main (template)
Minimal working template:
#include "esp_log.h"
#include "esp_mac.h"
#include "nvs_flash.h"
#include "ble_uart.h"
static const char *TAG = "app";
/* What to do with received bytes — up to you */
static void ble_uart_on_rx(const uint8_t *data, size_t len)
{
ESP_LOGI(TAG, "rx %u bytes", (unsigned)len);
/* echo it back as a demo */
ble_uart_tx(data, len);
}
void app_main(void)
{
/* 1. NVS: NimBLE uses it for PHY calibration and bond storage */
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
/* 2. Bring up ESP-BLE-UART */
ESP_ERROR_CHECK(ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.device_name = "MyDevice",
.ble_uart_on_rx = ble_uart_on_rx,
}));
/* 3. Take off */
ESP_ERROR_CHECK(ble_uart_open());
}
4.5 Build & flash
idf.py set-target esp32s3 # or whichever target you use
idf.py build flash monitor
Once flashed, the UART monitor should show (NimBLE backend):
I (xxx) ble_uart: registered service 6e400001-... handle=14
I (xxx) ble_uart: registered chr 6e400002-... def=15 val=16
I (xxx) ble_uart: registered chr 6e400003-... def=17 val=18
I (xxx) ble_uart: addr=...
I (xxx) ble_uart: BLE host task started
I (xxx) ble_uart: advertising as 'MyDevice'
…or with the Bluedroid backend:
I (xxx) ble_uart: gatts reg status=0 app_id=85 gatts_if=3
I (xxx) ble_uart: registered service svc_handle=40 rx=42 tx=44 cccd=45
I (xxx) ble_uart: advertising started
A phone GATT client app discovers MyDevice; connect, enter the
passkey, subscribe to TX, write to RX, and you will see the echo come
back.
5. API Reference
5.1 Configuration struct
typedef struct {
bool encrypted; /* Preset shortcut for SC + Bonding + MITM */
ble_uart_security_t security; /* Per-feature overrides — see §5.6 */
const char *device_name; /* GAP service device name (UUID 0x2A00) */
/* Custom advertising bytes — see §5.9. NULL keeps the default
* payload. ble_uart prepends the 3-byte Flags AD itself; you don't. */
const uint8_t *adv_data;
size_t adv_data_len; /* ≤ BLE_UART_ADV_DATA_MAX (28) */
const uint8_t *scan_rsp_data;
size_t scan_rsp_data_len;/* ≤ BLE_UART_SCAN_RSP_DATA_MAX (31) */
ble_uart_rx_cb_t ble_uart_on_rx;/* RX byte callback */
ble_uart_evt_cb_t on_event; /* Lifecycle / link-state events */
} ble_uart_config_t;
| Field | Type | Required | Default / meaning |
|---|---|---|---|
encrypted |
bool |
yes | One-line preset for the override fields under security: true = SC + Bonding + MITM + DisplayOnly + encrypted+authenticated GATT chars; false = fully plaintext (sniffable, lab use only). Override individual bits via security.* — see §5.6. |
security |
ble_uart_security_t |
optional | A zero-initialised member (security.{sc,bonding,mitm,io_cap} = AUTO) inherits everything from encrypted. Set any sub-field to OFF/ON (or pick a specific io_cap) to override just that bit. Out-of-range enum values, or impossible combos like mitm=ON with io_cap=NO_INPUT_OUTPUT, fail ble_uart_install() with BLE_UART_EINVAL. Full reference in §5.6. |
device_name |
const char * |
recommended | Set as the GAP-service Device Name (UUID 0x2A00). With the default advertising payload it is also placed in the primary adv as the Complete Local Name; with custom adv_data (see §5.9) it is not auto-included — the application owns the adv bytes. Length must be ≤ BLE_UART_DEVICE_NAME_MAX = 26 (sized so the default Flags + Name AD layout always fits in a 31-byte primary packet). Longer names fail ble_uart_install() synchronously with BLE_UART_EINVAL. |
adv_data / adv_data_len |
bytes + length | optional | Application-controlled raw advertisement data. NULL keeps the built-in default (Complete Local Name only). Max length BLE_UART_ADV_DATA_MAX = 28 (the 31-byte primary packet minus our 3-byte Flags AD). Buffer is copied in install; the pointer doesn't need to outlive the call. See §5.9. |
scan_rsp_data / scan_rsp_data_len |
bytes + length | optional | Application-controlled raw scan-response data. NULL keeps the built-in default (128-bit BLE UART service UUID). Max length BLE_UART_SCAN_RSP_DATA_MAX = 31 (no Flags element here). Same copy semantics as adv_data. |
ble_uart_on_rx |
callback | optional | NULL discards every received byte |
on_event |
callback | optional | NULL drops every event (see §5.2.1). Not required for the default preset (encrypted=true, all security.* AUTO → Passkey Display): the port logs the 6-digit passkey to UART and completes pairing without a callback. Required when io_cap is KEYBOARD_ONLY, DISPLAY_YES_NO, or KEYBOARD_DISPLAY — otherwise ble_uart_install() returns BLE_UART_EINVAL. |
5.2 RX callback signature
typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len);
static void my_handler(const uint8_t *data, size_t len)
{
/* `data` is reused after the callback returns; memcpy into your own
* buffer if you need to keep it. */
}
Caveats:
- The callback runs on the BLE host task (NimBLE host task / Bluedroid BTC task) — do not block; offload heavy work to your own task.
- A single callback may carry only part of an upper-layer frame (the central slices on ATT MTU). Framing logic (line / TLV / length-prefixed) is your responsibility.
- The data carries no
ctxargument. If your callback needs state, use a file-scopestaticor a global.
5.2.1 Event callback
typedef void (*ble_uart_evt_cb_t)(const ble_uart_evt_t *evt);
static void on_event(const ble_uart_evt_t *e)
{
switch (e->id) {
case BLE_UART_EVT_CONNECTED: /* link up */ break;
case BLE_UART_EVT_DISCONNECTED: /* e->disconnected.reason */ break;
case BLE_UART_EVT_SUBSCRIBED: /* e->subscribed.subscribed */ break;
case BLE_UART_EVT_LINK_SECURE:
if (e->link_secure.encrypted && e->link_secure.authenticated) {
/* Safe to forward sensitive payloads now */
}
break;
case BLE_UART_EVT_PASSKEY_DISPLAY: /* e->passkey.passkey */ break;
case BLE_UART_EVT_PASSKEY_REQUEST: /* user types peer's 6-digit;
ble_uart_passkey_reply(d) */ break;
case BLE_UART_EVT_NUMERIC_COMPARE: /* e->numeric_compare.passkey,
ble_uart_compare_reply(b) */ break;
case BLE_UART_EVT_PAIRING_FAILED: /* e->pairing_failed.reason */ break;
}
}
evt->id |
Payload (anonymous-union member) | Fires when |
|---|---|---|
BLE_UART_EVT_CONNECTED |
— | Physical link up |
BLE_UART_EVT_DISCONNECTED |
disconnected.reason (int, stack-specific) |
Physical link down — Bluedroid: esp_gatt_conn_reason_t; NimBLE: BLE host return code (BLE_HS_HCI_ERR() for HCI) |
BLE_UART_EVT_SUBSCRIBED |
subscribed.subscribed |
CCCD on TX changed (edge-triggered) |
BLE_UART_EVT_LINK_SECURE |
link_secure.{encrypted,authenticated,bonded,key_size} |
Pairing or bonded reconnect succeeds |
BLE_UART_EVT_PASSKEY_DISPLAY |
passkey.passkey (0..999999) |
SM generated a passkey for the central to type (Passkey Display). Optional — the port always prints a banner to UART; with on_event == NULL the event is dropped and pairing still completes (NimBLE injects the passkey internally; Bluedroid needs no app reply). Register on_event only if you want a custom UI in addition to the log line. |
BLE_UART_EVT_PASSKEY_REQUEST |
— | SM asks the user to enter a passkey shown by the central — application must reply via ble_uart_passkey_reply() (see §5.6.1). Requires on_event != NULL at install time. |
BLE_UART_EVT_NUMERIC_COMPARE |
numeric_compare.passkey (0..999999) |
SM asks the user to confirm the displayed value matches the central — application must reply via ble_uart_compare_reply() (see §5.6.1). Requires on_event != NULL at install time. |
BLE_UART_EVT_PAIRING_FAILED |
pairing_failed.reason |
Pairing rejected or timed out (including no application reply for PASSKEY_REQUEST / NUMERIC_COMPARE before the SM's pairing timeout) |
BLE_UART_EVT_CLOSED |
closed.status (BLE_UART_* from the worker's ble_uart_close()) |
ble_uart_close_async() worker finished — then uninstall on an app task (§5.3.2) |
Use LINK_SECURE, not is_connected(), to gate any logic that
requires the link to be encrypted / authenticated — bare
is_connected() returns true while the link is still plaintext, and
inferring security from encrypted / authenticated separately on the
caller side is exactly the kind of leak the callback is designed to
plug.
Threading: same context and rules as ble_uart_on_rx (NimBLE host
task / Bluedroid BTC task). Don't block, don't call ble_uart_close /
ble_uart_uninstall from inside the callback — use
ble_uart_close_async() (§5.3.2) if you need to teardown in response
to an event.
Exception — BLE_UART_EVT_CLOSED: this single event fires from
the close-async worker task instead of the BLE host task; by the time
it runs the host task is already gone. Keep the handler short: set a
flag or notify an app task — do not call ble_uart_uninstall()
here (see §5.3.2). The worker clears s_closing only after your
handler returns.
Ordering contracts (both backends):
- A single CCCD value change fires exactly one
SUBSCRIBEDevent (edge-triggered — repeating the same write is a no-op). - If the central was subscribed at the moment the link drops, you get
SUBSCRIBED(false)beforeDISCONNECTED. NimBLE does this natively (BLE_GAP_SUBSCRIBE_REASON_TERM); the Bluedroid backend synthesizes the same sequence so consumers can write a single state machine that works on either host. LINK_SECUREalways arrives afterCONNECTED— pairing can't run without a link.BLE_UART_EVT_CLOSEDalways arrives afterBLE_UART_EVT_DISCONNECTED(when there was a peer) — the close-async worker calls the same disconnect+wait sequence as the synchronousble_uart_close()before firing CLOSED.
Backend differences:
BLE_UART_EVT_LINK_SECURE.key_size: NimBLE reports the negotiated size (7..16); Bluedroid surfaces a fixed 16 — Bluedroid setsESP_BLE_SM_MAX_KEY_SIZE=16at install time and does not expose the negotiated size onAUTH_CMPL.- Bonded reconnects: NimBLE re-fires
LINK_SECUREon every encryption change; Bluedroid only firesAUTH_CMPL_EVTwhen the SM exchange actually runs, so a pure LTK-restart may not refire the event. - CCCD persistence on bonded reconnect: NimBLE re-fires
SUBSCRIBED(true)automatically (viaBLE_GAP_SUBSCRIBE_REASON_RESTORE) when the bonded peer reconnects; Bluedroid does not persist CCCD across connections, so the central has to write CCCD again to resubscribe.
5.3 Lifecycle — bring-up and release
API summary
int ble_uart_install(const ble_uart_config_t *cfg);
int ble_uart_open(void);
int ble_uart_close(void);
int ble_uart_close_async(void); /* fire-and-forget, see §5.3.2–5.3.4 */
int ble_uart_uninstall(void);
| Function | What it does (NimBLE) | What it does (Bluedroid) | When to call | Blocking? |
|---|---|---|---|---|
install |
nimble_port_init + ble_hs_cfg + SM + SIG services + UART GATT |
controller_init/enable + bluedroid_init/enable + SM + esp_ble_gatts_create_attr_tab (waits ≤500 ms for the attr-table event) |
After nvs_flash_init(), before open |
No, ~50 ms (NimBLE) / ~150 ms (Bluedroid) |
open |
Spawn host task + ble_hs_start (first time via BLE_HS_AUTO_START, later via ble_hs_sched_start) + advertising once synced; after a prior close, re-queues GAP/GATT/UART svc defs (§5.3.1a) |
Configure adv data + scan rsp + start advertising (GATT table from install stays up) |
After install |
No, host runs in the background |
close |
Stop adv → graceful disconnect (≤500 ms) → nimble_port_stop() → ble_gatts_reset() |
Stop adv → graceful disconnect (≤500 ms); host + GATT table stay up | After open, before uninstall; not from host-task callbacks (§5.3.2) |
Yes, up to ~500 ms (NimBLE) |
close_async |
Worker runs the same body as close, then BLE_UART_EVT_CLOSED |
Same | From on_event / on_rx (host task) when sync close would deadlock |
No (returns once worker is spawned) |
uninstall |
close if still open (+ poll in-flight close_async ≤~5 s), then nimble_port_deinit, wipe module state |
Same + controller deinit | After the radio is fully closed (§5.3.2); not from host-task callbacks | Yes |
Bring-up (every product):
nvs_flash_init()
└── ble_uart_install(&cfg) /* once per uninstall cycle */
└── ble_uart_open() /* advertising + pairing; BLE is live */
Run-forever firmware can stop here — no close / uninstall required.
Release — pick one path below. close stops the radio but keeps
install state (you can open() again). uninstall tears the host +
controller down so install() can run from scratch.
| Goal | Call sequence | Who calls close / uninstall |
|---|---|---|
Power BLE off from a normal app task (button, Wi-Fi, app_main shutdown) |
ble_uart_close() → ble_uart_uninstall() |
That app task only |
| Power BLE off because of a BLE event (RX command, failed pairing, policy) | ble_uart_close_async() in on_event / on_rx → wait for BLE_UART_EVT_CLOSED → ble_uart_uninstall() on an app task (§5.3.2) |
close_async in callback; uninstall deferred |
Each API returns BLE_UART_EALREADY when the module is already in the
target state, so defensive close / uninstall at shutdown without
manual state checks is fine as long as you follow the release path
for your scenario.
5.3.1 Path A — synchronous release (recommended default)
Use when teardown is not triggered from inside on_event /
on_rx (NimBLE host task / Bluedroid BTC task). This is what the
ble_uart_service example does.
void shutdown_ble_from_app_task(void)
{
int rc;
rc = ble_uart_close();
if (rc != BLE_UART_OK && rc != BLE_UART_EALREADY) {
ESP_LOGE(TAG, "ble_uart_close rc=%d", rc);
}
rc = ble_uart_uninstall();
if (rc != BLE_UART_OK && rc != BLE_UART_EALREADY) {
ESP_LOGE(TAG, "ble_uart_uninstall rc=%d", rc);
}
/* BLE UART fully released — safe to ble_uart_install() again */
}
ble_uart_open() /* running */
│
▼
ble_uart_close() /* same app task; not from on_event / on_rx */
│
▼
ble_uart_uninstall()
uninstallmay callcloseinternally if you skippedclose— still call both explicitly so return codes are obvious in your logs.- Do not call
closeoruninstallfromon_event/on_rx— use Path B instead.
5.3.1a Pausing and resuming (close then open again)
install state is preserved across close() — you may call open()
again without uninstall(). This is what the ble_uart_service example
exercises in app_main (open → close → open) to prove the cycle.
NimBLE backend
| Topic | Behaviour |
|---|---|
| GATT services | Same set as after install: GAP (0x1800), GATT (0x1801), BLE UART (NUS). close() calls the public ble_gatts_reset(); the next open() re-runs ble_svc_gap_init(), ble_svc_gatt_init(), and re-adds the UART service. |
| ATT handles | Not stable — centrals must run a full service discovery after each reconnect; do not cache handles across a close/open cycle. |
| Subscriptions | Cleared — the central must re-enable TX notifications (CCCD). |
| Bonds | NVS bond store is unchanged (still configured at install()). |
First vs later open |
With default BLE_HS_AUTO_START, the first open() consumes the one-shot auto-start queued by nimble_port_init(); every later open() must call ble_hs_sched_start() (handled inside ble_uart_open()). |
Bluedroid backend
close() only stops advertising and disconnects; the host and attribute
table created at install() stay registered. A second open() restarts
advertising. GATT handles are typically unchanged.
Extra GATT services (§6.3)
Services you register with ble_gatts_add_svcs() / ble_svc_*_init()
at install() time are not automatically re-registered by
ble_uart on a later open() after close() (NimBLE only re-adds
GAP, GATT, and UART). Either call your init/add functions again inside
your own open() hook after ble_uart_close(), or use
close() → uninstall() → install() → open() for a full rebuild.
5.3.2 Path B — release after a BLE event (close_async)
Use when the reason to shut down arrives on the host task (e.g.
BLE_UART_EVT_PAIRING_FAILED, an RX “power off” byte, or
LINK_SECURE policy). Synchronous close() deadlocks there; use
close_async() and defer uninstall() to a normal task.
static volatile bool s_ble_closed_ok;
static void on_event(const ble_uart_evt_t *e)
{
switch (e->id) {
case BLE_UART_EVT_PAIRING_FAILED:
ble_uart_close_async(); /* OK: host-task context */
break;
case BLE_UART_EVT_CLOSED:
/* Runs on the close-async worker — keep this short. Do NOT call
* ble_uart_uninstall() here (s_closing is still set; see §5.3.3). */
if (e->closed.status == BLE_UART_OK) {
s_ble_closed_ok = true; /* or xTaskNotifyGive / queue */
}
break;
default:
break;
}
}
void ble_shutdown_task(void *arg)
{
(void)arg;
for (;;) {
if (s_ble_closed_ok) {
s_ble_closed_ok = false;
ble_uart_uninstall(); /* normal app task */
break;
}
vTaskDelay(pdMS_TO_TICKS(50));
}
vTaskDelete(NULL);
}
on_event / on_rx (host task):
ble_uart_close_async()
│
▼
[worker: do_close ≈ sync close]
│
├── BLE_UART_EVT_DISCONNECTED (if peer was connected)
└── BLE_UART_EVT_CLOSED (worker task; set flag only)
│
▼
app task (not host, not inside CLOSED handler):
ble_uart_uninstall()
close_asyncreturnsBLE_UART_OKonce the worker is spawned, not when close finishes.- Only
BLE_UART_EVT_CLOSEDwith.closed.status == BLE_UART_OKmeans the same quiesced state asble_uart_close()— then it is safe touninstall()from your app task. - On failure (
BLE_UART_EFAIL, etc.) the port may still be open; retryble_uart_close()/ble_uart_close_async()from an app task.
5.3.3 close_async + uninstall — rules and pitfalls
ble_uart_uninstall() polls an in-flight close_async worker for
up to ~5 s. If the worker has not exited it logs
uninstall: close_async worker still running, tearing down anyway and
continues anyway — treat that as an application bug, not a supported
path.
| Do | Don't |
|---|---|
close_async() in on_event / on_rx; uninstall() later on one app task after CLOSED + BLE_UART_OK |
uninstall() in the same task right after close_async() without waiting |
Set a flag / queue in BLE_UART_EVT_CLOSED; return immediately |
ble_uart_uninstall() inside BLE_UART_EVT_CLOSED (worker still holds s_closing) |
Sync close + uninstall from a button / network task |
close / uninstall from host-task callbacks |
Keep on_event / on_rx short while a close is in flight |
Multi-second blocking in callbacks during close_async |
After a timeout log, fix ordering before install() again |
Immediate install() + open() + close_async() after a wedged teardown |
If you see uninstall: close_async worker still running, tearing down anyway, fix call ordering (§5.3.2) before calling install() again.
5.3.4 ble_uart_close_async() — reference
Some applications need to teardown the radio in response to a BLE
event — examples: a "shutdown" command on RX, a LINK_SECURE whose
flags don't meet the application's policy, or a PAIRING_FAILED from
a peer that's been blacklisted. Because the synchronous close() is
called from the host task it would normally run on, calling it
inline would deadlock. close_async() papers over that: it spawns a
small worker task (~3 KB stack, idle+2 priority) that runs the same
close body, then signals completion via the event callback.
Behaviour (see §5.3.2 for the full release flow):
close_asyncreturnsBLE_UART_OKonce the worker has been spawned.BLE_UART_EVT_DISCONNECTED(if connected) thenBLE_UART_EVT_CLOSEDwith.closed.status— same ≤500 ms disconnect window as syncclose.- Second call while draining →
BLE_UART_EALREADY; beforeopen→BLE_UART_EALREADY; spawn failure →BLE_UART_ENOMEM(latch reset).
5.4 TX interface
int ble_uart_tx(const uint8_t *data, size_t len);
For formatted output, format into your own buffer with snprintf first
and pass it to ble_uart_tx:
char line[64];
int n = snprintf(line, sizeof(line), "temp=%d.%d\n", t / 10, t % 10);
ble_uart_tx((const uint8_t *)line, (size_t)n);
Return values:
| Return | Meaning |
|---|---|
BLE_UART_OK |
Success (notification handed to the stack) |
BLE_UART_ENOTCONN |
No central connected; this is normal — typically ignore |
BLE_UART_EINVAL |
data == NULL or len == 0 |
BLE_UART_ENOMEM |
Stack mbuf pool exhausted |
BLE_UART_EFAIL |
Internal stack error — see logs |
Calling context: any FreeRTOS task at any priority. Not callable
from an ISR — push the data to a queue from the ISR and let a task
call ble_uart_tx.
Auto-fragmentation: regardless of buffer size, the implementation
splits the payload into successive notifications of (MTU - 3) bytes.
The central receives them in transmission order.
5.5 Status queries
bool ble_uart_is_connected(void);
bool ble_uart_is_subscribed(void);
is_connected(): a central is connected (it may not be paired yet).is_subscribed(): the central has subscribed to TX notifications (note: bonded reconnects often skip CCCD writes).
You usually don't need to query these up-front — ble_uart_tx
returns ENOTCONN to tell you.
5.6 Security configuration
cfg.encrypted is a one-line preset that turns on every part of
the stack's security toolbox at once — LE Secure Connections, bonding
(LTK persisted in NVS), MITM protection, DisplayOnly IO, and the
_ENC | _AUTHEN flags on the GATT characteristics. It maps to the
older two-state behaviour and is what the "secure by default" template
in §4.4 picks.
For applications that need finer control — a displayless gateway that
still wants encrypted bonding, a one-shot encrypted session that
doesn't keep an LTK, an interop test build that disables only MITM —
each component of the preset can be flipped individually through the
cfg.security sub-struct:
typedef enum {
BLE_UART_SEC_AUTO = 0, /* follow cfg.encrypted */
BLE_UART_SEC_OFF = 1,
BLE_UART_SEC_ON = 2,
} ble_uart_sec_t;
typedef enum {
BLE_UART_IO_CAP_AUTO = 0, /* DisplayOnly when MITM is on;
NoInputNoOutput when off.
Passkey Display needs no on_event */
BLE_UART_IO_CAP_NO_INPUT_OUTPUT = 1, /* Just Works only */
BLE_UART_IO_CAP_DISPLAY_ONLY = 2, /* Passkey Display — UART banner
+ optional PASSKEY_DISPLAY;
no on_event required */
BLE_UART_IO_CAP_KEYBOARD_ONLY = 3, /* Passkey Entry — PASSKEY_REQUEST;
on_event required */
BLE_UART_IO_CAP_DISPLAY_YES_NO = 4, /* Numeric Comparison;
on_event required */
BLE_UART_IO_CAP_KEYBOARD_DISPLAY = 5, /* PASSKEY_REQUEST or NUMERIC_COMPARE;
on_event required */
} ble_uart_io_cap_t;
typedef struct {
ble_uart_sec_t sc; /* tri-state */
ble_uart_sec_t bonding; /* tri-state */
ble_uart_sec_t mitm; /* tri-state */
ble_uart_io_cap_t io_cap; /* AUTO + the five IO caps above */
} ble_uart_security_t;
Each of cfg.security.{sc,bonding,mitm} is a tri-state. AUTO
(the value of any zero-initialised member) inherits from
cfg.encrypted; OFF / ON override that specific bit only. The
resolution table:
cfg.encrypted |
Override field | Resolved bit |
|---|---|---|
true |
AUTO |
ON |
true |
OFF |
OFF |
true |
ON |
ON |
false |
AUTO |
OFF |
false |
OFF |
OFF |
false |
ON |
ON |
cfg.security.io_cap follows the same AUTO / explicit pattern.
The application picks an IO cap matching its UI; the SM combines it
with the central's IO cap to elect the pairing model (see BT Core
Spec §2.3.5.1) and ble_uart fires the matching event:
| Pairing model | Trigger event | Application response |
|---|---|---|
| Just Works | (none — pairs silently) | — |
| Passkey Display (we show) | BLE_UART_EVT_PASSKEY_DISPLAY (optional; UART banner always) |
(none — port handles SM reply; central types the digits) |
| Passkey Entry (user types) | BLE_UART_EVT_PASSKEY_REQUEST |
ble_uart_passkey_reply(d) — on_event required |
| Numeric Comparison | BLE_UART_EVT_NUMERIC_COMPARE |
ble_uart_compare_reply(b) — on_event required |
Numeric Comparison additionally requires LE Secure Connections on
both sides (legacy SM doesn't support it); against a legacy peer a
DISPLAY_YES_NO / KEYBOARD_DISPLAY IO cap falls back to either
Passkey Entry (with our keypad) or Just Works.
What is checked synchronously
ble_uart_install() rejects the following with BLE_UART_EINVAL
before bringing the host stack up, so misconfigured applications
fail fast and predictably:
cfg.security.{sc,bonding,mitm}outside{AUTO, OFF, ON}cfg.security.io_capoutside the six values listed above- Resolved
mitm == ONtogether with resolvedio_cap == NO_INPUT_OUTPUT— Just Works cannot satisfy MITM and the SM would otherwise reject pairing in flight cfg.on_event == NULLtogether with a configured (not resolved) input-capableio_cap— onlyKEYBOARD_ONLY,DISPLAY_YES_NO, andKEYBOARD_DISPLAY. Without an event sink the application would never seePASSKEY_REQUEST/NUMERIC_COMPAREand pairing would silently stall until the SM times out.AUTO(even when it resolves to DisplayOnly becausemitm=ON),DISPLAY_ONLY, andNO_INPUT_OUTPUTdo not requireon_event— Passkey Display is satisfied inside the port (UART log + internal SM reply);PASSKEY_DISPLAYviaon_eventis additive only.
How the resolved policy is applied
| Component | Effect |
|---|---|
Resolved sc / bonding / mitm (any ON) |
SM is enabled; ble_gap_security_initiate (NimBLE) / esp_ble_set_encryption (Bluedroid) runs on connect |
Resolved mitm |
ESP_BLE_SEC_ENCRYPT_MITM vs _NO_MITM (Bluedroid); _AUTHEN flag added to GATT chars |
| Any of the three on | Encrypted GATT permission flags (_ENC on NimBLE, _ENCRYPTED on Bluedroid) |
| All three off | Plain READ/WRITE permissions; SM disabled |
Resolved io_cap |
BLE_HS_IO_* (NimBLE) / ESP_IO_CAP_* (Bluedroid) |
Common combinations
/* (a) Default — secure-by-default UART. SC + Bonding + MITM, DisplayOnly.
* on_event may be NULL: passkey is printed to UART and pairing
* completes without PASSKEY_DISPLAY / reply callbacks. */
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
/* security.{sc,bonding,mitm,io_cap} all AUTO → all ON. */
/* .on_event = NULL — valid for this preset */
});
/* (b) Displayless gateway. SC + Bonding + Just Works (no passkey UI). */
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.security = {
.mitm = BLE_UART_SEC_OFF,
.io_cap = BLE_UART_IO_CAP_NO_INPUT_OUTPUT,
},
});
/* (c) Encrypted but ephemeral. Re-pair every reconnect, no NVS bond. */
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.security = { .bonding = BLE_UART_SEC_OFF },
});
/* (d) Plaintext lab build. */
ble_uart_install(&(ble_uart_config_t){
.encrypted = false,
/* security.* all AUTO → all OFF. */
});
/* (e) Interop test — keep encryption + bonding, drop MITM only. */
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.security = { .mitm = BLE_UART_SEC_OFF },
/* security.io_cap AUTO → NoInputNoOutput once MITM is gone. */
});
/* (f) Passkey Entry — peripheral has a keypad, central has a display.
* User reads the 6-digit code off the central and types it here.
* on_event MUST be set; the application wires PASSKEY_REQUEST to
* a UI prompt and feeds the digits to ble_uart_passkey_reply(). */
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.security = { .io_cap = BLE_UART_IO_CAP_KEYBOARD_ONLY },
.on_event = on_event,
...
});
/* (g) Numeric Comparison — peripheral has display + yes/no button.
* Both sides see the same 6-digit value; user confirms match.
* Requires LE Secure Connections (so .sc must be ON, which it is
* by default with .encrypted=true). on_event MUST be set. */
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.security = { .io_cap = BLE_UART_IO_CAP_DISPLAY_YES_NO },
.on_event = on_event,
...
});
/* (h) Touchscreen UI — full keypad+display. The SM elects either
* Passkey Entry or Numeric Comparison depending on the central;
* wire BOTH events. */
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.security = { .io_cap = BLE_UART_IO_CAP_KEYBOARD_DISPLAY },
.on_event = on_event,
...
});
5.6.1 Pairing reply API
Passkey Display (default / DISPLAY_ONLY / AUTO + mitm=ON) does
not use the reply APIs. The port generates the 6-digit value, logs it,
and drives the SM (NimBLE: ble_sm_inject_io on BLE_SM_IOACT_DISP;
Bluedroid: no esp_ble_passkey_reply needed on PASSKEY_NOTIF). You
only need ble_uart_passkey_reply() / ble_uart_compare_reply() for
the interactive models below.
Passkey Entry and Numeric Comparison are interactive — the SM
suspends pairing until the application reports the user's input.
ble_uart exposes one reply call per flavour:
int ble_uart_passkey_reply(uint32_t passkey); /* 0..999999 */
int ble_uart_compare_reply(bool match);
Both are safe from any task, return immediately, and accept exactly
one reply per request. Subsequent calls (or calls with no request in
flight) return BLE_UART_ENOTCONN. passkey > 999999 returns
BLE_UART_EINVAL. If the user fails to reply before the SM's pairing
timeout (controller default ≈ 30 s), the link surfaces
BLE_UART_EVT_PAIRING_FAILED and any later reply is silently dropped.
static void on_event(const ble_uart_evt_t *e)
{
switch (e->id) {
case BLE_UART_EVT_PASSKEY_REQUEST:
/* Prompt the user; once digits are entered: */
ble_uart_passkey_reply(user_input); /* 0..999999 */
break;
case BLE_UART_EVT_NUMERIC_COMPARE:
ESP_LOGI(TAG, "compare %06" PRIu32, e->numeric_compare.passkey);
/* Once the user confirms: */
ble_uart_compare_reply(true /* or false on mismatch */);
break;
default: break;
}
}
A false reply to compare_reply() makes pairing fail with a
numeric-comparison mismatch — surfaced as
BLE_UART_EVT_PAIRING_FAILED. To cancel PASSKEY_REQUEST without a
mismatch event, just don't call passkey_reply(); the SM will time
out the pairing.
Backend differences
- Passkey Display without
on_event: both backends complete pairing; onlyPASSKEY_DISPLAYis suppressed when the callback isNULL. The UART banner (show_passkey) is always emitted for log-scraping tests. - Numeric Comparison edge case: if
io_capresolved to DisplayOnly but the central still negotiates NC (rare), Bluedroid rejects the request whenon_event == NULL; NimBLE may stall until the SM times out — useDISPLAY_YES_NO/KEYBOARD_DISPLAYwith a registeredon_eventif you need NC. - NimBLE lets the application observe the negotiated
key_sizeonBLE_UART_EVT_LINK_SECURE; Bluedroid surfaces a fixed16(the value forced viaESP_BLE_SM_MAX_KEY_SIZEat install time — Bluedroid does not expose the negotiated size onAUTH_CMPL). - With
mitm=OFF, NimBLE pairs with_AUTHENpermissions still off on the chars; Bluedroid usesESP_GATT_PERM_*_ENCRYPTED(the encryption-without-MITM tier) to match. cfg.encrypted=falseplus anycfg.security.*=ONoverride is allowed — it partially enables the SM, e.g.cfg.encrypted=false, cfg.security.sc=ONis "SC pairing without MITM and without persisted bond". Useful only for lab interop tests; production firmware should keepcfg.encrypted = trueand only override surgically.
5.7 Bond management
/* All three are usable as soon as ble_uart_install() returns; they
* do not require ble_uart_open() to have been called yet — clearing
* stale bonds before the first advertising window is the canonical
* use case. */
int ble_uart_get_bond_count(size_t *out_count);
int ble_uart_get_bonded_peers(ble_uart_addr_t *out, size_t cap, size_t *out_count);
int ble_uart_remove_peer(const ble_uart_addr_t *peer);
int ble_uart_clear_bonds(void);
/* Address type used by remove_peer and BLE_UART_EVT_CONNECTED. */
typedef struct {
uint8_t bytes[6]; /* big-endian: bytes[0] is the MSB octet */
uint8_t type; /* BLE_UART_ADDR_TYPE_PUBLIC or _RANDOM */
} ble_uart_addr_t;
| Function | Effect |
|---|---|
ble_uart_get_bond_count |
Number of peers in the persistent store; 0 means "no bonds yet". Pass cap == 0 to get_bonded_peers for the same count without an address buffer. |
ble_uart_get_bonded_peers |
List bonded peer addresses; writes up to cap, reports total in *out_count (caller may re-call with a larger buffer if total > cap). cap == 0 returns the count only. |
ble_uart_remove_peer |
Drop one peer's LTK / IRK / persisted CCCD. Idempotent — returns BLE_UART_OK even when the peer is not in the store (NimBLE: ble_store_util_delete_peer treats BLE_HS_ENOENT as success; Bluedroid: esp_ble_remove_bond_device does not fail on a missing entry). Call get_bonded_peers() first if you need to tell "removed" from "was never bonded". |
ble_uart_clear_bonds |
Drop all of the above; equivalent to a factory reset of the bond store, but does not touch any other NVS namespace |
ble_uart_remove_peer and ble_uart_clear_bonds do not actively
disconnect the current link (encrypted or not). Call ble_uart_close()
first if you need an immediate disconnect and re-pair.
Where do I get the address? From BLE_UART_EVT_CONNECTED.connected.peer
(see §5.2.1). Save it from your event handler the first time you see
each new peer, then pass it to ble_uart_remove_peer later when you
want to forget it.
Backend notes:
- Bluedroid matches bonds by BD address alone —
peer->typeis ignored byremove_peer. If the peer first connected asaddress_Aand bonding succeeded, CONNECT andget_bonded_peers()keep reportingaddress_Aon later reconnects even when the peer's over-the-air address has changed (e.g. a new RPA). - NimBLE matches by
(type, identity-address)— for an RPA peer this is the resolved identity, not the random address you saw on the wire.BLE_UART_EVT_CONNECTEDreports the resolved identity when it's known (post-pairing reconnect of a bonded RPA peer); on first pair it equals the OTA random address, so the bond is recorded under that random address andremove_peerworks either way. - Neither backend reports "peer not found" from
remove_peer— a wrong(type, bytes)pair that does not match any stored bond still returnsBLE_UART_OK. This mirrors the underlying stacks' delete-if-present semantics, not a lookup-then-delete API. ble_uart_clear_bondson Bluedroid iterates the bond list and removes each entry; on NimBLE it callsble_store_clear(), which also wipes the local LTK and any persisted CCCD.- NimBLE
get_bond_count/get_bonded_peers(cap=0)heap-allocate a scratch buffer sized toBLE_STORE_MAX_BONDS(not the caller's stack), so they are safe from small-stack tasks regardless ofCONFIG_BT_NIMBLE_MAX_BONDS.
5.8 Service UUID constant
extern const ble_uart_uuid128_t ble_uart_service_uuid;
Always 6e400001-b5a3-f393-e0a9-e50e24dcca9e (the de-facto BLE UART service UUID). It is
already inserted into the scan response by the default payload, so
the application only needs to reference it when it takes over the adv
bytes itself (see §5.9) or otherwise replaces our advertising (see §6.3).
5.9 Custom advertising payloads
ble_uart builds a sensible default for both the primary advertisement
and the scan response:
| Packet | Default content | Why |
|---|---|---|
| Primary adv (31 B max) | Flags AD + Complete Local Name (device_name) |
Phones show the name; everything else in the 31 bytes is left for the application to add via adv_data |
| Scan response (31 B max) | Complete 128-bit BLE UART service UUID (18 B element) | The 128-bit UUID alone is too big to share the primary packet with a typical name |
Set adv_data / scan_rsp_data in the config to override everything
the application sees — only the 3-byte Flags AD element of the primary
packet stays library-controlled (the BT spec mandates a Flags element,
and a few of its bits — General Discoverable / BR-EDR Not Supported —
are state we already negotiated with the controller).
/* +-- 31-byte primary advertisement packet ---------------------+
* | [02 01 06] ← Flags AD prepended by ble_uart (3 bytes) |
* | <up to BLE_UART_ADV_DATA_MAX = 28 bytes from your buffer> |
* +-------------------------------------------------------------+
*
* +-- 31-byte scan-response packet -----------------------------+
* | <up to BLE_UART_SCAN_RSP_DATA_MAX = 31 bytes from your buf> |
* +-------------------------------------------------------------+
*/
adv_data_len is checked at install time; oversized buffers fail with
BLE_UART_EINVAL. Both buffers are copied into module-private storage,
so the caller's pointers do not need to outlive the call.
Format: a sequence of standard BT Core "AD structure" triplets —
[length(1)] [AD type(1)] [value(length-1)]. AD-type values are
defined in the Bluetooth Assigned Numbers document
(Generic Access Profile, §1).
Common ones:
| Type | Name | Value format |
|---|---|---|
0x09 |
Complete Local Name | UTF-8 bytes |
0x08 |
Shortened Local Name | UTF-8 bytes |
0x0A |
TX Power Level | 1 signed byte (dBm) |
0x07 |
Complete List of 128-bit Service UUIDs | 16 bytes per UUID |
0xFF |
Manufacturer Specific Data | 2-byte company ID + payload |
Example — replace the default with name + UUID + 4 bytes of vendor data
static const uint8_t adv_payload[] = {
/* Complete Local Name "MyDev" (1 + 1 + 5 = 7 bytes) */
0x06, 0x09, 'M', 'y', 'D', 'e', 'v',
/* Complete 128-bit Service UUID — bytes are little-endian on air,
* matching ble_uart_service_uuid.bytes[]. (1 + 1 + 16 = 18 bytes) */
0x11, 0x07,
0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0,
0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e,
/* total = 7 + 18 = 25 bytes (≤ BLE_UART_ADV_DATA_MAX = 28) */
};
static const uint8_t scan_rsp_payload[] = {
/* Manufacturer Specific Data: Espressif Systems (0x02E5) + 4 bytes */
0x07, 0xFF, 0xE5, 0x02, 0xDE, 0xAD, 0xBE, 0xEF,
};
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.device_name = "MyDev", /* GAP service value, peer-readable */
.adv_data = adv_payload,
.adv_data_len = sizeof(adv_payload),
.scan_rsp_data = scan_rsp_payload,
.scan_rsp_data_len = sizeof(scan_rsp_payload),
.ble_uart_on_rx = on_rx,
.on_event = on_event,
});
Notes:
device_nameandadv_dataare independent. The first is the GAP service value that any connected peer can read over GATT; the second is what scanners see before connecting. If you want the device name visible during a scan, include a Complete-Local-Name AD element (0x09) inadv_datayourself — providing customadv_datadisables the auto-include path.- The 31-byte packet limit is BLE 4.x legacy advertising. Extended advertising (BLE 5.0) is not wired through this API — both backends fall back to legacy advertising for portability.
- Set only one half if you want the other to keep its default — e.g.
custom
adv_datawithscan_rsp_data = NULLkeeps the default service-UUID scan response.
6. Advanced Usage
6.1 Different RX framing strategies
A. Split on \n (suits ASCII protocols / JSON)
static uint8_t s_buf[1024];
static size_t s_len;
static void on_rx(const uint8_t *d, size_t n)
{
for (size_t i = 0; i < n; i++) {
if (d[i] == '\n') { handle_line(s_buf, s_len); s_len = 0; }
else if (s_len < sizeof s_buf) s_buf[s_len++] = d[i];
}
}
B. Length-prefixed binary frames
static void on_rx(const uint8_t *d, size_t n)
{
static uint16_t need = 0;
static uint8_t frame[256];
static size_t got = 0;
for (size_t i = 0; i < n; i++) {
if (need == 0) { need = d[i]; got = 0; continue; }
frame[got++] = d[i];
if (got == need) { handle_frame(frame, got); need = 0; }
}
}
C. Forward straight to UART
static void on_rx(const uint8_t *d, size_t n)
{
uart_write_bytes(UART_NUM_1, (const char *)d, n);
}
6.2 Disabling encryption (lab scenarios)
ble_uart_install(&(ble_uart_config_t){
.encrypted = false, /* ← turn it off */
.device_name = "OpenDev",
.ble_uart_on_rx = ...,
});
Effect:
- GATT characteristics drop the
_ENC | _AUTHENflags. - Any central can read/write — no pairing required.
- No passkey prompt.
- Data is sniffable by any nearby BLE sniffer or compromised radio in range.
Do not ship this in production firmware.
6.3 Coexisting with other GATT services
The snippet below is for the NimBLE backend. With Bluedroid, register additional profiles via
esp_ble_gatts_app_register()before callingble_uart_open()— the gating rule is the same: extra services must be in place before advertising starts.
ble_uart registers its own service; you can call ble_gatts_add_svcs()
multiple times and NimBLE will build all of them into the GATT
table. Caveat: this must happen before the first ble_uart_open()
for that install() cycle, otherwise the host task is already running
and the GATT table is locked. If you use ble_uart_close() and later
ble_uart_open() without uninstall(), you must call your extra
ble_svc_*_init() / ble_gatts_add_svcs() again before that second
open() — see §5.3.1a.
ble_uart_install(&cfg);
/* Register your extra services before open() */
ble_svc_dis_init(); /* Device Information Service */
my_battery_service_init(); /* your own battery service */
ble_uart_open();
If your service must appear in the advertising packet, you have to bypass
ble_uart's internal advertising logic — overrideble_hs_cfg.sync_cbwith your own implementation afterble_uart_install, then callble_uart_open(). Note thatble_uart's internalstart_advertisingwill not run, so you must callble_gap_adv_startyourself. In that case, just forkble_uart_nimble.c(or the matchingble_uart_bluedroid.c).
6.4 Configuring the device name via Kconfig
If you use the shared ble_uart component, options are already in
menuconfig → Component configuration → ESP-BLE-UART library. If you copied only
the .c / .h files into main/, copy Kconfig from common/ble_uart/ as
well (or merge its symbols into your own Kconfig.projbuild), then:
The bundled example builds a per-unit name as <prefix>-XXXX where
XXXX is the last two BT MAC bytes in hex:
uint8_t mac[6] = {0};
esp_read_mac(mac, ESP_MAC_BT);
char name[BLE_UART_DEVICE_NAME_MAX + 1];
snprintf(name, sizeof(name), "%s-%02X%02X",
CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]);
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.device_name = name,
.ble_uart_on_rx = on_rx,
});
Edit the prefix through menuconfig → Component configuration → ESP-BLE-UART library → BLE device name prefix.
For a fixed name on every unit, skip the MAC suffix and pass any
string ≤ BLE_UART_DEVICE_NAME_MAX directly to device_name.
6.5 Pushing data proactively
You can call TX from any task:
/* A periodic sensor-reporting task */
static void sensor_task(void *arg)
{
char line[64];
while (1) {
int t = read_temperature();
int n = snprintf(line, sizeof(line), "temp=%d.%d\n", t / 10, t % 10);
ble_uart_tx((const uint8_t *)line, (size_t)n);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
/* Spawn it from app_main */
xTaskCreate(sensor_task, "sensor", 3072, NULL, 5, NULL);
When nobody is subscribed, ble_uart_tx returns BLE_UART_ENOTCONN —
just ignore it.
7. Calling Context & Thread Safety
| Function | Calling context | Thread-safe |
|---|---|---|
ble_uart_install |
Any task; once per uninstall cycle | One-shot until uninstall |
ble_uart_open |
Any task; after install |
One-shot until close |
ble_uart_close |
Any task except the BLE host task (NimBLE host task / Bluedroid BTC task) | Idempotent; second call returns EALREADY |
ble_uart_close_async |
Any task — including the BLE host task (use this from inside on_rx / on_event) |
Idempotent; second call while a worker is draining returns EALREADY |
ble_uart_uninstall |
Any task except the BLE host task | Idempotent; see §5.3 release paths; polls in-flight close_async ≤~5 s. Best-effort teardown: returns the first BLE_UART_* failure (ble_uart_close or translated esp_err_t) but always wipes module state so a retry is possible. |
ble_uart_tx |
Any FreeRTOS task | Yes — multi-task concurrent |
ble_uart_is_connected / is_subscribed |
Any context | Yes (bool read; best-effort snapshot) |
ble_uart_on_rx / on_event callback |
BLE host task (NimBLE host task / Bluedroid BTC task); BLE_UART_EVT_CLOSED is the lone exception — fires on the close-async worker task |
Your code must not block, must not call close / uninstall — use ble_uart_close_async() instead |
Calling any ble_uart API from an ISR |
not allowed | Neither host stack supports it |
8. Memory / Performance
| Item | Footprint |
|---|---|
Code segment (ble_uart_nimble.c.o) |
~14 KB (with -Os) |
Code segment (ble_uart_bluedroid.c.o) |
~22 KB (with -Os; larger because long-write reassembly is open-coded) |
| Static RAM (globals + RX buffer) | ~1.1 KB (the bulk is CONFIG_BLE_UART_RX_SCRATCH_SIZE, default 1024 B) |
| Host task stack (NimBLE host / Bluedroid BTC) | 4 KB (default) |
| Controller task stack | ~3 KB (default) |
| Bond store (NVS) | ~80 bytes per bonded peer |
| ATT MTU | Negotiated; whatever you set in sdkconfig (247 / 256 / 512) |
Measured throughput (ESP32-S3, iPhone 14 Pro central, MTU 247):
- TX (notify): ~25 KB/s
- RX (write): ~20 KB/s
9. FAQ
| Symptom | Cause / fix |
|---|---|
nimble_port_init rc=... |
NVS not initialised, or BT controller not enabled |
Compile error: host/ble_hs.h not found |
REQUIRES bt is missing from CMakeLists |
ble_uart_install() returns BLE_UART_EINVAL |
Buffer too long (device_name / adv_data / scan_rsp limits in §5.9), impossible security (mitm=ON + io_cap=NO_INPUT_OUTPUT), io_cap in {KEYBOARD_ONLY, DISPLAY_YES_NO, KEYBOARD_DISPLAY} with on_event=NULL (note: default AUTO + encrypted=true and explicit DISPLAY_ONLY do allow on_event=NULL), or out-of-range sc/bonding/mitm/io_cap. See §5.6. |
| Pairing fails | Central uses "Just Works" but we require MITM (encrypted=true). Use a central that supports passkey entry |
enc_change status=13 encrypted=1 bonded=1 |
13 = BLE_HS_ETIMEOUT. Bonded-reconnect race; the link is actually encrypted — safe to ignore |
| Notifications missing after a reconnect | Bonded centrals often skip the CCCD write; our TX path doesn't gate on subscription state, so notifications still go out — make sure the central side has its callback registered |
| Second connection rejected | MAX_CONNECTIONS = 1 by default. For multi-connection support, bump the sdkconfig value and turn s_conn_handle (NimBLE backend) / s_conn_id (Bluedroid backend) into an array |
| Flash fills up | Bond entries accumulate. Periodically run idf.py erase-flash, or call ble_store_clear() in code |
10. Differences from This Example
If you build directly on top of this example:
| You already have | No further work needed |
|---|---|
main.c echo template |
Replace with your own on_rx body |
sdkconfig.defaults |
Reuse as-is |
sdkconfig.bluedroid |
Only if you switch to Bluedroid host — reuse as-is (see §4.3); omit for default NimBLE |
Root CMakeLists.txt (EXTRA_COMPONENT_DIRS → ../common/ble_uart) |
Reuse as-is (or follow §3 option B) |
CMakeLists.txt (root + main) |
Reuse as-is |
If you start from an empty project:
| What you need to do | Source |
|---|---|
Add EXTRA_COMPONENT_DIRS for examples/bluetooth/common/ble_uart in root CMakeLists.txt, or copy ble_uart.h + at least one of ble_uart_nimble.c / ble_uart_bluedroid.c into main/ |
§3 of this guide |
Copy the key lines of sdkconfig.defaults |
§4.3 of this guide |
Add REQUIRES ble_uart nvs_flash (after EXTRA_COMPONENT_DIRS) or SRC + REQUIRES bt nvs_flash (copied sources) to main/CMakeLists.txt |
§4.2 of this guide |
Write install + open in app_main |
§4.4 of this guide |
11. API Cheat Sheet (print and pin to the wall)
#include "ble_uart.h"
/* === Types === */
typedef void (*ble_uart_rx_cb_t) (const uint8_t *data, size_t len);
typedef void (*ble_uart_evt_cb_t)(const ble_uart_evt_t *evt);
typedef struct {
ble_uart_sec_t sc; /* AUTO / OFF / ON — follow `encrypted` when AUTO */
ble_uart_sec_t bonding;
ble_uart_sec_t mitm;
ble_uart_io_cap_t io_cap; /* AUTO / NO_INPUT_OUTPUT / DISPLAY_ONLY /
KEYBOARD_ONLY / DISPLAY_YES_NO /
KEYBOARD_DISPLAY */
} ble_uart_security_t;
typedef struct {
bool encrypted; /* preset: SC + Bonding + MITM + DisplayOnly */
ble_uart_security_t security; /* per-feature overrides; see §5.6 */
const char *device_name; /* ≤ BLE_UART_DEVICE_NAME_MAX (26) */
/* Custom adv payloads (NULL → defaults).
* Limits: adv_data_len ≤ BLE_UART_ADV_DATA_MAX (28),
* scan_rsp_data_len ≤ BLE_UART_SCAN_RSP_DATA_MAX (31). */
const uint8_t *adv_data;
size_t adv_data_len;
const uint8_t *scan_rsp_data;
size_t scan_rsp_data_len;
ble_uart_rx_cb_t ble_uart_on_rx;
ble_uart_evt_cb_t on_event; /* optional for default Passkey Display;
required for KEYBOARD_ONLY /
DISPLAY_YES_NO / KEYBOARD_DISPLAY */
} ble_uart_config_t;
/* === Lifecycle === */
int ble_uart_install(const ble_uart_config_t *cfg); /* host + GATT */
int ble_uart_open(void); /* start advertising (NimBLE: spawn host task) */
int ble_uart_close(void); /* stop adv / disconnect / quiesce host */
int ble_uart_close_async(void); /* same, fire-and-forget; signals BLE_UART_EVT_CLOSED on completion */
int ble_uart_uninstall(void); /* best-effort teardown; first error, state always cleared */
/* === Send (callable from any task) === */
int ble_uart_tx(const uint8_t *data, size_t len);
/* === Receive === */
/* Via the cfg.ble_uart_on_rx callback, signature:
* void cb(const uint8_t *data, size_t len); */
/* === Pairing replies (PASSKEY_REQUEST / NUMERIC_COMPARE only) === */
int ble_uart_passkey_reply(uint32_t passkey); /* answer PASSKEY_REQUEST */
int ble_uart_compare_reply(bool match); /* answer NUMERIC_COMPARE */
/* === Status === */
bool ble_uart_is_connected(void);
bool ble_uart_is_subscribed(void);
/* === Bond management (works after install) === */
typedef struct {
uint8_t bytes[6]; /* big-endian: bytes[0] is MSB */
uint8_t type; /* BLE_UART_ADDR_TYPE_PUBLIC or _RANDOM */
} ble_uart_addr_t;
int ble_uart_get_bond_count(size_t *out_count);
int ble_uart_get_bonded_peers(ble_uart_addr_t *out, size_t cap, size_t *out_count);
int ble_uart_remove_peer(const ble_uart_addr_t *peer);
int ble_uart_clear_bonds(void);
/* === Service UUID (for advertising; usually no need to touch) === */
extern const ble_uart_uuid128_t ble_uart_service_uuid;
12. Minimal Project Template (ready to flash)
A complete flashable project takes a handful of files. The inactive
backend .c compiles to nothing if you ship both.
Using the shared component (fewer copies):
my_ble_uart_project/
├── CMakeLists.txt ← EXTRA_COMPONENT_DIRS → …/common/ble_uart (before project())
├── sdkconfig.defaults
└── main/
├── CMakeLists.txt ← REQUIRES ble_uart nvs_flash; SRCS main.c only
└── main.c
Copying sources into main/ (classic layout):
my_ble_uart_project/
├── CMakeLists.txt
├── sdkconfig.defaults
└── main/
├── CMakeLists.txt
├── ble_uart.h ← from $IDF_PATH/examples/bluetooth/common/ble_uart/
├── ble_uart_nimble.c
├── ble_uart_bluedroid.c ← optional second backend
└── main.c
Root CMakeLists.txt (shared ble_uart via EXTRA_COMPONENT_DIRS):
cmake_minimum_required(VERSION 3.16)
list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../path/to/common/ble_uart")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(my_ble_uart)
main/CMakeLists.txt (shared component — no .c copies in main/):
idf_component_register(SRCS "main.c"
INCLUDE_DIRS "."
REQUIRES ble_uart nvs_flash)
main/CMakeLists.txt (classic copy layout — both backends in main/):
idf_component_register(SRCS "main.c"
"ble_uart_nimble.c"
"ble_uart_bluedroid.c"
INCLUDE_DIRS "."
REQUIRES bt nvs_flash)
sdkconfig.defaults (7 lines, NimBLE backend):
CONFIG_BT_ENABLED=y
CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_SM_SC=y
CONFIG_BT_NIMBLE_NVS_PERSIST=y
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=512
Bluedroid host instead of NimBLE: copy
examples/bluetooth/ble_uart_service/sdkconfig.bluedroid next to your
sdkconfig.defaults and pass
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bluedroid" (see §4.3).
main/main.c — copy the §4.4 template verbatim.
Flash:
idf.py set-target esp32s3
idf.py build flash monitor
Done.