Smart Home Relay Controller with ESP32 and MQTT: End-to-End Build

This project brings the earlier tutorials together into one useful device: a four-channel relay controller that switches lights and fans from a phone dashboard and from physical push buttons. It reports the true state of every relay, remembers states across power cuts, announces when it goes offline, and works with a self-hosted Mosquitto broker and Node-RED, so nothing depends on a third-party cloud.

The design follows rules that commercial smart switches also follow: the local button always works even when Wi-Fi is down, the device is the single source of truth for relay state, and commands are idempotent so a repeated message never toggles a load by accident.

System Overview

Phone / PC browser
      |  http://pi:1880/dashboard
Node-RED (ui-switch x4)  <---- state ----+
      | set                              |
Mosquitto broker (Raspberry Pi) ---------+
      | Wi-Fi
ESP32 ---- GPIO --> 4-channel relay module --> lights / fan
  ^
  +--- 4 push buttons (local control)
TopicPublisherRetainedPayload
home/hall/relay/1/set ... /4/setDashboardNoON or OFF
home/hall/relay/1/state ... /4/stateESP32YesON or OFF
home/hall/statusESP32 and broker LWTYesonline or offline

Hardware

FunctionESP32 GPIONotes
Relay 1 to 4 (IN1 to IN4)16, 17, 18, 19Most modules are active LOW
Button 1 to 432, 33, 25, 27Button to GND, internal pull-up enabled
Relay module VCC / JD-VCC5 V supplyRemove the JD-VCC jumper and power the coils separately for better isolation if the module supports it
Power5 V 2 A adapter or a certified AC-DC moduleRelays draw tens of mA each when on

The pins were chosen to avoid strapping pins (0, 2, 12, 15) and input-only pins (34 to 39), so relays do not chatter at boot and buttons can use internal pull-ups.

Firmware

Libraries: PubSubClient and the built-in Preferences. Wi-Fi and MQTT reconnect without blocking, so buttons keep working during outages.

4-channel MQTT relay controller with buttons, retained state and power-loss recovery
#include <WiFi.h>
#include <PubSubClient.h>
#include <Preferences.h>

const char* WIFI_SSID = "YourWiFi";
const char* WIFI_PASS = "YourWiFiPassword";
const char* MQTT_HOST = "192.168.1.50";
const char* MQTT_USER = "hall";
const char* MQTT_PASS = "secret";
const char* BASE = "home/hall";

const int N = 4;
const int RELAY_PIN[N]  = {16, 17, 18, 19};
const int BUTTON_PIN[N] = {32, 33, 25, 27};
const bool ACTIVE_LOW = true;          // typical relay modules

bool state[N];
bool lastButton[N];
uint32_t lastChange[N];
uint32_t lastMqttTry = 0;

WiFiClient net;
PubSubClient mqtt(net);
Preferences prefs;

String topic(int i, const char* leaf) {   // home/hall/relay/<i+1>/<leaf>
  return String(BASE) + "/relay/" + String(i + 1) + "/" + leaf;
}

void publishState(int i) {
  if (mqtt.connected())
    mqtt.publish(topic(i, "state").c_str(), state[i] ? "ON" : "OFF", true);
}

void setRelay(int i, bool on) {
  state[i] = on;
  digitalWrite(RELAY_PIN[i], (on ^ ACTIVE_LOW) ? HIGH : LOW);
  prefs.putBool(String("r" + String(i)).c_str(), on);  // survive power cuts
  publishState(i);
}

void onMessage(char* t, byte* payload, unsigned int len) {
  String msg;
  for (unsigned int k = 0; k < len; k++) msg += (char)payload[k];
  msg.trim();
  msg.toUpperCase();
  for (int i = 0; i < N; i++) {
    if (topic(i, "set") == t) {
      if (msg == "ON")  setRelay(i, true);    // idempotent commands
      if (msg == "OFF") setRelay(i, false);
    }
  }
}

void tryMqtt() {
  String will = String(BASE) + "/status";
  String id = "relay-" + String((uint32_t)ESP.getEfuseMac(), HEX);
  if (!mqtt.connect(id.c_str(), MQTT_USER, MQTT_PASS, will.c_str(), 1, true, "offline")) return;
  mqtt.publish(will.c_str(), "online", true);
  for (int i = 0; i < N; i++) {
    mqtt.subscribe(topic(i, "set").c_str(), 1);
    publishState(i);                     // report the real state
  }
}

void setup() {
  Serial.begin(115200);
  prefs.begin("relays", false);
  for (int i = 0; i < N; i++) {
    pinMode(RELAY_PIN[i], OUTPUT);
    pinMode(BUTTON_PIN[i], INPUT_PULLUP);
    lastButton[i] = HIGH;
    lastChange[i] = 0;
    // restore the state saved before the power cut (default OFF)
    bool saved = prefs.getBool(String("r" + String(i)).c_str(), false);
    state[i] = saved;
    digitalWrite(RELAY_PIN[i], (saved ^ ACTIVE_LOW) ? HIGH : LOW);
  }
  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.begin(WIFI_SSID, WIFI_PASS);      // no waiting: buttons work at once
  mqtt.setServer(MQTT_HOST, 1883);
  mqtt.setCallback(onMessage);
}

void loop() {
  // 1. Local buttons: toggle on press, 50 ms debounce
  for (int i = 0; i < N; i++) {
    bool b = digitalRead(BUTTON_PIN[i]);
    if (b != lastButton[i] && millis() - lastChange[i] > 50) {
      lastChange[i] = millis();
      lastButton[i] = b;
      if (b == LOW) setRelay(i, !state[i]);
    }
  }

  // 2. Network, never blocking the buttons
  if (WiFi.status() == WL_CONNECTED) {
    if (!mqtt.connected() && millis() - lastMqttTry > 5000) {
      lastMqttTry = millis();
      tryMqtt();
    }
    mqtt.loop();
  }
}

Design Decisions Explained

  • Separate set and state topics. The dashboard publishes to set; only the ESP32 publishes state. If the device is offline, the dashboard never shows a change that did not happen.
  • Retained state. When Node-RED restarts or a new phone opens the dashboard, the broker immediately delivers the latest state of all four relays.
  • Set commands are not retained. A retained ON on a set topic would be replayed on every reconnect and override a button press made while offline.
  • Preferences. Each change is written to NVS so the relays come back in the same state after a power cut. NVS spreads writes across its pages, but avoid automations that toggle relays every few seconds.
  • Idempotent ON/OFF. Receiving ON twice keeps the relay on. A TOGGLE command would flip it twice if QoS 1 delivered a duplicate.
  • Startup without waiting for Wi-Fi. The relays and buttons are usable within moments of power-on, and the network connects in the background.

Node-RED Dashboard and Testing

In Node-RED, add four ui-switch nodes with on value ON and off value OFF. Wire each to an mqtt out node on its set topic, and wire an mqtt in node on the matching state topic back into the switch input, with passthrough disabled. Add a ui-text node fed from home/hall/status to show online or offline. Before building the dashboard, test with the command-line clients.

Test the controller from a PC
mosquitto_sub -h 192.168.1.50 -u nodered -P secret -t 'home/hall/#' -v
mosquitto_pub -h 192.168.1.50 -u nodered -P secret -t home/hall/relay/2/set -m ON
home/hall/status online
home/hall/relay/1/state OFF
home/hall/relay/2/state ON
home/hall/relay/3/state OFF
home/hall/relay/4/state OFF

Acceptance tests before installing

  1. Press each button and confirm the relay clicks and the matching state message appears.
  2. Send ON twice to the same relay; it must stay on rather than toggle.
  3. Switch relay 3 on, then unplug and replug the ESP32. Relay 3 should come back on, and the dashboard should show it.
  4. Turn off the Wi-Fi router. The buttons must still work, and within a short time the status topic should change to offline.
  5. Turn the router back on. The device reconnects by itself and republishes all four states, including changes made with the buttons while offline.

Only after all five tests pass should the board go into a switchboard. These tests catch most of the problems that make home automation frustrating to live with.

Troubleshooting

  • All relays click at boot: relays are on strapping pins, or the module's inputs float before pinMode. Use the pins listed above.
  • ESP32 resets when a relay switches a motor or tube light: inductive kickback and supply dips. Add an RC snubber across the load contacts, use a separate supply for relay coils, and keep mains wires away from the ESP32.
  • Dashboard shows the wrong state: the switch is passing input straight to output, or something else is publishing to the state topics.
  • Relay toggles twice on one press: debounce time is too short for your buttons; increase it from 50 ms.

Summary

You built a complete smart home controller: local buttons that always work, MQTT commands on set topics, retained state topics as the single source of truth, Last Will status, and NVS-backed recovery after power cuts. Add TLS and per-device credentials from the security tutorial and OTA from the update tutorial, and the board is ready for daily use.