mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-01 10:40:47 +03:00
(cherry picked from commit 9de3eea3de)
Co-authored-by: zhiweijian <zhiweijian@espressif.com>
239 lines
10 KiB
Markdown
239 lines
10 KiB
Markdown
# BLE UART Service Example — NimBLE / Bluedroid
|
|
|
|
| Supported Targets | ESP32 | ESP32-C2 | ESP32-C3 | ESP32-C5 | ESP32-C6 | ESP32-C61 | ESP32-H2 | ESP32-S3 |
|
|
| ----------------- | ----- | -------- | -------- | -------- | -------- | --------- | -------- | -------- |
|
|
|
|
A turnkey serial-over-BLE peripheral that implements the de-facto
|
|
**Nordic UART Service** GATT layout (RX write, TX notify), so any
|
|
standard BLE-serial central (nRF Connect, Web Bluetooth examples, your
|
|
own iOS / Android / Linux / Python scripts) can talk to it unchanged.
|
|
|
|
The example ships with **two interchangeable backends** — NimBLE and
|
|
Bluedroid — both implementing the same stack-agnostic
|
|
`ble_uart.h` API. Pick one with `idf.py menuconfig → Component config →
|
|
Bluetooth → Host`; the build system links the matching backend
|
|
automatically. Default is NimBLE (smaller footprint).
|
|
|
|
The whole BLE stack — NVS-backed bond store, NimBLE host, security
|
|
manager, advertising, pairing, GAP event handling — is wrapped behind
|
|
**two function calls** in `app_main`:
|
|
|
|
```c
|
|
ble_uart_install(&cfg); // NimBLE host + NUS GATT service
|
|
ble_uart_open(); // start advertising + auto-encrypt
|
|
```
|
|
|
|
…and two matching tear-down calls if your app ever needs to power
|
|
BLE off at runtime:
|
|
|
|
```c
|
|
ble_uart_close(); // stop advertising / disconnect / halt host
|
|
ble_uart_uninstall(); // free the NimBLE port + reset state
|
|
```
|
|
|
|
When a central connects, the firmware automatically initiates LE Secure
|
|
Connections + Bonding pairing, displays a fresh 6-digit passkey to the
|
|
UART monitor, persists the LTK in NVS, and starts delivering received
|
|
bytes to the application's `on_rx` callback. The application sends bytes
|
|
back with `ble_uart_tx()`.
|
|
|
|
## GATT layout
|
|
|
|
| | UUID | Properties | Default flags |
|
|
| -------- | -------------------------------------- | ------------------------- | ------------------------- |
|
|
| Service | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` | — | — |
|
|
| RX (in) | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` | Write, WriteNR | encrypted, authenticated |
|
|
| TX (out) | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | Notify (auto-CCCD) | encrypted, authenticated |
|
|
|
|
The `_ENC | _AUTHEN` flags are turned on only when `cfg.encrypted = true`
|
|
(the default in this example).
|
|
|
|
## Files
|
|
|
|
| File | Lines | Role |
|
|
| --- | ---: | --- |
|
|
| `main/main.c` | ~70 | NVS init, MAC-derived device name, install + open, RX echo handler. Identical for both backends. |
|
|
| `main/ble_uart.h` | ~260 | Stack-agnostic public API: 3-field config + 4 lifecycle functions + TX/status + UUID + `BLE_UART_E*` return codes. No NimBLE / Bluedroid types leak through. |
|
|
| `main/ble_uart_nimble.c` | ~670 | NimBLE backend: host bring-up, NUS GATT service via `ble_gatts_add_svcs`, advertising, pairing, install/open/close/uninstall. Active when `CONFIG_BT_NIMBLE_ENABLED=y`. |
|
|
| `main/ble_uart_bluedroid.c` | ~900 | Bluedroid backend: controller + host enable, NUS 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`. |
|
|
| `main/Kconfig.projbuild` | ~40 | Device-name prefix + RX scratch buffer size knobs. |
|
|
| `sdkconfig.defaults` | — | Default: NimBLE backend, MTU 512, SC + bonding + persistent NVS. |
|
|
| `sdkconfig.ci.bluedroid` | — | Overlay: switch to Bluedroid backend (used via `-D SDKCONFIG_DEFAULTS=...`, see "Choosing the host stack" below). |
|
|
|
|
## Public API
|
|
|
|
```c
|
|
typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len);
|
|
|
|
typedef struct {
|
|
bool encrypted; /* SC + Bonding + MITM in one knob */
|
|
const char *device_name;
|
|
ble_uart_rx_cb_t ble_uart_on_rx;
|
|
} ble_uart_config_t;
|
|
|
|
/* Lifecycle */
|
|
int ble_uart_install(const ble_uart_config_t *cfg); /* NimBLE host + GATT */
|
|
int ble_uart_open(void); /* host task + advertising */
|
|
int ble_uart_close(void); /* stop adv / disconnect / halt host */
|
|
int ble_uart_uninstall(void); /* free NimBLE port + reset state */
|
|
|
|
/* Data path */
|
|
int ble_uart_tx(const uint8_t *data, size_t len);
|
|
|
|
/* Status (best-effort snapshot) */
|
|
bool ble_uart_is_connected(void);
|
|
bool ble_uart_is_subscribed(void);
|
|
|
|
extern const ble_uart_uuid128_t ble_uart_service_uuid;
|
|
```
|
|
|
|
## Choosing the host stack
|
|
|
|
The same `ble_uart.h` API is implemented twice — once on top of NimBLE
|
|
(`ble_uart_nimble.c`) and once on top of Bluedroid
|
|
(`ble_uart_bluedroid.c`). `main/CMakeLists.txt` registers both files;
|
|
each guards its body with `#if CONFIG_BT_NIMBLE_ENABLED` / `#if
|
|
CONFIG_BT_BLUEDROID_ENABLED`, so exactly one becomes live at compile
|
|
time.
|
|
|
|
Two ways to switch:
|
|
|
|
```bash
|
|
# A. Flip the Kconfig knob interactively
|
|
idf.py menuconfig
|
|
# Component config -> Bluetooth -> Host -> NimBLE / Bluedroid
|
|
|
|
# B. Apply the Bluedroid overlay non-interactively (great for CI)
|
|
idf.py -B build_bd \
|
|
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.ci.bluedroid" \
|
|
reconfigure
|
|
idf.py -B build_bd build flash monitor
|
|
```
|
|
|
|
When neither is enabled the build fails up-front with a clear error.
|
|
|
|
### Differences callers should know about
|
|
|
|
| | NimBLE backend | Bluedroid backend |
|
|
| --- | --- | --- |
|
|
| Long-write (PREP+EXEC) reassembly | done by NimBLE itself | done in `ble_uart_bluedroid.c`, capped at `CONFIG_BLE_UART_RX_SCRATCH_SIZE` |
|
|
| TX congestion behaviour | `mbuf` pool is generous; rarely returns ENOMEM | `esp_ble_gatts_send_indicate` may return ENOMEM under load → caller should back off |
|
|
| Passkey origin | generated locally with `esp_random()` | generated by the Bluedroid SM, surfaced via `PASSKEY_NOTIF_EVT` |
|
|
| Bond persistence | needs `CONFIG_BT_NIMBLE_NVS_PERSIST=y` | persisted by default |
|
|
| `install` blocking time | ~50 ms | ~150 ms (waits for `CREAT_ATTR_TAB_EVT`) |
|
|
| `uninstall` thoroughness | `nimble_port_deinit()` releases everything | `bluedroid_disable+deinit` + `controller_disable+deinit` releases everything |
|
|
|
|
## How to use
|
|
|
|
### Configure
|
|
|
|
```bash
|
|
idf.py set-target esp32c3 # or esp32, esp32s3, esp32c6, esp32h2 ...
|
|
idf.py menuconfig # optional
|
|
# Component config -> BLE UART Example
|
|
# - BLE device name prefix (default: BleUart)
|
|
```
|
|
|
|
The two security knobs are set in `sdkconfig.defaults`:
|
|
|
|
```ini
|
|
CONFIG_BT_NIMBLE_SM_SC=y # LE Secure Connections
|
|
CONFIG_BT_NIMBLE_NVS_PERSIST=y # Bond keys persist across reboots
|
|
```
|
|
|
|
Disable `cfg.encrypted` in `main.c` (set it to `false`) for plaintext
|
|
operation in the lab — the GATT characteristics drop their `_ENC`
|
|
flags accordingly. Production firmware should keep encryption on.
|
|
|
|
### Build & flash
|
|
|
|
```bash
|
|
idf.py build flash monitor
|
|
```
|
|
|
|
Expected boot log (NimBLE backend — the per-characteristic register
|
|
lines are NimBLE-specific; Bluedroid prints the four NUS handles in a
|
|
single line, see below):
|
|
|
|
```
|
|
I (xxx) ble_uart: registered service 6e400001-... handle=14
|
|
I (xxx) ble_uart: registered chr 6e400002-... def=15 val=16
|
|
I (xxx) ble_uart: registered chr 6e400003-... def=17 val=18
|
|
I (xxx) ble_uart: addr=80:7d:3a:11:22:33
|
|
I (xxx) ble_uart: BLE host task started
|
|
I (xxx) ble_uart: advertising as 'BleUart-XXXX'
|
|
```
|
|
|
|
Expected boot log (Bluedroid backend):
|
|
|
|
```
|
|
I (xxx) ble_uart: gatts reg status=0 app_id=85 gatts_if=3
|
|
I (xxx) ble_uart: registered service svc_handle=40 rx=42 tx=44 cccd=45
|
|
I (xxx) ble_uart: advertising started
|
|
```
|
|
|
|
## Pairing & demo
|
|
|
|
1. On a phone, install **nRF Connect for Mobile**.
|
|
2. Scan, tap **Connect** on `BleUart-XXXX`. The phone prompts for a
|
|
6-digit code.
|
|
3. The device prints a fresh code in a banner on UART:
|
|
|
|
```
|
|
W (xxx) ble_uart: +-----------------------------+
|
|
W (xxx) ble_uart: | BLE PAIRING PASSKEY: |
|
|
W (xxx) ble_uart: | 427183 |
|
|
W (xxx) ble_uart: +-----------------------------+
|
|
```
|
|
4. Type that code on the phone; pairing completes. The link is now
|
|
AES-CCM-encrypted and the LTK is stored to NVS.
|
|
5. Open the *Nordic UART Service*, subscribe to TX (the down-arrow
|
|
icon), then write any bytes to RX (the up-arrow icon). The device
|
|
logs them to UART and **echoes them right back** through TX.
|
|
6. Disconnect and reconnect: no passkey prompt — the bond resumes
|
|
automatically.
|
|
|
|
To wipe the bond and force a fresh passkey, run `idf.py erase-flash`
|
|
and re-flash.
|
|
|
|
## Adapting to your application
|
|
|
|
Replace the `ble_uart_on_rx` body in `main.c` with your own protocol
|
|
parser (line / TLV / length-prefixed framing — `ble_uart` delivers raw
|
|
bytes with no framing assumptions). Send replies with `ble_uart_tx()`.
|
|
|
|
## Reusing `ble_uart` in your own project
|
|
|
|
Copy `main/ble_uart.h` plus the backend(s) you want — `main/ble_uart_nimble.c`
|
|
and/or `main/ble_uart_bluedroid.c` — into your project, add `bt nvs_flash`
|
|
to your component's `REQUIRES`, then in your `app_main`:
|
|
|
|
```c
|
|
nvs_flash_init();
|
|
|
|
ble_uart_install(&(ble_uart_config_t){
|
|
.encrypted = true,
|
|
.device_name = "MyDevice",
|
|
.ble_uart_on_rx = my_handler,
|
|
});
|
|
ble_uart_open();
|
|
```
|
|
|
|
That's it — encrypted serial-over-BLE in 4 lines.
|
|
|
|
## Troubleshooting
|
|
|
|
- **Phone shows "pairing failed"** — the central asked for "Just Works"
|
|
and our SM rejected it because MITM is required when
|
|
`cfg.encrypted = true`. Pick a phone / app that supports passkey entry.
|
|
- **No passkey appears in UART** — verify `CONFIG_BT_NIMBLE_SM_SC=y` in
|
|
your sdkconfig and that you didn't toggle `cfg.encrypted` to `false`.
|
|
- **`enc_change status=13 encrypted=1 ...`** — `13` is `BLE_HS_ETIMEOUT`,
|
|
triggered by a benign race between our `ble_gap_security_initiate()`
|
|
and the central's own auto-encryption on a bonded reconnect. Status
|
|
is non-zero but the link is fully encrypted; safe to ignore.
|
|
- **Notifications missing after a reconnect** — `ble_uart_tx` deliberately
|
|
doesn't gate on the CCCD-subscribe state because bonded reconnects
|
|
often skip the CCCD write. Bytes are still pushed; the central
|
|
delivers them based on its remembered subscription.
|