Skip to main content
NotesFirmwareNew

ESP32 Beginner Guide – P6: Bluetooth Low Energy

Learn ESP32 BLE, GATT services, and characteristics by sending BME280 readings to a phone with Read and Notify.

Share

LinkedInFacebookX
An ESP32-S3 development board and smartphone testing Bluetooth Low Energy beside a MacBook Pro on a realistic electronics workbench

In P5: Wi-Fi and Web Server, we viewed BME280 readings through a router. P6 builds another path: a phone connects directly to ESP32 over Bluetooth Low Energy, with no SSID, IP address, or Internet connection required.

BME280 → ESP32 → BLE → phone

The goal is a small, observable GATT server. The phone can read the current value or subscribe to receive a new value every two seconds.

BLE is not Bluetooth Serial

The original ESP32 supports both Bluetooth Classic and Bluetooth Low Energy. The ESP32-S3 and ESP32-C3 boards used in this series support BLE but not Bluetooth Classic, so BluetoothSerial examples are not suitable for them.

BLE is designed for short exchanges such as sensor readings, device status, and nearby configuration. “Low Energy” does not make every circuit automatically efficient: advertising timing, connection intervals, sensors, LEDs, and the firmware's sleep strategy still determine real current consumption.

Four concepts are enough to begin

A phone scans for the Nivotek-BME280 BLE device advertised by an ESP32-S3-DevKitC-1 beside a MacBook Pro.

ESP32 advertises as the peripheral; the phone acts as the central that discovers and connects to it.

ConceptRole in this guide
PeripheralESP32 advertises and waits for a connection
CentralThe phone scans and connects to ESP32
GATT serverThe data table hosted by ESP32
GATT clientThe phone app that discovers and uses that table

Advertising says, roughly, “this device is nearby and offers this service.” It is not a continuous sensor-data stream. After connecting, the client discovers this GATT structure:

Nivotek sensor service
└── Measurement characteristic
    ├── Read
    └── Notify

A service groups one feature. A characteristic holds a value and its allowed operations. A UUID lets an app find the intended service or characteristic even when its interface shows only a long identifier.

Prepare the circuit and test app

Keep the P4 circuit: BME280 3V3, GND, SDA to GPIO4, and SCL to GPIO5. The code uses address 0x77; change it to 0x76 if that is what your module's I2C scanner reports.

You need:

  • An ESP32-S3-DevKitC-1 and a data-capable USB-C cable.
  • A BME280 already working with the P4 sketch.
  • Adafruit BME280 Library and its Adafruit Unified Sensor dependency.
  • A phone GATT client such as nRF Connect for Mobile.

The BLE headers below ship with the ESP32 core in Arduino IDE. You do not need another similarly named BLE library from Library Manager.

Complete sketch: send BME280 data with Read and Notify

This example creates one custom service and one characteristic. Its payload looks like T=26.4,H=61,P=1009 and deliberately stays around 20 bytes so it is easy to test with the default MTU. It is a learning format, not a production protocol.

#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

constexpr uint8_t SDA_PIN = 4;
constexpr uint8_t SCL_PIN = 5;
constexpr uint8_t BME280_ADDRESS = 0x77;
constexpr uint32_t SAMPLE_INTERVAL_MS = 2000;

constexpr char DEVICE_NAME[] = "Nivotek-BME280";
constexpr char SERVICE_UUID[] =
    "8f7e1201-6a2d-4f0b-9c31-5d2a7e991001";
constexpr char MEASUREMENT_UUID[] =
    "8f7e1202-6a2d-4f0b-9c31-5d2a7e991001";

Adafruit_BME280 bme;
BLEServer *bleServer = nullptr;
BLECharacteristic *measurement = nullptr;

volatile bool deviceConnected = false;
bool wasConnected = false;
bool sensorReady = false;
uint32_t lastSampleAt = 0;

class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer *) override {
    deviceConnected = true;
  }

  void onDisconnect(BLEServer *) override {
    deviceConnected = false;
  }
};

void publishReading() {
  if (!sensorReady) {
    measurement->setValue("sensor unavailable");
    return;
  }

  const float temperature = bme.readTemperature();
  const float humidity = bme.readHumidity();
  const float pressure = bme.readPressure() / 100.0F;

  char payload[24];
  snprintf(payload, sizeof(payload), "T=%.1f,H=%.0f,P=%.0f",
           temperature, humidity, pressure);

  measurement->setValue(payload);
  if (deviceConnected) {
    measurement->notify();
  }

  Serial.println(payload);
}

void setup() {
  Serial.begin(115200);
  Wire.begin(SDA_PIN, SCL_PIN);
  sensorReady = bme.begin(BME280_ADDRESS, &Wire);

  BLEDevice::init(DEVICE_NAME);
  bleServer = BLEDevice::createServer();
  bleServer->setCallbacks(new ServerCallbacks());

  BLEService *service = bleServer->createService(SERVICE_UUID);
  measurement = service->createCharacteristic(
      MEASUREMENT_UUID,
      BLECharacteristic::PROPERTY_READ |
      BLECharacteristic::PROPERTY_NOTIFY);

  // CCCD lets the client enable or disable notifications.
  measurement->addDescriptor(new BLE2902());
  publishReading();
  service->start();

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

  Serial.println("BLE ready. Scan for Nivotek-BME280.");
}

void loop() {
  const uint32_t now = millis();

  if (now - lastSampleAt >= SAMPLE_INTERVAL_MS) {
    lastSampleAt = now;
    publishReading();
  }

  if (!deviceConnected && wasConnected) {
    delay(500);  // Let the BLE stack finish disconnect handling.
    bleServer->startAdvertising();
    Serial.println("Disconnected; advertising restarted.");
    wasConnected = false;
  } else if (deviceConnected && !wasConnected) {
    Serial.println("BLE client connected.");
    wasConnected = true;
  }

  delay(5);
}

The callbacks only change connection state. I2C reads, string formatting, and notifications stay in loop(), where timing is easier to control. This is a useful habit: do not put delay(), sensor transactions, or heavy work inside BLE callbacks.

How are Read and Notify different?

A phone GATT app displays the custom ESP32-S3 service and characteristic beside a MacBook Pro.

GATT arranges data into services and characteristics; UUIDs let a client find the intended attribute.

  • Read: the phone asks for the value currently stored in the characteristic.
  • Notify: the phone subscribes once; ESP32 pushes new values whenever the firmware calls notify().

BLE2902 adds the Client Characteristic Configuration Descriptor (CCCD), where a client enables or disables notifications. Adding PROPERTY_NOTIFY in firmware does not make the phone receive updates automatically; you must still tap the subscribe/notification control in the app.

Test it on a phone

A phone receives BLE temperature, humidity, and pressure notifications from an ESP32-S3 correctly powered over USB-C by a MacBook Pro.

Once Notify is enabled, ESP32 pushes a fresh value to the phone every two seconds.

  1. Upload the sketch and open Serial Monitor at 115200 baud.
  2. Open a GATT client, grant the Bluetooth permission requested by the operating system, and start scanning.
  3. Select Nivotek-BME280, then connect.
  4. Find the service UUID ending in 1001 and characteristic ending in 1002.
  5. Tap Read to inspect the current value.
  6. Enable Notify/Subscribe; the value should change about every two seconds.
  7. Disconnect and scan again. Serial should print advertising restarted, and the device should reappear.

If the app displays hexadecimal bytes, switch its parser to UTF-8/ASCII. For example, 54 3D 32 36 2E 34 is the beginning of T=26.4.

Common BLE failures

SymptomCheck first
Device name is missingCorrect board/port, BLE ready log, Bluetooth permission, and distance
Device appears but connection failsDisconnect the old client, reset ESP32, and scan again
Read works but Notify does notEnable subscribe and verify descriptor 0x2902 exists
Value appears as hexChange the app's data parser to UTF-8/ASCII
BME280 says unavailableCheck 0x76/0x77, SDA/SCL, 3V3, and GND
Device vanishes after disconnectRestart advertising after disconnection
Firmware runs low on memory after BLERemove unused Wi-Fi code; consider NimBLE for a larger BLE project

ESP32-S3 shares its 2.4 GHz radio between Wi-Fi and BLE. The protocols can coexist, but P6 deliberately leaves Wi-Fi disabled so you can debug one communication path at a time.

One important security note

This sketch does not require pairing, encryption, or authentication. A nearby device can see its advertising, and a client can connect to read the data. That is acceptable for lab temperature, but not for Wi-Fi passwords, tokens, door locks, or sensitive control commands.

When a project moves beyond a demo, define Read/Write permissions, pairing, bonding, encryption, reconnection, and payload versions explicitly. The BLE communication guide for IoT devices goes deeper into those topics with NimBLE and binary packets.

P6 completion checklist

  • The phone finds Nivotek-BME280 while scanning.
  • You can distinguish peripheral/central from server/client.
  • The GATT client discovers the custom service and characteristic.
  • Read returns the current BME280 value.
  • Notify updates roughly every two seconds after subscription.
  • ESP32 advertises again after the client disconnects.
  • The demo contains no secrets because BLE security is not enabled.

P6 adds a nearby communication path that does not depend on a router. Continue to the final article, P7: Build a Complete ESP32 Project, to combine GPIO, sensors, and connectivity into a testable device.

References

Share

LinkedInFacebookX

Keep exploring

Read next

Related articles

View more in Notes

Nastrotek uses cookies for analytics and ad personalization to help us understand how the site is used. You can accept or decline non-essential cookies. Privacy Policy