ESP32 y Arduino

Gateway BLE MQTT con ESP32: escanea sensores Bluetooth y reenvía sus anuncios

Usa un ESP32 como escáner Bluetooth Low Energy que recoge los anuncios de sensores y beacons cercanos, los filtra y elimina duplicados, y los publica en topics MQTT por dispositivo.

Gateway BLE MQTT con ESP32 que recibe anuncios Bluetooth de sensores cercanos y los reenvía por Wi-Fi
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Un gateway BLE MQTT con ESP32 conecta los numerosos y baratos sensores Bluetooth Low Energy que tienes a tu alrededor (termómetros, sensores de puerta, beacons, monitores de plantas) con un broker MQTT a través de Wi-Fi. En este tutorial construirás un escáner BLE pasivo en un ESP32, recogerás los anuncios de forma segura desde la tarea de Bluetooth, eliminarás duplicados y publicarás los últimos datos de cada dispositivo en su propio topic MQTT.

Cómo funciona un gateway BLE a MQTT

La mayoría de los sensores BLE alimentados por batería nunca aceptan una conexión. Simplemente emiten anuncios (advertisements): paquetes cortos de hasta 31 bytes que contienen una dirección, un nombre opcional y campos de datos como datos específicos del fabricante o datos de servicio. Un sensor que informa de la temperatura suele incluir la lectura en uno de esos campos cada pocos segundos.

Un gateway escucha esos paquetes y los reenvía. Ese es todo el truco, y tiene dos grandes ventajas:

  • Los sensores permanecen en su modo de difusión de bajo consumo, así que las baterías duran mucho.
  • Un solo ESP32 puede oír muchos dispositivos a la vez sin gestionar conexiones.
Enfoque Cuándo usarlo Contrapartida
Escaneo pasivo de anuncios Sensores que difunden sus datos Sencillo, escala bien, solo lectura
Escaneo activo (scan requests) Necesitas la scan response, a menudo el nombre del dispositivo Algo más de tráfico de radio y consumo en el sensor
Conexiones GATT El dispositivo solo expone datos mediante características, o necesitas escribir Una conexión por slot a la vez, más código, más consumo

Este tutorial usa escaneo pasivo, que cubre la mayoría de los casos de uso con sensores.

Qué necesitas

  • Una placa ESP32 con Bluetooth (el ESP32 clásico, el ESP32-S3 y el ESP32-C3 admiten BLE; el ESP32-S2 no).
  • Arduino IDE 2.x con el paquete de placas esp32, que incluye la librería BLE usada aquí.
  • La librería PubSubClient.
  • Uno o más sensores BLE, o una app de móvil capaz de emitir un anuncio de prueba.

Wi-Fi, Bluetooth y MQTT juntos generan un firmware grande. Si la compilación falla con "Sketch too big", elige una partición de aplicación más grande en Tools > Partition Scheme, por ejemplo "Huge APP".

Wi-Fi y BLE comparten una sola radio

El ESP32 tiene una única radio de 2,4 GHz que se reparte en el tiempo entre Wi-Fi y Bluetooth. El planificador de coexistencia lo gestiona automáticamente, pero tiene consecuencias:

  • Una ventana de escaneo del 100 por ciento deja sin tiempo a Wi-Fi. Deja huecos configurando la ventana de escaneo un poco más corta que el intervalo.
  • Mientras se ejecuta un escaneo bloqueante, tu bucle MQTT no se ejecuta. Mantén cada escaneo muy por debajo del intervalo de keep alive de MQTT.
  • Cuenta con perder algunos anuncios. Los sensores repiten sus emisiones, así que normalmente no es un problema.

Espressif documenta el comportamiento de la coexistencia en la guía de coexistencia RF de ESP-IDF.

Diseño de topics

Topic Payload
connectmq/demo/ble-a81f/devices/<mac> {"rssi":-71,"name":"LYWSD03","mfg":"...","svc":"..."}
connectmq/demo/ble-a81f/gateway/status online / offline, retenido (Last Will)
connectmq/demo/ble-a81f/gateway/stats {"seen":12,"published":9,"dropped":0}

Los dos puntos están permitidos en los topics MQTT, pero el código los elimina de la MAC para que los topics queden limpios y sean fáciles de escribir en un terminal. Sustituye ble-a81f por tu propio identificador aleatorio.

El sketch del gateway BLE MQTT con ESP32

La librería BLE llama al callback de escaneo desde su propia tarea. Publicar desde dentro de ese callback supondría usar el cliente de red desde dos tareas a la vez, lo que provoca bloqueos aleatorios. En su lugar, el callback copia cada anuncio en una cola de FreeRTOS, y loop() vacía la cola y publica.

#include <WiFi.h>
#include <PubSubClient.h>
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>

const char* WIFI_SSID     = "YOUR_WIFI";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST     = "broker.connectmq.com";
const uint16_t MQTT_PORT  = 1883;
const char* CLIENT_ID     = "ble-a81f-gateway";
const char* PREFIX        = "connectmq/demo/ble-a81f";

const int   SCAN_SECONDS     = 5;
const int   MIN_RSSI         = -90;    // ignora dispositivos muy débiles
const uint32_t MIN_REPUBLISH_MS = 10000; // límite de frecuencia por dispositivo

struct Adv {
  char addr[18];
  char name[24];
  int  rssi;
  uint8_t mfg[31];
  uint8_t mfgLen;
};

QueueHandle_t advQueue;
WiFiClient net;
PubSubClient mqtt(net);
BLEScan* scanner;
uint32_t seen = 0, published = 0, dropped = 0;

// Memoria sencilla por dispositivo para limitar la frecuencia
struct Recent { char addr[18]; uint32_t lastMs; };
Recent recent[32];

class ScanCallbacks : public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice dev) override {
    if (dev.getRSSI() < MIN_RSSI) return;
    Adv a = {};
    auto addr = dev.getAddress().toString();
    strncpy(a.addr, addr.c_str(), sizeof(a.addr) - 1);
    if (dev.haveName()) {
      auto n = dev.getName();
      strncpy(a.name, n.c_str(), sizeof(a.name) - 1);
    }
    a.rssi = dev.getRSSI();
    if (dev.haveManufacturerData()) {
      auto md = dev.getManufacturerData();
      a.mfgLen = min((size_t)md.length(), sizeof(a.mfg));
      memcpy(a.mfg, md.c_str(), a.mfgLen);
    }
    seen++;
    if (xQueueSend(advQueue, &a, 0) != pdTRUE) dropped++;
  }
};

bool shouldPublish(const char* addr) {
  uint32_t now = millis();
  int freeSlot = -1, oldest = 0;
  for (int i = 0; i < 32; i++) {
    if (strcmp(recent[i].addr, addr) == 0) {
      if (now - recent[i].lastMs < MIN_REPUBLISH_MS) return false;
      recent[i].lastMs = now;
      return true;
    }
    if (recent[i].addr[0] == 0 && freeSlot < 0) freeSlot = i;
    if (recent[i].lastMs < recent[oldest].lastMs) oldest = i;
  }
  int slot = freeSlot >= 0 ? freeSlot : oldest;
  strncpy(recent[slot].addr, addr, sizeof(recent[slot].addr) - 1);
  recent[slot].lastMs = now;
  return true;
}

void publishAdv(const Adv& a) {
  char mac[13]; int j = 0;
  for (int i = 0; a.addr[i] && j < 12; i++) if (a.addr[i] != ':') mac[j++] = a.addr[i];
  mac[j] = 0;

  char hex[63] = {0};
  for (int i = 0; i < a.mfgLen; i++) sprintf(hex + i * 2, "%02x", a.mfg[i]);

  char topic[96], payload[200];
  snprintf(topic, sizeof(topic), "%s/devices/%s", PREFIX, mac);
  snprintf(payload, sizeof(payload), "{\"rssi\":%d,\"name\":\"%s\",\"mfg\":\"%s\"}",
           a.rssi, a.name, hex);
  if (mqtt.publish(topic, payload)) published++;
}

void connectWifi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) delay(500);
}

void connectMqtt() {
  char st[96];
  snprintf(st, sizeof(st), "%s/gateway/status", PREFIX);
  while (!mqtt.connected()) {
    if (mqtt.connect(CLIENT_ID, st, 1, true, "offline")) {
      mqtt.publish(st, "online", true);
    } else {
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  advQueue = xQueueCreate(40, sizeof(Adv));

  connectWifi();
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setBufferSize(512);
  mqtt.setKeepAlive(30);
  connectMqtt();

  BLEDevice::init("");
  scanner = BLEDevice::getScan();
  scanner->setAdvertisedDeviceCallbacks(new ScanCallbacks(), true); // notifica duplicados
  scanner->setActiveScan(false);   // pasivo: no envía scan requests
  scanner->setInterval(160);       // unidades de 0,625 ms
  scanner->setWindow(80);          // deja tiempo de antena para Wi-Fi
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) connectWifi();
  if (!mqtt.connected()) connectMqtt();

  scanner->start(SCAN_SECONDS, false);   // escaneo bloqueante, los resultados van a la cola
  scanner->clearResults();               // libera la memoria ocupada por el escaneo

  Adv a;
  while (xQueueReceive(advQueue, &a, 0) == pdTRUE) {
    if (shouldPublish(a.addr)) publishAdv(a);
    mqtt.loop();
  }

  char topic[96], stats[96];
  snprintf(topic, sizeof(topic), "%s/gateway/stats", PREFIX);
  snprintf(stats, sizeof(stats), "{\"seen\":%lu,\"published\":%lu,\"dropped\":%lu}",
           (unsigned long)seen, (unsigned long)published, (unsigned long)dropped);
  mqtt.publish(topic, stats);
  mqtt.loop();
}

Observa el tráfico desde tu ordenador:

mosquitto_sub -h broker.connectmq.com -p 1883 -i ble-a81f-viewer -t "connectmq/demo/ble-a81f/#" -v

En pocos segundos deberías ver una línea por cada dispositivo cercano. En una casa u oficina típica, los móviles, auriculares, televisores y relojes emiten anuncios, así que espera ver más dispositivos que sensores tienes.

Filtrar lo que reenvías

Reenviar todos los anuncios al alcance es útil para descubrir dispositivos, pero genera mucho ruido en producción y puede hacerte chocar con límites de frecuencia en brokers compartidos (el servidor MQTT público gratuito de ConnectMQ permite 10 mensajes por segundo por cliente). Tres filtros mantienen el tráfico bajo control:

  1. Umbral de RSSI. MIN_RSSI descarta los dispositivos lejanos o situados detrás de paredes.
  2. Límite de frecuencia por dispositivo. MIN_REPUBLISH_MS publica cada dispositivo como máximo una vez cada diez segundos, aunque emita anuncios cada segundo.
  3. Lista de permitidos. Cuando conozcas las direcciones o los IDs de fabricante de tus sensores, acepta solo esos. Los dos primeros bytes de los datos del fabricante son el identificador de la empresa, lo que lo convierte en un buen filtro para una marca concreta.

Ten en cuenta que muchos móviles y algunos accesorios usan direcciones aleatorias y rotatorias por privacidad, así que el mismo móvil puede aparecer con varias MAC. Los sensores suelen usar una dirección fija.

Decodificar los datos de los sensores

El gateway reenvía los datos del fabricante en hexadecimal y deja la decodificación a un suscriptor. Es intencionado: actualizar el firmware es difícil, mientras que un suscriptor en Python o Node-RED puede aprender nuevos formatos de sensor en minutos. Muchos firmwares de sensores de código abierto siguen formatos documentados, como los datos de servicio BTHome, que un suscriptor puede decodificar en temperatura, humedad y batería y volver a publicar en un topic limpio como home/livingroom/climate.

Este reparto (gateway sencillo y fiable en el edge; lógica flexible en un suscriptor) es el mismo patrón que usan los gateways industriales más grandes, descrito en el artículo sobre el gateway edge IIoT.

Lista de comprobación para resolver problemas del gateway BLE MQTT con ESP32

  1. No se encuentra ningún dispositivo. Confirma que tu placa admite BLE (el ESP32-S2 no) y que el sensor está emitiendo anuncios, usando una app de escaneo en el móvil.
  2. MQTT se desconecta durante los escaneos. Acorta SCAN_SECONDS o aumenta el keep alive; el cliente no puede responder a los pings mientras el escaneo bloquea.
  3. Reinicios aleatorios. Comprueba que no publicas desde el callback de escaneo y que la cola es lo bastante grande. Un contador dropped creciente indica que es demasiado pequeña.
  4. Wi-Fi va lento o se cae. Reduce la ventana de escaneo respecto al intervalo para que Wi-Fi tenga más tiempo de antena.
  5. El heap disminuye con el tiempo. Llama a clearResults() después de cada escaneo, como hace el sketch.

Para estrategias de reconexión que sobreviven a los reinicios del router, consulta reconexión MQTT en ESP32, y para más proyectos con sensores, el tutorial de IoT con ESP32.

Proteger el gateway

Los anuncios BLE son públicos por naturaleza: cualquiera que esté cerca puede leerlos. No empeores la situación publicándolos en un topic público en producción. Pasa a un broker privado en el puerto TLS 8883 con WiFiClientSecure y credenciales de tu portal del broker. Un broker MQTT gestionado como ConnectMQ da a cada gateway sus propias credenciales sin operar servidores, y mientras haces el prototipo, el servidor MQTT público gratuito sirve siempre que no haya nada sensible al alcance; recuerda que es solo para pruebas, nunca para datos de producción.

Empieza con ConnectMQ

Cuando el gateway esté listo para un uso real, dale un endpoint privado y cifrado en ConnectMQ. El explorador de topics en vivo del portal muestra cada topic de dispositivo en cuanto aparece, que es la forma más rápida de ver lo que oye tu gateway. Empieza con el plan Serverless gratuito en app.connectmq.com, sin tarjeta de crédito.

Preguntas frecuentes

¿Cuántos sensores BLE puede gestionar un gateway ESP32?

Con escaneo pasivo, tener decenas de dispositivos al alcance es normal; los límites prácticos son el tiempo de antena, el tamaño de la cola y la frecuencia con la que publicas. Limita la frecuencia por dispositivo y filtra solo los sensores que te interesan.

¿Debo usar NimBLE en lugar de la librería BLE integrada?

NimBLE-Arduino usa menos memoria y es una buena opción para gateways más grandes. Su API es ligeramente distinta, así que adapta el código del callback si cambias.

¿Puede el gateway conectarse a sensores que requieren emparejamiento?

Sí, pero eso exige conexiones GATT en lugar de escaneo, y solo unas pocas conexiones a la vez. Para sensores que emiten anuncios, el escaneo pasivo es más sencillo y fiable.

¿Por qué veo dispositivos que no son míos?

Los móviles, televisores, auriculares y muchos otros productos emiten anuncios continuamente. Usa un umbral de RSSI y una lista de permitidos de direcciones o IDs de fabricante.

Artículos relacionados

Ejemplo de ESP-IDF MQTT: crea un cliente esp-mqtt desde cero

Construye un proyecto ESP-IDF desde una carpeta vacía con el componente esp-mqtt: configuración de Wi-Fi en modo estación, el manejador de eventos MQTT, publicación y suscripción, propiedades de MQTT 5 y TLS con el bundle de certificados.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.