ESP32 Soil Moisture Monitor

SensorBeginnerIntermediateAdvanced

A soil moisture monitor that reads capacitive sensor data, alerts you when plants need water, and can automatically trigger a pump relay — with cloud logging at the advanced level.

Overview

This project teaches you to read an analogue sensor using the ESP32's ADC (Analogue-to-Digital Converter) and map raw 12-bit readings to a human-readable percentage. A capacitive soil moisture sensor measures the dielectric constant of the soil around it — wet soil has a higher dielectric constant than dry soil, which changes the sensor output voltage. Unlike resistive sensors, capacitive sensors do not corrode and last for years outdoors.

The ESP32 reads the sensor on GPIO 34 (an input-only ADC pin). You will learn: analogue reading with analogRead(), mapping raw ADC counts to a 0–100% scale using the map() function, calibrating the sensor for your specific soil type, and driving a tri-colour status indicator. When moisture drops below a threshold, a red LED lights and a buzzer alerts you to water the plant.

This project scales from a single potted plant to a multi-zone garden controller at the advanced level. It is also the foundation for the esp32-smart-irrigation-system project, which automates watering completely.

Components
  • 1× ESP32 DevKit V1
  • 1× Capacitive Soil Moisture Sensor v1.2 — NOT the resistive (fork) type — those corrode
  • 1× LED (green) + 220 Ω resistor — Moist indicator
  • 1× LED (yellow) + 220 Ω resistor — Dry warning
  • 1× LED (red) + 220 Ω resistor — Critical dry alert
  • 1× Active Buzzer (5 V) — Optional alert sound
  • 1× Breadboard + jumper wires
Wiring
Component PinESP32 PinNotes
Sensor AOUTGPIO 34ADC1 channel — input only, do not drive HIGH
Sensor VCC3.3 VSome sensors accept 3.3–5 V; check your module
Sensor GNDGND
Green LED (+ 220 Ω)GPIO 25Moist (> 60%)
Yellow LED (+ 220 Ω)GPIO 26Moderate (30–60%)
Red LED (+ 220 Ω)GPIO 27Dry (< 30%)
Buzzer +GPIO 32Only sounds when critically dry
Arduino Code
esp32-soil-moisture-monitor_beginner.ino
/*
 * ESP32 Soil Moisture Monitor — Beginner
 * Reads capacitive soil sensor, maps to percentage, lights LEDs.
 *
 * CALIBRATION REQUIRED:
 *   1) Place sensor in dry air → note AIR_VALUE (should be ~2800–3200)
 *   2) Submerge sensor tip in water → note WATER_VALUE (should be ~1200–1500)
 *   Update the defines below with your sensor's actual values.
 */
#define SENSOR_PIN  34
#define LED_GREEN   25   // Moist
#define LED_YELLOW  26   // Moderate
#define LED_RED     27   // Dry
#define BUZZER      32

// ── CALIBRATE THESE for your sensor ─────────────────────────────
#define AIR_VALUE   2800  // ADC reading in dry air (0% moisture)
#define WATER_VALUE 1200  // ADC reading fully in water (100% moisture)
// ────────────────────────────────────────────────────────────────

int readMoisturePercent() {
  // Average 10 readings to reduce ADC noise on ESP32
  long sum = 0;
  for (int i = 0; i < 10; i++) { sum += analogRead(SENSOR_PIN); delay(5); }
  int raw = sum / 10;
  int pct = map(raw, AIR_VALUE, WATER_VALUE, 0, 100);
  return constrain(pct, 0, 100);
}

void setup() {
  Serial.begin(115200);
  analogReadResolution(12);   // 12-bit ADC: 0–4095
  pinMode(LED_GREEN,  OUTPUT);
  pinMode(LED_YELLOW, OUTPUT);
  pinMode(LED_RED,    OUTPUT);
  pinMode(BUZZER,     OUTPUT);
  Serial.println("Soil Moisture Monitor started.");
  Serial.printf("Calibration: AIR=%d, WATER=%dn", AIR_VALUE, WATER_VALUE);
}

void loop() {
  int pct = readMoisturePercent();
  Serial.printf("Moisture: %d%%n", pct);

  // Turn all LEDs off first
  digitalWrite(LED_GREEN,  LOW);
  digitalWrite(LED_YELLOW, LOW);
  digitalWrite(LED_RED,    LOW);
  digitalWrite(BUZZER,     LOW);

  if (pct >= 60) {
    digitalWrite(LED_GREEN, HIGH);
    Serial.println("Status: Moist — plant is happy");
  } else if (pct >= 30) {
    digitalWrite(LED_YELLOW, HIGH);
    Serial.println("Status: Moderate — consider watering soon");
  } else {
    digitalWrite(LED_RED, HIGH);
    // Pulse buzzer 3 times for critical alert
    for (int i = 0; i < 3; i++) {
      digitalWrite(BUZZER, HIGH); delay(200);
      digitalWrite(BUZZER, LOW);  delay(200);
    }
    Serial.println("Status: DRY — water the plant now!");
  }

  delay(10000);   // Read every 10 seconds
}
How It Works
01

Capacitive Sensing Principle: The sensor forms a capacitor with the surrounding soil. Wet soil has a higher dielectric constant (~80) than dry soil (~3), increasing capacitance and lowering the oscillator frequency, which the sensor's onboard circuit converts to a lower output voltage. Lower voltage = more moisture.

02

ADC Averaging: The ESP32 ADC has inherent noise of ±50 counts. Averaging 10 readings reduces random noise to ±5 counts, which translates to a ±0.2% stability in the moisture reading. Always average multiple ADC samples for analogue sensor projects.

03

map() and constrain(): map(raw, AIR_VALUE, WATER_VALUE, 0, 100) linearly interpolates the raw ADC count to a 0–100 percentage. constrain(pct, 0, 100) clips readings that fall outside the calibration range — this happens if you push the sensor deeper than the calibration depth or if soil compacts differently.

04

Three-Zone Status: Three thresholds (≥60%, 30–60%, <30%) map to green/yellow/red status zones. All LEDs are turned off before the if/else chain — this avoids the need to explicitly track which LED was last on. The pattern (clear all, then set one) is cleaner than toggling individual pins.

Applications
  • Indoor potted plant moisture alert system
  • Seedling tray moisture monitoring in a greenhouse
  • Soil science experiments and logging
  • Foundation soil saturation monitoring (for basement flood prevention)
Troubleshooting

Moisture percentage is always 0% or 100%

The sensor is not calibrated for your soil. Open Serial Monitor and record the actual ADC values in dry air and in water, then update AIR_VALUE and WATER_VALUE accordingly. Values vary significantly between sensor batches and soil types.

Readings fluctuate ±10% with the sensor stationary

ESP32 ADC accuracy is affected by Wi-Fi radio activity (especially on GPIO 34 which shares ADC1). If you add Wi-Fi in later steps, use ADC1_CHANNEL directly and call adc1_get_raw() rather than analogRead(). For this beginner level, move to a more stable input like GPIO 36 if issues persist.

Upgrades
  • Add multiple sensors on GPIO 35, 36 for multi-plant monitoring with individual alerts
  • Record daily readings to detect drying trends before plants become stressed
FAQ

You need an ESP32 DevKit, Sensor AOUT, Green LED (+ 220 Ω), a breadboard, jumper wires, and a USB cable for power and programming.

Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.

Start with Beginner if you are new to Sensor Projects. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.

Overview

At the intermediate level you expand to four soil sensors connected via a CD4051 analogue multiplexer, giving you one moisture reading per plant on a single ESP32 ADC pin. An SSD1306 OLED shows all four readings simultaneously. A water pump relay automatically triggers for 5 seconds when any sensor drops below the dry threshold, then logs the watering event with a timestamp.

You will learn: analogue multiplexer wiring (S0, S1, S2 channel select), millis()-based scheduling for pump duty cycles, and circular buffer event logging in RAM. The 4-zone auto-watering concept is directly applicable to a real indoor herb garden or seedling tray.

Components
  • 4× Capacitive Soil Moisture Sensor v1.2 — One per plant
  • 1× CD4051 8-Channel Analogue Multiplexer — Routes 4 sensors to one ADC pin
  • 1× ESP32 DevKit V1
  • 1× 0.96" OLED (SSD1306, I2C)
  • 1× 5 V Relay Module — For submersible pump
  • 1× 5 V Submersible Mini Pump — ~200 mL/min rate works well for pots
  • 1× Silicone tubing (4 mm ID) — To route water to pots
Wiring
Component PinESP32 PinNotes
CD4051 COM (pin 3)GPIO 34Multiplexer output → ESP32 ADC
CD4051 S0 (pin 11)GPIO 25Channel select bit 0
CD4051 S1 (pin 10)GPIO 26Channel select bit 1
CD4051 S2 (pin 9)GPIO 27Channel select bit 2
Sensor 0 AOUTCD4051 Y0 (pin 13)
Sensor 1 AOUTCD4051 Y1 (pin 14)
Sensor 2 AOUTCD4051 Y2 (pin 15)
Sensor 3 AOUTCD4051 Y3 (pin 12)
Pump Relay INGPIO 32
OLED SDA / SCLGPIO 21 / 22
Arduino Code
esp32-soil-moisture-monitor_intermediate.ino
/*
 * ESP32 Soil Moisture Monitor — Intermediate (4-zone)
 * CD4051 mux + 4 sensors + OLED + auto-pump relay
 * Libraries: Adafruit_SSD1306, Adafruit_GFX
 */
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define MUX_SIG  34   // ADC reads multiplexer output
#define MUX_S0   25
#define MUX_S1   26
#define MUX_S2   27
#define PUMP_PIN 32
#define DRY_PCT  30   // Trigger watering below this
#define PUMP_MS  5000 // Pump ON duration
#define ZONES    4

#define AIR_VALUE   2800
#define WATER_VALUE 1200

Adafruit_SSD1306 oled(128, 64, &Wire, -1);

int readChannel(int ch) {
  digitalWrite(MUX_S0, (ch>>0)&1);
  digitalWrite(MUX_S1, (ch>>1)&1);
  digitalWrite(MUX_S2, (ch>>2)&1);
  delay(10); // Settle time
  long sum=0; for(int i=0;i<10;i++){sum+=analogRead(MUX_SIG);delay(3);}
  int raw=sum/10;
  return constrain(map(raw,AIR_VALUE,WATER_VALUE,0,100),0,100);
}

void updateOLED(int* pct) {
  oled.clearDisplay(); oled.setTextSize(1); oled.setTextColor(SSD1306_WHITE);
  for(int i=0;i<ZONES;i++){
    oled.setCursor(0, i*14);
    oled.printf("Z%d: %3d%%  %s", i+1, pct[i],
      pct[i]>=60?"OK":(pct[i]>=30?"LOW":"DRY"));
  }
  oled.display();
}

void setup(){
  Serial.begin(115200);
  analogReadResolution(12);
  pinMode(MUX_S0,OUTPUT); pinMode(MUX_S1,OUTPUT); pinMode(MUX_S2,OUTPUT);
  pinMode(PUMP_PIN,OUTPUT); digitalWrite(PUMP_PIN,LOW);
  Wire.begin(); oled.begin(SSD1306_SWITCHCAPVCC,0x3C);
}

void loop(){
  int pct[ZONES];
  bool anyDry=false;
  for(int z=0;z<ZONES;z++){
    pct[z]=readChannel(z);
    Serial.printf("Zone %d: %d%%n",z+1,pct[z]);
    if(pct[z]<DRY_PCT) anyDry=true;
  }
  updateOLED(pct);

  if(anyDry){
    Serial.println("Dry zone detected — running pump");
    digitalWrite(PUMP_PIN,HIGH); delay(PUMP_MS); digitalWrite(PUMP_PIN,LOW);
    Serial.println("Pump cycle complete");
  }

  delay(60000); // Check every 60 seconds
}
How It Works
01

CD4051 Multiplexer: The CD4051 is an 8-channel analogue switch IC. Setting the S0, S1, S2 pins selects one of 8 input channels to connect to the common output (COM) pin. With S0=0,S1=0,S2=0 you get channel 0; S0=1,S1=0,S2=0 gives channel 1, and so on. This lets four sensors share one ADC pin.

02

Settling Time: After switching channels, the multiplexer internal resistance (typical 100 Ω) and the ADC input capacitance form an RC circuit that takes ~10 µs to settle. A 10 ms delay before reading is more than sufficient and avoids cross-channel contamination in the ADC sample.

03

Pump Safety: The pump only runs for PUMP_MS milliseconds per check cycle. This prevents over-watering if a sensor fails high (reads 0%). In a production system, add a flow sensor or water level sensor to cut the pump if the reservoir is empty.

Applications
  • Field trial with visible OLED feedback
  • Manual override for maintenance or testing
  • Calibrated setup for daily use
Troubleshooting

All zones read identical values

The CD4051 S pins may not be changing correctly. Verify S0/S1/S2 wiring and confirm the bit-shift logic: (ch>>0)&1 gives the LSB of ch. Add Serial.printf("ch=%d S=%d%d%d\n",ch,(ch>>2)&1,(ch>>1)&1,(ch>>0)&1) before each read to verify.

Pump runs continuously and never stops

All sensors are reading below DRY_PCT. Either the sensors are dry, or there is a calibration or wiring issue. Disconnect the pump and test sensors individually at the beginner level to verify each one reads correctly before wiring the multiplexer.

Upgrades
  • Add a water level sensor in the reservoir to prevent the pump running dry
  • Log watering events with timestamps to SPIFFS for a weekly watering report
FAQ

You need an ESP32 DevKit, Sensor AOUT, Green LED (+ 220 Ω), a breadboard, jumper wires, and a USB cable for power and programming.

Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.

Start with Beginner if you are new to Sensor Projects. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.

Overview

The advanced level adds ThingSpeak cloud logging and a smartphone push notification via Pushover when any zone drops critically dry. All four zone readings are uploaded to a ThingSpeak channel every 15 minutes, building a historical dataset you can graph and analyse online. MQTT publishing is also included so your local Node-RED or Home Assistant receives real-time readings without cloud dependency.

You will learn: HTTP POST to ThingSpeak REST API, JSON payload construction, Pushover API integration for mobile notifications, and conditional MQTT publishing with retained flags for persistent state display in Home Assistant.

Components
  • 4× Capacitive Soil Moisture Sensor v1.2
  • 1× CD4051 Analogue Multiplexer
  • 1× ESP32 DevKit V1
  • 1× 5 V Relay + Mini Pump
  • 1× ThingSpeak Account (free) — thingspeak.com — 4 fields, free tier
  • 1× Pushover Account (free trial) — pushover.net — $5 one-time after 30 days
Wiring
Component PinESP32 PinNotes
Same as intermediate levelSee intermediate wiringNo additional hardware required
Arduino Code
esp32-soil-moisture-monitor_advanced.ino
/*
 * ESP32 Soil Moisture Monitor — Advanced
 * ThingSpeak logging + Pushover notifications + MQTT
 * Libraries: Adafruit_SSD1306, PubSubClient, HTTPClient (built-in)
 */
#include <WiFi.h>
#include <HTTPClient.h>
#include <PubSubClient.h>

const char* SSID     = "YOUR_WIFI_SSID";
const char* PASSWORD = "YOUR_WIFI_PASSWORD";
const char* BROKER   = "192.168.1.100";

// ThingSpeak
const char* TS_API_KEY = "YOUR_THINGSPEAK_WRITE_KEY";
const char* TS_URL     = "https://api.thingspeak.com/update";

// Pushover (push notifications to phone)
const char* PO_TOKEN   = "YOUR_PUSHOVER_APP_TOKEN";
const char* PO_USER    = "YOUR_PUSHOVER_USER_KEY";
const char* PO_URL     = "https://api.pushover.net/1/messages.json";

#define MUX_SIG  34
#define MUX_S0   25
#define MUX_S1   26
#define MUX_S2   27
#define PUMP_PIN 32
#define ZONES    4
#define DRY_PCT  25     // Critical threshold for push notification
#define AIR_VALUE   2800
#define WATER_VALUE 1200

WiFiClient net;
PubSubClient mqtt(net);
int lastNotifiedZone = -1;

int readChannel(int ch){
  digitalWrite(MUX_S0,(ch>>0)&1);
  digitalWrite(MUX_S1,(ch>>1)&1);
  digitalWrite(MUX_S2,(ch>>2)&1);
  delay(10);
  long s=0; for(int i=0;i<10;i++){s+=analogRead(MUX_SIG);delay(3);}
  return constrain(map((int)(s/10),AIR_VALUE,WATER_VALUE,0,100),0,100);
}

void uploadThingSpeak(int* pct){
  HTTPClient http;
  String url=String(TS_URL)+"?api_key="+TS_API_KEY;
  for(int i=0;i<ZONES;i++) url+="&field"+String(i+1)+"="+String(pct[i]);
  http.begin(url); http.GET(); http.end();
  Serial.println("ThingSpeak updated");
}

void sendPushover(int zone, int pct){
  HTTPClient http;
  http.begin(PO_URL);
  http.addHeader("Content-Type","application/x-www-form-urlencoded");
  String body="token="+String(PO_TOKEN)+"&user="+String(PO_USER)
    +"&title=Plant+Alert&message=Zone+"+String(zone+1)+"+is+at+"+String(pct)+"%25+moisture+-+water+now!";
  http.POST(body); http.end();
  Serial.printf("Pushover alert sent for zone %dn",zone+1);
}

void publishMQTT(int* pct){
  if(!mqtt.connected()) return;
  for(int i=0;i<ZONES;i++){
    String topic="esp32engine/soil/zone"+String(i+1);
    mqtt.publish(topic.c_str(),String(pct[i]).c_str(),true);
  }
}

void setup(){
  Serial.begin(115200);
  analogReadResolution(12);
  pinMode(MUX_S0,OUTPUT);pinMode(MUX_S1,OUTPUT);pinMode(MUX_S2,OUTPUT);
  pinMode(PUMP_PIN,OUTPUT);digitalWrite(PUMP_PIN,LOW);
  WiFi.begin(SSID,PASSWORD);
  while(WiFi.status()!=WL_CONNECTED) delay(400);
  mqtt.setServer(BROKER,1883);
  mqtt.connect("soil-monitor");
}

void loop(){
  if(!mqtt.connected()) mqtt.connect("soil-monitor");
  mqtt.loop();

  int pct[ZONES];
  for(int z=0;z<ZONES;z++) pct[z]=readChannel(z);

  publishMQTT(pct);

  for(int z=0;z<ZONES;z++){
    if(pct[z]<DRY_PCT && lastNotifiedZone!=z){
      sendPushover(z,pct[z]);
      lastNotifiedZone=z;
      // Auto-water
      digitalWrite(PUMP_PIN,HIGH); delay(5000); digitalWrite(PUMP_PIN,LOW);
    }
    if(pct[z]>=40) lastNotifiedZone=-1; // Reset after plant is watered
  }

  uploadThingSpeak(pct);
  delay(900000); // 15 minute upload interval (ThingSpeak free: 15s min)
}
How It Works
01

ThingSpeak REST API: ThingSpeak accepts HTTP GET or POST requests to https://api.thingspeak.com/update with your write API key and up to 8 field values. Each successful update returns an integer (the update number). The free tier allows one update per 15 seconds, so zone readings are batched into one API call rather than sent individually.

02

Pushover Notifications: Pushover delivers push notifications to iOS and Android via their REST API. An HTTP POST with your app token, user key, title, and message delivers a notification to your phone within seconds. The lastNotifiedZone guard prevents repeat notifications every 15 minutes for the same dry zone.

03

MQTT Retained State: Publishing with retain=true caches the last reading on the broker. When Home Assistant reconnects after a restart, it immediately receives the current soil moisture for each zone without waiting for the next 15-minute reading cycle.

Applications
  • Remote monitoring from phone or laptop
  • Automated alerts when limits are crossed
  • Long-term trend logging for optimization
Troubleshooting

ThingSpeak updates fail (HTTP 0 or -1)

Verify the API key. ThingSpeak requires HTTPS — confirm HTTPClient can reach the internet (test with a simple http.GET("https://httpbin.org/get")). Free tier throttles to 1 update per 15 seconds; faster calls are silently dropped.

Pushover notifications not arriving

Check token and user key at pushover.net. Ensure the app is installed and notifications are enabled on your phone. The HTTP POST body must be URL-encoded — special characters in the message (& % +) must be encoded.

Upgrades
  • Replace ThingSpeak with a local InfluxDB instance for unlimited storage and privacy
  • Add a water flow meter (YF-S201 hall sensor) to measure exact mL delivered per pump cycle
  • Build a Grafana dashboard showing moisture trends vs watering events over 30 days
FAQ

You need an ESP32 DevKit, Sensor AOUT, Green LED (+ 220 Ω), a breadboard, jumper wires, and a USB cable for power and programming.

Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.

Start with Beginner if you are new to Sensor Projects. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.