A soil moisture sensor with MQTT tells you when a bed, pot or field zone actually needs water, instead of watering on a fixed schedule. In this tutorial you will wire a capacitive probe to an ESP32, calibrate it against dry and saturated soil, publish percent moisture to an MQTT broker on a deep-sleep cycle, and raise a debounced watering alert that dashboards and automations can act on.
Why a Capacitive Probe
The cheap two-pronged resistive probes found in starter kits pass current through the soil. Within weeks the exposed electrodes corrode, readings drift and the metal leaches into the soil. Capacitive probes measure the dielectric permittivity of the soil through a coated circuit board, so nothing conductive touches the ground. They are the sensible minimum for anything that should last a season.
| Probe type | Lifespan in soil | Accuracy | Notes |
|---|---|---|---|
| Resistive fork | Weeks | Poor, drifts | Fine for a one-day demo only |
| Capacitive (v1.2 / v2.0 boards) | Seasons, if the edge is sealed | Relative, needs calibration | Best value for hobby and small farm use |
| Professional FDR/TDR probes | Years | Good, often volumetric | SDI-12 or RS485 output, higher cost |
Capacitive boards give a relative reading, not volumetric water content. That is fine for deciding when to water, as long as you calibrate each probe in the soil it will live in.
Parts and Wiring
| Part | Purpose |
|---|---|
| ESP32 dev board (or a low-quiescent board for batteries) | Reads the probe, connects to Wi-Fi and MQTT |
| Capacitive soil moisture probe | Measures moisture |
| 3 jumper wires | Power, ground, signal |
| Optional: 18650 cell, holder and small solar panel | Off-grid power |
Wiring:
| Probe pin | ESP32 pin |
|---|---|
| VCC | GPIO 25 (switched power) |
| GND | GND |
| AOUT | GPIO 34 (ADC1) |
Two details matter. First, use an ADC1 pin (GPIO 32 to 39). ADC2 pins cannot be read while Wi-Fi is active. Second, power the probe from a GPIO rather than the 3.3 V rail so it draws nothing while the ESP32 sleeps. Capacitive probes draw only a few milliamps, well within what a GPIO can supply. Some cheap boards ship with a regulator that misbehaves at 3.3 V; if readings are erratic, check the board revision.
Seal the top edge of the probe and the exposed components with nail varnish, conformal coat or heat shrink. Water creeping into the cut edge of the PCB is the most common cause of probe failure.
Calibrating the Probe
The ESP32 returns a raw value from 0 to 4095. Capacitive probes read high when dry and low when wet. Flash the sketch below with CALIBRATION_MODE set to true, open the serial monitor at 115200 baud and note two values:
- Dry: the probe in completely dry soil from the same bed (or in air, as a rough fallback).
- Wet: the probe in the same soil saturated with water, after a few minutes to settle.
Insert the probe to the same depth every time, up to the line marked on the board, never deeper. Typical values are around 2,600 to 3,000 dry and 1,000 to 1,400 wet, but every probe differs, which is why you measure rather than copy.
The ESP32 Soil Moisture Sensor MQTT Sketch
The sketch wakes, powers the probe, takes a median of several readings, converts it to percent, connects to Wi-Fi and the broker, publishes a retained telemetry message and an alert state, and goes back to deep sleep. It uses the PubSubClient library. Replace s5d1v8 with your own random string, since the public test area is readable by everyone.
#include <WiFi.h>
#include <PubSubClient.h>
#define CALIBRATION_MODE false
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* CLIENT_ID = "soil-bed1-s5d1v8";
const char* TOPIC_TELE = "connectmq/demo/s5d1v8/garden/bed1/telemetry";
const char* TOPIC_ALERT = "connectmq/demo/s5d1v8/garden/bed1/alert";
const char* TOPIC_STATUS = "connectmq/demo/s5d1v8/garden/bed1/status";
const int PIN_PROBE_PWR = 25;
const int PIN_PROBE_ADC = 34;
const int RAW_DRY = 2850; // from calibration
const int RAW_WET = 1180; // from calibration
const float ALERT_ON_PCT = 25.0; // start alert below this
const float ALERT_OFF_PCT = 35.0; // clear alert above this
const uint64_t SLEEP_MIN = 15;
RTC_DATA_ATTR bool alertActive = false;
RTC_DATA_ATTR uint32_t bootCount = 0;
WiFiClient net;
PubSubClient mqtt(net);
int readRawMedian() {
digitalWrite(PIN_PROBE_PWR, HIGH);
delay(300); // let the probe output settle
int v[9];
for (int i = 0; i < 9; i++) {
v[i] = analogRead(PIN_PROBE_ADC);
delay(20);
}
digitalWrite(PIN_PROBE_PWR, LOW);
for (int i = 1; i < 9; i++) { // insertion sort
int k = v[i], j = i - 1;
while (j >= 0 && v[j] > k) { v[j + 1] = v[j]; j--; }
v[j + 1] = k;
}
return v[4];
}
float rawToPercent(int raw) {
float pct = 100.0f * (RAW_DRY - raw) / (float)(RAW_DRY - RAW_WET);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
return pct;
}
bool connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
return WiFi.status() == WL_CONNECTED;
}
bool connectMqtt() {
mqtt.setServer(MQTT_HOST, MQTT_PORT);
for (int i = 0; i < 3; i++) {
if (mqtt.connect(CLIENT_ID, TOPIC_STATUS, 1, true, "offline")) return true;
delay(1000);
}
return false;
}
void goToSleep() {
esp_sleep_enable_timer_wakeup(SLEEP_MIN * 60ULL * 1000000ULL);
esp_deep_sleep_start();
}
void setup() {
Serial.begin(115200);
pinMode(PIN_PROBE_PWR, OUTPUT);
analogSetPinAttenuation(PIN_PROBE_ADC, ADC_11db);
bootCount++;
int raw = readRawMedian();
if (CALIBRATION_MODE) {
while (true) {
Serial.printf("raw=%d\n", readRawMedian());
delay(1000);
}
}
float pct = rawToPercent(raw);
bool changed = false;
if (!alertActive && pct < ALERT_ON_PCT) { alertActive = true; changed = true; }
if (alertActive && pct > ALERT_OFF_PCT) { alertActive = false; changed = true; }
if (connectWifi() && connectMqtt()) {
char buf[160];
snprintf(buf, sizeof(buf),
"{\"moisture_pct\":%.1f,\"raw\":%d,\"rssi\":%d,\"boot\":%lu}",
pct, raw, WiFi.RSSI(), (unsigned long)bootCount);
mqtt.publish(TOPIC_STATUS, "online", true);
mqtt.publish(TOPIC_TELE, buf, true);
snprintf(buf, sizeof(buf), "{\"active\":%s,\"moisture_pct\":%.1f}",
alertActive ? "true" : "false", pct);
mqtt.publish(TOPIC_ALERT, buf, true);
if (changed) Serial.println(alertActive ? "ALERT raised" : "ALERT cleared");
mqtt.loop();
delay(200); // give the TCP stack time to flush
mqtt.publish(TOPIC_STATUS, "sleeping", true);
mqtt.disconnect();
}
goToSleep();
}
void loop() {}
Watching the readings
mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-s5d1v8 \
-t 'connectmq/demo/s5d1v8/garden/#' -v
Because telemetry is retained, the subscriber shows the latest reading immediately, even though the device spends almost all its time asleep. The status topic tells you whether the node is online, sleeping, or dropped off unexpectedly (offline, from the last will).
How the Watering Alert Works
A single threshold would make the alert flap on and off as readings hover around it, especially right after light rain or when the sun warms the soil. The sketch uses hysteresis: the alert turns on below 25 percent and only clears once moisture climbs above 35 percent. The alertActive flag lives in RTC memory (RTC_DATA_ATTR), so it survives deep sleep.
The alert topic is retained and always carries the current state, so any consumer can act on it:
- a Home Assistant automation that notifies your phone, as shown in the Home Assistant MQTT automation guide;
- a Node-RED flow or Python controller that opens a valve, covered in the automatic irrigation system tutorial;
- a threshold alert rule in the ConnectMQ portal on
moisture_pct, which logs alerts even if your home server is offline.
Accuracy and Reliability Tips
- Calibrate in the real soil. Sandy and clay soils give very different readings for the same water content.
- Keep insertion depth constant. Air gaps around the probe make readings drop sharply; firm the soil around it after insertion.
- Watch the trend, not single values. A falling curve over two days is more meaningful than one reading.
- Avoid the ADC extremes. The ESP32 ADC is non-linear near 0 and 4095. With 11 dB attenuation the useful range comfortably covers a capacitive probe.
- Track the battery. Add a voltage divider on another ADC1 pin and include
battery_vin the payload; the ESP32 battery-powered sensor article has the details.
Battery Life
With 15-minute wakeups, the node is awake for only a few seconds per cycle, most of it waiting for Wi-Fi association. Using a static IP and the router's BSSID and channel can shorten that noticeably. Choose a board with a low-quiescent regulator and no always-on USB chip and LED, because many development boards draw more while "asleep" than the ESP32 itself. For fields far from Wi-Fi, a LoRaWAN node with the same probe and a network server bridge is the better design; see the smart agriculture IoT guide for the trade-offs.
Production Setup
The free public MQTT broker area is ideal for building and testing, but it is shared and public. For a permanent install, create a private broker and switch to TLS:
- include
WiFiClientSecure.h, useWiFiClientSecure net;andnet.setCACert(ISRG_ROOT_X1);with the ISRG Root X1 certificate; - change the port to 8883;
- connect with
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", TOPIC_STATUS, 1, true, "offline"); - move topics to your own prefix, for example
farm1/garden/bed1/telemetry.
TLS adds a second or two to each wake cycle, a small cost for keeping your data private. A hosted MQTT broker also saves you running a broker on the network the sensors depend on. More projects like this live in the IoT automation guide.
Monitor Your Soil on ConnectMQ
ConnectMQ gives your sensors a private, TLS-secured broker with a live topic explorer, a dashboard and threshold alert rules for dry-soil warnings. Create a free Serverless broker at app.connectmq.com, no credit card required, and swap three lines in the sketch to go live.
Frequently Asked Questions
Why does my capacitive soil moisture sensor read higher when the soil is dry?
That is how capacitive probes work: output voltage falls as the dielectric constant rises with water. The sketch inverts the scale during conversion to percent.
Can I use several probes on one ESP32?
Yes. Use other ADC1 pins (GPIO 32, 33, 35, 36, 39), calibrate each probe separately and publish each to its own topic or as fields in one JSON message.
How deep should the soil moisture sensor go?
At the root zone of the crop, typically 5 to 15 cm for vegetables and pots. Insert only up to the marked line so electronics stay above the soil.
How often should a soil moisture sensor publish over MQTT?
Every 15 to 60 minutes is plenty. Soil moisture changes slowly, and longer intervals dramatically extend battery life.



