19 Bluetooth Implementation and Labs
Practical BLE Development with ESP32
networking
wireless
bluetooth
ble
esp32
lab
Keywords
ble, esp32, arduino, wokwi, gatt, beacon, implementation, lab
Minimum Viable Understanding
Practical BLE development on ESP32 involves four core skills: scanning for nearby devices using RSSI-based proximity estimation, creating GATT servers with standard services and notifications, building iBeacon transmitters for indoor positioning, and implementing trilateration algorithms to calculate position from multiple beacon distances. The default BLE MTU is only 23 bytes, and RSSI-based distance estimation is inherently approximate due to environmental factors.
19.1 Learning Objectives
By the end of this chapter, you will be able to:
- Implement BLE scanning and advertising on ESP32 using the Arduino BLE library
- Configure GATT services and characteristics with appropriate properties and descriptors
- Construct BLE beacon applications in iBeacon format with correct manufacturer data
- Apply RSSI trilateration algorithms to calculate indoor position estimates
- Diagnose common BLE implementation errors including MTU mismatches and missing CCCD descriptors
- Calculate estimated BLE transmission range using the path loss exponent formula
19.2 Introduction
This chapter provides practical, hands-on experience with Bluetooth Low Energy development. Using the ESP32 microcontroller and Wokwi simulator, you will learn to build real BLE applications from scanning nearby devices to creating beacon-based indoor positioning systems.
For Beginners: Getting Started
Before diving into code, make sure you understand: - BLE roles: Central (scanner) vs Peripheral (advertiser) - GATT structure: Services contain Characteristics contain Values - Advertising: How devices broadcast their presence
The labs below start simple and build complexity gradually.
Sensor Squad: Build Your Own BLE Device!
“This is the hands-on chapter where you get to build things with us!” Sammy the Sensor said excitedly. “Using an ESP32 microcontroller, you can create a BLE scanner that finds all the Bluetooth devices nearby. It is like building your own device detector!”
“My favorite lab is the beacon project,” Lila the LED admitted. “You program the ESP32 to broadcast a signal, just like the beacons in stores and museums. Imagine placing little beacons around your house so your phone always knows which room you are in. You can actually build that!”
Max the Microcontroller rubbed his hands together. “The ESP32 is perfect for learning because it has Bluetooth built right in. You write the code, upload it, and watch the serial monitor light up with scan results – device names, signal strengths, and addresses. I process all of that data in real time.”
“Even the indoor positioning lab is fun,” Bella the Battery added. “You place three beacons in a room and use math called trilateration to figure out exactly where you are standing. It is like GPS but for indoors, powered by tiny BLE signals. And since we are using BLE, the beacons can run on coin cell batteries for months!”
19.3 Lab 1: BLE Beacon Scanner
This lab implements a BLE scanner that discovers nearby devices and estimates their proximity.
19.3.1 Learning Objectives
By completing this lab, you will be able to:
- Set up ESP32 BLE scanning: Configure the ESP32 as a BLE Central device
- Discover nearby BLE devices: Understand how BLE advertising and scanning work
- Parse BLE advertisement data: Extract device names, addresses, and RSSI values
- Analyze signal strength: Use RSSI to estimate device proximity
19.3.2 Embedded Wokwi Simulator
How to Use the Simulator
- Click inside the code editor in the simulator below
- Replace the default code with the BLE Scanner code provided
- Click the green “Play” button to compile and run
- Open Serial Monitor (click the terminal icon) to see scan results
19.3.3 BLE Scanner Code
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
BLEScan* pBLEScan;
// Callback: runs for each discovered BLE device
class ScanCallbacks : public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice dev) {
int rssi = dev.getRSSI();
const char* proximity = rssi >= -50 ? "IMMEDIATE (<1m)"
: rssi >= -70 ? "NEAR (1-3m)"
: rssi >= -90 ? "FAR (3-10m)"
: "VERY FAR (>10m)";
Serial.printf("Device: %s | Addr: %s | RSSI: %d dBm | %s\n",
dev.getName().c_str(),
dev.getAddress().toString().c_str(),
rssi, proximity);
}
};
void setup() {
Serial.begin(115200);
BLEDevice::init("ESP32-Scanner");
pBLEScan = BLEDevice::getScan();
pBLEScan->setAdvertisedDeviceCallbacks(new ScanCallbacks());
pBLEScan->setActiveScan(true); // Request scan response data
pBLEScan->setInterval(100);
pBLEScan->setWindow(99); // Nearly continuous scanning
}
void loop() {
BLEScanResults found = pBLEScan->start(5, false); // 5-second scan
Serial.printf("Scan complete: %d device(s)\n\n", found.getCount());
pBLEScan->clearResults();
delay(2000);
}19.3.4 Understanding the Code
1. BLE Initialization
BLEDevice::init("ESP32-Scanner");
pBLEScan = BLEDevice::getScan();Initializes the BLE stack and creates a scanner object.
2. Scan Configuration
pBLEScan->setActiveScan(true); // Request scan response data
pBLEScan->setInterval(100); // Time between scan windows
pBLEScan->setWindow(99); // Duration of each listening window3. RSSI to Distance Estimation
if (rssi >= -50) proximity = "IMMEDIATE (<1m)";
else if (rssi >= -70) proximity = "NEAR (1-3m)";
else if (rssi >= -90) proximity = "FAR (3-10m)";
else proximity = "VERY FAR (>10m)";
RSSI Limitations
RSSI-based distance estimation is approximate. Signal strength is affected by: - Obstacles (walls, furniture, people) - Device orientation and antenna design - Interference from other 2.4 GHz devices - Environmental factors (humidity, reflective surfaces)
19.4 Lab 2: BLE GATT Server (Temperature Sensor)
Create a BLE peripheral that exposes temperature data via GATT.
19.4.1 Code: ESP32 Temperature Service
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLE2902.h>
#define SERVICE_UUID "181A" // Environmental Sensing (standard)
#define TEMP_CHAR_UUID "2A6E" // Temperature (standard)
BLECharacteristic* pTempChar = NULL;
bool deviceConnected = false;
float temperature = 22.5;
class ServerCB : public BLEServerCallbacks {
void onConnect(BLEServer* s) { deviceConnected = true; }
void onDisconnect(BLEServer* s) {
deviceConnected = false;
s->getAdvertising()->start(); // Resume advertising
}
};
void setup() {
Serial.begin(115200);
BLEDevice::init("ESP32-TempSensor");
BLEServer* pServer = BLEDevice::createServer();
pServer->setCallbacks(new ServerCB());
BLEService* pSvc = pServer->createService(SERVICE_UUID);
pTempChar = pSvc->createCharacteristic(TEMP_CHAR_UUID,
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_NOTIFY);
pTempChar->addDescriptor(new BLE2902()); // CCCD for notifications
pSvc->start();
BLEDevice::getAdvertising()->addServiceUUID(SERVICE_UUID);
BLEDevice::getAdvertising()->start();
}
void loop() {
if (deviceConnected) {
temperature += random(-10, 11) / 100.0;
int16_t val = (int16_t)(temperature * 100); // 0.01 C resolution
pTempChar->setValue((uint8_t*)&val, 2);
pTempChar->notify();
}
delay(1000);
}19.4.2 Key Concepts
- Service Creation: Use standard UUID 0x181A for Environmental Sensing
- Characteristic Properties: READ for polling, NOTIFY for real-time updates
- CCCD (BLE2902): Required descriptor for notifications
- Data Format: Temperature in 0.01°C units as int16_t
19.5 Lab 3: iBeacon Transmitter
Create an iBeacon that broadcasts location information.
19.5.1 Code: ESP32 iBeacon
#include <BLEDevice.h>
#include <BLEBeacon.h>
#define BEACON_UUID "FDA50693-A4E2-4FB1-AFCF-C6EB07647825"
#define BEACON_MAJOR 1 // Store/building number
#define BEACON_MINOR 101 // Specific beacon ID
void setup() {
Serial.begin(115200);
BLEDevice::init("iBeacon");
BLEDevice::createServer();
BLEBeacon beacon;
beacon.setManufacturerId(0x4C00); // Apple's company ID
beacon.setProximityUUID(BLEUUID(BEACON_UUID));
beacon.setMajor(BEACON_MAJOR);
beacon.setMinor(BEACON_MINOR);
beacon.setSignalPower(-59); // Calibrated RSSI at 1 m
BLEAdvertisementData advData;
advData.setFlags(0x04); // BR_EDR_NOT_SUPPORTED
advData.setManufacturerData(beacon.getData());
BLEAdvertising* pAdv = BLEDevice::getAdvertising();
pAdv->setAdvertisementData(advData);
pAdv->start();
}
void loop() { delay(1000); }19.5.2 iBeacon Distance Calculation
Use the path loss formula to estimate distance from RSSI:
\[d = 10^{\frac{TxPower - RSSI}{10 \times n}}\]
Where: - TxPower = RSSI at 1 meter (typically -59 to -65 dBm) - n = Path loss exponent (2.0 for free space, 2.5-4.0 for indoor) - RSSI = Measured signal strength
float calculateDistance(int rssi, int txPower = -59, float n = 2.5) {
if (rssi == 0) return -1.0;
float ratio = (txPower - rssi) / (10.0 * n);
return pow(10, ratio);
}
Putting Numbers to It
The path loss equation shows how RSSI translates to distance, though environmental factors add significant error.
For a beacon calibrated at -59dBm at 1m with indoor path loss n=2.5, measuring RSSI = -74dBm:
\[d = 10^{\frac{-59 - (-74)}{10 \times 2.5}} = 10^{\frac{15}{25}} = 10^{0.6} \approx 3.98\text{m}\]
Measured RSSI = -84dBm yields \(d \approx 10^{1.0} = 10\text{m}\). But human body blockage can shift RSSI by ±8dBm, making the same beacon appear 2m to 15m away—why zone-based proximity (near/medium/far) is more reliable than exact distance.
19.6 Lab 4: Indoor Positioning System
Combine multiple beacons for trilateration-based positioning.
19.6.1 System Architecture
19.6.2 Trilateration Algorithm
import numpy as np
from scipy.optimize import least_squares
def trilaterate(beacons, distances):
"""
Calculate position from beacon positions and distances.
Args:
beacons: List of (x, y) beacon positions
distances: List of distances to each beacon
Returns:
(x, y) estimated position
"""
def residuals(point, beacons, distances):
return [
np.sqrt((point[0] - b[0])**2 + (point[1] - b[1])**2) - d
for b, d in zip(beacons, distances)
]
# Initial guess: centroid of beacons
x0 = np.mean([b[0] for b in beacons])
y0 = np.mean([b[1] for b in beacons])
result = least_squares(
residuals,
[x0, y0],
args=(beacons, distances)
)
return tuple(result.x)
# Example usage
beacons = [(0, 0), (5, 0), (0, 5)]
distances = [1.78, 4.47, 2.82] # From RSSI
position = trilaterate(beacons, distances)
print(f"Estimated position: {position}")19.7 Common Development Mistakes
Mistake 1: Forgetting to Enable Notifications
Problem: Code connects to BLE device but doesn’t receive updates.
Cause: Notifications require writing to CCCD descriptor.
Wrong:
// Only reads once, no updates
value = characteristic.read();Correct:
// Enable notifications via CCCD
characteristic.getDescriptor(BLEUUID((uint16_t)0x2902))
->writeValue((uint8_t*)"\x01\x00", 2, true);
// Now notifications will arrive in callback
Mistake 2: Connection Interval Mismatch
Problem: Battery drains quickly or response is too slow.
Cause: Using default connection interval without optimization.
| Application | Recommended Interval |
|---|---|
| Game controller | 7.5-15ms |
| Fitness tracker | 100-200ms |
| Temperature sensor | 1000-4000ms |
Fix: Request appropriate connection parameters after connecting.
Mistake 3: MTU Size Assumptions
Problem: Large data packets get truncated.
Cause: Default MTU is only 23 bytes (20 payload).
Fix: Negotiate larger MTU after connection:
// Request MTU exchange
BLEDevice::setMTU(247); // Request 247 bytes
// Actual MTU may be less based on negotiation19.8 Challenge Exercises
Challenge 1: Filter by Signal Strength
Modify the scanner to only display devices with RSSI stronger than -80 dBm.
Hint: Add an if statement in onResult():
if (rssi < -80) return; // Skip weak signals
Challenge 2: Count Devices by Proximity Zone
Track how many devices are in each proximity zone and display a summary after each scan.
Hint: Add counter variables and increment them in onResult().
Challenge 3: Track Known Devices
Create an array of “known” device addresses and highlight them differently.
Hint:
String knownDevices[] = {"a4:c1:38:12:34:56", "b8:27:eb:aa:bb:cc"};
// Check if address matches known devices
Challenge 4: Detect iBeacons
Modify the scanner to specifically detect and parse iBeacon packets.
Hint: Check manufacturer data for Apple’s company ID (0x004C).
19.9 Inline Knowledge Check
19.9.1 Knowledge Check: RSSI Distance Estimation
19.9.2 Knowledge Check: BLE MTU Negotiation
19.9.3 Knowledge Check: iBeacon Configuration
Common Mistake: Using Default MTU Size for Large Sensor Payloads
The Mistake: Sending sensor data packets larger than 20 bytes without negotiating a larger MTU (Maximum Transmission Unit), causing data truncation, protocol errors, or silent packet loss. This often manifests as “missing bytes” or “corrupted readings” that work fine in testing with short payloads but fail in production with full data.
Why It Happens: The default BLE MTU is only 23 bytes (20 bytes usable payload after 3-byte ATT header overhead). Developers test with simple sensor values (2-4 bytes) that fit easily, then add more features (timestamps, multiple readings, metadata) pushing total payload to 30-50 bytes without realizing MTU negotiation is required.
Real-World Impact:
Scenario: Environmental sensor sends combined reading:
- Temperature: 2 bytes (int16)
- Humidity: 2 bytes (uint16)
- Pressure: 4 bytes (uint32)
- Timestamp: 4 bytes (uint32_t)
- Battery: 1 byte (uint8)
- Device ID: 6 bytes (MAC address)
Total: 19 bytes ✓ (fits in default 20-byte MTU)
Then you add:
- CO2 level: 2 bytes
- Light intensity: 2 bytes
Total: 23 bytes ✗ (exceeds 20-byte payload!)
Result without MTU negotiation:
- First 20 bytes transmitted
- Last 3 bytes silently dropped
- Light intensity always reads 0
- Hours of debugging "sensor malfunction"
The Fix: Always negotiate MTU after connection establishment:
// ESP32 Example: Request larger MTU
void on_connected(uint16_t conn_handle) {
// Request 247 bytes (maximum for BLE 4.2+)
esp_ble_gattc_send_mtu_req(conn_handle, 247);
// Wait for MTU exchange callback before sending data!
mtu_negotiated = false;
}
void on_mtu_exchanged(uint16_t conn_handle, uint16_t mtu) {
Serial.printf("MTU negotiated: %d bytes\n", mtu);
effective_mtu = mtu;
max_payload = mtu - 3; // Subtract ATT header
mtu_negotiated = true;
Serial.printf("Max payload: %d bytes\n", max_payload);
// NOW safe to send large packets
}
// Only send when MTU is ready
void send_sensor_data() {
if (!mtu_negotiated) {
Serial.println("ERROR: Attempted to send before MTU negotiation!");
return;
}
if (payload_size > max_payload) {
Serial.printf("ERROR: Payload %d exceeds MTU %d\n",
payload_size, max_payload);
return;
}
pCharacteristic->setValue(data, payload_size);
pCharacteristic->notify();
}Alternative: Chunking Strategy (if MTU negotiation fails):
// Fallback for devices that won't negotiate larger MTU
void send_large_data_chunked(uint8_t* data, size_t total_len) {
const size_t CHUNK_SIZE = max_payload - 2; // Reserve 2 bytes for sequence
for (size_t offset = 0; offset < total_len; offset += CHUNK_SIZE) {
size_t chunk_len = min(CHUNK_SIZE, total_len - offset);
// Packet format: [sequence_number] [chunk_data]
uint8_t packet[max_payload];
packet[0] = offset / CHUNK_SIZE; // Chunk sequence
packet[1] = (offset + chunk_len >= total_len) ? 1 : 0; // Last chunk flag
memcpy(&packet[2], data + offset, chunk_len);
pCharacteristic->setValue(packet, chunk_len + 2);
pCharacteristic->notify();
delay(20); // Allow time for transmission
}
}Key Insight: The 23-byte default MTU is a BLE legacy constraint. Always negotiate MTU to 247 bytes (BLE 4.2+) immediately after connection. If you forget, your application will work fine until you exceed 20 bytes, then fail mysteriously.
19.10 Interactive Quizzes
19.11 Summary
This chapter provided hands-on BLE implementation experience:
- Lab 1: BLE Scanner - discovering devices and estimating proximity from RSSI
- Lab 2: GATT Server - creating temperature service with notifications
- Lab 3: iBeacon - broadcasting location information for indoor positioning
- Lab 4: Trilateration - calculating position from multiple beacon distances
- Common Mistakes: CCCD notifications, connection intervals, MTU negotiation
Common Pitfalls
1. Using Bluedroid When NimBLE is Sufficient
ESP32’s Bluedroid BLE stack requires ~120 KB RAM; NimBLE requires only ~40 KB. For IoT sensor projects that only need BLE (no Classic Bluetooth), using Bluedroid wastes 80 KB of RAM that could be used for application buffers. Select NimBLE via menuconfig (CONFIG_BT_NIMBLE_ENABLED=y) unless Classic Bluetooth profiles (A2DP, HFP) are required.
2. Blocking the BLE Event Loop
BLE event handlers (NimBLE ble_hs_cfg.sync_cb, gap_event_cb) run in the NimBLE host task context. Calling blocking operations (vTaskDelay, I2C sensor reads) inside these handlers blocks all BLE protocol processing, causing connection timeouts. Dispatch application work to a separate FreeRTOS task using xQueueSend() and return immediately from BLE callbacks.
3. Forgetting to Deinitialize BLE Before Deep Sleep
Entering ESP32 deep sleep without calling ble_hs_stop(), nimble_port_stop(), nimble_port_deinit(), and esp_bt_controller_disable() causes the BLE controller to consume ~8 mA during sleep instead of ~10 µA. Always perform a clean BLE shutdown sequence before calling esp_deep_sleep_start() and re-initialize the stack upon wakeup if connections are needed.
4. Incorrect Connection Security Requirement Configuration
Setting BLE_SM_IO_CAP_NO_INPUT_NO_OUTPUT (Just Works pairing) for a device that stores sensitive user data provides zero MITM protection. Just Works pairing generates an unauthenticated LTK that any BLE central can establish without user confirmation. For devices handling health, financial, or access-control data, require at minimum Passkey Entry (IO_CAP_DISP_ONLY or IO_CAP_KEYBOARD_ONLY) with MITM protection flag.
19.12 What’s Next
| Topic | Chapter | Why Read It |
|---|---|---|
| Bluetooth Mesh Networking | Bluetooth Mesh and Advanced Topics | Extend BLE beyond point-to-point: multi-hop mesh, provisioning, and publish/subscribe models |
| BLE Protocol Internals | Bluetooth Architecture and Protocol Stack | Understand the link layer, GAP advertising PDUs, and GATT attribute tables underpinning every lab |
| BLE Security | Bluetooth Security | Apply pairing modes, bonding, and LE Secure Connections to protect your BLE implementations |
| Zigbee and Thread | Zigbee, Thread and Matter | Compare BLE mesh with Zigbee and Thread mesh topologies for multi-device IoT deployments |
| Indoor Positioning Systems | RFID, NFC and UWB | Contrast RSSI trilateration with UWB time-of-flight positioning for sub-metre accuracy |
| IoT Protocol Selection | Protocol Integration | Apply a structured framework to select BLE, Wi-Fi, LoRaWAN, or Zigbee for a given use case |