14  BLE App Development on ESP32

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

14.1 Start With the Story

An ESP32 demo becomes an IoT lesson when the phone can explain what it read and why it trusted it. Advertising, services, characteristics, permissions, notifications, and code structure all shape that user-visible result.

Use this chapter as a build story. Start with the smallest sensor value, make it visible over BLE, then add only the checks needed to prove the firmware, app, and test record match the intended product behavior.

14.2 Your Phone Reads the ESP32

By the end of this chapter, your phone can read live sensor data from an ESP32 that you programmed. The ESP32 is a small board with Bluetooth built in. You will make it announce itself, give it a temperature value to share, and then watch that value move through the same kind of Bluetooth path used by wearables, room sensors, tags, and simple smart-home devices.

The Bluetooth we use here is Bluetooth Low Energy, usually shortened to BLE. “Low Energy” means the radio is designed to wake up, send a small amount of information, and go back to sleep instead of staying busy all the time. That is why BLE is a good fit for battery-powered sensors that only need to send a few bytes every few seconds.

The story starts with the simplest question: can we even see nearby Bluetooth devices? Once we can see them, we will make our own ESP32 visible, give the phone a value to read, and set up the beacons that the next chapter turns into an indoor positioning system. Each build step ends with something observable, because a BLE design is only useful when you can prove what happened.

14.3 The BLE Idea

BLE connection sequence between a central smartphone and a peripheral sensor: advertising, scan request and response, CONNECT_IND, then GATT operations on data channels
The whole chapter in one picture: the peripheral advertises, the central scans and connects, and every lab that follows lives somewhere on this timeline.

BLE can look complicated because the names are precise, but the idea is familiar. One device speaks up so others can find it, and another device listens, connects, and reads or writes small pieces of data.

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.

Phoebe the physics guide

Phoebe’s Why

The n in this chapter’s distance formula is not a fitting knob picked to make the numbers work; it starts as a physical constant and only becomes a knob once walls enter the picture. An isotropic source spreads its power over a sphere of area \(4\pi d^2\), so power density falls as \(1/d^2\) – that inverse-square spreading is where the free-space exponent \(n=2\) comes from, the same Friis relationship behind every path-loss formula in this course. Real rooms are not free space: reflections, absorption, and diffraction around furniture and bodies remove additional energy as distance grows, faster than the clean sphere would predict. Engineers absorb that extra loss into the same exponent, which is exactly why this chapter quotes \(n\approx2.0\) for free space but \(2.5\) to \(4.0\) indoors – the formula did not change, the environment being modeled did.

The Derivation

Friis, free-space power density falling as inverse-square distance gives the free-space exponent directly:

\[P_r \propto \frac{1}{d^2} \;\Rightarrow\; \mathrm{FSPL}(\mathrm{dB}) = 10 \times 2 \times \log_{10}(d) + \text{const}\]

Generalizing the fixed exponent \(2\) to a measured environment exponent \(n\) gives the log-distance model this chapter uses:

\[RSSI(d) = TxPower - 10n\log_{10}(d)\]

Inverted for distance – this chapter’s own formula:

\[d = 10^{\frac{TxPower - RSSI}{10n}}\]

A design’s fade margin is the gap between the RSSI a zone boundary requires and the RSSI the model predicts at the target range, which must exceed the expected shadowing swing to keep the classification stable:

\[FM = RSSI_{predicted}(d) - RSSI_{threshold}\]

Worked Numbers: This Chapter’s Own -59 dBm / n=2.5 Beacon

  • Recomputing this chapter’s own worked example: \(TxPower=-59\) dBm, \(n=2.5\), \(RSSI=-74\) dBm gives \(d=10^{(-59-(-74))/25}=10^{0.600}=3.98\) m – matches the chapter exactly. \(RSSI=-84\) dBm gives \(d=10^{1.00}=10.0\) m – also matches.
  • Same \(-74\) dBm reading, different assumed environment: free space (\(n=2.0\)) reads it as \(d=10^{15/20}=5.62\) m, \(41.3\%\) farther than the chapter’s own indoor \(n=2.5\) estimate of \(3.98\) m, before any RSSI noise at all – the exponent choice alone moves the answer more than a typical RSSI wobble does.
  • Fade margin at this chapter’s own NEAR/FAR boundary (\(-70\) dBm): at \(n=2.5\), the model predicts \(RSSI(3\text{ m})=-59-25\log_{10}(3)=-70.9\) dBm – already past the \(-70\) dBm boundary, so a beacon sitting at \(3\) m is on the edge of “FAR” with essentially zero fade margin.
  • Honest finding: the chapter’s own \(\pm8\) dBm body-blockage swing applied there gives \(-62.9\) dBm to \(-78.9\) dBm – a beacon physically fixed at \(3\) m can report anywhere from solidly “NEAR” to deep “FAR” depending on who is standing where. That is a fade-margin failure, not a formula error, and it is the physical reason this chapter recommends zone labels over exact metres.

14.3.1 Roles: who talks first

The device that advertises is the peripheral. In our build, the ESP32 becomes a peripheral when it behaves like a temperature sensor or an iBeacon, and in connected GATT examples it is also the server. The device that scans or connects is the central. A phone is usually the central because it has the screen, the app, and the power budget to search for nearby devices, and in connected GATT examples it usually behaves as the client.

The scanner lab reverses the view on purpose. First, the ESP32 acts as a central so you can see the radio world around you. Then, after the scan output makes advertising concrete, the ESP32 becomes the thing your phone can discover.

BLE idea Everyday picture In this chapter
Peripheral A small device putting up a sign ESP32 temperature sensor or beacon
Central A phone looking for signs and opening a connection Phone app or scanner
Advertising A short broadcast that says “I am here” Scanner results and iBeacon packets
GATT The organized data shelf inside a connected device Temperature service and characteristic

14.3.2 GATT: folders, files, and values

GATT is BLE’s way to organize connected data. Think of a service as a folder and a characteristic as a file in that folder. A temperature sensor might have an Environmental Sensing service, and inside it a Temperature characteristic. The value in that characteristic is the live reading.

The properties on a characteristic say what a central may do. READ means the phone can ask for the current value. NOTIFY means the ESP32 can push an update after the phone opts in. That opt-in matters: BLE notifications are not automatic just because the characteristic advertises the NOTIFY property.

14.3.3 What this chapter promises

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

  • implement BLE scanning and advertising on ESP32 with the Arduino BLE library
  • configure GATT services and characteristics with the right properties and descriptors
  • build iBeacon transmitters with correct manufacturer data and calibrated signal power (the trilateration build continues in Indoor Positioning with BLE Beacons)
  • diagnose the classic failures: MTU mismatches, missing CCCD descriptors, and connection-parameter surprises
  • estimate BLE range with the path-loss exponent formula

With the mental model in place, the next section turns it into code. We start by listening before we transmit, because the fastest way to understand BLE advertising is to watch real advertisements arrive.

14.4 Build the BLE Sensor, Step by Step

This section is one continuous build. Each step follows the previous one: first you learn to see advertisements, then you create data for a phone to read, then you broadcast calibrated beacon frames, and finally you combine several distance estimates into a position.

14.4.1 Step 1: Build a BLE beacon scanner

Advertising made BLE discoverable in the mental model. The first build step is therefore a scanner: the ESP32 is configured as a BLE Central device, listens for nearby advertisements, parses BLE advertisement data, and extracts the device name, address, RSSI, and a rough proximity zone. This nearby-device scanning is signal-strength proximity analysis in miniature, and it lets you see that BLE is not magic; it is a stream of short radio messages with measurable signal strength.

How to Use the Simulator

Open the ESP32 starter at https://wokwi.com/projects/new/esp32 if the embedded Wokwi simulator is slow to load. In the simulator below, click inside the code editor, replace the default code with the BLE scanner code, click the green Play button to compile and run, and open the Serial Monitor with the terminal icon to see scan results.

Here is the complete scanner. The important path is BLEDevice::init(), then BLEDevice::getScan(), then a callback that runs for each BLEAdvertisedDevice.

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

The setup begins by naming the scanner and creating the scan object.

BLEDevice::init("ESP32-Scanner");
pBLEScan = BLEDevice::getScan();

Active scanning asks advertisers for scan response data, while the interval and window decide how often and how long the ESP32 listens.

pBLEScan->setActiveScan(true);   // Request scan response data
pBLEScan->setInterval(100);       // Time between scan windows
pBLEScan->setWindow(99);          // Duration of each listening window

The scanner converts RSSI into coarse zones. Those names are deliberately rough: they are useful for “near enough” logic, not tape-measure distance.

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 such as walls, furniture, and people; by device orientation and antenna design; by interference from other 2.4 GHz devices; and by environmental factors such as humidity and 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.

Checkpoint: you should now see scan results in the Serial Monitor. Each line should include a device name if one was advertised, an address, an RSSI value in dBm, and a proximity label such as NEAR or FAR.

14.4.2 Step 2: Give the phone something to read

GATT hierarchy for an environmental monitor: services for sensing, device info, battery, configuration, and firmware, each grouping related characteristics
A GATT server is a filing system: each service groups related characteristics. Our temperature build fills exactly one drawer of this cabinet.

The scanner made advertisements visible. Now the phone needs something useful behind the advertisement: a GATT service with a characteristic. This step turns the ESP32 into a peripheral named ESP32-TempSensor, creates the standard Environmental Sensing service 0x181A, and exposes the standard Temperature characteristic 0x2A6E.

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

The service UUID 181A tells generic BLE tools that this is Environmental Sensing data. The characteristic UUID 2A6E says the value is temperature. Standard services improve interoperability with generic tools and gateways, while vendor-specific UUIDs still work for private services when you document them clearly; the Bluetooth SIG database matters for shared meaning, not for whether packets can move. PROPERTY_READ supports polling, while PROPERTY_NOTIFY supports real-time updates after the client enables notifications. The BLE2902 descriptor is the Client Characteristic Configuration Descriptor, or CCCD, that gives the client a place to write that opt-in. The temperature is packed as an int16_t in 0.01 C units, so 22.50 C travels as 2250.

Checkpoint: you should now be able to discover ESP32-TempSensor, find service 0x181A, read characteristic 0x2A6E, and receive changing notifications once the client writes the CCCD.

14.4.3 Step 3: Turn advertisements into beacons

iBeacon payload layout: Apple company ID 0x004C, type 0x02, length 0x15, 16-byte UUID, Major and Minor, TX power byte
The 30-byte iBeacon advertisement: UUID answers which deployment, Major/Minor answer where exactly, and TX Power calibrates distance estimates.

Once the phone can read connected data, the next useful pattern is a device that does not need a connection at all. An iBeacon is a small advertisement that carries identity and calibration fields. It is useful when the phone only needs to know “which beacon is nearby?” or “about how far away is it?”

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

The manufacturer ID 0x4C00 marks Apple’s company ID. The UUID identifies the beacon group, major can represent a store or building, and minor can represent a specific beacon. setSignalPower(-59) stores the calibrated RSSI at 1 meter, which receivers use as the reference point for distance estimation.

The distance estimate comes from the path loss formula:

\[d = 10^{\frac{TxPower - RSSI}{10 \times n}}\]

TxPower is the RSSI measured at 1 meter, typically around -59 to -65 dBm for iBeacon work. n is the path loss exponent: about 2.0 in free space and often 2.5 to 4.0 indoors. RSSI is the measured signal strength at the receiver.

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

Curious how those numbers behave in a real room? The worked example below runs two actual measurements through the formula — open it to see why exact distances wobble and why the zone labels are the trustworthy part. It is optional: the build works without 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.

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.

Checkpoint: you should now have an ESP32 that broadcasts an iBeacon frame. A scanner should be able to see the UUID, major value, minor value, and calibrated signal power, then estimate distance from RSSI with visible uncertainty.

14.4.4 Step 4: Make the build your own

After the scanner, temperature service, and beacon all work, the next useful move is practice. These challenges keep the same code path but ask you to change one design decision at a time.

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. 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. Check manufacturer data for Apple’s company ID (0x004C).

Checkpoint: you should now be able to point to the exact line you changed, describe the BLE behavior it affects, and predict what should appear differently in the Serial Monitor or phone scanner.

14.4.5 Build Checks

Quick self-tests for what you just built. The multiple-choice questions check the essentials; the matching, sequencing, labeling, and code checks after them push the same ideas further — each one earns XP.

Check: GATT Notifications

Check: iBeacon Configuration

The build now works as a sequence — and the failures that make BLE feel unreliable in the field have a chapter of their own: BLE Field Debugging and Internals.

Two paths deeper

The build works — and two bigger stories start exactly here:

14.5 Production Notes and What’s Next

This chapter gave you three working BLE implementation patterns: a scanner for discovering devices and estimating proximity from RSSI, a GATT server for a temperature service with notifications, and an iBeacon transmitter with calibrated signal power. The story continues in two directions: Indoor Positioning with BLE Beacons turns those beacons into a position estimate, and BLE Field Debugging and Internals covers the failure modes around CCCD notifications, connection intervals, and MTU negotiation.

14.5.1 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

Bluetooth implementation quality depends on the event lifecycle: advertise, connect, discover services, exchange data, handle errors, and recover after disconnects. Treat each state as testable behavior. Before you call the build production-ready, expand the review checks below — they test exactly these lifecycle states.