diff --git a/examples/bluetooth/ble_uart_service/CMakeLists.txt b/examples/bluetooth/ble_uart_service/CMakeLists.txt new file mode 100644 index 00000000000..1982eca7959 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/CMakeLists.txt @@ -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) diff --git a/examples/bluetooth/ble_uart_service/PORTING.md b/examples/bluetooth/ble_uart_service/PORTING.md new file mode 100644 index 00000000000..47e3859c58f --- /dev/null +++ b/examples/bluetooth/ble_uart_service/PORTING.md @@ -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. diff --git a/examples/bluetooth/ble_uart_service/README.md b/examples/bluetooth/ble_uart_service/README.md new file mode 100644 index 00000000000..8b0169ad7b5 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/README.md @@ -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. diff --git a/examples/bluetooth/ble_uart_service/main/CMakeLists.txt b/examples/bluetooth/ble_uart_service/main/CMakeLists.txt new file mode 100644 index 00000000000..c05578d30a9 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/CMakeLists.txt @@ -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) diff --git a/examples/bluetooth/ble_uart_service/main/Kconfig.projbuild b/examples/bluetooth/ble_uart_service/main/Kconfig.projbuild new file mode 100644 index 00000000000..0f3654d8305 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/Kconfig.projbuild @@ -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 `-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 diff --git a/examples/bluetooth/ble_uart_service/main/ble_uart.h b/examples/bluetooth/ble_uart_service/main/ble_uart.h new file mode 100644 index 00000000000..037b48390d7 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/ble_uart.h @@ -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 +#include +#include + +#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 diff --git a/examples/bluetooth/ble_uart_service/main/ble_uart_bluedroid.c b/examples/bluetooth/ble_uart_service/main/ble_uart_bluedroid.c new file mode 100644 index 00000000000..6f93ce057d2 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/ble_uart_bluedroid.c @@ -0,0 +1,1022 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + * + * BLE UART — Bluedroid backend. Implements the lifecycle declared in + * ble_uart.h on top of the Bluedroid host using the service-table API + * (esp_ble_gatts_create_attr_tab). Active when + * CONFIG_BT_BLUEDROID_ENABLED=y; otherwise ble_uart_nimble.c is used. + * + * Differences vs the NimBLE backend: + * - PREP/EXEC long-write reassembly is open-coded (NimBLE does it + * for us; Bluedroid hands raw fragments to the application). + * - MTU is cached locally in s_local_mtu — no Bluedroid query API. + */ + +#include "sdkconfig.h" + +#if CONFIG_BT_BLUEDROID_ENABLED + +#include "ble_uart.h" + +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "esp_log.h" +#include "esp_bt.h" +#include "esp_bt_main.h" +#include "esp_bt_defs.h" +#include "esp_bt_device.h" +#include "esp_gap_ble_api.h" +#include "esp_gatt_common_api.h" +#include "esp_gatts_api.h" + +static const char *TAG = "ble_uart"; + +/* ===== Backend constants ============================================== */ + +#define UART_APP_ID 0x55 /* arbitrary, must be unique per profile */ +#define UART_SVC_INST_ID 0 /* single service instance */ + +/* RX scratch capacity. See ble_uart_nimble.c for rationale. */ +#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 + +/* ===== UUIDs =========================================================== */ + +const ble_uart_uuid128_t ble_uart_service_uuid = { + .bytes = { 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e }, +}; + +static const uint8_t s_svc_uuid_bytes[16] = { + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e, +}; +static const uint8_t s_chr_rx_uuid[16] = { + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x02, 0x00, 0x40, 0x6e, +}; +static const uint8_t s_chr_tx_uuid[16] = { + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x03, 0x00, 0x40, 0x6e, +}; + +/* 16-bit UUIDs used in the attr table (declarations + CCCD). */ +static const uint16_t s_pri_svc_uuid = ESP_GATT_UUID_PRI_SERVICE; +static const uint16_t s_char_decl_uuid = ESP_GATT_UUID_CHAR_DECLARE; +static const uint16_t s_cccd_uuid = ESP_GATT_UUID_CHAR_CLIENT_CONFIG; + +static const uint8_t s_char_prop_write_nr = + ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_WRITE_NR; +static const uint8_t s_char_prop_notify = ESP_GATT_CHAR_PROP_BIT_NOTIFY; + +static const uint8_t s_cccd_default[2] = {0x00, 0x00}; + +/* Placeholders for the value attributes — RX is RSP_BY_APP (we own + * the response), TX is set on demand by send_indicate(). */ +static uint8_t s_rx_val_placeholder[1]; +static uint8_t s_tx_val_placeholder[1]; + +/* ===== Service-table indices ========================================== */ + +enum { + NUS_IDX_SVC, + NUS_IDX_RX_DECL, + NUS_IDX_RX_VAL, + NUS_IDX_TX_DECL, + NUS_IDX_TX_VAL, + NUS_IDX_TX_CCCD, + NUS_IDX_NB, +}; + +/* ===== State =========================================================== */ + +static ble_uart_config_t s_cfg; +static esp_gatt_if_t s_gatts_if = ESP_GATT_IF_NONE; +static uint16_t s_handles[NUS_IDX_NB]; +/* Volatile: written from BTC task, polled from caller task. */ +static volatile uint16_t s_conn_id = 0xFFFF; /* invalid sentinel */ +static esp_bd_addr_t s_remote_bda; +static uint16_t s_local_mtu = 23; /* spec default */ +static bool s_subscribed; +static bool s_installed; +static bool s_opened; +static bool s_shutting_down; +static volatile bool s_attr_tab_ready; +static bool s_adv_active; + +/* Two-bit latch driving the "configure adv data + scan rsp before + * start_advertising" sequence. start_advertising fires only when both + * SET_COMPLETE_EVT events have cleared their bit. */ +#define ADV_CONFIG_FLAG (1 << 0) +#define SCAN_RSP_CONFIG_FLAG (1 << 1) +static uint8_t s_adv_config_done; + +/* Long-write accumulator (Bluedroid doesn't reassemble for us). */ +static uint8_t s_rx_buf[RX_SCRATCH]; +static uint16_t s_prep_len; +static bool s_prep_bad; + +/* ===== Backend rc → public rc ========================================= */ + +static int xlate_rc(esp_err_t rc) +{ + switch (rc) { + case ESP_OK: return BLE_UART_OK; + case ESP_ERR_INVALID_ARG: + case ESP_ERR_INVALID_STATE: return BLE_UART_EINVAL; + case ESP_ERR_NO_MEM: return BLE_UART_ENOMEM; + default: return BLE_UART_EFAIL; + } +} + +/* ===== GATT attribute table =========================================== */ + +/* Permissions are patched at install time depending on cfg.encrypted. */ +static esp_gatts_attr_db_t s_nus_db[NUS_IDX_NB]; + +static void build_attr_table(bool encrypted) +{ + const esp_gatt_perm_t r_perm = encrypted + ? (ESP_GATT_PERM_READ_ENC_MITM) + : (ESP_GATT_PERM_READ); + const esp_gatt_perm_t w_perm = encrypted + ? (ESP_GATT_PERM_WRITE_ENC_MITM) + : (ESP_GATT_PERM_WRITE); + + /* [SVC] primary service declaration */ + s_nus_db[NUS_IDX_SVC] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_16, + .uuid_p = (uint8_t *)&s_pri_svc_uuid, + .perm = ESP_GATT_PERM_READ, + .max_length = sizeof(s_svc_uuid_bytes), + .length = sizeof(s_svc_uuid_bytes), + .value = (uint8_t *)s_svc_uuid_bytes, + }, + }; + + /* [RX] characteristic declaration (Write + Write-no-rsp) */ + s_nus_db[NUS_IDX_RX_DECL] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_16, + .uuid_p = (uint8_t *)&s_char_decl_uuid, + .perm = ESP_GATT_PERM_READ, + .max_length = sizeof(s_char_prop_write_nr), + .length = sizeof(s_char_prop_write_nr), + .value = (uint8_t *)&s_char_prop_write_nr, + }, + }; + + /* [RX value] — RSP_BY_APP because Bluedroid can't auto-respond + * to PREP_WRITE_REQ; we own the response path. */ + s_nus_db[NUS_IDX_RX_VAL] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_RSP_BY_APP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_128, + .uuid_p = (uint8_t *)s_chr_rx_uuid, + .perm = w_perm, + .max_length = RX_SCRATCH, + .length = sizeof(s_rx_val_placeholder), + .value = s_rx_val_placeholder, + }, + }; + + /* [TX] characteristic declaration (Notify only) */ + s_nus_db[NUS_IDX_TX_DECL] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_16, + .uuid_p = (uint8_t *)&s_char_decl_uuid, + .perm = ESP_GATT_PERM_READ, + .max_length = sizeof(s_char_prop_notify), + .length = sizeof(s_char_prop_notify), + .value = (uint8_t *)&s_char_prop_notify, + }, + }; + + /* [TX value] — NUS spec is notify-only, so the prop above doesn't + * advertise READ; perm only matters if a client tries READ anyway. */ + s_nus_db[NUS_IDX_TX_VAL] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_128, + .uuid_p = (uint8_t *)s_chr_tx_uuid, + .perm = r_perm, + .max_length = RX_SCRATCH, + .length = sizeof(s_tx_val_placeholder), + .value = s_tx_val_placeholder, + }, + }; + + /* [TX CCCD] — central writes 0x0001 to subscribe, 0x0000 to stop. + * w_perm enforces the encryption requirement. */ + s_nus_db[NUS_IDX_TX_CCCD] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_16, + .uuid_p = (uint8_t *)&s_cccd_uuid, + .perm = ESP_GATT_PERM_READ | w_perm, + .max_length = sizeof(uint16_t), + .length = sizeof(s_cccd_default), + .value = (uint8_t *)s_cccd_default, + }, + }; +} + +/* ===== Advertising ==================================================== */ + +static esp_ble_adv_data_t s_adv_data = { + .set_scan_rsp = false, + .include_name = true, + .include_txpower = true, + .min_interval = 0, + .max_interval = 0, + .appearance = 0x00, + .manufacturer_len = 0, + .p_manufacturer_data = NULL, + .service_data_len = 0, + .p_service_data = NULL, + .service_uuid_len = 0, + .p_service_uuid = NULL, + .flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT), +}; + +/* Scan response = NUS UUID. Splitting it off the primary payload + * leaves room for name + tx_pwr in the 31-byte primary. */ +static esp_ble_adv_data_t s_scan_rsp_data = { + .set_scan_rsp = true, + .include_name = false, + .include_txpower = false, + .service_uuid_len = sizeof(s_svc_uuid_bytes), + .p_service_uuid = (uint8_t *)s_svc_uuid_bytes, +}; + +static esp_ble_adv_params_t s_adv_params = { + .adv_int_min = 0xa0, /* 100 ms */ + .adv_int_max = 0xa0, + .adv_type = ADV_TYPE_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, +}; + +static int start_advertising(void) +{ + esp_err_t rc = esp_ble_gap_start_advertising(&s_adv_params); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "adv_start rc=%s", esp_err_to_name(rc)); + return xlate_rc(rc); + } + s_adv_active = true; + return BLE_UART_OK; +} + +/* Push adv data + scan response. start_advertising is triggered from + * the matching SET_COMPLETE_EVT once both halves are realised. + * + * On a sync failure of either config call, the matching SET_COMPLETE_EVT + * will NEVER fire — so we must wipe the latch entirely (not just clear + * one bit) to avoid (a) advertising silently lost, or (b) the other + * half's event firing start_advertising with a half-configured payload. */ +static int configure_advertising(void) +{ + s_adv_config_done = ADV_CONFIG_FLAG | SCAN_RSP_CONFIG_FLAG; + + esp_err_t rc = esp_ble_gap_config_adv_data(&s_adv_data); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "config_adv_data rc=%s", esp_err_to_name(rc)); + s_adv_config_done = 0; + return xlate_rc(rc); + } + rc = esp_ble_gap_config_adv_data(&s_scan_rsp_data); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "config_scan_rsp rc=%s", esp_err_to_name(rc)); + /* adv_data is in flight; its SET_COMPLETE_EVT will hit the + * "stale event" branch in the GAP handler and bail. */ + s_adv_config_done = 0; + return xlate_rc(rc); + } + return BLE_UART_OK; +} + +/* ===== Passkey banner ================================================= */ + +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, ""); +} + +/* ===== Long-write accumulator ========================================= */ + +static void prep_reset(void) +{ + s_prep_len = 0; + s_prep_bad = false; +} + +/* Append one PREP_WRITE fragment. Bad fragments latch s_prep_bad; we + * still answer OK to the client so its prepare-queue advances, then + * reject the whole batch at EXEC time (BT Core Spec §3.4.6.3 model). + * + * Two checks, both required: + * 1. Bounds — would otherwise memcpy past s_rx_buf. + * 2. Strict ascending contiguity — the spec lets a hostile client + * send arbitrary offsets; if we accepted gaps, the bytes between + * the writes would leak from the previous transaction (s_rx_buf + * is BSS and prep_reset() doesn't zero it). All real-world BLE + * centrals send PREP fragments in strict order anyway. */ +static void prep_append(uint16_t offset, const uint8_t *data, uint16_t len) +{ + if (s_prep_bad) { + return; + } + if ((uint32_t)offset + len > sizeof(s_rx_buf)) { + ESP_LOGW(TAG, "prep_write overflow: offset=%u len=%u cap=%u", + offset, len, (unsigned)sizeof(s_rx_buf)); + s_prep_bad = true; + return; + } + if (offset != s_prep_len) { + ESP_LOGW(TAG, "prep_write non-contiguous: offset=%u expected=%u", + offset, s_prep_len); + s_prep_bad = true; + return; + } + memcpy(s_rx_buf + offset, data, len); + s_prep_len = offset + len; +} + +/* ===== GATT event handler ============================================= */ + +/* Reply to a PREP_WRITE_REQ by echoing the value (BT spec mandates). */ +static void send_prep_write_response(uint16_t conn_id, uint32_t trans_id, + esp_gatt_status_t status, + uint16_t handle, uint16_t offset, + const uint8_t *value, uint16_t len) +{ + esp_gatt_rsp_t rsp = {0}; + rsp.attr_value.handle = handle; + rsp.attr_value.offset = offset; + rsp.attr_value.len = (len > sizeof(rsp.attr_value.value)) + ? sizeof(rsp.attr_value.value) + : len; + if (rsp.attr_value.len) { + memcpy(rsp.attr_value.value, value, rsp.attr_value.len); + } + esp_ble_gatts_send_response(s_gatts_if, conn_id, trans_id, status, &rsp); +} + +static void handle_write(esp_ble_gatts_cb_param_t *p) +{ + uint16_t handle = p->write.handle; + + if (p->write.is_prep) { + /* Two-tier validation per BT Core Spec §3.4.6.1 / §3.4.6.3: + * - Attribute-level (wrong handle): error in the PREP_RSP. + * - Fragment-level (offset/oversize/non-contiguous): latched + * in s_prep_bad; PREP_RSP stays OK and the error is + * surfaced once at EXEC time. */ + esp_gatt_status_t status = ESP_GATT_OK; + if (handle != s_handles[NUS_IDX_RX_VAL]) { + status = ESP_GATT_INVALID_HANDLE; + } else { + prep_append(p->write.offset, p->write.value, p->write.len); + /* status stays OK even if s_prep_bad got latched. */ + } + if (p->write.need_rsp) { + send_prep_write_response(p->write.conn_id, p->write.trans_id, + status, handle, p->write.offset, + p->write.value, p->write.len); + } + return; + } + + /* Plain (non-prep) write. */ + esp_gatt_status_t status = ESP_GATT_OK; + if (handle == s_handles[NUS_IDX_RX_VAL]) { + if (s_cfg.ble_uart_on_rx != NULL && p->write.len > 0) { + s_cfg.ble_uart_on_rx(p->write.value, p->write.len); + } + } else if (handle == s_handles[NUS_IDX_TX_CCCD]) { + /* CCCD must be exactly 2 bytes (BT Core §3.3.3.3). */ + if (p->write.len != 2) { + ESP_LOGW(TAG, "malformed CCCD write len=%u", p->write.len); + status = ESP_GATT_INVALID_ATTR_LEN; + } else { + uint16_t cccd = (uint16_t)p->write.value[0] + | ((uint16_t)p->write.value[1] << 8); + s_subscribed = (cccd & 0x0001) != 0; + ESP_LOGI(TAG, "subscribe cccd=0x%04x sub=%d", cccd, s_subscribed); + } + } + + if (p->write.need_rsp) { + esp_ble_gatts_send_response(s_gatts_if, p->write.conn_id, + p->write.trans_id, status, NULL); + } +} + +static void handle_exec_write(esp_ble_gatts_cb_param_t *p) +{ + /* CANCEL (flag=0x00) must always succeed; EXEC (flag=0x01) returns + * INVALID_ATTR_LEN if PREP latched an error. Either way, drop + * the queue. */ + esp_gatt_status_t status = ESP_GATT_OK; + + if (p->exec_write.exec_write_flag == ESP_GATT_PREP_WRITE_EXEC) { + if (s_prep_bad) { + ESP_LOGW(TAG, "exec_write: rejecting bad prep buffer (len=%u)", + s_prep_len); + status = ESP_GATT_INVALID_ATTR_LEN; + } else if (s_cfg.ble_uart_on_rx != NULL && s_prep_len > 0) { + s_cfg.ble_uart_on_rx(s_rx_buf, s_prep_len); + } + } + prep_reset(); + esp_ble_gatts_send_response(s_gatts_if, p->exec_write.conn_id, + p->exec_write.trans_id, status, NULL); +} + +static void gatts_profile_event_handler(esp_gatts_cb_event_t event, + esp_gatt_if_t gatts_if, + esp_ble_gatts_cb_param_t *param) +{ + switch (event) { + + case ESP_GATTS_REG_EVT: + ESP_LOGI(TAG, "gatts reg status=%d app_id=%u gatts_if=%u", + param->reg.status, param->reg.app_id, gatts_if); + if (param->reg.status != ESP_GATT_OK) { + return; + } + s_gatts_if = gatts_if; + if (s_cfg.device_name != NULL) { + esp_ble_gap_set_device_name(s_cfg.device_name); + /* Bluedroid copies internally; drop our pointer. */ + s_cfg.device_name = NULL; + } + esp_ble_gatts_create_attr_tab(s_nus_db, gatts_if, + NUS_IDX_NB, UART_SVC_INST_ID); + break; + + case ESP_GATTS_CREAT_ATTR_TAB_EVT: + if (param->add_attr_tab.status != ESP_GATT_OK + || param->add_attr_tab.num_handle != NUS_IDX_NB) { + ESP_LOGE(TAG, "create_attr_tab failed status=%d num=%u (expected %u)", + param->add_attr_tab.status, + param->add_attr_tab.num_handle, NUS_IDX_NB); + return; + } + memcpy(s_handles, param->add_attr_tab.handles, sizeof(s_handles)); + ESP_LOGI(TAG, "registered service svc_handle=%u rx=%u tx=%u cccd=%u", + s_handles[NUS_IDX_SVC], s_handles[NUS_IDX_RX_VAL], + s_handles[NUS_IDX_TX_VAL], s_handles[NUS_IDX_TX_CCCD]); + esp_ble_gatts_start_service(s_handles[NUS_IDX_SVC]); + s_attr_tab_ready = true; + /* If open() was waiting for the table, kick adv config now. + * On the BTC task — no caller to return errors to. */ + if (s_opened && !s_adv_active) { + (void)configure_advertising(); + } + break; + + case ESP_GATTS_WRITE_EVT: + handle_write(param); + break; + + case ESP_GATTS_EXEC_WRITE_EVT: + handle_exec_write(param); + break; + + case ESP_GATTS_MTU_EVT: + s_local_mtu = param->mtu.mtu; + ESP_LOGI(TAG, "mtu=%u (conn=%u)", s_local_mtu, param->mtu.conn_id); + break; + + case ESP_GATTS_CONNECT_EVT: + /* Bluedroid only fires this on a successful physical link; + * the param struct has no status field. */ + s_conn_id = param->connect.conn_id; + s_subscribed = false; + /* Link-layer auto-stops undirected adv on connect (per BT spec) + * but Bluedroid doesn't deliver ADV_STOP_COMPLETE_EVT for that + * case — only for explicit stop_advertising(). Clear here so + * close() doesn't try to stop an already-stopped advertiser. */ + s_adv_active = false; + memcpy(s_remote_bda, param->connect.remote_bda, sizeof(s_remote_bda)); + ESP_LOGI(TAG, "connect conn_id=%u remote " ESP_BD_ADDR_STR, + s_conn_id, ESP_BD_ADDR_HEX(s_remote_bda)); + if (s_cfg.encrypted) { + /* Kick pairing immediately rather than lazily on the + * first encrypted attribute access. */ + esp_ble_set_encryption(param->connect.remote_bda, + ESP_BLE_SEC_ENCRYPT_MITM); + } + break; + + case ESP_GATTS_DISCONNECT_EVT: + ESP_LOGI(TAG, "disconnect conn_id=%u reason=0x%x", + param->disconnect.conn_id, param->disconnect.reason); + s_conn_id = 0xFFFF; + s_subscribed = false; + /* MTU is per-connection: reset to the spec default 23 so the + * next peer (if it skips the MTU exchange) doesn't inherit + * the previous link's negotiated value and overflow tx chunks. */ + s_local_mtu = 23; + prep_reset(); + if (!s_shutting_down) { + start_advertising(); + } + break; + + case ESP_GATTS_CONF_EVT: + if (param->conf.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "notify confirm status=%d handle=%u", + param->conf.status, param->conf.handle); + } + break; + + case ESP_GATTS_READ_EVT: + ESP_LOGD(TAG, "read on handle=%u (ignored)", param->read.handle); + break; + + case ESP_GATTS_UNREG_EVT: + s_gatts_if = ESP_GATT_IF_NONE; + s_attr_tab_ready = false; + break; + + default: + break; + } +} + +/* ===== GAP event handler ============================================== */ + +static void gap_event_handler(esp_gap_ble_cb_event_t event, + esp_ble_gap_cb_param_t *param) +{ + switch (event) { + + /* Both SET_COMPLETE handlers below need to: + * 1) drop stale events (latch already zeroed by a sync failure); + * 2) drop async failures (status != SUCCESS) and wipe the latch + * so the other half can't satisfy the "==0 → start_adv" check + * and launch advertising with a malformed payload. */ + case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: + if (!(s_adv_config_done & ADV_CONFIG_FLAG)) { + ESP_LOGD(TAG, "stale ADV_DATA_SET_COMPLETE_EVT ignored"); + break; + } + if (param->adv_data_cmpl.status != ESP_BT_STATUS_SUCCESS) { + ESP_LOGE(TAG, "adv_data set failed status=0x%x", + param->adv_data_cmpl.status); + s_adv_config_done = 0; + break; + } + s_adv_config_done &= ~ADV_CONFIG_FLAG; + if (s_adv_config_done == 0 && s_opened && !s_shutting_down) { + start_advertising(); + } + break; + + case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: + if (!(s_adv_config_done & SCAN_RSP_CONFIG_FLAG)) { + ESP_LOGD(TAG, "stale SCAN_RSP_DATA_SET_COMPLETE_EVT ignored"); + break; + } + if (param->scan_rsp_data_cmpl.status != ESP_BT_STATUS_SUCCESS) { + ESP_LOGE(TAG, "scan_rsp set failed status=0x%x", + param->scan_rsp_data_cmpl.status); + s_adv_config_done = 0; + break; + } + s_adv_config_done &= ~SCAN_RSP_CONFIG_FLAG; + if (s_adv_config_done == 0 && s_opened && !s_shutting_down) { + start_advertising(); + } + break; + + case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: + if (param->adv_start_cmpl.status != ESP_BT_STATUS_SUCCESS) { + ESP_LOGE(TAG, "adv_start failed status=0x%x", + param->adv_start_cmpl.status); + s_adv_active = false; + break; + } + ESP_LOGI(TAG, "advertising started"); + break; + + case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: + s_adv_active = false; + ESP_LOGI(TAG, "advertising stopped"); + break; + + case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: + show_passkey(param->ble_security.key_notif.passkey); + break; + + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + esp_ble_auth_cmpl_t *a = ¶m->ble_security.auth_cmpl; + if (a->success) { + ESP_LOGI(TAG, "pairing ok auth_mode=0x%x", a->auth_mode); + } else { + ESP_LOGW(TAG, "pairing failed reason=0x%x", a->fail_reason); + } + break; + } + + case ESP_GAP_BLE_SEC_REQ_EVT: + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true); + break; + + case ESP_GAP_BLE_KEY_EVT: + ESP_LOGD(TAG, "key event type=0x%x", + param->ble_security.ble_key.key_type); + break; + + case ESP_GAP_BLE_NC_REQ_EVT: + /* Numeric Comparison shouldn't fire with our DisplayOnly IO + * (BT Core §2.3.5.1). Reject — accepting would flag the LTK + * as MITM-authenticated without any user actually comparing + * the digits, silently downgrading the security we asked for. */ + esp_ble_confirm_reply(param->ble_security.ble_req.bd_addr, false); + break; + + default: + break; + } +} + +/* ===== Public TX ====================================================== */ + +int ble_uart_tx(const uint8_t *data, size_t len) +{ + /* Snapshot conn_id and MTU once: a peer-A→peer-B disconnect+connect + * race during a multi-chunk send could otherwise leak later chunks + * to peer B (send_indicate doesn't gate on the CCCD subscription). + * Stale conn_id → ESP_ERR_INVALID_ARG, we bail cleanly. Stale MTU + * is harmless (chunks would only be smaller, never too big). */ + uint16_t conn_id = s_conn_id; + uint16_t mtu_snap = s_local_mtu; + if (conn_id == 0xFFFF) { + return BLE_UART_ENOTCONN; + } + if (data == NULL || len == 0) { + return BLE_UART_EINVAL; + } + + size_t chunk = (mtu_snap > 3) ? (size_t)(mtu_snap - 3) : 20; + size_t sent = 0; + while (sent < len) { + size_t n = len - sent; + if (n > chunk) { + n = chunk; + } + esp_err_t rc = esp_ble_gatts_send_indicate(s_gatts_if, conn_id, + s_handles[NUS_IDX_TX_VAL], + (uint16_t)n, + (uint8_t *)(data + sent), + false); /* notify, not indicate */ + if (rc != ESP_OK) { + ESP_LOGW(TAG, "send_indicate rc=%s", esp_err_to_name(rc)); + 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_id != 0xFFFF; } +bool ble_uart_is_subscribed(void) { return s_subscribed; } + +/* ===== Lifecycle ====================================================== */ + +static int configure_security(bool encrypted) +{ + esp_ble_auth_req_t auth_req = encrypted ? ESP_LE_AUTH_REQ_SC_MITM_BOND + : ESP_LE_AUTH_NO_BOND; + esp_ble_io_cap_t iocap = encrypted ? ESP_IO_CAP_OUT + : ESP_IO_CAP_NONE; + uint8_t key_size = 16; + uint8_t init_key = ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK; + uint8_t rsp_key = ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK; + + /* Apply each SM param individually and bail on the first failure. + * Do NOT collapse into `rc |= ...` — esp_err_t values aren't bit + * flags (e.g. NO_MEM|INVALID_ARG = INVALID_STATE), and a single + * ESP_FAIL=-1 sticks the accumulator. */ + struct { esp_ble_sm_param_t id; const void *val; uint8_t len; const char *tag; } params[] = { + { ESP_BLE_SM_AUTHEN_REQ_MODE, &auth_req, sizeof(auth_req), "AUTHEN_REQ_MODE" }, + { ESP_BLE_SM_IOCAP_MODE, &iocap, sizeof(iocap), "IOCAP_MODE" }, + { ESP_BLE_SM_MAX_KEY_SIZE, &key_size, sizeof(key_size), "MAX_KEY_SIZE" }, + { ESP_BLE_SM_SET_INIT_KEY, &init_key, sizeof(init_key), "SET_INIT_KEY" }, + { ESP_BLE_SM_SET_RSP_KEY, &rsp_key, sizeof(rsp_key), "SET_RSP_KEY" }, + }; + for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { + esp_err_t rc = esp_ble_gap_set_security_param(params[i].id, + (void *)params[i].val, + params[i].len); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "set_security_param[%s] rc=%s", + params[i].tag, esp_err_to_name(rc)); + return xlate_rc(rc); + } + } + return BLE_UART_OK; +} + +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)); + } + + /* Free BR/EDR controller RAM we won't use (no-op on BLE-only chips). */ + esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); + + /* Bring up controller + Bluedroid step by step. Track `stage` so + * the fail label can unwind exactly the layers we allocated — + * otherwise a partial install would leave the controller enabled + * with no module state pointing at it, and the next install() + * would fail with INVALID_STATE. */ + enum { + STAGE_NONE = 0, + STAGE_CTRL_INIT, + STAGE_CTRL_ENABLE, + STAGE_BLUEDROID_INIT, + STAGE_BLUEDROID_ENABLE, + STAGE_APP_REGISTER, + } stage = STAGE_NONE; + + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + esp_err_t rc = esp_bt_controller_init(&bt_cfg); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "controller_init rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_CTRL_INIT; + + rc = esp_bt_controller_enable(ESP_BT_MODE_BLE); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "controller_enable rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_CTRL_ENABLE; + + rc = esp_bluedroid_init(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "bluedroid_init rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_BLUEDROID_INIT; + + rc = esp_bluedroid_enable(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "bluedroid_enable rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_BLUEDROID_ENABLE; + + rc = esp_ble_gatts_register_callback(gatts_profile_event_handler); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "gatts_register rc=%s", esp_err_to_name(rc)); + goto fail; + } + rc = esp_ble_gap_register_callback(gap_event_handler); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "gap_register rc=%s", esp_err_to_name(rc)); + goto fail; + } + + /* SM must be configured before app_register so any incoming + * pairing request finds the right policy. */ + int srv = configure_security(s_cfg.encrypted); + if (srv != BLE_UART_OK) { + ESP_LOGE(TAG, "security config failed rc=%d", srv); + rc = ESP_FAIL; + goto fail; + } + + /* Bluedroid has no compile-time MTU Kconfig (NimBLE has + * CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU); set it at runtime here so + * both backends behave the same. Failure isn't fatal — we'd just + * fall back to the spec default 23. */ + rc = esp_ble_gatt_set_local_mtu(512); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "set_local_mtu(512) rc=%s; staying at default", + esp_err_to_name(rc)); + } + + build_attr_table(s_cfg.encrypted); + + rc = esp_ble_gatts_app_register(UART_APP_ID); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "app_register rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_APP_REGISTER; + + /* Wait briefly for CREAT_ATTR_TAB_EVT so the GATT table is in + * place before open() asks the stack to advertise (otherwise the + * peer would scan us with an empty database). 500 ms is well over + * the typical bring-up time (~150 ms on ESP32-S3). */ + for (int i = 0; i < 50 && !s_attr_tab_ready; i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (!s_attr_tab_ready) { + ESP_LOGW(TAG, "attr table not ready after 500 ms; open() will retry"); + } + + s_installed = true; + return BLE_UART_OK; + +fail: + /* Unwind exactly the layers we brought up; ignore secondary errors. */ + switch (stage) { + case STAGE_APP_REGISTER: + if (s_gatts_if != ESP_GATT_IF_NONE) { + esp_ble_gatts_app_unregister(s_gatts_if); + s_gatts_if = ESP_GATT_IF_NONE; + } + /* fallthrough */ + case STAGE_BLUEDROID_ENABLE: + esp_bluedroid_disable(); + /* fallthrough */ + case STAGE_BLUEDROID_INIT: + esp_bluedroid_deinit(); + /* fallthrough */ + case STAGE_CTRL_ENABLE: + esp_bt_controller_disable(); + /* fallthrough */ + case STAGE_CTRL_INIT: + esp_bt_controller_deinit(); + /* fallthrough */ + case STAGE_NONE: + break; + } + s_attr_tab_ready = false; + 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; + } + s_opened = true; + + /* If the GATT table is up, kick adv-config now; otherwise + * CREAT_ATTR_TAB_EVT will do it when it arrives. */ + if (s_attr_tab_ready) { + int rc = configure_advertising(); + if (rc != BLE_UART_OK) { + /* Roll back so the caller can retry open() without + * going through close + uninstall. */ + s_opened = false; + return rc; + } + } + return BLE_UART_OK; +} + +int ble_uart_close(void) +{ + if (!s_opened) { + return BLE_UART_EALREADY; + } + + /* Latch first so DISCONNECT_EVT skips the auto-readvertise. */ + s_shutting_down = true; + + if (s_adv_active) { + esp_ble_gap_stop_advertising(); + } + + /* Graceful disconnect: ask the controller to send LL_TERMINATE, + * then wait up to 500 ms for DISCONNECT_EVT to clear s_conn_id. */ + if (s_conn_id != 0xFFFF) { + esp_err_t rc = esp_ble_gap_disconnect(s_remote_bda); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "gap_disconnect rc=%s", esp_err_to_name(rc)); + } + for (int i = 0; i < 50 && s_conn_id != 0xFFFF; i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (s_conn_id != 0xFFFF) { + ESP_LOGW(TAG, "disconnect timed out; tearing down anyway"); + } + } + + s_conn_id = 0xFFFF; + s_subscribed = false; + /* Reset MTU here too in case DISCONNECT_EVT timed out above. */ + s_local_mtu = 23; + /* Defensively clear the long-write accumulator: if DISCONNECT_EVT + * didn't fire within our wait window, prep_reset() in the event + * handler never ran, and stale s_prep_len/s_prep_bad would poison + * the first PREP_WRITE of the next session (offset 0 != stale len + * latches s_prep_bad and rejects the whole batch at EXEC time). */ + prep_reset(); + 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. We MUST NOT early-return on a per-step + * failure: that would leave s_installed=true with the SDK in + * some half-torn-down state, blocking both re-install and retry. + * Mirror the install() goto-fail philosophy: record the first + * error, keep tearing down, and always wipe our state. */ + esp_err_t first_err = ESP_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_err == ESP_OK) first_err = rc; + } + } + + if (s_gatts_if != ESP_GATT_IF_NONE) { + esp_err_t rc = esp_ble_gatts_app_unregister(s_gatts_if); + if (rc != ESP_OK && first_err == ESP_OK) { + ESP_LOGE(TAG, "gatts_app_unregister rc=%s", esp_err_to_name(rc)); + first_err = rc; + } + s_gatts_if = ESP_GATT_IF_NONE; + } + + esp_err_t rc = esp_bluedroid_disable(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "bluedroid_disable rc=%s", esp_err_to_name(rc)); + if (first_err == ESP_OK) first_err = rc; + } + rc = esp_bluedroid_deinit(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "bluedroid_deinit rc=%s", esp_err_to_name(rc)); + if (first_err == ESP_OK) first_err = rc; + } + rc = esp_bt_controller_disable(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "controller_disable rc=%s", esp_err_to_name(rc)); + if (first_err == ESP_OK) first_err = rc; + } + rc = esp_bt_controller_deinit(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "controller_deinit rc=%s", esp_err_to_name(rc)); + if (first_err == ESP_OK) first_err = rc; + } + + /* Wipe state unconditionally, even on partial failure. */ + memset(&s_cfg, 0, sizeof(s_cfg)); + memset(s_handles, 0, sizeof(s_handles)); + memset(s_remote_bda, 0, sizeof(s_remote_bda)); + s_conn_id = 0xFFFF; + s_local_mtu = 23; + s_subscribed = false; + s_installed = false; + s_opened = false; + s_shutting_down = false; + s_attr_tab_ready = false; + s_adv_active = false; + s_adv_config_done = 0; + prep_reset(); + return first_err == ESP_OK ? BLE_UART_OK : xlate_rc(first_err); +} + +#endif /* CONFIG_BT_BLUEDROID_ENABLED */ diff --git a/examples/bluetooth/ble_uart_service/main/ble_uart_nimble.c b/examples/bluetooth/ble_uart_service/main/ble_uart_nimble.c new file mode 100644 index 00000000000..bce4ab66f93 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/ble_uart_nimble.c @@ -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 +#include +#include + +#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, + ¶ms, 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 : ""); + 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 */ diff --git a/examples/bluetooth/ble_uart_service/main/main.c b/examples/bluetooth/ble_uart_service/main/main.c new file mode 100644 index 00000000000..71cd8c506ab --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/main.c @@ -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 + +#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 = "-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()); +} diff --git a/examples/bluetooth/ble_uart_service/sdkconfig.ci.bluedroid b/examples/bluetooth/ble_uart_service/sdkconfig.ci.bluedroid new file mode 100644 index 00000000000..f0604fab87d --- /dev/null +++ b/examples/bluetooth/ble_uart_service/sdkconfig.ci.bluedroid @@ -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() 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 diff --git a/examples/bluetooth/ble_uart_service/sdkconfig.defaults b/examples/bluetooth/ble_uart_service/sdkconfig.defaults new file mode 100644 index 00000000000..7fa7799d2bd --- /dev/null +++ b/examples/bluetooth/ble_uart_service/sdkconfig.defaults @@ -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