diff --git a/CMakeLists.txt b/CMakeLists.txt
index f3bff50c..f5d73069 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -33,6 +33,7 @@ idf_component_register(
REQUIRED_IDF_TARGETS
"esp32"
"esp32s3"
+ "esp32s31"
"esp32c2"
"esp32c3"
"esp32c5"
diff --git a/Kconfig b/Kconfig
index bfd47962..f253211e 100644
--- a/Kconfig
+++ b/Kconfig
@@ -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
\ No newline at end of file
+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
diff --git a/README.md b/README.md
index ea6b8976..f3319c7e 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,13 @@ 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.
+## 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.
+
# Sponsors
Thank you to all the sponsors who support this project!
diff --git a/examples/L2CAP/L2CAP_Bench/.gitignore b/examples/L2CAP/L2CAP_Bench/.gitignore
new file mode 100644
index 00000000..221ade48
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/.gitignore
@@ -0,0 +1,5 @@
+build*/
+sdkconfig
+sdkconfig.old
+dependencies.lock
+managed_components/
diff --git a/examples/L2CAP/L2CAP_Bench/CMakeLists.txt b/examples/L2CAP/L2CAP_Bench/CMakeLists.txt
new file mode 100644
index 00000000..e93c34a8
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/CMakeLists.txt
@@ -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)
diff --git a/examples/L2CAP/L2CAP_Bench/README.md b/examples/L2CAP/L2CAP_Bench/README.md
new file mode 100644
index 00000000..a41ed8ff
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/README.md
@@ -0,0 +1,118 @@
+# L2CAP CoC benchmark
+
+Standalone ESP-IDF 6.1 firmware for ESP32-S3 and ESP32-S31, with `echo`,
+small-request/large-response `request`, `stream-tx`, `stream-rx` and `duplex`.
+It can be the DUT or the ESP reflector. Wi-Fi/Ethernet are not initialized.
+The ESP drives the application protocol independently of its GAP role.
+
+## Build and connect
+
+```sh
+# Use the matching ESP-IDF 6.1 environment.
+idf.py --version
+IDF_TARGET=esp32s3 idf.py -B build-s3 -D SDKCONFIG=build-s3/sdkconfig \
+ -D 'SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.defaults.esp32s3' build
+idf.py -B build-s3 -p /dev/serial/by-id/ flash
+```
+
+For S31 use `IDF_TARGET=esp32s31`, `idf.py --preview`, `build-s31` and
+`sdkconfig.defaults.esp32s31`. CPU defaults are 240/320 MHz respectively.
+The USB Serial/JTAG console accepts LF-terminated commands at 115200 baud.
+
+An ESP peer uses the same firmware and `role server`. For macOS use the
+[`l2cap-reflect` companion](https://github.com/mickeyl/core-bluetooth-tool)
+and its `tools/run-l2cap-reflector-app.sh`; macOS must grant Bluetooth access.
+The companion shares Foundation-only framing/codecs for a future iPhone peer;
+an iPhone peer is not included here. Start the Mac with
+`--connect L2CAP-Bench-ESP` for Apple-Central/ESP-Peripheral roles.
+
+```text
+params 15 2
+listen
+await 30
+run echo 64 64 64
+run request 8 4096 64
+run duplex 4096 4096 128
+disconnect
+```
+
+For ESP-Central mode use `connect auto` instead of `listen`/`await`. Automatic
+selection requires exactly one benchmark service; a name or BLE address can be
+selected explicitly. `role client` restores DUT mode after using the ESP as a
+reflector. A running reflector has a separate application TX worker.
+
+The Python runner automates this over USB; see
+[`tools/l2cap-bench`](../../../tools/l2cap-bench/README.md). Use
+`--require-params` for controlled comparisons. Link setup retries a refused
+interval after one second, up to three times, outside callbacks and timing.
+The peer still chooses the accepted parameters: requested values are not proof.
+
+## Wire contract, version 2
+
+Frames are `B7 type length:u16 payload`, with at most 4996 payload bytes.
+Multibyte fields are big endian except the GATT PSM's little-endian `u16`.
+The parser accepts split and coalesced frames; SDUs are not message boundaries.
+The service UUID is `e7100001-61b3-4b3d-8b4d-7e3b51e2c001`; its PSM read
+characteristic is `e7100002-61b3-4b3d-8b4d-7e3b51e2c001`.
+
+| Type | Payload |
+|---|---|
+| `01 HELLO` / `81 reply` | Version `02` followed by UTF-8 identity. |
+| `02 CONFIG` / `82 reply` | Pattern `u8`, request/response sizes `u16`, count `u32`; reply status `00` means accepted. Patterns: 1 echo, 2 request, 3 stream-tx, 4 stream-rx, 5 duplex. |
+| `03 PING` / `83 reply` | Sequence/token `u32`, then deterministic data. Reply adds reflector turnaround `u32` microseconds after token. |
+| `06 START` / `86 READY` | Run token `u32`; receive state is armed before READY. |
+| `04 DATA` | Sequence/token `u32` followed by deterministic data. |
+| `05 END` | Token/count `u32`. |
+| `85 RECEIPT` | Token/count `u32`, received bytes/first timestamp/last timestamp `u64`, errors `u32` (36 bytes). |
+| `07 FINISH` / `87 finished` | Token `u32`; confirmation requires all enabled directions and receipts complete. |
+| `7F ABORT` | Optional UTF-8 reason. |
+
+CLI sizes include message metadata and exclude the four-byte frame header.
+Echo/response/stream sizes are 12..4996 bytes; an asymmetric request may use 8. CONFIG count is 1..4096. Data byte `i` is
+`(sequence + token + i) & 255`, starting after sequence/token (and turnaround in
+a reply). Streaming has no per-DATA application ACK. A failed/incomplete write
+or validation aborts the run. There is a benchmark-only 10-second receive
+no-progress watchdog; this is not an ECU diagnostic timeout.
+
+## Metrics and interpretation
+
+`hello` identifies Git/IDF bases, SDK-config and ELF hashes, CPU, buffer counts
+and options. Per-run parameters include actual interval, PHY, MTU and observed
+DLE. Records use blocking USB writes after timing; all timed per-frame DUT
+samples are retained in memory and emitted afterwards. Reflector writes never
+run in a native receive callback. Fast streaming validation runs in that
+callback without console output or writes.
+
+Echo/request RTT ends at complete reply parsing on the DUT clock. The first
+callback timestamp means delivery of a complete SDU, not first RF byte. Peer
+turnaround is measured on its own clock and its subtraction still leaves a
+round trip. `tx_submit_us` measures blocking host submission, not remote use.
+
+Stream wall goodput divides useful pattern bytes in all enabled directions by
+START-through-FINISH duration, including control round trips. Arrival goodput
+uses each receiver's own first/last timestamps, excludes the first frame, and
+never compares clocks across peers. Neither metric is physical BLE throughput.
+
+Reconnect cycles close CoC/GAP, allow 100 ms for RTOS cleanup and report total/
+internal free heap, largest internal block and tasks. The runner excludes five
+initial cycles from heap trends. Parser capacity may retain a bounded high-water
+allocation; compare settled cycles within one build/role. Short runs do not
+prove day-long leak freedom. Failed files remain failed evidence.
+
+## Known resource limit
+
+With very large peer credits, unpaced large SDUs can exhaust native HCI/MSYS
+buffers. CoC credit availability does not bound local controller backlog.
+The library aborts the incomplete write instead of replaying a partial SDU.
+This benchmark deliberately reports that failure; it does not silently pace or
+retry the run. Experimental resource-window, alignment and native diagnostic
+changes are outside this submission. Use echo/request as a bounded-load
+baseline and preserve failed streaming captures when investigating the limit.
+
+The default pool profile also failed an ESP-S3→ESP-S31 single-request echo at
+4996 payload bytes plus framing (one 5000-byte SDU, negotiated MTU 5000). An
+error-only instrumented check returned code 6 (ENOMEM) at the first write. That
+identifies a resource failure, not the exact limiting pool; it is retained as a
+failed case and is not claimed fixed here. Use 4096-byte payloads for the bounded
+baseline. The parser's maximum frame size is not a guarantee that the default
+host/controller buffers can sustain every transfer geometry.
diff --git a/examples/L2CAP/L2CAP_Bench/main/CMakeLists.txt b/examples/L2CAP/L2CAP_Bench/main/CMakeLists.txt
new file mode 100644
index 00000000..8500df14
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/main/CMakeLists.txt
@@ -0,0 +1,6 @@
+idf_component_register(SRCS "main.cpp" INCLUDE_DIRS "." REQUIRES esp-nimble-cpp console esp_driver_usb_serial_jtag esp_timer espressif__cjson app_update)
+execute_process(COMMAND git -C "${CMAKE_CURRENT_LIST_DIR}/../../../.." rev-parse HEAD OUTPUT_VARIABLE BENCH_LIBRARY_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE)
+execute_process(COMMAND git -C "$ENV{IDF_PATH}" rev-parse HEAD OUTPUT_VARIABLE BENCH_IDF_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE)
+file(SHA256 "${SDKCONFIG}" BENCH_CONFIG_HASH)
+configure_file(bench_build.hpp.in "${CMAKE_CURRENT_BINARY_DIR}/bench_build.hpp" @ONLY)
+target_include_directories(${COMPONENT_LIB} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
diff --git a/examples/L2CAP/L2CAP_Bench/main/bench_build.hpp.in b/examples/L2CAP/L2CAP_Bench/main/bench_build.hpp.in
new file mode 100644
index 00000000..ce3d415e
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/main/bench_build.hpp.in
@@ -0,0 +1,4 @@
+#pragma once
+#define BENCH_LIBRARY_COMMIT "@BENCH_LIBRARY_COMMIT@"
+#define BENCH_IDF_COMMIT "@BENCH_IDF_COMMIT@"
+#define BENCH_CONFIG_HASH "@BENCH_CONFIG_HASH@"
diff --git a/examples/L2CAP/L2CAP_Bench/main/bench_protocol.hpp b/examples/L2CAP/L2CAP_Bench/main/bench_protocol.hpp
new file mode 100644
index 00000000..f6bdd786
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/main/bench_protocol.hpp
@@ -0,0 +1,126 @@
+#pragma once
+#include
+#include
+#include
+
+namespace bench {
+constexpr uint8_t magic = 0xB7;
+constexpr size_t maxPayload = 4996;
+constexpr uint8_t hello = 1, config = 2, ping = 3, abort = 0x7F;
+constexpr uint8_t helloReply = 0x81, configReply = 0x82, pingReply = 0x83;
+constexpr uint8_t version = 2;
+constexpr uint8_t stream = 4, streamEnd = 5, streamStart = 6, streamFinish = 7;
+constexpr uint8_t streamEndReply = 0x85, streamStartReply = 0x86, streamFinishReply = 0x87;
+struct Frame { uint8_t type; std::vector payload; int64_t receivedUs = 0; uint32_t generation = 0; };
+inline uint32_t u32(const uint8_t* p) {
+ return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | p[3];
+}
+inline void put32(std::vector& p, uint32_t v) {
+ p.push_back(v >> 24); p.push_back(v >> 16); p.push_back(v >> 8); p.push_back(v);
+}
+inline uint64_t u64(const uint8_t* p) { return (uint64_t(u32(p)) << 32) | u32(p + 4); }
+inline void put64(std::vector& p, uint64_t v) { put32(p, v >> 32); put32(p, uint32_t(v)); }
+inline uint8_t pattern(uint32_t sequence, uint32_t token, size_t offset) {
+ return static_cast(sequence + token + offset);
+}
+inline std::vector encode(uint8_t type, const std::vector& payload) {
+ std::vector result{magic, type, uint8_t(payload.size() >> 8), uint8_t(payload.size())};
+ result.insert(result.end(), payload.begin(), payload.end());
+ return result;
+}
+class Parser {
+ std::vector bytes;
+ bool failed = false;
+public:
+ void reset() { bytes.clear(); failed = false; }
+ template bool feed(const std::vector& incoming, Emit emit) {
+ if (failed) return false;
+ // Process incrementally: a read may contain multiple complete frames.
+ for (auto byte : incoming) {
+ bytes.push_back(byte);
+ if (bytes.size() == 1 && byte != magic) { failed = true; return false; }
+ if (bytes.size() < 4) continue;
+ const size_t length = (size_t(bytes[2]) << 8) | bytes[3];
+ if (length > maxPayload) { failed = true; return false; }
+ if (bytes.size() == length + 4) {
+ emit(Frame{bytes[1], {bytes.begin() + 4, bytes.end()}});
+ bytes.clear();
+ }
+ }
+ return true;
+ }
+};
+struct Config {
+ uint8_t pattern = 0;
+ uint16_t request = 0, response = 0;
+ uint32_t count = 0;
+ bool parse(const std::vector& p) {
+ if (p.size() != 9) return false;
+ Config candidate{p[0], uint16_t((uint16_t(p[1]) << 8) | p[2]),
+ uint16_t((uint16_t(p[3]) << 8) | p[4]), u32(p.data() + 5)};
+ if (candidate.pattern < 1 || candidate.pattern > 5 || candidate.request < 8 || candidate.request > maxPayload ||
+ candidate.response < 12 || candidate.response > maxPayload || candidate.count == 0 || candidate.count > 4096 ||
+ (candidate.pattern == 1 && candidate.request != candidate.response)) return false;
+ *this = candidate;
+ return true;
+ }
+ std::vector encode() const {
+ std::vector p{pattern, uint8_t(request >> 8), uint8_t(request), uint8_t(response >> 8), uint8_t(response)};
+ put32(p, count); return p;
+ }
+};
+
+// These are application stream frames, independent of SDU boundaries.
+inline std::vector streamPayload(uint32_t seq, uint32_t token, uint16_t size) {
+ std::vector p; p.reserve(size); put32(p, seq); put32(p, token);
+ for (size_t i = 8; i < size; ++i) p.push_back(pattern(seq, token, i - 8));
+ return p;
+}
+struct StreamSummary {
+ uint32_t token = 0, frames = 0;
+ uint64_t bytes = 0, firstUs = 0, lastUs = 0;
+ uint32_t errors = 0;
+ std::vector encode() const {
+ std::vector p; put32(p, token); put32(p, frames); put64(p, bytes);
+ put64(p, firstUs); put64(p, lastUs); put32(p, errors); return p;
+ }
+ bool parse(const std::vector& p) {
+ if (p.size() != 36) return false;
+ *this = {u32(p.data()), u32(p.data() + 4), u64(p.data() + 8),
+ u64(p.data() + 16), u64(p.data() + 24), u32(p.data() + 32)};
+ return firstUs <= lastUs;
+ }
+ bool matches(uint32_t expectedToken, uint32_t count, uint16_t size) const {
+ return token == expectedToken && frames == count && bytes == uint64_t(count) * size &&
+ errors == 0 && firstUs <= lastUs;
+ }
+};
+struct StreamReceiver {
+ StreamSummary summary;
+ uint32_t count = 0;
+ uint16_t size = 0;
+ bool ended = false;
+ std::vector arrivals;
+ void reset(uint32_t token, uint32_t expectedCount, uint16_t expectedSize) {
+ summary = {}; summary.token = token; count = expectedCount; size = expectedSize;
+ ended = false; arrivals.clear(); arrivals.reserve(expectedCount);
+ }
+ bool receive(const std::vector& p, uint64_t now) {
+ bool valid = !ended && summary.frames < count && p.size() == size && p.size() >= 8 &&
+ u32(p.data()) == summary.frames && u32(p.data() + 4) == summary.token;
+ for (size_t i = 8; valid && i < p.size(); ++i)
+ valid = p[i] == pattern(summary.frames, summary.token, i - 8);
+ if (!valid) { ++summary.errors; return false; }
+ if (!summary.frames) summary.firstUs = now;
+ summary.lastUs = now; ++summary.frames; summary.bytes += p.size(); arrivals.push_back(now);
+ return true;
+ }
+ bool end(const std::vector& p) {
+ const bool valid = !ended && p.size() == 8 && u32(p.data()) == summary.token &&
+ u32(p.data() + 4) == count && summary.matches(summary.token, count, size);
+ ended = true;
+ if (!valid) ++summary.errors;
+ return valid;
+ }
+};
+} // namespace bench
diff --git a/examples/L2CAP/L2CAP_Bench/main/bench_stream_run.inc b/examples/L2CAP/L2CAP_Bench/main/bench_stream_run.inc
new file mode 100644
index 00000000..b9cc856c
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/main/bench_stream_run.inc
@@ -0,0 +1,106 @@
+// Included by main.cpp inside the benchmark namespace. No product protocol code.
+bool sendStream(NimBLEL2CAPChannel* channel, uint32_t generation, uint32_t token,
+ uint32_t count, uint16_t size, std::vector* submits = nullptr) {
+ for (uint32_t seq = 0; seq < count; ++seq) {
+ if (!connected.load() || generation != connectionGeneration.load()) return false;
+ const auto payload = bench::encode(bench::stream, bench::streamPayload(seq, token, size));
+ const auto started = esp_timer_get_time();
+ if (!channel->write(payload)) return false;
+ if (submits) submits->push_back(uint32_t(esp_timer_get_time() - started));
+ }
+ std::vector end; bench::put32(end, token); bench::put32(end, count);
+ return generation == connectionGeneration.load() && channel->write(bench::encode(bench::streamEnd, end));
+}
+
+void addSummary(cJSON* parent, const char* key, const bench::StreamSummary& summary) {
+ auto object = cJSON_CreateObject();
+ number(object, "token", summary.token); number(object, "frames", summary.frames);
+ number(object, "bytes", summary.bytes); number(object, "first_us", summary.firstUs);
+ number(object, "last_us", summary.lastUs); number(object, "errors", summary.errors);
+ cJSON_AddItemToObject(parent, key, object);
+}
+
+int runStream(const bench::Config& config, const char* name) {
+ const bool send = config.pattern != 4, receive = config.pattern != 3;
+ const uint32_t token = esp_random(), generation = connectionGeneration.load();
+ const auto faults = callbackFaults.load();
+ {
+ std::lock_guard lock(streamMutex);
+ streamReceiver.reset(token, receive ? config.count : 0, config.response);
+ }
+ std::vector submits; if (send) submits.reserve(config.count);
+ auto begin = cJSON_CreateObject();
+ cJSON_AddStringToObject(begin, "pattern", name);
+ number(begin, "count", config.count); number(begin, "request_size", config.request);
+ number(begin, "response_size", config.response); number(begin, "token", token); emit("run-start", begin);
+ std::vector tokenPayload; bench::put32(tokenPayload, token);
+ const auto started = esp_timer_get_time();
+ auto ready = exchange(bench::streamStart, tokenPayload, bench::streamStartReply);
+ bool success = ready && ready->payload == tokenPayload;
+ auto channel = activeChannel.load();
+ const auto txStarted = esp_timer_get_time();
+ if (success && send) success = channel && sendStream(channel, generation, token, config.count, config.request, &submits);
+ const auto txSubmitted = esp_timer_get_time();
+ bool rxEnded = !receive, txConfirmed = !send;
+ bench::StreamSummary peerSummary;
+ const auto waitStarted = esp_timer_get_time();
+ while (success && (!rxEnded || !txConfirmed)) {
+ bench::Frame* raw = nullptr;
+ if (xQueueReceive(replies, &raw, pdMS_TO_TICKS(100)) != pdTRUE) {
+ uint64_t progress;
+ { std::lock_guard lock(streamMutex); progress = streamReceiver.summary.lastUs; }
+ // A benchmark watchdog, not an ECU response timer: valid RX data
+ // refreshes it, including while the two duplex streams differ.
+ if (!connected.load() || esp_timer_get_time() - std::max(waitStarted, progress) > 10000000) success = false;
+ continue;
+ }
+ std::unique_ptr frame(raw);
+ if (frame->type == bench::streamEnd && !rxEnded) {
+ std::vector reply;
+ {
+ std::lock_guard lock(streamMutex);
+ success = streamReceiver.end(frame->payload);
+ reply = streamReceiver.summary.encode();
+ }
+ rxEnded = true;
+ if (success) success = channel && channel->write(bench::encode(bench::streamEndReply, reply));
+ } else if (frame->type == bench::streamEndReply && !txConfirmed) {
+ success = peerSummary.parse(frame->payload) && peerSummary.matches(token, config.count, config.request);
+ txConfirmed = true;
+ } else success = false;
+ }
+ if (success) {
+ auto finished = exchange(bench::streamFinish, tokenPayload, bench::streamFinishReply);
+ success = finished && finished->payload == tokenPayload;
+ }
+ const auto finished = esp_timer_get_time();
+ bench::StreamReceiver received;
+ { std::lock_guard lock(streamMutex); received = std::move(streamReceiver); streamReceiver = {}; }
+ success = success && callbackFaults.load() == faults && generation == connectionGeneration.load() &&
+ (!receive || (received.ended && received.summary.matches(token, config.count, config.response))) &&
+ (!send || submits.size() == config.count);
+ params();
+ auto result = cJSON_CreateObject();
+ cJSON_AddStringToObject(result, "pattern", name); cJSON_AddBoolToObject(result, "success", success);
+ number(result, "completed", success ? config.count : 0); number(result, "token", token);
+ number(result, "tx_frames", submits.size()); number(result, "rx_frames", received.summary.frames);
+ number(result, "callback_faults", callbackFaults.load() - faults);
+ number(result, "elapsed_us", finished - started); number(result, "tx_submit_span_us", txSubmitted - txStarted);
+ number(result, "goodput_bytes_per_s", double(submits.size() * (config.request - 8) + received.summary.frames * (config.response - 8)) * 1e6 / (finished - started));
+ addSummary(result, "local_rx", received.summary); addSummary(result, "peer_rx", peerSummary);
+ cJSON_AddBoolToObject(result, "peer_finish_confirmed", success);
+ if (send && peerSummary.frames > 1 && peerSummary.lastUs > peerSummary.firstUs)
+ number(result, "tx_arrival_goodput_bytes_per_s", double(peerSummary.frames - 1) * (config.request - 8) * 1e6 / (peerSummary.lastUs - peerSummary.firstUs));
+ if (receive && received.summary.frames > 1 && received.summary.lastUs > received.summary.firstUs)
+ number(result, "rx_arrival_goodput_bytes_per_s", double(received.summary.frames - 1) * (config.response - 8) * 1e6 / (received.summary.lastUs - received.summary.firstUs));
+ emit("run-result", result);
+ for (size_t i = 0; i < submits.size(); ++i) {
+ auto sample = cJSON_CreateObject(); number(sample, "seq", i); number(sample, "tx_submit_us", submits[i]); emit("stream-tx-sample", sample);
+ }
+ for (size_t i = 0; i < received.arrivals.size(); ++i) {
+ auto sample = cJSON_CreateObject(); number(sample, "seq", i); number(sample, "received_at_us", received.arrivals[i]); emit("stream-rx-sample", sample);
+ }
+ emit("run-end");
+ if (!success) { error("stream data, receipt, finish or watchdog failure"); disconnectClient(); }
+ return success ? 0 : 1;
+}
diff --git a/examples/L2CAP/L2CAP_Bench/main/idf_component.yml b/examples/L2CAP/L2CAP_Bench/main/idf_component.yml
new file mode 100644
index 00000000..e83e7109
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/main/idf_component.yml
@@ -0,0 +1,4 @@
+dependencies:
+ idf: ">=6.1"
+ espressif/cjson:
+ version: "^1.7.19"
diff --git a/examples/L2CAP/L2CAP_Bench/main/main.cpp b/examples/L2CAP/L2CAP_Bench/main/main.cpp
new file mode 100644
index 00000000..4f9842e2
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/main/main.cpp
@@ -0,0 +1,587 @@
+#include "bench_protocol.hpp"
+#include "bench_build.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+constexpr auto serviceUuid = "e7100001-61b3-4b3d-8b4d-7e3b51e2c001";
+constexpr auto psmUuid = "e7100002-61b3-4b3d-8b4d-7e3b51e2c001";
+constexpr uint16_t serverPsm = 192, channelMtu = 5000;
+QueueHandle_t replies, reflectRequests;
+NimBLEClient* client = nullptr;
+NimBLEL2CAPChannel* ownedChannel = nullptr;
+std::atomic activeChannel{nullptr};
+std::atomic serverMode{false}, connected{false}, waiting{false};
+std::atomic reflectorMode{false}, advertisingEnabled{false};
+std::atomic gapHandle{BLE_HS_CONN_HANDLE_NONE};
+std::atomic connectionGeneration{0};
+bool serverStarted = false;
+std::atomic firstCallbackUs{0}, callbackFaults{0};
+std::atomic negotiatedMtu{0};
+std::atomic observedTxOctets{0}, observedRxOctets{0};
+std::atomic intervalStatus{0};
+std::atomic reflectorBusy{false};
+std::mutex streamMutex;
+bench::StreamReceiver streamReceiver;
+int64_t requestStartedUs = 0;
+uint16_t intervalUnits = 12;
+uint8_t wantedPhy = BLE_GAP_LE_PHY_2M;
+
+void emit(const char* event, cJSON* object = nullptr) {
+ if (!object) object = cJSON_CreateObject();
+ cJSON_AddStringToObject(object, "event", event);
+ char* line = cJSON_PrintUnformatted(object);
+ if (line) {
+ const std::string output = std::string("BENCH ") + line + "\n";
+ // IDF's stdout VFS can silently discard characters after a USB TX
+ // timeout. Benchmark evidence must be complete; these writes occur
+ // outside the measured request loop and can wait for the host.
+ size_t offset = 0;
+ while (offset < output.size()) {
+ const size_t size = std::min(512, output.size() - offset);
+ const int written = usb_serial_jtag_write_bytes(output.data() + offset, size, portMAX_DELAY);
+ if (written > 0) offset += written;
+ }
+ cJSON_free(line);
+ }
+ cJSON_Delete(object);
+}
+void error(const char* message) {
+ auto object = cJSON_CreateObject();
+ cJSON_AddStringToObject(object, "message", message);
+ emit("error", object);
+}
+void number(cJSON* object, const char* key, double value) { cJSON_AddNumberToObject(object, key, value); }
+void clearQueue(QueueHandle_t queue) {
+ bench::Frame* frame;
+ while (xQueueReceive(queue, &frame, 0) == pdTRUE) delete frame;
+}
+void clearStream() { std::lock_guard lock(streamMutex); streamReceiver = {}; }
+void heapStatus(const char* phase) {
+ auto object = cJSON_CreateObject();
+ cJSON_AddStringToObject(object, "phase", phase);
+ number(object, "free", esp_get_free_heap_size());
+ number(object, "minimum_ever", esp_get_minimum_free_heap_size());
+ number(object, "largest_internal_block", heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
+ number(object, "internal_free", heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
+ number(object, "tasks", uxTaskGetNumberOfTasks()); emit("heap", object);
+}
+
+class ChannelCallbacks : public NimBLEL2CAPChannelCallbacks {
+ bench::Parser parser;
+public:
+ void onConnect(NimBLEL2CAPChannel* channel, uint16_t mtu) override {
+ ++connectionGeneration;
+ clearQueue(replies); clearQueue(reflectRequests);
+ clearStream();
+ parser.reset(); negotiatedMtu = mtu; activeChannel = channel; connected = true;
+ }
+ void onRead(NimBLEL2CAPChannel*, std::vector& bytes) override {
+ const auto now = esp_timer_get_time();
+ if (waiting.load()) {
+ uint32_t empty = 0;
+ firstCallbackUs.compare_exchange_strong(empty, uint32_t(now - requestStartedUs));
+ }
+ if (!parser.feed(bytes, [now](bench::Frame frame) {
+ frame.receivedUs = now;
+ frame.generation = connectionGeneration.load();
+ if (frame.type == bench::stream) {
+ std::lock_guard lock(streamMutex);
+ if (!streamReceiver.receive(frame.payload, now)) ++callbackFaults;
+ return;
+ }
+ auto pending = new bench::Frame(std::move(frame));
+ auto queue = reflectorMode.load() ? reflectRequests : replies;
+ if (xQueueSend(queue, &pending, 0) != pdTRUE) { delete pending; ++callbackFaults; }
+ })) ++callbackFaults;
+ }
+ void onDisconnect(NimBLEL2CAPChannel*) override {
+ connected = false; activeChannel = nullptr;
+ ++connectionGeneration;
+ }
+};
+
+class ClientCallbacks : public NimBLEClientCallbacks {
+ void onConnect(NimBLEClient* peer) override { gapHandle = peer->getConnHandle(); peer->setDataLen(251); }
+ void onDisconnect(NimBLEClient*, int) override { gapHandle = BLE_HS_CONN_HANDLE_NONE; }
+} clientCallbacks;
+
+class ServerCallbacks : public NimBLEServerCallbacks {
+ void onConnect(NimBLEServer* server, NimBLEConnInfo& info) override {
+ gapHandle = info.getConnHandle();
+ server->setDataLen(info.getConnHandle(), 251);
+ server->updateConnParams(info.getConnHandle(), intervalUnits, intervalUnits, 0, 200);
+ }
+ void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) override {
+ gapHandle = BLE_HS_CONN_HANDLE_NONE;
+ if (advertisingEnabled.load()) NimBLEDevice::startAdvertising();
+ }
+} serverCallbacks;
+
+void metadata() {
+ auto object = cJSON_CreateObject();
+ cJSON_AddStringToObject(object, "target", CONFIG_IDF_TARGET);
+ number(object, "bench_protocol", bench::version);
+ cJSON_AddStringToObject(object, "gap_role", serverMode.load() ? "peripheral" : "central");
+ cJSON_AddStringToObject(object, "app_role", reflectorMode.load() ? "reflector" : "dut");
+ cJSON_AddStringToObject(object, "library_commit", BENCH_LIBRARY_COMMIT);
+ cJSON_AddStringToObject(object, "idf_commit", BENCH_IDF_COMMIT);
+ cJSON_AddStringToObject(object, "sdkconfig_sha256", BENCH_CONFIG_HASH);
+ const auto app = esp_app_get_description();
+ char hash[65];
+ for (size_t i = 0; i < 32; ++i) snprintf(hash + 2 * i, 3, "%02x", app->app_elf_sha256[i]);
+ cJSON_AddStringToObject(object, "elf_sha256", hash);
+ cJSON_AddStringToObject(object, "ble_address", NimBLEDevice::getAddress().toString().c_str());
+ number(object, "cpu_mhz", CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ);
+ number(object, "msys1_block_size", CONFIG_BT_NIMBLE_MSYS_1_BLOCK_SIZE);
+ number(object, "msys1_block_count", CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT);
+ number(object, "msys2_block_size", CONFIG_BT_NIMBLE_MSYS_2_BLOCK_SIZE);
+ number(object, "msys2_block_count", CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT);
+ number(object, "acl_buf_count", CONFIG_BT_NIMBLE_ACL_BUF_COUNT);
+ number(object, "sdu_buffers", CONFIG_NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT);
+#ifdef CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS
+ cJSON_AddBoolToObject(object, "deferred_callbacks", true);
+#else
+ cJSON_AddBoolToObject(object, "deferred_callbacks", false);
+#endif
+#if defined(CONFIG_COMPILER_OPTIMIZATION_PERF)
+ cJSON_AddStringToObject(object, "optimization", "performance");
+#elif defined(CONFIG_COMPILER_OPTIMIZATION_SIZE)
+ cJSON_AddStringToObject(object, "optimization", "size");
+#else
+ cJSON_AddStringToObject(object, "optimization", "debug");
+#endif
+ emit("hello", object);
+}
+
+void params() {
+ auto object = cJSON_CreateObject();
+ cJSON_AddBoolToObject(object, "connected", connected.load());
+ cJSON_AddStringToObject(object, "gap_role", serverMode.load() ? "peripheral" : "central");
+ number(object, "mtu", negotiatedMtu.load());
+ number(object, "requested_interval_ms", intervalUnits * 1.25);
+ number(object, "requested_phy", wantedPhy);
+ number(object, "requested_tx_octets", 251);
+ if (observedTxOctets.load()) {
+ number(object, "tx_octets", observedTxOctets.load());
+ number(object, "rx_octets", observedRxOctets.load());
+ }
+ ble_gap_conn_desc info{};
+ const auto handle = gapHandle.load();
+ if (handle != BLE_HS_CONN_HANDLE_NONE && ble_gap_conn_find(handle, &info) == 0) {
+ number(object, "interval_ms", info.conn_itvl * 1.25);
+ number(object, "latency", info.conn_latency);
+ number(object, "supervision_ms", info.supervision_timeout * 10);
+ int8_t rssi = 0;
+ if (ble_gap_conn_rssi(handle, &rssi) == 0) number(object, "rssi_dbm", rssi);
+ uint8_t txPhy = 0, rxPhy = 0;
+ if (ble_gap_read_le_phy(handle, &txPhy, &rxPhy) == 0) {
+ number(object, "tx_phy", txPhy); number(object, "rx_phy", rxPhy);
+ }
+ }
+ emit("params", object);
+}
+
+bool disconnectClient() {
+ waiting = false;
+ advertisingEnabled = false;
+ if (serverStarted) NimBLEDevice::stopAdvertising();
+ auto channel = activeChannel.load();
+ if (!channel) channel = ownedChannel;
+ if (channel) channel->disconnect();
+ if (serverMode.load() && gapHandle.load() != BLE_HS_CONN_HANDLE_NONE)
+ NimBLEDevice::getServer()->disconnect(gapHandle.load());
+ if (client && client->isConnected()) client->disconnect();
+ const auto deadline = esp_timer_get_time() + 3000000;
+ while (((channel && !channel->isIdle()) || gapHandle.load() != BLE_HS_CONN_HANDLE_NONE || reflectorBusy.load()) &&
+ esp_timer_get_time() < deadline) vTaskDelay(1);
+ if ((channel && !channel->isIdle()) || gapHandle.load() != BLE_HS_CONN_HANDLE_NONE || reflectorBusy.load()) {
+ error("channel teardown still busy"); return false;
+ }
+ delete ownedChannel; ownedChannel = nullptr;
+ if (client) {
+ NimBLEDevice::deleteClient(client); client = nullptr;
+ // Scan results include unrelated nearby advertisers. Keeping them makes
+ // post-teardown heap depend on the last scan rather than channel leaks.
+ NimBLEDevice::getScan()->clearResults();
+ }
+ activeChannel = nullptr; connected = false;
+ negotiatedMtu = 0; observedTxOctets = 0; observedRxOctets = 0;
+ intervalStatus = 0;
+ clearQueue(replies); clearQueue(reflectRequests); clearStream();
+ return true;
+}
+
+std::unique_ptr exchange(uint8_t type, const std::vector& payload,
+ uint8_t expected, uint32_t timeoutMs = 5000) {
+ auto channel = activeChannel.load();
+ if (!channel || !connected.load()) { error("no L2CAP connection"); return {}; }
+ if (!channel->write(bench::encode(type, payload))) { error("L2CAP write failed"); return {}; }
+ bench::Frame* frame = nullptr;
+ if (xQueueReceive(replies, &frame, pdMS_TO_TICKS(timeoutMs)) != pdTRUE) {
+ error("response timeout"); return {};
+ }
+ std::unique_ptr result(frame);
+ if (frame->type != expected) { error("unexpected response type"); return {}; }
+ return result;
+}
+
+bool greetPeer(uint16_t psm) {
+ for (int retry = 0; retry < 3; ++retry) {
+ if (ble_gap_set_prefered_le_phy(gapHandle.load(), wantedPhy, wantedPhy, 0) == 0) break;
+ vTaskDelay(pdMS_TO_TICKS(100));
+ }
+ vTaskDelay(pdMS_TO_TICKS(500));
+ // Setup only: the product firmware retries transient interval collisions
+ // too. Never wait or retry on a GAP callback, and never assume acceptance.
+ for (int attempt = 1; attempt <= 3; ++attempt) {
+ ble_gap_conn_desc info{};
+ const auto handle = gapHandle.load();
+ if (ble_gap_conn_find(handle, &info) != 0 ||
+ (info.conn_itvl == intervalUnits && info.conn_latency == 0)) break;
+ vTaskDelay(pdMS_TO_TICKS(1000));
+ ble_gap_upd_params update{};
+ update.itvl_min = update.itvl_max = intervalUnits;
+ update.latency = 0; update.supervision_timeout = 200;
+ const int priorStatus = intervalStatus.load();
+ intervalStatus = -1;
+ const int rc = ble_gap_update_params(handle, &update);
+ const auto deadline = esp_timer_get_time() + 1000000;
+ if (rc == 0 || rc == BLE_HS_EALREADY || rc == BLE_HS_EBUSY) {
+ while (esp_timer_get_time() < deadline) {
+ if (ble_gap_conn_find(handle, &info) != 0 || info.conn_itvl == intervalUnits) break;
+ if (intervalStatus.load() != -1) break;
+ vTaskDelay(pdMS_TO_TICKS(10));
+ }
+ }
+ auto object = cJSON_CreateObject();
+ number(object, "attempt", attempt); number(object, "request_rc", rc);
+ number(object, "prior_status", priorStatus);
+ number(object, "completion_status", intervalStatus.load());
+ number(object, "actual_interval_ms", info.conn_itvl * 1.25); emit("interval-update", object);
+ const int status = rc != 0 ? rc : intervalStatus.load();
+ const bool transient = status == -1 || status == BLE_HS_EBUSY || status == BLE_HS_EALREADY ||
+ status == BLE_HS_HCI_ERR(BLE_ERR_DIFF_TRANS_COLL) || status == BLE_HS_HCI_ERR(BLE_ERR_LMP_COLLISION);
+ if (!transient) break;
+ }
+ std::string identity = std::string(CONFIG_IDF_TARGET) + " " + BENCH_LIBRARY_COMMIT;
+ std::vector hello{bench::version}; hello.insert(hello.end(), identity.begin(), identity.end());
+ auto answer = exchange(bench::hello, hello, bench::helloReply);
+ if (!answer || answer->payload.empty() || answer->payload[0] != bench::version) {
+ error("HELLO failed"); disconnectClient(); return false;
+ }
+ auto object = cJSON_CreateObject();
+ const std::string peer(answer->payload.begin() + 1, answer->payload.end());
+ cJSON_AddStringToObject(object, "peer_identity", peer.c_str());
+ cJSON_AddStringToObject(object, "gap_role", serverMode.load() ? "peripheral" : "central");
+ number(object, "psm", psm); emit("connected", object); params(); return true;
+}
+
+bool connectPeer(const char* name) {
+ if (!disconnectClient()) return false;
+ serverMode = false; reflectorMode = false;
+ auto scan = NimBLEDevice::getScan();
+ scan->setActiveScan(true); scan->clearResults();
+ const auto results = scan->getResults(5000);
+ const NimBLEAdvertisedDevice* found = nullptr;
+ size_t matches = 0;
+ for (auto peer : results) {
+ const bool serviceMatch = peer->isAdvertisingService(NimBLEUUID(serviceUuid));
+ const bool identityMatch = (peer->haveName() && peer->getName() == name) || peer->getAddress().toString() == name;
+ const bool selected = !strcmp(name, "auto") ? serviceMatch : identityMatch;
+ if (!serviceMatch && !identityMatch) continue;
+ auto object = cJSON_CreateObject();
+ cJSON_AddStringToObject(object, "name", peer->getName().c_str());
+ cJSON_AddStringToObject(object, "address", peer->getAddress().toString().c_str());
+ cJSON_AddBoolToObject(object, "bench_service", serviceMatch);
+ number(object, "rssi_dbm", peer->getRSSI()); emit("discovered", object);
+ if (selected) { found = peer; ++matches; }
+ }
+ if (matches > 1) { error("multiple benchmark reflectors; select a name or BLE address"); return false; }
+ if (!found) { error("named reflector not found"); return false; }
+ client = NimBLEDevice::createClient();
+ client->setCallbacks(clientCallbacks);
+ client->setConnectionParams(intervalUnits, intervalUnits, 0, 200);
+ client->setConnectTimeout(10000);
+ if (!client->connect(found)) { error("GAP connect failed"); disconnectClient(); return false; }
+ auto service = client->getService(serviceUuid);
+ auto characteristic = service ? service->getCharacteristic(psmUuid) : nullptr;
+ const auto value = characteristic ? characteristic->readValue() : NimBLEAttValue();
+ if (value.size() != 2) { error("PSM discovery failed"); disconnectClient(); return false; }
+ const auto psm = uint16_t(value[0]) | (uint16_t(value[1]) << 8);
+ ownedChannel = NimBLEL2CAPChannel::connect(client, psm, channelMtu, new ChannelCallbacks());
+ if (!ownedChannel) { error("L2CAP connect failed"); disconnectClient(); return false; }
+ return greetPeer(psm);
+}
+
+#include "bench_stream_run.inc"
+
+struct Sample { uint32_t sequence, rtt, first, submit, turnaround; };
+int run(int argc, char** argv) {
+ if (argc != 5) { error("usage: run echo|request|stream-tx|stream-rx|duplex request_size response_size count"); return 1; }
+ const long req = strtol(argv[2], nullptr, 10), resp = strtol(argv[3], nullptr, 10), count = strtol(argv[4], nullptr, 10);
+ const uint8_t pattern = !strcmp(argv[1], "echo") ? 1 : !strcmp(argv[1], "request") ? 2 :
+ !strcmp(argv[1], "stream-tx") ? 3 : !strcmp(argv[1], "stream-rx") ? 4 : !strcmp(argv[1], "duplex") ? 5 : 0;
+ if (!pattern || req < 8 || req > bench::maxPayload || resp < 12 || resp > bench::maxPayload ||
+ count < 1 || count > 4096 || (pattern == 1 && req != resp)) {
+ error("invalid pattern, sizes or count (1..4096)"); return 1;
+ }
+ if (!connected.load() || reflectorMode.load()) { error("connect to a reflector first"); return 1; }
+ params();
+ bench::Config config{pattern, uint16_t(req), uint16_t(resp), uint32_t(count)};
+ auto ack = exchange(bench::config, config.encode(), bench::configReply);
+ if (!ack || ack->payload != std::vector{0}) { disconnectClient(); return 1; }
+ if (pattern >= 3) return runStream(config, argv[1]);
+ auto startObject = cJSON_CreateObject();
+ cJSON_AddStringToObject(startObject, "pattern", argv[1]);
+ number(startObject, "request_size", req); number(startObject, "response_size", resp); number(startObject, "count", count);
+ number(startObject, "heap_free", esp_get_free_heap_size()); emit("run-start", startObject);
+ std::vector samples; samples.reserve(count);
+ const uint32_t token = esp_random();
+ const auto faultsBefore = callbackFaults.load();
+ const auto started = esp_timer_get_time();
+ bool success = true;
+ for (uint32_t seq = 0; seq < uint32_t(count); ++seq) {
+ std::vector payload; payload.reserve(req);
+ bench::put32(payload, seq); bench::put32(payload, token);
+ for (size_t i = 0; i < size_t(req - 8); ++i) payload.push_back(bench::pattern(seq, token, i));
+ firstCallbackUs = 0; requestStartedUs = esp_timer_get_time(); waiting = true;
+ auto channel = activeChannel.load();
+ const bool written = channel && channel->write(bench::encode(bench::ping, payload));
+ const auto submitUs = uint32_t(esp_timer_get_time() - requestStartedUs);
+ bench::Frame* raw = nullptr;
+ const bool received = written && xQueueReceive(replies, &raw, pdMS_TO_TICKS(5000)) == pdTRUE;
+ waiting = false;
+ std::unique_ptr reply(raw);
+ if (!received) { error(written ? "PING timeout" : "PING write failed"); success = false; break; }
+ const auto& p = reply->payload;
+ bool valid = reply->type == bench::pingReply && p.size() == size_t(resp) &&
+ bench::u32(p.data()) == seq && bench::u32(p.data() + 4) == token;
+ for (size_t i = 12; valid && i < p.size(); ++i) valid = p[i] == bench::pattern(seq, token, i - 12);
+ const auto rtt = uint32_t(reply->receivedUs - requestStartedUs);
+ const auto turnaround = valid ? bench::u32(p.data() + 8) : 0;
+ if (!valid || turnaround > rtt) { error("PING length, sequence, pattern or timing mismatch"); success = false; break; }
+ samples.push_back({seq, rtt, firstCallbackUs.load(), submitUs, turnaround});
+ }
+ const auto elapsed = esp_timer_get_time() - started;
+ params();
+ success = success && callbackFaults.load() == faultsBefore;
+ std::vector rtts; uint64_t sum = 0;
+ for (const auto& sample : samples) { rtts.push_back(sample.rtt); sum += sample.rtt; }
+ std::sort(rtts.begin(), rtts.end());
+ auto result = cJSON_CreateObject();
+ cJSON_AddBoolToObject(result, "success", success);
+ number(result, "completed", samples.size()); number(result, "elapsed_us", elapsed);
+ number(result, "callback_faults", callbackFaults.load() - faultsBefore);
+ if (!rtts.empty()) {
+ number(result, "rtt_min_us", rtts.front()); number(result, "rtt_max_us", rtts.back());
+ number(result, "rtt_median_us", (double(rtts[(rtts.size() - 1) / 2]) + rtts[rtts.size() / 2]) / 2);
+ number(result, "rtt_p95_us", rtts[(rtts.size() * 95 + 99) / 100 - 1]);
+ number(result, "rtt_mean_us", double(sum) / samples.size());
+ number(result, "goodput_bytes_per_s", double(samples.size()) * (req + resp - 20) * 1e6 / elapsed);
+ number(result, "framed_bytes_per_s", double(samples.size()) * (req + resp + 8) * 1e6 / elapsed);
+ }
+ number(result, "heap_free", esp_get_free_heap_size()); number(result, "heap_min", esp_get_minimum_free_heap_size());
+ emit("run-result", result);
+ // Serialize samples after timing stops, so the serial link cannot pace the test.
+ for (const auto& sample : samples) {
+ auto object = cJSON_CreateObject();
+ number(object, "seq", sample.sequence); number(object, "rtt_us", sample.rtt);
+ number(object, "first_callback_us", sample.first); number(object, "tx_submit_us", sample.submit);
+ number(object, "peer_turnaround_us", sample.turnaround);
+ number(object, "adjusted_rtt_us", sample.rtt - sample.turnaround); emit("sample", object);
+ }
+ emit("run-end"); if (!success) disconnectClient(); return success ? 0 : 1;
+}
+
+void reflectorTask(void*) {
+ bench::Config config;
+ uint32_t expectedSequence = 0;
+ uint32_t generation = 0, streamToken = 0;
+ bool streaming = false, rxEnded = false, txConfirmed = false;
+ for (;;) {
+ bench::Frame* raw;
+ if (xQueueReceive(reflectRequests, &raw, portMAX_DELAY) != pdTRUE) continue;
+ std::unique_ptr request(raw);
+ struct Busy { Busy() { reflectorBusy = true; } ~Busy() { reflectorBusy = false; } } busy;
+ auto channel = activeChannel.load();
+ if (!channel || !connected.load() || request->generation != connectionGeneration.load()) continue;
+ if (generation != request->generation) { generation = request->generation; config = {}; streaming = false; }
+ uint8_t type = bench::abort;
+ std::vector response;
+ if (request->type == bench::hello && !request->payload.empty() && request->payload[0] == bench::version) {
+ config = {}; streaming = false;
+ const std::string identity = std::string(CONFIG_IDF_TARGET) + " " + BENCH_LIBRARY_COMMIT;
+ response = {bench::version}; response.insert(response.end(), identity.begin(), identity.end()); type = bench::helloReply;
+ } else if (request->type == bench::config && !streaming && config.parse(request->payload)) {
+ expectedSequence = 0;
+ response = {0}; type = bench::configReply;
+ } else if (request->type == bench::streamStart && config.pattern >= 3 && !streaming && request->payload.size() == 4) {
+ streamToken = bench::u32(request->payload.data()); streaming = true;
+ rxEnded = config.pattern == 4; txConfirmed = config.pattern == 3;
+ { std::lock_guard lock(streamMutex);
+ streamReceiver.reset(streamToken, config.pattern != 4 ? config.count : 0, config.request); }
+ bool ok = channel->write(bench::encode(bench::streamStartReply, request->payload));
+ if (ok && config.pattern != 3) ok = sendStream(channel, generation, streamToken, config.count, config.response);
+ if (!ok) { ++callbackFaults; channel->disconnect(); }
+ continue;
+ } else if (request->type == bench::streamEnd && streaming && !rxEnded) {
+ std::lock_guard lock(streamMutex);
+ if (streamReceiver.end(request->payload)) {
+ rxEnded = true; type = bench::streamEndReply; response = streamReceiver.summary.encode();
+ }
+ } else if (request->type == bench::streamEndReply && streaming && !txConfirmed) {
+ bench::StreamSummary summary;
+ if (!summary.parse(request->payload) || !summary.matches(streamToken, config.count, config.response)) {
+ ++callbackFaults; channel->disconnect();
+ } else txConfirmed = true;
+ continue;
+ } else if (request->type == bench::streamFinish && streaming && rxEnded && txConfirmed &&
+ request->payload.size() == 4 && bench::u32(request->payload.data()) == streamToken) {
+ streaming = false; clearStream(); type = bench::streamFinishReply; response = request->payload;
+ } else if (request->type == bench::ping && request->payload.size() == config.request && config.request >= 8) {
+ const auto seq = bench::u32(request->payload.data()), token = bench::u32(request->payload.data() + 4);
+ bool valid = seq == expectedSequence && seq < config.count;
+ for (size_t i = 8; i < request->payload.size(); ++i)
+ if (request->payload[i] != bench::pattern(seq, token, i - 8)) valid = false;
+ if (valid) {
+ bench::put32(response, seq); bench::put32(response, token);
+ ++expectedSequence;
+ bench::put32(response, 0);
+ for (size_t i = 0; i < size_t(config.response - 12); ++i) response.push_back(bench::pattern(seq, token, i));
+ type = bench::pingReply;
+ }
+ }
+ auto encoded = bench::encode(type, response);
+ if (type == bench::pingReply) {
+ const auto turnaround = uint32_t(esp_timer_get_time() - request->receivedUs);
+ for (int i = 0; i < 4; ++i) encoded[12 + i] = uint8_t(turnaround >> ((3 - i) * 8));
+ }
+ if (generation == connectionGeneration.load() && !channel->write(encoded)) ++callbackFaults;
+ }
+}
+
+int command(int argc, char** argv) {
+ if (!strcmp(argv[0], "run")) {
+ const int result = run(argc, argv);
+ heapStatus("after-run");
+ return result;
+ }
+ if (!strcmp(argv[0], "connect")) return argc == 2 && connectPeer(argv[1]) ? 0 : 1;
+ if (!strcmp(argv[0], "disconnect")) {
+ const bool ok = disconnectClient();
+ vTaskDelay(pdMS_TO_TICKS(100)); // Let FreeRTOS reclaim deleted tasks before heap sampling.
+ emit("disconnected"); heapStatus("disconnected"); return ok ? 0 : 1;
+ }
+ if (!strcmp(argv[0], "status")) { metadata(); params(); heapStatus(connected.load() ? "connected" : "idle"); return 0; }
+ if (!strcmp(argv[0], "reboot")) esp_restart();
+ if (!strcmp(argv[0], "params")) {
+ if (argc == 3) {
+ const double interval = strtod(argv[1], nullptr);
+ const int phy = atoi(argv[2]);
+ if (connected.load() || interval < 7.5 || interval > 4000 || (phy != 1 && phy != 2)) {
+ error("disconnect first; params <1|2>"); return 1;
+ }
+ intervalUnits = uint16_t(interval / 1.25); wantedPhy = phy == 2 ? BLE_GAP_LE_PHY_2M : BLE_GAP_LE_PHY_1M;
+ }
+ params(); return 0;
+ }
+ if (!strcmp(argv[0], "role") && argc == 2 && !strcmp(argv[1], "client")) {
+ if (!disconnectClient()) return 1;
+ serverMode = false; reflectorMode = false; emit("client-ready"); return 0;
+ }
+ if (!strcmp(argv[0], "await") && argc == 2) {
+ const long seconds = strtol(argv[1], nullptr, 10);
+ if (seconds < 1 || seconds > 120 || reflectorMode.load()) return 1;
+ const auto deadline = esp_timer_get_time() + seconds * 1000000LL;
+ while (!connected.load() && esp_timer_get_time() < deadline) vTaskDelay(pdMS_TO_TICKS(10));
+ if (!connected.load()) { error("waiting for peer timed out"); return 1; }
+ return greetPeer(serverPsm) ? 0 : 1;
+ }
+ if (!strcmp(argv[0], "listen") ||
+ (!strcmp(argv[0], "role") && argc == 2 && !strcmp(argv[1], "server"))) {
+ if (serverMode.load() && connected.load()) { error("disconnect before changing role"); return 1; }
+ if (!disconnectClient()) return 1;
+ serverMode = true;
+ reflectorMode = strcmp(argv[0], "listen") != 0;
+ if (!serverStarted) {
+ auto coc = NimBLEDevice::createL2CAPServer();
+ if (!coc->createService(serverPsm, channelMtu, new ChannelCallbacks())) { error("server create failed"); return 1; }
+ auto server = NimBLEDevice::createServer(); server->setCallbacks(serverCallbacks);
+ auto service = server->createService(serviceUuid);
+ auto characteristic = service->createCharacteristic(psmUuid, NIMBLE_PROPERTY::READ);
+ const uint8_t psm[] = {uint8_t(serverPsm), uint8_t(serverPsm >> 8)};
+ characteristic->setValue(psm, sizeof(psm));
+ auto advertising = NimBLEDevice::getAdvertising();
+ NimBLEAdvertisementData scanResponse; scanResponse.setName("L2CAP-Bench-ESP");
+ advertising->setScanResponseData(scanResponse); advertising->addServiceUUID(serviceUuid);
+ serverStarted = true;
+ }
+ auto advertising = NimBLEDevice::getAdvertising();
+ advertisingEnabled = true;
+ if (!advertising->start()) { error("advertising failed"); return 1; }
+ emit(reflectorMode.load() ? "server-ready" : "listening"); return 0;
+ }
+ error("unknown command; role server|client, listen, await , connect , run echo|request , params <1|2>, status, disconnect, reboot");
+ return 1;
+}
+} // namespace
+
+extern "C" void app_main() {
+ setvbuf(stdin, nullptr, _IONBF, 0); setvbuf(stdout, nullptr, _IONBF, 0);
+ usb_serial_jtag_driver_config_t usbConfig{}; usbConfig.rx_buffer_size = 1024; usbConfig.tx_buffer_size = 4096;
+ ESP_ERROR_CHECK(usb_serial_jtag_driver_install(&usbConfig));
+ usb_serial_jtag_vfs_set_rx_line_endings(ESP_LINE_ENDINGS_LF);
+ usb_serial_jtag_vfs_set_tx_line_endings(ESP_LINE_ENDINGS_LF);
+ usb_serial_jtag_vfs_use_driver(); fcntl(fileno(stdin), F_SETFL, 0);
+ replies = xQueueCreate(16, sizeof(bench::Frame*)); reflectRequests = xQueueCreate(16, sizeof(bench::Frame*));
+ configASSERT(replies && reflectRequests);
+ NimBLEDevice::init("L2CAP-Bench-ESP"); NimBLEDevice::setMTU(517);
+ NimBLEDevice::setCustomGapHandler([](ble_gap_event* event, void*) -> int {
+ if (event->type == BLE_GAP_EVENT_DATA_LEN_CHG) {
+ observedTxOctets = event->data_len_chg.max_tx_octets;
+ observedRxOctets = event->data_len_chg.max_rx_octets;
+ } else if (event->type == BLE_GAP_EVENT_CONN_UPDATE) {
+ intervalStatus = event->conn_update.status;
+ }
+ return 0;
+ });
+ xTaskCreate(reflectorTask, "reflect", 6144, nullptr, 4, nullptr);
+ esp_console_config_t console{}; console.max_cmdline_args = 8; console.max_cmdline_length = 256;
+ ESP_ERROR_CHECK(esp_console_init(&console));
+ for (auto name : {"connect", "disconnect", "run", "status", "params", "role", "listen", "await", "reboot"}) {
+ esp_console_cmd_t cmd{}; cmd.command = name; cmd.help = "L2CAP benchmark command"; cmd.func = command;
+ ESP_ERROR_CHECK(esp_console_cmd_register(&cmd));
+ }
+ metadata(); emit("ready");
+ char line[256];
+ while (true) {
+ if (!fgets(line, sizeof(line), stdin)) { clearerr(stdin); vTaskDelay(1); continue; }
+ line[strcspn(line, "\r\n")] = '\0';
+ if (!line[0]) continue;
+ int result = 0;
+ const auto err = esp_console_run(line, &result);
+ if (err != ESP_OK) error(esp_err_to_name(err));
+ auto object = cJSON_CreateObject(); number(object, "code", err == ESP_OK ? result : int(err)); emit("command-end", object);
+ }
+}
diff --git a/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults
new file mode 100644
index 00000000..a63f08dd
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults
@@ -0,0 +1,21 @@
+CONFIG_BT_ENABLED=y
+CONFIG_BT_NIMBLE_ENABLED=y
+CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM=1
+CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
+CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=517
+CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=24
+CONFIG_BT_NIMBLE_MSYS_1_BLOCK_SIZE=256
+CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=24
+CONFIG_BT_NIMBLE_MSYS_2_BLOCK_SIZE=320
+CONFIG_BT_NIMBLE_ACL_BUF_COUNT=24
+CONFIG_NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT=5
+CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=n
+CONFIG_COMPILER_OPTIMIZATION_PERF=y
+CONFIG_LOG_DEFAULT_LEVEL_ERROR=y
+CONFIG_NIMBLE_CPP_LOG_LEVEL_ERROR=y
+CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
+CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
+CONFIG_FREERTOS_HZ=1000
+CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384
+CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
+CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
diff --git a/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s3 b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s3
new file mode 100644
index 00000000..7382da57
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s3
@@ -0,0 +1 @@
+CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
diff --git a/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s31 b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s31
new file mode 100644
index 00000000..b34be413
--- /dev/null
+++ b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s31
@@ -0,0 +1 @@
+CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_320=y
diff --git a/src/NimBLEDevice.cpp b/src/NimBLEDevice.cpp
index 0cb93456..39508ff6 100644
--- a/src/NimBLEDevice.cpp
+++ b/src/NimBLEDevice.cpp
@@ -1037,6 +1037,15 @@ bool NimBLEDevice::deinit(bool clearAll) {
if (isInitialized()) {
rc = nimble_port_stop();
if (rc == 0) {
+# if MYNEWT_VAL(BLE_ROLE_PERIPHERAL) && MYNEWT_VAL(BLE_L2CAP_COC_MAX_NUM)
+ // The stopped host cannot enqueue new channel jobs. Destroy CoC
+ // events while the NPL function table and event pool still exist;
+ // nimble_port_deinit() releases both on ESP-IDF.
+ if (clearAll && m_pL2CAPServer != nullptr) {
+ delete m_pL2CAPServer;
+ m_pL2CAPServer = nullptr;
+ }
+# endif
m_initialized.store(false);
m_synced.store(false, std::memory_order_release);
# if MYNEWT_VAL(BLE_ROLE_OBSERVER)
diff --git a/src/NimBLEL2CAPChannel.cpp b/src/NimBLEL2CAPChannel.cpp
index 36b740ad..169eb2f9 100644
--- a/src/NimBLEL2CAPChannel.cpp
+++ b/src/NimBLEL2CAPChannel.cpp
@@ -8,6 +8,14 @@
# include "NimBLEClient.h"
# include "NimBLELog.h"
# include "NimBLEUtils.h"
+# include "freertos/queue.h"
+# include "freertos/task.h"
+# ifdef USING_NIMBLE_ARDUINO_HEADERS
+# include "nimble/porting/nimble/include/nimble/nimble_port.h"
+# else
+# include "nimble/nimble_port.h"
+# endif
+# include
# ifdef USING_NIMBLE_ARDUINO_HEADERS
# include "nimble/nimble/host/include/host/ble_gap.h"
@@ -15,50 +23,238 @@
# include "host/ble_gap.h"
# endif
-// L2CAP buffer block size
-# define L2CAP_BUF_BLOCK_SIZE (250)
-# define L2CAP_BUF_SIZE_MTUS_PER_CHANNEL (3)
-// Round-up integer division
-# define CEIL_DIVIDE(a, b) (((a) + (b) - 1) / (b))
-# define ROUND_DIVIDE(a, b) (((a) + (b) / 2) / (b))
+// Allocate one full SDU per mbuf, matching NimBLE's own CoC examples.
+# define L2CAP_SDU_BUFFER_COUNT CONFIG_NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT
// Retry
-constexpr uint32_t RetryTimeout = 50;
-constexpr int RetryCounter = 3;
+constexpr uint32_t SendTimeoutMs = 2000;
+#ifndef CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH
+#define CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH 16
+#define CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_STACK_SIZE 4096
+#define CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_PRIORITY 5
+#endif
+
+static void logPoolSanityWarning(const char* tag, uint16_t psm, const ble_l2cap_chan_info& info) {
+ const uint16_t negotiatedCocMtu = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu;
+ const uint16_t effectiveL2capMtu = info.peer_l2cap_mtu < info.our_l2cap_mtu ? info.peer_l2cap_mtu : info.our_l2cap_mtu;
+
+ if (negotiatedCocMtu == 0 || effectiveL2capMtu == 0) {
+ return;
+ }
+
+ const uint32_t fragmentsPerSdu = (negotiatedCocMtu + effectiveL2capMtu - 1) / effectiveL2capMtu;
+ const uint32_t msys1BlockCount = MYNEWT_VAL(MSYS_1_BLOCK_COUNT);
+ const uint32_t msys1BlockSize = MYNEWT_VAL(MSYS_1_BLOCK_SIZE);
+ const uint32_t cocMps = MYNEWT_VAL(BLE_L2CAP_COC_MPS);
+ const uint32_t aclFromLlCount = MYNEWT_VAL(BLE_TRANSPORT_ACL_FROM_LL_COUNT);
+ const uint32_t localSduBufferCount = L2CAP_SDU_BUFFER_COUNT;
+
+ NIMBLE_LOGI(tag,
+ "L2CAP COC 0x%04X path geometry: negotiated_coc=%u effective_l2cap=%u fragments_per_sdu=%lu mps=%lu local_sdu_bufs=%lu msys1=%lux%lu acl_from_ll=%lu",
+ psm,
+ negotiatedCocMtu,
+ effectiveL2capMtu,
+ (unsigned long)fragmentsPerSdu,
+ (unsigned long)cocMps,
+ (unsigned long)localSduBufferCount,
+ (unsigned long)msys1BlockCount,
+ (unsigned long)msys1BlockSize,
+ (unsigned long)aclFromLlCount);
+
+ if (effectiveL2capMtu < negotiatedCocMtu &&
+ (fragmentsPerSdu >= msys1BlockCount || fragmentsPerSdu * 2 >= msys1BlockCount || fragmentsPerSdu >= aclFromLlCount)) {
+ NIMBLE_LOGW(tag,
+ "L2CAP COC 0x%04X large-SDU risk: negotiated MTU %u requires %lu fragments at L2CAP MTU %u. Current pools (MSYS_1=%lux%lu, ACL_FROM_LL=%lu) may cause ENOMEM/timeouts under load.",
+ psm,
+ negotiatedCocMtu,
+ (unsigned long)fragmentsPerSdu,
+ effectiveL2capMtu,
+ (unsigned long)msys1BlockCount,
+ (unsigned long)msys1BlockSize,
+ (unsigned long)aclFromLlCount);
+ }
+
+ if (localSduBufferCount < 3) {
+ NIMBLE_LOGW(tag,
+ "L2CAP COC 0x%04X local SDU buffer count is %lu. Very small per-channel SDU pools reduce heap use but can make large MTUs fragile when send/receive work overlaps or callbacks lag.",
+ psm,
+ (unsigned long)localSduBufferCount);
+ }
+}
+
+enum CallbackKind : uint8_t { Connected, Read, Disconnected };
+struct DeferredReadItem {
+ NimBLEL2CAPChannel* channel;
+ std::vector* data;
+ uint32_t generation;
+ uint8_t kind;
+};
+static QueueHandle_t s_l2capDeferredReadQueue = nullptr;
+static TaskHandle_t s_l2capDeferredReadTask = nullptr;
+static size_t s_l2capChannelCount = 0;
+static constexpr size_t LifecycleSlots = 2 * MYNEWT_VAL(BLE_L2CAP_COC_MAX_NUM);
+
+void deferredReadWorker(void*) {
+ DeferredReadItem item{};
+ while (true) {
+ if (xQueueReceive(s_l2capDeferredReadQueue, &item, portMAX_DELAY) != pdTRUE) { continue; }
+ auto* ch = item.channel;
+ if (item.generation == ch->m_generation.load()) {
+ if (item.kind == Connected) {
+ ch->callbacks->onConnect(ch, ch->m_negotiatedMTU.load());
+ } else if (item.kind == Read && ch->isConnected()) {
+ ch->callbacks->onRead(ch, *item.data);
+ } else if (item.kind == Disconnected) {
+ // Off the host task: callbacks may join CAN RX or finish a command.
+ ch->callbacks->onDisconnect(ch);
+ while (ch->m_pendingHostJobs.load() || ch->m_writers.load()) { vTaskDelay(1); }
+ ch->m_state.store(NimBLEL2CAPChannel::State::idle);
+ }
+ }
+ delete item.data;
+ ch->m_pendingDeferredReads.fetch_sub(1);
+ }
+}
+
+bool ensureDeferredReadWorker() {
+ // Channel construction belongs to the application's initialization task.
+ if (s_l2capDeferredReadQueue && s_l2capDeferredReadTask) { return true; }
+ s_l2capDeferredReadQueue = xQueueCreate(CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH + LifecycleSlots,
+ sizeof(DeferredReadItem));
+ if (!s_l2capDeferredReadQueue) { return false; }
+ return xTaskCreate(deferredReadWorker, "nimble_l2cap_cb", CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_STACK_SIZE,
+ nullptr, CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_PRIORITY, &s_l2capDeferredReadTask) == pdPASS;
+}
+
+void NimBLEL2CAPChannel::dispatchCallback(uint8_t kind, std::vector* data) {
+ // The host task is the only producer. Reserve two lifecycle slots per
+ // channel; reconnect is refused until its disconnect callback has finished.
+ if (kind == Read && uxQueueSpacesAvailable(s_l2capDeferredReadQueue) <= LifecycleSlots) {
+ delete data;
+ disconnect();
+ return;
+ }
+ DeferredReadItem item{this, data, m_generation.load(), kind};
+ m_pendingDeferredReads.fetch_add(1);
+ auto result = xQueueSend(s_l2capDeferredReadQueue, &item, 0);
+ assert(result == pdTRUE);
+}
+
+struct NimBLEL2CAPChannel::TxCompletion {
+ SemaphoreHandle_t ready = xSemaphoreCreateBinary();
+ std::atomic status{BLE_HS_EUNKNOWN};
+ std::atomic completed{false};
+ ~TxCompletion() { vSemaphoreDelete(ready); }
+ void reset() {
+ // The whole-PDU mutex allows only one waiter. A timeout closes the
+ // session, and accept waits for the old host job before reusing this.
+ xSemaphoreTake(ready, 0);
+ status.store(BLE_HS_EUNKNOWN);
+ completed.store(false);
+ }
+ void finish(int value) {
+ if (completed.exchange(true)) { return; }
+ status.store(value);
+ xSemaphoreGive(ready);
+ }
+};
+void NimBLEL2CAPChannel::transmitOnHost(ble_npl_event* event) {
+ auto* ch = static_cast(ble_npl_event_get_arg(event));
+ auto* buffer = ch->m_txBuffer.exchange(nullptr);
+ if (!buffer) { return; }
+ if (!ch->isConnected() || ch->m_txGeneration.load() != ch->m_generation.load() || !ch->channel) {
+ os_mbuf_free_chain(buffer);
+ ch->m_completion->finish(BLE_HS_ENOTCONN);
+ } else {
+ // Arm before send. An immediate TX_UNSTALLED must not be discarded.
+ ch->m_activeSend = ch->m_completion.get();
+ int rc = ble_l2cap_send(ch->channel, buffer);
+ // Pinned NimBLE consumes the SDU even on errors from continue_tx.
+ // Only these two early returns leave ownership with the caller.
+ if (rc == BLE_HS_EBADDATA || rc == BLE_HS_EBUSY) { os_mbuf_free_chain(buffer); }
+ if (rc != BLE_HS_ESTALLED) {
+ ch->m_completion->finish(rc);
+ ch->m_activeSend = nullptr;
+ }
+ }
+ ch->m_pendingHostJobs.fetch_sub(1);
+}
+
+void NimBLEL2CAPChannel::disconnectOnHost(ble_npl_event* event) {
+ auto* ch = static_cast(ble_npl_event_get_arg(event));
+ if (ch->m_activeSend) {
+ ch->m_activeSend->finish(BLE_HS_ENOTCONN);
+ ch->m_activeSend = nullptr;
+ }
+ if (ch->channel) { ble_l2cap_disconnect(ch->channel); }
+ ch->m_pendingHostJobs.fetch_sub(1);
+}
NimBLEL2CAPChannel::NimBLEL2CAPChannel(uint16_t psm, uint16_t mtu, NimBLEL2CAPChannelCallbacks* callbacks)
: psm(psm), mtu(mtu), callbacks(callbacks) {
assert(mtu); // fail here, if MTU is too little
assert(callbacks); // fail here, if no callbacks are given
- assert(setupMemPool()); // fail here, if the memory pool could not be setup
+ const bool poolReady = setupMemPool();
+ assert(poolReady); // fail here, if the memory pool could not be setup
+ const bool workerReady = ensureDeferredReadWorker();
+ assert(workerReady);
+ ++s_l2capChannelCount;
+ m_writeMutex = xSemaphoreCreateMutex();
+ assert(m_writeMutex);
+ m_completion = std::make_unique();
+ assert(m_completion->ready);
+ ble_npl_event_init(&m_txEvent, transmitOnHost, this);
+ ble_npl_event_init(&m_disconnectEvent, disconnectOnHost, this);
NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X initialized w/ L2CAP MTU %i", this->psm, this->mtu);
};
NimBLEL2CAPChannel::~NimBLEL2CAPChannel() {
+ while (m_pendingDeferredReads.load() || m_pendingHostJobs.load() || m_writers.load()) {
+ vTaskDelay(pdMS_TO_TICKS(1));
+ }
+ ble_npl_event_deinit(&m_txEvent);
+ ble_npl_event_deinit(&m_disconnectEvent);
teardownMemPool();
+ // All producers and callbacks have drained before the last channel is
+ // destroyed. Keeping the shared worker would retain its internal stack
+ // and queue across a full Bluetooth shutdown.
+ if (--s_l2capChannelCount == 0) {
+ vTaskDelete(s_l2capDeferredReadTask);
+ s_l2capDeferredReadTask = nullptr;
+ vQueueDelete(s_l2capDeferredReadQueue);
+ s_l2capDeferredReadQueue = nullptr;
+ }
+
NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X shutdown and freed.", this->psm);
}
bool NimBLEL2CAPChannel::setupMemPool() {
- const size_t buf_blocks = CEIL_DIVIDE(mtu, L2CAP_BUF_BLOCK_SIZE) * L2CAP_BUF_SIZE_MTUS_PER_CHANNEL;
- NIMBLE_LOGD(LOG_TAG, "Computed number of buf_blocks = %d", buf_blocks);
+ const size_t buf_blocks = L2CAP_SDU_BUFFER_COUNT;
+ // Pool block size includes both headers. Without this headroom a full
+ // local-MTU packet would silently need more than one configured buffer.
+ const size_t blockSize = size_t(mtu) + sizeof(os_mbuf) + sizeof(os_mbuf_pkthdr);
+ if (blockSize > UINT16_MAX) { return false; }
+ NIMBLE_LOGD(LOG_TAG, "Allocating %d L2CAP SDU buffers of %d bytes", buf_blocks, mtu);
+
+ memset(&_coc_mempool, 0, sizeof(_coc_mempool));
+ memset(&_coc_mbuf_pool, 0, sizeof(_coc_mbuf_pool));
- _coc_memory = malloc(OS_MEMPOOL_SIZE(buf_blocks, L2CAP_BUF_BLOCK_SIZE) * sizeof(os_membuf_t));
+ _coc_memory = malloc(OS_MEMPOOL_SIZE(buf_blocks, blockSize) * sizeof(os_membuf_t));
if (_coc_memory == 0) {
NIMBLE_LOGE(LOG_TAG, "Can't allocate _coc_memory: %d", errno);
return false;
}
- auto rc = os_mempool_init(&_coc_mempool, buf_blocks, L2CAP_BUF_BLOCK_SIZE, _coc_memory, "appbuf");
+ auto rc = os_mempool_init(&_coc_mempool, buf_blocks, blockSize, _coc_memory, "appbuf");
if (rc != 0) {
NIMBLE_LOGE(LOG_TAG, "Can't os_mempool_init: %d", rc);
return false;
}
- auto rc2 = os_mbuf_pool_init(&_coc_mbuf_pool, &_coc_mempool, L2CAP_BUF_BLOCK_SIZE, buf_blocks);
+ auto rc2 = os_mbuf_pool_init(&_coc_mbuf_pool, &_coc_mempool, blockSize, buf_blocks);
if (rc2 != 0) {
- NIMBLE_LOGE(LOG_TAG, "Can't os_mbuf_pool_init: %d", rc);
+ NIMBLE_LOGE(LOG_TAG, "Can't os_mbuf_pool_init: %d", rc2);
return false;
}
@@ -72,87 +268,49 @@ bool NimBLEL2CAPChannel::setupMemPool() {
}
void NimBLEL2CAPChannel::teardownMemPool() {
+ if (m_writeMutex) {
+ vSemaphoreDelete(m_writeMutex);
+ m_writeMutex = nullptr;
+ }
if (this->callbacks) {
delete this->callbacks;
+ this->callbacks = nullptr;
}
if (this->receiveBuffer) {
free(this->receiveBuffer);
+ this->receiveBuffer = nullptr;
+ }
+ if (_coc_mempool.name) {
+ os_mempool_unregister(&_coc_mempool);
+ memset(&_coc_mempool, 0, sizeof(_coc_mempool));
+ memset(&_coc_mbuf_pool, 0, sizeof(_coc_mbuf_pool));
}
if (_coc_memory) {
free(_coc_memory);
+ _coc_memory = nullptr;
}
}
int NimBLEL2CAPChannel::writeFragment(std::vector::const_iterator begin, std::vector::const_iterator end) {
- auto toSend = end - begin;
-
- if (stalled) {
- NIMBLE_LOGD(LOG_TAG, "L2CAP Channel waiting for unstall...");
- NimBLEUtils::TaskData taskData;
- m_pTaskData = &taskData;
- NimBLEUtils::taskWait(taskData, BLE_NPL_TIME_FOREVER);
- m_pTaskData = nullptr;
- stalled = false;
- NIMBLE_LOGD(LOG_TAG, "L2CAP Channel unstalled!");
+ auto* buffer = os_mbuf_get_pkthdr(&_coc_mbuf_pool, 0);
+ if (!buffer) { return BLE_HS_ENOMEM; }
+ int rc = os_mbuf_append(buffer, &*begin, end - begin);
+ if (rc) { os_mbuf_free_chain(buffer); return rc; }
+ auto* completion = m_completion.get();
+ completion->reset();
+ m_txGeneration.store(m_generation.load());
+ m_pendingHostJobs.fetch_add(1);
+ m_txBuffer.store(buffer);
+ if (xTaskGetCurrentTaskHandle() == m_hostTask.load()) {
+ transmitOnHost(&m_txEvent);
+ // A host callback must never wait for host progress.
+ if (xSemaphoreTake(completion->ready, 0) != pdTRUE) { return BLE_HS_ETIMEOUT; }
+ } else {
+ // At most one TX event and one disconnect event are outstanding per channel.
+ ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &m_txEvent);
+ if (xSemaphoreTake(completion->ready, pdMS_TO_TICKS(SendTimeoutMs)) != pdTRUE) { return BLE_HS_ETIMEOUT; }
}
-
- struct ble_l2cap_chan_info info;
- ble_l2cap_get_chan_info(channel, &info);
- // Take the minimum of our and peer MTU
- auto mtu = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu;
-
- if (toSend > mtu) {
- return -BLE_HS_EBADDATA;
- }
-
- auto retries = RetryCounter;
-
- while (retries--) {
- auto txd = os_mbuf_get_pkthdr(&_coc_mbuf_pool, 0);
- if (!txd) {
- NIMBLE_LOGE(LOG_TAG, "Can't os_mbuf_get_pkthdr.");
- return -BLE_HS_ENOMEM;
- }
- auto append = os_mbuf_append(txd, &(*begin), toSend);
- if (append != 0) {
- NIMBLE_LOGE(LOG_TAG, "Can't os_mbuf_append: %d", append);
- return append;
- }
-
- auto res = ble_l2cap_send(channel, txd);
- switch (res) {
- case 0:
- NIMBLE_LOGD(LOG_TAG, "L2CAP COC 0x%04X sent %d bytes.", this->psm, toSend);
- return 0;
-
- case BLE_HS_ESTALLED:
- stalled = true;
- NIMBLE_LOGD(LOG_TAG, "L2CAP COC 0x%04X sent %d bytes.", this->psm, toSend);
- NIMBLE_LOGW(LOG_TAG,
- "ble_l2cap_send returned BLE_HS_ESTALLED. Next send will wait for unstalled event...");
- return 0;
-
- case BLE_HS_ENOMEM:
- case BLE_HS_EAGAIN:
- /* ble_l2cap_send already consumed and freed txd on these errors */
- NIMBLE_LOGD(LOG_TAG, "ble_l2cap_send returned %d (consumed buffer). Retrying shortly...", res);
- ble_npl_time_delay(ble_npl_time_ms_to_ticks32(RetryTimeout));
- continue;
-
- case BLE_HS_EBUSY:
- /* Channel busy; txd not consumed */
- NIMBLE_LOGD(LOG_TAG, "ble_l2cap_send returned %d (busy). Retrying shortly...", res);
- os_mbuf_free_chain(txd);
- ble_npl_time_delay(ble_npl_time_ms_to_ticks32(RetryTimeout));
- continue;
-
- default:
- NIMBLE_LOGE(LOG_TAG, "ble_l2cap_send failed: %d", res);
- return res;
- }
- }
- NIMBLE_LOGE(LOG_TAG, "Retries exhausted, dropping %d bytes to send.", toSend);
- return -BLE_HS_EREJECT;
+ return completion->status.load();
}
# if MYNEWT_VAL(BLE_ROLE_CENTRAL)
@@ -172,89 +330,136 @@ NimBLEL2CAPChannel* NimBLEL2CAPChannel::connect(NimBLEClient* cli
auto sdu_rx = os_mbuf_get_pkthdr(&channel->_coc_mbuf_pool, 0);
if (!sdu_rx) {
NIMBLE_LOGE(LOG_TAG, "Can't allocate SDU buffer: %d, %s", errno, strerror(errno));
+ delete channel;
return nullptr;
}
auto rc = ble_l2cap_connect(client->getConnHandle(), psm, mtu, sdu_rx, NimBLEL2CAPChannel::handleL2capEvent, channel);
if (rc != 0) {
NIMBLE_LOGE(LOG_TAG, "ble_l2cap_connect failed: %d", rc);
+ os_mbuf_free_chain(sdu_rx);
+ delete channel;
+ return nullptr;
}
return channel;
}
# endif // MYNEWT_VAL(BLE_ROLE_CENTRAL)
bool NimBLEL2CAPChannel::write(const std::vector& bytes) {
- if (!this->channel) {
- NIMBLE_LOGW(LOG_TAG, "L2CAP Channel not open");
- return false;
- }
-
- struct ble_l2cap_chan_info info;
- ble_l2cap_get_chan_info(channel, &info);
- auto mtu = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu;
-
+ const auto generation = m_generation.load();
+ m_writers.fetch_add(1);
+ struct WriterExit { std::atomic& count; ~WriterExit() { count.fetch_sub(1); } } exit{m_writers};
+ if (!isConnected()) { return false; }
+ auto wait = xTaskGetCurrentTaskHandle() == m_hostTask.load() ? 0 : pdMS_TO_TICKS(SendTimeoutMs);
+ if (xSemaphoreTake(m_writeMutex, wait) != pdTRUE) { disconnect(); return false; }
+ struct Unlock { SemaphoreHandle_t mutex; ~Unlock() { xSemaphoreGive(mutex); } } unlock{m_writeMutex};
+ if (!isConnected() || generation != m_generation.load()) { return false; }
+ const auto width = m_txSduWidth.load();
+ if (!width) { disconnect(); return false; }
auto start = bytes.begin();
while (start != bytes.end()) {
- auto end = start + mtu < bytes.end() ? start + mtu : bytes.end();
- if (writeFragment(start, end) < 0) {
- return false;
- }
+ if (!isConnected()) { return false; }
+ const auto length = std::min(width, bytes.end() - start);
+ auto end = start + length;
+ if (writeFragment(start, end) != 0) { disconnect(); return false; }
start = end;
}
return true;
}
bool NimBLEL2CAPChannel::disconnect() {
- if (!this->channel) {
- NIMBLE_LOGW(LOG_TAG, "L2CAP Channel not open");
- return false;
- }
-
- int rc = ble_l2cap_disconnect(this->channel);
- if (rc != 0 && rc != BLE_HS_ENOTCONN && rc != BLE_HS_EALREADY) {
- NIMBLE_LOGE(LOG_TAG, "ble_l2cap_disconnect failed: rc=%d %s", rc, NimBLEUtils::returnCodeToString(rc));
- return false;
- }
-
+ auto state = m_state.load();
+ do {
+ if (state == State::idle || state == State::closing) { return false; }
+ } while (!m_state.compare_exchange_weak(state, State::closing));
+ m_pendingHostJobs.fetch_add(1);
+ if (xTaskGetCurrentTaskHandle() == m_hostTask.load()) { disconnectOnHost(&m_disconnectEvent); }
+ else { ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &m_disconnectEvent); }
return true;
}
uint16_t NimBLEL2CAPChannel::getConnHandle() const {
- if (!this->channel) {
- return BLE_HS_CONN_HANDLE_NONE;
- }
- return ble_l2cap_get_conn_handle(this->channel);
+ return m_connHandle.load();
}
// private
int NimBLEL2CAPChannel::handleConnectionEvent(struct ble_l2cap_event* event) {
+ m_hostTask = xTaskGetCurrentTaskHandle();
channel = event->connect.chan;
+ m_generation.fetch_add(1);
+ m_connHandle.store(ble_l2cap_get_conn_handle(channel));
+ m_state.store(State::open);
struct ble_l2cap_chan_info info;
- ble_l2cap_get_chan_info(channel, &info);
+ int rc = ble_l2cap_get_chan_info(channel, &info);
+ if (rc != 0) {
+ NIMBLE_LOGE(LOG_TAG, "L2CAP COC 0x%04X connected but ble_l2cap_get_chan_info failed: %d", psm, rc);
+ disconnect();
+ return rc;
+ }
NIMBLE_LOGI(LOG_TAG,
- "L2CAP COC 0x%04X connected. Local MTU = %d [%d], remote MTU = %d [%d].",
+ "L2CAP COC 0x%04X connected. scid=0x%04X dcid=0x%04X local_l2cap=%d local_coc=%d peer_l2cap=%d peer_coc=%d.",
psm,
- info.our_coc_mtu,
+ info.scid,
+ info.dcid,
info.our_l2cap_mtu,
- info.peer_coc_mtu,
- info.peer_l2cap_mtu);
- if (info.our_coc_mtu > info.peer_coc_mtu) {
+ info.our_coc_mtu,
+ info.peer_l2cap_mtu,
+ info.peer_coc_mtu);
+ logPoolSanityWarning(LOG_TAG, psm, info);
+ if (info.our_coc_mtu > 0 && info.peer_coc_mtu > 0 && info.our_coc_mtu > info.peer_coc_mtu) {
NIMBLE_LOGW(LOG_TAG, "L2CAP COC 0x%04X connected, but local MTU is bigger than remote MTU.", psm);
}
- auto mtu = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu;
- callbacks->onConnect(this, mtu);
+
+ uint16_t negotiatedMTU = 0;
+ if (info.our_coc_mtu > 0 && info.peer_coc_mtu > 0) {
+ negotiatedMTU = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu;
+ } else if (info.our_l2cap_mtu > 0 && info.peer_l2cap_mtu > 0) {
+ negotiatedMTU = info.peer_l2cap_mtu < info.our_l2cap_mtu ? info.peer_l2cap_mtu : info.our_l2cap_mtu;
+ NIMBLE_LOGW(LOG_TAG,
+ "L2CAP COC 0x%04X connected with zero CoC MTU info; falling back to L2CAP MTU %u for callback.",
+ psm,
+ negotiatedMTU);
+ } else {
+ negotiatedMTU = mtu;
+ NIMBLE_LOGW(LOG_TAG,
+ "L2CAP COC 0x%04X connected with no peer MTU info; falling back to configured MTU %u for callback.",
+ psm,
+ negotiatedMTU);
+ }
+
+ m_negotiatedMTU.store(negotiatedMTU);
+ uint16_t txSduWidth = negotiatedMTU;
+ m_txSduWidth.store(txSduWidth);
+#if CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS
+ dispatchCallback(Connected);
+#else
+ callbacks->onConnect(this, negotiatedMTU);
+#endif
return 0;
}
int NimBLEL2CAPChannel::handleAcceptEvent(struct ble_l2cap_event* event) {
NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X accept.", psm);
- if (!callbacks->shouldAcceptConnection(this)) {
+ m_hostTask = xTaskGetCurrentTaskHandle();
+ if (m_state.load() != State::idle || m_pendingDeferredReads.load() || m_pendingHostJobs.load() || m_writers.load() ||
+ !callbacks->shouldAcceptConnection(this)) {
NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X refused by delegate.", psm);
return -1;
}
struct os_mbuf* sdu_rx = os_mbuf_get_pkthdr(&_coc_mbuf_pool, 0);
- assert(sdu_rx != NULL);
- ble_l2cap_recv_ready(event->accept.chan, sdu_rx);
+ if (!sdu_rx) {
+ NIMBLE_LOGE(LOG_TAG, "L2CAP COC 0x%04X could not allocate receive SDU.", psm);
+ return BLE_HS_ENOMEM;
+ }
+
+ int rc = ble_l2cap_recv_ready(event->accept.chan, sdu_rx);
+ if (rc != 0) {
+ NIMBLE_LOGE(LOG_TAG, "L2CAP COC 0x%04X ble_l2cap_recv_ready failed during accept: %d", psm, rc);
+ os_mbuf_free_chain(sdu_rx);
+ return rc;
+ }
+
+ m_state.store(State::accepted);
return 0;
}
@@ -276,7 +481,6 @@ int NimBLEL2CAPChannel::handleDataReceivedEvent(struct ble_l2cap_event* event) {
assert(res == 0);
std::vector incomingData(receiveBuffer, receiveBuffer + rx_len);
- callbacks->onRead(this, incomingData);
struct os_mbuf* next = os_mbuf_get_pkthdr(&_coc_mbuf_pool, 0);
assert(next != NULL);
@@ -284,22 +488,35 @@ int NimBLEL2CAPChannel::handleDataReceivedEvent(struct ble_l2cap_event* event) {
res = ble_l2cap_recv_ready(channel, next);
assert(res == 0);
+#if CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS
+ dispatchCallback(Read, new std::vector(std::move(incomingData)));
+#else
+ if (isConnected()) { callbacks->onRead(this, incomingData); }
+#endif
+
return 0;
}
int NimBLEL2CAPChannel::handleTxUnstalledEvent(struct ble_l2cap_event* event) {
- if (m_pTaskData != nullptr) {
- NimBLEUtils::taskRelease(*m_pTaskData, event->tx_unstalled.status);
+ if (event->tx_unstalled.chan == channel && m_activeSend) {
+ m_activeSend->finish(event->tx_unstalled.status);
+ m_activeSend = nullptr;
}
-
- NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X transmit unstalled.", psm);
return 0;
}
int NimBLEL2CAPChannel::handleDisconnectionEvent(struct ble_l2cap_event* event) {
- NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X disconnected.", psm);
- channel = NULL;
- callbacks->onDisconnect(this);
+ if (!channel || event->disconnect.chan != channel) { return 0; }
+ // NimBLE invokes this while holding its host lock. Never wait or invoke
+ // application cleanup here; that cleanup can join a producer using NimBLE.
+ m_state.store(State::closing);
+ m_connHandle.store(BLE_HS_CONN_HANDLE_NONE);
+ channel = nullptr;
+ if (m_activeSend) {
+ m_activeSend->finish(BLE_HS_ENOTCONN);
+ m_activeSend = nullptr;
+ }
+ dispatchCallback(Disconnected);
return 0;
}
diff --git a/src/NimBLEL2CAPChannel.h b/src/NimBLEL2CAPChannel.h
index 0e196bdb..a2f970f5 100644
--- a/src/NimBLEL2CAPChannel.h
+++ b/src/NimBLEL2CAPChannel.h
@@ -11,9 +11,11 @@
# include "inttypes.h"
# ifdef USING_NIMBLE_ARDUINO_HEADERS
# include "nimble/nimble/host/include/host/ble_l2cap.h"
+# include "nimble/nimble/host/include/host/ble_hs.h"
# include "nimble/porting/nimble/include/os/os_mbuf.h"
# else
# include "host/ble_l2cap.h"
+# include "host/ble_hs.h"
# include "os/os_mbuf.h"
# endif
@@ -26,6 +28,15 @@
# include
# include
+# include
+# ifdef USING_NIMBLE_ARDUINO_HEADERS
+# include "nimble/nimble/include/nimble/nimble_npl.h"
+# else
+# include "nimble/nimble_npl.h"
+# endif
+# include "freertos/FreeRTOS.h"
+# include "freertos/semphr.h"
+# include "freertos/task.h"
class NimBLEClient;
class NimBLEL2CAPChannelCallbacks;
@@ -44,7 +55,9 @@ class NimBLEL2CAPChannel {
/// @param[in] mtu The MTU to use. Note that this is the local MTU. Upon opening the channel,
/// the final MTU will be negotiated to be the minimum of local and remote.
/// @param[in] callbacks The callbacks to use. NOTE that these callbacks are called from the
- /// context of the NimBLE bluetooth task (`nimble_host`) and MUST be handled as fast as possible.
+ /// context of the NimBLE bluetooth task (`nimble_host`) unless deferred callbacks are
+ /// enabled. In that mode onConnect()/onRead() run on a shared worker.
+ /// onDisconnect() always runs on that worker, serialized after deferred reads.
/// @return True if the channel was opened successfully, false otherwise.
static NimBLEL2CAPChannel* connect(NimBLEClient* client, uint16_t psm, uint16_t mtu, NimBLEL2CAPChannelCallbacks* callbacks);
@@ -54,7 +67,9 @@ class NimBLEL2CAPChannel {
/// @return true on success, after the data has been sent.
/// @return false, if the data can't be sent.
///
- /// NOTE: This function will block until the data has been sent or an error occurred.
+ /// Whole writes are serialized. Credit waits are bounded; an incomplete write
+ /// aborts the connection. Call from a worker task for blocking operation.
+ /// Owners must disconnect and let callbacks finish before deleting a channel.
bool write(const std::vector& bytes);
/// @brief Disconnect this L2CAP channel.
@@ -64,13 +79,18 @@ class NimBLEL2CAPChannel {
/// @brief Get the connection handle associated with this channel.
/// @return Connection handle, or BLE_HS_CONN_HANDLE_NONE if not connected.
uint16_t getConnHandle() const;
-
/// @return True, if the channel is connected. False, otherwise.
- bool isConnected() const { return !!channel; }
+ bool isConnected() const { return m_state.load() == State::open; }
+ // Stop accepting new connections before using this to drain a channel.
+ bool isIdle() const {
+ return m_state.load() == State::idle && !m_pendingDeferredReads.load() &&
+ !m_pendingHostJobs.load() && !m_writers.load();
+ }
+
+ ~NimBLEL2CAPChannel();
protected:
NimBLEL2CAPChannel(uint16_t psm, uint16_t mtu, NimBLEL2CAPChannelCallbacks* callbacks);
- ~NimBLEL2CAPChannel();
int handleConnectionEvent(struct ble_l2cap_event* event);
int handleAcceptEvent(struct ble_l2cap_event* event);
@@ -80,6 +100,7 @@ class NimBLEL2CAPChannel {
private:
friend class NimBLEL2CAPServer;
+ friend void deferredReadWorker(void*);
static constexpr const char* LOG_TAG = "NimBLEL2CAPChannel";
const uint16_t psm; // PSM of the channel
@@ -93,9 +114,30 @@ class NimBLEL2CAPChannel {
struct os_mempool _coc_mempool;
struct os_mbuf_pool _coc_mbuf_pool;
- // Runtime handling
- std::atomic stalled{false};
- NimBLEUtils::TaskData* m_pTaskData{nullptr};
+ // Only host events access the native channel and active SDU. Never hold a
+ // wrapper lock while entering NimBLE: disconnect callbacks hold ble_hs_lock.
+ enum class State { idle, accepted, open, closing };
+ std::atomic m_state{State::idle};
+ std::atomic m_generation{0};
+ std::atomic m_negotiatedMTU{0};
+ std::atomic m_txSduWidth{0};
+ std::atomic m_connHandle{BLE_HS_CONN_HANDLE_NONE};
+ std::atomic m_pendingDeferredReads{0}; // includes running callbacks
+ std::atomic m_pendingHostJobs{0};
+ std::atomic m_writers{0};
+ SemaphoreHandle_t m_writeMutex{nullptr};
+ struct TxCompletion;
+ std::unique_ptr m_completion;
+ std::atomic m_txBuffer{nullptr};
+ std::atomic m_txGeneration{0};
+ TxCompletion* m_activeSend{nullptr}; // host task only
+ ble_npl_event m_txEvent{};
+ ble_npl_event m_disconnectEvent{};
+ std::atomic m_hostTask{nullptr};
+ static void transmitOnHost(ble_npl_event* event);
+ static void disconnectOnHost(ble_npl_event* event);
+ void dispatchCallback(uint8_t kind, std::vector* data = nullptr);
+
// Allocate / deallocate NimBLE memory pool
bool setupMemPool();
diff --git a/src/NimBLEServer.cpp b/src/NimBLEServer.cpp
index fbbb0129..65f23cfb 100644
--- a/src/NimBLEServer.cpp
+++ b/src/NimBLEServer.cpp
@@ -394,7 +394,11 @@ bool NimBLEServer::disconnect(const NimBLEConnInfo& connInfo, uint8_t reason) co
* @param [in] enable true == advertise, false == don't advertise.
*/
void NimBLEServer::advertiseOnDisconnect(bool enable) {
+# if MYNEWT_VAL(BLE_ROLE_BROADCASTER)
m_advertiseOnDisconnect = enable;
+# else
+ (void)enable;
+# endif
} // advertiseOnDisconnect
# endif
diff --git a/tools/l2cap-bench/.gitignore b/tools/l2cap-bench/.gitignore
new file mode 100644
index 00000000..b45f5596
--- /dev/null
+++ b/tools/l2cap-bench/.gitignore
@@ -0,0 +1,2 @@
+results/
+__pycache__/
diff --git a/tools/l2cap-bench/README.md b/tools/l2cap-bench/README.md
new file mode 100644
index 00000000..85667579
--- /dev/null
+++ b/tools/l2cap-bench/README.md
@@ -0,0 +1,80 @@
+# Serial runner
+
+Python 3 and pyserial are required. The ESP-IDF Python environment includes
+pyserial. Start the macOS reflector or an ESP in `role server` before running.
+
+For the ECOS GAP roles, launch the Mac app with
+`bash tools/run-l2cap-reflector-app.sh --connect L2CAP-Bench-ESP` from the
+core-bluetooth-tool repository, then use `--dut-role peripheral` below. The
+ESP still initiates and times all benchmark messages; Apple opens GAP and CoC.
+
+```sh
+python bench.py run --port /dev/serial/by-id/ \
+ --peer auto --pattern echo --sizes 64,256,1024,4096 \
+ --count 64 --warmup 8 --repeat 3 --label mac-s3-baseline
+python bench.py run --port /dev/serial/by-id/ \
+ --pattern request --request-size 8 --sizes 32,256,1024,4096 \
+ --count 64 --repeat 3 --label mac-s3-request
+python bench.py compare results/*baseline.json results/*candidate.json
+```
+
+```sh
+python bench.py run --port /dev/serial/by-id/ --dut-role peripheral \
+ --pattern request --request-size 8 --sizes 32,256,1024,4096 \
+ --count 64 --warmup 8 --repeat 3 --label apple-central-s3
+```
+
+Sizes and request/response direction are always relative to the ESP DUT:
+`request --request-size 8 --sizes 4096` loads Apple→ESP; use
+`request --request-size 4096 --sizes 32` to load ESP→Apple. These are raw
+transport patterns; the separate ECOS benchmark reproduces product requests.
+Use `--require-params` for A/B runs. Without it, refused interval/PHY requests
+remain measurable, with a warning and the actual values stored in the file.
+
+For ESP↔ESP, use `--peer L2CAP-Bench-ESP --peer-port `. The runner
+sets that board to reflector mode. Keep board placement fixed and record distance,
+orientation, Mac/iPhone model and any concurrent radio use in `--notes`.
+
+Default output is `tools/l2cap-bench/results/` regardless of the working
+directory; this directory is Git-ignored. `--output` selects another path.
+
+The benchmark firmware requires ESP-IDF 6.1. Each file contains firmware identity, negotiated parameters, all per-packet
+samples, warmup events and the aggregate results. The full serial stream stays
+beside it as `.log`; an optional reflector console has `.peer.log`. Results are
+saved after each measured size, including partial failures. `compare` refuses
+failed runs and reports the median of repeated run medians/p95 values.
+
+The four-byte frame header is excluded from `--sizes`; sequence/token and
+turnaround are included. See the firmware README for the exact wire layout and
+timing definitions. RTT changes compare with the first matching file supplied
+to `compare` within the same GAP role. Warmup measurements are retained but
+excluded from its table. The role column prevents silently mixing inverse and
+product-role baselines. Missing/reordered samples fail the run, including warmup.
+
+Streaming and reconnect examples (matching version-2 firmware/peer):
+
+```sh
+python bench.py run --port /dev/serial/by-id/ --dut-role peripheral \
+ --pattern duplex --sizes 64,4096 --count 128 --warmup 8 --repeat 3 \
+ --require-params --label duplex
+python bench.py run --port /dev/serial/by-id/ --dut-role peripheral \
+ --pattern duplex --sizes 64,4096 --count 4 --warmup 0 --repeat 1 \
+ --cycles 100 --require-params --label reconnect
+```
+
+`stream-tx` sends ESP→peer, `stream-rx` sends peer→ESP, `duplex` sends both.
+For these patterns the CLI uses each `--sizes` value for both configured sizes.
+Streaming has no RTT median: comparison tables use dashes and explicitly mark
+changes as goodput changes. Goodput is the sum of enabled directions and includes
+the START/FINISH round trips; inspect per-receiver arrival rates in the JSON too.
+
+Every enabled direction needs a complete per-frame sample sequence, matching
+receipt, zero errors and a confirmed FINISH. Missing evidence fails the run.
+Schema 2 adds `cycles`, per-run cycle numbers, heap trends in `lifecycle` and
+parsed `txstats`; old echo/request files remain readable by `compare`.
+The CLI fails on a broken cycle instead of hiding it with an automatic retry.
+Teardown still runs and partial evidence is saved. Failed files cannot be used
+as successful comparison baselines.
+
+Optional TX-stat records from separately instrumented firmware are parsed when
+present. This example does not enable internal instrumentation or tuning.
diff --git a/tools/l2cap-bench/bench.py b/tools/l2cap-bench/bench.py
new file mode 100755
index 00000000..b5c965a2
--- /dev/null
+++ b/tools/l2cap-bench/bench.py
@@ -0,0 +1,339 @@
+#!/usr/bin/env python3
+"""Drive the serial L2CAP bench. Raw samples are saved alongside the full log."""
+import argparse
+import datetime as dt
+import json
+from pathlib import Path
+import statistics
+import re
+import sys
+import time
+
+
+def tx_stats(line):
+ match = re.search(rb"BLETx:\s+(.*)", line)
+ if not match:
+ return None
+ fields = {key.decode(): [int(v) for v in value.split(b"/")] if b"/" in value else int(value)
+ for key, value in re.findall(rb"(\w+(?:/\w+)*)=([0-9/]+)", match[1])}
+ if not fields:
+ return None
+ fields["event"] = "txstats"
+ stamp = re.search(rb"\(([0-9]+)\)", line)
+ if stamp:
+ fields["log_timestamp_ms"] = int(stamp[1])
+ return fields
+
+
+class Console:
+ def __init__(self, port, log):
+ import serial
+ self.serial = serial.Serial(port, 115200, timeout=0.1)
+ self.log = log
+ self.buffer = bytearray()
+ self.events = []
+
+ def command(self, text, timeout=20):
+ self.serial.write((text + "\n").encode())
+ deadline = time.monotonic() + timeout
+ received = []
+ while time.monotonic() < deadline:
+ data = self.serial.read(8192)
+ if not data:
+ continue
+ self.log.write(data)
+ self.log.flush()
+ self.buffer.extend(data)
+ while b"\n" in self.buffer:
+ line, _, rest = self.buffer.partition(b"\n")
+ self.buffer = bytearray(rest)
+ marker = line.find(b"BENCH ")
+ if marker < 0:
+ stats = tx_stats(line)
+ if stats:
+ self.events.append(stats); received.append(stats)
+ continue
+ event = json.loads(line[marker + 6:])
+ self.events.append(event)
+ received.append(event)
+ if event["event"] == "ready" and text.split()[0] not in ("status", "reboot"):
+ raise RuntimeError(f"device restarted during {text!r}")
+ if event["event"] == "error":
+ print("DUT:", event["message"], file=sys.stderr)
+ if event["event"] == "command-end":
+ if event["code"] != 0:
+ raise RuntimeError(f"{text!r} failed with code {event['code']}")
+ return received
+ raise TimeoutError(f"No command-end for {text!r} after {timeout}s")
+
+ def close(self):
+ self.serial.close()
+
+
+def save(path, document):
+ temporary = path.with_suffix(".json.tmp")
+ temporary.write_text(json.dumps(document, indent=2) + "\n")
+ temporary.replace(path)
+
+
+def checked_run(events, requested_count):
+ result = next(event for event in events if event["event"] == "run-result")
+ samples = [event for event in events if event["event"] == "sample"]
+ if [s["seq"] for s in samples] != list(range(result["completed"])):
+ raise RuntimeError("serial evidence is incomplete: missing or reordered samples")
+ if result["success"] and result["completed"] != requested_count:
+ raise RuntimeError("successful run reported an unexpected sample count")
+ return result, samples
+
+
+def params_match(params, args):
+ return (params.get("interval_ms") == args.interval and
+ params.get("tx_phy") == args.phy and params.get("rx_phy") == args.phy)
+
+
+def checked_measurement(events, count, pattern, request_size, response_size):
+ if pattern in ("echo", "request"):
+ return checked_run(events, count)
+ result = next(event for event in events if event["event"] == "run-result")
+ expected_tx = count if pattern != "stream-rx" else 0
+ expected_rx = count if pattern != "stream-tx" else 0
+ samples = [event for event in events if event["event"] in ("stream-tx-sample", "stream-rx-sample")]
+ for direction, expected, size, summary_name in (("tx", expected_tx, request_size, "peer_rx"),
+ ("rx", expected_rx, response_size, "local_rx")):
+ directional = [s for s in samples if s["event"] == f"stream-{direction}-sample"]
+ if [s["seq"] for s in directional] != list(range(expected)):
+ raise RuntimeError(f"incomplete stream {direction} sample evidence")
+ if result[f"{direction}_frames"] != expected:
+ raise RuntimeError(f"stream {direction} count mismatch")
+ if expected:
+ summary = result[summary_name]
+ if (summary["token"] != result["token"] or summary["frames"] != expected or
+ summary["bytes"] != expected * size or summary["errors"] != 0 or
+ summary["first_us"] > summary["last_us"]):
+ raise RuntimeError(f"invalid stream {direction} receipt")
+ if direction == "rx":
+ arrivals = [s["received_at_us"] for s in directional]
+ if (arrivals != sorted(arrivals) or arrivals[0] != summary["first_us"] or
+ arrivals[-1] != summary["last_us"]):
+ raise RuntimeError("inconsistent stream arrival evidence")
+ if (not result["success"] or result["completed"] != count or
+ not result["peer_finish_confirmed"] or result["callback_faults"]):
+ raise RuntimeError("stream did not complete on both peers")
+ return result, samples
+
+
+def lifecycle_summary(cycles):
+ settled = cycles[min(5, max(0, len(cycles) - 2)):]
+ heaps = [c["disconnected_heap"] for c in settled]
+ result = {"completed_cycles": len(cycles), "warmup_cycles_excluded": len(cycles) - len(settled)}
+ for key in ("free", "internal_free", "largest_internal_block", "tasks"):
+ values = [h[key] for h in heaps]
+ result[key] = {"first": values[0], "last": values[-1], "min": min(values), "max": max(values)}
+ if len(values) > 1:
+ result[key]["slope_per_cycle"] = statistics.linear_regression(range(len(values)), values).slope
+ return result
+
+
+def run(args):
+ sizes = [int(value) for value in args.sizes.split(",")]
+ if (not 1 <= args.count <= 4096 or not 0 <= args.warmup <= 4096 or args.repeat < 1 or getattr(args, "cycles", 1) < 1 or
+ not sizes or any(not 12 <= size <= 4996 for size in sizes) or
+ not 8 <= args.request_size <= 4996):
+ raise ValueError("count: 1..4096, warmup: 0..4096, repeat: >=1, sizes: 12..4996, request-size: 8..4996")
+ now = dt.datetime.now(dt.timezone.utc)
+ path = (Path(args.output) if args.output else
+ Path(__file__).resolve().parent / "results" / f"{now:%Y%m%dT%H%M%SZ}-{args.label}.json")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ document = {"schema": 2, "created_at": now.isoformat(), "label": args.label,
+ "peer_name": args.peer, "dut_gap_role": args.dut_role,
+ "notes": args.notes, "runs": [], "success": False}
+ dut = peer = None
+ with path.with_suffix(".log").open("wb") as log:
+ try:
+ if args.peer_port:
+ peer_log = path.with_suffix(".peer.log").open("wb")
+ peer = Console(args.peer_port, peer_log)
+ peer_status = peer.command("status")
+ if any(e.get("tx_diagnostics") for e in peer_status if e["event"] == "hello"):
+ raise ValueError("ESP reflector TX diagnostics need a continuous peer-console reader; enable diagnostics on the DUT only")
+ peer.command("role server")
+ dut = Console(args.port, log)
+ initial = dut.command("status")
+ document["initial_status"] = initial
+ document["cycles"] = []
+ for cycle in range(getattr(args, "cycles", 1)):
+ document["cycle_in_progress"] = cycle
+ if args.peer_port and cycle:
+ peer.command("role server")
+ if args.dut_role == "central":
+ dut.command("role client")
+ dut.command(f"params {args.interval} {args.phy}")
+ if args.dut_role == "peripheral":
+ dut.command("listen")
+ connected_events = dut.command("await 30", timeout=40)
+ else:
+ connected_events = dut.command(f"connect {args.peer}", timeout=40)
+ negotiated = next(e for e in reversed(connected_events)
+ if e["event"] == "params" and e.get("connected"))
+ document["negotiated"] = negotiated
+ document.setdefault("parameters_match", True)
+ document["parameters_match"] &= params_match(negotiated, args)
+ print(f"Connected cycle {cycle + 1}: DUT {args.dut_role}, interval {negotiated.get('interval_ms')} ms, "
+ f"PHY {negotiated.get('tx_phy')}/{negotiated.get('rx_phy')}, "
+ f"DLE {negotiated.get('tx_octets')}/{negotiated.get('rx_octets')}, "
+ f"MTU {negotiated['mtu']}", flush=True)
+ if not params_match(negotiated, args):
+ print("Peer did not accept the requested interval/PHY", file=sys.stderr)
+ if args.require_params:
+ raise RuntimeError("negotiated parameters do not match the requested A/B setup")
+ for repeat in range(args.repeat):
+ for size in sizes:
+ streaming = args.pattern in ("stream-tx", "stream-rx", "duplex")
+ request = size if args.pattern == "echo" or streaming else args.request_size
+ warmup = []
+ if args.warmup:
+ warmup = dut.command(f"run {args.pattern} {request} {size} {args.warmup}",
+ timeout=args.warmup * 6 + 30)
+ if not checked_measurement(warmup, args.warmup, args.pattern, request, size)[0]["success"]:
+ raise RuntimeError("warmup failed")
+ events = dut.command(f"run {args.pattern} {request} {size} {args.count}",
+ timeout=args.count * 6 + 30)
+ result, samples = checked_measurement(events, args.count, args.pattern, request, size)
+ run_params = [e for e in events if e["event"] == "params" and e.get("connected")]
+ if args.require_params and len(run_params) < 2:
+ raise RuntimeError("missing link parameters before/after the measured run")
+ if any(not params_match(e, args) for e in run_params):
+ document["parameters_match"] = False
+ if args.require_params:
+ raise RuntimeError("link parameters changed during the comparison")
+ document["runs"].append({"cycle": cycle, "repeat": repeat, "pattern": args.pattern,
+ "request_size": request, "response_size": size,
+ "warmup": warmup, "params": run_params,
+ "result": result, "samples": samples,
+ "txstats": [e for e in events if e["event"] == "txstats"]})
+ document["events"] = dut.events
+ save(path, document)
+ timing = (f"TX {result['tx_frames']}, RX {result['rx_frames']}, " if streaming else
+ f"median {result['rtt_median_us'] / 1000:.3f} ms, p95 {result['rtt_p95_us'] / 1000:.3f} ms, ")
+ print(f"{args.pattern} {request}->{size}, cycle {cycle + 1}, repeat {repeat + 1}: " + timing +
+ f"{result.get('goodput_bytes_per_s', 0) / 1024:.2f} KiB/s, "
+ f"ok={result['success']}", flush=True)
+ if not result["success"]:
+ raise RuntimeError("benchmark reported a failed run")
+ disconnected = dut.command("disconnect", timeout=10)
+ heap = next(e for e in reversed(disconnected) if e["event"] == "heap")
+ document["cycles"].append({"cycle": cycle, "negotiated": negotiated, "disconnected_heap": heap})
+ document["events"] = dut.events
+ save(path, document)
+ document.pop("cycle_in_progress", None)
+ document["lifecycle"] = lifecycle_summary(document["cycles"])
+ document["success"] = True
+ except Exception as error:
+ document["error"] = str(error)
+ raise
+ finally:
+ if dut:
+ try:
+ if "cycle_in_progress" in document or not document.get("cycles"):
+ dut.command("disconnect", timeout=10)
+ except Exception as error:
+ document["disconnect_error"] = str(error)
+ document["success"] = False
+ document["events"] = dut.events
+ dut.close()
+ if peer:
+ try:
+ peer.command("role client", timeout=10)
+ except Exception as error:
+ document["peer_disconnect_error"] = str(error)
+ document["success"] = False
+ document["peer_events"] = peer.events
+ peer.close()
+ peer_log.close()
+ save(path, document)
+ print(f"Saved {path}", flush=True)
+ if document.get("disconnect_error") or document.get("peer_disconnect_error"):
+ raise RuntimeError(document.get("disconnect_error") or document["peer_disconnect_error"])
+
+
+def compare(args):
+ print("| File | DUT GAP role | Interval ms | PHY TX/RX | MTU | Pattern | Request → response | Median RTT ms | p95 ms | Goodput KiB/s | Change basis | Change |")
+ print("|---|---|---:|---|---:|---|---:|---:|---:|---:|---|---:|")
+ baseline = {}
+ for path in map(Path, args.files):
+ document = json.loads(path.read_text())
+ if not document["success"]:
+ raise ValueError(f"{path}: incomplete or failed measurement file")
+ grouped = {}
+ role = document.get("dut_gap_role", "central")
+ fallback_params = document.get("negotiated") or next(
+ (e for e in document.get("events", []) if e["event"] == "params" and e.get("connected")), {})
+ for run in document["runs"]:
+ recorded_params = run.get("params") or [fallback_params]
+ params = recorded_params[0]
+ link_fields = ("interval_ms", "tx_phy", "rx_phy", "mtu", "tx_octets", "rx_octets")
+ if any(tuple(p.get(k) for k in link_fields) != tuple(params.get(k) for k in link_fields)
+ for p in recorded_params[1:]):
+ raise ValueError(f"{path}: link parameters changed within a run")
+ key = (run["pattern"], run["request_size"], run["response_size"],
+ params.get("interval_ms"), params.get("tx_phy"), params.get("rx_phy"),
+ params.get("mtu"), params.get("tx_octets"), params.get("rx_octets"))
+ if not run["result"]["success"]:
+ raise ValueError(f"{path}: failed run cannot be compared as a successful baseline")
+ grouped.setdefault(key, []).append(run["result"])
+ for key, results in grouped.items():
+ timed = "rtt_median_us" in results[0]
+ median = statistics.median(r["rtt_median_us"] for r in results) if timed else None
+ p95 = statistics.median(r["rtt_p95_us"] for r in results) if timed else None
+ goodput = statistics.median(r["goodput_bytes_per_s"] for r in results)
+ metric = median if timed else goodput
+ reference = baseline.setdefault((role,) + key, metric)
+ change = 100 * (metric / reference - 1) if reference else 0
+ median_text = f"{median / 1000:.3f}" if timed else "—"
+ p95_text = f"{p95 / 1000:.3f}" if timed else "—"
+ basis = "RTT" if timed else "goodput"
+ print(f"| {path.name} | {role} | {key[3]} | {key[4]}/{key[5]} | {key[6]} | {key[0]} | {key[1]} → {key[2]} | {median_text} | "
+ f"{p95_text} | {goodput / 1024:.2f} | {basis} | {change:+.1f}% |")
+
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest="command", required=True)
+ measure = commands.add_parser("run")
+ measure.add_argument("--port", required=True)
+ measure.add_argument("--peer", default="auto", help="Advertised name, BLE address, or auto (exactly one benchmark service)")
+ measure.add_argument("--peer-port", help="Also configure an ESP reflector over this serial port")
+ measure.add_argument("--dut-role", choices=["central", "peripheral"], default="central",
+ help="Peripheral reproduces Apple-Central/ESP-Peripheral product roles")
+ measure.add_argument("--pattern", choices=["echo", "request", "stream-tx", "stream-rx", "duplex"], default="echo")
+ measure.add_argument("--sizes", default="64,256,1024,4096")
+ measure.add_argument("--request-size", type=int, default=8)
+ measure.add_argument("--cycles", type=int, default=1, help="Connect/run/disconnect cycles; heap is captured after each teardown")
+ measure.add_argument("--count", type=int, default=64)
+ measure.add_argument("--warmup", type=int, default=8)
+ measure.add_argument("--repeat", type=int, default=3)
+ measure.add_argument("--interval", type=float, default=15)
+ measure.add_argument("--phy", type=int, choices=[1, 2], default=2)
+ measure.add_argument("--require-params", action="store_true",
+ help="Fail if the peer does not accept the requested interval/PHY (for A/B tests)")
+ measure.add_argument("--label", default="baseline")
+ measure.add_argument("--notes", default="")
+ measure.add_argument("--output")
+ measure.set_defaults(func=run)
+ comparison = commands.add_parser("compare")
+ comparison.add_argument("files", nargs="+")
+ comparison.set_defaults(func=compare)
+ args = parser.parse_args()
+ if args.command == "run" and args.peer_port and args.dut_role != "central":
+ parser.error("--peer-port currently requires --dut-role central")
+ try:
+ args.func(args)
+ except Exception as error:
+ print(str(error), file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())