Is it the soil or the air that's making your plant unhappy? This plant sensor watches both. The soil moisture module measures how wet the earth is, the DHT11 measures the air around the leaves, and the ESP32 shows everything on a web page that you can open on your phone. When the soil gets too dry, the page tells you so in big letters.
What you'll learn
- How to combine two sensors on one web page
- How to calibrate the soil sensor for your own soil
- How to protect the soil probe by powering it only while measuring
- How a page can show a message based on the data
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- Soil moisture module with probe (45-in-1 Sensor Kit)
- DHT11 module, KY-015 (ESP32 IoT Starter Kit)
- A plant in a pot, and a glass of water for calibration
- A 2.4 GHz Wi-Fi network
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Make it real: tape the module boards to a lolly stick or a chopstick and push it into the pot next to the probe, so that only the fork-shaped probe touches the soil.
Wiring

| From | To |
|---|---|
| Soil module VCC | ESP32 GPIO 32 (powers the sensor only while measuring) |
| Soil module GND | GND |
| Soil module AO | ESP32 GPIO 35 |
| Soil module DO | Not used |
| DHT11 S (signal) | ESP32 GPIO 27 |
| DHT11 + and − | 3V3 and GND |
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
The sketch
Install the DHT sensor library by Adafruit.
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only). Open the Serial Monitor at 115200 baud to see the address, then open it in a browser on a phone or computer on the same Wi-Fi.
#include <WiFi.h>
#include <WebServer.h>
#include <DHT.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const int SOIL_POWER = 32;
const int SOIL_PIN = 35;
const int DRY_READING = 3500; // probe in dry air (calibrate!)
const int WET_READING = 1400; // probe in a glass of water
const int WATER_BELOW = 30; // "water me" below this moisture (%)
DHT dht(27, DHT11);
WebServer server(80);
int moisture = 0;
float temperature = NAN, humidity = NAN;
unsigned long lastRead = 0;
const char PAGE[] = R"rawliteral(
<!DOCTYPE html><html><head><meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'><title>My plant</title>
<style>body{font-family:sans-serif;padding:16px;max-width:420px;margin:auto}
.bar{height:28px;background:#eee;border-radius:14px;overflow:hidden}.bar div{height:100%;background:#2E9E5B;transition:width .5s}
#msg{font-size:28px;font-weight:bold;margin:18px 0}</style></head><body>
<h1>My plant</h1>
<div id='msg'>-</div>
<p>Soil moisture: <b id='m'>-</b></p><div class='bar'><div id='mb' style='width:0'></div></div>
<p>Air: <b id='t'>-</b> Humidity: <b id='h'>-</b></p>
<script>
async function update() {
const d = await (await fetch('/data')).json();
document.getElementById('m').textContent = d.moisture + ' %';
document.getElementById('mb').style.width = d.moisture + '%';
document.getElementById('t').textContent = d.temperature === null ? '-' : d.temperature.toFixed(1) + ' °C';
document.getElementById('h').textContent = d.humidity === null ? '-' : d.humidity.toFixed(0) + ' %';
const msg = document.getElementById('msg');
msg.textContent = d.dry ? 'Water me, please!' : 'All good';
msg.style.color = d.dry ? '#C0392B' : '#2E9E5B';
}
setInterval(update, 5000);
update();
</script></body></html>
)rawliteral";
String number(float v, int decimals) {
return isnan(v) ? "null" : String(v, decimals);
}
void readSensors() {
digitalWrite(SOIL_POWER, HIGH);
delay(50);
int reading = analogRead(SOIL_PIN);
digitalWrite(SOIL_POWER, LOW);
moisture = constrain(map(reading, DRY_READING, WET_READING, 0, 100), 0, 100);
Serial.printf("Soil reading %d = %d %%\n", reading, moisture);
float t = dht.readTemperature(), h = dht.readHumidity();
if (!isnan(t) && !isnan(h)) {
temperature = t;
humidity = h;
}
}
void setup() {
Serial.begin(115200);
pinMode(SOIL_POWER, OUTPUT);
dht.begin();
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.print("Open http://");
Serial.println(WiFi.localIP());
server.on("/", []() { server.send(200, "text/html", PAGE); });
server.on("/data", []() {
String json = "{\"moisture\":" + String(moisture) +
",\"temperature\":" + number(temperature, 1) +
",\"humidity\":" + number(humidity, 0) +
",\"dry\":" + (moisture < WATER_BELOW ? "true" : "false") + "}";
server.send(200, "application/json", json);
});
server.begin();
readSensors();
}
void loop() {
server.handleClient();
if (millis() - lastRead >= 10000) { // measure every 10 seconds
lastRead = millis();
readSensors();
}
}
Calibrate the soil sensor
- Open the Serial Monitor at 115200 baud; it prints the soil reading every ten seconds.
- Hold the probe in the air and put the reading in
DRY_READING. - Dip it in water up to the line where the metal ends and put that reading in
WET_READING. - Upload again and push the probe into the soil.
How it works
Every ten seconds the ESP32 powers the soil probe for 50 ms, reads it, switches it off again and reads the DHT11. Powering the probe only briefly slows down the corrosion of its metal prongs. The web page asks for /data every five seconds and gets everything in one JSON message, including a ready-made yes/no for "dry". Deciding that on the ESP32 means the rule lives in one place: change WATER_BELOW and the page follows.
Until the DHT11 has given its first good reading, the temperature is NAN. JSON has no way to write that, so the sketch sends null and the page shows a dash.
Try this next
- Record the lowest moisture of the day and show it on the page.
- Change the bar colour from green to orange to red as the soil dries.
- Send a phone notification instead, as in the Blynk plant lesson.
Troubleshooting
- It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.
- The page doesn't open: the phone or computer must be on the same Wi-Fi as the ESP32, not on mobile data.
- Moisture is always 0 % or 100 %: calibrate again; your readings may be outside the default numbers.
- Temperature shows "-": check the DHT11 signal wire on GPIO 27 and that its + pin is on 3V3.