diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30a8521b..0799d878 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,7 @@ jobs: name: Build with ESP-IDF ${{ matrix.idf_ver }} for ${{ matrix.idf_target }} runs-on: ubuntu-latest strategy: + fail-fast: false matrix: # The version names here correspond to the versions of espressif/idf Docker image. # See https://hub.docker.com/r/espressif/idf/tags and @@ -22,6 +23,9 @@ jobs: example: - NimBLE_Client - NimBLE_Server + - NimBLE_Stream_Client + - NimBLE_Stream_Server + - NimBLE_Stream_Echo - Bluetooth_5/NimBLE_extended_client - Bluetooth_5/NimBLE_extended_server exclude: diff --git a/.gitignore b/.gitignore index 33815ed8..10189d21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ docs/doxydocs -dist \ No newline at end of file +dist +.development +_codeql_detected_source_root diff --git a/examples/NimBLE_Stream_Client/CMakeLists.txt b/examples/NimBLE_Stream_Client/CMakeLists.txt new file mode 100644 index 00000000..51eefb17 --- /dev/null +++ b/examples/NimBLE_Stream_Client/CMakeLists.txt @@ -0,0 +1,6 @@ +# 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.5) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(NimBLE_Stream_Client) \ No newline at end of file diff --git a/examples/NimBLE_Stream_Client/README.md b/examples/NimBLE_Stream_Client/README.md new file mode 100644 index 00000000..b5b7ad4b --- /dev/null +++ b/examples/NimBLE_Stream_Client/README.md @@ -0,0 +1,53 @@ +# NimBLE Stream Client Example + +This example demonstrates how to use the `NimBLEStreamClient` class to connect to a BLE GATT server and communicate using the familiar Arduino Stream interface. + +## Features + +- Uses Arduino Stream interface (print, println, read, available, etc.) +- Automatic server discovery and connection +- Bidirectional communication +- Buffered TX/RX using ring buffers +- Automatic reconnection on disconnect +- Similar usage to Serial communication + +## How it Works + +1. Scans for BLE devices advertising the target service UUID +2. Connects to the server and discovers the stream characteristic +3. Initializes `NimBLEStreamClient` with the remote characteristic +4. Subscribes to notifications to receive data in the RX buffer +5. Uses familiar Stream methods like `print()`, `println()`, `read()`, and `available()` + +## Usage + +1. Build and flash the NimBLE_Stream_Server example to one ESP32 using ESP-IDF (`idf.py build flash monitor`) +2. Build and flash this client example to another ESP32 using ESP-IDF +3. The client will automatically: + - Scan for the server + - Connect when found + - Set up the stream interface + - Begin bidirectional communication +4. Open `idf.py monitor` on each board to observe stream traffic + +## Service UUIDs + +Must match the server: +- Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` +- Characteristic: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` + +## Monitor Output + +The example displays: +- Server discovery progress +- Connection status +- All data received from the server +- Confirmation of data sent to the server + +## Testing + +Run with NimBLE_Stream_Server to see bidirectional communication: +- Server sends periodic status messages +- Client sends periodic uptime messages +- Both echo data received from each other +- You can send data from either `idf.py monitor` session diff --git a/examples/NimBLE_Stream_Client/main/CMakeLists.txt b/examples/NimBLE_Stream_Client/main/CMakeLists.txt new file mode 100644 index 00000000..9be90751 --- /dev/null +++ b/examples/NimBLE_Stream_Client/main/CMakeLists.txt @@ -0,0 +1,4 @@ +set(COMPONENT_SRCS "main.cpp") +set(COMPONENT_ADD_INCLUDEDIRS ".") + +register_component() \ No newline at end of file diff --git a/examples/NimBLE_Stream_Client/main/main.cpp b/examples/NimBLE_Stream_Client/main/main.cpp new file mode 100644 index 00000000..86265e46 --- /dev/null +++ b/examples/NimBLE_Stream_Client/main/main.cpp @@ -0,0 +1,217 @@ +/** + * NimBLE_Stream_Client Example: + * + * Demonstrates using NimBLEStreamClient to connect to a BLE GATT server + * and communicate using the Stream-like interface. + * + * This example connects to the NimBLE_Stream_Server example. + */ + +#include +#include +#include + +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include + +// Service and Characteristic UUIDs (must match the server) +#define SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +#define CHARACTERISTIC_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" + +// Create the stream client instance +NimBLEStreamClient bleStream; + +struct RxOverflowStats { + uint32_t droppedOld{0}; + uint32_t droppedNew{0}; +}; + +RxOverflowStats g_rxOverflowStats; +uint32_t scanTime = 5000; // Scan duration in milliseconds + +NimBLEStream::RxOverflowAction onRxOverflow(const uint8_t* data, size_t len, void* userArg) { + auto* stats = static_cast(userArg); + if (stats) { + stats->droppedOld++; + } + + // For status/telemetry streams, prioritize newest packets. + (void)data; + (void)len; + return NimBLEStream::DROP_OLDER_DATA; +} + +static uint64_t millis() { + return esp_timer_get_time() / 1000ULL; +} + +// Connection state variables +static bool doConnect = false; +static bool connected = false; +static const NimBLEAdvertisedDevice* pServerDevice = nullptr; +static NimBLEClient* pClient = nullptr; + +/** Scan callbacks to find the server */ +class ScanCallbacks : public NimBLEScanCallbacks { + void onResult(const NimBLEAdvertisedDevice* advertisedDevice) override { + printf("Advertised Device: %s\n", advertisedDevice->toString().c_str()); + + // Check if this device advertises our service. + if (advertisedDevice->isAdvertisingService(NimBLEUUID(SERVICE_UUID))) { + printf("Found our stream server!\n"); + pServerDevice = advertisedDevice; + NimBLEDevice::getScan()->stop(); + doConnect = true; + } + } + + void onScanEnd(const NimBLEScanResults& results, int reason) override { + (void)results; + (void)reason; + printf("Scan ended\n"); + if (!doConnect && !connected) { + printf("Server not found, restarting scan...\n"); + NimBLEDevice::getScan()->start(scanTime, false, true); + } + } +} scanCallbacks; + +/** Client callbacks for connection/disconnection events */ +class ClientCallbacks : public NimBLEClientCallbacks { + void onConnect(NimBLEClient* pClient) override { + printf("Connected to server\n"); + // Update connection parameters for better throughput. + pClient->updateConnParams(12, 24, 0, 200); + } + + void onDisconnect(NimBLEClient* pClient, int reason) override { + (void)pClient; + printf("Disconnected from server, reason: %d\n", reason); + connected = false; + bleStream.end(); + + // Restart scanning. + printf("Restarting scan...\n"); + NimBLEDevice::getScan()->start(scanTime, false, true); + } +} clientCallbacks; + +/** Connect to the BLE Server and set up the stream */ +bool connectToServer() { + printf("Connecting to: %s\n", pServerDevice->getAddress().toString().c_str()); + + // Create or reuse a client. + pClient = NimBLEDevice::getClientByPeerAddress(pServerDevice->getAddress()); + if (!pClient) { + pClient = NimBLEDevice::createClient(); + if (!pClient) { + printf("Failed to create client\n"); + return false; + } + pClient->setClientCallbacks(&clientCallbacks, false); + pClient->setConnectionParams(12, 24, 0, 200); + pClient->setConnectTimeout(5000); + } + + // Connect to the remote BLE Server. + if (!pClient->connect(pServerDevice)) { + printf("Failed to connect to server\n"); + return false; + } + + printf("Connected! Discovering services...\n"); + + // Get the service and characteristic. + NimBLERemoteService* pRemoteService = pClient->getService(SERVICE_UUID); + if (!pRemoteService) { + printf("Failed to find our service UUID\n"); + pClient->disconnect(); + return false; + } + printf("Found the stream service\n"); + + NimBLERemoteCharacteristic* pRemoteCharacteristic = pRemoteService->getCharacteristic(CHARACTERISTIC_UUID); + if (!pRemoteCharacteristic) { + printf("Failed to find our characteristic UUID\n"); + pClient->disconnect(); + return false; + } + printf("Found the stream characteristic\n"); + + // subscribeNotify=true means notifications are stored in the RX buffer. + if (!bleStream.begin(pRemoteCharacteristic, true)) { + printf("Failed to initialize BLE stream!\n"); + pClient->disconnect(); + return false; + } + + bleStream.setRxOverflowCallback(onRxOverflow, &g_rxOverflowStats); + + printf("BLE Stream initialized successfully!\n"); + connected = true; + return true; +} + +extern "C" void app_main(void) { + printf("Starting NimBLE Stream Client\n"); + + /** Initialize NimBLE */ + NimBLEDevice::init("NimBLE-StreamClient"); + + // Create the BLE scan instance and set callbacks. + NimBLEScan* pScan = NimBLEDevice::getScan(); + pScan->setScanCallbacks(&scanCallbacks, false); + pScan->setActiveScan(true); + + // Start scanning for the server. + printf("Scanning for BLE Stream Server...\n"); + pScan->start(scanTime, false, true); + + uint32_t lastDroppedOld = 0; + uint32_t lastDroppedNew = 0; + uint64_t lastSend = 0; + + for (;;) { + if (g_rxOverflowStats.droppedOld != lastDroppedOld || g_rxOverflowStats.droppedNew != lastDroppedNew) { + lastDroppedOld = g_rxOverflowStats.droppedOld; + lastDroppedNew = g_rxOverflowStats.droppedNew; + printf("RX overflow handled (drop-old=%" PRIu32 ", drop-new=%" PRIu32 ")\n", lastDroppedOld, lastDroppedNew); + } + + // If we found a server, try to connect. + if (doConnect) { + doConnect = false; + if (connectToServer()) { + printf("Stream ready for communication!\n"); + } else { + printf("Failed to connect to server, restarting scan...\n"); + pServerDevice = nullptr; + NimBLEDevice::getScan()->start(scanTime, false, true); + } + } + + // If connected, demonstrate stream communication. + if (connected && bleStream) { + if (bleStream.available()) { + printf("Received from server: "); + while (bleStream.available()) { + char c = bleStream.read(); + putchar(c); + } + printf("\n"); + } + + uint64_t now = esp_timer_get_time() / 1000ULL; + if (now - lastSend > 5000) { + lastSend = now; + bleStream.printf("Hello from client! Uptime: %" PRIu64 " seconds\n", now / 1000); + printf("Sent data to server via BLE stream\n"); + } + } + + vTaskDelay(pdMS_TO_TICKS(10)); + } +} \ No newline at end of file diff --git a/examples/NimBLE_Stream_Client/sdkconfig.defaults b/examples/NimBLE_Stream_Client/sdkconfig.defaults new file mode 100644 index 00000000..c829fc5c --- /dev/null +++ b/examples/NimBLE_Stream_Client/sdkconfig.defaults @@ -0,0 +1,12 @@ +# Override some defaults so BT stack is enabled +# in this example + +# +# BT config +# +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 diff --git a/examples/NimBLE_Stream_Echo/CMakeLists.txt b/examples/NimBLE_Stream_Echo/CMakeLists.txt new file mode 100644 index 00000000..aefc94fa --- /dev/null +++ b/examples/NimBLE_Stream_Echo/CMakeLists.txt @@ -0,0 +1,6 @@ +# 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.5) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(NimBLE_Stream_Echo) \ No newline at end of file diff --git a/examples/NimBLE_Stream_Echo/README.md b/examples/NimBLE_Stream_Echo/README.md new file mode 100644 index 00000000..d0d7d8a0 --- /dev/null +++ b/examples/NimBLE_Stream_Echo/README.md @@ -0,0 +1,39 @@ +# NimBLE Stream Echo Example + +This is the simplest example demonstrating `NimBLEStreamServer`. It echoes back any data received from BLE clients. + +## Features + +- Minimal code showing essential NimBLE Stream usage +- Echoes all received data back to the client +- Uses default service and characteristic UUIDs +- Perfect starting point for learning the Stream interface + +## How it Works + +1. Initializes BLE with minimal configuration +2. Creates a stream server with default UUIDs +3. Waits for client connection and data +4. Echoes received data back to the client +5. Displays received data in the ESP-IDF monitor output + +## Default UUIDs + +- Service: `0xc0de` +- Characteristic: `0xfeed` + +## Usage + +1. Build and flash this example to your ESP32 using ESP-IDF (`idf.py build flash monitor`) +2. Connect with a BLE client app (nRF Connect, Serial Bluetooth Terminal, etc.) +3. Find the service `0xc0de` and characteristic `0xfeed` +4. Subscribe to notifications +5. Write data to the characteristic +6. The data will be echoed back and displayed in `idf.py monitor` + +## Good For + +- Learning the basic NimBLE Stream API +- Testing BLE connectivity +- Starting point for custom applications +- Understanding Stream read/write operations diff --git a/examples/NimBLE_Stream_Echo/main/CMakeLists.txt b/examples/NimBLE_Stream_Echo/main/CMakeLists.txt new file mode 100644 index 00000000..9be90751 --- /dev/null +++ b/examples/NimBLE_Stream_Echo/main/CMakeLists.txt @@ -0,0 +1,4 @@ +set(COMPONENT_SRCS "main.cpp") +set(COMPONENT_ADD_INCLUDEDIRS ".") + +register_component() \ No newline at end of file diff --git a/examples/NimBLE_Stream_Echo/main/main.cpp b/examples/NimBLE_Stream_Echo/main/main.cpp new file mode 100644 index 00000000..e0eda500 --- /dev/null +++ b/examples/NimBLE_Stream_Echo/main/main.cpp @@ -0,0 +1,83 @@ +/** + * NimBLE_Stream_Echo Example: + * + * A minimal example demonstrating NimBLEStreamServer. + * Echoes back any data received from BLE clients. + */ + +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include + +NimBLEStreamServer bleStream; + +struct RxOverflowStats { + uint32_t droppedOld{0}; + uint32_t droppedNew{0}; +}; + +RxOverflowStats g_rxOverflowStats; + +NimBLEStream::RxOverflowAction onRxOverflow(const uint8_t* data, size_t len, void* userArg) { + auto* stats = static_cast(userArg); + if (stats) { + stats->droppedOld++; + } + + // Echo mode prefers the latest incoming bytes. + (void)data; + (void)len; + return NimBLEStream::DROP_OLDER_DATA; +} + +extern "C" void app_main(void) { + printf("NimBLE Stream Echo Server\n"); + + // Initialize BLE. + NimBLEDevice::init("BLE-Echo"); + auto pServer = NimBLEDevice::createServer(); + pServer->advertiseOnDisconnect(true); // Keep advertising after disconnects. + + if (!bleStream.begin(NimBLEUUID(uint16_t(0xc0de)), + NimBLEUUID(uint16_t(0xfeed)), + 1024, + 1024, + false)) { + printf("Failed to initialize BLE stream\n"); + return; + } + + bleStream.setRxOverflowCallback(onRxOverflow, &g_rxOverflowStats); + + // Start advertising. + NimBLEDevice::getAdvertising()->start(); + printf("Ready! Connect with a BLE client and send data.\n"); + + uint32_t lastDroppedOld = 0; + uint32_t lastDroppedNew = 0; + + for (;;) { + if (g_rxOverflowStats.droppedOld != lastDroppedOld || g_rxOverflowStats.droppedNew != lastDroppedNew) { + lastDroppedOld = g_rxOverflowStats.droppedOld; + lastDroppedNew = g_rxOverflowStats.droppedNew; + printf("RX overflow handled (drop-old=%" PRIu32 ", drop-new=%" PRIu32 ")\n", lastDroppedOld, lastDroppedNew); + } + + // Echo any received data back to the client. + if (bleStream.ready() && bleStream.available()) { + printf("Echo: "); + while (bleStream.available()) { + char c = bleStream.read(); + putchar(c); + bleStream.write(c); + } + printf("\n"); + } + vTaskDelay(pdMS_TO_TICKS(10)); + } +} \ No newline at end of file diff --git a/examples/NimBLE_Stream_Echo/sdkconfig.defaults b/examples/NimBLE_Stream_Echo/sdkconfig.defaults new file mode 100644 index 00000000..c829fc5c --- /dev/null +++ b/examples/NimBLE_Stream_Echo/sdkconfig.defaults @@ -0,0 +1,12 @@ +# Override some defaults so BT stack is enabled +# in this example + +# +# BT config +# +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 diff --git a/examples/NimBLE_Stream_Server/CMakeLists.txt b/examples/NimBLE_Stream_Server/CMakeLists.txt new file mode 100644 index 00000000..672c03b7 --- /dev/null +++ b/examples/NimBLE_Stream_Server/CMakeLists.txt @@ -0,0 +1,6 @@ +# 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.5) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(NimBLE_Stream_Server) \ No newline at end of file diff --git a/examples/NimBLE_Stream_Server/README.md b/examples/NimBLE_Stream_Server/README.md new file mode 100644 index 00000000..a3a3a00e --- /dev/null +++ b/examples/NimBLE_Stream_Server/README.md @@ -0,0 +1,42 @@ +# NimBLE Stream Server Example + +This example demonstrates how to use the `NimBLEStreamServer` class to create a BLE GATT server that behaves like a serial port using the familiar Arduino Stream interface. + +## Features + +- Uses Arduino Stream interface (print, println, read, available, etc.) +- Automatic connection management +- Bidirectional communication +- Buffered TX/RX using ring buffers +- Similar usage to Serial communication + +## How it Works + +1. Creates a BLE GATT server with a custom service and characteristic +2. Initializes `NimBLEStreamServer` with the characteristic configured for notifications and writes +3. Uses familiar Stream methods like `print()`, `println()`, `read()`, and `available()` +4. Automatically handles connection state and MTU negotiation + +## Usage + +1. Build and flash this example to your ESP32 using ESP-IDF (`idf.py build flash monitor`) +2. The device will advertise as "NimBLE-Stream" +3. Connect with a BLE client (such as the NimBLE_Stream_Client example or a mobile app) +4. Once connected, the server will: + - Send periodic messages to the client + - Echo back any data received from the client + - Display all communication in `idf.py monitor` + +## Service UUIDs + +- Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` +- Characteristic: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` + +These are based on the Nordic UART Service (NUS) UUIDs for compatibility with many BLE terminal apps. + +## Compatible With + +- NimBLE_Stream_Client example +- nRF Connect mobile app +- Serial Bluetooth Terminal apps +- Any BLE client that supports characteristic notifications and writes diff --git a/examples/NimBLE_Stream_Server/main/CMakeLists.txt b/examples/NimBLE_Stream_Server/main/CMakeLists.txt new file mode 100644 index 00000000..9be90751 --- /dev/null +++ b/examples/NimBLE_Stream_Server/main/CMakeLists.txt @@ -0,0 +1,4 @@ +set(COMPONENT_SRCS "main.cpp") +set(COMPONENT_ADD_INCLUDEDIRS ".") + +register_component() \ No newline at end of file diff --git a/examples/NimBLE_Stream_Server/main/main.cpp b/examples/NimBLE_Stream_Server/main/main.cpp new file mode 100644 index 00000000..c650e95f --- /dev/null +++ b/examples/NimBLE_Stream_Server/main/main.cpp @@ -0,0 +1,146 @@ +/** + * NimBLE_Stream_Server Example: + * + * Demonstrates using NimBLEStreamServer to create a BLE GATT server + * that behaves like a serial port using the Stream-like interface. + */ + +#include +#include +#include + +#include "esp_heap_caps.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include + +// Create the stream server instance +NimBLEStreamServer bleStream; + +struct RxOverflowStats { + uint32_t droppedOld{0}; + uint32_t droppedNew{0}; +}; + +RxOverflowStats g_rxOverflowStats; + +NimBLEStream::RxOverflowAction onRxOverflow(const uint8_t* data, size_t len, void* userArg) { + auto* stats = static_cast(userArg); + if (stats) { + stats->droppedOld++; + } + + // Keep the newest bytes for command/stream style traffic. + (void)data; + (void)len; + return NimBLEStream::DROP_OLDER_DATA; +} + +static uint64_t millis() { + return esp_timer_get_time() / 1000ULL; +} + +// Service and Characteristic UUIDs for the stream. +#define SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +#define CHARACTERISTIC_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" + +/** Server callbacks to handle connection/disconnection events */ +class ServerCallbacks : public NimBLEServerCallbacks { + void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) override { + printf("Client connected: %s\n", connInfo.getAddress().toString().c_str()); + // Optionally update connection parameters for better throughput. + pServer->updateConnParams(connInfo.getConnHandle(), 12, 24, 0, 200); + } + + void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) override { + (void)pServer; + (void)connInfo; + printf("Client disconnected - reason: %d, restarting advertising\n", reason); + NimBLEDevice::startAdvertising(); + } + + void onMTUChange(uint16_t MTU, NimBLEConnInfo& connInfo) override { + printf("MTU updated: %u for connection ID: %u\n", MTU, connInfo.getConnHandle()); + } +} serverCallbacks; + +extern "C" void app_main(void) { + printf("Starting NimBLE Stream Server\n"); + + /** Initialize NimBLE and set the device name */ + NimBLEDevice::init("NimBLE-Stream"); + + /** + * Create the BLE server and set callbacks. + * Note: The stream will create its own service and characteristic. + */ + NimBLEServer* pServer = NimBLEDevice::createServer(); + pServer->setCallbacks(&serverCallbacks); + + /** + * Initialize the stream server with: + * - Service UUID + * - Characteristic UUID + * - txBufSize: 1024 bytes for outgoing data (notifications) + * - rxBufSize: 1024 bytes for incoming data (writes) + * - secure: false (no encryption required - set true for secure connections) + */ + if (!bleStream.begin(NimBLEUUID(SERVICE_UUID), + NimBLEUUID(CHARACTERISTIC_UUID), + 1024, + 1024, + false)) { + printf("Failed to initialize BLE stream!\n"); + return; + } + + bleStream.setRxOverflowCallback(onRxOverflow, &g_rxOverflowStats); + + // Make the stream service discoverable. + NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising(); + pAdvertising->addServiceUUID(SERVICE_UUID); + pAdvertising->setName("NimBLE-Stream"); + pAdvertising->enableScanResponse(true); + pAdvertising->start(); + + printf("BLE Stream Server ready!\n"); + printf("Waiting for client connection...\n"); + + uint32_t lastDroppedOld = 0; + uint32_t lastDroppedNew = 0; + uint64_t lastSend = 0; + + for (;;) { + if (g_rxOverflowStats.droppedOld != lastDroppedOld || g_rxOverflowStats.droppedNew != lastDroppedNew) { + lastDroppedOld = g_rxOverflowStats.droppedOld; + lastDroppedNew = g_rxOverflowStats.droppedNew; + printf("RX overflow handled (drop-old=%" PRIu32 ", drop-new=%" PRIu32 ")\n", lastDroppedOld, lastDroppedNew); + } + + if (bleStream.ready()) { + uint64_t now = esp_timer_get_time() / 1000ULL; + if (now - lastSend > 2000) { + lastSend = now; + bleStream.printf("Hello from client! Uptime: %" PRIu64 " seconds\n", now / 1000); + bleStream.printf("Free heap: %u bytes\n", esp_get_free_heap_size()); + printf("Sent data to server via BLE stream\n"); + } + + if (bleStream.available()) { + printf("Received from client: "); + while (bleStream.available()) { + char c = bleStream.read(); + putchar(c); + bleStream.write(c); // Echo back to BLE client. + } + printf("\n"); + } + } else { + vTaskDelay(pdMS_TO_TICKS(100)); + } + + vTaskDelay(pdMS_TO_TICKS(10)); + } +} \ No newline at end of file diff --git a/examples/NimBLE_Stream_Server/sdkconfig.defaults b/examples/NimBLE_Stream_Server/sdkconfig.defaults new file mode 100644 index 00000000..c829fc5c --- /dev/null +++ b/examples/NimBLE_Stream_Server/sdkconfig.defaults @@ -0,0 +1,12 @@ +# Override some defaults so BT stack is enabled +# in this example + +# +# BT config +# +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 diff --git a/src/NimBLEDevice.h b/src/NimBLEDevice.h index 28baadf6..df63b3dc 100644 --- a/src/NimBLEDevice.h +++ b/src/NimBLEDevice.h @@ -245,7 +245,7 @@ class NimBLEDevice { # endif # ifdef ESP_PLATFORM -# if NIMBLE_CPP_SCAN_DUPL_ENABLED +# if NIMBLE_CPP_SCAN_DUPL_ENABLED static uint16_t m_scanDuplicateSize; static uint8_t m_scanFilterMode; static uint16_t m_scanDuplicateResetTime; @@ -306,6 +306,7 @@ class NimBLEDevice { # if MYNEWT_VAL(BLE_ROLE_CENTRAL) || MYNEWT_VAL(BLE_ROLE_PERIPHERAL) # include "NimBLEConnInfo.h" +# include "NimBLEStream.h" # endif # include "NimBLEAddress.h" diff --git a/src/NimBLEStream.cpp b/src/NimBLEStream.cpp new file mode 100644 index 00000000..6156b918 --- /dev/null +++ b/src/NimBLEStream.cpp @@ -0,0 +1,1071 @@ +/* + * Copyright 2020-2025 Ryan Powell and + * esp-nimble-cpp, NimBLE-Arduino contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "NimBLEStream.h" +#if CONFIG_BT_NIMBLE_ENABLED && (MYNEWT_VAL(BLE_ROLE_PERIPHERAL) || MYNEWT_VAL(BLE_ROLE_CENTRAL)) + +# include "NimBLEDevice.h" +# include "NimBLELog.h" +# if defined(CONFIG_NIMBLE_CPP_IDF) +# include "os/os_mbuf.h" +# include "nimble/nimble_port.h" +# else +# include "nimble/porting/nimble/include/os/os_mbuf.h" +# include "nimble/porting/nimble/include/nimble/nimble_port.h" +# endif +# include +# include +# include +# include + +static const char* LOG_TAG = "NimBLEStream"; + +struct NimBLEStream::ByteRingBuffer { + /** @brief Guard for ByteRingBuffer to manage locking. */ + struct Guard { + const ByteRingBuffer& _b; + bool _locked; + Guard(const ByteRingBuffer& b) : _b(b), _locked(b.lock()) {} + ~Guard() { + if (_locked) _b.unlock(); + } + operator bool() const { return _locked; } // Allows: if (Guard g{*this}) { ... } + }; + + /** @brief Construct a ByteRingBuffer with the specified capacity. */ + explicit ByteRingBuffer(size_t capacity) : m_capacity(capacity) { + memset(&m_mutex, 0, sizeof(m_mutex)); + auto rc = ble_npl_mutex_init(&m_mutex); + if (rc != BLE_NPL_OK) { + NIMBLE_LOGE(LOG_TAG, "Failed to initialize ring buffer mutex, error: %d", rc); + return; + } + + m_buf = static_cast(malloc(capacity)); + if (!m_buf) { + NIMBLE_LOGE(LOG_TAG, "Failed to allocate ring buffer memory"); + return; + } + } + + /** @brief Destroy the ByteRingBuffer and release resources. */ + ~ByteRingBuffer() { + if (m_buf) { + free(m_buf); + } + ble_npl_mutex_deinit(&m_mutex); + } + + /** @brief Check if the ByteRingBuffer is valid. */ + bool valid() const { return m_buf != nullptr; } + + /** @brief Get the capacity of the ByteRingBuffer. */ + size_t capacity() const { return m_capacity; } + + /** @brief Get the current size of the ByteRingBuffer. */ + size_t size() const { + Guard g(*this); + return g ? m_size : 0; + } + + /** @brief Get the available free space in the ByteRingBuffer. */ + size_t freeSize() const { + Guard g(*this); + return g ? m_capacity - m_size : 0; + } + + /** + * @brief Write data to the ByteRingBuffer. + * @param data Pointer to the data to write. + * @param len Length of the data to write. + * @returns the number of bytes actually written, which may be less than len if the buffer does not have enough free space. + */ + size_t write(const uint8_t* data, size_t len) { + if (!data || len == 0) { + return 0; + } + + Guard g(*this); + if (!g || m_size >= m_capacity) { + return 0; + } + + size_t count = std::min(len, m_capacity - m_size); + size_t first = std::min(count, m_capacity - m_head); + memcpy(m_buf + m_head, data, first); + size_t remain = count - first; + if (remain > 0) { + memcpy(m_buf, data + first, remain); + } + + m_head = (m_head + count) % m_capacity; + m_size += count; + return count; + } + + /** + * @brief Read data from the ByteRingBuffer. + * @param out Pointer to the buffer where read data will be stored. + * @param len Maximum number of bytes to read. + * @returns the number of bytes actually read. + */ + size_t read(uint8_t* out, size_t len) { + if (!out || len == 0) { + return 0; + } + + Guard g(*this); + if (!g || m_size == 0) { + return 0; + } + + size_t count = std::min(len, m_size); + size_t first = std::min(count, m_capacity - m_tail); + memcpy(out, m_buf + m_tail, first); + size_t remain = count - first; + if (remain > 0) { + memcpy(out + first, m_buf, remain); + } + + m_tail = (m_tail + count) % m_capacity; + m_size -= count; + return count; + } + + /** + * @brief Peek at data in the ByteRingBuffer without removing it. + * @param out Pointer to the buffer where peeked data will be stored. + * @param len Maximum number of bytes to peek. + * @returns the number of bytes actually peeked. + */ + size_t peek(uint8_t* out, size_t len) { + if (!out || len == 0) { + return 0; + } + + Guard g(*this); + if (!g || m_size == 0) { + return 0; + } + + size_t count = std::min(len, m_size); + size_t first = std::min(count, m_capacity - m_tail); + memcpy(out, m_buf + m_tail, first); + size_t remain = count - first; + if (remain > 0) { + memcpy(out + first, m_buf, remain); + } + + return count; + } + + /** + * @brief Drop data from the ByteRingBuffer without reading it. + * @param len Maximum number of bytes to drop. + * @returns the number of bytes actually dropped. + */ + size_t drop(size_t len) { + if (len == 0) { + return 0; + } + + Guard g(*this); + if (!g || m_size == 0) { + return 0; + } + size_t count = std::min(len, m_size); + m_tail = (m_tail + count) % m_capacity; + m_size -= count; + return count; + } + + private: + /** + * @brief Lock the ByteRingBuffer for exclusive access. + * @return true if the lock was successfully acquired, false otherwise. + */ + bool lock() const { return valid() && ble_npl_mutex_pend(&m_mutex, BLE_NPL_TIME_FOREVER) == BLE_NPL_OK; } + + /** + * @brief Unlock the ByteRingBuffer after exclusive access. + */ + void unlock() const { ble_npl_mutex_release(&m_mutex); } + + uint8_t* m_buf{nullptr}; + size_t m_capacity{0}; + size_t m_head{0}; + size_t m_tail{0}; + size_t m_size{0}; + mutable ble_npl_mutex m_mutex{}; +}; + +// Stub Print/Stream implementations when Arduino not available +# if !NIMBLE_CPP_ARDUINO_STRING_AVAILABLE +# include + +size_t Print::print(const char* s) { + if (!s) return 0; + return write(reinterpret_cast(s), strlen(s)); +} + +size_t Print::println(const char* s) { + size_t n = print(s); + static const char crlf[] = "\r\n"; + n += write(reinterpret_cast(crlf), 2); + return n; +} + +size_t Print::printf(const char* fmt, ...) { + if (!fmt) { + return 0; + } + + char stackBuf[128]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(stackBuf, sizeof(stackBuf), fmt, ap); + va_end(ap); + if (n < 0) { + return 0; + } + + if (static_cast(n) < sizeof(stackBuf)) { + return write(reinterpret_cast(stackBuf), static_cast(n)); + } + + // allocate for larger output + size_t needed = static_cast(n) + 1; + char* buf = static_cast(malloc(needed)); + if (!buf) { + return 0; + } + + va_start(ap, fmt); + vsnprintf(buf, needed, fmt, ap); + va_end(ap); + size_t ret = write(reinterpret_cast(buf), static_cast(n)); + free(buf); + return ret; +} +# endif + +/** + * @brief Initialize the NimBLEStream, creating TX and RX buffers and setting up events. + * @return true if initialization was successful, false otherwise. + */ +bool NimBLEStream::begin() { + if (m_txBuf || m_rxBuf) { + NIMBLE_LOGW(LOG_TAG, "Already initialized"); + return true; + } + + if (m_txBufSize == 0 && m_rxBufSize == 0) { + NIMBLE_LOGE(LOG_TAG, "Cannot initialize stream with both TX and RX buffer sizes set to 0"); + return false; + } + + if (ble_npl_callout_init(&m_txDrainCallout, nimble_port_get_dflt_eventq(), NimBLEStream::txDrainCalloutCb, this) != 0) { + NIMBLE_LOGE(LOG_TAG, "Failed to initialize TX drain callout"); + return false; + } + m_coInitialized = true; + + ble_npl_event_init(&m_txDrainEvent, NimBLEStream::txDrainEventCb, this); + m_eventInitialized = true; + + if (m_txBufSize) { + m_txBuf = new ByteRingBuffer(m_txBufSize); + if (!m_txBuf || !m_txBuf->valid()) { + NIMBLE_LOGE(LOG_TAG, "Failed to create TX ringbuffer"); + end(); + return false; + } + } + + if (m_rxBufSize) { + m_rxBuf = new ByteRingBuffer(m_rxBufSize); + if (!m_rxBuf || !m_rxBuf->valid()) { + NIMBLE_LOGE(LOG_TAG, "Failed to create RX ringbuffer"); + end(); + return false; + } + } + + return true; +} + +/** + * @brief Clean up the NimBLEStream, stopping events and deleting buffers. + */ +void NimBLEStream::end() { + if (m_coInitialized) { + ble_npl_callout_stop(&m_txDrainCallout); + ble_npl_callout_deinit(&m_txDrainCallout); + m_coInitialized = false; + } + + if (m_eventInitialized) { + ble_npl_eventq_remove(nimble_port_get_dflt_eventq(), &m_txDrainEvent); + ble_npl_event_deinit(&m_txDrainEvent); + m_eventInitialized = false; + } + + if (m_txBuf) { + delete m_txBuf; + m_txBuf = nullptr; + } + + if (m_rxBuf) { + delete m_rxBuf; + m_rxBuf = nullptr; + } +} + +/** + * @brief Write data to the stream, which will be sent over BLE. + * @param data Pointer to the data to write. + * @param len Length of the data to write. + * @return the number of bytes actually written to the stream buffer. + */ +size_t NimBLEStream::write(const uint8_t* data, size_t len) { + if (!m_txBuf) { + return 0; + } + + auto written = m_txBuf->write(data, len); + drainTx(); + return written; +} + +/** + * @brief Get the available free space in the stream's TX buffer. + * @return the number of bytes that can be written to the stream without blocking. + */ +size_t NimBLEStream::availableForWrite() const { + return m_txBuf ? m_txBuf->freeSize() : 0; +} + +/** + * @brief Schedule the stream to attempt to send data from the TX buffer. + * @details This should be called whenever new data is written to the TX buffer + * or when a send attempt fails due to lack of BLE buffers. + */ +void NimBLEStream::drainTx() { + if (!m_txBuf || m_txBuf->size() == 0) { + return; + } + + ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &m_txDrainEvent); +} + +/** + * @brief Event callback for when the stream is scheduled to drain the TX buffer. + * @param ev Pointer to the event that triggered the callback. + * @details This will attempt to send data from the TX buffer. If sending fails due to + * lack of BLE buffers, it will reschedule itself to try again after a short delay. + */ +void NimBLEStream::txDrainEventCb(struct ble_npl_event* ev) { + if (!ev) { + return; + } + + auto* stream = static_cast(ble_npl_event_get_arg(ev)); + if (!stream) { + return; + } + + if (stream->send()) { + // Schedule a short delayed retry to give the stack time to free buffers, use 5ms for now + // TODO: consider options for the delay time and retry strategy if the stack is persistently out of buffers + ble_npl_callout_reset(&stream->m_txDrainCallout, ble_npl_time_ms_to_ticks32(5)); + } +} + +/** + * @brief Callout callback for when the stream is scheduled to retry draining the TX buffer. + * @param ev Pointer to the event that triggered the callback. + * @details This will call drainTx() to attempt to send data from the TX buffer again. + */ +void NimBLEStream::txDrainCalloutCb(struct ble_npl_event* ev) { + if (!ev) { + return; + } + + auto* stream = static_cast(ble_npl_event_get_arg(ev)); + if (!stream) { + return; + } + + stream->drainTx(); +} + +/** + * @brief Get the number of bytes available to read from the stream. + * @return the number of bytes that can be read from the stream. + */ +int NimBLEStream::available() { + if (!m_rxBuf) { + return 0; + } + + return static_cast(m_rxBuf->size()); +} + +/** + * @brief Read a single byte from the stream. + * @return the byte read as an int, or -1 if no data is available. + */ +int NimBLEStream::read() { + if (!m_rxBuf) { + return -1; + } + + uint8_t byte = 0; + if (m_rxBuf->read(&byte, 1) == 0) { + return -1; + } + + return static_cast(byte); +} + +/** + * @brief Peek at the next byte in the stream without removing it. + * @return the byte peeked as an int, or -1 if no data is available. + */ +int NimBLEStream::peek() { + if (!m_rxBuf) { + return -1; + } + + uint8_t byte = 0; + if (m_rxBuf->peek(&byte, 1) == 0) { + return -1; + } + + return static_cast(byte); +} + +/** + * @brief Read data from the stream into a buffer. + * @param buffer Pointer to the buffer where read data will be stored. + * @param len Maximum number of bytes to read. + * @return the number of bytes actually read from the stream. + */ +size_t NimBLEStream::read(uint8_t* buffer, size_t len) { + if (!m_rxBuf) { + return 0; + } + + return m_rxBuf->read(buffer, len); +} + +/** + * @brief Push received data into the stream's RX buffer. + * @param data Pointer to the data to push into the RX buffer. + * @param len Length of the data to push. + * @return the number of bytes actually pushed into the RX buffer, which may be less than + * len if the buffer does not have enough free space. + */ +size_t NimBLEStream::pushRx(const uint8_t* data, size_t len) { + if (!ready()) { + return 0; + } + + ByteRingBuffer::Guard g(*m_rxBuf); + if (!g) { + NIMBLE_LOGE(LOG_TAG, "Failed to acquire RX buffer lock to push data"); + return 0; + } + + size_t freeSize = m_rxBuf->freeSize(); + if (len > freeSize) { + const RxOverflowAction action = m_rxOverflowCallback ? m_rxOverflowCallback(data, len, m_rxOverflowUserArg) + : DROP_NEW_DATA; + if (action != DROP_OLDER_DATA) { + NIMBLE_LOGE(LOG_TAG, "RX buffer overflow, dropping current data"); + return 0; + } + + if (len >= m_rxBuf->capacity()) { + m_rxBuf->drop(m_rxBuf->size()); + const uint8_t* tail = data + (len - m_rxBuf->capacity()); + size_t written = m_rxBuf->write(tail, m_rxBuf->capacity()); + if (written != m_rxBuf->capacity()) { + NIMBLE_LOGE(LOG_TAG, "RX buffer overflow, %zu bytes dropped", m_rxBuf->capacity() - written); + } + return written; + } + + const size_t requiredSpace = len - freeSize; + size_t dropped = m_rxBuf->drop(requiredSpace); + if (dropped < requiredSpace) { + NIMBLE_LOGE(LOG_TAG, "RX buffer overflow, failed to drop enough buffered data"); + return 0; + } + } + + return m_rxBuf->write(data, len); +} + +# if MYNEWT_VAL(BLE_ROLE_PERIPHERAL) +/** + * @brief Initialize the NimBLEStreamServer with an existing characteristic. + * @param pChr Pointer to the existing NimBLECharacteristic to use for the stream. + * @param txBufSize Size of the TX buffer. + * @param rxBufSize Size of the RX buffer. + * @return true if initialization was successful, false otherwise. + * @details The provided characteristic must have the NOTIFY property set to allow the server + * to send data (TX) to the client, and the WRITE or WRITE_NR property set to allow the server + * to receive data (RX) from the client. + * The RX buffer will only be created if the characteristic has WRITE or WRITE_NR properties + * (i.e. can receive data from the client). + * The TX buffer will only be created if the characteristic has NOTIFY properties + * (i.e. can send data to the client). + */ +bool NimBLEStreamServer::begin(NimBLECharacteristic* pChr, uint32_t txBufSize, uint32_t rxBufSize) { + if (!NimBLEDevice::isInitialized()) { + return false; + } + + if (m_pChr) { + NIMBLE_LOGW(LOG_TAG, "Already initialized with a characteristic"); + return true; + } + + if (!pChr) { + NIMBLE_LOGE(LOG_TAG, "Characteristic is null"); + return false; + } + + auto props = pChr->getProperties(); + bool canWrite = (props & NIMBLE_PROPERTY::WRITE) || (props & NIMBLE_PROPERTY::WRITE_NR); + if (!canWrite && rxBufSize > 0) { + NIMBLE_LOGW(LOG_TAG, "Characteristic does not support WRITE, ignoring RX buffer size"); + } + + bool canNotify = props & NIMBLE_PROPERTY::NOTIFY; + if (!canNotify && txBufSize > 0) { + NIMBLE_LOGW(LOG_TAG, "Characteristic does not support NOTIFY, ignoring TX buffer size"); + } + + m_rxBufSize = canWrite ? rxBufSize : 0; // disable RX if not writable + m_txBufSize = canNotify ? txBufSize : 0; // disable TX if notifications not supported + + if (!NimBLEStream::begin()) { + NIMBLE_LOGE(LOG_TAG, "Failed to initialize stream buffers"); + return false; + } + + m_charCallbacks.m_userCallbacks = pChr->getCallbacks(); + pChr->setCallbacks(&m_charCallbacks); + m_pChr = pChr; + return true; +} + +/** + * @brief Initialize the NimBLEStreamServer, creating a BLE service and characteristic for streaming. + * @param svcUuid UUID of the BLE service to create. + * @param chrUuid UUID of the BLE characteristic to create. + * @param txBufSize Size of the TX buffer, set to 0 to disable TX and create a write-only characteristic. + * @param rxBufSize Size of the RX buffer, set to 0 to disable RX and create a notify-only characteristic. + * @param secure Whether the characteristic requires encryption. + * @return true if initialization was successful, false otherwise. + */ +bool NimBLEStreamServer::begin( + const NimBLEUUID& svcUuid, const NimBLEUUID& chrUuid, uint32_t txBufSize, uint32_t rxBufSize, bool secure) { + if (!NimBLEDevice::isInitialized()) { + NIMBLE_LOGE(LOG_TAG, "NimBLEDevice not initialized"); + return false; + } + + if (m_pChr != nullptr) { + NIMBLE_LOGE(LOG_TAG, "NimBLEStreamServer already initialized;"); + return false; + } + + NimBLEServer* pServer = NimBLEDevice::getServer(); + if (!pServer) { + pServer = NimBLEDevice::createServer(); + } + + auto pSvc = pServer->createService(svcUuid); + if (!pSvc) { + return false; + } + + // Create characteristic with notify + write properties for bidirectional stream + uint32_t props = 0; + if (txBufSize > 0) { + props |= NIMBLE_PROPERTY::NOTIFY; + if (secure) { + props |= NIMBLE_PROPERTY::READ_ENC; + } + } + + if (rxBufSize > 0) { + props |= NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR; + if (secure) { + props |= NIMBLE_PROPERTY::WRITE_ENC; + } + } + + auto pChr = pSvc->createCharacteristic(chrUuid, props); + if (!pChr) { + NIMBLE_LOGE(LOG_TAG, "Failed to create characteristic"); + goto error; + } + + m_deleteSvcOnEnd = true; // mark service for deletion on end since we created it here + + if (!pSvc->start()) { + NIMBLE_LOGE(LOG_TAG, "Failed to start service"); + goto error; + } + + if (!begin(pChr, txBufSize, rxBufSize)) { + NIMBLE_LOGE(LOG_TAG, "Failed to initialize stream with characteristic"); + goto error; + } + + return true; + +error: + pServer->removeService(pSvc, true); // delete service and all its characteristics + m_pChr = nullptr; // reset characteristic pointer as it's now invalid after service removal + end(); + return false; +} + +/** + * @brief Stop the NimBLEStreamServer + * @details This will stop the stream and delete the service created if it was created by this class. + */ +void NimBLEStreamServer::end() { + if (m_pChr) { + if (m_deleteSvcOnEnd) { + auto pSvc = m_pChr->getService(); + if (pSvc) { + auto pServer = pSvc->getServer(); + if (pServer) { + pServer->removeService(pSvc, true); + } + } + } else { + m_pChr->setCallbacks(m_charCallbacks.m_userCallbacks); // restore any user callbacks + } + } + + m_pChr = nullptr; + m_charCallbacks.m_peerHandle = BLE_HS_CONN_HANDLE_NONE; + m_charCallbacks.m_userCallbacks = nullptr; + m_deleteSvcOnEnd = false; + NimBLEStream::end(); +} + +/** + * @brief Write data to the stream, which will be sent as BLE notifications. + * @param data Pointer to the data to write. + * @param len Length of the data to write. + * @return the number of bytes actually written to the stream buffer. + */ +size_t NimBLEStreamServer::write(const uint8_t* data, size_t len) { + if (!m_pChr || len == 0 || !ready()) { + return 0; + } + +# if MYNEWT_VAL(NIMBLE_CPP_LOG_LEVEL) >= 4 + // Skip server gap events to avoid log recursion + static const char filterStr[] = "handleGapEvent"; + constexpr size_t filterLen = sizeof(filterStr) - 1; + if (len >= filterLen + 3) { + for (size_t i = 3; i <= len - filterLen; i++) { + if (memcmp(data + i, filterStr, filterLen) == 0) { + return len; // drop to avoid recursion + } + } + } +# endif + + return NimBLEStream::write(data, len); +} + +/** + * @brief Flush all pending TX data by attempting immediate sends. + * @details This blocks while trying to drain the TX buffer. If send cannot make + * progress (e.g. disconnected or persistent failure), queued TX/RX data is cleared. + */ +void NimBLEStreamServer::flush() { + if (!m_txBuf || m_txBuf->size() == 0) { + return; + } + + const uint32_t timeoutMs = static_cast(std::min(getTimeout(), 0xFFFFFFFFUL)); + const uint32_t retryDelay = std::max(1, ble_npl_time_ms_to_ticks32(5)); + uint32_t waitStart = ble_npl_time_get(); + while (m_txBuf->size() > 0) { + size_t before = m_txBuf->size(); + bool retry = send(); + size_t after = m_txBuf->size(); + + if (after == 0) { + return; + } + + if (after < before) { + waitStart = ble_npl_time_get(); + continue; + } + + if (retry && timeoutMs > 0) { + const uint32_t elapsed = ble_npl_time_get() - waitStart; + if (elapsed < ble_npl_time_ms_to_ticks32(timeoutMs)) { + ble_npl_time_delay(retryDelay); + continue; + } + } + + m_txBuf->drop(m_txBuf->size()); + if (m_rxBuf) { + m_rxBuf->drop(m_rxBuf->size()); + } + return; + } +} + +/** + * @brief Attempt to send data from the TX buffer as BLE notifications. + * @return true if a retry should be scheduled, false otherwise. + * @details This will try to send as much data as possible from the TX buffer in chunks + * that fit within the current BLE MTU. If sending fails due to lack of BLE buffers, it will return true + * to indicate that a retry should be scheduled, but it will not drop any data from the TX buffer. + * For other errors or if all data is sent, it returns false. + */ +bool NimBLEStreamServer::send() { + if (!m_pChr || !m_txBuf || !ready()) { + return false; + } + + size_t mtu = ble_att_mtu(getPeerHandle()); + if (mtu < 23) { + return false; + } + + size_t maxDataLen = std::min(mtu - 3, sizeof(m_txChunkBuf)); + + while (m_txBuf->size()) { + size_t chunkLen = m_txBuf->peek(m_txChunkBuf, maxDataLen); + if (!chunkLen) { + break; + } + + if (!m_pChr->notify(m_txChunkBuf, chunkLen, getPeerHandle())) { + if (m_rc == BLE_HS_ENOMEM || os_msys_num_free() <= 2) { + // NimBLE stack out of buffers, likely due to pending notifications/indications + // Don't drop data, but wait for stack to free buffers and try again later + return true; + } + + return false; // disconnect or other error don't retry send, preserve data for next attempt + } + + m_txBuf->drop(chunkLen); + } + + return false; // no more data to send +} + +/** + * @brief Check if the stream is ready to send/receive data, which requires an active BLE connection. + * @return true if the stream is ready, false otherwise. + */ +bool NimBLEStreamServer::ready() const { + if (m_txBufSize > 0 && !m_txBuf) { + return false; + } + + if (m_rxBufSize > 0 && !m_rxBuf) { + return false; + } + + // Write-only mode has no TX peer tracking requirements. + if (m_txBufSize == 0) { + return m_rxBufSize > 0; + } + + if (m_charCallbacks.m_peerHandle == BLE_HS_CONN_HANDLE_NONE) { + return false; + } + + return ble_gap_conn_find(m_charCallbacks.m_peerHandle, nullptr) == 0; +} + +/** + * @brief Callback for when the characteristic is written to by a client. + * @param pChr Pointer to the characteristic that was written to. + * @param connInfo Information about the connection that performed the write. + * @details This will push the received data into the RX buffer and call any user-defined callbacks. + */ +void NimBLEStreamServer::ChrCallbacks::onWrite(NimBLECharacteristic* pChr, NimBLEConnInfo& connInfo) { + // Push received data into RX buffer + auto val = pChr->getValue(); + m_parent->pushRx(val.data(), val.size()); + + if (m_userCallbacks) { + m_userCallbacks->onWrite(pChr, connInfo); + } +} + +/** + * @brief Callback for when a client subscribes or unsubscribes to notifications/indications. + * @param pChr Pointer to the characteristic that was subscribed/unsubscribed. + * @param connInfo Information about the connection that performed the subscribe/unsubscribe. + * @param subValue The new subscription value (0 for unsubscribe, non-zero for subscribe). + * @details This will track the subscriber's connection handle and call any user-defined callbacks. + * Only one subscriber is supported; if another client tries to subscribe while one is already subscribed, it will be ignored. + */ +void NimBLEStreamServer::ChrCallbacks::onSubscribe(NimBLECharacteristic* pChr, NimBLEConnInfo& connInfo, uint16_t subValue) { + // If we have a stored peer handle, ensure it still refers to an active connection. + // If the connection has gone away without an explicit unsubscribe, clear it so a new + // subscriber can be accepted. + if (m_peerHandle != BLE_HS_CONN_HANDLE_NONE) { + if (ble_gap_conn_find(m_peerHandle, nullptr) != 0) { + m_peerHandle = BLE_HS_CONN_HANDLE_NONE; + } + } + + // only one subscriber supported + if (subValue && m_peerHandle != BLE_HS_CONN_HANDLE_NONE) { + NIMBLE_LOGI(LOG_TAG, + "Already have a subscriber, rejecting new subscription from conn handle %d", + connInfo.getConnHandle()); + return; + } + + m_peerHandle = subValue ? connInfo.getConnHandle() : BLE_HS_CONN_HANDLE_NONE; + if (m_userCallbacks) { + m_userCallbacks->onSubscribe(pChr, connInfo, subValue); + } +} + +/** + * @brief Callback for when the connection status changes (e.g. disconnect). + * @param pChr Pointer to the characteristic associated with the status change. + * @param connInfo Information about the connection that changed status. + * @param code The new status code (e.g. success or error code). + * @details The code is used to track when the stack is out of buffers (BLE_HS_ENOMEM) + * to trigger retries without dropping data. User-defined callbacks will also be called if set. + */ +void NimBLEStreamServer::ChrCallbacks::onStatus(NimBLECharacteristic* pChr, NimBLEConnInfo& connInfo, int code) { + m_parent->m_rc = code; + if (m_userCallbacks) { + m_userCallbacks->onStatus(pChr, connInfo, code); + } +} + +# endif // MYNEWT_VAL(BLE_ROLE_PERIPHERAL) + +# if MYNEWT_VAL(BLE_ROLE_CENTRAL) +/** + * @brief Initialize the NimBLEStreamClient, setting up the remote characteristic and subscribing to notifications if requested. + * @param pChr Pointer to the remote characteristic to use for streaming. + * @param subscribe Whether to subscribe to notifications/indications from the characteristic for RX. + * @param txBufSize Size of the TX buffer. + * @param rxBufSize Size of the RX buffer. + * @return true if initialization was successful, false otherwise. + * @details The characteristic must support write without response for TX. + * If subscribe is true, it will subscribe to notifications/indications for RX. + * If subscribe is false, the RX buffer will not be created and no notifications will be received. + */ +bool NimBLEStreamClient::begin(NimBLERemoteCharacteristic* pChr, bool subscribe, uint32_t txBufSize, uint32_t rxBufSize) { + if (!NimBLEDevice::isInitialized()) { + NIMBLE_LOGE(LOG_TAG, "NimBLE stack not initialized, call NimBLEDevice::init() first"); + return false; + } + + if (m_pChr) { + NIMBLE_LOGW(LOG_TAG, "Already initialized, must end() first"); + return true; + } + + if (!pChr) { + NIMBLE_LOGE(LOG_TAG, "Remote characteristic is null"); + return false; + } + + if (!pChr->canWriteNoResponse()) { + NIMBLE_LOGE(LOG_TAG, "Characteristic does not support write without response"); + return false; + } + + if (subscribe && !pChr->canNotify() && !pChr->canIndicate()) { + NIMBLE_LOGW(LOG_TAG, "Characteristic does not support subscriptions, RX disabled"); + subscribe = false; // disable subscribe if not supported + } + + m_txBufSize = txBufSize; + m_rxBufSize = subscribe ? rxBufSize : 0; // disable RX buffer if not subscribing + + if (!NimBLEStream::begin()) { + NIMBLE_LOGE(LOG_TAG, "Failed to initialize stream buffers"); + return false; + } + + // Subscribe to notifications/indications for RX if requested + if (subscribe) { + using namespace std::placeholders; + if (!pChr->subscribe(pChr->canNotify(), std::bind(&NimBLEStreamClient::notifyCallback, this, _1, _2, _3, _4))) { + NIMBLE_LOGE(LOG_TAG, "Failed to subscribe for %s", pChr->canNotify() ? "notifications" : "indications"); + end(); + return false; + } + } + + m_pChr = pChr; + return true; +} + +/** + * @brief Clean up the NimBLEStreamClient, unsubscribing from notifications and clearing the remote characteristic reference. + */ +void NimBLEStreamClient::end() { + if (m_pChr && (m_pChr->canNotify() || m_pChr->canIndicate())) { + m_pChr->unsubscribe(); + } + + m_pChr = nullptr; + NimBLEStream::end(); +} + +/** + * @brief Flush all pending TX data by attempting immediate writes. + * @details This blocks while trying to drain the TX buffer. If send cannot make + * progress (e.g. disconnected or persistent failure), queued TX/RX data is cleared. + */ +void NimBLEStreamClient::flush() { + if (!m_txBuf || m_txBuf->size() == 0) { + return; + } + + const uint32_t timeoutMs = static_cast(std::min(getTimeout(), 0xFFFFFFFFUL)); + const uint32_t retryDelay = std::max(1, ble_npl_time_ms_to_ticks32(5)); + uint32_t waitStart = ble_npl_time_get(); + while (m_txBuf->size() > 0) { + size_t before = m_txBuf->size(); + bool retry = send(); + size_t after = m_txBuf->size(); + + if (after == 0) { + return; + } + + if (after < before) { + waitStart = ble_npl_time_get(); + continue; + } + + if (retry && timeoutMs > 0) { + const uint32_t elapsed = ble_npl_time_get() - waitStart; + if (elapsed < ble_npl_time_ms_to_ticks32(timeoutMs)) { + ble_npl_time_delay(retryDelay); + continue; + } + } + + m_txBuf->drop(m_txBuf->size()); + if (m_rxBuf) { + m_rxBuf->drop(m_rxBuf->size()); + } + return; + } +} + +/** + * @brief Write data to the stream, which will be sent as BLE writes to the remote characteristic. + * @return True if a retry should be scheduled due to lack of BLE buffers, false otherwise. + * @details This will try to send as much data as possible from the TX buffer in chunks + * that fit within the memory buffers. If sending fails due to lack of BLE buffers, it will return true + * to indicate that a retry should be scheduled, but it will not drop any data from the TX buffer. + * For other errors or if all data is sent, it returns false. + */ +bool NimBLEStreamClient::send() { + if (!ready()) { + return false; + } + + auto mtu = m_pChr->getClient()->getMTU(); + if (mtu < 23) { + return false; + } + + size_t maxDataLen = std::min(mtu - 3, sizeof(m_txChunkBuf)); + + while (m_txBuf->size()) { + size_t chunkLen = m_txBuf->peek(m_txChunkBuf, maxDataLen); + if (!chunkLen) { + break; + } + + if (!m_pChr->writeValue(m_txChunkBuf, chunkLen, false)) { + if (os_msys_num_free() <= 2) { + // NimBLE stack out of buffers, likely due to pending writes + // Don't drop data, wait for stack to free buffers and try again later + return true; + } + + break; // preserve data, no retry + } + + m_txBuf->drop(chunkLen); + } + + return false; // don't retry, it's either sent or we are disconnected +} + +/** + * @brief Check if the stream is ready for communication. + * @return true if the stream is ready, false otherwise. + * @details The stream is considered ready if buffers are allocated, the remote characteristic is set, + * and the client connection is active. + */ +bool NimBLEStreamClient::ready() const { + if (m_txBufSize > 0 && !m_txBuf) { + return false; + } + + if (m_rxBufSize > 0 && !m_rxBuf) { + return false; + } + + return m_pChr != nullptr && m_pChr->getClient()->isConnected(); +} + +/** + * @brief Callback for when a notification or indication is received from the remote characteristic. + * @param pChar Pointer to the characteristic that sent the notification/indication. + * @param pData Pointer to the data received in the notification/indication. + * @param len Length of the data received. + * @param isNotify True if the data was received as a notification, false if it was received as an indication. + * @details This will push the received data into the RX buffer and call any user-defined callbacks. + */ +void NimBLEStreamClient::notifyCallback(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t len, bool isNotify) { + pushRx(pData, len); + if (m_userNotifyCallback) { + m_userNotifyCallback(pChar, pData, len, isNotify); + } +} +# endif // MYNEWT_VAL(BLE_ROLE_CENTRAL) +#endif // CONFIG_BT_NIMBLE_ENABLED && (MYNEWT_VAL(BLE_ROLE_PERIPHERAL) || MYNEWT_VAL(BLE_ROLE_CENTRAL)) diff --git a/src/NimBLEStream.h b/src/NimBLEStream.h new file mode 100644 index 00000000..b5ba6d80 --- /dev/null +++ b/src/NimBLEStream.h @@ -0,0 +1,227 @@ +/* + * Copyright 2020-2025 Ryan Powell and + * esp-nimble-cpp, NimBLE-Arduino contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef NIMBLE_CPP_STREAM_H +#define NIMBLE_CPP_STREAM_H + +#include "syscfg/syscfg.h" +#if CONFIG_BT_NIMBLE_ENABLED && (MYNEWT_VAL(BLE_ROLE_PERIPHERAL) || MYNEWT_VAL(BLE_ROLE_CENTRAL)) + +# if defined(CONFIG_NIMBLE_CPP_IDF) +# include "nimble/nimble_npl.h" +# else +# include "nimble/nimble/include/nimble/nimble_npl.h" +# endif + +# include +# include +# include + +# if NIMBLE_CPP_ARDUINO_STRING_AVAILABLE +# include +# else + +// Minimal Stream/Print stubs when Arduino not available +class Print { + public: + virtual ~Print() {} + virtual size_t write(uint8_t) = 0; + virtual size_t write(const uint8_t* buffer, size_t size) = 0; + size_t print(const char* s); + size_t println(const char* s); + size_t printf(const char* format, ...) __attribute__((format(printf, 2, 3))); +}; + +class Stream : public Print { + public: + virtual int available() = 0; + virtual int read() = 0; + virtual int peek() = 0; + virtual void flush() {} + void setTimeout(unsigned long timeout) { m_timeout = timeout; } + unsigned long getTimeout() const { return m_timeout; } + + protected: + unsigned long m_timeout{0}; +}; +# endif + +class NimBLEStream : public Stream { + public: + enum RxOverflowAction { + DROP_OLDER_DATA, // Drop older buffered data to make room for new data + DROP_NEW_DATA // Drop new incoming data when buffer is full + }; + + using RxOverflowCallback = std::function; + + NimBLEStream() = default; + virtual ~NimBLEStream() { end(); } + + // Print/Stream TX methods + virtual size_t write(const uint8_t* data, size_t len) override; + virtual size_t write(uint8_t data) override { return write(&data, 1); } + + // Template for other integral types (char, int, long, etc.) + template + typename std::enable_if::value && !std::is_same::value, size_t>::type write(T data) { + return write(static_cast(data)); + } + + size_t availableForWrite() const; + + // Read up to len bytes into buffer (non-blocking) + size_t read(uint8_t* buffer, size_t len); + + // Stream RX methods + virtual int available() override; + virtual int read() override; + virtual int peek() override; + virtual bool ready() const = 0; + + /** + * @brief Set a callback to be invoked when incoming data exceeds RX buffer capacity. + * @param cb The callback function, which should return DROP_OLDER_DATA to drop older buffered data and + * make room for the new data, or DROP_NEW_DATA to drop the new data instead. + */ + void setRxOverflowCallback(RxOverflowCallback cb, void* userArg = nullptr) { + m_rxOverflowCallback = cb; + m_rxOverflowUserArg = userArg; + } + + operator bool() const { return ready(); } + + using Print::write; + + struct ByteRingBuffer; + + protected: + bool begin(); + void drainTx(); + size_t pushRx(const uint8_t* data, size_t len); + virtual void end(); + virtual bool send() = 0; + static void txDrainEventCb(struct ble_npl_event* ev); + static void txDrainCalloutCb(struct ble_npl_event* ev); + + ByteRingBuffer* m_txBuf{nullptr}; + ByteRingBuffer* m_rxBuf{nullptr}; + uint8_t m_txChunkBuf[MYNEWT_VAL(BLE_ATT_PREFERRED_MTU)]; + uint32_t m_txBufSize{1024}; + uint32_t m_rxBufSize{1024}; + ble_npl_event m_txDrainEvent{}; + ble_npl_callout m_txDrainCallout{}; + RxOverflowCallback m_rxOverflowCallback{nullptr}; + void* m_rxOverflowUserArg{nullptr}; + bool m_coInitialized{false}; + bool m_eventInitialized{false}; +}; + +# if MYNEWT_VAL(BLE_ROLE_PERIPHERAL) +# include "NimBLECharacteristic.h" + +class NimBLEStreamServer : public NimBLEStream { + public: + NimBLEStreamServer() : m_charCallbacks(this) {} + ~NimBLEStreamServer() override { end(); } + + // non-copyable + NimBLEStreamServer(const NimBLEStreamServer&) = delete; + NimBLEStreamServer& operator=(const NimBLEStreamServer&) = delete; + + bool begin(NimBLECharacteristic* chr, uint32_t txBufSize = 1024, uint32_t rxBufSize = 1024); + + // Convenience overload to create service/characteristic internally; service will be deleted on end() + bool begin(const NimBLEUUID& svcUuid, + const NimBLEUUID& chrUuid, + uint32_t txBufSize = 1024, + uint32_t rxBufSize = 1024, + bool secure = false); + + void end() override; + size_t write(const uint8_t* data, size_t len) override; + uint16_t getPeerHandle() const { return m_charCallbacks.m_peerHandle; } + void setCallbacks(NimBLECharacteristicCallbacks* pCallbacks) { m_charCallbacks.m_userCallbacks = pCallbacks; } + bool ready() const override; + virtual void flush() override; + + using NimBLEStream::write; // Inherit template write overloads + + protected: + bool send() override; + + struct ChrCallbacks : public NimBLECharacteristicCallbacks { + ChrCallbacks(NimBLEStreamServer* parent) + : m_parent(parent), m_userCallbacks(nullptr), m_peerHandle(BLE_HS_CONN_HANDLE_NONE) {} + void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) override; + void onSubscribe(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo, uint16_t subValue) override; + void onStatus(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo, int code) override; + // override this to avoid recursion when debug logs are enabled + void onStatus(NimBLECharacteristic* pCharacteristic, int code) override { + if (m_userCallbacks != nullptr) { + m_userCallbacks->onStatus(pCharacteristic, code); + } + } + + NimBLEStreamServer* m_parent; + NimBLECharacteristicCallbacks* m_userCallbacks; + uint16_t m_peerHandle; + } m_charCallbacks; + + NimBLECharacteristic* m_pChr{nullptr}; + int m_rc{0}; + // Whether to delete the BLE service when end() is called; set to false if service is managed externally + bool m_deleteSvcOnEnd{false}; +}; +# endif // BLE_ROLE_PERIPHERAL + +# if MYNEWT_VAL(BLE_ROLE_CENTRAL) +# include "NimBLERemoteCharacteristic.h" + +class NimBLEStreamClient : public NimBLEStream { + public: + NimBLEStreamClient() = default; + ~NimBLEStreamClient() override { end(); } + + // non-copyable + NimBLEStreamClient(const NimBLEStreamClient&) = delete; + NimBLEStreamClient& operator=(const NimBLEStreamClient&) = delete; + + // Attach a discovered remote characteristic; app owns discovery/connection. + // Set subscribeNotify=true to receive notifications into RX buffer. + bool begin(NimBLERemoteCharacteristic* pChr, + bool subscribeNotify = false, + uint32_t txBufSize = 1024, + uint32_t rxBufSize = 1024); + void end() override; + void setNotifyCallback(NimBLERemoteCharacteristic::notify_callback cb) { m_userNotifyCallback = cb; } + bool ready() const override; + virtual void flush() override; + + using NimBLEStream::write; // Inherit template write overloads + + protected: + bool send() override; + void notifyCallback(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t len, bool isNotify); + + NimBLERemoteCharacteristic* m_pChr{nullptr}; + NimBLERemoteCharacteristic::notify_callback m_userNotifyCallback{nullptr}; +}; +# endif // BLE_ROLE_CENTRAL + +#endif // CONFIG_BT_NIMBLE_ENABLED && (MYNEWT_VAL(BLE_ROLE_PERIPHERAL) || MYNEWT_VAL(BLE_ROLE_CENTRAL)) +#endif // NIMBLE_CPP_STREAM_H