ESP32 Home Automation Node with MQTT & Home Assistant
Overview
I'd already built the Telegram-bot relay switch — the one where you text /fan on and a relay clicks somewhere across the house. It works, and for a single appliance it's honestly enough. But once I wanted to add a second ESP32 in the garage, then a third by the water heater, the whole thing fell apart. Each board needed its own bot token, its own chat thread, and I was the one remembering which /lamp on command belonged to which device. Telegram is fine for one node. It's not an architecture.
Home Assistant with MQTT is the architecture. Instead of a phone chat you're maintaining by hand, every device publishes to a broker and gets discovered automatically — new sensors, new switches, new automations, all without touching the dashboard config. This build turns a bare ESP32 WROOM-32 into exactly that: a node that reads temperature and humidity off a DHT22, drives a relay, announces itself to Home Assistant the moment it boots, and can be reflashed over WiFi once it's mounted somewhere annoying to reach with a USB cable.
None of this is exotic. It's the same pattern every serious ESP32 home automation project ends up converging on, because it's the one that actually scales past a single board.
Why MQTT Instead of a Direct API Call
The lazy version of this project has the ESP32 hitting the Home Assistant REST API directly — a POST request every time the relay state changes. It works right up until Home Assistant restarts, or the ESP32's WiFi hiccups mid-request, or you want a second consumer of the same sensor data (a Grafana dashboard, say) without writing a second integration.
MQTT decouples all of that. The ESP32 doesn't know or care who's listening — it publishes a temperature reading to a topic and moves on. Home Assistant, an InfluxDB logger, a phone notification automation, and a debugging session with mosquitto_sub can all subscribe to the same topic simultaneously, and none of them affect the others. If the broker restarts, the ESP32's reconnect loop handles it without any manual intervention. That resilience is the entire reason to reach for MQTT over a webhook.
Home Assistant MQTT Discovery is the piece that makes this genuinely pleasant to work with instead of just correct. Rather than manually defining a sensor: and switch: entry in Home Assistant's YAML for every device you add, the ESP32 publishes a small JSON payload describing itself — name, unit, device class, which topic to read state from, which topic to write commands to — to a specially-named discovery topic. Home Assistant listens on homeassistant/# for exactly these payloads and builds the entity automatically. Add a third sensor to the sketch, reflash, and it shows up in the dashboard with zero YAML editing.
What This Node Does
- Reads temperature and humidity from a DHT22 every 30 seconds and publishes both readings
- Drives a 5V relay — a light, a fan, a water valve, whatever's wired to it — through an MQTT command topic
- Announces itself via Home Assistant MQTT Discovery, so it appears in HA automatically with the right icons and units
- Supports Arduino OTA, so firmware updates go out over WiFi instead of requiring a laptop and a USB cable next to whatever this thing ends up mounted behind
- Reconnects with exponential backoff when the broker drops, instead of hammering it every second or giving up entirely
Run this against a local Mosquitto broker — either on a Raspberry Pi or the Home Assistant MQTT add-on — not a public broker. Public test brokers have no access control, and a relay controlling something physical in your house is not something you want anyone else on that broker able to flip.
Parts List
| Component | Notes |
|---|---|
| ESP32 WROOM-32 dev board | Any variant works; this uses the standard 30-pin DevKit layout |
| DHT22 temperature/humidity sensor | DHT11 works too — swap the DHT22 constant for DHT11 in the code |
| 10kΩ resistor | Pull-up on the DHT22 data line |
| 5V single-channel relay module | Most breakout boards are active-LOW — confirm yours before wiring |
| Breadboard + jumper wires | — |
| 5V 1–2A power supply | Relay coils draw more current than the ESP32's USB power alone comfortably supplies under load |
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| DHT22 VCC | 3V3 | |
| DHT22 GND | GND | |
| DHT22 DATA | GPIO 4 | Through the 10kΩ pull-up to 3V3 |
| Relay VCC | 5V (VIN) | Not 3V3 — most relay coils won't reliably switch on 3.3V |
| Relay GND | GND | Shared ground with the ESP32 |
| Relay IN | GPIO 26 | Active-LOW on most boards: LOW energizes the relay |
If your relay board turns out to be active-HIGH instead, the fix is a one-line change — flip the LOW/HIGH values in onMessage() and the initial digitalWrite() in setup(). There's no way to tell which type you have just by looking at it; test it once with the board unplugged from anything load-bearing and confirm which state clicks the relay.
The Code
A couple of details in here are doing more work than they look like they are. The discovery payloads are published with retain = true — that's not optional. Retained messages are what let Home Assistant see the device's configuration even if HA itself restarts after the ESP32 already published; without the retain flag, discovery only works if HA happens to be listening at the exact moment the ESP32 boots, which in practice means it works in testing and silently stops working after the next power outage.
The reconnect function backs off exponentially — 1 second, 2, 4, 8, up to a 30-second ceiling — instead of retrying every second forever. This matters more than it seems like it should. A single misbehaving node hammering a broker every second isn't a big deal, but a network hiccup that knocks out five or six nodes simultaneously and has all of them retrying every second at the same time is a real, if minor, denial-of-service against your own broker. Backoff avoids that entirely.
What Actually Broke When I Built This
The version above is the clean one. The first version I actually flashed didn't have the exponential backoff — it retried the broker connection once a second, forever, no ceiling. That was fine for about two weeks, until I moved the Mosquitto container to a different machine and forgot to update the IP on one of the three nodes I had running by then. That one node spent the next four days trying to connect to a broker that didn't exist at that address anymore, once a second, twenty-four hours a day. Nothing caught fire, nothing crashed, and I only noticed because the node's WiFi indicator LED never stopped blinking on reconnect attempts. It wasn't hurting anything, but it was a completely unnecessary amount of traffic on the network for a mistake that should have been silent and cheap to leave unfixed for a few days. The backoff logic in the code above exists specifically because of that node — capping retries at 30 seconds turns "misconfigured device hammering the network indefinitely" into "misconfigured device checking in every half a minute," which is a much better failure mode to accidentally leave running.
The other thing that tripped me up initially was assuming the relay module I'd ordered was active-HIGH, because that's what I was used to from a different project. It wasn't — flipping the switch in Home Assistant turned the light off, and turning it "off" turned the light on. Nothing was actually broken; the logic was just inverted from what I expected, and it took longer than it should have to realize that because I was looking for a wiring fault instead of just testing both states deliberately with the load disconnected first. That's why the wiring section above is explicit about testing active-HIGH vs. active-LOW before connecting anything that actually switches power — it's a thirty-second check that saves a genuinely confusing debugging session later.
Setting Up the Broker
If you don't already have Mosquitto running somewhere on your network, the fastest path is the Mosquitto broker add-on from the Home Assistant add-on store — search for it under Settings → Add-ons, install, and start it. It integrates with HA's own user database, so you can reuse an existing HA login as the MQTT credentials instead of managing a separate password file.
Running it standalone on a Raspberry Pi is just as valid, and arguably more robust if you want the broker to survive a Home Assistant reinstall untouched:
Either way, once it's running, confirm it's actually reachable from wherever the ESP32 will be before touching the sketch at all:
If that round-trips, the broker's fine and any connection problems later are on the ESP32 side — wrong IP, wrong credentials, or a firewall rule blocking port 1883 between subnets, which trips people up more often than you'd expect on networks with guest WiFi segmentation enabled.
First Boot
- Fill in
WIFI_SSID,WIFI_PASS,MQTT_BROKER,MQTT_USER, andMQTT_PASSat the top of the sketch. - Upload, then open the Serial Monitor at 115200 baud. You should see the WiFi IP print, followed by
MQTT connecting...OK. - In Home Assistant, go to Settings → Devices & Services → MQTT — if MQTT integration isn't already configured, add it and point it at the same broker. Within a few seconds, ESP32 Node 01 should appear under Devices with a temperature entity, a humidity entity, and a relay switch, fully named and correctly typed.
- Toggle the relay from the Home Assistant dashboard. The physical relay should click, and the toggle state in HA should reflect it — that round trip (command out, state back) confirms both directions of the MQTT link are working.
- Build an automation on top of it however you'd normally do that in HA — turn the relay on when the temperature crosses a threshold, notify your phone if humidity spikes, whatever the actual use case is. The node itself doesn't care; it's just publishing and subscribing.
Adding More Sensors
The pattern here generalizes cleanly. Every new sensor is: one TOPIC_X define, one DISCO_X define, one block in publishDiscovery(), and one publish call in loop(). A soil moisture sensor, a second relay for a different appliance, a PIR motion sensor — all of them slot into the exact same shape without touching the connection or reconnect logic at all. That's the actual payoff of building it this way instead of hardcoding a single sensor path: the boilerplate (WiFi, MQTT connect, backoff, OTA) is written once and every subsequent sensor is a few lines, not a redesign.
Concretely, adding a PIR motion sensor on GPIO 27 looks like this. Define the topic and discovery topic next to the existing ones:
Add a discovery block in publishDiscovery():
And read + publish it in loop(), same as temperature and humidity already are. That's the whole addition — no changes anywhere else in the sketch, and Home Assistant picks up the new binary_sensor entity on the next boot without any manual entity configuration. Multiply that by however many sensors a given node actually needs, and the sketch stays readable even at six or seven entities per board, which is not something you can say about the equivalent built as a pile of individual REST calls.
OTA Updates in Practice
Once this board is mounted somewhere inconvenient — behind a wall panel, up near a light fixture, wherever the relay actually needs to live — pulling it down for every firmware tweak gets old fast. ArduinoOTA solves that: after the first upload over USB, the board announces itself on the network, and Arduino IDE's Tools → Port menu will show it listed under "Network Ports" alongside the usual serial ports. Select it, hit upload, and the new firmware goes out over WiFi.
Two things worth knowing before relying on this: the board has to already be running ArduinoOTA-enabled firmware for network upload to work at all — the very first flash has to be over USB — and OTA updates fail if the board loses WiFi mid-transfer, which can leave it in a state where you need physical access anyway. It's a convenience, not a guarantee you'll never touch the hardware again.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Device never appears in Home Assistant | Discovery topic not retained, or MQTT integration not configured | Confirm retain = true on discovery publishes; check Settings → Devices & Services → MQTT is set up |
MQTT connecting...failed (5) | Wrong username/password | rc=5 is specifically "not authorized" — double-check MQTT_USER/MQTT_PASS |
| Relay state in HA doesn't match physical relay | Active-HIGH relay board wired assuming active-LOW logic | Swap the LOW/HIGH values in onMessage() and the initial state in setup() |
Temperature reads as nan | DHT22 wiring or missing pull-up resistor | Confirm the 10kΩ resistor is between DATA and 3V3, not left out |
| OTA port never shows up in Arduino IDE | Board and computer on different subnets/VLANs, or mDNS blocked | Confirm both devices are on the same network segment; some routers block mDNS between guest and main WiFi |
Common Mistakes
Skipping the retain flag on discovery messages. This is the single most common reason "it worked once and then never again" happens with this pattern — it works during testing because HA and the ESP32 boot around the same time, then quietly breaks the next time only one of them restarts.
Using a public MQTT broker for anything beyond a bench test. Everything here assumes a private, local broker. A public broker publishing relay commands is a genuinely bad idea the moment it's connected to something that actually switches power.
Forgetting the relay needs its own 5V rail. Powering a relay coil off the ESP32's 3.3V line either won't switch it reliably or will brownout the board under load. Feed the relay from VIN/5V, share ground, and keep the coil current off the 3.3V regulator entirely.
Not testing active-HIGH vs active-LOW before wiring anything load-bearing to it. It takes thirty seconds to verify with the board on a bench, and it's the difference between "the light turns on when I flip the switch" and "the light turns on when I turn the switch off," which is a genuinely confusing bug to debug after the fact.
Where to Go Next
This node covers temperature, humidity, and a single relay — the shape scales to whatever a specific project needs from here. A few natural next steps: add a second relay and a second switch entity for a two-appliance node, wire in a PIR sensor and publish binary_sensor discovery for motion-triggered automations, or move the WiFi credentials out of the sketch and into a captive-portal WiFiManager setup so reflashing for a new network doesn't mean editing and recompiling. For the underlying protocol this all sits on top of, see SolderHub's ESP32 MQTT Tutorial, which covers QoS, retained messages, and Last Will in more depth than fits here.
