BLE server and client
BLE is not a wireless serial port, and expecting one is the whole learning curve. You publish a tree of named values, a phone reads or subscribes to them, and the choice between read and notify decides your battery life.
What a phone actually sees
Server or client
Server — the board holds the values and a phone connects to it. This is almost always what you want: a sensor, a lock, a light.
Client — the board connects to something else, such as a commercial heart rate strap or a Bluetooth thermometer. Same UUIDs, opposite direction, and the code is a scan followed by a connect.
NimBLE, not Bluedroid
The BLE library that ships with the Arduino core is Bluedroid and it is enormous — well over a megabyte of flash. NimBLE-Arduino does the same job in around half of that with a third of the RAM, and the API is close enough to port in an afternoon. On a 4 MB board with OTA enabled, that difference decides whether the sketch fits.
The code
One service, one characteristic, notify enabled. The UUIDs are the names - a phone finds values by UUID, never by position, so they have to be stable.
#include <NimBLEDevice.h>
NimBLECharacteristic *temp;
void setup() {
Serial.begin(115200);
NimBLEDevice::init("Greenhouse");
NimBLEServer *server = NimBLEDevice::createServer();
NimBLEService *svc = server->createService("181A"); // Environmental
temp = svc->createCharacteristic(
"2A6E", // Temperature
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
svc->start();
NimBLEAdvertising *adv = NimBLEDevice::getAdvertising();
adv->addServiceUUID("181A");
adv->start();
}
void loop() {
int16_t hundredths = 2140; // 21.40 C, as the spec defines it
temp->setValue((uint8_t *)&hundredths, 2);
temp->notify();
delay(1000);
}Use the standard 16-bit UUIDs where one exists, such as 0x2A6E for temperature. Generic apps and health platforms already know how to read those.
The bluetooth module is low level on purpose. This is the minimum server - register a service, advertise, and notify when the value changes.
import bluetooth, struct, time
_ENV = bluetooth.UUID(0x181A)
_TEMP = (bluetooth.UUID(0x2A6E), bluetooth.FLAG_READ | bluetooth.FLAG_NOTIFY)
ble = bluetooth.BLE()
ble.active(True)
((handle,),) = ble.gatts_register_services(((_ENV, (_TEMP,)),))
payload = b'\x02\x01\x06' + bytes([len('Greenhouse') + 1, 0x09]) + b'Greenhouse'
ble.gap_advertise(100_000, adv_data=payload)
while True:
ble.gatts_write(handle, struct.pack('<h', 2140)) # 21.40 C
ble.gatts_notify(0, handle)
time.sleep(1)aioble is the friendlier wrapper and it is worth using for anything real. This version is here because it shows what aioble is doing.
When it does not work
You advertised without including the service UUID, so generic apps do not know to look. Add it to the advertising payload, not only to the GATT table.
The phone dropped the connection to save power, or your connection interval is too long. Reconnect on disconnect - a BLE server should always restart advertising when a client goes.
That is the default MTU. Request a larger one after connecting, or split the value yourself. There is no automatic fragmentation for a characteristic.
The Bluedroid stack is large. NimBLE does the same job in roughly half the flash and a third of the RAM, and it is a drop-in for most projects.
A service exists. Putting a number in it a phone can read — and then the one property that stops the phone having to ask.
A value a phone can read →Edit this page — content/esp32/ble-server-and-client.mdx
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.