ESP32 as BLE Client (Central)

Scan for and connect to BLE peripherals to read sensor data from another ESP32

In the previous tutorial, you built a BLE server that broadcasts sensor data. Now you will build the other side of the conversation: a BLE client (also called a central device) that scans for nearby BLE peripherals, connects to one, and reads its sensor data. By the end of this tutorial, you will have two ESP32s communicating wirelessly over Bluetooth Low Energy.

This pattern -- one ESP32 reading sensors and broadcasting, another collecting and forwarding -- is the foundation for BLE sensor networks, gateways, and many commercial IoT products.

🔗What You'll Need

ComponentQtyNotesBuy
ESP32 dev board2One running the BLE Server sketch from the previous tutorialAliExpress | Amazon.de .co.uk .com
USB cable (data-capable)2One for each ESP32 (data-capable)AliExpress | Amazon.de .co.uk .com

Links marked Amazon/AliExpress are affiliate links. We may earn a small commission at no extra cost to you.

You need two ESP32 boards: one running the BLE server sketch from the BLE Server tutorial, and one that you will program as the client. Each board needs its own USB cable so both can be powered and monitored through their Serial Monitors simultaneously.

Tip: You do not strictly need a BME280 for testing. The BLE server sketch from the previous tutorial works fine on its own. If you do not have a second ESP32, you can also practice scanning with any BLE peripheral device nearby (fitness tracker, smart watch, BLE beacon, etc.).

🔗How a BLE Client Works

The BLE client takes the active role in establishing a connection:

  1. Scan -- The client listens for advertisement packets from nearby BLE devices.
  2. Filter -- The client checks each discovered device against criteria (name, service UUID, address) to find the target.
  3. Connect -- Once the target is found, the client initiates a connection.
  4. Discover services -- After connecting, the client queries the server for its available services and characteristics.
  5. Read or subscribe -- The client reads characteristic values on demand, or subscribes to notifications for automatic updates.
sequenceDiagram
    participant Server as ESP32 #1 (Server)
    participant Client as ESP32 #2 (Client)
    Server->>Server: Advertise service UUID
    Client->>Client: Start BLE scan
    Server-->>Client: Advertisement packet
    Client->>Client: Found target service UUID
    Client->>Server: Connect
    Client->>Server: Discover services & characteristics
    Client->>Server: Read temperature characteristic
    Server-->>Client: "23.5"
    Client->>Server: Subscribe to notifications
    loop Every 2 seconds
        Server-->>Client: Notify: "23.7"
    end

🔗Code Example: BLE Scanner

Before writing a full client, it is useful to scan and see what BLE devices are around you. This short sketch prints the name, address, and signal strength (RSSI) of every BLE device it finds.

View BLE scanner sketch
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>

const int scanDuration = 5;  // Scan for 5 seconds

class ScanCallbacks : public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    Serial.printf("Device: %s | Address: %s | RSSI: %d dBm",
      advertisedDevice.haveName() ? advertisedDevice.getName().c_str() : "(unnamed)",
      advertisedDevice.getAddress().toString().c_str(),
      advertisedDevice.getRSSI()
    );

    if (advertisedDevice.haveServiceUUID()) {
      Serial.printf(" | Service: %s", advertisedDevice.getServiceUUID().toString().c_str());
    }

    Serial.println();
  }
};

void setup() {
  Serial.begin(115200);
  delay(1000);

  Serial.println("Starting BLE scan...\n");

  BLEDevice::init("");
  BLEScan* pScan = BLEDevice::getScan();
  pScan->setAdvertisedDeviceCallbacks(new ScanCallbacks());
  pScan->setActiveScan(true);   // Active scan gets more data (scan response)
  pScan->setInterval(100);      // Scan interval in ms
  pScan->setWindow(99);         // Scan window (must be <= interval)
}

void loop() {
  Serial.println("--- Scan start ---");
  BLEScanResults* results = BLEDevice::getScan()->start(scanDuration, false);
  Serial.printf("--- Scan complete: %d device(s) found ---\n\n",
                results->getCount());

  BLEDevice::getScan()->clearResults();  // Free memory
  delay(5000);  // Wait before next scan
}

🔗What You Should See

Upload this sketch, open the Serial Monitor at $115200\,\text{baud}$, and you will see output like:

--- Scan start ---
Device: ESP32-BLE-Server | Address: 24:0a:c4:xx:xx:xx | RSSI: -45 dBm | Service: e3223119-9445-4e96-a4a1-85358c4046a2
Device: (unnamed) | Address: 12:34:56:xx:xx:xx | RSSI: -78 dBm
Device: Mi Band 5 | Address: ab:cd:ef:xx:xx:xx | RSSI: -62 dBm
--- Scan complete: 3 device(s) found ---

Look for your BLE server in the list. Note the service UUID -- you will use it in the next sketch to identify and connect to the correct device.

Tip: If you see many unnamed devices, that is normal. Many BLE devices (headphones, smart watches, beacons) advertise without a name. The service UUID or address is a more reliable identifier.

🔗Code Example: BLE Client (Connect and Read)

This sketch scans for a BLE device advertising the custom service UUID from the server tutorial, connects to it, reads the temperature characteristic, and prints the value to the Serial Monitor.

View BLE client sketch
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>

// Must match the UUIDs in the BLE server sketch
#define SERVICE_UUID        "e3223119-9445-4e96-a4a1-85358c4046a2"
#define TEMPERATURE_CHAR_UUID "e3223120-9445-4e96-a4a1-85358c4046a2"

static BLEUUID serviceUUID(SERVICE_UUID);
static BLEUUID charUUID(TEMPERATURE_CHAR_UUID);

static BLEAdvertisedDevice* targetDevice = nullptr;
static BLERemoteCharacteristic* pRemoteChar = nullptr;
static BLEClient* pClient = nullptr;

bool doConnect = false;
bool connected = false;

const int scanDuration = 5;  // seconds

// Scan callback: check each device for our target service UUID
class ScanCallbacks : public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    // Check if this device advertises the service we want
    if (advertisedDevice.haveServiceUUID() &&
        advertisedDevice.isAdvertisingService(serviceUUID)) {
      Serial.printf("Found target device: %s\n",
                     advertisedDevice.getName().c_str());

      // Stop scanning -- we found our device
      BLEDevice::getScan()->stop();

      targetDevice = new BLEAdvertisedDevice(advertisedDevice);
      doConnect = true;
    }
  }
};

// Client callback: track connection state
class ClientCallbacks : public BLEClientCallbacks {
  void onConnect(BLEClient* pClient) {
    Serial.println("Connected to server.");
  }

  void onDisconnect(BLEClient* pClient) {
    connected = false;
    Serial.println("Disconnected from server.");
  }
};

bool connectToServer() {
  Serial.printf("Connecting to %s...\n",
                targetDevice->getAddress().toString().c_str());

  pClient = BLEDevice::createClient();
  pClient->setClientCallbacks(new ClientCallbacks());

  // Connect to the server
  if (!pClient->connect(targetDevice)) {
    Serial.println("Failed to connect.");
    return false;
  }

  // Find the service
  BLERemoteService* pRemoteService = pClient->getService(serviceUUID);
  if (pRemoteService == nullptr) {
    Serial.println("Error: Service not found on server.");
    pClient->disconnect();
    return false;
  }
  Serial.println("Service found.");

  // Find the characteristic
  pRemoteChar = pRemoteService->getCharacteristic(charUUID);
  if (pRemoteChar == nullptr) {
    Serial.println("Error: Characteristic not found.");
    pClient->disconnect();
    return false;
  }
  Serial.println("Characteristic found.");

  return true;
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  Serial.println("BLE Client starting...\n");

  BLEDevice::init("ESP32-BLE-Client");

  // Start scanning
  BLEScan* pScan = BLEDevice::getScan();
  pScan->setAdvertisedDeviceCallbacks(new ScanCallbacks());
  pScan->setActiveScan(true);
  pScan->setInterval(100);
  pScan->setWindow(99);
  pScan->start(scanDuration, false);
}

void loop() {
  // Connect if we found the target during scanning
  if (doConnect) {
    if (connectToServer()) {
      connected = true;
      Serial.println("Ready to read data.\n");
    } else {
      Serial.println("Connection failed. Will retry scan.\n");
    }
    doConnect = false;
  }

  // Read the characteristic periodically
  if (connected && pRemoteChar != nullptr) {
    std::string value = pRemoteChar->readValue();
    if (!value.empty()) {
      Serial.printf("Temperature: %s °C\n", value.c_str());
    }
    delay(2000);
  }

  // If not connected, scan again
  if (!connected) {
    Serial.println("Scanning for server...");
    BLEDevice::getScan()->start(scanDuration, false);
    delay(1000);
  }
}

🔗Code Walkthrough

Scan callbacks -- The ScanCallbacks class is invoked for every BLE device found during scanning. It checks whether the device advertises the target service UUID. When found, scanning stops and the device reference is saved.

Connecting -- connectToServer() creates a BLE client, connects to the target device, then navigates the GATT hierarchy: first finding the service by UUID, then finding the characteristic within that service.

Reading -- Once connected, pRemoteChar->readValue() sends a read request to the server and returns the current characteristic value. The client reads every 2 seconds in the main loop.

Reconnection -- If the connection is lost (the onDisconnect callback sets connected = false), the main loop falls through to the scanning block and starts looking for the server again.

Note: The UUIDs in the client sketch must exactly match those in the server sketch. If you changed the UUIDs in the server, update them here too.

🔗Registering for Notifications

Reading the characteristic every 2 seconds works but is inefficient. The client sends a request, the server responds, and this happens repeatedly even if the value has not changed. Notifications are better: the server pushes new values to the client only when the data updates.

View notification client sketch
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>

// Must match the server's UUIDs
#define SERVICE_UUID        "e3223119-9445-4e96-a4a1-85358c4046a2"
#define TEMPERATURE_CHAR_UUID "e3223120-9445-4e96-a4a1-85358c4046a2"

static BLEUUID serviceUUID(SERVICE_UUID);
static BLEUUID charUUID(TEMPERATURE_CHAR_UUID);

static BLEAdvertisedDevice* targetDevice = nullptr;
static BLERemoteCharacteristic* pRemoteChar = nullptr;
static BLEClient* pClient = nullptr;

bool doConnect = false;
bool connected = false;

const int scanDuration = 5;

// This callback fires every time the server sends a notification
void notifyCallback(
  BLERemoteCharacteristic* pChar,
  uint8_t* pData,
  size_t length,
  bool isNotify
) {
  // Convert the raw data to a string
  std::string value((char*)pData, length);
  Serial.printf("Notification received: %s °C\n", value.c_str());
}

class ScanCallbacks : public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    if (advertisedDevice.haveServiceUUID() &&
        advertisedDevice.isAdvertisingService(serviceUUID)) {
      Serial.printf("Found target: %s\n", advertisedDevice.getName().c_str());
      BLEDevice::getScan()->stop();
      targetDevice = new BLEAdvertisedDevice(advertisedDevice);
      doConnect = true;
    }
  }
};

class ClientCallbacks : public BLEClientCallbacks {
  void onConnect(BLEClient* pClient) {
    Serial.println("Connected to server.");
  }

  void onDisconnect(BLEClient* pClient) {
    connected = false;
    Serial.println("Disconnected from server.");
  }
};

bool connectToServer() {
  Serial.printf("Connecting to %s...\n",
                targetDevice->getAddress().toString().c_str());

  pClient = BLEDevice::createClient();
  pClient->setClientCallbacks(new ClientCallbacks());

  if (!pClient->connect(targetDevice)) {
    Serial.println("Failed to connect.");
    return false;
  }

  BLERemoteService* pService = pClient->getService(serviceUUID);
  if (pService == nullptr) {
    Serial.println("Error: Service not found.");
    pClient->disconnect();
    return false;
  }

  pRemoteChar = pService->getCharacteristic(charUUID);
  if (pRemoteChar == nullptr) {
    Serial.println("Error: Characteristic not found.");
    pClient->disconnect();
    return false;
  }

  // Register for notifications
  if (pRemoteChar->canNotify()) {
    pRemoteChar->registerForNotify(notifyCallback);
    Serial.println("Registered for notifications.");
  } else {
    Serial.println("Warning: Characteristic does not support notify.");
  }

  return true;
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  Serial.println("BLE Notification Client starting...\n");

  BLEDevice::init("ESP32-BLE-Client");

  BLEScan* pScan = BLEDevice::getScan();
  pScan->setAdvertisedDeviceCallbacks(new ScanCallbacks());
  pScan->setActiveScan(true);
  pScan->setInterval(100);
  pScan->setWindow(99);
  pScan->start(scanDuration, false);
}

void loop() {
  if (doConnect) {
    if (connectToServer()) {
      connected = true;
      Serial.println("Listening for notifications...\n");
    } else {
      Serial.println("Connection failed. Will retry scan.\n");
    }
    doConnect = false;
  }

  // If disconnected, scan again after a delay
  if (!connected) {
    delay(3000);
    Serial.println("Scanning for server...");
    BLEDevice::getScan()->start(scanDuration, false);
  }

  delay(100);  // Small delay to prevent watchdog resets
}

🔗What Changed from the Polling Client

The key difference is in connectToServer(). After finding the characteristic, the client calls:

pRemoteChar->registerForNotify(notifyCallback);

This tells the server to start pushing updates. The notifyCallback function is invoked automatically every time the server sends a new value -- no polling loop needed.

The loop() function is now almost empty when connected. The client just waits, and data arrives via the callback. This is more efficient both in terms of BLE traffic and power consumption.

Tip: The notifyCallback runs in the BLE stack's context, not in loop(). Avoid doing anything slow (like Serial prints of large strings or complex calculations) inside the callback. For heavy processing, set a flag in the callback and handle it in loop().

🔗Reconnection Logic

In real deployments, BLE connections drop. The server might reboot, move out of range, or experience interference. A robust client should handle disconnects gracefully and automatically try to reconnect.

The key pattern is straightforward:

void loop() {
  // If we were connected but lost the connection
  if (!connected && pClient != nullptr) {
    Serial.println("Connection lost. Cleaning up...");

    // Clean up the old client
    pClient->disconnect();
    delete pClient;
    pClient = nullptr;
    pRemoteChar = nullptr;

    // Wait before retrying (avoid rapid reconnection attempts)
    delay(3000);
  }

  // If not connected, scan for the server
  if (!connected && !doConnect) {
    Serial.println("Scanning...");
    BLEDevice::getScan()->start(scanDuration, false);
  }

  // Connect if found
  if (doConnect) {
    if (connectToServer()) {
      connected = true;
    }
    doConnect = false;
  }

  delay(100);
}

The important details:

  • Clean up the old client before creating a new one. BLE resources are limited on the ESP32, and leaking client objects will eventually cause crashes.
  • Add a delay between reconnection attempts. Rapid scanning and connecting drains power and can overwhelm the BLE stack.
  • Delete and recreate the BLEClient object on each reconnection. Reusing a disconnected client can cause undefined behavior.

Warning: The ESP32's BLE stack has limited memory. If you repeatedly create clients without deleting old ones, you will run out of memory and the ESP32 will crash. Always delete the old BLEClient with delete pClient before creating a new one.

🔗Practical Use Cases

🔗BLE-to-WiFi Gateway

One of the most powerful patterns is using a BLE client as a gateway. Multiple ESP32s with BLE servers collect sensor data (temperature, humidity, soil moisture), and a single ESP32 with both BLE and WiFi connects to each one and forwards the data to an MQTT broker or cloud service.

graph LR
    A[ESP32 + BME280<br>BLE Server] -->|BLE| D[ESP32 Gateway<br>BLE Client + WiFi]
    B[ESP32 + Soil Sensor<br>BLE Server] -->|BLE| D
    C[ESP32 + Light Sensor<br>BLE Server] -->|BLE| D
    D -->|WiFi / MQTT| E[Cloud / Dashboard]

This architecture is useful when your sensor nodes are in locations without WiFi coverage (a greenhouse, a basement, a field). The sensor nodes run on battery power with BLE (low power), and only the gateway needs a WiFi connection.

Note: The ESP32 BLE client can connect to one server at a time in the examples above. To talk to multiple servers, you would connect to each one sequentially: connect, read, disconnect, move to the next. For true simultaneous connections, the ESP32 supports up to around 3-4 concurrent BLE connections, but the code complexity increases significantly.

🔗Reading Commercial BLE Devices

Many consumer devices use BLE: thermometers, heart rate monitors, smart scales, and plant sensors. In theory, you can use your ESP32 client to read data from these devices.

In practice, there are some caveats:

  • Standard services (heart rate, temperature) are well-documented and follow Bluetooth SIG specifications. These are the easiest to work with.
  • Proprietary protocols are common. Many manufacturers use custom UUIDs and encrypt their data. Reverse-engineering these protocols requires tools like Wireshark with a BLE sniffer.
  • Pairing and bonding may be required. Some devices will not share data until a secure pairing process is completed.

The BLE scanner sketch from earlier in this tutorial is a great starting point for exploring what services and characteristics a device exposes.

🔗Troubleshooting

ProblemPossible CauseSolution
Cannot find device during scanServer is not advertisingMake sure the server sketch is running and Serial Monitor shows "Waiting for connections"
Cannot find device during scanWrong service UUIDVerify the UUID in the client matches the server exactly (copy-paste to avoid typos)
Cannot find device during scanServer connected to another clientMost BLE servers only accept one connection at a time; disconnect the other client first
Connection refused or failsServer out of rangeBLE range is typically $10$--$30\,\text{m}$ indoors; move the devices closer
Connection refused or failsBLE stack in bad stateReset both ESP32s (press the EN/Reset button)
Characteristic not foundService UUID matches but characteristic UUID does notDouble-check the characteristic UUID in both sketches
Notification callback not firingServer does not support notifyEnsure the server characteristic has PROPERTY_NOTIFY and a BLE2902 descriptor
Notification callback not firingregisterForNotify not calledVerify the client calls registerForNotify after finding the characteristic
ESP32 crashes after several reconnectsMemory leak from unreleased BLE clientsAlways delete pClient before creating a new one on reconnect
Garbled data in callbackLength mismatch or encoding issueUse the length parameter to correctly interpret the received bytes

🔗What's Next?

You now know how to build both sides of a BLE connection with the ESP32. Here are some directions to explore next:

  • MQTT Protocol Reference -- combine BLE with WiFi and MQTT to build a complete sensor-to-cloud pipeline
  • ESP-NOW Protocol Reference -- an alternative wireless protocol for ESP32-to-ESP32 communication that does not require pairing or connection setup
  • BLE Protocol Reference -- a deeper dive into BLE concepts including GAP, GATT profiles, security, and power optimization