Join an ESP32 advertisement the controller splits across scan reports

An HCI LE Extended Advertising Report carries at most 229 bytes of data, so
the controller hands a longer advertisement up as several reports from the
same address and SID, each but the last INCOMPLETE. NimBLE passes them
through one by one. handleExtendedAdvertisement() dropped every INCOMPLETE
report on the belief that nothing chains on send, then parsed the COMPLETE
tail as if it were the whole advertisement, so an ESP32 heard no packet over
221 bytes. That belief was wrong twice: a 251-byte advertisement is more than
the 245 one AUX_ADV_IND holds, so the Link Layer chains it, and a single PDU
over 229 bytes is split at HCI anyway.

BLEMeshHandler::onScanReport() now joins reports keyed on (address type,
address, SID): INCOMPLETE starts or extends a buffer, COMPLETE finishes it
and delivers once, TRUNCATED discards it. Memory is bounded at
BLE_MESH_REASSEMBLY_SLOTS buffers of BLE_MESH_ADV_TOTAL_MAX; a further
advertiser evicts the stalest, anything longer than a mesh advertisement is
skipped to its last report, and HCI marks no first report, so a buffer idle
for BLE_MESH_REASSEMBLY_TIMEOUT_MS is abandoned rather than prefixed to the
next advertisement.

The AD parser moves into BLEMeshHandler beside it, shared with nRF52. The
nRF52 scan path needs no join: S140 does not support report_incomplete_evts,
assembles a chained advertisement into the 255-byte scan buffer itself, and
reports it COMPLETE, TRUNCATED or MISSED, of which only COMPLETE was ever
parsed.

The comments that called 251 bytes one unfragmented PDU now say what it is:
the most one HCI Set Extended Advertising Data command carries.
This commit is contained in:
James Rich committed 2026-10-12 19:34:28 -05:00
1 parent 9c4fdca9f0
commit 7e7248f6b3
8 files changed
+289 -79

No files matched your search

+104
View File
@@ -3,8 +3,12 @@
#if HAS_BLE_MESH
#include "BLEMeshHandler.h"
#include "Throttle.h"
#include "UptimeClock.h"
#include "main.h"
#include <cstring>
BLEMeshHandler *bleMeshHandler = nullptr;
// AD type constants from the Bluetooth Core Supplement, spelled locally so this file need not pick
@@ -242,6 +246,106 @@ void BLEMeshHandler::deliverToRouter(const uint8_t *data, size_t len, int8_t rss
enqueueReceived(p.release());
}
BLEMeshHandler::PartialAdv *BLEMeshHandler::partialFor(uint8_t addrType, const uint8_t addr[6], uint8_t sid)
{
for (auto &slot : partials) {
if (slot.inUse && slot.addrType == addrType && slot.sid == sid && memcmp(slot.addr, addr, sizeof(slot.addr)) == 0)
return &slot;
}
return nullptr;
}
BLEMeshHandler::PartialAdv &BLEMeshHandler::claimPartial(uint8_t addrType, const uint8_t addr[6], uint8_t sid)
{
const uint32_t now = Time::getMillis();
PartialAdv *pick = &partials[0];
for (auto &slot : partials) {
if (!slot.inUse) {
pick = &slot;
break;
}
if ((uint32_t)(now - slot.lastMs) > (uint32_t)(now - pick->lastMs))
pick = &slot;
}
pick->inUse = true;
pick->overflowed = false;
pick->addrType = addrType;
memcpy(pick->addr, addr, sizeof(pick->addr));
pick->sid = sid;
pick->len = 0;
pick->lastMs = now;
return *pick;
}
void BLEMeshHandler::onScanReport(uint8_t addrType, const uint8_t addr[6], uint8_t sid, ScanReportStatus status,
const uint8_t *data, size_t len, int8_t rssi)
{
if (!isRunning || !addr || (len > 0 && !data))
return;
PartialAdv *slot = partialFor(addrType, addr, sid);
if (slot && Throttle::hasElapsed(slot->lastMs, BLE_MESH_REASSEMBLY_TIMEOUT_MS)) {
slot->inUse = false;
slot = nullptr;
}
if (status == ScanReportStatus::Truncated) {
if (slot)
slot->inUse = false;
return;
}
if (status == ScanReportStatus::Complete && !slot) {
handleAdvertisementData(data, len, rssi);
return;
}
if (!slot)
slot = &claimPartial(addrType, addr, sid);
if (!slot->overflowed) {
if (slot->len + len > slot->data.size()) {
slot->overflowed = true;
} else if (len > 0) {
memcpy(slot->data.data() + slot->len, data, len);
slot->len += len;
}
}
slot->lastMs = Time::getMillis();
if (status == ScanReportStatus::Complete) {
slot->inUse = false;
if (!slot->overflowed)
handleAdvertisementData(slot->data.data(), slot->len, rssi);
}
}
void BLEMeshHandler::handleAdvertisementData(const uint8_t *data, size_t len, int8_t rssi)
{
if (!isRunning || !data)
return;
// An advertisement carries several AD structures, and the mesh one is not necessarily first.
size_t offset = 0;
while (offset + 1 < len) {
uint8_t adLen = data[offset];
// An AD structure spans data[offset] .. data[offset + adLen]; anything else is truncated.
if (adLen == 0 || offset + adLen >= len)
break;
uint8_t adType = data[offset + 1];
if (adType == BLE_MESH_AD_TYPE_MFG_DATA && adLen >= 4) {
uint16_t companyId = data[offset + 2] | (data[offset + 3] << 8);
if (companyId == BLE_MESH_COMPANY_ID && data[offset + 4] == BLE_MESH_PROTOCOL_VERSION) {
// Skip the type byte, the 2-byte company ID and the 1-byte version.
deliverToRouter(&data[offset + 5], adLen - 4, rssi);
return;
}
}
offset += adLen + 1;
}
}
void BLEMeshHandler::enqueueReceived(meshtastic_MeshPacket *p)
{
router->enqueueReceivedMessage(p);
+46 -5
View File
@@ -17,10 +17,10 @@
#define BLE_MESH_COMPANY_ID 0xFFFF
#define BLE_MESH_PROTOCOL_VERSION 1
// One unfragmented extended advertising payload caps at 251 bytes, not the 254 an AUX_ADV_IND
// holds: HCI LE Set Extended Advertising Data spends four of its 255 parameter bytes on handle,
// operation, fragment preference and length. Each platform static_asserts this against its own
// stack's constant.
// The advertising data one HCI LE Set Extended Advertising Data command carries. One AUX_ADV_IND
// holds 245 bytes of it, so the Link Layer chains more into an AUX_CHAIN_IND, and a receiving
// controller reports anything over 229 bytes as several HCI reports, which onScanReport()
// reassembles. Each platform static_asserts this against its own stack's constant.
#define BLE_MESH_ADV_TOTAL_MAX 251
// Flags AD structure (3) + manufacturer-data AD header (2) + company ID (2) + version (1).
@@ -38,6 +38,16 @@
#define BLE_MESH_ADV_EVENTS 3
#endif
// Advertisers whose split reports can be reassembled at once; a further one evicts the stalest.
#ifndef BLE_MESH_REASSEMBLY_SLOTS
#define BLE_MESH_REASSEMBLY_SLOTS 2
#endif
// HCI marks no first report, so a partial advertisement this old is abandoned rather than joined
// to whatever its advertiser sends next.
#ifndef BLE_MESH_REASSEMBLY_TIMEOUT_MS
#define BLE_MESH_REASSEMBLY_TIMEOUT_MS 100
#endif
/// A burst of `events` advertising events at `intervalUnits` x 0.625 ms, as a duration in ms. The
/// extra interval covers the up-to-10 ms advDelay the Link Layer adds before each event.
@@ -73,7 +83,7 @@ class BLEMeshHandler : private concurrency::OSThread, public MeshTransportBase
return config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_BROADCAST;
}
/// Packets refused for not fitting one unfragmented advertisement. The ceiling is
/// Packets refused for not fitting one advertisement. The ceiling is
/// BLE_MESH_MAX_PROTO_LEN for the whole encoded MeshPacket, below LoRa's MAX_RADIO_PAYLOAD_LEN.
uint32_t txDroppedTooLarge = 0;
@@ -119,6 +129,19 @@ class BLEMeshHandler : private concurrency::OSThread, public MeshTransportBase
/// Decode a received advertisement payload and enqueue it into the router.
void deliverToRouter(const uint8_t *data, size_t len, int8_t rssi);
/// How much of an advertisement one scan report carries, as HCI LE Extended Advertising Report
/// grades it.
enum class ScanReportStatus : uint8_t { Complete, Incomplete, Truncated };
/// One extended scan report. A controller splits an advertisement over 229 bytes into several
/// reports from the same (address, SID), each but the last Incomplete; this joins them and hands
/// the whole advertisement to handleAdvertisementData(). A Truncated advertisement is dropped.
void onScanReport(uint8_t addrType, const uint8_t addr[6], uint8_t sid, ScanReportStatus status, const uint8_t *data,
size_t len, int8_t rssi);
/// Find the mesh manufacturer-data structure in a whole advertisement and deliver its packet.
void handleAdvertisementData(const uint8_t *data, size_t len, int8_t rssi);
/// Hand an accepted packet on. Virtual only so the native tests can observe what survives the
/// ingress guards without standing up a live Router; production always takes the default.
virtual void enqueueReceived(meshtastic_MeshPacket *p);
@@ -149,6 +172,24 @@ class BLEMeshHandler : private concurrency::OSThread, public MeshTransportBase
// one-shot readiness callback would race. runOnce() polls platformReady() and calls
// onBluetoothReady() itself, exactly once.
bool readyHandled = false;
/// An advertisement whose reports have not all arrived. Touched only from the scan callback,
/// which runs on the BLE host task alone.
struct PartialAdv {
bool inUse;
// Longer than any mesh advertisement, so the rest is skipped until its last report.
bool overflowed;
uint8_t addrType;
uint8_t addr[6];
uint8_t sid;
uint16_t len;
uint32_t lastMs;
std::array<uint8_t, BLE_MESH_ADV_TOTAL_MAX> data;
};
std::array<PartialAdv, BLE_MESH_REASSEMBLY_SLOTS> partials{};
PartialAdv *partialFor(uint8_t addrType, const uint8_t addr[6], uint8_t sid);
PartialAdv &claimPartial(uint8_t addrType, const uint8_t addr[6], uint8_t sid);
};
extern BLEMeshHandler *bleMeshHandler;
+8 -35
View File
@@ -14,7 +14,7 @@
#if BLE_MESH_USE_EXT_ADV
static_assert(BLE_MESH_ADV_TOTAL_MAX <= BLE_HCI_MAX_EXT_ADV_DATA_LEN,
"advertisement budget must fit NimBLE's unfragmented ext-adv data limit");
"advertisement budget must fit one NimBLE LE Set Extended Advertising Data command");
#endif
void ESP32BLEMesh::start()
@@ -249,7 +249,7 @@ void ESP32BLEMesh::handleAdvertisement(const struct ble_gap_disc_desc *desc)
if (!isRunning || !desc)
return;
handleAdvertisementData(desc->rssi, desc->data, desc->length_data);
handleAdvertisementData(desc->data, desc->length_data, desc->rssi);
}
#if BLE_MESH_USE_EXT_ADV
@@ -258,41 +258,14 @@ void ESP32BLEMesh::handleExtendedAdvertisement(const struct ble_gap_ext_disc_des
if (!isRunning || !desc)
return;
// Nothing chains on send, so anything flagged INCOMPLETE is another advertiser's.
if (desc->data_status != BLE_GAP_EXT_ADV_DATA_STATUS_COMPLETE || !desc->data)
return;
ScanReportStatus status = ScanReportStatus::Truncated;
if (desc->data_status == BLE_GAP_EXT_ADV_DATA_STATUS_COMPLETE)
status = ScanReportStatus::Complete;
else if (desc->data_status == BLE_GAP_EXT_ADV_DATA_STATUS_INCOMPLETE)
status = ScanReportStatus::Incomplete;
handleAdvertisementData(desc->rssi, desc->data, desc->length_data);
onScanReport(desc->addr.type, desc->addr.val, desc->sid, status, desc->data, desc->length_data, desc->rssi);
}
#endif
void ESP32BLEMesh::handleAdvertisementData(int8_t rssi, const uint8_t *data, uint8_t len)
{
if (!isRunning || !data)
return;
// An advertisement carries several AD structures, and the mesh one is not necessarily first.
uint16_t offset = 0;
while (offset + 1 < len) {
uint8_t adLen = data[offset];
// An AD structure spans data[offset] .. data[offset + adLen]; anything else is truncated.
if (adLen == 0 || offset + adLen >= len)
break;
uint8_t adType = data[offset + 1];
if (adType == BLE_HS_ADV_TYPE_MFG_DATA && adLen >= 4) {
uint16_t companyId = data[offset + 2] | (data[offset + 3] << 8);
if (companyId == BLE_MESH_COMPANY_ID && data[offset + 4] == BLE_MESH_PROTOCOL_VERSION) {
// Skip the type byte, the 2-byte company ID and the 1-byte version.
const uint8_t *payload = &data[offset + 5];
size_t payloadLen = adLen - 4;
deliverToRouter(payload, payloadLen, rssi);
return;
}
}
offset += adLen + 1;
}
}
#endif // HAS_BLE_MESH && ARCH_ESP32
-1
View File
@@ -50,7 +50,6 @@ class ESP32BLEMesh : public BLEMeshHandler
bool configureAdvInstance();
bool advInstanceConfigured = false;
#endif
void handleAdvertisementData(int8_t rssi, const uint8_t *data, uint8_t len);
};
#endif // HAS_BLE_MESH && ARCH_ESP32
+4 -32
View File
@@ -195,9 +195,10 @@ void NRF52BLEMesh::onBleEvent(ble_evt_t *event)
case BLE_GAP_EVT_ADV_REPORT: {
ble_gap_evt_adv_report_t *report = &event->evt.gap_evt.params.adv_report;
if (report->type.status == BLE_GAP_ADV_DATA_STATUS_COMPLETE) {
instance->handleScanResult(report);
}
// The SoftDevice joins a chained advertisement in the scan buffer itself and reports it once
// (report_incomplete_evts is unsupported), so a whole advertisement is always COMPLETE.
if (report->type.status == BLE_GAP_ADV_DATA_STATUS_COMPLETE)
instance->handleAdvertisementData(report->data.p_data, report->data.len, report->rssi);
// The SoftDevice pauses scanning after each report; hand the buffer back to resume.
bleMeshScanReportData.len = sizeof(bleMeshScanBuffer);
@@ -225,33 +226,4 @@ void NRF52BLEMesh::onBleEvent(ble_evt_t *event)
}
}
void NRF52BLEMesh::handleScanResult(ble_gap_evt_adv_report_t *report)
{
if (!isRunning || !report->data.p_data)
return;
const uint8_t *data = report->data.p_data;
uint16_t len = report->data.len;
uint16_t offset = 0;
while (offset + 1 < len) {
uint8_t adLen = data[offset];
if (adLen == 0 || offset + adLen >= len)
break;
uint8_t adType = data[offset + 1];
if (adType == BLE_GAP_AD_TYPE_MANUFACTURER_SPECIFIC_DATA && adLen >= 4) {
uint16_t companyId = data[offset + 2] | (data[offset + 3] << 8);
if (companyId == BLE_MESH_COMPANY_ID && data[offset + 4] == BLE_MESH_PROTOCOL_VERSION) {
const uint8_t *payload = &data[offset + 5];
size_t payloadLen = adLen - 4;
deliverToRouter(payload, payloadLen, report->rssi);
return;
}
}
offset += adLen + 1;
}
}
#endif // HAS_BLE_MESH && ARCH_NRF52
-1
View File
@@ -35,7 +35,6 @@ class NRF52BLEMesh : public BLEMeshHandler
private:
void startScanning();
void stopScanning();
void handleScanResult(ble_gap_evt_adv_report_t *report);
// A SoftDevice advertising set of its own, allocated by passing BLE_GAP_ADV_SET_HANDLE_NOT_SET.
// Handle 0 is Bluefruit's phone advertisement; sharing it means tearing that down and restoring
+125 -3
View File
@@ -4,6 +4,7 @@
#if defined(ARCH_PORTDUINO) && HAS_BLE_MESH
#include "UptimeClock.h"
#include "mesh/BLEMeshHandler.h"
#include "mesh/NodeDB.h"
#include "mesh/Router.h"
@@ -25,6 +26,8 @@ class FakeBLEMesh : public BLEMeshHandler
bool ready = true;
bool advertising = false;
using BLEMeshHandler::ScanReportStatus;
void start() override { isRunning = true; }
void stop() override { isRunning = false; }
@@ -34,6 +37,11 @@ class FakeBLEMesh : public BLEMeshHandler
return onCancelSending(medium, from, id);
}
uint8_t build(const meshtastic_MeshPacket *mp, uint8_t *out, size_t cap) { return buildAdvPayload(mp, out, cap); }
void report(uint8_t sid, ScanReportStatus status, const uint8_t *data, size_t len, const uint8_t *addr = defaultAddr)
{
onScanReport(0 /* public */, addr, sid, status, data, len, -60);
}
static constexpr uint8_t defaultAddr[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
int32_t pump() { return runOnce(); }
void enqueueReceived(meshtastic_MeshPacket *p) override
@@ -109,6 +117,22 @@ size_t encodeForAir(const meshtastic_MeshPacket &mp, uint8_t *out, size_t cap)
return pb_encode_to_bytes(out, cap, &meshtastic_MeshPacket_msg, &mp);
}
/// Build a whole advertisement of exactly `total` bytes, the size an HCI split is measured against.
size_t advertisementOf(FakeBLEMesh &h, size_t total, uint32_t id, uint8_t *out, meshtastic_MeshPacket *sent = nullptr)
{
for (size_t payload = 1; payload <= MAX_ENCRYPTED_FOR_TEST; payload++) {
auto p = encryptedPacket(0x3061b02e, id, payload);
if (h.build(&p, out, BLE_MESH_ADV_TOTAL_MAX) == total) {
if (sent)
*sent = p;
return total;
}
}
return 0;
}
using Status = FakeBLEMesh::ScanReportStatus;
} // namespace
void test_advertisement_carries_the_packet(void)
@@ -159,9 +183,9 @@ void test_drops_a_packet_too_large_for_one_advertisement(void)
{
FakeBLEMesh h;
h.start();
// One unfragmented extended advertisement holds 251 bytes and nothing chains, so the largest
// packets cannot ride BLE. They still go out over LoRa: Router::send has already handed them to
// the radio by the time the transport refuses.
// One advertisement holds 251 bytes, one HCI Set Extended Advertising Data command, so the
// largest packets cannot ride BLE. They still go out over LoRa: Router::send has already handed
// them to the radio by the time the transport refuses.
auto p = encryptedPacket(0x3061b02e, 0x04050b6e, MAX_ENCRYPTED_FOR_TEST);
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
@@ -520,6 +544,99 @@ void test_legacy_burst_duration_is_in_milliseconds(void)
TEST_ASSERT_GREATER_OR_EQUAL_INT32(3 * 30, bleMeshBurstMs(3, 48));
}
// HCI LE Extended Advertising Report carries at most 229 bytes, so a controller hands a 245-byte
// advertisement (one full AUX_ADV_IND) up as a 229-byte INCOMPLETE head and a 16-byte COMPLETE tail.
// Dropping the head and parsing the tail alone loses every packet over 221 bytes.
void test_a_split_advertisement_is_joined(void)
{
FakeBLEMesh h;
h.start();
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
meshtastic_MeshPacket p;
TEST_ASSERT_EQUAL_MESSAGE(245, advertisementOf(h, 245, 0x0a0b0c0d, adv, &p), "a 245-byte advertisement exists");
h.report(0, Status::Incomplete, adv, 229);
TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "nothing delivered from the head alone");
h.report(0, Status::Complete, adv + 229, 16);
TEST_ASSERT_EQUAL_MESSAGE(1, h.received.size(), "the joined advertisement delivered once");
TEST_ASSERT_EQUAL_UINT32(p.id, h.received[0].id);
TEST_ASSERT_EQUAL_UINT32(p.encrypted.size, h.received[0].encrypted.size);
TEST_ASSERT_EQUAL_MEMORY(p.encrypted.bytes, h.received[0].encrypted.bytes, p.encrypted.size);
}
void test_an_unsplit_advertisement_is_delivered(void)
{
FakeBLEMesh h;
h.start();
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL(120, advertisementOf(h, 120, 0x0a0b0c0d, adv));
h.report(0, Status::Complete, adv, 120);
TEST_ASSERT_EQUAL(1, h.received.size());
}
// TRUNCATED means the controller lost the rest of the chain; what arrived is not a packet.
void test_a_truncated_advertisement_is_dropped(void)
{
FakeBLEMesh h;
h.start();
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL(245, advertisementOf(h, 245, 0x0a0b0c0d, adv));
h.report(0, Status::Incomplete, adv, 229);
h.report(0, Status::Truncated, adv + 229, 8);
TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "a truncated advertisement is not delivered");
// The abandoned head must not be joined to the advertiser's next burst.
h.report(0, Status::Incomplete, adv, 229);
h.report(0, Status::Complete, adv + 229, 16);
TEST_ASSERT_EQUAL_MESSAGE(1, h.received.size(), "the next burst arrives whole");
}
// The key is (address, SID): two advertisers, or two sets of one, interleave on air.
void test_interleaved_advertisers_stay_apart(void)
{
FakeBLEMesh h;
h.start();
uint8_t a[BLE_MESH_ADV_TOTAL_MAX];
uint8_t b[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL(245, advertisementOf(h, 245, 0x0000000a, a));
TEST_ASSERT_EQUAL(240, advertisementOf(h, 240, 0x0000000b, b));
const uint8_t otherAddr[6] = {0x66, 0x55, 0x44, 0x33, 0x22, 0x11};
h.report(0, Status::Incomplete, a, 229);
h.report(0, Status::Incomplete, b, 229, otherAddr);
h.report(0, Status::Complete, a + 229, 16);
h.report(0, Status::Complete, b + 229, 11, otherAddr);
TEST_ASSERT_EQUAL(2, h.received.size());
TEST_ASSERT_EQUAL_UINT32(0x0000000a, h.received[0].id);
TEST_ASSERT_EQUAL_UINT32(0x0000000b, h.received[1].id);
}
// HCI marks no first report, so a head whose tail never came must expire rather than prefix the
// advertiser's next advertisement.
void test_a_stale_partial_is_abandoned(void)
{
FakeBLEMesh h;
h.start();
uint8_t stale[BLE_MESH_ADV_TOTAL_MAX];
uint8_t fresh[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL(245, advertisementOf(h, 245, 0x0000000a, stale));
TEST_ASSERT_EQUAL(245, advertisementOf(h, 245, 0x0000000b, fresh));
Time::setTestMillis(10000);
h.report(0, Status::Incomplete, stale, 229);
Time::advanceTestMillis(BLE_MESH_REASSEMBLY_TIMEOUT_MS + 1);
h.report(0, Status::Incomplete, fresh, 229);
h.report(0, Status::Complete, fresh + 229, 16);
Time::useRealClock();
TEST_ASSERT_EQUAL(1, h.received.size());
TEST_ASSERT_EQUAL_UINT32(0x0000000b, h.received[0].id);
}
void setUp(void) {}
void tearDown(void) {}
@@ -552,6 +669,11 @@ void setup()
RUN_TEST(test_ingress_ignores_our_own_advertisement);
RUN_TEST(test_pump_waits_for_the_platform);
RUN_TEST(test_legacy_burst_duration_is_in_milliseconds);
RUN_TEST(test_a_split_advertisement_is_joined);
RUN_TEST(test_an_unsplit_advertisement_is_delivered);
RUN_TEST(test_a_truncated_advertisement_is_dropped);
RUN_TEST(test_interleaved_advertisers_stay_apart);
RUN_TEST(test_a_stale_partial_is_abandoned);
exit(UNITY_END());
}
+2 -2
View File
@@ -404,8 +404,8 @@ custom_sdkconfig =
; OBSERVER not set), so ble_gap_ext_disc returns BLE_HS_ENOTSUP and the transport can advertise
; but never receive.
CONFIG_BT_NIMBLE_ROLE_OBSERVER=y
; The default 1650 is the chained ceiling and is reserved per instance, for a transport capped
; at one 251-byte PDU.
; The default 1650 is the chained ceiling and is reserved per instance, for a transport that
; sets at most 251 bytes.
CONFIG_BT_NIMBLE_EXT_ADV_MAX_SIZE=257
; The controller counts advertising sets, scans and connections alike as "activities", and short
; of the budget both the scan enable and the second ext_adv_configure fail with HCI 0x07, Memory