Skip to content

resetGATT() discards the GAP device name under BLE_STATIC_TO_DYNAMIC (0x2A00 reverts to the Kconfig default) #440

Description

@Nicba1010

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().

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions