1. Required hardware and knowledge
- Capacitive moisture sensors
- 74HC4067 16-channel multiplexer mounted on a breakout board
- AC mains voltage solenoid or servo water valve
- 3V relay mounted on a breakout board (one per water valve)
- ESP-12E development board
- Enough multi-core cable to connect your sensors. Cat-5 networking cable will do fine.
For this tutorial, you should already know how to compile and upload a program to your ESP-12E.
2. Different types of soil moisture sensors
Resistive soil moisture sensors are simply two metal electrodes positioned in soil electrolyte. A current is passed through the soil and the impedance is measured. There are many cheap examples available on aliexpress and ebay. These sensors are the least preferred, because the voltage between the two terminals forms an electrochemical cell and the anode terminal corrodes away, polluting the soil. Their life expectancy is mere months.
Capacitive soil moisture sensors are superior, as they can remain in service for years if you don't scratch the PCB's insulative solder mask, and they do not corrode into the soil. Two capacitor plates are etched into the sensor PCB, and the soil acts as the capacitor dielectric. Like all soil sensors, they require calibration in your soil; the dielectric properties of your wet soil will be different to other people's soil. You should also re-calibrate your system from time to time when you alter the dielectric properties of the soil by adding fertilizer, pH corrector, etc.
Finally, it's worth noting resistive plaster sensors. They measure resistance between stainless steel screws encased in gypsum (porus plaster). These sensors are designed to be buried at various depths underground near grapevines' root systems (to measure water penetration so as to save water by avoiding excessive irrigation). The advantage of these sensors is that when the electrodes are encased in plaster, the resistance is fairly independent of soil type, so they don't need calibration when being installed in soil for the first time. They do, of course, require calibration before installation, and may require recalibration later when fertilizer and salt ions leach into the plaster. The disadvantage of these sensors is their hysteresis; they take a while to get wet and return a useful reading.
3. How capacitive soil moisture sensors work
Like all our tutorials, we want to use inexpensive commodity hardware so that we can get our data without having to surmount budgetary constraints, and find replacement parts easily. Our particular model sensor of choice is widely-available from aliexpress and ebay, based on a design by DFRobot.
The capacitive sensor outputs a DC voltage that is proportional to the change in capacitance. It does this by incorporating this capacitance into an oscillator circuit whose frequency changes in proportion to the change in probe capacitance, and then converts the outputed PWM square wave to a smoothed DC voltage (whose voltage is proportional to the pulse width) via a diode and slowly discharging capacitor. In our model, a NE555 integrated circuit is used as the oscillator. There's also a voltage regulator, meaning it can run using VCC
of both 5V and 3.3V.
You may ask yourself why we can't just use two spare pieces of PCB as the capacitor and move the measuring-and-conversion circuit to somewhere safe and dry? This requires long wires from the capacitor PCBs to the measuring circuit, and is error-prone due to the stray capacitance of the wires. To deal with the exposed electronics, just enclose the electronics end in a watertight plastic food container or equivalent, sealed with silicone putty. Include a packet of silica gel dessicant to absorb water vapor condensation. You could also seal the PCB in epoxy or something permanent at the expense of cutting, soldering, and resealing wires if you need to replace the PCB after a few seasons.
Make sure your sensor's solder mask (the insulating film covering the board) is waterproof by soaking in a glass of water for a few hours without wetting the electronics. If capacitance is zero, you have a short circuit between the two. In that regard, be careful not to physically abrade the solder mask when pushing the sensor into the soil.
4. Connecting a simple soil moisture monitor circuit
Connect your VCC
, GND
and AOUT
pins to your ESP8266's 3V
, GND
and as per this diagram:
The ESP8266-12E is supposed to be able to take an input voltage of up to 3.3V on its A0
pin. The actual ESP8266 chip can only handle 1V, but you'll notice the two resistors labelled R1
and R2
near the A0
pin actually form a 220k/100k voltage divider.
The ESP8266 chip's analog-to-digital convertor (ADC) then samples this at 10-bit resolution, meaning it returns a reading between 0 and 1023.
Put your probe in the soil when it's a little too dry, and take note of the reading. Then, make the necessary adjustment to SOIL_THRESHOLD_LEVEL
in the below code to get a printed warning in your serial monitor.
# define SOIL_THRESHOLD_LEVEL your_value_goes_here
void setup() {}
void loop() {
uint16_t level = analogRead(A0);
if(level > SOIL_THRESHOLD_LEVEL) {
Serial.print("Threshold triggered with level="); Serial.println(level);
} else {
Serial.print("level="); Serial.println(level);
}
delay(3000); // repeat every 3s
}
You'll note that we're providing a 5V DC power source, but both devices run natively off 3V. Our intention is to trigger the AC mains voltage irrigation system when the soil is drier than SOIL_THRESHOLD_LEVEL
using relays. Though the relays trigger at 3V input, they require a 5V power supply, so for this reason we power our system with VCC
of 5V. Let's look at this system in the next section.
5. Connecting a medium scale soil moisure sensor array
We've understood the principles of operation through the prototype above; now let's make a proper system like in a greenhouse, with multiple probes, internet-connection, and a relay to power the watering system automatically.
The first thing to note is that the ESP8266 has a single analog pin but many digital pins, so if we want to read several probes from a single microcontroller, we'll need a way to read those analog signals sequentially. To do this, we'll use the 74HC4067 16-channel analog multiplexer chip (or "mux" as they're commonly known). They're available on breakout boards, ready for soldering to your probes, for around $1-2 on ebay/aliexpress. The 8-channel 74HC4051 format is a drop-in replacement if you only intend to measure up to 8 signals ... but trust us, you want the 16-channel version as it's the same cost and you will be able to add other sensors and meters to your project later.
We will be using our 74HC4067 as a demultiplexer; selecing one of several analog channels by its binary address - for example, the sixth channel will be selected by setting the digital address pins S3/S2/S1/S0
to 0101
(5 in binary, because the first channel is 0000
). The common pin (labelled SIG
) is where the selected probe's signal is routed out to our ESP8266 analog pin A0
. The EN
pin disables all channels when high.
6. Connecting a relay to trigger the irrigation system
We're also going to want to trigger a watering system when the soil gets too dry. In this example, we will trigger a single relay which opens a solenoid valve (irrigating with mains water pressure just to illustrate the concept; you may choose to connect your relay to a servo valve or pump instead). Our solenoid valve example is normally closed, and opens when the solenoid is actuated. You'll need to anticipate how long you want to leave your water running so that you can buy a solenoid valve model that can withstand the rated current for the required length of time.
A relay is actuated by a solenoid coil, and we need a coil rated for 3V so it can be triggered by one of our ESP8266-12E's digital output pins.
Warning: Don't connect your microcontroller output pins directly to the relay. Inductors cannot change their current instantaneously, and induce a "flyback voltage" to counter the effect of sudden energization and de-energization of the relay coil.
A relay breakout board prevents damaging flyback voltages by arrangement of a diode and NPN transistor; these are available with the relay soldered to the board on ebay/aliexpress for under $2. Choose the appropriate number of relays and valves for your irrigation system, and then connect your system up as per the following diagram:
If you want more granular control over your irrigation, and you run out of digital output pins, you could use a second 74HC4067 as a multiplexer to several relays; as the 74HC4076 is bi-directional, you just connect your relays to your 16 channels, choose which channel you want with the S0/S1/S2/S3
pins, and drive the SIG/COM
pin high with your ESP8266.
Finally, we're going to connect our irrigation system to the internet so that we can monitor irrigation patterns and manually trigger the watering system remotely if we want. Let's see how to setup our dashboard in the WombatDashboard™ web interface:
7. Set up your dashboard
First, in your WombatDashboard™ account, add your ESP266 device from the side menu: Devices → "Add device" button.
- You'll need to give it a sensible name such as
irrigation1"
, and note its type (ESP8266
). - You can specify the maximum number of records to retrieve per API request (to prevent accidental large requests consuming your bandwidth). You also have the option of deleting old data automatically.
- Activate the device by clicking the "status" toggle on (it may take up to a minute for the MQTT server to start communicating with the device after activation), and specify if you wish to share the device data publicly. Note that if the device is not "active", it's saved data will still be stored, but the MQTT server won't be listening for new data. This allows you to debug any problems without polluting your dataset too much.
After clicking "Create", you'll note that it appears in the table of your devices. Here's how it works:
- You can assign your device to a group (for example "greenhouse2") if you wish.
- Click the toggles in the status and share columns to change these settings.
- The public identifier is how your devices is identified publicly.
- The private identifier is your device's secret "API key" that identifies it to the WombatDashboard servers. Don't share this, otherwise someone could impersonate your device.
- Data transfer and storage metrics are listed. Transfer is updated every minute, storage daily.
- There is an input field to manually send your device a message, for example tell it to close a relay or turn on a light on. Hint: make sure your device also has a command to open the relay and turn the light off!
- There three icons to edit and delete your device, and download the data for your specified date range in JSON or CSV formats.
Repeat this process for as many ESP8266 devices as you wish to set up.
Now that you've created your device, you can create a dashboard for it. Click the side menu: Dashboard → "Create Dashboard" button. You will see a new dashboard appear in the list. Give it a name, and choose if you want it to be publicly visible (for example, sharing with colleagues).
Then click your dashboard link to add widgets to see your data. The Widget wizard will pop up and guide you through adding widgets:
- First, choose your widget type. We'll choose a line chart so we can see when our irrigation system is on. Give the chart a sensible name, such as "Relay 1".
- Then, choose which device will supply the data (i.e. the "data source").
- After that, you'll have an option of supplying a date range, getting the last N days' data, or streaming live data as it comes in. You'll also need to choose the CSV index or JSON key of your data. In this example, we're sending JSON that looks like this:
{"relay1":1, "relay2":0}
- so we will specifyrelay1
as the JSON key to display. - If you want to display your second relay on the same chart, add it as a data source too (changing your key to
relay2
). Otherwise, click "Save widget" - You can use the icons and title bar of each widget to resize and reposition it on your dashboard. (In this example, we've filled the chart with random data.)
Now all you have to do is click that "Save dashboard" button. (Don't forget!)
Alright, our entire irrigation system is set up, and all that's left is to flash our program to our ESP8266 device. Let's look at the code now.
8. Optional: review the code
The files for this code are available at our github repository.
void setup() {
r1 = {NULL, D5, 0, NULL};
sprintf(r1.name, "relay1");
sprintf(r1.command, IRRIGATE_COMMAND_RELAY1);
r2 = {NULL, D6, 0, NULL};
sprintf(r2.name, "relay2");
sprintf(r2.command, IRRIGATE_COMMAND_RELAY2);
// set up the relevant digital pins to output mode
pinMode(ENABLE_PIN, OUTPUT);
digitalWrite(ENABLE_PIN, HIGH); // disactivate the mux inputs
for(Relay r: relays) {
pinMode(r.pin, OUTPUT);
}
for(auto p: muxPins) {
pinMode(p, OUTPUT);
}
setupWifi();
client.setServer("mqtt.wombatdashboard.com", 1883);
client.setCallback(callback);
client.subscribe(DEVICE_PRIVATE_IDENTIFIER);
// don't forget to set the baud rate to 9600 in your serial monitor
Serial.begin(9600);
}
The setup()
function is run once by the ESP8266 bootloader after power on. As mentioned previously, we set the Relay.name
and Relay.command
fields for each relay here (because we can't call sprintf()
outside the scope of a function in C++). We also tell our microcontroller than the relay and mux digital I/O pins should be set to output mode, and connect to the MQTT server.
void loop() {
// handle connectivity checking to MQTT broker
if (!client.connected()) {
reconnect();
}
client.loop(); // this maintains communication with the MQTT server
if(loop_counter > SOIL_READING_INTERVAL) {
for(Sensor s: sensors) {
convert_bits_to_pins(s.muxPins); // convert binary number i into pin representation for mux
delay(2); // allow all the transistors between MCU and sensor time to trigger
digitalWrite(ENABLE_PIN, LOW); // activate the mux
delay(2);
uint16_t level = analogRead(A0); // read the sensor's value
digitalWrite(ENABLE_PIN, HIGH); // disactivate the mux
if(level < s.soilThreshold) {
digitalWrite(s.relay->pin, HIGH);
// start the relay counter
s.relay->counter = 1;
char message[12];
sprintf(message, "{\"%s\":1}", s.relay->name);
client.publish(DEVICE_PRIVATE_IDENTIFIER, message, false);
char serialMessage[40];
sprintf(serialMessage, "%s level = %d, triggered %s", s.name, level, s.relay->name);
Serial.println(serialMessage);
} else {
char serialMessage[24];
sprintf(serialMessage, "%s level = %d", s.name, level);
Serial.println(serialMessage);
}
}
loop_counter = 0; // reset the loop counter
} else { // otherwise, if it's not time to take another soil reading yet
loop_counter++;
// traverse the relays to increment the counters for any acive relays
for(Relay r : relays) {
if(r.counter != 0) r.counter++; // increment counter for current relay
if(r.counter > MAX_RELAY_DURATION) {
// open the relay if it has been closed for longer than MAX_RELAY_DURATION
digitalWrite(r.pin, LOW);
r.counter = 0;
// construct json message to record that relay was switched off
char message[12];
sprintf(message, "{\"%s\":0}", r.name);
client.publish(DEVICE_PRIVATE_IDENTIFIER, message, false);
}
}
}
delay(1000); // delay a second
}
The loop()
function is run repeatedly by the ESP8266 bootloader after the setup()
function completes. It runs approximately once per second, and increments a loop_counter
variable too keep track of how long since we last polled the soil probes. If it's time to probe again, the probe's voltage level is < the soilThreshold
for that probe, we close its corresponding relay, start the relay counter, and send the MQTT server a message to record that we have started irrigating. If it's not yet time to take another soil reading, then we loop through the relays and increment the counters on any currently closed ones. If we need to turn any off, we set the corresponding pin LOW
and send a message to the MQTT server to record that we turned it off.
9. A note on calibrating your irrigation system
You must calibrate your sensors after installation, for two reasons:
- Water takes time to soak down into roots. If you position your sensor at the bottom of the root system, by the time the first drops soak down that low you may have watered too much, and the waterlogged soil above will continue to drain down past the roots, taking the nutrients with it. The easiest option is to just sense when the roots are too dry, water for a fixed period of time, and then take another measurement in an hour to see if it was enough. This is also the lowest-maintenance option, as it doesn't depend on precise calibration of your sensor, meaning you won't have to recalibrate as much when the seasons change. A bit of experimenting will soon give you a good idea how much to irrigate per burst (especially if you combine the watering system with a temperature and humidity sensor.
- The cables carrying the analog signals will have a resistance of roughly 1 Ohm per meter. The voltage drop will cause a signal to read low if you calibrate the
SOIL_THRESHOLD_LEVEL
prior to connecting the probe to the cable.
#include <ESP8266WiFi.h> #include <PubSubClient.h> // don't forget to install the MQTT library // delete this line and replace with your calibrated measurements for each sensor #define SOIL_THRESHOLD_LEVEL 500 #define SOIL_READING_INTERVAL 60 // In seconds. Check the soil once per minute. #define MAX_RELAY_DURATION 10 // In seconds. We'll allow the relay to close for for a maximum of ten seconds. // note: MAX_RELAY_DURATION should be < SOIL_READING_INTERVAL #define ENABLE_PIN D7 // the EN pin on the mux #define WIFI_SSID "your_ssid" #define WIFI_PASSWORD "your_pw" #define MQTT_SERVER "mqtt.wombatdashboard.com" #define MQTT_PORT 8883 #define TLS_FINGERPRINT "replace_this" // see wombatdashboard.com/tutorials/tls #define DEVICE_PRIVATE_IDENTIFIER "your_private_device_id" #define DEVICE_PUBLIC_IDENTIFIER "your_public_device_id" #define IRRIGATE_COMMAND_RELAY1 "your_command1" #define IRRIGATE_COMMAND_RELAY2 "your_command2"
Here we define some variables that depend on the electrical characteristics of your system: your probe signals calibrated to when your soil is too dry, how long you permit your relay to be closed (and hence how long you want to irrigate per burst. Your device's public and private identifiers are taken from your WombatDashboard™ web interface (when you registered your device). Your
IRRIGATE_COMMAND
commands are strings of your choice that you can send from your WombatDashboard™ web interface to manually activate the relay. Make sure they're unique for each relay, unless you want to activate two relays at the same time.// declare sensor and relay types struct Relay { char name[8]; // max 7 chars per name + null termination uint8_t pin; // the connection to ESP-12E ditigal IO pin number uint16_t counter; // how long the relay has been closed for, in seconds. 0 means open. char command[10]; // for if you want to close using a MQTT message from the WombatDashboard™ interface }; struct Sensor { char name[8]; // max 7 chars per name + null termination uint8_t muxPins; // the bits used to select the sensor through the mux Relay *relay; // pointer to associated relay to activate if soil is too dry uint16_t soilThreshold; // probe reading when soil is too dry }; // declare all our global variables Relay r1, r2; Relay relays[2] = {r1, r2}; Sensor s1 = {"probe1", 0b00000000, &r1, SOIL_THRESHOLD_LEVEL}; Sensor s2 = {"probe2", 0b00000001, &r1, SOIL_THRESHOLD_LEVEL}; Sensor s3 = {"probe3", 0b00000010, &r2, SOIL_THRESHOLD_LEVEL}; Sensor s4 = {"probe4", 0b00000011, &r2, SOIL_THRESHOLD_LEVEL}; Sensor sensors[4] = {s1, s2, s3, s4}; // N pins are required to represent 2^N sensors through the mux uint8_t muxPins[2] = {D1, D2}; uint8_t nb_pins = 2; // we'll keep count of our main loop using this variable uint16_t loop_counter = 0; // clients for internet connectivity WiFiClientSecure wc; PubSubClient client(wc);
The
Relay
andSensor
structs allow us to group all the data related to relays and sensors together for maintainable and understandable code. We initialize the sensors here with their starting values, but initialize the relays later in thesetup()
function because they require some dynamically-calculated values, and C++ requires that you do that within a function. Remember, multiplexers choose one of theirN
channels based on a binary representation of that channel's index. For our case, we need two pins, because we have four probe channels (N
channels require2^N
pins), represented by the values of theSensor.muxPins
field of00
,01
,10
, and11
.void setupWifi(void) { WiFi.begin(WIFI_SSID, WIFI_PASSWORD); Serial.print("Connecting"); while (WiFi.status() != WL_CONNECTED) { delay(1000); Serial.print("...waiting"); } Serial.println(); WiFi.printDiag(Serial); } void reconnect() { // Loop until we're reconnected while (!client.connected()) { Serial.print("Attempting MQTT connection..."); if (client.connect(DEVICE_PUBLIC_IDENTIFIER)) { // Attempt to connect Serial.println("connected"); client.subscribe(DEVICE_PRIVATE_IDENTIFIER); } else { Serial.print("failed, rc="); Serial.print(client.state()); Serial.println(" try again in 5 seconds"); delay(5000); } } }
These are just standard boilerplate functions that connect to wifi and the WombatDashboard™ MQTT server.
void convert_bits_to_pins(uint8_t bits) { // limit 4 pins max (for 16-channel mux) for(uint8_t i=0; i<4; i++) { uint8_t bitmask = 0b00000001 << i; // create the mask for the current pin if(pins & bitmask) { // if the bit was HIGH in current position digitalWrite(muxPins[i], HIGH); } else { digitalWrite(muxPins[i], LOW); } } }
Remember how each sensor has a
muxPins
field? When we want to read the signal from a sensor, this function sets the corresponding mux input pinsHIGH
to select that sensor's channel. For example, if we want to read sensors2
, we know thats2.muxPins
equals0b00000001
, so we'll pass that value as thebits
argument to this function, which will then set pinD1
toHIGH
and pinD2
toLOW
.bool stringcompare(char a[],char b[]){ for(uint8_t i=0; a[i] != '\0' || b[i] != '\0'; i++) { if(a[i] != b[i]) return false; } return true; }
This is a helper function to determine if two char arrays (C-style strings) have the same contents. The arrays should be null-terminated (with the
'\0'
character). We use this because it's generally not a good idea to use actualString
objects in microcontroller code due to the memory penalty.void callback(char* topic, byte* payload, unsigned int length) { if(!stringcompare(topic, DEVICE_PRIVATE_IDENTIFIER)) return; for(Relay r: relays) { if(stringcompare(r.command, (char *)payload)) { digitalWrite(r.pin, HIGH); // start the relay counter r.counter = 1; char message[12]; sprintf(message, "{\"%s\":1}", r.name); // json to indicate that we closed relay client.publish(DEVICE_PRIVATE_IDENTIFIER, message); } } }
The callback function is called every time the device receives a message from the server. We can send a message to close one of our two irrigation relays manually via the WombatDashboard™ web interface (if, say, we want to do an extra burst of irrigation to "water in" some new plants). We define the message corresponding to each relay as
Relay.command
(which we set toIRRIGATE_COMMAND_RELAY1
andIRRIGATE_COMMAND_RELAY2
at the start). Once the microcontroller receives the commands, it checks to see if it can find a relay with a matching command, sets the corresponding relay's pinHIGH
, and then posts a message back to the server to indicate that the relay was closed (so you can chart your irrigation patterns).