Arduino Workshop Series

Plant Health
Monitor

Capacitive Soil Moisture · I²C OLED Display · Automated Watering

Beginner–Intermediate
60–75 min per iteration
Arduino Uno R3 / R4
01

Background

Why Capacitive Over Resistive?

Older soil moisture sensors used two bare metal probes that passed current directly through the soil to measure resistance. They work, but they corrode rapidly because electrolysis attacks the metal. Capacitive sensors never pass current through the soil — they measure the soil's dielectric permittivity instead, which changes dramatically with water content. The probes last far longer and give more stable readings over time.

⚡ How Capacitive Sensing Works
  1. A PCB trace on the sensor acts as a capacitor's electrode.
  2. Soil inserted between the traces becomes the dielectric material.
  3. Water has a dielectric constant of ~80; dry soil is only ~4.
  4. A 555-timer oscillator on-board generates a frequency that shifts with capacitance.
  5. The circuit converts this to an analogue voltage: HIGH = dry, LOW = wet.

What You'll Build: OLED Display Preview

Your OLED will display a large moisture percentage, a progress bar, and a status message. Use the slider below to preview:

Plant Monitor
65%
Moisture OK
DRY WET
02

Learning Objectives

  • Read analogue sensor values using Arduino's ADC (Analogue-to-Digital Converter)
  • Map raw ADC values to a meaningful percentage using map()
  • Display dynamic text and graphics on an I²C OLED using Adafruit SSD1306 library
  • Use conditional logic to trigger actions based on threshold sensor values
  • Understand the importance of calibration in sensor-based systems
  • Control a relay module to switch a water pump safely (Iteration 2)
03

Components

Click each component as you gather it from your kit.

🟦
Arduino Uno R3
× 1
🌱
Capacitive Soil Sensor v1.2
× 1 — 3 pins: VCC GND AOUT
📺
0.96″ I²C OLED (SSD1306)
× 1 — 128×64 px
🔌
USB-A to USB-B Cable
× 1 — for programming
Half-size Breadboard
× 1 — 400-tie
🔴
Jumper Wires (M-M)
× 10+ — assorted colours
🪴
Plant Pot with Soil
× 1 — for calibration
04

Library Setup

Open the Arduino IDE, then go to Sketch → Include Library → Manage Libraries and install both libraries below.

Adafruit SSD1306

Search: SSD1306 → Install Adafruit SSD1306. The IDE will also prompt for Adafruit GFX Library — click Install All.

⚠️ I²C Address
Most 0.96" OLED modules use I²C address 0x3C. Some use 0x3D. If your display shows nothing after uploading, change 0x3C to 0x3D in the #define OLED_ADDRESS line. You can find your module's address by running an I²C Scanner sketch.
05

Wiring the Circuit

💡 Breadboard Power Rail Tip
Connect Arduino 5V → breadboard + rail and GND → breadboard − rail first. Then connect both modules to those rails. It keeps wiring neat and reduces errors.

OLED Display — I²C

The OLED uses only two data wires. On Arduino Uno, I²C lives on A4 (SDA) and A5 (SCL).

OLED PinArduino PinWire Colour
VCC5VRed
GNDGNDBlack
SCLA5Yellow
SDAA4Blue

Capacitive Soil Sensor

The sensor outputs an analogue voltage on AOUT. Connect to any analogue input pin (we use A0).

Sensor PinArduino PinWire Colour
VCC5VRed
GNDGNDBlack
AOUTA0Green

* Most v1.2 sensors work at both 3.3V and 5V. Using 3.3V gives a slightly wider analogue range.

06

Calibration

The sensor outputs a raw ADC value (0–1023), not a percentage. We need to measure the values at completely dry and fully saturated soil, then use map() to convert between them.

Step 1: Upload the Calibration Sketch

Arduino C++
// Calibration Sketch — open Serial Monitor at 9600 baud

void setup() {
  Serial.begin(9600);
}

void loop() {
  int rawValue = analogRead(A0);
  Serial.print("Raw ADC Value: ");
  Serial.println(rawValue);
  delay(500);
}

Step 2: Record Your Values

1

Open Serial Monitor (Tools → Serial Monitor, set to 9600 baud).

2

Hold the sensor in open air. Note the reading — this is your DRY value (typically 520–620).

3

Push the sensor into very wet/saturated soil. Note the reading — this is your WET value (typically 260–350).

4

The sensor reads higher when dry and lower when wet. This is normal! Enter your values below.

ConditionYour ADC ReadingVariable Name
🌵 Air / Bone Dry SENSOR_DRY
💧 Saturated / Wet SENSOR_WET

Enter your calibration values above — they'll automatically appear in the main code below.

07

Main Code: Plant Monitor

Your calibrated values from Section 06 are automatically filled in. If you haven't entered them yet, replace 580 and 290 with your own values.

Arduino C++ — Plant Health Monitor
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// --- Display Configuration ---
#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT  64
#define OLED_RESET     -1
#define OLED_ADDRESS 0x3C

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// --- Sensor Configuration ---
#define SENSOR_PIN     A0
#define SENSOR_DRY    580  // Replace with YOUR dry value
#define SENSOR_WET    290  // Replace with YOUR wet value

// --- Moisture Thresholds ---
#define THRESH_DRY     30  // Below 30% = Needs Water
#define THRESH_OK      60  // 30–60% = OK, above 60% = Well Watered

void setup() {
  Serial.begin(9600);

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
    Serial.println("OLED not found! Check wiring.");
    while (true);
  }

  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(10, 25);
  display.println("Plant Monitor Ready");
  display.display();
  delay(2000);
}

void loop() {
  int rawValue    = analogRead(SENSOR_PIN);
  int moisturePct = map(rawValue, SENSOR_DRY, SENSOR_WET, 0, 100);
  moisturePct     = constrain(moisturePct, 0, 100);

  // Determine status
  String status;
  if (moisturePct < THRESH_DRY) {
    status = "NEEDS WATER!";
  } else if (moisturePct < THRESH_OK) {
    status = "OK";
  } else {
    status = "Well Watered";
  }

  // --- Draw OLED Screen ---
  display.clearDisplay();

  // Title bar (white rectangle, black text)
  display.fillRect(0, 0, 128, 12, SSD1306_WHITE);
  display.setTextColor(SSD1306_BLACK);
  display.setTextSize(1);
  display.setCursor(20, 2);
  display.println("Plant Monitor");

  // Large percentage (white)
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(3);
  display.setCursor(10, 18);
  display.print(moisturePct);
  display.println("%");

  // Progress bar
  display.drawRect(0, 46, 128, 10, SSD1306_WHITE);
  int barWidth = map(moisturePct, 0, 100, 0, 126);
  display.fillRect(1, 47, barWidth, 8, SSD1306_WHITE);

  // Status text
  display.setTextSize(1);
  display.setCursor(0, 57);
  display.println(status);

  display.display();

  Serial.print("Raw: "); Serial.print(rawValue);
  Serial.print(" | Moisture: "); Serial.print(moisturePct);
  Serial.println("%");

  delay(1000);
}
08

How the Code Works

FunctionWhat it Does
analogRead(A0)Reads the voltage on A0 and returns 0–1023 (0V = 0, 5V = 1023).
map(value, fromLow, fromHigh, toLow, toHigh)Re-maps a number from one range to another. Here: raw ADC → 0–100%.
constrain(value, min, max)Clamps a value between min and max. Prevents moisture showing as 110% or −5%.
display.clearDisplay()Clears the OLED buffer. Always call before drawing a new frame.
display.display()Pushes the buffer to the physical screen. Nothing shows until this is called.
display.fillRect(x,y,w,h,c)Draws a filled rectangle. Used for the title bar and moisture progress bar.
09

Troubleshooting

Check SDA → A4 and SCL → A5. Try changing 0x3C to 0x3D in #define OLED_ADDRESS. Run an I²C Scanner sketch to confirm the display's address.
Recalibrate. The SENSOR_DRY or SENSOR_WET values are wrong. Run the calibration sketch again and record fresh values directly from your Serial Monitor.
Ensure the 5V supply is stable. Try adding a 100µF electrolytic capacitor across the 5V and GND on the breadboard. Check all jumper connections are seated firmly.
Ensure the sensor probe is fully and evenly inserted into the soil. Also try averaging 5 readings before displaying — this smooths out electrical noise.
Confirm delay(1000) is not too long. Try delay(500). Also make sure you are calling display.clearDisplay() at the start of each loop iteration.
10

Challenge Exercises

  • Add a third status: show "Overwatered!" when moisture exceeds 90%
  • Change the progress bar to fill only when moisture is in the healthy range (30–70%)
  • Display the raw ADC value in small text at the bottom of the OLED screen
  • Blink the entire OLED display when the plant needs water
  • Calculate the average of 5 readings before displaying, to smooth noisy values
01

How Relays Work

A relay is an electrically controlled switch. It contains two completely separate circuits so your Arduino never connects directly to the pump.

Control Circuit
Arduino Pin 7
5V · low current
⚡→
Switched Circuit
Water Pump
5V · up to 500mA
⚡ Active LOW Logic
Most relay modules are Active LOW. This means:

digitalWrite(RELAY_PIN, LOW); → Relay ON → Pump runs
digitalWrite(RELAY_PIN, HIGH); → Relay OFF → Pump stops

You must set the relay pin HIGH in setup() to prevent the pump running at power-on.

Extra Components Needed

🔲
5V Single-Channel Relay Module
× 1 — opto-isolated preferred
💧
5V Mini Water Pump + Tubing
× 1 — max 200mA
🥤
Small Water Reservoir
× 1 — plastic cup or bottle
🔴
Additional Jumper Wires
× 6
02

Additional Wiring

🔴 Current Warning
The Arduino 5V pin supplies ~400–500mA total. Mini submersible pumps draw 100–200mA. If your pump draws more, use a separate 5V power supply and share only GND. Never connect a pump directly to a GPIO pin — they handle 40mA maximum.

Relay Module

Relay PinArduino PinNotes
VCC5VPower for relay coil
GNDGNDCommon ground
IN (Signal)D7Active LOW — LOW = relay ON

Water Pump

ConnectionFromNotes
Pump Positive (+)Relay COM terminal5V supply switched via relay
5V supplyRelay NO terminal ← Arduino 5VOnly flows when relay closes
Pump Negative (−)GNDCommon ground
03

Full Code: Auto Watering

Remember to replace SENSOR_DRY and SENSOR_WET with the values you recorded during calibration.

Arduino C++ — Automated Watering System
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// --- Display Configuration ---
#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT  64
#define OLED_RESET     -1
#define OLED_ADDRESS 0x3C

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// --- Pin Definitions ---
#define SENSOR_PIN     A0
#define RELAY_PIN       7   // Active LOW relay signal

// --- Sensor Calibration (replace with YOUR values) ---
#define SENSOR_DRY    580
#define SENSOR_WET    290

// --- Watering Settings ---
#define WATER_THRESHOLD  30   // Water if moisture below 30%
#define PUMP_ON_TIME   3000   // Run pump for 3 seconds
#define CHECK_INTERVAL 5000   // Check moisture every 5 seconds

bool isWatering = false;
unsigned long lastCheckTime = 0;
unsigned long pumpStartTime = 0;

void setup() {
  Serial.begin(9600);
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, HIGH);  // Relay OFF at startup (active LOW)

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
    Serial.println("OLED not found!");
    while (true);
  }

  showSplash();
  delay(2000);
}

void loop() {
  unsigned long currentTime = millis();

  // Stop pump after PUMP_ON_TIME
  if (isWatering && (currentTime - pumpStartTime >= PUMP_ON_TIME)) {
    stopPump();
  }

  // Check moisture on interval
  if (!isWatering && (currentTime - lastCheckTime >= CHECK_INTERVAL)) {
    lastCheckTime = currentTime;

    int rawValue = analogRead(SENSOR_PIN);
    int moisture = map(rawValue, SENSOR_DRY, SENSOR_WET, 0, 100);
    moisture     = constrain(moisture, 0, 100);

    updateDisplay(moisture, false);

    if (moisture < WATER_THRESHOLD) {
      startPump();
      updateDisplay(moisture, true);
    }

    Serial.print("Moisture: "); Serial.print(moisture); Serial.println("%");
  }
}

void startPump() {
  digitalWrite(RELAY_PIN, LOW);   // LOW = relay ON = pump runs
  isWatering    = true;
  pumpStartTime = millis();
  Serial.println("Pump ON");
}

void stopPump() {
  digitalWrite(RELAY_PIN, HIGH);  // HIGH = relay OFF = pump stops
  isWatering = false;
  Serial.println("Pump OFF");
}

void updateDisplay(int moisture, bool pumping) {
  display.clearDisplay();

  display.fillRect(0, 0, 128, 12, SSD1306_WHITE);
  display.setTextColor(SSD1306_BLACK);
  display.setTextSize(1);
  display.setCursor(18, 2);
  display.println("Auto Waterer");

  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(3);
  display.setCursor(10, 16);
  display.print(moisture);
  display.println("%");

  display.drawRect(0, 46, 128, 8, SSD1306_WHITE);
  int barW = map(moisture, 0, 100, 0, 126);
  display.fillRect(1, 47, barW, 6, SSD1306_WHITE);

  display.setTextSize(1);
  display.setCursor(0, 56);
  if (pumping) {
    display.println(">> WATERING... <<");
  } else if (moisture < WATER_THRESHOLD) {
    display.println("Dry - starting pump");
  } else {
    display.println("Moisture OK");
  }

  display.display();
}

void showSplash() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(10, 20);
  display.println("Auto Watering");
  display.setCursor(15, 35);
  display.println("System Ready");
  display.display();
}
04

millis() Timing Explained

This code does not use delay() for the pump timer. Here's why that matters:

❌ The Problem with delay()
delay(3000) freezes the Arduino for 3 seconds — no sensor reads, no display updates, no button responses. The Arduino cannot do anything while inside a delay.
✅ millis() Solution
millis() returns the number of milliseconds since the Arduino powered on. We record when the pump started (pumpStartTime = millis()) then check in every loop how long it has been running. The loop runs freely — sensors read, display updates, everything works normally while the pump is counting.

State Flags

The boolean variable isWatering is a state flag. At any moment the system is in one of two states:

  • isWatering = true → Check if PUMP_ON_TIME has elapsed; if so, call stopPump().
  • isWatering = false → Check moisture on CHECK_INTERVAL; call startPump() if soil is dry.

State flags are a fundamental technique in embedded programming and robotics. Any reactive system — robots, game controllers, smart home devices — uses this pattern.

05

Troubleshooting

Check relay wiring. Print digitalRead(RELAY_PIN) to Serial Monitor to confirm the pin state is changing. Ensure relay VCC is connected to 5V and GND to ground. Try manually forcing LOW in setup() briefly to test the relay click.
Confirm Active LOW logic. The pin should write LOW to start and HIGH to stop. If you see the relay module LED on at startup, you forgot digitalWrite(RELAY_PIN, HIGH) in setup().
You have probably used delay(3000) somewhere instead of the millis() pattern. Search your code for any delay() calls inside the pump logic and replace them with the timestamp approach.
Check pump 5V supply and the NO (Normally Open) / COM terminal connections on the relay. When the relay energises, COM and NO become connected. If wired to NC instead, the behaviour is inverted.
The pump is drawing too much current from the Arduino 5V pin, causing a brownout. Use a separate 5V supply (e.g. a powered USB hub or 5V wall adapter) for the pump circuit, and share only the GND rail.
06

Challenge Exercises

  • Add a manual override button: pressing it should trigger a watering cycle regardless of moisture level
  • Count how many times the plant has been watered and display the total on the OLED
  • Add a cooldown period — prevent the pump running again within 60 seconds of the last watering
  • Log moisture readings to the Serial Monitor in CSV format for analysis in a spreadsheet
  • Flash an LED critically if moisture drops below 10% — a secondary "emergency" alert

Pin Reference

Arduino PinConnected ToPurposeIteration
5VOLED VCC · Sensor VCC · Relay VCCModule power supplyBoth
GNDOLED GND · Sensor GND · Relay GNDCommon groundBoth
A4 (SDA)OLED SDAI²C DataBoth
A5 (SCL)OLED SCLI²C ClockBoth
A0Soil Sensor AOUTAnalogue moisture readingBoth
D7Relay IN (Signal)Active LOW relay controlIter. 2

Glossary

ADC
Analogue-to-Digital Converter. Converts a voltage (0–5V) into a number (0–1023) the microcontroller can process.
I²C
Two-wire serial protocol (SDA + SCL) for connecting multiple devices to one microcontroller.
map()
Arduino function that linearly re-maps a value from one number range to another.
Relay
An electrically controlled mechanical switch. Lets a low-power circuit switch a higher-power load.
Active LOW
A module that activates when its signal pin is pulled to 0V (LOW), not 5V (HIGH).
millis()
Returns milliseconds since the Arduino was powered on. Used for non-blocking timing.
Calibration
Measuring real-world extremes to accurately map sensor output values to meaningful units.
SSD1306
The display controller chip in most small OLED screens. Controlled over I²C or SPI.
State Flag
A boolean variable that tracks what mode the program is in, enabling non-blocking logic.
Dielectric
An insulating material whose electrical polarisability changes with its composition — used in capacitive sensing.

Further Iterations

Completed both iterations? Here are paths to take the project further:

📂 Data Logging to SD Card

Use an SD card module over SPI to save moisture readings with timestamps. Export CSV data to Excel or Google Sheets for analysis. Learn: SPI communication, file I/O, data formatting.

🌡 DHT11 / DHT22 Temperature Sensor

Add temperature and humidity alongside soil moisture. Display all three on the OLED. Introduce multi-sensor systems and environmental monitoring concepts.

📱 Bluetooth App Control

Use an HC-05 Bluetooth module and MIT App Inventor to send moisture data to a smartphone. Manually trigger watering from your phone. Learn: serial communication, app development basics.

⏰ Scheduled Watering with RTC

Add a DS3231 Real-Time Clock module to water at specific times of day. Learn: I²C multi-device, timekeeping in embedded systems, battery-backed memory.

🌿 Multi-Plant System

Wire multiple soil sensors to different analogue pins (A0–A5). Give each plant its own threshold. Learn: array data structures, sensor multiplexing, scalable code.

🌐 MQTT IoT Dashboard

Add an ESP8266 WiFi module and send sensor data to Node-RED or a Grafana dashboard for remote monitoring from any browser. Learn: networking, MQTT protocol, IoT architecture.