Unit I - ESP32 Advanced

46. Bluetooth Low Energy Essentials

Build a phone-controlled desk lamp over BLE: advertise, write on/off, then notify the phone of lamp state.

Estimated time 3-4 hours

Learning outcomes

  • Frame a BLE GATT lamp as a short-range product without Wi-Fi
  • Explain advertising, connection, service, characteristic and UUID
  • Toggle GPIO from a phone characteristic write
  • Notify the phone when lamp state changes (including from Serial)
  • Contrast BLE with Wi-Fi for desk-scale control

Parts and preparation

ESP32 board with Bluetooth LE (classic ESP32 or C3/C5/C6 - not P4 without companion). USB data cable, LED + resistor on GPIO 2 (desk lamp stand-in). Phone with nRF Connect or similar.

Before power: inspect wiring, confirm supply voltage and ensure all connected circuits share GND.

Libraries for this lesson

In Arduino IDE 2 open Tools → Manage Libraries…. Search by the Library Manager name and install the package by the exact author below. Similar names from other authors can use different APIs and will break the example.

IncludeLibrary Manager nameAuthorInstall note
BLEDevice.hESP32 BLE ArduinoEspressifBuilt into the esp32 Arduino core (BLEDevice, BLEServer, BLEUtils). No Library Manager install required.
Bluetooth Low Energy Essentials instructional connection diagram

Project: Phone desk lamp

You are building a short-range desk lamp you can switch from a phone with no home Wi-Fi. Success looks like: phone finds WilteqLamp, writing 1/0 turns the LED, and the phone can see the current state when you also change it from Serial.

Sketch 1 proves advertise + write. Sketch 2 adds NOTIFY so the phone stays in sync - the behaviour you would want on a real BLE accessory.

Phone as BLE client writing a characteristic on ESP32 GATT server to toggle LED
Desk lamp over GATT: write to switch, notify to confirm state.
NeedOften better fit
Browser on the LAN / cloud APIWi-Fi (Unit H)
Phone next to a gadget, no routerBLE GATT
Audio headset style linkClassic BT (not this course)

Advertising, then connecting

A BLE peripheral (the ESP32) advertises a name and service UUID. A central (phone app) connects, discovers GATT, then reads/writes characteristics. On disconnect, restart advertising so the next phone can find the lamp.

PhaseWhat happens
AdvertiseESP32 broadcasts WilteqLamp + service UUID
ConnectPhone becomes the GATT client
WritePhone sends 1 or 0 to switch the lamp
NotifyESP32 pushes state changes to the phone
DisconnectAdvertising restarts

GATT: service, characteristic, UUID

A service groups characteristics. A characteristic is the value (lamp on/off). Custom 128-bit UUIDs identify your service and characteristic in the phone app. Properties: READ, WRITE, and NOTIFY (server pushes updates).

TermRole in this project
Peripheral / serverESP32 lamp
Central / clientPhone app
Characteristic valueASCII 1 = on, 0 = off
NOTIFYPhone learns state after Serial change

Phone lab walkthrough

1) Upload sketch 1; Serial says advertising. 2) nRF Connect: scan, connect to WilteqLamp, open the custom service. 3) Write text 1 / 0 (or hex 0x31 / 0x30). 4) Upload sketch 2; enable notifications on the characteristic; type 1 or 0 in Serial Monitor.

Board and API notes

Classic ESP32 and many C-series chips include BLE. ESP32-P4 needs a companion radio. If getValue() type errors appear, your core may return std::string instead of String - adjust one line.

Wiring and safe build sequence

  1. GPIO 2 -> series resistor -> LED anode; cathode -> GND (desk lamp stand-in)
  2. USB power; BLE-capable ESP32 board
  3. Phone within a few metres; Bluetooth on
Power rule: switch off before moving wires. Arduino I/O pins are control signals; high-current loads require a driver and suitable external supply.

Worked sketch 1: Advertise and switch from the phone

Download .ino sketch

What this sketch is for: Prove the lamp path: phone finds WilteqLamp and writing ASCII 1/0 toggles GPIO 2. No notify yet - discover write callbacks first.

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>

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

const int ledPin = 2;

class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer *pServer) {
    Serial.println("Central connected");
  }
  void onDisconnect(BLEServer *pServer) {
    Serial.println("Central disconnected - advertising again");
    BLEDevice::startAdvertising();
  }
};

class LedCallbacks : public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic *pCharacteristic) {
    String value = pCharacteristic->getValue();
    if (value.length() < 1) return;
    if (value[0] == '1') digitalWrite(ledPin, HIGH);
    if (value[0] == '0') digitalWrite(ledPin, LOW);
    Serial.print("Write: ");
    Serial.println(value.c_str());
  }
};

void setup() {
  Serial.begin(115200);
  pinMode(ledPin, OUTPUT);
  digitalWrite(ledPin, LOW);

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

  BLEService *pService = pServer->createService(SERVICE_UUID);
  BLECharacteristic *pCharacteristic = pService->createCharacteristic(
    CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE
  );
  pCharacteristic->setCallbacks(new LedCallbacks());
  pCharacteristic->setValue("0");
  pService->start();

  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->start();
  Serial.println("Desk lamp advertising as WilteqLamp");
  Serial.println("Write ASCII 1 or 0 from the phone");
}

void loop() {
  delay(200);
}

How the code works

  1. Device name WilteqLamp is what the phone scanner shows.
  2. onWrite drives the lamp from the first ASCII character.
  3. onDisconnect restarts advertising for the next connection.

Worked sketch 2: Desk lamp with NOTIFY + Serial control

Download .ino sketch

What this sketch is for: Same lamp, product behaviour: phone writes still work, and enabling notifications lets the phone see state when you type 1 or 0 in Serial Monitor. Match the breadboard layout below before upload.

Breadboard layout for ESP32 BLE desk lamp: LED with series resistor on GPIO 2
Breadboard for sketch 2: desk lamp LED with series resistor on GPIO 2 to GND. Click to enlarge.
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#include <BLE2902.h>

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

const int ledPin = 2;
BLECharacteristic *pLampChar = nullptr;
bool lampOn = false;

void setLamp(bool on, bool fromSerial) {
  lampOn = on;
  digitalWrite(ledPin, lampOn ? HIGH : LOW);
  if (pLampChar) {
    pLampChar->setValue(lampOn ? "1" : "0");
    pLampChar->notify();
  }
  Serial.print(fromSerial ? "Serial -> lamp " : "Phone -> lamp ");
  Serial.println(lampOn ? "ON" : "OFF");
}

class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer *pServer) {
    Serial.println("Central connected");
  }
  void onDisconnect(BLEServer *pServer) {
    Serial.println("Central disconnected - advertising again");
    BLEDevice::startAdvertising();
  }
};

class LedCallbacks : public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic *pCharacteristic) {
    String value = pCharacteristic->getValue();
    if (value.length() < 1) return;
    if (value[0] == '1') setLamp(true, false);
    if (value[0] == '0') setLamp(false, false);
  }
};

void setup() {
  Serial.begin(115200);
  pinMode(ledPin, OUTPUT);

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

  BLEService *pService = pServer->createService(SERVICE_UUID);
  pLampChar = pService->createCharacteristic(
    CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ |
    BLECharacteristic::PROPERTY_WRITE |
    BLECharacteristic::PROPERTY_NOTIFY
  );
  pLampChar->addDescriptor(new BLE2902());
  pLampChar->setCallbacks(new LedCallbacks());
  pLampChar->setValue("0");
  pService->start();

  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->start();
  Serial.println("WilteqLamp ready - enable NOTIFY in the phone app");
  Serial.println("Type 1 or 0 in Serial to change the lamp");
}

void loop() {
  if (Serial.available()) {
    char c = Serial.read();
    if (c == '1') setLamp(true, true);
    if (c == '0') setLamp(false, true);
  }
  delay(20);
}

How the code works

  1. PROPERTY_NOTIFY plus BLE2902 is the usual CCCD so phones can subscribe.
  2. setLamp updates GPIO, characteristic value, and notify() in one place.
  3. Enable notifications in nRF Connect before testing Serial control.
  4. Build from the breadboard photo: lamp LED on GPIO 2.

Test and record evidence

Expected result: Sketch 1: phone finds WilteqLamp; writing 1/0 toggles the LED. Sketch 2: with notifications enabled, typing 1/0 in Serial updates the LED and the phone value.

Practical evidence checklist

Common faults and checks
  • No advertise: board without BLE, or phone Bluetooth off.
  • Notify does nothing: enable notifications / CCCD in the app; confirm BLE2902.
  • Write silent: correct characteristic UUID; try hex 0x31 / 0x30.
  • P4 board: needs companion radio for BLE.
Extension challenge: Add a second characteristic for brightness (0-9) that sets PWM on another pin, keeping on/off on the first characteristic.

Check your understanding

Q1. What product are you building in this lesson?

Show answer

A short-range phone-controlled desk lamp over BLE.

Q2. When might BLE beat Wi-Fi?

Show answer

Phone control with no router, or lower average power than always-on Wi-Fi.

Q3. What does advertising do?

Show answer

Broadcasts the peripheral name/UUIDs so phones can find it.

Q4. What does NOTIFY add to the lamp?

Show answer

The phone receives state updates without polling.

Q5. What role is the ESP32?

Show answer

BLE peripheral / GATT server.

Q6. What ASCII write turns the lamp on?

Show answer

1

Q7. Why restart advertising on disconnect?

Show answer

So the next phone can find the lamp again.

Q8. Why prefer BLE over Classic BT here?

Show answer

Low-power small data; Classic is legacy for new ESP32 labs.