Merge branch 'feat/support_ble_uart_more_interfaces_v5.3' into 'release/v5.3'

feat(bt/ble_uart): Support ble uart more interfaces (5.3)

See merge request espressif/esp-idf!50044
This commit is contained in:
Island
2026-06-29 10:31:47 +08:00
10 changed files with 3381 additions and 234 deletions
+8
View File
@@ -15,6 +15,14 @@ examples/bluetooth:
disable: disable:
- if: SOC_BT_SUPPORTED != 1 - if: SOC_BT_SUPPORTED != 1
examples/bluetooth/ble_uart_service:
<<: *bt_default_depends
disable:
- if: SOC_BLE_SUPPORTED != 1
depends_filepatterns:
- examples/bluetooth/common/ble_uart/**/*
- examples/bluetooth/ble_uart_service/**/*
examples/bluetooth/bluedroid/ble: examples/bluetooth/bluedroid/ble:
<<: *bt_default_depends <<: *bt_default_depends
disable: disable:
+157 -23
View File
@@ -24,12 +24,19 @@ ble_uart_install(&cfg); // NimBLE host + BLE UART GATT service
ble_uart_open(); // start advertising + auto-encrypt ble_uart_open(); // start advertising + auto-encrypt
``` ```
…and two matching tear-down calls if your app ever needs to power If your app powers BLE off at runtime, use **one** of the release paths
BLE off at runtime: in [PORTING.md §5.3](../common/ble_uart/PORTING.md#53-lifecycle--bring-up-and-release)
(this example uses Path A from `app_main`):
| Path | When | Calls |
| --- | --- | --- |
| **A — sync** (default) | Shutdown from a normal task (button, Wi-Fi, `app_main`) | `ble_uart_close()` → `ble_uart_uninstall()` |
| **B — async** | Shutdown triggered inside `on_event` / `on_rx` | `close_async()` in callback → `CLOSED` sets flag → **`uninstall()` on a separate app task** (not inside `CLOSED`) |
```c ```c
ble_uart_close(); // stop advertising / disconnect / halt host /* Path A — this example style */
ble_uart_uninstall(); // free the NimBLE port + reset state ble_uart_close();
ble_uart_uninstall();
``` ```
When a central connects, the firmware automatically initiates LE Secure When a central connects, the firmware automatically initiates LE Secure
@@ -47,49 +54,135 @@ back with `ble_uart_tx()`.
| TX (out) | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | Notify (auto-CCCD) | encrypted, authenticated | | TX (out) | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | Notify (auto-CCCD) | encrypted, authenticated |
The `_ENC | _AUTHEN` flags are turned on only when `cfg.encrypted = true` The `_ENC | _AUTHEN` flags are turned on only when `cfg.encrypted = true`
(the default in this example). (the default in this example). The two flags can be controlled
independently via `cfg.security.mitm` (drops `_AUTHEN`) and the
combined `cfg.security.{sc,bonding,mitm}` set (all OFF drops `_ENC`
too) — see PORTING.md §5.6.
## Files ## Files
| File | Lines | Role | | File | Lines | Role |
| --- | ---: | --- | | --- | ---: | --- |
| `main/main.c` | ~70 | NVS init, MAC-derived device name, install + open, RX echo handler. Identical for both backends. | | `main/main.c` | ~200 | NVS init, install + open with `<prefix>-XXXX` device name (Kconfig prefix + BT MAC suffix), RX echo handler, lifecycle/link-state event sink, bonded-peer dump on boot. Identical for both backends. |
| `main/Kconfig.projbuild` | ~50 | Example-local `EXAMPLE_CUSTOM_ADV_DATA` switch — toggles the `ble_uart_config_t::adv_data` demo path in `main.c`. |
| `CMakeLists.txt` (root) | ~15 | `list(APPEND EXTRA_COMPONENT_DIRS .../common/ble_uart)` before `project()` so `main` can `REQUIRES ble_uart`. | | `CMakeLists.txt` (root) | ~15 | `list(APPEND EXTRA_COMPONENT_DIRS .../common/ble_uart)` before `project()` so `main` can `REQUIRES ble_uart`. |
| `../common/ble_uart/ble_uart.h` | ~155 | Stack-agnostic public API: 3-field config + 4 lifecycle functions + TX/status + UUID + `BLE_UART_E*` return codes. No NimBLE / Bluedroid types leak through. | | `../common/ble_uart/ble_uart.h` | ~640 | Stack-agnostic public API: configuration struct (preset + per-feature security overrides + custom adv payload + RX/event callbacks) + lifecycle (install/open/close/close_async/uninstall) + TX + pairing replies + bond-management + status + UUID + `BLE_UART_E*` return codes. No NimBLE / Bluedroid types leak through. |
| `../common/ble_uart/ble_uart_nimble.c` | ~650 | NimBLE backend: host bring-up, BLE UART GATT service via `ble_gatts_add_svcs`, advertising, pairing, install/open/close/uninstall. Active when `CONFIG_BT_NIMBLE_ENABLED=y`. | | `../common/ble_uart/ble_uart_nimble.c` | ~1290 | NimBLE backend: host bring-up, BLE UART GATT service via `ble_gatts_add_svcs`, advertising (default + raw), pairing (incl. Passkey Entry / Numeric Comparison), bond store, async close, install/open/close/uninstall. Active when `CONFIG_BT_NIMBLE_ENABLED=y`. |
| `../common/ble_uart/ble_uart_bluedroid.c` | ~1020 | Bluedroid backend: controller + host enable, BLE UART GATT service via `esp_ble_gatts_create_attr_tab` (service-table API), advertising, pairing, full PREP/EXEC long-write reassembly, install/open/close/uninstall. Active when `CONFIG_BT_BLUEDROID_ENABLED=y`. | | `../common/ble_uart/ble_uart_bluedroid.c` | ~1660 | Bluedroid backend: controller + host enable, BLE UART GATT service via `esp_ble_gatts_create_attr_tab` (service-table API), advertising (default + raw), pairing (incl. Passkey Entry / Numeric Comparison), bond store, async close, full PREP/EXEC long-write reassembly, install/open/close/uninstall. Active when `CONFIG_BT_BLUEDROID_ENABLED=y`. |
| `../common/ble_uart/Kconfig` | ~30 | Device-name prefix + RX scratch size (`menuconfig → Component configuration → ESP-BLE-UART library`). | | `../common/ble_uart/Kconfig` | ~30 | Device name prefix + RX scratch size (`menuconfig → Component configuration → ESP-BLE-UART library`). |
| `../common/ble_uart/PORTING.md` | ~724 | Porting and API guide (integration, CMake, sdkconfig, thread safety). | | `../common/ble_uart/PORTING.md` | ~1300 | Porting and API guide (integration, CMake, sdkconfig, security model, custom advertising, bond management, thread safety). |
| `sdkconfig.defaults` | — | Default: NimBLE backend, MTU 512, SC + bonding + persistent NVS. | | `sdkconfig.defaults` | — | Default: NimBLE backend, MTU 512, SC + bonding + persistent NVS. |
| `sdkconfig.bluedroid` | — | Overlay: switch to Bluedroid backend (used via `-D SDKCONFIG_DEFAULTS=...`, see "Choosing the host stack" below). | | `sdkconfig.bluedroid` | — | Overlay: switch to Bluedroid backend (used via `-D SDKCONFIG_DEFAULTS=...`, see "Choosing the host stack" below). |
## Public API ## Public API
```c ```c
typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len); 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 { typedef struct {
bool encrypted; /* SC + Bonding + MITM in one knob */ ble_uart_sec_t sc; /* AUTO / OFF / ON */
const char *device_name; ble_uart_sec_t bonding;
ble_uart_rx_cb_t ble_uart_on_rx; ble_uart_sec_t mitm;
ble_uart_io_cap_t io_cap; /* AUTO / NO_INPUT_OUTPUT / DISPLAY_ONLY /
KEYBOARD_ONLY / DISPLAY_YES_NO /
KEYBOARD_DISPLAY */
} ble_uart_security_t;
typedef struct {
bool encrypted; /* preset: SC + Bonding + MITM + DisplayOnly */
ble_uart_security_t security; /* per-feature overrides; see PORTING.md §5.6 */
const char *device_name; /* ≤ BLE_UART_DEVICE_NAME_MAX (26) */
/* Optional: raw advertising / scan-response bytes (NULL → defaults).
* Limits: adv_data_len ≤ BLE_UART_ADV_DATA_MAX (28),
* scan_rsp_data_len ≤ BLE_UART_SCAN_RSP_DATA_MAX (31).
* The 3-byte Flags AD element is prepended automatically — don't
* include it in adv_data. See PORTING.md §5.9 for examples. */
const uint8_t *adv_data;
size_t adv_data_len;
const uint8_t *scan_rsp_data;
size_t scan_rsp_data_len;
ble_uart_rx_cb_t ble_uart_on_rx;
ble_uart_evt_cb_t on_event; /* lifecycle / link-state events; NULL drops */
} ble_uart_config_t; } ble_uart_config_t;
typedef struct {
uint8_t bytes[6]; /* big-endian: bytes[0] is the MSB (AA:BB:CC:DD:EE:FF) */
uint8_t type; /* BLE_UART_ADDR_TYPE_PUBLIC | _RANDOM */
} ble_uart_addr_t;
/* Lifecycle */ /* Lifecycle */
int ble_uart_install(const ble_uart_config_t *cfg); /* NimBLE host + GATT */ int ble_uart_install(const ble_uart_config_t *cfg); /* host + GATT */
int ble_uart_open(void); /* host task + advertising */ int ble_uart_open(void); /* host task + advertising */
int ble_uart_close(void); /* stop adv / disconnect / halt host */ int ble_uart_close(void); /* stop adv / disconnect / halt host */
int ble_uart_uninstall(void); /* free NimBLE port + reset state */ int ble_uart_close_async(void); /* same, fire-and-forget; safe from inside on_event/on_rx */
int ble_uart_uninstall(void); /* free port + reset state */
/* Data path */ /* Data path */
int ble_uart_tx(const uint8_t *data, size_t len); int ble_uart_tx(const uint8_t *data, size_t len);
/* Pairing replies (call from on_event for input-capable IO caps) */
int ble_uart_passkey_reply(uint32_t passkey); /* answer PASSKEY_REQUEST */
int ble_uart_compare_reply(bool match); /* answer NUMERIC_COMPARE */
/* Status (best-effort snapshot) */ /* Status (best-effort snapshot) */
bool ble_uart_is_connected(void); bool ble_uart_is_connected(void);
bool ble_uart_is_subscribed(void); bool ble_uart_is_subscribed(void);
/* Bond management (works after install()) */
int ble_uart_get_bond_count(size_t *out_count);
int ble_uart_get_bonded_peers(ble_uart_addr_t *out, size_t cap, size_t *out_count);
int ble_uart_remove_peer(const ble_uart_addr_t *peer);
int ble_uart_clear_bonds(void);
extern const ble_uart_uuid128_t ble_uart_service_uuid; extern const ble_uart_uuid128_t ble_uart_service_uuid;
``` ```
### Event callback
`on_event` is invoked on the BLE host task (same context as `ble_uart_on_rx`)
with a tagged `ble_uart_evt_t`. Use `LINK_SECURE` — not `is_connected()` —
to gate any application logic that requires the channel to be encrypted /
authenticated:
| `evt->id` | Payload | Fires when |
| ------------------------------- | ------------------------------------------------------- | ---------- |
| `BLE_UART_EVT_CONNECTED` | `connected.peer` | Physical link up |
| `BLE_UART_EVT_DISCONNECTED` | `disconnected.reason` (int, stack-specific) | Physical link down — Bluedroid: `esp_gatt_conn_reason_t`; NimBLE: BLE host return code (`BLE_HS_HCI_ERR()` for HCI) |
| `BLE_UART_EVT_SUBSCRIBED` | `subscribed.subscribed` | Central writes CCCD on TX (edge-triggered) |
| `BLE_UART_EVT_LINK_SECURE` | `link_secure.{encrypted,authenticated,bonded,key_size}` | Pairing or bonded reconnect succeeds |
| `BLE_UART_EVT_PASSKEY_DISPLAY` | `passkey.passkey` (0..999999) | SM asks the device to show a passkey |
| `BLE_UART_EVT_PASSKEY_REQUEST` | — | SM asks the user to enter a passkey shown by the central — answer with `ble_uart_passkey_reply()` |
| `BLE_UART_EVT_NUMERIC_COMPARE` | `numeric_compare.passkey` (0..999999) | SM asks the user to confirm both sides display the same value — answer with `ble_uart_compare_reply()` |
| `BLE_UART_EVT_PAIRING_FAILED` | `pairing_failed.reason` (stack-specific) | Pairing rejected or timed out |
| `BLE_UART_EVT_CLOSED` | `closed.status` (`BLE_UART_*`) | `ble_uart_close_async()` worker has finished; `BLE_UART_OK` means tear-down succeeded |
The default passkey UART banner still prints; the callback is additive so
log-scraping tests stay compatible. Don't block in the callback.
**Callback rules:**
- Do **not** call `ble_uart_close()` or `ble_uart_uninstall()` from
`on_event` / `on_rx` (host task — deadlocks).
- To start teardown from a callback, call `ble_uart_close_async()` only.
- Call `ble_uart_uninstall()` from a **normal app task** after
`BLE_UART_EVT_CLOSED` with `closed.status == BLE_UART_OK` (see
[PORTING.md §5.3.2](../common/ble_uart/PORTING.md#532-path-b--release-after-a-ble-event-close_async)).
Path B sketch (full code in PORTING.md):
```c
case BLE_UART_EVT_PAIRING_FAILED:
ble_uart_close_async();
break;
case BLE_UART_EVT_CLOSED:
if (e->closed.status == BLE_UART_OK) {
s_ble_closed_ok = true; /* app task calls uninstall */
}
break;
```
## Choosing the host stack ## Choosing the host stack
The same `ble_uart.h` API is implemented twice — once on top of NimBLE The same `ble_uart.h` API is implemented twice — once on top of NimBLE
@@ -134,8 +227,10 @@ When neither is enabled the build fails up-front with a clear error.
idf.py set-target esp32c3 # or esp32, esp32s3, esp32c6, esp32h2 ... idf.py set-target esp32c3 # or esp32, esp32s3, esp32c6, esp32h2 ...
idf.py menuconfig # optional idf.py menuconfig # optional
# Component configuration -> ESP-BLE-UART library # Component configuration -> ESP-BLE-UART library
# - BLE device name prefix (default: BleUart) # - BLE device name prefix (default: BleUart; example appends -XXXX from BT MAC)
# - RX scratch buffer size (default: 1024 bytes) # - RX scratch buffer size (default: 1024 bytes)
# BLE UART service example
# - Use custom advertising data (default: off)
``` ```
Those `BLE_UART_*` options are defined in **`../common/ble_uart/Kconfig`** Those `BLE_UART_*` options are defined in **`../common/ble_uart/Kconfig`**
@@ -143,6 +238,27 @@ Those `BLE_UART_*` options are defined in **`../common/ble_uart/Kconfig`**
build (this example pulls it in via `EXTRA_COMPONENT_DIRS` in the root build (this example pulls it in via `EXTRA_COMPONENT_DIRS` in the root
`CMakeLists.txt`). `CMakeLists.txt`).
`EXAMPLE_CUSTOM_ADV_DATA` is example-local (`main/Kconfig.projbuild`)
and demonstrates `ble_uart_config_t::adv_data` — the field that lets
the application fully control the over-the-air advertising payload
instead of using the library default.
When the option is on, `app_main` hands a static byte array
(`example_adv_payload[]`, top of `main.c`) to `ble_uart_install()`.
The array is just a sequence of `[length][AD type][value]` triplets;
edit it directly to advertise whatever you want — a different Local
Name, Manufacturer Specific Data, custom Service Data, additional
Service UUIDs, etc. The only hard rule is total length ≤
`BLE_UART_ADV_DATA_MAX` (28); the 3-byte Flags AD is added by the
library and does not count against that budget.
The GAP-service Device Name (set via `device_name` in the same
config struct) is independent and is what connected centrals read
post-pair, regardless of `adv_data`.
With the option off the library default is used (Complete Local Name
in the primary packet, 128-bit Service UUID in the scan response).
The two security knobs are set in `sdkconfig.defaults`: The two security knobs are set in `sdkconfig.defaults`:
```ini ```ini
@@ -154,6 +270,17 @@ Disable `cfg.encrypted` in `main.c` (set it to `false`) for plaintext
operation in the lab — the GATT characteristics drop their `_ENC` operation in the lab — the GATT characteristics drop their `_ENC`
flags accordingly. Production firmware should keep encryption on. flags accordingly. Production firmware should keep encryption on.
For finer control without going all-or-nothing — e.g. a displayless
gateway that wants encryption + bonding but no passkey UI, or a
device with a keypad that wants Passkey Entry / Numeric Comparison —
keep `cfg.encrypted = true` and override individual bits via
`cfg.security.{sc,bonding,mitm,io_cap}`. The input-capable IO caps
(`KEYBOARD_ONLY`, `DISPLAY_YES_NO`, `KEYBOARD_DISPLAY`) require an
`on_event` handler that wires `BLE_UART_EVT_PASSKEY_REQUEST` /
`NUMERIC_COMPARE` to `ble_uart_passkey_reply()` /
`ble_uart_compare_reply()`. See PORTING.md §5.6 for the full matrix
and worked examples.
### Build & flash ### Build & flash
```bash ```bash
@@ -170,7 +297,7 @@ 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: registered chr 6e400003-... def=17 val=18
I (xxx) ble_uart: addr=80:7d:3a:11:22:33 I (xxx) ble_uart: addr=80:7d:3a:11:22:33
I (xxx) ble_uart: BLE host task started I (xxx) ble_uart: BLE host task started
I (xxx) ble_uart: advertising as 'BleUart-XXXX' I (xxx) ble_uart: advertising as 'BleUart-2233'
``` ```
Expected boot log (Bluedroid backend): Expected boot log (Bluedroid backend):
@@ -186,8 +313,10 @@ I (xxx) ble_uart: advertising started
1. On a phone, install **a BLE GATT client app** that supports scanning, 1. On a phone, install **a BLE GATT client app** that supports scanning,
pairing, characteristic write, and notify/CCCD (many mobile “BLE tools” pairing, characteristic write, and notify/CCCD (many mobile “BLE tools”
or serial-over-BLE utilities qualify). or serial-over-BLE utilities qualify).
2. Scan, tap **Connect** on `BleUart-XXXX`. The phone prompts for a 2. Scan, tap **Connect** on `BleUart-XXXX` (prefix from
6-digit code. `CONFIG_BLE_UART_DEVICE_NAME_PREFIX`, `XXXX` = last two BT MAC
bytes). The phone prompts for a 6-digit
code.
3. The device prints a fresh code in a banner on UART: 3. The device prints a fresh code in a banner on UART:
``` ```
@@ -204,8 +333,13 @@ I (xxx) ble_uart: advertising started
6. Disconnect and reconnect: no passkey prompt — the bond resumes 6. Disconnect and reconnect: no passkey prompt — the bond resumes
automatically. automatically.
To wipe the bond and force a fresh passkey, run `idf.py erase-flash` To wipe the bond and force a fresh passkey there are three options:
and re-flash.
- Call `ble_uart_clear_bonds()` from your app (preserves the rest of NVS)
- Call `ble_uart_remove_peer(&addr)` to drop one peer (use the address
reported in `BLE_UART_EVT_CONNECTED`, or any address you happen to
have stored — Bluedroid matches by address only, NimBLE by identity)
- Run `idf.py erase-flash` and re-flash (also wipes WiFi creds, NVS, etc.)
## Adapting to your application ## Adapting to your application
@@ -0,0 +1,47 @@
menu "BLE UART service example"
config EXAMPLE_CUSTOM_ADV_DATA
bool "Use custom advertising data"
default n
help
Demonstrates `ble_uart_config_t::adv_data` — the field that
lets the application fully control the advertising payload
instead of relying on the library default.
When enabled, the example passes a static byte array
(`example_adv_payload[]` defined at the top of `main.c`) to
`ble_uart_install()`. Edit that array to broadcast anything
you want: a different Local Name, Manufacturer Specific
Data, custom Service Data, multiple Service UUIDs, etc.
Format
The array is a sequence of standard Bluetooth Core "AD
structure" triplets:
[length(1)] [AD type(1)] [value(length-1)]
See the Bluetooth Assigned Numbers (Generic Access
Profile) document for the full type list.
Length budget
Total bytes in the array must be
≤ BLE_UART_ADV_DATA_MAX (28). The 3-byte mandatory
Flags AD element is prepended automatically by
ble_uart and does NOT count against this budget. An
oversized buffer makes `ble_uart_install()` fail with
BLE_UART_EINVAL.
Scope
Only affects the over-the-air advertising payload.
The GAP-service Device Name (UUID 0x2A00, set via
`device_name` in the same struct) is independent and
stays whatever the application configured — connected
centrals read that name regardless of what is in
`adv_data`.
Default value
Off. The library default is used (Complete Local Name
in the primary packet, 128-bit Service UUID in the
scan response).
endmenu
+150 -1
View File
@@ -8,6 +8,7 @@
* writes to the RX characteristic is echoed back over TX. * writes to the RX characteristic is echoed back over TX.
*/ */
#include <inttypes.h>
#include <stdio.h> #include <stdio.h>
#include "esp_log.h" #include "esp_log.h"
@@ -17,6 +18,41 @@
#include "ble_uart.h" #include "ble_uart.h"
#if CONFIG_EXAMPLE_CUSTOM_ADV_DATA
/* Sample advertising payload demonstrating ble_uart_config_t::adv_data.
* Replace these bytes with whatever your product needs (a different
* Local Name, Manufacturer Specific Data, custom Service Data,
* additional Service UUIDs, ...) — ble_uart broadcasts them verbatim.
*
* Format: a sequence of standard BT Core "AD structure" triplets,
* [length(1)] [AD type(1)] [value(length-1)].
*
* Length budget: total ≤ BLE_UART_ADV_DATA_MAX (28). The mandatory
* 3-byte Flags AD is prepended by ble_uart and does NOT count against
* this budget; oversize fails ble_uart_install() with EINVAL.
*
* The current contents (purely illustrative — edit freely):
*
* Layout bytes
* -------------------------------------- -----
* Complete Local Name AD "BleUart" 1 + 1 + 7 = 9
* Complete 128-bit UUID AD 1 + 1 + 16 = 18
* -------------------------------------- -----
* total 27 (≤ 28)
*/
static const uint8_t example_adv_payload[] = {
/* AD type 0x09: Complete Local Name */
0x08, 0x09, 'B', 'l', 'e', 'U', 'a', 'r', 't',
/* AD type 0x07: Complete List of 128-bit Service UUIDs.
* UUID bytes are in over-the-air (little-endian) order, matching
* ble_uart_service_uuid.bytes[]. */
0x11, 0x07,
0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0,
0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e,
};
#endif
static const char *TAG = "app"; static const char *TAG = "app";
static void ble_uart_on_rx(const uint8_t *data, size_t len) static void ble_uart_on_rx(const uint8_t *data, size_t len)
@@ -29,6 +65,83 @@ static void ble_uart_on_rx(const uint8_t *data, size_t len)
ble_uart_tx(data, len); /* echo back */ ble_uart_tx(data, len); /* echo back */
} }
/* Lifecycle / link-state event sink. Runs on the BLE host task —
* keep it short, never call ble_uart_close()/uninstall() from here.
*
* For production code: gate any sensitive TX on
* BLE_UART_EVT_LINK_SECURE (encrypted+authenticated) instead of just
* "connected"; ble_uart_is_connected() returns true while the link is
* still plaintext during the pairing window. */
static void ble_uart_on_event(const ble_uart_evt_t *e)
{
switch (e->id) {
case BLE_UART_EVT_CONNECTED: {
const uint8_t *b = e->connected.peer.bytes;
ESP_LOGI(TAG,
"evt: connected peer=%02x:%02x:%02x:%02x:%02x:%02x type=%u",
b[0], b[1], b[2], b[3], b[4], b[5], e->connected.peer.type);
break;
}
case BLE_UART_EVT_DISCONNECTED:
ESP_LOGI(TAG, "evt: disconnected reason=0x%x",
e->disconnected.reason);
break;
case BLE_UART_EVT_SUBSCRIBED:
ESP_LOGI(TAG, "evt: %ssubscribed",
e->subscribed.subscribed ? "" : "un");
break;
case BLE_UART_EVT_LINK_SECURE:
ESP_LOGI(TAG, "evt: link_secure enc=%d auth=%d bond=%d ks=%u",
e->link_secure.encrypted, e->link_secure.authenticated,
e->link_secure.bonded, e->link_secure.key_size);
break;
case BLE_UART_EVT_PASSKEY_DISPLAY:
ESP_LOGI(TAG, "evt: passkey=%06" PRIu32, e->passkey.passkey);
break;
case BLE_UART_EVT_PASSKEY_REQUEST:
/* Fires only when cfg.security.io_cap is KEYBOARD_ONLY or
* KEYBOARD_DISPLAY (this example leaves io_cap at AUTO →
* DisplayOnly, so it should not fire). For a real keypad
* product, prompt the user for the 6 digits the central
* displayed and feed them in:
*
* ble_uart_passkey_reply(digits);
*
* See PORTING.md §5.6.1 for the full pattern. */
ESP_LOGW(TAG, "evt: passkey entry requested — no UI wired in this "
"example (see PORTING.md §5.6.1)");
break;
case BLE_UART_EVT_NUMERIC_COMPARE:
/* Fires only when cfg.security.io_cap is DISPLAY_YES_NO or
* KEYBOARD_DISPLAY (likewise dormant in this example). For a
* product with a yes/no control, surface the digits to the
* user and resolve the comparison:
*
* ble_uart_compare_reply(user_says_match);
*
* See PORTING.md §5.6.1. */
ESP_LOGW(TAG, "evt: numeric compare %06" PRIu32
" — no yes/no UI wired (see PORTING.md §5.6.1)",
e->numeric_compare.passkey);
break;
case BLE_UART_EVT_PAIRING_FAILED:
ESP_LOGW(TAG, "evt: pairing failed reason=0x%x",
e->pairing_failed.reason);
break;
case BLE_UART_EVT_CLOSED:
/* Only after ble_uart_close_async(). This example does not use
* close_async; do not ble_uart_uninstall() here — defer to an
* app task (PORTING.md §5.3.2). Kept for -Wswitch. */
if (e->closed.status == BLE_UART_OK) {
ESP_LOGI(TAG, "evt: closed (async-close succeeded)");
} else {
ESP_LOGW(TAG, "evt: closed async-close failed status=%d",
e->closed.status);
}
break;
}
}
void app_main(void) void app_main(void)
{ {
/* NVS is required by the BT controller (PHY calibration) and the /* NVS is required by the BT controller (PHY calibration) and the
@@ -49,15 +162,51 @@ void app_main(void)
ESP_LOGW(TAG, "esp_read_mac(BT) failed (%s); device name suffix will be 0000", ESP_LOGW(TAG, "esp_read_mac(BT) failed (%s); device name suffix will be 0000",
esp_err_to_name(mac_err)); esp_err_to_name(mac_err));
} }
char name[24]; char name[BLE_UART_DEVICE_NAME_MAX + 1];
snprintf(name, sizeof(name), "%s-%02X%02X", snprintf(name, sizeof(name), "%s-%02X%02X",
CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]); CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]);
ESP_ERROR_CHECK(ble_uart_install(&(ble_uart_config_t){ ESP_ERROR_CHECK(ble_uart_install(&(ble_uart_config_t){
.encrypted = true, .encrypted = true,
.device_name = name, .device_name = name,
#if CONFIG_EXAMPLE_CUSTOM_ADV_DATA
/* Hand the application-defined bytes to ble_uart. Whatever
* the array contains is broadcast verbatim; what `device_name`
* (above) holds is exposed via the GAP service for connected
* centrals to read — independent paths. */
.adv_data = example_adv_payload,
.adv_data_len = sizeof(example_adv_payload),
/* scan_rsp_data is left at its default (NULL) → ble_uart still
* sends its built-in scan response. Override it the same way
* if you want to control those bytes too. */
#endif
.ble_uart_on_rx = ble_uart_on_rx, .ble_uart_on_rx = ble_uart_on_rx,
.on_event = ble_uart_on_event,
})); }));
/* Demonstrate the bond-management API: list every bonded peer
* already on flash. Replace the log with `ble_uart_clear_bonds()`
* to wipe them at boot (e.g. when a "factory reset" GPIO is held);
* use `ble_uart_remove_peer(&list[i])` to target one specifically. */
size_t total = 0;
ble_uart_addr_t list[8];
int rc = ble_uart_get_bonded_peers(list, sizeof(list) / sizeof(list[0]),
&total);
if (rc == 0) {
ESP_LOGI(TAG, "%u peer(s) currently bonded", (unsigned)total);
size_t shown = total < sizeof(list) / sizeof(list[0])
? total : sizeof(list) / sizeof(list[0]);
for (size_t i = 0; i < shown; i++) {
const uint8_t *b = list[i].bytes;
ESP_LOGI(TAG, " [%u] %02x:%02x:%02x:%02x:%02x:%02x type=%u",
(unsigned)i,
b[0], b[1], b[2], b[3], b[4], b[5], list[i].type);
}
if (total > shown) {
ESP_LOGI(TAG, " (%u more not shown)",
(unsigned)(total - shown));
}
}
ESP_ERROR_CHECK(ble_uart_open()); ESP_ERROR_CHECK(ble_uart_open());
} }
@@ -0,0 +1,24 @@
# CI build overlay: Bluedroid host (sdkconfig.defaults selects NimBLE).
# Mirrors sdkconfig.bluedroid; kept in sync for idf-build-apps CONFIG_NAME=bluedroid.
CONFIG_BT_NIMBLE_ENABLED=n
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=n
CONFIG_BT_BLUEDROID_ENABLED=y
CONFIG_BT_BLE_SMP_ENABLE=y
CONFIG_BT_GATTS_ENABLE=y
# CONFIG_BT_GATTC_ENABLE is not set
# CONFIG_BT_BLE_50_FEATURES_SUPPORTED is not set
CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y
# CONFIG_BT_BLE_42_DTM_TEST_EN is not set
CONFIG_BT_BLE_42_ADV_EN=y
# CONFIG_BT_BLE_42_SCAN_EN is not set
@@ -0,0 +1,7 @@
# CI build overlay: NimBLE host (sdkconfig.defaults is NimBLE-first).
# Explicit config so idf-build-apps builds both nimble and bluedroid in CI.
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_SM_SC=y
CONFIG_BT_NIMBLE_NVS_PERSIST=y
+803 -55
View File
@@ -8,9 +8,10 @@ This document lives in **`examples/bluetooth/common/ble_uart/`** next to the
**Reference application:** use the **`examples/bluetooth/ble_uart_service`** **Reference application:** use the **`examples/bluetooth/ble_uart_service`**
example as the working template. Its root `CMakeLists.txt` appends this example as the working template. Its root `CMakeLists.txt` appends this
directory to **`EXTRA_COMPONENT_DIRS`** so `main` can `REQUIRES ble_uart`; directory to **`EXTRA_COMPONENT_DIRS`** so `main` can `REQUIRES ble_uart`;
`main/main.c` initializes NVS and a MAC-derived GAP name, calls `main/main.c` initializes NVS, calls `ble_uart_install()` /
`ble_uart_install()` / `ble_uart_open()` with the default encrypted UART-over-BLE echo `ble_uart_open()` with the Kconfig-supplied GAP name and the default
path, and the tree ships `sdkconfig.defaults` plus the Bluedroid overlay 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 (`sdkconfig.bluedroid`). Clone or diff that project when adapting to a new
target or host stack. target or host stack.
@@ -61,7 +62,7 @@ is entirely up to you**.
Canonical sources live under **`$IDF_PATH/examples/bluetooth/common/ble_uart/`** Canonical sources live under **`$IDF_PATH/examples/bluetooth/common/ble_uart/`**
(component name `ble_uart`): `ble_uart.h`, `ble_uart_nimble.c`, (component name `ble_uart`): `ble_uart.h`, `ble_uart_nimble.c`,
`ble_uart_bluedroid.c`, `CMakeLists.txt`, and `Kconfig` (prefix + RX scratch; `ble_uart_bluedroid.c`, `CMakeLists.txt`, and `Kconfig` (device name + RX scratch;
`menuconfig → Component configuration → ESP-BLE-UART library`). When reusing `menuconfig → Component configuration → ESP-BLE-UART library`). When reusing
outside this tree, copy the whole `common/ble_uart/` directory or at least 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 merge `Kconfig` into your component so the same `CONFIG_BLE_UART_*` symbols
@@ -264,17 +265,32 @@ back.
```c ```c
typedef struct { typedef struct {
bool encrypted; /* Master switch for SC + Bonding + MITM */ bool encrypted; /* Preset shortcut for SC + Bonding + MITM */
const char *device_name; /* GAP device name; NULL uses the NimBLE default */ ble_uart_security_t security; /* Per-feature overrides — see §5.6 */
ble_uart_rx_cb_t ble_uart_on_rx;/* RX byte callback */
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; } ble_uart_config_t;
``` ```
| Field | Type | Required | Default / meaning | | Field | Type | Required | Default / meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `encrypted` | `bool` | yes | `true` = SC + Bonding + MITM + DisplayOnly + encrypted GATT chars; `false` = fully plaintext (sniffable, lab use only) | | `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. |
| `device_name` | `const char *` | recommended | Any string. Mind the 31-byte primary advertising packet limit: flags(3) + tx_pwr(3) + name(2 + length) + 128-bit UUID(18) → keep the name ≤ 8 bytes | | `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 | | `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 ### 5.2 RX callback signature
@@ -290,50 +306,322 @@ static void my_handler(const uint8_t *data, size_t len)
**Caveats**: **Caveats**:
- The callback runs in the **NimBLE host task** context — **do not - The callback runs on the BLE host task (NimBLE host task /
block**; offload heavy work to your own 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 - A single callback may carry only **part** of an upper-layer frame
(the central slices on ATT MTU). Framing logic (line / TLV / (the central slices on ATT MTU). Framing logic (line / TLV /
length-prefixed) is your responsibility. length-prefixed) is your responsibility.
- The data carries **no `ctx` argument**. If your callback needs state, - The data carries **no `ctx` argument**. If your callback needs state,
use a file-scope `static` or a global. use a file-scope `static` or a global.
### 5.3 Lifecycle functions ### 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 ```c
int ble_uart_install(const ble_uart_config_t *cfg); int ble_uart_install(const ble_uart_config_t *cfg);
int ble_uart_open(void); int ble_uart_open(void);
int ble_uart_close(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); int ble_uart_uninstall(void);
``` ```
| Function | What it does (NimBLE) | What it does (Bluedroid) | When to call | Blocking? | | 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) | | `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` | Bond store + spawn host task + start advertising once synced | Configure adv data + scan rsp + start advertising | After `install` | No, host runs in the background | | `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 (LL_TERMINATE_IND, waits ≤500 ms for the disconnect event) → `nimble_port_stop()` | Stop adv → graceful disconnect (`esp_ble_gap_disconnect`, waits ≤500 ms) | After `open`, before `uninstall` | Yes, up to ~500 ms while waiting for the peer disconnect | | `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) |
| `uninstall` | Calls `close` if still open, then `nimble_port_deinit()` and resets module state | Calls `close` if still open, then `bluedroid_disable+deinit` + `controller_disable+deinit` | After `close` (or directly — `uninstall` cascades into `close` on its own) | Yes, follows the same wait window as `close` | | `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 |
Call order: **Bring-up** (every product):
``` ```text
nvs_flash_init nvs_flash_init()
└── ble_uart_install └── ble_uart_install(&cfg) /* once per uninstall cycle */
└── ble_uart_open ← BLE is live └── ble_uart_open() /* advertising + pairing; BLE is live */
└── ble_uart_close
└── ble_uart_uninstall ← clean state, can install again
``` ```
Each call returns `BLE_HS_EALREADY` if the corresponding state is Run-forever firmware can stop here — no `close` / `uninstall` required.
already true (e.g. `open` called twice, or `close` called when the
radio is already down). It is therefore safe to call `close` /
`uninstall` defensively at shutdown without checking the current state
yourself.
**Do NOT call `close` / `uninstall` from inside `ble_uart_on_rx`** — **Release** — pick **one** path below. `close` stops the radio but keeps
that callback runs on the NimBLE host task, and `close` blocks on `install` state (you can `open()` again). `uninstall` tears the host +
`nimble_port_stop()` which expects the host task to exit. Self-stop controller down so `install()` can run from scratch.
deadlocks. Forward the request to a normal FreeRTOS task instead.
| 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 ### 5.4 TX interface
@@ -354,11 +642,11 @@ ble_uart_tx((const uint8_t *)line, (size_t)n);
| Return | Meaning | | Return | Meaning |
| --- | --- | | --- | --- |
| `0` | Success (notification handed to the stack) | | `BLE_UART_OK` | Success (notification handed to the stack) |
| `BLE_HS_ENOTCONN` | No central connected; **this is normal — typically ignore** | | `BLE_UART_ENOTCONN` | No central connected; **this is normal — typically ignore** |
| `BLE_HS_EINVAL` | `data == NULL` or `len == 0` | | `BLE_UART_EINVAL` | `data == NULL` or `len == 0` |
| `BLE_HS_ENOMEM` | Stack mbuf pool exhausted | | `BLE_UART_ENOMEM` | Stack mbuf pool exhausted |
| other | Internal stack error — see `ble_hs.h` | | `BLE_UART_EFAIL` | Internal stack error — see logs |
**Calling context**: any FreeRTOS task at any priority. **Not callable **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 from an ISR** — push the data to a queue from the ISR and let a task
@@ -382,16 +670,425 @@ bool ble_uart_is_subscribed(void);
You usually **don't need** to query these up-front — `ble_uart_tx` You usually **don't need** to query these up-front — `ble_uart_tx`
returns `ENOTCONN` to tell you. returns `ENOTCONN` to tell you.
### 5.6 Service UUID constant ### 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 ```c
extern const ble_uart_uuid128_t ble_uart_service_uuid; 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 Always `6e400001-b5a3-f393-e0a9-e50e24dcca9e` (the de-facto BLE UART service UUID). It is
already inserted into the scan response, so the **application normally already inserted into the scan response **by the default payload**, so
does not touch it**. You only need it if you take over advertising the application only needs to reference it when it takes over the adv
yourself (see 6.3). 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.
--- ---
@@ -467,8 +1164,12 @@ Effect:
`ble_uart` registers its own service; you can call `ble_gatts_add_svcs()` `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 **multiple times** and NimBLE will build all of them into the GATT
table. **Caveat**: this must happen before `ble_uart_open()`, otherwise table. **Caveat**: this must happen before the **first** `ble_uart_open()`
the host task is already running and the GATT table is locked. 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 ```c
ble_uart_install(&cfg); ble_uart_install(&cfg);
@@ -488,15 +1189,20 @@ ble_uart_open();
> call `ble_gap_adv_start` yourself. In that case, just fork > call `ble_gap_adv_start` yourself. In that case, just fork
> `ble_uart_nimble.c` (or the matching `ble_uart_bluedroid.c`). > `ble_uart_nimble.c` (or the matching `ble_uart_bluedroid.c`).
### 6.4 Configuring the device-name prefix via Kconfig ### 6.4 Configuring the device name via Kconfig
If you use the shared `ble_uart` component, options are already in If you use the shared `ble_uart` component, options are already in
`menuconfig → Component configuration → ESP-BLE-UART library`. If you copied only `menuconfig → Component configuration → ESP-BLE-UART library`. If you copied only
the `.c` / `.h` files into `main/`, copy `Kconfig` from `common/ble_uart/` as the `.c` / `.h` files into `main/`, copy `Kconfig` from `common/ble_uart/` as
well (or merge its symbols into your own `Kconfig.projbuild`), then: 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 ```c
char name[24]; 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", snprintf(name, sizeof(name), "%s-%02X%02X",
CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]); CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]);
@@ -507,8 +1213,11 @@ ble_uart_install(&(ble_uart_config_t){
}); });
``` ```
Edit the default through `menuconfig → Component configuration → ESP-BLE-UART Edit the prefix through `menuconfig → Component configuration →
library → BLE device name prefix`. 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 ### 6.5 Pushing data proactively
@@ -531,7 +1240,7 @@ static void sensor_task(void *arg)
xTaskCreate(sensor_task, "sensor", 3072, NULL, 5, NULL); xTaskCreate(sensor_task, "sensor", 3072, NULL, 5, NULL);
``` ```
When nobody is subscribed, `ble_uart_tx` returns `BLE_HS_ENOTCONN` — When nobody is subscribed, `ble_uart_tx` returns `BLE_UART_ENOTCONN` —
**just ignore it**. **just ignore it**.
--- ---
@@ -543,10 +1252,11 @@ When nobody is subscribed, `ble_uart_tx` returns `BLE_HS_ENOTCONN` —
| `ble_uart_install` | Any task; once per uninstall cycle | One-shot until `uninstall` | | `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_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` | Any task **except the BLE host task** (NimBLE host task / Bluedroid BTC task) | Idempotent; second call returns `EALREADY` |
| `ble_uart_uninstall` | Any task **except the BLE host task** | Idempotent; cascades into `close` if needed | | `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_tx` | Any FreeRTOS task | Yes — multi-task concurrent |
| `ble_uart_is_connected` / `is_subscribed` | Any context | Yes (bool read; best-effort snapshot) | | `ble_uart_is_connected` / `is_subscribed` | Any context | Yes (bool read; best-effort snapshot) |
| `ble_uart_on_rx` callback | BLE host task (NimBLE host task / Bluedroid BTC task) | Your code must not block, **must not call `close` / `uninstall`** | | `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 | | **Calling any `ble_uart` API from an ISR** | not allowed | Neither host stack supports it |
--- ---
@@ -575,7 +1285,7 @@ Measured throughput (ESP32-S3, iPhone 14 Pro central, MTU 247):
| --- | --- | | --- | --- |
| `nimble_port_init rc=...` | NVS not initialised, or BT controller not enabled | | `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 | | Compile error: `host/ble_hs.h` not found | `REQUIRES bt` is missing from CMakeLists |
| Device not discoverable | Device name exceeds the advertising packet limit (drop the tx_pwr field or shorten the name) | | `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 | | 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** | | `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 | | 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 |
@@ -613,19 +1323,42 @@ If you **start from an empty project**:
#include "ble_uart.h" #include "ble_uart.h"
/* === Types === */ /* === Types === */
typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len); 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 { typedef struct {
bool encrypted; ble_uart_sec_t sc; /* AUTO / OFF / ON — follow `encrypted` when AUTO */
const char *device_name; ble_uart_sec_t bonding;
ble_uart_rx_cb_t ble_uart_on_rx; 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; } ble_uart_config_t;
/* === Lifecycle === */ /* === Lifecycle === */
int ble_uart_install(const ble_uart_config_t *cfg); /* host + GATT */ 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_open(void); /* start advertising (NimBLE: spawn host task) */
int ble_uart_close(void); /* stop adv / disconnect / quiesce host */ int ble_uart_close(void); /* stop adv / disconnect / quiesce host */
int ble_uart_uninstall(void); /* tear down host + reset state */ 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) === */ /* === Send (callable from any task) === */
int ble_uart_tx(const uint8_t *data, size_t len); int ble_uart_tx(const uint8_t *data, size_t len);
@@ -634,10 +1367,25 @@ int ble_uart_tx(const uint8_t *data, size_t len);
/* Via the cfg.ble_uart_on_rx callback, signature: /* Via the cfg.ble_uart_on_rx callback, signature:
* void cb(const uint8_t *data, size_t len); */ * 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 === */ /* === Status === */
bool ble_uart_is_connected(void); bool ble_uart_is_connected(void);
bool ble_uart_is_subscribed(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) === */ /* === Service UUID (for advertising; usually no need to touch) === */
extern const ble_uart_uuid128_t ble_uart_service_uuid; extern const ble_uart_uuid128_t ble_uart_service_uuid;
``` ```
+570 -15
View File
@@ -10,16 +10,24 @@
* backend is picked at compile time via CONFIG_BT_NIMBLE_ENABLED / * backend is picked at compile time via CONFIG_BT_NIMBLE_ENABLED /
* CONFIG_BT_BLUEDROID_ENABLED. * CONFIG_BT_BLUEDROID_ENABLED.
* *
* Lifecycle: * Lifecycle — bring-up:
* *
* ble_uart_install(&cfg); // host + GATT service * nvs_flash_init();
* ble_uart_open(); // start advertising + auto-encrypt * ble_uart_install(&cfg); // host + GATT service (once per uninstall)
* ... * ble_uart_open(); // advertising + pairing
*
* Run-forever apps stop after open().
*
* Lifecycle — release (pick one path; see PORTING.md §5.3):
*
* Path A — from a normal app task (not on_event / on_rx):
* ble_uart_close(); // stop adv / disconnect / halt host * ble_uart_close(); // stop adv / disconnect / halt host
* ble_uart_uninstall(); // free port + reset state * ble_uart_uninstall(); // free port + reset state
* *
* Run-forever apps only need install + open. close / uninstall is * Path B — teardown triggered by a BLE event on the host task:
* for apps that need to power BLE off at runtime. * ble_uart_close_async(); // in on_event / on_rx only
* // wait for BLE_UART_EVT_CLOSED (.closed.status == BLE_UART_OK)
* ble_uart_uninstall(); // on an app task — NOT inside CLOSED
* *
* GATT layout (UUIDs are the widely used fixed 128-bit values): * GATT layout (UUIDs are the widely used fixed 128-bit values):
* *
@@ -57,6 +65,23 @@ typedef struct {
uint8_t bytes[16]; uint8_t bytes[16];
} ble_uart_uuid128_t; } ble_uart_uuid128_t;
/* ----- BLE address ---------------------------------------------------- */
/** Address type, mirroring the BT Core spec values. */
#define BLE_UART_ADDR_TYPE_PUBLIC 0
#define BLE_UART_ADDR_TYPE_RANDOM 1
/** 6-octet BLE device address.
*
* `bytes` is in big-endian order — `bytes[0]` is the MSB octet, the
* way addresses are usually printed (`AA:BB:CC:DD:EE:FF`). Both
* backends marshal between this representation and their own native
* byte order internally, so callers never need to flip bytes. */
typedef struct {
uint8_t bytes[6];
uint8_t type; /* BLE_UART_ADDR_TYPE_PUBLIC or _RANDOM */
} ble_uart_addr_t;
/* ----- Configuration -------------------------------------------------- */ /* ----- Configuration -------------------------------------------------- */
/** RX byte callback. Invoked from the BLE host task whenever bytes /** RX byte callback. Invoked from the BLE host task whenever bytes
@@ -71,27 +96,427 @@ typedef struct {
* are rejected with ATT error 0x0d. */ * are rejected with ATT error 0x0d. */
typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len); typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len);
/* ----- Event callback ------------------------------------------------- */
/** Lifecycle / link-state events delivered to ble_uart_config_t::on_event.
*
* All events fire from the BLE host task context (NimBLE host task /
* Bluedroid BTC task), with one documented exception:
* BLE_UART_EVT_CLOSED is fired by the close-async worker task, after
* the host stack has been torn down — there is no host task left to
* deliver it from. See ble_uart_close_async().
*
* The same threading rules as ble_uart_on_rx apply: don't block, and
* don't call ble_uart_close() / ble_uart_uninstall() (use the async
* variant if you need to teardown from inside an event handler). */
typedef enum {
/** Physical link established. Payload: .connected.peer.
* Type is always BLE_UART_ADDR_TYPE_PUBLIC or _RANDOM (each
* backend's wider addr-type enum is collapsed before delivery).
*
* Backend semantics differ:
* - NimBLE: peer identity address (`peer_id_addr`). On first
* connect this equals the over-the-air address; on a bonded
* RPA reconnect it is the resolved identity, not the random
* address currently on the wire.
* - Bluedroid: the BD address recorded at bond time. If the
* peer connected as address_A and bonding succeeded, later
* reconnects still report address_A in CONNECT even when the
* peer's over-the-air address has changed (e.g. a new RPA).
* Matches `get_bonded_peers()` / `remove_peer` (`bytes` only). */
BLE_UART_EVT_CONNECTED,
/** Physical link torn down. Payload: .disconnected.reason
* (stack-specific disconnect code — esp_gatt_conn_reason_t on
* Bluedroid, NimBLE BLE host return code on NimBLE; see
* BLE_HS_HCI_ERR() / BLE_HS_ERR_HCI_BASE for HCI encoding). */
BLE_UART_EVT_DISCONNECTED,
/** CCCD on the TX characteristic changed. Payload:
* .subscribed.subscribed (true = notifications enabled). */
BLE_UART_EVT_SUBSCRIBED,
/** Link reached the encrypted+authenticated state requested at
* install time. Payload: .link_secure.{encrypted, authenticated,
* bonded, key_size}. Use this — not is_connected() — to gate any
* application logic that requires the channel to be secure. */
BLE_UART_EVT_LINK_SECURE,
/** SM asks the application to display a 6-digit passkey.
* Payload: .passkey.passkey (0..999999). The default banner on
* UART still prints; this callback is additive so a UI / test
* harness can avoid scraping logs. */
BLE_UART_EVT_PASSKEY_DISPLAY,
/** SM asks the application to collect a 6-digit passkey from the
* user (the central displays it; the user types it into this
* device). No payload.
*
* The application MUST respond by calling ble_uart_passkey_reply()
* with the 6 digits the user entered. Until the reply arrives —
* or until the SM's pairing timeout fires (the controller's
* default ~30 s) — pairing is suspended; on timeout the link
* surfaces BLE_UART_EVT_PAIRING_FAILED.
*
* Only fires when cfg.security.io_cap is one of the input-capable
* values (KEYBOARD_ONLY / KEYBOARD_DISPLAY) and the central asks
* for Passkey Entry. */
BLE_UART_EVT_PASSKEY_REQUEST,
/** SM asks the application to display a 6-digit value and let the
* user confirm whether the same value appears on the central.
* Payload: .numeric_compare.passkey (0..999999).
*
* The application MUST respond by calling ble_uart_compare_reply()
* with the user's verdict (true = match). Same suspend-and-time-
* out semantics as BLE_UART_EVT_PASSKEY_REQUEST.
*
* Only fires when cfg.security.io_cap is one of the
* comparison-capable values (DISPLAY_YES_NO / KEYBOARD_DISPLAY)
* and the central asks for Numeric Comparison (which itself
* requires LE Secure Connections on both sides). */
BLE_UART_EVT_NUMERIC_COMPARE,
/** Pairing failed or was rejected. Payload: .pairing_failed.reason
* (NimBLE BLE_HS_E* / Bluedroid esp_ble_auth_fail_rsn_t). */
BLE_UART_EVT_PAIRING_FAILED,
/** Async-close completion — fired only by ble_uart_close_async()
* after the worker task has finished the equivalent of a
* synchronous ble_uart_close(). Payload: .closed.status — the
* return code from that close (BLE_UART_OK on success).
*
* When .closed.status is BLE_UART_OK the host stack is fully
* quiesced — same state as right after ble_uart_close() returns.
* Defer ble_uart_uninstall() to a normal app task (set a flag /
* queue here); do not call uninstall from this handler — see
* PORTING.md §5.3.2. On failure (e.g. BLE_UART_EFAIL) the port
* may still be open; retry ble_uart_close() / ble_uart_close_async()
* from an app task.
*
* Unlike every other event in this enum, this one runs on the
* close-async worker task, NOT on the BLE host task — by the
* time it fires the host task is already gone. Keep the handler
* short; the worker clears s_closing after it returns. */
BLE_UART_EVT_CLOSED,
} ble_uart_evt_id_t;
/** Tagged union delivered to ble_uart_config_t::on_event. */
typedef struct {
ble_uart_evt_id_t id;
union {
struct {
ble_uart_addr_t peer;
} connected;
struct {
int reason; /* stack-specific disconnect code */
} disconnected;
struct {
bool subscribed;
} subscribed;
struct {
bool encrypted; /* 1 = link is AES-CCM encrypted */
bool authenticated; /* 1 = pairing used MITM protection */
bool bonded; /* 1 = LTK persisted in NVS */
uint8_t key_size; /* 7..16 (octets) */
} link_secure;
struct {
uint32_t passkey; /* 0..999999 */
} passkey;
struct {
uint32_t passkey; /* 0..999999 — the value to display */
} numeric_compare;
struct {
int reason; /* stack-specific status code */
} pairing_failed;
struct {
int status; /* BLE_UART_* from async close worker */
} closed;
};
} ble_uart_evt_t;
/** Event callback. May be NULL — events are silently dropped then. */
typedef void (*ble_uart_evt_cb_t)(const ble_uart_evt_t *evt);
/* ----- Security configuration ---------------------------------------- */
/** Tri-state knob for the per-feature security overrides in
* ble_uart_config_t (`sc`, `bonding`, `mitm`).
*
* AUTO (= 0, the value of a zero-initialised struct member) means
* "use whatever cfg.encrypted implies":
*
* encrypted = true → AUTO behaves as ON
* encrypted = false → AUTO behaves as OFF
*
* OFF / ON force the bit regardless of the preset, letting the
* caller mix the preset with one or two surgical overrides without
* spelling out every other field. */
typedef enum {
BLE_UART_SEC_AUTO = 0,
BLE_UART_SEC_OFF = 1,
BLE_UART_SEC_ON = 2,
} ble_uart_sec_t;
/** SM Input/Output capability — combines with the central's IO cap and
* the resolved `mitm` bit to pick the pairing model (Just Works /
* Passkey Display / Passkey Entry / Numeric Comparison — see BT Core
* Spec §2.3.5.1). The application doesn't decide the method directly;
* it picks the IO cap that matches its UI and ble_uart fires the right
* event when the SM negotiates a method.
*
* Passing an out-of-range integer makes ble_uart_install() return
* BLE_UART_EINVAL. Only the input-capable values (KEYBOARD_ONLY,
* DISPLAY_YES_NO, KEYBOARD_DISPLAY) require cfg.on_event to be
* non-NULL — pairing would otherwise stall on unanswered
* BLE_UART_EVT_PASSKEY_REQUEST / NUMERIC_COMPARE. AUTO (resolves to
* DisplayOnly when MITM is ON), DISPLAY_ONLY, and NO_INPUT_OUTPUT do
* not require on_event; Passkey Display is handled internally. */
typedef enum {
/** Default: DisplayOnly when the resolved MITM bit is ON;
* NoInputNoOutput when it is OFF. */
BLE_UART_IO_CAP_AUTO = 0,
/** Device has no UI; pairing always uses Just Works. Cannot
* satisfy MITM — combining this with mitm=ON makes
* ble_uart_install() return BLE_UART_EINVAL. */
BLE_UART_IO_CAP_NO_INPUT_OUTPUT = 1,
/** Device shows a 6-digit passkey on a display; the central
* enters it. Generates a fresh passkey for every pairing,
* surfaced via BLE_UART_EVT_PASSKEY_DISPLAY (no reply call
* needed — the central does the typing). */
BLE_UART_IO_CAP_DISPLAY_ONLY = 2,
/** Device has keys (or some other way to feed digits to the
* library) but no display; the central displays a 6-digit
* passkey, the user reads it from there and types it in.
*
* ble_uart fires BLE_UART_EVT_PASSKEY_REQUEST and waits for
* ble_uart_passkey_reply(). Requires cfg.on_event != NULL. */
BLE_UART_IO_CAP_KEYBOARD_ONLY = 3,
/** Device has a display + a yes/no confirmation control. With a
* similarly-equipped LE Secure Connections central this elects
* Numeric Comparison: ble_uart fires BLE_UART_EVT_NUMERIC_COMPARE
* with the 6-digit value to display, and waits for
* ble_uart_compare_reply().
*
* Falls back to Just Works against legacy or NoInput peers.
* Requires cfg.on_event != NULL. */
BLE_UART_IO_CAP_DISPLAY_YES_NO = 4,
/** Device has a display AND a keypad (covers both Numeric
* Comparison and Passkey Entry). Best fit for a touchscreen UI
* that wants to handle every MITM-capable peer.
*
* ble_uart fires either BLE_UART_EVT_PASSKEY_REQUEST or
* BLE_UART_EVT_NUMERIC_COMPARE depending on what the SM
* negotiates with the central; respond with the matching reply
* API. Requires cfg.on_event != NULL. */
BLE_UART_IO_CAP_KEYBOARD_DISPLAY = 5,
} ble_uart_io_cap_t;
/** Per-feature security overrides, embedded in ble_uart_config_t.
*
* Each tri-state field defaults to AUTO (= 0, the value of any
* zero-initialised member), inheriting its bit from
* ble_uart_config_t::encrypted:
*
* encrypted = true → AUTO behaves as ON
* encrypted = false → AUTO behaves as OFF
*
* Set any field to OFF / ON to override that single bit while the
* rest still follow the preset. Common patterns are listed in
* PORTING.md §5.6 (e.g. encrypted=true with mitm=OFF +
* io_cap=NO_INPUT_OUTPUT for a displayless gateway).
*
* Combinations the SM cannot satisfy — io_cap=NO_INPUT_OUTPUT
* together with the resolved mitm=ON, or an out-of-range enum value
* — make ble_uart_install() return BLE_UART_EINVAL up front, before
* the host stack is brought up. */
typedef struct {
/** Override LE Secure Connections (the BT 4.2+ pairing method
* that uses ECDH for the LTK). */
ble_uart_sec_t sc;
/** Override bonding (persistence of the LTK / IRK / persisted
* CCCD in NVS). With bonding=OFF the link is still encrypted
* (if sc/mitm are on) but every reconnect re-pairs. */
ble_uart_sec_t bonding;
/** Override MITM protection (man-in-the-middle: link
* authentication via passkey display / entry / numeric
* comparison). With mitm=OFF the link pairs via Just Works,
* which is encrypted but unauthenticated; the GATT permission
* flags drop their _AUTHEN bit so a Just-Works peer can
* read/write the UART characteristics. */
ble_uart_sec_t mitm;
/** SM IO capability — controls which pairing model is chosen
* alongside `mitm`. AUTO picks DisplayOnly when the resolved
* MITM bit is ON, NoInputNoOutput when it is OFF. */
ble_uart_io_cap_t io_cap;
} ble_uart_security_t;
/* ----- Advertising payload limits ------------------------------------ */
/** Maximum bytes the application may put in `adv_data`.
*
* BLE 4.x legacy primary advertising packets are capped at 31 bytes
* total. Of those, the 3-byte Flags AD element (length+type+value)
* is always added by ble_uart, leaving 31 − 3 = 28 bytes for the
* application. */
#define BLE_UART_ADV_DATA_MAX 28
/** Maximum bytes the application may put in `scan_rsp_data`.
*
* Scan response packets are also capped at 31 bytes, with no
* mandatory AD elements — the entire 31 bytes belong to the
* application. */
#define BLE_UART_SCAN_RSP_DATA_MAX 31
/** Maximum length (bytes, excluding NUL terminator) of `device_name`.
*
* Sized so that the *default* advertising payload — Flags AD +
* Complete Local Name AD — always fits in the 31-byte primary packet:
*
* 31 − 3 (Flags AD) − 2 (Name AD header) = 26
*
* Names that exceed this length make `ble_uart_install()` return
* `BLE_UART_EINVAL` synchronously, instead of silently failing later
* in the host stack when advertising starts.
*
* This applies regardless of whether `adv_data` is set — the GAP
* service Device Name characteristic (UUID 0x2A00) reports the same
* string. Apps that need a longer GAP-service name with a shorter
* advertised name should keep `device_name` ≤ this limit and use
* `adv_data` to broadcast a shortened/different name instead. */
#define BLE_UART_DEVICE_NAME_MAX 26
/** Configuration handed to ble_uart_install(). */ /** Configuration handed to ble_uart_install(). */
typedef struct { typedef struct {
/** True = LE Secure Connections + Bonding + MITM, DisplayOnly IO, /** Security preset (a one-line shortcut for the four override
* encrypted RX/TX chars, bond persisted in NVS (NimBLE: requires * fields under `security` below).
* CONFIG_BT_NIMBLE_NVS_PERSIST=y; Bluedroid: default). *
* False = plaintext (lab debugging only — sniffable). */ * True = LE Secure Connections + Bonding + MITM, DisplayOnly IO,
* encrypted+authenticated RX/TX chars, bond persisted in
* NVS (NimBLE: requires CONFIG_BT_NIMBLE_NVS_PERSIST=y;
* Bluedroid: default).
* False = plaintext (lab debugging only — sniffable).
*
* Every member of `security` defaults to AUTO, meaning "follow
* this preset". Override individual bits there; see
* ble_uart_security_t for the resolution rules. */
bool encrypted; bool encrypted;
/** GAP device name. NULL keeps the host stack default. Mind the /** Per-feature security overrides. A zero-initialised value
* 31-byte primary advertising limit (≤ 8 bytes recommended). */ * (every field AUTO) inherits everything from `encrypted`, so
* callers that just want the secure-by-default preset can leave
* this field unset:
*
* ble_uart_install(&(ble_uart_config_t){
* .encrypted = true, // sc/bonding/mitm/io_cap all AUTO
* ...
* });
*
* Surgical override:
*
* ble_uart_install(&(ble_uart_config_t){
* .encrypted = true,
* .security = { .mitm = BLE_UART_SEC_OFF }, // SC + Bonding, no MITM
* ...
* });
*
* See ble_uart_security_t for the full per-field docs. */
ble_uart_security_t security;
/** GAP device name (peer-readable via the GAP service, UUID 0x2A00).
* NULL keeps the host-stack default.
*
* Length must be ≤ BLE_UART_DEVICE_NAME_MAX (26) — over-long
* strings make ble_uart_install() return BLE_UART_EINVAL.
*
* This is NOT automatically inserted into the advertising payload
* when `adv_data` (below) is non-NULL — if you want the name to
* appear in scans without connecting, include a Complete Local
* Name AD element (type 0x09) in your `adv_data` bytes yourself. */
const char *device_name; const char *device_name;
/** Optional raw advertising data — everything that goes after the
* 3-byte Flags AD element in the primary advertising packet. The
* Flags element is built by ble_uart and is NOT part of these
* bytes (don't include it).
*
* Format: standard BT Core "AD structure" sequence — repeating
* `[length(1)][AD type(1)][value(length-1)]` triplets. See the
* Bluetooth Assigned Numbers (Generic Access Profile) document
* for the full type list.
*
* Length must be ≤ BLE_UART_ADV_DATA_MAX (28). The buffer is
* copied at install time; the pointer does not need to outlive
* the call.
*
* Set to NULL (with adv_data_len=0) to keep the built-in default,
* which advertises only the Complete Local Name (taken from
* device_name). */
const uint8_t *adv_data;
size_t adv_data_len;
/** Optional raw scan response data — entire 31-byte payload is at
* the application's disposal; ble_uart adds nothing.
*
* Same `[len][type][value]` format and copy semantics as
* adv_data. Length must be ≤ BLE_UART_SCAN_RSP_DATA_MAX (31).
*
* Set to NULL (with scan_rsp_data_len=0) to keep the built-in
* default, which advertises the 128-bit BLE UART service UUID. */
const uint8_t *scan_rsp_data;
size_t scan_rsp_data_len;
/** Byte handler for RX writes. NULL discards incoming data. */ /** Byte handler for RX writes. NULL discards incoming data. */
ble_uart_rx_cb_t ble_uart_on_rx; ble_uart_rx_cb_t ble_uart_on_rx;
/** Lifecycle / link-state event sink. NULL drops every event.
* See ble_uart_evt_id_t for the supported events; runs on the
* BLE host task with the same caveats as ble_uart_on_rx. */
ble_uart_evt_cb_t on_event;
} ble_uart_config_t; } ble_uart_config_t;
/* ----- Lifecycle ------------------------------------------------------ */ /* ----- Lifecycle ------------------------------------------------------ */
/** Bring up host stack + Security Manager + SIG services + BLE UART GATT /** Bring up host stack + Security Manager + SIG services + BLE UART GATT
* service. Caller must have already called nvs_flash_init(). * service. Caller must have already called nvs_flash_init().
* cfg->device_name is copied; doesn't need to outlive the call. *
* cfg->device_name, cfg->adv_data and cfg->scan_rsp_data are all
* copied internally; the caller's buffers don't need to outlive the
* call. Returns BLE_UART_EINVAL if any of these checks fail:
* strlen(cfg->device_name) > BLE_UART_DEVICE_NAME_MAX (26)
* cfg->adv_data_len > BLE_UART_ADV_DATA_MAX (28)
* cfg->scan_rsp_data_len > BLE_UART_SCAN_RSP_DATA_MAX (31)
* cfg->security.{sc,bonding,mitm} outside BLE_UART_SEC_{AUTO,OFF,ON}
* cfg->security.io_cap outside BLE_UART_IO_CAP_{AUTO,
* NO_INPUT_OUTPUT,DISPLAY_ONLY,
* KEYBOARD_ONLY,DISPLAY_YES_NO,
* KEYBOARD_DISPLAY}
* resolved mitm=ON + io_cap=NO_INPUT_OUTPUT
* (Just Works can never satisfy MITM)
* io_cap requires user input (KEYBOARD_ONLY, DISPLAY_YES_NO,
* KEYBOARD_DISPLAY) but cfg->on_event
* is NULL — the application would have
* no way to receive PASSKEY_REQUEST /
* NUMERIC_COMPARE and answer it
* (io_cap=AUTO with resolved mitm=ON, or DISPLAY_ONLY, does not
* need on_event — equivalent to Passkey Display handled inside
* the port; PASSKEY_DISPLAY via on_event is optional)
*
* Single-shot until ble_uart_uninstall(); a second call returns * Single-shot until ble_uart_uninstall(); a second call returns
* BLE_UART_EALREADY. */ * BLE_UART_EALREADY. */
int ble_uart_install(const ble_uart_config_t *cfg); int ble_uart_install(const ble_uart_config_t *cfg);
@@ -111,16 +536,84 @@ int ble_uart_open(void);
* quiesces the host. install state is preserved — call open() again * quiesces the host. install state is preserved — call open() again
* to resume. * to resume.
* *
* Don't call from the BLE host task (i.e. from ble_uart_on_rx). */ * NimBLE: also resets the local GATT server; the next open() re-adds
* GAP/GATT/UART. Service UUIDs are unchanged but ATT handles may
* differ — centrals must rediscover and re-subscribe (PORTING.md
* §5.3.1a). Bluedroid: host and GATT table stay up; open() only
* restarts advertising.
*
* Don't call from the BLE host task (i.e. from ble_uart_on_rx or
* ble_uart_evt_cb_t) — it would deadlock waiting for the disconnect
* event that the host task itself is supposed to deliver. Use
* ble_uart_close_async() in those contexts instead. */
int ble_uart_close(void); int ble_uart_close(void);
/** Fire-and-forget variant of ble_uart_close(). Returns immediately
* after spawning a small worker task that runs the regular close
* sequence in the background; safe from ANY task — including the
* BLE host task (i.e. from inside ble_uart_on_rx or on_event), where
* the synchronous variant deadlocks.
*
* Completion is reported on the on_event callback as
* BLE_UART_EVT_CLOSED with .closed.status set to the worker's
* ble_uart_close() result. When status is BLE_UART_OK the host stack
* is fully torn down — then uninstall on an app task after
* BLE_UART_EVT_CLOSED (PORTING.md §5.3.2 Path B).
* (BLE_UART_EVT_DISCONNECTED is also delivered, ahead of CLOSED, if
* there was a peer.)
*
* Idempotent in the harmless sense: calling it before
* ble_uart_open() has succeeded, or while a previous async close
* is still draining, returns BLE_UART_EALREADY without spawning a
* second worker. Returns BLE_UART_ENOMEM if FreeRTOS can't
* allocate the worker task. */
int ble_uart_close_async(void);
/** Counterpart to ble_uart_install(). Force-closes if still open, /** Counterpart to ble_uart_install(). Force-closes if still open,
* then tears down the host stack + controller. After this returns, * then tears down the host stack + controller. After this returns,
* install() can run from scratch. * install() can run from scratch.
* *
* Don't call from the BLE host task. */ * Don't call from the BLE host task (NimBLE host / Bluedroid BTC).
* If a ble_uart_close_async() worker is still running, this call
* polls for up to ~5 s and then proceeds with teardown anyway if the
* worker has not exited — do not call uninstall from another task
* while a close_async is in flight unless you follow PORTING.md §5.3:
* Path A — ble_uart_close() then uninstall from an app task; or
* Path B — close_async, then uninstall on an app task after
* BLE_UART_EVT_CLOSED with .closed.status == BLE_UART_OK (never
* call uninstall from inside the CLOSED handler). */
int ble_uart_uninstall(void); int ble_uart_uninstall(void);
/* ----- Pairing replies ----------------------------------------------- */
/** Answer an in-flight BLE_UART_EVT_PASSKEY_REQUEST.
*
* `passkey` is the 6-digit value the user read off the central's
* display and entered on this device — must be in 0..999999.
*
* Safe from any task. Returns:
* BLE_UART_OK reply was injected into the SM
* BLE_UART_EINVAL passkey > 999999
* BLE_UART_ENOTCONN no PASSKEY_REQUEST is currently pending
* (link dropped, pairing already timed out,
* or the SM asked for something else)
* BLE_UART_EFAIL backend rejected the inject
*
* Each PASSKEY_REQUEST event accepts exactly one reply; subsequent
* calls return BLE_UART_ENOTCONN until the next request. */
int ble_uart_passkey_reply(uint32_t passkey);
/** Answer an in-flight BLE_UART_EVT_NUMERIC_COMPARE.
*
* `match` is the user's verdict: true if the 6-digit values shown
* on this device and on the central are identical, false otherwise.
* A `false` reply makes pairing fail with a numeric-comparison
* mismatch, surfaced as BLE_UART_EVT_PAIRING_FAILED.
*
* Same threading semantics and return codes as
* ble_uart_passkey_reply(). */
int ble_uart_compare_reply(bool match);
/* ----- TX ------------------------------------------------------------- */ /* ----- TX ------------------------------------------------------------- */
/** Send raw bytes to the connected central as one or more TX /** Send raw bytes to the connected central as one or more TX
@@ -143,6 +636,68 @@ bool ble_uart_is_connected(void);
* the CCCD write); exposed for diagnostics only. */ * the CCCD write); exposed for diagnostics only. */
bool ble_uart_is_subscribed(void); bool ble_uart_is_subscribed(void);
/* ----- Bond management ----------------------------------------------- */
/** Number of bonded peers in the persistent store.
*
* Requires ble_uart_install() to have run; works whether or not
* ble_uart_open() has been called. *out_count is left untouched on
* failure. Safe from any task. */
int ble_uart_get_bond_count(size_t *out_count);
/** List the bonded peers' addresses.
*
* Up to `cap` entries are written to `out`; on success *out_count
* receives the **total** number of bonds (which may exceed `cap`).
* When *out_count > cap the caller may allocate a larger buffer
* and re-call to read the rest.
*
* `out` may be NULL if `cap` is 0 — useful as a preflight to size
* an exactly-fitting buffer (although ble_uart_get_bond_count()
* does the same with one less argument).
*
* Safe from any task. Requires ble_uart_install() to have run. */
int ble_uart_get_bonded_peers(ble_uart_addr_t *out,
size_t cap,
size_t *out_count);
/** Drop the bond (LTK / IRK / persisted CCCD) for one peer.
*
* Does not actively disconnect the current link (encrypted or not).
* Call ble_uart_close() first if you need an immediate disconnect
* and re-pair.
*
* `peer` is matched against the identity address in the bond store.
* Backend matching:
* - NimBLE: `(type, bytes)`. `BLE_UART_EVT_CONNECTED` and
* `get_bonded_peers()` both yield identity addresses suitable
* for this call (first connect: same as over-the-air; bonded RPA
* reconnect: resolved identity, not the random on the wire).
* - Bluedroid: `bytes` only — `type` is ignored. The bond store
* and CONNECT both use the address seen when bonding was
* established (address_A); later over-the-air changes are not
* reflected in either API.
*
* Idempotent: returns BLE_UART_OK whether or not the peer was bonded
* (both backends treat "already absent" as success — NimBLE's
* ble_store_util_delete_peer maps BLE_HS_ENOENT to 0). Use
* ble_uart_get_bonded_peers() first if you need to distinguish
* "removed" from "was never bonded".
*
* Returns BLE_UART_EINVAL if peer is NULL or ble_uart_install() has
* not run. Safe from any task. */
int ble_uart_remove_peer(const ble_uart_addr_t *peer);
/** Drop ALL bonded peers — equivalent to a factory reset of the bond
* store, but does not touch any other NVS namespace.
*
* Does not actively disconnect the current link (encrypted or not).
* Call ble_uart_close() first if you need an immediate disconnect
* and re-pair.
*
* Returns BLE_UART_OK if the store was cleared. Safe from any task. */
int ble_uart_clear_bonds(void);
/* ----- Service UUID -------------------------------------------------- */ /* ----- Service UUID -------------------------------------------------- */
/** The BLE UART service UUID, exposed for custom advertising payloads. /** The BLE UART service UUID, exposed for custom advertising payloads.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff