After completing this project, you will have a bidirectional Bluetooth communication system between two ESP32 development boards:
/ledon or /ledoff on either side to control only the remote board's onboard LEDSimple analogy: Like two people using walkie-talkies — the Master is the one who initiates the call, the Slave is the one who receives it. Both can talk and listen, and both can remotely control the other's device.
💡 Difference from single-board + web version: The web version requires a computer browser as the central device; this project uses two ESP32 boards communicating directly, no computer needed (except for flashing and serial debugging), making it closer to real IoT device-to-device communication.
| Device | Description |
|---|---|
| ESP32 dev board × 2 | Must support BLE (ESP32 / ESP32-WROOM / ESP32-S3 all work) |
| USB data cable × 2 | For flashing code and serial communication |
| Computer × 1 | For flashing code and serial debugging (can connect both boards simultaneously) |
| Software | Description |
|---|---|
| Arduino IDE | For compiling and flashing ESP32 code |
| Serial terminal (optional) | e.g., PuTTY, SSCOM, etc. — convenient for viewing both boards' output simultaneously |
ESP32_BLE_Slave/ledon → Sends command to Slave → Only Slave LED turns on (Master LED unchanged)/ledoff → Sends command to Master → Only Master LED turns off (Slave LED unchanged)ESP32_Slave/ESP32_Slave.ino → Slave code (advertises and waits for connection)ESP32_Master/ESP32_Master.ino → Master code (scans and connects)💡 The default configuration works out of the box! If you want to customize the Bluetooth name or UUID, just modify the corresponding code.
Change Bluetooth name (in both .ino files):
// In Slave:
BLEDevice::init("ESP32_BLE_Slave"); // Change to your desired slave name
// In Master:
BLEDevice::init("ESP32_BLE_Master"); // Change to your desired master name
⚠️ If you change the Slave's advertising name, the Master code does not need to be modified (Master identifies devices by service UUID, not by name).
Change service UUID (must be identical in both .ino files):
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
⚠️ The UUID is the BLE device's ID card. If you change the UUID in one file, the other must be updated as well, otherwise communication will fail.
Important: Each board gets different code!
| ESP32 Board | Code to Flash | Role |
|---|---|---|
| Board A | ESP32_Slave.ino |
Slave — advertises and waits for connection |
| Board B | ESP32_Master.ino |
Master — scans and connects |
Flashing steps:
ESP32_Slave.ino in Arduino IDETools → Board → Select ESP32 Dev ModuleTools → Port → Select the corresponding COM portCtrl+U) to flash the codeESP32_Master.ino in Arduino IDE💡 If your computer has only one USB port, you can flash the boards one at a time. If you have two USB ports, you can connect and flash both simultaneously.
Ctrl+Shift+M)💡 It's recommended to use two serial monitor windows to view each board's output separately. Arduino IDE only supports one serial monitor by default, so you can use PuTTY or SSCOM to monitor two COM ports simultaneously.
========================================
ESP32 BLE Slave — Starting up...
========================================
[Slave] BLE device name: ESP32_BLE_Slave
[Slave] Advertising started. Waiting for Master connection...
[Slave] Type a message and press Enter to send to Master.
[Slave] Commands: /ledon /ledoff /help
BLE device name: ESP32_BLE_Slave → BLE initialization successful ✔Advertising started → Advertising started, waiting for Master to connect ✔========================================
ESP32 BLE Master — Starting up...
========================================
[Master] Scanning for ESP32_BLE_Slave...
[Master] Found target device: ESP32_BLE_Slave
[Master] Connecting to ESP32_BLE_Slave...
[Master] >>> Connected to Slave!
[Master] Connected! Discovering services...
[Master] Service found! Discovering characteristics...
[Master] Subscribed to Slave notifications.
[Master] Ready! Type a message and press Enter to send.
Found target device → Scan successful, Slave discovered ✔Connected to Slave! → Connection established ✔Subscribed to Slave notifications → Notification subscription successful, ready to chat ✔Meanwhile, the Slave serial will show:
[Slave] >>> Master connected!
Type a message in the Master serial monitor, press Enter:
[Master] Sent: Hello Slave![Slave] Received: Hello Slave!Type a message in the Slave serial monitor, press Enter:
[Slave] Sent: Hello Master![Master] Received: Hello Master!💡 Rule: Entering a command controls only the remote board's LED, not the local board's LED.
Master enters /ledon (controls Slave LED):
Master (sender):
[Master] >>> Sent command: turn Slave LED ON
Master LED unchanged ✔
Slave (receiver):
[Slave] >>> Master command: LED turned ON
Slave onboard LED (GPIO2) turns on ✔
Slave enters /ledon (controls Master LED):
Slave (sender):
[Slave] >>> Sent command: turn Master LED ON
Slave LED unchanged ✔
Master (receiver):
[Master] >>> Slave command: LED turned ON
Master onboard LED (GPIO2) turns on ✔
/ledoff works the same way: Whichever side you enter it on, it turns off the remote board's LED.
View help: Enter /help on either side, the remote side will display the available command list.
If the connection between the two boards is lost:
Master serial:
[Master] <<< Disconnected from Slave. Reconnecting...
[Master] Connecting to ESP32_BLE_Slave...
[Master] >>> Connected to Slave!
Slave serial:
[Slave] <<< Master disconnected. Restarting advertising...
[Slave] >>> Master connected!
Both boards will automatically attempt to reconnect — no manual intervention required.
💡 New users start here: 90% of issues are covered below.
A: Baud rate is incorrect.
A: Line ending setting is incorrect.
A: ESP32 core version is too old or wrong board selected.
esp32 core to the latest version in Arduino IDE's "Boards Manager"Tools → Board → Select ESP32 Dev Module (do not select ESP32-S2)A: Slave is not advertising or is too far away.
Advertising startedSERVICE_UUID is identical in both code filesA: Multiple possible causes.
Advertising startedA: Characteristic subscription may have failed or UUIDs don't match.
Subscribed to Slave notifications>>> Master connected!CHARACTERISTIC_UUID_RX and CHARACTERISTIC_UUID_TX must be identical in both code filesA: Check the following:
LED turned ONLED_PIN = 2 (GPIO2), most ESP32 boards have onboard LED on this pinHIGH and LOW to be swappedA: One side's characteristic may have an issue.
BLE2902 descriptor addedA: BLE default MTU limit.
BLEDevice::setMTU(512), allowing single messages up to ~500 bytesA:
| Symptom | Most Likely Cause | Fastest Solution |
|---|---|---|
| Garbled / blank serial | Wrong baud rate | Change to 115200, press EN to reset |
| No response to serial input | Wrong line ending | Set to "Newline" or "Both NL & CR" |
| Compile error — BLE library not found | ESP32 core too old | Update esp32 core to latest |
| Master can't find Slave | Slave not advertising / too far | Restart Slave, move boards closer |
| Master connection fails | UUID mismatch / already connected | Check UUIDs, restart both boards |
| Connected but no messages | Characteristic mismatch / subscription failed | Check UUIDs, restart and reconnect |
| LED doesn't light up | Wrong pin / active-low LED | Confirm GPIO2, try swapping HIGH/LOW |
| Only one-way communication | TX subscription failed | Check BLE2902 descriptor and notify callback |
| Changes don't take effect | Not re-flashed | Re-upload code to the corresponding board |
💡 After completing the experiment, understanding the principles behind it will be much easier!
This project uses a dual-board architecture:
| Device | Role | Main Responsibilities | Analogy |
|---|---|---|---|
| ESP32 Slave | BLE Peripheral | Advertise Bluetooth, wait for connection, provide services, respond to commands | Receiver / Controlled end |
| ESP32 Master | BLE Central | Scan devices, establish connection, send requests, actively control | Initiator / Controller |
Module relationship diagram:
ESP32_Slave.ino ESP32_Master.ino
├── BLEDevice::init() Init BLE ├── BLEDevice::init() Init BLE
├── createServer() Create BLE server ├── BLEScan Start scanning
├── createService() Create custom service ├── onResult() Discover Slave
├── TX Characteristic (NOTIFY) ├── BLEClient::connect() Establish connection
│ → Slave serial input → Notify Master ├── getService() Get service
├── RX Characteristic (WRITE) ├── getCharacteristic(TX) Subscribe to notify
│ → Receive Master message → Display │ → Master receives Slave message
├── handleCommand() Command handling ├── getCharacteristic(RX) Send message
└── digitalWrite(LED_PIN) Control LED │ → Master writes message to Slave
├── notifyCallback() Receive Slave notifications
├── handleCommand() Command handling
└── digitalWrite(LED_PIN) Control LED
BLE uses the GATT (Generic Attribute Profile) protocol for data exchange. The core concepts are "services" and "characteristics":
| Concept | Analogy | Description |
|---|---|---|
| Service | An app | A collection of related functions, identified by UUID |
| Characteristic | A data item in an app | The actual data-carrying unit, with different properties |
The custom service in this project contains two characteristics:
| Characteristic | UUID | Property | Direction |
|---|---|---|---|
| RX | beb5483e-... |
Write | Master → Slave |
| TX | 8d8f28a9-... |
Notify | Slave → Master |
⚠️ Note:
writeValue()requiresuint8_t*type. When usingc_str(), a cast is needed:writeValue((uint8_t*)str.c_str(), len, false). The third parameter set tofalsemeans Write Command (no response), matching the Slave'sPROPERTY_WRITE_NRproperty. Alternatively, use theStringoverload:writeValue(packet, false).
BLE communication involves two main roles:
| Role | Description | Analogy |
|---|---|---|
| Peripheral | Broadcasts itself, waits for other devices to connect | Bluetooth speaker, smartwatch |
| Central | Scans for and connects to other devices | Phone, computer |
In this project:
ESP32_BLE_Slave, waits for connection💡 This is similar to the "master/slave" concept in Classic Bluetooth, but BLE's Peripheral/Central roles can be swapped during connection (Role Switching). In this project, the Master is the initiator and the Slave is the passive party.
1. Slave: BLEDevice::init("ESP32_BLE_Slave")
└─→ Initialize BLE protocol stack
2. Slave: createService() + createCharacteristic()
└─→ Create GATT service and characteristics
3. Slave: BLEDevice::startAdvertising()
└─→ Start advertising, wait for connection
4. Master: BLEScan::start()
└─→ Scan for nearby BLE devices
5. Master: onResult() callback
└─→ Discover Slave with matching Service UUID
6. Master: BLEClient::connect(targetDevice)
└─→ Initiate GATT connection
7. Slave: onConnect() callback
└─→ Accept connection, stop advertising
8. Master: getService() → getCharacteristic()
└─→ Discover service and characteristics
9. Master: registerForNotify()
└─→ Subscribe to Slave's TX notifications
10. Bidirectional communication established!
| Feature | Classic BT (SPP) | BLE | This Project |
|---|---|---|---|
| Transport model | Serial stream | GATT service/characteristic | GATT |
| Power consumption | Higher | Very low | BLE |
| Connection speed | Slower (pairing) | Fast (direct connection) | BLE |
| Reliability | Stream-based | Packet-based | BLE |
| Chip support | ESP32 (original) only | ESP32 / S3 / C3, etc. | BLE |
| Multi-connection | Usually 1 | Multiple (mode dependent) | BLE |
📌 Why choose BLE: BLE is low-power Bluetooth with fast connection speed, low power consumption, and a broader ecosystem. Although Classic Bluetooth SPP can also achieve dual-board communication, BLE is more aligned with IoT trends and can directly interconnect with phones, web pages, and other BLE devices.
This project uses the / prefix to distinguish commands from regular messages. Both sides share the same command protocol:
| Command | Sender | Effect |
|---|---|---|
/ledon |
Master or Slave | Turn on the remote board's GPIO2 LED (local board unchanged) |
/ledoff |
Master or Slave | Turn off the remote board's GPIO2 LED (local board unchanged) |
/help |
Master or Slave | Request available command list from the remote side |
Command processing flow (using /ledon as an example):
Local serial input: /ledon
│
▼
handleCommand(msg, source="Local") ── is command? ──→ Forward command to remote only
│ no (do not operate local LED)
▼
Treat as regular chat message │
▼
Remote onWrite() / notifyCallback receives
│
▼
handleCommand(msg, source="remote")
│
▼
Execute local LED control + status echo
💡 This "command + regular message" coexistence design is identical to chat bots and smart home control protocols — the same channel can carry both data and control commands. The
sourceparameter distinguishes "local input" from "remote command", naturally implementing the "control remote only" semantics.
BLE uses callback functions to handle asynchronous events:
Slave-side callbacks:
| Callback | Trigger | Action |
|---|---|---|
ServerCallbacks::onConnect() |
Master connects | Set deviceConnected = true |
ServerCallbacks::onDisconnect() |
Master disconnects | Set deviceConnected = false, restart advertising |
RxCallbacks::onWrite() |
Master sends data | Parse message, handle command or display chat |
Master-side callbacks:
| Callback | Trigger | Action |
|---|---|---|
ClientCallbacks::onConnect() |
Connected to Slave | Set deviceConnected = true |
ClientCallbacks::onDisconnect() |
Connection lost | Set deviceConnected = false, reconnect |
ScanCallbacks::onResult() |
Device found during scan | Check if UUID matches |
notifyCallback() |
Slave sends notification | Parse message, handle command or display chat |
📌 Callbacks vs Polling: Callbacks are "event-driven" — they execute only when something happens, not wasting CPU when idle. This is much more efficient than constantly checking in loop().
| Library | Description |
|---|---|
BLEDevice.h |
BLE device management, initialization, creating server |
BLEServer.h |
BLE server, handling connect/disconnect callbacks |
BLEUtils.h |
BLE utility classes |
BLE2902.h |
CCC descriptor, required for notify functionality |
| Library | Description |
|---|---|
BLEDevice.h |
BLE device management, initialization |
BLEScan.h |
BLE scanning, device discovery |
BLEClient.h |
BLE client, connecting to remote devices |
BLERemoteCharacteristic.h |
Remote characteristic, reading/writing data |
BLEUtils.h |
BLE utility classes |
BLE2902.h |
CCC descriptor, required for notify functionality |
| Function | Side | Description |
|---|---|---|
BLEDevice::init(name) |
Both | Initialize BLE device, set name |
BLEDevice::setMTU(512) |
Both | Set MTU, affects max transfer bytes |
BLEDevice::createServer() |
Slave | Create BLE server |
BLEDevice::getScan() |
Master | Get BLE scanner |
pServer->createService(uuid) |
Slave | Create BLE service |
pService->createCharacteristic(uuid, props) |
Slave | Create characteristic |
pTx->addDescriptor(new BLE2902()) |
Slave | Add CCC descriptor (required for notify) |
pAdvertising->start() |
Slave | Start advertising |
pScan->start(0, false) |
Master | Start scanning (0 = unlimited) |
pClient->connect(device) |
Master | Connect to remote device |
pClient->getService(uuid) |
Master | Get remote service |
pService->getCharacteristic(uuid) |
Master | Get remote characteristic |
pTx->registerForNotify(callback) |
Master | Register notification callback |
pRx->writeValue(packet, false) |
Master | Write data to remote characteristic, false = Write Command (no response), matching Slave's WRITE_NR property |
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID_RX "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define CHARACTERISTIC_UUID_TX "8d8f28a9-467b-4f25-a019-7cc5e6c8f721"
#define LED_PIN 2
BLEServer *pServer = nullptr;
BLECharacteristic *pTxCharacteristic = nullptr;
bool deviceConnected = false;
Logic: Defines BLE service UUID, characteristic UUIDs, LED pin, and state variables.
Key points:
RX characteristic: Master writes data here (Master → Slave)TX characteristic: Slave sends data via notification (Slave → Master)deviceConnected: Connection state flagvoid sendViaBLE(const String &msg) {
if (deviceConnected && pTxCharacteristic != nullptr) {
pTxCharacteristic->setValue((msg + "\n").c_str());
pTxCharacteristic->notify();
delay(10);
}
}
Logic: Checks connection state, writes message to TX characteristic and sends notification.
Key points:
setValue((msg + "\n").c_str()): Sets the data to send, appends \n as message delimiternotify(): Actively pushes data to the subscribed Masterdelay(10): Gives the BLE protocol stack time to send dataclass ServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer *pServer) {
deviceConnected = true;
}
void onDisconnect(BLEServer *pServer) {
deviceConnected = false;
BLEDevice::startAdvertising();
}
};
class RxCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pCharacteristic) {
String msg = pCharacteristic->getValue();
msg.trim();
if (msg.length() > 0) {
if (!handleCommand(msg, "Master")) {
// Not a command, treat as regular chat message
}
}
}
};
Logic:
ServerCallbacks: Handles connect/disconnect eventsRxCallbacks: Handles data received from Mastervoid setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
BLEDevice::init("ESP32_BLE_Slave");
BLEDevice::setMTU(512);
pServer = BLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
BLEService *pService = pServer->createService(SERVICE_UUID);
// TX characteristic (notify)
pTxCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID_TX, BLECharacteristic::PROPERTY_NOTIFY);
pTxCharacteristic->addDescriptor(new BLE2902());
// RX characteristic (write)
BLECharacteristic *pRxCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID_RX,
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR);
pRxCharacteristic->setCallbacks(new RxCallbacks());
pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(true);
pAdvertising->start();
}
Logic:
Key points:
pAdvertising->addServiceUUID(SERVICE_UUID): Includes service UUID in advertising, so Master can discover via UUID filteringpAdvertising->setScanResponse(true): Enables scan response, allowing Master to get more device infovoid loop() {
if (Serial.available()) {
String msg = Serial.readStringUntil('\n');
msg.trim();
if (msg.length() > 0) {
if (!handleCommand(msg, "Local")) {
if (deviceConnected) {
sendViaBLE(msg);
} else {
Serial.println("Not connected...");
}
}
}
}
delay(10);
}
Logic: Checks serial input, first determines if it's a local command, otherwise sends to Master via BLE.
BLEClient *pClient = nullptr;
BLEScan *pScan = nullptr;
BLERemoteCharacteristic *pTxCharacteristic = nullptr;
BLERemoteCharacteristic *pRxCharacteristic = nullptr;
bool deviceConnected = false;
bool shouldConnect = false;
bool scanDone = false;
BLEAdvertisedDevice *targetDevice = nullptr;
Key points:
pClient: BLE client, used to connect to SlavepTxCharacteristic: Remote TX characteristic (receive data from Slave)pRxCharacteristic: Remote RX characteristic (send data to Slave)scanDone: Scan complete flagtargetDevice: Stores scanned target device infoclass ScanCallbacks : public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice advertisedDevice) {
if (advertisedDevice.haveServiceUUID() &&
advertisedDevice.getServiceUUID().toString() == SERVICE_UUID) {
targetDevice = new BLEAdvertisedDevice(advertisedDevice);
pScan->stop();
scanDone = true;
}
}
};
Logic: Triggered for each device found during scan, checks if service UUID matches.
Key points:
advertisedDevice.haveServiceUUID(): Checks if advertising contains service UUIDpScan->stop(): Stops scanning immediately after finding targetscanDone = true: Notifies main loop to start connectingbool connectToSlave() {
pClient = BLEDevice::createClient();
pClient->setClientCallbacks(new ClientCallbacks());
if (!pClient->connect(targetDevice)) {
return false;
}
BLERemoteService *pService = pClient->getService(SERVICE_UUID);
if (pService == nullptr) {
pClient->disconnect();
return false;
}
pTxCharacteristic = pService->getCharacteristic(CHARACTERISTIC_UUID_TX);
pRxCharacteristic = pService->getCharacteristic(CHARACTERISTIC_UUID_RX);
if (pTxCharacteristic->canNotify()) {
pTxCharacteristic->registerForNotify(notifyCallback);
}
return true;
}
Logic:
Key points:
pClient->connect(): Initiates GATT connectionpClient->getService(): Discovers service from SlaveregisterForNotify(): Registers notification callback to receive Slave datavoid notifyCallback(BLERemoteCharacteristic *pCharacteristic,
uint8_t *data, size_t length, bool isNotify) {
String msg = "";
for (size_t i = 0; i < length; i++) {
msg += (char)data[i];
}
msg.trim();
// Parse and display message
}
Logic: When Slave sends a notification, this callback is automatically invoked, converting byte data to a string and displaying it.
void setup() {
Serial.begin(115200);
BLEDevice::init("ESP32_BLE_Master");
startScan();
}
void loop() {
if (scanDone) {
connectToSlave();
}
if (shouldConnect && !deviceConnected) {
connectToSlave();
}
if (Serial.available()) {
// Read serial input, send to Slave
}
delay(10);
}
Logic:
setup(): Initialize BLE and start scanningloop(): Check scan status, connection status, and serial inputSlave side:
BLEDevice::init("Your_Slave_Name");
Master side:
BLEDevice::init("Your_Master_Name");
Modify in both files:
#define LED_PIN 2 // Change to your desired pin
Common ESP32 dev board onboard LED pins:
Add an if branch in the handleCommand() function in both files. New commands follow the "local input → forward to remote for execution, remote command → execute locally" pattern:
// Write this in both Slave and Master's handleCommand()
if (msg == "/blink") { // Blink remote LED 3 times
if (source == "Local") {
sendViaBLE("/blink"); // Local input → forward command to remote
Serial.println(">>> Sent blink command.");
} else { // source == "remote name"
for (int i = 0; i < 3; i++) { // Remote command → execute locally
digitalWrite(LED_PIN, HIGH); delay(200);
digitalWrite(LED_PIN, LOW); delay(200);
}
}
return true;
}
⚠️ Both sides must be modified in sync: Since commands are handled on both sides independently, new commands must be added to both Slave and Master code.
💡
sourceparameter rules: On the Master side,"Local"= local serial input,"Slave"= notification from Slave; on the Slave side,"Local"= local serial input,"Master"= write from Master.
To customize UUIDs (must be identical in both files):
#define SERVICE_UUID "your-uuid-here"
#define CHARACTERISTIC_UUID_RX "your-uuid-here"
#define CHARACTERISTIC_UUID_TX "your-uuid-here"
💡 You can use an online UUID generator (e.g., https://www.uuidgenerator.net/) to generate random UUIDs.
BLEDevice::setMTU(256); // Min 23, max ~517
⚠️ MTU settings on both sides don't need to be identical — the actual MTU is the smaller of the two. The default 512 is sufficient for chat.