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
| Component | Qty | Notes | Buy |
|---|---|---|---|
| ESP32 dev board | 2 | One running the BLE Server sketch from the previous tutorial | AliExpress | Amazon.de .co.uk .com |
| USB cable (data-capable) | 2 | One 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:
- Scan -- The client listens for advertisement packets from nearby BLE devices.
- Filter -- The client checks each discovered device against criteria (name, service UUID, address) to find the target.
- Connect -- Once the target is found, the client initiates a connection.
- Discover services -- After connecting, the client queries the server for its available services and characteristics.
- 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
notifyCallbackruns in the BLE stack's context, not inloop(). 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 inloop().
🔗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
BLEClientobject 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
BLEClientwithdelete pClientbefore 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
| Problem | Possible Cause | Solution |
|---|---|---|
| Cannot find device during scan | Server is not advertising | Make sure the server sketch is running and Serial Monitor shows "Waiting for connections" |
| Cannot find device during scan | Wrong service UUID | Verify the UUID in the client matches the server exactly (copy-paste to avoid typos) |
| Cannot find device during scan | Server connected to another client | Most BLE servers only accept one connection at a time; disconnect the other client first |
| Connection refused or fails | Server out of range | BLE range is typically $10$--$30\,\text{m}$ indoors; move the devices closer |
| Connection refused or fails | BLE stack in bad state | Reset both ESP32s (press the EN/Reset button) |
| Characteristic not found | Service UUID matches but characteristic UUID does not | Double-check the characteristic UUID in both sketches |
| Notification callback not firing | Server does not support notify | Ensure the server characteristic has PROPERTY_NOTIFY and a BLE2902 descriptor |
| Notification callback not firing | registerForNotify not called | Verify the client calls registerForNotify after finding the characteristic |
| ESP32 crashes after several reconnects | Memory leak from unreleased BLE clients | Always delete pClient before creating a new one on reconnect |
| Garbled data in callback | Length mismatch or encoding issue | Use 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