Smart lighting MQTT control lets any app, dashboard or automation dim a light, recall a scene or run a schedule simply by publishing a message. In this tutorial you will build an ESP32 PWM dimmer for LED strips with smooth fades, design topics that work for one room or a whole building, store scenes as retained messages, and run schedules from a tiny Python service connected to a ConnectMQ broker.
Choosing Lighting Hardware for MQTT
Before writing firmware, decide which kind of lighting you are controlling. Each option fits MQTT in a slightly different way.
| Hardware | How it connects to MQTT | Good for | Watch out for |
|---|---|---|---|
| Low-voltage LED strips with an ESP32 PWM driver | Custom firmware, as in this tutorial | Desks, shelves, cabinets, accent lighting | Power supply sizing and voltage drop on long runs |
| Addressable LEDs (WS2812B and similar) | Custom firmware or WLED | Effects and per-pixel color | Current draw, data line length |
| Mains dimmers and relays with MQTT firmware | Tasmota, Shelly or similar devices | Existing ceiling lights | Only certified hardware, correct dimmer type for the lamp |
| DALI or KNX lighting | Bridge from the bus to MQTT | Offices and commercial buildings | The bus remains the controller; MQTT adds visibility |
In homes, PWM strips and smart relays cover most needs. In commercial buildings, the lighting is usually on DALI or KNX already, and the right move is to bridge that bus to MQTT rather than replace it. The topic structure below works for all four, which is the main reason to standardize it early.
Topic Design for Lights
A light needs three things on MQTT: a command topic, a state topic and an availability topic. Keeping them separate prevents feedback loops and lets dashboards show the true state.
<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
Commands use JSON so you can extend them later without breaking older clients:
{"state": "ON", "brightness": 180, "transition": 2.0}
brightness ranges from 0 to 255 and transition is the fade time in seconds. This format matches the JSON schema that the Home Assistant MQTT integration understands for lights, so the same dimmer can appear in Home Assistant with no firmware changes.
Hardware
- An ESP32 development board.
- A 12 V or 24 V single-color LED strip and a matching power supply.
- A logic-level N-channel MOSFET (for example IRLZ44N or AO3400 on a module) per channel, or a ready-made MOSFET driver board.
- A 220 ohm gate resistor and a 10 kOhm pull-down per channel.
Wire the MOSFET gate to GPIO 25 through the resistor, the source to ground (shared with the ESP32 ground), and the drain to the LED strip's negative lead. The strip's positive lead goes to the supply positive. Never power a strip from the ESP32's 5 V pin.
For mains dimmers, use a certified product that already speaks MQTT, such as a Tasmota or Shelly device, rather than building mains-voltage hardware yourself. The topic design in this article still applies; see Tasmota MQTT for flashing and configuration.
The ESP32 Dimmer Firmware
The sketch uses the ESP32 Arduino core 3.x LEDC API (ledcAttach and ledcWrite), PubSubClient and ArduinoJson 7. It applies a gamma curve so dimming looks linear to the eye, fades smoothly, and publishes a retained state after every change.
#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, above visible flicker
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; // last brightness 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); // gamma correction
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 updates per second
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);
}
}
Note that the fade runs in loop() without delay(), so the device keeps answering MQTT while it dims. The last will marks the light offline if it loses power or Wi-Fi.
Test the Dimmer
# watch everything for this demo
mosquitto_sub -h broker.connectmq.com -p 1883 -i light-view-8pz3 -t "connectmq/demo/light-8pz3/#" -v
# turn on at about 70 percent with a 2 second fade
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}'
# off with a slow fade
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}'
The public MQTT test broker is open to everyone, so a stranger could switch your demo light. That is harmless on a desk, but it is exactly why real lighting must run on a private broker with credentials.
Scenes as Retained Messages
A scene is a named set of light states. Storing each scene as a retained message on the broker means every controller, dashboard or wall panel sees the same definitions, and you can edit a scene by publishing a new version.
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"}}'
Scene Recall and Schedules in Python
A small service subscribes to scene definitions and recall requests, fans scene recalls out to individual lights, and runs a daily schedule. It uses paho-mqtt 2.x and the standard library only.
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, weekdays 0=Mon..6=Sun, scene)
("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)
Recall a scene by hand with:
mosquitto_pub -h broker.connectmq.com -p 1883 -i light-cmd-8pz3 \
-t "connectmq/demo/light-8pz3/office-201/scene/set" -m "focus"
If you prefer a visual editor for schedules and sun-based triggers, the same topics work from Node-RED or from Home Assistant automations.
Extending to Tunable White and RGB
The same firmware structure scales to multi-channel strips. A tunable white strip has a warm and a cool channel; an RGB or RGBW strip has three or four. Attach one LEDC channel per color pin and extend the JSON command with optional fields, for example "color_temp": 300 in mireds for tunable white, or "color": {"r": 255, "g": 140, "b": 0} for RGB. Keep brightness as a separate field and scale every channel by it, so brightness and color can change independently and a scene can set both in one message.
Two practical points save trouble later. Mix tunable white by keeping the sum of the two channels constant for a given brightness, otherwise the light gets noticeably brighter in the middle of the range. And publish the full resulting state, including color, on the state topic after every command, because dashboards and Home Assistant rely on it to draw the correct color picker position.
Production Checklist
- Move to a private broker on port 8883 with TLS and give every dimmer its own credentials.
- Keep wall switches working locally; MQTT should add control, not be the only way to turn a light on.
- Combine lighting with office occupancy sensors so empty rooms switch off automatically.
- In buildings with KNX or DALI, let the existing bus do the switching and bridge it to MQTT, as in our KNX MQTT integration.
- Use QoS 1 for commands and always send absolute values, never "toggle", so a duplicate delivery does no harm.
| Pitfall | Symptom | Fix |
|---|---|---|
| No gamma correction | Most visible change happens at the bottom of the range | Apply a power curve as in the sketch |
Fade with delay() |
Light ignores commands during fades | Step the fade in loop() |
| State not retained | Dashboards show unknown after reconnect | Publish state with the retain flag |
| Commands on the state topic | Lights oscillate or echo | Separate set and state topics |
| Low PWM frequency | Visible flicker on camera | Use 1 kHz or more |
A managed MQTT broker keeps lighting control available without a server in the building, and the IoT automation guide shows how lighting fits with other building systems.
Run Your Lighting on ConnectMQ
Create a free Serverless broker at app.connectmq.com, no credit card required. Flash your dimmers with the issued credentials, then use the in-browser MQTT client in the ConnectMQ portal to send test commands and recall scenes while you commission each room.
Frequently Asked Questions
Why use JSON commands instead of plain ON and OFF?
JSON lets one message carry state, brightness and transition time, and you can add fields such as color temperature later without breaking existing clients.
Should light state topics be retained?
Yes. A retained state lets any dashboard or app show the correct state the moment it subscribes, without waiting for the next change.
Can I control mains lights with an ESP32?
Use a certified mains dimmer or relay module that supports MQTT, such as a Tasmota or Shelly device. Building your own mains-voltage hardware is risky and often not compliant.
What happens to scheduled scenes if the scheduler is down?
Lights keep their current state and remain controllable by switches and direct MQTT commands. Run the scheduler as a service with automatic restart so missed events are rare.



