904  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

904.1 Learning Objectives

By the end of this chapter, you will be able to:

  • Implement BLE scanning and advertising on ESP32
  • Create GATT services and characteristics
  • Build BLE beacon applications (iBeacon format)
  • Develop indoor positioning using RSSI trilateration
  • Debug BLE applications using Serial Monitor and tools

904.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.

904.3 Lab 1: BLE Beacon Scanner

This lab implements a BLE scanner that discovers nearby devices and estimates their proximity.

904.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

904.3.2 Embedded Wokwi Simulator

TipHow 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

904.3.3 BLE Scanner Code

/*
 * ESP32 BLE Beacon Scanner
 * Scans for nearby BLE devices and displays:
 * - Device name (if available)
 * - MAC address
 * - RSSI (signal strength)
 * - Estimated distance category
 */

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>

// Scan configuration
const int SCAN_TIME_SECONDS = 5;
const int PAUSE_BETWEEN_SCANS = 2;

BLEScan* pBLEScan;
int scanCount = 0;

// Callback class for handling discovered devices
class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
    void onResult(BLEAdvertisedDevice advertisedDevice) {
      String deviceName = advertisedDevice.getName().c_str();
      String deviceAddress = advertisedDevice.getAddress().toString().c_str();
      int rssi = advertisedDevice.getRSSI();

      // Determine proximity category based on RSSI
      String proximity;
      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)";
      }

      // Print device information
      Serial.println("----------------------------------------");
      Serial.print("Device Found: ");
      if (deviceName.length() > 0) {
        Serial.println(deviceName);
      } else {
        Serial.println("[Unknown Name]");
      }
      Serial.print("  Address: ");
      Serial.println(deviceAddress);
      Serial.print("  RSSI: ");
      Serial.print(rssi);
      Serial.println(" dBm");
      Serial.print("  Proximity: ");
      Serial.println(proximity);

      if (advertisedDevice.haveServiceUUID()) {
        Serial.print("  Service UUID: ");
        Serial.println(advertisedDevice.getServiceUUID().toString().c_str());
      }
    }
};

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

  Serial.println();
  Serial.println("========================================");
  Serial.println("    ESP32 BLE Beacon Scanner Lab");
  Serial.println("========================================");
  Serial.println();
  Serial.println("Initializing BLE...");

  BLEDevice::init("ESP32-Scanner");
  pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
  pBLEScan->setActiveScan(true);
  pBLEScan->setInterval(100);
  pBLEScan->setWindow(99);

  Serial.println("BLE Scanner Ready!");
  Serial.println();
}

void loop() {
  scanCount++;

  Serial.println("========================================");
  Serial.print("Starting Scan #");
  Serial.println(scanCount);
  Serial.println("========================================");

  BLEScanResults foundDevices = pBLEScan->start(SCAN_TIME_SECONDS, false);

  Serial.println();
  Serial.print("Scan Complete! Found ");
  Serial.print(foundDevices.getCount());
  Serial.println(" device(s)");

  pBLEScan->clearResults();

  Serial.print("Next scan in ");
  Serial.print(PAUSE_BETWEEN_SCANS);
  Serial.println(" seconds...");
  Serial.println();

  delay(PAUSE_BETWEEN_SCANS * 1000);
}

904.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)";
WarningRSSI 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)

904.4 Lab 2: BLE GATT Server (Temperature Sensor)

Create a BLE peripheral that exposes temperature data via GATT.

904.4.1 Code: ESP32 Temperature Service

/*
 * ESP32 BLE Temperature Sensor
 * Exposes Environmental Sensing Service (0x181A)
 * with Temperature characteristic (0x2A6E)
 */

#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

// UUIDs
#define SERVICE_UUID        "181A"  // Environmental Sensing
#define TEMP_CHAR_UUID      "2A6E"  // Temperature

BLEServer* pServer = NULL;
BLECharacteristic* pTempCharacteristic = NULL;
bool deviceConnected = false;
float temperature = 22.5;  // Simulated temperature

class MyServerCallbacks: public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
      deviceConnected = true;
      Serial.println("Client connected!");
    };

    void onDisconnect(BLEServer* pServer) {
      deviceConnected = false;
      Serial.println("Client disconnected");
      // Restart advertising
      pServer->getAdvertising()->start();
    }
};

void setup() {
  Serial.begin(115200);
  Serial.println("Starting BLE Temperature Sensor...");

  // Initialize BLE
  BLEDevice::init("ESP32-TempSensor");

  // Create server
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new MyServerCallbacks());

  // Create service
  BLEService *pService = pServer->createService(SERVICE_UUID);

  // Create temperature characteristic
  pTempCharacteristic = pService->createCharacteristic(
                          TEMP_CHAR_UUID,
                          BLECharacteristic::PROPERTY_READ |
                          BLECharacteristic::PROPERTY_NOTIFY
                        );

  // Add CCCD descriptor for notifications
  pTempCharacteristic->addDescriptor(new BLE2902());

  // Start service
  pService->start();

  // Start advertising
  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->start();

  Serial.println("Temperature sensor ready!");
  Serial.println("Advertising as 'ESP32-TempSensor'");
}

void loop() {
  if (deviceConnected) {
    // Simulate temperature variation
    temperature += random(-10, 11) / 100.0;

    // Convert to BLE format (0.01 degree resolution)
    int16_t tempValue = (int16_t)(temperature * 100);

    // Update characteristic
    pTempCharacteristic->setValue((uint8_t*)&tempValue, 2);
    pTempCharacteristic->notify();

    Serial.print("Temperature: ");
    Serial.print(temperature, 2);
    Serial.println(" C (notified)");
  }

  delay(1000);
}

904.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

904.5 Lab 3: iBeacon Transmitter

Create an iBeacon that broadcasts location information.

904.5.1 Code: ESP32 iBeacon

/*
 * ESP32 iBeacon Transmitter
 * Broadcasts iBeacon format for indoor positioning
 */

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEBeacon.h>

// iBeacon configuration
#define BEACON_UUID     "FDA50693-A4E2-4FB1-AFCF-C6EB07647825"
#define BEACON_MAJOR    1       // Store/building number
#define BEACON_MINOR    101     // Specific beacon ID

BLEAdvertising *pAdvertising;

void setup() {
  Serial.begin(115200);
  Serial.println("Starting iBeacon...");

  BLEDevice::init("iBeacon");
  BLEServer *pServer = BLEDevice::createServer();

  // Configure iBeacon
  BLEBeacon beacon;
  beacon.setManufacturerId(0x4C00);  // Apple's ID
  beacon.setProximityUUID(BLEUUID(BEACON_UUID));
  beacon.setMajor(BEACON_MAJOR);
  beacon.setMinor(BEACON_MINOR);
  beacon.setSignalPower(-59);  // Calibrated RSSI at 1m

  // Get advertising object
  pAdvertising = BLEDevice::getAdvertising();

  // Set beacon data
  BLEAdvertisementData advData;
  advData.setFlags(0x04);  // BR_EDR_NOT_SUPPORTED
  advData.setManufacturerData(beacon.getData());

  pAdvertising->setAdvertisementData(advData);
  pAdvertising->setScanResponseData(advData);
  pAdvertising->start();

  Serial.printf("iBeacon broadcasting (Major: %d, Minor: %d)\n",
                BEACON_MAJOR, BEACON_MINOR);
}

void loop() {
  delay(1000);
}

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

904.6 Lab 4: Indoor Positioning System

Combine multiple beacons for trilateration-based positioning.

904.6.1 System Architecture

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor':'#2C3E50','primaryTextColor':'#fff','primaryBorderColor':'#16A085','lineColor':'#E67E22','secondaryColor':'#E67E22','tertiaryColor':'#7F8C8D'}}}%%
flowchart TD
    subgraph BEACONS["Known Beacon Positions"]
        B1[Beacon 1<br/>0,0]
        B2[Beacon 2<br/>5,0]
        B3[Beacon 3<br/>0,5]
    end

    PHONE[Smartphone<br/>Scanner]

    B1 -->|"RSSI: -52 dBm<br/>d1 = 1.8m"| PHONE
    B2 -->|"RSSI: -65 dBm<br/>d2 = 4.5m"| PHONE
    B3 -->|"RSSI: -58 dBm<br/>d3 = 2.8m"| PHONE

    PHONE --> CALC[Trilateration<br/>Algorithm]
    CALC --> POS[Estimated Position<br/>1.2m, 1.5m]

    style B1 fill:#16A085,stroke:#2C3E50,stroke-width:2px,color:#fff
    style B2 fill:#16A085,stroke:#2C3E50,stroke-width:2px,color:#fff
    style B3 fill:#16A085,stroke:#2C3E50,stroke-width:2px,color:#fff
    style PHONE fill:#E67E22,stroke:#2C3E50,stroke-width:2px,color:#fff
    style CALC fill:#2C3E50,stroke:#16A085,stroke-width:2px,color:#fff
    style POS fill:#7F8C8D,stroke:#2C3E50,stroke-width:2px,color:#fff

Figure 904.1: Indoor positioning using trilateration with 3 beacons estimating position from RSSI distances.

904.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}")

904.7 Common Development Mistakes

WarningMistake 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
WarningMistake 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.

WarningMistake 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

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

904.9 Inline Knowledge Check

Question 1: How does BLE achieve low power consumption?

BLE achieves ultra-low power through multiple techniques: connection intervals (device sleeps between transmissions), fast connection (6ms vs 6 seconds), simpler modulation (GFSK), fewer channels (40 vs 79), optimized protocol stack, and role separation (Broadcaster, Observer, Peripheral, Central).

Question 2: Your BLE temperature sensor advertises every 1 second. After deployment, you notice smartphone battery drains faster than expected. What’s the optimization?

BLE advertising is for discovery, not data transfer. Continuously scanning for advertisements drains phone battery. The solution is to establish a GATT connection: phone scans briefly, discovers sensor, establishes connection, then receives data via GATT Notify - much more efficient than continuous scanning.

904.10 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

904.11 What’s Next

Continue to Bluetooth Mesh and Advanced Topics for mesh networking, security features, and troubleshooting guidance.