Community project
Automated Plant Watering
This project builds an automated watering system that monitors soil moisture and water availability, then activates a pump to keep plants hydrated. The ESP32 reads a capacitive soil sensor to detect when watering is needed and uses an ultrasonic distance sensor to ensure the water bucket hasn't run dry before dispensing.
The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions with emphasis on electrical safety around water. Firmware with WiFi connectivity and safety lockouts is included, along with calibration procedures and testing protocols to verify the system operates reliably before leaving it unattended.
Wiring diagram
Gather all the parts
Assemble it in 6 steps
1. Keep electricity away from water
Unplug all supplies. Use only the specified low-voltage DC pump, an isolated enclosed 12 V adapter, and an active-low 5 V relay module with onboard coil protection. Keep the ESP32, relay, connections and adapter dry and above the bucket.
- Never wire mains electricity to this project. Do not power the pump from ESP32 or USB.
2. Connect the soil probe and distance sensor
Soil VCC to 3V3, GND to GND, AOUT to GPIO0 (soil reading). Sonar VCC to 5V, GND to GND, TRIG to GPIO6 (measurement start). ECHO goes through the 10 kohm top resistor to GPIO7; the 15 kohm bottom resistor joins GPIO7 to GND. Mount sonar facing straight down at the water, above splash level, with at least 2 cm clearance at full water.
- Never connect a 5 V ECHO directly to the ESP32. The HC-SR04 is not waterproof; condensation and angled surfaces can cause faults.
3. Connect the protected relay input
Relay VCC to USB 5V and GND to ESP32 GND. GPIO10 connects through the 2.2 kohm resistor to transistor base B. Connect 100 kohm from B to GND, emitter E to GND, collector C to relay IN, and 10 kohm from IN to 5V. For the specified onsemi 2N3904, pins are 1 emitter, 2 base, 3 collector; check its package drawing before insertion. This makes GPIO10 HIGH turn the pump on while keeping 5 V away from the ESP32.
4. Connect pump power
Adapter positive to inline 1 A fuse, fuse output to relay COM, relay NO to pump positive. Pump negative to adapter negative. Leave NC unused. Fit diode across pump terminals, striped end to positive and unstriped end to negative. Keep this 12 V circuit separate from ESP32 rails. Use a pump rated no more than 500 mA running and below 1 A at startup. Use secure screw connections for motor current.
- A reversed diode can short the supply. Keep pump submerged as its maker requires. Keep the hose outlet above the bucket's maximum water line to avoid continuous siphoning.
5. Calibrate before connecting pump power
Leave 12 V disconnected. Observe soil millivolts with the probe in dry soil and watered, drained soil. Record these as SOIL_DRY_MV and SOIL_WET_MV in config.h. Measure distance from sonar face to water at the lowest safe level while the pump inlet remains submerged; set LOW_WATER_CM and MAX_BUCKET_CM for your bucket. Default values are examples, not calibrated measurements. Set CALIBRATED true only after checking them.
6. Test the cutoff before unattended use
With pump supply disconnected, check relay stays off at startup and clicks for five seconds only with dry soil and enough water. Test low water and blocked/unplugged sonar: both must latch watering off. After refilling or repair, send capital R over the serial monitor to clear the lock; a power cycle does not clear it. Confirm an ntfy alert arrives before relying on notifications. Connect pump power only after these checks and supervise initial watering.
- Set Wi-Fi, HTTPS ntfy topic URL, access token and the server's trusted root CA certificate in src/config.h; subscribe to the same topic in the ntfy app. Token alone does not identify a topic. Alerts retry after network failures, but cannot arrive without working Wi-Fi and ntfy.
- A steady distance can be normal in a large bucket. This build uses a measured low-water limit, not lack of movement. It cannot detect every stuck-but-plausible sensor reading or a broken hose.
Review all connections
1. Connections between "soil" and "ESP32"
2. Connections between "sonar" and "ESP32"
3. Connections between "echo_top" and "ESP32"
4. Connections between "echo_bottom" and "ESP32"
5. Connections between "relay" and "ESP32"
6. Connections between "pullup" and "ESP32"
7. Connections between "q1" and "ESP32"
8. Connections between "base" and "ESP32"
9. Connections between "pulldown" and "ESP32"
10. Connections between "supply" and "ESP32"
11. Connections between "fuse" and "ESP32"
12. Connections between "pump" and "ESP32"
13. Connections between "diode" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <time.h>
#include "config.h"
constexpr int SOIL_PIN=0, TRIG_PIN=6, ECHO_PIN=7, RELAY_PIN=10;
constexpr uint32_t WAIT_MS=180000, RUN_MS=5000;
Preferences prefs;
bool locked=false, pending=false;
String alert;
uint32_t lastCheck=0, lastNotify=0;
bool first=true;
void lockWater(const char *why) {
digitalWrite(RELAY_PIN, LOW);
if (!locked) {
locked=true; prefs.putBool("locked",true);
alert=why; prefs.putString("reason",alert); pending=true; prefs.putBool("pending",true);
Serial.println(alert);
}
}
float distanceCm() {
float a[3];
for(int i=0;i<3;i++) {
digitalWrite(TRIG_PIN,LOW); delayMicroseconds(3);
digitalWrite(TRIG_PIN,HIGH); delayMicroseconds(10); digitalWrite(TRIG_PIN,LOW);
unsigned long us=pulseIn(ECHO_PIN,HIGH,30000);
a[i]=us/58.0f;
if(!us || a[i]<2 || a[i]>MAX_BUCKET_CM) return -1;
delay(65);
}
if(a[0]>a[1]) {float t=a[0];a[0]=a[1];a[1]=t;}
if(a[1]>a[2]) {float t=a[1];a[1]=a[2];a[2]=t;}
if(a[0]>a[1]) {float t=a[0];a[0]=a[1];a[1]=t;}
return a[1];
}
bool safeWater() {
float cm=distanceCm(); Serial.printf("Water distance %.1f cm\n",cm);
if(cm<0) {lockWater("Watering stopped: ultrasonic sensor reading invalid. Check sensor and bucket, then reset lock.");return false;}
if(cm>=LOW_WATER_CM) {lockWater("Watering stopped: bucket water is low. Refill bucket, then reset lock.");return false;}
return true;
}
void notify() {
if(!pending || WiFi.status()!=WL_CONNECTED || !strlen(NTFY_TOKEN) || !strlen(NTFY_CA_PEM)) return;
if(time(nullptr)<1700000000) return;
if(lastNotify && millis()-lastNotify<60000) return;
lastNotify=millis();
WiFiClientSecure tls; tls.setCACert(NTFY_CA_PEM); tls.setHandshakeTimeout(5);
HTTPClient http; http.setConnectTimeout(5000); http.setTimeout(5000);
if(!String(NTFY_URL).startsWith("https://") || !http.begin(tls,NTFY_URL)) return;
http.addHeader("Authorization",String("Bearer ")+NTFY_TOKEN);
http.addHeader("Title","Plant watering stopped");
http.addHeader("Priority","high");
int code=http.POST(alert); http.end();
Serial.printf("ntfy HTTP status: %d\n",code);
if(code>=200 && code<300) {pending=false; prefs.putBool("pending",false);}
}
void setup() {
digitalWrite(RELAY_PIN,LOW); pinMode(RELAY_PIN,OUTPUT);
pinMode(TRIG_PIN,OUTPUT); digitalWrite(TRIG_PIN,LOW); pinMode(ECHO_PIN,INPUT);
Serial.begin(115200); analogReadResolution(12); analogSetPinAttenuation(SOIL_PIN,ADC_11db);
prefs.begin("watering",false); locked=prefs.getBool("locked",false);
pending=prefs.getBool("pending",false); alert=prefs.getString("reason","Watering locked. Inspect bucket and sensor.");
WiFi.setAutoReconnect(true);
if(strlen(WIFI_SSID)) {WiFi.begin(WIFI_SSID,WIFI_PASSWORD);configTime(0,0,"pool.ntp.org","time.nist.gov");}
Serial.println("Pump defaults OFF. Send R over serial after refill/repair to clear safety lock. Calibration must be enabled in config.h.");
// Full soak delay on every boot prevents rapid watering after power interruptions.
lastCheck=millis();
}
void loop() {
if(Serial.available() && Serial.read()=='R') {
if(CALIBRATED && safeWater()) {locked=false;pending=false;prefs.putBool("locked",false);prefs.putBool("pending",false);lastCheck=millis();Serial.println("Lock cleared; waiting 180 seconds.");}
}
// All network operations happen with pump OFF.
notify();
if(millis()-lastCheck<WAIT_MS) {delay(10);return;}
lastCheck=millis();
long mv=0;for(int i=0;i<16;i++){mv+=analogReadMilliVolts(SOIL_PIN);delay(3);}mv/=16;
Serial.printf("Soil: %ld mV\n",mv);
if(!CALIBRATED) {Serial.println("Calibration required; pump disabled.");return;}
if(locked) return;
if(SOIL_DRY_MV<=SOIL_WET_MV || mv<100 || mv>3200) {lockWater("Watering stopped: check soil probe connection or calibration.");return;}
if(!safeWater()) return;
int moisture=constrain((SOIL_DRY_MV-mv)*100/(SOIL_DRY_MV-SOIL_WET_MV),0L,100L);
Serial.printf("Moisture: %d percent\n",moisture);
if(moisture<DRY_BELOW_PERCENT) {
digitalWrite(RELAY_PIN,HIGH); // NPN pulls active-low relay input down.
delay(RUN_MS); // No networking or measurements can extend this run.
digitalWrite(RELAY_PIN,LOW);
lastCheck=millis(); // At least 180 seconds from pump OFF before soil recheck.
}
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




