ESP32: connect to BLE peripherals with the GATT client pattern
Make the ESP32 read data from BLE devices like heart rate straps, temperature sensors, and beacons. The reverse of the peripheral tutorial.
The ESP32 is not just a BLE peripheral. It can also be a BLE central: scan for nearby BLE devices, connect to them, and read their data. This turns the ESP32 into a sensor hub that reads heart rate straps, temperature beacons, fitness trackers, and other BLE peripherals.
This is the reverse of the BLE peripheral tutorial. The ESP32 here is the client; the BLE device you are reading is the server.
This tutorial covers scanning, connecting, discovering services, and reading characteristics. Most of what you do with a BLE central is follow the standard service UUIDs (heart rate, battery, environment sensing) and read the standard characteristics.
What you need
- ESP32 dev board
- A BLE peripheral device (your phone with the nRF Connect app works great for testing; a heart rate strap, BLE temperature sensor, or another ESP32 also works)
- Arduino ESP32 board package 2.x or later
The code: scan for nearby devices
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
class ScanCallback : public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice advertisedDevice) {
Serial.print("Found: ");
Serial.print(advertisedDevice.getName().c_str());
Serial.print(" RSSI: ");
Serial.print(advertisedDevice.getRSSI());
Serial.print(" Address: ");
Serial.println(advertisedDevice.getAddress().toString().c_str());
}
};
void setup() {
Serial.begin(115200);
delay(1000);
BLEDevice::init("");
BLEScan *scanner = BLEDevice::getScan();
scanner->setAdvertisedDeviceCallbacks(new ScanCallback());
scanner->setActiveScan(true);
scanner->setInterval(100);
scanner->setWindow(99);
}
void loop() {
BLEScanResults results = *BLEDevice::getScan()->start(5, false);
Serial.print("Found ");
Serial.print(results.getCount());
Serial.println(" devices");
BLEDevice::getScan()->clearResults();
delay(5000);
}
Upload. Open Serial Monitor. You should see a list of BLE devices every 5 seconds, including their RSSI (signal strength, higher is closer) and MAC address.
RSSI is a quick way to estimate distance. -30 dBm is right next to the ESP32. -90 dBm is at the edge of range. BLE has about 50 m line of sight, less through walls.
Connecting to a specific device
Once you find a device you want to connect to, filter by name or address, then open a connection:
class ConnectCallback : public BLEAdvertisedDeviceCallbacks {
bool doConnect = false;
BLEAdvertisedDevice *target = nullptr;
void onResult(BLEAdvertisedDevice advertisedDevice) {
if (advertisedDevice.haveName() &&
advertisedDevice.getName() == "ESP32-BLE-Example") {
advertisedDevice.getScan()->stop();
target = new BLEAdvertisedDevice(advertisedDevice);
doConnect = true;
}
}
bool shouldConnect() { return doConnect; }
BLEAdvertisedDevice* getTarget() { return target; }
};
Then in loop():
void loop() {
if (connectCallback.shouldConnect()) {
connectToServer(connectCallback.getTarget());
connectCallback.clear();
}
// ...
}
void connectToServer(BLEAdvertisedDevice *device) {
BLEClient *client = BLEDevice::createClient();
client->connect(device);
Serial.print("Connected to ");
Serial.println(device->getName().c_str());
BLERemoteService *service = client->getService(SERVICE_UUID);
if (service == nullptr) {
Serial.println("Service not found");
client->disconnect();
return;
}
BLERemoteCharacteristic *characteristic =
service->getCharacteristic(CHARACTERISTIC_UUID);
if (characteristic == nullptr) {
Serial.println("Characteristic not found");
client->disconnect();
return;
}
if (characteristic->canRead()) {
String value = characteristic->readValue();
Serial.print("Read: ");
Serial.println(value);
}
client->disconnect();
}
This connects, finds the service and characteristic by UUID, reads the value, then disconnects. Most BLE clients work in this pattern: connect, read what you need, disconnect.
Subscribing to notifications
If you want continuous updates (e.g. heart rate every second), subscribe to notifications instead of polling:
if (characteristic->canNotify()) {
characteristic->registerForNotify([](BLERemoteCharacteristic *c, uint8_t *data,
size_t length, bool isNotify) {
Serial.print("Notify: ");
for (size_t i = 0; i < length; i++) {
Serial.print((char)data[i]);
}
Serial.println();
});
}
The callback fires every time the peripheral sends a notification. This is the right pattern for streaming sensor data.
Standard service UUIDs
Most consumer BLE devices implement one or more standard services. The Bluetooth SIG assigns short 16-bit UUIDs for these:
| Service | UUID | What you read |
|---|---|---|
| Battery | 0x180F | Battery level (0-100%) |
| Heart Rate | 0x180D | Heart rate (BPM) |
| Environment Sensing | 0x181A | Temperature, humidity, pressure |
| Health Thermometer | 0x1809 | Body temperature |
| Cycling Power | 0x1818 | Power output in watts |
| Running Speed and Cadence | 0x1814 | Pace and stride |
For example, to read a heart rate strap:
BLERemoteService *hrService = client->getService(BLEUUID((uint16_t)0x180D));
BLERemoteCharacteristic *hrChar = hrService->getCharacteristic(BLEUUID((uint16_t)0x2A37));
if (hrChar->canNotify()) {
hrChar->registerForNotify([](BLERemoteCharacteristic *c, uint8_t *data,
size_t length, bool isNotify) {
// Heart Rate Measurement format: byte 0 = flags, byte 1+ = HR value
uint8_t hr = data[1];
Serial.print("Heart rate: ");
Serial.println(hr);
});
}
The standard services are documented at https://www.bluetooth.com/specifications/specs/.
BLE scanning is power-hungry
Active scanning (the kind that gets device names) takes about 30 mA on the ESP32. For battery-powered projects, scan briefly and then sleep:
void loop() {
BLEScanResults results = *BLEDevice::getScan()->start(2, false);
// process results
BLEDevice::getScan()->clearResults();
esp_deep_sleep_start(); // sleep until next wake
}
This pattern works for projects that wake every minute to scan, take a reading, and go back to sleep. Battery life is measured in months.
The “device disappeared” problem
BLE devices are flaky. They go out of range, run out of battery, or disconnect without warning. Your client code needs to handle the disconnect gracefully:
class MyClientCallback : public BLEClientCallbacks {
void onDisconnect(BLEClient *client) {
Serial.println("Disconnected, will retry");
doConnect = true; // flag for the next loop iteration
}
};
client->setCallbacks(new MyClientCallback());
Set a flag and retry on the next loop. Without this, a flaky device permanently breaks your project.
What you learned
- The ESP32 can be a BLE central, scanning for and connecting to other BLE devices.
- Scanning is power-hungry. Brief scans + deep sleep is the right pattern for battery projects.
- Most consumer BLE devices implement standard services with 16-bit UUIDs. Learn those and you can read any heart rate strap, fitness tracker, or temperature beacon.
- Notifications are how you stream data, not polling.
When something breaks
- Scan finds nothing. No BLE devices nearby, or the antenna is covered (the ESP32’s antenna is on the PCB near the top edge).
- Connect succeeds but service not found. Wrong UUID. Or the peripheral has not finished advertising the service (try again after a few seconds).
- Read returns empty. The peripheral is waiting for an encryption key, or the characteristic requires a specific read protocol.
- Notifications do not fire. You registered for notify after the peripheral already started sending them. Disconnect, reconnect, then register.
What to build next
- The BLE peripheral tutorial is the other half. Build a sensor that publishes to your phone, then write a client to read it back.
- The ESP32 MQTT tutorial combines BLE readings with MQTT publishing for a sensor network.
- The book ESP32 IoT Projects covers real BLE applications: indoor positioning using BLE beacons, fitness tracking, BLE-based mesh networks.