14 BLE App Development on ESP32
Practical BLE Development with ESP32
networking
wireless
bluetooth
ble
esp32
lab
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 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.
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 windowThe 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.
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
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
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.
Worked Example: From RSSI to Meters
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.
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.
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. 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. 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:
- Indoor Positioning with BLE Beacons — turn the beacons you just built into an indoor GPS with trilateration.
- BLE Field Debugging and Internals — the war stories: what breaks in real deployments and what the stack is really doing underneath.
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.
