Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/tests/l2cap-build/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
build*/
sdkconfig
sdkconfig.old
managed_components/
dependencies.lock
6 changes: 6 additions & 0 deletions .github/tests/l2cap-build/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
cmake_minimum_required(VERSION 3.16)
get_filename_component(NIMBLE_CPP_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE)
set(EXTRA_COMPONENT_DIRS "${NIMBLE_CPP_ROOT}")
set(COMPONENTS main)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(nimble_l2cap_build)
1 change: 1 addition & 0 deletions .github/tests/l2cap-build/main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
idf_component_register(SRCS "main.cpp" REQUIRES esp-nimble-cpp)
49 changes: 49 additions & 0 deletions .github/tests/l2cap-build/main/main.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#include "NimBLEDevice.h"
#include "NimBLEL2CAPServer.h"
#include "NimBLEL2CAPChannel.h"
#include "freertos/queue.h"
#include <atomic>
#include <cassert>

// Compile/link probe and minimal worker-driven echo pattern. The service lives
// for the process lifetime; no callback deletes its channel or blocks for TX.
struct EchoJob {
NimBLEL2CAPChannel* channel;
uint32_t generation;
std::vector<uint8_t> data;
};
static QueueHandle_t jobs;
static std::atomic<uint32_t> generation{0};

class EchoCallbacks : public NimBLEL2CAPChannelCallbacks {
void onConnect(NimBLEL2CAPChannel*, uint16_t) override { ++generation; }
void onDisconnect(NimBLEL2CAPChannel*) override { ++generation; }
void onRead(NimBLEL2CAPChannel* channel, std::vector<uint8_t>& bytes) override {
auto* job = new EchoJob{channel, generation.load(), bytes};
if (xQueueSend(jobs, &job, 0) != pdTRUE) {
delete job;
channel->disconnect();
}
}
};

static void echoWorker(void*) {
EchoJob* job;
while (true) {
if (xQueueReceive(jobs, &job, portMAX_DELAY) != pdTRUE) continue;
if (job->generation == generation.load() && job->channel->isConnected()) {
job->channel->write(job->data);
}
delete job;
}
}

extern "C" void app_main() {
jobs = xQueueCreate(4, sizeof(EchoJob*));
assert(jobs);
const auto created = xTaskCreate(echoWorker, "coc-echo", 4096, nullptr, 5, nullptr);
assert(created == pdPASS);
NimBLEDevice::init("coc-build-probe");
auto* channel = NimBLEDevice::createL2CAPServer()->createService(129, 4096, new EchoCallbacks);
(void)channel->isIdle();
}
4 changes: 4 additions & 0 deletions .github/tests/l2cap-build/sdkconfig.defaults
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM=1
CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=n
1 change: 1 addition & 0 deletions .github/tests/l2cap-build/sdkconfig.deferred
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=y
5 changes: 5 additions & 0 deletions .github/tests/server-build/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
build*/
sdkconfig
sdkconfig.old
managed_components/
dependencies.lock
6 changes: 6 additions & 0 deletions .github/tests/server-build/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
cmake_minimum_required(VERSION 3.16)
get_filename_component(NIMBLE_CPP_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE)
set(EXTRA_COMPONENT_DIRS "${NIMBLE_CPP_ROOT}")
set(COMPONENTS main)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(nimble_server_build)
1 change: 1 addition & 0 deletions .github/tests/server-build/main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
idf_component_register(SRCS "main.cpp" REQUIRES esp-nimble-cpp)
9 changes: 9 additions & 0 deletions .github/tests/server-build/main/main.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#include "NimBLEDevice.h"

// Compile/link probe: exercise the server even when advertising is disabled.
extern "C" void app_main() {
NimBLEDevice::init("server-build-probe");
auto* server = NimBLEDevice::createServer();
server->advertiseOnDisconnect(false);
server->createService("180f");
}
7 changes: 7 additions & 0 deletions .github/tests/server-build/sdkconfig.defaults
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_ROLE_CENTRAL=n
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
CONFIG_COMPILER_OPTIMIZATION_PERF=y
1 change: 1 addition & 0 deletions .github/tests/server-build/sdkconfig.no-broadcaster
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=n
41 changes: 41 additions & 0 deletions .github/workflows/l2cap-benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: L2CAP benchmark
on:
workflow_dispatch:
pull_request:
push:
branches: [master]
permissions:
contents: read

jobs:
evidence:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install pyserial
- run: python -m unittest discover -s tools/l2cap-bench -p 'test*.py'
firmware:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target: [esp32s3, esp32s31]
container: espressif/idf@sha256:81893c71bb5e570088901f21def8684c25cd2a9020281bd01b843a7655edb18c
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
path: esp-nimble-cpp
- name: Compile benchmark firmware
env:
IDF_TARGET: ${{ matrix.target }}
shell: bash
run: |
. "${IDF_PATH}/export.sh"
cd esp-nimble-cpp/examples/L2CAP/L2CAP_Bench
idf.py --preview -D "SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.defaults.$IDF_TARGET" build
42 changes: 42 additions & 0 deletions .github/workflows/l2cap-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: L2CAP CoC build
on:
workflow_dispatch:
pull_request:
push:
branches: [master]
permissions:
contents: read

jobs:
coc:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
idf: [v5.4.3, v5.5.1]
callbacks: [inline, deferred]
container: espressif/idf:${{ matrix.idf }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
path: esp-nimble-cpp
- name: Compile and link CoC implementation
env:
IDF_TARGET: esp32s3
CALLBACKS: ${{ matrix.callbacks }}
shell: bash
run: |
. "${IDF_PATH}/export.sh"
cd esp-nimble-cpp/.github/tests/l2cap-build
defaults="sdkconfig.defaults"
if [ "$CALLBACKS" = deferred ]; then defaults="$defaults;sdkconfig.deferred"; fi
idf.py -D "SDKCONFIG_DEFAULTS=$defaults" build
python - "$CALLBACKS" <<'PYTHON'
import sys
from pathlib import Path
config = Path("sdkconfig").read_text().splitlines()
assert "CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM=1" in config
enabled = "CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=y" in config
assert enabled == (sys.argv[1] == "deferred")
PYTHON
22 changes: 22 additions & 0 deletions .github/workflows/l2cap-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: L2CAP native tests
on:
workflow_dispatch:
pull_request:
push:
branches: [master]
permissions:
contents: read

jobs:
native:
runs-on: ubuntu-latest
strategy:
matrix:
cxx_flags: ["", "-fsanitize=address,undefined -fno-omit-frame-pointer"]
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- run: cmake -S tests/l2cap -B build/l2cap-tests -DCMAKE_CXX_FLAGS="${{ matrix.cxx_flags }}"
- run: cmake --build build/l2cap-tests
- run: ctest --test-dir build/l2cap-tests --output-on-failure
43 changes: 43 additions & 0 deletions .github/workflows/s31-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: S31 and server role builds

on:
workflow_dispatch:
pull_request:
push:
branches: [master]

permissions:
contents: read

jobs:
server-build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target: [esp32s3, esp32s31]
broadcaster: [enabled, disabled]
# ESP-IDF v6.1; S31 still requires --preview. Stable 5.x jobs stay unchanged.
container: espressif/idf@sha256:81893c71bb5e570088901f21def8684c25cd2a9020281bd01b843a7655edb18c
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
path: esp-nimble-cpp
- name: Build server role probe
env:
IDF_TARGET: ${{ matrix.target }}
BROADCASTER: ${{ matrix.broadcaster }}
shell: bash
run: |
. "${IDF_PATH}/export.sh"
cd esp-nimble-cpp/.github/tests/server-build
defaults="sdkconfig.defaults"
if [ "$BROADCASTER" = disabled ]; then defaults="$defaults;sdkconfig.no-broadcaster"; fi
idf.py --preview -D "SDKCONFIG_DEFAULTS=$defaults" build
python - "$BROADCASTER" <<'PYTHON'
import sys
from pathlib import Path
enabled = "CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y" in Path("sdkconfig").read_text().splitlines()
assert enabled == (sys.argv[1] == "enabled"), "role override was not applied"
PYTHON
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ idf_component_register(
REQUIRED_IDF_TARGETS
"esp32"
"esp32s3"
"esp32s31"
"esp32c2"
"esp32c3"
"esp32c5"
Expand Down
63 changes: 62 additions & 1 deletion Kconfig
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,65 @@ config NIMBLE_CPP_DEBUG_ASSERT_ENABLED
Enabling this option will add debug asserts to the NimBLE CPP library.
This will use approximately 1kB of flash memory.

endmenu
config NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT
int "L2CAP SDU buffer count."
range 2 32
default 5
help
Number of full-SDU mbufs allocated per NimBLEL2CAPChannel instance for
local CoC send/receive work. Each buffer is sized to the channel MTU, so
increasing this directly increases per-channel heap consumption.

Higher values improve tolerance for bursty traffic, overlapping send and
receive work, deferred read callback lag, and other short periods where
the channel needs more than one full SDU buffer ready at once.

Lower values reduce memory footprint, but make the channel more fragile
under load. If this value is too small for the negotiated MTU and the way
the application consumes callbacks, local allocations may fail and large
SDUs can start timing out or disconnecting even though CoC MTU negotiation
itself succeeded.

The default value of 5 preserves the currently validated behavior. Reduce
it only when memory is tight and the application traffic pattern is known
to be light; increase it if callbacks may lag or if multiple large SDUs
must be in flight locally.

config NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS
bool "Defer L2CAP read callbacks to a worker task."
default "n"
help
When enabled, NimBLEL2CAPChannel rearms receive first on the NimBLE host task
and then dispatches onConnect() and onRead() callbacks on a shared worker task.
Disconnect callbacks always run on that worker, outside the NimBLE host lock.
This reduces the risk of doing heavy application work directly on the NimBLE host path.

if NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS

config NIMBLE_CPP_L2CAP_CALLBACK_TASK_STACK_SIZE
int "L2CAP callback worker stack size."
range 2048 32768
default 4096
help
Stack size in bytes for the shared worker task used to invoke deferred
L2CAP read callbacks.

config NIMBLE_CPP_L2CAP_CALLBACK_TASK_PRIORITY
int "L2CAP callback worker task priority."
range 1 24
default 5
help
FreeRTOS priority for the shared worker task used to invoke deferred
L2CAP read callbacks.

config NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH
int "L2CAP callback queue length."
range 4 128
default 16
help
Number of queued deferred L2CAP read callbacks. Overflow aborts the
connection; additional reserved slots guarantee lifecycle delivery.

endif

endmenu
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.DEFAULT_GOAL := help
SANITIZER ?= none
L2CAP_BUILD ?= build/l2cap-$(SANITIZER)
SAN_FLAGS_none :=
SAN_FLAGS_address := -fsanitize=address,undefined -fno-omit-frame-pointer
SAN_FLAGS_thread := -fsanitize=thread -fno-omit-frame-pointer

.PHONY: help test-l2cap
help: ## Show development targets.
@awk 'BEGIN { print "Usage: make <target> [SANITIZER=none|address|thread] [L2CAP_BUILD=path]\n" } /^[a-zA-Z0-9_.-]+:.*##/ { split($$0, a, ":.*## "); printf " %-18s %s\n", a[1], a[2] }' $(MAKEFILE_LIST)

test-l2cap: ## Build and run the L2CAP concurrency and error tests.
@case "$(SANITIZER)" in none|address|thread) ;; *) echo "Unknown SANITIZER: use none, address or thread" >&2; exit 2;; esac
cmake -S tests/l2cap -B "$(L2CAP_BUILD)" -DCMAKE_CXX_FLAGS="$(SAN_FLAGS_$(SANITIZER))"
cmake --build "$(L2CAP_BUILD)"
ctest --test-dir "$(L2CAP_BUILD)" --output-on-failure
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ When using this library along with Arduino and compiling with *CMake* you must a
in your project/CMakeLists.txt after the line `include($ENV{IDF_PATH}/tools/cmake/project.cmake)` to prevent Arduino from releasing BLE memory.
<br>

## L2CAP measurement

The standalone [CoC benchmark](examples/L2CAP/L2CAP_Bench/README.md) for ESP-IDF
6.1 supports ESP32-S3/S31, ESP and macOS peers, request/response and streaming,
and repeated reconnects. Its [serial runner](tools/l2cap-bench/README.md)
retains accepted link parameters and validates complete per-frame evidence.
Native channel regression tests are documented in [tests/l2cap](tests/l2cap/README.md).

# Sponsors
Thank you to all the sponsors who support this project!

Expand Down
5 changes: 5 additions & 0 deletions examples/L2CAP/L2CAP_Bench/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
build*/
sdkconfig
sdkconfig.old
dependencies.lock
managed_components/
7 changes: 7 additions & 0 deletions examples/L2CAP/L2CAP_Bench/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
cmake_minimum_required(VERSION 3.16)
get_filename_component(NIMBLE_CPP_ROOT "../../.." ABSOLUTE)
set(EXTRA_COMPONENT_DIRS "${NIMBLE_CPP_ROOT}")
set(COMPONENTS main)
set(SUPPORTED_TARGETS esp32s3 esp32s31)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(l2cap_bench)
Loading
Loading