This demo lets you use a web page as a wireless serial terminal to your ESP32—no USB cable needed. Under the hood it uses:
• An Arduino-style sketch on the ESP32 implementing the Nordic UART Service (NUS) over Bluetooth Low Energy (BLE).
• A HTML page with Web-Bluetooth JavaScript that:
– Opens the standard BLE device picker in your browser.
– Connects to the ESP32’s GATT server.
– Subscribes to the “TX” characteristic so incoming ESP32 serial prints appear in the page.
– Writes to the “RX” characteristic so anything you type in the page is sent to the ESP32’s Serial Monitor.

Key Benefits:
• Wireless serial terminal: monitor and control the ESP32 from your phone or desktop browser.
• No native app required—runs in Chrome (desktop or Android) with just a web page.
• Works on Android Chrome Beta/Dev/Canary (enable Web Bluetooth flags + grant “Nearby devices” permission) and on desktop Chrome without extra setup.
• Simple, bidirectional, line-oriented console: type a line in the page → it appears on Serial; Serial.println() on the ESP32 → it appears in the page log.
Load this on your ESP32 (Wemos LOLIN32, DevKitC, etc.):
#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#include <BLE2902.h>
// UART Service UUIDs for own use https://www.uuidgenerator.net/
#define NUS_SERVICE_UUID "6e28c1e1-29f7-4e0c-9ac1-69c4930153ab"
#define NUS_RX_CHAR_UUID "6e28c1e1-29f7-4e0c-9ac1-69c4930153ab"
#define NUS_TX_CHAR_UUID "6e28c1e1-29f7-4e0c-9ac1-69c4930153ab"
BLECharacteristic *pTxCharacteristic;
bool deviceConnected = false;
// Track connect/disconnect to restart advertising
class ServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer* server) override {
deviceConnected = true;
}
void onDisconnect(BLEServer* server) override {
deviceConnected = false;
server->getAdvertising()->start();
}
};
// Handle data written by the web page (RX characteristic)
class RXCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic* characteristic) override {
String rx = characteristic->getValue();
if (rx.length()) {
// 1) Print to Serial Monitor
Serial.print("From HTML → ESP32: ");
Serial.println(rx);
// 2) Echo back over BLE (TX characteristic)
pTxCharacteristic->setValue(rx);
pTxCharacteristic->notify();
}
}
};
void setup() {
// Initialize Serial Monitor
Serial.begin(115200);
while (!Serial) {
delay(10);
}
// 1) Create BLE device
BLEDevice::init("ESP32-BLE-UART");
BLEServer *pServer = BLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
// 2) Create NUS service
BLEService *pService = pServer->createService(NUS_SERVICE_UUID);
// 3) Create TX characteristic (ESP32 → client notifications)
pTxCharacteristic = pService->createCharacteristic(
NUS_TX_CHAR_UUID,
BLECharacteristic::PROPERTY_NOTIFY
);
pTxCharacteristic->addDescriptor(new BLE2902()); // enable notifications
// 4) Create RX characteristic (client → ESP32 writes)
BLECharacteristic *pRxCharacteristic = pService->createCharacteristic(
NUS_RX_CHAR_UUID,
BLECharacteristic::PROPERTY_WRITE
);
pRxCharacteristic->setCallbacks(new RXCallbacks());
// 5) Start service and begin advertising
pService->start();
pServer->getAdvertising()->start();
Serial.println(">> BLE UART started. Waiting for HTML client to connect...");
}
void loop() {
// Forward anything printed to Serial while connected back to the page
while (Serial.available()) {
String line = Serial.readStringUntil('\n');
if (line.length() && deviceConnected) {
pTxCharacteristic->setValue(line);
pTxCharacteristic->notify();
}
}
delay(20);
}
HTML + JS PAGE
Save as index.html locally, then open in your browser:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1">
<title>Serial API Over BLE</title>
<style>
body {
margin:0; padding:1rem;
font-family:sans-serif;
display:flex; flex-direction:column;
height:90vh; background:#f9f9f9;
}
h1 {
margin:0 0 1rem;
font-size:1.6rem;
text-align:left;
}
#controls {
display:flex; gap:.5rem;
margin-bottom:1rem;
}
button, input {
font-size:1rem;
padding:.5rem;
border:1px solid #888;
border-radius:4px;
background:#fff;
}
button { background:#006aff; color:#fff; }
button:disabled { background:#999; }
input { flex:1; }
#console {
flex:1;
background:#fff; border:1px solid #ccc;
border-radius:4px; padding:.5rem;
overflow-y:auto; font-size:.9rem; line-height:1.2;
max-height: 60vh;
}
.log-entry { margin:0 0 .25rem; }
</style>
</head>
<body>
<h1>Serial API Over BLE</h1>
<div id="controls">
<button id="btnPick">Pick Device</button>
<input id="txtSend"
placeholder="Type message…"
disabled
autocomplete="off"
autocorrect="off"
autocapitalize="none">
<button id="btnSend" disabled>Send</button>
</div>
<div id="console"></div>
<script>
const btnPick = document.getElementById('btnPick');
const btnSend = document.getElementById('btnSend');
const txtSend = document.getElementById('txtSend');
const consoleEl= document.getElementById('console');
let writeChar = null, notifyChar = null;
function log(text) {
const p = document.createElement('p');
p.className = 'log-entry';
p.textContent = text;
consoleEl.appendChild(p);
consoleEl.scrollTop = consoleEl.scrollHeight;
}
btnPick.onclick = async () => {
if (!navigator.bluetooth) {
return alert('Web Bluetooth not supported.\nUse Chrome Beta/Dev/Canary and enable the flags.');
}
log('Requesting device…');
try {
const device = await navigator.bluetooth.requestDevice({
acceptAllDevices: true,
optionalServices: []
});
log(`Connecting to GATT on “${device.name||device.id}”…`);
const server = await device.gatt.connect();
log('✅ Connected. Discovering services…');
const services = await server.getPrimaryServices();
for (const svc of services) {
const chars = await svc.getCharacteristics();
for (const c of chars) {
const props = c.properties;
if (!writeChar && (props.write || props.writeWithoutResponse)) {
writeChar = c;
log(`• Found write char: ${c.uuid}`);
}
if (!notifyChar && props.notify) {
notifyChar = c;
await c.startNotifications();
c.addEventListener('characteristicvaluechanged', e => {
const v = new TextDecoder().decode(e.target.value);
log(`⟵ ${c.uuid}: ${v.trim()}`);
});
log(`• Subscribed notify char: ${c.uuid}`);
}
if (writeChar && notifyChar) break;
}
if (writeChar && notifyChar) break;
}
if (!writeChar) log('⚠️ No writable characteristic found.');
if (!notifyChar) log('⚠️ No notifiable characteristic found.');
if (writeChar) {
txtSend.disabled = false;
btnSend.disabled = false;
txtSend.focus();
log('You can now type a message and press Send or Enter.');
}
} catch (err) {
log('❌ ' + err);
}
};
async function sendMessage() {
const text = txtSend.value.trim();
if (!text || !writeChar) return;
try {
await writeChar.writeValue(new TextEncoder().encode(text + '\n'));
log(`⟶ ${writeChar.uuid}: ${text}`);
txtSend.value = '';
txtSend.focus();
} catch (e) {
log('❌ Send error: ' + e);
}
}
btnSend.onclick = sendMessage;
// Send on Enter key
txtSend.addEventListener('keydown', e => {
if (e.key === 'Enter' && !btnSend.disabled) {
e.preventDefault();
sendMessage();
}
});
</script>
</body>
</html>
BROWSER SETTINGS & PERMISSIONS
On Android Chrome (Beta/Dev/Canary):
- chrome://flags → Enable
- “Web Bluetooth” (
#enable-web-bluetooth) - “Web Bluetooth Scanning” (
#enable-web-bluetooth-scanning)
- “Web Bluetooth” (
- Relaunch browser.
- Android Settings → Apps → Chrome → Permissions → Nearby devices (or Location) → Allow.
On Desktop Chrome (v56+), no flags or permissions are needed—just open the page locally.
HOW TO USE
Power on your ESP32 with the BLE-UART sketch.
- Save HTML page locally.
- Open the page in Chrome (Android or Desktop).
- Tap Connect to ESP32, select “ESP32-BLE-UART” in the picker.
- Once “Connected!” appears, type a message and click Send (or press Enter).
- Watch messages echo in both the page’s log and the ESP32’s Serial Monitor.