- Added tagged event API (ble_uart_evt_t / on_event) - Added bond management APIs - Supported custom adv_data / scan_rsp_data - Validate device_name length synchronously - Added ble_uart_close_async() and EVT_CLOSED - Added granular security config (security struct) - Supported Passkey Entry and Numeric Comparison
ESP-BLE-UART Example — NimBLE / Bluedroid
| Supported Targets | ESP32 | ESP32-C2 | ESP32-C3 | ESP32-C5 | ESP32-C6 | ESP32-C61 | ESP32-H2 | ESP32-H21 | ESP32-H4 | ESP32-S3 | ESP32-S31 |
|---|
A turnkey serial-over-BLE peripheral that implements the de-facto BLE UART-over-GATT layout (RX write, TX notify; fixed 128-bit UUIDs below), so any widely used BLE-serial central (mobile GATT client apps, Web Bluetooth examples, your own iOS / Android / Linux / Python scripts) can talk to it unchanged.
The example ships with two interchangeable backends — NimBLE and
Bluedroid — both implementing the same stack-agnostic
ble_uart.h API. Pick one with idf.py menuconfig → Component config → Bluetooth → Host; the build system links the matching backend
automatically. Default is NimBLE (smaller footprint).
The whole BLE stack — NVS-backed bond store, NimBLE host, security
manager, advertising, pairing, GAP event handling — is wrapped behind
two function calls in app_main:
ble_uart_install(&cfg); // NimBLE host + BLE UART GATT service
ble_uart_open(); // start advertising + auto-encrypt
If your app powers BLE off at runtime, use one of the release paths
in PORTING.md §5.3
(this example uses Path A from app_main):
| Path | When | Calls |
|---|---|---|
| A — sync (default) | Shutdown from a normal task (button, Wi-Fi, app_main) |
ble_uart_close() → ble_uart_uninstall() |
| B — async | Shutdown triggered inside on_event / on_rx |
close_async() in callback → CLOSED sets flag → uninstall() on a separate app task (not inside CLOSED) |
/* Path A — this example style */
ble_uart_close();
ble_uart_uninstall();
When a central connects, the firmware automatically initiates LE Secure
Connections + Bonding pairing, displays a fresh 6-digit passkey to the
UART monitor, persists the LTK in NVS, and starts delivering received
bytes to the application's on_rx callback. The application sends bytes
back with ble_uart_tx().
GATT layout
| UUID | Properties | Default flags | |
|---|---|---|---|
| Service | 6e400001-b5a3-f393-e0a9-e50e24dcca9e |
— | — |
| RX (in) | 6e400002-b5a3-f393-e0a9-e50e24dcca9e |
Write, WriteNR | encrypted, authenticated |
| TX (out) | 6e400003-b5a3-f393-e0a9-e50e24dcca9e |
Notify (auto-CCCD) | encrypted, authenticated |
The _ENC | _AUTHEN flags are turned on only when cfg.encrypted = true
(the default in this example). The two flags can be controlled
independently via cfg.security.mitm (drops _AUTHEN) and the
combined cfg.security.{sc,bonding,mitm} set (all OFF drops _ENC
too) — see PORTING.md §5.6.
Files
| File | Lines | Role |
|---|---|---|
main/main.c |
~200 | NVS init, install + open with <prefix>-XXXX device name (Kconfig prefix + BT MAC suffix), RX echo handler, lifecycle/link-state event sink, bonded-peer dump on boot. Identical for both backends. |
main/Kconfig.projbuild |
~50 | Example-local EXAMPLE_CUSTOM_ADV_DATA switch — toggles the ble_uart_config_t::adv_data demo path in main.c. |
CMakeLists.txt (root) |
~15 | list(APPEND EXTRA_COMPONENT_DIRS .../common/ble_uart) before project() so main can REQUIRES ble_uart. |
../common/ble_uart/ble_uart.h |
~640 | Stack-agnostic public API: configuration struct (preset + per-feature security overrides + custom adv payload + RX/event callbacks) + lifecycle (install/open/close/close_async/uninstall) + TX + pairing replies + bond-management + status + UUID + BLE_UART_E* return codes. No NimBLE / Bluedroid types leak through. |
../common/ble_uart/ble_uart_nimble.c |
~1290 | NimBLE backend: host bring-up, BLE UART GATT service via ble_gatts_add_svcs, advertising (default + raw), pairing (incl. Passkey Entry / Numeric Comparison), bond store, async close, install/open/close/uninstall. Active when CONFIG_BT_NIMBLE_ENABLED=y. |
../common/ble_uart/ble_uart_bluedroid.c |
~1660 | Bluedroid backend: controller + host enable, BLE UART GATT service via esp_ble_gatts_create_attr_tab (service-table API), advertising (default + raw), pairing (incl. Passkey Entry / Numeric Comparison), bond store, async close, full PREP/EXEC long-write reassembly, install/open/close/uninstall. Active when CONFIG_BT_BLUEDROID_ENABLED=y. |
../common/ble_uart/Kconfig |
~30 | Device name prefix + RX scratch size (menuconfig → Component configuration → ESP-BLE-UART library). |
../common/ble_uart/PORTING.md |
~1300 | Porting and API guide (integration, CMake, sdkconfig, security model, custom advertising, bond management, thread safety). |
sdkconfig.defaults |
— | Default: NimBLE backend, MTU 512, SC + bonding + persistent NVS. |
sdkconfig.bluedroid |
— | Overlay: switch to Bluedroid backend (used via -D SDKCONFIG_DEFAULTS=..., see "Choosing the host stack" below). |
Public API
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 */
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 PORTING.md §5.6 */
const char *device_name; /* ≤ BLE_UART_DEVICE_NAME_MAX (26) */
/* Optional: raw advertising / scan-response bytes (NULL → defaults).
* Limits: adv_data_len ≤ BLE_UART_ADV_DATA_MAX (28),
* scan_rsp_data_len ≤ BLE_UART_SCAN_RSP_DATA_MAX (31).
* The 3-byte Flags AD element is prepended automatically — don't
* include it in adv_data. See PORTING.md §5.9 for examples. */
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; /* lifecycle / link-state events; NULL drops */
} ble_uart_config_t;
typedef struct {
uint8_t bytes[6]; /* big-endian: bytes[0] is the MSB (AA:BB:CC:DD:EE:FF) */
uint8_t type; /* BLE_UART_ADDR_TYPE_PUBLIC | _RANDOM */
} ble_uart_addr_t;
/* Lifecycle */
int ble_uart_install(const ble_uart_config_t *cfg); /* host + GATT */
int ble_uart_open(void); /* host task + advertising */
int ble_uart_close(void); /* stop adv / disconnect / halt host */
int ble_uart_close_async(void); /* same, fire-and-forget; safe from inside on_event/on_rx */
int ble_uart_uninstall(void); /* free port + reset state */
/* Data path */
int ble_uart_tx(const uint8_t *data, size_t len);
/* Pairing replies (call from on_event for input-capable IO caps) */
int ble_uart_passkey_reply(uint32_t passkey); /* answer PASSKEY_REQUEST */
int ble_uart_compare_reply(bool match); /* answer NUMERIC_COMPARE */
/* Status (best-effort snapshot) */
bool ble_uart_is_connected(void);
bool ble_uart_is_subscribed(void);
/* Bond management (works after install()) */
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);
extern const ble_uart_uuid128_t ble_uart_service_uuid;
Event callback
on_event is invoked on the BLE host task (same context as ble_uart_on_rx)
with a tagged ble_uart_evt_t. Use LINK_SECURE — not is_connected() —
to gate any application logic that requires the channel to be encrypted /
authenticated:
evt->id |
Payload | Fires when |
|---|---|---|
BLE_UART_EVT_CONNECTED |
connected.peer |
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 |
Central writes CCCD on TX (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 asks the device to show a passkey |
BLE_UART_EVT_PASSKEY_REQUEST |
— | SM asks the user to enter a passkey shown by the central — answer with ble_uart_passkey_reply() |
BLE_UART_EVT_NUMERIC_COMPARE |
numeric_compare.passkey (0..999999) |
SM asks the user to confirm both sides display the same value — answer with ble_uart_compare_reply() |
BLE_UART_EVT_PAIRING_FAILED |
pairing_failed.reason (stack-specific) |
Pairing rejected or timed out |
BLE_UART_EVT_CLOSED |
closed.status (BLE_UART_*) |
ble_uart_close_async() worker has finished; BLE_UART_OK means tear-down succeeded |
The default passkey UART banner still prints; the callback is additive so log-scraping tests stay compatible. Don't block in the callback.
Callback rules:
- Do not call
ble_uart_close()orble_uart_uninstall()fromon_event/on_rx(host task — deadlocks). - To start teardown from a callback, call
ble_uart_close_async()only. - Call
ble_uart_uninstall()from a normal app task afterBLE_UART_EVT_CLOSEDwithclosed.status == BLE_UART_OK(see PORTING.md §5.3.2).
Path B sketch (full code in PORTING.md):
case BLE_UART_EVT_PAIRING_FAILED:
ble_uart_close_async();
break;
case BLE_UART_EVT_CLOSED:
if (e->closed.status == BLE_UART_OK) {
s_ble_closed_ok = true; /* app task calls uninstall */
}
break;
Choosing the host stack
The same ble_uart.h API is implemented twice — once on top of NimBLE
(ble_uart_nimble.c) and once on top of Bluedroid
(ble_uart_bluedroid.c). The shared ble_uart component's
CMakeLists.txt registers both files; each guards its body with
#if CONFIG_BT_NIMBLE_ENABLED / #if CONFIG_BT_BLUEDROID_ENABLED, so
exactly one becomes live at compile time.
Two ways to switch:
# A. Flip the Kconfig knob interactively
idf.py menuconfig
# Component config -> Bluetooth -> Host -> NimBLE / Bluedroid
# B. Apply the Bluedroid overlay non-interactively (scripts / reproducible builds)
idf.py -B build_bd \
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bluedroid" \
reconfigure
idf.py -B build_bd build flash monitor
When neither is enabled the build fails up-front with a clear error.
Differences callers should know about
| NimBLE backend | Bluedroid backend | |
|---|---|---|
| Long-write (PREP+EXEC) reassembly | done by NimBLE itself | done in ble_uart_bluedroid.c, capped at CONFIG_BLE_UART_RX_SCRATCH_SIZE |
| TX congestion behaviour | mbuf pool is generous; rarely returns ENOMEM |
esp_ble_gatts_send_indicate may return ENOMEM under load → caller should back off |
| Passkey origin | generated locally with esp_random() |
generated by the Bluedroid SM, surfaced via PASSKEY_NOTIF_EVT |
| Bond persistence | needs CONFIG_BT_NIMBLE_NVS_PERSIST=y |
persisted by default |
install blocking time |
~50 ms | ~150 ms (waits for CREAT_ATTR_TAB_EVT) |
uninstall thoroughness |
nimble_port_deinit() releases everything |
bluedroid_disable+deinit + controller_disable+deinit releases everything |
How to use
Configure
idf.py set-target esp32c3 # or esp32, esp32s3, esp32c6, esp32h2 ...
idf.py menuconfig # optional
# Component configuration -> ESP-BLE-UART library
# - BLE device name prefix (default: BleUart; example appends -XXXX from BT MAC)
# - RX scratch buffer size (default: 1024 bytes)
# BLE UART service example
# - Use custom advertising data (default: off)
Those BLE_UART_* options are defined in ../common/ble_uart/Kconfig
(the ble_uart component); they appear whenever ble_uart is part of the
build (this example pulls it in via EXTRA_COMPONENT_DIRS in the root
CMakeLists.txt).
EXAMPLE_CUSTOM_ADV_DATA is example-local (main/Kconfig.projbuild)
and demonstrates ble_uart_config_t::adv_data — the field that lets
the application fully control the over-the-air advertising payload
instead of using the library default.
When the option is on, app_main hands a static byte array
(example_adv_payload[], top of main.c) to ble_uart_install().
The array is just a sequence of [length][AD type][value] triplets;
edit it directly to advertise whatever you want — a different Local
Name, Manufacturer Specific Data, custom Service Data, additional
Service UUIDs, etc. The only hard rule is total length ≤
BLE_UART_ADV_DATA_MAX (28); the 3-byte Flags AD is added by the
library and does not count against that budget.
The GAP-service Device Name (set via device_name in the same
config struct) is independent and is what connected centrals read
post-pair, regardless of adv_data.
With the option off the library default is used (Complete Local Name in the primary packet, 128-bit Service UUID in the scan response).
The two security knobs are set in sdkconfig.defaults:
CONFIG_BT_NIMBLE_SM_SC=y # LE Secure Connections
CONFIG_BT_NIMBLE_NVS_PERSIST=y # Bond keys persist across reboots
Disable cfg.encrypted in main.c (set it to false) for plaintext
operation in the lab — the GATT characteristics drop their _ENC
flags accordingly. Production firmware should keep encryption on.
For finer control without going all-or-nothing — e.g. a displayless
gateway that wants encryption + bonding but no passkey UI, or a
device with a keypad that wants Passkey Entry / Numeric Comparison —
keep cfg.encrypted = true and override individual bits via
cfg.security.{sc,bonding,mitm,io_cap}. The input-capable IO caps
(KEYBOARD_ONLY, DISPLAY_YES_NO, KEYBOARD_DISPLAY) require an
on_event handler that wires BLE_UART_EVT_PASSKEY_REQUEST /
NUMERIC_COMPARE to ble_uart_passkey_reply() /
ble_uart_compare_reply(). See PORTING.md §5.6 for the full matrix
and worked examples.
Build & flash
idf.py build flash monitor
Expected boot log (NimBLE backend — the per-characteristic register lines are NimBLE-specific; Bluedroid prints the four UART-service handles in a single line, see below):
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=80:7d:3a:11:22:33
I (xxx) ble_uart: BLE host task started
I (xxx) ble_uart: advertising as 'BleUart-2233'
Expected boot log (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
Pairing & demo
-
On a phone, install a BLE GATT client app that supports scanning, pairing, characteristic write, and notify/CCCD (many mobile “BLE tools” or serial-over-BLE utilities qualify).
-
Scan, tap Connect on
BleUart-XXXX(prefix fromCONFIG_BLE_UART_DEVICE_NAME_PREFIX,XXXX= last two BT MAC bytes). The phone prompts for a 6-digit code. -
The device prints a fresh code in a banner on UART:
W (xxx) ble_uart: +-----------------------------+ W (xxx) ble_uart: | BLE PAIRING PASSKEY: | W (xxx) ble_uart: | 427183 | W (xxx) ble_uart: +-----------------------------+ -
Type that code on the phone; pairing completes. The link is now AES-CCM-encrypted and the LTK is stored to NVS.
-
Open the UART service (UUID
6e400001-…), subscribe to TX (the down-arrow icon), then write any bytes to RX (the up-arrow icon). The device logs them to UART and echoes them right back through TX. -
Disconnect and reconnect: no passkey prompt — the bond resumes automatically.
To wipe the bond and force a fresh passkey there are three options:
- Call
ble_uart_clear_bonds()from your app (preserves the rest of NVS) - Call
ble_uart_remove_peer(&addr)to drop one peer (use the address reported inBLE_UART_EVT_CONNECTED, or any address you happen to have stored — Bluedroid matches by address only, NimBLE by identity) - Run
idf.py erase-flashand re-flash (also wipes WiFi creds, NVS, etc.)
Adapting to your application
Replace the ble_uart_on_rx body in main.c with your own protocol
parser (line / TLV / length-prefixed framing — ble_uart delivers raw
bytes with no framing assumptions). Send replies with ble_uart_tx().
Reusing ble_uart in your own project
Recommended (no copy): register the shared component directory before
project() so CMake can resolve REQUIRES ble_uart from main/ (same pattern
as this example's root CMakeLists.txt):
cmake_minimum_required(VERSION 3.22)
list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../path/to/common/ble_uart")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(my_app)
Then in main/CMakeLists.txt use REQUIRES ble_uart nvs_flash and
#include "ble_uart.h".
Adjust the EXTRA_COMPONENT_DIRS path if you vendor common/ble_uart elsewhere
(e.g. ${CMAKE_CURRENT_LIST_DIR}/components/ble_uart).
Alternative: copy the whole examples/bluetooth/common/ble_uart/ directory
into your tree (or only the .h / .c files into main/) and add bt nvs_flash
to that component's REQUIRES, then in your app_main:
nvs_flash_init();
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.device_name = "MyDevice",
.ble_uart_on_rx = my_handler,
});
ble_uart_open();
That's it — encrypted serial-over-BLE in 4 lines.
OpenCode Companion
This example serves as the transport layer for the OpenCode Companion tutorial, which walks through building a physical companion device for OpenCode using ESP-BLE-UART and ESP-VoCat. The tutorial covers:
- Part 1: Using ESP-BLE-UART Console to verify the BLE UART data path (Echo Server mode).
- Part 2: Using the
ble_uart_serviceexample on ESP-VoCat with the ESP-BLE-UART Daemon and OpenCode Plugin for session status display and physical permission approval.
See the full guide in English: OPENCODE_COMPANION.md Chinese version: OPENCODE_COMPANION_CN.md
Troubleshooting
- Phone shows "pairing failed" — the central asked for "Just Works"
and our SM rejected it because MITM is required when
cfg.encrypted = true. Pick a phone / app that supports passkey entry. - No passkey appears in UART — verify
CONFIG_BT_NIMBLE_SM_SC=yin your sdkconfig and that you didn't togglecfg.encryptedtofalse. enc_change status=13 encrypted=1 ...—13isBLE_HS_ETIMEOUT, triggered by a benign race between ourble_gap_security_initiate()and the central's own auto-encryption on a bonded reconnect. Status is non-zero but the link is fully encrypted; safe to ignore.- Notifications missing after a reconnect —
ble_uart_txdeliberately doesn't gate on the CCCD-subscribe state because bonded reconnects often skip the CCCD write. Bytes are still pushed; the central delivers them based on its remembered subscription.