IoT Automation

Smart Lighting MQTT Control: Dimmers, LED Strips, Scenes and Schedules

Control LED strips and dimmers over MQTT with an ESP32, use JSON commands with smooth transitions, store scenes as retained messages and run schedules from a small Python service.

Dimmable LED strip under a desk controlled by an ESP32 in a smart lighting mqtt project
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

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

  1. Move to a private broker on port 8883 with TLS and give every dimmer its own credentials.
  2. Keep wall switches working locally; MQTT should add control, not be the only way to turn a light on.
  3. Combine lighting with office occupancy sensors so empty rooms switch off automatically.
  4. In buildings with KNX or DALI, let the existing bus do the switching and bridge it to MQTT, as in our KNX MQTT integration.
  5. 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.

Related articles

Zigbee2MQTT Setup Guide: From Coordinator to Dashboard

Install Zigbee2MQTT with Docker, configure the coordinator and MQTT connection, pair and rename devices, control them over MQTT and view their states on a dashboard, testing first on a free public broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.