A smart switch is the project that makes home automation feel real. You tap a button on your phone, a relay clicks and a lamp, pump or fan switches on. With an ESP32, a relay module and Home Assistant, you can build one in an evening for a fraction of the cost of a commercial smart plug, and you control every detail: how it behaves after a power cut, whether a wall button still works, what happens if WiFi drops.
This guide builds a switch that does five things properly. It appears in Home Assistant by itself through MQTT Discovery, with no YAML. It can be toggled from Home Assistant and from a physical push button. It reports its true state back, so the dashboard never lies. It remembers its state through a power cut. And it shows as unavailable when the ESP32 goes offline. We start with a low-voltage demo, then cover what you must know before switching anything connected to mains electricity.
This builds on the MQTT temperature sensor guide. If MQTT is new to you, read the MQTT and Home Assistant beginner guide first.
Safety First: Mains Voltage Can Kill
The first build in this guide switches a low-voltage load, such as an LED strip on a 12V supply or a small DC fan, through a relay. That is safe to learn on.
Switching mains voltage (230V in India and most of the world, 120V in North America) is a different matter. It can cause fatal shock and fires. Do not wire mains to a bare relay module on a breadboard. If you want a mains-switching version:
- Use a fully enclosed, certified relay or smart-plug module, mounted in a proper insulated enclosure, with strain relief on every cable.
- Keep mains wiring physically separated from the low-voltage ESP32 side, and never touch anything while it is powered.
- Fuse the circuit and match the relay's contact rating to the load. A relay rated 10A resistive can fail with a motor or a pump because of inrush current.
- Have the mains side wired or inspected by a qualified electrician.
If you are not experienced with mains wiring, build and use the low-voltage version, or buy a pre-certified device and flash it with open firmware instead.
What You Need
| Item | Notes |
|---|---|
| ESP32 development board | See the ESP32 pinout guide |
| 1-channel relay module (5V, optocoupler isolated) | Check whether it is active-LOW (most common) or active-HIGH |
| Momentary push button | For local control |
| Test load | An LED on a 12V supply, a small 12V fan, or a 12V LED strip |
| Jumper wires and a breadboard | |
| Home Assistant with an MQTT broker | See the temperature sensor guide for broker setup |
| Arduino libraries | PubSubClient and ArduinoJson 7 (Preferences is built in) |
Wiring
| Component | ESP32 pin | Notes |
|---|---|---|
| Relay VCC | VIN (5V) | Most relay modules want 5V |
| Relay GND | GND | |
| Relay IN | GPIO 26 | Signal from the ESP32 |
| Button, one leg | GPIO 27 | Uses the internal pull-up |
| Button, other leg | GND | Pressed reads LOW |
The load wires go to the relay's screw terminals: COM (common) and NO (normally open). With NO, the load is off when the relay is off, which is the safe default. The relay's input is isolated by an optocoupler on most modules, so a 3.3V ESP32 signal is normally enough to drive it. A few cheap modules do not switch reliably at 3.3V, in which case use a module with a transistor input or add a small transistor stage.
GPIO 26 and 27 are safe, boot-neutral pins (see the ESP32 pinout guide).
Active-LOW or Active-HIGH? Get This Right First
Relay modules come in two flavors:
- Active-LOW (very common): the relay turns on when the input pin is LOW.
- Active-HIGH: the relay turns on when the input pin is HIGH.
The sketch has one setting, RELAY_ACTIVE_LOW, so you can match your module. If your relay is on at boot and off when you command it on, the setting is inverted.
There is also a boot-time subtlety. While the ESP32 is starting, its pins are briefly floating, and a relay can click once. For an active-LOW module, add a 10 kΩ pull-up resistor from the IN pin to 3.3V so the relay stays off during boot. In code, set the output level before enabling the pin as an output, which the sketch below does.
The Complete Sketch
Fill in your network and broker details, set RELAY_ACTIVE_LOW, then upload.
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
// ---------- Settings ----------
const char* WIFI_SSID = "YOUR_WIFI_NAME";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "192.168.1.10";
const int MQTT_PORT = 1883;
const char* MQTT_USER = "esp32";
const char* MQTT_PASS = "YOUR_MQTT_PASSWORD";
const char* DEVICE_ID = "esp32_switch1";
const char* DEVICE_NAME = "Workshop Switch";
#define RELAY_PIN 26
#define BUTTON_PIN 27
#define RELAY_ACTIVE_LOW true // set false for active-HIGH modules
#define RESTORE_STATE true // restore last state after power loss
// ---------- Globals ----------
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
Preferences prefs;
String commandTopic, stateTopic, availTopic;
bool relayOn = false;
unsigned long lastReconnect = 0;
// ---------- Relay ----------
void applyRelay() {
digitalWrite(RELAY_PIN, (relayOn != RELAY_ACTIVE_LOW) ? HIGH : LOW);
}
void publishState() {
if (mqtt.connected()) {
mqtt.publish(stateTopic.c_str(), relayOn ? "ON" : "OFF", true); // retained
}
}
void setRelay(bool on) {
relayOn = on;
applyRelay();
if (RESTORE_STATE) prefs.putBool("on", relayOn);
publishState();
Serial.printf("Relay %s\n", relayOn ? "ON" : "OFF");
}
// ---------- Home Assistant discovery ----------
void publishDiscovery() {
JsonDocument doc;
doc["name"] = "Relay";
doc["unique_id"] = String(DEVICE_ID) + "_relay";
doc["command_topic"] = commandTopic;
doc["state_topic"] = stateTopic;
doc["availability_topic"] = availTopic;
doc["payload_on"] = "ON";
doc["payload_off"] = "OFF";
JsonObject dev = doc["device"].to<JsonObject>();
JsonArray ids = dev["identifiers"].to<JsonArray>();
ids.add(DEVICE_ID);
dev["name"] = DEVICE_NAME;
dev["manufacturer"] = "DIY";
dev["model"] = "ESP32 Relay Switch";
String payload;
serializeJson(doc, payload);
String topic = String("homeassistant/switch/") + DEVICE_ID + "/relay/config";
mqtt.publish(topic.c_str(), payload.c_str(), true);
}
// ---------- MQTT ----------
void onMessage(char* topic, byte* payload, unsigned int length) {
String msg;
for (unsigned int i = 0; i < length; i++) msg += (char)payload[i];
String t = String(topic);
if (t == commandTopic) {
if (msg == "ON") setRelay(true);
if (msg == "OFF") setRelay(false);
} else if (t == "homeassistant/status" && msg == "online") {
publishDiscovery();
mqtt.publish(availTopic.c_str(), "online", true);
publishState();
}
}
bool connectMqtt() {
String clientId = String(DEVICE_ID) + "-" + String((uint32_t)ESP.getEfuseMac(), HEX);
bool ok = mqtt.connect(clientId.c_str(), MQTT_USER, MQTT_PASS,
availTopic.c_str(), 1, true, "offline");
if (ok) {
Serial.println("MQTT connected");
mqtt.subscribe(commandTopic.c_str());
mqtt.subscribe("homeassistant/status");
mqtt.publish(availTopic.c_str(), "online", true);
publishDiscovery();
publishState();
} else {
Serial.printf("MQTT failed, rc=%d\n", mqtt.state());
}
return ok;
}
void ensureWifi() {
if (WiFi.status() == WL_CONNECTED) return;
Serial.println("Reconnecting WiFi...");
WiFi.disconnect();
WiFi.begin(WIFI_SSID, WIFI_PASS);
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) delay(250);
}
// ---------- Local button with debounce ----------
void handleButton() {
static bool lastRead = HIGH, stable = HIGH;
static unsigned long lastChange = 0;
bool r = digitalRead(BUTTON_PIN);
if (r != lastRead) { lastChange = millis(); lastRead = r; }
if (millis() - lastChange > 50 && r != stable) {
stable = r;
if (stable == LOW) setRelay(!relayOn); // toggle on press
}
}
void setup() {
Serial.begin(115200);
// Set the output level BEFORE enabling the pin to avoid a boot glitch
digitalWrite(RELAY_PIN, RELAY_ACTIVE_LOW ? HIGH : LOW);
pinMode(RELAY_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
prefs.begin("switch", false);
relayOn = RESTORE_STATE ? prefs.getBool("on", false) : false;
applyRelay();
commandTopic = String("esp32/") + DEVICE_ID + "/switch/set";
stateTopic = String("esp32/") + DEVICE_ID + "/switch/state";
availTopic = String("esp32/") + DEVICE_ID + "/status";
WiFi.mode(WIFI_STA);
WiFi.setHostname(DEVICE_ID);
WiFi.begin(WIFI_SSID, WIFI_PASS);
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) delay(250);
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(1024);
mqtt.setCallback(onMessage);
}
void loop() {
handleButton(); // works even if WiFi or MQTT is down
ensureWifi();
if (!mqtt.connected()) {
if (millis() - lastReconnect > 5000) {
lastReconnect = millis();
connectMqtt();
}
} else {
mqtt.loop();
}
}
What makes this a reliable switch
- The button works without WiFi.
handleButton()runs first in every loop, so a dead router never leaves you unable to switch the light. - State is published after every change, whether it came from Home Assistant or the button, so the dashboard always matches the real relay.
- State is retained, so Home Assistant restores the right value after a restart.
Preferencesstores the last state in flash, so after a power cut the switch returns to what it was. SetRESTORE_STATEtofalseif you would rather it always start off, which is the safer choice for heaters, pumps and motors.- Availability and Last Will make the switch show as unavailable the moment the ESP32 drops off the network.
See It in Home Assistant
Once the sketch connects, open Settings → Devices & Services → MQTT → Devices. A device named Workshop Switch appears with a Relay switch entity, created automatically. Click the toggle and the relay clicks. Press the physical button and the toggle in Home Assistant follows within a moment.
Add it to a dashboard with Edit dashboard → Add card → Button or Tile, choose the switch entity and save.
Test the failure behavior too, because that is what separates a good switch from a flaky one:
- Unplug the ESP32. The entity should become unavailable within seconds.
- Plug it back in. It should return to its previous state if
RESTORE_STATEis on. - Restart the Home Assistant MQTT broker. The ESP32 should reconnect on its own within about five seconds.
- Turn the router off. The physical button should still work.
Automations
Because the switch is a normal Home Assistant entity, you can automate it. Open Settings → Automations & Scenes → Create automation → Edit in YAML.
Turn on at sunset, off at 11 PM:
alias: Workshop light schedule
trigger:
- platform: sun
event: sunset
id: "dusk"
- platform: time
at: "23:00:00"
id: "night"
action:
- choose:
- conditions:
- condition: trigger
id: "dusk"
sequence:
- service: switch.turn_on
target:
entity_id: switch.workshop_switch_relay
- conditions:
- condition: trigger
id: "night"
sequence:
- service: switch.turn_off
target:
entity_id: switch.workshop_switch_relay
mode: single
Run a fan when the room is hot, pairing this with the temperature sensor:
alias: Fan on when hot
trigger:
- platform: numeric_state
entity_id: sensor.living_room_sensor_temperature
above: 29
action:
- service: switch.turn_on
target:
entity_id: switch.workshop_switch_relay
mode: single
Check the exact entity IDs on each entity's settings page, since they follow your device and entity names.
Troubleshooting
The switch never appears in Home Assistant. Confirm MQTT connected in the Serial Monitor, that setBufferSize(1024) is present and that MQTT Discovery is enabled. Listen to homeassistant/# for the .../relay/config message.
The relay is inverted (on when it should be off). Flip RELAY_ACTIVE_LOW.
The relay clicks at boot. Add a 10 kΩ pull-up from IN to 3.3V on active-LOW modules and keep the digitalWrite before pinMode order in setup().
The relay clicks but the ESP32 resets. The relay coil is browning out the supply. Power the relay from a stable 5V source with a shared ground, or add a 470 µF capacitor across the supply.
The relay does not click at all. The module may need more than 3.3V at the input. Try a different module, or drive it with a transistor.
It toggles twice per press. Contact bounce. Increase the debounce time above 50 ms, and check for a loose button wire.
State in Home Assistant is wrong after a restart. The state topic must be published retained (it is in this sketch), and state_topic must match in the discovery message.
It works for hours and then goes unavailable. WiFi problems. See the WiFi troubleshooting guide, and give the device a static IP.
Strange behavior with motors or heaters. Inductive loads create voltage spikes and large inrush currents. Use a relay rated for the load, a flyback diode across DC coils and a snubber for AC loads.
Frequently Asked Questions
Can I use a second relay? Yes. Add another relay pin, another command and state topic pair and another discovery message with a different unique_id. A loop over an array of channels keeps the code tidy.
Should I use MQTT or ESPHome for this? ESPHome can create this entire device from a short YAML file and flash it for you, and is a great choice if you do not want to write firmware. Writing the sketch yourself teaches how it works and gives full control. Both are covered in Home Assistant without MQTT.
Why not control the relay directly by HTTP? You can, with a REST API, but MQTT gives you instant two-way state, availability and discovery.
Does it keep working if the internet is down? Yes. Everything is local. Only your router and the Home Assistant machine need to be running.
How do I make it more secure? Use a strong MQTT password, keep the broker off the public internet, restrict the broker user to the topics it needs and consider TLS on port 8883.
What next? Add a physical status LED, a current sensor for power monitoring, or a second ESP32 temperature node to automate the switch.