Bluetooth Low Energy (BLE)¶
WiFi (module 1-09) is great when there's an access point and you don't mind the power draw. Bluetooth Low Energy is the other half of the ESP32's radio: no router needed, milliamps instead of tens of milliamps, and a phone in every pocket already knows how to speak it. This module covers the two concepts BLE is built from — GAP and GATT — then builds a BLE peripheral a phone app (nRF Connect, LightBlue) can discover, read, write to, and receive live notifications from.
GAP: how devices find each other¶
GAP (Generic Access Profile) governs discovery and connection, before any data is exchanged. A device plays one of two roles here:
- Peripheral — advertises its presence and accepts connections (your ESP32, in this module).
- Central — scans for advertisements and initiates connections (the phone).
Advertising is a small broadcast packet sent repeatedly (name, a service UUID or two, maybe a manufacturer field) that any scanning central can see without connecting — this is how a phone's Bluetooth settings screen lists devices before you tap one.
GATT: how data is structured¶
Once connected, GATT (Generic Attribute Profile) defines the data model:
| Level | What it is |
|---|---|
| Service | A group of related functionality, identified by a UUID (e.g. "Environmental Sensing") |
| Characteristic | One value inside a service (e.g. "Temperature"), with its own UUID, a value, and properties |
| Properties | What's allowed: READ, WRITE, NOTIFY, INDICATE, ... |
| Descriptor | Metadata attached to a characteristic — most importantly the CCCD (0x2902), which a central writes to subscribe to notifications |
UUIDs are 128-bit (or a handful of Bluetooth SIG-reserved 16-bit ones) — generate your own with any UUID v4 generator; they just need to be unique to your service, not registered anywhere.
Building a BLE peripheral¶
Arduino-ESP32's BLEDevice library wraps the underlying NimBLE/Bluedroid
host stack in a small set of classes that map directly onto the GAP/GATT
concepts above:
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
BLECharacteristic *pCharacteristic;
bool deviceConnected = false;
class MyServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer *pServer) override {
deviceConnected = true;
Serial.println("central connected");
}
void onDisconnect(BLEServer *pServer) override {
deviceConnected = false;
Serial.println("central disconnected — advertising again");
pServer->getAdvertising()->start(); // advertising stops on connect; restart it
}
};
class MyCharacteristicCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *c) override {
std::string value = c->getValue();
if (!value.empty()) {
Serial.printf("central wrote: %s\n", value.c_str());
}
}
};
void setup() {
Serial.begin(115200);
BLEDevice::init("ESP32-Sensor"); // sets the advertised name
BLEServer *pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks());
BLEService *pService = pServer->createService(SERVICE_UUID);
pCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_WRITE |
BLECharacteristic::PROPERTY_NOTIFY
);
pCharacteristic->addDescriptor(new BLE2902()); // CCCD — required for notify()
pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());
pCharacteristic->setValue("0.0");
pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->start();
Serial.println("advertising as ESP32-Sensor");
}
void loop() {
if (deviceConnected) {
static float t = 20.0f;
t += 0.1f;
char buf[16];
snprintf(buf, sizeof(buf), "%.1f", t);
pCharacteristic->setValue(buf);
pCharacteristic->notify(); // pushed only to centrals that subscribed
}
delay(2000);
}
Open nRF Connect (Android/iOS) or LightBlue, scan, connect to
ESP32-Sensor, and the characteristic appears under its UUID — read it,
write a value back (watch it print over serial), and enable notifications
(the app writes the CCCD for you) to watch the number tick every 2 s.
notify() with nobody subscribed does nothing — silently
Calling pCharacteristic->notify() before a central has written its
CCCD is not an error; the stack just drops it. If notifications "aren't
arriving," check the central actually subscribed (in nRF Connect, tap
the download-arrow icon next to the characteristic) before suspecting
the firmware.
Traps worth knowing before you build further¶
- Advertising stops on connection. A peripheral advertises to attract a
connection, then stops — only one central can be connected at a time on
the classic single-connection model.
onDisconnect()must explicitly restart advertising, or the device becomes unreachable after the first phone disconnects (exactly the bug the callback above avoids). - MTU is small by default — 23 bytes total, ~20 usable per characteristic write/notify, until the central negotiates a larger MTU. Don't assume you can shove a JSON blob through in one notification without checking negotiated size or chunking it.
- BLE and WiFi share one radio and one 2.4 GHz antenna. Running both simultaneously (module 2-10's project territory) works, but throughput on each drops under contention — budget for it rather than being surprised.
- The BLE stack costs real heap — tens of KB just to bring the stack up, on top of whatever WiFi already reserved. On memory-constrained builds this is a genuine design constraint, not a rounding error.
How It Actually Works¶
What an "advertisement" is at the radio level: BLE dedicates three fixed
2.4 GHz channels (37, 38, 39 — deliberately chosen to sit between WiFi's
crowded 1/6/11 channels) purely for advertising. pAdvertising->start()
programs the radio's link-layer hardware to transmit a small packet
(≤31 bytes of payload: flags, name, service UUIDs) on all three channels in
sequence, repeated at an advertising interval (tens to hundreds of
milliseconds), entirely without needing any connection state — this is what
lets a phone's scanner see and list your device before tapping it. When a
central sends a CONNECT_REQ in response, both radios switch to a private,
frequency-hopping connection, which is precisely why "advertising stops
on connection": the link-layer hardware can only be in one mode
(advertising-on-3-channels or connected-and-hopping) at a time, and the
library's onDisconnect callback restarting pAdvertising->start() is
telling that same hardware to go back to broadcasting once the connection
tears down.
Why the MTU is 23 bytes by default: the BLE Attribute Protocol (ATT)
that carries GATT reads/writes/notifications rides directly on top of the
Link Layer's fixed-size packets, and the original Bluetooth 4.0 spec fixed
the default ATT MTU at 23 bytes total — 3 bytes of ATT opcode/handle
overhead, leaving 20 usable payload bytes — chosen to fit inside a single
Link Layer PDU without fragmentation, keeping every BLE stack interoperable
even on the cheapest hardware. An MTU negotiation exchange (ATT_MTU_REQ/
RSP) can raise this up to 517 bytes if both sides support it, but code that
assumes the larger size without checking will silently truncate on stacks
that don't — which is exactly the trap the module's warning is pointing at.
Why notify() silently no-ops without a subscriber: BLECharacteristic
tracks its CCCD (0x2902) as an ordinary GATT descriptor value — 2 bits
meaning "notifications off/on" — that lives in the stack's attribute table,
separate from the characteristic's own value. notify() checks that bit
before doing any radio work; it isn't "sending and nobody's listening," it's
"checking a flag and declining to transmit at all" — no packet is even
constructed, hence no error path exists to report. This is also why
subscribing is itself a GATT write the central performs (writing 0x0001
to that descriptor) — from the peripheral's firmware perspective, a central
subscribing and a central writing ordinary characteristic data go through
literally the same ATT write-handling code path in the stack, just targeting
a different attribute handle.
Cheat sheet¶
| Concept | Detail |
|---|---|
| GAP | Discovery/connection layer — roles: peripheral (advertises) / central (scans) |
| GATT | Data layer — services contain characteristics; characteristics have properties |
| Properties | READ, WRITE, NOTIFY, INDICATE — what a central may do |
CCCD (0x2902) |
Descriptor a central writes to subscribe to notify/indicate |
BLEDevice::init(name) |
Sets the advertised device name, starts the BLE stack |
createServer() → createService() → createCharacteristic() |
GATT tree, built top-down |
BLE2902 descriptor |
Required on any NOTIFY/INDICATE characteristic |
pCharacteristic->notify() |
Push value to subscribed centrals only — no-op if none |
onDisconnect() |
Must call pServer->getAdvertising()->start() to be reachable again |
| Default MTU | 23 bytes (~20 usable) unless negotiated up |
Exercise¶
Extend the peripheral above with a second, writable-only characteristic
(PROPERTY_WRITE) that accepts "on"/"off" and drives the onboard LED
from onWrite(), plus a read-only characteristic reporting
ESP.getFreeHeap() as a string, refreshed each time it's read (override
onRead()). Connect with nRF Connect, verify: the LED responds to writes,
the heap characteristic's value changes between reads, and the notify
characteristic keeps ticking. Then disconnect deliberately and confirm the
device reappears in a new scan within a few seconds — proof your
onDisconnect() handler is doing its job.