If you are building an ESP32 Bluetooth project in 2026, your first architectural decision is choosing between Classic Bluetooth (BR/EDR) and Bluetooth Low Energy (BLE). For 90% of maker applications—sensor telemetry, relay control, and battery-powered nodes—BLE is the correct choice. Furthermore, the legacy Bluedroid stack is largely deprecated for simple tasks; the NimBLE stack is now the standard for ESP32 BLE, consuming roughly 50% less RAM and offering faster initialization times.
This guide walks through building a definitive ESP32 Bluetooth BLE sensor and relay node. We will cover the hardware selection, exact pin mappings, complete compilable C++ code using the NimBLE stack, and a rigorous debugging protocol for the most common stack panics.
Decision Tree: Classic vs. BLE on the ESP32
Do not guess which Bluetooth protocol to use. Follow this decision path to select the right stack and silicon variant for your project.
- IF you need to stream stereo audio (A2DP) or transfer large files continuously → Choose Classic Bluetooth.
- IF you need low-power sensor telemetry, intermittent relay control, or mesh networking → Choose BLE.
- IF you chose BLE and need to minimize BOM cost/power → Choose ESP32-C3 or ESP32-C6.
- IF you chose BLE but also need Wi-Fi and dual-core processing for complex local logic → Choose ESP32-WROOM-32E.
Concrete Pick for this Build: We are using the ESP32-WROOM-32E DevKit V1 (38-pin) running BLE via the NimBLE stack. This provides the dual-core headroom for concurrent Wi-Fi (if added later) while keeping the BLE memory footprint under 30KB.
Hardware BOM and Pin Mapping
The following bill of materials uses specific, widely available variants. Do not substitute the DHT22 for a DHT11 if you require sub-degree accuracy, and ensure your relay module is opto-isolated to prevent back-EMF from resetting the ESP32.
| Component | Exact Variant / Spec | Estimated Cost (2026) |
|---|---|---|
| Microcontroller | ESP32-WROOM-32E DevKit V1 (38-pin, 4MB Flash) | $5.50 |
| Temp/Humidity Sensor | DHT22 (AM2302) with integrated 4.7kΩ pull-up | $3.00 |
| Actuator | Songle SRD-05VDC-SL-C (Opto-isolated relay module) | $1.50 |
| Power Supply | 5V 2A USB-C Wall Adapter (Data-capable cable) | $8.00 |
Pin Mapping Table
| ESP32 GPIO | Target Component | Notes / Constraints |
|---|---|---|
| GPIO 4 | DHT22 Data Pin | Requires 4.7kΩ pull-up to 3.3V (often built into module) |
| GPIO 5 | Relay IN (Signal) | Active LOW on most opto-isolated modules |
| 3V3 | DHT22 VCC | Do not use 5V for the sensor data line logic |
| 5V (VIN) | Relay VCC | Relay coil requires 5V to trigger reliably |
| GND | Common Ground | Tie all module GNDs together |
Step-by-Step Wiring and Assembly
- Prep the Breadboard: Connect the ESP32 DevKit V1 to the breadboard. Note that standard 38-pin DevKits span the entire width of a standard breadboard, leaving no open holes on the side. Use a wider breadboard or jumper directly from the pins.
- Wire the DHT22: Connect the DHT22 VCC to ESP32 3V3, GND to ESP32 GND, and the Data pin to GPIO 4. If your DHT22 module does not have a built-in resistor, solder a 4.7kΩ resistor between the VCC and Data pins.
- Wire the Relay Module: Connect the Relay VCC to the ESP32 5V (VIN) pin. Connect Relay GND to ESP32 GND. Connect the Relay IN (signal) pin to GPIO 5.
- Verify Jumper Gauges: Use 22 AWG solid core jumper wires for breadboard connections. For the relay load side (the screw terminals), use at least 18 AWG stranded wire and tin the ends with solder to prevent fraying.
- Power Up: Plug the 5V 2A USB-C supply into the ESP32. The onboard LED should flash briefly. The relay module should click once upon power-up if the GPIO defaults to LOW (active).
Complete ESP32 Bluetooth BLE Code
This code targets the ESP32 Dev Module (ESP32-WROOM-32E) board definition in the Arduino IDE. It uses the h2zero/NimBLE-Arduino library, which you must install via the Library Manager (search for "NimBLE-Arduino" by h2zero). Do not use the legacy BLEDevice.h Bluedroid wrapper for this build.
/*
* ESP32 Bluetooth BLE Sensor & Relay Node
* Target Board: ESP32 Dev Module (ESP32-WROOM-32E)
* Stack: NimBLE-Arduino (h2zero)
* Dependencies: NimBLE-Arduino, DHT sensor library (Adafruit)
*/
#include <NimBLEDevice.h>
#include <DHT.h>
// --- PIN DEFINITIONS ---
#define DHTPIN 4 // GPIO 4
#define DHTTYPE DHT22 // DHT 22 (AM2302)
#define RELAY_PIN 5 // GPIO 5 (Active LOW on most modules)
// --- BLE UUIDs ---
// Custom 128-bit UUIDs for Service and Characteristics
#define BLE_SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define BLE_TEMP_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define BLE_RELAY_CHAR_UUID "d5875405-fa6e-4f59-b092-7a658c41b2f3"
// --- GLOBAL OBJECTS ---
DHT dht(DHTPIN, DHTTYPE);
NimBLECharacteristic* pTempCharacteristic;
NimBLECharacteristic* pRelayCharacteristic;
bool deviceConnected = false;
// --- BLE SERVER CALLBACKS ---
class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* pServer) {
deviceConnected = true;
Serial.println("[BLE] Client Connected");
}
void onDisconnect(NimBLEServer* pServer) {
deviceConnected = false;
Serial.println("[BLE] Client Disconnected");
// Restart advertising on disconnect
NimBLEDevice::startAdvertising();
}
};
// --- RELAY CHARACTERISTIC CALLBACK ---
class RelayCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic* pCharacteristic) {
std::string value = pCharacteristic->getValue();
if (value.length() > 0) {
// Expecting 0x01 for ON, 0x00 for OFF
if (value[0] == 1) {
digitalWrite(RELAY_PIN, LOW); // Active LOW
Serial.println("[RELAY] Engaged");
} else {
digitalWrite(RELAY_PIN, HIGH); // Active LOW
Serial.println("[RELAY] Disengaged");
}
}
}
};
void setup() {
Serial.begin(115200);
delay(1000); // Allow serial monitor to attach
Serial.println("\n[SYS] Initializing ESP32 BLE Node...");
// 1. Initialize Hardware Pins
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, HIGH); // Default to OFF (Active LOW)
dht.begin();
// 2. Initialize NimBLE Stack
// Error Handling: Check if BLE init succeeds
if (!NimBLEDevice::init("ESP32-BLE-Node")) {
Serial.println("[ERR] NimBLE Init Failed. Check board definition.");
while(1) { delay(1000); } // Halt execution
}
// Optimize for low power and memory
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
// 3. Create BLE Server and Service
NimBLEServer* pServer = NimBLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
NimBLEService* pService = pServer->createService(BLE_SERVICE_UUID);
// 4. Create Characteristics
// Temperature (Read Only, Notify)
pTempCharacteristic = pService->createCharacteristic(
BLE_TEMP_CHAR_UUID,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
);
// Relay Control (Read/Write)
pRelayCharacteristic = pService->createCharacteristic(
BLE_RELAY_CHAR_UUID,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE
);
pRelayCharacteristic->setCallbacks(new RelayCallbacks());
pRelayCharacteristic->setValue((uint8_t)0); // Default state
// 5. Start Service and Advertising
pService->start();
NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->addServiceUUID(BLE_SERVICE_UUID);
pAdvertising->setScanResponse(true);
NimBLEDevice::startAdvertising();
Serial.println("[SYS] BLE Advertising Started. Ready for connections.");
}
void loop() {
if (deviceConnected) {
// Read sensor with error handling
float temp = dht.readTemperature();
if (isnan(temp)) {
Serial.println("[ERR] Failed to read from DHT sensor!");
} else {
// Convert float to string for BLE payload
char tempStr[8];
dtostrf(temp, 1, 2, tempStr);
pTempCharacteristic->setValue(tempStr);
// Notify connected client if subscribed
if (pTempCharacteristic->getSubscribedCount() > 0) {
pTempCharacteristic->notify();
}
}
}
// Non-blocking delay (crucial for BLE stack watchdog)
delay(2000);
}
Debugging: First 3 Checks and Exact Error Strings
When working with the ESP32 Bluetooth stack, failures usually manifest as boot loops or silent advertising failures. Before rewriting your code, execute these first three checks:
- Board Definition Mismatch: Ensure your Arduino IDE board is set to ESP32 Dev Module. If you accidentally select ESP32-S3 Dev Module or ESP32-C3, the compiler will attempt to link against a different BLE hardware abstraction layer, resulting in immediate core panics on boot.
- Partition Scheme Overlap: The NimBLE stack requires sufficient app partition space. In the IDE Tools menu, set Partition Scheme to "Huge APP (3MB No OTA/1MB SPIFFS)". Using the default 4MB with OTA often leaves too little contiguous space for the BLE stack and your compiled C++ code, causing
esp_image: image length is greater than segment lengtherrors. - USB Cable Data Lines: If the serial monitor outputs garbage or the board fails to flash, swap the USB-C cable. Over 40% of cheap USB-C cables are charge-only and lack the D+/D- data lines required for UART communication.
Ranked Causes for Exact Error Strings
If your serial monitor outputs the following exact error string during BLE initialization:
E (1452) BT_BTM: BTM_Ble_Write_Adv_Data failed, status=0x07
This is an HCI (Host Controller Interface) error indicating the advertising payload was rejected by the baseband controller. Here are the ranked causes and fixes:
| Rank | Cause | Fix |
|---|---|---|
| 1 | Advertising payload exceeds 31 bytes (including UUIDs and device name). | Shorten the device name in NimBLEDevice::init() or remove secondary service UUIDs from the scan response. |
| 2 | Scan response data exceeds 31 bytes. | Set pAdvertising->setScanResponse(false); if you don't strictly need scan response data. |
| 3 | Calling startAdvertising() before the service is fully started. |
Ensure pService->start(); is called and returns before initializing the advertising object. |
Extending and Simplifying the Build
This architecture is designed to be modular. Depending on your immediate needs, you can scale the complexity up or down without rewriting the core BLE stack.
How to Simplify (Bench Testing Mode)
If you are testing the Bluetooth relay logic and do not have a DHT22 on hand, simplify the build by removing the DHT.h dependency. In the loop() function, replace the sensor read block with a dummy payload:
pTempCharacteristic->setValue("24.50");
if (pTempCharacteristic->getSubscribedCount() > 0) {
pTempCharacteristic->notify();
}
This isolates the BLE stack from hardware sensor timing issues (the DHT22 requires strict microsecond timing that can occasionally trigger the ESP32 watchdog timer if interrupted by BLE interrupts).
How to Extend (Wi-Fi MQTT Bridge)
To integrate this BLE node into a broader smart home ecosystem (like Home Assistant), extend the build by adding a Wi-Fi MQTT bridge. Because we chose the ESP32-WROOM-32E and the NimBLE stack, you have sufficient RAM (~220KB free) to run Wi-Fi and BLE concurrently.
- Add the
PubSubClientlibrary. - Initialize Wi-Fi in
setup()usingWiFi.begin(ssid, password). - In the
RelayCallbacks::onWritefunction, after toggling the GPIO, publish the new state to an MQTT topic (e.g.,home/esp32/relay/state). - This allows the ESP32 to be controlled locally via BLE when the internet is down, and remotely via MQTT when connected.
By standardizing on the NimBLE stack and explicitly defining your UUIDs and pin states, you eliminate the memory bloat and timing panics that plague legacy ESP32 Bluetooth tutorials. Stick to the ESP32-WROOM-32E for dual-radio needs, and use the decision tree above to ensure you never over-provision silicon for a simple BLE sensor task.






