1475 lines
64 KiB
Markdown
1475 lines
64 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
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`
|
||
|
||
```cmake
|
||
# 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):**
|
||
|
||
```ini
|
||
# 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:
|
||
|
||
```bash
|
||
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**):
|
||
|
||
```ini
|
||
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:
|
||
|
||
```c
|
||
#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
|
||
|
||
```bash
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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):
|
||
|
||
```text
|
||
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.
|
||
|
||
```c
|
||
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 */
|
||
}
|
||
```
|
||
|
||
```text
|
||
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.
|
||
|
||
```c
|
||
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);
|
||
}
|
||
```
|
||
|
||
```text
|
||
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 `open` →
|
||
`BLE_UART_EALREADY`; spawn failure → `BLE_UART_ENOMEM` (latch reset).
|
||
|
||
### 5.4 TX interface
|
||
|
||
```c
|
||
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`:
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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:
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
/* (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:
|
||
|
||
```c
|
||
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.
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
/* 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
|
||
|
||
```c
|
||
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).
|
||
|
||
```c
|
||
/* +-- 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](https://www.bluetooth.com/specifications/assigned-numbers/)).
|
||
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**
|
||
|
||
```c
|
||
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)**
|
||
|
||
```c
|
||
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**
|
||
|
||
```c
|
||
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**
|
||
|
||
```c
|
||
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)
|
||
|
||
```c
|
||
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.
|
||
|
||
```c
|
||
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:
|
||
|
||
```c
|
||
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:
|
||
|
||
```c
|
||
/* 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)
|
||
|
||
```c
|
||
#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
|
||
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/`):
|
||
```cmake
|
||
idf_component_register(SRCS "main.c"
|
||
INCLUDE_DIRS "."
|
||
REQUIRES ble_uart nvs_flash)
|
||
```
|
||
|
||
**`main/CMakeLists.txt`** (classic copy layout — both backends in `main/`):
|
||
```cmake
|
||
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):
|
||
```ini
|
||
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:
|
||
|
||
```bash
|
||
idf.py set-target esp32s3
|
||
idf.py build flash monitor
|
||
```
|
||
|
||
Done.
|