Heads-up on provenance: this was found and analysed by an AI agent while debugging a
real connection failure in our firmware, and the analysis has not yet been independently
reviewed by a human. What is verified on hardware (ESP32-S3, ESP-IDF 5.5.3): the
symptom, and that the workaround below fixes it. The source-level call chain and the
suggested patch come from reading the code, not from instrumenting each step — please
sanity-check the reasoning before acting on it.
Summary
NimBLEServer::resetGATT() calls ble_svc_gap_init(), which under ESP-IDF's
CONFIG_BT_NIMBLE_STATIC_TO_DYNAMIC=y (a default on IDF 5.5.x) unconditionally rewrites
the GAP Device Name characteristic (0x2A00) back to CONFIG_BT_NIMBLE_SVC_GAP_DEVICE_NAME
(default "nimble"), silently discarding the name set by NimBLEDevice::init() /
NimBLEDevice::setDeviceName().
Because NimBLEAdvertising::start() starts the GATT server implicitly, the ordinary
"init with a name, then advertise" flow loses the name every time — there is no API
misuse involved.
Impact
The device advertises the correct name (scan response, via NimBLEAdvertising::setName())
but reports nimble from 0x2A00. The two name surfaces silently disagree.
Any central that resolves names via GAP rather than the advertisement then sees the wrong
name. On iOS/macOS CBPeripheral.name is GAP-derived and aggressively cached, so an app
filtering on peripheral.name refuses to connect and the board looks healthy from every
other angle. That is how we found it — the advertisement was perfect, so the bug presented
as "the app won't connect to this board" with nothing wrong on the wire.
Versions
- esp-nimble-cpp 2.5.0
- ESP-IDF 5.5.3 (PlatformIO
framework-espidf), ESP32-S3
CONFIG_BT_NIMBLE_STATIC_TO_DYNAMIC=y — the IDF default; not set by the application
Call chain
NimBLEDevice::init(name)
└─ NimBLEDevice.cpp:1000 setDeviceName(name) → ble_svc_gap_device_name_set() ✅ correct here
NimBLEAdvertising::start()
└─ NimBLEAdvertising.cpp:201 pServer->start()
└─ NimBLEServer.cpp:289 resetGATT()
└─ NimBLEServer.cpp:895 ble_svc_gap_init()
└─ ble_svc_gap.c:545 ble_svc_gap_init_name() ❌ name := "nimble"
IDF's ble_svc_gap_init_name()
(components/bt/host/nimble/nimble/nimble/host/services/gap/src/ble_svc_gap.c:499):
int ble_svc_gap_init_name(void) {
const char *default_name = MYNEWT_VAL(BLE_SVC_GAP_DEVICE_NAME);
size_t len = strlen(default_name);
...
ble_hs_gap_svc_ctx->svc_gap_name = nimble_platform_mem_calloc(1, len + 1); // leaks the old buffer
...
memcpy(ble_svc_gap_name, default_name, len);
}
There is no "already set" guard, and the previous allocation is overwritten rather than
freed (a small one-time leak per resetGATT()). In the non-STATIC_TO_DYNAMIC branch
(ble_svc_gap.c:66) the name is a static char[] that ble_svc_gap_init() never touches,
so this only bites with the dynamic allocator — which is why it is config-dependent and
easy to miss.
Reproduce
NimBLEDevice::init("MyDevice");
NimBLEServer *s = NimBLEDevice::createServer();
s->createService("ABCD");
NimBLEAdvertising *adv = s->getAdvertising();
adv->setName("MyDevice");
adv->start();
printf("gap name = %s\n", ble_svc_gap_device_name()); // "nimble", expected "MyDevice"
Suggested fix
Preserve the name (and appearance) across the reset in NimBLEServer::resetGATT():
bool NimBLEServer::resetGATT() {
...
// ble_svc_gap_init() re-applies the build-time defaults under
// BLE_STATIC_TO_DYNAMIC — keep whatever the application configured.
const char *cur = ble_svc_gap_device_name();
std::string savedName = cur ? cur : "";
uint16_t savedAppearance = ble_svc_gap_device_appearance();
ble_gatts_reset();
ble_svc_gap_init();
ble_svc_gatt_init();
if (!savedName.empty()) {
ble_svc_gap_device_name_set(savedName.c_str());
}
ble_svc_gap_device_appearance_set(savedAppearance);
...
}
The appearance has the same problem via ble_svc_gap_appearance_init(), which resets it
to MYNEWT_VAL(BLE_SVC_GAP_APPEARANCE) on the same path.
Workaround (what we shipped)
Start the GATT server explicitly so the reset happens at a known point, then re-assert the
name before advertising:
server->start(); // resetGATT() runs here
NimBLEDevice::setDeviceName(deviceName); // re-assert after it
adv->start(); // server already started → no second reset
NimBLEServer::start() is idempotent (m_gattsStarted), so the implicit call inside
NimBLEAdvertising::start() becomes a no-op. Any code path that changes the service set at
runtime (we remove the main service to expose a DFU service) has to repeat this, since that
marks the server dirty and triggers another resetGATT().
Summary
NimBLEServer::resetGATT()callsble_svc_gap_init(), which under ESP-IDF'sCONFIG_BT_NIMBLE_STATIC_TO_DYNAMIC=y(a default on IDF 5.5.x) unconditionally rewritesthe GAP Device Name characteristic (0x2A00) back to
CONFIG_BT_NIMBLE_SVC_GAP_DEVICE_NAME(default
"nimble"), silently discarding the name set byNimBLEDevice::init()/NimBLEDevice::setDeviceName().Because
NimBLEAdvertising::start()starts the GATT server implicitly, the ordinary"init with a name, then advertise" flow loses the name every time — there is no API
misuse involved.
Impact
The device advertises the correct name (scan response, via
NimBLEAdvertising::setName())but reports
nimblefrom 0x2A00. The two name surfaces silently disagree.Any central that resolves names via GAP rather than the advertisement then sees the wrong
name. On iOS/macOS
CBPeripheral.nameis GAP-derived and aggressively cached, so an appfiltering on
peripheral.namerefuses to connect and the board looks healthy from everyother angle. That is how we found it — the advertisement was perfect, so the bug presented
as "the app won't connect to this board" with nothing wrong on the wire.
Versions
framework-espidf), ESP32-S3CONFIG_BT_NIMBLE_STATIC_TO_DYNAMIC=y— the IDF default; not set by the applicationCall chain
IDF's
ble_svc_gap_init_name()(
components/bt/host/nimble/nimble/nimble/host/services/gap/src/ble_svc_gap.c:499):There is no "already set" guard, and the previous allocation is overwritten rather than
freed (a small one-time leak per
resetGATT()). In the non-STATIC_TO_DYNAMICbranch(
ble_svc_gap.c:66) the name is astatic char[]thatble_svc_gap_init()never touches,so this only bites with the dynamic allocator — which is why it is config-dependent and
easy to miss.
Reproduce
Suggested fix
Preserve the name (and appearance) across the reset in
NimBLEServer::resetGATT():The appearance has the same problem via
ble_svc_gap_appearance_init(), which resets itto
MYNEWT_VAL(BLE_SVC_GAP_APPEARANCE)on the same path.Workaround (what we shipped)
Start the GATT server explicitly so the reset happens at a known point, then re-assert the
name before advertising:
NimBLEServer::start()is idempotent (m_gattsStarted), so the implicit call insideNimBLEAdvertising::start()becomes a no-op. Any code path that changes the service set atruntime (we remove the main service to expose a DFU service) has to repeat this, since that
marks the server dirty and triggers another
resetGATT().