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 Pin | ESP32 Pin | Notes |
|---|---|---|
| Sensor AOUT | GPIO 34 | ADC1 channel — input only, do not drive HIGH |
| Sensor VCC | 3.3 V | Some sensors accept 3.3–5 V; check your module |
| Sensor GND | GND | |
| Green LED (+ 220 Ω) | GPIO 25 | Moist (> 60%) |
| Yellow LED (+ 220 Ω) | GPIO 26 | Moderate (30–60%) |
| Red LED (+ 220 Ω) | GPIO 27 | Dry (< 30%) |
| Buzzer + | GPIO 32 | Only sounds when critically dry |
Arduino Code
/*
* 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
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.
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.
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.
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 Pin | ESP32 Pin | Notes |
|---|---|---|
| CD4051 COM (pin 3) | GPIO 34 | Multiplexer output → ESP32 ADC |
| CD4051 S0 (pin 11) | GPIO 25 | Channel select bit 0 |
| CD4051 S1 (pin 10) | GPIO 26 | Channel select bit 1 |
| CD4051 S2 (pin 9) | GPIO 27 | Channel select bit 2 |
| Sensor 0 AOUT | CD4051 Y0 (pin 13) | |
| Sensor 1 AOUT | CD4051 Y1 (pin 14) | |
| Sensor 2 AOUT | CD4051 Y2 (pin 15) | |
| Sensor 3 AOUT | CD4051 Y3 (pin 12) | |
| Pump Relay IN | GPIO 32 | |
| OLED SDA / SCL | GPIO 21 / 22 |
Arduino Code
/*
* 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
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.
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.
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 Pin | ESP32 Pin | Notes |
|---|---|---|
| Same as intermediate level | See intermediate wiring | No additional hardware required |
Arduino Code
/*
* 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
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.
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.
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.