ESP32 MQTT Sensor Publisher with PubSubClient, DHT22 and Last Will

With a Mosquitto broker running on your network, the next step is a real device. In this tutorial an ESP32 reads temperature and humidity from a DHT22 every 15 seconds and publishes them as JSON over MQTT. It also announces itself as online or offline using a retained status topic and Last Will, and listens on a command topic so you can change the reporting interval remotely.

The sketch is written to survive the real world: Wi-Fi drops, broker restarts and sensor read failures are all handled without freezing the loop. That robustness is what separates a demo from a node you can leave running for months.

Hardware and Libraries

ItemDetails
BoardESP32 DevKit V1 (ESP32-WROOM-32) or similar
SensorDHT22 (AM2302), 3.3 V supply
Pull-up10 kΩ from DHT22 data to 3.3 V (many breakout boards include it)
Data pinGPIO 4
BrokerMosquitto on a PC or Raspberry Pi, port 1883, with a user and password
LibrariesPubSubClient (Nick O'Leary), DHT sensor library (Adafruit), Adafruit Unified Sensor, ArduinoJson 7
DHT22 pin 1 (VCC)  -> ESP32 3V3
DHT22 pin 2 (DATA) -> ESP32 GPIO 4  (+10k to 3V3)
DHT22 pin 4 (GND)  -> ESP32 GND

Topic Plan

The node uses a base topic built from a name you choose, so several nodes can share the same firmware with only one line changed.

TopicDirectionRetainedPayload
home/node1/telemetryESP32 publishesNo{"temp_c":28.4,"hum_pct":61.0,"rssi":-58}
home/node1/statusESP32 and broker (LWT)Yesonline or offline
home/node1/interval/setApp publishesNoSeconds between readings, 5 to 3600

The Complete Sketch

ESP32 MQTT publisher with Last Will, non-blocking reconnect and a command topic
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#include <ArduinoJson.h>

// ---------- user settings ----------
const char* WIFI_SSID   = "YourWiFi";
const char* WIFI_PASS   = "YourWiFiPassword";
const char* MQTT_HOST   = "192.168.1.50";   // broker IP
const uint16_t MQTT_PORT = 1883;
const char* MQTT_USER   = "iotuser";
const char* MQTT_PASS   = "secret";
const char* NODE        = "home/node1";     // base topic
// -----------------------------------

DHT dht(4, DHT22);
WiFiClient net;
PubSubClient mqtt(net);

String tTelemetry = String(NODE) + "/telemetry";
String tStatus    = String(NODE) + "/status";
String tInterval  = String(NODE) + "/interval/set";

uint32_t intervalMs = 15000;
uint32_t lastSend = 0, lastTry = 0;

// Called by mqtt.loop() whenever a subscribed message arrives
void onMessage(char* topic, byte* payload, unsigned int len) {
  String msg;
  for (unsigned int i = 0; i < len; i++) msg += (char)payload[i];
  if (tInterval == topic) {
    long s = msg.toInt();
    if (s >= 5 && s <= 3600) {
      intervalMs = s * 1000UL;
      Serial.println("Interval set to " + msg + " s");
    }
  }
}

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("WiFi");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.println(" connected, IP " + WiFi.localIP().toString());
}

// Try once to connect; returns true on success. Never blocks for long.
bool connectMqtt() {
  String clientId = "esp32-" + String((uint32_t)ESP.getEfuseMac(), HEX);
  // connect(id, user, pass, willTopic, willQos, willRetain, willMessage)
  if (!mqtt.connect(clientId.c_str(), MQTT_USER, MQTT_PASS,
                    tStatus.c_str(), 1, true, "offline")) {
    Serial.print("MQTT failed, state=");
    Serial.println(mqtt.state());
    return false;
  }
  mqtt.publish(tStatus.c_str(), "online", true);   // retained
  mqtt.subscribe(tInterval.c_str(), 1);
  Serial.println("MQTT connected");
  return true;
}

void publishReading() {
  float t = dht.readTemperature();
  float h = dht.readHumidity();
  if (isnan(t) || isnan(h)) { Serial.println("DHT read failed"); return; }

  JsonDocument doc;
  doc["temp_c"]  = round(t * 10) / 10.0;
  doc["hum_pct"] = round(h * 10) / 10.0;
  doc["rssi"]    = WiFi.RSSI();
  char buf[128];
  size_t n = serializeJson(doc, buf);
  mqtt.publish(tTelemetry.c_str(), (const uint8_t*)buf, n, false);
  Serial.println(buf);
}

void setup() {
  Serial.begin(115200);
  dht.begin();
  connectWiFi();
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setCallback(onMessage);
  mqtt.setKeepAlive(15);       // seconds
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) {   // Wi-Fi lost: let it reconnect
    WiFi.reconnect();
    delay(1000);
    return;
  }
  if (!mqtt.connected()) {
    // retry every 5 s without blocking the rest of the loop
    if (millis() - lastTry > 5000) { lastTry = millis(); connectMqtt(); }
    return;
  }
  mqtt.loop();                 // keeps the connection alive, runs callbacks

  if (millis() - lastSend >= intervalMs) {
    lastSend = millis();
    publishReading();
  }
}

How the Code Works

Client ID and Last Will

The client ID is made from the chip's eFuse MAC, so it is unique per board. The long form of mqtt.connect() registers a Last Will: if the ESP32 loses power, the broker publishes a retained "offline" on the status topic after the keep-alive expires. Straight after connecting, the sketch publishes a retained "online", which overwrites the old offline value. Any dashboard subscribing later gets the current state immediately.

Non-blocking reconnect

Many examples call mqtt.connect() inside a while loop, which freezes the device while the broker is down. Here a failed attempt simply returns, and the next attempt happens five seconds later. mqtt.state() returns a code you can look up: -2 means the TCP connection failed (wrong IP, broker down, firewall), 4 means bad username or password, 5 means not authorised.

Sending the payload

serializeJson writes into a fixed char buffer and returns the length, which is passed to the publish(topic, payload, length, retained) overload. PubSubClient's default packet buffer is 256 bytes including the topic; for larger messages call mqtt.setBufferSize(512) before connecting. Telemetry is not retained because each reading is replaced by the next one.

Why mqtt.loop() matters

mqtt.loop() reads incoming packets, calls your callback and sends PINGREQ packets for keep-alive. If anything in loop() blocks for longer than the keep-alive period, such as a long delay(), the broker drops the connection and fires the Last Will. Use millis() timing as shown.

Testing

Watch the node and change its interval from a PC
mosquitto_sub -h 192.168.1.50 -u iotuser -P secret -t 'home/node1/#' -v

# change reporting interval to 60 seconds
mosquitto_pub -h 192.168.1.50 -u iotuser -P secret -t home/node1/interval/set -m 60
home/node1/status online
home/node1/telemetry {"temp_c":28.4,"hum_pct":61.2,"rssi":-58}
home/node1/telemetry {"temp_c":28.5,"hum_pct":61.0,"rssi":-59}

Now unplug the ESP32. Within about 25 seconds the subscriber prints home/node1/status offline.

Common Problems

  • State -2 forever: the ESP32 and broker are on different networks, or the PC firewall blocks port 1883.
  • Messages never arrive though publish returns true: the payload plus topic exceeds the buffer size; increase it with setBufferSize().
  • Constant reconnects: another device uses the same client ID.
  • DHT read failed every time: missing pull-up, wrong pin, or reading faster than once every two seconds.

Extending the Node

Choosing QoS for each message

PubSubClient publishes only at QoS 0, which is a sensible choice for telemetry: if one reading is lost, the next one arrives 15 seconds later. It can subscribe at QoS 0 or 1, so the interval command above is subscribed at QoS 1 and the broker will retry delivery to the ESP32 until it is acknowledged. If you need QoS 1 or 2 publishing from the device, use a library such as AsyncMqttClient or the ESP-IDF MQTT client, which support it.

Running several nodes

To deploy the same firmware on many boards, change only the NODE string, for example home/kitchen, home/bedroom and home/terrace. A dashboard or Node-RED flow can subscribe to home/+/telemetry and receive all of them, with the room name available from the topic. Each node gets its own retained status topic, so you can see at a glance which rooms are reporting.

Checking link quality

The rssi field is the received Wi-Fi signal strength in dBm. Values around -50 to -65 dBm are comfortable; as they approach -80 dBm you will start to see reconnects and missed readings. Plotting RSSI next to temperature is a cheap way to diagnose an unreliable node before blaming the code. Moving the board a metre, turning the antenna end away from metal, or adding a repeater usually fixes it.

Summary

You now have an ESP32 that publishes JSON telemetry, reports its own online state through a retained topic and Last Will, accepts a remote command, and recovers from network failures without blocking. This node is the data source for the Node-RED dashboard and smart home tutorials later in the series.