IoT Automation

Soil Moisture Sensor with MQTT: ESP32 Build with Calibration and Watering Alerts

Wire a capacitive soil moisture probe to an ESP32, calibrate dry and wet points, publish percent moisture over MQTT with deep sleep, and raise debounced watering alerts.

Soil moisture sensor MQTT node with an ESP32 and a capacitive probe in a plant bed sending readings to a cloud broker
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

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:

  1. Dry: the probe in completely dry soil from the same bed (or in air, as a rough fallback).
  2. 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

  1. Calibrate in the real soil. Sandy and clay soils give very different readings for the same water content.
  2. Keep insertion depth constant. Air gaps around the probe make readings drop sharply; firm the soil around it after insertion.
  3. Watch the trend, not single values. A falling curve over two days is more meaningful than one reading.
  4. 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.
  5. Track the battery. Add a voltage divider on another ADC1 pin and include battery_v in 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, use WiFiClientSecure net; and net.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.

Related articles

Zigbee2MQTT Setup Guide: From Coordinator to Dashboard

Install Zigbee2MQTT with Docker, configure the coordinator and MQTT connection, pair and rename devices, control them over MQTT and view their states on a dashboard, testing first on a free public broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.