What's going on
NimBLEAddress::NimBLEAddress(const uint8_t address[BLE_DEV_ADDR_LEN], uint8_t type) calls std::reverse_copy on the input before storing it in val[]:
NimBLEAddress::NimBLEAddress(const uint8_t address[BLE_DEV_ADDR_LEN], uint8_t type) {
std::reverse_copy(address, address + BLE_DEV_ADDR_LEN, this->val);
this->type = type;
}
The accompanying docstring says:
Constructor for compatibility with bluedroid esp library using native ESP representation.
This is technically correct (Bluedroid's esp_bd_addr_t is MSB-first; NimBLE's ble_addr_t.val is LSB-first; the reverse bridges them), but "native ESP representation" is ambiguous because both stacks are "native" on ESP. A reader who has a uint8_t[6] from any other source — a parsed scan, an HCI dump, an over-the-wire address from a higher-level protocol — has no signal whether to pre-reverse it.
The neighbouring NimBLEAddress(const uint64_t&, uint8_t) constructor is well-documented:
Use the same byte order, so use 0xa4c1385def16 for "a4:c1:38:5d:ef:16"
…which leaves the byte-array variant as the only address constructor whose expected byte order isn't stated.
How it bites in practice
In a project bridging a host-side protocol (aioesphomeapi) to NimBLE, I had MAC 20:A1:11:02:23:45 as a uint64_t = 0x20a111022345. I wrote it to a uint8_t[6] in LSB-first wire order ({0x45, 0x23, 0x02, 0x11, 0xa1, 0x20}) — what NimBLE callbacks would have given me natively — and passed it to NimBLEAddress(bytes, type).
reverse_copy then stored val = {0x20, 0xa1, 0x11, 0x02, 0x23, 0x45} (MSB-first in the LSB-first slot). NimBLE used that directly in ble_gap_connect, sending CONNECT_REQ for a nonexistent peer. The connect timed out with BLE_HS_ETIMEOUT (reason 13) — looked indistinguishable from "out of range," because NimBLE was scanning for an address that simply never appears on-air.
The bug masked itself well:
NimBLEAddress::toString() printed the stored bytes in storage order — so 45:23:02:11:a1:20 showed up in logs, which is also exactly what the correct LSB-first storage of the real MAC would look like. Visually identical to "everything is fine."
NimBLEAddress::operator uint64_t() on a correctly-stored address (LSB-first) produces the user-facing MSB-first uint64 on a little-endian host. So scanner-path code (where NimBLE callbacks deliver NimBLEAdvertisedDevice and you read static_cast<uint64_t>(addr)) was working perfectly. Only the connect-path constructor (which inverts) was broken — and the symptom was just "connects never complete."
Switching to NimBLEAddress(uint64_t, type) was an immediate, clean fix.
What would help
Update the docstring on the byte-array constructor to make the expected order explicit, e.g.:
/**
* @brief Constructor accepting a 6-byte MSB-first BLE address.
* @details This matches Bluedroid's `esp_bd_addr_t` convention. The
* bytes are stored internally in LSB-first wire order (NimBLE's
* `ble_addr_t.val` layout), so the constructor reverses the input.
*
* If you already have LSB-first wire bytes (e.g. from a NimBLE
* callback's `ble_addr_t.val`), use `NimBLEAddress(ble_addr_t)`
* instead. If you have the address as a hex integer (e.g.
* `0xa4c1385def16` for `a4:c1:38:5d:ef:16`), use
* `NimBLEAddress(uint64_t, uint8_t)`.
*
* @param [in] address Six bytes in MSB-first order
* (`{aa, bb, cc, dd, ee, ff}` for `aa:bb:cc:dd:ee:ff`).
* @param [in] type BLE_ADDR_PUBLIC (0) or BLE_ADDR_RANDOM (1).
*/
Optional follow-up: a separate constructor taking LSB-first bytes (the "raw NimBLE storage" order) for cases where a caller has already produced wire-order bytes and would otherwise have to pre-reverse them just to have them reversed again. But the docstring alone removes the footgun.
Environment
- esp-nimble-cpp 2.5.0 (current at time of writing)
- ESP-IDF 5.5, ESP32-S3
What's going on
NimBLEAddress::NimBLEAddress(const uint8_t address[BLE_DEV_ADDR_LEN], uint8_t type)callsstd::reverse_copyon the input before storing it inval[]:The accompanying docstring says:
This is technically correct (Bluedroid's
esp_bd_addr_tis MSB-first; NimBLE'sble_addr_t.valis LSB-first; the reverse bridges them), but "native ESP representation" is ambiguous because both stacks are "native" on ESP. A reader who has auint8_t[6]from any other source — a parsed scan, an HCI dump, an over-the-wire address from a higher-level protocol — has no signal whether to pre-reverse it.The neighbouring
NimBLEAddress(const uint64_t&, uint8_t)constructor is well-documented:…which leaves the byte-array variant as the only address constructor whose expected byte order isn't stated.
How it bites in practice
In a project bridging a host-side protocol (aioesphomeapi) to NimBLE, I had MAC
20:A1:11:02:23:45as auint64_t = 0x20a111022345. I wrote it to auint8_t[6]in LSB-first wire order ({0x45, 0x23, 0x02, 0x11, 0xa1, 0x20}) — what NimBLE callbacks would have given me natively — and passed it toNimBLEAddress(bytes, type).reverse_copythen storedval = {0x20, 0xa1, 0x11, 0x02, 0x23, 0x45}(MSB-first in the LSB-first slot). NimBLE used that directly inble_gap_connect, sending CONNECT_REQ for a nonexistent peer. The connect timed out withBLE_HS_ETIMEOUT(reason 13) — looked indistinguishable from "out of range," because NimBLE was scanning for an address that simply never appears on-air.The bug masked itself well:
NimBLEAddress::toString()printed the stored bytes in storage order — so45:23:02:11:a1:20showed up in logs, which is also exactly what the correct LSB-first storage of the real MAC would look like. Visually identical to "everything is fine."NimBLEAddress::operator uint64_t()on a correctly-stored address (LSB-first) produces the user-facing MSB-first uint64 on a little-endian host. So scanner-path code (where NimBLE callbacks deliverNimBLEAdvertisedDeviceand you readstatic_cast<uint64_t>(addr)) was working perfectly. Only the connect-path constructor (which inverts) was broken — and the symptom was just "connects never complete."Switching to
NimBLEAddress(uint64_t, type)was an immediate, clean fix.What would help
Update the docstring on the byte-array constructor to make the expected order explicit, e.g.:
Optional follow-up: a separate constructor taking LSB-first bytes (the "raw NimBLE storage" order) for cases where a caller has already produced wire-order bytes and would otherwise have to pre-reverse them just to have them reversed again. But the docstring alone removes the footgun.
Environment