Skip to content

Doc: NimBLEAddress(const uint8_t[6], uint8_t) silently reverses bytes; "native ESP representation" is ambiguous #423

@fl4p

Description

@fl4p

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

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