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.
- A PCB trace on the sensor acts as a capacitor's electrode.
- Soil inserted between the traces becomes the dielectric material.
- Water has a dielectric constant of ~80; dry soil is only ~4.
- A 555-timer oscillator on-board generates a frequency that shifts with capacitance.
- 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:
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)
Components
Click each component as you gather it from your kit.
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.
0x3C to 0x3D in the #define OLED_ADDRESS line. You can find your module's address by running an I²C Scanner sketch.
Wiring the Circuit
OLED Display — I²C
The OLED uses only two data wires. On Arduino Uno, I²C lives on A4 (SDA) and A5 (SCL).
| OLED Pin | Arduino Pin | Wire Colour |
|---|---|---|
| VCC | 5V | Red |
| GND | GND | Black |
| SCL | A5 | Yellow |
| SDA | A4 | Blue |
Capacitive Soil Sensor
The sensor outputs an analogue voltage on AOUT. Connect to any analogue input pin (we use A0).
| Sensor Pin | Arduino Pin | Wire Colour |
|---|---|---|
| VCC | 5V | Red |
| GND | GND | Black |
| AOUT | A0 | Green |
* Most v1.2 sensors work at both 3.3V and 5V. Using 3.3V gives a slightly wider analogue range.
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
// 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
Open Serial Monitor (Tools → Serial Monitor, set to 9600 baud).
Hold the sensor in open air. Note the reading — this is your DRY value (typically 520–620).
Push the sensor into very wet/saturated soil. Note the reading — this is your WET value (typically 260–350).
The sensor reads higher when dry and lower when wet. This is normal! Enter your values below.
| Condition | Your ADC Reading | Variable Name |
|---|---|---|
| 🌵 Air / Bone Dry | SENSOR_DRY |
|
| 💧 Saturated / Wet | SENSOR_WET |
Enter your calibration values above — they'll automatically appear in the main code below.
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.
#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); }
How the Code Works
| Function | What 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. |
Troubleshooting
0x3C to 0x3D in #define OLED_ADDRESS. Run an I²C Scanner sketch to confirm the display's address.delay(1000) is not too long. Try delay(500). Also make sure you are calling display.clearDisplay() at the start of each loop iteration.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
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.
digitalWrite(RELAY_PIN, LOW); → Relay ON → Pump runsdigitalWrite(RELAY_PIN, HIGH); → Relay OFF → Pump stopsYou must set the relay pin HIGH in
setup() to prevent the pump running at power-on.
Extra Components Needed
Additional Wiring
Relay Module
| Relay Pin | Arduino Pin | Notes |
|---|---|---|
| VCC | 5V | Power for relay coil |
| GND | GND | Common ground |
| IN (Signal) | D7 | Active LOW — LOW = relay ON |
Water Pump
| Connection | From | Notes |
|---|---|---|
| Pump Positive (+) | Relay COM terminal | 5V supply switched via relay |
| 5V supply | Relay NO terminal ← Arduino 5V | Only flows when relay closes |
| Pump Negative (−) | GND | Common ground |
Full Code: Auto Watering
Remember to replace SENSOR_DRY and SENSOR_WET with the values you recorded during calibration.
#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(); }
millis() Timing Explained
This code does not use delay() for the pump timer. Here's why that matters:
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() 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.
Troubleshooting
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.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().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.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 Pin | Connected To | Purpose | Iteration |
|---|---|---|---|
| 5V | OLED VCC · Sensor VCC · Relay VCC | Module power supply | Both |
| GND | OLED GND · Sensor GND · Relay GND | Common ground | Both |
| A4 (SDA) | OLED SDA | I²C Data | Both |
| A5 (SCL) | OLED SCL | I²C Clock | Both |
| A0 | Soil Sensor AOUT | Analogue moisture reading | Both |
| D7 | Relay IN (Signal) | Active LOW relay control | Iter. 2 |
Glossary
Further Iterations
Completed both iterations? Here are paths to take the project further:
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.
Add temperature and humidity alongside soil moisture. Display all three on the OLED. Introduce multi-sensor systems and environmental monitoring concepts.
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.
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.
Wire multiple soil sensors to different analogue pins (A0–A5). Give each plant its own threshold. Learn: array data structures, sensor multiplexing, scalable code.
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.