Contents — 26 sections
Project Overview
Soil-moisture-driven watering that understands the difference between "the surface is dry" and "the plant is thirsty" — with capacitive probes that survive, a pump that cannot flood your floor, and per-plant thresholds you calibrate once.
The failure mode of most automatic plant waterers is not that they under-water. It is that they over-water, kill the plant through root rot, and then flood the floor when a tube pops off. Every design decision here is aimed at those three outcomes rather than at the easy part, which is turning a pump on.
The sensing choice matters first. The resistive soil probe that ships with almost every starter kit is two exposed metal prongs with DC across them. In damp soil that is an electrolysis cell: the prongs corrode visibly within two to four weeks, the readings drift steadily drier, and the system responds by watering more and more. It is not a cheaper option, it is a broken one. A capacitive probe measures the dielectric constant of the soil through an insulating coating with no exposed metal at all, and lasts years.
The control logic then has to respect what soil actually does. Water applied at the surface takes fifteen to forty minutes to redistribute through the root zone, so a controller that waters, re-reads immediately, still sees dry soil and waters again will drown the plant in about four cycles. The firmware therefore runs a strict water-then-wait cycle: a bounded pulse, a mandatory soak period, and only then a fresh decision.
Finally the safety layer. A pump that can run indefinitely is a flood waiting for a stuck relay or a crashed loop. This design bounds every pulse in hardware terms — a maximum run time enforced independently of the control logic, a daily volume cap, and a reservoir level switch that refuses to run the pump dry. Running a small diaphragm pump dry destroys it in minutes.
What this project does
- Measures volumetric soil moisture with a capacitive probe per plant, calibrated to that specific soil and pot.
- Waters in bounded pulses with an enforced soak period between them, so the reading has time to become meaningful.
- Refuses to run the pump when the reservoir level switch reports empty.
- Enforces a daily volume cap so a stuck sensor cannot drown a plant over a weekend.
- Supports up to four independent zones from one controller, each with its own threshold and schedule.
- Logs every watering event with duration, estimated volume and the moisture before and after.
- Publishes to MQTT and Home Assistant, with per-zone manual override.
Real-World Applications
| Setting | How it is used |
|---|---|
| Indoor houseplants during travel | The original use case, and the one where over-watering does the most damage because nobody is there to notice. |
| Balcony herb and vegetable planters | Small soil volumes dry out fast in summer; daily watering by hand is exactly the chore this removes. |
| Seed propagation trays | Germination needs consistently damp, never saturated — a band that is very hard to hit manually. |
| Bonsai and specimen plants | High-value plants where the cost of getting it wrong justifies instrumenting it properly. |
| Office plants | Nobody waters them at weekends, and everybody waters them on Monday. |
| Teaching sensors and control | A complete closed loop — measure, decide, actuate, measure again — with a visible physical result. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- Capacitive probes only — no exposed electrodes, no electrolysis, multi-year life.
- Two-point calibration per probe (dry air and saturated soil) mapped to volumetric water content.
- Water-then-soak cycle with a 30-minute mandatory wait, which is what prevents over-watering.
- Hard pulse limit of 20 seconds enforced by a separate timer from the control logic.
- Daily volume cap in millilitres, reset at midnight.
- Dry-run protection via a reservoir float switch, protecting the pump.
- Temperature and humidity compensation — evapotranspiration rises with heat, so thresholds shift.
- Per-plant profiles stored in NVS: succulents, herbs and ferns want completely different set points.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Beginner |
| Estimated completion time | 6–10 hours |
| Indicative build cost | ₹2,900 – ₹3,800 for four zones |
| Primary discipline | Smart Home |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Basic Arduino C++ and analogue reading
- Understanding of a two-point sensor calibration
- Driving a pump through a MOSFET or relay with a flyback diode
- Simple plumbing — tubing, drippers, reservoir
- MQTT basics for the reporting half
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 1 | ₹450 |
| Capacitive soil-moisture sensor v2.0 Always pick capacitive over the cheap resistive fork — resistive probes corrode in weeks. | Corrosion-free capacitive sensing, 0–3 V analogue swing, 55 × 20 mm probe | 4 | ₹720 |
| 5 V submersible mini water pump Never run it dry; add a flyback diode across the motor terminals. | 80–120 L/h, 0.4–1.5 m head, 5 V DC, 5 mm outlet | 1 | ₹160 |
| DHT22 / AM2302 temperature + humidity sensor Needs a 4.7 kΩ pull-up on the data line and 2 s between reads. | −40 to +80 °C ±0.5 °C, 0–100 %RH ±2 %, 0.5 Hz sample rate, single-wire digital | 1 | ₹250 |
| 0.96″ SSD1306 OLED display Static images burn in — invert or scroll the screen periodically. | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 1 | ₹250 |
| LM2596 adjustable buck converter module Set the output voltage with no load connected before wiring the board. | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 1 | ₹90 |
| 5 V 3 A regulated SMPS adapter Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 1 | ₹350 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| IP65 ABS junction enclosure 158 × 90 × 60 mm Fit cable glands, not drilled holes, or the IP rating means nothing. | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | 1 | ₹260 |
| Reservoir float switch Wire normally-closed so a broken wire reads as empty and stops the pump. | Vertical, NO/NC, 100 V 0.5 A | 1 | ₹180 |
| IRLZ44N logic-level MOSFET + 1N4007 Logic-level gate is essential — a standard IRF540 will not fully turn on from 3.3 V. | N-channel, 3.3 V gate drive, 47 A | 4 | ₹480 |
| 6 mm silicone tubing + drippers + T-pieces | 3 m tube, 4 adjustable drippers | 1 | ₹280 |
| 5 L reservoir with lid | Food-grade HDPE | 1 | ₹220 |
Estimated total: ₹3,750, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| Capacitive soil-moisture sensor v2.0 | Corrosion-free capacitive sensing, 0–3 V analogue swing, 55 × 20 mm probe | 3.3–5.5 V | Analogue | Datasheet |
| 5 V submersible mini water pump | 80–120 L/h, 0.4–1.5 m head, 5 V DC, 5 mm outlet | 3–6 V | Relay / MOSFET | Datasheet |
| DHT22 / AM2302 temperature + humidity sensor | −40 to +80 °C ±0.5 °C, 0–100 %RH ±2 %, 0.5 Hz sample rate, single-wire digital | 3.3–6 V | 1-wire proprietary | Datasheet |
| 0.96″ SSD1306 OLED display | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 3.3–5 V | I²C (0x3C) | Datasheet |
| LM2596 adjustable buck converter module | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5–40 V | Screw terminals + trimmer | Datasheet |
| 5 V 3 A regulated SMPS adapter | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 5 V | DC barrel / USB | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| Capacitive soil-moisture sensor v2.0 | 3.3–5.5 V | 20 | Always pick capacitive over the cheap resistive fork — resistive probes corrode in weeks. |
| 5 V submersible mini water pump | 3–6 V | 220 | Never run it dry; add a flyback diode across the motor terminals. |
| DHT22 / AM2302 temperature + humidity sensor | 3.3–6 V | 1.5 | Needs a 4.7 kΩ pull-up on the data line and 2 s between reads. |
| 0.96″ SSD1306 OLED display | 3.3–5 V | 20 | Static images burn in — invert or scroll the screen periodically. |
| LM2596 adjustable buck converter module | 4.5–40 V | 8 | Set the output voltage with no load connected before wiring the board. |
| 5 V 3 A regulated SMPS adapter | 5 V | 3000 | Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. |
Summed typical draw is 3429.5 mA. With a 1.5× design margin the supply should deliver at least 5200 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools → Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
- Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| DHT sensor library 1.4.6 | Timing-critical driver for DHT11/DHT22. | Library Manager → "DHT sensor library" by Adafruit |
| Adafruit Unified Sensor 1.1.x | Common sensor event abstraction; a dependency of most Adafruit drivers. | Library Manager → "Adafruit Unified Sensor" |
| Adafruit SSD1306 + GFX 2.5.x | Framebuffer and text/graphics primitives for the OLED. | Library Manager → "Adafruit SSD1306" |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-clock time from an NTP server for timestamping. | Bundled (`configTime()` on ESP32) |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| Capacitive soil probe 1–4 | AOUT | GPIO 34 35 36 39 | Analogue, input-only pins |
| DHT22 air sensor | DATA | GPIO 27 | 1-wire, 4.7 kΩ pull-up |
| Reservoir float switch | NC contact | GPIO 32 | Pull-up; open = empty |
| Manual water button | NO | GPIO 33 | Pull-up |
| Pump 1–4 via MOSFET | Gate | GPIO 25 26 14 12 | 3.3 V logic-level gate |
| SSD1306 OLED | SDA / SCL | GPIO 21 / 22 | I²C at 0x3C |
| Status LED | Anode | GPIO 2 | Through 330 Ω |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- GPIO 34, 35, 36 and 39 are input-only and have no internal pull-ups — perfect for analogue probes and useless for anything else. Use them here and keep the output-capable pins for the pumps.
- Use ADC1 pins (32–39) for the probes. ADC2 (GPIO 0, 2, 4, 12–15, 25–27) is unavailable whenever Wi-Fi is active, and the failure is silent:
analogRead()simply returns garbage. - The pump is inductive. Fit a 1N4007 across its terminals, cathode to positive, or the back-EMF will destroy the MOSFET. This is the single most common cause of a pump project that works twice and then stops.
- Use a logic-level MOSFET such as the IRLZ44N. A standard IRF540 needs about 10 V on the gate to turn fully on; driven from 3.3 V it operates in its linear region, dissipates several watts and gets hot enough to fail.
- Keep the probe electronics above the soil line. The probe is designed to be inserted to a marked depth — pushing it in past the coating line lets water reach the electronics and destroys it.
- Route tubing so that if a connection fails, water goes into a tray rather than onto the floor. Assume it will fail eventually, because it will.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
A capacitive soil probe works because water has an unusually high relative permittivity — about 80, against roughly 4 for dry soil minerals and 1 for air. The probe is a pair of PCB traces forming a capacitor with the soil as its dielectric; more water in the soil means more capacitance. The board runs a 555-style oscillator whose frequency depends on that capacitance, rectifies the result, and presents a DC voltage on the analogue output. Because the copper is entirely covered by solder mask, no current flows through the soil and there is nothing to corrode.
The output is not calibrated in any physical unit, and the mapping differs with soil type, compaction, temperature and even pot size. A two-point calibration is therefore mandatory: record the raw ADC value with the probe in dry air (the driest possible reading) and again in a pot of thoroughly saturated soil (the wettest). Everything between is linearly interpolated. That linear assumption is not perfectly true — the real relationship curves — but across the 20–60 % range where watering decisions actually happen, it is accurate to a few percent.
What the plant cares about is not moisture at the surface but water availability in the root zone. This is where naive controllers fail. Applying 30 mL to the top of a pot wets the top two centimetres immediately and takes 20–40 minutes to redistribute downward through capillary action. A controller that re-reads at one minute sees soil that is still dry at probe depth, waters again, and repeats. Four cycles later the pot is saturated and the roots are anaerobic. The mandatory soak period is not a nicety, it is the core of the algorithm.
The threshold itself should be a band, not a point. Watering when moisture drops below a lower limit and stopping when it rises above an upper limit gives hysteresis and prevents the controller from chasing sensor noise. For most houseplants a band of roughly 30 % to 55 % works; succulents want 15 % to 30 % and ferns want 45 % to 70 %. These are set-point profiles, not universal truths, and the calibration procedure is what makes the numbers mean the same thing across different soils.
Evapotranspiration compensation is a small refinement with a real effect. A plant loses water faster when the air is warm and dry. Shifting the lower threshold up by a couple of percent for every 5 °C above 25 °C, and down when humidity is high, keeps the plant in the same effective water-stress band across seasons rather than across thermometer readings.
The maths behind it
Two-point calibration to volumetric water content
Raw ADC readings for a specific probe:
air_dry (0 % VWC) = 3180
saturated (100 % scale)= 1420
VWC% = (air_dry − raw) / (air_dry − saturated) × 100
Example raw = 2400:
VWC = (3180 − 2400) / (3180 − 1420) × 100
= 780 / 1760 × 100 = 44.3 %
Note the inversion: a wetter soil gives a LOWER
raw value, because more capacitance lowers the
oscillator frequency and hence the rectified output.
Pulse volume and daily cap
Pump rated 100 L/h at zero head:
= 100 000 mL / 3600 s = 27.8 mL/s
At 0.5 m head, derate ~35 %:
effective ≈ 18 mL/s
8-second pulse = 144 mL
20-second cap = 360 mL (hard limit)
Daily cap for a 15 cm pot (~1.7 L soil):
a full re-wet is about 400 mL
set the daily cap at 500 mL — enough for one
full watering, not enough to drown it twice.
Evapotranspiration threshold shift
Baseline threshold T₀ = 30 % VWC at 25 °C, 50 %RH
T = T₀ + 0.4 × (temp_C − 25) − 0.05 × (rh − 50)
35 °C, 30 %RH:
T = 30 + 0.4 × 10 − 0.05 × (−20)
= 30 + 4 + 1 = 35 %
18 °C, 70 %RH:
T = 30 + 0.4 × (−7) − 0.05 × 20
= 30 − 2.8 − 1 = 26.2 %
The plant is kept at a similar water stress level
rather than a similar sensor reading.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Calibrate each probe before writing any control logic
Every probe is different and every soil is different. This routine records the two endpoints and stores them, and it is the difference between a system that works and one that waters at random.
cpp01-probe-calibrate.ino#include <Preferences.h> Preferences prefs; const uint8_t PROBE_PIN[4] = { 34, 35, 36, 39 }; uint16_t calDry[4], calWet[4]; uint16_t probeRaw(uint8_t zone) { uint32_t acc = 0; for (int i = 0; i < 32; i++) { acc += analogRead(PROBE_PIN[zone]); delay(2); } return acc / 32; // the ESP32 ADC is noisy; average hard } float probePercent(uint8_t zone) { int span = (int)calDry[zone] - (int)calWet[zone]; if (span < 200) return -1; // calibration is nonsense or missing float pct = (calDry[zone] - (float)probeRaw(zone)) * 100.0f / span; return pct < 0 ? 0 : (pct > 100 ? 100 : pct); } void calibrateZone(uint8_t zone) { Serial.printf("Zone %d: hold the probe in DRY AIR, then press Enter\n", zone); while (!Serial.available()) delay(50); while (Serial.available()) Serial.read(); calDry[zone] = probeRaw(zone); Serial.printf("Zone %d: insert into THOROUGHLY SOAKED soil, press Enter\n", zone); while (!Serial.available()) delay(50); while (Serial.available()) Serial.read(); calWet[zone] = probeRaw(zone); char kd[8], kw[8]; snprintf(kd, sizeof(kd), "d%u", zone); snprintf(kw, sizeof(kw), "w%u", zone); prefs.putUShort(kd, calDry[zone]); prefs.putUShort(kw, calWet[zone]); Serial.printf("Zone %d calibrated: dry=%u wet=%u span=%d\n", zone, calDry[zone], calWet[zone], (int)calDry[zone] - (int)calWet[zone]); if ((int)calDry[zone] - (int)calWet[zone] < 400) Serial.println(" WARNING: span too small — probe may be faulty or not inserted"); }analogRead averaged 32 timesThe ESP32 SAR ADC has significant noise and non-linearity. A single reading can swing 3–5 %, which on a 30 % threshold is enough to cause spurious watering. Averaging 32 samples costs 64 ms and removes the problem.span < 200 → return −1A probe that is not inserted, disconnected, or dead produces a tiny span between its two calibration points. Returning an explicit error rather than a plausible percentage stops the controller acting on nonsense.calDry > calWetCapacitive probes read lower when wet, which is the opposite of the resistive probes people are used to. Getting the direction wrong produces a system that waters when the soil is already saturated."thoroughly soaked soil", not waterCalibrating the wet point in a glass of water gives a value the probe will never see in soil, which compresses the useful range. Saturate an actual pot and let it drain for a minute.Bound the pump in a way the control logic cannot override
The pump guard is deliberately written as a separate module with its own timer. If the decision logic has a bug and asks for water forever, the guard still stops at twenty seconds.
cpp02-pump-guard.inoconst uint8_t PUMP_PIN[4] = { 25, 26, 14, 12 }; #define PIN_FLOAT 32 #define MAX_PULSE_MS 20000UL #define DAILY_CAP_ML 500 #define PUMP_ML_PER_SEC 18.0f struct Zone { uint32_t pumpStart; // 0 when idle uint32_t requestedMs; uint32_t soakUntil; uint16_t mlToday; } zones[4]; bool reservoirHasWater() { // Float switch wired normally-CLOSED: a cut wire reads empty. Fail safe. return digitalRead(PIN_FLOAT) == LOW; } bool pumpRequest(uint8_t z, uint32_t ms) { if (zones[z].pumpStart) return false; // already running if (millis() < zones[z].soakUntil) return false; // still soaking if (!reservoirHasWater()) return false; // dry reservoir if (ms > MAX_PULSE_MS) ms = MAX_PULSE_MS; // hard clamp uint16_t wouldAdd = (uint16_t)(ms / 1000.0f * PUMP_ML_PER_SEC); if (zones[z].mlToday + wouldAdd > DAILY_CAP_ML) return false; zones[z].pumpStart = millis(); zones[z].requestedMs = ms; digitalWrite(PUMP_PIN[z], HIGH); return true; } // Called every loop. This is the only place a pump is ever switched off, // and it stops on time regardless of what the control logic believes. void pumpService() { for (uint8_t z = 0; z < 4; z++) { if (!zones[z].pumpStart) continue; uint32_t ran = millis() - zones[z].pumpStart; bool stop = ran >= zones[z].requestedMs || ran >= MAX_PULSE_MS || !reservoirHasWater(); if (stop) { digitalWrite(PUMP_PIN[z], LOW); zones[z].mlToday += (uint16_t)(ran / 1000.0f * PUMP_ML_PER_SEC); zones[z].soakUntil = millis() + 30UL * 60UL * 1000UL; // 30 min soak zones[z].pumpStart = 0; } } }Float switch normally closedA cut or disconnected float wire reads the same as an empty reservoir, so the failure mode is a pump that refuses to run rather than one that runs dry. Running a diaphragm pump dry destroys it within minutes.Three independent stop conditionsRequested duration, absolute cap, and reservoir state. Any one of them stops the pump. A bug in the control logic can only ever cause under-watering, never a flood.soakUntil set on stop, not on startThe soak timer begins when watering ends, which is when redistribution actually starts. Starting it at pump-on would shorten the effective soak by the pulse length.mlToday accumulated from actual run timeVolume is credited from how long the pump really ran, not from what was requested — so a pulse cut short by an empty reservoir does not consume the daily budget.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
/* ═══════════════════════════════════════════════════════════════
Smart Plant Irrigation — ESP32 + capacitive probes + pumps
Four independent zones, each with its own two-point calibration,
moisture band and plant profile. Watering is a bounded pulse
followed by a mandatory 30-minute soak, guarded by a hard pulse
limit, a daily volume cap and a reservoir float switch.
══════════════════════════════════════════════════════════════════ */
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <DHT.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <time.h>
#define WIFI_SSID "YOUR_WIFI"
#define WIFI_PASS "YOUR_PASSWORD"
#define MQTT_HOST "192.168.1.50"
#define DEVICE_ID "irrigation-indoor"
#define PIN_DHT 27
#define PIN_FLOAT 32
#define PIN_BTN 33
#define PIN_LED 2
#define N_ZONES 4
#define MAX_PULSE_MS 20000UL
#define SOAK_MS (30UL * 60UL * 1000UL)
#define DAILY_CAP_ML 500
#define PUMP_ML_PER_SEC 18.0f
const uint8_t PROBE_PIN[N_ZONES] = { 34, 35, 36, 39 };
const uint8_t PUMP_PIN[N_ZONES] = { 25, 26, 14, 12 };
/* Plant profiles: low and high moisture band, and pulse length. */
struct Profile { const char *name; uint8_t low, high; uint16_t pulseMs; };
const Profile PROFILES[] = {
{ "succulent", 15, 30, 4000 },
{ "herb", 30, 55, 8000 },
{ "houseplant",30, 55, 8000 },
{ "fern", 45, 70, 10000 },
{ "seedling", 40, 60, 3000 },
};
struct Zone {
uint8_t profile;
uint16_t calDry, calWet;
uint32_t pumpStart, requestedMs, soakUntil;
uint16_t mlToday;
float lastPct, pctBeforeWater;
bool enabled;
} zones[N_ZONES];
DHT dht(PIN_DHT, DHT22);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
WiFiClient net;
PubSubClient mqtt(net);
Preferences prefs;
float airTemp = 25, airRh = 50;
int lastCapDay = -1;
/* ── probes ─────────────────────────────────────────────────── */
uint16_t probeRaw(uint8_t z) {
uint32_t acc = 0;
for (int i = 0; i < 32; i++) { acc += analogRead(PROBE_PIN[z]); delay(2); }
return acc / 32;
}
float probePercent(uint8_t z) {
int span = (int)zones[z].calDry - (int)zones[z].calWet;
if (span < 200) return -1;
float pct = (zones[z].calDry - (float)probeRaw(z)) * 100.0f / span;
return pct < 0 ? 0 : (pct > 100 ? 100 : pct);
}
/* ── evapotranspiration-adjusted threshold ──────────────────── */
float thresholdFor(uint8_t z) {
const Profile &p = PROFILES[zones[z].profile];
float t = p.low + 0.4f * (airTemp - 25.0f) - 0.05f * (airRh - 50.0f);
if (t < p.low - 8) t = p.low - 8; // clamp the compensation
if (t > p.low + 8) t = p.low + 8;
return t;
}
/* ── pump guard ─────────────────────────────────────────────── */
bool reservoirHasWater() { return digitalRead(PIN_FLOAT) == LOW; }
bool pumpRequest(uint8_t z, uint32_t ms) {
Zone &Z = zones[z];
if (!Z.enabled || Z.pumpStart) return false;
if (millis() < Z.soakUntil) return false;
if (!reservoirHasWater()) return false;
if (ms > MAX_PULSE_MS) ms = MAX_PULSE_MS;
uint16_t add = (uint16_t)(ms / 1000.0f * PUMP_ML_PER_SEC);
if (Z.mlToday + add > DAILY_CAP_ML) return false;
Z.pctBeforeWater = Z.lastPct;
Z.pumpStart = millis();
Z.requestedMs = ms;
digitalWrite(PUMP_PIN[z], HIGH);
digitalWrite(PIN_LED, HIGH);
return true;
}
void publishEvent(uint8_t z, uint32_t ranMs, uint16_t ml, const char *why);
void pumpService() {
bool any = false;
for (uint8_t z = 0; z < N_ZONES; z++) {
Zone &Z = zones[z];
if (!Z.pumpStart) continue;
any = true;
uint32_t ran = millis() - Z.pumpStart;
const char *why = nullptr;
if (!reservoirHasWater()) why = "reservoir-empty";
else if (ran >= MAX_PULSE_MS) why = "hard-limit";
else if (ran >= Z.requestedMs) why = "complete";
if (!why) continue;
digitalWrite(PUMP_PIN[z], LOW);
uint16_t ml = (uint16_t)(ran / 1000.0f * PUMP_ML_PER_SEC);
Z.mlToday += ml;
Z.soakUntil = millis() + SOAK_MS;
Z.pumpStart = 0;
publishEvent(z, ran, ml, why);
}
if (!any) digitalWrite(PIN_LED, LOW);
}
/* ── MQTT ───────────────────────────────────────────────────── */
void publishEvent(uint8_t z, uint32_t ranMs, uint16_t ml, const char *why) {
JsonDocument d;
d["zone"] = z;
d["plant"] = PROFILES[zones[z].profile].name;
d["ran_ms"] = ranMs;
d["ml"] = ml;
d["ml_today"]= zones[z].mlToday;
d["before"] = roundf(zones[z].pctBeforeWater);
d["reason"] = why;
char b[224]; size_t n = serializeJson(d, b, sizeof(b));
mqtt.publish("home/irrigation/" DEVICE_ID "/event", (uint8_t *)b, n, false);
Serial.printf("zone %d watered %u ms (%u mL) — %s\n", z, (unsigned)ranMs, ml, why);
}
void publishState() {
JsonDocument d;
d["temp"] = roundf(airTemp * 10) / 10.0f;
d["rh"] = roundf(airRh);
d["reservoir"] = reservoirHasWater() ? "ok" : "empty";
JsonArray zs = d["zones"].to<JsonArray>();
for (uint8_t z = 0; z < N_ZONES; z++) {
JsonObject o = zs.add<JsonObject>();
o["pct"] = roundf(zones[z].lastPct);
o["target"] = roundf(thresholdFor(z));
o["ml_today"] = zones[z].mlToday;
o["plant"] = PROFILES[zones[z].profile].name;
o["soaking"] = millis() < zones[z].soakUntil;
}
char b[512]; size_t n = serializeJson(d, b, sizeof(b));
mqtt.publish("home/irrigation/" DEVICE_ID "/state", (uint8_t *)b, n, true);
}
void onMessage(char *topic, byte *payload, unsigned int len) {
JsonDocument d;
if (deserializeJson(d, payload, len)) return;
int z = d["zone"] | -1;
if (z < 0 || z >= N_ZONES) return;
const char *action = d["action"] | "";
if (!strcmp(action, "water")) {
zones[z].soakUntil = 0; // manual overrides the soak
pumpRequest(z, d["ms"] | 5000);
} else if (!strcmp(action, "enable")) {
zones[z].enabled = d["value"] | true;
} else if (!strcmp(action, "profile")) {
uint8_t p = d["value"] | 2;
if (p < sizeof(PROFILES) / sizeof(PROFILES[0])) {
zones[z].profile = p;
char k[8]; snprintf(k, sizeof(k), "p%u", z);
prefs.putUChar(k, p);
}
}
}
/* ── display ────────────────────────────────────────────────── */
void draw() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1);
for (uint8_t z = 0; z < N_ZONES; z++) {
oled.setCursor(0, z * 12);
if (zones[z].lastPct < 0) oled.printf("Z%d --- probe fault", z + 1);
else oled.printf("Z%d %3.0f%% / %2.0f%% %s", z + 1, zones[z].lastPct,
thresholdFor(z),
zones[z].pumpStart ? "PUMP"
: millis() < zones[z].soakUntil ? "soak" : "");
}
oled.setCursor(0, 52);
oled.printf("%.0fC %.0f%%RH tank %s", airTemp, airRh,
reservoirHasWater() ? "ok" : "EMPTY");
oled.display();
}
/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
Serial.begin(115200);
pinMode(PIN_FLOAT, INPUT_PULLUP);
pinMode(PIN_BTN, INPUT_PULLUP);
pinMode(PIN_LED, OUTPUT);
for (uint8_t z = 0; z < N_ZONES; z++) {
pinMode(PUMP_PIN[z], OUTPUT);
digitalWrite(PUMP_PIN[z], LOW);
analogSetPinAttenuation(PROBE_PIN[z], ADC_11db);
}
dht.begin();
Wire.begin(21, 22);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
prefs.begin("irrig", false);
for (uint8_t z = 0; z < N_ZONES; z++) {
char kd[8], kw[8], kp[8];
snprintf(kd, sizeof(kd), "d%u", z);
snprintf(kw, sizeof(kw), "w%u", z);
snprintf(kp, sizeof(kp), "p%u", z);
zones[z].calDry = prefs.getUShort(kd, 3180);
zones[z].calWet = prefs.getUShort(kw, 1420);
zones[z].profile = prefs.getUChar(kp, 2);
zones[z].enabled = true;
}
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
configTime(19800, 0, "pool.ntp.org");
mqtt.setServer(MQTT_HOST, 1883);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(768);
Serial.println("Irrigation controller ready");
}
void loop() {
if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) {
if (mqtt.connect(DEVICE_ID)) mqtt.subscribe("home/irrigation/" DEVICE_ID "/cmd");
}
mqtt.loop();
pumpService(); // must run every loop — it stops the pumps
static uint32_t lastSlow = 0;
if (millis() - lastSlow < 60000) { // decide once a minute; soil is slow
if (!digitalRead(PIN_BTN)) { pumpRequest(0, 3000); delay(400); }
return;
}
lastSlow = millis();
float t = dht.readTemperature(), h = dht.readHumidity();
if (!isnan(t)) airTemp = t;
if (!isnan(h)) airRh = h;
time_t now = time(nullptr);
struct tm tmv; localtime_r(&now, &tmv);
if (tmv.tm_yday != lastCapDay) { // reset the daily cap
lastCapDay = tmv.tm_yday;
for (uint8_t z = 0; z < N_ZONES; z++) zones[z].mlToday = 0;
}
for (uint8_t z = 0; z < N_ZONES; z++) {
zones[z].lastPct = probePercent(z);
if (zones[z].lastPct < 0) continue; // probe fault, do nothing
if (zones[z].lastPct < thresholdFor(z))
pumpRequest(z, PROFILES[zones[z].profile].pulseMs);
}
draw();
publishState();
}
Configuration & Calibration
Configuration steps
- Calibrate every probe individually before enabling automatic watering. Run the calibration sketch, record the dry and wet raw values, and confirm the span exceeds 400 counts.
- Choose a plant profile per zone. Succulents at 15–30 % and ferns at 45–70 % are genuinely different systems and sharing one threshold guarantees one of them suffers.
- Measure your pump's real flow rate at your real head height and set
PUMP_ML_PER_SECaccordingly. The rated figure assumes zero head and is optimistic by 30–50 %. - Set
DAILY_CAP_MLfrom pot volume. A useful rule is one full re-wet per day maximum — roughly 25 % of the soil volume in millilitres. - Set the soak period to at least 30 minutes. Shorter is the single most common cause of over-watering in these builds.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
Two-point probe calibration
Record the raw ADC in dry air, then in soil you have saturated and allowed to drain for one minute. Store both. A span below 400 counts means the probe is faulty, not inserted to the marked depth, or has water in its electronics.
Measure real pump flow
Run the pump into a measuring jug for exactly 30 seconds at the actual installed height. Divide by 30 for millilitres per second. Do this per zone if the drippers differ, since dripper restriction changes flow substantially.
Tune pulse length empirically
Water once, then log the moisture reading every five minutes for an hour. If the reading overshoots the upper band, the pulse is too long; if it barely moves, too short. Two iterations gets it right for that pot.
Verify the threshold against the plant
The numbers are a proxy. After a fortnight, check whether the plant actually looks right. Drooping between waterings means the lower threshold is too low; consistently soggy topsoil means it is too high.
Network Architecture & Connectivity
| Topic / endpoint | Direction | Payload |
|---|---|---|
home/irrigation/<id>/state | device → broker (retained) | JSON: temp, rh, reservoir, zones[] with pct, target, ml_today, plant, soaking |
home/irrigation/<id>/event | device → broker | JSON: zone, plant, ran_ms, ml, before, reason |
home/irrigation/<id>/cmd | broker → device | JSON: zone + action = water | enable | profile |
Message contract between the device and the broker.
Dashboard setup
The chart worth building is soil moisture with watering events marked. A healthy zone shows a sawtooth: a sharp rise on watering, then a gentle decline over two to five days. A zone that never drops is over-watered; one that falls off a cliff has a probe that has come loose.
Plot millilitres per day against air temperature over a season and you get your plants' actual water demand curve, which is a far better basis for a holiday watering schedule than guesswork.
Security considerations
- The command topic can run pumps. Use broker authentication — an open topic means anyone on the network can flood your floor.
- Keep the hard guards in firmware, not in the automation layer. A Home Assistant automation with a bug should not be able to bypass the pulse limit.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Read a probe in dry air and in wet soil | A difference of at least 400 ADC counts, with wet reading lower than dry. |
| Check calibrated percentages | Dry air about 0 %, saturated soil about 100 %, a normally damp pot somewhere between 35 % and 60 %. |
| Trigger a manual water | The pump runs for the profile pulse length and stops precisely, with the LED following it. |
| Lift the float switch to simulate an empty reservoir | The pump stops mid-pulse and an event is published with reason reservoir-empty. |
| Disconnect a probe | That zone displays "probe fault" and is skipped entirely — no watering, no guessing. |
| Request watering twice in quick succession | The second request is refused because the soak timer is running. |
| Request repeatedly for a whole day | Watering stops once the daily cap is reached and resumes after midnight. |
| Watch a full cycle over 48 hours | A clear sawtooth in the moisture chart — sharp rise, slow decline — and no back-to-back waterings. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Decide once a minute, not once a loop. Soil moisture changes over hours; sampling faster adds ADC noise and nothing else.
- Average 32 ADC samples per probe. The ESP32 ADC is noisy enough that a single reading can move a decision across a threshold.
- Keep the pump guard in the fast loop and the decision logic in the slow one. Mixing the two rates is how over-runs happen.
- Replace every
delay()with amillis()comparison — blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
- Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
- For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- Water and mains do not mix. Keep the controller and its supply above the maximum possible water level, and site the reservoir where an overflow drains somewhere harmless.
- Assume a tube will come off eventually. Stand every pot in a tray and route tubing so a failure spills into it.
- Never run a diaphragm pump dry — it destroys the membrane within minutes. The float switch is protecting the pump as much as the plant.
- Moving parts pinch. Keep fingers, cables and hair out of gear trains and wheels, and always test motion with the drivetrain unloaded and the robot on blocks first.
- Motors are inductive — always fit a flyback diode across a DC coil, or use a driver that already has one, or the back-EMF spike will destroy your GPIO.
- Water and electronics: mount all boards above the maximum possible water line, use drip loops on every cable, and pressure-test plumbing before wiring anything up.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Flush the tubing and drippers every three months; algae and mineral deposits block adjustable drippers first.
- Wipe the probes and check the seal at the coating line whenever you repot.
- Refill the reservoir before it triggers the float switch — repeated dry-run stops shorten pump life.
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Clean the sensing element on a schedule. Optical and electrochemical sensors foul, and a fouled sensor reports plausible nonsense rather than failing outright.
- Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add a flow sensor in the delivery line so the controller measures the water it actually delivered rather than estimating from run time. That catches blocked drippers and burst tubes immediately.
- Add a load cell under the pot. Pot weight is the single most accurate proxy for total water content, and it is immune to probe placement and soil variation.
- Add light measurement and use it in the evapotranspiration model — light drives transpiration more strongly than temperature does.
- Add a nutrient dosing pump for hydroponics or fertigation, with its own volume cap and interlock.
- Add solar and battery so a balcony system needs no mains at all.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add over-the-air firmware updates so you never have to physically reach a deployed node again.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- DFRobot capacitive soil moisture sensor v2.0 — wiki and calibration guidanceDFRobot
- ESP32 ADC — ADC1 versus ADC2 and the Wi-Fi conflictEspressif
- Soil water content measurement methods — a review of dielectric techniquesScienceDirect
- FAO Irrigation and Drainage Paper 56 — crop evapotranspirationFAO
- IRLZ44N logic-level N-channel MOSFET — datasheetInfineon
- Root rot and over-watering in container plantsPenn State Extension