Skip to content

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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