El control de iluminación inteligente con MQTT permite que cualquier app, panel o automatización regule una luz, recupere una escena o ejecute un horario con solo publicar un mensaje. En este tutorial construirás un regulador PWM con ESP32 para tiras LED con fundidos suaves, diseñarás topics que funcionen para una sala o para todo un edificio, guardarás las escenas como mensajes retenidos y ejecutarás horarios desde un pequeño servicio en Python conectado a un broker de ConnectMQ.
Elegir el hardware de iluminación para MQTT
Antes de escribir el firmware, decide qué tipo de iluminación vas a controlar. Cada opción encaja con MQTT de una forma ligeramente distinta.
| Hardware | Cómo se conecta a MQTT | Ideal para | Precauciones |
|---|---|---|---|
| Tiras LED de baja tensión con un driver PWM en ESP32 | Firmware propio, como en este tutorial | Escritorios, estanterías, armarios, iluminación decorativa | Dimensionado de la fuente de alimentación y caída de tensión en tramos largos |
| LED direccionables (WS2812B y similares) | Firmware propio o WLED | Efectos y color por píxel | Consumo de corriente, longitud de la línea de datos |
| Reguladores y relés de red con firmware MQTT | Tasmota, Shelly o dispositivos similares | Luces de techo existentes | Solo hardware certificado, tipo de regulador correcto para la lámpara |
| Iluminación DALI o KNX | Puente desde el bus hacia MQTT | Oficinas y edificios comerciales | El bus sigue siendo el controlador; MQTT añade visibilidad |
En los hogares, las tiras PWM y los relés inteligentes cubren la mayoría de las necesidades. En los edificios comerciales, la iluminación suele estar ya en DALI o KNX, y lo correcto es puentear ese bus a MQTT en lugar de sustituirlo. La estructura de topics que sigue funciona para las cuatro opciones, que es la principal razón para estandarizarla pronto.
Diseño de topics para luces
Una luz necesita tres cosas en MQTT: un topic de órdenes, un topic de estado y un topic de disponibilidad. Mantenerlos separados evita bucles de realimentación y permite que los paneles muestren el estado real.
<prefix>/<room>/<light>/set commands in (JSON)
<prefix>/<room>/<light>/state actual state out (retained)
<prefix>/<room>/<light>/availability "online" / "offline" (retained, last will)
<prefix>/scenes/<scene> scene definitions (retained)
<prefix>/<room>/scene/set recall a scene by name
Las órdenes usan JSON para que puedas ampliarlas más adelante sin romper los clientes antiguos:
{"state": "ON", "brightness": 180, "transition": 2.0}
brightness va de 0 a 255 y transition es el tiempo de fundido en segundos. Este formato coincide con el esquema JSON que entiende la integración MQTT de Home Assistant para las luces, así que el mismo regulador puede aparecer en Home Assistant sin cambios en el firmware.
Hardware
- Una placa de desarrollo ESP32.
- Una tira LED monocolor de 12 V o 24 V y una fuente de alimentación adecuada.
- Un MOSFET de canal N de nivel lógico (por ejemplo IRLZ44N, o AO3400 en un módulo) por canal, o una placa driver de MOSFET ya montada.
- Una resistencia de puerta de 220 ohmios y una pull-down de 10 kOhm por canal.
Conecta la puerta del MOSFET al GPIO 25 a través de la resistencia, la fuente a masa (compartida con la masa del ESP32) y el drenador al cable negativo de la tira LED. El cable positivo de la tira va al positivo de la fuente. Nunca alimentes una tira desde el pin de 5 V del ESP32.
Para reguladores de red, usa un producto certificado que ya hable MQTT, como un dispositivo Tasmota o Shelly, en lugar de construir tú mismo hardware de tensión de red. El diseño de topics de este artículo sigue siendo aplicable; consulta Tasmota con MQTT para la grabación y la configuración.
El firmware del regulador ESP32
El sketch usa la API LEDC del core Arduino para ESP32 3.x (ledcAttach y ledcWrite), PubSubClient y ArduinoJson 7. Aplica una curva gamma para que la regulación parezca lineal al ojo, hace fundidos suaves y publica un estado retenido tras cada cambio.
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <math.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* BROKER = "broker.connectmq.com";
const int PORT = 1883;
#define PREFIX "connectmq/demo/light-8pz3/office-201/desk-strip"
const char* T_SET = PREFIX "/set";
const char* T_STATE = PREFIX "/state";
const char* T_AVAIL = PREFIX "/availability";
const int LED_PIN = 25;
const int PWM_FREQ = 5000; // Hz, por encima del parpadeo visible
const int PWM_BITS = 12; // 0..4095
const int PWM_MAX = (1 << PWM_BITS) - 1;
WiFiClient net;
PubSubClient mqtt(net);
bool on = false;
int brightness = 255; // último brillo 1..255
float current = 0, target = 0; // 0..255
float step = 0;
unsigned long lastStep = 0;
void writePwm(float level) {
float norm = level / 255.0f;
int duty = (int)(powf(norm, 2.2f) * PWM_MAX + 0.5f); // corrección gamma
ledcWrite(LED_PIN, duty);
}
void publishState() {
JsonDocument doc;
doc["state"] = on ? "ON" : "OFF";
doc["brightness"] = brightness;
char buf[96];
size_t n = serializeJson(doc, buf);
mqtt.publish(T_STATE, (const uint8_t*)buf, n, true);
}
void startFade(float to, float seconds) {
target = to;
int steps = max(1, (int)(seconds * 50)); // 50 actualizaciones por segundo
step = (target - current) / steps;
if (seconds <= 0) { current = target; writePwm(current); step = 0; }
}
void onMessage(char* topic, byte* payload, unsigned int len) {
JsonDocument doc;
if (deserializeJson(doc, payload, len)) return;
float transition = doc["transition"] | 0.5f;
if (doc["brightness"].is<int>()) {
brightness = constrain(doc["brightness"].as<int>(), 1, 255);
on = true;
}
if (doc["state"].is<const char*>()) {
on = strcmp(doc["state"].as<const char*>(), "ON") == 0;
}
startFade(on ? brightness : 0, transition);
publishState();
}
void connectMqtt() {
while (!mqtt.connected()) {
String id = "light-" + String((uint32_t)ESP.getEfuseMac(), HEX);
if (mqtt.connect(id.c_str(), T_AVAIL, 1, true, "offline")) {
mqtt.publish(T_AVAIL, "online", true);
mqtt.subscribe(T_SET, 1);
publishState();
} else {
delay(2000);
}
}
}
void setup() {
ledcAttach(LED_PIN, PWM_FREQ, PWM_BITS);
writePwm(0);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
mqtt.setServer(BROKER, PORT);
mqtt.setCallback(onMessage);
}
void loop() {
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
if (step != 0 && millis() - lastStep >= 20) {
lastStep = millis();
current += step;
if ((step > 0 && current >= target) || (step < 0 && current <= target)) {
current = target;
step = 0;
}
writePwm(current);
}
}
Observa que el fundido se ejecuta en loop() sin delay(), de modo que el dispositivo sigue respondiendo a MQTT mientras regula. La última voluntad marca la luz como offline si pierde la alimentación o el Wi-Fi.
Prueba el regulador
# observa todo lo de esta demo
mosquitto_sub -h broker.connectmq.com -p 1883 -i light-view-8pz3 -t "connectmq/demo/light-8pz3/#" -v
# enciende a un 70 por ciento aproximadamente con un fundido de 2 segundos
mosquitto_pub -h broker.connectmq.com -p 1883 -i light-cmd-8pz3 -q 1 \
-t "connectmq/demo/light-8pz3/office-201/desk-strip/set" \
-m '{"state":"ON","brightness":180,"transition":2}'
# apaga con un fundido lento
mosquitto_pub -h broker.connectmq.com -p 1883 -i light-cmd-8pz3 -q 1 \
-t "connectmq/demo/light-8pz3/office-201/desk-strip/set" \
-m '{"state":"OFF","transition":5}'
El servidor MQTT público gratuito está abierto a todo el mundo y solo sirve para pruebas y prototipos, así que un desconocido podría encender o apagar tu luz de demostración. En un escritorio es inofensivo, pero es exactamente la razón por la que la iluminación real debe funcionar en un broker privado con credenciales.
Escenas como mensajes retenidos
Una escena es un conjunto de estados de luz con nombre. Guardar cada escena como mensaje retenido en el broker significa que todos los controladores, paneles o pulsadores de pared ven las mismas definiciones, y puedes editar una escena publicando una nueva versión.
mosquitto_pub -h broker.connectmq.com -p 1883 -i light-cmd-8pz3 -r \
-t "connectmq/demo/light-8pz3/scenes/focus" \
-m '{"office-201/desk-strip":{"state":"ON","brightness":230},"office-201/ceiling":{"state":"ON","brightness":200}}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i light-cmd-8pz3 -r \
-t "connectmq/demo/light-8pz3/scenes/presentation" \
-m '{"office-201/desk-strip":{"state":"ON","brightness":40},"office-201/ceiling":{"state":"OFF"}}'
Recuperación de escenas y horarios en Python
Un pequeño servicio se suscribe a las definiciones de escena y a las peticiones de recuperación, reparte cada recuperación de escena entre las luces individuales y ejecuta un horario diario. Usa paho-mqtt 2.x y solo la librería estándar.
import json
import time
import uuid
from datetime import datetime
import paho.mqtt.client as mqtt
BROKER, PORT = "broker.connectmq.com", 1883
PREFIX = "connectmq/demo/light-8pz3"
scenes = {}
SCHEDULE = [ # (HH:MM, días de la semana 0=lun..6=dom, escena)
("08:00", range(0, 5), "focus"),
("12:30", range(0, 5), "presentation"),
("19:00", range(0, 7), "off"),
]
scenes["off"] = {"office-201/desk-strip": {"state": "OFF", "transition": 10}}
def recall(client, name):
scene = scenes.get(name)
if not scene:
print("Unknown scene", name)
return
for light, cmd in scene.items():
cmd = {"transition": 1.5, **cmd}
client.publish(f"{PREFIX}/{light}/set", json.dumps(cmd), qos=1)
print("Recalled", name)
def on_connect(client, userdata, flags, reason_code, properties):
client.subscribe(f"{PREFIX}/scenes/+", qos=1)
client.subscribe(f"{PREFIX}/+/scene/set", qos=1)
def on_message(client, userdata, msg):
parts = msg.topic.split("/")
if parts[-2] == "scenes":
if msg.payload:
scenes[parts[-1]] = json.loads(msg.payload)
elif parts[-2] == "scene" and parts[-1] == "set":
recall(client, msg.payload.decode().strip())
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"light-sched-{uuid.uuid4().hex[:6]}")
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, PORT, 60)
client.loop_start()
last_run = None
while True:
now = datetime.now()
key = now.strftime("%Y-%m-%d %H:%M")
if key != last_run:
for hhmm, days, scene in SCHEDULE:
if now.strftime("%H:%M") == hhmm and now.weekday() in days:
recall(client, scene)
last_run = key
time.sleep(5)
Recupera una escena manualmente con:
mosquitto_pub -h broker.connectmq.com -p 1883 -i light-cmd-8pz3 \
-t "connectmq/demo/light-8pz3/office-201/scene/set" -m "focus"
Si prefieres un editor visual para los horarios y los disparadores basados en el sol, los mismos topics funcionan desde Node-RED o desde las automatizaciones de Home Assistant.
Ampliar a blanco regulable y RGB
La misma estructura de firmware escala a tiras multicanal. Una tira de blanco regulable tiene un canal cálido y otro frío; una tira RGB o RGBW tiene tres o cuatro. Asigna un canal LEDC por cada pin de color y amplía la orden JSON con campos opcionales, por ejemplo "color_temp": 300 en mireds para el blanco regulable, o "color": {"r": 255, "g": 140, "b": 0} para RGB. Mantén brightness como campo independiente y escala cada canal con él, de modo que el brillo y el color puedan cambiar por separado y una escena pueda fijar ambos en un solo mensaje.
Dos detalles prácticos te ahorrarán problemas más adelante. Mezcla el blanco regulable manteniendo constante la suma de los dos canales para un brillo dado; de lo contrario, la luz se vuelve notablemente más brillante en la mitad del rango. Y publica el estado resultante completo, color incluido, en el topic de estado tras cada orden, porque los paneles y Home Assistant dependen de él para situar correctamente el selector de color.
Lista de comprobación para producción
- Pasa a un broker privado en el puerto 8883 con TLS y da a cada regulador sus propias credenciales.
- Mantén operativos en local los interruptores de pared; MQTT debe añadir control, no ser la única forma de encender una luz.
- Combina la iluminación con sensores de ocupación de oficinas para que las salas vacías se apaguen automáticamente.
- En edificios con KNX o DALI, deja que el bus existente haga la conmutación y puentéalo a MQTT, como en nuestra integración KNX MQTT.
- Usa QoS 1 para las órdenes y envía siempre valores absolutos, nunca "toggle", para que una entrega duplicada no cause daños.
| Error | Síntoma | Solución |
|---|---|---|
| Sin corrección gamma | La mayor parte del cambio visible ocurre en la parte baja del rango | Aplica una curva de potencia como en el sketch |
Fundido con delay() |
La luz ignora las órdenes durante los fundidos | Avanza el fundido por pasos en loop() |
| Estado no retenido | Los paneles muestran "desconocido" tras reconectar | Publica el estado con el indicador de retención |
| Órdenes en el topic de estado | Las luces oscilan o hacen eco | Separa los topics set y state |
| Frecuencia PWM baja | Parpadeo visible en cámara | Usa 1 kHz o más |
Un broker MQTT gestionado mantiene disponible el control de la iluminación sin un servidor en el edificio, y la guía de automatización IoT muestra cómo encaja la iluminación con otros sistemas del edificio. Los mismos topics de estado retenidos pueden alimentar un gemelo digital 3D del edificio en el que cada luminaria muestra su estado real sobre el plano.
Ejecuta tu iluminación en ConnectMQ
Crea un broker Serverless gratuito en app.connectmq.com, sin tarjeta de crédito. Graba tus reguladores con las credenciales emitidas y después usa el cliente MQTT del navegador integrado en el portal de ConnectMQ para enviar órdenes de prueba y recuperar escenas mientras pones en marcha cada sala.
Preguntas frecuentes
¿Por qué usar órdenes JSON en lugar de ON y OFF sin más?
JSON permite que un solo mensaje lleve el estado, el brillo y el tiempo de transición, y puedes añadir más adelante campos como la temperatura de color sin romper los clientes existentes.
¿Deben retenerse los topics de estado de las luces?
Sí. Un estado retenido permite que cualquier panel o app muestre el estado correcto en cuanto se suscribe, sin esperar al siguiente cambio.
¿Puedo controlar luces de red con un ESP32?
Usa un módulo regulador o de relé de red certificado que admita MQTT, como un dispositivo Tasmota o Shelly. Construir tu propio hardware de tensión de red es arriesgado y a menudo no cumple la normativa.
¿Qué pasa con las escenas programadas si el programador está caído?
Las luces mantienen su estado actual y siguen siendo controlables mediante los interruptores y las órdenes MQTT directas. Ejecuta el programador como un servicio con reinicio automático para que los eventos perdidos sean poco frecuentes.



