ESP32 as BLE Server (Peripheral)

Create a BLE peripheral with the ESP32 that exposes sensor data to phones and other devices

Bluetooth Low Energy (BLE) lets your ESP32 communicate with phones, tablets, and other microcontrollers without WiFi or an internet connection. In this tutorial, you will turn your ESP32 into a BLE server (also called a peripheral) that reads temperature data from a BME280 sensor and makes it available to any BLE client that connects -- including your phone.

Unlike classic Bluetooth, BLE is designed for low-power, intermittent data transfers. It is ideal for sensor nodes, wearables, and any project where you want to share small amounts of data wirelessly without the overhead of a full WiFi connection.

🔗What You'll Need

ComponentQtyNotesBuy
ESP32 dev board1AliExpress | Amazon.de .co.uk .com
BME280 sensor module1AliExpress | Amazon.de .co.uk .com
Breadboard1AliExpress | Amazon.de .co.uk .com
Jumper wires~4Male-to-maleAliExpress | Amazon.de .co.uk .com

Links marked Amazon/AliExpress are affiliate links. We may earn a small commission at no extra cost to you.

Software: Install the free nRF Connect app (iOS / Android) on your phone. You will use it to discover and interact with your BLE server.

🔗How a BLE Server Works

In BLE, communication follows a clear hierarchy:

  1. The server (your ESP32) advertises its presence and hosts data organized into services and characteristics.
  2. A client (your phone or another ESP32) scans for nearby devices, connects to the server, and reads or subscribes to characteristics.

Think of it like a menu at a restaurant: the server publishes a menu (services and characteristics), and the client picks what it wants to read.

sequenceDiagram
    participant Server as ESP32 (Server)
    participant Client as Phone (Client)
    Server->>Client: Advertise "I'm here, I have data"
    Client->>Server: Connect
    Client->>Server: Discover services
    Server-->>Client: Service: Environmental Sensing
    Client->>Server: Read temperature characteristic
    Server-->>Client: 23.5 °C
    Server-->>Client: Notify: 23.7 °C (push update)

🔗Key BLE Terminology

TermMeaning
Server (Peripheral)The device that holds data and waits for connections
Client (Central)The device that scans, connects, and reads data
ServiceA group of related data (e.g., "Environmental Sensing")
CharacteristicA single data point within a service (e.g., "Temperature")
UUIDA unique identifier for each service and characteristic
DescriptorMetadata about a characteristic (e.g., enable/disable notifications)
AdvertisingBroadcasting a signal so clients can discover the server

🔗Wiring

Connect the BME280 to the ESP32 using I2C. This is the same wiring used in the BME280 sensor guide.

BME280 PinESP32 PinNotes
VIN / VCC3.3VUse 3.3V (some modules accept 5V via onboard regulator)
GNDGND
SDAGPIO 21Default I2C data line
SCLGPIO 22Default I2C clock line

Note: Pin labels and GPIO numbers vary between ESP32 boards. GPIOs 21 (SDA) and 22 (SCL) are the defaults on most ESP32-WROOM-32 DevKit boards. Check your board's pinout diagram if you are using a different board.

🔗Required Libraries

The BLE libraries (BLEDevice.h, BLEServer.h, BLE2902.h) are built into the ESP32 Arduino core -- no extra installation needed. For the BME280, install through the Arduino Library Manager:

  1. Adafruit BME280 Library by Adafruit
  2. Adafruit Unified Sensor by Adafruit (installed as a dependency)

🔗Code Example: Basic BLE Server

This sketch creates a BLE server that reads the BME280 sensor and exposes the temperature as a readable characteristic. Any BLE client can connect and read the current value.

View complete sketch
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

// Custom UUIDs (generated with an online UUID generator)
#define SERVICE_UUID        "e3223119-9445-4e96-a4a1-85358c4046a2"
#define TEMPERATURE_CHAR_UUID "e3223120-9445-4e96-a4a1-85358c4046a2"

Adafruit_BME280 bme;

BLEServer* pServer = nullptr;
BLECharacteristic* pTemperatureChar = nullptr;
bool deviceConnected = false;
bool oldDeviceConnected = false;

unsigned long lastUpdateTime = 0;
const unsigned long updateInterval = 2000;  // Update every 2 seconds

// Callback to track connection state
class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer* pServer) {
    deviceConnected = true;
    Serial.println("Client connected.");
  }

  void onDisconnect(BLEServer* pServer) {
    deviceConnected = false;
    Serial.println("Client disconnected.");
  }
};

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

  // Initialize BME280
  if (!bme.begin(0x76)) {
    Serial.println("Error: Could not find BME280 sensor.");
    Serial.println("Check wiring or try address 0x77.");
    while (1) delay(10);
  }
  Serial.println("BME280 sensor found.");

  // Initialize BLE
  BLEDevice::init("ESP32-BLE-Server");

  // Create the BLE server
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new ServerCallbacks());

  // Create a BLE service
  BLEService* pService = pServer->createService(SERVICE_UUID);

  // Create the temperature characteristic (read + notify)
  pTemperatureChar = pService->createCharacteristic(
    TEMPERATURE_CHAR_UUID,
    BLECharacteristic::PROPERTY_READ |
    BLECharacteristic::PROPERTY_NOTIFY
  );

  // Add a BLE2902 descriptor (required for notifications)
  pTemperatureChar->addDescriptor(new BLE2902());

  // Start the service
  pService->start();

  // Start advertising
  BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);  // Helps with iPhone connection issues
  BLEDevice::startAdvertising();

  Serial.println("BLE server started. Waiting for connections...");
}

void loop() {
  unsigned long now = millis();

  if (now - lastUpdateTime >= updateInterval) {
    lastUpdateTime = now;

    float temperature = bme.readTemperature();

    // Update the characteristic value
    char tempStr[8];
    snprintf(tempStr, sizeof(tempStr), "%.1f", temperature);
    pTemperatureChar->setValue(tempStr);

    if (deviceConnected) {
      // Send a notification to the connected client
      pTemperatureChar->notify();
      Serial.printf("Notified client: %s °C\n", tempStr);
    } else {
      Serial.printf("Temperature: %s °C (no client connected)\n", tempStr);
    }
  }

  // Handle reconnection: restart advertising after a client disconnects
  if (!deviceConnected && oldDeviceConnected) {
    delay(500);  // Give the BLE stack time to clean up
    pServer->getAdvertising()->start();
    Serial.println("Restarted advertising.");
    oldDeviceConnected = false;
  }

  if (deviceConnected && !oldDeviceConnected) {
    oldDeviceConnected = true;
  }
}

🔗Code Walkthrough

BLE initialization -- BLEDevice::init("ESP32-BLE-Server") initializes the BLE stack and sets the device name that appears during scanning.

Server and callbacks -- The ServerCallbacks class tracks when clients connect and disconnect. This is important for knowing when to send notifications and when to restart advertising.

Service and characteristic -- A service groups related data under a single UUID. Inside it, the temperature characteristic holds the actual sensor value. The PROPERTY_READ flag lets clients read the value on demand, and PROPERTY_NOTIFY lets the server push updates.

BLE2902 descriptor -- This descriptor is required for the notify feature. It acts as a switch that the client toggles to enable or disable notifications.

Advertising -- After starting the service, the server begins advertising. This broadcasts a signal that BLE scanners can detect. The addServiceUUID call includes the service UUID in the advertisement, which lets clients filter for specific services during scanning.

Reconnection -- When a client disconnects, advertising stops automatically. The reconnection logic at the bottom of loop() restarts advertising so new clients can find the server.

Tip: Always restart advertising after a disconnect by calling pServer->getAdvertising()->start(). Without this, your server becomes invisible after the first client disconnects.

🔗Testing with nRF Connect

  1. Upload the sketch to your ESP32 and open the Serial Monitor at $115200\,\text{baud}$.
  2. Open the nRF Connect app on your phone.
  3. Tap Scan -- you should see "ESP32-BLE-Server" in the list of nearby devices.
  4. Tap Connect next to your device.
  5. You will see a list of services. Tap on the service matching your custom UUID (e3223119-...).
  6. Inside the service, you will find the temperature characteristic. Tap the read button (downward arrow icon) to read the current value.
  7. The value will appear as a string like 23.5 -- this is the temperature in degrees Celsius.
  8. To enable live updates, tap the notification button (three downward arrows). The value will update automatically every 2 seconds.

Note: If "ESP32-BLE-Server" does not appear in the scan results, make sure the sketch uploaded successfully and the Serial Monitor shows "BLE server started." Also ensure Bluetooth is enabled on your phone and location permissions are granted (required on Android for BLE scanning).

🔗Using Standard UUIDs (Bluetooth SIG)

The first example used custom (randomly generated) UUIDs. This works fine, but BLE also defines standard UUIDs published by the Bluetooth Special Interest Group (SIG) for common data types. Using standard UUIDs makes your server compatible with apps and devices that understand these well-known services.

The relevant standard UUIDs for environmental data are:

DescriptionUUID
Environmental Sensing Service0x181A
Temperature Characteristic0x2A6E
Humidity Characteristic0x2A6F
Pressure Characteristic0x2A6D

The standard temperature characteristic (0x2A6E) expects the value as a signed 16-bit integer in units of $0.01\,\text{°C}$. For example, $23.45\,\text{°C}$ is transmitted as the integer $2345$.

View sketch with standard UUIDs
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

// Standard Bluetooth SIG UUIDs
#define ENV_SENSING_SERVICE_UUID  BLEUUID((uint16_t)0x181A)
#define TEMPERATURE_CHAR_UUID     BLEUUID((uint16_t)0x2A6E)

Adafruit_BME280 bme;

BLEServer* pServer = nullptr;
BLECharacteristic* pTemperatureChar = nullptr;
bool deviceConnected = false;
bool oldDeviceConnected = false;

unsigned long lastUpdateTime = 0;
const unsigned long updateInterval = 2000;

class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer* pServer) {
    deviceConnected = true;
    Serial.println("Client connected.");
  }

  void onDisconnect(BLEServer* pServer) {
    deviceConnected = false;
    Serial.println("Client disconnected.");
  }
};

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

  if (!bme.begin(0x76)) {
    Serial.println("Error: Could not find BME280. Check wiring or try 0x77.");
    while (1) delay(10);
  }
  Serial.println("BME280 sensor found.");

  BLEDevice::init("ESP32-EnvSensor");

  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new ServerCallbacks());

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

  pTemperatureChar = pService->createCharacteristic(
    TEMPERATURE_CHAR_UUID,
    BLECharacteristic::PROPERTY_READ |
    BLECharacteristic::PROPERTY_NOTIFY
  );

  pTemperatureChar->addDescriptor(new BLE2902());

  pService->start();

  BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(ENV_SENSING_SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);
  BLEDevice::startAdvertising();

  Serial.println("BLE Environmental Sensing server started.");
}

void loop() {
  unsigned long now = millis();

  if (now - lastUpdateTime >= updateInterval) {
    lastUpdateTime = now;

    float temperature = bme.readTemperature();

    // Standard format: signed 16-bit integer in 0.01 °C units
    int16_t tempRaw = (int16_t)(temperature * 100);

    // Set the characteristic value as raw bytes (little-endian)
    pTemperatureChar->setValue((uint8_t*)&tempRaw, sizeof(tempRaw));

    if (deviceConnected) {
      pTemperatureChar->notify();
      Serial.printf("Notified: %.2f °C (raw: %d)\n", temperature, tempRaw);
    } else {
      Serial.printf("Temperature: %.2f °C (raw: %d)\n", temperature, tempRaw);
    }
  }

  if (!deviceConnected && oldDeviceConnected) {
    delay(500);
    pServer->getAdvertising()->start();
    Serial.println("Restarted advertising.");
    oldDeviceConnected = false;
  }

  if (deviceConnected && !oldDeviceConnected) {
    oldDeviceConnected = true;
  }
}

🔗What Changed

The key differences from the custom UUID version:

  • Service UUID uses the standard Environmental Sensing UUID (0x181A) instead of a random one. Apps like nRF Connect will recognize and label this service automatically.
  • Characteristic UUID uses the standard Temperature UUID (0x2A6E). nRF Connect will display the value with proper formatting and units.
  • Data format changed from a text string ("23.5") to a raw 16-bit integer in $0.01\,\text{°C}$ units. The standard specification requires this format. The ESP32 uses little-endian byte order, which matches the BLE specification.

Tip: When you connect with nRF Connect and browse the 0x181A service, you will see the temperature characteristic automatically decoded with the correct unit. This is one of the main benefits of using standard UUIDs.

🔗Adding Notify (Push Updates)

Both examples above already include the notify capability, but it is worth understanding how it works in detail. Without notifications, a client must repeatedly poll the server (read the characteristic over and over). With notifications, the server pushes new values to the client automatically.

The three pieces that make notifications work:

  1. PROPERTY_NOTIFY flag on the characteristic -- tells clients that this characteristic supports notifications.
  2. BLE2902 descriptor -- the client writes to this descriptor to enable or disable notifications.
  3. notify() call in the server's loop -- actually sends the updated value to the client.
View notification-focused sketch
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

#define SERVICE_UUID          "e3223119-9445-4e96-a4a1-85358c4046a2"
#define TEMPERATURE_CHAR_UUID "e3223120-9445-4e96-a4a1-85358c4046a2"

Adafruit_BME280 bme;

BLEServer* pServer = nullptr;
BLECharacteristic* pTemperatureChar = nullptr;
bool deviceConnected = false;
bool oldDeviceConnected = false;

unsigned long lastNotifyTime = 0;
const unsigned long notifyInterval = 2000;  // Push updates every 2 seconds

class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer* pServer) {
    deviceConnected = true;
    Serial.println("Client connected.");
  }

  void onDisconnect(BLEServer* pServer) {
    deviceConnected = false;
    Serial.println("Client disconnected.");
  }
};

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

  if (!bme.begin(0x76)) {
    Serial.println("Error: Could not find BME280. Check wiring or try 0x77.");
    while (1) delay(10);
  }
  Serial.println("BME280 sensor found.");

  BLEDevice::init("ESP32-BLE-Notify");

  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new ServerCallbacks());

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

  pTemperatureChar = pService->createCharacteristic(
    TEMPERATURE_CHAR_UUID,
    BLECharacteristic::PROPERTY_READ |
    BLECharacteristic::PROPERTY_NOTIFY
  );

  // The BLE2902 descriptor is the "notification switch"
  // The client writes 0x0001 to this descriptor to enable notifications
  // and 0x0000 to disable them
  pTemperatureChar->addDescriptor(new BLE2902());

  // Set an initial value
  pTemperatureChar->setValue("--.-");

  pService->start();

  BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);
  BLEDevice::startAdvertising();

  Serial.println("BLE Notify server started. Waiting for connections...");
}

void loop() {
  unsigned long now = millis();

  if (now - lastNotifyTime >= notifyInterval) {
    lastNotifyTime = now;

    float temperature = bme.readTemperature();
    char tempStr[8];
    snprintf(tempStr, sizeof(tempStr), "%.1f", temperature);

    // Always update the stored value (so read requests get fresh data)
    pTemperatureChar->setValue(tempStr);

    if (deviceConnected) {
      // Push the new value to the connected client
      pTemperatureChar->notify();
      Serial.printf("Pushed to client: %s °C\n", tempStr);
    }
  }

  // Restart advertising after disconnect
  if (!deviceConnected && oldDeviceConnected) {
    delay(500);
    pServer->getAdvertising()->start();
    Serial.println("Restarted advertising.");
    oldDeviceConnected = false;
  }

  if (deviceConnected && !oldDeviceConnected) {
    oldDeviceConnected = true;
  }
}

🔗How Notifications Flow

sequenceDiagram
    participant S as ESP32 Server
    participant C as Phone Client
    C->>S: Enable notifications (write 0x0001 to BLE2902)
    loop Every 2 seconds
        S->>S: Read BME280 sensor
        S-->>C: Notify: new temperature value
    end
    C->>S: Disable notifications (write 0x0000 to BLE2902)

The client enables notifications by writing to the BLE2902 descriptor -- nRF Connect does this automatically when you tap the notification icon. From that point on, every time the server calls notify(), the client receives the updated value without having to ask for it.

Tip: Notifications are more power-efficient than polling because the client does not need to send a read request for each update. This matters especially for battery-powered clients like phones and wearables.

🔗Multiple Characteristics

A single service can contain multiple characteristics. To expose humidity and pressure alongside temperature, add more characteristics to the same service. Here are the key additions -- the rest of the code stays the same:

// Define additional characteristic UUIDs
#define HUMIDITY_CHAR_UUID    "e3223121-9445-4e96-a4a1-85358c4046a2"
#define PRESSURE_CHAR_UUID    "e3223122-9445-4e96-a4a1-85358c4046a2"

BLECharacteristic* pHumidityChar = nullptr;
BLECharacteristic* pPressureChar = nullptr;

Create them inside setup() after the temperature characteristic:

pHumidityChar = pService->createCharacteristic(
  HUMIDITY_CHAR_UUID,
  BLECharacteristic::PROPERTY_READ |
  BLECharacteristic::PROPERTY_NOTIFY
);
pHumidityChar->addDescriptor(new BLE2902());

pPressureChar = pService->createCharacteristic(
  PRESSURE_CHAR_UUID,
  BLECharacteristic::PROPERTY_READ |
  BLECharacteristic::PROPERTY_NOTIFY
);
pPressureChar->addDescriptor(new BLE2902());

Update all three in loop():

float humidity = bme.readHumidity();
float pressure = bme.readPressure() / 100.0F;  // Convert to hPa

char humStr[8];
snprintf(humStr, sizeof(humStr), "%.1f", humidity);
pHumidityChar->setValue(humStr);

char presStr[10];
snprintf(presStr, sizeof(presStr), "%.1f", pressure);
pPressureChar->setValue(presStr);

if (deviceConnected) {
  pTemperatureChar->notify();
  pHumidityChar->notify();
  pPressureChar->notify();
}

Warning: The default BLE service can hold up to 15 handles (roughly 5 characteristics with descriptors). If you need more, increase the handle count when creating the service: pServer->createService(SERVICE_UUID, 30). The second parameter sets the number of handles.

🔗Troubleshooting

ProblemPossible CauseSolution
Device not appearing in scanAdvertising not started or sketch did not uploadCheck Serial Monitor for "BLE server started"; re-upload the sketch
Device not appearing in scanPhone Bluetooth is off or location permission deniedEnable Bluetooth; on Android, grant location permission to nRF Connect
Connection drops immediatelyBLE stack crash or memory issueAdd delay(500) before restarting advertising after disconnect
Notify not workingBLE2902 descriptor not added to the characteristicEnsure pTemperatureChar->addDescriptor(new BLE2902()) is present
Notify not workingClient has not enabled notificationsIn nRF Connect, tap the triple-arrow icon to subscribe
Garbled or unexpected valuesData format mismatchIf using standard UUIDs, send raw bytes in the correct format (e.g., sint16 for temperature). If using custom UUIDs, make sure the client interprets the data as a string
"ESP32-BLE-Server" name not showingName too long or init failedKeep the device name under 20 characters; check Serial Monitor for errors
Cannot reconnect after disconnectAdvertising not restartedMake sure pServer->getAdvertising()->start() is called after disconnect

🔗What's Next?

Now that your ESP32 can broadcast sensor data over BLE, the natural next step is building a client that connects and reads that data. In the next tutorial, you will program a second ESP32 to scan for and connect to this BLE server.