Stream Sensor Data to the Phone with BLE Notifications
Learning Objectives
By the end of this lesson you can:
- Explain why a notification is different from a read, and when to use each.
- Describe the CCCD (UUID
0x2902) and how the phone uses it to switch notifications on. - Set the correct characteristic properties for a notifying data characteristic.
- Read a sensor value in
loop()and push it to a subscribed phone without blocking the BLE stack. - Choose a sensible update interval and payload size for your gadget.
Quick Review
Last lesson you designed the contract: the phone (central) finds the ESP32 (peripheral) through advertising, connects, and walks a GATT tree with one custom service holding a data characteristic and a control characteristic. Each characteristic has a UUID, a set of properties (what is allowed), and a value (the bytes).
So far the phone could only pull that value with a read. In this lesson we make the ESP32 push it instead.
Theory
Read versus notify
A read is a question the phone asks. A notification is the ESP32 speaking first, with no question. That matters because a phone does not poll in the background reliably: apps get suspended, and polling drains both batteries. Notifications let the ESP32 say "here is a new reading" the moment it has one.
| Read | Notify | |
|---|---|---|
| Who starts it | Phone | ESP32 |
| Needs subscription | No | Yes, via CCCD |
| Acknowledged | Yes (the response is the data) | No, fire and forget |
| Typical use | Config, calibration, one-off values | Streaming sensor data |
If you need delivery confirmation, BLE also offers indications, which wait for an acknowledgement from the phone. They are slower, so we use notifications for streaming.
The CCCD is the on/off switch
A phone will not accept notifications it never asked for. The permission lives in a small descriptor attached to your characteristic: the Client Characteristic Configuration Descriptor (CCCD), UUID 0x2902, holding 2 bytes.
When the phone writes {{c1::0x0001}} to the CCCD, notifications are enabled, and {{c2::0x0000}} turns them off again.
The CCCD descriptor has the UUID {{c3::0x2902}}.
A notification is {{c4::unacknowledged}} (fire and forget), while an indication waits for a confirmation.
The default ATT MTU of 23 bytes leaves {{c5::20}} bytes of usable payload per notification.
With the ESP32 Arduino BLE library you add this descriptor yourself with new BLE2902(). Without it, the phone sees no switch to flip, and notify() has nowhere to go. The NimBLE library creates the descriptor automatically, which is one reason some people switch to it.
Properties matter too
The characteristic still needs the correct property bits, or the phone will refuse to subscribe:
| Property | Bit |
|---|---|
| Read | 0x02 |
| Notify | 0x10 |
| Indicate | 0x20 |
| Write | 0x08 |
Read plus notify is 0x12. In code you rarely type the byte, you OR the constants: PROPERTY_READ | PROPERTY_NOTIFY.
The streaming loop
flowchart LR
Sensor["π‘οΈ<br/>Sensor read"]
Value["π¦<br/>Characteristic value"]
Check["π<br/>Client subscribed?"]
Phone["π±<br/>Phone app"]
Drop["π«<br/>Skip this cycle"]
Sensor --> Value --> Check
Check -->|Yes| Phone
Check -->|No| Drop
Three rules make this loop behave:
- Never call
delay()to set the sample rate.delay()freezes everything, including the BLE stack that must answer the phone. Usemillis()and a timestamp instead. - Check that a client is connected before you build and send data.
- Set the value, then notify.
setValue()fills the characteristic;notify()transmits it.
Worked Examples
Example 1: Trace one notification by hand
millis()reaches the 200 ms mark, so the loop decides it is time to sample.readTemperature()returns23.5.setValue()copies the 4 bytes of that float into the characteristic value.notify()checks whether any connected client has written0x0001to the CCCD.- If yes, the ESP32 sends one ATT notification holding those 4 bytes.
- The phone's
onCharacteristicChangedcallback fires, and the app decodes 4 bytes back into a float.
Stop at step 4 and notice the failure case: on a fresh boot with no phone, the ESP32 still burns cycles building values nobody receives. That is why the connection check comes first.
Example 2: Property byte math
You want a characteristic the phone can read once (to show the current value on connect) and also subscribe to.
- Read =
0x02 - Notify =
0x10 0x02 | 0x10 = 0x12
Example 3: Sizing the stream
Your gadget samples a temperature every 200 ms and sends a 4-byte float.
- Notifications per second: $1000 / 200 = 5$
- Payload per second: $5 \times 4 = 20$ bytes
That is trivial for BLE. BLE's default maximum payload per packet is 20 bytes, so even a 5 Hz stream of a small struct fits with room to spare. If you needed 100 bytes per reading, you would negotiate a larger MTU or reconsider the design.
Example 4: The actual code
// setup(): Read + Notify, and the CCCD the phone writes to
BLECharacteristic *dataChar = dataService->createCharacteristic(
DATA_CHAR_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
dataChar->addDescriptor(new BLE2902());
// loop(): non-blocking sampling
unsigned long lastSend = 0;
const unsigned long SEND_EVERY_MS = 200;
void loop() {
if (deviceConnected && millis() - lastSend >= SEND_EVERY_MS) {
lastSend = millis();
float temperature = readTemperature();
dataChar->setValue((uint8_t *)&temperature, sizeof(temperature));
dataChar->notify();
}
}
The notify() return value is a boolean: false usually means no client is subscribed, so log it while you are developing.
Discussion & Reflection
- Your phone stops showing values after the screen locks. Is that a bug in your firmware, or an app-side subscription problem? How would you tell the two apart from the serial log?
- What is the slowest update rate your gadget could get away with and still feel live to a user?
- If you sent a temperature as text (
"23.5") instead of a float, what gets simpler and what gets bigger?
Expert Corner
Common mistakes and rules of thumb:
- Notifying before setting a value sends the previous reading. Always
setValue()first. - Byte order is your contract. A raw float is little-endian on the ESP32; a phone decoding big-endian shows nonsense. Either document the format, scale to an integer (temperature times 100 as
int16_t), or send text for readability while debugging. - Never do sensor work inside a BLE callback. Callbacks run on the BLE task; a slow read there can drop the connection. Copy the incoming command to a variable and act on it in
loop(). - Do not notify faster than the connection interval. A phone typically uses 30 to 50 ms. Sampling at 1 ms and notifying every time just queues data you cannot deliver.
- The first notification after subscribing is often lost on Android. Send one immediate update when the client subscribes, or accept that the phone may need a moment.
- Keep notifications small. Default MTU gives you 20 bytes; anything larger needs negotiation and a phone that supports it.
Summary & Next Steps
You now know the push half of the gadget: a data characteristic with Read and Notify, a CCCD at 0x2902 that the phone writes to subscribe, a millis()-driven loop that reads a sensor and calls setValue() then notify(), and a payload small enough to fit in one packet.
Next lesson flips the arrow: the phone writes to the control characteristic, and your firmware learns to receive commands, validate them, and change what the gadget is doing.
[
{ "front": "How does a notification differ from a read?", "back": "The ESP32 pushes the value on its own; the phone does not have to ask each time." },
{ "front": "Which descriptor lets the phone switch notifications on?", "back": "The Client Characteristic Configuration Descriptor (CCCD), UUID 0x2902." },
{ "front": "What 2-byte value does the phone write to the CCCD to enable notifications?", "back": "0x0001. The value 0x0000 disables them." },
{ "front": "Which property bit must be set for notifications?", "back": "Notify, bit value 0x10." },
{ "front": "Read (0x02) plus Notify (0x10) equals which property byte?", "back": "0x12." },
{ "front": "Default ATT MTU and usable notification payload?", "back": "23 bytes MTU, about 20 bytes of usable payload after 3 bytes of overhead." },
{ "front": "Why use millis() instead of delay() in the streaming loop?", "back": "delay() blocks the BLE stack, so connection events and incoming writes get missed." },
{ "front": "What must you check before calling notify()?", "back": "That a client is connected and has subscribed to the characteristic." },
{ "front": "What is the difference between setValue() and notify()?", "back": "setValue() fills the characteristic value; notify() transmits it to the subscribed client." },
{ "front": "Notification versus indication?", "back": "A notification is unacknowledged; an indication waits for a confirmation from the phone." },
{ "front": "Why avoid notifying faster than the connection interval?", "back": "The phone typically uses 30 to 50 ms; faster sends just queue undeliverable data." },
{ "front": "What does a false return from notify() usually mean?", "back": "No client is subscribed, so nothing was sent." },
{ "front": "How should a float sensor value be encoded for the phone?", "back": "Both ends must agree on the format: raw little-endian bytes, a scaled integer, or text." },
{ "front": "Where should the sensor read happen, in a BLE callback or in loop()?", "back": "In loop() with a millis() timer, so callbacks stay short and the connection stays healthy." }
]