Project: Smart Plant-Watering System
By the end of this project you will have built a complete IoT device: it measures soil moisture, waters the plant in short safe bursts with a low-voltage pump, refuses to run the pump when the water tank is empty, shows its status on an OLED screen, and sends readings and alerts over Wi-Fi. It brings together almost every lesson in this course.
Picture Priya's tulsi plant on the balcony in Lucknow. She goes home for Diwali for a week. This device keeps the plant watered and messages her phone if the water tank runs dry.
What the system does
| Stage | Part | Job |
|---|---|---|
| Sense | Capacitive soil moisture sensor | Measures how wet the soil is (0 to 100% after calibration) |
| Sense | Float switch in the water tank | Tells the controller whether there is water to pump |
| Think | ESP32 | Decides when to water, with hysteresis, time limits and a daily limit |
| Act | Relay module and 5 V pump | Pumps water in 5-second bursts |
| Show | 0.96 inch SSD1306 OLED | Moisture, mode, tank state and connection status |
| Tell | Wi-Fi and MQTT | Publishes readings and events; alerts when the tank is empty |
The watering rules:
- Hysteresis: start watering when moisture falls below 35%; consider the soil watered when it rises above 60%. You built this in Light & Soil Moisture Sensors.
- Short bursts, then soak: pump for 5 seconds, then wait 60 seconds so water can spread through the soil before measuring again. Without the soak, the sensor would still read dry and the pump would flood the pot.
- Tank protection: never run the pump without water. A pump running dry overheats.
- Daily limit: at most 6 bursts per 24 hours. If a sensor wire falls out and the soil always reads dry, the limit stops the device from emptying the tank onto the balcony floor.
Parts list
| Part | Quantity | Notes |
|---|---|---|
| ESP32 development board (ESP32-WROOM-32, "ESP32 Dev Module") | 1 | Any ESP32 with Wi-Fi; check your board's pinout |
| Capacitive soil moisture sensor (v1.2 or v2.0 style) | 1 | Prefer one that works at 3.3 V; avoid resistive fork probes |
| 1-channel relay module | 1 | Must switch reliably from a 3.3 V input signal; check the listing |
| Mini submersible DC pump, 3 to 5 V | 1 | Check its current rating; small ones are often a few hundred mA |
| Silicone tube to fit the pump | About 1 m | Long enough to reach from the tank to the pot |
| Vertical float switch (water level switch) | 1 | Low-voltage type, mounted near the bottom of the tank |
| SSD1306 OLED, 128 × 64, I2C | 1 | Address usually 0x3C |
| 5 V USB adapter for the ESP32 | 1 | A certified phone charger is fine |
| Separate 5 V USB supply for the pump (adapter or power bank) | 1 | Keeps pump current away from the ESP32 |
| USB cable cut-off or USB breakout board | 1 | To bring the pump supply's 5 V and GND to the relay safely |
| 1N4007 diode (optional) | 1 | Across the pump terminals to reduce sparking at the relay contacts |
| Breadboard, jumper wires, small plastic box | As needed | The box keeps electronics dry |
| Water container with a lid | 1 | Plastic, placed below the electronics |
Every part runs on 5 V or 3.3 V DC. No part of this project connects to mains wiring.
Wiring table
| Part | Pin | Connects to |
|---|---|---|
| Soil sensor | VCC | ESP32 3V3 |
| Soil sensor | GND | ESP32 GND |
| Soil sensor | AOUT | ESP32 GPIO 34 (ADC1, safe with Wi-Fi) |
| Float switch | Wire 1 | ESP32 GPIO 27 |
| Float switch | Wire 2 | ESP32 GND |
| OLED | VCC | ESP32 3V3 |
| OLED | GND | ESP32 GND |
| OLED | SDA | ESP32 GPIO 21 |
| OLED | SCL | ESP32 GPIO 22 |
| Relay module | VCC | ESP32 5V (VIN) pin, which carries USB 5 V |
| Relay module | GND | ESP32 GND |
| Relay module | IN | ESP32 GPIO 26 |
| Pump supply | 5 V (+) | Relay COM |
| Relay module | NO | Pump red wire (+) |
| Pump | Black wire (-) | Pump supply GND |
| 1N4007 diode (optional) | Stripe end | Pump red wire (+) |
| 1N4007 diode (optional) | Other end | Pump black wire (-) |
The pump circuit (pump supply, relay contacts, pump) is completely separate from the ESP32's circuit. The relay's COM and NO contacts are the only link, and they carry no signal back. Using NO means the pump is off whenever the relay is not powered, so a crash, a reset or a power cut always stops the pump.
Step 1: Test each part on its own
Build and test one part at a time. If you connect everything at once and it fails, you will not know which part is wrong.
- Soil sensor: run the live soil sketch from Light & Soil Moisture Sensors on the ESP32 with
SOIL_PINset to 34. Note the reading in dry air and in a glass of water. The ESP32's ADC is 12-bit, so expect numbers between 0 and 4095. - Float switch: with
pinMode(27, INPUT_PULLUP), printdigitalRead(27)while you lift and lower the float in a glass of water. Mount it so it reads LOW when there is enough water. If your switch works the other way, turn the float upside down on its stem. - Relay: run the relay test from Relay Modules & DC Motors with Drivers with pin 26, without the pump connected. Confirm that LOW switches it on.
- Pump: connect the pump through the relay, put it in a bowl of water with the tube leading back into the same bowl, and run it for 2 seconds.
- OLED: run the I2C scanner and the OLED sketch from Displays: I2C LCD & OLED. On the ESP32,
Wireuses GPIO 21 and 22 by default.
You should end this step with five working parts and two calibration numbers.
Step 2: Test the brain without hardware
The decision logic is the most important part, so it gets its own function, controller(), which takes the time, the moisture and the tank state, and returns whether the pump should run. It never touches a pin. That means you can test it with a made-up timeline on any board, even with nothing connected.
// Watering rules
const int START_BELOW = 35; // % moisture: start watering below this
const int STOP_ABOVE = 60; // % moisture: soil is fine above this
const unsigned long PUMP_MS = 5000; // one burst
const unsigned long SOAK_MS = 60000; // wait after each burst
const int MAX_BURSTS = 6; // per day
enum Mode { IDLE, WATERING, SOAKING };
Mode mode = IDLE;
bool needWater = false, tankWarned = false, limitWarned = false;
unsigned long modeStart = 0;
int bursts = 0;
void logEvent(unsigned long now, const char *msg, int moisture) {
char line[64];
snprintf(line, sizeof(line), "[%lu s] %s (%d %%)", now / 1000, msg, moisture);
Serial.println(line);
}
bool controller(unsigned long now, int moisture, bool tankOk) {
if (!needWater && moisture < START_BELOW) {
needWater = true;
logEvent(now, "soil dry", moisture);
}
if (needWater && moisture > STOP_ABOVE) {
needWater = false;
logEvent(now, "soil OK", moisture);
}
if (tankOk) tankWarned = false;
switch (mode) {
case IDLE:
if (needWater && !tankOk && !tankWarned) {
tankWarned = true;
logEvent(now, "ALERT: tank empty", moisture);
}
if (needWater && tankOk && bursts < MAX_BURSTS) {
mode = WATERING;
modeStart = now;
bursts++;
logEvent(now, "pump ON", moisture);
} else if (needWater && tankOk && !limitWarned) {
limitWarned = true;
logEvent(now, "ALERT: daily limit reached", moisture);
}
break;
case WATERING:
if (!tankOk || now - modeStart >= PUMP_MS) {
mode = SOAKING;
modeStart = now;
logEvent(now, tankOk ? "pump OFF, soaking" : "pump OFF: tank empty", moisture);
}
break;
case SOAKING:
if (now - modeStart >= SOAK_MS) {
mode = IDLE;
logEvent(now, "soak done", moisture);
}
break;
}
return mode == WATERING;
}
void setup() {
Serial.begin(115200);
delay(500);
// A made-up timeline: seconds, moisture %, is there water in the tank?
const unsigned long t[] = {0, 10, 15, 75, 80, 85, 145, 600, 900, 960, 962, 1022};
const int m[] = {50, 33, 34, 45, 47, 55, 63, 41, 32, 31, 31, 30};
const bool tank[] = {true, true, true, true, true, true, true, true, false, true, false, false};
for (int i = 0; i < 12; i++) {
controller(t[i] * 1000, m[i], tank[i]);
}
}
void loop() {
}[10 s] soil dry (33 %) [10 s] pump ON (33 %) [15 s] pump OFF, soaking (34 %) [75 s] soak done (45 %) [80 s] pump ON (47 %) [85 s] pump OFF, soaking (55 %) [145 s] soil OK (63 %) [145 s] soak done (63 %) [900 s] soil dry (32 %) [900 s] ALERT: tank empty (32 %) [960 s] pump ON (31 %) [962 s] pump OFF: tank empty (31 %) [1022 s] soak done (30 %)
Read the log as a story. The soil dries at 10 s, so the pump runs one 5-second burst. After soaking, the soil is at 45%: better, but not above 60%, so a second burst follows. At 145 s the soil reads 63% and watering is finished. At 900 s the soil is dry again, but the tank is empty, so instead of pumping, the device raises an alert, once. When someone refills the tank at 960 s, watering starts, and when the tank runs out again two seconds later, the pump stops at once instead of finishing its burst.
This design is called a state machine: the device is always in exactly one mode (IDLE, WATERING or SOAKING), and clear rules move it between modes. An enum gives the modes readable names. State machines make complex behaviour predictable and testable, which is why washing machines and lifts are built this way.
Step 3: The complete sketch
Create a new sketch called plant_watering. Add a secrets.h tab with your Wi-Fi and MQTT details, as in ESP32 & Wi-Fi and Sending Data: HTTP & MQTT:
// secrets.h: keep this file private; never share or upload it
#define WIFI_SSID "your-wifi-name"
#define WIFI_PASS "your-wifi-password"
#define MQTT_USER "plantuser"
#define MQTT_PASS "your-mqtt-password"In the main tab, paste the rule constants, the Mode enum, the state variables and controller() from Step 2 unchanged, but replace logEvent() with the version below, which also publishes each event. Then add the rest:
#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "secrets.h"
// Pins
const int SOIL_PIN = 34; // ADC1: works while Wi-Fi is on
const int TANK_PIN = 27; // float switch to GND: LOW = water present
const int RELAY_PIN = 26; // relay IN, active LOW
// Calibration: replace with your own Step 1 readings (12-bit ESP32 ADC)
const int AIR_VALUE = 3200;
const int WATER_VALUE = 1400;
// Network
const char *BROKER = "192.168.1.60"; // your Raspberry Pi
const char *T_SOIL = "garden/plant-01/soil";
const char *T_EVENT = "garden/plant-01/event";
const char *T_STATUS = "garden/plant-01/status";
const unsigned long DAY_MS = 24UL * 60 * 60 * 1000;
WiFiClient net;
PubSubClient mqtt(net);
Adafruit_SSD1306 display(128, 64, &Wire, -1);
// (paste the rule constants, Mode, state variables and controller() from Step 2 here)
int moisture = 0;
bool tankOk = true;
unsigned long lastRead = 0, lastPublish = 0, lastMqttTry = 0, dayStart = 0;
void relayOn() { digitalWrite(RELAY_PIN, LOW); }
void relayOff() { digitalWrite(RELAY_PIN, HIGH); }
void logEvent(unsigned long now, const char *msg, int m) {
char line[64];
snprintf(line, sizeof(line), "[%lu s] %s (%d %%)", now / 1000, msg, m);
Serial.println(line);
if (mqtt.connected()) mqtt.publish(T_EVENT, line); // alerts reach your phone
}
int readMoisture() {
long total = 0;
for (int i = 0; i < 10; i++) total += analogRead(SOIL_PIN);
int raw = total / 10;
return constrain(map(raw, AIR_VALUE, WATER_VALUE, 0, 100), 0, 100);
}
void updateDisplay() {
const char *names[] = {"Idle", "WATERING", "Soaking"};
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.print(WiFi.status() == WL_CONNECTED ? "WiFi OK" : "WiFi --");
display.print(mqtt.connected() ? " MQTT OK" : " MQTT --");
display.setTextSize(2);
display.setCursor(0, 16);
display.print("Soil ");
display.print(moisture);
display.print("%");
display.setTextSize(1);
display.setCursor(0, 40);
display.print(names[mode]);
display.setCursor(0, 52);
display.print(tankOk ? "Tank OK" : "TANK EMPTY!");
display.print(" bursts ");
display.print(bursts);
display.display();
}
void keepNetwork(unsigned long now) {
if (WiFi.status() != WL_CONNECTED) return; // Wi-Fi reconnects by itself
if (!mqtt.connected() && now - lastMqttTry >= 5000) {
lastMqttTry = now; // try every 5 s, never block
if (mqtt.connect("plant-01", MQTT_USER, MQTT_PASS, T_STATUS, 1, true, "offline")) {
mqtt.publish(T_STATUS, "online", true);
}
}
mqtt.loop();
}
void setup() {
pinMode(RELAY_PIN, OUTPUT);
relayOff(); // safe state before anything else
pinMode(TANK_PIN, INPUT_PULLUP);
Serial.begin(115200);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("OLED not found"); // keep watering even without a screen
}
display.setTextColor(SSD1306_WHITE);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASS); // does not wait
mqtt.setServer(BROKER, 1883);
moisture = readMoisture(); // first real reading before deciding
tankOk = digitalRead(TANK_PIN) == LOW;
lastRead = dayStart = millis();
}
void loop() {
unsigned long now = millis();
keepNetwork(now);
if (now - dayStart >= DAY_MS) { // a new day: reset the limit
dayStart += DAY_MS;
bursts = 0;
limitWarned = false;
}
if (now - lastRead >= 1000) { // sense once per second
lastRead = now;
moisture = readMoisture();
tankOk = digitalRead(TANK_PIN) == LOW;
updateDisplay();
}
if (controller(now, moisture, tankOk)) relayOn(); else relayOff(); // think, act
if (mqtt.connected() && now - lastPublish >= 60000) { // tell
lastPublish = now;
char msg[8];
snprintf(msg, sizeof(msg), "%d", moisture);
mqtt.publish(T_SOIL, msg, true); // retained: new viewers see it at once
}
}Put the pasted Step 2 code where the comment says, and delete Step 2's setup(), loop() and its Serial-only logEvent(). Variables must appear before the code that uses them, which is why the state variables go near the top; the Arduino IDE adds function declarations for you, so controller() may call logEvent() even though it is defined further down. Upload with the board set to ESP32 Dev Module.
What you should see: the OLED shows the connection status on the top line, a large "Soil 52%" (your number), the mode, and the tank state. In a dry pot, the Serial Monitor shows the same kind of log as Step 2, with real times, and you should hear the relay click and the pump run for 5 seconds.
Notice that nothing in loop() blocks. Wi-Fi and MQTT reconnect in the background, the screen updates every second, and the pump decision runs thousands of times per second. That is the millis() timing pattern at full scale.
Step 4: Receive the alerts
On the Raspberry Pi running Mosquitto, watch all of the plant's topics:
mosquitto_sub -h localhost -t 'garden/plant-01/#' -v -u plantuser -P 'your-mqtt-password'With the tank removed while the soil is dry, the subscriber shows lines like these (your times and values will differ):
garden/plant-01/status online garden/plant-01/soil 31 garden/plant-01/event [4210 s] soil dry (31 %) garden/plant-01/event [4210 s] ALERT: tank empty (31 %)
To get these on your phone, install any MQTT client app and subscribe to garden/plant-01/# while on your home Wi-Fi, or use Node-RED on the Pi to forward lines containing ALERT to a messaging service of your choice. Keep the broker on your home network, as explained in IoT Security Basics.
Step 5: Final safety checklist
Before leaving the system running unattended, check every item:
- The pump is wired through relay COM and NO, so it is off when the ESP32 is off.
- Unplugging the ESP32's USB supply stops the pump within a second.
- Lifting the float switch out of the water stops the pump immediately.
- A burst never lasts more than 5 seconds, and there are at most 6 per day.
- All electronics are in a closed box above the water, with drip loops on every cable.
- Both supplies are certified USB adapters or power banks; nothing connects to mains wiring.
secrets.his not shared, and the Wi-Fi, MQTT and router passwords are strong and unique.
Run the system for two days while you are at home before trusting it during a trip. Adjust the 35% and 60% thresholds and the burst length to suit your plant and pot.
Ideas to extend it
- Add a DHT22 and skip watering on cool, humid days.
- Log every event to an SD card or to the Pi for a monthly chart.
- Use ESP32 deep sleep between checks and run from a protected lithium cell, as in Power Management & Battery Projects.
- Control two pots with two sensors, two relays and a
controller()state for each.
Common mistakes
- Skipping calibration. Wrong AIR and WATER values make the percentage meaningless.
- Using an ADC2 pin for the soil sensor. Readings fail while Wi-Fi is on; use GPIO 32 to 39.
- A relay module that does not respond to 3.3 V. Choose one that does, or use a MOSFET module.
- No soak time. The pump keeps running because the sensor has not felt the water yet.
- Electronics below the tank or the pot. One leak and the board is gone.
Key points
- A complete IoT device combines sense, think, act, show and tell, each as a separate, testable part.
- Hysteresis, short bursts with soak time, tank protection and a daily limit make watering safe and reliable.
- A state machine (
IDLE,WATERING,SOAKING) keeps complex behaviour clear, and can be tested with a made-up timeline. - Non-blocking code with
millis()keeps Wi-Fi, MQTT, the display and the pump logic all running together. - Safety comes from the design: NO contacts, safe start-up states, low voltage only and water kept away from electronics.
Exercise
Change MAX_BURSTS to 2 in the Step 2 test sketch and run the same timeline again. Before running it, predict which line replaces [960 s] pump ON (31 %) and why. Then explain what happens at 962 s.
Show solution
Only one line changes in the code:
const int MAX_BURSTS = 2; // per dayThe output becomes:
[10 s] soil dry (33 %) [10 s] pump ON (33 %) [15 s] pump OFF, soaking (34 %) [75 s] soak done (45 %) [80 s] pump ON (47 %) [85 s] pump OFF, soaking (55 %) [145 s] soil OK (63 %) [145 s] soak done (63 %) [900 s] soil dry (32 %) [900 s] ALERT: tank empty (32 %) [960 s] ALERT: daily limit reached (31 %) [962 s] ALERT: tank empty (31 %)
Two bursts were used before 145 s, so at 960 s the tank is full but the limit blocks a third burst and sends a single alert. The device stays in IDLE, so when the tank reads empty again at 962 s it raises a fresh tank alert (the tank warning was reset when the tank read full at 960 s). The 1022 s line disappears because the device never entered SOAKING.
Quick Quiz
1. Why does the pump use the relay's COM and NO contacts?
Normally open contacts close only when the relay is on, which makes "off" the safe default.
2. Why is there a 60-second soak after each 5-second burst?
Without soaking, the sensor still reads dry and the pump would overwater.
3. Why is the soil sensor on GPIO 34 rather than an ADC2 pin?
On the original ESP32, ADC2 is shared with the Wi-Fi radio.
4. What is the main advantage of keeping the watering rules in controller(), separate from the pins?
Pure decision logic can be checked line by line before it ever controls a real pump.
Learn IoT & Arduino with a mentor
Free lessons get you started. Our IoT & Embedded Systems training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
