Skip to content
Tutorial
16 min read
October 17, 2026

ESP32 + Home Assistant WiFi Smart Switch: Relay, Button & MQTT Discovery

Build a WiFi smart switch with an ESP32 and a relay that appears in Home Assistant automatically through MQTT Discovery. Includes a physical button, power-loss state restore, safe relay wiring, full code, an automation and a safety section on mains voltage.

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.

Image pending: ESP32, a relay module and a push button wired on a breadboard, with a Home Assistant switch toggle on a phone beside it

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

ItemNotes
ESP32 development boardSee the ESP32 pinout guide
1-channel relay module (5V, optocoupler isolated)Check whether it is active-LOW (most common) or active-HIGH
Momentary push buttonFor local control
Test loadAn LED on a 12V supply, a small 12V fan, or a 12V LED strip
Jumper wires and a breadboard
Home Assistant with an MQTT brokerSee the temperature sensor guide for broker setup
Arduino librariesPubSubClient and ArduinoJson 7 (Preferences is built in)

Wiring

ComponentESP32 pinNotes
Relay VCCVIN (5V)Most relay modules want 5V
Relay GNDGND
Relay INGPIO 26Signal from the ESP32
Button, one legGPIO 27Uses the internal pull-up
Button, other legGNDPressed 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).

Image pending: Wiring diagram of the ESP32, a 1-channel relay module on GPIO 26 and a push button on GPIO 27 with a 12V LED strip on the relay COM and NO terminals
Image pending: Close-up of the relay module showing the IN, VCC, GND pins, the COM, NO and NC screw terminals and the active-low jumper

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.
  • Preferences stores the last state in flash, so after a power cut the switch returns to what it was. Set RESTORE_STATE to false if 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.
Image pending: Serial Monitor showing MQTT connected, the relay turning ON from a Home Assistant command and OFF from a button press

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:

  1. Unplug the ESP32. The entity should become unavailable within seconds.
  2. Plug it back in. It should return to its previous state if RESTORE_STATE is on.
  3. Restart the Home Assistant MQTT broker. The ESP32 should reconnect on its own within about five seconds.
  4. Turn the router off. The physical button should still work.
Image pending: Home Assistant dashboard tile for the Workshop Switch showing the on and off states and the device page with the relay entity

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.

Image pending: Finished smart switch installed in a plastic enclosure with the ESP32, relay module and push button on the lid
Image pending: Home Assistant history graph showing the switch on and off events alongside the temperature trend that triggered the fan automation

Signal In

Built from what makers search for

The lookups piling up in SolderHub's search bar every day shape what we publish next — new component pages, board guides, and firmware notes, sent out as they go live.

By subscribing you agree to our Privacy Policy. Unsubscribe anytime.