Un controlador de relé ESP32 MQTT te permite encender lámparas, bombas, ventiladores o contactores desde cualquier app, script o dashboard capaz de publicar un mensaje. En este tutorial conectarás un módulo de relés a un ESP32, te suscribirás a un topic de comandos, publicarás el estado del relé como retained message y restaurarás el último estado conocido tras un reinicio. Todo se prueba contra el área pública gratuita de broker.connectmq.com y después se pasa a credenciales para producción.
Cómo funciona el diseño de relé ESP32 MQTT
Un nodo de relé es el actuador útil más sencillo del IoT, pero también es donde muchos proyectos fallan. El error habitual es tratar MQTT como un botón remoto: algo publica ON, el ESP32 conmuta el relé y nadie sabe nunca si el comando llegó de verdad. Un diseño mejor separa lo que alguien quiere de lo que el dispositivo está haciendo realmente.
| Topic | Dirección | Retenido | Payload |
|---|---|---|---|
connectmq/demo/<id>/relay/1/set |
De la app al ESP32 | No | ON, OFF, TOGGLE |
connectmq/demo/<id>/relay/1/state |
Del ESP32 a las apps | Sí | ON u OFF |
connectmq/demo/<id>/status |
Del ESP32 a las apps | Sí | online u offline (Last Will) |
El topic de comandos no es retenido. Si lo fuera, cada reconexión reproduciría un comando antiguo, y un relé que alguien apagó manualmente podría volver a encenderse de repente. El topic de estado sí es retenido, así que un dashboard que se abra un minuto después recibe al instante el estado actual sin esperar al siguiente cambio. El topic status usa el Last Will de MQTT para que los suscriptores sepan cuándo el nodo se cae de la red.
Si los retained messages y el Last Will son nuevos para ti, la introducción sobre qué hace un broker MQTT explica ambos en unos minutos.
Hardware y conexiones
Necesitas:
- Cualquier placa de desarrollo ESP32 (sirven DevKitC, NodeMCU-32S y módulos WROOM o WROVER).
- Un módulo de relés de 1, 2 o 4 canales con entrada optoacoplada. Los módulos de 5 V con jumpers de "disparo por nivel alto/bajo" son los más versátiles.
- Cables de puente y un cable USB. Para cualquier cosa conectada a la red eléctrica, una caja adecuada y regletas de bornes.
Conexiones típicas para un módulo de 5 V:
| Pin del módulo de relés | Pin del ESP32 | Nota |
|---|---|---|
| VCC | 5V (VIN) | Alimenta la bobina del relé |
| GND | GND | Masa común |
| IN1 | GPIO 26 | Señal de control del canal 1 |
| IN2 | GPIO 27 | Señal de control del canal 2 (opcional) |
La mayoría de las placas de relés baratas son activas a nivel bajo: el relé se activa cuando la entrada se lleva a GND. El sketch siguiente tiene una sola constante para invertir esa lógica. Evita GPIO 0, 2, 12 y 15 para los relés, porque son pines de arranque (strapping) y un relé que hace clic durante el arranque es molesto y, con algunas cargas, peligroso. Los GPIO 34 a 39 son solo de entrada y no funcionarán.
Una nota de seguridad: si conmutas tensión de red, el módulo de relés debe estar dimensionado para la carga, la parte de alta tensión debe ir encerrada y el trabajo debe cumplir la normativa eléctrica local. Mientras desarrollas el firmware, conmuta una tira LED de baja tensión o nada en absoluto y escucha el clic.
Instala las librerías
En Arduino IDE 2.x, instala el paquete de placas esp32 de Espressif desde el Gestor de tarjetas y la librería PubSubClient de Nick O'Leary desde el Gestor de librerías. No hace falta ninguna otra. Si nunca has creado un sketch MQTT en el ESP32, el tutorial de IoT con ESP32 explica con más detalle la configuración de la placa y tu primera publicación.
El sketch completo de relé ESP32 MQTT
El sketch siguiente admite cualquier número de relés, gestiona las reconexiones sin bloquear el bucle y publica el estado retenido tras cada cambio.
#include <WiFi.h>
#include <PubSubClient.h>
// ---------- Configuración ----------
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* DEVICE_ID = "relay-7f3a91"; // hazlo único
const String BASE = String("connectmq/demo/") + DEVICE_ID;
const bool ACTIVE_LOW = true; // la mayoría de las placas de relés
const uint8_t RELAY_PINS[] = {26, 27};
const uint8_t RELAY_COUNT = sizeof(RELAY_PINS);
bool relayState[RELAY_COUNT] = {false};
bool restoreWindow = true; // acepta el estado retenido una vez al arrancar
unsigned long connectedAt = 0;
WiFiClient net;
PubSubClient mqtt(net);
unsigned long lastAttempt = 0;
void applyRelay(uint8_t i, bool on) {
relayState[i] = on;
digitalWrite(RELAY_PINS[i], (on ^ ACTIVE_LOW) ? HIGH : LOW);
}
void publishState(uint8_t i) {
String topic = BASE + "/relay/" + String(i + 1) + "/state";
mqtt.publish(topic.c_str(), relayState[i] ? "ON" : "OFF", true);
}
void onMessage(char* topic, byte* payload, unsigned int length) {
String t(topic);
String msg;
for (unsigned int i = 0; i < length; i++) msg += (char)payload[i];
msg.trim();
msg.toUpperCase();
// Esperado: <BASE>/relay/<n>/set o <BASE>/relay/<n>/state
int start = BASE.length() + 7; // salta "/relay/"
int slash = t.indexOf('/', start);
if (slash < 0) return;
int n = t.substring(start, slash).toInt();
if (n < 1 || n > RELAY_COUNT) return;
uint8_t i = n - 1;
String leaf = t.substring(slash + 1);
if (leaf == "state") {
// Estado retenido de antes del reinicio: restáuralo una vez y después ignóralo
if (restoreWindow) applyRelay(i, msg == "ON");
return;
}
if (leaf != "set") return;
if (msg == "ON") applyRelay(i, true);
else if (msg == "OFF") applyRelay(i, false);
else if (msg == "TOGGLE") applyRelay(i, !relayState[i]);
else return; // ignora comandos desconocidos
publishState(i);
Serial.printf("Relay %d -> %s\n", n, relayState[i] ? "ON" : "OFF");
}
bool connectMqtt() {
String willTopic = BASE + "/status";
bool ok = mqtt.connect(DEVICE_ID, nullptr, nullptr,
willTopic.c_str(), 1, true, "offline");
if (!ok) {
Serial.printf("MQTT connect failed, state=%d\n", mqtt.state());
return false;
}
mqtt.publish(willTopic.c_str(), "online", true);
for (uint8_t i = 0; i < RELAY_COUNT; i++) {
String base = BASE + "/relay/" + String(i + 1);
mqtt.subscribe((base + "/set").c_str(), 1);
if (restoreWindow) mqtt.subscribe((base + "/state").c_str(), 1);
}
connectedAt = millis();
Serial.println("MQTT connected");
return true;
}
void setup() {
Serial.begin(115200);
for (uint8_t i = 0; i < RELAY_COUNT; i++) {
pinMode(RELAY_PINS[i], OUTPUT);
applyRelay(i, false); // valor seguro antes del Wi-Fi
}
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setKeepAlive(30);
mqtt.setSocketTimeout(5);
}
void loop() {
if (WiFi.status() != WL_CONNECTED) return;
if (!mqtt.connected()) {
if (millis() - lastAttempt > 5000) {
lastAttempt = millis();
connectMqtt();
}
return;
}
mqtt.loop();
// Cierra la ventana de restauración dos segundos después de la primera conexión
if (restoreWindow && millis() - connectedAt > 2000) {
restoreWindow = false;
for (uint8_t i = 0; i < RELAY_COUNT; i++) {
String s = BASE + "/relay/" + String(i + 1) + "/state";
mqtt.unsubscribe(s.c_str());
publishState(i); // confirma el estado real
}
}
}
Qué hace la ventana de restauración
Cuando el ESP32 se reinicia tras un corte de luz, los relés arrancan en la posición segura OFF. En la primera conexión, el sketch se suscribe brevemente a sus propios topics state retenidos. El broker entrega de inmediato el último estado que conserva, el sketch lo aplica y, al cabo de dos segundos, cancela la suscripción y vuelve a publicar el estado real. Si prefieres que los relés arranquen siempre apagados, pon restoreWindow a false. Para cargas críticas como calefactores, "siempre apagado tras reiniciar" suele ser la política más segura.
Ten en cuenta que esto depende de que el broker siga conservando el retained message. Los retained messages duran mientras el broker los mantenga, así que considera la restauración una comodidad y no una garantía, y diseña la carga de modo que OFF sea siempre un estado aceptable.
Prueba el relé desde tu ordenador
Abre un terminal y observa todo lo que hay bajo el prefijo de tu dispositivo:
mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-7f3a91 -v -t "connectmq/demo/relay-7f3a91/#"
En un segundo terminal, envía comandos:
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-7f3a91 -t "connectmq/demo/relay-7f3a91/relay/1/set" -m ON
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-7f3a91 -t "connectmq/demo/relay-7f3a91/relay/1/set" -m TOGGLE
Deberías oír el clic y ver el estado retenido de vuelta:
connectmq/demo/relay-7f3a91/status online
connectmq/demo/relay-7f3a91/relay/1/state ON
connectmq/demo/relay-7f3a91/relay/1/state OFF
Ahora desenchufa el ESP32. En aproximadamente un intervalo y medio de keep alive, el broker publica el Last Will y el suscriptor imprime status offline. Es justo la señal que necesita un dashboard para atenuar el interruptor. Como broker.connectmq.com es un servidor MQTT público gratuito pensado para pruebas y prototipos, cualquiera que adivine tu topic puede conmutar tu relé, así que nunca dejes una carga real conectada al área pública.
Lista de comprobación para solucionar problemas
- El relé hace clic al arrancar. Estás en un pin de arranque o el módulo es activo a nivel bajo y el pin queda flotando antes de que se ejecute
setup(). Pasa a GPIO 26/27/32/33 y fija el nivel del pin antes depinModecuando sea posible. - El relé nunca conmuta aunque el LED del módulo se enciende. Muchos módulos necesitan 5 V en VCC para la bobina; con 3,3 V se enciende el LED pero no se cierran los contactos. Algunas placas también necesitan colocado el jumper
JD-VCC. - Los comandos funcionan una vez y luego dejan de hacerlo. Busca
state=-3(conexión perdida) en el log serie. Usar el mismo client ID en dos dispositivos hace que se expulsen mutuamente una y otra vez. - El ESP32 se reinicia cuando conmuta el relé. Las cargas inductivas provocan caídas de tensión y ruido. Alimenta la bobina del relé con una fuente de 5 V independiente con masa común y usa un snubber o protección flyback en el lado de la carga.
- El topic de estado muestra un valor incorrecto tras reiniciar. Otra cosa está publicando retained messages en el topic
state. Solo el propio dispositivo debería publicar ahí. - No llega nada. Los nombres de topic distinguen mayúsculas y minúsculas, y una barra final de más crea un topic distinto.
Pasa a producción con credenciales y TLS
Para una instalación real, crea un broker en el portal de ConnectMQ y usa el usuario, la contraseña y el client ID generados. Cambia WiFiClient por WiFiClientSecure, conéctate al puerto 8883 y pasa las credenciales a connect():
#include <WiFiClientSecure.h>
WiFiClientSecure net;
// en setup(): net.setCACert(ROOT_CA_PEM); // ISRG Root X1 (Let's Encrypt)
mqtt.setServer("broker.connectmq.com", 8883);
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD",
willTopic.c_str(), 1, true, "offline");
Como los client IDs están vinculados a las credenciales emitidas, usa el client ID que generó el portal en lugar de uno inventado. La configuración completa del certificado, incluida la sincronización de la hora y las consideraciones de memoria, se explica en ESP32 MQTT sobre SSL/TLS. Si tu nodo de relé tiene que soportar un Wi-Fi inestable, también merece la pena añadir la estrategia de espera progresiva de la lógica de reconexión MQTT del ESP32.
Un broker MQTT gestionado importa sobre todo en los actuadores: un relé al que no puedes llegar es peor que un sensor que no puedes leer. Con ConnectMQ obtienes TLS en el 8883, credenciales por dispositivo y un portal donde puedes observar relay/+/state en el explorador de topics en vivo o publicar un comando de prueba desde el cliente del navegador sin tocar un terminal.
Ideas para ampliar el nodo de relé
- Temporizadores. Acepta
{"state":"ON","seconds":600}como JSON y apaga automáticamente al cabo de diez minutos. Mantén el temporizador en el dispositivo, para que se apague aunque se caiga la red. - Botón local. Añade un pulsador que conmute el relé y publique el nuevo estado, para que el interruptor físico y la app nunca discrepen.
- Enclavamientos. Para inversión de motores o válvulas de dos vías, impide en el firmware, no en la app, que ambos canales se activen a la vez.
- Hermanos con servo o LED. El mismo patrón de comando/estado funciona para el control de servos por MQTT y las tiras LED direccionables.
Empieza con ConnectMQ
Crea un broker Serverless gratuito en app.connectmq.com, sin tarjeta de crédito. Obtienes credenciales en menos de un minuto, TLS de serie y un explorador de topics en vivo para ver cómo conmutan tus relés. Cuando el plan gratuito se te quede pequeño, el mismo firmware pasa a un plan de pago o dedicado sin cambiar nada más que las credenciales.
Preguntas frecuentes
¿Debe ser retenido el topic de comandos del relé?
No. Retén el topic de estado, no el de comandos. Un comando retenido se reproduciría en cada reconexión y podría conmutar una carga de forma inesperada.
¿Qué QoS debo usar para los comandos del relé?
QoS 1 es la opción práctica: el comando se entrega al menos una vez y, como ON y OFF son idempotentes, un duplicado no hace daño. PubSubClient puede suscribirse con QoS 0 o 1.
¿Puedo controlar varios relés con un solo ESP32?
Sí. Añade los pines a RELAY_PINS y cada uno tendrá sus propios topics relay/<n>/set y relay/<n>/state. Una única suscripción con comodín a relay/+/set también funciona si extraes el número de canal del topic.
¿Cómo sé si el ESP32 está desconectado?
Suscríbete al topic status retenido. El ESP32 publica online al conectarse, y el broker publica el mensaje Last Will offline si la conexión se corta sin una desconexión limpia.



