ESP32 y Arduino

Reconexión MQTT en ESP32: lógica sin bloqueos que sobrevive a los cortes

Sustituye el bucle de reconexión bloqueante de la mayoría de ejemplos por una máquina de estados sin bloqueos para Wi-Fi y MQTT, con backoff exponencial, jitter, resuscripción y búfer offline, y compruébalo reiniciando tu router.

Router y placa ESP32 recuperando la conexión en una prueba de reconexión MQTT en ESP32
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

La lógica de reconexión MQTT en el ESP32 es lo que separa una demo que funciona en tu escritorio de un dispositivo que funciona durante meses. En esta guía sustituirás el bucle bloqueante reconnect() que aparece en la mayoría de ejemplos por una máquina de estados sin bloqueos que gestiona Wi-Fi y MQTT por separado, aplica backoff exponencial con jitter, se vuelve a suscribir automáticamente y guarda las lecturas en un búfer mientras está offline. Al final lo verificarás de la forma honesta: reiniciando tu router mientras el ESP32 está conectado a broker.connectmq.com.

Por qué falla el bucle de reconexión clásico

Casi todos los ejemplos de PubSubClient contienen alguna versión de esto:

void reconnect() {
  while (!client.connected()) {
    if (client.connect("esp32")) {
      client.subscribe("cmd");
    } else {
      delay(5000);
    }
  }
}

Funciona hasta que la red desaparece. Entonces aparecen varios problemas a la vez:

Problema Qué ocurre en la práctica
Bucle while infinito No se ejecuta nada más: botones, sensores, pantallas y control de motores se congelan hasta que el broker vuelve
delay(5000) Lo mismo, y además el task watchdog puede saltar en algunas configuraciones
Sin comprobación de Wi-Fi El sketch machaca con conexiones MQTT mientras el propio Wi-Fi está caído, y cada una espera a los timeouts de DNS o TCP
Intervalo de reintento fijo Cientos de dispositivos que se recuperan del mismo corte se reconectan al unísono y golpean el broker en el mismo instante
Client ID fijo en el código Dos placas con el mismo firmware se expulsan mutuamente sin fin

La solución es tratar la conectividad como una tarea de fondo. loop() debe ejecutarse cada pocos milisegundos pase lo que pase, y los intentos de conexión solo deben producirse cuando toca.

Entiende los códigos de estado de PubSubClient

Antes de mejorar nada, registra client.state() en cada fallo. Los códigos te dicen si el problema es la red, el broker o tus credenciales.

Código Constante Significado Causa típica
-4 MQTT_CONNECTION_TIMEOUT Sin respuesta dentro del keep-alive Red atascada, Wi-Fi débil
-3 MQTT_CONNECTION_LOST Conexión perdida Reinicio del router, reinicio del broker, client ID duplicado
-2 MQTT_CONNECT_FAILED Falló la conexión TCP Host o puerto incorrectos, fallo de DNS, sin internet
-1 MQTT_DISCONNECTED Desconexión limpia Llamaste a disconnect()
1 MQTT_CONNECT_BAD_PROTOCOL Protocolo no soportado Raro con brokers 3.1.1
2 MQTT_CONNECT_BAD_CLIENT_ID Client ID rechazado ID vacío o no válido
3 MQTT_CONNECT_UNAVAILABLE Servidor no disponible Broker sobrecargado o reiniciándose
4 MQTT_CONNECT_BAD_CREDENTIALS Usuario/contraseña incorrectos Errata, contraseña rotada
5 MQTT_CONNECT_UNAUTHORIZED No autorizado Credenciales no permitidas para este client ID

Los códigos 4 y 5 no son transitorios. Reintentar cada pocos segundos no los arreglará y solo suma inicios de sesión fallidos. Muchos brokers, ConnectMQ incluido, bloquean direcciones IP tras repetidos inicios de sesión fallidos, así que un dispositivo que entra en bucle con credenciales incorrectas puede bloquearse a sí mismo. El firmware de abajo aplica un backoff mucho más agresivo con esos códigos. Si obtienes -2 desde el primerísimo intento, el problema no es la reconexión en absoluto; revisa primero por qué un ESP32 no puede conectarse al broker MQTT.

Diseño de una reconexión MQTT sin bloqueos en el ESP32

El firmware separa tres capas:

  1. Wi-Fi. Deja que la reconexión automática propia del ESP32 gestione la radio y escucha los eventos de Wi-Fi para saber cuándo el enlace está activo. No llames a WiFi.begin() en un bucle cerrado.
  2. MQTT. Intenta conectar solo cuando el Wi-Fi tenga dirección IP y haya expirado el temporizador de backoff. Si tiene éxito, reinicia el backoff, publica un estado online y vuelve a suscribirte. Si falla, duplica la espera hasta un tope y añade jitter aleatorio.
  3. Aplicación. Lee sensores y gestiona actuadores en cada iteración, haya o no conectividad. Publica cuando estés conectado; si no, guarda las lecturas en un pequeño búfer circular y vacíalo tras reconectar.

El backoff exponencial con jitter es el patrón estándar para reintentar contra un servicio compartido. El jitter importa más de lo que se suele pensar: tras un corte regional, reparte miles de reconexiones en una ventana de tiempo en lugar de un único pico.

El sketch completo

#include <WiFi.h>
#include <PubSubClient.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;

String clientId;                       // construido a partir de la dirección MAC
String base;                           // connectmq/demo/<clientId>

WiFiClient net;
PubSubClient mqtt(net);

// ---- Estado del backoff ----
const uint32_t BACKOFF_MIN = 1000;     // 1 s
const uint32_t BACKOFF_MAX = 60000;    // 60 s
const uint32_t BACKOFF_AUTH = 300000;  // 5 min para errores de credenciales
uint32_t backoff = BACKOFF_MIN;
unsigned long nextAttempt = 0;
volatile bool wifiUp = false;
uint32_t reconnects = 0;

// ---- Búfer circular offline ----
const int BUF_SIZE = 20;
String pending[BUF_SIZE];
int head = 0, count = 0;

void onWiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      wifiUp = true;
      Serial.printf("Wi-Fi up, IP %s\n", WiFi.localIP().toString().c_str());
      break;
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      if (wifiUp) Serial.println("Wi-Fi lost");
      wifiUp = false;
      break;
    default:
      break;
  }
}

void onMessage(char* topic, byte* payload, unsigned int length) {
  Serial.printf("cmd on %s: %.*s\n", topic, (int)length, (const char*)payload);
}

void subscribeAll() {
  // Se llama tras CADA conexión correcta: las suscripciones no sobreviven
  // a una reconexión con clean session.
  mqtt.subscribe((base + "/cmd/#").c_str(), 1);
}

void flushPending() {
  while (count > 0 && mqtt.connected()) {
    int tail = (head - count + BUF_SIZE) % BUF_SIZE;
    if (!mqtt.publish((base + "/telemetry").c_str(), pending[tail].c_str())) break;
    count--;
    mqtt.loop();
  }
}

void scheduleRetry(int state) {
  bool authError = (state == MQTT_CONNECT_BAD_CREDENTIALS || state == MQTT_CONNECT_UNAUTHORIZED);
  uint32_t wait = authError ? BACKOFF_AUTH : backoff;
  uint32_t jitter = random(0, wait / 4 + 1);
  nextAttempt = millis() + wait + jitter;
  if (!authError) backoff = min(backoff * 2, BACKOFF_MAX);
  Serial.printf("MQTT connect failed (state %d), retry in %lu ms\n", state, (unsigned long)(wait + jitter));
}

void maintainMqtt() {
  if (mqtt.connected()) {
    mqtt.loop();
    return;
  }
  if (!wifiUp || (long)(millis() - nextAttempt) < 0) return;

  String status = base + "/status";
  bool ok = mqtt.connect(clientId.c_str(), nullptr, nullptr,
                         status.c_str(), 1, true, "offline");
  if (!ok) {
    scheduleRetry(mqtt.state());
    return;
  }
  reconnects++;
  backoff = BACKOFF_MIN;
  Serial.printf("MQTT connected (#%lu)\n", (unsigned long)reconnects);
  mqtt.publish(status.c_str(), "online", true);
  subscribeAll();
  flushPending();
}

void publishReading(const String& json) {
  if (mqtt.connected() && mqtt.publish((base + "/telemetry").c_str(), json.c_str())) return;
  pending[head] = json;                          // guardar en búfer mientras está offline
  head = (head + 1) % BUF_SIZE;
  if (count < BUF_SIZE) count++;                 // la entrada más antigua se sobrescribe cuando está lleno
}

void setup() {
  Serial.begin(115200);
  uint8_t mac[6];
  WiFi.mode(WIFI_STA);
  WiFi.macAddress(mac);
  char id[24];
  snprintf(id, sizeof(id), "esp32-%02x%02x%02x", mac[3], mac[4], mac[5]);
  clientId = id;
  base = "connectmq/demo/" + clientId;
  randomSeed(esp_random());

  WiFi.onEvent(onWiFiEvent);
  WiFi.setAutoReconnect(true);
  WiFi.persistent(false);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setCallback(onMessage);
  mqtt.setKeepAlive(30);
  mqtt.setSocketTimeout(5);                     // limita cuánto puede bloquear una conexión atascada
}

unsigned long lastSample = 0;

void loop() {
  maintainMqtt();

  // El trabajo de la aplicación siempre se ejecuta, con o sin conexión
  if (millis() - lastSample >= 10000) {
    lastSample = millis();
    String json = "{\"uptime\":" + String(millis() / 1000) +
                  ",\"rssi\":" + String(WiFi.RSSI()) +
                  ",\"reconnects\":" + String(reconnects) + "}";
    publishReading(json);
  }
}

Notas sobre las líneas importantes

  • Client ID a partir de la dirección MAC. Cada placa obtiene un ID estable y único sin editar el sketch. Los IDs duplicados son la causa número uno de bucles misteriosos con -3, porque el broker desconecta la sesión más antigua cada vez que se conecta la nueva. Con credenciales del portal, usa en su lugar el client ID que emitió el portal, ya que ConnectMQ vincula los client IDs a las credenciales.
  • subscribeAll() tras cada conexión. PubSubClient se conecta con clean session, así que el broker olvida las suscripciones cuando se cae la conexión. Olvidar volver a suscribirse es el bug clásico de "se reconecta pero deja de recibir comandos".
  • Estado online / offline retenido. El Last Will avisa a todos de que el dispositivo se cayó; el online retenido lo corrige tras la recuperación. La guía de Last Will MQTT en ESP32 cubre este patrón en profundidad.
  • setSocketTimeout(5). Incluso un diseño sin bloqueos se bloquea durante la llamada de conexión en sí, porque la resolución DNS, el handshake TCP y la espera del CONNACK son síncronos en PubSubClient. Mantener el timeout corto acota esa pausa.
  • Temporizador seguro frente al desbordamiento. (long)(millis() - nextAttempt) < 0 sigue funcionando cuando millis() da la vuelta tras unos 49 días.

Pruébalo: reinicia el router

Una estrategia de reconexión que no has probado es una esperanza, no una estrategia. Aquí tienes un plan de pruebas que lleva diez minutos:

  1. Flashea el sketch y abre el monitor serie.
  2. En tu equipo, suscríbete a los topics del dispositivo (sustituye el ID por el que aparece en el monitor serie):
mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-test01 -v -t "connectmq/demo/esp32-a1b2c3/#"
  1. Confirma que ves status online y un mensaje de telemetría cada diez segundos.
  2. Apaga y enciende el router. Tu equipo también pierde la conexión, así que observa el monitor serie.
  3. En aproximadamente 1,5 veces el keep-alive (45 segundos aquí) el broker publica el Last Will. Lo verás en cuanto tu portátil se reconecte.
  4. Observa el log serie: Wi-Fi lost, luego nada mientras arranca el router (sin intentos MQTT fallidos, porque el Wi-Fi está caído), luego Wi-Fi up y después MQTT connected (#2).
  5. En el suscriptor, verifica que vuelve status online, que la telemetría almacenada llega de golpe y que reconnects ha aumentado en uno.
  6. Envía un comando a connectmq/demo/esp32-a1b2c3/cmd/test y confirma que el dispositivo todavía lo recibe. Eso demuestra que la resuscripción funciona.

Repite la prueba con el broker inaccesible pero el Wi-Fi activo, por ejemplo cambiando temporalmente MQTT_HOST por un nombre no utilizado. Deberías ver cómo los intervalos de reintento crecen desde aproximadamente un segundo hasta el tope de 60 segundos, con valores ligeramente distintos cada vez por el jitter.

Solución de problemas de reconexión

  • Se reconecta cada pocos segundos con estado -3. Otro dispositivo o un cliente de prueba olvidado usa el mismo client ID.
  • Conectado, pero los comandos dejan de llegar tras una reconexión. Falta la resuscripción.
  • Estado -4 con señal débil. Aumenta moderadamente el keep-alive, mejora la colocación de la antena y registra el RSSI como se muestra.
  • El dispositivo nunca se recupera tras un corte largo sin un reinicio. Algo se bloquea para siempre, a menudo una llamada HTTP o una librería de sensores. Añade el task watchdog, como se describe en task watchdog del ESP32 con MQTT.
  • Una ráfaga de mensajes antiguos sorprende al dashboard. Incluye una marca de tiempo en las lecturas almacenadas para que los consumidores distingan los datos tardíos de los datos en vivo.

Cuando importa el lado del broker

Un buen firmware asume que el broker puede ser inaccesible, pero la frecuencia con que eso ocurre depende de dónde se ejecute el broker. Una instancia de Mosquitto en un servidor doméstico cae con un corte de luz, una actualización del sistema operativo o un certificado caducado. Un broker MQTT gestionado traslada esas preocupaciones a un equipo que opera el servicio por ti. ConnectMQ además expone $SYS/broker/connection/{clientId}/state, de modo que un backend puede vigilar el estado de conexión de un dispositivo, y el explorador de topics en vivo del portal te permite ver cómo los topics de estado retenidos cambian entre online y offline mientras pruebas. Para entender el keep-alive, las sesiones y el Last Will, la introducción a qué hace un broker MQTT es un buen complemento. Para este ejercicio basta con probar en el servidor MQTT público gratuito; el mismo firmware pasa a credenciales privadas y TLS cambiando tres constantes y la clase del cliente, como se muestra en MQTT sobre SSL/TLS en ESP32. Para el recorrido completo con el ESP32, consulta el tutorial de IoT con ESP32.

Empieza con ConnectMQ

Crea un broker Serverless gratuito en app.connectmq.com, sin tarjeta de crédito. Obtienes credenciales dedicadas, TLS en el puerto 8883 y un portal que muestra cuándo se conecta y desconecta cada dispositivo, justo lo que necesitas al ajustar el comportamiento de reconexión.

Preguntas frecuentes

¿Con qué frecuencia debe un ESP32 reintentar una conexión MQTT?

Empieza en torno a un segundo y duplica la espera tras cada fallo hasta un tope de aproximadamente un minuto, añadiendo jitter aleatorio. Ante errores de credenciales, espera mucho más o detente y notifica el problema, porque los reintentos no pueden tener éxito.

¿Tengo que volver a suscribirme tras una reconexión MQTT del ESP32?

Con PubSubClient, sí. Usa clean session, así que el broker descarta las suscripciones cuando termina la conexión. Llama a tu función de suscripción tras cada conexión correcta.

¿Debo llamar otra vez a WiFi.begin() cuando se cae el Wi-Fi?

Normalmente no. Con WiFi.setAutoReconnect(true) el ESP32 se reconecta por sí solo. Llamar a WiFi.begin() repetidamente puede interrumpir una asociación que ya está en curso.

¿Por qué mi ESP32 se desconecta continuamente con estado -3?

La causa más habitual son dos clientes que comparten un mismo client ID; el broker desconecta al más antiguo cada vez que el otro se conecta. Los siguientes sospechosos son un Wi-Fi débil y un keep-alive más largo que el timeout de inactividad de un NAT o un firewall.

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.