diff --git a/examples/bluetooth/.build-test-rules.yml b/examples/bluetooth/.build-test-rules.yml index 9338d894ade..da4190057b9 100644 --- a/examples/bluetooth/.build-test-rules.yml +++ b/examples/bluetooth/.build-test-rules.yml @@ -15,6 +15,14 @@ examples/bluetooth: disable: - if: SOC_BT_SUPPORTED != 1 +examples/bluetooth/ble_uart_service: + <<: *bt_default_depends + disable: + - if: SOC_BLE_SUPPORTED != 1 + depends_filepatterns: + - examples/bluetooth/common/ble_uart/**/* + - examples/bluetooth/ble_uart_service/**/* + examples/bluetooth/bluedroid/ble: <<: *bt_default_depends disable: diff --git a/examples/bluetooth/ble_uart_service/README.md b/examples/bluetooth/ble_uart_service/README.md index 7ac05d24644..3e16e5b5e84 100644 --- a/examples/bluetooth/ble_uart_service/README.md +++ b/examples/bluetooth/ble_uart_service/README.md @@ -24,12 +24,19 @@ ble_uart_install(&cfg); // NimBLE host + BLE UART 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: +If your app powers BLE off at runtime, use **one** of the release paths +in [PORTING.md §5.3](../common/ble_uart/PORTING.md#53-lifecycle--bring-up-and-release) +(this example uses Path A from `app_main`): + +| Path | When | Calls | +| --- | --- | --- | +| **A — sync** (default) | Shutdown from a normal task (button, Wi-Fi, `app_main`) | `ble_uart_close()` → `ble_uart_uninstall()` | +| **B — async** | Shutdown triggered inside `on_event` / `on_rx` | `close_async()` in callback → `CLOSED` sets flag → **`uninstall()` on a separate app task** (not inside `CLOSED`) | ```c -ble_uart_close(); // stop advertising / disconnect / halt host -ble_uart_uninstall(); // free the NimBLE port + reset state +/* Path A — this example style */ +ble_uart_close(); +ble_uart_uninstall(); ``` When a central connects, the firmware automatically initiates LE Secure @@ -47,49 +54,135 @@ back with `ble_uart_tx()`. | TX (out) | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | Notify (auto-CCCD) | encrypted, authenticated | The `_ENC | _AUTHEN` flags are turned on only when `cfg.encrypted = true` -(the default in this example). +(the default in this example). The two flags can be controlled +independently via `cfg.security.mitm` (drops `_AUTHEN`) and the +combined `cfg.security.{sc,bonding,mitm}` set (all OFF drops `_ENC` +too) — see PORTING.md §5.6. ## Files | File | Lines | Role | | --- | ---: | --- | -| `main/main.c` | ~70 | NVS init, MAC-derived device name, install + open, RX echo handler. Identical for both backends. | +| `main/main.c` | ~200 | NVS init, install + open with `-XXXX` device name (Kconfig prefix + BT MAC suffix), RX echo handler, lifecycle/link-state event sink, bonded-peer dump on boot. Identical for both backends. | +| `main/Kconfig.projbuild` | ~50 | Example-local `EXAMPLE_CUSTOM_ADV_DATA` switch — toggles the `ble_uart_config_t::adv_data` demo path in `main.c`. | | `CMakeLists.txt` (root) | ~15 | `list(APPEND EXTRA_COMPONENT_DIRS .../common/ble_uart)` before `project()` so `main` can `REQUIRES ble_uart`. | -| `../common/ble_uart/ble_uart.h` | ~155 | Stack-agnostic public API: 3-field config + 4 lifecycle functions + TX/status + UUID + `BLE_UART_E*` return codes. No NimBLE / Bluedroid types leak through. | -| `../common/ble_uart/ble_uart_nimble.c` | ~650 | NimBLE backend: host bring-up, BLE UART GATT service via `ble_gatts_add_svcs`, advertising, pairing, install/open/close/uninstall. Active when `CONFIG_BT_NIMBLE_ENABLED=y`. | -| `../common/ble_uart/ble_uart_bluedroid.c` | ~1020 | Bluedroid backend: controller + host enable, BLE UART GATT service via `esp_ble_gatts_create_attr_tab` (service-table API), advertising, pairing, full PREP/EXEC long-write reassembly, install/open/close/uninstall. Active when `CONFIG_BT_BLUEDROID_ENABLED=y`. | -| `../common/ble_uart/Kconfig` | ~30 | Device-name prefix + RX scratch size (`menuconfig → Component configuration → ESP-BLE-UART library`). | -| `../common/ble_uart/PORTING.md` | ~724 | Porting and API guide (integration, CMake, sdkconfig, thread safety). | +| `../common/ble_uart/ble_uart.h` | ~640 | Stack-agnostic public API: configuration struct (preset + per-feature security overrides + custom adv payload + RX/event callbacks) + lifecycle (install/open/close/close_async/uninstall) + TX + pairing replies + bond-management + status + UUID + `BLE_UART_E*` return codes. No NimBLE / Bluedroid types leak through. | +| `../common/ble_uart/ble_uart_nimble.c` | ~1290 | NimBLE backend: host bring-up, BLE UART GATT service via `ble_gatts_add_svcs`, advertising (default + raw), pairing (incl. Passkey Entry / Numeric Comparison), bond store, async close, install/open/close/uninstall. Active when `CONFIG_BT_NIMBLE_ENABLED=y`. | +| `../common/ble_uart/ble_uart_bluedroid.c` | ~1660 | Bluedroid backend: controller + host enable, BLE UART GATT service via `esp_ble_gatts_create_attr_tab` (service-table API), advertising (default + raw), pairing (incl. Passkey Entry / Numeric Comparison), bond store, async close, full PREP/EXEC long-write reassembly, install/open/close/uninstall. Active when `CONFIG_BT_BLUEDROID_ENABLED=y`. | +| `../common/ble_uart/Kconfig` | ~30 | Device name prefix + RX scratch size (`menuconfig → Component configuration → ESP-BLE-UART library`). | +| `../common/ble_uart/PORTING.md` | ~1300 | Porting and API guide (integration, CMake, sdkconfig, security model, custom advertising, bond management, thread safety). | | `sdkconfig.defaults` | — | Default: NimBLE backend, MTU 512, SC + bonding + persistent NVS. | | `sdkconfig.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 void (*ble_uart_rx_cb_t) (const uint8_t *data, size_t len); +typedef void (*ble_uart_evt_cb_t)(const ble_uart_evt_t *evt); typedef struct { - bool encrypted; /* SC + Bonding + MITM in one knob */ - const char *device_name; - ble_uart_rx_cb_t ble_uart_on_rx; + ble_uart_sec_t sc; /* AUTO / OFF / ON */ + ble_uart_sec_t bonding; + ble_uart_sec_t mitm; + ble_uart_io_cap_t io_cap; /* AUTO / NO_INPUT_OUTPUT / DISPLAY_ONLY / + KEYBOARD_ONLY / DISPLAY_YES_NO / + KEYBOARD_DISPLAY */ +} ble_uart_security_t; + +typedef struct { + bool encrypted; /* preset: SC + Bonding + MITM + DisplayOnly */ + ble_uart_security_t security; /* per-feature overrides; see PORTING.md §5.6 */ + + const char *device_name; /* ≤ BLE_UART_DEVICE_NAME_MAX (26) */ + /* Optional: raw advertising / scan-response bytes (NULL → defaults). + * Limits: adv_data_len ≤ BLE_UART_ADV_DATA_MAX (28), + * scan_rsp_data_len ≤ BLE_UART_SCAN_RSP_DATA_MAX (31). + * The 3-byte Flags AD element is prepended automatically — don't + * include it in adv_data. See PORTING.md §5.9 for examples. */ + const uint8_t *adv_data; + size_t adv_data_len; + const uint8_t *scan_rsp_data; + size_t scan_rsp_data_len; + ble_uart_rx_cb_t ble_uart_on_rx; + ble_uart_evt_cb_t on_event; /* lifecycle / link-state events; NULL drops */ } ble_uart_config_t; +typedef struct { + uint8_t bytes[6]; /* big-endian: bytes[0] is the MSB (AA:BB:CC:DD:EE:FF) */ + uint8_t type; /* BLE_UART_ADDR_TYPE_PUBLIC | _RANDOM */ +} ble_uart_addr_t; + /* Lifecycle */ -int ble_uart_install(const ble_uart_config_t *cfg); /* NimBLE host + GATT */ +int ble_uart_install(const ble_uart_config_t *cfg); /* host + GATT */ int ble_uart_open(void); /* host task + advertising */ int ble_uart_close(void); /* stop adv / disconnect / halt host */ -int ble_uart_uninstall(void); /* free NimBLE port + reset state */ +int ble_uart_close_async(void); /* same, fire-and-forget; safe from inside on_event/on_rx */ +int ble_uart_uninstall(void); /* free port + reset state */ /* Data path */ int ble_uart_tx(const uint8_t *data, size_t len); +/* Pairing replies (call from on_event for input-capable IO caps) */ +int ble_uart_passkey_reply(uint32_t passkey); /* answer PASSKEY_REQUEST */ +int ble_uart_compare_reply(bool match); /* answer NUMERIC_COMPARE */ + /* Status (best-effort snapshot) */ bool ble_uart_is_connected(void); bool ble_uart_is_subscribed(void); +/* Bond management (works after install()) */ +int ble_uart_get_bond_count(size_t *out_count); +int ble_uart_get_bonded_peers(ble_uart_addr_t *out, size_t cap, size_t *out_count); +int ble_uart_remove_peer(const ble_uart_addr_t *peer); +int ble_uart_clear_bonds(void); + extern const ble_uart_uuid128_t ble_uart_service_uuid; ``` +### Event callback + +`on_event` is invoked on the BLE host task (same context as `ble_uart_on_rx`) +with a tagged `ble_uart_evt_t`. Use `LINK_SECURE` — not `is_connected()` — +to gate any application logic that requires the channel to be encrypted / +authenticated: + +| `evt->id` | Payload | Fires when | +| ------------------------------- | ------------------------------------------------------- | ---------- | +| `BLE_UART_EVT_CONNECTED` | `connected.peer` | Physical link up | +| `BLE_UART_EVT_DISCONNECTED` | `disconnected.reason` (int, stack-specific) | Physical link down — Bluedroid: `esp_gatt_conn_reason_t`; NimBLE: BLE host return code (`BLE_HS_HCI_ERR()` for HCI) | +| `BLE_UART_EVT_SUBSCRIBED` | `subscribed.subscribed` | Central writes CCCD on TX (edge-triggered) | +| `BLE_UART_EVT_LINK_SECURE` | `link_secure.{encrypted,authenticated,bonded,key_size}` | Pairing or bonded reconnect succeeds | +| `BLE_UART_EVT_PASSKEY_DISPLAY` | `passkey.passkey` (0..999999) | SM asks the device to show a passkey | +| `BLE_UART_EVT_PASSKEY_REQUEST` | — | SM asks the user to enter a passkey shown by the central — answer with `ble_uart_passkey_reply()` | +| `BLE_UART_EVT_NUMERIC_COMPARE` | `numeric_compare.passkey` (0..999999) | SM asks the user to confirm both sides display the same value — answer with `ble_uart_compare_reply()` | +| `BLE_UART_EVT_PAIRING_FAILED` | `pairing_failed.reason` (stack-specific) | Pairing rejected or timed out | +| `BLE_UART_EVT_CLOSED` | `closed.status` (`BLE_UART_*`) | `ble_uart_close_async()` worker has finished; `BLE_UART_OK` means tear-down succeeded | + +The default passkey UART banner still prints; the callback is additive so +log-scraping tests stay compatible. Don't block in the callback. + +**Callback rules:** + +- Do **not** call `ble_uart_close()` or `ble_uart_uninstall()` from + `on_event` / `on_rx` (host task — deadlocks). +- To start teardown from a callback, call `ble_uart_close_async()` only. +- Call `ble_uart_uninstall()` from a **normal app task** after + `BLE_UART_EVT_CLOSED` with `closed.status == BLE_UART_OK` (see + [PORTING.md §5.3.2](../common/ble_uart/PORTING.md#532-path-b--release-after-a-ble-event-close_async)). + +Path B sketch (full code in PORTING.md): + +```c +case BLE_UART_EVT_PAIRING_FAILED: + ble_uart_close_async(); + break; +case BLE_UART_EVT_CLOSED: + if (e->closed.status == BLE_UART_OK) { + s_ble_closed_ok = true; /* app task calls uninstall */ + } + break; +``` + ## Choosing the host stack The same `ble_uart.h` API is implemented twice — once on top of NimBLE @@ -134,8 +227,10 @@ When neither is enabled the build fails up-front with a clear error. idf.py set-target esp32c3 # or esp32, esp32s3, esp32c6, esp32h2 ... idf.py menuconfig # optional # Component configuration -> ESP-BLE-UART library -# - BLE device name prefix (default: BleUart) +# - BLE device name prefix (default: BleUart; example appends -XXXX from BT MAC) # - RX scratch buffer size (default: 1024 bytes) +# BLE UART service example +# - Use custom advertising data (default: off) ``` Those `BLE_UART_*` options are defined in **`../common/ble_uart/Kconfig`** @@ -143,6 +238,27 @@ Those `BLE_UART_*` options are defined in **`../common/ble_uart/Kconfig`** build (this example pulls it in via `EXTRA_COMPONENT_DIRS` in the root `CMakeLists.txt`). +`EXAMPLE_CUSTOM_ADV_DATA` is example-local (`main/Kconfig.projbuild`) +and demonstrates `ble_uart_config_t::adv_data` — the field that lets +the application fully control the over-the-air advertising payload +instead of using the library default. + +When the option is on, `app_main` hands a static byte array +(`example_adv_payload[]`, top of `main.c`) to `ble_uart_install()`. +The array is just a sequence of `[length][AD type][value]` triplets; +edit it directly to advertise whatever you want — a different Local +Name, Manufacturer Specific Data, custom Service Data, additional +Service UUIDs, etc. The only hard rule is total length ≤ +`BLE_UART_ADV_DATA_MAX` (28); the 3-byte Flags AD is added by the +library and does not count against that budget. + +The GAP-service Device Name (set via `device_name` in the same +config struct) is independent and is what connected centrals read +post-pair, regardless of `adv_data`. + +With the option off the library default is used (Complete Local Name +in the primary packet, 128-bit Service UUID in the scan response). + The two security knobs are set in `sdkconfig.defaults`: ```ini @@ -154,6 +270,17 @@ Disable `cfg.encrypted` in `main.c` (set it to `false`) for plaintext operation in the lab — the GATT characteristics drop their `_ENC` flags accordingly. Production firmware should keep encryption on. +For finer control without going all-or-nothing — e.g. a displayless +gateway that wants encryption + bonding but no passkey UI, or a +device with a keypad that wants Passkey Entry / Numeric Comparison — +keep `cfg.encrypted = true` and override individual bits via +`cfg.security.{sc,bonding,mitm,io_cap}`. The input-capable IO caps +(`KEYBOARD_ONLY`, `DISPLAY_YES_NO`, `KEYBOARD_DISPLAY`) require an +`on_event` handler that wires `BLE_UART_EVT_PASSKEY_REQUEST` / +`NUMERIC_COMPARE` to `ble_uart_passkey_reply()` / +`ble_uart_compare_reply()`. See PORTING.md §5.6 for the full matrix +and worked examples. + ### Build & flash ```bash @@ -170,7 +297,7 @@ I (xxx) ble_uart: registered chr 6e400002-... def=15 val=16 I (xxx) ble_uart: registered chr 6e400003-... def=17 val=18 I (xxx) ble_uart: addr=80:7d:3a:11:22:33 I (xxx) ble_uart: BLE host task started -I (xxx) ble_uart: advertising as 'BleUart-XXXX' +I (xxx) ble_uart: advertising as 'BleUart-2233' ``` Expected boot log (Bluedroid backend): @@ -186,8 +313,10 @@ I (xxx) ble_uart: advertising started 1. On a phone, install **a BLE GATT client app** that supports scanning, pairing, characteristic write, and notify/CCCD (many mobile “BLE tools” or serial-over-BLE utilities qualify). -2. Scan, tap **Connect** on `BleUart-XXXX`. The phone prompts for a - 6-digit code. +2. Scan, tap **Connect** on `BleUart-XXXX` (prefix from + `CONFIG_BLE_UART_DEVICE_NAME_PREFIX`, `XXXX` = last two BT MAC + bytes). The phone prompts for a 6-digit + code. 3. The device prints a fresh code in a banner on UART: ``` @@ -204,8 +333,13 @@ I (xxx) ble_uart: advertising started 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. +To wipe the bond and force a fresh passkey there are three options: + +- Call `ble_uart_clear_bonds()` from your app (preserves the rest of NVS) +- Call `ble_uart_remove_peer(&addr)` to drop one peer (use the address + reported in `BLE_UART_EVT_CONNECTED`, or any address you happen to + have stored — Bluedroid matches by address only, NimBLE by identity) +- Run `idf.py erase-flash` and re-flash (also wipes WiFi creds, NVS, etc.) ## Adapting to your application 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..8ffa24ed029 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/Kconfig.projbuild @@ -0,0 +1,47 @@ +menu "BLE UART service example" + + config EXAMPLE_CUSTOM_ADV_DATA + bool "Use custom advertising data" + default n + help + Demonstrates `ble_uart_config_t::adv_data` — the field that + lets the application fully control the advertising payload + instead of relying on the library default. + + When enabled, the example passes a static byte array + (`example_adv_payload[]` defined at the top of `main.c`) to + `ble_uart_install()`. Edit that array to broadcast anything + you want: a different Local Name, Manufacturer Specific + Data, custom Service Data, multiple Service UUIDs, etc. + + Format + The array is a sequence of standard Bluetooth Core "AD + structure" triplets: + + [length(1)] [AD type(1)] [value(length-1)] + + See the Bluetooth Assigned Numbers (Generic Access + Profile) document for the full type list. + + Length budget + Total bytes in the array must be + ≤ BLE_UART_ADV_DATA_MAX (28). The 3-byte mandatory + Flags AD element is prepended automatically by + ble_uart and does NOT count against this budget. An + oversized buffer makes `ble_uart_install()` fail with + BLE_UART_EINVAL. + + Scope + Only affects the over-the-air advertising payload. + The GAP-service Device Name (UUID 0x2A00, set via + `device_name` in the same struct) is independent and + stays whatever the application configured — connected + centrals read that name regardless of what is in + `adv_data`. + + Default value + Off. The library default is used (Complete Local Name + in the primary packet, 128-bit Service UUID in the + scan response). + +endmenu diff --git a/examples/bluetooth/ble_uart_service/main/main.c b/examples/bluetooth/ble_uart_service/main/main.c index 0726b9efde5..b209bfb5782 100644 --- a/examples/bluetooth/ble_uart_service/main/main.c +++ b/examples/bluetooth/ble_uart_service/main/main.c @@ -8,6 +8,7 @@ * writes to the RX characteristic is echoed back over TX. */ +#include #include #include "esp_log.h" @@ -17,6 +18,41 @@ #include "ble_uart.h" +#if CONFIG_EXAMPLE_CUSTOM_ADV_DATA +/* Sample advertising payload demonstrating ble_uart_config_t::adv_data. + * Replace these bytes with whatever your product needs (a different + * Local Name, Manufacturer Specific Data, custom Service Data, + * additional Service UUIDs, ...) — ble_uart broadcasts them verbatim. + * + * Format: a sequence of standard BT Core "AD structure" triplets, + * [length(1)] [AD type(1)] [value(length-1)]. + * + * Length budget: total ≤ BLE_UART_ADV_DATA_MAX (28). The mandatory + * 3-byte Flags AD is prepended by ble_uart and does NOT count against + * this budget; oversize fails ble_uart_install() with EINVAL. + * + * The current contents (purely illustrative — edit freely): + * + * Layout bytes + * -------------------------------------- ----- + * Complete Local Name AD "BleUart" 1 + 1 + 7 = 9 + * Complete 128-bit UUID AD 1 + 1 + 16 = 18 + * -------------------------------------- ----- + * total 27 (≤ 28) + */ +static const uint8_t example_adv_payload[] = { + /* AD type 0x09: Complete Local Name */ + 0x08, 0x09, 'B', 'l', 'e', 'U', 'a', 'r', 't', + + /* AD type 0x07: Complete List of 128-bit Service UUIDs. + * UUID bytes are in over-the-air (little-endian) order, matching + * ble_uart_service_uuid.bytes[]. */ + 0x11, 0x07, + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e, +}; +#endif + static const char *TAG = "app"; static void ble_uart_on_rx(const uint8_t *data, size_t len) @@ -29,6 +65,83 @@ static void ble_uart_on_rx(const uint8_t *data, size_t len) ble_uart_tx(data, len); /* echo back */ } +/* Lifecycle / link-state event sink. Runs on the BLE host task — + * keep it short, never call ble_uart_close()/uninstall() from here. + * + * For production code: gate any sensitive TX on + * BLE_UART_EVT_LINK_SECURE (encrypted+authenticated) instead of just + * "connected"; ble_uart_is_connected() returns true while the link is + * still plaintext during the pairing window. */ +static void ble_uart_on_event(const ble_uart_evt_t *e) +{ + switch (e->id) { + case BLE_UART_EVT_CONNECTED: { + const uint8_t *b = e->connected.peer.bytes; + ESP_LOGI(TAG, + "evt: connected peer=%02x:%02x:%02x:%02x:%02x:%02x type=%u", + b[0], b[1], b[2], b[3], b[4], b[5], e->connected.peer.type); + break; + } + case BLE_UART_EVT_DISCONNECTED: + ESP_LOGI(TAG, "evt: disconnected reason=0x%x", + e->disconnected.reason); + break; + case BLE_UART_EVT_SUBSCRIBED: + ESP_LOGI(TAG, "evt: %ssubscribed", + e->subscribed.subscribed ? "" : "un"); + break; + case BLE_UART_EVT_LINK_SECURE: + ESP_LOGI(TAG, "evt: link_secure enc=%d auth=%d bond=%d ks=%u", + e->link_secure.encrypted, e->link_secure.authenticated, + e->link_secure.bonded, e->link_secure.key_size); + break; + case BLE_UART_EVT_PASSKEY_DISPLAY: + ESP_LOGI(TAG, "evt: passkey=%06" PRIu32, e->passkey.passkey); + break; + case BLE_UART_EVT_PASSKEY_REQUEST: + /* Fires only when cfg.security.io_cap is KEYBOARD_ONLY or + * KEYBOARD_DISPLAY (this example leaves io_cap at AUTO → + * DisplayOnly, so it should not fire). For a real keypad + * product, prompt the user for the 6 digits the central + * displayed and feed them in: + * + * ble_uart_passkey_reply(digits); + * + * See PORTING.md §5.6.1 for the full pattern. */ + ESP_LOGW(TAG, "evt: passkey entry requested — no UI wired in this " + "example (see PORTING.md §5.6.1)"); + break; + case BLE_UART_EVT_NUMERIC_COMPARE: + /* Fires only when cfg.security.io_cap is DISPLAY_YES_NO or + * KEYBOARD_DISPLAY (likewise dormant in this example). For a + * product with a yes/no control, surface the digits to the + * user and resolve the comparison: + * + * ble_uart_compare_reply(user_says_match); + * + * See PORTING.md §5.6.1. */ + ESP_LOGW(TAG, "evt: numeric compare %06" PRIu32 + " — no yes/no UI wired (see PORTING.md §5.6.1)", + e->numeric_compare.passkey); + break; + case BLE_UART_EVT_PAIRING_FAILED: + ESP_LOGW(TAG, "evt: pairing failed reason=0x%x", + e->pairing_failed.reason); + break; + case BLE_UART_EVT_CLOSED: + /* Only after ble_uart_close_async(). This example does not use + * close_async; do not ble_uart_uninstall() here — defer to an + * app task (PORTING.md §5.3.2). Kept for -Wswitch. */ + if (e->closed.status == BLE_UART_OK) { + ESP_LOGI(TAG, "evt: closed (async-close succeeded)"); + } else { + ESP_LOGW(TAG, "evt: closed async-close failed status=%d", + e->closed.status); + } + break; + } +} + void app_main(void) { /* NVS is required by the BT controller (PHY calibration) and the @@ -49,15 +162,51 @@ void app_main(void) ESP_LOGW(TAG, "esp_read_mac(BT) failed (%s); device name suffix will be 0000", esp_err_to_name(mac_err)); } - char name[24]; + char name[BLE_UART_DEVICE_NAME_MAX + 1]; snprintf(name, sizeof(name), "%s-%02X%02X", CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]); ESP_ERROR_CHECK(ble_uart_install(&(ble_uart_config_t){ .encrypted = true, .device_name = name, +#if CONFIG_EXAMPLE_CUSTOM_ADV_DATA + /* Hand the application-defined bytes to ble_uart. Whatever + * the array contains is broadcast verbatim; what `device_name` + * (above) holds is exposed via the GAP service for connected + * centrals to read — independent paths. */ + .adv_data = example_adv_payload, + .adv_data_len = sizeof(example_adv_payload), + /* scan_rsp_data is left at its default (NULL) → ble_uart still + * sends its built-in scan response. Override it the same way + * if you want to control those bytes too. */ +#endif .ble_uart_on_rx = ble_uart_on_rx, + .on_event = ble_uart_on_event, })); + /* Demonstrate the bond-management API: list every bonded peer + * already on flash. Replace the log with `ble_uart_clear_bonds()` + * to wipe them at boot (e.g. when a "factory reset" GPIO is held); + * use `ble_uart_remove_peer(&list[i])` to target one specifically. */ + size_t total = 0; + ble_uart_addr_t list[8]; + int rc = ble_uart_get_bonded_peers(list, sizeof(list) / sizeof(list[0]), + &total); + if (rc == 0) { + ESP_LOGI(TAG, "%u peer(s) currently bonded", (unsigned)total); + size_t shown = total < sizeof(list) / sizeof(list[0]) + ? total : sizeof(list) / sizeof(list[0]); + for (size_t i = 0; i < shown; i++) { + const uint8_t *b = list[i].bytes; + ESP_LOGI(TAG, " [%u] %02x:%02x:%02x:%02x:%02x:%02x type=%u", + (unsigned)i, + b[0], b[1], b[2], b[3], b[4], b[5], list[i].type); + } + if (total > shown) { + ESP_LOGI(TAG, " (%u more not shown)", + (unsigned)(total - shown)); + } + } + ESP_ERROR_CHECK(ble_uart_open()); } 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..fcb8571707e --- /dev/null +++ b/examples/bluetooth/ble_uart_service/sdkconfig.ci.bluedroid @@ -0,0 +1,24 @@ +# CI build overlay: Bluedroid host (sdkconfig.defaults selects NimBLE). +# Mirrors sdkconfig.bluedroid; kept in sync for idf-build-apps CONFIG_NAME=bluedroid. + +CONFIG_BT_NIMBLE_ENABLED=n +CONFIG_BT_ENABLED=y + +CONFIG_BT_NIMBLE_ENABLED=n +CONFIG_BT_BLUEDROID_ENABLED=y + +CONFIG_BT_BLE_SMP_ENABLE=y + + +CONFIG_BT_GATTS_ENABLE=y + +# CONFIG_BT_GATTC_ENABLE is not set + +# CONFIG_BT_BLE_50_FEATURES_SUPPORTED is not set +CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y + +# CONFIG_BT_BLE_42_DTM_TEST_EN is not set + +CONFIG_BT_BLE_42_ADV_EN=y + +# CONFIG_BT_BLE_42_SCAN_EN is not set diff --git a/examples/bluetooth/ble_uart_service/sdkconfig.ci.nimble b/examples/bluetooth/ble_uart_service/sdkconfig.ci.nimble new file mode 100644 index 00000000000..b28da057406 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/sdkconfig.ci.nimble @@ -0,0 +1,7 @@ +# CI build overlay: NimBLE host (sdkconfig.defaults is NimBLE-first). +# Explicit config so idf-build-apps builds both nimble and bluedroid in CI. + +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=n +CONFIG_BT_NIMBLE_SM_SC=y +CONFIG_BT_NIMBLE_NVS_PERSIST=y diff --git a/examples/bluetooth/common/ble_uart/PORTING.md b/examples/bluetooth/common/ble_uart/PORTING.md index e090f31a13f..bea60be19bc 100644 --- a/examples/bluetooth/common/ble_uart/PORTING.md +++ b/examples/bluetooth/common/ble_uart/PORTING.md @@ -8,9 +8,10 @@ This document lives in **`examples/bluetooth/common/ble_uart/`** next to the **Reference application:** use the **`examples/bluetooth/ble_uart_service`** example as the working template. Its root `CMakeLists.txt` appends this directory to **`EXTRA_COMPONENT_DIRS`** so `main` can `REQUIRES ble_uart`; -`main/main.c` initializes NVS and a MAC-derived GAP name, calls -`ble_uart_install()` / `ble_uart_open()` with the default encrypted UART-over-BLE echo -path, and the tree ships `sdkconfig.defaults` plus the Bluedroid overlay +`main/main.c` initializes NVS, calls `ble_uart_install()` / +`ble_uart_open()` with the Kconfig-supplied GAP name and the default +encrypted UART-over-BLE echo path, and the tree ships +`sdkconfig.defaults` plus the Bluedroid overlay (`sdkconfig.bluedroid`). Clone or diff that project when adapting to a new target or host stack. @@ -61,7 +62,7 @@ is entirely up to you**. Canonical sources live under **`$IDF_PATH/examples/bluetooth/common/ble_uart/`** (component name `ble_uart`): `ble_uart.h`, `ble_uart_nimble.c`, -`ble_uart_bluedroid.c`, `CMakeLists.txt`, and `Kconfig` (prefix + RX scratch; +`ble_uart_bluedroid.c`, `CMakeLists.txt`, and `Kconfig` (device name + RX scratch; `menuconfig → Component configuration → ESP-BLE-UART library`). When reusing outside this tree, copy the whole `common/ble_uart/` directory or at least merge `Kconfig` into your component so the same `CONFIG_BLE_UART_*` symbols @@ -264,17 +265,32 @@ back. ```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 */ + bool encrypted; /* Preset shortcut for SC + Bonding + MITM */ + ble_uart_security_t security; /* Per-feature overrides — see §5.6 */ + + const char *device_name; /* GAP service device name (UUID 0x2A00) */ + + /* Custom advertising bytes — see §5.9. NULL keeps the default + * payload. ble_uart prepends the 3-byte Flags AD itself; you don't. */ + const uint8_t *adv_data; + size_t adv_data_len; /* ≤ BLE_UART_ADV_DATA_MAX (28) */ + const uint8_t *scan_rsp_data; + size_t scan_rsp_data_len;/* ≤ BLE_UART_SCAN_RSP_DATA_MAX (31) */ + + ble_uart_rx_cb_t ble_uart_on_rx;/* RX byte callback */ + ble_uart_evt_cb_t on_event; /* Lifecycle / link-state events */ } ble_uart_config_t; ``` | Field | Type | Required | Default / meaning | | --- | --- | --- | --- | -| `encrypted` | `bool` | yes | `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 | +| `encrypted` | `bool` | yes | One-line preset for the override fields under `security`: `true` = SC + Bonding + MITM + DisplayOnly + encrypted+authenticated GATT chars; `false` = fully plaintext (sniffable, lab use only). Override individual bits via `security.*` — see §5.6. | +| `security` | `ble_uart_security_t` | optional | A zero-initialised member (`security.{sc,bonding,mitm,io_cap} = AUTO`) inherits everything from `encrypted`. Set any sub-field to `OFF`/`ON` (or pick a specific `io_cap`) to override just that bit. Out-of-range enum values, or impossible combos like `mitm=ON` with `io_cap=NO_INPUT_OUTPUT`, fail `ble_uart_install()` with `BLE_UART_EINVAL`. Full reference in §5.6. | +| `device_name` | `const char *` | recommended | Set as the GAP-service Device Name (UUID 0x2A00). With the **default** advertising payload it is also placed in the primary adv as the Complete Local Name; with custom `adv_data` (see §5.9) it is **not** auto-included — the application owns the adv bytes. Length must be ≤ **`BLE_UART_DEVICE_NAME_MAX` = 26** (sized so the default Flags + Name AD layout always fits in a 31-byte primary packet). Longer names fail `ble_uart_install()` synchronously with `BLE_UART_EINVAL`. | +| `adv_data` / `adv_data_len` | bytes + length | optional | Application-controlled raw advertisement data. NULL keeps the built-in default (Complete Local Name only). Max length **`BLE_UART_ADV_DATA_MAX` = 28** (the 31-byte primary packet minus our 3-byte Flags AD). Buffer is copied in `install`; the pointer doesn't need to outlive the call. See §5.9. | +| `scan_rsp_data` / `scan_rsp_data_len` | bytes + length | optional | Application-controlled raw scan-response data. NULL keeps the built-in default (128-bit BLE UART service UUID). Max length **`BLE_UART_SCAN_RSP_DATA_MAX` = 31** (no Flags element here). Same copy semantics as `adv_data`. | | `ble_uart_on_rx` | callback | optional | `NULL` discards every received byte | +| `on_event` | callback | optional | `NULL` drops every event (see §5.2.1). **Not required** for the default preset (`encrypted=true`, all `security.*` AUTO → Passkey Display): the port logs the 6-digit passkey to UART and completes pairing without a callback. **Required** when `io_cap` is `KEYBOARD_ONLY`, `DISPLAY_YES_NO`, or `KEYBOARD_DISPLAY` — otherwise `ble_uart_install()` returns `BLE_UART_EINVAL`. | ### 5.2 RX callback signature @@ -290,50 +306,322 @@ static void my_handler(const uint8_t *data, size_t len) **Caveats**: -- The callback runs in the **NimBLE host task** context — **do not - block**; offload heavy work to your own task. +- The callback runs on the BLE host task (NimBLE host task / + Bluedroid BTC task) — **do not block**; offload heavy work to your + own task. - A single callback may carry only **part** of an upper-layer frame (the central slices on ATT MTU). Framing logic (line / TLV / length-prefixed) is your responsibility. - The data carries **no `ctx` argument**. If your callback needs state, use a file-scope `static` or a global. -### 5.3 Lifecycle functions +### 5.2.1 Event callback + +```c +typedef void (*ble_uart_evt_cb_t)(const ble_uart_evt_t *evt); + +static void on_event(const ble_uart_evt_t *e) +{ + switch (e->id) { + case BLE_UART_EVT_CONNECTED: /* link up */ break; + case BLE_UART_EVT_DISCONNECTED: /* e->disconnected.reason */ break; + case BLE_UART_EVT_SUBSCRIBED: /* e->subscribed.subscribed */ break; + case BLE_UART_EVT_LINK_SECURE: + if (e->link_secure.encrypted && e->link_secure.authenticated) { + /* Safe to forward sensitive payloads now */ + } + break; + case BLE_UART_EVT_PASSKEY_DISPLAY: /* e->passkey.passkey */ break; + case BLE_UART_EVT_PASSKEY_REQUEST: /* user types peer's 6-digit; + ble_uart_passkey_reply(d) */ break; + case BLE_UART_EVT_NUMERIC_COMPARE: /* e->numeric_compare.passkey, + ble_uart_compare_reply(b) */ break; + case BLE_UART_EVT_PAIRING_FAILED: /* e->pairing_failed.reason */ break; + } +} +``` + +| `evt->id` | Payload (anonymous-union member) | Fires when | +| --- | --- | --- | +| `BLE_UART_EVT_CONNECTED` | — | Physical link up | +| `BLE_UART_EVT_DISCONNECTED` | `disconnected.reason` (int, stack-specific) | Physical link down — Bluedroid: `esp_gatt_conn_reason_t`; NimBLE: BLE host return code (`BLE_HS_HCI_ERR()` for HCI) | +| `BLE_UART_EVT_SUBSCRIBED` | `subscribed.subscribed` | CCCD on TX changed (edge-triggered) | +| `BLE_UART_EVT_LINK_SECURE` | `link_secure.{encrypted,authenticated,bonded,key_size}` | Pairing or bonded reconnect succeeds | +| `BLE_UART_EVT_PASSKEY_DISPLAY` | `passkey.passkey` (0..999999) | SM generated a passkey for the central to type (Passkey Display). **Optional** — the port always prints a banner to UART; with `on_event == NULL` the event is dropped and pairing still completes (NimBLE injects the passkey internally; Bluedroid needs no app reply). Register `on_event` only if you want a custom UI in addition to the log line. | +| `BLE_UART_EVT_PASSKEY_REQUEST` | — | SM asks the user to enter a passkey shown by the central — application **must** reply via `ble_uart_passkey_reply()` (see §5.6.1). Requires `on_event != NULL` at install time. | +| `BLE_UART_EVT_NUMERIC_COMPARE` | `numeric_compare.passkey` (0..999999) | SM asks the user to confirm the displayed value matches the central — application **must** reply via `ble_uart_compare_reply()` (see §5.6.1). Requires `on_event != NULL` at install time. | +| `BLE_UART_EVT_PAIRING_FAILED` | `pairing_failed.reason` | Pairing rejected or timed out (including no application reply for `PASSKEY_REQUEST` / `NUMERIC_COMPARE` before the SM's pairing timeout) | +| `BLE_UART_EVT_CLOSED` | `closed.status` (`BLE_UART_*` from the worker's `ble_uart_close()`) | `ble_uart_close_async()` worker finished — then `uninstall` on an app task (§5.3.2) | + +**Use `LINK_SECURE`, not `is_connected()`, to gate any logic that +requires the link to be encrypted / authenticated** — bare +`is_connected()` returns `true` while the link is still plaintext, and +inferring security from `encrypted` / `authenticated` separately on the +caller side is exactly the kind of leak the callback is designed to +plug. + +**Threading**: same context and rules as `ble_uart_on_rx` (NimBLE host +task / Bluedroid BTC task). Don't block, don't call `ble_uart_close` / +`ble_uart_uninstall` from inside the callback — use +`ble_uart_close_async()` (§5.3.2) if you need to teardown in response +to an event. + +**Exception — `BLE_UART_EVT_CLOSED`**: this single event fires from +the close-async worker task instead of the BLE host task; by the time +it runs the host task is already gone. Keep the handler short: set a +flag or notify an app task — do **not** call `ble_uart_uninstall()` +here (see §5.3.2). The worker clears `s_closing` only after your +handler returns. + +**Ordering contracts (both backends)**: + +- A single CCCD value change fires exactly one `SUBSCRIBED` event + (edge-triggered — repeating the same write is a no-op). +- If the central was subscribed at the moment the link drops, you get + `SUBSCRIBED(false)` **before** `DISCONNECTED`. NimBLE does this + natively (`BLE_GAP_SUBSCRIBE_REASON_TERM`); the Bluedroid backend + synthesizes the same sequence so consumers can write a single state + machine that works on either host. +- `LINK_SECURE` always arrives after `CONNECTED` — pairing can't run + without a link. +- `BLE_UART_EVT_CLOSED` always arrives **after** + `BLE_UART_EVT_DISCONNECTED` (when there was a peer) — the + close-async worker calls the same disconnect+wait sequence as the + synchronous `ble_uart_close()` before firing CLOSED. + +**Backend differences**: + +- `BLE_UART_EVT_LINK_SECURE.key_size`: NimBLE reports the negotiated + size (7..16); Bluedroid surfaces a fixed 16 — Bluedroid sets + `ESP_BLE_SM_MAX_KEY_SIZE=16` at install time and does not expose the + negotiated size on `AUTH_CMPL`. +- Bonded reconnects: NimBLE re-fires `LINK_SECURE` on every encryption + change; Bluedroid only fires `AUTH_CMPL_EVT` when the SM exchange + actually runs, so a pure LTK-restart may not refire the event. +- CCCD persistence on bonded reconnect: NimBLE re-fires + `SUBSCRIBED(true)` automatically (via `BLE_GAP_SUBSCRIBE_REASON_RESTORE`) + when the bonded peer reconnects; Bluedroid does not persist CCCD + across connections, so the central has to write CCCD again to + resubscribe. + +### 5.3 Lifecycle — bring-up and release + +#### API summary ```c int ble_uart_install(const ble_uart_config_t *cfg); int ble_uart_open(void); int ble_uart_close(void); +int ble_uart_close_async(void); /* fire-and-forget, see §5.3.2–5.3.4 */ int ble_uart_uninstall(void); ``` | Function | What it does (NimBLE) | What it does (Bluedroid) | When to call | Blocking? | | --- | --- | --- | --- | --- | | `install` | `nimble_port_init` + `ble_hs_cfg` + SM + SIG services + UART GATT | `controller_init/enable` + `bluedroid_init/enable` + SM + `esp_ble_gatts_create_attr_tab` (waits ≤500 ms for the attr-table event) | After `nvs_flash_init()`, before `open` | No, ~50 ms (NimBLE) / ~150 ms (Bluedroid) | -| `open` | 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` | +| `open` | Spawn host task + `ble_hs_start` (first time via `BLE_HS_AUTO_START`, later via `ble_hs_sched_start`) + advertising once synced; after a prior `close`, re-queues GAP/GATT/UART svc defs (§5.3.1a) | Configure adv data + scan rsp + start advertising (GATT table from `install` stays up) | After `install` | No, host runs in the background | +| `close` | Stop adv → graceful disconnect (≤500 ms) → `nimble_port_stop()` → `ble_gatts_reset()` | Stop adv → graceful disconnect (≤500 ms); host + GATT table stay up | After `open`, before `uninstall`; **not** from host-task callbacks (§5.3.2) | Yes, up to ~500 ms (NimBLE) | +| `close_async` | Worker runs the same body as `close`, then `BLE_UART_EVT_CLOSED` | Same | From `on_event` / `on_rx` (host task) when sync `close` would deadlock | No (returns once worker is spawned) | +| `uninstall` | `close` if still open (+ poll in-flight `close_async` ≤~5 s), then `nimble_port_deinit`, wipe module state | Same + controller deinit | After the radio is fully closed (§5.3.2); **not** from host-task callbacks | Yes | -Call order: +**Bring-up** (every product): -``` -nvs_flash_init - └── ble_uart_install - └── ble_uart_open ← BLE is live - └── ble_uart_close - └── ble_uart_uninstall ← clean state, can install again +```text +nvs_flash_init() + └── ble_uart_install(&cfg) /* once per uninstall cycle */ + └── ble_uart_open() /* advertising + pairing; BLE is live */ ``` -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. +Run-forever firmware can stop here — no `close` / `uninstall` required. -**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. +**Release** — pick **one** path below. `close` stops the radio but keeps +`install` state (you can `open()` again). `uninstall` tears the host + +controller down so `install()` can run from scratch. + +| Goal | Call sequence | Who calls `close` / `uninstall` | +| --- | --- | --- | +| Power BLE off from a **normal app task** (button, Wi-Fi, `app_main` shutdown) | `ble_uart_close()` → `ble_uart_uninstall()` | That app task only | +| Power BLE off **because of a BLE event** (RX command, failed pairing, policy) | `ble_uart_close_async()` in `on_event` / `on_rx` → wait for `BLE_UART_EVT_CLOSED` → `ble_uart_uninstall()` on an **app task** (§5.3.2) | `close_async` in callback; `uninstall` deferred | + +Each API returns `BLE_UART_EALREADY` when the module is already in the +target state, so defensive `close` / `uninstall` at shutdown without +manual state checks is fine **as long as** you follow the release path +for your scenario. + +#### 5.3.1 Path A — synchronous release (recommended default) + +Use when teardown is **not** triggered from inside `on_event` / +`on_rx` (NimBLE host task / Bluedroid BTC task). This is what the +`ble_uart_service` example does. + +```c +void shutdown_ble_from_app_task(void) +{ + int rc; + + rc = ble_uart_close(); + if (rc != BLE_UART_OK && rc != BLE_UART_EALREADY) { + ESP_LOGE(TAG, "ble_uart_close rc=%d", rc); + } + + rc = ble_uart_uninstall(); + if (rc != BLE_UART_OK && rc != BLE_UART_EALREADY) { + ESP_LOGE(TAG, "ble_uart_uninstall rc=%d", rc); + } + /* BLE UART fully released — safe to ble_uart_install() again */ +} +``` + +```text +ble_uart_open() /* running */ + │ + ▼ +ble_uart_close() /* same app task; not from on_event / on_rx */ + │ + ▼ +ble_uart_uninstall() +``` + +- `uninstall` may call `close` internally if you skipped `close` — still + call both explicitly so return codes are obvious in your logs. +- Do **not** call `close` or `uninstall` from `on_event` / `on_rx` — use + Path B instead. + +#### 5.3.1a Pausing and resuming (`close` then `open` again) + +`install` state is preserved across `close()` — you may call `open()` +again without `uninstall()`. This is what the `ble_uart_service` example +exercises in `app_main` (open → close → open) to prove the cycle. + +**NimBLE backend** + +| Topic | Behaviour | +| --- | --- | +| GATT services | Same set as after `install`: GAP (`0x1800`), GATT (`0x1801`), BLE UART (NUS). `close()` calls the public `ble_gatts_reset()`; the next `open()` re-runs `ble_svc_gap_init()`, `ble_svc_gatt_init()`, and re-adds the UART service. | +| ATT handles | **Not stable** — centrals must run a full service discovery after each reconnect; do not cache handles across a `close`/`open` cycle. | +| Subscriptions | Cleared — the central must re-enable TX notifications (CCCD). | +| Bonds | NVS bond store is unchanged (still configured at `install()`). | +| First vs later `open` | With default `BLE_HS_AUTO_START`, the first `open()` consumes the one-shot auto-start queued by `nimble_port_init()`; every later `open()` must call `ble_hs_sched_start()` (handled inside `ble_uart_open()`). | + +**Bluedroid backend** + +`close()` only stops advertising and disconnects; the host and attribute +table created at `install()` stay registered. A second `open()` restarts +advertising. GATT handles are typically unchanged. + +**Extra GATT services (§6.3)** + +Services you register with `ble_gatts_add_svcs()` / `ble_svc_*_init()` +at `install()` time are **not** automatically re-registered by +`ble_uart` on a later `open()` after `close()` (NimBLE only re-adds +GAP, GATT, and UART). Either call your init/add functions again inside +your own `open()` hook after `ble_uart_close()`, or use +`close()` → `uninstall()` → `install()` → `open()` for a full rebuild. + +#### 5.3.2 Path B — release after a BLE event (`close_async`) + +Use when the **reason** to shut down arrives on the host task (e.g. +`BLE_UART_EVT_PAIRING_FAILED`, an RX “power off” byte, or +`LINK_SECURE` policy). Synchronous `close()` deadlocks there; use +`close_async()` and **defer** `uninstall()` to a normal task. + +```c +static volatile bool s_ble_closed_ok; + +static void on_event(const ble_uart_evt_t *e) +{ + switch (e->id) { + case BLE_UART_EVT_PAIRING_FAILED: + ble_uart_close_async(); /* OK: host-task context */ + break; + + case BLE_UART_EVT_CLOSED: + /* Runs on the close-async worker — keep this short. Do NOT call + * ble_uart_uninstall() here (s_closing is still set; see §5.3.3). */ + if (e->closed.status == BLE_UART_OK) { + s_ble_closed_ok = true; /* or xTaskNotifyGive / queue */ + } + break; + default: + break; + } +} + +void ble_shutdown_task(void *arg) +{ + (void)arg; + for (;;) { + if (s_ble_closed_ok) { + s_ble_closed_ok = false; + ble_uart_uninstall(); /* normal app task */ + break; + } + vTaskDelay(pdMS_TO_TICKS(50)); + } + vTaskDelete(NULL); +} +``` + +```text +on_event / on_rx (host task): + ble_uart_close_async() + │ + ▼ + [worker: do_close ≈ sync close] + │ + ├── BLE_UART_EVT_DISCONNECTED (if peer was connected) + └── BLE_UART_EVT_CLOSED (worker task; set flag only) + │ + ▼ +app task (not host, not inside CLOSED handler): + ble_uart_uninstall() +``` + +- `close_async` returns `BLE_UART_OK` once the worker is **spawned**, not + when close finishes. +- Only `BLE_UART_EVT_CLOSED` with `.closed.status == BLE_UART_OK` means + the same quiesced state as `ble_uart_close()` — then it is safe to + `uninstall()` from your app task. +- On failure (`BLE_UART_EFAIL`, etc.) the port may still be open; retry + `ble_uart_close()` / `ble_uart_close_async()` from an app task. + +#### 5.3.3 `close_async` + `uninstall` — rules and pitfalls + +`ble_uart_uninstall()` **polls** an in-flight `close_async` worker for +up to **~5 s**. If the worker has not exited it logs +`uninstall: close_async worker still running, tearing down anyway` and +continues anyway — treat that as an application bug, not a supported +path. + +| Do | Don't | +| --- | --- | +| `close_async()` in `on_event` / `on_rx`; `uninstall()` later on **one** app task after `CLOSED` + `BLE_UART_OK` | `uninstall()` in the same task right after `close_async()` without waiting | +| Set a flag / queue in `BLE_UART_EVT_CLOSED`; return immediately | `ble_uart_uninstall()` **inside** `BLE_UART_EVT_CLOSED` (worker still holds `s_closing`) | +| Sync `close` + `uninstall` from a button / network task | `close` / `uninstall` from host-task callbacks | +| Keep `on_event` / `on_rx` short while a close is in flight | Multi-second blocking in callbacks during `close_async` | +| After a timeout log, fix ordering before `install()` again | Immediate `install()` + `open()` + `close_async()` after a wedged teardown | + +If you see `uninstall: close_async worker still running, tearing down +anyway`, fix call ordering (§5.3.2) before calling `install()` again. + +#### 5.3.4 `ble_uart_close_async()` — reference + +Some applications need to teardown the radio in response to a BLE +event — examples: a "shutdown" command on RX, a `LINK_SECURE` whose +flags don't meet the application's policy, or a `PAIRING_FAILED` from +a peer that's been blacklisted. Because the synchronous `close()` is +called *from* the host task it would normally run on, calling it +inline would deadlock. `close_async()` papers over that: it spawns a +small worker task (~3 KB stack, idle+2 priority) that runs the same +close body, then signals completion via the event callback. + +**Behaviour** (see §5.3.2 for the full release flow): + +- `close_async` returns `BLE_UART_OK` once the worker has been spawned. +- `BLE_UART_EVT_DISCONNECTED` (if connected) then `BLE_UART_EVT_CLOSED` + with `.closed.status` — same ≤500 ms disconnect window as sync `close`. +- Second call while draining → `BLE_UART_EALREADY`; before `open` → + `BLE_UART_EALREADY`; spawn failure → `BLE_UART_ENOMEM` (latch reset). ### 5.4 TX interface @@ -354,11 +642,11 @@ ble_uart_tx((const uint8_t *)line, (size_t)n); | 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` | +| `BLE_UART_OK` | Success (notification handed to the stack) | +| `BLE_UART_ENOTCONN` | No central connected; **this is normal — typically ignore** | +| `BLE_UART_EINVAL` | `data == NULL` or `len == 0` | +| `BLE_UART_ENOMEM` | Stack mbuf pool exhausted | +| `BLE_UART_EFAIL` | Internal stack error — see logs | **Calling context**: any FreeRTOS task at any priority. **Not callable from an ISR** — push the data to a queue from the ISR and let a task @@ -382,16 +670,425 @@ bool ble_uart_is_subscribed(void); You usually **don't need** to query these up-front — `ble_uart_tx` returns `ENOTCONN` to tell you. -### 5.6 Service UUID constant +### 5.6 Security configuration + +`cfg.encrypted` is a one-line **preset** that turns on every part of +the stack's security toolbox at once — LE Secure Connections, bonding +(LTK persisted in NVS), MITM protection, DisplayOnly IO, and the +`_ENC | _AUTHEN` flags on the GATT characteristics. It maps to the +older two-state behaviour and is what the "secure by default" template +in §4.4 picks. + +For applications that need finer control — a displayless gateway that +still wants encrypted bonding, a one-shot encrypted session that +doesn't keep an LTK, an interop test build that disables only MITM — +each component of the preset can be flipped individually through the +`cfg.security` sub-struct: + +```c +typedef enum { + BLE_UART_SEC_AUTO = 0, /* follow cfg.encrypted */ + BLE_UART_SEC_OFF = 1, + BLE_UART_SEC_ON = 2, +} ble_uart_sec_t; + +typedef enum { + BLE_UART_IO_CAP_AUTO = 0, /* DisplayOnly when MITM is on; + NoInputNoOutput when off. + Passkey Display needs no on_event */ + BLE_UART_IO_CAP_NO_INPUT_OUTPUT = 1, /* Just Works only */ + BLE_UART_IO_CAP_DISPLAY_ONLY = 2, /* Passkey Display — UART banner + + optional PASSKEY_DISPLAY; + no on_event required */ + BLE_UART_IO_CAP_KEYBOARD_ONLY = 3, /* Passkey Entry — PASSKEY_REQUEST; + on_event required */ + BLE_UART_IO_CAP_DISPLAY_YES_NO = 4, /* Numeric Comparison; + on_event required */ + BLE_UART_IO_CAP_KEYBOARD_DISPLAY = 5, /* PASSKEY_REQUEST or NUMERIC_COMPARE; + on_event required */ +} ble_uart_io_cap_t; + +typedef struct { + ble_uart_sec_t sc; /* tri-state */ + ble_uart_sec_t bonding; /* tri-state */ + ble_uart_sec_t mitm; /* tri-state */ + ble_uart_io_cap_t io_cap; /* AUTO + the five IO caps above */ +} ble_uart_security_t; +``` + +Each of `cfg.security.{sc,bonding,mitm}` is a tri-state. `AUTO` +(the value of any zero-initialised member) inherits from +`cfg.encrypted`; `OFF` / `ON` override that specific bit only. The +resolution table: + +| `cfg.encrypted` | Override field | Resolved bit | +| --- | --- | --- | +| `true` | `AUTO` | ON | +| `true` | `OFF` | OFF | +| `true` | `ON` | ON | +| `false` | `AUTO` | OFF | +| `false` | `OFF` | OFF | +| `false` | `ON` | ON | + +`cfg.security.io_cap` follows the same `AUTO` / explicit pattern. +The application picks an IO cap matching its UI; the SM combines it +with the central's IO cap to elect the pairing model (see BT Core +Spec §2.3.5.1) and ble_uart fires the matching event: + +| Pairing model | Trigger event | Application response | +| --- | --- | --- | +| Just Works | (none — pairs silently) | — | +| Passkey Display (we show) | `BLE_UART_EVT_PASSKEY_DISPLAY` (optional; UART banner always) | (none — port handles SM reply; central types the digits) | +| Passkey Entry (user types)| `BLE_UART_EVT_PASSKEY_REQUEST` | `ble_uart_passkey_reply(d)` — **`on_event` required** | +| Numeric Comparison | `BLE_UART_EVT_NUMERIC_COMPARE` | `ble_uart_compare_reply(b)` — **`on_event` required** | + +Numeric Comparison additionally requires LE Secure Connections on +both sides (legacy SM doesn't support it); against a legacy peer a +`DISPLAY_YES_NO` / `KEYBOARD_DISPLAY` IO cap falls back to either +Passkey Entry (with our keypad) or Just Works. + +#### What is checked synchronously + +`ble_uart_install()` rejects the following with `BLE_UART_EINVAL` +**before** bringing the host stack up, so misconfigured applications +fail fast and predictably: + +- `cfg.security.{sc,bonding,mitm}` outside `{AUTO, OFF, ON}` +- `cfg.security.io_cap` outside the six values listed above +- Resolved `mitm == ON` together with resolved + `io_cap == NO_INPUT_OUTPUT` — Just Works cannot satisfy MITM and + the SM would otherwise reject pairing in flight +- `cfg.on_event == NULL` together with a **configured** (not resolved) + input-capable `io_cap` — only `KEYBOARD_ONLY`, `DISPLAY_YES_NO`, and + `KEYBOARD_DISPLAY`. Without an event sink the application would never + see `PASSKEY_REQUEST` / `NUMERIC_COMPARE` and pairing would silently + stall until the SM times out. **`AUTO` (even when it resolves to + DisplayOnly because `mitm=ON`), `DISPLAY_ONLY`, and `NO_INPUT_OUTPUT` + do not require `on_event`** — Passkey Display is satisfied inside the + port (UART log + internal SM reply); `PASSKEY_DISPLAY` via `on_event` + is additive only. + +#### How the resolved policy is applied + +| Component | Effect | +| --- | --- | +| Resolved `sc` / `bonding` / `mitm` (any ON) | SM is enabled; `ble_gap_security_initiate` (NimBLE) / `esp_ble_set_encryption` (Bluedroid) runs on connect | +| Resolved `mitm` | `ESP_BLE_SEC_ENCRYPT_MITM` vs `_NO_MITM` (Bluedroid); `_AUTHEN` flag added to GATT chars | +| Any of the three on | Encrypted GATT permission flags (`_ENC` on NimBLE, `_ENCRYPTED` on Bluedroid) | +| All three off | Plain `READ`/`WRITE` permissions; SM disabled | +| Resolved `io_cap` | `BLE_HS_IO_*` (NimBLE) / `ESP_IO_CAP_*` (Bluedroid) | + +#### Common combinations + +```c +/* (a) Default — secure-by-default UART. SC + Bonding + MITM, DisplayOnly. + * on_event may be NULL: passkey is printed to UART and pairing + * completes without PASSKEY_DISPLAY / reply callbacks. */ +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + /* security.{sc,bonding,mitm,io_cap} all AUTO → all ON. */ + /* .on_event = NULL — valid for this preset */ +}); + +/* (b) Displayless gateway. SC + Bonding + Just Works (no passkey UI). */ +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .security = { + .mitm = BLE_UART_SEC_OFF, + .io_cap = BLE_UART_IO_CAP_NO_INPUT_OUTPUT, + }, +}); + +/* (c) Encrypted but ephemeral. Re-pair every reconnect, no NVS bond. */ +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .security = { .bonding = BLE_UART_SEC_OFF }, +}); + +/* (d) Plaintext lab build. */ +ble_uart_install(&(ble_uart_config_t){ + .encrypted = false, + /* security.* all AUTO → all OFF. */ +}); + +/* (e) Interop test — keep encryption + bonding, drop MITM only. */ +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .security = { .mitm = BLE_UART_SEC_OFF }, + /* security.io_cap AUTO → NoInputNoOutput once MITM is gone. */ +}); + +/* (f) Passkey Entry — peripheral has a keypad, central has a display. + * User reads the 6-digit code off the central and types it here. + * on_event MUST be set; the application wires PASSKEY_REQUEST to + * a UI prompt and feeds the digits to ble_uart_passkey_reply(). */ +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .security = { .io_cap = BLE_UART_IO_CAP_KEYBOARD_ONLY }, + .on_event = on_event, + ... +}); + +/* (g) Numeric Comparison — peripheral has display + yes/no button. + * Both sides see the same 6-digit value; user confirms match. + * Requires LE Secure Connections (so .sc must be ON, which it is + * by default with .encrypted=true). on_event MUST be set. */ +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .security = { .io_cap = BLE_UART_IO_CAP_DISPLAY_YES_NO }, + .on_event = on_event, + ... +}); + +/* (h) Touchscreen UI — full keypad+display. The SM elects either + * Passkey Entry or Numeric Comparison depending on the central; + * wire BOTH events. */ +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .security = { .io_cap = BLE_UART_IO_CAP_KEYBOARD_DISPLAY }, + .on_event = on_event, + ... +}); +``` + +#### 5.6.1 Pairing reply API + +**Passkey Display (default / `DISPLAY_ONLY` / `AUTO` + `mitm=ON`)** does +not use the reply APIs. The port generates the 6-digit value, logs it, +and drives the SM (NimBLE: `ble_sm_inject_io` on `BLE_SM_IOACT_DISP`; +Bluedroid: no `esp_ble_passkey_reply` needed on `PASSKEY_NOTIF`). You +only need `ble_uart_passkey_reply()` / `ble_uart_compare_reply()` for +the interactive models below. + +`Passkey Entry` and `Numeric Comparison` are interactive — the SM +suspends pairing until the application reports the user's input. +`ble_uart` exposes one reply call per flavour: + +```c +int ble_uart_passkey_reply(uint32_t passkey); /* 0..999999 */ +int ble_uart_compare_reply(bool match); +``` + +Both are safe from any task, return immediately, and accept exactly +one reply per request. Subsequent calls (or calls with no request in +flight) return `BLE_UART_ENOTCONN`. `passkey > 999999` returns +`BLE_UART_EINVAL`. If the user fails to reply before the SM's pairing +timeout (controller default ≈ 30 s), the link surfaces +`BLE_UART_EVT_PAIRING_FAILED` and any later reply is silently dropped. + +```c +static void on_event(const ble_uart_evt_t *e) +{ + switch (e->id) { + case BLE_UART_EVT_PASSKEY_REQUEST: + /* Prompt the user; once digits are entered: */ + ble_uart_passkey_reply(user_input); /* 0..999999 */ + break; + + case BLE_UART_EVT_NUMERIC_COMPARE: + ESP_LOGI(TAG, "compare %06" PRIu32, e->numeric_compare.passkey); + /* Once the user confirms: */ + ble_uart_compare_reply(true /* or false on mismatch */); + break; + + default: break; + } +} +``` + +A `false` reply to `compare_reply()` makes pairing fail with a +numeric-comparison mismatch — surfaced as +`BLE_UART_EVT_PAIRING_FAILED`. To cancel `PASSKEY_REQUEST` without a +mismatch event, just don't call `passkey_reply()`; the SM will time +out the pairing. + +#### Backend differences + +- **Passkey Display without `on_event`**: both backends complete pairing; + only `PASSKEY_DISPLAY` is suppressed when the callback is `NULL`. The + UART banner (`show_passkey`) is always emitted for log-scraping tests. +- **Numeric Comparison edge case**: if `io_cap` resolved to DisplayOnly + but the central still negotiates NC (rare), Bluedroid rejects the + request when `on_event == NULL`; NimBLE may stall until the SM times + out — use `DISPLAY_YES_NO` / `KEYBOARD_DISPLAY` with a registered + `on_event` if you need NC. +- **NimBLE** lets the application observe the negotiated `key_size` + on `BLE_UART_EVT_LINK_SECURE`; **Bluedroid** surfaces a fixed `16` + (the value forced via `ESP_BLE_SM_MAX_KEY_SIZE` at install time — + Bluedroid does not expose the negotiated size on `AUTH_CMPL`). +- With `mitm=OFF`, NimBLE pairs with `_AUTHEN` permissions still + off on the chars; Bluedroid uses `ESP_GATT_PERM_*_ENCRYPTED` + (the encryption-without-MITM tier) to match. +- `cfg.encrypted=false` plus any `cfg.security.*=ON` override is + allowed — it partially enables the SM, e.g. + `cfg.encrypted=false, cfg.security.sc=ON` is "SC pairing without + MITM and without persisted bond". Useful only for lab interop + tests; production firmware should keep `cfg.encrypted = true` and + only override surgically. + +### 5.7 Bond management + +```c +/* All three are usable as soon as ble_uart_install() returns; they + * do not require ble_uart_open() to have been called yet — clearing + * stale bonds before the first advertising window is the canonical + * use case. */ +int ble_uart_get_bond_count(size_t *out_count); +int ble_uart_get_bonded_peers(ble_uart_addr_t *out, size_t cap, size_t *out_count); +int ble_uart_remove_peer(const ble_uart_addr_t *peer); +int ble_uart_clear_bonds(void); + +/* Address type used by remove_peer and BLE_UART_EVT_CONNECTED. */ +typedef struct { + uint8_t bytes[6]; /* big-endian: bytes[0] is the MSB octet */ + uint8_t type; /* BLE_UART_ADDR_TYPE_PUBLIC or _RANDOM */ +} ble_uart_addr_t; +``` + +| Function | Effect | +| --- | --- | +| `ble_uart_get_bond_count` | Number of peers in the persistent store; 0 means "no bonds yet". Pass `cap == 0` to `get_bonded_peers` for the same count without an address buffer. | +| `ble_uart_get_bonded_peers` | List bonded peer addresses; writes up to `cap`, reports total in `*out_count` (caller may re-call with a larger buffer if total > cap). `cap == 0` returns the count only. | +| `ble_uart_remove_peer` | Drop one peer's LTK / IRK / persisted CCCD. **Idempotent** — returns `BLE_UART_OK` even when the peer is not in the store (NimBLE: `ble_store_util_delete_peer` treats `BLE_HS_ENOENT` as success; Bluedroid: `esp_ble_remove_bond_device` does not fail on a missing entry). Call `get_bonded_peers()` first if you need to tell "removed" from "was never bonded". | +| `ble_uart_clear_bonds` | Drop *all* of the above; equivalent to a factory reset of the bond store, but does not touch any other NVS namespace | + +`ble_uart_remove_peer` and `ble_uart_clear_bonds` do **not** actively +disconnect the current link (encrypted or not). Call `ble_uart_close()` +first if you need an immediate disconnect and re-pair. + +**Where do I get the address?** From `BLE_UART_EVT_CONNECTED.connected.peer` +(see §5.2.1). Save it from your event handler the first time you see +each new peer, then pass it to `ble_uart_remove_peer` later when you +want to forget it. + +**Backend notes**: + +- Bluedroid matches bonds by BD address alone — `peer->type` is + ignored by `remove_peer`. If the peer first connected as + `address_A` and bonding succeeded, CONNECT and `get_bonded_peers()` + keep reporting `address_A` on later reconnects even when the + peer's over-the-air address has changed (e.g. a new RPA). +- NimBLE matches by `(type, identity-address)` — for an RPA peer this + is the resolved identity, **not** the random address you saw on the + wire. `BLE_UART_EVT_CONNECTED` reports the resolved identity when + it's known (post-pairing reconnect of a bonded RPA peer); on first + pair it equals the OTA random address, so the bond is recorded + under that random address and `remove_peer` works either way. +- Neither backend reports "peer not found" from `remove_peer` — a + wrong `(type, bytes)` pair that does not match any stored bond + still returns `BLE_UART_OK`. This mirrors the underlying stacks' + delete-if-present semantics, not a lookup-then-delete API. +- `ble_uart_clear_bonds` on Bluedroid iterates the bond list and + removes each entry; on NimBLE it calls `ble_store_clear()`, which + also wipes the local LTK and any persisted CCCD. +- **NimBLE** `get_bond_count` / `get_bonded_peers(cap=0)` heap-allocate a + scratch buffer sized to `BLE_STORE_MAX_BONDS` (not the caller's stack), + so they are safe from small-stack tasks regardless of + `CONFIG_BT_NIMBLE_MAX_BONDS`. + +### 5.8 Service UUID constant ```c extern const ble_uart_uuid128_t ble_uart_service_uuid; ``` Always `6e400001-b5a3-f393-e0a9-e50e24dcca9e` (the de-facto BLE UART service UUID). It is -already inserted into the scan response, so the **application normally -does not touch it**. You only need it if you take over advertising -yourself (see 6.3). +already inserted into the scan response **by the default payload**, so +the application only needs to reference it when it takes over the adv +bytes itself (see §5.9) or otherwise replaces our advertising (see §6.3). + +### 5.9 Custom advertising payloads + +`ble_uart` builds a sensible default for both the primary advertisement +and the scan response: + +| Packet | Default content | Why | +| --- | --- | --- | +| Primary adv (31 B max) | Flags AD + Complete Local Name (`device_name`) | Phones show the name; everything else in the 31 bytes is left for the application to add via `adv_data` | +| Scan response (31 B max) | Complete 128-bit BLE UART service UUID (18 B element) | The 128-bit UUID alone is too big to share the primary packet with a typical name | + +Set `adv_data` / `scan_rsp_data` in the config to override **everything +the application sees** — only the 3-byte Flags AD element of the primary +packet stays library-controlled (the BT spec mandates a Flags element, +and a few of its bits — General Discoverable / BR-EDR Not Supported — +are state we already negotiated with the controller). + +```c +/* +-- 31-byte primary advertisement packet ---------------------+ + * | [02 01 06] ← Flags AD prepended by ble_uart (3 bytes) | + * | | + * +-------------------------------------------------------------+ + * + * +-- 31-byte scan-response packet -----------------------------+ + * | | + * +-------------------------------------------------------------+ + */ +``` + +`adv_data_len` is checked at install time; oversized buffers fail with +`BLE_UART_EINVAL`. Both buffers are copied into module-private storage, +so the caller's pointers do not need to outlive the call. + +**Format**: a sequence of standard BT Core "AD structure" triplets — +`[length(1)] [AD type(1)] [value(length-1)]`. AD-type values are +defined in the *Bluetooth Assigned Numbers* document +([Generic Access Profile, §1](https://www.bluetooth.com/specifications/assigned-numbers/)). +Common ones: + +| Type | Name | Value format | +| --- | --- | --- | +| `0x09` | Complete Local Name | UTF-8 bytes | +| `0x08` | Shortened Local Name | UTF-8 bytes | +| `0x0A` | TX Power Level | 1 signed byte (dBm) | +| `0x07` | Complete List of 128-bit Service UUIDs | 16 bytes per UUID | +| `0xFF` | Manufacturer Specific Data | 2-byte company ID + payload | + +**Example — replace the default with name + UUID + 4 bytes of vendor data** + +```c +static const uint8_t adv_payload[] = { + /* Complete Local Name "MyDev" (1 + 1 + 5 = 7 bytes) */ + 0x06, 0x09, 'M', 'y', 'D', 'e', 'v', + + /* Complete 128-bit Service UUID — bytes are little-endian on air, + * matching ble_uart_service_uuid.bytes[]. (1 + 1 + 16 = 18 bytes) */ + 0x11, 0x07, + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e, + /* total = 7 + 18 = 25 bytes (≤ BLE_UART_ADV_DATA_MAX = 28) */ +}; + +static const uint8_t scan_rsp_payload[] = { + /* Manufacturer Specific Data: Espressif Systems (0x02E5) + 4 bytes */ + 0x07, 0xFF, 0xE5, 0x02, 0xDE, 0xAD, 0xBE, 0xEF, +}; + +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .device_name = "MyDev", /* GAP service value, peer-readable */ + .adv_data = adv_payload, + .adv_data_len = sizeof(adv_payload), + .scan_rsp_data = scan_rsp_payload, + .scan_rsp_data_len = sizeof(scan_rsp_payload), + .ble_uart_on_rx = on_rx, + .on_event = on_event, +}); +``` + +**Notes**: + +- `device_name` and `adv_data` are independent. The first is the GAP + service value that any connected peer can read over GATT; the second + is what scanners see before connecting. If you want the device name + visible during a scan, include a Complete-Local-Name AD element + (`0x09`) in `adv_data` yourself — providing custom `adv_data` + disables the auto-include path. +- The 31-byte packet limit is BLE 4.x legacy advertising. Extended + advertising (BLE 5.0) is **not** wired through this API — both + backends fall back to legacy advertising for portability. +- Set only one half if you want the other to keep its default — e.g. + custom `adv_data` with `scan_rsp_data = NULL` keeps the default + service-UUID scan response. --- @@ -467,8 +1164,12 @@ Effect: `ble_uart` registers its own service; you can call `ble_gatts_add_svcs()` **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. +table. **Caveat**: this must happen before the **first** `ble_uart_open()` +for that `install()` cycle, otherwise the host task is already running +and the GATT table is locked. If you use `ble_uart_close()` and later +`ble_uart_open()` without `uninstall()`, you must call your extra +`ble_svc_*_init()` / `ble_gatts_add_svcs()` again before that second +`open()` — see §5.3.1a. ```c ble_uart_install(&cfg); @@ -488,15 +1189,20 @@ ble_uart_open(); > 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 +### 6.4 Configuring the device name via Kconfig If you use the shared `ble_uart` component, options are already in `menuconfig → Component configuration → ESP-BLE-UART library`. If you copied only the `.c` / `.h` files into `main/`, copy `Kconfig` from `common/ble_uart/` as well (or merge its symbols into your own `Kconfig.projbuild`), then: +The bundled example builds a per-unit name as `-XXXX` where +`XXXX` is the last two BT MAC bytes in hex: + ```c -char name[24]; +uint8_t mac[6] = {0}; +esp_read_mac(mac, ESP_MAC_BT); +char name[BLE_UART_DEVICE_NAME_MAX + 1]; snprintf(name, sizeof(name), "%s-%02X%02X", CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]); @@ -507,8 +1213,11 @@ ble_uart_install(&(ble_uart_config_t){ }); ``` -Edit the default through `menuconfig → Component configuration → ESP-BLE-UART -library → BLE device name prefix`. +Edit the prefix through `menuconfig → Component configuration → +ESP-BLE-UART library → BLE device name prefix`. + +For a fixed name on every unit, skip the MAC suffix and pass any +string ≤ `BLE_UART_DEVICE_NAME_MAX` directly to `device_name`. ### 6.5 Pushing data proactively @@ -531,7 +1240,7 @@ static void sensor_task(void *arg) xTaskCreate(sensor_task, "sensor", 3072, NULL, 5, NULL); ``` -When nobody is subscribed, `ble_uart_tx` returns `BLE_HS_ENOTCONN` — +When nobody is subscribed, `ble_uart_tx` returns `BLE_UART_ENOTCONN` — **just ignore it**. --- @@ -543,10 +1252,11 @@ When nobody is subscribed, `ble_uart_tx` returns `BLE_HS_ENOTCONN` — | `ble_uart_install` | Any task; once per uninstall cycle | One-shot until `uninstall` | | `ble_uart_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_close_async` | Any task — including the BLE host task (use this from inside `on_rx` / `on_event`) | Idempotent; second call while a worker is draining returns `EALREADY` | +| `ble_uart_uninstall` | Any task **except the BLE host task** | Idempotent; see §5.3 release paths; polls in-flight `close_async` ≤~5 s. Best-effort teardown: returns the **first** `BLE_UART_*` failure (`ble_uart_close` or translated `esp_err_t`) but always wipes module state so a retry is possible. | | `ble_uart_tx` | Any FreeRTOS task | Yes — multi-task concurrent | | `ble_uart_is_connected` / `is_subscribed` | Any context | Yes (bool read; best-effort snapshot) | -| `ble_uart_on_rx` callback | BLE host task (NimBLE host task / Bluedroid BTC task) | Your code must not block, **must not call `close` / `uninstall`** | +| `ble_uart_on_rx` / `on_event` callback | BLE host task (NimBLE host task / Bluedroid BTC task); **`BLE_UART_EVT_CLOSED` is the lone exception — fires on the close-async worker task** | Your code must not block, **must not call `close` / `uninstall`** — use `ble_uart_close_async()` instead | | **Calling any `ble_uart` API from an ISR** | not allowed | Neither host stack supports it | --- @@ -575,7 +1285,7 @@ Measured throughput (ESP32-S3, iPhone 14 Pro central, MTU 247): | --- | --- | | `nimble_port_init rc=...` | NVS not initialised, or BT controller not enabled | | Compile error: `host/ble_hs.h` not found | `REQUIRES bt` is missing from CMakeLists | -| Device not discoverable | Device name exceeds the advertising packet limit (drop the tx_pwr field or shorten the name) | +| `ble_uart_install()` returns `BLE_UART_EINVAL` | Buffer too long (`device_name` / `adv_data` / `scan_rsp` limits in §5.9), impossible security (`mitm=ON` + `io_cap=NO_INPUT_OUTPUT`), **`io_cap` in `{KEYBOARD_ONLY, DISPLAY_YES_NO, KEYBOARD_DISPLAY}` with `on_event=NULL`** (note: default `AUTO` + `encrypted=true` and explicit `DISPLAY_ONLY` **do** allow `on_event=NULL`), or out-of-range `sc`/`bonding`/`mitm`/`io_cap`. See §5.6. | | Pairing fails | Central uses "Just Works" but we require MITM (`encrypted=true`). Use a central that supports passkey entry | | `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 | @@ -613,19 +1323,42 @@ If you **start from an empty project**: #include "ble_uart.h" /* === Types === */ -typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len); +typedef void (*ble_uart_rx_cb_t) (const uint8_t *data, size_t len); +typedef void (*ble_uart_evt_cb_t)(const ble_uart_evt_t *evt); typedef struct { - bool encrypted; - const char *device_name; - ble_uart_rx_cb_t ble_uart_on_rx; + ble_uart_sec_t sc; /* AUTO / OFF / ON — follow `encrypted` when AUTO */ + ble_uart_sec_t bonding; + ble_uart_sec_t mitm; + ble_uart_io_cap_t io_cap; /* AUTO / NO_INPUT_OUTPUT / DISPLAY_ONLY / + KEYBOARD_ONLY / DISPLAY_YES_NO / + KEYBOARD_DISPLAY */ +} ble_uart_security_t; + +typedef struct { + bool encrypted; /* preset: SC + Bonding + MITM + DisplayOnly */ + ble_uart_security_t security; /* per-feature overrides; see §5.6 */ + + const char *device_name; /* ≤ BLE_UART_DEVICE_NAME_MAX (26) */ + /* Custom adv payloads (NULL → defaults). + * Limits: adv_data_len ≤ BLE_UART_ADV_DATA_MAX (28), + * scan_rsp_data_len ≤ BLE_UART_SCAN_RSP_DATA_MAX (31). */ + const uint8_t *adv_data; + size_t adv_data_len; + const uint8_t *scan_rsp_data; + size_t scan_rsp_data_len; + ble_uart_rx_cb_t ble_uart_on_rx; + ble_uart_evt_cb_t on_event; /* optional for default Passkey Display; + required for KEYBOARD_ONLY / + DISPLAY_YES_NO / KEYBOARD_DISPLAY */ } ble_uart_config_t; /* === Lifecycle === */ int ble_uart_install(const ble_uart_config_t *cfg); /* host + GATT */ int ble_uart_open(void); /* start advertising (NimBLE: spawn host task) */ int ble_uart_close(void); /* stop adv / disconnect / quiesce host */ -int ble_uart_uninstall(void); /* tear down host + reset state */ +int ble_uart_close_async(void); /* same, fire-and-forget; signals BLE_UART_EVT_CLOSED on completion */ +int ble_uart_uninstall(void); /* best-effort teardown; first error, state always cleared */ /* === Send (callable from any task) === */ int ble_uart_tx(const uint8_t *data, size_t len); @@ -634,10 +1367,25 @@ int ble_uart_tx(const uint8_t *data, size_t len); /* Via the cfg.ble_uart_on_rx callback, signature: * void cb(const uint8_t *data, size_t len); */ +/* === Pairing replies (PASSKEY_REQUEST / NUMERIC_COMPARE only) === */ +int ble_uart_passkey_reply(uint32_t passkey); /* answer PASSKEY_REQUEST */ +int ble_uart_compare_reply(bool match); /* answer NUMERIC_COMPARE */ + /* === Status === */ bool ble_uart_is_connected(void); bool ble_uart_is_subscribed(void); +/* === Bond management (works after install) === */ +typedef struct { + uint8_t bytes[6]; /* big-endian: bytes[0] is MSB */ + uint8_t type; /* BLE_UART_ADDR_TYPE_PUBLIC or _RANDOM */ +} ble_uart_addr_t; + +int ble_uart_get_bond_count(size_t *out_count); +int ble_uart_get_bonded_peers(ble_uart_addr_t *out, size_t cap, size_t *out_count); +int ble_uart_remove_peer(const ble_uart_addr_t *peer); +int ble_uart_clear_bonds(void); + /* === Service UUID (for advertising; usually no need to touch) === */ extern const ble_uart_uuid128_t ble_uart_service_uuid; ``` diff --git a/examples/bluetooth/common/ble_uart/ble_uart.h b/examples/bluetooth/common/ble_uart/ble_uart.h index 584c0ac9eb3..ccf5cbd24cb 100644 --- a/examples/bluetooth/common/ble_uart/ble_uart.h +++ b/examples/bluetooth/common/ble_uart/ble_uart.h @@ -10,16 +10,24 @@ * backend is picked at compile time via CONFIG_BT_NIMBLE_ENABLED / * CONFIG_BT_BLUEDROID_ENABLED. * - * Lifecycle: + * Lifecycle — bring-up: * - * ble_uart_install(&cfg); // host + GATT service - * ble_uart_open(); // start advertising + auto-encrypt - * ... + * nvs_flash_init(); + * ble_uart_install(&cfg); // host + GATT service (once per uninstall) + * ble_uart_open(); // advertising + pairing + * + * Run-forever apps stop after open(). + * + * Lifecycle — release (pick one path; see PORTING.md §5.3): + * + * Path A — from a normal app task (not on_event / on_rx): * ble_uart_close(); // stop adv / disconnect / halt host * ble_uart_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. + * Path B — teardown triggered by a BLE event on the host task: + * ble_uart_close_async(); // in on_event / on_rx only + * // wait for BLE_UART_EVT_CLOSED (.closed.status == BLE_UART_OK) + * ble_uart_uninstall(); // on an app task — NOT inside CLOSED * * GATT layout (UUIDs are the widely used fixed 128-bit values): * @@ -57,6 +65,23 @@ typedef struct { uint8_t bytes[16]; } ble_uart_uuid128_t; +/* ----- BLE address ---------------------------------------------------- */ + +/** Address type, mirroring the BT Core spec values. */ +#define BLE_UART_ADDR_TYPE_PUBLIC 0 +#define BLE_UART_ADDR_TYPE_RANDOM 1 + +/** 6-octet BLE device address. + * + * `bytes` is in big-endian order — `bytes[0]` is the MSB octet, the + * way addresses are usually printed (`AA:BB:CC:DD:EE:FF`). Both + * backends marshal between this representation and their own native + * byte order internally, so callers never need to flip bytes. */ +typedef struct { + uint8_t bytes[6]; + uint8_t type; /* BLE_UART_ADDR_TYPE_PUBLIC or _RANDOM */ +} ble_uart_addr_t; + /* ----- Configuration -------------------------------------------------- */ /** RX byte callback. Invoked from the BLE host task whenever bytes @@ -71,27 +96,427 @@ typedef struct { * are rejected with ATT error 0x0d. */ typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len); +/* ----- Event callback ------------------------------------------------- */ + +/** Lifecycle / link-state events delivered to ble_uart_config_t::on_event. + * + * All events fire from the BLE host task context (NimBLE host task / + * Bluedroid BTC task), with one documented exception: + * BLE_UART_EVT_CLOSED is fired by the close-async worker task, after + * the host stack has been torn down — there is no host task left to + * deliver it from. See ble_uart_close_async(). + * + * The same threading rules as ble_uart_on_rx apply: don't block, and + * don't call ble_uart_close() / ble_uart_uninstall() (use the async + * variant if you need to teardown from inside an event handler). */ +typedef enum { + /** Physical link established. Payload: .connected.peer. + * Type is always BLE_UART_ADDR_TYPE_PUBLIC or _RANDOM (each + * backend's wider addr-type enum is collapsed before delivery). + * + * Backend semantics differ: + * - NimBLE: peer identity address (`peer_id_addr`). On first + * connect this equals the over-the-air address; on a bonded + * RPA reconnect it is the resolved identity, not the random + * address currently on the wire. + * - Bluedroid: the BD address recorded at bond time. If the + * peer connected as address_A and bonding succeeded, later + * reconnects still report address_A in CONNECT even when the + * peer's over-the-air address has changed (e.g. a new RPA). + * Matches `get_bonded_peers()` / `remove_peer` (`bytes` only). */ + BLE_UART_EVT_CONNECTED, + + /** Physical link torn down. Payload: .disconnected.reason + * (stack-specific disconnect code — esp_gatt_conn_reason_t on + * Bluedroid, NimBLE BLE host return code on NimBLE; see + * BLE_HS_HCI_ERR() / BLE_HS_ERR_HCI_BASE for HCI encoding). */ + BLE_UART_EVT_DISCONNECTED, + + /** CCCD on the TX characteristic changed. Payload: + * .subscribed.subscribed (true = notifications enabled). */ + BLE_UART_EVT_SUBSCRIBED, + + /** Link reached the encrypted+authenticated state requested at + * install time. Payload: .link_secure.{encrypted, authenticated, + * bonded, key_size}. Use this — not is_connected() — to gate any + * application logic that requires the channel to be secure. */ + BLE_UART_EVT_LINK_SECURE, + + /** SM asks the application to display a 6-digit passkey. + * Payload: .passkey.passkey (0..999999). The default banner on + * UART still prints; this callback is additive so a UI / test + * harness can avoid scraping logs. */ + BLE_UART_EVT_PASSKEY_DISPLAY, + + /** SM asks the application to collect a 6-digit passkey from the + * user (the central displays it; the user types it into this + * device). No payload. + * + * The application MUST respond by calling ble_uart_passkey_reply() + * with the 6 digits the user entered. Until the reply arrives — + * or until the SM's pairing timeout fires (the controller's + * default ~30 s) — pairing is suspended; on timeout the link + * surfaces BLE_UART_EVT_PAIRING_FAILED. + * + * Only fires when cfg.security.io_cap is one of the input-capable + * values (KEYBOARD_ONLY / KEYBOARD_DISPLAY) and the central asks + * for Passkey Entry. */ + BLE_UART_EVT_PASSKEY_REQUEST, + + /** SM asks the application to display a 6-digit value and let the + * user confirm whether the same value appears on the central. + * Payload: .numeric_compare.passkey (0..999999). + * + * The application MUST respond by calling ble_uart_compare_reply() + * with the user's verdict (true = match). Same suspend-and-time- + * out semantics as BLE_UART_EVT_PASSKEY_REQUEST. + * + * Only fires when cfg.security.io_cap is one of the + * comparison-capable values (DISPLAY_YES_NO / KEYBOARD_DISPLAY) + * and the central asks for Numeric Comparison (which itself + * requires LE Secure Connections on both sides). */ + BLE_UART_EVT_NUMERIC_COMPARE, + + /** Pairing failed or was rejected. Payload: .pairing_failed.reason + * (NimBLE BLE_HS_E* / Bluedroid esp_ble_auth_fail_rsn_t). */ + BLE_UART_EVT_PAIRING_FAILED, + + /** Async-close completion — fired only by ble_uart_close_async() + * after the worker task has finished the equivalent of a + * synchronous ble_uart_close(). Payload: .closed.status — the + * return code from that close (BLE_UART_OK on success). + * + * When .closed.status is BLE_UART_OK the host stack is fully + * quiesced — same state as right after ble_uart_close() returns. + * Defer ble_uart_uninstall() to a normal app task (set a flag / + * queue here); do not call uninstall from this handler — see + * PORTING.md §5.3.2. On failure (e.g. BLE_UART_EFAIL) the port + * may still be open; retry ble_uart_close() / ble_uart_close_async() + * from an app task. + * + * Unlike every other event in this enum, this one runs on the + * close-async worker task, NOT on the BLE host task — by the + * time it fires the host task is already gone. Keep the handler + * short; the worker clears s_closing after it returns. */ + BLE_UART_EVT_CLOSED, +} ble_uart_evt_id_t; + +/** Tagged union delivered to ble_uart_config_t::on_event. */ +typedef struct { + ble_uart_evt_id_t id; + union { + struct { + ble_uart_addr_t peer; + } connected; + + struct { + int reason; /* stack-specific disconnect code */ + } disconnected; + + struct { + bool subscribed; + } subscribed; + + struct { + bool encrypted; /* 1 = link is AES-CCM encrypted */ + bool authenticated; /* 1 = pairing used MITM protection */ + bool bonded; /* 1 = LTK persisted in NVS */ + uint8_t key_size; /* 7..16 (octets) */ + } link_secure; + + struct { + uint32_t passkey; /* 0..999999 */ + } passkey; + + struct { + uint32_t passkey; /* 0..999999 — the value to display */ + } numeric_compare; + + struct { + int reason; /* stack-specific status code */ + } pairing_failed; + + struct { + int status; /* BLE_UART_* from async close worker */ + } closed; + }; +} ble_uart_evt_t; + +/** Event callback. May be NULL — events are silently dropped then. */ +typedef void (*ble_uart_evt_cb_t)(const ble_uart_evt_t *evt); + +/* ----- Security configuration ---------------------------------------- */ + +/** Tri-state knob for the per-feature security overrides in + * ble_uart_config_t (`sc`, `bonding`, `mitm`). + * + * AUTO (= 0, the value of a zero-initialised struct member) means + * "use whatever cfg.encrypted implies": + * + * encrypted = true → AUTO behaves as ON + * encrypted = false → AUTO behaves as OFF + * + * OFF / ON force the bit regardless of the preset, letting the + * caller mix the preset with one or two surgical overrides without + * spelling out every other field. */ +typedef enum { + BLE_UART_SEC_AUTO = 0, + BLE_UART_SEC_OFF = 1, + BLE_UART_SEC_ON = 2, +} ble_uart_sec_t; + +/** SM Input/Output capability — combines with the central's IO cap and + * the resolved `mitm` bit to pick the pairing model (Just Works / + * Passkey Display / Passkey Entry / Numeric Comparison — see BT Core + * Spec §2.3.5.1). The application doesn't decide the method directly; + * it picks the IO cap that matches its UI and ble_uart fires the right + * event when the SM negotiates a method. + * + * Passing an out-of-range integer makes ble_uart_install() return + * BLE_UART_EINVAL. Only the input-capable values (KEYBOARD_ONLY, + * DISPLAY_YES_NO, KEYBOARD_DISPLAY) require cfg.on_event to be + * non-NULL — pairing would otherwise stall on unanswered + * BLE_UART_EVT_PASSKEY_REQUEST / NUMERIC_COMPARE. AUTO (resolves to + * DisplayOnly when MITM is ON), DISPLAY_ONLY, and NO_INPUT_OUTPUT do + * not require on_event; Passkey Display is handled internally. */ +typedef enum { + /** Default: DisplayOnly when the resolved MITM bit is ON; + * NoInputNoOutput when it is OFF. */ + BLE_UART_IO_CAP_AUTO = 0, + + /** Device has no UI; pairing always uses Just Works. Cannot + * satisfy MITM — combining this with mitm=ON makes + * ble_uart_install() return BLE_UART_EINVAL. */ + BLE_UART_IO_CAP_NO_INPUT_OUTPUT = 1, + + /** Device shows a 6-digit passkey on a display; the central + * enters it. Generates a fresh passkey for every pairing, + * surfaced via BLE_UART_EVT_PASSKEY_DISPLAY (no reply call + * needed — the central does the typing). */ + BLE_UART_IO_CAP_DISPLAY_ONLY = 2, + + /** Device has keys (or some other way to feed digits to the + * library) but no display; the central displays a 6-digit + * passkey, the user reads it from there and types it in. + * + * ble_uart fires BLE_UART_EVT_PASSKEY_REQUEST and waits for + * ble_uart_passkey_reply(). Requires cfg.on_event != NULL. */ + BLE_UART_IO_CAP_KEYBOARD_ONLY = 3, + + /** Device has a display + a yes/no confirmation control. With a + * similarly-equipped LE Secure Connections central this elects + * Numeric Comparison: ble_uart fires BLE_UART_EVT_NUMERIC_COMPARE + * with the 6-digit value to display, and waits for + * ble_uart_compare_reply(). + * + * Falls back to Just Works against legacy or NoInput peers. + * Requires cfg.on_event != NULL. */ + BLE_UART_IO_CAP_DISPLAY_YES_NO = 4, + + /** Device has a display AND a keypad (covers both Numeric + * Comparison and Passkey Entry). Best fit for a touchscreen UI + * that wants to handle every MITM-capable peer. + * + * ble_uart fires either BLE_UART_EVT_PASSKEY_REQUEST or + * BLE_UART_EVT_NUMERIC_COMPARE depending on what the SM + * negotiates with the central; respond with the matching reply + * API. Requires cfg.on_event != NULL. */ + BLE_UART_IO_CAP_KEYBOARD_DISPLAY = 5, +} ble_uart_io_cap_t; + +/** Per-feature security overrides, embedded in ble_uart_config_t. + * + * Each tri-state field defaults to AUTO (= 0, the value of any + * zero-initialised member), inheriting its bit from + * ble_uart_config_t::encrypted: + * + * encrypted = true → AUTO behaves as ON + * encrypted = false → AUTO behaves as OFF + * + * Set any field to OFF / ON to override that single bit while the + * rest still follow the preset. Common patterns are listed in + * PORTING.md §5.6 (e.g. encrypted=true with mitm=OFF + + * io_cap=NO_INPUT_OUTPUT for a displayless gateway). + * + * Combinations the SM cannot satisfy — io_cap=NO_INPUT_OUTPUT + * together with the resolved mitm=ON, or an out-of-range enum value + * — make ble_uart_install() return BLE_UART_EINVAL up front, before + * the host stack is brought up. */ +typedef struct { + /** Override LE Secure Connections (the BT 4.2+ pairing method + * that uses ECDH for the LTK). */ + ble_uart_sec_t sc; + + /** Override bonding (persistence of the LTK / IRK / persisted + * CCCD in NVS). With bonding=OFF the link is still encrypted + * (if sc/mitm are on) but every reconnect re-pairs. */ + ble_uart_sec_t bonding; + + /** Override MITM protection (man-in-the-middle: link + * authentication via passkey display / entry / numeric + * comparison). With mitm=OFF the link pairs via Just Works, + * which is encrypted but unauthenticated; the GATT permission + * flags drop their _AUTHEN bit so a Just-Works peer can + * read/write the UART characteristics. */ + ble_uart_sec_t mitm; + + /** SM IO capability — controls which pairing model is chosen + * alongside `mitm`. AUTO picks DisplayOnly when the resolved + * MITM bit is ON, NoInputNoOutput when it is OFF. */ + ble_uart_io_cap_t io_cap; +} ble_uart_security_t; + +/* ----- Advertising payload limits ------------------------------------ */ + +/** Maximum bytes the application may put in `adv_data`. + * + * BLE 4.x legacy primary advertising packets are capped at 31 bytes + * total. Of those, the 3-byte Flags AD element (length+type+value) + * is always added by ble_uart, leaving 31 − 3 = 28 bytes for the + * application. */ +#define BLE_UART_ADV_DATA_MAX 28 + +/** Maximum bytes the application may put in `scan_rsp_data`. + * + * Scan response packets are also capped at 31 bytes, with no + * mandatory AD elements — the entire 31 bytes belong to the + * application. */ +#define BLE_UART_SCAN_RSP_DATA_MAX 31 + +/** Maximum length (bytes, excluding NUL terminator) of `device_name`. + * + * Sized so that the *default* advertising payload — Flags AD + + * Complete Local Name AD — always fits in the 31-byte primary packet: + * + * 31 − 3 (Flags AD) − 2 (Name AD header) = 26 + * + * Names that exceed this length make `ble_uart_install()` return + * `BLE_UART_EINVAL` synchronously, instead of silently failing later + * in the host stack when advertising starts. + * + * This applies regardless of whether `adv_data` is set — the GAP + * service Device Name characteristic (UUID 0x2A00) reports the same + * string. Apps that need a longer GAP-service name with a shorter + * advertised name should keep `device_name` ≤ this limit and use + * `adv_data` to broadcast a shortened/different name instead. */ +#define BLE_UART_DEVICE_NAME_MAX 26 + /** Configuration handed to ble_uart_install(). */ 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). */ + /** Security preset (a one-line shortcut for the four override + * fields under `security` below). + * + * True = LE Secure Connections + Bonding + MITM, DisplayOnly IO, + * encrypted+authenticated RX/TX chars, bond persisted in + * NVS (NimBLE: requires CONFIG_BT_NIMBLE_NVS_PERSIST=y; + * Bluedroid: default). + * False = plaintext (lab debugging only — sniffable). + * + * Every member of `security` defaults to AUTO, meaning "follow + * this preset". Override individual bits there; see + * ble_uart_security_t for the resolution rules. */ bool encrypted; - /** GAP device name. NULL keeps the host stack default. Mind the - * 31-byte primary advertising limit (≤ 8 bytes recommended). */ + /** Per-feature security overrides. A zero-initialised value + * (every field AUTO) inherits everything from `encrypted`, so + * callers that just want the secure-by-default preset can leave + * this field unset: + * + * ble_uart_install(&(ble_uart_config_t){ + * .encrypted = true, // sc/bonding/mitm/io_cap all AUTO + * ... + * }); + * + * Surgical override: + * + * ble_uart_install(&(ble_uart_config_t){ + * .encrypted = true, + * .security = { .mitm = BLE_UART_SEC_OFF }, // SC + Bonding, no MITM + * ... + * }); + * + * See ble_uart_security_t for the full per-field docs. */ + ble_uart_security_t security; + + /** GAP device name (peer-readable via the GAP service, UUID 0x2A00). + * NULL keeps the host-stack default. + * + * Length must be ≤ BLE_UART_DEVICE_NAME_MAX (26) — over-long + * strings make ble_uart_install() return BLE_UART_EINVAL. + * + * This is NOT automatically inserted into the advertising payload + * when `adv_data` (below) is non-NULL — if you want the name to + * appear in scans without connecting, include a Complete Local + * Name AD element (type 0x09) in your `adv_data` bytes yourself. */ const char *device_name; + /** Optional raw advertising data — everything that goes after the + * 3-byte Flags AD element in the primary advertising packet. The + * Flags element is built by ble_uart and is NOT part of these + * bytes (don't include it). + * + * Format: standard BT Core "AD structure" sequence — repeating + * `[length(1)][AD type(1)][value(length-1)]` triplets. See the + * Bluetooth Assigned Numbers (Generic Access Profile) document + * for the full type list. + * + * Length must be ≤ BLE_UART_ADV_DATA_MAX (28). The buffer is + * copied at install time; the pointer does not need to outlive + * the call. + * + * Set to NULL (with adv_data_len=0) to keep the built-in default, + * which advertises only the Complete Local Name (taken from + * device_name). */ + const uint8_t *adv_data; + size_t adv_data_len; + + /** Optional raw scan response data — entire 31-byte payload is at + * the application's disposal; ble_uart adds nothing. + * + * Same `[len][type][value]` format and copy semantics as + * adv_data. Length must be ≤ BLE_UART_SCAN_RSP_DATA_MAX (31). + * + * Set to NULL (with scan_rsp_data_len=0) to keep the built-in + * default, which advertises the 128-bit BLE UART service UUID. */ + const uint8_t *scan_rsp_data; + size_t scan_rsp_data_len; + /** Byte handler for RX writes. NULL discards incoming data. */ ble_uart_rx_cb_t ble_uart_on_rx; + + /** Lifecycle / link-state event sink. NULL drops every event. + * See ble_uart_evt_id_t for the supported events; runs on the + * BLE host task with the same caveats as ble_uart_on_rx. */ + ble_uart_evt_cb_t on_event; } ble_uart_config_t; /* ----- Lifecycle ------------------------------------------------------ */ /** Bring up host stack + Security Manager + SIG services + BLE UART GATT * service. Caller must have already called nvs_flash_init(). - * cfg->device_name is copied; doesn't need to outlive the call. + * + * cfg->device_name, cfg->adv_data and cfg->scan_rsp_data are all + * copied internally; the caller's buffers don't need to outlive the + * call. Returns BLE_UART_EINVAL if any of these checks fail: + * strlen(cfg->device_name) > BLE_UART_DEVICE_NAME_MAX (26) + * cfg->adv_data_len > BLE_UART_ADV_DATA_MAX (28) + * cfg->scan_rsp_data_len > BLE_UART_SCAN_RSP_DATA_MAX (31) + * cfg->security.{sc,bonding,mitm} outside BLE_UART_SEC_{AUTO,OFF,ON} + * cfg->security.io_cap outside BLE_UART_IO_CAP_{AUTO, + * NO_INPUT_OUTPUT,DISPLAY_ONLY, + * KEYBOARD_ONLY,DISPLAY_YES_NO, + * KEYBOARD_DISPLAY} + * resolved mitm=ON + io_cap=NO_INPUT_OUTPUT + * (Just Works can never satisfy MITM) + * io_cap requires user input (KEYBOARD_ONLY, DISPLAY_YES_NO, + * KEYBOARD_DISPLAY) but cfg->on_event + * is NULL — the application would have + * no way to receive PASSKEY_REQUEST / + * NUMERIC_COMPARE and answer it + * (io_cap=AUTO with resolved mitm=ON, or DISPLAY_ONLY, does not + * need on_event — equivalent to Passkey Display handled inside + * the port; PASSKEY_DISPLAY via on_event is optional) + * * Single-shot until ble_uart_uninstall(); a second call returns * BLE_UART_EALREADY. */ int ble_uart_install(const ble_uart_config_t *cfg); @@ -111,16 +536,84 @@ int ble_uart_open(void); * quiesces the host. install state is preserved — call open() again * to resume. * - * Don't call from the BLE host task (i.e. from ble_uart_on_rx). */ + * NimBLE: also resets the local GATT server; the next open() re-adds + * GAP/GATT/UART. Service UUIDs are unchanged but ATT handles may + * differ — centrals must rediscover and re-subscribe (PORTING.md + * §5.3.1a). Bluedroid: host and GATT table stay up; open() only + * restarts advertising. + * + * Don't call from the BLE host task (i.e. from ble_uart_on_rx or + * ble_uart_evt_cb_t) — it would deadlock waiting for the disconnect + * event that the host task itself is supposed to deliver. Use + * ble_uart_close_async() in those contexts instead. */ int ble_uart_close(void); +/** Fire-and-forget variant of ble_uart_close(). Returns immediately + * after spawning a small worker task that runs the regular close + * sequence in the background; safe from ANY task — including the + * BLE host task (i.e. from inside ble_uart_on_rx or on_event), where + * the synchronous variant deadlocks. + * + * Completion is reported on the on_event callback as + * BLE_UART_EVT_CLOSED with .closed.status set to the worker's + * ble_uart_close() result. When status is BLE_UART_OK the host stack + * is fully torn down — then uninstall on an app task after + * BLE_UART_EVT_CLOSED (PORTING.md §5.3.2 Path B). + * (BLE_UART_EVT_DISCONNECTED is also delivered, ahead of CLOSED, if + * there was a peer.) + * + * Idempotent in the harmless sense: calling it before + * ble_uart_open() has succeeded, or while a previous async close + * is still draining, returns BLE_UART_EALREADY without spawning a + * second worker. Returns BLE_UART_ENOMEM if FreeRTOS can't + * allocate the worker task. */ +int ble_uart_close_async(void); + /** Counterpart to ble_uart_install(). Force-closes if still open, * then tears down the host stack + controller. After this returns, * install() can run from scratch. * - * Don't call from the BLE host task. */ + * Don't call from the BLE host task (NimBLE host / Bluedroid BTC). + * If a ble_uart_close_async() worker is still running, this call + * polls for up to ~5 s and then proceeds with teardown anyway if the + * worker has not exited — do not call uninstall from another task + * while a close_async is in flight unless you follow PORTING.md §5.3: + * Path A — ble_uart_close() then uninstall from an app task; or + * Path B — close_async, then uninstall on an app task after + * BLE_UART_EVT_CLOSED with .closed.status == BLE_UART_OK (never + * call uninstall from inside the CLOSED handler). */ int ble_uart_uninstall(void); +/* ----- Pairing replies ----------------------------------------------- */ + +/** Answer an in-flight BLE_UART_EVT_PASSKEY_REQUEST. + * + * `passkey` is the 6-digit value the user read off the central's + * display and entered on this device — must be in 0..999999. + * + * Safe from any task. Returns: + * BLE_UART_OK reply was injected into the SM + * BLE_UART_EINVAL passkey > 999999 + * BLE_UART_ENOTCONN no PASSKEY_REQUEST is currently pending + * (link dropped, pairing already timed out, + * or the SM asked for something else) + * BLE_UART_EFAIL backend rejected the inject + * + * Each PASSKEY_REQUEST event accepts exactly one reply; subsequent + * calls return BLE_UART_ENOTCONN until the next request. */ +int ble_uart_passkey_reply(uint32_t passkey); + +/** Answer an in-flight BLE_UART_EVT_NUMERIC_COMPARE. + * + * `match` is the user's verdict: true if the 6-digit values shown + * on this device and on the central are identical, false otherwise. + * A `false` reply makes pairing fail with a numeric-comparison + * mismatch, surfaced as BLE_UART_EVT_PAIRING_FAILED. + * + * Same threading semantics and return codes as + * ble_uart_passkey_reply(). */ +int ble_uart_compare_reply(bool match); + /* ----- TX ------------------------------------------------------------- */ /** Send raw bytes to the connected central as one or more TX @@ -143,6 +636,68 @@ bool ble_uart_is_connected(void); * the CCCD write); exposed for diagnostics only. */ bool ble_uart_is_subscribed(void); +/* ----- Bond management ----------------------------------------------- */ + +/** Number of bonded peers in the persistent store. + * + * Requires ble_uart_install() to have run; works whether or not + * ble_uart_open() has been called. *out_count is left untouched on + * failure. Safe from any task. */ +int ble_uart_get_bond_count(size_t *out_count); + +/** List the bonded peers' addresses. + * + * Up to `cap` entries are written to `out`; on success *out_count + * receives the **total** number of bonds (which may exceed `cap`). + * When *out_count > cap the caller may allocate a larger buffer + * and re-call to read the rest. + * + * `out` may be NULL if `cap` is 0 — useful as a preflight to size + * an exactly-fitting buffer (although ble_uart_get_bond_count() + * does the same with one less argument). + * + * Safe from any task. Requires ble_uart_install() to have run. */ +int ble_uart_get_bonded_peers(ble_uart_addr_t *out, + size_t cap, + size_t *out_count); + +/** Drop the bond (LTK / IRK / persisted CCCD) for one peer. + * + * Does not actively disconnect the current link (encrypted or not). + * Call ble_uart_close() first if you need an immediate disconnect + * and re-pair. + * + * `peer` is matched against the identity address in the bond store. + * Backend matching: + * - NimBLE: `(type, bytes)`. `BLE_UART_EVT_CONNECTED` and + * `get_bonded_peers()` both yield identity addresses suitable + * for this call (first connect: same as over-the-air; bonded RPA + * reconnect: resolved identity, not the random on the wire). + * - Bluedroid: `bytes` only — `type` is ignored. The bond store + * and CONNECT both use the address seen when bonding was + * established (address_A); later over-the-air changes are not + * reflected in either API. + * + * Idempotent: returns BLE_UART_OK whether or not the peer was bonded + * (both backends treat "already absent" as success — NimBLE's + * ble_store_util_delete_peer maps BLE_HS_ENOENT to 0). Use + * ble_uart_get_bonded_peers() first if you need to distinguish + * "removed" from "was never bonded". + * + * Returns BLE_UART_EINVAL if peer is NULL or ble_uart_install() has + * not run. Safe from any task. */ +int ble_uart_remove_peer(const ble_uart_addr_t *peer); + +/** Drop ALL bonded peers — equivalent to a factory reset of the bond + * store, but does not touch any other NVS namespace. + * + * Does not actively disconnect the current link (encrypted or not). + * Call ble_uart_close() first if you need an immediate disconnect + * and re-pair. + * + * Returns BLE_UART_OK if the store was cleared. Safe from any task. */ +int ble_uart_clear_bonds(void); + /* ----- Service UUID -------------------------------------------------- */ /** The BLE UART service UUID, exposed for custom advertising payloads. diff --git a/examples/bluetooth/common/ble_uart/ble_uart_bluedroid.c b/examples/bluetooth/common/ble_uart/ble_uart_bluedroid.c index 8e09ac6cc4d..7bd3474ee08 100644 --- a/examples/bluetooth/common/ble_uart/ble_uart_bluedroid.c +++ b/examples/bluetooth/common/ble_uart/ble_uart_bluedroid.c @@ -21,6 +21,7 @@ #include "ble_uart.h" #include +#include #include #include "freertos/FreeRTOS.h" @@ -109,9 +110,42 @@ static bool s_subscribed; static bool s_installed; static bool s_opened; static bool s_shutting_down; +/* Set by ble_uart_close_async() when its worker task is in flight, + * cleared by the worker just before it exits. uninstall() polls this + * to drain a pending async close before tearing the stack down. */ +static volatile bool s_closing; static volatile bool s_attr_tab_ready; static bool s_adv_active; +/* Resolved security policy. Computed once in install() from cfg.encrypted + * + the per-feature overrides (cfg.sc/bonding/mitm/io_cap), then read + * by the GAP event handler and the GATT-table builder. + * s_link_encrypted = true if any of {sc, bonding, mitm} resolved ON + * → kick pairing on connect, require encryption on chars + * s_mitm_required = resolved mitm bit + * → require ENC_MITM perm flags (Just-Works peer cannot read/write) */ +static bool s_link_encrypted; +static bool s_mitm_required; + +/* Pending Passkey-Entry / Numeric-Comparison request awaiting an + * application reply via ble_uart_passkey_reply / ble_uart_compare_reply. + * + * Bluedroid identifies the pairing peer by BD address (no conn-handle + * exposed at the SM layer), so we cache it in s_pending_io_bda. The + * `kind` field discriminates the two flavors so the wrong reply API + * is rejected up front. NONE = no request in flight. + * + * No FreeRTOS lock — both fields are written only from the BTC task, + * and the reply API is the only outside reader. The reader takes a + * local snapshot before issuing the SDK reply call. */ +typedef enum { + PENDING_IO_NONE = 0, + PENDING_IO_PASSKEY, /* expects ble_uart_passkey_reply */ + PENDING_IO_NUMCMP, /* expects ble_uart_compare_reply */ +} pending_io_kind_t; +static volatile pending_io_kind_t s_pending_io_kind; +static esp_bd_addr_t s_pending_io_bda; + /* 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. */ @@ -119,11 +153,26 @@ static bool s_adv_active; #define SCAN_RSP_CONFIG_FLAG (1 << 1) static uint8_t s_adv_config_done; +/* Optional user-supplied advertising payloads. When *_len is non-zero + * we feed *_data straight to the Bluedroid raw configuration API + * (esp_ble_gap_config_adv_data_raw / config_scan_rsp_data_raw); the + * adv buffer carries the 3-byte Flags AD element we built in install() + * followed by the user's bytes. Zero length means "use the default + * struct-based path in configure_advertising()". */ +static uint8_t s_adv_data_buf[3 + BLE_UART_ADV_DATA_MAX]; +static uint8_t s_adv_data_len; +static uint8_t s_scan_rsp_buf[BLE_UART_SCAN_RSP_DATA_MAX]; +static uint8_t s_scan_rsp_len; + /* 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; +/* Forward declaration so handle_write() / GATT-event handler / GAP-event + * handler can fire events before emit_evt's body lower in this file. */ +static void emit_evt(const ble_uart_evt_t *evt); + /* ===== Backend rc → public rc ========================================= */ static int xlate_rc(esp_err_t rc) @@ -139,17 +188,26 @@ static int xlate_rc(esp_err_t rc) /* ===== GATT attribute table =========================================== */ -/* Permissions are patched at install time depending on cfg.encrypted. */ +/* Permissions are patched at install time depending on the resolved + * security policy. Three permission tiers: + * !link_enc → plain READ / WRITE (any peer) + * link_enc && !mitm → ENCRYPTED (Just-Works peer OK; auth bit not required) + * link_enc && mitm → ENC_MITM (only authenticated peers) */ static esp_gatts_attr_db_t s_nus_db[NUS_IDX_NB]; -static void build_attr_table(bool encrypted) +static void build_attr_table(bool link_enc, bool mitm) { - 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); + esp_gatt_perm_t r_perm, w_perm; + if (!link_enc) { + r_perm = ESP_GATT_PERM_READ; + w_perm = ESP_GATT_PERM_WRITE; + } else if (mitm) { + r_perm = ESP_GATT_PERM_READ_ENC_MITM; + w_perm = ESP_GATT_PERM_WRITE_ENC_MITM; + } else { + r_perm = ESP_GATT_PERM_READ_ENCRYPTED; + w_perm = ESP_GATT_PERM_WRITE_ENCRYPTED; + } /* [SVC] primary service declaration */ s_nus_db[NUS_IDX_SVC] = (esp_gatts_attr_db_t){ @@ -238,7 +296,7 @@ static void build_attr_table(bool encrypted) static esp_ble_adv_data_t s_adv_data = { .set_scan_rsp = false, .include_name = true, - .include_txpower = true, + .include_txpower = false, .min_interval = 0, .max_interval = 0, .appearance = 0x00, @@ -251,8 +309,9 @@ static esp_ble_adv_data_t s_adv_data = { .flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT), }; -/* Scan response = 128-bit UART service UUID. Splitting it off the primary payload - * leaves room for name + tx_pwr in the 31-byte primary. */ +/* Scan response = 128-bit UART service UUID. Splitting it off the primary + * payload leaves room for the Complete Local Name in the 31-byte primary + * (an 18-byte UUID element + a 9-byte name AD wouldn't fit alongside Flags). */ static esp_ble_adv_data_t s_scan_rsp_data = { .set_scan_rsp = true, .include_name = false, @@ -284,6 +343,11 @@ static int start_advertising(void) /* Push adv data + scan response. start_advertising is triggered from * the matching SET_COMPLETE_EVT once both halves are realised. * + * Each half independently picks struct-API (default payload) or raw-API + * (when the app supplied bytes via cfg). The two SET_COMPLETE event + * variants (regular vs. _RAW_) clear the same latch bit, so the GAP + * handler doesn't need to know which path we took. + * * 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 @@ -292,13 +356,23 @@ 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); + esp_err_t rc; + if (s_adv_data_len > 0) { + rc = esp_ble_gap_config_adv_data_raw(s_adv_data_buf, s_adv_data_len); + } else { + 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 (s_scan_rsp_len > 0) { + rc = esp_ble_gap_config_scan_rsp_data_raw(s_scan_rsp_buf, s_scan_rsp_len); + } else { + 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 @@ -420,8 +494,17 @@ static void handle_write(esp_ble_gatts_cb_param_t *p) } 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); + bool sub = (cccd & 0x0001) != 0; + ESP_LOGI(TAG, "subscribe cccd=0x%04x sub=%d", cccd, sub); + /* Edge-trigger: a redundant CCCD write (same value twice) + * shouldn't double-fire SUBSCRIBED. */ + if (sub != s_subscribed) { + s_subscribed = sub; + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_SUBSCRIBED, + .subscribed = { .subscribed = sub }, + }); + } } } @@ -508,7 +591,7 @@ static void gatts_profile_event_handler(esp_gatts_cb_event_t event, ESP_LOGI(TAG, "mtu=%u (conn=%u)", s_local_mtu, param->mtu.conn_id); break; - case ESP_GATTS_CONNECT_EVT: + 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; @@ -521,24 +604,61 @@ static void gatts_profile_event_handler(esp_gatts_cb_event_t event, 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) { + /* esp_bd_addr_t is already MSB-first, matches our public + * bytes[] convention — no byte reversal needed. Narrow the + * 4-value Bluedroid addr type into our public 2-value enum + * (RPA_* collapse onto their underlying public/random type). */ + ble_uart_evt_t e = { .id = BLE_UART_EVT_CONNECTED }; + memcpy(e.connected.peer.bytes, param->connect.remote_bda, 6); + e.connected.peer.type = + (param->connect.ble_addr_type == BLE_ADDR_TYPE_PUBLIC + || param->connect.ble_addr_type == BLE_ADDR_TYPE_RPA_PUBLIC) + ? BLE_UART_ADDR_TYPE_PUBLIC + : BLE_UART_ADDR_TYPE_RANDOM; + emit_evt(&e); + if (s_link_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); + * first encrypted attribute access. The security level + * tracks the resolved MITM bit so a mitm=OFF peer is + * allowed to pair via Just Works. */ + esp_ble_sec_act_t sec_act = s_mitm_required + ? ESP_BLE_SEC_ENCRYPT_MITM + : ESP_BLE_SEC_ENCRYPT_NO_MITM; + esp_ble_set_encryption(param->connect.remote_bda, sec_act); } break; + } case ESP_GATTS_DISCONNECT_EVT: ESP_LOGI(TAG, "disconnect conn_id=%u reason=0x%x", param->disconnect.conn_id, param->disconnect.reason); + /* Drop any pending Passkey-Entry / NC reply; pairing was + * cancelled along with the link. */ + s_pending_io_kind = PENDING_IO_NONE; + /* Match NimBLE's BLE_GAP_SUBSCRIBE_REASON_TERM behaviour: if + * the central was subscribed when the link dropped, synthesize + * an "implicit unsubscribe" event before DISCONNECTED so a + * strict state-machine consumer can rely on a single rule + * ("SUBSCRIBED tracks notification flow") regardless of host + * stack. NimBLE does this in ble_gatts.c on TERM; Bluedroid + * doesn't, so we do it here. */ + if (s_subscribed) { + s_subscribed = false; + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_SUBSCRIBED, + .subscribed = { .subscribed = false }, + }); + } 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(); + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_DISCONNECTED, + .disconnected = { .reason = (int)param->disconnect.reason }, + }); if (!s_shutting_down) { start_advertising(); } @@ -576,15 +696,23 @@ static void gap_event_handler(esp_gap_ble_cb_event_t event, * 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. */ + * and launch advertising with a malformed payload. + * + * Each side handles two event variants — the regular SET_COMPLETE + * (struct API) and the _RAW_ variant (raw-bytes API). They both + * clear the same latch bit; only the parameter struct differs. */ case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: + case ESP_GAP_BLE_ADV_DATA_RAW_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); + esp_bt_status_t st = + (event == ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT) + ? param->adv_data_cmpl.status + : param->adv_data_raw_cmpl.status; + if (st != ESP_BT_STATUS_SUCCESS) { + ESP_LOGE(TAG, "adv_data set failed status=0x%x", st); s_adv_config_done = 0; break; } @@ -593,15 +721,20 @@ static void gap_event_handler(esp_gap_ble_cb_event_t event, start_advertising(); } break; + } case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: + case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_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); + esp_bt_status_t st = + (event == ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT) + ? param->scan_rsp_data_cmpl.status + : param->scan_rsp_data_raw_cmpl.status; + if (st != ESP_BT_STATUS_SUCCESS) { + ESP_LOGE(TAG, "scan_rsp set failed status=0x%x", st); s_adv_config_done = 0; break; } @@ -610,6 +743,7 @@ static void gap_event_handler(esp_gap_ble_cb_event_t event, start_advertising(); } break; + } case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: if (param->adv_start_cmpl.status != ESP_BT_STATUS_SUCCESS) { @@ -618,7 +752,20 @@ static void gap_event_handler(esp_gap_ble_cb_event_t event, s_adv_active = false; break; } - ESP_LOGI(TAG, "advertising started"); + /* With a caller-supplied adv_data the broadcast name is whatever + * bytes the caller put in — not necessarily the GAP-service + * Device Name. Log each path differently so a misconfigured + * payload is easy to spot. (The GAP name itself isn't echoed + * here on this backend: Bluedroid swallows the pointer in + * REG_EVT and there's no sync getter.) */ + if (s_adv_data_len > 0 || s_scan_rsp_len > 0) { + ESP_LOGI(TAG, "advertising with custom payload " + "(adv=%u B, scan_rsp=%u B)", + (unsigned)s_adv_data_len, + (unsigned)s_scan_rsp_len); + } else { + ESP_LOGI(TAG, "advertising started"); + } break; case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: @@ -626,16 +773,45 @@ static void gap_event_handler(esp_gap_ble_cb_event_t event, ESP_LOGI(TAG, "advertising stopped"); break; - case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: - show_passkey(param->ble_security.key_notif.passkey); + case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: { + uint32_t pk = param->ble_security.key_notif.passkey; + /* Banner stays for backward compat with log-scraping tests; + * on_event is additive. */ + show_passkey(pk); + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_PASSKEY_DISPLAY, + .passkey = { .passkey = pk }, + }); break; + } case ESP_GAP_BLE_AUTH_CMPL_EVT: { esp_ble_auth_cmpl_t *a = ¶m->ble_security.auth_cmpl; + /* Pairing has resolved one way or the other; clear any pending + * Passkey-Entry / NC request so the next pairing starts fresh + * and a stale reply from a slow user gets rejected. */ + s_pending_io_kind = PENDING_IO_NONE; if (a->success) { ESP_LOGI(TAG, "pairing ok auth_mode=0x%x", a->auth_mode); + /* Bluedroid has no `key_size` field on auth_cmpl; we + * forced 16 in configure_security() (ESP_BLE_SM_MAX_KEY_SIZE). + * Authenticated/bonded come from the negotiated auth_mode + * bitfield (ESP_LE_AUTH_BOND=bit0, REQ_MITM=bit2, SC=bit3). */ + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_LINK_SECURE, + .link_secure = { + .encrypted = true, + .authenticated = !!(a->auth_mode & ESP_LE_AUTH_REQ_MITM), + .bonded = !!(a->auth_mode & ESP_LE_AUTH_BOND), + .key_size = 16, + }, + }); } else { ESP_LOGW(TAG, "pairing failed reason=0x%x", a->fail_reason); + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_PAIRING_FAILED, + .pairing_failed = { .reason = (int)a->fail_reason }, + }); } break; } @@ -649,14 +825,50 @@ static void gap_event_handler(esp_gap_ble_cb_event_t event, 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); + case ESP_GAP_BLE_PASSKEY_REQ_EVT: + /* Central is asking us to enter a passkey it just displayed. + * Cache the peer + flavour so ble_uart_passkey_reply() knows + * where to inject; the application now owes us a reply. */ + ESP_LOGI(TAG, "passkey entry requested from " ESP_BD_ADDR_STR, + ESP_BD_ADDR_HEX(param->ble_security.ble_req.bd_addr)); + memcpy(s_pending_io_bda, param->ble_security.ble_req.bd_addr, + sizeof(s_pending_io_bda)); + s_pending_io_kind = PENDING_IO_PASSKEY; + emit_evt(&(ble_uart_evt_t){ .id = BLE_UART_EVT_PASSKEY_REQUEST }); break; + case ESP_GAP_BLE_NC_REQ_EVT: { + /* Numeric Comparison: both ends should display the same + * 6-digit value. We surface it via on_event and wait for the + * application to confirm via ble_uart_compare_reply(). + * + * Backstop: with no on_event registered we'd silently hang + * the SM until pairing times out. resolve_sec_policy already + * rejects that combination at install time (DISPLAY_YES_NO / + * KEYBOARD_DISPLAY both require on_event), but a peer can + * still trigger NC against an AUTO io_cap that resolved to + * DisplayOnly — extremely unlikely in practice (would need + * the central to *also* run with DisplayOnly), but if it + * does happen we reject the comparison rather than silently + * accept and pretend the user verified the digits. */ + uint32_t cmp = param->ble_security.key_notif.passkey; + if (s_cfg.on_event == NULL) { + ESP_LOGW(TAG, "NC_REQ but no on_event handler; rejecting"); + esp_ble_confirm_reply(param->ble_security.ble_req.bd_addr, false); + break; + } + ESP_LOGI(TAG, "numeric compare %06" PRIu32 " from " ESP_BD_ADDR_STR, + cmp, ESP_BD_ADDR_HEX(param->ble_security.ble_req.bd_addr)); + memcpy(s_pending_io_bda, param->ble_security.ble_req.bd_addr, + sizeof(s_pending_io_bda)); + s_pending_io_kind = PENDING_IO_NUMCMP; + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_NUMERIC_COMPARE, + .numeric_compare = { .passkey = cmp }, + }); + break; + } + default: break; } @@ -705,14 +917,307 @@ int ble_uart_tx(const uint8_t *data, size_t len) bool ble_uart_is_connected(void) { return s_conn_id != 0xFFFF; } bool ble_uart_is_subscribed(void) { return s_subscribed; } +/* ===== Event dispatch ================================================= */ + +/* NULL-safe forwarder so each call site stays one-liner. Runs on the + * BTC task; emit_evt's caller owns the (typically stack-allocated) + * ble_uart_evt_t. */ +static void emit_evt(const ble_uart_evt_t *evt) +{ + if (s_cfg.on_event != NULL) { + s_cfg.on_event(evt); + } +} + +/* ===== Pairing replies ================================================ */ + +/* Snapshot the pending request, validate against the expected kind, + * issue the matching SDK reply, clear pending state. Mirror of the + * NimBLE backend's do_pairing_reply. */ +static int do_pairing_reply(pending_io_kind_t expected, + uint32_t passkey, + bool accept) +{ + pending_io_kind_t kind = s_pending_io_kind; + if (kind == PENDING_IO_NONE || kind != expected) { + return BLE_UART_ENOTCONN; + } + + /* Snapshot the address; clear pending state up front so a + * re-entrant on_event triggered by the SDK reply doesn't see + * stale state. (esp_ble_passkey_reply / confirm_reply are + * synchronous on Bluedroid.) */ + esp_bd_addr_t bda; + memcpy(bda, s_pending_io_bda, sizeof(bda)); + s_pending_io_kind = PENDING_IO_NONE; + + esp_err_t rc; + if (expected == PENDING_IO_PASSKEY) { + rc = esp_ble_passkey_reply(bda, true, passkey); + } else { /* PENDING_IO_NUMCMP */ + rc = esp_ble_confirm_reply(bda, accept); + } + if (rc != ESP_OK) { + ESP_LOGW(TAG, "%s rc=%s", + expected == PENDING_IO_PASSKEY ? "passkey_reply" : "confirm_reply", + esp_err_to_name(rc)); + return xlate_rc(rc); + } + return BLE_UART_OK; +} + +int ble_uart_passkey_reply(uint32_t passkey) +{ + if (passkey > 999999) { + return BLE_UART_EINVAL; + } + return do_pairing_reply(PENDING_IO_PASSKEY, passkey, false); +} + +int ble_uart_compare_reply(bool match) +{ + return do_pairing_reply(PENDING_IO_NUMCMP, 0, match); +} + +/* ===== Bond management ================================================ */ + +int ble_uart_get_bond_count(size_t *out_count) +{ + if (out_count == NULL || !s_installed) { + return BLE_UART_EINVAL; + } + int n = esp_ble_get_bond_device_num(); + if (n < 0) { + ESP_LOGW(TAG, "get_bond_device_num rc=%d", n); + return BLE_UART_EFAIL; + } + *out_count = (size_t)n; + return BLE_UART_OK; +} + +int ble_uart_get_bonded_peers(ble_uart_addr_t *out, size_t cap, size_t *out_count) +{ + if (out_count == NULL || !s_installed + || (out == NULL && cap > 0)) { + return BLE_UART_EINVAL; + } + int total = esp_ble_get_bond_device_num(); + if (total < 0) { + ESP_LOGW(TAG, "get_bond_device_num rc=%d", total); + return BLE_UART_EFAIL; + } + if (total == 0) { + *out_count = 0; + return BLE_UART_OK; + } + if (cap == 0) { + *out_count = (size_t)total; + return BLE_UART_OK; + } + + /* esp_ble_get_bond_device_list expects a buffer sized to `total` + * (it takes dev_num as in/out — the input must be ≥ actual). Heap + * because esp_ble_bond_dev_t is ~80 B per entry. */ + esp_ble_bond_dev_t *list = calloc((size_t)total, sizeof(*list)); + if (list == NULL) { + return BLE_UART_ENOMEM; + } + int got = total; + esp_err_t rc = esp_ble_get_bond_device_list(&got, list); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "get_bond_device_list rc=%s", esp_err_to_name(rc)); + free(list); + return xlate_rc(rc); + } + + /* Marshal at most `cap` entries; report total count regardless so + * a caller with an under-sized buffer learns to retry. */ + size_t to_copy = ((size_t)got < cap) ? (size_t)got : cap; + for (size_t i = 0; i < to_copy; i++) { + memcpy(out[i].bytes, list[i].bd_addr, 6); + out[i].type = + (list[i].bd_addr_type == BLE_ADDR_TYPE_PUBLIC + || list[i].bd_addr_type == BLE_ADDR_TYPE_RPA_PUBLIC) + ? BLE_UART_ADDR_TYPE_PUBLIC + : BLE_UART_ADDR_TYPE_RANDOM; + } + free(list); + *out_count = (size_t)got; + return BLE_UART_OK; +} + +int ble_uart_remove_peer(const ble_uart_addr_t *peer) +{ + if (peer == NULL || !s_installed) { + return BLE_UART_EINVAL; + } + /* esp_bd_addr_t is uint8_t[6] in MSB-first order, identical to + * our public ble_uart_addr_t.bytes — pass through. The address + * type is not part of esp_ble_remove_bond_device's contract: + * Bluedroid identifies bonds by BD address alone. */ + esp_bd_addr_t bd; + memcpy(bd, peer->bytes, sizeof(bd)); + esp_err_t rc = esp_ble_remove_bond_device(bd); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "remove_bond_device rc=%s", esp_err_to_name(rc)); + return xlate_rc(rc); + } + return BLE_UART_OK; +} + +int ble_uart_clear_bonds(void) +{ + if (!s_installed) { + return BLE_UART_EINVAL; + } + int n = esp_ble_get_bond_device_num(); + if (n < 0) { + ESP_LOGW(TAG, "get_bond_device_num rc=%d", n); + return BLE_UART_EFAIL; + } + if (n == 0) { + return BLE_UART_OK; + } + + /* Pull the full list once. Removing entries one-by-one inside + * the iterator would not be safe — esp_ble_remove_bond_device + * mutates the underlying SMP list. Heap-allocate to avoid a + * worst-case stack burst (each esp_ble_bond_dev_t is ~80 B). */ + esp_ble_bond_dev_t *list = calloc((size_t)n, sizeof(*list)); + if (list == NULL) { + return BLE_UART_ENOMEM; + } + esp_err_t rc = esp_ble_get_bond_device_list(&n, list); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "get_bond_device_list rc=%s", esp_err_to_name(rc)); + free(list); + return xlate_rc(rc); + } + + /* Remove each. Record the first failure but keep going so a + * single corrupt entry doesn't strand the rest. */ + esp_err_t first_err = ESP_OK; + for (int i = 0; i < n; i++) { + esp_err_t e = esp_ble_remove_bond_device(list[i].bd_addr); + if (e != ESP_OK && first_err == ESP_OK) { + first_err = e; + ESP_LOGW(TAG, "remove_bond_device[%d] rc=%s", + i, esp_err_to_name(e)); + } + } + free(list); + return first_err == ESP_OK ? BLE_UART_OK : xlate_rc(first_err); +} + /* ===== Lifecycle ====================================================== */ -static int configure_security(bool encrypted) +/* Resolved view of cfg.encrypted + the per-feature overrides + * (cfg.sc / cfg.bonding / cfg.mitm / cfg.io_cap). Computed once in + * install() and consumed by configure_security() / build_attr_table(). */ +struct sec_policy { + bool sc; + bool bonding; + bool mitm; + bool link_enc; /* derived: sc || bonding || mitm */ + esp_ble_io_cap_t iocap; /* ESP_IO_CAP_OUT / NONE */ + esp_ble_auth_req_t auth_req;/* assembled bit-mask, see below */ +}; + +static int resolve_sec_policy(const ble_uart_config_t *cfg, + struct sec_policy *out) { - 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; + const ble_uart_security_t *sec = &cfg->security; + + /* Range-check the public enums up front. Accepting (say) a + * dangling 99 here would propagate to esp_ble_gap_set_security_param + * as garbage and the SM would refuse pairing for non-obvious reasons. */ + if ((unsigned)sec->sc > BLE_UART_SEC_ON + || (unsigned)sec->bonding > BLE_UART_SEC_ON + || (unsigned)sec->mitm > BLE_UART_SEC_ON + || (unsigned)sec->io_cap > BLE_UART_IO_CAP_KEYBOARD_DISPLAY) { + return BLE_UART_EINVAL; + } + + /* Input-capable IO caps fire BLE_UART_EVT_PASSKEY_REQUEST or + * BLE_UART_EVT_NUMERIC_COMPARE and need an application reply via + * ble_uart_passkey_reply / ble_uart_compare_reply. With on_event + * NULL the caller would never see the request and pairing would + * silently stall until the SM times out — fail synchronously. + * + * This checks the *configured* io_cap, not the value resolved + * below. AUTO and DISPLAY_ONLY are excluded on purpose: AUTO with + * mitm=ON becomes DisplayOnly; the central enters the passkey we + * generate — no ble_uart_passkey_reply() / compare_reply() needed. + * BLE_UART_EVT_PASSKEY_DISPLAY is additive when on_event is set. */ + if (cfg->on_event == NULL + && (sec->io_cap == BLE_UART_IO_CAP_KEYBOARD_ONLY + || sec->io_cap == BLE_UART_IO_CAP_DISPLAY_YES_NO + || sec->io_cap == BLE_UART_IO_CAP_KEYBOARD_DISPLAY)) { + return BLE_UART_EINVAL; + } + + /* AUTO inherits the bit from cfg.encrypted; OFF / ON override. */ + bool preset = cfg->encrypted; + out->sc = (sec->sc == BLE_UART_SEC_AUTO) ? preset + : (sec->sc == BLE_UART_SEC_ON); + out->bonding = (sec->bonding == BLE_UART_SEC_AUTO) ? preset + : (sec->bonding == BLE_UART_SEC_ON); + out->mitm = (sec->mitm == BLE_UART_SEC_AUTO) ? preset + : (sec->mitm == BLE_UART_SEC_ON); + /* "Link will be encrypted" iff the SM runs at all — and the SM + * runs whenever any of these three bits is set. Pairing-without- + * bonding still encrypts the live link with a session LTK. */ + out->link_enc = out->sc || out->bonding || out->mitm; + + /* IO capability: AUTO picks the minimum that lets the resolved + * MITM bit succeed; the explicit values map straight to the + * Bluedroid ESP_IO_CAP_* constants used by the SM. */ + switch (sec->io_cap) { + case BLE_UART_IO_CAP_DISPLAY_ONLY: + out->iocap = ESP_IO_CAP_OUT; + break; + case BLE_UART_IO_CAP_NO_INPUT_OUTPUT: + out->iocap = ESP_IO_CAP_NONE; + break; + case BLE_UART_IO_CAP_KEYBOARD_ONLY: + out->iocap = ESP_IO_CAP_IN; + break; + case BLE_UART_IO_CAP_DISPLAY_YES_NO: + out->iocap = ESP_IO_CAP_IO; + break; + case BLE_UART_IO_CAP_KEYBOARD_DISPLAY: + out->iocap = ESP_IO_CAP_KBDISP; + break; + case BLE_UART_IO_CAP_AUTO: + default: + out->iocap = out->mitm ? ESP_IO_CAP_OUT : ESP_IO_CAP_NONE; + break; + } + + /* Just Works (NoInputNoOutput) cannot satisfy MITM — the SM + * would reject pairing in flight. Catch it synchronously here. */ + if (out->mitm && out->iocap == ESP_IO_CAP_NONE) { + return BLE_UART_EINVAL; + } + + /* Bluedroid's auth_req is a bit-mask: + * bit 0 = ESP_LE_AUTH_BOND + * bit 2 = ESP_LE_AUTH_REQ_MITM + * bit 3 = ESP_LE_AUTH_REQ_SC_ONLY + * The combined ESP_LE_AUTH_REQ_SC_MITM_BOND etc. constants are + * just convenience names for those bit unions — assembling from + * the individual flags here mirrors any combination cleanly. */ + out->auth_req = (esp_ble_auth_req_t)( + (out->bonding ? ESP_LE_AUTH_BOND : 0) + | (out->mitm ? ESP_LE_AUTH_REQ_MITM : 0) + | (out->sc ? ESP_LE_AUTH_REQ_SC_ONLY : 0)); + return BLE_UART_OK; +} + +static int configure_security(const struct sec_policy *pol) +{ + esp_ble_auth_req_t auth_req = pol->auth_req; + esp_ble_io_cap_t iocap = pol->iocap; 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; @@ -754,6 +1259,83 @@ int ble_uart_install(const ble_uart_config_t *cfg) memset(&s_cfg, 0, sizeof(s_cfg)); } + /* Validate device_name length up front. Beyond + * BLE_UART_DEVICE_NAME_MAX the default-path advertising would + * silently fail at config_adv_data time; surfacing the error here + * is much friendlier. strnlen with cap+1 also stops a missing-NUL + * caller buffer from running into uninitialised memory. */ + if (s_cfg.device_name != NULL) { + size_t nlen = strnlen(s_cfg.device_name, BLE_UART_DEVICE_NAME_MAX + 1); + if (nlen > BLE_UART_DEVICE_NAME_MAX) { + ESP_LOGE(TAG, "device_name too long: > %u bytes", + (unsigned)BLE_UART_DEVICE_NAME_MAX); + memset(&s_cfg, 0, sizeof(s_cfg)); + return BLE_UART_EINVAL; + } + } + + /* Validate caller-supplied advertising payloads up front so an + * oversized buffer fails the install instead of corrupting the + * adv packet at start time (where errors only surface in logs). + * (NULL + len>0 is also rejected — almost always a caller bug.) */ + if (s_cfg.adv_data_len > BLE_UART_ADV_DATA_MAX + || (s_cfg.adv_data == NULL && s_cfg.adv_data_len > 0)) { + ESP_LOGE(TAG, "bad adv_data: ptr=%p len=%u (max=%u)", + s_cfg.adv_data, + (unsigned)s_cfg.adv_data_len, + (unsigned)BLE_UART_ADV_DATA_MAX); + memset(&s_cfg, 0, sizeof(s_cfg)); + return BLE_UART_EINVAL; + } + if (s_cfg.scan_rsp_data_len > BLE_UART_SCAN_RSP_DATA_MAX + || (s_cfg.scan_rsp_data == NULL && s_cfg.scan_rsp_data_len > 0)) { + ESP_LOGE(TAG, "bad scan_rsp_data: ptr=%p len=%u (max=%u)", + s_cfg.scan_rsp_data, + (unsigned)s_cfg.scan_rsp_data_len, + (unsigned)BLE_UART_SCAN_RSP_DATA_MAX); + memset(&s_cfg, 0, sizeof(s_cfg)); + return BLE_UART_EINVAL; + } + + /* Resolve cfg.encrypted + per-feature overrides into a flat + * policy. Validation (out-of-range enums + impossible MITM/IO + * combination) happens up front so install() rejects bad cfgs + * synchronously, before any host-stack resources are allocated. */ + struct sec_policy pol; + int srv = resolve_sec_policy(&s_cfg, &pol); + if (srv != BLE_UART_OK) { + ESP_LOGE(TAG, "bad security cfg: encrypted=%d sc=%d bonding=%d " + "mitm=%d io_cap=%d", + (int)s_cfg.encrypted, + (int)s_cfg.security.sc, (int)s_cfg.security.bonding, + (int)s_cfg.security.mitm, (int)s_cfg.security.io_cap); + memset(&s_cfg, 0, sizeof(s_cfg)); + return srv; + } + s_link_encrypted = pol.link_enc; + s_mitm_required = pol.mitm; + + /* Copy raw payloads now (caller's pointers may not outlive install). + * For adv_data we also prepend the 3-byte Flags AD ourselves — + * the controller-visible Flags element is library-controlled and + * not part of what the application owns. */ + s_adv_data_len = 0; + s_scan_rsp_len = 0; + if (s_cfg.adv_data != NULL && s_cfg.adv_data_len > 0) { + s_adv_data_buf[0] = 0x02; /* AD length */ + s_adv_data_buf[1] = 0x01; /* AD type: Flags */ + s_adv_data_buf[2] = ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT; + memcpy(s_adv_data_buf + 3, s_cfg.adv_data, s_cfg.adv_data_len); + s_adv_data_len = (uint8_t)(3 + s_cfg.adv_data_len); + } + if (s_cfg.scan_rsp_data != NULL && s_cfg.scan_rsp_data_len > 0) { + memcpy(s_scan_rsp_buf, s_cfg.scan_rsp_data, s_cfg.scan_rsp_data_len); + s_scan_rsp_len = (uint8_t)s_cfg.scan_rsp_data_len; + } + /* Drop the pointers — install must not retain caller buffers. */ + s_cfg.adv_data = NULL; + s_cfg.scan_rsp_data = NULL; + /* 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); @@ -813,7 +1395,7 @@ int ble_uart_install(const ble_uart_config_t *cfg) /* SM must be configured before app_register so any incoming * pairing request finds the right policy. */ - int srv = configure_security(s_cfg.encrypted); + srv = configure_security(&pol); if (srv != BLE_UART_OK) { ESP_LOGE(TAG, "security config failed rc=%d", srv); rc = ESP_FAIL; @@ -830,7 +1412,7 @@ int ble_uart_install(const ble_uart_config_t *cfg) esp_err_to_name(rc)); } - build_attr_table(s_cfg.encrypted); + build_attr_table(s_link_encrypted, s_mitm_required); rc = esp_ble_gatts_app_register(UART_APP_ID); if (rc != ESP_OK) { @@ -908,7 +1490,11 @@ int ble_uart_open(void) return BLE_UART_OK; } -int ble_uart_close(void) +/* Body of ble_uart_close(); also called directly by the close-async + * worker, which has already latched s_closing itself. The public + * wrapper below uses s_closing to reject a sync close that races + * with an in-flight async close. */ +static int do_close(void) { if (!s_opened) { return BLE_UART_EALREADY; @@ -951,32 +1537,115 @@ int ble_uart_close(void) return BLE_UART_OK; } +int ble_uart_close(void) +{ + /* If a ble_uart_close_async() worker is in flight, the close + * sequence is already running on the worker's task — let it + * finish rather than racing it from here. The worker drives + * s_opened to false on its own, so the next sync close after + * the worker drains will get the natural !s_opened EALREADY. */ + if (s_closing) { + return BLE_UART_EALREADY; + } + return do_close(); +} + +/* ===== Async close ==================================================== */ + +/* Background worker spawned by ble_uart_close_async(). Lives just + * long enough to run the synchronous close path (which blocks up to + * 500 ms waiting for DISCONNECT_EVT), then fires the completion event + * and self-deletes. Spawned as a separate task so on_event handlers + * running on the BTC task aren't pinned by the disconnect wait. */ +static void close_async_task(void *arg) +{ + (void)arg; + + /* Bypass the s_closing gate in ble_uart_close(): we ARE the + * in-flight async close that gate is meant to protect against. */ + int rc = do_close(); + if (rc != BLE_UART_OK && rc != BLE_UART_EALREADY) { + ESP_LOGW(TAG, "close_async: do_close rc=%d", rc); + } + + /* Deliver CLOSED on the worker task. Applications must defer + * ble_uart_uninstall() to another task (PORTING.md §5.3.2). + * Concurrent uninstall() may clear s_cfg while we read on_event. */ + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_CLOSED, + .closed = { .status = rc }, + }); + + s_closing = false; + vTaskDelete(NULL); +} + +int ble_uart_close_async(void) +{ + /* Same-state checks as the synchronous variant: nothing to close + * if we never opened, and no point spawning a second worker if + * the first hasn't drained yet. */ + if (!s_opened || s_closing) { + return BLE_UART_EALREADY; + } + + /* Latch BEFORE spawning so a racing caller (different task) sees + * the in-flight state immediately and gets EALREADY. */ + s_closing = true; + + /* 3 KB is comfortably more than the close path uses (a couple of + * GAP API calls + a 50×10ms vTaskDelay loop); bump if you wedge + * a heavy on_event handler between adv_stop and CLOSED. */ + BaseType_t ok = xTaskCreate(close_async_task, "ble_close", + 3072, NULL, + tskIDLE_PRIORITY + 2, NULL); + if (ok != pdPASS) { + s_closing = false; + return BLE_UART_ENOMEM; + } + return BLE_UART_OK; +} + int ble_uart_uninstall(void) { if (!s_installed) { return BLE_UART_EALREADY; } + /* If a ble_uart_close_async() worker is still draining, poll s_closing + * for up to ~5 s before touching shared state. On timeout, teardown + * continues anyway — applications must follow PORTING.md §5.3.2 so + * uninstall runs only after the worker has finished. */ + for (int i = 0; i < 500 && s_closing; i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (s_closing) { + ESP_LOGW(TAG, "uninstall: close_async worker still running, " + "tearing down anyway"); + } + /* 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; + 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_err == ESP_OK) first_err = rc; + if (first_rc == BLE_UART_OK) { + first_rc = 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) { + if (rc != ESP_OK && first_rc == BLE_UART_OK) { ESP_LOGE(TAG, "gatts_app_unregister rc=%s", esp_err_to_name(rc)); - first_err = rc; + first_rc = xlate_rc(rc); } s_gatts_if = ESP_GATT_IF_NONE; } @@ -984,22 +1653,30 @@ int ble_uart_uninstall(void) 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; + if (first_rc == BLE_UART_OK) { + first_rc = xlate_rc(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; + if (first_rc == BLE_UART_OK) { + first_rc = xlate_rc(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; + if (first_rc == BLE_UART_OK) { + first_rc = xlate_rc(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; + if (first_rc == BLE_UART_OK) { + first_rc = xlate_rc(rc); + } } /* Wipe state unconditionally, even on partial failure. */ @@ -1012,11 +1689,18 @@ int ble_uart_uninstall(void) s_installed = false; s_opened = false; s_shutting_down = false; + s_closing = false; s_attr_tab_ready = false; s_adv_active = false; s_adv_config_done = 0; + s_adv_data_len = 0; + s_scan_rsp_len = 0; + s_link_encrypted = false; + s_mitm_required = false; + s_pending_io_kind = PENDING_IO_NONE; + memset(s_pending_io_bda, 0, sizeof(s_pending_io_bda)); prep_reset(); - return first_err == ESP_OK ? BLE_UART_OK : xlate_rc(first_err); + return first_rc; } #endif /* CONFIG_BT_BLUEDROID_ENABLED */ diff --git a/examples/bluetooth/common/ble_uart/ble_uart_nimble.c b/examples/bluetooth/common/ble_uart/ble_uart_nimble.c index c1c515c51f6..9887240c517 100644 --- a/examples/bluetooth/common/ble_uart/ble_uart_nimble.c +++ b/examples/bluetooth/common/ble_uart/ble_uart_nimble.c @@ -16,6 +16,7 @@ #include #include +#include #include #include "freertos/FreeRTOS.h" @@ -59,6 +60,25 @@ static int xlate_rc(int nimble_rc) } } +/* Collapse NimBLE's 4-value peer addr type (0–3) into our public + * 2-value enum (identity types map onto public/random). */ +static uint8_t nimble_peer_type_to_uart(uint8_t nimble_type) +{ + return (nimble_type == BLE_ADDR_PUBLIC || + nimble_type == BLE_ADDR_PUBLIC_ID) + ? BLE_UART_ADDR_TYPE_PUBLIC + : BLE_UART_ADDR_TYPE_RANDOM; +} + +/* Marshal a NimBLE ble_addr_t into our public ble_uart_addr_t. */ +static void from_nimble_addr(const ble_addr_t *src, ble_uart_addr_t *dst) +{ + dst->type = nimble_peer_type_to_uart(src->type); + for (int i = 0; i < 6; i++) { + dst->bytes[i] = src->val[5 - i]; + } +} + /* Provided by NimBLE's `store/config` lib. */ extern void ble_store_config_init(void); @@ -89,9 +109,10 @@ static const ble_uuid128_t s_chr_tx_uuid = BLE_UUID128_INIT(NUS_TX_BYTES); #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 + * NULL when CONFIG_BT_NIMBLE_GAP_SERVICE=n (would NULL-deref). The + * cap is BLE_UART_DEVICE_NAME_MAX (validated in install) + NUL; we + * round up for safety margin. */ +#define DEV_NAME_MAX (BLE_UART_DEVICE_NAME_MAX + 2) static ble_uart_config_t s_cfg; static char s_dev_name[DEV_NAME_MAX]; @@ -101,9 +122,60 @@ static volatile uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE; static bool s_subscribed; static bool s_installed; static bool s_opened; +/* After close(), ble_gatts_stop() drops svc-def pointers; open() must + * count/add again before the next ble_hs_start(). Cleared on install. */ +static bool s_gatts_needs_readd; +#if MYNEWT_VAL(BLE_HS_AUTO_START) +/* Set in install() when nimble_port_init() queues the one-shot AUTO_START + * event; cleared on first open() so we don't also ble_hs_sched_start() and + * trip assert(rc==0) in ble_hs_event_start_stage2 (BLE_HS_EALREADY). */ +static bool s_hs_auto_start_pending; +#endif static bool s_shutting_down; /* gates auto-readvertise during close */ +/* Set by ble_uart_close_async() when its worker task is in flight, + * cleared by the worker just before it exits. uninstall() polls this + * to drain a pending async close before tearing the port down. */ +static volatile bool s_closing; static uint8_t s_own_addr_type; +/* Resolved security policy. Computed once in install() from cfg.encrypted + * + the per-feature overrides (cfg.sc/bonding/mitm/io_cap), then read + * in the GAP event handler and the GATT-table builder. + * s_link_encrypted = true if any of {sc, bonding, mitm} resolved ON + * → kick pairing on connect, require encryption on chars + * s_mitm_required = resolved mitm bit + * → require AUTHEN flag on chars (Just-Works peer cannot read/write) */ +static bool s_link_encrypted; +static bool s_mitm_required; + +/* Pending Passkey-Entry / Numeric-Comparison request awaiting an + * application reply via ble_uart_passkey_reply / ble_uart_compare_reply. + * + * s_pending_io_conn — conn_handle the SM is asking about, or + * BLE_HS_CONN_HANDLE_NONE if no request is in + * flight. Set in PASSKEY_ACTION, cleared on + * reply, on disconnect, and on enc_change. + * s_pending_io_action — BLE_SM_IOACT_INPUT or BLE_SM_IOACT_NUMCMP; + * used to reject mismatched reply calls (e.g. + * passkey_reply during NUMCMP). + * + * No FreeRTOS lock — both fields are written only from the host task, + * and the reply API is the only outside reader. The reader takes a + * local snapshot before injecting. */ +static volatile uint16_t s_pending_io_conn = BLE_HS_CONN_HANDLE_NONE; +static volatile uint8_t s_pending_io_action; + +/* Optional user-supplied advertising payloads. When `s_adv_data_len` + * is non-zero we feed `s_adv_data` straight to ble_gap_adv_set_data; + * the buffer always carries the 3-byte Flags AD element we built in + * install() followed by the user's bytes. Same for the scan response + * (no Flags element there). Zero length means "use the default + * field-builder path in start_advertising()". */ +static uint8_t s_adv_data[3 + BLE_UART_ADV_DATA_MAX]; +static uint8_t s_adv_data_len; +static uint8_t s_scan_rsp_data[BLE_UART_SCAN_RSP_DATA_MAX]; +static uint8_t s_scan_rsp_data_len; + static int gap_event(struct ble_gap_event *event, void *arg); static int start_advertising(void); @@ -144,21 +216,35 @@ static int chr_access(uint16_t conn_handle, uint16_t attr_handle, * 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) + * unencrypted link (see ble_gatts.c:ble_gatts_chr_clt_cfg_flags_from_chr_flags). + * + * The _ENC and _AUTHEN halves are split so an encrypted-but-unauthenticated + * (Just Works) link still passes when mitm=OFF — _AUTHEN gates on the + * link's authenticated bit which Just Works doesn't set. */ +#define CHR_FLAG_RW_ENC (BLE_GATT_CHR_F_READ_ENC | \ + BLE_GATT_CHR_F_WRITE_ENC) +#define CHR_FLAG_RW_AUTHEN (BLE_GATT_CHR_F_READ_AUTHEN | \ + BLE_GATT_CHR_F_WRITE_AUTHEN) +#define CHR_FLAG_NOTIFY_ENC (BLE_GATT_CHR_F_NOTIFY_INDICATE_ENC) +#define CHR_FLAG_NOTIFY_AUTHEN (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) +static void build_gatt_table(bool link_enc, bool mitm) { /* `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; + ble_gatt_chr_flags rw_enc = 0; + ble_gatt_chr_flags notify_enc = 0; + if (link_enc) { + rw_enc |= CHR_FLAG_RW_ENC; + notify_enc |= CHR_FLAG_NOTIFY_ENC; + if (mitm) { + rw_enc |= CHR_FLAG_RW_AUTHEN; + notify_enc |= CHR_FLAG_NOTIFY_AUTHEN; + } + } s_chr_defs[0] = (struct ble_gatt_chr_def){ .uuid = &s_chr_rx_uuid.u, @@ -181,16 +267,47 @@ static void build_gatt_table(bool encrypted) s_svc_defs[1] = (struct ble_gatt_svc_def){0}; } +static int register_uart_gatt_svc(void) +{ + build_gatt_table(s_link_encrypted, s_mitm_required); + + int rc = ble_gatts_count_cfg(s_svc_defs); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gatts_count_cfg rc=%d", rc); + return xlate_rc(rc); + } + rc = ble_gatts_add_svcs(s_svc_defs); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gatts_add_svcs rc=%d", rc); + return xlate_rc(rc); + } + return BLE_UART_OK; +} + +/* Re-queue std + UART svc defs after close(). Host stop + gatts_reset clear + * the ATT table and free the svc-def pointer array; only re-adding UART would + * leave GAP/GATT (and anything else added at install) off-air on the next + * ble_hs_start(). ble_svc_*_init() is safe to recall on ESP-IDF: SYSINIT_ASSERT + * is a no-op and gap name storage is not reallocated if already present. */ +static int reregister_gatt_svcs_after_close(void) +{ +#if NIMBLE_BLE_CONNECT + ble_svc_gap_init(); +#endif + ble_svc_gatt_init(); + return register_uart_gatt_svc(); +} + 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", + ESP_LOGD(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", + ESP_LOGD(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; @@ -248,41 +365,74 @@ int ble_uart_tx(const uint8_t *data, size_t len) bool ble_uart_is_connected(void) { return s_conn_handle != BLE_HS_CONN_HANDLE_NONE; } bool ble_uart_is_subscribed(void) { return s_subscribed; } +/* ===== Event dispatch ================================================= */ + +/* Forward a tagged event to the application callback. NULL-safe so all + * call sites stay one-liners; runs on the NimBLE host task — caller + * must keep the local `ble_uart_evt_t` alive across the call (we do + * via stack/compound literal at each site). */ +static void emit_evt(const ble_uart_evt_t *evt) +{ + if (s_cfg.on_event != NULL) { + s_cfg.on_event(evt); + } +} + /* ===== 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 = 128-bit service UUID. */ - const char *name = s_dev_name; - size_t name_len = strlen(name); + int rc; - 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 service 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; + /* Two paths: raw-bytes (when the app provided its own payload) + * and field-builder (default). Mixing is allowed — e.g. raw + * adv_data + default scan_rsp. + * + * Default primary payload: Flags AD + Complete Local Name. The + * 128-bit service UUID lives in the scan response (the 31-byte + * primary packet can't hold name + 128-bit UUID together). */ + if (s_adv_data_len > 0) { + rc = ble_gap_adv_set_data(s_adv_data, s_adv_data_len); + if (rc != 0) { + ESP_LOGE(TAG, "adv_set_data rc=%d", rc); + return rc; + } + } else { + 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, + /* If no name was set, advertise without one (NimBLE accepts + * NULL+0); the service 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, + }; + 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; + if (s_scan_rsp_data_len > 0) { + rc = ble_gap_adv_rsp_set_data(s_scan_rsp_data, s_scan_rsp_data_len); + if (rc != 0) { + ESP_LOGE(TAG, "adv_rsp_set_data rc=%d", rc); + return rc; + } + } else { + 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 = { @@ -295,7 +445,21 @@ static int start_advertising(void) ESP_LOGE(TAG, "adv_start rc=%d", rc); return rc; } - ESP_LOGI(TAG, "advertising as '%s'", name_len > 0 ? name : ""); + /* "advertising as ''" only makes sense when ble_uart owns + * the primary payload — with a caller-supplied adv_data the name + * the scanner sees is whatever bytes the caller put in there, not + * s_dev_name (which is only exposed via the GAP-service Device + * Name characteristic, post-connect). Pick the wording per path. */ + if (s_adv_data_len > 0 || s_scan_rsp_data_len > 0) { + ESP_LOGI(TAG, "advertising with custom payload " + "(adv=%u B, scan_rsp=%u B; GAP-service name='%s')", + (unsigned)s_adv_data_len, + (unsigned)s_scan_rsp_data_len, + s_dev_name[0] ? s_dev_name : ""); + } else { + ESP_LOGI(TAG, "advertising as '%s'", + s_dev_name[0] ? s_dev_name : ""); + } return 0; } @@ -325,9 +489,22 @@ static int gap_event(struct ble_gap_event *event, void *arg) if (event->connect.status == 0) { s_conn_handle = event->connect.conn_handle; s_subscribed = false; + /* Look up the peer's address; on first pair this equals + * peer_ota_addr, on bonded reconnect this is the resolved + * identity address. ble_gap_conn_find should never fail + * for a just-arrived connect event, but guard anyway — + * a zero-address payload is preferable to a stale stack + * read. */ + ble_uart_evt_t e = { .id = BLE_UART_EVT_CONNECTED }; + struct ble_gap_conn_desc d; + if (ble_gap_conn_find(event->connect.conn_handle, &d) == 0) { + from_nimble_addr(&d.peer_id_addr, &e.connected.peer); + } + emit_evt(&e); /* Start pairing immediately (rather than lazily on the - * first encrypted attribute access). */ - if (s_cfg.encrypted) { + * first encrypted attribute access). Resolved policy: + * any of {sc, bonding, mitm} ON → pairing required. */ + if (s_link_encrypted) { ble_gap_security_initiate(event->connect.conn_handle); } } else if (!s_shutting_down) { @@ -339,6 +516,14 @@ static int gap_event(struct ble_gap_event *event, void *arg) ESP_LOGI(TAG, "disconnect reason=%d", event->disconnect.reason); s_conn_handle = BLE_HS_CONN_HANDLE_NONE; s_subscribed = false; + /* Drop any pending Passkey-Entry / NC reply; pairing was + * cancelled along with the link. A stale value here would + * make the next reply call inject into a closed conn. */ + s_pending_io_conn = BLE_HS_CONN_HANDLE_NONE; + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_DISCONNECTED, + .disconnected = { .reason = event->disconnect.reason }, + }); if (!s_shutting_down) { start_advertising(); } @@ -350,18 +535,49 @@ static int gap_event(struct ble_gap_event *event, void *arg) case BLE_GAP_EVENT_ADV_COMPLETE: ESP_LOGI(TAG, "adv_complete reason=%d", event->adv_complete.reason); - if (!s_shutting_down) { + /* Don't auto-restart while a connection is up. Undirected adv + * auto-stops at the LL on connect (BT Core spec), and an + * explicit start while connected would either fail (single- + * conn build, the default) or accept a second peripheral + * link we don't want to handle here. ADV_COMPLETE can still + * arrive in connected state via NimBLE-internal cleanup + * (e.g. resolving-list updates after bonding); ignore it. */ + if (!s_shutting_down && s_conn_handle == BLE_HS_CONN_HANDLE_NONE) { start_advertising(); } return 0; case BLE_GAP_EVENT_ENC_CHANGE: + /* Pairing has resolved one way or the other; clear any pending + * Passkey-Entry / NC request so the next pairing starts fresh. */ + s_pending_io_conn = BLE_HS_CONN_HANDLE_NONE; 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); + /* Dispatch on the actual sec_state, not the rc — bonded + * reconnects can finish with status=BLE_HS_ETIMEOUT (13) + * while encrypted=1 thanks to a benign race with the + * peer's auto-encrypt; reporting that as PAIRING_FAILED + * would be wrong. */ + if (desc.sec_state.encrypted) { + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_LINK_SECURE, + .link_secure = { + .encrypted = (bool)desc.sec_state.encrypted, + .authenticated = (bool)desc.sec_state.authenticated, + .bonded = (bool)desc.sec_state.bonded, + .key_size = (uint8_t)desc.sec_state.key_size, + }, + }); + } else { + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_PAIRING_FAILED, + .pairing_failed = { .reason = event->enc_change.status }, + }); + } } return 0; @@ -373,7 +589,8 @@ static int gap_event(struct ble_gap_event *event, void *arg) return BLE_GAP_REPEAT_PAIRING_RETRY; case BLE_GAP_EVENT_PASSKEY_ACTION: - if (event->passkey.params.action == BLE_SM_IOACT_DISP) { + switch (event->passkey.params.action) { + case BLE_SM_IOACT_DISP: { /* Rejection sampling avoids the modulo bias of * `esp_random() % 1000000` (2^32 % 1e6 != 0). */ const uint32_t passkey_max = 1000000U; @@ -387,14 +604,57 @@ static int gap_event(struct ble_gap_event *event, void *arg) .action = BLE_SM_IOACT_DISP, .passkey = r % passkey_max, }; + /* Banner stays for backward compat with log-scraping + * tests; on_event is additive. */ show_passkey(pkey.passkey); + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_PASSKEY_DISPLAY, + .passkey = { .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); + ESP_LOGW(TAG, "ble_sm_inject_io(DISP) rc=%d", rc); } - } else { - ESP_LOGW(TAG, "passkey action %d not handled (DisplayOnly only)", + break; + } + + case BLE_SM_IOACT_INPUT: + /* Central displays a passkey, user reads it from there + * and types it into our device. We can't inject anything + * yet — wait for ble_uart_passkey_reply(). */ + ESP_LOGI(TAG, "passkey entry requested (conn=%d)", + event->passkey.conn_handle); + s_pending_io_conn = event->passkey.conn_handle; + s_pending_io_action = BLE_SM_IOACT_INPUT; + emit_evt(&(ble_uart_evt_t){ .id = BLE_UART_EVT_PASSKEY_REQUEST }); + break; + + case BLE_SM_IOACT_NUMCMP: + /* Both sides should display the same 6-digit value; user + * confirms match. The value is in `numcmp` (already a + * decimal 0..999999, computed by the SM). */ + ESP_LOGI(TAG, "numeric compare %06" PRIu32 " (conn=%d)", + event->passkey.params.numcmp, + event->passkey.conn_handle); + s_pending_io_conn = event->passkey.conn_handle; + s_pending_io_action = BLE_SM_IOACT_NUMCMP; + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_NUMERIC_COMPARE, + .numeric_compare = { .passkey = event->passkey.params.numcmp }, + }); + break; + + case BLE_SM_IOACT_OOB: + /* OOB plumbing is intentionally not exposed. Letting the + * SM hang here would surface as a pairing timeout — log + * loudly and let it. */ + ESP_LOGW(TAG, "OOB pairing requested but not implemented"); + break; + + default: + ESP_LOGW(TAG, "unexpected passkey action %d", event->passkey.params.action); + break; } return 0; @@ -407,7 +667,16 @@ static int gap_event(struct ble_gap_event *event, void *arg) 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); + bool sub = (event->subscribe.cur_notify != 0); + /* Edge-trigger so a redundant CCCD write (same value + * twice) doesn't fire two SUBSCRIBED events. */ + if (sub != s_subscribed) { + s_subscribed = sub; + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_SUBSCRIBED, + .subscribed = { .subscribed = sub }, + }); + } } return 0; @@ -416,6 +685,65 @@ static int gap_event(struct ble_gap_event *event, void *arg) } } +/* ===== Pairing replies ================================================ */ + +/* Shared body for both reply APIs — looks at the pending state, builds + * the matching ble_sm_io payload, and injects it. `expected_action` is + * BLE_SM_IOACT_INPUT for passkey_reply and BLE_SM_IOACT_NUMCMP for + * compare_reply; calling the wrong API for the in-flight request + * returns ENOTCONN (treated as "no such request waiting"). */ +static int do_pairing_reply(uint8_t expected_action, + uint32_t passkey, + bool numcmp_accept) +{ + /* Snapshot the volatile fields once. The host task may clear them + * at any moment (disconnect / enc_change), and we want a coherent + * decision below. */ + uint16_t conn = s_pending_io_conn; + uint8_t action = s_pending_io_action; + if (conn == BLE_HS_CONN_HANDLE_NONE || action != expected_action) { + return BLE_UART_ENOTCONN; + } + + struct ble_sm_io io = { .action = expected_action }; + if (expected_action == BLE_SM_IOACT_INPUT) { + io.passkey = passkey; + } else { /* BLE_SM_IOACT_NUMCMP */ + io.numcmp_accept = numcmp_accept ? 1 : 0; + } + + /* Clear pending BEFORE inject so a re-entrant on_event triggered + * by inject_io doesn't see stale state. If inject fails the + * request is gone anyway (the SM will time out from the central's + * side), so leaving it cleared is the right move. */ + s_pending_io_conn = BLE_HS_CONN_HANDLE_NONE; + + int rc = ble_sm_inject_io(conn, &io); + if (rc != 0) { + ESP_LOGW(TAG, "ble_sm_inject_io(%s) rc=%d", + expected_action == BLE_SM_IOACT_INPUT ? "INPUT" : "NUMCMP", + rc); + /* ENOTCONN from NimBLE means the conn vanished between the + * snapshot and the inject — surface that to the caller as + * such; everything else is a stack-internal failure. */ + return (rc == BLE_HS_ENOTCONN) ? BLE_UART_ENOTCONN : BLE_UART_EFAIL; + } + return BLE_UART_OK; +} + +int ble_uart_passkey_reply(uint32_t passkey) +{ + if (passkey > 999999) { + return BLE_UART_EINVAL; + } + return do_pairing_reply(BLE_SM_IOACT_INPUT, passkey, false); +} + +int ble_uart_compare_reply(bool match) +{ + return do_pairing_reply(BLE_SM_IOACT_NUMCMP, 0, match); +} + /* ===== Host plumbing =================================================== */ static void on_reset(int reason) @@ -444,13 +772,112 @@ static void on_sync(void) static void nimble_host_task(void *param) { + (void)param; ESP_LOGI(TAG, "BLE host task started"); nimble_port_run(); - nimble_port_freertos_deinit(); + /* Self-delete instead of nimble_port_freertos_deinit(): do_close()'s + * nimble_port_stop() returns once port_run() exits in this task, but + * this task is still running. A quick ble_uart_open() may already have + * updated the port layer's host_task_h; freertos_deinit() would + * vTaskDelete(host_task_h) and kill the new host task. */ + vTaskDelete(NULL); } /* ===== Public lifecycle ================================================ */ +/* Resolved view of cfg.encrypted + the per-feature overrides + * (cfg.sc / cfg.bonding / cfg.mitm / cfg.io_cap). Used in install() + * to drive both ble_hs_cfg.sm_* and the GATT-table builder. */ +struct sec_policy { + bool sc; /* LE Secure Connections */ + bool bonding; /* persist LTK in NVS */ + bool mitm; /* require authentication */ + bool link_enc; /* derived: sc || bonding || mitm */ + uint8_t sm_io_cap; /* NimBLE BLE_HS_IO_* */ +}; + +static int resolve_sec_policy(const ble_uart_config_t *cfg, + struct sec_policy *out) +{ + const ble_uart_security_t *sec = &cfg->security; + + /* Range-check the public enums up front. Accepting (say) a + * dangling 99 here would propagate to ble_hs_cfg as garbage and + * the SM would refuse pairing for non-obvious reasons. */ + if ((unsigned)sec->sc > BLE_UART_SEC_ON + || (unsigned)sec->bonding > BLE_UART_SEC_ON + || (unsigned)sec->mitm > BLE_UART_SEC_ON + || (unsigned)sec->io_cap > BLE_UART_IO_CAP_KEYBOARD_DISPLAY) { + return BLE_UART_EINVAL; + } + + /* Input-capable IO caps fire BLE_UART_EVT_PASSKEY_REQUEST or + * BLE_UART_EVT_NUMERIC_COMPARE and need an application reply via + * ble_uart_passkey_reply / ble_uart_compare_reply. With on_event + * NULL the caller would never see the request and pairing would + * silently stall until the SM times out — fail synchronously. + * + * This checks the *configured* io_cap, not the value resolved + * below. AUTO and DISPLAY_ONLY are excluded on purpose: AUTO with + * mitm=ON becomes DisplayOnly; Passkey Display (BLE_SM_IOACT_DISP) + * is satisfied inside gap_event via ble_sm_inject_io with no app + * reply. BLE_UART_EVT_PASSKEY_DISPLAY is additive when on_event is + * set; with on_event NULL emit_evt() drops it and pairing still + * completes. */ + if (cfg->on_event == NULL + && (sec->io_cap == BLE_UART_IO_CAP_KEYBOARD_ONLY + || sec->io_cap == BLE_UART_IO_CAP_DISPLAY_YES_NO + || sec->io_cap == BLE_UART_IO_CAP_KEYBOARD_DISPLAY)) { + return BLE_UART_EINVAL; + } + + /* AUTO inherits the bit from cfg.encrypted; OFF / ON override. */ + bool preset = cfg->encrypted; + out->sc = (sec->sc == BLE_UART_SEC_AUTO) ? preset + : (sec->sc == BLE_UART_SEC_ON); + out->bonding = (sec->bonding == BLE_UART_SEC_AUTO) ? preset + : (sec->bonding == BLE_UART_SEC_ON); + out->mitm = (sec->mitm == BLE_UART_SEC_AUTO) ? preset + : (sec->mitm == BLE_UART_SEC_ON); + /* "Link will be encrypted" iff the SM runs at all — and the SM + * runs whenever any of these three bits is set. Pairing-without- + * bonding still encrypts the live link with a session LTK. */ + out->link_enc = out->sc || out->bonding || out->mitm; + + /* IO capability: AUTO picks the minimum that lets the resolved + * MITM bit succeed; the explicit values map straight to the + * NimBLE BLE_HS_IO_* constants used by the SM. */ + switch (sec->io_cap) { + case BLE_UART_IO_CAP_DISPLAY_ONLY: + out->sm_io_cap = BLE_HS_IO_DISPLAY_ONLY; + break; + case BLE_UART_IO_CAP_NO_INPUT_OUTPUT: + out->sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT; + break; + case BLE_UART_IO_CAP_KEYBOARD_ONLY: + out->sm_io_cap = BLE_HS_IO_KEYBOARD_ONLY; + break; + case BLE_UART_IO_CAP_DISPLAY_YES_NO: + out->sm_io_cap = BLE_HS_IO_DISPLAY_YESNO; + break; + case BLE_UART_IO_CAP_KEYBOARD_DISPLAY: + out->sm_io_cap = BLE_HS_IO_KEYBOARD_DISPLAY; + break; + case BLE_UART_IO_CAP_AUTO: + default: + out->sm_io_cap = out->mitm ? BLE_HS_IO_DISPLAY_ONLY + : BLE_HS_IO_NO_INPUT_OUTPUT; + break; + } + + /* Just Works (NoInputNoOutput) cannot satisfy MITM — the SM + * would reject pairing in flight. Catch it synchronously here. */ + if (out->mitm && out->sm_io_cap == BLE_HS_IO_NO_INPUT_OUTPUT) { + return BLE_UART_EINVAL; + } + return BLE_UART_OK; +} + int ble_uart_install(const ble_uart_config_t *cfg) { if (s_installed) { @@ -464,11 +891,91 @@ int ble_uart_install(const ble_uart_config_t *cfg) memset(&s_cfg, 0, sizeof(s_cfg)); } + /* Validate device_name length up front. Beyond + * BLE_UART_DEVICE_NAME_MAX the default-path advertising would + * silently fail at adv_set_fields time; surfacing the error here + * is much friendlier. strnlen with cap+1 also stops a missing-NUL + * caller buffer from running into uninitialised memory. */ + if (s_cfg.device_name != NULL) { + size_t nlen = strnlen(s_cfg.device_name, BLE_UART_DEVICE_NAME_MAX + 1); + if (nlen > BLE_UART_DEVICE_NAME_MAX) { + ESP_LOGE(TAG, "device_name too long: > %u bytes", + (unsigned)BLE_UART_DEVICE_NAME_MAX); + memset(&s_cfg, 0, sizeof(s_cfg)); + return BLE_UART_EINVAL; + } + } + + /* Validate caller-supplied advertising payloads up front so an + * oversized buffer fails the install instead of corrupting the + * adv packet at start time (where errors only surface in logs). + * (NULL + len>0 is also rejected — almost always a caller bug.) */ + if (s_cfg.adv_data_len > BLE_UART_ADV_DATA_MAX + || (s_cfg.adv_data == NULL && s_cfg.adv_data_len > 0)) { + ESP_LOGE(TAG, "bad adv_data: ptr=%p len=%u (max=%u)", + s_cfg.adv_data, + (unsigned)s_cfg.adv_data_len, + (unsigned)BLE_UART_ADV_DATA_MAX); + memset(&s_cfg, 0, sizeof(s_cfg)); + return BLE_UART_EINVAL; + } + if (s_cfg.scan_rsp_data_len > BLE_UART_SCAN_RSP_DATA_MAX + || (s_cfg.scan_rsp_data == NULL && s_cfg.scan_rsp_data_len > 0)) { + ESP_LOGE(TAG, "bad scan_rsp_data: ptr=%p len=%u (max=%u)", + s_cfg.scan_rsp_data, + (unsigned)s_cfg.scan_rsp_data_len, + (unsigned)BLE_UART_SCAN_RSP_DATA_MAX); + memset(&s_cfg, 0, sizeof(s_cfg)); + return BLE_UART_EINVAL; + } + + /* Resolve cfg.encrypted + per-feature overrides into a flat + * policy. Validation (out-of-range enums + impossible MITM/IO + * combination) happens up front so install() rejects bad cfgs + * synchronously, before any host-stack resources are allocated. */ + struct sec_policy pol; + int srv = resolve_sec_policy(&s_cfg, &pol); + if (srv != BLE_UART_OK) { + ESP_LOGE(TAG, "bad security cfg: encrypted=%d sc=%d bonding=%d " + "mitm=%d io_cap=%d", + (int)s_cfg.encrypted, + (int)s_cfg.security.sc, (int)s_cfg.security.bonding, + (int)s_cfg.security.mitm, (int)s_cfg.security.io_cap); + memset(&s_cfg, 0, sizeof(s_cfg)); + return srv; + } + s_link_encrypted = pol.link_enc; + s_mitm_required = pol.mitm; + + /* Copy raw payloads now (caller's pointers may not outlive install). + * For adv_data we also prepend the 3-byte Flags AD ourselves — + * the controller-visible Flags element is library-controlled and + * not part of what the application owns. */ + s_adv_data_len = 0; + s_scan_rsp_data_len = 0; + if (s_cfg.adv_data != NULL && s_cfg.adv_data_len > 0) { + s_adv_data[0] = 0x02; /* AD length */ + s_adv_data[1] = 0x01; /* AD type: Flags */ + s_adv_data[2] = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; + memcpy(s_adv_data + 3, s_cfg.adv_data, s_cfg.adv_data_len); + s_adv_data_len = (uint8_t)(3 + s_cfg.adv_data_len); + } + if (s_cfg.scan_rsp_data != NULL && s_cfg.scan_rsp_data_len > 0) { + memcpy(s_scan_rsp_data, s_cfg.scan_rsp_data, s_cfg.scan_rsp_data_len); + s_scan_rsp_data_len = (uint8_t)s_cfg.scan_rsp_data_len; + } + /* Drop the pointers — install must not retain caller buffers. */ + s_cfg.adv_data = NULL; + s_cfg.scan_rsp_data = NULL; + esp_err_t err = nimble_port_init(); if (err != ESP_OK) { ESP_LOGE(TAG, "nimble_port_init rc=%d", err); return BLE_UART_EFAIL; } +#if MYNEWT_VAL(BLE_HS_AUTO_START) + s_hs_auto_start_pending = true; +#endif /* From here every failure must `goto fail` so nimble_port_deinit() * runs — leaving the port allocated breaks the next install(). */ @@ -477,23 +984,27 @@ int ble_uart_install(const ble_uart_config_t *cfg) 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; + /* Apply the resolved security policy. NimBLE checks sm_bonding + * before consulting the key-distribution masks, so it's safe to + * leave them set unconditionally — they're a no-op when bonding=0. */ + if (pol.link_enc) { + ble_hs_cfg.sm_io_cap = pol.sm_io_cap; + ble_hs_cfg.sm_sc = pol.sc ? 1 : 0; + ble_hs_cfg.sm_bonding = pol.bonding ? 1 : 0; + ble_hs_cfg.sm_mitm = pol.mitm ? 1 : 0; 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 { + /* Fully plaintext: SM disabled, no keys exchanged. */ 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; } +#if NIMBLE_BLE_CONNECT ble_svc_gap_init(); +#endif ble_svc_gatt_init(); /* Cache the device name into our own buffer (caller's pointer may @@ -517,23 +1028,26 @@ int ble_uart_install(const ble_uart_config_t *cfg) s_dev_name[0] = '\0'; } - build_gatt_table(s_cfg.encrypted); + rc = register_uart_gatt_svc(); + if (rc != BLE_UART_OK) { + goto fail; + } + s_gatts_needs_readd = false; - 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; - } + /* Wire up the NVS bond store (requires CONFIG_BT_NIMBLE_NVS_PERSIST=y). + * Done here — not in open() — so bond-management APIs + * (ble_uart_get_bond_count / clear_bonds / remove_peer) work + * between install and open, letting callers wipe stale bonds + * before the first advertising window opens. */ + ble_store_config_init(); s_installed = true; return BLE_UART_OK; fail: +#if MYNEWT_VAL(BLE_HS_AUTO_START) + s_hs_auto_start_pending = false; +#endif nimble_port_deinit(); memset(&s_cfg, 0, sizeof(s_cfg)); return xlate_rc(rc); @@ -550,16 +1064,40 @@ int ble_uart_open(void) return BLE_UART_EALREADY; } - /* NVS bond store (requires CONFIG_BT_NIMBLE_NVS_PERSIST=y). */ - ble_store_config_init(); + if (s_gatts_needs_readd) { + int grc = reregister_gatt_svcs_after_close(); + if (grc != BLE_UART_OK) { + return grc; + } + s_gatts_needs_readd = false; + } - /* Spawn host task; on_sync starts advertising once controller is ready. */ + /* Spawn host task, then queue host start. on_sync() starts advertising + * once the controller sync completes. + * + * With BLE_HS_AUTO_START (default), install()'s nimble_port_init() + * already queued a one-shot start event — open() must not sched_start() + * again or ble_hs_start() returns BLE_HS_EALREADY and the host task + * asserts. After close()'s nimble_port_stop() the host is OFF and no + * AUTO_START event remains, so every later open() must sched_start(). */ nimble_port_freertos_init(nimble_host_task); +#if MYNEWT_VAL(BLE_HS_AUTO_START) + if (!s_hs_auto_start_pending) { + ble_hs_sched_start(); + } + s_hs_auto_start_pending = false; +#else + ble_hs_sched_start(); +#endif s_opened = true; return BLE_UART_OK; } -int ble_uart_close(void) +/* Body of ble_uart_close(); also called directly by the close-async + * worker, which has already latched s_closing itself. The public + * wrapper below uses s_closing to reject a sync close that races + * with an in-flight async close. */ +static int do_close(void) { if (!s_opened) { return BLE_UART_EALREADY; @@ -568,9 +1106,16 @@ int ble_uart_close(void) /* 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); + /* Only stop adv if it's still running. Undirected adv auto-stops + * at the LL on connect, so calling adv_stop while connected just + * burns one HCI cmd that NimBLE answers with BLE_HS_EALREADY. + * Mirrors the Bluedroid backend's `if (s_adv_active)` gate. */ + int rc = 0; + if (ble_gap_adv_active()) { + 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 @@ -588,8 +1133,8 @@ int ble_uart_close(void) } } - /* nimble_host_task self-cleans (port_freertos_deinit + delete) when - * port_run returns, so no explicit join. */ + /* nimble_port_stop() waits for port_run() to exit in the host task; + * the host then vTaskDelete(NULL) — no join after stop returns. */ rc = nimble_port_stop(); if (rc != 0) { ESP_LOGE(TAG, "nimble_port_stop rc=%d", rc); @@ -597,6 +1142,16 @@ int ble_uart_close(void) return BLE_UART_EFAIL; } + /* Host stop frees the svc-def pointer array; stale ATT rows can remain + * until cleared. Use the public ble_gatts_reset() only (no NimBLE + * source edits) and re-queue svc defs on the next open(). */ + rc = ble_gatts_reset(); + if (rc != 0) { + ESP_LOGW(TAG, "ble_gatts_reset rc=%d", rc); + } + s_tx_val_handle = 0; + s_gatts_needs_readd = true; + s_conn_handle = BLE_HS_CONN_HANDLE_NONE; s_subscribed = false; s_opened = false; @@ -604,12 +1159,237 @@ int ble_uart_close(void) return BLE_UART_OK; } +int ble_uart_close(void) +{ + /* If a ble_uart_close_async() worker is in flight, the close + * sequence is already running on the worker's task — let it + * finish rather than racing it from here. The worker drives + * s_opened to false on its own, so the next sync close after + * the worker drains will get the natural !s_opened EALREADY. */ + if (s_closing) { + return BLE_UART_EALREADY; + } + return do_close(); +} + +/* ===== Async close ==================================================== */ + +/* Background worker spawned by ble_uart_close_async(). Lives just + * long enough to run the synchronous close path (which itself can + * block on the disconnect timeout and on nimble_port_stop()), then + * fires the completion event and self-deletes. + * + * Spawned as a separate task — not a deferred ble_npl callout — so + * that nimble_port_stop() can join the host task without us being + * the host task. */ +static void close_async_task(void *arg) +{ + (void)arg; + + /* Bypass the s_closing gate in ble_uart_close(): we ARE the + * in-flight async close that gate is meant to protect against. */ + int rc = do_close(); + if (rc != BLE_UART_OK && rc != BLE_UART_EALREADY) { + ESP_LOGW(TAG, "close_async: do_close rc=%d", rc); + } + + /* Deliver CLOSED on the worker task. Applications must defer + * ble_uart_uninstall() to another task (PORTING.md §5.3.2). + * Concurrent uninstall() may clear s_cfg while we read on_event. */ + emit_evt(&(ble_uart_evt_t){ + .id = BLE_UART_EVT_CLOSED, + .closed = { .status = rc }, + }); + + s_closing = false; + vTaskDelete(NULL); +} + +int ble_uart_close_async(void) +{ + /* Same-state checks as the synchronous variant: nothing to close + * if we never opened, and no point spawning a second worker if + * the first hasn't drained yet. */ + if (!s_opened || s_closing) { + return BLE_UART_EALREADY; + } + + /* Latch BEFORE spawning so a racing caller (different task) sees + * the in-flight state immediately and gets EALREADY. */ + s_closing = true; + + /* 3 KB is comfortably more than the close path uses (mostly small + * GAP/HCI helpers + a 50×10ms vTaskDelay loop); bump if you wedge + * a heavy on_event handler between adv_stop and CLOSED. */ + BaseType_t ok = xTaskCreate(close_async_task, "ble_close", + 3072, NULL, + tskIDLE_PRIORITY + 2, NULL); + if (ok != pdPASS) { + s_closing = false; + return BLE_UART_ENOMEM; + } + return BLE_UART_OK; +} + +/* ===== Bond management ================================================ */ + +/* Public API uses big-endian bytes (bytes[0] = MSB) but NimBLE stores + * addresses little-endian (val[0] = LSB). Caller must supply + * BLE_UART_ADDR_TYPE_PUBLIC/RANDOM (0/1); the bond store keys on + * those same identity types. */ +static void to_nimble_addr(const ble_uart_addr_t *src, ble_addr_t *dst) +{ + dst->type = src->type; + for (int i = 0; i < 6; i++) { + dst->val[i] = src->bytes[5 - i]; + } +} + +#if MYNEWT_VAL(BLE_STORE_MAX_BONDS) > 0 +/* ble_store_util_bonded_peers enumerates OUR_SEC (unique peer_addr). + * Heap-allocate the scratch buffer so callers on small-stack tasks are + * safe regardless of CONFIG_BT_NIMBLE_MAX_BONDS. */ +static int bonded_peers_unique_count(size_t *out_count) +{ + const int max_peers = MYNEWT_VAL(BLE_STORE_MAX_BONDS); + ble_addr_t *peer_addrs = calloc((size_t)max_peers, sizeof(*peer_addrs)); + if (peer_addrs == NULL) { + return BLE_UART_ENOMEM; + } + int num_peers = 0; + int rc = ble_store_util_bonded_peers(peer_addrs, &num_peers, max_peers); + free(peer_addrs); + if (rc != 0) { + ESP_LOGW(TAG, "ble_store_util_bonded_peers rc=%d", rc); + return xlate_rc(rc); + } + *out_count = (size_t)num_peers; + return BLE_UART_OK; +} +#endif + +int ble_uart_get_bond_count(size_t *out_count) +{ + if (out_count == NULL || !s_installed) { + return BLE_UART_EINVAL; + } +#if MYNEWT_VAL(BLE_STORE_MAX_BONDS) > 0 + return bonded_peers_unique_count(out_count); +#else + *out_count = 0; + return BLE_UART_OK; +#endif +} + +int ble_uart_get_bonded_peers(ble_uart_addr_t *out, size_t cap, size_t *out_count) +{ + if (out_count == NULL || !s_installed + || (out == NULL && cap > 0)) { + return BLE_UART_EINVAL; + } + +#if MYNEWT_VAL(BLE_STORE_MAX_BONDS) <= 0 + *out_count = 0; + return BLE_UART_OK; +#else + /* ble_store_util_bonded_peers enumerates OUR_SEC (unique peer_addr). + * Size the buffer to BLE_STORE_MAX_BONDS — the compile-time cap — + * not PEER_SEC/OUR_SEC raw entry counts (they can disagree). */ + const int max_peers = MYNEWT_VAL(BLE_STORE_MAX_BONDS); + + if (cap == 0) { + return bonded_peers_unique_count(out_count); + } + + int n_our = 0; + int rc = ble_store_util_count(BLE_STORE_OBJ_TYPE_OUR_SEC, &n_our); + if (rc != 0) { + ESP_LOGW(TAG, "ble_store_util_count rc=%d", rc); + return xlate_rc(rc); + } + if (n_our <= 0) { + *out_count = 0; + return BLE_UART_OK; + } + + /* Bonded peers in NimBLE's native LE byte order. Heap-allocate to + * keep the host task's stack untouched even when many peers exist. */ + ble_addr_t *tmp = calloc((size_t)max_peers, sizeof(*tmp)); + if (tmp == NULL) { + return BLE_UART_ENOMEM; + } + int got = 0; + rc = ble_store_util_bonded_peers(tmp, &got, max_peers); + if (rc != 0) { + ESP_LOGW(TAG, "ble_store_util_bonded_peers rc=%d", rc); + free(tmp); + return xlate_rc(rc); + } + + /* Copy at most cap entries into the caller's buffer, flipping + * NimBLE's LE byte order back to our public big-endian convention + * and narrowing addr types to BLE_UART_ADDR_TYPE_* . */ + size_t to_copy = ((size_t)got < cap) ? (size_t)got : cap; + for (size_t i = 0; i < to_copy; i++) { + from_nimble_addr(&tmp[i], &out[i]); + } + free(tmp); + *out_count = (size_t)got; + return BLE_UART_OK; +#endif +} + +int ble_uart_remove_peer(const ble_uart_addr_t *peer) +{ + if (peer == NULL || !s_installed) { + return BLE_UART_EINVAL; + } + ble_addr_t addr; + to_nimble_addr(peer, &addr); + int rc = ble_store_util_delete_peer(&addr); + if (rc != 0) { + ESP_LOGW(TAG, "ble_store_util_delete_peer rc=%d", rc); + return xlate_rc(rc); + } + return BLE_UART_OK; +} + +int ble_uart_clear_bonds(void) +{ + if (!s_installed) { + return BLE_UART_EINVAL; + } + /* Wipes peer LTK + our LTK + persisted CCCD (and a few NimBLE + * internal records). Doesn't touch our s_cfg or any other NVS + * namespace. */ + int rc = ble_store_clear(); + if (rc != 0) { + ESP_LOGW(TAG, "ble_store_clear rc=%d", rc); + return xlate_rc(rc); + } + return BLE_UART_OK; +} + +/* ===== Uninstall ====================================================== */ + int ble_uart_uninstall(void) { if (!s_installed) { return BLE_UART_EALREADY; } + /* If a ble_uart_close_async() worker is still draining, poll s_closing + * for up to ~5 s before touching shared state. On timeout, teardown + * continues anyway — applications must follow PORTING.md §5.3.2 so + * uninstall runs only after the worker has finished. */ + for (int i = 0; i < 500 && s_closing; i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (s_closing) { + ESP_LOGW(TAG, "uninstall: close_async worker still running, " + "tearing down anyway"); + } + /* 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 @@ -639,14 +1419,25 @@ int ble_uart_uninstall(void) } 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; + 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_closing = false; + s_installed = false; + s_opened = false; + s_gatts_needs_readd = false; +#if MYNEWT_VAL(BLE_HS_AUTO_START) + s_hs_auto_start_pending = false; +#endif + s_adv_data_len = 0; + s_scan_rsp_data_len = 0; + s_link_encrypted = false; + s_mitm_required = false; + s_pending_io_conn = BLE_HS_CONN_HANDLE_NONE; + s_pending_io_action = 0; return first_rc; }