19  Bluetooth Implementation and Labs

Practical BLE Development with ESP32

networking
wireless
bluetooth
ble
esp32
lab
Author

IoT Textbook

Published

January 19, 2026

Keywords

ble, esp32, arduino, wokwi, gatt, beacon, implementation, lab

In 60 Seconds

Practical BLE development on ESP32 centers on four core skills: scanning for nearby devices using RSSI-based proximity, creating GATT servers with standard services and notifications, building iBeacon transmitters for indoor positioning, and implementing trilateration algorithms from multiple beacon distances. The default BLE MTU is only 23 bytes.

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.

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.

“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
  1. Click inside the code editor in the simulator below
  2. Replace the default code with the BLE Scanner code provided
  3. Click the green “Play” button to compile and run
  4. 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 window

3. 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)

Try It: RSSI Proximity Estimator

Adjust the RSSI value to see how signal strength maps to proximity zones, matching the classification used in the scanner code above.

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

  1. Service Creation: Use standard UUID 0x181A for Environmental Sensing
  2. Characteristic Properties: READ for polling, NOTIFY for real-time updates
  3. CCCD (BLE2902): Required descriptor for notifications
  4. Data Format: Temperature in 0.01°C units as int16_t
Mid-Chapter Check: GATT Notifications

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);
}

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.

Try It: iBeacon Path Loss Distance Calculator

Explore how the path loss formula converts RSSI to distance. Adjust the parameters to see how calibration (TxPower) and environment (path loss exponent n) dramatically affect the estimate.

19.6 Lab 4: Indoor Positioning System

Combine multiple beacons for trilateration-based positioning.

19.6.1 System Architecture

Indoor positioning using trilateration with 3 beacons at known positions, calculating user position from RSSI-derived distances
Figure 19.1: Indoor positioning using trilateration with 3 beacons estimating position from RSSI distances.

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}")
Try It: BLE Trilateration Simulator

Place 3 beacons and adjust the measured distance from each to see where trilateration estimates your position. The circles represent the distance measurement from each beacon — the estimated position is where they intersect.

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.

Try It: BLE Connection Interval Power Estimator

See how connection interval affects battery life. Shorter intervals mean faster response but higher power consumption — find the right balance for your application.

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 negotiation

19.8 Challenge Exercises

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

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().

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

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.

Try It: BLE MTU Payload Calculator

Build your sensor payload by toggling fields on/off. See if your total fits within the default BLE MTU or if you need to negotiate a larger one.

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

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.

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.

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.

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