Tutoriales

Tutorial MQTT para ESP8266 con PubSubClient

Conecta un NodeMCU o Wemos D1 mini a un broker MQTT con PubSubClient: sketch completo, reconexión sin bloqueos, Last Will y TLS con huella del certificado.

Actualizado · 9 min de lectura

Tutorial MQTT para ESP8266 con PubSubClient

El ESP8266 sigue siendo una de las formas más baratas de poner un sensor en Wi-Fi, y PubSubClient es la biblioteca MQTT que usan la mayoría de los sketches para ESP8266. Este tutorial te da un sketch completo para un NodeMCU o un Wemos D1 mini que se conecta a un broker MQTT, publica telemetría, recibe comandos, se reconecta sin bloquear y se anuncia con un Last Will; después pasa la conexión a TLS con la huella (fingerprint) del certificado y explica cuándo usar una CA raíz en su lugar.

Configuración probada: Arduino IDE 2.x o arduino-cli, core ESP8266 3.1.x, PubSubClient 2.8.

Instala el toolchain

  1. En el Arduino IDE, abre Preferencias y añade esta URL del gestor de placas: https://arduino.esp8266.com/stable/package_esp8266com_index.json.
  2. Gestor de placas → instala esp8266 by ESP8266 Community.
  3. Gestor de bibliotecas → instala PubSubClient de Nick O'Leary.
  4. Selecciona NodeMCU 1.0 (ESP-12E Module) o LOLIN(WEMOS) D1 R2 & mini como placa.

Con arduino-cli, los mismos pasos son:

Configuración con arduino-cli
arduino-cli config add board_manager.additional_urls \
  https://arduino.esp8266.com/stable/package_esp8266com_index.json
arduino-cli core update-index
arduino-cli core install esp8266:esp8266
arduino-cli lib install PubSubClient

El ESP8266 solo admite Wi-Fi de 2,4 GHz. Si tu router emite un único SSID combinado para ambas bandas, normalmente funciona; una red solo de 5 GHz no funcionará nunca.

El sketch completo

esp8266_mqtt.ino
#include <ESP8266WiFi.h>
#include <PubSubClient.h>

const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASS = "your-wifi-password";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* MQTT_USER = "username";
const char* MQTT_PASS = "password";

WiFiClient net;
PubSubClient mqtt(net);

String clientId, baseTopic;
unsigned long lastAttempt = 0, retryDelay = 1000, lastPublish = 0;

void onMessage(char* topic, byte* payload, unsigned int length) {
  String msg;
  for (unsigned int i = 0; i < length; i++) msg += (char)payload[i];
  Serial.printf("[%s] %s\n", topic, msg.c_str());
  if (msg == "on")  digitalWrite(LED_BUILTIN, LOW);    // el LED integrado es activo a nivel bajo
  if (msg == "off") digitalWrite(LED_BUILTIN, HIGH);
}

bool connectMqtt() {
  String status = baseTopic + "/status";
  // connect(id, user, pass, willTopic, willQos, willRetain, willMessage)
  if (!mqtt.connect(clientId.c_str(), MQTT_USER, MQTT_PASS,
                    status.c_str(), 1, true, "offline")) {
    Serial.printf("MQTT connect failed, state=%d\n", mqtt.state());
    return false;
  }
  mqtt.publish(status.c_str(), "online", true);              // retenido
  mqtt.subscribe((baseTopic + "/led").c_str(), 1);
  Serial.println("MQTT connected as " + clientId);
  return true;
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, HIGH);

  WiFi.persistent(false);              // no reescribir la flash en cada arranque
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  clientId  = "esp8266-" + String(ESP.getChipId(), HEX);
  baseTopic = "testmqtt/esp8266/" + clientId;

  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setCallback(onMessage);
  mqtt.setBufferSize(512);             // por defecto 256 bytes, incluidos topic y cabecera
  mqtt.setKeepAlive(30);
  mqtt.setSocketTimeout(5);            // un connect fallido bloquea como mucho ~5 s
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    delay(10);                         // el core reconecta el Wi-Fi por su cuenta
    return;
  }

  if (!mqtt.connected()) {
    unsigned long now = millis();
    if (now - lastAttempt >= retryDelay) {
      lastAttempt = now;
      retryDelay = connectMqtt() ? 1000 : min(retryDelay * 2, 60000UL);
    }
    return;
  }

  mqtt.loop();                         // debe ejecutarse a menudo: recibe mensajes, envía pings

  if (millis() - lastPublish >= 10000) {
    lastPublish = millis();
    char payload[96];
    snprintf(payload, sizeof(payload),
             "{\"rssi\":%d,\"heap\":%u,\"uptime\":%lu}",
             WiFi.RSSI(), ESP.getFreeHeap(), millis() / 1000);
    mqtt.publish((baseTopic + "/telemetry").c_str(), payload);
  }
}

Por qué está escrito así

  • Reconexión sin bloqueo. El ejemplo clásico usa while (!client.connected()) { ...; delay(5000); }, que congela todo lo demás (botones, sensores, incluso la pila Wi-Fi) mientras el broker no responde. Aquí, loop() vuelve de inmediato y reintenta con backoff exponencial (1 s, 2 s, 4 s… hasta 60 s), para que una flota no machaque a un broker que se está reiniciando.
  • Client ID estable a partir de ESP.getChipId(). Dos placas con el mismo ID se expulsan mutuamente en un bucle sin fin; consulta las buenas prácticas de client ID.
  • Last Will + estado retenido. Si la placa se queda sin alimentación, el broker publica offline en .../status cuando expira el keep-alive (1,5 × 30 s aquí). Cualquiera que se suscriba después ve el estado actual al instante.
  • Suscribirse tras cada conexión. PubSubClient usa una clean session, así que las suscripciones no sobreviven a una reconexión. Los endpoints de prueba compartidos como broker.connectmq.com tampoco guardan sesiones, de modo que el sketch siempre se vuelve a suscribir en connectMqtt().
  • setBufferSize(512): el buffer por defecto de PubSubClient es de 256 bytes para todo el paquete, topic incluido. Un topic largo más un payload JSON lo superan enseguida, y entonces publish() devuelve false sin ninguna otra pista. La calculadora de tamaño de paquete muestra cuánto ocupa realmente tu paquete.
  • setSocketTimeout(5): el propio connect() es bloqueante. El timeout por defecto es de 15 segundos, suficiente para provocar resets del watchdog o una interfaz congelada.

Prueba el sketch

Flashea la placa y abre el Monitor Serie a 115200 baudios. Deberías ver MQTT connected as esp8266-xxxxxx. Si en su lugar aparece state=-2 u otro código, salta a la tabla de errores más abajo.

Después abre el cliente MQTT online, conéctate a broker.connectmq.com y suscríbete a testmqtt/esp8266/#. Primero aparece el estado online retenido y luego un JSON de telemetría cada 10 segundos. Publica on en testmqtt/esp8266/<client-id>/led y el LED se enciende.

Ver tu ESP8266 en el navegador

Para probar el Last Will, desenchufa la placa y espera unos 45 segundos: el topic de estado pasa a offline. Vuelve a enchufarla y regresa a online: es tu ruta de reconexión funcionando.

Añadir TLS con una huella

El puerto 1883 envía la contraseña MQTT en texto plano. El core del ESP8266 incluye BearSSL, así que TLS en el puerto 8883 solo requiere otra clase de cliente. La forma más ligera de verificar el servidor es el pinning del certificado por huella SHA-1.

Obtén la huella del certificado actual del broker desde tu ordenador:

Leer la huella SHA-1
openssl s_client -connect broker.connectmq.com:8883 \
  -servername broker.connectmq.com </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha1 \
  | sed 's/.*=//; s/:/ /g'

Después cambia tres cosas en el sketch:

Cambios para TLS
#include <WiFiClientSecure.h>

const uint16_t MQTT_PORT = 8883;
const char* FINGERPRINT = "AA BB CC DD EE FF 00 11 22 33 44 55 66 77 88 99 AA BB CC DD";

BearSSL::WiFiClientSecure net;         // sustituye a WiFiClient
PubSubClient mqtt(net);

// en setup(), antes de mqtt.setServer():
net.setFingerprint(FINGERPRINT);
mqtt.setSocketTimeout(10);             // el handshake TLS es lento en un chip de 80 MHz

El handshake tarda bastante más que un connect TCP normal y necesita una buena parte de la escasa RAM del ESP8266. Vigila ESP.getFreeHeap() en la telemetría; si va justo, BearSSL puede usar buffers más pequeños con net.setBufferSizes(1024, 1024), pero solo si el broker admite la extensión TLS max fragment length; compruébalo antes con BearSSL::WiFiClientSecure::probeMaxFragmentLength(MQTT_HOST, 8883, 1024).

Huella frente a CA raíz

MétodoCómoCompromiso
setFingerprint()Fija el SHA-1 del certificado finalLo más pequeño y rápido, pero se rompe en cada renovación del certificado
setTrustAnchors()Fija la CA raíz (BearSSL::X509List)Sobrevive a las renovaciones; necesita la hora correcta mediante configTime()
setInsecure()Sin verificaciónCifrado, pero acepta cualquier servidor: solo para depurar

Los certificados de CA públicas se renuevan con regularidad, a menudo cada pocos meses. Una flota con huella fijada deja de conectarse el día de la renovación con state=-2, y tienes que reflashear cada dispositivo. Para cualquier cosa de larga duración, fija la CA raíz:

CA raíz en lugar de huella
#include <time.h>
static const char ROOT_CA[] PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
...certificado raíz de la CA que firma el certificado de tu broker...
-----END CERTIFICATE-----
)EOF";
BearSSL::X509List trustAnchor(ROOT_CA);

// en setup(), con el Wi-Fi ya conectado:
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
while (time(nullptr) < 1700000000) delay(200);   // esperar a NTP
net.setTrustAnchors(&trustAnchor);

Sin un reloj válido, BearSSL considera que todos los certificados han caducado o aún no son válidos. Tienes más detalles sobre TLS, incluidos los certificados de cliente, en la guía de TLS para ESP32: la API de BearSSL es distinta, pero los conceptos son los mismos.

Códigos de error

mqtt.state() devuelve estos valores:

stateConstanteCausa típica
-4MQTT_CONNECTION_TIMEOUTSin respuesta al ping dentro del keep-alive; Wi-Fi débil
-3MQTT_CONNECTION_LOSTCaída de red o el broker cerró el socket
-2MQTT_CONNECT_FAILEDFalló TCP/TLS: host o puerto incorrecto, DNS, firewall, huella caducada
-1MQTT_DISCONNECTEDDesconexión limpia o nunca conectado
1MQTT_CONNECT_BAD_PROTOCOLEl broker rechaza MQTT 3.1.1
2MQTT_CONNECT_BAD_CLIENT_IDClient ID rechazado
3MQTT_CONNECT_UNAVAILABLEBroker no disponible
4MQTT_CONNECT_BAD_CREDENTIALSUsuario o contraseña incorrectos
5MQTT_CONNECT_UNAUTHORIZEDAutenticado, pero sin permiso para conectarse

Los valores positivos son códigos de retorno CONNACK del broker; búscalos en la herramienta de reason codes. Los negativos vienen de la propia biblioteca y casi siempre indican un problema de red. El método paso a paso de probar la conexión de un ESP32 con el broker funciona igual en un ESP8266.

Próximos pasos

  • ¿Te pasas a un chip más nuevo? El tutorial de ESP32 con Arduino usa la misma API de PubSubClient.
  • ¿Necesitas publicar con QoS 1 u otro estilo de API? Compáralo con la biblioteca arduino-mqtt.
  • ¿Payloads más grandes? ArduinoJson con MQTT muestra cómo dimensionar bien los buffers.

Un nodo MQTT fiable con ESP8266 se reduce a unos pocos hábitos: no bloquear nunca loop(), reconectar con backoff, volver a suscribirse en cada conexión, dimensionar el buffer para tus paquetes reales y fijar una CA raíz en lugar de una huella en los dispositivos que deban seguir funcionando tras la próxima renovación del certificado.

Preguntas frecuentes

¿Por qué mi conexión TLS del ESP8266 deja de funcionar al cabo de unas semanas?

Si fijaste el certificado con setFingerprint(), la huella cambia cada vez que el broker renueva su certificado, y el handshake falla entonces con state -2. Fija la CA raíz con setTrustAnchors(): sobrevive a las renovaciones mientras la misma CA firme el nuevo certificado.

¿PubSubClient puede publicar con QoS 1 en el ESP8266?

No. PubSubClient solo publica con QoS 0. Puede suscribirse con QoS 0 o 1, así que los mensajes entrantes pueden confirmarse, pero los salientes son de tipo fire-and-forget. Si necesitas publicar con QoS 1, usa una biblioteca como arduino-mqtt o añade una confirmación a nivel de aplicación.

¿Qué significa el state -2 de PubSubClient?

MQTT_CONNECT_FAILED: no se pudo abrir la conexión TCP o TLS con el broker. El problema está por debajo de MQTT: Wi-Fi, DNS, puerto incorrecto, un firewall o un fallo del handshake TLS, como una huella caducada o un WiFiClient normal en el puerto 8883.