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:
- Umbral de RSSI.
MIN_RSSIdescarta los dispositivos lejanos o situados detrás de paredes. - Límite de frecuencia por dispositivo.
MIN_REPUBLISH_MSpublica cada dispositivo como máximo una vez cada diez segundos, aunque emita anuncios cada segundo. - 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
- 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.
- MQTT se desconecta durante los escaneos. Acorta
SCAN_SECONDSo aumenta el keep alive; el cliente no puede responder a los pings mientras el escaneo bloquea. - Reinicios aleatorios. Comprueba que no publicas desde el callback de escaneo y que la cola es lo bastante grande. Un contador
droppedcreciente indica que es demasiado pequeña. - 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.
- 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.



