Phase 4: Connectivity 14 min read

BLE on ESP32 — GATT Server, Characteristics, and Notifications

Implement Bluetooth Low Energy on ESP32 as a GATT server. Create services and characteristics, send notifications to connected clients, and receive commands from iOS and Android.

Updated June 18, 2026

BLE vs Bluetooth Classic

Bluetooth Low Energy (BLE) is not a lower-speed version of Bluetooth Classic — it is a completely different protocol stack optimised for sending small bursts of data infrequently. A BLE temperature sensor can run on a coin cell for months. The tradeoff: BLE throughput is limited to ~27 Kbps effective (BLE 4.2) vs ~300 Kbps for Classic SPP. BLE is also the only option for iOS connectivity.

GATT Concepts

  • Server — the ESP32; holds the data (services and characteristics)
  • Client — the phone; connects to read/write characteristics and subscribe to notifications
  • Service UUID — identifies a group of related data (e.g. 4fafc201-1fb5-459e-8fcc-c5c9c3319000)
  • Characteristic UUID — identifies one value within a service (e.g. beb5483e-36e1-4688-b7f5-ea07361b26a8)

Temperature Notification Server

ble_temperature.ino
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#include <BLE2902.h>

#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c3319000"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

BLECharacteristic* pChar;
bool deviceConnected = false;

class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer* pServer)    { deviceConnected = true;  Serial.println("BLE: client connected"); }
  void onDisconnect(BLEServer* pServer) {
    deviceConnected = false;
    Serial.println("BLE: client disconnected — restarting advertising");
    pServer->getAdvertising()->start();
  }
};

void setup() {
  Serial.begin(115200);

  BLEDevice::init("ESP32-Temp");
  BLEServer* pServer = BLEDevice::createServer();
  pServer->setCallbacks(new ServerCallbacks());

  BLEService* pService = pServer->createService(SERVICE_UUID);

  pChar = pService->createCharacteristic(
    CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
  );
  pChar->addDescriptor(new BLE2902());   // enables notify subscription

  pService->start();

  BLEAdvertising* pAdv = BLEDevice::getAdvertising();
  pAdv->addServiceUUID(SERVICE_UUID);
  pAdv->setScanResponse(true);
  BLEDevice::startAdvertising();
  Serial.println("BLE advertising as 'ESP32-Temp'");
}

void loop() {
  if (deviceConnected) {
    float temp = 22.5 + (random(-5, 5) / 10.0);   // replace with real sensor
    char buf[8];
    dtostrf(temp, 4, 1, buf);
    pChar->setValue(buf);
    pChar->notify();
    Serial.printf("Notified: %s°C\n", buf);
  }
  delay(2000);
}

Receiving Write Commands from a Phone

ble_write.ino
#define CMD_UUID "a1234567-0000-1000-8000-00805f9b34fb"

const int LED = 2;

class CmdCallbacks : public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic* pC) {
    String val = pC->getValue().c_str();
    val.trim();
    if (val == "ON")  { digitalWrite(LED, HIGH); Serial.println("LED ON"); }
    if (val == "OFF") { digitalWrite(LED, LOW);  Serial.println("LED OFF"); }
  }
};

// In setup(), after createService():
BLECharacteristic* pCmd = pService->createCharacteristic(
  CMD_UUID,
  BLECharacteristic::PROPERTY_WRITE
);
pCmd->setCallbacks(new CmdCallbacks());

Read-Only Characteristic

ble_read.ino
#define UPTIME_UUID "b2345678-0001-1000-8000-00805f9b34fb"

BLECharacteristic* pUptime = pService->createCharacteristic(
  UPTIME_UUID,
  BLECharacteristic::PROPERTY_READ
);

// Update before each read (use a callback to stay current):
class UptimeCallbacks : public BLECharacteristicCallbacks {
  void onRead(BLECharacteristic* pC) {
    unsigned long s = millis() / 1000;
    char buf[16];
    snprintf(buf, sizeof(buf), "%lus", s);
    pC->setValue(buf);
  }
};
pUptime->setCallbacks(new UptimeCallbacks());

Using nRF Connect to Test

  1. Install nRF Connect for Mobile (Android/iOS) — free from Nordic Semiconductor
  2. Open the app → Scan → find “ESP32-Temp”
  3. Connect → expand the Unknown Service
  4. Tap the notification icon (three arrows down) to subscribe to notifications
  5. Watch temperature values arrive every 2 seconds
  6. Tap the write icon on the command characteristic → type “ON” → send

NimBLE for Lower Memory Usage

The default Bluedroid BLE stack uses ~100 KB of heap. For projects that also use Wi-Fi, switch to NimBLE-Arduino which uses ~50 KB:

nimble_switch.ino
// Replace all BLEDevice.h imports with:
#include <NimBLEDevice.h>

// API is nearly identical — NimBLEServer, NimBLEService, NimBLECharacteristic
// No BLE2902 descriptor needed — NimBLE handles notifications automatically
// Connections: up to 9 simultaneous clients (vs 3 with Bluedroid)

Frequently Asked Questions

A service groups related data (e.g. "Heart Rate Service"). A characteristic is one data point within a service (e.g. "Heart Rate Measurement") — it has a UUID, a value, and properties (read/write/notify). A descriptor adds metadata to a characteristic (e.g. human-readable name, units, notify enable flag).
For standard profiles use official Bluetooth SIG UUIDs (16-bit, e.g. 0x180D for Heart Rate). For custom services use any valid 128-bit UUID (e.g. generated at uuidgenerator.net). Avoid reusing standard UUIDs for non-standard data.
Set the characteristic property to BLECharacteristic::PROPERTY_NOTIFY, add a BLE2902 descriptor, then call characteristic->setValue(data) and characteristic->notify(). The phone receives the update without polling.
The default ATT MTU is 23 bytes (20 bytes of payload after 3 bytes overhead). After MTU negotiation, this can increase to 517 bytes (512 bytes payload) for BLE 4.2+ on ESP32. For larger data, split across multiple characteristic writes.
The default BLE stack allows one central (client) connection at a time. The ESP32 can technically support up to 3–4 simultaneous BLE connections but this requires lower-level SDK configuration and significantly increases RAM usage.
Advertising is the periodic broadcast of the ESP32's identity (device name, service UUIDs) so nearby phones can discover it. It happens before a connection is established. Once connected, advertising typically stops. Restart it after disconnection with pServer->getAdvertising()->start().
Set the characteristic property to PROPERTY_WRITE, attach a callback class that extends BLECharacteristicCallbacks, and override the onWrite() method. Inside it call pCharacteristic->getValue() to get the written bytes.
Yes, significantly. NimBLE-Arduino uses approximately 50% less heap and flash compared to the default Bluedroid stack. For RAM-constrained applications, switch to NimBLE by installing the library and changing include headers accordingly.
Yes. Both share the 2.4 GHz radio via time-division co-existence. You will see slight throughput reduction on both interfaces. Call esp_coex_preference_set(ESP_COEX_PREFER_BALANCE) to balance performance between them.
Use nRF Connect (iOS/Android) to discover services, read/write characteristics, and enable notifications. Use LightBlue (iOS/Android) for a friendlier UI. Both apps are free and invaluable for BLE debugging.

Projects to Build

Put this knowledge to work — try one of these hands-on projects.