feat(ble): Support nimble uart service

(cherry picked from commit 9de3eea3de)

Co-authored-by: zhiweijian <zhiweijian@espressif.com>
This commit is contained in:
Zhi Wei Jian
2026-04-27 20:00:27 +08:00
committed by zhiweijian
parent 173f84edb6
commit b336f559d2
11 changed files with 2881 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# The following lines of boilerplate have to be in your project's
# CMakeLists in this exact order for cmake to work correctly.
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
idf_build_set_property(MINIMAL_BUILD ON)
project(ble_uart_service)

View File

@@ -0,0 +1,645 @@
# BLE UART Porting & API Guide
A complete guide to integrating `ble_uart` into any ESP-IDF project.
**Two or three files plus 5 steps of glue code** are enough to bring an
encrypted BLE serial peripheral up in a fresh project — the same
`ble_uart.h` API works on top of either NimBLE or Bluedroid; pick the
host with a Kconfig knob.
This guide uses **NimBLE** as the running example because it is the
default on every ESP32 family target. The Bluedroid path is identical
from the application's point of view; the only differences are the
sdkconfig knobs called out in §4.3 and a few stack-specific notes
flagged inline.
---
## 1. What `ble_uart` Provides
| Capability | Description |
| --- | --- |
| Standard Nordic UART Service GATT (RX/TX) | Interoperates with every generic BLE-serial tool (nRF Connect, Web Bluetooth, custom scripts) |
| LE Secure Connections + Bonding pairing | Single switch; when enabled, a fresh 6-digit passkey is printed to UART |
| Auto-reconnect | After a bonded central disconnects, advertising restarts immediately and the LTK is reused — no passkey prompt |
| Raw byte pass-through | RX is delivered via a callback; TX is exposed as `ble_uart_tx` |
| Auto-fragmentation | TX is sliced according to the negotiated ATT MTU |
| Fully wrapped | The user's `app_main` only calls two functions: `install` + `open` |
`ble_uart` is agnostic of any application-layer protocol (no JSON, no
line framing). It only delivers bytes — **what you do with those bytes
is entirely up to you**.
---
## 2. Prerequisites
| Requirement | Notes |
| --- | --- |
| ESP-IDF v5.0+ | v5.x or v6.x recommended |
| BT controller | Must support BLE (ESP32 / C2 / C3 / C5 / C6 / C61 / H2 / S3 / …) |
| Host stack | Exactly one of `CONFIG_BT_NIMBLE_ENABLED=y` (default, smaller) or `CONFIG_BT_BLUEDROID_ENABLED=y` in sdkconfig (covered in detail below) |
| Flash size | At least 2 MB (the default partition table is plenty) |
---
## 3. File Inventory
Files to copy into the target project — pick the backend you want and
copy that pair plus the public header:
```
your_project/main/
├── ble_uart.h ← copy this (stack-agnostic public API, ~260 lines)
├── ble_uart_nimble.c ← if you'll set CONFIG_BT_NIMBLE_ENABLED=y (~670 lines)
└── ble_uart_bluedroid.c ← if you'll set CONFIG_BT_BLUEDROID_ENABLED=y (~900 lines)
```
You can also copy *both* `ble_uart_nimble.c` and `ble_uart_bluedroid.c`
unchanged — each `.c` file gates its body on the matching Kconfig
symbol, so the inactive one compiles to nothing. This is what the
example itself does, and it lets you flip stacks without changing the
source list.
Optional: `Kconfig.projbuild` defines `BLE_UART_DEVICE_NAME_PREFIX`
and `BLE_UART_RX_SCRATCH_SIZE`. Copy it too if you want either to be
tunable from `menuconfig`; otherwise hard-code the name in your
source and rely on the 1024-byte fallback for RX scratch.
---
## 4. Step-by-Step Integration
Assume you already have an ESP-IDF project (`my_project/`).
### 4.1 Copy the files
```bash
cd my_project/main
# Stack-agnostic public header — always.
cp /path/to/ble_uart_service/main/ble_uart.h .
# Pick one (or copy both — the inactive one compiles to nothing).
cp /path/to/ble_uart_service/main/ble_uart_nimble.c .
cp /path/to/ble_uart_service/main/ble_uart_bluedroid.c .
```
### 4.2 Edit `main/CMakeLists.txt`
```cmake
# List both backends; each .c file is gated on its matching Kconfig
# symbol, so only the active one contributes code.
idf_component_register(SRCS "main.c"
"ble_uart_nimble.c"
"ble_uart_bluedroid.c"
INCLUDE_DIRS "."
REQUIRES bt nvs_flash)
```
### 4.3 Edit `sdkconfig.defaults` (the 7 critical lines)
**NimBLE backend (default, smaller footprint):**
```ini
# Enable NimBLE
CONFIG_BT_ENABLED=y
CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y # only needed on classic ESP32; C3/S3/C6/... will warn "unknown" — safe to ignore
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_ENABLED=y
# Encryption + persistent bonds
CONFIG_BT_NIMBLE_SM_SC=y # LE Secure Connections
CONFIG_BT_NIMBLE_NVS_PERSIST=y # persist LTKs in NVS — passkey-free reconnects
```
`CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU` is optional; the default (256) is
fine. Bumping it to 512 lets TX push larger chunks per notification, but
the central must support it.
**Bluedroid backend (drop-in alternative):**
```ini
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=n
CONFIG_BT_BLUEDROID_ENABLED=y
# LE Secure Connections + bonding (Bluedroid persists LTKs by default)
CONFIG_BT_BLE_SMP_ENABLE=y
# Optional: bigger MTU
CONFIG_BT_GATT_MAX_MTU_SIZE=512
# BLE-only feature set (saves flash on classic-BT-capable parts)
CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y
CONFIG_BT_BLE_42_ADV_EN=y
```
### 4.4 Write `app_main` (template)
Minimal working template:
```c
#include "esp_log.h"
#include "esp_mac.h"
#include "nvs_flash.h"
#include "ble_uart.h"
static const char *TAG = "app";
/* What to do with received bytes — up to you */
static void ble_uart_on_rx(const uint8_t *data, size_t len)
{
ESP_LOGI(TAG, "rx %u bytes", (unsigned)len);
/* echo it back as a demo */
ble_uart_tx(data, len);
}
void app_main(void)
{
/* 1. NVS: NimBLE uses it for PHY calibration and bond storage */
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
/* 2. Bring up BLE UART */
ESP_ERROR_CHECK(ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.device_name = "MyDevice",
.ble_uart_on_rx = ble_uart_on_rx,
}));
/* 3. Take off */
ESP_ERROR_CHECK(ble_uart_open());
}
```
### 4.5 Build & flash
```bash
idf.py set-target esp32s3 # or whichever target you use
idf.py build flash monitor
```
Once flashed, the UART monitor should show (NimBLE backend):
```
I (xxx) ble_uart: registered service 6e400001-... handle=14
I (xxx) ble_uart: registered chr 6e400002-... def=15 val=16
I (xxx) ble_uart: registered chr 6e400003-... def=17 val=18
I (xxx) ble_uart: addr=...
I (xxx) ble_uart: BLE host task started
I (xxx) ble_uart: advertising as 'MyDevice'
```
…or with the Bluedroid backend:
```
I (xxx) ble_uart: gatts reg status=0 app_id=85 gatts_if=3
I (xxx) ble_uart: registered service svc_handle=40 rx=42 tx=44 cccd=45
I (xxx) ble_uart: advertising started
```
nRF Connect on a phone discovers `MyDevice`; connect, enter the
passkey, subscribe to TX, write to RX, and you will see the echo come
back.
---
## 5. API Reference
### 5.1 Configuration struct
```c
typedef struct {
bool encrypted; /* Master switch for SC + Bonding + MITM */
const char *device_name; /* GAP device name; NULL uses the NimBLE default */
ble_uart_rx_cb_t ble_uart_on_rx;/* RX byte callback */
} ble_uart_config_t;
```
| Field | Type | Required | Default / meaning |
| --- | --- | --- | --- |
| `encrypted` | `bool` | yes | `true` = SC + Bonding + MITM + DisplayOnly + encrypted GATT chars; `false` = fully plaintext (sniffable, lab use only) |
| `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 |
| `ble_uart_on_rx` | callback | optional | `NULL` discards every received byte |
### 5.2 RX callback signature
```c
typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len);
static void my_handler(const uint8_t *data, size_t len)
{
/* `data` is reused after the callback returns; memcpy into your own
* buffer if you need to keep it. */
}
```
**Caveats**:
- The callback runs in the **NimBLE host task** context — **do not
block**; offload heavy work to your own task.
- A single callback may carry only **part** of an upper-layer frame
(the central slices on ATT MTU). Framing logic (line / TLV /
length-prefixed) is your responsibility.
- The data carries **no `ctx` argument**. If your callback needs state,
use a file-scope `static` or a global.
### 5.3 Lifecycle functions
```c
int ble_uart_install(const ble_uart_config_t *cfg);
int ble_uart_open(void);
int ble_uart_close(void);
int ble_uart_uninstall(void);
```
| Function | What it does (NimBLE) | What it does (Bluedroid) | When to call | Blocking? |
| --- | --- | --- | --- | --- |
| `install` | `nimble_port_init` + `ble_hs_cfg` + SM + SIG services + UART GATT | `controller_init/enable` + `bluedroid_init/enable` + SM + `esp_ble_gatts_create_attr_tab` (waits ≤500 ms for the attr-table event) | After `nvs_flash_init()`, before `open` | No, ~50 ms (NimBLE) / ~150 ms (Bluedroid) |
| `open` | Bond store + spawn host task + start advertising once synced | Configure adv data + scan rsp + start advertising | 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 |
| `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` |
Call order:
```
nvs_flash_init
└── ble_uart_install
└── ble_uart_open ← 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
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`**
that callback runs on the NimBLE host task, and `close` blocks on
`nimble_port_stop()` which expects the host task to exit. Self-stop
deadlocks. Forward the request to a normal FreeRTOS task instead.
### 5.4 TX interface
```c
int ble_uart_tx(const uint8_t *data, size_t len);
```
For formatted output, format into your own buffer with `snprintf` first
and pass it to `ble_uart_tx`:
```c
char line[64];
int n = snprintf(line, sizeof(line), "temp=%d.%d\n", t / 10, t % 10);
ble_uart_tx((const uint8_t *)line, (size_t)n);
```
**Return values**:
| Return | Meaning |
| --- | --- |
| `0` | Success (notification handed to the stack) |
| `BLE_HS_ENOTCONN` | No central connected; **this is normal — typically ignore** |
| `BLE_HS_EINVAL` | `data == NULL` or `len == 0` |
| `BLE_HS_ENOMEM` | Stack mbuf pool exhausted |
| other | Internal stack error — see `ble_hs.h` |
**Calling context**: any FreeRTOS task at any priority. **Not callable
from an ISR** — push the data to a queue from the ISR and let a task
call `ble_uart_tx`.
**Auto-fragmentation**: regardless of buffer size, the implementation
splits the payload into successive notifications of `(MTU - 3)` bytes.
The central receives them in transmission order.
### 5.5 Status queries
```c
bool ble_uart_is_connected(void);
bool ble_uart_is_subscribed(void);
```
- `is_connected()`: a central is connected (it may not be paired yet).
- `is_subscribed()`: the central has subscribed to TX notifications
(note: bonded reconnects often skip CCCD writes).
You usually **don't need** to query these up-front — `ble_uart_tx`
returns `ENOTCONN` to tell you.
### 5.6 Service UUID constant
```c
extern const ble_uart_uuid128_t ble_uart_service_uuid;
```
Always `6e400001-b5a3-f393-e0a9-e50e24dcca9e` (the NUS standard). It is
already inserted into the scan response, so the **application normally
does not touch it**. You only need it if you take over advertising
yourself (see 6.3).
---
## 6. Advanced Usage
### 6.1 Different RX framing strategies
**A. Split on `\n` (suits ASCII protocols / JSON)**
```c
static uint8_t s_buf[1024];
static size_t s_len;
static void on_rx(const uint8_t *d, size_t n)
{
for (size_t i = 0; i < n; i++) {
if (d[i] == '\n') { handle_line(s_buf, s_len); s_len = 0; }
else if (s_len < sizeof s_buf) s_buf[s_len++] = d[i];
}
}
```
**B. Length-prefixed binary frames**
```c
static void on_rx(const uint8_t *d, size_t n)
{
static uint16_t need = 0;
static uint8_t frame[256];
static size_t got = 0;
for (size_t i = 0; i < n; i++) {
if (need == 0) { need = d[i]; got = 0; continue; }
frame[got++] = d[i];
if (got == need) { handle_frame(frame, got); need = 0; }
}
}
```
**C. Forward straight to UART**
```c
static void on_rx(const uint8_t *d, size_t n)
{
uart_write_bytes(UART_NUM_1, (const char *)d, n);
}
```
### 6.2 Disabling encryption (lab scenarios)
```c
ble_uart_install(&(ble_uart_config_t){
.encrypted = false, /* ← turn it off */
.device_name = "OpenDev",
.ble_uart_on_rx = ...,
});
```
Effect:
- GATT characteristics drop the `_ENC | _AUTHEN` flags.
- Any central can read/write — no pairing required.
- No passkey prompt.
- Data is sniffable by any nearby nRF dongle.
**Do not ship this in production firmware.**
### 6.3 Coexisting with other GATT services
> The snippet below is for the **NimBLE backend**. With Bluedroid, register
> additional profiles via `esp_ble_gatts_app_register()` before calling
> `ble_uart_open()` — the gating rule is the same: extra services must
> be in place before advertising starts.
`ble_uart` registers its own service; you can call `ble_gatts_add_svcs()`
**multiple times** and NimBLE will build all of them into the GATT
table. **Caveat**: this must happen before `ble_uart_open()`, otherwise
the host task is already running and the GATT table is locked.
```c
ble_uart_install(&cfg);
/* Register your extra services before open() */
ble_svc_dis_init(); /* Device Information Service */
my_battery_service_init(); /* your own battery service */
ble_uart_open();
```
> If your service must appear in the **advertising packet**, you have
> to bypass `ble_uart`'s internal advertising logic — override
> `ble_hs_cfg.sync_cb` with your own implementation after
> `ble_uart_install`, then call `ble_uart_open()`. Note that
> `ble_uart`'s internal `start_advertising` will not run, so you must
> call `ble_gap_adv_start` yourself. In that case, just fork
> `ble_uart_nimble.c` (or the matching `ble_uart_bluedroid.c`).
### 6.4 Configuring the device-name prefix via Kconfig
Copy `Kconfig.projbuild` into `main/`, then:
```c
char name[24];
snprintf(name, sizeof(name), "%s-%02X%02X",
CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]);
ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.device_name = name,
.ble_uart_on_rx = on_rx,
});
```
Edit the default through `menuconfig → BLE UART Example → BLE device
name prefix`.
### 6.5 Pushing data proactively
You can call TX from any task:
```c
/* A periodic sensor-reporting task */
static void sensor_task(void *arg)
{
char line[64];
while (1) {
int t = read_temperature();
int n = snprintf(line, sizeof(line), "temp=%d.%d\n", t / 10, t % 10);
ble_uart_tx((const uint8_t *)line, (size_t)n);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
/* Spawn it from app_main */
xTaskCreate(sensor_task, "sensor", 3072, NULL, 5, NULL);
```
When nobody is subscribed, `ble_uart_tx` returns `BLE_HS_ENOTCONN`
**just ignore it**.
---
## 7. Calling Context & Thread Safety
| Function | Calling context | Thread-safe |
| --- | --- | --- |
| `ble_uart_install` | Any task; once per uninstall cycle | One-shot until `uninstall` |
| `ble_uart_open` | Any task; after `install` | One-shot until `close` |
| `ble_uart_close` | Any task **except the BLE host task** (NimBLE host task / Bluedroid BTC task) | Idempotent; second call returns `EALREADY` |
| `ble_uart_uninstall` | Any task **except the BLE host task** | Idempotent; cascades into `close` if needed |
| `ble_uart_tx` | Any FreeRTOS task | Yes — multi-task concurrent |
| `ble_uart_is_connected` / `is_subscribed` | Any context | Yes (bool read; best-effort snapshot) |
| `ble_uart_on_rx` callback | BLE host task (NimBLE host task / Bluedroid BTC task) | Your code must not block, **must not call `close` / `uninstall`** |
| **Calling any `ble_uart` API from an ISR** | not allowed | Neither host stack supports it |
---
## 8. Memory / Performance
| Item | Footprint |
| --- | --- |
| Code segment (`ble_uart_nimble.c.o`) | ~14 KB (with `-Os`) |
| Code segment (`ble_uart_bluedroid.c.o`) | ~22 KB (with `-Os`; larger because long-write reassembly is open-coded) |
| Static RAM (globals + RX buffer) | ~1.1 KB (the bulk is `CONFIG_BLE_UART_RX_SCRATCH_SIZE`, default 1024 B) |
| Host task stack (NimBLE host / Bluedroid BTC) | 4 KB (default) |
| Controller task stack | ~3 KB (default) |
| Bond store (NVS) | ~80 bytes per bonded peer |
| ATT MTU | Negotiated; whatever you set in sdkconfig (247 / 256 / 512) |
Measured throughput (ESP32-S3, iPhone 14 Pro central, MTU 247):
- TX (notify): ~25 KB/s
- RX (write): ~20 KB/s
---
## 9. FAQ
| Symptom | Cause / fix |
| --- | --- |
| `nimble_port_init rc=...` | NVS not initialised, or BT controller not enabled |
| Compile error: `host/ble_hs.h` not found | `REQUIRES bt` is missing from CMakeLists |
| Device not discoverable | Device name exceeds the advertising packet limit (drop the tx_pwr field or shorten the name) |
| Pairing fails | Central uses "Just Works" but we require MITM (`encrypted=true`). Use a central that supports passkey entry |
| `enc_change status=13 encrypted=1 bonded=1` | `13 = BLE_HS_ETIMEOUT`. Bonded-reconnect race; **the link is actually encrypted — safe to ignore** |
| Notifications missing after a reconnect | Bonded centrals often skip the CCCD write; our TX path doesn't gate on subscription state, so notifications still go out — make sure the central side has its callback registered |
| Second connection rejected | `MAX_CONNECTIONS = 1` by default. For multi-connection support, bump the sdkconfig value and turn `s_conn_handle` (NimBLE backend) / `s_conn_id` (Bluedroid backend) into an array |
| Flash fills up | Bond entries accumulate. Periodically run `idf.py erase-flash`, or call `ble_store_clear()` in code |
---
## 10. Differences from This Example
If you **build directly on top of this example**:
| You already have | No further work needed |
| --- | --- |
| `main.c` echo template | Replace with your own `on_rx` body |
| `sdkconfig.defaults` | Reuse as-is |
| `Kconfig.projbuild` | Reuse as-is |
| `CMakeLists.txt` (root + main) | Reuse as-is |
If you **start from an empty project**:
| What you need to do | Source |
| --- | --- |
| Copy `ble_uart.h` + at least one of `ble_uart_nimble.c` / `ble_uart_bluedroid.c` into `main/` | This example |
| Copy the key lines of `sdkconfig.defaults` | §4.3 of this guide |
| Add SRC + REQUIRES to `main/CMakeLists.txt` | §4.2 of this guide |
| Write `install` + `open` in `app_main` | §4.4 of this guide |
---
## 11. API Cheat Sheet (print and pin to the wall)
```c
#include "ble_uart.h"
/* === Types === */
typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len);
typedef struct {
bool encrypted;
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); /* host + GATT */
int ble_uart_open(void); /* start advertising (NimBLE: spawn host task) */
int ble_uart_close(void); /* stop adv / disconnect / quiesce host */
int ble_uart_uninstall(void); /* tear down host + reset state */
/* === Send (callable from any task) === */
int ble_uart_tx(const uint8_t *data, size_t len);
/* === Receive === */
/* Via the cfg.ble_uart_on_rx callback, signature:
* void cb(const uint8_t *data, size_t len); */
/* === Status === */
bool ble_uart_is_connected(void);
bool ble_uart_is_subscribed(void);
/* === Service UUID (for advertising; usually no need to touch) === */
extern const ble_uart_uuid128_t ble_uart_service_uuid;
```
---
## 12. Minimal Project Template (ready to flash)
A complete flashable project takes 7 files (the inactive backend `.c`
compiles to nothing, so it costs you nothing to ship both):
```
my_ble_uart_project/
├── CMakeLists.txt
├── sdkconfig.defaults
└── main/
├── CMakeLists.txt
├── ble_uart.h ← copied from this example
├── ble_uart_nimble.c ← copied from this example
├── ble_uart_bluedroid.c ← copied from this example (optional)
└── main.c
```
**Root `CMakeLists.txt`**:
```cmake
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(my_ble_uart)
```
**`main/CMakeLists.txt`**:
```cmake
idf_component_register(SRCS "main.c"
"ble_uart_nimble.c"
"ble_uart_bluedroid.c"
INCLUDE_DIRS "."
REQUIRES bt nvs_flash)
```
**`sdkconfig.defaults`** (7 lines, NimBLE backend):
```ini
CONFIG_BT_ENABLED=y
CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_SM_SC=y
CONFIG_BT_NIMBLE_NVS_PERSIST=y
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=512
```
**`main/main.c`** — copy the §4.4 template verbatim.
Flash:
```bash
idf.py set-target esp32s3
idf.py build flash monitor
```
Done.

View File

@@ -0,0 +1,238 @@
# 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.

View File

@@ -0,0 +1,10 @@
# Both backends are listed; each .c file body is wrapped in
# #if CONFIG_BT_NIMBLE_ENABLED / CONFIG_BT_BLUEDROID_ENABLED so only
# the matching backend produces code. This is the standard IDF
# pattern for conditional sources, because sdkconfig isn't loaded
# during the early CMake component-requirement scan.
idf_component_register(SRCS "main.c"
"ble_uart_nimble.c"
"ble_uart_bluedroid.c"
INCLUDE_DIRS "."
REQUIRES bt nvs_flash)

View File

@@ -0,0 +1,26 @@
menu "BLE UART Example"
config BLE_UART_DEVICE_NAME_PREFIX
string "BLE device name prefix"
default "BleUart"
help
The firmware advertises as `<prefix>-XXXX` where XXXX is
the last two bytes of the BT MAC in hex.
config BLE_UART_RX_SCRATCH_SIZE
int "RX scratch buffer size (bytes)"
range 64 16384
default 1024
help
Upper bound on a single RX payload delivered to
ble_uart_on_rx(). Covers both plain writes (MTU - 3 bytes)
and reassembled long writes (PREP + EXEC). Oversized writes
are rejected with ATT error 0x0D.
The buffer lives in BSS, so this value directly translates
into RAM cost. Bump it if your protocol sends larger frames
in one shot; with the NimBLE backend, raising past ~10 KB
may also require increasing
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT.
endmenu

View File

@@ -0,0 +1,154 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Unlicense OR CC0-1.0
*
* BLE UART — turnkey serial-over-BLE peripheral.
*
* Implements the de-facto Nordic UART Service (NUS) GATT layout
* (RX write, TX notify) on top of either NimBLE or Bluedroid; the
* backend is picked at compile time via CONFIG_BT_NIMBLE_ENABLED /
* CONFIG_BT_BLUEDROID_ENABLED.
*
* Lifecycle:
*
* ble_uart_install(&cfg); // host + GATT service
* ble_uart_open(); // start advertising + auto-encrypt
* ...
* ble_uart_close(); // stop adv / disconnect / halt host
* ble_uart_uninstall(); // free port + reset state
*
* Run-forever apps only need install + open. close / uninstall is
* for apps that need to power BLE off at runtime.
*
* GATT layout (UUIDs fixed by the NUS spec):
*
* Service: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
* RX : 6e400002-b5a3-f393-e0a9-e50e24dcca9e write
* TX : 6e400003-b5a3-f393-e0a9-e50e24dcca9e notify
*
* See PORTING.md for the integration guide.
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ----- Return codes --------------------------------------------------- */
/** All ble_uart_* APIs return one of these stack-neutral codes. */
#define BLE_UART_OK 0 /* Success */
#define BLE_UART_EINVAL -1 /* Bad argument or unsupported op */
#define BLE_UART_ENOTCONN -2 /* No central currently connected */
#define BLE_UART_ENOMEM -3 /* Out of mbufs / send queue full */
#define BLE_UART_EALREADY -4 /* Lifecycle already in this state */
#define BLE_UART_EFAIL -5 /* Backend internal failure (see logs) */
/* ----- 128-bit UUID helper -------------------------------------------- */
/** Stack-agnostic 128-bit UUID in little-endian (wire) order. */
typedef struct {
uint8_t bytes[16];
} ble_uart_uuid128_t;
/* ----- Configuration -------------------------------------------------- */
/** RX byte callback. Invoked from the BLE host task whenever bytes
* arrive on the RX characteristic. The buffer is owned by the stack
* and reused after return — copy what you need to keep.
*
* Don't block here; offload heavy work to your own task.
*
* Long-write (PREP/EXEC) reassembly is handled transparently — you
* always see one contiguous payload, capped by
* CONFIG_BLE_UART_RX_SCRATCH_SIZE (default 1024). Oversized writes
* are rejected with ATT error 0x0d. */
typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len);
/** Configuration handed to ble_uart_install(). */
typedef struct {
/** True = LE Secure Connections + Bonding + MITM, DisplayOnly IO,
* encrypted RX/TX chars, bond persisted in NVS (NimBLE: requires
* CONFIG_BT_NIMBLE_NVS_PERSIST=y; Bluedroid: default).
* False = plaintext (lab debugging only — sniffable). */
bool encrypted;
/** GAP device name. NULL keeps the host stack default. Mind the
* 31-byte primary advertising limit (≤ 8 bytes recommended). */
const char *device_name;
/** Byte handler for RX writes. NULL discards incoming data. */
ble_uart_rx_cb_t ble_uart_on_rx;
} ble_uart_config_t;
/* ----- Lifecycle ------------------------------------------------------ */
/** Bring up host stack + Security Manager + SIG services + NUS GATT
* service. Caller must have already called nvs_flash_init().
* cfg->device_name is copied; doesn't need to outlive the call.
* Single-shot until ble_uart_uninstall(); a second call returns
* BLE_UART_EALREADY. */
int ble_uart_install(const ble_uart_config_t *cfg);
/** Start advertising. NimBLE: spawns the host task and primes the bond
* store; advertising begins once the controller signals ready.
* Bluedroid: triggers adv-data + scan-response config; advertising
* begins once the stack acknowledges both.
*
* Returns immediately; the BLE UART then runs autonomously
* (connect, pairing, passkey display, RX delivery all via internal
* callbacks). Single-shot. */
int ble_uart_open(void);
/** Counterpart to ble_uart_open(). Stops advertising, gracefully
* disconnects (waits up to 500 ms for LL_TERMINATE_IND ack), and
* quiesces the host. install state is preserved — call open() again
* to resume.
*
* Don't call from the BLE host task (i.e. from ble_uart_on_rx). */
int ble_uart_close(void);
/** Counterpart to ble_uart_install(). Force-closes if still open,
* then tears down the host stack + controller. After this returns,
* install() can run from scratch.
*
* Don't call from the BLE host task. */
int ble_uart_uninstall(void);
/* ----- TX ------------------------------------------------------------- */
/** Send raw bytes to the connected central as one or more TX
* notifications, fragmented to fit the live ATT MTU. Safe from any
* FreeRTOS task; not safe from ISR.
*
* Returns BLE_UART_ENOTCONN when no peer is connected (this is
* normal — typically just ignore). */
int ble_uart_tx(const uint8_t *data, size_t len);
/* ----- Status (best-effort, optional) -------------------------------- */
/** True when a central is connected (link may not yet be encrypted).
* Best-effort snapshot; production callers should rely on the return
* code of ble_uart_tx() instead. */
bool ble_uart_is_connected(void);
/** True when the central has subscribed to TX notifications.
* ble_uart_tx() does NOT gate on this (bonded reconnects often skip
* the CCCD write); exposed for diagnostics only. */
bool ble_uart_is_subscribed(void);
/* ----- Service UUID -------------------------------------------------- */
/** The NUS service UUID, exposed for custom advertising payloads.
* The two characteristic UUIDs are private to the backend. */
extern const ble_uart_uuid128_t ble_uart_service_uuid;
#ifdef __cplusplus
}
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,652 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Unlicense OR CC0-1.0
*
* BLE UART — NimBLE backend. Implements the lifecycle declared in
* ble_uart.h on top of the NimBLE host. Active when
* CONFIG_BT_NIMBLE_ENABLED=y; otherwise ble_uart_bluedroid.c is used.
*/
#include "sdkconfig.h"
#if CONFIG_BT_NIMBLE_ENABLED
#include "ble_uart.h"
#include <assert.h>
#include <inttypes.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_random.h"
#include "nimble/ble.h"
#include "host/ble_att.h"
#include "host/ble_gap.h"
#include "host/ble_gatt.h"
#include "host/ble_hs.h"
#include "host/ble_hs_mbuf.h"
#include "host/ble_sm.h"
#include "host/ble_uuid.h"
#include "host/util/util.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
/* tx path needs notifications; the disabled-path in
* ble_gatts_notify_custom() leaks the caller's mbuf. */
#if !MYNEWT_VAL(BLE_GATT_NOTIFY)
#error "ble_uart NimBLE backend requires MYNEWT_VAL(BLE_GATT_NOTIFY)=1"
#endif
static const char *TAG = "ble_uart";
/* Map NimBLE rc → public BLE_UART_E* code; unknown rcs → EFAIL. */
static int xlate_rc(int nimble_rc)
{
switch (nimble_rc) {
case 0: return BLE_UART_OK;
case BLE_HS_EINVAL: return BLE_UART_EINVAL;
case BLE_HS_ENOTCONN: return BLE_UART_ENOTCONN;
case BLE_HS_ENOMEM: return BLE_UART_ENOMEM;
case BLE_HS_EALREADY: return BLE_UART_EALREADY;
default: return BLE_UART_EFAIL;
}
}
/* Provided by NimBLE's `store/config` lib. */
extern void ble_store_config_init(void);
/* ===== UUIDs =========================================================== */
/* NUS UUIDs in little-endian byte order. */
#define NUS_SVC_BYTES 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, \
0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e
#define NUS_RX_BYTES 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, \
0x93, 0xf3, 0xa3, 0xb5, 0x02, 0x00, 0x40, 0x6e
#define NUS_TX_BYTES 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, \
0x93, 0xf3, 0xa3, 0xb5, 0x03, 0x00, 0x40, 0x6e
const ble_uart_uuid128_t ble_uart_service_uuid = { .bytes = { NUS_SVC_BYTES } };
static const ble_uuid128_t s_svc_uuid = BLE_UUID128_INIT(NUS_SVC_BYTES);
static const ble_uuid128_t s_chr_rx_uuid = BLE_UUID128_INIT(NUS_RX_BYTES);
static const ble_uuid128_t s_chr_tx_uuid = BLE_UUID128_INIT(NUS_TX_BYTES);
/* ===== State =========================================================== */
/* RX scratch capacity. Tunable via menuconfig; fall back to 1024 if
* Kconfig.projbuild isn't carried along when reusing this file. */
#ifndef CONFIG_BLE_UART_RX_SCRATCH_SIZE
#define CONFIG_BLE_UART_RX_SCRATCH_SIZE 1024
#endif
#define RX_SCRATCH CONFIG_BLE_UART_RX_SCRATCH_SIZE
/* Cached device name. Avoids ble_svc_gap_device_name() which returns
* NULL when CONFIG_BT_NIMBLE_GAP_SERVICE=n (would NULL-deref). 32B
* covers the BLE 31-byte adv-payload limit + NUL. */
#define DEV_NAME_MAX 32
static ble_uart_config_t s_cfg;
static char s_dev_name[DEV_NAME_MAX];
static uint16_t s_tx_val_handle;
/* Volatile: written from NimBLE host task, polled from caller task. */
static volatile uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
static bool s_subscribed;
static bool s_installed;
static bool s_opened;
static bool s_shutting_down; /* gates auto-readvertise during close */
static uint8_t s_own_addr_type;
static int gap_event(struct ble_gap_event *event, void *arg);
static int start_advertising(void);
/* ===== GATT (NUS) ====================================================== */
static int chr_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
switch (ctxt->op) {
case BLE_GATT_ACCESS_OP_WRITE_CHR: {
/* File-scope (BSS) — host task is single-threaded so no reentry. */
static uint8_t s_rx_buf[RX_SCRATCH];
uint16_t total = OS_MBUF_PKTLEN(ctxt->om);
if (total > sizeof(s_rx_buf)) {
ESP_LOGW(TAG, "rx oversize: %u > %u, rejecting",
(unsigned)total, (unsigned)sizeof(s_rx_buf));
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
uint16_t copied = 0;
int rc = ble_hs_mbuf_to_flat(ctxt->om, s_rx_buf, total, &copied);
if (rc != 0) {
return BLE_ATT_ERR_UNLIKELY;
}
if (s_cfg.ble_uart_on_rx != NULL && copied > 0) {
s_cfg.ble_uart_on_rx(s_rx_buf, copied);
}
return 0;
}
case BLE_GATT_ACCESS_OP_READ_CHR:
return BLE_ATT_ERR_READ_NOT_PERMITTED;
default:
return BLE_ATT_ERR_UNLIKELY;
}
}
/* Encryption-required flag masks. NimBLE derives CCCD permissions from
* NOTIFY_INDICATE_* (not from READ/WRITE_*), so notify-only chars need
* the NOTIFY_INDICATE mask, not just the RW mask — otherwise an
* unpaired central could subscribe and receive notifications over the
* unencrypted link (see ble_gatts.c:ble_gatts_chr_clt_cfg_flags_from_chr_flags). */
#define CHR_FLAG_RW_ENC (BLE_GATT_CHR_F_READ_ENC | BLE_GATT_CHR_F_READ_AUTHEN | \
BLE_GATT_CHR_F_WRITE_ENC | BLE_GATT_CHR_F_WRITE_AUTHEN)
#define CHR_FLAG_NOTIFY_ENC (BLE_GATT_CHR_F_NOTIFY_INDICATE_ENC | \
BLE_GATT_CHR_F_NOTIFY_INDICATE_AUTHEN)
static struct ble_gatt_chr_def s_chr_defs[3];
static struct ble_gatt_svc_def s_svc_defs[2];
static void build_gatt_table(bool encrypted)
{
/* `ble_gatt_chr_flags` is uint32_t — match width here so the
* 0x10000-and-above NOTIFY_INDICATE flags don't get truncated. */
ble_gatt_chr_flags rw_enc = encrypted ? CHR_FLAG_RW_ENC : 0;
ble_gatt_chr_flags notify_enc = encrypted ? CHR_FLAG_NOTIFY_ENC : 0;
s_chr_defs[0] = (struct ble_gatt_chr_def){
.uuid = &s_chr_rx_uuid.u,
.access_cb = chr_access,
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP | rw_enc,
};
s_chr_defs[1] = (struct ble_gatt_chr_def){
.uuid = &s_chr_tx_uuid.u,
.access_cb = chr_access,
.flags = BLE_GATT_CHR_F_NOTIFY | notify_enc,
.val_handle = &s_tx_val_handle,
};
s_chr_defs[2] = (struct ble_gatt_chr_def){0};
s_svc_defs[0] = (struct ble_gatt_svc_def){
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &s_svc_uuid.u,
.characteristics = s_chr_defs,
};
s_svc_defs[1] = (struct ble_gatt_svc_def){0};
}
static void register_cb(struct ble_gatt_register_ctxt *ctxt, void *arg)
{
char buf[BLE_UUID_STR_LEN];
switch (ctxt->op) {
case BLE_GATT_REGISTER_OP_SVC:
ESP_LOGI(TAG, "registered service %s handle=%d",
ble_uuid_to_str(ctxt->svc.svc_def->uuid, buf), ctxt->svc.handle);
break;
case BLE_GATT_REGISTER_OP_CHR:
ESP_LOGI(TAG, "registered chr %s def=%d val=%d",
ble_uuid_to_str(ctxt->chr.chr_def->uuid, buf),
ctxt->chr.def_handle, ctxt->chr.val_handle);
break;
default:
break;
}
}
/* ===== TX ============================================================== */
int ble_uart_tx(const uint8_t *data, size_t len)
{
/* Snapshot conn_handle once: a peer-A→peer-B disconnect+connect
* race during a multi-chunk send could otherwise leak later chunks
* to peer B (notify_custom doesn't gate on the CCCD subscription).
* Stale handle → BLE_HS_ENOTCONN, we bail cleanly. */
uint16_t conn_handle = s_conn_handle;
if (conn_handle == BLE_HS_CONN_HANDLE_NONE) {
return BLE_UART_ENOTCONN;
}
if (data == NULL || len == 0) {
return BLE_UART_EINVAL;
}
uint16_t mtu = ble_att_mtu(conn_handle);
size_t chunk = (mtu > 3) ? (size_t)(mtu - 3) : 20;
size_t sent = 0;
while (sent < len) {
size_t n = len - sent;
if (n > chunk) {
n = chunk;
}
struct os_mbuf *om = ble_hs_mbuf_from_flat(data + sent, n);
if (om == NULL) {
return BLE_UART_ENOMEM;
}
int rc = ble_gatts_notify_custom(conn_handle, s_tx_val_handle, om);
if (rc != 0) {
ESP_LOGW(TAG, "notify failed: rc=%d", rc);
/* Callee frees om on every failure path EXCEPT the
* BLE_GATT_NOTIFY-disabled early-return (BLE_HS_ENOTSUP).
* Freeing on any other rc would be a double free. */
if (rc == BLE_HS_ENOTSUP) {
os_mbuf_free_chain(om);
}
return xlate_rc(rc);
}
sent += n;
}
return BLE_UART_OK;
}
/* Best-effort snapshots; see header for threading caveat. */
bool ble_uart_is_connected(void) { return s_conn_handle != BLE_HS_CONN_HANDLE_NONE; }
bool ble_uart_is_subscribed(void) { return s_subscribed; }
/* ===== Advertising ==================================================== */
static int start_advertising(void)
{
/* 31-byte primary adv can't hold flags + tx_pwr + name + 128-bit
* UUID together, so split: primary = flags+tx_pwr+name,
* scan rsp = NUS UUID. */
const char *name = s_dev_name;
size_t name_len = strlen(name);
struct ble_hs_adv_fields adv = {
.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP,
.tx_pwr_lvl_is_present = 1,
.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO,
/* If no name was set, advertise without one (NimBLE accepts
* NULL+0); the NUS UUID in scan rsp still identifies us. */
.name = name_len > 0 ? (uint8_t *)name : NULL,
.name_len = name_len,
.name_is_complete = name_len > 0 ? 1 : 0,
};
int rc = ble_gap_adv_set_fields(&adv);
if (rc != 0) {
ESP_LOGE(TAG, "adv_set_fields rc=%d (name too long?)", rc);
return rc;
}
struct ble_hs_adv_fields rsp = {
.uuids128 = &s_svc_uuid,
.num_uuids128 = 1,
.uuids128_is_complete = 1,
};
rc = ble_gap_adv_rsp_set_fields(&rsp);
if (rc != 0) {
ESP_LOGE(TAG, "adv_rsp_set_fields rc=%d", rc);
return rc;
}
struct ble_gap_adv_params params = {
.conn_mode = BLE_GAP_CONN_MODE_UND,
.disc_mode = BLE_GAP_DISC_MODE_GEN,
};
rc = ble_gap_adv_start(s_own_addr_type, NULL, BLE_HS_FOREVER,
&params, gap_event, NULL);
if (rc != 0) {
ESP_LOGE(TAG, "adv_start rc=%d", rc);
return rc;
}
ESP_LOGI(TAG, "advertising as '%s'", name_len > 0 ? name : "<no name>");
return 0;
}
/* ===== GAP event handler ============================================== */
static void show_passkey(uint32_t passkey)
{
ESP_LOGW(TAG, "");
ESP_LOGW(TAG, " +-----------------------------+");
ESP_LOGW(TAG, " | BLE PAIRING PASSKEY: |");
ESP_LOGW(TAG, " | %06" PRIu32 " |", passkey);
ESP_LOGW(TAG, " +-----------------------------+");
ESP_LOGW(TAG, "");
}
static int gap_event(struct ble_gap_event *event, void *arg)
{
struct ble_gap_conn_desc desc;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
ESP_LOGI(TAG, "connect %s status=%d handle=%d",
event->connect.status == 0 ? "ok" : "failed",
event->connect.status,
event->connect.conn_handle);
if (event->connect.status == 0) {
s_conn_handle = event->connect.conn_handle;
s_subscribed = false;
/* Start pairing immediately (rather than lazily on the
* first encrypted attribute access). */
if (s_cfg.encrypted) {
ble_gap_security_initiate(event->connect.conn_handle);
}
} else if (!s_shutting_down) {
start_advertising();
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI(TAG, "disconnect reason=%d", event->disconnect.reason);
s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
s_subscribed = false;
if (!s_shutting_down) {
start_advertising();
}
return 0;
case BLE_GAP_EVENT_CONN_UPDATE:
ESP_LOGI(TAG, "conn_update status=%d", event->conn_update.status);
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
ESP_LOGI(TAG, "adv_complete reason=%d", event->adv_complete.reason);
if (!s_shutting_down) {
start_advertising();
}
return 0;
case BLE_GAP_EVENT_ENC_CHANGE:
if (ble_gap_conn_find(event->enc_change.conn_handle, &desc) == 0) {
ESP_LOGI(TAG, "enc_change status=%d encrypted=%d authenticated=%d bonded=%d",
event->enc_change.status,
desc.sec_state.encrypted,
desc.sec_state.authenticated,
desc.sec_state.bonded);
}
return 0;
case BLE_GAP_EVENT_REPEAT_PAIRING:
/* Drop old keys + retry rather than reject. */
if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) == 0) {
ble_store_util_delete_peer(&desc.peer_id_addr);
}
return BLE_GAP_REPEAT_PAIRING_RETRY;
case BLE_GAP_EVENT_PASSKEY_ACTION:
if (event->passkey.params.action == BLE_SM_IOACT_DISP) {
/* Rejection sampling avoids the modulo bias of
* `esp_random() % 1000000` (2^32 % 1e6 != 0). */
const uint32_t passkey_max = 1000000U;
const uint32_t reject_above = UINT32_MAX -
(UINT32_MAX % passkey_max);
uint32_t r;
do {
r = esp_random();
} while (r >= reject_above);
struct ble_sm_io pkey = {
.action = BLE_SM_IOACT_DISP,
.passkey = r % passkey_max,
};
show_passkey(pkey.passkey);
int rc = ble_sm_inject_io(event->passkey.conn_handle, &pkey);
if (rc != 0) {
ESP_LOGW(TAG, "ble_sm_inject_io rc=%d", rc);
}
} else {
ESP_LOGW(TAG, "passkey action %d not handled (DisplayOnly only)",
event->passkey.params.action);
}
return 0;
case BLE_GAP_EVENT_MTU:
ESP_LOGI(TAG, "mtu=%d (conn=%d)",
event->mtu.value, event->mtu.conn_handle);
return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
ESP_LOGI(TAG, "subscribe attr=%d cur_notify=%d",
event->subscribe.attr_handle, event->subscribe.cur_notify);
if (event->subscribe.attr_handle == s_tx_val_handle) {
s_subscribed = (event->subscribe.cur_notify != 0);
}
return 0;
default:
return 0;
}
}
/* ===== Host plumbing =================================================== */
static void on_reset(int reason)
{
ESP_LOGE(TAG, "Resetting NimBLE state; reason=%d", reason);
}
static void on_sync(void)
{
int rc = ble_hs_util_ensure_addr(0);
assert(rc == 0);
rc = ble_hs_id_infer_auto(0, &s_own_addr_type);
if (rc != 0) {
ESP_LOGE(TAG, "infer addr type rc=%d", rc);
return;
}
uint8_t addr[6] = {0};
ble_hs_id_copy_addr(s_own_addr_type, addr, NULL);
ESP_LOGI(TAG, "addr=%02x:%02x:%02x:%02x:%02x:%02x",
addr[5], addr[4], addr[3], addr[2], addr[1], addr[0]);
start_advertising();
}
static void nimble_host_task(void *param)
{
ESP_LOGI(TAG, "BLE host task started");
nimble_port_run();
nimble_port_freertos_deinit();
}
/* ===== Public lifecycle ================================================ */
int ble_uart_install(const ble_uart_config_t *cfg)
{
if (s_installed) {
ESP_LOGW(TAG, "ble_uart_install called twice; ignoring");
return BLE_UART_EALREADY;
}
if (cfg != NULL) {
s_cfg = *cfg;
} else {
memset(&s_cfg, 0, sizeof(s_cfg));
}
esp_err_t err = nimble_port_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "nimble_port_init rc=%d", err);
return BLE_UART_EFAIL;
}
/* From here every failure must `goto fail` so nimble_port_deinit()
* runs — leaving the port allocated breaks the next install(). */
ble_hs_cfg.reset_cb = on_reset;
ble_hs_cfg.sync_cb = on_sync;
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
ble_hs_cfg.gatts_register_cb = register_cb;
/* Encrypted = LE Secure Connections + Bonding + MITM, DisplayOnly.
* Plaintext = SM disabled. */
if (s_cfg.encrypted) {
ble_hs_cfg.sm_io_cap = BLE_HS_IO_DISPLAY_ONLY;
ble_hs_cfg.sm_sc = 1;
ble_hs_cfg.sm_bonding = 1;
ble_hs_cfg.sm_mitm = 1;
ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
} else {
ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT;
ble_hs_cfg.sm_sc = 0;
ble_hs_cfg.sm_bonding = 0;
ble_hs_cfg.sm_mitm = 0;
}
ble_svc_gap_init();
ble_svc_gatt_init();
/* Cache the device name into our own buffer (caller's pointer may
* not outlive this call; also avoids the GAP-service stub path
* which returns NULL from ble_svc_gap_device_name()). */
int rc = 0;
if (s_cfg.device_name != NULL) {
strncpy(s_dev_name, s_cfg.device_name, sizeof(s_dev_name) - 1);
s_dev_name[sizeof(s_dev_name) - 1] = '\0';
s_cfg.device_name = NULL;
/* Best-effort: also set in the GAP service for peer reads.
* Returns -1 on the stub path — fine, we already cached locally. */
rc = ble_svc_gap_device_name_set(s_dev_name);
if (rc != 0) {
ESP_LOGI(TAG, "ble_svc_gap_device_name_set rc=%d (GAP service stubbed?)",
rc);
rc = 0;
}
} else {
s_dev_name[0] = '\0';
}
build_gatt_table(s_cfg.encrypted);
rc = ble_gatts_count_cfg(s_svc_defs);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gatts_count_cfg rc=%d", rc);
goto fail;
}
rc = ble_gatts_add_svcs(s_svc_defs);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gatts_add_svcs rc=%d", rc);
goto fail;
}
s_installed = true;
return BLE_UART_OK;
fail:
nimble_port_deinit();
memset(&s_cfg, 0, sizeof(s_cfg));
return xlate_rc(rc);
}
int ble_uart_open(void)
{
if (!s_installed) {
ESP_LOGE(TAG, "ble_uart_open before ble_uart_install");
return BLE_UART_EINVAL;
}
if (s_opened) {
ESP_LOGW(TAG, "ble_uart_open called twice; ignoring");
return BLE_UART_EALREADY;
}
/* NVS bond store (requires CONFIG_BT_NIMBLE_NVS_PERSIST=y). */
ble_store_config_init();
/* Spawn host task; on_sync starts advertising once controller is ready. */
nimble_port_freertos_init(nimble_host_task);
s_opened = true;
return BLE_UART_OK;
}
int ble_uart_close(void)
{
if (!s_opened) {
return BLE_UART_EALREADY;
}
/* Latch first so GAP events stop re-arming advertising. */
s_shutting_down = true;
int rc = ble_gap_adv_stop();
if (rc != 0 && rc != BLE_HS_EALREADY) {
ESP_LOGW(TAG, "adv_stop rc=%d", rc);
}
/* Graceful disconnect: wait up to 500 ms for the disconnect event
* so the peer sees a proper LL_TERMINATE_IND, not a controller-yank. */
if (s_conn_handle != BLE_HS_CONN_HANDLE_NONE) {
rc = ble_gap_terminate(s_conn_handle, BLE_ERR_REM_USER_CONN_TERM);
if (rc != 0 && rc != BLE_HS_EALREADY) {
ESP_LOGW(TAG, "ble_gap_terminate rc=%d", rc);
}
for (int i = 0; i < 50 && s_conn_handle != BLE_HS_CONN_HANDLE_NONE; i++) {
vTaskDelay(pdMS_TO_TICKS(10));
}
if (s_conn_handle != BLE_HS_CONN_HANDLE_NONE) {
ESP_LOGW(TAG, "disconnect timed out; tearing down anyway");
}
}
/* nimble_host_task self-cleans (port_freertos_deinit + delete) when
* port_run returns, so no explicit join. */
rc = nimble_port_stop();
if (rc != 0) {
ESP_LOGE(TAG, "nimble_port_stop rc=%d", rc);
s_shutting_down = false;
return BLE_UART_EFAIL;
}
s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
s_subscribed = false;
s_opened = false;
s_shutting_down = false;
return BLE_UART_OK;
}
int ble_uart_uninstall(void)
{
if (!s_installed) {
return BLE_UART_EALREADY;
}
/* Best-effort cleanup. Do NOT early-return on a per-step failure:
* leaving s_installed=true with partially torn-down NimBLE state
* makes the module unrecoverable (can't re-install, can't retry
* uninstall cleanly). Mirror the Bluedroid backend: record the
* first error, keep tearing down, and always wipe our state. */
int first_rc = BLE_UART_OK;
if (s_opened) {
int rc = ble_uart_close();
if (rc != BLE_UART_OK && rc != BLE_UART_EALREADY) {
ESP_LOGE(TAG, "ble_uart_close rc=%d", rc);
if (first_rc == BLE_UART_OK) {
first_rc = rc;
}
}
}
/* Best-effort: even if port_deinit fails, wipe our state anyway —
* otherwise s_installed stays true and the module is unrecoverable
* (can't re-install, can't retry uninstall cleanly). */
esp_err_t err = nimble_port_deinit();
if (err != ESP_OK) {
ESP_LOGE(TAG, "nimble_port_deinit rc=%d", err);
if (first_rc == BLE_UART_OK) {
first_rc = BLE_UART_EFAIL;
}
}
memset(&s_cfg, 0, sizeof(s_cfg));
s_dev_name[0] = '\0';
s_tx_val_handle = 0;
s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
s_subscribed = false;
s_own_addr_type = 0;
s_shutting_down = false;
s_installed = false;
s_opened = false;
return first_rc;
}
#endif /* CONFIG_BT_NIMBLE_ENABLED */

View File

@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Unlicense OR CC0-1.0
*
* BLE UART Service example. Backend (NimBLE / Bluedroid) is picked
* by the host-stack Kconfig at compile time. Whatever the central
* writes to the RX characteristic is echoed back over TX.
*/
#include <stdio.h>
#include "esp_log.h"
#include "esp_mac.h"
#include "nvs_flash.h"
#include "sdkconfig.h"
#include "ble_uart.h"
static const char *TAG = "app";
static void ble_uart_on_rx(const uint8_t *data, size_t len)
{
ESP_LOGI(TAG, "rx len: %u bytes", (unsigned)len);
if (data == NULL || len == 0) {
return;
}
ESP_LOG_BUFFER_HEX(TAG, data, len);
ble_uart_tx(data, len); /* echo back */
}
void app_main(void)
{
/* NVS is required by the BT controller (PHY calibration) and the
* bond store, so it must be live before ble_uart_install(). */
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
/* Device name = "<prefix>-XXXX" with XXXX = last two MAC bytes.
* If esp_read_mac() fails, mac stays zero and the suffix degrades
* to "0000" — log so the operator notices uniqueness was lost. */
uint8_t mac[6] = {0};
esp_err_t mac_err = esp_read_mac(mac, ESP_MAC_BT);
if (mac_err != ESP_OK) {
ESP_LOGW(TAG, "esp_read_mac(BT) failed (%s); device name suffix will be 0000",
esp_err_to_name(mac_err));
}
char name[24];
snprintf(name, sizeof(name), "%s-%02X%02X",
CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]);
ESP_ERROR_CHECK(ble_uart_install(&(ble_uart_config_t){
.encrypted = true,
.device_name = name,
.ble_uart_on_rx = ble_uart_on_rx,
}));
ESP_ERROR_CHECK(ble_uart_open());
}

View File

@@ -0,0 +1,42 @@
# Overlay applied on top of sdkconfig.defaults to switch the example
# from the default NimBLE backend to Bluedroid. Use it like:
#
# idf.py -B build_bd \
# -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.ci.bluedroid" \
# reconfigure
# idf.py -B build_bd build flash monitor
#
# When this overlay wins, main/CMakeLists.txt links ble_uart_bluedroid.c
# instead of ble_uart_nimble.c. The public ble_uart.h API is identical
# either way.
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=n
CONFIG_BT_BLUEDROID_ENABLED=y
# LE Secure Connections + bonding (matches the NimBLE side).
# CONFIG_BT_SMP_ENABLE is derived from this and BT_CLASSIC_ENABLED, so
# we don't set it explicitly.
CONFIG_BT_BLE_SMP_ENABLE=y
# Note: Bluedroid has no compile-time MTU Kconfig (the NimBLE
# CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU has no Bluedroid counterpart).
# To negotiate a larger ATT MTU at runtime, the application calls
# esp_ble_gatt_set_local_mtu(<bytes>) before peers connect.
# ble_uart_tx auto-fragments to whatever MTU is live, so the default
# 23 also works — just at lower throughput.
# Service-table API is needed for esp_ble_gatts_create_attr_tab().
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

View File

@@ -0,0 +1,22 @@
# Bluetooth controller in BLE-only mode + NimBLE host stack.
# CONFIG_BTDM_CTRL_MODE_* are ESP32-classic-only knobs; on C2/C3/C5/C6/
# C61/H2/H4/S3 they may emit an "unknown symbol" warning at configure
# time but are otherwise harmless, so this single sdkconfig.defaults
# stays valid for every supported target.
CONFIG_BT_ENABLED=y
CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y
CONFIG_BTDM_CTRL_MODE_BR_EDR_ONLY=n
CONFIG_BTDM_CTRL_MODE_BTDM=n
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_ENABLED=y
# Negotiate the largest ATT MTU we can; ble_uart_tx auto-fragments to
# the live MTU so smaller-MTU centrals still work.
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=512
# LE Secure Connections + persistent bond store. The example defaults to
# encrypted operation (ble_uart_config_t::encrypted=true) and stores LTK
# in NVS so a previously-paired peer reconnects without re-prompting for
# the passkey.
CONFIG_BT_NIMBLE_SM_SC=y
CONFIG_BT_NIMBLE_NVS_PERSIST=y