¿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.

Gráficos en vivo de temperatura, humedad y presión desde ESP32 vía Web BLE

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, 0x2A6F y 0x2A6D permite que apps genéricas de inspección BLE (nRF Connect, LightBlue) reconozcan tu dispositivo sin configuración extra.

Diagrama de la arquitectura BLE con ESP32 actuando como servidor de servicio Environmental Sensing

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

Diagrama de conexión ESP32 con BME280 por I2C en GPIO 21 y 22

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

Foto del ESP32 conectado a un BME280 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:

  1. Abre Programa → Incluir librería → Administrar bibliotecas…
  2. Busca Adafruit BME280 e instálala.
  3. Cuando aparezca el popup, acepta también la Adafruit Unified Sensor Library (dependencia).

Instalación de la librería Adafruit BME280 desde el Library Manager de Arduino IDE

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.

C++
/*
  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)

  • BLE2902 en 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 hacer read, pero NUNCA los notify. Es la causa más común de "se conecta pero no actualiza solo".
  • pChar->notify() con setValue antes: el orden importa. Si llamas notify() 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:

ESP32 con BME280 esperando una conexión BLE entrante

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):

HTML
<!-- 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">&nbsp;&deg;C</span>
            </div>
        </div>
        <div class="sensor-card hum">
            <div class="sensor-label">Humedad</div>
            <div class="sensor-value"><span id="humValue">--</span><span>&nbsp;%</span></div>
        </div>
        <div class="sensor-card press">
            <div class="sensor-label">Presión</div>
            <div class="sensor-value"><span id="pressValue">--</span><span>&nbsp;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:

JavaScript
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.

App ESP32 Web BLE mostrando temperatura y humedad en vivo en un smartphone Android

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.

App Bluefy en iOS abriendo una página Web BLE para conectar con el ESP32

Variantes y mejoras

Con el esqueleto andando, podés extender el proyecto en varias direcciones que el tutorial original NO cubre:

  1. 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 localStorage o IndexedDB. Cuando el usuario se conecte de nuevo, ve la curva completa sin perder datos al cerrar la pestaña. La librería recharts o chart.js con plugin de zoom funcionan perfecto.
  2. 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é.
  3. 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_BME280 por Adafruit_BMP3XX). Si te interesa CO₂ + COVs, sumá un SGP40 en paralelo y exponé más características BLE.
  4. 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.
  5. 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 (SKU GE1-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

Versión chilena con componentes en stock local, inspirada en el tutorial de Rui Santos y Sara Santos para Random Nerd Tutorials.