¿Por qué Web BLE cambia el juego para tus proyectos IoT?
La forma clásica de leer un sensor con ESP32 te obliga a montar un servidor HTTP, abrir puertos del router o publicar a la nube con MQTT y, encima, instalar una app dedicada en el teléfono. Web Bluetooth (Web BLE) rompe esa cadena: el navegador habla directamente con tu ESP32 vía Bluetooth Low Energy, sin app, sin WiFi y sin servidor intermedio. Abres una URL, presionas "Conectar" y empiezas a recibir lecturas con notificaciones push del firmware.
En este tutorial vas a montar un sensor ambiental BME280 sobre ESP32, exponerlo como periférico BLE con el servicio estándar Environmental Sensing (UUID 0x181A) y consumirlo desde una página web con tarjetas y gráficos en vivo usando Chart.js. Al final tendrás:
- Un ESP32 que actúa como servidor BLE con tres características (temperatura, humedad, presión) que notifican cambios.
- Una web app local (HTML + JS) que se conecta al ESP32, muestra los valores actuales y grafica el histórico.
- Una versión "móvil" funcional en Android, Windows y macOS con Chrome, y en iOS usando la app Bluefy como navegador BLE.
Heads up sobre compatibilidad: Web Bluetooth está soportado oficialmente en Chrome, Edge y Opera (Android/Windows/macOS). En iOS Safari NO funciona, pero la app gratuita Bluefy actúa como navegador BLE y rinde igual de bien.

Concepto: la arquitectura BLE en 90 segundos
Antes de soldar nada, vale entender qué está pasando "abajo":
- El ESP32 es el periférico (server): anuncia su existencia y expone un servicio con características. En este proyecto el servicio es Environmental Sensing y las características son las tres lecturas del BME280.
- El navegador es el central (client): cuando le das "Conectar", escanea, encuentra al ESP32 por nombre, abre la conexión GATT y se suscribe a las características.
- Notificaciones, no polling: cada vez que el ESP32 actualiza una característica con
pChar->notify(), el navegador recibe el dato en milisegundos sin tener que preguntar. Esto es lo que mantiene los gráficos fluidos. - UUIDs estándar SIG: usar
0x181A,0x2A6E,0x2A6Fy0x2A6Dpermite que apps genéricas de inspección BLE (nRF Connect, LightBlue) reconozcan tu dispositivo sin configuración extra.

Hardware y cableado
Conecta el BME280 al ESP32 por el bus I²C usando GPIO 21 (SDA) y GPIO 22 (SCL). Estos son los pines I²C por defecto del ESP32 DevKit; si tu placa los expone en otra posición, ajusta los #define del firmware. El BME280 funciona con 3.3 V. alimentalo desde el pin 3V3 del ESP32, NO desde 5V, o vas a freír el sensor.
| BME280 | ESP32 |
|---|---|
| VCC | 3V3 |
| GND | GND |
| SCL | GPIO 22 |
| SDA | GPIO 21 |

Una vez cableado, así se ve el montaje real sobre protoboard:

Preparar el Arduino IDE
Asumimos que ya tienes el core de ESP32 instalado en Arduino IDE (si no, sigue el tutorial oficial "Installing ESP32 Board in Arduino IDE"). Para este proyecto necesitas dos librerías más:
- Abre Programa → Incluir librería → Administrar bibliotecas…
- Busca Adafruit BME280 e instálala.
- Cuando aparezca el popup, acepta también la Adafruit Unified Sensor Library (dependencia).

Firmware ESP32: servidor BLE con tres características
Carga este sketch en tu ESP32. Implementa el servidor BLE, lee el BME280 cada 5 segundos y notifica los tres valores a cualquier cliente conectado.
/*
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details at https://RandomNerdTutorials.com/esp32-web-ble-sensor-visualization/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22
// BME280
Adafruit_BME280 bme;
// BLE UUIDs
// Environmental Sensing Service
#define SERVICE_UUID "181A"
// Temperature
#define TEMP_CHARACTERISTIC_UUID "2A6E"
// Humidity
#define HUM_CHARACTERISTIC_UUID "2A6F"
// Pressure
#define PRESS_CHARACTERISTIC_UUID "2A6D"
// BLE variables
BLEServer* pServer = nullptr;
BLECharacteristic* pTempChar = nullptr;
BLECharacteristic* pHumChar = nullptr;
BLECharacteristic* pPressChar = nullptr;
bool deviceConnected = false;
bool oldDeviceConnected = false;
// Send a reading every 5 seconds
const unsigned long SAMPLE_INTERVAL_MS = 5000;
unsigned long lastSampleMs = 0;
// BLE server callbacks
class ServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer* pServer) override {
deviceConnected = true;
Serial.println("BLE client connected.");
}
void onDisconnect(BLEServer* pServer) override {
deviceConnected = false;
Serial.println("BLE client disconnected.");
}
};
// Format a float to one decimal place and set it on a BLE characteristic,
// then call notify() so connected clients receive it immediately
void notifyFloat(BLECharacteristic* pChar, float value) {
char buf[16];
snprintf(buf, sizeof(buf), "%.1f", value);
pChar->setValue(buf);
pChar->notify();
}
void setup() {
Serial.begin(115200);
Serial.println("\nESP32 BME280 BLE Server starting...");
// Initialize BME280 Sensor
Wire.begin(SDA_PIN, SCL_PIN);
if (!bme.begin(0x76)) {
Serial.println("BME280 not found!");
while(1);
}
// ESP32 BLE init
BLEDevice::init("ESP32");
pServer = BLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
BLEService* pService = pServer->createService(SERVICE_UUID);
// Temperature characteristic — READ + NOTIFY
pTempChar = pService->createCharacteristic(
TEMP_CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
);
pTempChar->addDescriptor(new BLE2902());
// Humidity characteristic — READ + NOTIFY
pHumChar = pService->createCharacteristic(
HUM_CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
);
pHumChar->addDescriptor(new BLE2902());
// Pressure characteristic — READ + NOTIFY
pPressChar = pService->createCharacteristic(
PRESS_CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
);
pPressChar->addDescriptor(new BLE2902());
// Set initial values
pTempChar->setValue("0.0");
pHumChar->setValue("0.0");
pPressChar->setValue("0.0");
// Start the service
pService->start();
// Start BLE Device Advertising
BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(false);
pAdvertising->setMinPreferred(0x0);
BLEDevice::startAdvertising();
Serial.println("BLE advertising started. Waiting for client...");
}
void loop() {
unsigned long now = millis();
// Sample and notify on schedule
if (now - lastSampleMs >= SAMPLE_INTERVAL_MS) {
lastSampleMs = now;
float tempC = bme.readTemperature(); // °C
float humidity = bme.readHumidity(); // %
float pressurePa = bme.readPressure(); // Pa
float pressureHPa = pressurePa / 100.0F; // hPa
Serial.printf("Temp: %.1f °C Hum: %.1f %% Press: %.1f hPa\n",
tempC, humidity, pressureHPa);
// Notify BLE client with sensor readings if a BLE client is connected
if (deviceConnected) {
notifyFloat(pTempChar, tempC);
notifyFloat(pHumChar, humidity);
notifyFloat(pPressChar, pressureHPa);
}
}
// Handle BLE reconnect after unexpected disconnect
if (!deviceConnected && oldDeviceConnected) {
delay(500);
pServer->startAdvertising();
Serial.println("Restarted advertising.");
oldDeviceConnected = false;
}
if (deviceConnected && !oldDeviceConnected) {
oldDeviceConnected = true;
Serial.println("BLE client connected.");
}
}
Por qué este código está armado así (los detalles que no son obvios)
BLE2902en cada característica: es el descriptor Client Characteristic Configuration. Sin él, el cliente no puede activar las notificaciones. el navegador recibe los valores actuales al hacerread, pero NUNCA losnotify. Es la causa más común de "se conecta pero no actualiza solo".pChar->notify()consetValueantes: el orden importa. Si llamasnotify()sobre un valor viejo, mandas dato rancio.SAMPLE_INTERVAL_MS = 5000: 5 segundos es un buen punto medio. El BME280 puede sampling más rápido, pero a 5 s la batería dura mucho más si lo alimentas con LiPo, y los gráficos se ven naturales.- Reconexión gestionada con
oldDeviceConnected: cuando un cliente se desconecta, el ESP32 espera 500 ms y vuelve a anunciar. Sin eso, después de cerrar la pestaña tienes que reiniciar la placa para volver a parear.
Sube el código, abre el Serial Monitor a 115200 baudios y reinicia la placa. Deberías ver:

La web app: HTML + Chart.js + Web Bluetooth
La aplicación web es un único archivo index.html con CSS y JavaScript embebidos. Cuando el usuario presiona "Conectar", el navegador llama a navigator.bluetooth.requestDevice con un filtro por el nombre "ESP32" y abre la conexión GATT. Luego se suscribe a las tres características y, en cada characteristicvaluechanged, dibuja un nuevo punto en el gráfico Chart.js correspondiente.
Aquí va el esqueleto de la página (puedes copiarlo y servirlo localmente con python3 -m http.server o hospedarlo en GitHub Pages):
<!-- Rui Santos & Sara Santos - Random Nerd Tutorials -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>ESP32 Web BLE - SENSOR</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8">
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
</head>
<body>
<div class="topnav"><h1>ESP32 Web BLE - SENSOR</h1></div>
<div class="content">
<div class="connection-card">
<button id="connectBleButton" class="connectButton">Conectar al dispositivo BLE</button>
<button id="disconnectBleButton" class="disconnectButton hidden">Desconectar</button>
<div class="ble-status">
<span class="ble-dot" id="bleDot"></span>
<span id="bleState">Desconectado</span>
</div>
</div>
<div class="sensor-grid">
<div class="sensor-card temp">
<div class="sensor-label">Temperatura</div>
<div class="sensor-value">
<span id="tempValue">--</span><span id="tempUnitLabel"> °C</span>
</div>
</div>
<div class="sensor-card hum">
<div class="sensor-label">Humedad</div>
<div class="sensor-value"><span id="humValue">--</span><span> %</span></div>
</div>
<div class="sensor-card press">
<div class="sensor-label">Presión</div>
<div class="sensor-value"><span id="pressValue">--</span><span> hPa</span></div>
</div>
</div>
<div class="charts-section">
<div class="chart-card"><canvas id="tempChart"></canvas></div>
<div class="chart-card"><canvas id="humChart"></canvas></div>
<div class="chart-card"><canvas id="pressChart"></canvas></div>
</div>
</div>
</body>
</html>
Y el JavaScript clave que conecta al ESP32 y maneja las notificaciones:
var deviceName = 'ESP32';
var bleService = '0000181a-0000-1000-8000-00805f9b34fb';
var tempCharUUID = '00002a6e-0000-1000-8000-00805f9b34fb';
var humCharUUID = '00002a6f-0000-1000-8000-00805f9b34fb';
var pressCharUUID = '00002a6d-0000-1000-8000-00805f9b34fb';
async function connectToDevice() {
const device = await navigator.bluetooth.requestDevice({
filters: [{ name: deviceName }],
optionalServices: [bleService]
});
const gattServer = await device.gatt.connect();
const service = await gattServer.getPrimaryService(bleService);
await subscribeChar(service, tempCharUUID, handleTemp);
await subscribeChar(service, humCharUUID, handleHum);
await subscribeChar(service, pressCharUUID, handlePress);
}
async function subscribeChar(service, uuid, onValue) {
const characteristic = await service.getCharacteristic(uuid);
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', (e) => {
const v = new TextDecoder().decode(e.target.value);
onValue(parseFloat(v));
});
}
Las características envían texto plano ("23.5", "45.2", etc.) en lugar de el formato binario IEEE-11073 del estándar SIG. Es más fácil de debuggear, ocupa solo unos bytes más por notificación y el navegador lo parsea con un parseFloat.
¿No querés escribir el HTML/CSS desde cero? Los autores originales publicaron la app lista para usar en
https://ruisantosdotme.github.io/esp32-web-ble-sensor/. Podés abrirla directamente y empezar a conectar.
Probar en el navegador (y en iPhone con Bluefy)
Una vez cargado el firmware y abierta la web (local o el demo público), presiona Conectar. El navegador mostrará la lista de dispositivos BLE cercanos. selecciona "ESP32". En segundos verás los tres valores actualizándose y los gráficos rellenándose punto por punto.

Si estás en iPhone o iPad, descarga la app gratuita Bluefy, Web BLE Browser desde la App Store. Abre la URL de tu web app dentro de Bluefy y funciona idéntico a Chrome en Android.

Variantes y mejoras
Con el esqueleto andando, podés extender el proyecto en varias direcciones que el tutorial original NO cubre:
- Dashboard React con histórico persistente: en lugar de Chart.js vanilla, montá un cliente React (o Svelte) que guarde los últimos 24 h en
localStorageo IndexedDB. Cuando el usuario se conecte de nuevo, ve la curva completa sin perder datos al cerrar la pestaña. La libreríarechartsochart.jscon plugin de zoom funcionan perfecto. - Notificaciones push del navegador cuando un valor cruza un umbral: usá la Notifications API + un Service Worker. Si la temperatura sube de 30 °C, salta un toast nativo del SO. Útil para invernaderos, racks de servidores o el dormitorio del bebé.
- Comparativa de sensores: si querés más precisión barométrica para altímetros, reemplazá el BME280 por el BMP388 (mismo bus I²C, mismo código solo cambiando
Adafruit_BME280porAdafruit_BMP3XX). Si te interesa CO₂ + COVs, sumá un SGP40 en paralelo y exponé más características BLE. - Multi ESP32 en un solo dashboard: el navegador puede mantener varias conexiones BLE simultáneas. Renombrá cada ESP32 (
BLEDevice::init("ESP32-Sala"),"ESP32-Pieza", etc.) y la web los lista por separado con gráficos paralelos. Excelente para mapear una casa entera. - Logging a Google Sheets: con un
fetch()desde el navegador a un Google Apps Script publicado como Web App, podés persistir cada lectura en una hoja sin tocar la nube de nadie. Cero servidor propio.
Personalización para Chile
Todos los componentes están disponibles en MechatronicStore con stock local:
- ESP32 DevKit (WROOM-32) (SKU
X2-10V2). $7.990 CLP. Sirve cualquier ESP32 con BLE: si tienes un ESP32-S3 XIAO o un ESP32-C3, también funcionan con cambios mínimos. - Sensor BME280 I²C (SKU
GR1-10). $8.690 CLP. Si solo necesitas presión y temperatura (sin humedad), el BMP180 (SKUGE1-10, $2.490 CLP) es funcionalmente compatible cambiando la librería. - Protoboard 830 puntos MB102 (SKU
C-302). $3.790 CLP. Tamaño cómodo para dejar el ESP32 y el sensor con cables jumpers sin apretarlos. - Cables jumpers macho hembra 10 cm (SKU
C-415). $1.990 CLP. Necesitas 4 cables (VCC, GND, SDA, SCL). - Cable USB-C a USB-A 1 m (SKU
B-101). $2.190 CLP. Para programar la placa desde tu PC.
Costo total estimado: ~$24.660 CLP (excluyendo el cable USB si ya lo tienes).
Si el tutorial original menciona una "ESP32 DOIT V1 Board" o "ESP32-S3 DevKitC", el ESP32 DevKit (WROOM-32) del catálogo cumple exactamente el mismo rol. son variantes del mismo chip de Espressif con el mismo pinout I²C.
Recursos
- Tutorial original (inglés): ESP32 Web BLE: Live Sensor Data Visualization (BME280 Charts)
- Web app pre hospedada por el autor: esp32-web ble-sensor
- Documentación oficial Web Bluetooth API: WICG/web bluetooth
- Bluefy, Web BLE Browser (iOS): App Store
- Librería Adafruit BME280: GitHub
Versión chilena con componentes en stock local, inspirada en el tutorial de Rui Santos y Sara Santos para Random Nerd Tutorials.








