From 7a7893d006938c5d419c4633fb526ab95097a951 Mon Sep 17 00:00:00 2001 From: Zhi Wei Jian Date: Mon, 27 Apr 2026 20:00:27 +0800 Subject: [PATCH 1/7] feat(ble): Support nimble uart service (cherry picked from commit 9de3eea3ded452a51b78e56d9748645a19828735) Co-authored-by: zhiweijian --- docs/en/api-reference/bluetooth/index.rst | 1 + .../bluetooth/ble_uart_service/CMakeLists.txt | 7 + .../bluetooth/ble_uart_service/PORTING.md | 645 +++++++++++ examples/bluetooth/ble_uart_service/README.md | 238 ++++ .../ble_uart_service/main/CMakeLists.txt | 10 + .../ble_uart_service/main/Kconfig.projbuild | 26 + .../ble_uart_service/main/ble_uart.h | 154 +++ .../main/ble_uart_bluedroid.c | 1022 +++++++++++++++++ .../ble_uart_service/main/ble_uart_nimble.c | 652 +++++++++++ .../bluetooth/ble_uart_service/main/main.c | 63 + .../ble_uart_service/sdkconfig.ci.bluedroid | 42 + .../ble_uart_service/sdkconfig.defaults | 22 + 12 files changed, 2882 insertions(+) create mode 100644 examples/bluetooth/ble_uart_service/CMakeLists.txt create mode 100644 examples/bluetooth/ble_uart_service/PORTING.md create mode 100644 examples/bluetooth/ble_uart_service/README.md create mode 100644 examples/bluetooth/ble_uart_service/main/CMakeLists.txt create mode 100644 examples/bluetooth/ble_uart_service/main/Kconfig.projbuild create mode 100644 examples/bluetooth/ble_uart_service/main/ble_uart.h create mode 100644 examples/bluetooth/ble_uart_service/main/ble_uart_bluedroid.c create mode 100644 examples/bluetooth/ble_uart_service/main/ble_uart_nimble.c create mode 100644 examples/bluetooth/ble_uart_service/main/main.c create mode 100644 examples/bluetooth/ble_uart_service/sdkconfig.ci.bluedroid create mode 100644 examples/bluetooth/ble_uart_service/sdkconfig.defaults diff --git a/docs/en/api-reference/bluetooth/index.rst b/docs/en/api-reference/bluetooth/index.rst index e2fb8a2e7f1..bf47fd227a6 100644 --- a/docs/en/api-reference/bluetooth/index.rst +++ b/docs/en/api-reference/bluetooth/index.rst @@ -74,6 +74,7 @@ Explore examples and tutorials in the ESP-IDF examples directory: - **Bluedroid**: :example:`bluetooth/bluedroid` - **NimBLE**: :example:`bluetooth/nimble` +- **BLE UART Service** (turnkey serial-over-BLE peripheral on top of NimBLE or Bluedroid, exposing the BLE UART Service GATT layout): :example:`bluetooth/ble_uart_service` Step-by-step tutorials for developing with the Bluedroid stack: diff --git a/examples/bluetooth/ble_uart_service/CMakeLists.txt b/examples/bluetooth/ble_uart_service/CMakeLists.txt new file mode 100644 index 00000000000..1982eca7959 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/CMakeLists.txt @@ -0,0 +1,7 @@ +# The following lines of boilerplate have to be in your project's +# CMakeLists in this exact order for cmake to work correctly. +cmake_minimum_required(VERSION 3.22) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +idf_build_set_property(MINIMAL_BUILD ON) +project(ble_uart_service) diff --git a/examples/bluetooth/ble_uart_service/PORTING.md b/examples/bluetooth/ble_uart_service/PORTING.md new file mode 100644 index 00000000000..47e3859c58f --- /dev/null +++ b/examples/bluetooth/ble_uart_service/PORTING.md @@ -0,0 +1,645 @@ +# BLE UART Porting & API Guide + +A complete guide to integrating `ble_uart` into any ESP-IDF project. +**Two or three files plus 5 steps of glue code** are enough to bring an +encrypted BLE serial peripheral up in a fresh project — the same +`ble_uart.h` API works on top of either NimBLE or Bluedroid; pick the +host with a Kconfig knob. + +This guide uses **NimBLE** as the running example because it is the +default on every ESP32 family target. The Bluedroid path is identical +from the application's point of view; the only differences are the +sdkconfig knobs called out in §4.3 and a few stack-specific notes +flagged inline. + +--- + +## 1. What `ble_uart` Provides + +| Capability | Description | +| --- | --- | +| Standard Nordic UART Service GATT (RX/TX) | Interoperates with every generic BLE-serial tool (nRF Connect, Web Bluetooth, custom scripts) | +| LE Secure Connections + Bonding pairing | Single switch; when enabled, a fresh 6-digit passkey is printed to UART | +| Auto-reconnect | After a bonded central disconnects, advertising restarts immediately and the LTK is reused — no passkey prompt | +| Raw byte pass-through | RX is delivered via a callback; TX is exposed as `ble_uart_tx` | +| Auto-fragmentation | TX is sliced according to the negotiated ATT MTU | +| Fully wrapped | The user's `app_main` only calls two functions: `install` + `open` | + +`ble_uart` is agnostic of any application-layer protocol (no JSON, no +line framing). It only delivers bytes — **what you do with those bytes +is entirely up to you**. + +--- + +## 2. Prerequisites + +| Requirement | Notes | +| --- | --- | +| ESP-IDF v5.0+ | v5.x or v6.x recommended | +| BT controller | Must support BLE (ESP32 / C2 / C3 / C5 / C6 / C61 / H2 / S3 / …) | +| Host stack | Exactly one of `CONFIG_BT_NIMBLE_ENABLED=y` (default, smaller) or `CONFIG_BT_BLUEDROID_ENABLED=y` in sdkconfig (covered in detail below) | +| Flash size | At least 2 MB (the default partition table is plenty) | + +--- + +## 3. File Inventory + +Files to copy into the target project — pick the backend you want and +copy that pair plus the public header: + +``` +your_project/main/ +├── ble_uart.h ← copy this (stack-agnostic public API, ~260 lines) +├── ble_uart_nimble.c ← if you'll set CONFIG_BT_NIMBLE_ENABLED=y (~670 lines) +└── ble_uart_bluedroid.c ← if you'll set CONFIG_BT_BLUEDROID_ENABLED=y (~900 lines) +``` + +You can also copy *both* `ble_uart_nimble.c` and `ble_uart_bluedroid.c` +unchanged — each `.c` file gates its body on the matching Kconfig +symbol, so the inactive one compiles to nothing. This is what the +example itself does, and it lets you flip stacks without changing the +source list. + +Optional: `Kconfig.projbuild` defines `BLE_UART_DEVICE_NAME_PREFIX` +and `BLE_UART_RX_SCRATCH_SIZE`. Copy it too if you want either to be +tunable from `menuconfig`; otherwise hard-code the name in your +source and rely on the 1024-byte fallback for RX scratch. + +--- + +## 4. Step-by-Step Integration + +Assume you already have an ESP-IDF project (`my_project/`). + +### 4.1 Copy the files + +```bash +cd my_project/main +# Stack-agnostic public header — always. +cp /path/to/ble_uart_service/main/ble_uart.h . +# Pick one (or copy both — the inactive one compiles to nothing). +cp /path/to/ble_uart_service/main/ble_uart_nimble.c . +cp /path/to/ble_uart_service/main/ble_uart_bluedroid.c . +``` + +### 4.2 Edit `main/CMakeLists.txt` + +```cmake +# List both backends; each .c file is gated on its matching Kconfig +# symbol, so only the active one contributes code. +idf_component_register(SRCS "main.c" + "ble_uart_nimble.c" + "ble_uart_bluedroid.c" + INCLUDE_DIRS "." + REQUIRES bt nvs_flash) +``` + +### 4.3 Edit `sdkconfig.defaults` (the 7 critical lines) + +**NimBLE backend (default, smaller footprint):** + +```ini +# Enable NimBLE +CONFIG_BT_ENABLED=y +CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y # only needed on classic ESP32; C3/S3/C6/... will warn "unknown" — safe to ignore +CONFIG_BT_BLUEDROID_ENABLED=n +CONFIG_BT_NIMBLE_ENABLED=y + +# Encryption + persistent bonds +CONFIG_BT_NIMBLE_SM_SC=y # LE Secure Connections +CONFIG_BT_NIMBLE_NVS_PERSIST=y # persist LTKs in NVS — passkey-free reconnects +``` + +`CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU` is optional; the default (256) is +fine. Bumping it to 512 lets TX push larger chunks per notification, but +the central must support it. + +**Bluedroid backend (drop-in alternative):** + +```ini +CONFIG_BT_ENABLED=y +CONFIG_BT_NIMBLE_ENABLED=n +CONFIG_BT_BLUEDROID_ENABLED=y + +# LE Secure Connections + bonding (Bluedroid persists LTKs by default) +CONFIG_BT_BLE_SMP_ENABLE=y + +# Optional: bigger MTU +CONFIG_BT_GATT_MAX_MTU_SIZE=512 + +# BLE-only feature set (saves flash on classic-BT-capable parts) +CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y +CONFIG_BT_BLE_42_ADV_EN=y +``` + +### 4.4 Write `app_main` (template) + +Minimal working template: + +```c +#include "esp_log.h" +#include "esp_mac.h" +#include "nvs_flash.h" + +#include "ble_uart.h" + +static const char *TAG = "app"; + +/* What to do with received bytes — up to you */ +static void ble_uart_on_rx(const uint8_t *data, size_t len) +{ + ESP_LOGI(TAG, "rx %u bytes", (unsigned)len); + /* echo it back as a demo */ + ble_uart_tx(data, len); +} + +void app_main(void) +{ + /* 1. NVS: NimBLE uses it for PHY calibration and bond storage */ + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_ERROR_CHECK(err); + + /* 2. Bring up BLE UART */ + ESP_ERROR_CHECK(ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .device_name = "MyDevice", + .ble_uart_on_rx = ble_uart_on_rx, + })); + + /* 3. Take off */ + ESP_ERROR_CHECK(ble_uart_open()); +} +``` + +### 4.5 Build & flash + +```bash +idf.py set-target esp32s3 # or whichever target you use +idf.py build flash monitor +``` + +Once flashed, the UART monitor should show (NimBLE backend): + +``` +I (xxx) ble_uart: registered service 6e400001-... handle=14 +I (xxx) ble_uart: registered chr 6e400002-... def=15 val=16 +I (xxx) ble_uart: registered chr 6e400003-... def=17 val=18 +I (xxx) ble_uart: addr=... +I (xxx) ble_uart: BLE host task started +I (xxx) ble_uart: advertising as 'MyDevice' +``` + +…or with the Bluedroid backend: + +``` +I (xxx) ble_uart: gatts reg status=0 app_id=85 gatts_if=3 +I (xxx) ble_uart: registered service svc_handle=40 rx=42 tx=44 cccd=45 +I (xxx) ble_uart: advertising started +``` + +nRF Connect on a phone discovers `MyDevice`; connect, enter the +passkey, subscribe to TX, write to RX, and you will see the echo come +back. + +--- + +## 5. API Reference + +### 5.1 Configuration struct + +```c +typedef struct { + bool encrypted; /* Master switch for SC + Bonding + MITM */ + const char *device_name; /* GAP device name; NULL uses the NimBLE default */ + ble_uart_rx_cb_t ble_uart_on_rx;/* RX byte callback */ +} ble_uart_config_t; +``` + +| Field | Type | Required | Default / meaning | +| --- | --- | --- | --- | +| `encrypted` | `bool` | yes | `true` = SC + Bonding + MITM + DisplayOnly + encrypted GATT chars; `false` = fully plaintext (sniffable, lab use only) | +| `device_name` | `const char *` | recommended | Any string. Mind the 31-byte primary advertising packet limit: flags(3) + tx_pwr(3) + name(2 + length) + 128-bit UUID(18) → keep the name ≤ 8 bytes | +| `ble_uart_on_rx` | callback | optional | `NULL` discards every received byte | + +### 5.2 RX callback signature + +```c +typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len); + +static void my_handler(const uint8_t *data, size_t len) +{ + /* `data` is reused after the callback returns; memcpy into your own + * buffer if you need to keep it. */ +} +``` + +**Caveats**: + +- The callback runs in the **NimBLE host task** context — **do not + block**; offload heavy work to your own task. +- A single callback may carry only **part** of an upper-layer frame + (the central slices on ATT MTU). Framing logic (line / TLV / + length-prefixed) is your responsibility. +- The data carries **no `ctx` argument**. If your callback needs state, + use a file-scope `static` or a global. + +### 5.3 Lifecycle functions + +```c +int ble_uart_install(const ble_uart_config_t *cfg); +int ble_uart_open(void); +int ble_uart_close(void); +int ble_uart_uninstall(void); +``` + +| Function | What it does (NimBLE) | What it does (Bluedroid) | When to call | Blocking? | +| --- | --- | --- | --- | --- | +| `install` | `nimble_port_init` + `ble_hs_cfg` + SM + SIG services + UART GATT | `controller_init/enable` + `bluedroid_init/enable` + SM + `esp_ble_gatts_create_attr_tab` (waits ≤500 ms for the attr-table event) | After `nvs_flash_init()`, before `open` | No, ~50 ms (NimBLE) / ~150 ms (Bluedroid) | +| `open` | Bond store + spawn host task + start advertising once synced | Configure adv data + scan rsp + start advertising | After `install` | No, host runs in the background | +| `close` | Stop adv → graceful disconnect (LL_TERMINATE_IND, waits ≤500 ms for the disconnect event) → `nimble_port_stop()` | Stop adv → graceful disconnect (`esp_ble_gap_disconnect`, waits ≤500 ms) | After `open`, before `uninstall` | Yes, up to ~500 ms while waiting for the peer disconnect | +| `uninstall` | Calls `close` if still open, then `nimble_port_deinit()` and resets module state | Calls `close` if still open, then `bluedroid_disable+deinit` + `controller_disable+deinit` | After `close` (or directly — `uninstall` cascades into `close` on its own) | Yes, follows the same wait window as `close` | + +Call order: + +``` +nvs_flash_init + └── ble_uart_install + └── ble_uart_open ← BLE is live + └── ble_uart_close + └── ble_uart_uninstall ← clean state, can install again +``` + +Each call returns `BLE_HS_EALREADY` if the corresponding state is +already true (e.g. `open` called twice, or `close` called when the +radio is already down). It is therefore safe to call `close` / +`uninstall` defensively at shutdown without checking the current state +yourself. + +**Do NOT call `close` / `uninstall` from inside `ble_uart_on_rx`** — +that callback runs on the NimBLE host task, and `close` blocks on +`nimble_port_stop()` which expects the host task to exit. Self-stop +deadlocks. Forward the request to a normal FreeRTOS task instead. + +### 5.4 TX interface + +```c +int ble_uart_tx(const uint8_t *data, size_t len); +``` + +For formatted output, format into your own buffer with `snprintf` first +and pass it to `ble_uart_tx`: + +```c +char line[64]; +int n = snprintf(line, sizeof(line), "temp=%d.%d\n", t / 10, t % 10); +ble_uart_tx((const uint8_t *)line, (size_t)n); +``` + +**Return values**: + +| Return | Meaning | +| --- | --- | +| `0` | Success (notification handed to the stack) | +| `BLE_HS_ENOTCONN` | No central connected; **this is normal — typically ignore** | +| `BLE_HS_EINVAL` | `data == NULL` or `len == 0` | +| `BLE_HS_ENOMEM` | Stack mbuf pool exhausted | +| other | Internal stack error — see `ble_hs.h` | + +**Calling context**: any FreeRTOS task at any priority. **Not callable +from an ISR** — push the data to a queue from the ISR and let a task +call `ble_uart_tx`. + +**Auto-fragmentation**: regardless of buffer size, the implementation +splits the payload into successive notifications of `(MTU - 3)` bytes. +The central receives them in transmission order. + +### 5.5 Status queries + +```c +bool ble_uart_is_connected(void); +bool ble_uart_is_subscribed(void); +``` + +- `is_connected()`: a central is connected (it may not be paired yet). +- `is_subscribed()`: the central has subscribed to TX notifications + (note: bonded reconnects often skip CCCD writes). + +You usually **don't need** to query these up-front — `ble_uart_tx` +returns `ENOTCONN` to tell you. + +### 5.6 Service UUID constant + +```c +extern const ble_uart_uuid128_t ble_uart_service_uuid; +``` + +Always `6e400001-b5a3-f393-e0a9-e50e24dcca9e` (the NUS standard). It is +already inserted into the scan response, so the **application normally +does not touch it**. You only need it if you take over advertising +yourself (see 6.3). + +--- + +## 6. Advanced Usage + +### 6.1 Different RX framing strategies + +**A. Split on `\n` (suits ASCII protocols / JSON)** + +```c +static uint8_t s_buf[1024]; +static size_t s_len; + +static void on_rx(const uint8_t *d, size_t n) +{ + for (size_t i = 0; i < n; i++) { + if (d[i] == '\n') { handle_line(s_buf, s_len); s_len = 0; } + else if (s_len < sizeof s_buf) s_buf[s_len++] = d[i]; + } +} +``` + +**B. Length-prefixed binary frames** + +```c +static void on_rx(const uint8_t *d, size_t n) +{ + static uint16_t need = 0; + static uint8_t frame[256]; + static size_t got = 0; + + for (size_t i = 0; i < n; i++) { + if (need == 0) { need = d[i]; got = 0; continue; } + frame[got++] = d[i]; + if (got == need) { handle_frame(frame, got); need = 0; } + } +} +``` + +**C. Forward straight to UART** + +```c +static void on_rx(const uint8_t *d, size_t n) +{ + uart_write_bytes(UART_NUM_1, (const char *)d, n); +} +``` + +### 6.2 Disabling encryption (lab scenarios) + +```c +ble_uart_install(&(ble_uart_config_t){ + .encrypted = false, /* ← turn it off */ + .device_name = "OpenDev", + .ble_uart_on_rx = ..., +}); +``` + +Effect: +- GATT characteristics drop the `_ENC | _AUTHEN` flags. +- Any central can read/write — no pairing required. +- No passkey prompt. +- Data is sniffable by any nearby nRF dongle. + +**Do not ship this in production firmware.** + +### 6.3 Coexisting with other GATT services + +> The snippet below is for the **NimBLE backend**. With Bluedroid, register +> additional profiles via `esp_ble_gatts_app_register()` before calling +> `ble_uart_open()` — the gating rule is the same: extra services must +> be in place before advertising starts. + +`ble_uart` registers its own service; you can call `ble_gatts_add_svcs()` +**multiple times** and NimBLE will build all of them into the GATT +table. **Caveat**: this must happen before `ble_uart_open()`, otherwise +the host task is already running and the GATT table is locked. + +```c +ble_uart_install(&cfg); + +/* Register your extra services before open() */ +ble_svc_dis_init(); /* Device Information Service */ +my_battery_service_init(); /* your own battery service */ + +ble_uart_open(); +``` + +> If your service must appear in the **advertising packet**, you have +> to bypass `ble_uart`'s internal advertising logic — override +> `ble_hs_cfg.sync_cb` with your own implementation after +> `ble_uart_install`, then call `ble_uart_open()`. Note that +> `ble_uart`'s internal `start_advertising` will not run, so you must +> call `ble_gap_adv_start` yourself. In that case, just fork +> `ble_uart_nimble.c` (or the matching `ble_uart_bluedroid.c`). + +### 6.4 Configuring the device-name prefix via Kconfig + +Copy `Kconfig.projbuild` into `main/`, then: + +```c +char name[24]; +snprintf(name, sizeof(name), "%s-%02X%02X", + CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]); + +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .device_name = name, + .ble_uart_on_rx = on_rx, +}); +``` + +Edit the default through `menuconfig → BLE UART Example → BLE device +name prefix`. + +### 6.5 Pushing data proactively + +You can call TX from any task: + +```c +/* A periodic sensor-reporting task */ +static void sensor_task(void *arg) +{ + char line[64]; + while (1) { + int t = read_temperature(); + int n = snprintf(line, sizeof(line), "temp=%d.%d\n", t / 10, t % 10); + ble_uart_tx((const uint8_t *)line, (size_t)n); + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} + +/* Spawn it from app_main */ +xTaskCreate(sensor_task, "sensor", 3072, NULL, 5, NULL); +``` + +When nobody is subscribed, `ble_uart_tx` returns `BLE_HS_ENOTCONN` — +**just ignore it**. + +--- + +## 7. Calling Context & Thread Safety + +| Function | Calling context | Thread-safe | +| --- | --- | --- | +| `ble_uart_install` | Any task; once per uninstall cycle | One-shot until `uninstall` | +| `ble_uart_open` | Any task; after `install` | One-shot until `close` | +| `ble_uart_close` | Any task **except the BLE host task** (NimBLE host task / Bluedroid BTC task) | Idempotent; second call returns `EALREADY` | +| `ble_uart_uninstall` | Any task **except the BLE host task** | Idempotent; cascades into `close` if needed | +| `ble_uart_tx` | Any FreeRTOS task | Yes — multi-task concurrent | +| `ble_uart_is_connected` / `is_subscribed` | Any context | Yes (bool read; best-effort snapshot) | +| `ble_uart_on_rx` callback | BLE host task (NimBLE host task / Bluedroid BTC task) | Your code must not block, **must not call `close` / `uninstall`** | +| **Calling any `ble_uart` API from an ISR** | not allowed | Neither host stack supports it | + +--- + +## 8. Memory / Performance + +| Item | Footprint | +| --- | --- | +| Code segment (`ble_uart_nimble.c.o`) | ~14 KB (with `-Os`) | +| Code segment (`ble_uart_bluedroid.c.o`) | ~22 KB (with `-Os`; larger because long-write reassembly is open-coded) | +| Static RAM (globals + RX buffer) | ~1.1 KB (the bulk is `CONFIG_BLE_UART_RX_SCRATCH_SIZE`, default 1024 B) | +| Host task stack (NimBLE host / Bluedroid BTC) | 4 KB (default) | +| Controller task stack | ~3 KB (default) | +| Bond store (NVS) | ~80 bytes per bonded peer | +| ATT MTU | Negotiated; whatever you set in sdkconfig (247 / 256 / 512) | + +Measured throughput (ESP32-S3, iPhone 14 Pro central, MTU 247): +- TX (notify): ~25 KB/s +- RX (write): ~20 KB/s + +--- + +## 9. FAQ + +| Symptom | Cause / fix | +| --- | --- | +| `nimble_port_init rc=...` | NVS not initialised, or BT controller not enabled | +| Compile error: `host/ble_hs.h` not found | `REQUIRES bt` is missing from CMakeLists | +| Device not discoverable | Device name exceeds the advertising packet limit (drop the tx_pwr field or shorten the name) | +| Pairing fails | Central uses "Just Works" but we require MITM (`encrypted=true`). Use a central that supports passkey entry | +| `enc_change status=13 encrypted=1 bonded=1` | `13 = BLE_HS_ETIMEOUT`. Bonded-reconnect race; **the link is actually encrypted — safe to ignore** | +| Notifications missing after a reconnect | Bonded centrals often skip the CCCD write; our TX path doesn't gate on subscription state, so notifications still go out — make sure the central side has its callback registered | +| Second connection rejected | `MAX_CONNECTIONS = 1` by default. For multi-connection support, bump the sdkconfig value and turn `s_conn_handle` (NimBLE backend) / `s_conn_id` (Bluedroid backend) into an array | +| Flash fills up | Bond entries accumulate. Periodically run `idf.py erase-flash`, or call `ble_store_clear()` in code | + +--- + +## 10. Differences from This Example + +If you **build directly on top of this example**: + +| You already have | No further work needed | +| --- | --- | +| `main.c` echo template | Replace with your own `on_rx` body | +| `sdkconfig.defaults` | Reuse as-is | +| `Kconfig.projbuild` | Reuse as-is | +| `CMakeLists.txt` (root + main) | Reuse as-is | + +If you **start from an empty project**: + +| What you need to do | Source | +| --- | --- | +| Copy `ble_uart.h` + at least one of `ble_uart_nimble.c` / `ble_uart_bluedroid.c` into `main/` | This example | +| Copy the key lines of `sdkconfig.defaults` | §4.3 of this guide | +| Add SRC + REQUIRES to `main/CMakeLists.txt` | §4.2 of this guide | +| Write `install` + `open` in `app_main` | §4.4 of this guide | + +--- + +## 11. API Cheat Sheet (print and pin to the wall) + +```c +#include "ble_uart.h" + +/* === Types === */ +typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len); + +typedef struct { + bool encrypted; + const char *device_name; + ble_uart_rx_cb_t ble_uart_on_rx; +} ble_uart_config_t; + +/* === Lifecycle === */ +int ble_uart_install(const ble_uart_config_t *cfg); /* host + GATT */ +int ble_uart_open(void); /* start advertising (NimBLE: spawn host task) */ +int ble_uart_close(void); /* stop adv / disconnect / quiesce host */ +int ble_uart_uninstall(void); /* tear down host + reset state */ + +/* === Send (callable from any task) === */ +int ble_uart_tx(const uint8_t *data, size_t len); + +/* === Receive === */ +/* Via the cfg.ble_uart_on_rx callback, signature: + * void cb(const uint8_t *data, size_t len); */ + +/* === Status === */ +bool ble_uart_is_connected(void); +bool ble_uart_is_subscribed(void); + +/* === Service UUID (for advertising; usually no need to touch) === */ +extern const ble_uart_uuid128_t ble_uart_service_uuid; +``` + +--- + +## 12. Minimal Project Template (ready to flash) + +A complete flashable project takes 7 files (the inactive backend `.c` +compiles to nothing, so it costs you nothing to ship both): + +``` +my_ble_uart_project/ +├── CMakeLists.txt +├── sdkconfig.defaults +└── main/ + ├── CMakeLists.txt + ├── ble_uart.h ← copied from this example + ├── ble_uart_nimble.c ← copied from this example + ├── ble_uart_bluedroid.c ← copied from this example (optional) + └── main.c +``` + +**Root `CMakeLists.txt`**: +```cmake +cmake_minimum_required(VERSION 3.16) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(my_ble_uart) +``` + +**`main/CMakeLists.txt`**: +```cmake +idf_component_register(SRCS "main.c" + "ble_uart_nimble.c" + "ble_uart_bluedroid.c" + INCLUDE_DIRS "." + REQUIRES bt nvs_flash) +``` + +**`sdkconfig.defaults`** (7 lines, NimBLE backend): +```ini +CONFIG_BT_ENABLED=y +CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y +CONFIG_BT_BLUEDROID_ENABLED=n +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_NIMBLE_SM_SC=y +CONFIG_BT_NIMBLE_NVS_PERSIST=y +CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=512 +``` + +**`main/main.c`** — copy the §4.4 template verbatim. + +Flash: + +```bash +idf.py set-target esp32s3 +idf.py build flash monitor +``` + +Done. diff --git a/examples/bluetooth/ble_uart_service/README.md b/examples/bluetooth/ble_uart_service/README.md new file mode 100644 index 00000000000..8b0169ad7b5 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/README.md @@ -0,0 +1,238 @@ +# BLE UART Service Example — NimBLE / Bluedroid + +| Supported Targets | ESP32 | ESP32-C2 | ESP32-C3 | ESP32-C5 | ESP32-C6 | ESP32-C61 | ESP32-H2 | ESP32-S3 | +| ----------------- | ----- | -------- | -------- | -------- | -------- | --------- | -------- | -------- | + +A turnkey serial-over-BLE peripheral that implements the de-facto +**Nordic UART Service** GATT layout (RX write, TX notify), so any +standard BLE-serial central (nRF Connect, Web Bluetooth examples, your +own iOS / Android / Linux / Python scripts) can talk to it unchanged. + +The example ships with **two interchangeable backends** — NimBLE and +Bluedroid — both implementing the same stack-agnostic +`ble_uart.h` API. Pick one with `idf.py menuconfig → Component config → +Bluetooth → Host`; the build system links the matching backend +automatically. Default is NimBLE (smaller footprint). + +The whole BLE stack — NVS-backed bond store, NimBLE host, security +manager, advertising, pairing, GAP event handling — is wrapped behind +**two function calls** in `app_main`: + +```c +ble_uart_install(&cfg); // NimBLE host + NUS GATT service +ble_uart_open(); // start advertising + auto-encrypt +``` + +…and two matching tear-down calls if your app ever needs to power +BLE off at runtime: + +```c +ble_uart_close(); // stop advertising / disconnect / halt host +ble_uart_uninstall(); // free the NimBLE port + reset state +``` + +When a central connects, the firmware automatically initiates LE Secure +Connections + Bonding pairing, displays a fresh 6-digit passkey to the +UART monitor, persists the LTK in NVS, and starts delivering received +bytes to the application's `on_rx` callback. The application sends bytes +back with `ble_uart_tx()`. + +## GATT layout + +| | UUID | Properties | Default flags | +| -------- | -------------------------------------- | ------------------------- | ------------------------- | +| Service | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` | — | — | +| RX (in) | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` | Write, WriteNR | encrypted, authenticated | +| TX (out) | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | Notify (auto-CCCD) | encrypted, authenticated | + +The `_ENC | _AUTHEN` flags are turned on only when `cfg.encrypted = true` +(the default in this example). + +## Files + +| File | Lines | Role | +| --- | ---: | --- | +| `main/main.c` | ~70 | NVS init, MAC-derived device name, install + open, RX echo handler. Identical for both backends. | +| `main/ble_uart.h` | ~260 | Stack-agnostic public API: 3-field config + 4 lifecycle functions + TX/status + UUID + `BLE_UART_E*` return codes. No NimBLE / Bluedroid types leak through. | +| `main/ble_uart_nimble.c` | ~670 | NimBLE backend: host bring-up, NUS GATT service via `ble_gatts_add_svcs`, advertising, pairing, install/open/close/uninstall. Active when `CONFIG_BT_NIMBLE_ENABLED=y`. | +| `main/ble_uart_bluedroid.c` | ~900 | Bluedroid backend: controller + host enable, NUS GATT service via `esp_ble_gatts_create_attr_tab` (service-table API), advertising, pairing, full PREP/EXEC long-write reassembly, install/open/close/uninstall. Active when `CONFIG_BT_BLUEDROID_ENABLED=y`. | +| `main/Kconfig.projbuild` | ~40 | Device-name prefix + RX scratch buffer size knobs. | +| `sdkconfig.defaults` | — | Default: NimBLE backend, MTU 512, SC + bonding + persistent NVS. | +| `sdkconfig.ci.bluedroid` | — | Overlay: switch to Bluedroid backend (used via `-D SDKCONFIG_DEFAULTS=...`, see "Choosing the host stack" below). | + +## Public API + +```c +typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len); + +typedef struct { + bool encrypted; /* SC + Bonding + MITM in one knob */ + const char *device_name; + ble_uart_rx_cb_t ble_uart_on_rx; +} ble_uart_config_t; + +/* Lifecycle */ +int ble_uart_install(const ble_uart_config_t *cfg); /* NimBLE host + GATT */ +int ble_uart_open(void); /* host task + advertising */ +int ble_uart_close(void); /* stop adv / disconnect / halt host */ +int ble_uart_uninstall(void); /* free NimBLE port + reset state */ + +/* Data path */ +int ble_uart_tx(const uint8_t *data, size_t len); + +/* Status (best-effort snapshot) */ +bool ble_uart_is_connected(void); +bool ble_uart_is_subscribed(void); + +extern const ble_uart_uuid128_t ble_uart_service_uuid; +``` + +## Choosing the host stack + +The same `ble_uart.h` API is implemented twice — once on top of NimBLE +(`ble_uart_nimble.c`) and once on top of Bluedroid +(`ble_uart_bluedroid.c`). `main/CMakeLists.txt` registers both files; +each guards its body with `#if CONFIG_BT_NIMBLE_ENABLED` / `#if +CONFIG_BT_BLUEDROID_ENABLED`, so exactly one becomes live at compile +time. + +Two ways to switch: + +```bash +# A. Flip the Kconfig knob interactively +idf.py menuconfig +# Component config -> Bluetooth -> Host -> NimBLE / Bluedroid + +# B. Apply the Bluedroid overlay non-interactively (great for CI) +idf.py -B build_bd \ + -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.ci.bluedroid" \ + reconfigure +idf.py -B build_bd build flash monitor +``` + +When neither is enabled the build fails up-front with a clear error. + +### Differences callers should know about + +| | NimBLE backend | Bluedroid backend | +| --- | --- | --- | +| Long-write (PREP+EXEC) reassembly | done by NimBLE itself | done in `ble_uart_bluedroid.c`, capped at `CONFIG_BLE_UART_RX_SCRATCH_SIZE` | +| TX congestion behaviour | `mbuf` pool is generous; rarely returns ENOMEM | `esp_ble_gatts_send_indicate` may return ENOMEM under load → caller should back off | +| Passkey origin | generated locally with `esp_random()` | generated by the Bluedroid SM, surfaced via `PASSKEY_NOTIF_EVT` | +| Bond persistence | needs `CONFIG_BT_NIMBLE_NVS_PERSIST=y` | persisted by default | +| `install` blocking time | ~50 ms | ~150 ms (waits for `CREAT_ATTR_TAB_EVT`) | +| `uninstall` thoroughness | `nimble_port_deinit()` releases everything | `bluedroid_disable+deinit` + `controller_disable+deinit` releases everything | + +## How to use + +### Configure + +```bash +idf.py set-target esp32c3 # or esp32, esp32s3, esp32c6, esp32h2 ... +idf.py menuconfig # optional +# Component config -> BLE UART Example +# - BLE device name prefix (default: BleUart) +``` + +The two security knobs are set in `sdkconfig.defaults`: + +```ini +CONFIG_BT_NIMBLE_SM_SC=y # LE Secure Connections +CONFIG_BT_NIMBLE_NVS_PERSIST=y # Bond keys persist across reboots +``` + +Disable `cfg.encrypted` in `main.c` (set it to `false`) for plaintext +operation in the lab — the GATT characteristics drop their `_ENC` +flags accordingly. Production firmware should keep encryption on. + +### Build & flash + +```bash +idf.py build flash monitor +``` + +Expected boot log (NimBLE backend — the per-characteristic register +lines are NimBLE-specific; Bluedroid prints the four NUS handles in a +single line, see below): + +``` +I (xxx) ble_uart: registered service 6e400001-... handle=14 +I (xxx) ble_uart: registered chr 6e400002-... def=15 val=16 +I (xxx) ble_uart: registered chr 6e400003-... def=17 val=18 +I (xxx) ble_uart: addr=80:7d:3a:11:22:33 +I (xxx) ble_uart: BLE host task started +I (xxx) ble_uart: advertising as 'BleUart-XXXX' +``` + +Expected boot log (Bluedroid backend): + +``` +I (xxx) ble_uart: gatts reg status=0 app_id=85 gatts_if=3 +I (xxx) ble_uart: registered service svc_handle=40 rx=42 tx=44 cccd=45 +I (xxx) ble_uart: advertising started +``` + +## Pairing & demo + +1. On a phone, install **nRF Connect for Mobile**. +2. Scan, tap **Connect** on `BleUart-XXXX`. The phone prompts for a + 6-digit code. +3. The device prints a fresh code in a banner on UART: + + ``` + W (xxx) ble_uart: +-----------------------------+ + W (xxx) ble_uart: | BLE PAIRING PASSKEY: | + W (xxx) ble_uart: | 427183 | + W (xxx) ble_uart: +-----------------------------+ + ``` +4. Type that code on the phone; pairing completes. The link is now + AES-CCM-encrypted and the LTK is stored to NVS. +5. Open the *Nordic UART Service*, subscribe to TX (the down-arrow + icon), then write any bytes to RX (the up-arrow icon). The device + logs them to UART and **echoes them right back** through TX. +6. Disconnect and reconnect: no passkey prompt — the bond resumes + automatically. + +To wipe the bond and force a fresh passkey, run `idf.py erase-flash` +and re-flash. + +## Adapting to your application + +Replace the `ble_uart_on_rx` body in `main.c` with your own protocol +parser (line / TLV / length-prefixed framing — `ble_uart` delivers raw +bytes with no framing assumptions). Send replies with `ble_uart_tx()`. + +## Reusing `ble_uart` in your own project + +Copy `main/ble_uart.h` plus the backend(s) you want — `main/ble_uart_nimble.c` +and/or `main/ble_uart_bluedroid.c` — into your project, add `bt nvs_flash` +to your component's `REQUIRES`, then in your `app_main`: + +```c +nvs_flash_init(); + +ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .device_name = "MyDevice", + .ble_uart_on_rx = my_handler, +}); +ble_uart_open(); +``` + +That's it — encrypted serial-over-BLE in 4 lines. + +## Troubleshooting + +- **Phone shows "pairing failed"** — the central asked for "Just Works" + and our SM rejected it because MITM is required when + `cfg.encrypted = true`. Pick a phone / app that supports passkey entry. +- **No passkey appears in UART** — verify `CONFIG_BT_NIMBLE_SM_SC=y` in + your sdkconfig and that you didn't toggle `cfg.encrypted` to `false`. +- **`enc_change status=13 encrypted=1 ...`** — `13` is `BLE_HS_ETIMEOUT`, + triggered by a benign race between our `ble_gap_security_initiate()` + and the central's own auto-encryption on a bonded reconnect. Status + is non-zero but the link is fully encrypted; safe to ignore. +- **Notifications missing after a reconnect** — `ble_uart_tx` deliberately + doesn't gate on the CCCD-subscribe state because bonded reconnects + often skip the CCCD write. Bytes are still pushed; the central + delivers them based on its remembered subscription. diff --git a/examples/bluetooth/ble_uart_service/main/CMakeLists.txt b/examples/bluetooth/ble_uart_service/main/CMakeLists.txt new file mode 100644 index 00000000000..c05578d30a9 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/CMakeLists.txt @@ -0,0 +1,10 @@ +# Both backends are listed; each .c file body is wrapped in +# #if CONFIG_BT_NIMBLE_ENABLED / CONFIG_BT_BLUEDROID_ENABLED so only +# the matching backend produces code. This is the standard IDF +# pattern for conditional sources, because sdkconfig isn't loaded +# during the early CMake component-requirement scan. +idf_component_register(SRCS "main.c" + "ble_uart_nimble.c" + "ble_uart_bluedroid.c" + INCLUDE_DIRS "." + REQUIRES bt nvs_flash) diff --git a/examples/bluetooth/ble_uart_service/main/Kconfig.projbuild b/examples/bluetooth/ble_uart_service/main/Kconfig.projbuild new file mode 100644 index 00000000000..0f3654d8305 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/Kconfig.projbuild @@ -0,0 +1,26 @@ +menu "BLE UART Example" + + config BLE_UART_DEVICE_NAME_PREFIX + string "BLE device name prefix" + default "BleUart" + help + The firmware advertises as `-XXXX` where XXXX is + the last two bytes of the BT MAC in hex. + + config BLE_UART_RX_SCRATCH_SIZE + int "RX scratch buffer size (bytes)" + range 64 16384 + default 1024 + help + Upper bound on a single RX payload delivered to + ble_uart_on_rx(). Covers both plain writes (MTU - 3 bytes) + and reassembled long writes (PREP + EXEC). Oversized writes + are rejected with ATT error 0x0D. + + The buffer lives in BSS, so this value directly translates + into RAM cost. Bump it if your protocol sends larger frames + in one shot; with the NimBLE backend, raising past ~10 KB + may also require increasing + CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT. + +endmenu diff --git a/examples/bluetooth/ble_uart_service/main/ble_uart.h b/examples/bluetooth/ble_uart_service/main/ble_uart.h new file mode 100644 index 00000000000..037b48390d7 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/ble_uart.h @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + * + * BLE UART — turnkey serial-over-BLE peripheral. + * + * Implements the de-facto Nordic UART Service (NUS) GATT layout + * (RX write, TX notify) on top of either NimBLE or Bluedroid; the + * backend is picked at compile time via CONFIG_BT_NIMBLE_ENABLED / + * CONFIG_BT_BLUEDROID_ENABLED. + * + * Lifecycle: + * + * ble_uart_install(&cfg); // host + GATT service + * ble_uart_open(); // start advertising + auto-encrypt + * ... + * ble_uart_close(); // stop adv / disconnect / halt host + * ble_uart_uninstall(); // free port + reset state + * + * Run-forever apps only need install + open. close / uninstall is + * for apps that need to power BLE off at runtime. + * + * GATT layout (UUIDs fixed by the NUS spec): + * + * Service: 6e400001-b5a3-f393-e0a9-e50e24dcca9e + * RX : 6e400002-b5a3-f393-e0a9-e50e24dcca9e write + * TX : 6e400003-b5a3-f393-e0a9-e50e24dcca9e notify + * + * See PORTING.md for the integration guide. + */ + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ----- Return codes --------------------------------------------------- */ + +/** All ble_uart_* APIs return one of these stack-neutral codes. */ +#define BLE_UART_OK 0 /* Success */ +#define BLE_UART_EINVAL -1 /* Bad argument or unsupported op */ +#define BLE_UART_ENOTCONN -2 /* No central currently connected */ +#define BLE_UART_ENOMEM -3 /* Out of mbufs / send queue full */ +#define BLE_UART_EALREADY -4 /* Lifecycle already in this state */ +#define BLE_UART_EFAIL -5 /* Backend internal failure (see logs) */ + +/* ----- 128-bit UUID helper -------------------------------------------- */ + +/** Stack-agnostic 128-bit UUID in little-endian (wire) order. */ +typedef struct { + uint8_t bytes[16]; +} ble_uart_uuid128_t; + +/* ----- Configuration -------------------------------------------------- */ + +/** RX byte callback. Invoked from the BLE host task whenever bytes + * arrive on the RX characteristic. The buffer is owned by the stack + * and reused after return — copy what you need to keep. + * + * Don't block here; offload heavy work to your own task. + * + * Long-write (PREP/EXEC) reassembly is handled transparently — you + * always see one contiguous payload, capped by + * CONFIG_BLE_UART_RX_SCRATCH_SIZE (default 1024). Oversized writes + * are rejected with ATT error 0x0d. */ +typedef void (*ble_uart_rx_cb_t)(const uint8_t *data, size_t len); + +/** Configuration handed to ble_uart_install(). */ +typedef struct { + /** True = LE Secure Connections + Bonding + MITM, DisplayOnly IO, + * encrypted RX/TX chars, bond persisted in NVS (NimBLE: requires + * CONFIG_BT_NIMBLE_NVS_PERSIST=y; Bluedroid: default). + * False = plaintext (lab debugging only — sniffable). */ + bool encrypted; + + /** GAP device name. NULL keeps the host stack default. Mind the + * 31-byte primary advertising limit (≤ 8 bytes recommended). */ + const char *device_name; + + /** Byte handler for RX writes. NULL discards incoming data. */ + ble_uart_rx_cb_t ble_uart_on_rx; +} ble_uart_config_t; + +/* ----- Lifecycle ------------------------------------------------------ */ + +/** Bring up host stack + Security Manager + SIG services + NUS GATT + * service. Caller must have already called nvs_flash_init(). + * cfg->device_name is copied; doesn't need to outlive the call. + * Single-shot until ble_uart_uninstall(); a second call returns + * BLE_UART_EALREADY. */ +int ble_uart_install(const ble_uart_config_t *cfg); + +/** Start advertising. NimBLE: spawns the host task and primes the bond + * store; advertising begins once the controller signals ready. + * Bluedroid: triggers adv-data + scan-response config; advertising + * begins once the stack acknowledges both. + * + * Returns immediately; the BLE UART then runs autonomously + * (connect, pairing, passkey display, RX delivery all via internal + * callbacks). Single-shot. */ +int ble_uart_open(void); + +/** Counterpart to ble_uart_open(). Stops advertising, gracefully + * disconnects (waits up to 500 ms for LL_TERMINATE_IND ack), and + * quiesces the host. install state is preserved — call open() again + * to resume. + * + * Don't call from the BLE host task (i.e. from ble_uart_on_rx). */ +int ble_uart_close(void); + +/** Counterpart to ble_uart_install(). Force-closes if still open, + * then tears down the host stack + controller. After this returns, + * install() can run from scratch. + * + * Don't call from the BLE host task. */ +int ble_uart_uninstall(void); + +/* ----- TX ------------------------------------------------------------- */ + +/** Send raw bytes to the connected central as one or more TX + * notifications, fragmented to fit the live ATT MTU. Safe from any + * FreeRTOS task; not safe from ISR. + * + * Returns BLE_UART_ENOTCONN when no peer is connected (this is + * normal — typically just ignore). */ +int ble_uart_tx(const uint8_t *data, size_t len); + +/* ----- Status (best-effort, optional) -------------------------------- */ + +/** True when a central is connected (link may not yet be encrypted). + * Best-effort snapshot; production callers should rely on the return + * code of ble_uart_tx() instead. */ +bool ble_uart_is_connected(void); + +/** True when the central has subscribed to TX notifications. + * ble_uart_tx() does NOT gate on this (bonded reconnects often skip + * the CCCD write); exposed for diagnostics only. */ +bool ble_uart_is_subscribed(void); + +/* ----- Service UUID -------------------------------------------------- */ + +/** The NUS service UUID, exposed for custom advertising payloads. + * The two characteristic UUIDs are private to the backend. */ +extern const ble_uart_uuid128_t ble_uart_service_uuid; + +#ifdef __cplusplus +} +#endif diff --git a/examples/bluetooth/ble_uart_service/main/ble_uart_bluedroid.c b/examples/bluetooth/ble_uart_service/main/ble_uart_bluedroid.c new file mode 100644 index 00000000000..6f93ce057d2 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/ble_uart_bluedroid.c @@ -0,0 +1,1022 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + * + * BLE UART — Bluedroid backend. Implements the lifecycle declared in + * ble_uart.h on top of the Bluedroid host using the service-table API + * (esp_ble_gatts_create_attr_tab). Active when + * CONFIG_BT_BLUEDROID_ENABLED=y; otherwise ble_uart_nimble.c is used. + * + * Differences vs the NimBLE backend: + * - PREP/EXEC long-write reassembly is open-coded (NimBLE does it + * for us; Bluedroid hands raw fragments to the application). + * - MTU is cached locally in s_local_mtu — no Bluedroid query API. + */ + +#include "sdkconfig.h" + +#if CONFIG_BT_BLUEDROID_ENABLED + +#include "ble_uart.h" + +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "esp_log.h" +#include "esp_bt.h" +#include "esp_bt_main.h" +#include "esp_bt_defs.h" +#include "esp_bt_device.h" +#include "esp_gap_ble_api.h" +#include "esp_gatt_common_api.h" +#include "esp_gatts_api.h" + +static const char *TAG = "ble_uart"; + +/* ===== Backend constants ============================================== */ + +#define UART_APP_ID 0x55 /* arbitrary, must be unique per profile */ +#define UART_SVC_INST_ID 0 /* single service instance */ + +/* RX scratch capacity. See ble_uart_nimble.c for rationale. */ +#ifndef CONFIG_BLE_UART_RX_SCRATCH_SIZE +#define CONFIG_BLE_UART_RX_SCRATCH_SIZE 1024 +#endif +#define RX_SCRATCH CONFIG_BLE_UART_RX_SCRATCH_SIZE + +/* ===== UUIDs =========================================================== */ + +const ble_uart_uuid128_t ble_uart_service_uuid = { + .bytes = { 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e }, +}; + +static const uint8_t s_svc_uuid_bytes[16] = { + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e, +}; +static const uint8_t s_chr_rx_uuid[16] = { + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x02, 0x00, 0x40, 0x6e, +}; +static const uint8_t s_chr_tx_uuid[16] = { + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x03, 0x00, 0x40, 0x6e, +}; + +/* 16-bit UUIDs used in the attr table (declarations + CCCD). */ +static const uint16_t s_pri_svc_uuid = ESP_GATT_UUID_PRI_SERVICE; +static const uint16_t s_char_decl_uuid = ESP_GATT_UUID_CHAR_DECLARE; +static const uint16_t s_cccd_uuid = ESP_GATT_UUID_CHAR_CLIENT_CONFIG; + +static const uint8_t s_char_prop_write_nr = + ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_WRITE_NR; +static const uint8_t s_char_prop_notify = ESP_GATT_CHAR_PROP_BIT_NOTIFY; + +static const uint8_t s_cccd_default[2] = {0x00, 0x00}; + +/* Placeholders for the value attributes — RX is RSP_BY_APP (we own + * the response), TX is set on demand by send_indicate(). */ +static uint8_t s_rx_val_placeholder[1]; +static uint8_t s_tx_val_placeholder[1]; + +/* ===== Service-table indices ========================================== */ + +enum { + NUS_IDX_SVC, + NUS_IDX_RX_DECL, + NUS_IDX_RX_VAL, + NUS_IDX_TX_DECL, + NUS_IDX_TX_VAL, + NUS_IDX_TX_CCCD, + NUS_IDX_NB, +}; + +/* ===== State =========================================================== */ + +static ble_uart_config_t s_cfg; +static esp_gatt_if_t s_gatts_if = ESP_GATT_IF_NONE; +static uint16_t s_handles[NUS_IDX_NB]; +/* Volatile: written from BTC task, polled from caller task. */ +static volatile uint16_t s_conn_id = 0xFFFF; /* invalid sentinel */ +static esp_bd_addr_t s_remote_bda; +static uint16_t s_local_mtu = 23; /* spec default */ +static bool s_subscribed; +static bool s_installed; +static bool s_opened; +static bool s_shutting_down; +static volatile bool s_attr_tab_ready; +static bool s_adv_active; + +/* Two-bit latch driving the "configure adv data + scan rsp before + * start_advertising" sequence. start_advertising fires only when both + * SET_COMPLETE_EVT events have cleared their bit. */ +#define ADV_CONFIG_FLAG (1 << 0) +#define SCAN_RSP_CONFIG_FLAG (1 << 1) +static uint8_t s_adv_config_done; + +/* Long-write accumulator (Bluedroid doesn't reassemble for us). */ +static uint8_t s_rx_buf[RX_SCRATCH]; +static uint16_t s_prep_len; +static bool s_prep_bad; + +/* ===== Backend rc → public rc ========================================= */ + +static int xlate_rc(esp_err_t rc) +{ + switch (rc) { + case ESP_OK: return BLE_UART_OK; + case ESP_ERR_INVALID_ARG: + case ESP_ERR_INVALID_STATE: return BLE_UART_EINVAL; + case ESP_ERR_NO_MEM: return BLE_UART_ENOMEM; + default: return BLE_UART_EFAIL; + } +} + +/* ===== GATT attribute table =========================================== */ + +/* Permissions are patched at install time depending on cfg.encrypted. */ +static esp_gatts_attr_db_t s_nus_db[NUS_IDX_NB]; + +static void build_attr_table(bool encrypted) +{ + const esp_gatt_perm_t r_perm = encrypted + ? (ESP_GATT_PERM_READ_ENC_MITM) + : (ESP_GATT_PERM_READ); + const esp_gatt_perm_t w_perm = encrypted + ? (ESP_GATT_PERM_WRITE_ENC_MITM) + : (ESP_GATT_PERM_WRITE); + + /* [SVC] primary service declaration */ + s_nus_db[NUS_IDX_SVC] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_16, + .uuid_p = (uint8_t *)&s_pri_svc_uuid, + .perm = ESP_GATT_PERM_READ, + .max_length = sizeof(s_svc_uuid_bytes), + .length = sizeof(s_svc_uuid_bytes), + .value = (uint8_t *)s_svc_uuid_bytes, + }, + }; + + /* [RX] characteristic declaration (Write + Write-no-rsp) */ + s_nus_db[NUS_IDX_RX_DECL] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_16, + .uuid_p = (uint8_t *)&s_char_decl_uuid, + .perm = ESP_GATT_PERM_READ, + .max_length = sizeof(s_char_prop_write_nr), + .length = sizeof(s_char_prop_write_nr), + .value = (uint8_t *)&s_char_prop_write_nr, + }, + }; + + /* [RX value] — RSP_BY_APP because Bluedroid can't auto-respond + * to PREP_WRITE_REQ; we own the response path. */ + s_nus_db[NUS_IDX_RX_VAL] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_RSP_BY_APP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_128, + .uuid_p = (uint8_t *)s_chr_rx_uuid, + .perm = w_perm, + .max_length = RX_SCRATCH, + .length = sizeof(s_rx_val_placeholder), + .value = s_rx_val_placeholder, + }, + }; + + /* [TX] characteristic declaration (Notify only) */ + s_nus_db[NUS_IDX_TX_DECL] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_16, + .uuid_p = (uint8_t *)&s_char_decl_uuid, + .perm = ESP_GATT_PERM_READ, + .max_length = sizeof(s_char_prop_notify), + .length = sizeof(s_char_prop_notify), + .value = (uint8_t *)&s_char_prop_notify, + }, + }; + + /* [TX value] — NUS spec is notify-only, so the prop above doesn't + * advertise READ; perm only matters if a client tries READ anyway. */ + s_nus_db[NUS_IDX_TX_VAL] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_128, + .uuid_p = (uint8_t *)s_chr_tx_uuid, + .perm = r_perm, + .max_length = RX_SCRATCH, + .length = sizeof(s_tx_val_placeholder), + .value = s_tx_val_placeholder, + }, + }; + + /* [TX CCCD] — central writes 0x0001 to subscribe, 0x0000 to stop. + * w_perm enforces the encryption requirement. */ + s_nus_db[NUS_IDX_TX_CCCD] = (esp_gatts_attr_db_t){ + .attr_control = {ESP_GATT_AUTO_RSP}, + .att_desc = { + .uuid_length = ESP_UUID_LEN_16, + .uuid_p = (uint8_t *)&s_cccd_uuid, + .perm = ESP_GATT_PERM_READ | w_perm, + .max_length = sizeof(uint16_t), + .length = sizeof(s_cccd_default), + .value = (uint8_t *)s_cccd_default, + }, + }; +} + +/* ===== Advertising ==================================================== */ + +static esp_ble_adv_data_t s_adv_data = { + .set_scan_rsp = false, + .include_name = true, + .include_txpower = true, + .min_interval = 0, + .max_interval = 0, + .appearance = 0x00, + .manufacturer_len = 0, + .p_manufacturer_data = NULL, + .service_data_len = 0, + .p_service_data = NULL, + .service_uuid_len = 0, + .p_service_uuid = NULL, + .flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT), +}; + +/* Scan response = NUS UUID. Splitting it off the primary payload + * leaves room for name + tx_pwr in the 31-byte primary. */ +static esp_ble_adv_data_t s_scan_rsp_data = { + .set_scan_rsp = true, + .include_name = false, + .include_txpower = false, + .service_uuid_len = sizeof(s_svc_uuid_bytes), + .p_service_uuid = (uint8_t *)s_svc_uuid_bytes, +}; + +static esp_ble_adv_params_t s_adv_params = { + .adv_int_min = 0xa0, /* 100 ms */ + .adv_int_max = 0xa0, + .adv_type = ADV_TYPE_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, +}; + +static int start_advertising(void) +{ + esp_err_t rc = esp_ble_gap_start_advertising(&s_adv_params); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "adv_start rc=%s", esp_err_to_name(rc)); + return xlate_rc(rc); + } + s_adv_active = true; + return BLE_UART_OK; +} + +/* Push adv data + scan response. start_advertising is triggered from + * the matching SET_COMPLETE_EVT once both halves are realised. + * + * On a sync failure of either config call, the matching SET_COMPLETE_EVT + * will NEVER fire — so we must wipe the latch entirely (not just clear + * one bit) to avoid (a) advertising silently lost, or (b) the other + * half's event firing start_advertising with a half-configured payload. */ +static int configure_advertising(void) +{ + s_adv_config_done = ADV_CONFIG_FLAG | SCAN_RSP_CONFIG_FLAG; + + esp_err_t rc = esp_ble_gap_config_adv_data(&s_adv_data); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "config_adv_data rc=%s", esp_err_to_name(rc)); + s_adv_config_done = 0; + return xlate_rc(rc); + } + rc = esp_ble_gap_config_adv_data(&s_scan_rsp_data); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "config_scan_rsp rc=%s", esp_err_to_name(rc)); + /* adv_data is in flight; its SET_COMPLETE_EVT will hit the + * "stale event" branch in the GAP handler and bail. */ + s_adv_config_done = 0; + return xlate_rc(rc); + } + return BLE_UART_OK; +} + +/* ===== Passkey banner ================================================= */ + +static void show_passkey(uint32_t passkey) +{ + ESP_LOGW(TAG, ""); + ESP_LOGW(TAG, " +-----------------------------+"); + ESP_LOGW(TAG, " | BLE PAIRING PASSKEY: |"); + ESP_LOGW(TAG, " | %06" PRIu32 " |", passkey); + ESP_LOGW(TAG, " +-----------------------------+"); + ESP_LOGW(TAG, ""); +} + +/* ===== Long-write accumulator ========================================= */ + +static void prep_reset(void) +{ + s_prep_len = 0; + s_prep_bad = false; +} + +/* Append one PREP_WRITE fragment. Bad fragments latch s_prep_bad; we + * still answer OK to the client so its prepare-queue advances, then + * reject the whole batch at EXEC time (BT Core Spec §3.4.6.3 model). + * + * Two checks, both required: + * 1. Bounds — would otherwise memcpy past s_rx_buf. + * 2. Strict ascending contiguity — the spec lets a hostile client + * send arbitrary offsets; if we accepted gaps, the bytes between + * the writes would leak from the previous transaction (s_rx_buf + * is BSS and prep_reset() doesn't zero it). All real-world BLE + * centrals send PREP fragments in strict order anyway. */ +static void prep_append(uint16_t offset, const uint8_t *data, uint16_t len) +{ + if (s_prep_bad) { + return; + } + if ((uint32_t)offset + len > sizeof(s_rx_buf)) { + ESP_LOGW(TAG, "prep_write overflow: offset=%u len=%u cap=%u", + offset, len, (unsigned)sizeof(s_rx_buf)); + s_prep_bad = true; + return; + } + if (offset != s_prep_len) { + ESP_LOGW(TAG, "prep_write non-contiguous: offset=%u expected=%u", + offset, s_prep_len); + s_prep_bad = true; + return; + } + memcpy(s_rx_buf + offset, data, len); + s_prep_len = offset + len; +} + +/* ===== GATT event handler ============================================= */ + +/* Reply to a PREP_WRITE_REQ by echoing the value (BT spec mandates). */ +static void send_prep_write_response(uint16_t conn_id, uint32_t trans_id, + esp_gatt_status_t status, + uint16_t handle, uint16_t offset, + const uint8_t *value, uint16_t len) +{ + esp_gatt_rsp_t rsp = {0}; + rsp.attr_value.handle = handle; + rsp.attr_value.offset = offset; + rsp.attr_value.len = (len > sizeof(rsp.attr_value.value)) + ? sizeof(rsp.attr_value.value) + : len; + if (rsp.attr_value.len) { + memcpy(rsp.attr_value.value, value, rsp.attr_value.len); + } + esp_ble_gatts_send_response(s_gatts_if, conn_id, trans_id, status, &rsp); +} + +static void handle_write(esp_ble_gatts_cb_param_t *p) +{ + uint16_t handle = p->write.handle; + + if (p->write.is_prep) { + /* Two-tier validation per BT Core Spec §3.4.6.1 / §3.4.6.3: + * - Attribute-level (wrong handle): error in the PREP_RSP. + * - Fragment-level (offset/oversize/non-contiguous): latched + * in s_prep_bad; PREP_RSP stays OK and the error is + * surfaced once at EXEC time. */ + esp_gatt_status_t status = ESP_GATT_OK; + if (handle != s_handles[NUS_IDX_RX_VAL]) { + status = ESP_GATT_INVALID_HANDLE; + } else { + prep_append(p->write.offset, p->write.value, p->write.len); + /* status stays OK even if s_prep_bad got latched. */ + } + if (p->write.need_rsp) { + send_prep_write_response(p->write.conn_id, p->write.trans_id, + status, handle, p->write.offset, + p->write.value, p->write.len); + } + return; + } + + /* Plain (non-prep) write. */ + esp_gatt_status_t status = ESP_GATT_OK; + if (handle == s_handles[NUS_IDX_RX_VAL]) { + if (s_cfg.ble_uart_on_rx != NULL && p->write.len > 0) { + s_cfg.ble_uart_on_rx(p->write.value, p->write.len); + } + } else if (handle == s_handles[NUS_IDX_TX_CCCD]) { + /* CCCD must be exactly 2 bytes (BT Core §3.3.3.3). */ + if (p->write.len != 2) { + ESP_LOGW(TAG, "malformed CCCD write len=%u", p->write.len); + status = ESP_GATT_INVALID_ATTR_LEN; + } else { + uint16_t cccd = (uint16_t)p->write.value[0] + | ((uint16_t)p->write.value[1] << 8); + s_subscribed = (cccd & 0x0001) != 0; + ESP_LOGI(TAG, "subscribe cccd=0x%04x sub=%d", cccd, s_subscribed); + } + } + + if (p->write.need_rsp) { + esp_ble_gatts_send_response(s_gatts_if, p->write.conn_id, + p->write.trans_id, status, NULL); + } +} + +static void handle_exec_write(esp_ble_gatts_cb_param_t *p) +{ + /* CANCEL (flag=0x00) must always succeed; EXEC (flag=0x01) returns + * INVALID_ATTR_LEN if PREP latched an error. Either way, drop + * the queue. */ + esp_gatt_status_t status = ESP_GATT_OK; + + if (p->exec_write.exec_write_flag == ESP_GATT_PREP_WRITE_EXEC) { + if (s_prep_bad) { + ESP_LOGW(TAG, "exec_write: rejecting bad prep buffer (len=%u)", + s_prep_len); + status = ESP_GATT_INVALID_ATTR_LEN; + } else if (s_cfg.ble_uart_on_rx != NULL && s_prep_len > 0) { + s_cfg.ble_uart_on_rx(s_rx_buf, s_prep_len); + } + } + prep_reset(); + esp_ble_gatts_send_response(s_gatts_if, p->exec_write.conn_id, + p->exec_write.trans_id, status, NULL); +} + +static void gatts_profile_event_handler(esp_gatts_cb_event_t event, + esp_gatt_if_t gatts_if, + esp_ble_gatts_cb_param_t *param) +{ + switch (event) { + + case ESP_GATTS_REG_EVT: + ESP_LOGI(TAG, "gatts reg status=%d app_id=%u gatts_if=%u", + param->reg.status, param->reg.app_id, gatts_if); + if (param->reg.status != ESP_GATT_OK) { + return; + } + s_gatts_if = gatts_if; + if (s_cfg.device_name != NULL) { + esp_ble_gap_set_device_name(s_cfg.device_name); + /* Bluedroid copies internally; drop our pointer. */ + s_cfg.device_name = NULL; + } + esp_ble_gatts_create_attr_tab(s_nus_db, gatts_if, + NUS_IDX_NB, UART_SVC_INST_ID); + break; + + case ESP_GATTS_CREAT_ATTR_TAB_EVT: + if (param->add_attr_tab.status != ESP_GATT_OK + || param->add_attr_tab.num_handle != NUS_IDX_NB) { + ESP_LOGE(TAG, "create_attr_tab failed status=%d num=%u (expected %u)", + param->add_attr_tab.status, + param->add_attr_tab.num_handle, NUS_IDX_NB); + return; + } + memcpy(s_handles, param->add_attr_tab.handles, sizeof(s_handles)); + ESP_LOGI(TAG, "registered service svc_handle=%u rx=%u tx=%u cccd=%u", + s_handles[NUS_IDX_SVC], s_handles[NUS_IDX_RX_VAL], + s_handles[NUS_IDX_TX_VAL], s_handles[NUS_IDX_TX_CCCD]); + esp_ble_gatts_start_service(s_handles[NUS_IDX_SVC]); + s_attr_tab_ready = true; + /* If open() was waiting for the table, kick adv config now. + * On the BTC task — no caller to return errors to. */ + if (s_opened && !s_adv_active) { + (void)configure_advertising(); + } + break; + + case ESP_GATTS_WRITE_EVT: + handle_write(param); + break; + + case ESP_GATTS_EXEC_WRITE_EVT: + handle_exec_write(param); + break; + + case ESP_GATTS_MTU_EVT: + s_local_mtu = param->mtu.mtu; + ESP_LOGI(TAG, "mtu=%u (conn=%u)", s_local_mtu, param->mtu.conn_id); + break; + + case ESP_GATTS_CONNECT_EVT: + /* Bluedroid only fires this on a successful physical link; + * the param struct has no status field. */ + s_conn_id = param->connect.conn_id; + s_subscribed = false; + /* Link-layer auto-stops undirected adv on connect (per BT spec) + * but Bluedroid doesn't deliver ADV_STOP_COMPLETE_EVT for that + * case — only for explicit stop_advertising(). Clear here so + * close() doesn't try to stop an already-stopped advertiser. */ + s_adv_active = false; + memcpy(s_remote_bda, param->connect.remote_bda, sizeof(s_remote_bda)); + ESP_LOGI(TAG, "connect conn_id=%u remote " ESP_BD_ADDR_STR, + s_conn_id, ESP_BD_ADDR_HEX(s_remote_bda)); + if (s_cfg.encrypted) { + /* Kick pairing immediately rather than lazily on the + * first encrypted attribute access. */ + esp_ble_set_encryption(param->connect.remote_bda, + ESP_BLE_SEC_ENCRYPT_MITM); + } + break; + + case ESP_GATTS_DISCONNECT_EVT: + ESP_LOGI(TAG, "disconnect conn_id=%u reason=0x%x", + param->disconnect.conn_id, param->disconnect.reason); + s_conn_id = 0xFFFF; + s_subscribed = false; + /* MTU is per-connection: reset to the spec default 23 so the + * next peer (if it skips the MTU exchange) doesn't inherit + * the previous link's negotiated value and overflow tx chunks. */ + s_local_mtu = 23; + prep_reset(); + if (!s_shutting_down) { + start_advertising(); + } + break; + + case ESP_GATTS_CONF_EVT: + if (param->conf.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "notify confirm status=%d handle=%u", + param->conf.status, param->conf.handle); + } + break; + + case ESP_GATTS_READ_EVT: + ESP_LOGD(TAG, "read on handle=%u (ignored)", param->read.handle); + break; + + case ESP_GATTS_UNREG_EVT: + s_gatts_if = ESP_GATT_IF_NONE; + s_attr_tab_ready = false; + break; + + default: + break; + } +} + +/* ===== GAP event handler ============================================== */ + +static void gap_event_handler(esp_gap_ble_cb_event_t event, + esp_ble_gap_cb_param_t *param) +{ + switch (event) { + + /* Both SET_COMPLETE handlers below need to: + * 1) drop stale events (latch already zeroed by a sync failure); + * 2) drop async failures (status != SUCCESS) and wipe the latch + * so the other half can't satisfy the "==0 → start_adv" check + * and launch advertising with a malformed payload. */ + case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: + if (!(s_adv_config_done & ADV_CONFIG_FLAG)) { + ESP_LOGD(TAG, "stale ADV_DATA_SET_COMPLETE_EVT ignored"); + break; + } + if (param->adv_data_cmpl.status != ESP_BT_STATUS_SUCCESS) { + ESP_LOGE(TAG, "adv_data set failed status=0x%x", + param->adv_data_cmpl.status); + s_adv_config_done = 0; + break; + } + s_adv_config_done &= ~ADV_CONFIG_FLAG; + if (s_adv_config_done == 0 && s_opened && !s_shutting_down) { + start_advertising(); + } + break; + + case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: + if (!(s_adv_config_done & SCAN_RSP_CONFIG_FLAG)) { + ESP_LOGD(TAG, "stale SCAN_RSP_DATA_SET_COMPLETE_EVT ignored"); + break; + } + if (param->scan_rsp_data_cmpl.status != ESP_BT_STATUS_SUCCESS) { + ESP_LOGE(TAG, "scan_rsp set failed status=0x%x", + param->scan_rsp_data_cmpl.status); + s_adv_config_done = 0; + break; + } + s_adv_config_done &= ~SCAN_RSP_CONFIG_FLAG; + if (s_adv_config_done == 0 && s_opened && !s_shutting_down) { + start_advertising(); + } + break; + + case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: + if (param->adv_start_cmpl.status != ESP_BT_STATUS_SUCCESS) { + ESP_LOGE(TAG, "adv_start failed status=0x%x", + param->adv_start_cmpl.status); + s_adv_active = false; + break; + } + ESP_LOGI(TAG, "advertising started"); + break; + + case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: + s_adv_active = false; + ESP_LOGI(TAG, "advertising stopped"); + break; + + case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: + show_passkey(param->ble_security.key_notif.passkey); + break; + + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + esp_ble_auth_cmpl_t *a = ¶m->ble_security.auth_cmpl; + if (a->success) { + ESP_LOGI(TAG, "pairing ok auth_mode=0x%x", a->auth_mode); + } else { + ESP_LOGW(TAG, "pairing failed reason=0x%x", a->fail_reason); + } + break; + } + + case ESP_GAP_BLE_SEC_REQ_EVT: + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true); + break; + + case ESP_GAP_BLE_KEY_EVT: + ESP_LOGD(TAG, "key event type=0x%x", + param->ble_security.ble_key.key_type); + break; + + case ESP_GAP_BLE_NC_REQ_EVT: + /* Numeric Comparison shouldn't fire with our DisplayOnly IO + * (BT Core §2.3.5.1). Reject — accepting would flag the LTK + * as MITM-authenticated without any user actually comparing + * the digits, silently downgrading the security we asked for. */ + esp_ble_confirm_reply(param->ble_security.ble_req.bd_addr, false); + break; + + default: + break; + } +} + +/* ===== Public TX ====================================================== */ + +int ble_uart_tx(const uint8_t *data, size_t len) +{ + /* Snapshot conn_id and MTU once: a peer-A→peer-B disconnect+connect + * race during a multi-chunk send could otherwise leak later chunks + * to peer B (send_indicate doesn't gate on the CCCD subscription). + * Stale conn_id → ESP_ERR_INVALID_ARG, we bail cleanly. Stale MTU + * is harmless (chunks would only be smaller, never too big). */ + uint16_t conn_id = s_conn_id; + uint16_t mtu_snap = s_local_mtu; + if (conn_id == 0xFFFF) { + return BLE_UART_ENOTCONN; + } + if (data == NULL || len == 0) { + return BLE_UART_EINVAL; + } + + size_t chunk = (mtu_snap > 3) ? (size_t)(mtu_snap - 3) : 20; + size_t sent = 0; + while (sent < len) { + size_t n = len - sent; + if (n > chunk) { + n = chunk; + } + esp_err_t rc = esp_ble_gatts_send_indicate(s_gatts_if, conn_id, + s_handles[NUS_IDX_TX_VAL], + (uint16_t)n, + (uint8_t *)(data + sent), + false); /* notify, not indicate */ + if (rc != ESP_OK) { + ESP_LOGW(TAG, "send_indicate rc=%s", esp_err_to_name(rc)); + return xlate_rc(rc); + } + sent += n; + } + return BLE_UART_OK; +} + +/* Best-effort snapshots; see header for threading caveat. */ +bool ble_uart_is_connected(void) { return s_conn_id != 0xFFFF; } +bool ble_uart_is_subscribed(void) { return s_subscribed; } + +/* ===== Lifecycle ====================================================== */ + +static int configure_security(bool encrypted) +{ + esp_ble_auth_req_t auth_req = encrypted ? ESP_LE_AUTH_REQ_SC_MITM_BOND + : ESP_LE_AUTH_NO_BOND; + esp_ble_io_cap_t iocap = encrypted ? ESP_IO_CAP_OUT + : ESP_IO_CAP_NONE; + uint8_t key_size = 16; + uint8_t init_key = ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK; + uint8_t rsp_key = ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK; + + /* Apply each SM param individually and bail on the first failure. + * Do NOT collapse into `rc |= ...` — esp_err_t values aren't bit + * flags (e.g. NO_MEM|INVALID_ARG = INVALID_STATE), and a single + * ESP_FAIL=-1 sticks the accumulator. */ + struct { esp_ble_sm_param_t id; const void *val; uint8_t len; const char *tag; } params[] = { + { ESP_BLE_SM_AUTHEN_REQ_MODE, &auth_req, sizeof(auth_req), "AUTHEN_REQ_MODE" }, + { ESP_BLE_SM_IOCAP_MODE, &iocap, sizeof(iocap), "IOCAP_MODE" }, + { ESP_BLE_SM_MAX_KEY_SIZE, &key_size, sizeof(key_size), "MAX_KEY_SIZE" }, + { ESP_BLE_SM_SET_INIT_KEY, &init_key, sizeof(init_key), "SET_INIT_KEY" }, + { ESP_BLE_SM_SET_RSP_KEY, &rsp_key, sizeof(rsp_key), "SET_RSP_KEY" }, + }; + for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) { + esp_err_t rc = esp_ble_gap_set_security_param(params[i].id, + (void *)params[i].val, + params[i].len); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "set_security_param[%s] rc=%s", + params[i].tag, esp_err_to_name(rc)); + return xlate_rc(rc); + } + } + return BLE_UART_OK; +} + +int ble_uart_install(const ble_uart_config_t *cfg) +{ + if (s_installed) { + ESP_LOGW(TAG, "ble_uart_install called twice; ignoring"); + return BLE_UART_EALREADY; + } + + if (cfg != NULL) { + s_cfg = *cfg; + } else { + memset(&s_cfg, 0, sizeof(s_cfg)); + } + + /* Free BR/EDR controller RAM we won't use (no-op on BLE-only chips). */ + esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); + + /* Bring up controller + Bluedroid step by step. Track `stage` so + * the fail label can unwind exactly the layers we allocated — + * otherwise a partial install would leave the controller enabled + * with no module state pointing at it, and the next install() + * would fail with INVALID_STATE. */ + enum { + STAGE_NONE = 0, + STAGE_CTRL_INIT, + STAGE_CTRL_ENABLE, + STAGE_BLUEDROID_INIT, + STAGE_BLUEDROID_ENABLE, + STAGE_APP_REGISTER, + } stage = STAGE_NONE; + + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + esp_err_t rc = esp_bt_controller_init(&bt_cfg); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "controller_init rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_CTRL_INIT; + + rc = esp_bt_controller_enable(ESP_BT_MODE_BLE); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "controller_enable rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_CTRL_ENABLE; + + rc = esp_bluedroid_init(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "bluedroid_init rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_BLUEDROID_INIT; + + rc = esp_bluedroid_enable(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "bluedroid_enable rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_BLUEDROID_ENABLE; + + rc = esp_ble_gatts_register_callback(gatts_profile_event_handler); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "gatts_register rc=%s", esp_err_to_name(rc)); + goto fail; + } + rc = esp_ble_gap_register_callback(gap_event_handler); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "gap_register rc=%s", esp_err_to_name(rc)); + goto fail; + } + + /* SM must be configured before app_register so any incoming + * pairing request finds the right policy. */ + int srv = configure_security(s_cfg.encrypted); + if (srv != BLE_UART_OK) { + ESP_LOGE(TAG, "security config failed rc=%d", srv); + rc = ESP_FAIL; + goto fail; + } + + /* Bluedroid has no compile-time MTU Kconfig (NimBLE has + * CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU); set it at runtime here so + * both backends behave the same. Failure isn't fatal — we'd just + * fall back to the spec default 23. */ + rc = esp_ble_gatt_set_local_mtu(512); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "set_local_mtu(512) rc=%s; staying at default", + esp_err_to_name(rc)); + } + + build_attr_table(s_cfg.encrypted); + + rc = esp_ble_gatts_app_register(UART_APP_ID); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "app_register rc=%s", esp_err_to_name(rc)); + goto fail; + } + stage = STAGE_APP_REGISTER; + + /* Wait briefly for CREAT_ATTR_TAB_EVT so the GATT table is in + * place before open() asks the stack to advertise (otherwise the + * peer would scan us with an empty database). 500 ms is well over + * the typical bring-up time (~150 ms on ESP32-S3). */ + for (int i = 0; i < 50 && !s_attr_tab_ready; i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (!s_attr_tab_ready) { + ESP_LOGW(TAG, "attr table not ready after 500 ms; open() will retry"); + } + + s_installed = true; + return BLE_UART_OK; + +fail: + /* Unwind exactly the layers we brought up; ignore secondary errors. */ + switch (stage) { + case STAGE_APP_REGISTER: + if (s_gatts_if != ESP_GATT_IF_NONE) { + esp_ble_gatts_app_unregister(s_gatts_if); + s_gatts_if = ESP_GATT_IF_NONE; + } + /* fallthrough */ + case STAGE_BLUEDROID_ENABLE: + esp_bluedroid_disable(); + /* fallthrough */ + case STAGE_BLUEDROID_INIT: + esp_bluedroid_deinit(); + /* fallthrough */ + case STAGE_CTRL_ENABLE: + esp_bt_controller_disable(); + /* fallthrough */ + case STAGE_CTRL_INIT: + esp_bt_controller_deinit(); + /* fallthrough */ + case STAGE_NONE: + break; + } + s_attr_tab_ready = false; + memset(&s_cfg, 0, sizeof(s_cfg)); + return xlate_rc(rc); +} + +int ble_uart_open(void) +{ + if (!s_installed) { + ESP_LOGE(TAG, "ble_uart_open before ble_uart_install"); + return BLE_UART_EINVAL; + } + if (s_opened) { + ESP_LOGW(TAG, "ble_uart_open called twice; ignoring"); + return BLE_UART_EALREADY; + } + s_opened = true; + + /* If the GATT table is up, kick adv-config now; otherwise + * CREAT_ATTR_TAB_EVT will do it when it arrives. */ + if (s_attr_tab_ready) { + int rc = configure_advertising(); + if (rc != BLE_UART_OK) { + /* Roll back so the caller can retry open() without + * going through close + uninstall. */ + s_opened = false; + return rc; + } + } + return BLE_UART_OK; +} + +int ble_uart_close(void) +{ + if (!s_opened) { + return BLE_UART_EALREADY; + } + + /* Latch first so DISCONNECT_EVT skips the auto-readvertise. */ + s_shutting_down = true; + + if (s_adv_active) { + esp_ble_gap_stop_advertising(); + } + + /* Graceful disconnect: ask the controller to send LL_TERMINATE, + * then wait up to 500 ms for DISCONNECT_EVT to clear s_conn_id. */ + if (s_conn_id != 0xFFFF) { + esp_err_t rc = esp_ble_gap_disconnect(s_remote_bda); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "gap_disconnect rc=%s", esp_err_to_name(rc)); + } + for (int i = 0; i < 50 && s_conn_id != 0xFFFF; i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (s_conn_id != 0xFFFF) { + ESP_LOGW(TAG, "disconnect timed out; tearing down anyway"); + } + } + + s_conn_id = 0xFFFF; + s_subscribed = false; + /* Reset MTU here too in case DISCONNECT_EVT timed out above. */ + s_local_mtu = 23; + /* Defensively clear the long-write accumulator: if DISCONNECT_EVT + * didn't fire within our wait window, prep_reset() in the event + * handler never ran, and stale s_prep_len/s_prep_bad would poison + * the first PREP_WRITE of the next session (offset 0 != stale len + * latches s_prep_bad and rejects the whole batch at EXEC time). */ + prep_reset(); + s_opened = false; + s_shutting_down = false; + return BLE_UART_OK; +} + +int ble_uart_uninstall(void) +{ + if (!s_installed) { + return BLE_UART_EALREADY; + } + + /* Best-effort cleanup. We MUST NOT early-return on a per-step + * failure: that would leave s_installed=true with the SDK in + * some half-torn-down state, blocking both re-install and retry. + * Mirror the install() goto-fail philosophy: record the first + * error, keep tearing down, and always wipe our state. */ + esp_err_t first_err = ESP_OK; + + if (s_opened) { + int rc = ble_uart_close(); + if (rc != BLE_UART_OK && rc != BLE_UART_EALREADY) { + ESP_LOGE(TAG, "ble_uart_close rc=%d", rc); + if (first_err == ESP_OK) first_err = rc; + } + } + + if (s_gatts_if != ESP_GATT_IF_NONE) { + esp_err_t rc = esp_ble_gatts_app_unregister(s_gatts_if); + if (rc != ESP_OK && first_err == ESP_OK) { + ESP_LOGE(TAG, "gatts_app_unregister rc=%s", esp_err_to_name(rc)); + first_err = rc; + } + s_gatts_if = ESP_GATT_IF_NONE; + } + + esp_err_t rc = esp_bluedroid_disable(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "bluedroid_disable rc=%s", esp_err_to_name(rc)); + if (first_err == ESP_OK) first_err = rc; + } + rc = esp_bluedroid_deinit(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "bluedroid_deinit rc=%s", esp_err_to_name(rc)); + if (first_err == ESP_OK) first_err = rc; + } + rc = esp_bt_controller_disable(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "controller_disable rc=%s", esp_err_to_name(rc)); + if (first_err == ESP_OK) first_err = rc; + } + rc = esp_bt_controller_deinit(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "controller_deinit rc=%s", esp_err_to_name(rc)); + if (first_err == ESP_OK) first_err = rc; + } + + /* Wipe state unconditionally, even on partial failure. */ + memset(&s_cfg, 0, sizeof(s_cfg)); + memset(s_handles, 0, sizeof(s_handles)); + memset(s_remote_bda, 0, sizeof(s_remote_bda)); + s_conn_id = 0xFFFF; + s_local_mtu = 23; + s_subscribed = false; + s_installed = false; + s_opened = false; + s_shutting_down = false; + s_attr_tab_ready = false; + s_adv_active = false; + s_adv_config_done = 0; + prep_reset(); + return first_err == ESP_OK ? BLE_UART_OK : xlate_rc(first_err); +} + +#endif /* CONFIG_BT_BLUEDROID_ENABLED */ diff --git a/examples/bluetooth/ble_uart_service/main/ble_uart_nimble.c b/examples/bluetooth/ble_uart_service/main/ble_uart_nimble.c new file mode 100644 index 00000000000..bce4ab66f93 --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/ble_uart_nimble.c @@ -0,0 +1,652 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + * + * BLE UART — NimBLE backend. Implements the lifecycle declared in + * ble_uart.h on top of the NimBLE host. Active when + * CONFIG_BT_NIMBLE_ENABLED=y; otherwise ble_uart_bluedroid.c is used. + */ + +#include "sdkconfig.h" + +#if CONFIG_BT_NIMBLE_ENABLED + +#include "ble_uart.h" + +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "esp_log.h" +#include "esp_random.h" + +#include "nimble/ble.h" +#include "host/ble_att.h" +#include "host/ble_gap.h" +#include "host/ble_gatt.h" +#include "host/ble_hs.h" +#include "host/ble_hs_mbuf.h" +#include "host/ble_sm.h" +#include "host/ble_uuid.h" +#include "host/util/util.h" +#include "nimble/nimble_port.h" +#include "nimble/nimble_port_freertos.h" +#include "services/gap/ble_svc_gap.h" +#include "services/gatt/ble_svc_gatt.h" + +/* tx path needs notifications; the disabled-path in + * ble_gatts_notify_custom() leaks the caller's mbuf. */ +#if !MYNEWT_VAL(BLE_GATT_NOTIFY) +#error "ble_uart NimBLE backend requires MYNEWT_VAL(BLE_GATT_NOTIFY)=1" +#endif + +static const char *TAG = "ble_uart"; + +/* Map NimBLE rc → public BLE_UART_E* code; unknown rcs → EFAIL. */ +static int xlate_rc(int nimble_rc) +{ + switch (nimble_rc) { + case 0: return BLE_UART_OK; + case BLE_HS_EINVAL: return BLE_UART_EINVAL; + case BLE_HS_ENOTCONN: return BLE_UART_ENOTCONN; + case BLE_HS_ENOMEM: return BLE_UART_ENOMEM; + case BLE_HS_EALREADY: return BLE_UART_EALREADY; + default: return BLE_UART_EFAIL; + } +} + +/* Provided by NimBLE's `store/config` lib. */ +extern void ble_store_config_init(void); + +/* ===== UUIDs =========================================================== */ + +/* NUS UUIDs in little-endian byte order. */ +#define NUS_SVC_BYTES 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, \ + 0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e +#define NUS_RX_BYTES 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, \ + 0x93, 0xf3, 0xa3, 0xb5, 0x02, 0x00, 0x40, 0x6e +#define NUS_TX_BYTES 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, \ + 0x93, 0xf3, 0xa3, 0xb5, 0x03, 0x00, 0x40, 0x6e + +const ble_uart_uuid128_t ble_uart_service_uuid = { .bytes = { NUS_SVC_BYTES } }; + +static const ble_uuid128_t s_svc_uuid = BLE_UUID128_INIT(NUS_SVC_BYTES); +static const ble_uuid128_t s_chr_rx_uuid = BLE_UUID128_INIT(NUS_RX_BYTES); +static const ble_uuid128_t s_chr_tx_uuid = BLE_UUID128_INIT(NUS_TX_BYTES); + +/* ===== State =========================================================== */ + +/* RX scratch capacity. Tunable via menuconfig; fall back to 1024 if + * Kconfig.projbuild isn't carried along when reusing this file. */ +#ifndef CONFIG_BLE_UART_RX_SCRATCH_SIZE +#define CONFIG_BLE_UART_RX_SCRATCH_SIZE 1024 +#endif +#define RX_SCRATCH CONFIG_BLE_UART_RX_SCRATCH_SIZE + +/* Cached device name. Avoids ble_svc_gap_device_name() which returns + * NULL when CONFIG_BT_NIMBLE_GAP_SERVICE=n (would NULL-deref). 32B + * covers the BLE 31-byte adv-payload limit + NUL. */ +#define DEV_NAME_MAX 32 + +static ble_uart_config_t s_cfg; +static char s_dev_name[DEV_NAME_MAX]; +static uint16_t s_tx_val_handle; +/* Volatile: written from NimBLE host task, polled from caller task. */ +static volatile uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE; +static bool s_subscribed; +static bool s_installed; +static bool s_opened; +static bool s_shutting_down; /* gates auto-readvertise during close */ +static uint8_t s_own_addr_type; + +static int gap_event(struct ble_gap_event *event, void *arg); +static int start_advertising(void); + +/* ===== GATT (NUS) ====================================================== */ + +static int chr_access(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg) +{ + switch (ctxt->op) { + case BLE_GATT_ACCESS_OP_WRITE_CHR: { + /* File-scope (BSS) — host task is single-threaded so no reentry. */ + static uint8_t s_rx_buf[RX_SCRATCH]; + + uint16_t total = OS_MBUF_PKTLEN(ctxt->om); + if (total > sizeof(s_rx_buf)) { + ESP_LOGW(TAG, "rx oversize: %u > %u, rejecting", + (unsigned)total, (unsigned)sizeof(s_rx_buf)); + return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; + } + uint16_t copied = 0; + int rc = ble_hs_mbuf_to_flat(ctxt->om, s_rx_buf, total, &copied); + if (rc != 0) { + return BLE_ATT_ERR_UNLIKELY; + } + if (s_cfg.ble_uart_on_rx != NULL && copied > 0) { + s_cfg.ble_uart_on_rx(s_rx_buf, copied); + } + return 0; + } + case BLE_GATT_ACCESS_OP_READ_CHR: + return BLE_ATT_ERR_READ_NOT_PERMITTED; + default: + return BLE_ATT_ERR_UNLIKELY; + } +} + +/* Encryption-required flag masks. NimBLE derives CCCD permissions from + * NOTIFY_INDICATE_* (not from READ/WRITE_*), so notify-only chars need + * the NOTIFY_INDICATE mask, not just the RW mask — otherwise an + * unpaired central could subscribe and receive notifications over the + * unencrypted link (see ble_gatts.c:ble_gatts_chr_clt_cfg_flags_from_chr_flags). */ +#define CHR_FLAG_RW_ENC (BLE_GATT_CHR_F_READ_ENC | BLE_GATT_CHR_F_READ_AUTHEN | \ + BLE_GATT_CHR_F_WRITE_ENC | BLE_GATT_CHR_F_WRITE_AUTHEN) +#define CHR_FLAG_NOTIFY_ENC (BLE_GATT_CHR_F_NOTIFY_INDICATE_ENC | \ + BLE_GATT_CHR_F_NOTIFY_INDICATE_AUTHEN) + +static struct ble_gatt_chr_def s_chr_defs[3]; +static struct ble_gatt_svc_def s_svc_defs[2]; + +static void build_gatt_table(bool encrypted) +{ + /* `ble_gatt_chr_flags` is uint32_t — match width here so the + * 0x10000-and-above NOTIFY_INDICATE flags don't get truncated. */ + ble_gatt_chr_flags rw_enc = encrypted ? CHR_FLAG_RW_ENC : 0; + ble_gatt_chr_flags notify_enc = encrypted ? CHR_FLAG_NOTIFY_ENC : 0; + + s_chr_defs[0] = (struct ble_gatt_chr_def){ + .uuid = &s_chr_rx_uuid.u, + .access_cb = chr_access, + .flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP | rw_enc, + }; + s_chr_defs[1] = (struct ble_gatt_chr_def){ + .uuid = &s_chr_tx_uuid.u, + .access_cb = chr_access, + .flags = BLE_GATT_CHR_F_NOTIFY | notify_enc, + .val_handle = &s_tx_val_handle, + }; + s_chr_defs[2] = (struct ble_gatt_chr_def){0}; + + s_svc_defs[0] = (struct ble_gatt_svc_def){ + .type = BLE_GATT_SVC_TYPE_PRIMARY, + .uuid = &s_svc_uuid.u, + .characteristics = s_chr_defs, + }; + s_svc_defs[1] = (struct ble_gatt_svc_def){0}; +} + +static void register_cb(struct ble_gatt_register_ctxt *ctxt, void *arg) +{ + char buf[BLE_UUID_STR_LEN]; + switch (ctxt->op) { + case BLE_GATT_REGISTER_OP_SVC: + ESP_LOGI(TAG, "registered service %s handle=%d", + ble_uuid_to_str(ctxt->svc.svc_def->uuid, buf), ctxt->svc.handle); + break; + case BLE_GATT_REGISTER_OP_CHR: + ESP_LOGI(TAG, "registered chr %s def=%d val=%d", + ble_uuid_to_str(ctxt->chr.chr_def->uuid, buf), + ctxt->chr.def_handle, ctxt->chr.val_handle); + break; + default: + break; + } +} + +/* ===== TX ============================================================== */ + +int ble_uart_tx(const uint8_t *data, size_t len) +{ + /* Snapshot conn_handle once: a peer-A→peer-B disconnect+connect + * race during a multi-chunk send could otherwise leak later chunks + * to peer B (notify_custom doesn't gate on the CCCD subscription). + * Stale handle → BLE_HS_ENOTCONN, we bail cleanly. */ + uint16_t conn_handle = s_conn_handle; + if (conn_handle == BLE_HS_CONN_HANDLE_NONE) { + return BLE_UART_ENOTCONN; + } + if (data == NULL || len == 0) { + return BLE_UART_EINVAL; + } + + uint16_t mtu = ble_att_mtu(conn_handle); + size_t chunk = (mtu > 3) ? (size_t)(mtu - 3) : 20; + + size_t sent = 0; + while (sent < len) { + size_t n = len - sent; + if (n > chunk) { + n = chunk; + } + struct os_mbuf *om = ble_hs_mbuf_from_flat(data + sent, n); + if (om == NULL) { + return BLE_UART_ENOMEM; + } + int rc = ble_gatts_notify_custom(conn_handle, s_tx_val_handle, om); + if (rc != 0) { + ESP_LOGW(TAG, "notify failed: rc=%d", rc); + /* Callee frees om on every failure path EXCEPT the + * BLE_GATT_NOTIFY-disabled early-return (BLE_HS_ENOTSUP). + * Freeing on any other rc would be a double free. */ + if (rc == BLE_HS_ENOTSUP) { + os_mbuf_free_chain(om); + } + return xlate_rc(rc); + } + sent += n; + } + return BLE_UART_OK; +} + +/* Best-effort snapshots; see header for threading caveat. */ +bool ble_uart_is_connected(void) { return s_conn_handle != BLE_HS_CONN_HANDLE_NONE; } +bool ble_uart_is_subscribed(void) { return s_subscribed; } + +/* ===== Advertising ==================================================== */ + +static int start_advertising(void) +{ + /* 31-byte primary adv can't hold flags + tx_pwr + name + 128-bit + * UUID together, so split: primary = flags+tx_pwr+name, + * scan rsp = NUS UUID. */ + const char *name = s_dev_name; + size_t name_len = strlen(name); + + struct ble_hs_adv_fields adv = { + .flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP, + .tx_pwr_lvl_is_present = 1, + .tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO, + /* If no name was set, advertise without one (NimBLE accepts + * NULL+0); the NUS UUID in scan rsp still identifies us. */ + .name = name_len > 0 ? (uint8_t *)name : NULL, + .name_len = name_len, + .name_is_complete = name_len > 0 ? 1 : 0, + }; + int rc = ble_gap_adv_set_fields(&adv); + if (rc != 0) { + ESP_LOGE(TAG, "adv_set_fields rc=%d (name too long?)", rc); + return rc; + } + + struct ble_hs_adv_fields rsp = { + .uuids128 = &s_svc_uuid, + .num_uuids128 = 1, + .uuids128_is_complete = 1, + }; + rc = ble_gap_adv_rsp_set_fields(&rsp); + if (rc != 0) { + ESP_LOGE(TAG, "adv_rsp_set_fields rc=%d", rc); + return rc; + } + + struct ble_gap_adv_params params = { + .conn_mode = BLE_GAP_CONN_MODE_UND, + .disc_mode = BLE_GAP_DISC_MODE_GEN, + }; + rc = ble_gap_adv_start(s_own_addr_type, NULL, BLE_HS_FOREVER, + ¶ms, gap_event, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "adv_start rc=%d", rc); + return rc; + } + ESP_LOGI(TAG, "advertising as '%s'", name_len > 0 ? name : ""); + return 0; +} + +/* ===== GAP event handler ============================================== */ + +static void show_passkey(uint32_t passkey) +{ + ESP_LOGW(TAG, ""); + ESP_LOGW(TAG, " +-----------------------------+"); + ESP_LOGW(TAG, " | BLE PAIRING PASSKEY: |"); + ESP_LOGW(TAG, " | %06" PRIu32 " |", passkey); + ESP_LOGW(TAG, " +-----------------------------+"); + ESP_LOGW(TAG, ""); +} + +static int gap_event(struct ble_gap_event *event, void *arg) +{ + struct ble_gap_conn_desc desc; + + switch (event->type) { + + case BLE_GAP_EVENT_CONNECT: + ESP_LOGI(TAG, "connect %s status=%d handle=%d", + event->connect.status == 0 ? "ok" : "failed", + event->connect.status, + event->connect.conn_handle); + if (event->connect.status == 0) { + s_conn_handle = event->connect.conn_handle; + s_subscribed = false; + /* Start pairing immediately (rather than lazily on the + * first encrypted attribute access). */ + if (s_cfg.encrypted) { + ble_gap_security_initiate(event->connect.conn_handle); + } + } else if (!s_shutting_down) { + start_advertising(); + } + return 0; + + case BLE_GAP_EVENT_DISCONNECT: + ESP_LOGI(TAG, "disconnect reason=%d", event->disconnect.reason); + s_conn_handle = BLE_HS_CONN_HANDLE_NONE; + s_subscribed = false; + if (!s_shutting_down) { + start_advertising(); + } + return 0; + + case BLE_GAP_EVENT_CONN_UPDATE: + ESP_LOGI(TAG, "conn_update status=%d", event->conn_update.status); + return 0; + + case BLE_GAP_EVENT_ADV_COMPLETE: + ESP_LOGI(TAG, "adv_complete reason=%d", event->adv_complete.reason); + if (!s_shutting_down) { + start_advertising(); + } + return 0; + + case BLE_GAP_EVENT_ENC_CHANGE: + if (ble_gap_conn_find(event->enc_change.conn_handle, &desc) == 0) { + ESP_LOGI(TAG, "enc_change status=%d encrypted=%d authenticated=%d bonded=%d", + event->enc_change.status, + desc.sec_state.encrypted, + desc.sec_state.authenticated, + desc.sec_state.bonded); + } + return 0; + + case BLE_GAP_EVENT_REPEAT_PAIRING: + /* Drop old keys + retry rather than reject. */ + if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) == 0) { + ble_store_util_delete_peer(&desc.peer_id_addr); + } + return BLE_GAP_REPEAT_PAIRING_RETRY; + + case BLE_GAP_EVENT_PASSKEY_ACTION: + if (event->passkey.params.action == BLE_SM_IOACT_DISP) { + /* Rejection sampling avoids the modulo bias of + * `esp_random() % 1000000` (2^32 % 1e6 != 0). */ + const uint32_t passkey_max = 1000000U; + const uint32_t reject_above = UINT32_MAX - + (UINT32_MAX % passkey_max); + uint32_t r; + do { + r = esp_random(); + } while (r >= reject_above); + struct ble_sm_io pkey = { + .action = BLE_SM_IOACT_DISP, + .passkey = r % passkey_max, + }; + show_passkey(pkey.passkey); + int rc = ble_sm_inject_io(event->passkey.conn_handle, &pkey); + if (rc != 0) { + ESP_LOGW(TAG, "ble_sm_inject_io rc=%d", rc); + } + } else { + ESP_LOGW(TAG, "passkey action %d not handled (DisplayOnly only)", + event->passkey.params.action); + } + return 0; + + case BLE_GAP_EVENT_MTU: + ESP_LOGI(TAG, "mtu=%d (conn=%d)", + event->mtu.value, event->mtu.conn_handle); + return 0; + + case BLE_GAP_EVENT_SUBSCRIBE: + ESP_LOGI(TAG, "subscribe attr=%d cur_notify=%d", + event->subscribe.attr_handle, event->subscribe.cur_notify); + if (event->subscribe.attr_handle == s_tx_val_handle) { + s_subscribed = (event->subscribe.cur_notify != 0); + } + return 0; + + default: + return 0; + } +} + +/* ===== Host plumbing =================================================== */ + +static void on_reset(int reason) +{ + ESP_LOGE(TAG, "Resetting NimBLE state; reason=%d", reason); +} + +static void on_sync(void) +{ + int rc = ble_hs_util_ensure_addr(0); + assert(rc == 0); + + rc = ble_hs_id_infer_auto(0, &s_own_addr_type); + if (rc != 0) { + ESP_LOGE(TAG, "infer addr type rc=%d", rc); + return; + } + + uint8_t addr[6] = {0}; + ble_hs_id_copy_addr(s_own_addr_type, addr, NULL); + ESP_LOGI(TAG, "addr=%02x:%02x:%02x:%02x:%02x:%02x", + addr[5], addr[4], addr[3], addr[2], addr[1], addr[0]); + + start_advertising(); +} + +static void nimble_host_task(void *param) +{ + ESP_LOGI(TAG, "BLE host task started"); + nimble_port_run(); + nimble_port_freertos_deinit(); +} + +/* ===== Public lifecycle ================================================ */ + +int ble_uart_install(const ble_uart_config_t *cfg) +{ + if (s_installed) { + ESP_LOGW(TAG, "ble_uart_install called twice; ignoring"); + return BLE_UART_EALREADY; + } + + if (cfg != NULL) { + s_cfg = *cfg; + } else { + memset(&s_cfg, 0, sizeof(s_cfg)); + } + + esp_err_t err = nimble_port_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "nimble_port_init rc=%d", err); + return BLE_UART_EFAIL; + } + /* From here every failure must `goto fail` so nimble_port_deinit() + * runs — leaving the port allocated breaks the next install(). */ + + ble_hs_cfg.reset_cb = on_reset; + ble_hs_cfg.sync_cb = on_sync; + ble_hs_cfg.store_status_cb = ble_store_util_status_rr; + ble_hs_cfg.gatts_register_cb = register_cb; + + /* Encrypted = LE Secure Connections + Bonding + MITM, DisplayOnly. + * Plaintext = SM disabled. */ + if (s_cfg.encrypted) { + ble_hs_cfg.sm_io_cap = BLE_HS_IO_DISPLAY_ONLY; + ble_hs_cfg.sm_sc = 1; + ble_hs_cfg.sm_bonding = 1; + ble_hs_cfg.sm_mitm = 1; + ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID; + ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID; + } else { + ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT; + ble_hs_cfg.sm_sc = 0; + ble_hs_cfg.sm_bonding = 0; + ble_hs_cfg.sm_mitm = 0; + } + + ble_svc_gap_init(); + ble_svc_gatt_init(); + + /* Cache the device name into our own buffer (caller's pointer may + * not outlive this call; also avoids the GAP-service stub path + * which returns NULL from ble_svc_gap_device_name()). */ + int rc = 0; + if (s_cfg.device_name != NULL) { + strncpy(s_dev_name, s_cfg.device_name, sizeof(s_dev_name) - 1); + s_dev_name[sizeof(s_dev_name) - 1] = '\0'; + s_cfg.device_name = NULL; + + /* Best-effort: also set in the GAP service for peer reads. + * Returns -1 on the stub path — fine, we already cached locally. */ + rc = ble_svc_gap_device_name_set(s_dev_name); + if (rc != 0) { + ESP_LOGI(TAG, "ble_svc_gap_device_name_set rc=%d (GAP service stubbed?)", + rc); + rc = 0; + } + } else { + s_dev_name[0] = '\0'; + } + + build_gatt_table(s_cfg.encrypted); + + rc = ble_gatts_count_cfg(s_svc_defs); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gatts_count_cfg rc=%d", rc); + goto fail; + } + rc = ble_gatts_add_svcs(s_svc_defs); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gatts_add_svcs rc=%d", rc); + goto fail; + } + + s_installed = true; + return BLE_UART_OK; + +fail: + nimble_port_deinit(); + memset(&s_cfg, 0, sizeof(s_cfg)); + return xlate_rc(rc); +} + +int ble_uart_open(void) +{ + if (!s_installed) { + ESP_LOGE(TAG, "ble_uart_open before ble_uart_install"); + return BLE_UART_EINVAL; + } + if (s_opened) { + ESP_LOGW(TAG, "ble_uart_open called twice; ignoring"); + return BLE_UART_EALREADY; + } + + /* NVS bond store (requires CONFIG_BT_NIMBLE_NVS_PERSIST=y). */ + ble_store_config_init(); + + /* Spawn host task; on_sync starts advertising once controller is ready. */ + nimble_port_freertos_init(nimble_host_task); + s_opened = true; + return BLE_UART_OK; +} + +int ble_uart_close(void) +{ + if (!s_opened) { + return BLE_UART_EALREADY; + } + + /* Latch first so GAP events stop re-arming advertising. */ + s_shutting_down = true; + + int rc = ble_gap_adv_stop(); + if (rc != 0 && rc != BLE_HS_EALREADY) { + ESP_LOGW(TAG, "adv_stop rc=%d", rc); + } + + /* Graceful disconnect: wait up to 500 ms for the disconnect event + * so the peer sees a proper LL_TERMINATE_IND, not a controller-yank. */ + if (s_conn_handle != BLE_HS_CONN_HANDLE_NONE) { + rc = ble_gap_terminate(s_conn_handle, BLE_ERR_REM_USER_CONN_TERM); + if (rc != 0 && rc != BLE_HS_EALREADY) { + ESP_LOGW(TAG, "ble_gap_terminate rc=%d", rc); + } + for (int i = 0; i < 50 && s_conn_handle != BLE_HS_CONN_HANDLE_NONE; i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (s_conn_handle != BLE_HS_CONN_HANDLE_NONE) { + ESP_LOGW(TAG, "disconnect timed out; tearing down anyway"); + } + } + + /* nimble_host_task self-cleans (port_freertos_deinit + delete) when + * port_run returns, so no explicit join. */ + rc = nimble_port_stop(); + if (rc != 0) { + ESP_LOGE(TAG, "nimble_port_stop rc=%d", rc); + s_shutting_down = false; + return BLE_UART_EFAIL; + } + + s_conn_handle = BLE_HS_CONN_HANDLE_NONE; + s_subscribed = false; + s_opened = false; + s_shutting_down = false; + return BLE_UART_OK; +} + +int ble_uart_uninstall(void) +{ + if (!s_installed) { + return BLE_UART_EALREADY; + } + + /* Best-effort cleanup. Do NOT early-return on a per-step failure: + * leaving s_installed=true with partially torn-down NimBLE state + * makes the module unrecoverable (can't re-install, can't retry + * uninstall cleanly). Mirror the Bluedroid backend: record the + * first error, keep tearing down, and always wipe our state. */ + int first_rc = BLE_UART_OK; + + if (s_opened) { + int rc = ble_uart_close(); + if (rc != BLE_UART_OK && rc != BLE_UART_EALREADY) { + ESP_LOGE(TAG, "ble_uart_close rc=%d", rc); + if (first_rc == BLE_UART_OK) { + first_rc = rc; + } + } + } + + /* Best-effort: even if port_deinit fails, wipe our state anyway — + * otherwise s_installed stays true and the module is unrecoverable + * (can't re-install, can't retry uninstall cleanly). */ + esp_err_t err = nimble_port_deinit(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "nimble_port_deinit rc=%d", err); + if (first_rc == BLE_UART_OK) { + first_rc = BLE_UART_EFAIL; + } + } + + memset(&s_cfg, 0, sizeof(s_cfg)); + s_dev_name[0] = '\0'; + s_tx_val_handle = 0; + s_conn_handle = BLE_HS_CONN_HANDLE_NONE; + s_subscribed = false; + s_own_addr_type = 0; + s_shutting_down = false; + s_installed = false; + s_opened = false; + return first_rc; +} + +#endif /* CONFIG_BT_NIMBLE_ENABLED */ diff --git a/examples/bluetooth/ble_uart_service/main/main.c b/examples/bluetooth/ble_uart_service/main/main.c new file mode 100644 index 00000000000..71cd8c506ab --- /dev/null +++ b/examples/bluetooth/ble_uart_service/main/main.c @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + * + * BLE UART Service example. Backend (NimBLE / Bluedroid) is picked + * by the host-stack Kconfig at compile time. Whatever the central + * writes to the RX characteristic is echoed back over TX. + */ + +#include + +#include "esp_log.h" +#include "esp_mac.h" +#include "nvs_flash.h" +#include "sdkconfig.h" + +#include "ble_uart.h" + +static const char *TAG = "app"; + +static void ble_uart_on_rx(const uint8_t *data, size_t len) +{ + ESP_LOGI(TAG, "rx len: %u bytes", (unsigned)len); + if (data == NULL || len == 0) { + return; + } + ESP_LOG_BUFFER_HEX(TAG, data, len); + ble_uart_tx(data, len); /* echo back */ +} + +void app_main(void) +{ + /* NVS is required by the BT controller (PHY calibration) and the + * bond store, so it must be live before ble_uart_install(). */ + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_ERROR_CHECK(err); + + /* Device name = "-XXXX" with XXXX = last two MAC bytes. + * If esp_read_mac() fails, mac stays zero and the suffix degrades + * to "0000" — log so the operator notices uniqueness was lost. */ + uint8_t mac[6] = {0}; + esp_err_t mac_err = esp_read_mac(mac, ESP_MAC_BT); + if (mac_err != ESP_OK) { + ESP_LOGW(TAG, "esp_read_mac(BT) failed (%s); device name suffix will be 0000", + esp_err_to_name(mac_err)); + } + char name[24]; + snprintf(name, sizeof(name), "%s-%02X%02X", + CONFIG_BLE_UART_DEVICE_NAME_PREFIX, mac[4], mac[5]); + + ESP_ERROR_CHECK(ble_uart_install(&(ble_uart_config_t){ + .encrypted = true, + .device_name = name, + .ble_uart_on_rx = ble_uart_on_rx, + })); + + ESP_ERROR_CHECK(ble_uart_open()); +} diff --git a/examples/bluetooth/ble_uart_service/sdkconfig.ci.bluedroid b/examples/bluetooth/ble_uart_service/sdkconfig.ci.bluedroid new file mode 100644 index 00000000000..f0604fab87d --- /dev/null +++ b/examples/bluetooth/ble_uart_service/sdkconfig.ci.bluedroid @@ -0,0 +1,42 @@ +# Overlay applied on top of sdkconfig.defaults to switch the example +# from the default NimBLE backend to Bluedroid. Use it like: +# +# idf.py -B build_bd \ +# -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.ci.bluedroid" \ +# reconfigure +# idf.py -B build_bd build flash monitor +# +# When this overlay wins, main/CMakeLists.txt links ble_uart_bluedroid.c +# instead of ble_uart_nimble.c. The public ble_uart.h API is identical +# either way. + +CONFIG_BT_ENABLED=y + +CONFIG_BT_NIMBLE_ENABLED=n +CONFIG_BT_BLUEDROID_ENABLED=y + +# LE Secure Connections + bonding (matches the NimBLE side). +# CONFIG_BT_SMP_ENABLE is derived from this and BT_CLASSIC_ENABLED, so +# we don't set it explicitly. +CONFIG_BT_BLE_SMP_ENABLE=y + +# Note: Bluedroid has no compile-time MTU Kconfig (the NimBLE +# CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU has no Bluedroid counterpart). +# To negotiate a larger ATT MTU at runtime, the application calls +# esp_ble_gatt_set_local_mtu() before peers connect. +# ble_uart_tx auto-fragments to whatever MTU is live, so the default +# 23 also works — just at lower throughput. + +# Service-table API is needed for esp_ble_gatts_create_attr_tab(). +CONFIG_BT_GATTS_ENABLE=y + +# CONFIG_BT_GATTC_ENABLE is not set + +# CONFIG_BT_BLE_50_FEATURES_SUPPORTED is not set +CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y + +# CONFIG_BT_BLE_42_DTM_TEST_EN is not set + +CONFIG_BT_BLE_42_ADV_EN=y + +# CONFIG_BT_BLE_42_SCAN_EN is not set diff --git a/examples/bluetooth/ble_uart_service/sdkconfig.defaults b/examples/bluetooth/ble_uart_service/sdkconfig.defaults new file mode 100644 index 00000000000..7fa7799d2bd --- /dev/null +++ b/examples/bluetooth/ble_uart_service/sdkconfig.defaults @@ -0,0 +1,22 @@ +# Bluetooth controller in BLE-only mode + NimBLE host stack. +# CONFIG_BTDM_CTRL_MODE_* are ESP32-classic-only knobs; on C2/C3/C5/C6/ +# C61/H2/H4/S3 they may emit an "unknown symbol" warning at configure +# time but are otherwise harmless, so this single sdkconfig.defaults +# stays valid for every supported target. +CONFIG_BT_ENABLED=y +CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y +CONFIG_BTDM_CTRL_MODE_BR_EDR_ONLY=n +CONFIG_BTDM_CTRL_MODE_BTDM=n +CONFIG_BT_BLUEDROID_ENABLED=n +CONFIG_BT_NIMBLE_ENABLED=y + +# Negotiate the largest ATT MTU we can; ble_uart_tx auto-fragments to +# the live MTU so smaller-MTU centrals still work. +CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=512 + +# LE Secure Connections + persistent bond store. The example defaults to +# encrypted operation (ble_uart_config_t::encrypted=true) and stores LTK +# in NVS so a previously-paired peer reconnects without re-prompting for +# the passkey. +CONFIG_BT_NIMBLE_SM_SC=y +CONFIG_BT_NIMBLE_NVS_PERSIST=y From 539ee0503c2cd1182361a06a1ffac9d884cbaaeb Mon Sep 17 00:00:00 2001 From: Zhou Xiao Date: Mon, 27 Apr 2026 15:56:46 +0800 Subject: [PATCH 2/7] feat(ble): add BLE UART bridge console --- .../ble_uart_bridge/src/console/__init__.py | 7 + tools/ble/ble_uart_bridge/src/console/api.py | 30 ++ .../ble_uart_bridge/src/console/console.py | 263 ++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 tools/ble/ble_uart_bridge/src/console/__init__.py create mode 100644 tools/ble/ble_uart_bridge/src/console/api.py create mode 100644 tools/ble/ble_uart_bridge/src/console/console.py diff --git a/tools/ble/ble_uart_bridge/src/console/__init__.py b/tools/ble/ble_uart_bridge/src/console/__init__.py new file mode 100644 index 00000000000..a52fb66e712 --- /dev/null +++ b/tools/ble/ble_uart_bridge/src/console/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +from .api import run_console +from .console import ConsoleEncoding +from .console import ConsoleTerminator + +__all__ = ['run_console', 'ConsoleEncoding', 'ConsoleTerminator'] diff --git a/tools/ble/ble_uart_bridge/src/console/api.py b/tools/ble/ble_uart_bridge/src/console/api.py new file mode 100644 index 00000000000..e8e01ebb058 --- /dev/null +++ b/tools/ble/ble_uart_bridge/src/console/api.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio + +from .console import BLEUARTConsole +from .console import ConsoleEncoding +from .console import ConsoleTerminator + + +def run_console( + device_id: str, + terminator: ConsoleTerminator | str = ConsoleTerminator.lf, + encoding: ConsoleEncoding | str = ConsoleEncoding.text, + with_response: bool = False, +) -> None: + # Initialize BLE UART Console + console = BLEUARTConsole( + device_id, + terminator=terminator, + encoding=encoding, + with_response=with_response, + ) + + try: + asyncio.run(console.run_console()) + except KeyboardInterrupt: + pass diff --git a/tools/ble/ble_uart_bridge/src/console/console.py b/tools/ble/ble_uart_bridge/src/console/console.py new file mode 100644 index 00000000000..ffa8079689c --- /dev/null +++ b/tools/ble/ble_uart_bridge/src/console/console.py @@ -0,0 +1,263 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import codecs +from enum import Enum + +from loguru import logger +from rich.highlighter import Highlighter +from rich.text import Text +from textual import on +from textual.app import App +from textual.app import ComposeResult +from textual.binding import Binding +from textual.binding import BindingType +from textual.message import Message +from textual.widgets import Footer +from textual.widgets import Header +from textual.widgets import Input +from textual.widgets import Log + +from ..core import BLEUARTBridge + +CONSOLE_TEXT_ENCODING = 'utf-8' + + +class ConsoleTerminator(str, Enum): + lf = 'lf' + crlf = 'crlf' + none = 'none' + + +class ConsoleEncoding(str, Enum): + text = 'text' + hex = 'hex' + + +TERMINATORS: dict[ConsoleTerminator, bytes] = { + ConsoleTerminator.lf: b'\n', + ConsoleTerminator.crlf: b'\r\n', + ConsoleTerminator.none: b'', +} + +CONSOLE_CSS = """ +Screen { + layout: vertical; +} + +#log { + height: 1fr; +} +""" + +CONSOLE_BINDINGS: list[BindingType] = [ + Binding('ctrl+c', 'quit', 'Quit', priority=True), + Binding('ctrl+d', 'quit', show=False), + Binding('ctrl+l', 'clear_log', 'Clear'), +] + + +class BLEUARTLogHighlighter(Highlighter): + def highlight(self, text: Text) -> None: + if text.plain.startswith('[RX] '): + text.stylize('green', 0, 5) + elif text.plain.startswith('[TX] '): + text.stylize('yellow', 0, 5) + elif text.plain.startswith('[INFO] '): + text.stylize('cyan', 0, 7) + + +class BLEUARTLog(Log): + can_focus = False + FOCUS_ON_CLICK = False + + def __init__(self) -> None: + super().__init__(id='log', highlight=True, auto_scroll=True) + self.highlighter = BLEUARTLogHighlighter() + + +class BLEUARTDataReceived(Message): + def __init__(self, data: bytes) -> None: + super().__init__() + self.data = data + + +class BLEUARTConsole(App): + CSS = CONSOLE_CSS + BINDINGS = CONSOLE_BINDINGS + ENABLE_COMMAND_PALETTE = False + + def __init__( + self, + device_id: str, + terminator: ConsoleTerminator | str = ConsoleTerminator.lf, + encoding: ConsoleEncoding | str = ConsoleEncoding.text, + with_response: bool = False, + ) -> None: + super().__init__() + self._device_id = device_id + self._bridge = BLEUARTBridge(device_id) + self._terminator = self._parse_terminator(terminator) + self._encoding = self._parse_encoding(encoding) + self._with_response = with_response + self._rx_decoder = codecs.getincrementaldecoder(CONSOLE_TEXT_ENCODING)(errors='replace') + self._rx_line_open = False + self._rx_pending: list[str] = [] + self._ui_ready = False + + @staticmethod + def _parse_terminator(terminator: ConsoleTerminator | str) -> bytes: + try: + return TERMINATORS[ConsoleTerminator(terminator)] + except ValueError: + choices = ', '.join(item.value for item in ConsoleTerminator) + raise ValueError(f'Unsupported terminator: {terminator}. Expected one of: {choices}') from None + + @staticmethod + def _parse_encoding(encoding: ConsoleEncoding | str) -> ConsoleEncoding: + try: + return ConsoleEncoding(encoding) + except ValueError: + choices = ', '.join(item.value for item in ConsoleEncoding) + raise ValueError(f'Unsupported encoding: {encoding}. Expected one of: {choices}') + + async def run_console(self) -> None: + # Connect before starting Textual so failed connections do not flash an empty UI. + self._bridge.add_rx_handler(self._on_ble_notif) + try: + # Should try connection to catch KeyInterrupt during connection establishment + if not await self._bridge.connect(): + logger.error(f'Failed to open BLE UART Console for {self._device_id}') + return + + # Run UI event loop + await self.run_async() + finally: + # Disconnect from device + logger.info(f'Closing BLE UART Console for {self._device_id}...') + await self._bridge.disconnect() + + # Textual lifecycle hook: build the widget tree before the app is mounted. + def compose(self) -> ComposeResult: + yield Header() + yield BLEUARTLog() + yield Input(placeholder='Send BLE UART data...', id='input') + yield Footer() + + # Device notification callback + def _on_ble_notif(self, data: bytearray) -> None: + self.post_message(BLEUARTDataReceived(bytes(data))) + + @on(BLEUARTDataReceived) + def on_ble_uart_data_received(self, message: BLEUARTDataReceived) -> None: + if self._encoding == 'hex': + self._write_rx(f'{message.data.hex(" ")}\n') + return + + text = self._rx_decoder.decode(message.data) + if not text: + return + + self._write_rx(text) + + # Textual lifecycle hook: widgets are ready, so BLE can be connected and UI updated. + async def on_mount(self) -> None: + self._ui_ready = True + self.title = f'BLE UART — {self._device_id}' + self.query_one('#input', Input).focus() + self._write_info(f'Connected to {self._device_id}') + self._drain_rx_pending() + self.run_worker(self._monitor_connection) + + async def on_unmount(self) -> None: + self._ui_ready = False + + # Textual event handler: called when the Input widget is submitted with Enter. + @on(Input.Submitted) + async def on_input_submitted(self, event: Input.Submitted) -> None: + line = event.value + event.input.clear() + + if not line: + return + + try: + if not self._bridge.is_connected: + self._write_tx(f'failed, not connected: {line}') + return + + payload = self._encode_tx(line) + if await self._bridge.send(payload, with_response=self._with_response): + self._write_tx(line) + else: + self._write_tx(f'failed: {line}') + except ValueError as e: + self._write_tx(f'failed: {e}') + except Exception as e: + logger.exception(e) + self._write_tx(f'failed: {line}') + + def _encode_tx(self, line: str) -> bytes: + if self._encoding == 'hex': + try: + return bytes.fromhex(line) + except ValueError as e: + raise ValueError('invalid hex input') from e + + try: + payload = line.encode(CONSOLE_TEXT_ENCODING) + except UnicodeEncodeError as e: + raise ValueError(f'failed to encode input: {e}') from e + return payload + self._terminator + + async def _monitor_connection(self) -> None: + while self._bridge.is_connected: + await asyncio.sleep(0.5) + + # DOM may be uninstalled already + if self._ui_ready: + self._write_info('Disconnected. Press Ctrl+C to quit.') + + def _write_rx(self, text: str) -> None: + if not self._ui_ready: + self._rx_pending.append(text) + return + + log = self.query_one('#log', Log) + + for chunk in text.splitlines(keepends=True): + if not self._rx_line_open: + log.write('[RX] ') + self._rx_line_open = True + + log.write(chunk) + if chunk.endswith(('\r', '\n')): + self._rx_line_open = False + + def _drain_rx_pending(self) -> None: + for text in self._rx_pending: + self._write_rx(text) + self._rx_pending.clear() + + def _write_tx(self, text: str) -> None: + self._write_log('[TX] ', text) + + def _write_info(self, text: str) -> None: + self._write_log('[INFO] ', text) + + def _write_log(self, prefix: str, text: str) -> None: + log = self.query_one('#log', Log) + if self._rx_line_open: + log.write('\n') + self._rx_line_open = False + + log.write(f'{prefix}{text}\n') + + # Textual action: invoked by the ``clear_log`` key binding. + def action_clear_log(self) -> None: + self.query_one('#log', Log).clear() + self._rx_line_open = False + self._rx_pending.clear() From f1733f544d6d81b0eca35d56ae5719f90255f4e1 Mon Sep 17 00:00:00 2001 From: Zhou Xiao Date: Mon, 27 Apr 2026 15:56:55 +0800 Subject: [PATCH 3/7] feat(ble): add BLE UART daemon RPC API --- .../ble_uart_bridge/src/daemon/__init__.py | 7 ++ tools/ble/ble_uart_bridge/src/daemon/api.py | 92 ++++++++++++++ tools/ble/ble_uart_bridge/src/daemon/jsonl.py | 103 ++++++++++++++++ .../ble/ble_uart_bridge/src/daemon/models.py | 16 +++ .../ble/ble_uart_bridge/src/daemon/server.py | 112 ++++++++++++++++++ 5 files changed, 330 insertions(+) create mode 100644 tools/ble/ble_uart_bridge/src/daemon/__init__.py create mode 100644 tools/ble/ble_uart_bridge/src/daemon/api.py create mode 100644 tools/ble/ble_uart_bridge/src/daemon/jsonl.py create mode 100644 tools/ble/ble_uart_bridge/src/daemon/models.py create mode 100644 tools/ble/ble_uart_bridge/src/daemon/server.py diff --git a/tools/ble/ble_uart_bridge/src/daemon/__init__.py b/tools/ble/ble_uart_bridge/src/daemon/__init__.py new file mode 100644 index 00000000000..1aefe89058c --- /dev/null +++ b/tools/ble/ble_uart_bridge/src/daemon/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +from .api import run_daemon +from .api import run_daemon_send +from .api import run_daemon_status + +__all__ = ['run_daemon', 'run_daemon_status', 'run_daemon_send'] diff --git a/tools/ble/ble_uart_bridge/src/daemon/api.py b/tools/ble/ble_uart_bridge/src/daemon/api.py new file mode 100644 index 00000000000..212f76a60d8 --- /dev/null +++ b/tools/ble/ble_uart_bridge/src/daemon/api.py @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +import json +from typing import Any +from urllib.error import HTTPError +from urllib.error import URLError +from urllib.request import Request +from urllib.request import urlopen + +import uvicorn + +from .server import app as daemon_app + + +def _daemon_url(host: str, port: int, path: str) -> str: + return f'http://{host}:{port}{path}' + + +def _request_json( + method: str, + url: str, + payload: dict[str, Any] | None = None, + timeout: float = 10.0, +) -> dict[str, Any]: + data = json.dumps(payload).encode() if payload is not None else None + headers = {'Content-Type': 'application/json'} if payload is not None else {} + request = Request(url, data=data, headers=headers, method=method) + + try: + with urlopen(request, timeout=timeout) as response: + body = response.read().decode() + except HTTPError as e: + detail = e.read().decode(errors='replace') + raise RuntimeError(f'Daemon request failed with HTTP {e.code}: {detail}') from e + except TimeoutError as e: + raise RuntimeError(f'Timed out waiting for BLE UART Daemon: {url}') from e + except URLError as e: + raise RuntimeError(f'Failed to connect to BLE UART Daemon: {e.reason}') from e + + if not body: + return {} + + try: + result = json.loads(body) + except json.JSONDecodeError as e: + raise RuntimeError(f'Invalid daemon response: {body!r}') from e + if not isinstance(result, dict): + raise RuntimeError(f'Invalid daemon response: {result!r}') + return result + + +def run_daemon(device_id: str, host: str, port: int) -> None: + daemon_app.state.device_id = device_id + uvicorn.run(daemon_app, host=host, port=port) + + +def run_daemon_status(host: str = '127.0.0.1', port: int = 8888) -> None: + try: + status = _request_json('GET', _daemon_url(host, port, '/status')) + except RuntimeError as e: + print(e) + raise SystemExit(1) from e + print(json.dumps(status, indent=2)) + + +def run_daemon_send( + data: str, + op: str = 'raw', + json_payload: bool = False, + timeout: float = 10.0, + host: str = '127.0.0.1', + port: int = 8888, +) -> None: + try: + payload_data: Any = json.loads(data) if json_payload else data + except json.JSONDecodeError as e: + print(f'Invalid JSON payload: {e}') + raise SystemExit(1) from e + + try: + response = _request_json( + 'POST', + _daemon_url(host, port, '/request'), + payload={'op': op, 'data': payload_data, 'timeout': timeout}, + timeout=timeout + 1.0, + ) + except RuntimeError as e: + print(e) + raise SystemExit(1) from e + result = response.get('data', response.get('response', '')) + print(result if isinstance(result, str) else json.dumps(result)) diff --git a/tools/ble/ble_uart_bridge/src/daemon/jsonl.py b/tools/ble/ble_uart_bridge/src/daemon/jsonl.py new file mode 100644 index 00000000000..5e0702426dc --- /dev/null +++ b/tools/ble/ble_uart_bridge/src/daemon/jsonl.py @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +import asyncio +import json +from json import JSONDecodeError +from typing import Any + +from loguru import logger + +from ..core.constants import DEFAULT_READ_BUFFER_LIMIT + +PROTOCOL_VERSION = 1 + + +def encode_jsonl_request(request_id: str, op: str, data: Any) -> str: + return json.dumps({'v': PROTOCOL_VERSION, 'id': request_id, 'op': op, 'data': data}) + '\n' + + +def drain_jsonl_messages( + buffer: bytearray, data: bytes, buffer_limit: int = DEFAULT_READ_BUFFER_LIMIT +) -> list[dict[str, Any]]: + buffer.extend(data) + messages: list[dict[str, Any]] = [] + + while True: + try: + newline_index = buffer.index(b'\n') + except ValueError: + break + + line = bytes(buffer[:newline_index]) + del buffer[: newline_index + 1] + if not line: + continue + + try: + message = json.loads(line.decode()) + except (JSONDecodeError, UnicodeDecodeError): + logger.warning(f'Invalid JSONL message from device, dropping data: {line!r}') + continue + + if not isinstance(message, dict): + logger.warning(f'JSONL message is not an object, dropping data: {message!r}') + continue + + messages.append(message) + + if len(buffer) > buffer_limit: + logger.warning(f'JSONL receive buffer exceeded {buffer_limit} bytes, dropping buffered data') + buffer.clear() + + return messages + + +def resolve_pending_response(pending_requests: dict[str, asyncio.Future[Any]], message: dict[str, Any]) -> bool: + request_id = message.get('id') + if not isinstance(request_id, str): + return False + + future = pending_requests.get(request_id) + if future is None: + return False + + pending_requests.pop(request_id) + if future.done(): + return True + + version = message.get('v') + if version is not None and version != PROTOCOL_VERSION: + future.set_exception(RuntimeError(f'Invalid protocol response: unsupported version {version!r}')) + return True + + if 'ok' in message: + ok = message['ok'] + if not isinstance(ok, bool): + future.set_exception(RuntimeError('Invalid protocol response: ok must be boolean')) + return True + + if ok: + if 'data' not in message: + future.set_exception(RuntimeError('Invalid protocol response: missing data for successful response')) + return True + future.set_result(message['data']) + return True + + error = message.get('error') + if not isinstance(error, str) or not error: + future.set_exception(RuntimeError('Invalid protocol response: error must be non-empty string')) + return True + future.set_exception(RuntimeError(error)) + return True + + if 'error' in message: + future.set_exception(RuntimeError(str(message['error']))) + return True + + if 'response' in message: + future.set_result(message['response']) + return True + + future.set_exception(RuntimeError('Invalid protocol response: missing ok/data, response, or error')) + return True diff --git a/tools/ble/ble_uart_bridge/src/daemon/models.py b/tools/ble/ble_uart_bridge/src/daemon/models.py new file mode 100644 index 00000000000..1eb59ea3aca --- /dev/null +++ b/tools/ble/ble_uart_bridge/src/daemon/models.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any + +from pydantic import BaseModel +from pydantic import Field + +MAX_OP_LENGTH = 64 +MAX_REQUEST_DATA_BYTES = 4096 + + +class BLEUARTRequestPayload(BaseModel): + op: str = Field('raw', min_length=1, max_length=MAX_OP_LENGTH, description='Operation name to send to BLE device') + data: Any = Field(..., description='Request payload to send to BLE device') + timeout: float = Field(10.0, gt=0, description='Response timeout in seconds') diff --git a/tools/ble/ble_uart_bridge/src/daemon/server.py b/tools/ble/ble_uart_bridge/src/daemon/server.py new file mode 100644 index 00000000000..2e5afbc2f02 --- /dev/null +++ b/tools/ble/ble_uart_bridge/src/daemon/server.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +import asyncio +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi import HTTPException +from loguru import logger + +from ..core import BLEUARTBridge +from .jsonl import PROTOCOL_VERSION +from .jsonl import drain_jsonl_messages +from .jsonl import encode_jsonl_request +from .jsonl import resolve_pending_response +from .models import MAX_REQUEST_DATA_BYTES +from .models import BLEUARTRequestPayload + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + # Server initialization + app.state.bridge = BLEUARTBridge(app.state.device_id) + app.state.request_lock = asyncio.Lock() + + # Set BLE UART Bridge RX callback + loop = asyncio.get_running_loop() + app.state.rx_buffer = bytearray() + app.state.pending_requests = {} + + def _handle_rx_data(data: bytes) -> None: + for message in drain_jsonl_messages(app.state.rx_buffer, data): + if resolve_pending_response(app.state.pending_requests, message): + continue + logger.debug(f'Received unsolicited BLE UART message: {message!r}') + + def _rx_handler(data: bytearray) -> None: + try: + loop.call_soon_threadsafe(_handle_rx_data, bytes(data)) + except RuntimeError: + logger.warning(f'Event loop is unavailable, dropping data: {data.decode(errors="replace")}') + + app.state.bridge.add_rx_handler(_rx_handler) + + # Try to connect to the device + if not await app.state.bridge.connect(): + logger.error('Failed to start BLE UART Daemon!') + raise RuntimeError('Failed to start BLE UART Daemon!') + + yield + + # Disconnect from the device + await app.state.bridge.disconnect() + + +app = FastAPI(title='BLE UART Daemon', lifespan=lifespan) + + +def _request_data_size(data: object) -> int: + return len(json.dumps(data).encode()) + + +@app.get('/status') +async def status() -> dict: + bridge: BLEUARTBridge | None = getattr(app.state, 'bridge', None) + pending_requests: dict | None = getattr(app.state, 'pending_requests', None) + + return { + 'device_id': getattr(app.state, 'device_id', None), + 'connection_state': bridge.connection_state.value if bridge else 'DISCONNECTED', + 'is_connected': bridge.is_connected if bridge else False, + 'pending_requests': len(pending_requests or {}), + 'single_flight': True, + 'max_request_data_bytes': MAX_REQUEST_DATA_BYTES, + 'protocol': f'esp-jsonl-rpc-lite-v{PROTOCOL_VERSION}', + } + + +@app.post('/request') +async def request(payload: BLEUARTRequestPayload) -> dict: + if _request_data_size(payload.data) > MAX_REQUEST_DATA_BYTES: + raise HTTPException(status_code=413, detail=f'Request data exceeds {MAX_REQUEST_DATA_BYTES} bytes') + + # Request with coroutine lock + async with app.state.request_lock: + request_id = uuid4().hex + response_future = asyncio.get_running_loop().create_future() + app.state.pending_requests[request_id] = response_future + + try: + # Send command to BLE device + success = await app.state.bridge.send( + encode_jsonl_request(request_id, payload.op, payload.data), + with_response=True, + ) + if not success: + raise HTTPException(status_code=500, detail='Failed to send data to device') + + # Wait for BLE device to respond + try: + response = await asyncio.wait_for(response_future, timeout=payload.timeout) + except asyncio.TimeoutError: + raise HTTPException(status_code=504, detail='Response timeout') + except RuntimeError as e: + raise HTTPException(status_code=502, detail=str(e)) + finally: + app.state.pending_requests.pop(request_id, None) + + return {'ok': True, 'data': response} From 3961ad0e292d3ec27ece78a201f60bda3902056a Mon Sep 17 00:00:00 2001 From: Zhou Xiao Date: Mon, 27 Apr 2026 15:57:02 +0800 Subject: [PATCH 4/7] feat(ble): wire BLE UART bridge CLI --- tools/ble/ble_uart_bridge/main.py | 68 +++++++++++++++++++++++ tools/ble/ble_uart_bridge/src/__init__.py | 17 ++++++ 2 files changed, 85 insertions(+) create mode 100644 tools/ble/ble_uart_bridge/main.py create mode 100644 tools/ble/ble_uart_bridge/src/__init__.py diff --git a/tools/ble/ble_uart_bridge/main.py b/tools/ble/ble_uart_bridge/main.py new file mode 100644 index 00000000000..4158ca1c02b --- /dev/null +++ b/tools/ble/ble_uart_bridge/main.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +from src import run_connection_check +from src import run_console +from src import run_daemon +from src import run_daemon_send +from src import run_daemon_status +from src import run_list_devices +from src.console import ConsoleEncoding +from src.console import ConsoleTerminator +from typer import Option +from typer import Typer + +# CLI app singleton +app = Typer() + + +# Core service +@app.command() +def list_devices() -> None: + run_list_devices() + + +@app.command() +def connection_check(device_id: str) -> None: + run_connection_check(device_id) + + +# Console +@app.command() +def console( + device_id: str, + terminator: ConsoleTerminator = Option(ConsoleTerminator.lf, help='Line terminator for text mode'), + encoding: ConsoleEncoding = Option(ConsoleEncoding.text, help='Console encoding'), + with_response: bool = Option(False, help='Use BLE write-with-response for TX data'), +) -> None: + run_console(device_id, terminator=terminator, encoding=encoding, with_response=with_response) + + +# Daemon +@app.command() +def daemon(device_id: str, host: str = '127.0.0.1', port: int = 8888) -> None: + run_daemon(device_id, host, port) + + +@app.command() +def daemon_status(host: str = '127.0.0.1', port: int = 8888) -> None: + run_daemon_status(host=host, port=port) + + +@app.command() +def daemon_send( + data: str, + op: str = Option('raw', help='Operation name in the JSONL request envelope'), + json_payload: bool = Option(False, '--json', help='Parse DATA as JSON instead of sending it as a string'), + timeout: float = 10.0, + host: str = '127.0.0.1', + port: int = 8888, +) -> None: + run_daemon_send(data=data, op=op, json_payload=json_payload, timeout=timeout, host=host, port=port) + + +def main() -> None: + app() + + +if __name__ == '__main__': + main() diff --git a/tools/ble/ble_uart_bridge/src/__init__.py b/tools/ble/ble_uart_bridge/src/__init__.py new file mode 100644 index 00000000000..770559e46fc --- /dev/null +++ b/tools/ble/ble_uart_bridge/src/__init__.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +from .console import run_console +from .core import run_connection_check +from .core import run_list_devices +from .daemon import run_daemon +from .daemon import run_daemon_send +from .daemon import run_daemon_status + +__all__ = [ + 'run_connection_check', + 'run_list_devices', + 'run_daemon', + 'run_daemon_send', + 'run_daemon_status', + 'run_console', +] From 044339c29c10aa2185e41aa6400a7c62d10c34fd Mon Sep 17 00:00:00 2001 From: Zhou Xiao Date: Mon, 27 Apr 2026 15:57:10 +0800 Subject: [PATCH 5/7] docs(ble): document BLE UART bridge usage --- tools/ble/ble_uart_bridge/README.md | 270 ++++++++++++++ tools/ble/ble_uart_bridge/docs/PORTING.md | 286 +++++++++++++++ .../docs/Profile-Compatibility.md | 86 +++++ .../docs/Quick-Start-BLE-UART-Console.md | 202 +++++++++++ .../docs/Quick-Start-BLE-UART-Daemon.md | 340 ++++++++++++++++++ 5 files changed, 1184 insertions(+) create mode 100644 tools/ble/ble_uart_bridge/README.md create mode 100644 tools/ble/ble_uart_bridge/docs/PORTING.md create mode 100644 tools/ble/ble_uart_bridge/docs/Profile-Compatibility.md create mode 100644 tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Console.md create mode 100644 tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md diff --git a/tools/ble/ble_uart_bridge/README.md b/tools/ble/ble_uart_bridge/README.md new file mode 100644 index 00000000000..6a4024204ba --- /dev/null +++ b/tools/ble/ble_uart_bridge/README.md @@ -0,0 +1,270 @@ + + + +# BLE UART Bridge + +BLE UART Bridge is a host-side utility for talking to ESP-IDF applications that expose a BLE UART-style GATT service. It provides a reusable Python transport layer, an interactive console for manual testing, and a daemon mode for simple local IPC request/response workflows. + +## Table of contents + +- [Quick Start](#quick-start) - install dependencies and run the first commands +- [CLI overview](#cli-overview) - command list and common Console/Daemon workflows + - [Typical Console workflow](#typical-console-workflow) + - [Typical Daemon workflow](#typical-daemon-workflow) + - [Custom scripts and porting](#custom-scripts-and-porting) +- [What is included](#what-is-included) - directory layout and component roles + - [Core](#core) + - [Console](#console) + - [Daemon](#daemon) +- [Choosing Core, Console, or Daemon](#choosing-core-console-or-daemon) +- [Profile compatibility](#profile-compatibility) +- [Dependencies](#dependencies) +- [Limitations](#limitations) +- [Further reading](#further-reading) + +## Quick Start + +You can reuse the ESP-IDF Python environment, or use your own Python virtual environment. If you reuse the ESP-IDF environment, export it first and then install the additional BLE UART Bridge dependencies: + +```bash +cd $IDF_PATH +. ./export.sh +cd tools/ble/ble_uart_bridge +python -m pip install -r requirements.txt +``` + +On Windows, run `export.bat` or `export.ps1` from the ESP-IDF root directory before installing `requirements.txt`. If you use your own Python virtual environment instead, activate it before running: + +```bash +cd tools/ble/ble_uart_bridge +python -m pip install -r requirements.txt +``` + +List nearby BLE UART devices: + +```bash +python main.py list-devices +``` + +Use the printed device identifier as `DEVICE_ID` in later commands. On macOS, this identifier is a CoreBluetooth UUID and is different from the device MAC address. + +Check whether the device can be connected: + +```bash +python main.py connection-check DEVICE_ID +``` + +Open an interactive BLE UART Console: + +```bash +python main.py console DEVICE_ID +``` + +For Console options such as line endings, hex mode, and write-with-response, see [Quick-Start-BLE-UART-Console.md](docs/Quick-Start-BLE-UART-Console.md). If you need firmware to test against, use the [BLE UART Service example](../../../examples/bluetooth/ble_uart_service) as an Echo Server: it advertises the default Nordic UART Service profile and echoes RX writes back through TX notifications. + +Run the BLE UART Daemon: + +```bash +python main.py daemon DEVICE_ID +``` + +In another terminal, check daemon status and send a request: + +```bash +python main.py daemon-status +python main.py daemon-send --op echo "hello" +``` + +For Daemon details, the HTTP API, and the JSONL RPC protocol, see [Quick-Start-BLE-UART-Daemon.md](docs/Quick-Start-BLE-UART-Daemon.md). + +## CLI overview + +Run: + +```bash +python main.py --help +``` + +Available commands: + +```bash +python main.py list-devices +python main.py connection-check DEVICE_ID +python main.py console DEVICE_ID +python main.py daemon DEVICE_ID +python main.py daemon-status +python main.py daemon-send DATA +``` + +### Typical Console workflow + +Use Console when you want to manually test a BLE UART device from a terminal UI. For a known-compatible target, build and flash the [BLE UART Service example](../../../examples/bluetooth/ble_uart_service), which acts as an Echo Server for Console smoke tests: + +```bash +python main.py list-devices +python main.py connection-check DEVICE_ID +python main.py console DEVICE_ID +``` + +Common Console variants: + +```bash +# Use CRLF for AT-style commands +python main.py console DEVICE_ID --terminator crlf + +# Send and display raw bytes in hex +python main.py console DEVICE_ID --encoding hex + +# Use BLE write-with-response +python main.py console DEVICE_ID --with-response +``` + +For the full Console guide, see [Quick-Start-BLE-UART-Console.md](docs/Quick-Start-BLE-UART-Console.md). + +### Typical Daemon workflow + +Use Daemon when a local script, editor integration, or automation tool needs request/response access to a BLE UART device. + +Terminal 1 starts the daemon and owns the BLE connection: + +```bash +python main.py daemon DEVICE_ID +``` + +Terminal 2 checks status and sends requests through the daemon: + +```bash +python main.py daemon-status +python main.py daemon-send --op echo "hello" +python main.py daemon-send --op set_led --json '{"state": true}' +``` + +For the HTTP API and JSONL RPC wire protocol, see [Quick-Start-BLE-UART-Daemon.md](docs/Quick-Start-BLE-UART-Daemon.md). + +### Custom scripts and porting + +Use the Core API directly when you want your own Python script to own the BLE connection, implement custom framing, or integrate BLE UART into a larger automation flow. + +For examples using `BLEUARTBridge`, RX handlers, byte payloads, custom `BLEUARTProfile`, and custom request/response logic, see [PORTING.md](docs/PORTING.md). + +## What is included + +```text +tools/ble/ble_uart_bridge/ +├── main.py +├── requirements.txt +├── README.md +├── docs/ +│ ├── Quick-Start-BLE-UART-Console.md +│ ├── Quick-Start-BLE-UART-Daemon.md +│ ├── Profile-Compatibility.md +│ └── PORTING.md +└── src/ + ├── core/ + ├── console/ + └── daemon/ +``` + +### Core + +The Core component is the reusable BLE transport layer. + +Use it when you want to write your own Python script or tool on top of BLE UART without reimplementing scanning, connection management, notification subscription, and chunked GATT writes. + +Main responsibilities: + +- Scan for BLE UART devices. +- Check whether a target device can be connected. +- Connect and disconnect with a BLE UART GATT profile. +- Subscribe to device-to-host notifications. +- Send host-to-device data as `str`, `bytes`, or `bytearray`. +- Support a default NUS profile and user-defined BLE UART profiles. + +Important APIs: + +- `BLEUARTBridge` +- `BLEUARTProfile` +- `run_list_devices()` +- `run_connection_check()` + +Additional models are available from `src.core.models`, including `DeviceInfo` and `ConnectionState`. + +### Console + +The Console component is an interactive terminal UI for quick BLE UART testing. + +Use it when you want to manually type data into a BLE UART device and observe received data without writing code. + +Main responsibilities: + +- Open an interactive Textual-based UI. +- Display TX, RX, and INFO logs separately. +- Send text lines with configurable line terminators. +- Send and display raw bytes in hex mode. +- Optionally use BLE write-with-response. +- Detect disconnects and show a notice in the UI. + +### Daemon + +The Daemon component exposes BLE UART as a local HTTP service. + +Use it when another local tool, script, editor integration, or automation process needs request/response IPC with a BLE UART device. + +Main responsibilities: + +- Keep one BLE UART connection open in a background server process. +- Expose local HTTP endpoints for status and request/response calls. +- Encode requests as newline-delimited JSON messages over BLE UART. +- Correlate device responses by request ID. +- Provide a small JSONL RPC-style envelope as an example protocol. + +The daemon protocol is intentionally small. It is not a full RPC framework. It demonstrates a portable pattern that users can copy into firmware or extend in their own application protocol. + +By default, the daemon binds to `127.0.0.1`. Keep it on a loopback address unless you add your own network access control, because the daemon exposes unauthenticated HTTP endpoints that can send data to the BLE device. + +## Choosing Core, Console, or Daemon + +| Component | Best for | Interface | +| --- | --- | --- | +| Core | Custom Python tools and scripts | Python API | +| Console | Manual BLE UART smoke tests | Interactive TUI | +| Daemon | Local IPC and automation | HTTP + CLI client | + +Use Core when your business logic lives in Python. Use Console when you only need to manually test a BLE UART endpoint. Use Daemon when multiple local processes need to share one BLE connection through a simple request/response boundary. + +## Profile compatibility + +The default profile is compatible with the Nordic UART Service (NUS): + +- Service UUID: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` +- RX characteristic UUID, host to device: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` +- TX characteristic UUID, device to host: `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` + +For ESP-IDF BLE SPP examples and custom profile mapping details, see [Profile-Compatibility.md](docs/Profile-Compatibility.md). + +## Dependencies + +The tool depends on: + +- `bleak` for BLE host access +- `textual` and `rich` for the console UI +- `fastapi` and `uvicorn` for daemon mode +- `typer` for the CLI +- `loguru` for logging + +## Limitations + +- Custom GATT UUIDs require constructing `BLEUARTProfile` in Python code. +- Daemon mode is single-flight: it processes one `/request` at a time. +- Daemon mode does not automatically reconnect after a BLE disconnect; restart the daemon after the device starts advertising again. +- Daemon `/request` limits `op` to 64 characters and JSON-encoded `data` to 4096 bytes. +- The JSONL RPC protocol is a demonstration envelope, not a complete RPC framework. +- Unmatched device messages are logged as unsolicited messages and are not exposed as a streaming API. +- The daemon request framing is newline-delimited JSON; device firmware must send a newline after every JSON response. + +## Further reading + +- [Quick-Start-BLE-UART-Console.md](docs/Quick-Start-BLE-UART-Console.md) +- [Quick-Start-BLE-UART-Daemon.md](docs/Quick-Start-BLE-UART-Daemon.md) +- [Profile-Compatibility.md](docs/Profile-Compatibility.md) +- [PORTING.md](docs/PORTING.md) diff --git a/tools/ble/ble_uart_bridge/docs/PORTING.md b/tools/ble/ble_uart_bridge/docs/PORTING.md new file mode 100644 index 00000000000..86cd1d096c6 --- /dev/null +++ b/tools/ble/ble_uart_bridge/docs/PORTING.md @@ -0,0 +1,286 @@ + + + +# Porting BLE UART Bridge to Custom Scripts + +This guide explains how to reuse BLE UART Bridge in your own Python scripts. + +Use the Core API when the Console and Daemon are not the right abstraction for your application. For example, use Core directly when you want to implement custom framing, a test harness, a device provisioning flow, or a domain-specific automation script. + +## Choose the right integration level + +| Need | Recommended integration | +| --- | --- | +| Manual testing | Use `python main.py console DEVICE_ID` | +| Local process talks to a BLE device through HTTP | Use Daemon mode | +| Custom Python logic owns the BLE connection | Use `BLEUARTBridge` directly | +| Custom service UUIDs or characteristics | Use `BLEUARTProfile` with `BLEUARTBridge` | + +## Install dependencies + +You can reuse the ESP-IDF Python environment, or use your own Python virtual environment. If you reuse the ESP-IDF environment, export it first and then install the extra dependencies required by BLE UART Bridge: + +```bash +cd $IDF_PATH +. ./export.sh +cd tools/ble/ble_uart_bridge +python -m pip install -r requirements.txt +``` + +On Windows, run `export.bat` or `export.ps1` from the ESP-IDF root directory before installing `requirements.txt`. If you use your own Python virtual environment instead, activate it before installing `requirements.txt`. + +When importing from a script outside this directory, make sure `tools/ble/ble_uart_bridge` is on `PYTHONPATH`, or run the script from this directory. + +Example: + +```bash +PYTHONPATH=tools/ble/ble_uart_bridge python my_script.py +``` + +## Basic script + +The simplest script connects, sends one line, and disconnects: + +```python +import asyncio + +from src.core import BLEUARTBridge + + +async def main() -> None: + bridge = BLEUARTBridge("AA:BB:CC:DD:EE:FF") + + try: + if not await bridge.connect(): + raise RuntimeError("failed to connect") + + await bridge.send("hello\n") + finally: + await bridge.disconnect() + + +asyncio.run(main()) +``` + +## Receive data with handlers + +Register one or more RX handlers before connecting: + +```python +import asyncio + +from src.core import BLEUARTBridge + + +def print_rx(data: bytearray) -> None: + print("RX:", data.decode(errors="replace")) + + +async def main() -> None: + bridge = BLEUARTBridge("AA:BB:CC:DD:EE:FF") + bridge.add_rx_handler(print_rx) + + try: + if not await bridge.connect(): + raise RuntimeError("failed to connect") + + await bridge.send("help\n") + await asyncio.sleep(2) + finally: + await bridge.disconnect() + + +asyncio.run(main()) +``` + +Handlers are synchronous callables. If your application needs async processing, push received data into an `asyncio.Queue`: + +```python +import asyncio + +from src.core import BLEUARTBridge + + +async def main() -> None: + bridge = BLEUARTBridge("AA:BB:CC:DD:EE:FF") + rx_queue: asyncio.Queue[bytes] = asyncio.Queue() + + def enqueue_rx(data: bytearray) -> None: + rx_queue.put_nowait(bytes(data)) + + bridge.add_rx_handler(enqueue_rx) + + try: + if not await bridge.connect(): + raise RuntimeError("failed to connect") + + await bridge.send("status\n") + data = await asyncio.wait_for(rx_queue.get(), timeout=5.0) + print("RX:", data) + finally: + await bridge.disconnect() + + +asyncio.run(main()) +``` + +## Send bytes instead of text + +`BLEUARTBridge.send()` accepts `str`, `bytes`, and `bytearray`. + +```python +await bridge.send(b"\x01\x02\x03\x0a") +await bridge.send(bytearray([0x01, 0x02, 0x03, 0x0A])) +``` + +Use `with_response=True` when the target characteristic or debugging workflow should use BLE write-with-response: + +```python +await bridge.send(b"\x01\x02", with_response=True) +``` + +## Use a custom BLE UART profile + +The default profile uses Nordic UART Service UUIDs. For custom firmware, create a `BLEUARTProfile`: + +```python +from src.core import BLEUARTBridge +from src.core import BLEUARTProfile + + +profile = BLEUARTProfile( + service_uuid="00000000-0000-0000-0000-000000000001", + rx_char_uuid="00000000-0000-0000-0000-000000000002", + tx_char_uuid="00000000-0000-0000-0000-000000000003", +) + +bridge = BLEUARTBridge("AA:BB:CC:DD:EE:FF", profile=profile) +``` + +The naming follows BLE UART convention: + +- RX characteristic: host writes to device. +- TX characteristic: device notifies host. + +## Implement your own request/response protocol + +If your script needs request/response semantics, use a queue or future map and correlate responses at the application layer. + +The daemon uses a lightweight JSONL envelope. You can reuse the same pattern: + +```python +import asyncio +import json +from uuid import uuid4 + +from src.core import BLEUARTBridge + + +async def main() -> None: + bridge = BLEUARTBridge("AA:BB:CC:DD:EE:FF") + rx_buffer = bytearray() + pending: dict[str, asyncio.Future[object]] = {} + + def handle_rx(data: bytearray) -> None: + rx_buffer.extend(data) + while b"\n" in rx_buffer: + index = rx_buffer.index(b"\n") + line = bytes(rx_buffer[:index]) + del rx_buffer[: index + 1] + + message = json.loads(line.decode()) + request_id = message.get("id") + future = pending.get(request_id) + if future is None or future.done(): + continue + + if message.get("ok") is False: + future.set_exception(RuntimeError(str(message.get("error")))) + else: + future.set_result(message.get("data")) + + bridge.add_rx_handler(handle_rx) + + try: + if not await bridge.connect(): + raise RuntimeError("failed to connect") + + request_id = uuid4().hex + loop = asyncio.get_running_loop() + pending[request_id] = loop.create_future() + + request = {"v": 1, "id": request_id, "op": "echo", "data": "hello"} + await bridge.send(json.dumps(request) + "\n", with_response=True) + + response = await asyncio.wait_for(pending[request_id], timeout=10.0) + print(response) + finally: + await bridge.disconnect() + + +asyncio.run(main()) +``` + +For production scripts, add validation around incoming JSON and clean up `pending` entries on timeout. + +## Scan for devices from Python + +Use `scan_devices()` if your script needs to discover devices first: + +```python +import asyncio + +from src.core.scanner import scan_devices + + +async def main() -> None: + devices = await scan_devices(timeout=5.0) + for device in devices: + print(device.device_id, device.name, device.rssi) + + +asyncio.run(main()) +``` + +For custom service UUID discovery: + +```python +devices = await scan_devices( + timeout=5.0, + service_uuid="00000000-0000-0000-0000-000000000001", +) +``` + +## Error handling guidance + +The current Core API returns `False` for connection or send failures and logs details through `loguru`. + +Recommended script pattern: + +```python +if not await bridge.connect(): + raise RuntimeError("failed to connect to BLE UART device") + +if not await bridge.send("hello\n"): + raise RuntimeError("failed to send BLE UART data") +``` + +Always disconnect in `finally`: + +```python +try: + ... +finally: + await bridge.disconnect() +``` + +## Porting checklist + +- [ ] Decide whether your use case needs Console, Daemon, or Core. +- [ ] Confirm the BLE service and characteristic UUIDs. +- [ ] Decide whether your payload is text, binary, JSON, or another framing format. +- [ ] Register RX handlers before calling `connect()`. +- [ ] Add a newline delimiter if your protocol is JSONL or line-oriented. +- [ ] Use `with_response=True` only when needed. +- [ ] Clean up pending request state on timeout. +- [ ] Call `disconnect()` in `finally`. diff --git a/tools/ble/ble_uart_bridge/docs/Profile-Compatibility.md b/tools/ble/ble_uart_bridge/docs/Profile-Compatibility.md new file mode 100644 index 00000000000..799c979e7fe --- /dev/null +++ b/tools/ble/ble_uart_bridge/docs/Profile-Compatibility.md @@ -0,0 +1,86 @@ + + + +# BLE UART Profile Compatibility + +BLE UART Bridge works with BLE GATT profiles that provide a UART-like data path: + +- one characteristic that the host writes to +- one characteristic that the device uses to notify data back to the host + +The default profile is compatible with the Nordic UART Service (NUS), but NUS is not the only possible BLE UART-style profile. + +## Default NUS-compatible profile + +The built-in default profile uses these UUIDs: + +| Role | UUID | +| --- | --- | +| Service | `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` | +| RX, host to device | `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` | +| TX, device to host | `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` | + +Use the default profile when the device advertises a NUS-compatible service. + +## ESP-IDF BLE SPP examples + +ESP-IDF includes BLE SPP examples that implement Espressif BLE UART-like vendor-specific GATT profiles: + +- `examples/bluetooth/nimble/ble_spp/spp_server` +- `examples/bluetooth/nimble/ble_spp/spp_client` +- `examples/bluetooth/bluedroid/ble/ble_spp_server` +- `examples/bluetooth/bluedroid/ble/ble_spp_client` + +BLE SPP over BLE is not a Bluetooth SIG standard profile. It is a vendor-specific GATT design that emulates a serial link, similar in purpose to NUS. + +ESP-IDF BLE SPP examples may define more characteristics than BLE UART Bridge needs, such as data, command, and status characteristics. To use BLE UART Bridge with such a profile, map only the UART-like data path into `BLEUARTProfile`. + +## Mapping an ESP-IDF BLE SPP profile + +Map the profile fields as follows: + +| `BLEUARTProfile` field | Map to | +| --- | --- | +| `service_uuid` | BLE SPP service UUID | +| `rx_char_uuid` | Characteristic that the host writes to, such as the SPP data receive characteristic | +| `tx_char_uuid` | Characteristic that the device notifies from, such as the SPP data notify characteristic | + +Example: + +```python +from src.core import BLEUARTBridge +from src.core import BLEUARTProfile + + +profile = BLEUARTProfile( + service_uuid="00000000-0000-0000-0000-00000000ABF0", + rx_char_uuid="00000000-0000-0000-0000-00000000ABF1", + tx_char_uuid="00000000-0000-0000-0000-00000000ABF2", +) + +bridge = BLEUARTBridge("AA:BB:CC:DD:EE:FF", profile=profile) +``` + +Replace the UUIDs with the actual UUIDs used by the device firmware. + +## What BLE UART Bridge does not map + +BLE UART Bridge is intentionally focused on the data path. It does not automatically map extra control-plane characteristics that a profile may expose, such as: + +- command characteristics +- status characteristics +- custom configuration characteristics +- profile-specific flow-control semantics + +If an application needs those characteristics, implement that logic in a custom script on top of `bleak`, or extend BLE UART Bridge for that specific profile. + +## Classic Bluetooth SPP is different + +Classic Bluetooth SPP examples, such as `examples/bluetooth/bluedroid/classic_bt/bt_spp_*`, are not BLE GATT profiles. + +They use Classic Bluetooth SPP rather than BLE GATT characteristics, so they are not compatible with BLE UART Bridge. + +## Related docs + +- [README.md](../README.md) +- [PORTING.md](PORTING.md) diff --git a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Console.md b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Console.md new file mode 100644 index 00000000000..8768b155d03 --- /dev/null +++ b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Console.md @@ -0,0 +1,202 @@ + + + +# Quick Start: BLE UART Console + +This guide shows how to use the BLE UART Console for quick manual testing. + +The Console is useful when you want to type data into a BLE UART device and inspect the bytes or text sent back by the device. + +## Prerequisites + +1. A host machine with Bluetooth access. +2. Python environment prepared. You can reuse the ESP-IDF Python environment, or use your own Python virtual environment. If you reuse the ESP-IDF environment, export it first and then install the BLE UART Bridge dependencies: + + ```bash + cd $IDF_PATH + . ./export.sh + cd tools/ble/ble_uart_bridge + python -m pip install -r requirements.txt + ``` + + On Windows, run `export.bat` or `export.ps1` from the ESP-IDF root directory before installing `requirements.txt`. If you use your own Python virtual environment instead, activate it before installing `requirements.txt`. + +3. A BLE device advertising the BLE UART service. By default the tool scans for Nordic UART Service UUIDs. For a known-compatible test target, build and flash the [BLE UART Service example](../../../../examples/bluetooth/ble_uart_service), which acts as an Echo Server by echoing RX writes back through TX notifications. + +## Find a device + +```bash +cd tools/ble/ble_uart_bridge +python main.py list-devices +``` + +Example output may include a device address and name: + +```text +Found: AA:BB:CC:DD:EE:FF, with name esp-ble-uart, rssi=-42 +``` + +Use the printed device identifier as `DEVICE_ID`. On macOS, this identifier is a CoreBluetooth UUID and is different from the device MAC address. + +## Check the connection + +```bash +python main.py connection-check AA:BB:CC:DD:EE:FF +``` + +This command connects to the device, discovers the BLE UART service and characteristics, then disconnects. + +## Start the Console + +```bash +python main.py console AA:BB:CC:DD:EE:FF +``` + +The console connects before opening the UI. If connection fails, the UI is not started. + +Inside the UI: + +- Type a line and press Enter to send it. +- Received data is shown with an `[RX]` prefix. +- Transmitted data is shown with a `[TX]` prefix. +- Connection information is shown with an `[INFO]` prefix. +- Press `Ctrl+C` or `Ctrl+D` to quit. +- Press `Ctrl+L` to clear the log. + +## Text mode + +Text mode is the default. It UTF-8 encodes input and appends a line terminator. + +```bash +python main.py console AA:BB:CC:DD:EE:FF +``` + +By default, each submitted line is sent with `\n`. + +### Choose a line terminator + +Use `--terminator` for protocols that expect different line endings: + +```bash +python main.py console AA:BB:CC:DD:EE:FF --terminator lf +python main.py console AA:BB:CC:DD:EE:FF --terminator crlf +python main.py console AA:BB:CC:DD:EE:FF --terminator none +``` + +Supported values: + +| Value | Bytes appended | +| --- | --- | +| `lf` | `\n` | +| `crlf` | `\r\n` | +| `none` | nothing | + +Use `crlf` for many AT-style command interpreters. Use `none` if the device expects the exact bytes you type. + +## Hex mode + +Hex mode sends raw bytes parsed from hexadecimal input and displays received bytes as hexadecimal. + +```bash +python main.py console AA:BB:CC:DD:EE:FF --encoding hex +``` + +Inside the UI, enter bytes as hex: + +```text +01 02 03 0a +``` + +The console sends: + +```text +0x01 0x02 0x03 0x0a +``` + +Notes: + +- Hex input is parsed with Python `bytes.fromhex()`. +- Spaces are allowed. +- In hex mode, `--terminator` is ignored because the input already represents exact bytes. + +## Write-with-response + +By default the console writes without response. Use `--with-response` if the target characteristic or debugging workflow should use BLE write-with-response: + +```bash +python main.py console AA:BB:CC:DD:EE:FF --with-response +``` + +This affects BLE GATT write behavior only. It does not create an application-level request/response protocol. For application-level request/response, use Daemon mode instead. + +## Common examples + +### ESP-IDF BLE UART Echo Server + +Use the [BLE UART Service example](../../../../examples/bluetooth/ble_uart_service) when you want a ready-made ESP-IDF Echo Server for testing BLE UART Bridge Console. After building, flashing, and pairing with the example, open Console and type any text; the example should echo the same data back as `[RX]` output. + +```bash +# List nearby BLE UART devices and use the printed device ID as DEVICE_ID +python main.py list-devices +python main.py console AA:BB:CC:DD:EE:FF +``` + +### ESP-IDF console-style command + +```bash +python main.py console AA:BB:CC:DD:EE:FF --terminator lf +``` + +Then type: + +```text +help +``` + +### AT-style command + +```bash +python main.py console AA:BB:CC:DD:EE:FF --terminator crlf +``` + +Then type: + +```text +AT +``` + +### Binary smoke test + +```bash +python main.py console AA:BB:CC:DD:EE:FF --encoding hex --with-response +``` + +Then type: + +```text +aa 55 01 00 +``` + +## Troubleshooting + +### No devices found + +- Confirm the host Bluetooth adapter is available. +- Confirm the device is advertising the BLE UART service UUID. +- Move the device closer to the host. + +### Connection fails + +- Make sure no other host is already connected to the BLE device. +- Restart advertising on the device. +- Run `connection-check` before opening the console. + +### Text looks broken + +- The console decodes RX bytes as UTF-8 in text mode. +- Use `--encoding hex` if the device sends binary data. + +### Device does not react to input + +- Check the required line ending. Try `--terminator crlf` or `--terminator none`. +- Check whether the device requires write-with-response. Try `--with-response`. diff --git a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md new file mode 100644 index 00000000000..b5f4ae6eefe --- /dev/null +++ b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md @@ -0,0 +1,340 @@ + + + +# Quick Start: BLE UART Daemon + +This guide shows how to use BLE UART Daemon mode and the lightweight JSONL RPC protocol used between the host and the BLE device. + +Daemon mode is useful when another local process needs to communicate with a BLE UART device without owning the BLE connection itself. + +## Prerequisites + +1. A host machine with Bluetooth access. +2. Python environment prepared. You can reuse the ESP-IDF Python environment, or use your own Python virtual environment. If you reuse the ESP-IDF environment, export it first and then install the BLE UART Bridge dependencies: + + ```bash + cd $IDF_PATH + . ./export.sh + cd tools/ble/ble_uart_bridge + python -m pip install -r requirements.txt + ``` + + On Windows, run `export.bat` or `export.ps1` from the ESP-IDF root directory before installing `requirements.txt`. If you use your own Python virtual environment instead, activate it before installing `requirements.txt`. + +3. A BLE UART device that understands the daemon JSONL request/response protocol, or a device implementation you can adapt. + +## Start the daemon + +First, scan for a device: + +```bash +cd tools/ble/ble_uart_bridge +python main.py list-devices +``` + +Then start the daemon: + +```bash +python main.py daemon AA:BB:CC:DD:EE:FF +``` + +By default, the daemon listens on `127.0.0.1:8888`. + +To choose another host or port: + +```bash +python main.py daemon AA:BB:CC:DD:EE:FF --host 127.0.0.1 --port 8899 +``` + +The daemon keeps one BLE connection open until it is stopped. + +Security note: the daemon HTTP API does not implement authentication or authorization. Keep `--host` on `127.0.0.1` for local-only access unless you place the daemon behind your own access control. + +## Check daemon status + +In another terminal: + +```bash +python main.py daemon-status +``` + +Example response: + +```json +{ + "device_id": "AA:BB:CC:DD:EE:FF", + "connection_state": "CONNECTED", + "is_connected": true, + "pending_requests": 0, + "single_flight": true, + "max_request_data_bytes": 4096, + "protocol": "esp-jsonl-rpc-lite-v1" +} +``` + +If the daemon uses a non-default address: + +```bash +python main.py daemon-status --host 127.0.0.1 --port 8899 +``` + +## Send a request from the CLI + +Send a raw string payload with the default operation name `raw`: + +```bash +python main.py daemon-send "hello" +``` + +Send a request with an explicit operation name: + +```bash +python main.py daemon-send --op echo "hello" +``` + +Send a JSON payload: + +```bash +python main.py daemon-send --op set_led --json '{"state": true}' +``` + +Set the request timeout: + +```bash +python main.py daemon-send --op echo --timeout 5.0 "hello" +``` + +Use a non-default daemon address: + +```bash +python main.py daemon-send --host 127.0.0.1 --port 8899 --op echo "hello" +``` + +Do not send requests to a daemon bound to a shared network interface unless that network path is trusted or protected by your own access control. + +The CLI prints only the response payload. If the device returns a JSON object, the CLI prints it as JSON. + +## HTTP API + +Daemon mode exposes a local HTTP API. + +### `GET /status` + +Returns daemon and BLE connection state: + +```bash +curl http://127.0.0.1:8888/status +``` + +Response fields: + +| Field | Meaning | +| --- | --- | +| `device_id` | BLE device ID used by the daemon | +| `connection_state` | Bridge connection state | +| `is_connected` | Whether the BLE client is currently connected | +| `pending_requests` | Number of pending request futures | +| `single_flight` | Whether the daemon serializes requests | +| `max_request_data_bytes` | Maximum JSON-encoded `data` size accepted by `/request` | +| `protocol` | Wire protocol name and version | + +### `POST /request` + +Sends one request to the BLE device and waits for the response: + +```bash +curl -X POST http://127.0.0.1:8888/request \ + -H 'Content-Type: application/json' \ + -d '{"op":"echo","data":"hello","timeout":10}' +``` + +Request body: + +```json +{ + "op": "echo", + "data": "hello", + "timeout": 10.0 +} +``` + +Fields: + +| Field | Required | Meaning | +| --- | --- | --- | +| `op` | No | Operation name. Defaults to `raw`. | +| `data` | Yes | Request payload. Can be a string, number, boolean, array, object, or null. | +| `timeout` | No | Response timeout in seconds. Defaults to `10.0`. | + +Limits: + +- `op` must be 1 to 64 characters. +- JSON-encoded `data` must not exceed 4096 bytes. + +Successful response: + +```json +{ + "ok": true, + "data": "hello" +} +``` + +HTTP error behavior: + +| HTTP status | Meaning | +| --- | --- | +| `413` | Request data exceeds the daemon payload limit | +| `500` | Failed to send data to the BLE device | +| `502` | Device returned a protocol error or invalid response | +| `504` | Timed out waiting for the device response | + +## BLE JSONL RPC protocol + +The daemon communicates with the BLE device using newline-delimited JSON. Every message is one JSON object followed by `\n`. + +The protocol is named: + +```text +esp-jsonl-rpc-lite-v1 +``` + +It is intentionally small: + +- Human-readable during debugging. +- Easy to generate and parse on ESP-IDF firmware with `cJSON`. +- No schema registry or capability negotiation. +- No built-in routing framework. +- One request at a time in the current daemon implementation. + +### Host to device request + +The daemon sends this JSONL message to the BLE device: + +```json +{"v":1,"id":"6f8f...","op":"echo","data":"hello"} +``` + +Actual wire bytes include a final newline: + +```text +{"v":1,"id":"6f8f...","op":"echo","data":"hello"}\n +``` + +Fields: + +| Field | Meaning | +| --- | --- | +| `v` | Protocol version. Current value is `1`. | +| `id` | Request ID generated by the daemon. The device must echo this in the response. | +| `op` | Operation name selected by the client. | +| `data` | Request payload. | + +### Device to host success response + +```json +{"v":1,"id":"6f8f...","ok":true,"data":"hello"} +``` + +Fields: + +| Field | Meaning | +| --- | --- | +| `v` | Protocol version. Recommended value is `1`. | +| `id` | The request ID from the host message. | +| `ok` | `true` for success. | +| `data` | Response payload. | + +The daemon requires `data` to be present when `ok` is `true`. + +### Device to host error response + +```json +{"v":1,"id":"6f8f...","ok":false,"error":"unsupported op"} +``` + +Fields: + +| Field | Meaning | +| --- | --- | +| `id` | The request ID from the host message. | +| `ok` | `false` for error. | +| `error` | Human-readable error message. | + +The daemon requires `error` to be a non-empty string when `ok` is `false`. + +### Response validation rules + +For the preferred `ok/data/error` format, the daemon validates these rules: + +- `id` must be a string and must match a pending request. +- If present, `v` must be `1`. +- `ok` must be a boolean. +- `ok: true` requires a `data` field. +- `ok: false` requires a non-empty string `error` field. + +Messages without a matching pending `id` are treated as unsolicited messages and are logged only. + +### Legacy response compatibility + +The daemon also accepts older response shapes: + +```json +{"id":"6f8f...","response":"hello"} +{"id":"6f8f...","error":"failed"} +``` + +New device firmware should prefer the `ok/data/error` format. + +## Minimal firmware-side behavior + +On the BLE device, implement this loop conceptually: + +1. Accumulate bytes received on the BLE UART RX characteristic. +2. Split input on `\n`. +3. Parse each line as JSON. +4. Read `id`, `op`, and `data`. +5. Execute the requested operation. +6. Send a JSON response with the same `id` and a final `\n`. + +For example, an `echo` operation can return the same data: + +```json +{"v":1,"id":"6f8f...","ok":true,"data":"hello"} +``` + +## Single-flight behavior + +The daemon currently processes one `/request` at a time. This is exposed as: + +```json +"single_flight": true +``` + +This keeps the firmware-side example simple because the device only needs to handle one active request at a time. The request `id` is still included so the protocol can be extended later if concurrent requests are needed. + +## Disconnect behavior + +The daemon does not automatically reconnect after the BLE link is disconnected. If the device disconnects, stop and restart the daemon after the device starts advertising again. + +## Troubleshooting + +### `daemon-send` times out + +- Confirm the device sends a newline after the JSON response. +- Confirm the device response contains the same `id` as the request. +- Confirm the firmware handles the requested `op`. +- Increase `--timeout` if the operation is slow. +- Restart the daemon if the BLE link was disconnected. + +### Daemon returns HTTP 502 + +- The device returned an error response, or the response was missing required fields. +- Check daemon logs for the exact error. + +### Device receives data but daemon never resolves the request + +- Check that the response is valid JSON. +- Check that the response is an object, not a JSON array or string. +- Check that the response is newline terminated. +- Check that the response `id` matches the request `id` exactly. From 11447dd9ad648bb0c3f232c45e42d707cfc81aaa Mon Sep 17 00:00:00 2001 From: Zhou Xiao Date: Mon, 27 Apr 2026 16:24:02 +0800 Subject: [PATCH 6/7] feat(ble): add BLE UART daemon notify API --- tools/ble/ble_uart_bridge/README.md | 13 +-- .../docs/Quick-Start-BLE-UART-Daemon.md | 85 ++++++++++++++++++- tools/ble/ble_uart_bridge/main.py | 12 +++ tools/ble/ble_uart_bridge/src/__init__.py | 2 + .../ble_uart_bridge/src/daemon/__init__.py | 3 +- tools/ble/ble_uart_bridge/src/daemon/api.py | 24 ++++++ .../ble/ble_uart_bridge/src/daemon/models.py | 5 ++ .../ble/ble_uart_bridge/src/daemon/server.py | 16 ++++ 8 files changed, 151 insertions(+), 9 deletions(-) diff --git a/tools/ble/ble_uart_bridge/README.md b/tools/ble/ble_uart_bridge/README.md index 6a4024204ba..ad8c7e87b47 100644 --- a/tools/ble/ble_uart_bridge/README.md +++ b/tools/ble/ble_uart_bridge/README.md @@ -73,6 +73,7 @@ In another terminal, check daemon status and send a request: ```bash python main.py daemon-status python main.py daemon-send --op echo "hello" +python main.py daemon-notify --op set_led --json '{"state": true}' ``` For Daemon details, the HTTP API, and the JSONL RPC protocol, see [Quick-Start-BLE-UART-Daemon.md](docs/Quick-Start-BLE-UART-Daemon.md). @@ -94,6 +95,7 @@ python main.py console DEVICE_ID python main.py daemon DEVICE_ID python main.py daemon-status python main.py daemon-send DATA +python main.py daemon-notify DATA ``` ### Typical Console workflow @@ -137,6 +139,7 @@ Terminal 2 checks status and sends requests through the daemon: python main.py daemon-status python main.py daemon-send --op echo "hello" python main.py daemon-send --op set_led --json '{"state": true}' +python main.py daemon-notify --op set_led --json '{"state": true}' ``` For the HTTP API and JSONL RPC wire protocol, see [Quick-Start-BLE-UART-Daemon.md](docs/Quick-Start-BLE-UART-Daemon.md). @@ -208,12 +211,12 @@ Main responsibilities: The Daemon component exposes BLE UART as a local HTTP service. -Use it when another local tool, script, editor integration, or automation process needs request/response IPC with a BLE UART device. +Use it when another local tool, script, editor integration, or automation process needs request/response or fire-and-forget IPC with a BLE UART device. Main responsibilities: - Keep one BLE UART connection open in a background server process. -- Expose local HTTP endpoints for status and request/response calls. +- Expose local HTTP endpoints for status, request/response, and fire-and-forget calls. - Encode requests as newline-delimited JSON messages over BLE UART. - Correlate device responses by request ID. - Provide a small JSONL RPC-style envelope as an example protocol. @@ -230,7 +233,7 @@ By default, the daemon binds to `127.0.0.1`. Keep it on a loopback address unles | Console | Manual BLE UART smoke tests | Interactive TUI | | Daemon | Local IPC and automation | HTTP + CLI client | -Use Core when your business logic lives in Python. Use Console when you only need to manually test a BLE UART endpoint. Use Daemon when multiple local processes need to share one BLE connection through a simple request/response boundary. +Use Core when your business logic lives in Python. Use Console when you only need to manually test a BLE UART endpoint. Use Daemon when multiple local processes need to share one BLE connection through a simple request/response or notification boundary. ## Profile compatibility @@ -255,9 +258,9 @@ The tool depends on: ## Limitations - Custom GATT UUIDs require constructing `BLEUARTProfile` in Python code. -- Daemon mode is single-flight: it processes one `/request` at a time. +- Daemon mode is single-flight for request/response calls: it processes one `/request` at a time. `/notify` sends without waiting for a device response. - Daemon mode does not automatically reconnect after a BLE disconnect; restart the daemon after the device starts advertising again. -- Daemon `/request` limits `op` to 64 characters and JSON-encoded `data` to 4096 bytes. +- Daemon `/request` and `/notify` limit `op` to 64 characters and JSON-encoded `data` to 4096 bytes. - The JSONL RPC protocol is a demonstration envelope, not a complete RPC framework. - Unmatched device messages are logged as unsolicited messages and are not exposed as a streaming API. - The daemon request framing is newline-delimited JSON; device firmware must send a newline after every JSON response. diff --git a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md index b5f4ae6eefe..74852758f6d 100644 --- a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md +++ b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md @@ -114,6 +114,28 @@ Do not send requests to a daemon bound to a shared network interface unless that The CLI prints only the response payload. If the device returns a JSON object, the CLI prints it as JSON. +## Send a notification from the CLI + +Use `daemon-notify` for fire-and-forget operations where the caller only needs the daemon to write to the BLE device and does not need a protocol response: + +```bash +python main.py daemon-notify --op set_led --json '{"state": true}' +``` + +Send a raw string notification with the default operation name `raw`: + +```bash +python main.py daemon-notify "hello" +``` + +Use a non-default daemon address: + +```bash +python main.py daemon-notify --host 127.0.0.1 --port 8899 --op set_led --json '{"state": true}' +``` + +`daemon-notify` returns after the local BLE write completes. It does not wait for the device to send a JSONL response. + ## HTTP API Daemon mode exposes a local HTTP API. @@ -135,7 +157,7 @@ Response fields: | `is_connected` | Whether the BLE client is currently connected | | `pending_requests` | Number of pending request futures | | `single_flight` | Whether the daemon serializes requests | -| `max_request_data_bytes` | Maximum JSON-encoded `data` size accepted by `/request` | +| `max_request_data_bytes` | Maximum JSON-encoded `data` size accepted by `/request` and `/notify` | | `protocol` | Wire protocol name and version | ### `POST /request` @@ -189,6 +211,54 @@ HTTP error behavior: | `502` | Device returned a protocol error or invalid response | | `504` | Timed out waiting for the device response | +### `POST /notify` + +Sends one notification to the BLE device and returns without waiting for a protocol response: + +```bash +curl -X POST http://127.0.0.1:8888/notify \ + -H 'Content-Type: application/json' \ + -d '{"op":"set_led","data":{"state":true}}' +``` + +Request body: + +```json +{ + "op": "set_led", + "data": { + "state": true + } +} +``` + +Fields: + +| Field | Required | Meaning | +| --- | --- | --- | +| `op` | No | Operation name. Defaults to `raw`. | +| `data` | Yes | Notification payload. Can be a string, number, boolean, array, object, or null. | + +Limits: + +- `op` must be 1 to 64 characters. +- JSON-encoded `data` must not exceed 4096 bytes. + +Successful response: + +```json +{ + "ok": true +} +``` + +HTTP error behavior: + +| HTTP status | Meaning | +| --- | --- | +| `413` | Request data exceeds the daemon payload limit | +| `500` | Failed to send data to the BLE device | + ## BLE JSONL RPC protocol The daemon communicates with the BLE device using newline-delimited JSON. Every message is one JSON object followed by `\n`. @@ -226,7 +296,7 @@ Fields: | Field | Meaning | | --- | --- | | `v` | Protocol version. Current value is `1`. | -| `id` | Request ID generated by the daemon. The device must echo this in the response. | +| `id` | Request ID generated by the daemon. The device must echo this in `/request` responses. `/notify` uses an empty string because no response is expected. | | `op` | Operation name selected by the client. | | `data` | Request payload. | @@ -295,7 +365,8 @@ On the BLE device, implement this loop conceptually: 3. Parse each line as JSON. 4. Read `id`, `op`, and `data`. 5. Execute the requested operation. -6. Send a JSON response with the same `id` and a final `\n`. +6. If `id` is non-empty, send a JSON response with the same `id` and a final `\n`. +7. If `id` is empty, treat the message as fire-and-forget and normally do not send a response. For example, an `echo` operation can return the same data: @@ -303,6 +374,14 @@ For example, an `echo` operation can return the same data: {"v":1,"id":"6f8f...","ok":true,"data":"hello"} ``` +For notifications sent through `/notify`, the daemon uses an empty `id`: + +```json +{"v":1,"id":"","op":"set_led","data":{"state":true}} +``` + +Firmware can execute the operation without responding. If it does respond with `id: ""`, the daemon will log the message as unsolicited because no pending request is waiting for that ID. + ## Single-flight behavior The daemon currently processes one `/request` at a time. This is exposed as: diff --git a/tools/ble/ble_uart_bridge/main.py b/tools/ble/ble_uart_bridge/main.py index 4158ca1c02b..567169a561c 100644 --- a/tools/ble/ble_uart_bridge/main.py +++ b/tools/ble/ble_uart_bridge/main.py @@ -3,6 +3,7 @@ from src import run_connection_check from src import run_console from src import run_daemon +from src import run_daemon_notify from src import run_daemon_send from src import run_daemon_status from src import run_list_devices @@ -60,6 +61,17 @@ def daemon_send( run_daemon_send(data=data, op=op, json_payload=json_payload, timeout=timeout, host=host, port=port) +@app.command() +def daemon_notify( + data: str, + op: str = Option('raw', help='Operation name in the JSONL request envelope'), + json_payload: bool = Option(False, '--json', help='Parse DATA as JSON instead of sending it as a string'), + host: str = '127.0.0.1', + port: int = 8888, +) -> None: + run_daemon_notify(data=data, op=op, json_payload=json_payload, host=host, port=port) + + def main() -> None: app() diff --git a/tools/ble/ble_uart_bridge/src/__init__.py b/tools/ble/ble_uart_bridge/src/__init__.py index 770559e46fc..5f497e34f38 100644 --- a/tools/ble/ble_uart_bridge/src/__init__.py +++ b/tools/ble/ble_uart_bridge/src/__init__.py @@ -4,6 +4,7 @@ from .console import run_console from .core import run_connection_check from .core import run_list_devices from .daemon import run_daemon +from .daemon import run_daemon_notify from .daemon import run_daemon_send from .daemon import run_daemon_status @@ -12,6 +13,7 @@ __all__ = [ 'run_list_devices', 'run_daemon', 'run_daemon_send', + 'run_daemon_notify', 'run_daemon_status', 'run_console', ] diff --git a/tools/ble/ble_uart_bridge/src/daemon/__init__.py b/tools/ble/ble_uart_bridge/src/daemon/__init__.py index 1aefe89058c..d0fdbd5c70d 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/__init__.py +++ b/tools/ble/ble_uart_bridge/src/daemon/__init__.py @@ -1,7 +1,8 @@ # SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: Apache-2.0 from .api import run_daemon +from .api import run_daemon_notify from .api import run_daemon_send from .api import run_daemon_status -__all__ = ['run_daemon', 'run_daemon_status', 'run_daemon_send'] +__all__ = ['run_daemon', 'run_daemon_status', 'run_daemon_send', 'run_daemon_notify'] diff --git a/tools/ble/ble_uart_bridge/src/daemon/api.py b/tools/ble/ble_uart_bridge/src/daemon/api.py index 212f76a60d8..a2e5f2ea15e 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/api.py +++ b/tools/ble/ble_uart_bridge/src/daemon/api.py @@ -90,3 +90,27 @@ def run_daemon_send( raise SystemExit(1) from e result = response.get('data', response.get('response', '')) print(result if isinstance(result, str) else json.dumps(result)) + + +def run_daemon_notify( + data: str, + op: str = 'raw', + json_payload: bool = False, + host: str = '127.0.0.1', + port: int = 8888, +) -> None: + try: + payload_data: Any = json.loads(data) if json_payload else data + except json.JSONDecodeError as e: + print(f'Invalid JSON payload: {e}') + raise SystemExit(1) from e + + try: + _request_json( + 'POST', + _daemon_url(host, port, '/notify'), + payload={'op': op, 'data': payload_data}, + ) + except RuntimeError as e: + print(e) + raise SystemExit(1) from e diff --git a/tools/ble/ble_uart_bridge/src/daemon/models.py b/tools/ble/ble_uart_bridge/src/daemon/models.py index 1eb59ea3aca..99e8bce2d74 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/models.py +++ b/tools/ble/ble_uart_bridge/src/daemon/models.py @@ -14,3 +14,8 @@ class BLEUARTRequestPayload(BaseModel): op: str = Field('raw', min_length=1, max_length=MAX_OP_LENGTH, description='Operation name to send to BLE device') data: Any = Field(..., description='Request payload to send to BLE device') timeout: float = Field(10.0, gt=0, description='Response timeout in seconds') + + +class BLEUARTNotifyPayload(BaseModel): + op: str = Field('raw', min_length=1, max_length=MAX_OP_LENGTH, description='Operation name to send to BLE device') + data: Any = Field(..., description='Notification payload to send to BLE device') diff --git a/tools/ble/ble_uart_bridge/src/daemon/server.py b/tools/ble/ble_uart_bridge/src/daemon/server.py index 2e5afbc2f02..b4bb915c038 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/server.py +++ b/tools/ble/ble_uart_bridge/src/daemon/server.py @@ -17,6 +17,7 @@ from .jsonl import drain_jsonl_messages from .jsonl import encode_jsonl_request from .jsonl import resolve_pending_response from .models import MAX_REQUEST_DATA_BYTES +from .models import BLEUARTNotifyPayload from .models import BLEUARTRequestPayload @@ -110,3 +111,18 @@ async def request(payload: BLEUARTRequestPayload) -> dict: app.state.pending_requests.pop(request_id, None) return {'ok': True, 'data': response} + + +@app.post('/notify') +async def notify(payload: BLEUARTNotifyPayload) -> dict: + if _request_data_size(payload.data) > MAX_REQUEST_DATA_BYTES: + raise HTTPException(status_code=413, detail=f'Request data exceeds {MAX_REQUEST_DATA_BYTES} bytes') + + success = await app.state.bridge.send( + encode_jsonl_request('', payload.op, payload.data), + with_response=False, + ) + if not success: + raise HTTPException(status_code=500, detail='Failed to send data to device') + + return {'ok': True} From 4ceb950dc1524c65aa6a418f23065edf4ee331ea Mon Sep 17 00:00:00 2001 From: Zhou Xiao Date: Tue, 28 Apr 2026 12:13:53 +0800 Subject: [PATCH 7/7] fix(ble): support Python 3.9 type annotations --- tools/ble/ble_uart_bridge/src/console/api.py | 7 +++++-- tools/ble/ble_uart_bridge/src/console/console.py | 11 +++++++---- tools/ble/ble_uart_bridge/src/daemon/api.py | 6 +++++- tools/ble/ble_uart_bridge/src/daemon/server.py | 8 ++++++-- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/tools/ble/ble_uart_bridge/src/console/api.py b/tools/ble/ble_uart_bridge/src/console/api.py index e8e01ebb058..1d36f76b65b 100644 --- a/tools/ble/ble_uart_bridge/src/console/api.py +++ b/tools/ble/ble_uart_bridge/src/console/api.py @@ -3,7 +3,10 @@ from __future__ import annotations +# Keep annotations compatible with Python 3.9. +# ruff: noqa: UP007 import asyncio +from typing import Union from .console import BLEUARTConsole from .console import ConsoleEncoding @@ -12,8 +15,8 @@ from .console import ConsoleTerminator def run_console( device_id: str, - terminator: ConsoleTerminator | str = ConsoleTerminator.lf, - encoding: ConsoleEncoding | str = ConsoleEncoding.text, + terminator: Union[ConsoleTerminator, str] = ConsoleTerminator.lf, + encoding: Union[ConsoleEncoding, str] = ConsoleEncoding.text, with_response: bool = False, ) -> None: # Initialize BLE UART Console diff --git a/tools/ble/ble_uart_bridge/src/console/console.py b/tools/ble/ble_uart_bridge/src/console/console.py index ffa8079689c..318029392f0 100644 --- a/tools/ble/ble_uart_bridge/src/console/console.py +++ b/tools/ble/ble_uart_bridge/src/console/console.py @@ -3,9 +3,12 @@ from __future__ import annotations +# Keep annotations compatible with Python 3.9. +# ruff: noqa: UP007 import asyncio import codecs from enum import Enum +from typing import Union from loguru import logger from rich.highlighter import Highlighter @@ -93,8 +96,8 @@ class BLEUARTConsole(App): def __init__( self, device_id: str, - terminator: ConsoleTerminator | str = ConsoleTerminator.lf, - encoding: ConsoleEncoding | str = ConsoleEncoding.text, + terminator: Union[ConsoleTerminator, str] = ConsoleTerminator.lf, + encoding: Union[ConsoleEncoding, str] = ConsoleEncoding.text, with_response: bool = False, ) -> None: super().__init__() @@ -109,7 +112,7 @@ class BLEUARTConsole(App): self._ui_ready = False @staticmethod - def _parse_terminator(terminator: ConsoleTerminator | str) -> bytes: + def _parse_terminator(terminator: Union[ConsoleTerminator, str]) -> bytes: try: return TERMINATORS[ConsoleTerminator(terminator)] except ValueError: @@ -117,7 +120,7 @@ class BLEUARTConsole(App): raise ValueError(f'Unsupported terminator: {terminator}. Expected one of: {choices}') from None @staticmethod - def _parse_encoding(encoding: ConsoleEncoding | str) -> ConsoleEncoding: + def _parse_encoding(encoding: Union[ConsoleEncoding, str]) -> ConsoleEncoding: try: return ConsoleEncoding(encoding) except ValueError: diff --git a/tools/ble/ble_uart_bridge/src/daemon/api.py b/tools/ble/ble_uart_bridge/src/daemon/api.py index a2e5f2ea15e..f1dda14db80 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/api.py +++ b/tools/ble/ble_uart_bridge/src/daemon/api.py @@ -1,8 +1,12 @@ # SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: Apache-2.0 +# Keep annotations compatible with Python 3.9. +# ruff: noqa: UP007 + import json from typing import Any +from typing import Optional from urllib.error import HTTPError from urllib.error import URLError from urllib.request import Request @@ -20,7 +24,7 @@ def _daemon_url(host: str, port: int, path: str) -> str: def _request_json( method: str, url: str, - payload: dict[str, Any] | None = None, + payload: Optional[dict[str, Any]] = None, timeout: float = 10.0, ) -> dict[str, Any]: data = json.dumps(payload).encode() if payload is not None else None diff --git a/tools/ble/ble_uart_bridge/src/daemon/server.py b/tools/ble/ble_uart_bridge/src/daemon/server.py index b4bb915c038..dd69ae224bb 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/server.py +++ b/tools/ble/ble_uart_bridge/src/daemon/server.py @@ -1,10 +1,14 @@ # SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: Apache-2.0 +# Keep annotations compatible with Python 3.9. +# ruff: noqa: UP007 + import asyncio import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import Optional from uuid import uuid4 from fastapi import FastAPI @@ -66,8 +70,8 @@ def _request_data_size(data: object) -> int: @app.get('/status') async def status() -> dict: - bridge: BLEUARTBridge | None = getattr(app.state, 'bridge', None) - pending_requests: dict | None = getattr(app.state, 'pending_requests', None) + bridge: Optional[BLEUARTBridge] = getattr(app.state, 'bridge', None) + pending_requests: Optional[dict] = getattr(app.state, 'pending_requests', None) return { 'device_id': getattr(app.state, 'device_id', None),