After completing this project, you'll have a ESP32 + Computer Webpage Bluetooth two-way chat system:
/ledon and /ledoff commands from the web pageSimple analogy: It's like chatting with a device via WeChat Web โ the web page is the chat window, the ESP32 is the "person" on the other end. You send messages, it receives them; it sends messages, you see them; and you can remotely control its light.
๐ก Difference from the dual-board Bluetooth version: The dual-board version requires two ESP32 boards to communicate with each other; this project only needs one ESP32 + a computer browser, and the web page talks directly to the ESP32 via Bluetooth.
| Device | Description |
|---|---|
| ESP32 development board ร 1 | Must support BLE (ESP32 / ESP32-WROOM / ESP32-S3 all work) |
| USB data cable ร 1 | For code flashing and serial communication |
| Computer ร 1 | Needs Bluetooth module (laptops almost always have one) |
| Software | Description |
|---|---|
| Arduino IDE | Used to compile and flash ESP32 code |
| Chrome or Edge browser | โ ๏ธ Must use Chrome or Edge โ Firefox and Safari do not support Web Bluetooth API |
โ ๏ธ Important! Web Bluetooth API is only supported in Chrome, Edge (Chromium-based), and Opera. Firefox and Safari are not supported. Chrome for Android on mobile is also supported.
ESP32_BLE_ChatESP32_BLE_Chat/ledon โ ESP32 lights up GPIO2 LED/ledoff โ ESP32 turns off LEDESP32_Bluetooth_Chat/ESP32_Bluetooth_Chat.ino โ ESP32 flashing codeweb/index.html โ Computer Bluetooth web page๐ก Default configuration works out of the box! If you want to customize the Bluetooth name, just modify the ESP32 code.
Change ESP32 Bluetooth name (ESP32_Bluetooth_Chat.ino):
BLEDevice::init("ESP32_BLE_Chat"); // โ ๏ธ Change to your desired Bluetooth name
โ ๏ธ If you change the Bluetooth name, just select the modified name when the web page searches. No changes needed to the web page code (it automatically lists all available BLE devices).
File โ Open โ select ESP32_Bluetooth_Chat/ESP32_Bluetooth_Chat.inoTools โ Board โ select your ESP32 dev board (e.g., ESP32 Dev Module)Tools โ Port โ select the COM port corresponding to your ESP32Upload button (arrow icon) to start flashingHard resetting via RTS pin... indicating successCtrl+Shift+M)Method 1: Double-click to open (recommended to try first)
Double-click web/index.html and it will open with your default browser. If your default browser is Chrome or Edge, it usually works directly.
Method 2: Open with a local server (most stable)
If Method 1 doesn't work (some Chrome versions restrict Bluetooth permissions for file:// protocol), start a local server using Python:
web folder:python -m http.server 8000
http://localhost:8000 in Chrome or Edge๐ก Don't have Python? You can also use VS Code's "Live Server" extension โ right-click
index.htmlโ "Open with Live Server".
========================================
ESP32 BLE Chat โ Starting up...
========================================
[BLE] BLE device name: ESP32_BLE_Chat
[BLE] Advertising started. Open the web page to connect.
[BLE] Type a message and press Enter to send to the web page.
[BLE] Commands: /ledon /ledoff /help
BLE device name: ESP32_BLE_Chat โ BLE initialized successfully โAdvertising started โ Advertising started, can be discovered by web page โindex.html in Chrome / EdgeESP32_BLE_Chat[BLE] >>> Web client connected!Type a message on the web page and press Enter or click "Send":
[14:30:25] Hello ESP32! (green, right-aligned)[BLE] Received: Hello ESP32!Type a message on the ESP32 serial and press Enter:
[BLE] Sent: Hello web page![14:30:35] Hello web page! (blue, left-aligned)Method 1: Click web page buttons
The web page displays the sent command, and ESP32 serial shows:
[BLE] >>> Web command: LED turned ON
ESP32 also sends a confirmation message via Bluetooth, and the web page displays:
[14:31:00] [ESP32] LED turned ON
Method 2: Type commands directly in the web page input box
Type /ledon or /ledoff in the web page input box and press Enter โ the effect is the same as clicking the buttons.
Method 3: Type commands in the ESP32 serial port
Type /ledon or /ledoff in the ESP32 serial monitor โ ESP32's local LED is also controlled, and the web page is notified via Bluetooth.
Get help: Type /help on either end to display the available command list.
Close the web page tab or click the "Disconnect" button. ESP32 serial will show:
[BLE] <<< Web client disconnected. Restarting advertising...
ESP32 automatically restarts advertising. Simply open the web page again and click "Connect Bluetooth" to reconnect.
๐ก Newcomers, read this first: 90% of issues are covered below.
A: Incorrect baud rate.
A: Incorrect line ending setting.
A: ESP32 core version is too old or wrong board selected.
esp32 core to the latest version in Arduino IDE's "Board Manager"Tools โ Board โ select ESP32 Dev Module (do not select ESP32-S2)A: Browser doesn't support Web Bluetooth or permission denied.
file:// doesn't work, use a local server (http://localhost:8000)A: ESP32 is not advertising or computer can't find it.
Advertising startedBLEDevice::init("ESP32_BLE_Chat") in the code hasn't been modified incorrectlyA: Possibly characteristic subscription failed.
>>> Web client connected!A: Possibly not connected or timing issue.
Web client connectedNot connected. Open the web page first.A: Check the following:
[ESP32] LED turned ONLED_PIN = 2 (GPIO2) โ most ESP32 boards have the onboard LED on this pinHIGH and LOW>>> Web command: LED turned ONA: BLE default MTU limitation.
BLEDevice::setMTU(512) โ single message max about 500 bytesA:
Ctrl+F5 for forced refresh)| Symptom | Most Likely Cause | Fastest Solution |
|---|---|---|
| Garbled / blank serial | Wrong baud rate | Change to 115200, press EN reset |
| No response to serial input | Wrong line ending | Change to "Newline" or "Both NL & CR" |
| Compilation error: BLE not found | ESP32 core too old | Update esp32 core to latest version |
| Web page can't pop up Bluetooth window | Browser not supported | Switch to Chrome or Edge |
| Can't find ESP32 device | ESP32 not advertising / Bluetooth off | Restart ESP32, check computer Bluetooth |
| Connected but can't receive messages | UUID mismatch / subscription failed | Check UUID, refresh and reconnect |
| LED not lighting | Wrong pin / active-low LED | Confirm GPIO2, try reversing HIGH/LOW |
| Web page file:// not working | Protocol restriction | Use local server http://localhost:8000 |
| Changes not taking effect | Not re-flashed / not refreshed | Re-upload + Ctrl+F5 refresh |
๐ก After completing the experiment, understanding the principles behind it will give you twice the result with half the effort!
This project uses a single-board + web page architecture:
| Device | Role | Main Responsibilities | Analogy |
|---|---|---|---|
| ESP32 | BLE Peripheral | Advertising Bluetooth, waiting for connections, sending/receiving messages, controlling LED | Chat server + controlled device |
| Computer web page | BLE Central | Searching for devices, establishing connections, sending/receiving messages, sending commands | Chat client + controller |
Module Relationship Diagram:
ESP32 (.ino) Web Page (index.html)
โโโ BLEDevice::init() Initialize BLE โโโ navigator.bluetooth.requestDevice() Search for devices
โโโ createServer() Create BLE server โโโ gatt.connect() Establish GATT connection
โโโ createService() Create UART service โโโ getPrimaryService() Get service
โโโ TX Characteristic (NOTIFY) โโโ getCharacteristic(TX) Subscribe to notifications
โ โ ESP32 serial input โ send to web โโโ TX onValueChanged Receive messages
โโโ RX Characteristic (WRITE) โโโ getCharacteristic(RX) Get write characteristic
โ โ Received web messages โ display โโโ RX writeValue() Send messages
โโโ handleCommand() Command handling โโโ LED buttons โ Send /ledon /ledoff
โโโ digitalWrite(LED_PIN) Control LED โโโ Display chat messages
This project must use BLE (Bluetooth Low Energy) rather than Classic Bluetooth (Classic BT / SPP) because:
| Feature | Classic BT (SPP) | BLE | This Project |
|---|---|---|---|
| Web Bluetooth API Support | โ Not supported | โ Supported | โ Must use BLE |
| Transfer Model | Serial stream | GATT service/characteristic | GATT |
| Power Consumption | Higher | Very low | BLE |
| Connection Speed | Slower (pairing) | Fast (direct connection) | BLE |
| Chip Support | ESP32 (original) only | ESP32 / S3 / C3, etc. | BLE |
๐ Key reason: Web Bluetooth API (webpage Bluetooth) only supports BLE, not Classic Bluetooth SPP. If you use BluetoothSerial (Classic Bluetooth), the web page cannot connect. Therefore this project uses BLE's Nordic UART-like service to simulate serial communication.
BLE uses the GATT (Generic Attribute Profile) protocol for data exchange, with core concepts being "Service" and "Characteristic":
| Concept | Analogy | Description |
|---|---|---|
| Service | An application | A collection of related functions, identified by UUID |
| Characteristic | A data item within the application | The actual data-bearing unit with different properties |
Nordic UART Service is a de facto BLE serial standard, defined by Nordic Semiconductor, widely used for BLE serial communication:
| Role | UUID | Property | Direction |
|---|---|---|---|
| Service | 6e400001-... |
โ | โ |
| TX Characteristic | 6e400002-... |
Notify | ESP32 โ Web page |
| RX Characteristic | 6e400003-... |
Write | Web page โ ESP32 |
Data Flow:
ESP32 Serial Input Web Page Input
โ โ
โผ โผ
sendViaBLE() writeValue()
โ โ
โผ โผ
TX Characteristic.notify() โโโ Web onValueChanged() display
RX Characteristic.onWrite() โโโ ESP32 serial display
๐ก Why use Nordic UART Service? Because it's widely supported โ many BLE serial tools and tutorials use this set of UUIDs, making interoperability convenient.
The complete process of connecting the web page to ESP32:
1. navigator.bluetooth.requestDevice({ filters: [{ services: [SERVICE_UUID] }] })
โโโ Browser pops up device selection window, user selects ESP32_BLE_Chat
2. device.gatt.connect()
โโโ Establish GATT connection
3. server.getPrimaryService(SERVICE_UUID)
โโโ Get Nordic UART Service
4. service.getCharacteristic(TX_CHAR_UUID)
โโโ Get TX characteristic (ESP32 โ Web page)
5. txCharacteristic.startNotifications()
โโโ Subscribe to notifications โ data from ESP32 will trigger events
6. txCharacteristic.addEventListener('characteristicvaluechanged', callback)
โโโ Register callback function โ automatically called when data is received
7. service.getCharacteristic(RX_CHAR_UUID)
โโโ Get RX characteristic (Web page โ ESP32)
8. rxCharacteristic.writeValue(data)
โโโ Send data to ESP32
โ ๏ธ Security restriction:
requestDevice()must be triggered by a user gesture (such as a button click) โ it cannot be called automatically. This is a browser security policy.
The ESP32 BLE library uses callback functions to handle asynchronous events, similar to Classic Bluetooth event callbacks:
| Callback | Trigger Timing | Handling in This Project |
|---|---|---|
onConnect() |
Web page connects to ESP32 | Set deviceConnected = true, print log |
onDisconnect() |
Web page disconnects | Set deviceConnected = false, restart advertising |
onWrite() |
Data arrives from web page | Parse message, handle command or display as chat |
๐ Callback vs Polling: Callbacks are "event-driven" โ they only execute when there's something to do, wasting no CPU. They are much more efficient than constantly checking in loop().
This project distinguishes commands from regular messages using the / prefix:
| Command | Source | Function |
|---|---|---|
/ledon |
Web page or serial | Turn on GPIO2 LED |
/ledoff |
Web page or serial | Turn off GPIO2 LED |
/help |
Web page or serial | Display help information |
Command Processing Flow:
Message received (web onWrite or serial input)
โ
โผ
handleCommand(msg) โโ Is it a command? โโโ Execute LED control / Show help
โ No
โผ
Display as regular chat message
๐ก This "command + regular message" coexistence design is exactly like group chat bot commands or QQ's
/commands โ the same channel can both chat and issue control commands.
| Library | Source | Description |
|---|---|---|
BLEDevice.h |
ESP32 core built-in | BLE device management โ initialization, create server/client |
BLEServer.h |
ESP32 core built-in | BLE server โ handle connection/disconnection callbacks |
BLEUtils.h |
ESP32 core built-in | BLE utility classes |
BLE2902.h |
ESP32 core built-in | CCC descriptor โ required for notify functionality |
| Function/Instruction | Meaning and Purpose |
|---|---|
BLEDevice::init(name) |
Initialize BLE device, set Bluetooth name |
BLEDevice::setMTU(512) |
Set MTU size, affects max bytes per transmission |
BLEDevice::createServer() |
Create BLE server |
pServer->setCallbacks(cb) |
Register server callback (connect/disconnect events) |
pServer->createService(uuid) |
Create BLE service |
pService->createCharacteristic(uuid, props) |
Create characteristic, specify UUID and properties |
pTx->addDescriptor(new BLE2902()) |
Add CCC descriptor, required for notify functionality |
pTx->setValue(data) |
Set characteristic data |
pTx->notify() |
Send notification to connected clients |
pRx->setCallbacks(cb) |
Register characteristic callback (write events) |
pCharacteristic->getValue() |
Get written data |
BLEDevice::startAdvertising() |
Start advertising, so clients can discover and connect |
| Function/Instruction | Meaning and Purpose |
|---|---|
navigator.bluetooth.requestDevice(options) |
Pop up device selection window, filter by service UUID |
device.gatt.connect() |
Establish GATT connection |
server.getPrimaryService(uuid) |
Get specified service |
service.getCharacteristic(uuid) |
Get specified characteristic |
characteristic.startNotifications() |
Subscribe to notifications, trigger events when data arrives |
characteristic.writeValue(data) |
Write data to characteristic |
characteristicvaluechanged event |
Notification event, triggered when characteristic data changes |
#define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
#define CHARACTERISTIC_UUID_RX "6e400003-b5a3-f393-e0a9-e50e24dcca9e" // Write (Web -> ESP32)
#define CHARACTERISTIC_UUID_TX "6e400002-b5a3-f393-e0a9-e50e24dcca9e" // Notify (ESP32 -> Web)
#define LED_PIN 2
BLEServer *pServer = nullptr;
BLECharacteristic *pTxCharacteristic = nullptr;
bool deviceConnected = false;
Runtime Logic: Defines BLE service UUID, LED pin, and state variables.
Key Instruction Analysis:
RX characteristic: Web page writes data here (Web โ ESP32)TX characteristic: ESP32 sends data via notification (ESP32 โ Web)LED_PIN = 2: GPIO2, onboard LEDpTxCharacteristic: Global pointer because notify() needs to be called from multiple placesdeviceConnected: Connection state flag, controls whether data is sentvoid sendViaBLE(const String &msg) {
if (deviceConnected && pTxCharacteristic != nullptr) {
pTxCharacteristic->setValue((msg + "\n").c_str());
pTxCharacteristic->notify();
delay(10);
}
}
Runtime Logic: Check connection status, write message to TX characteristic, and send notification.
Key Instruction Analysis:
setValue((msg + "\n").c_str()): Set data to be sent, append \n as message separatornotify(): Push data proactively to subscribed clientsdelay(10): Give the BLE stack time to send data, avoiding packet loss during continuous sendsbool handleCommand(const String &msg, const String &source) {
if (msg == "/ledon") {
digitalWrite(LED_PIN, HIGH);
Serial.println("[BLE] >>> " + source + " command: LED turned ON");
sendViaBLE("[ESP32] LED turned ON");
return true;
}
if (msg == "/ledoff") {
digitalWrite(LED_PIN, LOW);
Serial.println("[BLE] >>> " + source + " command: LED turned OFF");
sendViaBLE("[ESP32] LED turned OFF");
return true;
}
if (msg == "/help") {
sendViaBLE("[ESP32] Commands: /ledon /ledoff /help");
return true;
}
return false;
}
Runtime Logic: Check if the message is a known command โ if so, execute and return true.
Key Instruction Analysis:
source parameter: Marks the command source ("Web" or "Local"), making log distinction easierdigitalWrite(LED_PIN, HIGH/LOW): Control LED on/offsendViaBLE(...): Send confirmation message to web page after executing the commandtrue: Tells the caller "this is a command, already handled โ don't display as chat message"class ServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer *pServer) {
deviceConnected = true;
Serial.println("[BLE] >>> Web client connected!");
}
void onDisconnect(BLEServer *pServer) {
deviceConnected = false;
Serial.println("[BLE] <<< Web client disconnected. Restarting advertising...");
BLEDevice::startAdvertising();
}
};
class RxCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pCharacteristic) {
String rxValue = pCharacteristic->getValue();
if (rxValue.length() == 0) return;
String msg = rxValue;
msg.trim();
if (msg.length() == 0) return;
if (!handleCommand(msg, "Web")) {
Serial.print("[BLE] Received: ");
Serial.println(msg);
}
}
};
Runtime Logic:
ServerCallbacks: Handle connect/disconnect events, automatically restart advertising on disconnectRxCallbacks: Handle data from web page โ first check if it's a command, if not, display as chatKey Instruction Analysis:
onConnect / onDisconnect: BLE server callbacks, triggered asynchronouslyonWrite: Characteristic write callback โ triggered every time the web page calls writeValue()pCharacteristic->getValue(): Get the raw bytes writtenBLEDevice::startAdvertising(): Restart advertising after disconnect, waiting for next connectionvoid setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
BLEDevice::init("ESP32_BLE_Chat");
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();
BLEDevice::startAdvertising();
}
Runtime Logic:
Key Instruction Analysis:
BLEDevice::setMTU(512): Increase MTU, allowing more bytes per single transmissionPROPERTY_NOTIFY: TX characteristic property, allows ESP32 to proactively push dataBLE2902(): CCC descriptor โ clients subscribe to notifications through itPROPERTY_WRITE | PROPERTY_WRITE_NR: RX characteristic supports both acknowledged write and unacknowledged writepService->start(): Start the service so clients can discover itstartAdvertising(): Start advertising so clients can find itvoid loop() {
if (Serial.available()) {
String msg = Serial.readStringUntil('\n');
msg.trim();
if (msg.length() > 0) {
if (!handleCommand(msg, "Local")) {
if (deviceConnected) {
sendViaBLE(msg);
Serial.print("[BLE] Sent: ");
Serial.println(msg);
} else {
Serial.println("[BLE] Not connected. Open the web page first.");
}
}
}
}
delay(10);
}
Runtime Logic:
/ledon) โ if so, execute directlyKey Instruction Analysis:
Serial.available(): Non-blocking check for serial inputhandleCommand(msg, "Local"): Local commands can also control LED without requiring web page connectionsendViaBLE(msg): Send regular messages via BLE notification to the web pageconnectBtn.addEventListener('click', async () => {
bluetoothDevice = await navigator.bluetooth.requestDevice({
filters: [{ services: [SERVICE_UUID] }]
});
bluetoothDevice.addEventListener('gattserverdisconnected', onDisconnected);
const server = await bluetoothDevice.gatt.connect();
const service = await server.getPrimaryService(SERVICE_UUID);
txCharacteristic = await service.getCharacteristic(TX_CHAR_UUID);
rxCharacteristic = await service.getCharacteristic(RX_CHAR_UUID);
await txCharacteristic.startNotifications();
txCharacteristic.addEventListener('characteristicvaluechanged', onValueChanged);
});
Runtime Logic:
requestDevice(): Pop up device selection window, filter by service UUIDgatt.connect(): Establish GATT connectionKey Instruction Analysis:
filters: [{ services: [SERVICE_UUID] }]: Only show devices containing this service, filtering out irrelevant Bluetooth devicesawait: Web Bluetooth API is entirely Promise-based โ must use async/await or .then()startNotifications(): Subscribe to notifications โ every time ESP32 calls notify(), an event will be triggeredasync function sendMessage(msg) {
const data = new TextEncoder().encode(msg + '\n');
await rxCharacteristic.writeValue(data);
displayMessage(msg, 'sent');
}
Runtime Logic: Encode the message as a byte array, write it to the RX characteristic โ ESP32's onWrite callback will receive it.
Key Instruction Analysis:
TextEncoder().encode(): Encode string to UTF-8 byte arraywriteValue(data): Write data to characteristic โ triggers onWrite callback on ESP32 side\n: Consistent with ESP32 convention, used as message separatorfunction onValueChanged(event) {
const value = event.target.value;
let text = '';
for (let i = 0; i < value.byteLength; i++) {
text += String.fromCharCode(value.getUint8(i));
}
receiveBuffer += text;
let idx;
while ((idx = receiveBuffer.indexOf('\n')) >= 0) {
const msg = receiveBuffer.substring(0, idx).trim();
receiveBuffer = receiveBuffer.substring(idx + 1);
if (msg.length > 0) {
displayMessage(msg, 'received');
}
}
}
Runtime Logic:
\n, each complete message is displayed as one chat entryKey Instruction Analysis:
event.target.value: DataView object containing received bytesgetUint8(i): Read byte by byteESP32 side:
BLEDevice::init("your_bluetooth_name");
No changes needed on the web page โ the device selection window will automatically list the discovered device names.
#define LED_PIN 2 // Change to your desired pin
Common onboard LED pins for ESP32 development boards:
Add an if branch in the handleCommand() function of the ESP32 code:
if (msg == "/blink") { // Blink 3 times
for (int i = 0; i < 3; i++) {
digitalWrite(LED_PIN, HIGH); delay(200);
digitalWrite(LED_PIN, LOW); delay(200);
}
sendViaBLE("[ESP32] Blink done");
return true;
}
Add a corresponding button on the web page:
<button id="blinkBtn" disabled>Blink LED</button>
document.getElementById('blinkBtn').addEventListener('click', () => sendMessage('/blink'));
All web page styles are in the <style> tag, and all logic is in the <script> tag. You can freely modify: