Files
2026-07-13 13:04:25 +08:00

64 KiB
Raw Permalink Blame History

ESP-BLE-UART Porting & API Guide

Naming convention: Use ESP-BLE-UART for Espressif-owned product names (Bridge, Console, Daemon, Echo Server, the ble_uart component, and the ble_uart_service example). 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.yml path dependency alone is not sufficient if main/CMakeLists.txt lists REQUIRES ble_uart: the early requirement scan runs before the component manager injects that dependency, so CMake fails with unknown component ble_uart. Prefer EXTRA_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 ctx argument. If your callback needs state, use a file-scope static or 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 SUBSCRIBED event (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) before DISCONNECTED. 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_SECURE always arrives after CONNECTED — pairing can't run without a link.
  • BLE_UART_EVT_CLOSED always arrives after BLE_UART_EVT_DISCONNECTED (when there was a peer) — the close-async worker calls the same disconnect+wait sequence as the synchronous ble_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 sets ESP_BLE_SM_MAX_KEY_SIZE=16 at install time and does not expose the negotiated size on AUTH_CMPL.
  • Bonded reconnects: NimBLE re-fires LINK_SECURE on every encryption change; Bluedroid only fires AUTH_CMPL_EVT when 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 (via BLE_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.25.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_CLOSEDble_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.

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()
  • uninstall may call close internally if you skipped close — still call both explicitly so return codes are obvious in your logs.
  • Do not call close or uninstall from on_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_async returns BLE_UART_OK once the worker is spawned, not when close finishes.
  • Only BLE_UART_EVT_CLOSED with .closed.status == BLE_UART_OK means the same quiesced state as ble_uart_close() — then it is safe to uninstall() from your app task.
  • On failure (BLE_UART_EFAIL, etc.) the port may still be open; retry ble_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_async returns BLE_UART_OK once the worker has been spawned.
  • BLE_UART_EVT_DISCONNECTED (if connected) then BLE_UART_EVT_CLOSED with .closed.status — same ≤500 ms disconnect window as sync close.
  • Second call while draining → BLE_UART_EALREADY; before openBLE_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_cap outside the six values listed above
  • Resolved mitm == ON together with resolved io_cap == NO_INPUT_OUTPUT — Just Works cannot satisfy MITM and the SM would otherwise reject pairing in flight
  • cfg.on_event == NULL together with a configured (not resolved) input-capable io_cap — only KEYBOARD_ONLY, DISPLAY_YES_NO, and KEYBOARD_DISPLAY. Without an event sink the application would never see PASSKEY_REQUEST / NUMERIC_COMPARE and pairing would silently stall until the SM times out. AUTO (even when it resolves to DisplayOnly because mitm=ON), DISPLAY_ONLY, and NO_INPUT_OUTPUT do not require on_event — Passkey Display is satisfied inside the port (UART log + internal SM reply); PASSKEY_DISPLAY via on_event is 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; only PASSKEY_DISPLAY is suppressed when the callback is NULL. The UART banner (show_passkey) is always emitted for log-scraping tests.
  • Numeric Comparison edge case: if io_cap resolved to DisplayOnly but the central still negotiates NC (rare), Bluedroid rejects the request when on_event == NULL; NimBLE may stall until the SM times out — use DISPLAY_YES_NO / KEYBOARD_DISPLAY with a registered on_event if you need NC.
  • NimBLE lets the application observe the negotiated key_size on BLE_UART_EVT_LINK_SECURE; Bluedroid surfaces a fixed 16 (the value forced via ESP_BLE_SM_MAX_KEY_SIZE at install time — Bluedroid does not expose the negotiated size on AUTH_CMPL).
  • With mitm=OFF, NimBLE pairs with _AUTHEN permissions still off on the chars; Bluedroid uses ESP_GATT_PERM_*_ENCRYPTED (the encryption-without-MITM tier) to match.
  • cfg.encrypted=false plus any cfg.security.*=ON override is allowed — it partially enables the SM, e.g. cfg.encrypted=false, cfg.security.sc=ON is "SC pairing without MITM and without persisted bond". Useful only for lab interop tests; production firmware should keep cfg.encrypted = true and 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->type is ignored by remove_peer. If the peer first connected as address_A and bonding succeeded, CONNECT and get_bonded_peers() keep reporting address_A on 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_CONNECTED reports 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 and remove_peer works either way.
  • Neither backend reports "peer not found" from remove_peer — a wrong (type, bytes) pair that does not match any stored bond still returns BLE_UART_OK. This mirrors the underlying stacks' delete-if-present semantics, not a lookup-then-delete API.
  • ble_uart_clear_bonds on Bluedroid iterates the bond list and removes each entry; on NimBLE it calls ble_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 to BLE_STORE_MAX_BONDS (not the caller's stack), so they are safe from small-stack tasks regardless of CONFIG_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_name and adv_data are 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) in adv_data yourself — providing custom adv_data disables 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_data with scan_rsp_data = NULL keeps 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 | _AUTHEN flags.
  • 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 calling ble_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 — override ble_hs_cfg.sync_cb with your own implementation after ble_uart_install, then call ble_uart_open(). Note that ble_uart's internal start_advertising will not run, so you must call ble_gap_adv_start yourself. In that case, just fork ble_uart_nimble.c (or the matching ble_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_ENOTCONNjust 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.