Intermediate2–3 hoursESP32

Soil Moisture Sensor with MQTT and Deep Sleep

Read a capacitive soil moisture sensor on an ADC1 pin, calibrate it to a 0–100% scale, publish the reading to an MQTT broker, and deep sleep between readings.

This project measures how wet a plant pot is and reports it over Wi-Fi to an MQTT broker, where Home Assistant, Node-RED or a command-line subscriber can pick it up. Soil moisture changes over hours, not seconds, so the ESP32 deep sleeps between readings. Each cycle is: wake, measure, connect, publish, sleep. If you don't have a broker yet, "Set up a test broker" below gets Mosquitto running in a few commands.

Why a capacitive sensor

Two-prong "resistive" soil sensors pass current through the soil, and their exposed metal corrodes. A capacitive sensor keeps its electrodes sealed inside the circuit board and senses the soil's effect on their capacitance, so nothing metal touches the soil. Its analog output voltage goes down as the soil gets wetter.

Wiring

Sensor pinESP32 pinWhy
VCCGPIO23Powers the sensor only while measuring.
GNDGND
AOUTGPIO34ADC1 input, see below.

Powering the sensor from a GPIO lets the sketch switch it on just for the reading and leave it off while the ESP32 sleeps. You can connect VCC to 3V3 instead; the sketch still works, but the sensor keeps drawing current during sleep.

Why GPIO34 and not just any analog pin. The ESP32 has two ADCs, and the Wi-Fi driver uses ADC2. While Wi-Fi is running, analogRead() on an ADC2 pin (GPIO0, 2, 4, 12–15, 25–27) doesn't return valid readings. ADC1 pins (GPIO32–36 and 39) aren't affected. This sketch actually reads the sensor before starting Wi-Fi, so an ADC2 pin would happen to work, but only until someone moves the reading after the connection or adds a second reading. Using ADC1 removes that trap. GPIO34 is also input-only, which suits an analog input.

Libraries

  • PubSubClient by Nick O'Leary, from Tools → Manage Libraries.
  • WiFi comes with the ESP32 core.

The sketch

Edit the settings block at the top: Wi-Fi, broker address, topic and sleep interval. Leave CALIBRATION_MODE set to true for the first upload. You'll set the two calibration values in the next step.

// Soil moisture -> MQTT, with deep sleep between readings.
// Target: "ESP32 Dev Module", ESP32 Arduino core 3.x. Library: PubSubClient.
#include <WiFi.h>
#include <PubSubClient.h>
 
// ================= Settings: change these =================
const char WIFI_SSID[]     = "YOUR_WIFI_NAME";
const char WIFI_PASSWORD[] = "YOUR_WIFI_PASSWORD";
 
const char MQTT_HOST[]     = "192.168.1.10";  // broker IP address or hostname
const uint16_t MQTT_PORT   = 1883;
const char MQTT_USER[]     = "";              // leave empty if the broker allows anonymous clients
const char MQTT_PASSWORD[] = "";
const char MQTT_TOPIC[]    = "garden/soil/plant1";
 
const uint32_t SLEEP_MINUTES = 15;
 
// Calibration: replace with YOUR sensor's raw readings (see "Calibrate the sensor").
const int DRY_RAW = 2800;  // PLACEHOLDER: reading in dry air or bone-dry soil
const int WET_RAW = 1200;  // PLACEHOLDER: reading in water or freshly soaked soil
 
// true: print raw readings every second, no Wi-Fi, no sleep (for calibration)
const bool CALIBRATION_MODE = true;
// ==========================================================
 
const int SENSOR_PIN       = 34;  // ADC1, input-only
const int SENSOR_POWER_PIN = 23;  // switches the sensor on only while measuring
const int SAMPLES          = 16;  // readings averaged per measurement
 
const unsigned long SENSOR_SETTLE_MS = 500;
const unsigned long WIFI_TIMEOUT_MS  = 15000;
const int MQTT_ATTEMPTS              = 3;
 
// RTC memory survives deep sleep (but not a power cut or reset).
RTC_DATA_ATTR uint32_t bootCount = 0;
RTC_DATA_ATTR uint32_t failedCycles = 0;  // failed publishes since the last success
 
WiFiClient netClient;
PubSubClient mqtt(netClient);
 
int readSoilRaw() {
  digitalWrite(SENSOR_POWER_PIN, HIGH);
  delay(SENSOR_SETTLE_MS);  // let the sensor's output settle after power-up
 
  long sum = 0;
  for (int i = 0; i < SAMPLES; i++) {
    sum += analogRead(SENSOR_PIN);
    delay(10);
  }
 
  digitalWrite(SENSOR_POWER_PIN, LOW);
  return (int)(sum / SAMPLES);
}
 
// Map a raw reading to 0-100 %: DRY_RAW -> 0 %, WET_RAW -> 100 %.
int rawToPercent(int raw) {
  if (DRY_RAW == WET_RAW) {
    return 0;  // not calibrated; avoid dividing by zero
  }
  float percent = (float)(DRY_RAW - raw) * 100.0f / (float)(DRY_RAW - WET_RAW);
  if (percent < 0.0f) percent = 0.0f;
  if (percent > 100.0f) percent = 100.0f;
  return (int)(percent + 0.5f);
}
 
bool connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
 
  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED) {
    if (millis() - start >= WIFI_TIMEOUT_MS) {
      Serial.println("Wi-Fi: timed out");
      return false;
    }
    delay(100);
  }
  Serial.print("Wi-Fi: connected, IP ");
  Serial.println(WiFi.localIP());
  return true;
}
 
bool connectMqtt() {
  String clientId = "soil-" + WiFi.macAddress();  // unique per board
  clientId.replace(":", "");
 
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  for (int attempt = 1; attempt <= MQTT_ATTEMPTS; attempt++) {
    bool ok;
    if (strlen(MQTT_USER) > 0) {
      ok = mqtt.connect(clientId.c_str(), MQTT_USER, MQTT_PASSWORD);
    } else {
      ok = mqtt.connect(clientId.c_str());
    }
    if (ok) {
      Serial.println("MQTT: connected as " + clientId);
      return true;
    }
    Serial.printf("MQTT: connect failed, state %d (attempt %d of %d)\n",
                  mqtt.state(), attempt, MQTT_ATTEMPTS);
    delay(1000);
  }
  return false;
}
 
bool publishReading(int raw, int percent) {
  if (!connectWiFi() || !connectMqtt()) {
    return false;
  }
 
  char payload[128];
  snprintf(payload, sizeof(payload),
           "{\"moisture\":%d,\"raw\":%d,\"rssi\":%d,\"boot\":%lu,\"failed\":%lu}",
           percent, raw, (int)WiFi.RSSI(),
           (unsigned long)bootCount, (unsigned long)failedCycles);
 
  // retained = true: the broker keeps the last value for new subscribers.
  bool ok = mqtt.publish(MQTT_TOPIC, payload, true);
  Serial.printf("MQTT: publish %s: %s\n", ok ? "ok" : "FAILED", payload);
 
  mqtt.loop();
  mqtt.disconnect();
  delay(100);  // give the network stack a moment to send before Wi-Fi goes off
  return ok;
}
 
void goToSleep() {
  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);
 
  Serial.printf("Sleeping for %lu minutes\n", (unsigned long)SLEEP_MINUTES);
  Serial.flush();
 
  esp_sleep_enable_timer_wakeup((uint64_t)SLEEP_MINUTES * 60ULL * 1000000ULL);
  esp_deep_sleep_start();  // never returns; waking up restarts setup()
}
 
void setup() {
  Serial.begin(115200);
  delay(200);
 
  pinMode(SENSOR_POWER_PIN, OUTPUT);
  digitalWrite(SENSOR_POWER_PIN, LOW);
  analogReadResolution(12);  // 0-4095 (the default, stated for clarity)
 
  if (CALIBRATION_MODE) {
    Serial.println("Calibration mode: printing raw readings, no Wi-Fi, no sleep.");
    return;  // loop() takes over
  }
 
  bootCount++;
 
  // Measure first, before Wi-Fi is on.
  int raw = readSoilRaw();
  int percent = rawToPercent(raw);
  Serial.printf("Boot %lu: raw=%d, moisture=%d%%\n",
                (unsigned long)bootCount, raw, percent);
 
  if (publishReading(raw, percent)) {
    failedCycles = 0;
  } else {
    failedCycles++;
    Serial.println("Publish failed; will try again after the next sleep.");
  }
 
  goToSleep();
}
 
void loop() {
  // Only reached in calibration mode.
  int raw = readSoilRaw();
  Serial.printf("raw=%d  (with current calibration: %d%%)\n", raw, rawToPercent(raw));
  delay(1000);
}

Calibrate the sensor

Every sensor, board and soil gives different raw numbers, so the two values in the sketch are placeholders. Measure your own:

  1. Upload with CALIBRATION_MODE = true and open the Serial Monitor at 115200. You'll see a raw reading every second or so.
  2. Dry point: hold the sensor in the air, or push it into completely dry soil from the same pot. Wait for the numbers to stop drifting and note a typical value.
  3. Wet point: put the sensor in a glass of water, or in soil you've just soaked. Don't let the water go above the line printed on the sensor; the electronics at the top aren't waterproof. Note a typical value. It will be lower than the dry point.
  4. Put the two numbers into DRY_RAW and WET_RAW, set CALIBRATION_MODE = false, and upload again.

The mapping is a straight line between your two points: DRY_RAW becomes 0 %, WET_RAW becomes 100 %, and anything outside is clamped. Calibrating in real soil gives a more useful scale than air and water, because even soaked soil never reads as wet as plain water. Either way, the percentage is relative to your reference points. It's good for "water the plant below 30 %", not a scientific measure of water content.

Keep the conditions the same between calibration and use: same supply (GPIO23 or 3V3) and the sensor pushed in to the same depth. Both change the readings.

If the raw value barely moves between dry and wet, suspect the sensor before the code. Some low-cost "v1.2" boards are built with an NE555 timer chip, which isn't specified to run at 3.3 V, instead of the low-voltage TLC555 the design calls for. Check the marking on the 8-pin chip. If it's an NE555, get a different sensor.

What one cycle does

  1. Wake and measure. Waking from deep sleep is a reboot, so setup() runs from the top. It powers the sensor, lets it settle, averages 16 readings and powers it off, all before Wi-Fi starts, which also keeps radio noise out of the reading.
  2. Connect. Wi-Fi gets 15 seconds and the broker three attempts. If the router is down, the board gives up, counts the failure and tries again next cycle instead of draining the battery.
  3. Publish. One JSON message with the retained flag set, so the broker keeps the latest value and hands it to anyone who subscribes later, which suits a device that's asleep most of the time.
  4. Sleep. Wi-Fi off, timer set, esp_deep_sleep_start().

bootCount and failedCycles are marked RTC_DATA_ATTR, so they survive sleep. The failed field in each message tells you how many cycles failed before this one got through. A rising value points at Wi-Fi or broker trouble.

A message looks like this:

{"moisture":43,"raw":2112,"rssi":-61,"boot":17,"failed":0}

PubSubClient publishes with QoS 0: no delivery confirmation. The short delay() after disconnecting gives the network stack time to send the data before the radio switches off. For a sensor that reports every 15 minutes, an occasional lost reading doesn't matter.

Set up a test broker

On a Raspberry Pi or any Debian/Ubuntu machine:

sudo apt install mosquitto mosquitto-clients

Mosquitto 2.x only accepts connections from the same machine until you configure a listener. For a quick test on a trusted home network, create /etc/mosquitto/conf.d/lan.conf with:

listener 1883
allow_anonymous true

Watch out

allow_anonymous true lets any device on your network publish and subscribe. That's fine for a test on a trusted network. For anything long-lived, set up a password file and fill in MQTT_USER and MQTT_PASSWORD in the sketch.

Restart the broker and watch the messages arrive:

sudo systemctl restart mosquitto
mosquitto_sub -h localhost -t 'garden/#' -v

Put the broker machine's IP address in MQTT_HOST. If the sketch prints state -2, it couldn't reach the broker at all: wrong address, or the broker is still listening only on localhost. State 5 means the broker rejected the client (not authorized), so check the username and password.

Next steps

Stuck on a step?

Describe your board and what you see; the assistant has this page as context.

Ask the assistant

Keep going