EthicsComputerCourses
Join training +91 90532 10052

Project: Smart Plant-Watering System

IoT & Arduino · Lesson 29 of 29 Advanced 12 min read Updated

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

StagePartJob
SenseCapacitive soil moisture sensorMeasures how wet the soil is (0 to 100% after calibration)
SenseFloat switch in the water tankTells the controller whether there is water to pump
ThinkESP32Decides when to water, with hysteresis, time limits and a daily limit
ActRelay module and 5 V pumpPumps water in 5-second bursts
Show0.96 inch SSD1306 OLEDMoisture, mode, tank state and connection status
TellWi-Fi and MQTTPublishes readings and events; alerts when the tank is empty

The watering rules:

  1. 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.
  2. 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.
  3. Tank protection: never run the pump without water. A pump running dry overheats.
  4. 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

PartQuantityNotes
ESP32 development board (ESP32-WROOM-32, "ESP32 Dev Module")1Any ESP32 with Wi-Fi; check your board's pinout
Capacitive soil moisture sensor (v1.2 or v2.0 style)1Prefer one that works at 3.3 V; avoid resistive fork probes
1-channel relay module1Must switch reliably from a 3.3 V input signal; check the listing
Mini submersible DC pump, 3 to 5 V1Check its current rating; small ones are often a few hundred mA
Silicone tube to fit the pumpAbout 1 mLong enough to reach from the tank to the pot
Vertical float switch (water level switch)1Low-voltage type, mounted near the bottom of the tank
SSD1306 OLED, 128 × 64, I2C1Address usually 0x3C
5 V USB adapter for the ESP321A certified phone charger is fine
Separate 5 V USB supply for the pump (adapter or power bank)1Keeps pump current away from the ESP32
USB cable cut-off or USB breakout board1To bring the pump supply's 5 V and GND to the relay safely
1N4007 diode (optional)1Across the pump terminals to reduce sparking at the relay contacts
Breadboard, jumper wires, small plastic boxAs neededThe box keeps electronics dry
Water container with a lid1Plastic, 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

PartPinConnects to
Soil sensorVCCESP32 3V3
Soil sensorGNDESP32 GND
Soil sensorAOUTESP32 GPIO 34 (ADC1, safe with Wi-Fi)
Float switchWire 1ESP32 GPIO 27
Float switchWire 2ESP32 GND
OLEDVCCESP32 3V3
OLEDGNDESP32 GND
OLEDSDAESP32 GPIO 21
OLEDSCLESP32 GPIO 22
Relay moduleVCCESP32 5V (VIN) pin, which carries USB 5 V
Relay moduleGNDESP32 GND
Relay moduleINESP32 GPIO 26
Pump supply5 V (+)Relay COM
Relay moduleNOPump red wire (+)
PumpBlack wire (-)Pump supply GND
1N4007 diode (optional)Stripe endPump red wire (+)
1N4007 diode (optional)Other endPump 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.

  1. Soil sensor: run the live soil sketch from Light & Soil Moisture Sensors on the ESP32 with SOIL_PIN set 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.
  2. Float switch: with pinMode(27, INPUT_PULLUP), print digitalRead(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.
  3. 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.
  4. 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.
  5. OLED: run the I2C scanner and the OLED sketch from Displays: I2C LCD & OLED. On the ESP32, Wire uses 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.

C++
// 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() {
}
Output
[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:

C++
// 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:

C++
#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:

Terminal
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):

Output
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.h is 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:

C++
const int MAX_BURSTS = 2;              // per day

The output becomes:

Output
[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?

2. Why is there a 60-second soak after each 5-second burst?

3. Why is the soil sensor on GPIO 34 rather than an ADC2 pin?

4. What is the main advantage of keeping the watering rules in controller(), separate from the pins?

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