When makers search for reliable IoT projects with ESP32, a WiFi-connected soil moisture monitor is the undisputed rite of passage. But most online tutorials skip the messy reality of the ESP32's analog-to-digital converter (ADC) noise and WiFi reconnection edge cases, leaving you with a sensor that drops offline or reports 100% moisture when the plant is bone dry.
This guide cuts the fluff. We are building a robust, MQTT-publishing capacitive soil moisture sensor. The code targets the ESP32-WROOM-32E DevKit V1 (30-pin variant), uses hardware and software filtering to tame the ADC, and includes proper error handling for network drops.
Time to Build: 45 minutes.
Hardware Spec Sheet & Parts List
Do not buy the cheap resistive soil sensors with the exposed nickel pads. They will corrode and fail within two weeks due to electrolysis. You need a capacitive sensor, which measures dielectric changes in the soil without passing direct current through it.
| Component | Exact Variant / Model | Est. Cost (2026) | Bench Notes |
|---|---|---|---|
| Microcontroller | ESP32-WROOM-32E DevKit V1 (30-pin) | $4.50 | Ensure you get the 30-pin version; the 38-pin has a different GPIO layout. |
| Sensor | Capacitive Soil Moisture Sensor v1.2 | $1.50 | Look for the black epoxy coating on the top half. Avoid bare PCBs. |
| Decoupling Cap | 100nF (0.1µF) Ceramic Capacitor | $0.05 | Critical for filtering high-frequency noise on the analog trace. |
| Power Supply | 5V 2A USB-C or Micro-USB Adapter | $6.00 | Do not power from a PC USB port if using long wires; voltage drop will skew ADC. |
Pin Mapping & Wiring Steps
The ESP32's ADC1 pins are generally more stable than ADC2 pins (which conflict with the WiFi radio). We will use GPIO 34 (ADC1_CH6). Note that GPIO 34 is an input-only pin with no internal pull-up resistors, making it ideal for analog reads but useless for digital I/O.
| ESP32 Pin | Sensor Pin | Wire Color | Notes |
|---|---|---|---|
| 3V3 | VCC | Red | Do NOT use 5V (VIN). The sensor's analog output will exceed the ESP32's 3.3V ADC max and risk frying the pin. |
| GND | GND | Black | Keep this wire short to minimize ground loop noise. |
| GPIO 34 | AOUT | Yellow | Analog output. Solder the 100nF cap between this wire and GND at the ESP32 end. |
Assembly Steps:
- Solder the 100nF ceramic capacitor directly across the GND and AOUT pins on the sensor module, or place it on the breadboard bridging the ESP32's GND and GPIO 34 rails.
- Connect the VCC, GND, and AOUT wires according to the table above.
- Insert the sensor into a glass of dry air (to get your baseline dry reading) and then into a cup of wet soil (for your wet reading) before finalizing the code calibration.
Complete MQTT Firmware
This code uses the PubSubClient library. It implements a 16-sample multisampling routine to smooth out the ESP32's notorious ADC jitter, and includes non-blocking WiFi/MQTT reconnection logic. Install the PubSubClient library via the Arduino Library Manager before compiling.
#include <WiFi.h>
#include <PubSubClient.h>
// --- PIN DEFINITIONS ---
#define SOIL_SENSOR_PIN 34 // ADC1_CH6 (Input only, no pull-up)
// --- NETWORK CREDENTIALS ---
const char* ssid = "YourNetworkSSID";
const char* password = "YourNetworkPassword";
const char* mqtt_server = "192.168.1.50"; // Your local Mosquitto broker IP
const int mqtt_port = 1883;
const char* mqtt_topic = "garden/soil/moisture";
// --- CALIBRATION VALUES (Update these based on your bench tests) ---
const int AIR_VALUE = 3100; // Raw ADC reading in dry air
const int WATER_VALUE = 1400; // Raw ADC reading submerged in water
WiFiClient espClient;
PubSubClient client(espClient);
unsigned long lastMsg = 0;
void setup_wifi() {
delay(10);
Serial.print("Connecting to ");
Serial.println(ssid);
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connected. IP: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("\nWiFi connection failed. Rebooting...");
ESP.restart();
}
}
void reconnect() {
while (!client.connected()) {
Serial.print("Attempting MQTT connection...");
String clientId = "ESP32-Soil-" + String(random(0xffff), HEX);
if (client.connect(clientId.c_str())) {
Serial.println("connected");
} else {
Serial.print("failed, rc=");
Serial.print(client.state());
Serial.println(" retry in 5 seconds");
delay(5000);
}
}
}
// Multisampling function to smooth ESP32 ADC noise
int readSoilMoistureRaw() {
long total = 0;
for (int i = 0; i < 16; i++) {
total += analogRead(SOIL_SENSOR_PIN);
delayMicroseconds(200); // Allow ADC capacitor to settle
}
return total / 16;
}
void setup() {
Serial.begin(115200);
analogReadResolution(12); // Ensure 12-bit resolution (0-4095)
setup_wifi();
client.setServer(mqtt_server, mqtt_port);
}
void loop() {
if (!client.connected()) {
reconnect();
}
client.loop();
unsigned long now = millis();
// Publish every 60 seconds (non-blocking)
if (now - lastMsg > 60000) {
lastMsg = now;
int raw = readSoilMoistureRaw();
// Map raw ADC to percentage (constrain prevents negative/over 100 values)
int moisturePercent = map(raw, AIR_VALUE, WATER_VALUE, 0, 100);
moisturePercent = constrain(moisturePercent, 0, 100);
char msg[50];
snprintf(msg, 50, "%d", moisturePercent);
Serial.printf("Publishing: %s%% (Raw: %d)\n", msg, raw);
client.publish(mqtt_topic, msg);
}
}
Debugging: First Three Things to Check
When your build fails, don't start rewriting code. Check these three physical and network layers first.
1. Error: WiFi drops or fails to connect initially
Exact Error String: WiFi.status() == WL_NO_SSID_AVAIL or the serial monitor hangs on Connecting to... indefinitely.
- Cause A (Most Likely): 5GHz vs 2.4GHz band. The ESP32-WROOM-32E only supports 802.11 b/g/n on the 2.4GHz band. If your router uses a unified SSID for both bands, the ESP32 may fail the handshake. Create a dedicated 2.4GHz IoT SSID.
- Cause B: Insufficient current. If powering via a long USB cable, voltage drop at the 3.3V LDO will cause brownouts during WiFi TX bursts. Measure the 3.3V pin with a multimeter; it should not dip below 3.1V during connection.
2. Error: MQTT Broker Rejection
Exact Error String: Attempting MQTT connection...failed, rc=-2 or rc=-4.
- Cause A (rc=-2): Network unreachable. The ESP32 cannot route to the broker IP. Verify the broker IP is on the same subnet and that your router's AP Isolation (client isolation) feature is disabled.
- Cause B (rc=-4): Connection lost or timeout. The Mosquitto broker might be rejecting the connection due to a missing ACL (Access Control List) entry or the broker service crashed. Check
systemctl status mosquittoon your server.
3. Error: ADC Readings Stuck at 4095 or 0
Exact Error String: Serial monitor outputs Publishing: 0% (Raw: 4095) regardless of soil wetness.
- Cause A: You wired AOUT to a GPIO pin that does not have an ADC channel (e.g., GPIO 16 or 17). Stick strictly to GPIO 32-39 for ADC1.
- Cause B: You are powering the sensor with 5V, which maxes out the sensor's op-amp output, permanently pegging the ESP32's 3.3V ADC at its maximum 4095 limit. Switch VCC to the 3V3 pin immediately to prevent permanent silicon damage to the ESP32's ADC channel.
Extending and Simplifying the Build
Once the baseline monitor is stable, you have two distinct paths to modify the project based on your deployment environment.
How to Simplify (Ultra-Low Power / No Router):
Strip out the WiFi and MQTT libraries. Use ESP-NOW to broadcast the moisture payload directly to a receiver ESP32 plugged into your home server. ESP-NOW bypasses the WiFi handshake entirely, dropping the TX burst current from ~240mA to ~80mA and reducing transmission time to milliseconds. This is ideal if you are running the sensor on a small LiPo battery.
How to Extend (Deep Sleep & Solar):
Add esp_sleep_enable_timer_wakeup(3600000000ULL); (1 hour in microseconds) at the end of your loop(), followed by esp_deep_sleep_start();. Pair this with a 18650 lithium cell, a TP4056 charging module, and a 5V 1W mini solar panel. The ESP32 will wake, sample the soil, publish via MQTT, and shut down the radio, pulling less than 15µA during sleep. For deep sleep, ensure you move the sensor power to a GPIO pin (acting as a high-side switch via a P-channel MOSFET) so the sensor itself doesn't drain 5mA continuously while the ESP32 sleeps.
FAQ: IoT Projects with ESP32
Why do my ESP32 ADC readings fluctuate so much in IoT projects?
The ESP32's internal ADC is notoriously noisy due to the lack of internal decoupling capacitors on the silicon die and interference from the WiFi radio. A single analogRead() can swing by ±100 points. You must use software multisampling (averaging 16 to 64 reads) and hardware filtering (a 100nF to 1µF capacitor on the analog input pin) to get stable data for IoT projects with ESP32.
Which is better for IoT projects with ESP32: MQTT or HTTP REST APIs?
For sensor telemetry, MQTT is vastly superior. HTTP requires opening a TCP socket, sending headers, waiting for a response, and closing the socket for every single reading. This keeps the WiFi radio active for seconds, draining battery. MQTT maintains a persistent, lightweight TCP connection where publishing a payload takes milliseconds, allowing the ESP32 to return to sleep much faster. Use HTTP only if you need to fetch large configuration files on boot.
How do I power IoT projects with ESP32 off-grid for months?
You must utilize the ESP32's deep sleep modes and manage your peripherals. The ESP32 itself draws ~10µA in deep sleep, but a standard soil sensor draws 5mA continuously. To survive months on a single 18650 cell, you must cut power to the sensor using a MOSFET controlled by a GPIO pin, only turning it on for the 2 seconds required to take a reading. Combine this with a small 5V solar panel and an MPPT or TP4056 charge controller for indefinite runtime.
Can I use the ESP32-S3 or ESP32-C3 for this same soil moisture project?
Yes, but pin mappings and ADC behaviors change. The ESP32-C3 is a great low-cost, single-core RISC-V alternative that drops right into this code with minor pin changes. The ESP32-S3 has a much improved, more linear ADC and native USB, making it excellent for precision analog IoT projects with ESP32 architectures, though it draws slightly more current in deep sleep than the original ESP32-WROOM-32E. Always check the specific datasheet for the ADC1 channel assignments on newer variants.






