Soil Control System
Introduction
I love plants, but I usually forget to water them. I’ve decided to build a soil control system to help me to keep track of soil moisture levels. The system should monitor a soil moisture level and report whether the soil is wet or dry.
The project uses the ESP32 MCU and the YL-69 soil moisture sensor. You are free to use any other hardware.
- MCU: ESP32, CH32V003, CH32V207, CH32V307, NXP, STM32, AVR, etc
- Sensors: see the table below and the DFRobot website
Sensors
Sensor Name | Sensor Price (Approximate) |
---|---|
YL-69 | $2 - $5 |
Capacitive Soil Moisture Sensor (v1.2) | $5 - $10 |
DHT11/DHT22 + Soil Moisture Probes | $10 - $20 |
DFRobot Soil Moisture Sensor | $10 - $15 |
Watermark Soil Moisture Sensor | $30 - $50 |
Decagon EC-5 | $100 - $150 |
TEROS 10 | $100 - $200 |
Decagon GS3 | $150 - $200 |
AquaCheck Sub-Surface Sensor | $150 - $250 |
Schematics Design
Every project should start with some sort of map or schematic. It helps to understand the wiring and the overall hardware design. You can just use pen and paper if you like, but I encourage you to use real tools to create the schematic of the project. I mostly use KiCad, but Eagle or Altium can also be considered. See schematics for an example built in KiCad.
Soil Moisture Sensor
There are many of soil moisture sensors with different communication interfaces and mechanics used to measure soil moisture. The YL-69 is a resistive sensor. This means, that it measures the resistance of the soil, which changes with the moisture content.
There are also other types of sensors:
- Capacitive sensors: measure changes in capacitance caused by moisture levels. More durable and accurate.
- Tensiometers: measure soil water tension, providing a direct indication of plant-available water.
The project uses the YL-69 because I have one and want to play around with it. This sensor has some limitations. It is good to know these limitations, because it helps to learn new things and build more robust systems. There is a good article that explains how to control the YL-69 from the Arduino.
I use the following connection between the sensor and the ESP32 board:
ESP32 | YL-69 Module | Description |
---|---|---|
GPIO 34 | AO | Soil moisture value |
3v3 | VCC | VCC |
GND | GND | GND |
- | DO | Not used |
If you wish to use a different GPIO to connect the sensor’s AO pin, please refer to the available ESP32 pinout documentation:
- Article 1
- Article 2
- ESP32 official documentation
Be careful with existing hardware limitations:
- Latest stable documentation
- Outdated by still relevant documentation targeted for ESP-IDF version 4.2
Let’s start by connecting ESP32 to the sensor. Once the sensor is properly connected, it’s time to write the firmware to test how the sensor works. At this point I’m assuming that you’ve already properly setup ESP-IDF environment and are able to compile and flash the examples provided by the ESP-IDF project. If not, please refer to the official documentation. Espressif provides a very clear and detailed explanation of each installation step.
Now we need to read the analogue value from the AO sensor pin and display it on the console. Reading the analogue value is done via ADC. There are a number of good articles on ADC reading via ESP32:
- Documentation: ESP-IDF ADC reference
- Tutorial: ADC reading example
- Video: which ESP32 Pins are safe to use
Also take a look at the the ESP-IDF examples folder, it contains a lot of useful information on how things should be done.
Below is a complete example written in C++17.
#include <cstdio>
#include <cstring>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_adc/adc_oneshot.h"
namespace {
// GPIO 34 -> ADC CHANNEL 6
const adc_channel_t DEFAULT_ADC_CHANNEL = ADC_CHANNEL_6;
const adc_atten_t DEFAULT_ADC_ATTEN = ADC_ATTEN_DB_12;
const adc_bitwidth_t DEFAULT_ADC_BITWIDTH = ADC_BITWIDTH_10;
int read_adc_raw(adc_channel_t ch, adc_oneshot_unit_handle_t unit_handle) {
int raw = 0;
ESP_ERROR_CHECK(adc_oneshot_read(unit_handle, ch, &raw));
return raw;
}
int convert_raw_to_voltage(int raw, adc_cali_handle_t calibration_handle) {
int voltage = 0;
ESP_ERROR_CHECK(adc_cali_raw_to_voltage(calibration_handle, raw, &voltage));
return voltage;
}
} // namespace
extern "C" void app_main(void) {
// Unit configuration.
adc_oneshot_unit_handle_t unit_handle = nullptr;
adc_oneshot_unit_init_cfg_t unit_config;
memset(&unit_config, 0, sizeof(unit_config));
unit_config.unit_id = ADC_UNIT_1;
ESP_ERROR_CHECK(adc_oneshot_new_unit(&unit_config, &unit_handle));
// ADC1 configuration.
adc_oneshot_chan_cfg_t config;
memset(&config, 0, sizeof(config));
config.bitwidth = DEFAULT_ADC_BITWIDTH;
config.atten = DEFAULT_ADC_ATTEN;
ESP_ERROR_CHECK(
adc_oneshot_config_channel(unit_handle, DEFAULT_ADC_CHANNEL, &config));
// ADC1 calibration configuration.
adc_cali_handle_t calibration_handle = nullptr;
adc_cali_line_fitting_config_t calibration_config;
memset(&calibration_config, 0, sizeof(calibration_config));
calibration_config.unit_id = ADC_UNIT_1;
calibration_config.atten = DEFAULT_ADC_ATTEN;
calibration_config.bitwidth = DEFAULT_ADC_BITWIDTH;
ESP_ERROR_CHECK(
adc_cali_create_scheme_line_fitting(&calibration_config, &calibration_handle));
while (true) {
const auto raw = read_adc_raw(DEFAULT_ADC_CHANNEL, unit_handle);
const auto voltage = convert_raw_to_voltage(raw, calibration_handle);
fprintf(stderr, "raw=%i voltage=%i(mV)\n", raw, voltage);
vTaskDelay(pdMS_TO_TICKS(1000));
}
ESP_ERROR_CHECK(adc_oneshot_del_unit(unit_handle));
ESP_ERROR_CHECK(adc_cali_delete_scheme_line_fitting(calibration_handle));
}
CMakeLists.txt:
# For more information about build system see
# https://docs.espressif.com/projects/esp-idf/en/latest/api-guides/build-system.html
# The following five lines of boilerplate have to be in your project's
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(main)
After compiling and flashing the firmware to the ESP32 board, the result should be the following, you may get different numbers:
raw=1023 voltage=3154(mV)
raw=1023 voltage=3154(mV)
raw=1023 voltage=3154(mV)
raw=1023 voltage=3154(mV)
Try putting a sensor in the water and the values should change. Congratulations, at this stage you should be able to monitor the data provided by the sensor. The next step is to introduce some sort of soil moisture status, representing if the soil is wet or dry. See the example below:
const unsigned threshold = 800;
enum class MoistureStatus {
Dry,
Wet,
};
const char* moisture_status_to_str(MoistureStatus status) {
switch (status) {
case MoistureStatus::Dry:
return "dry";
case MoistureStatus::Wet:
return "wet";
default:
break;
}
return "<none>";
}
MoistureStatus convert_raw_to_moisture_status(int raw) {
return raw < threshold ? MoistureStatus::Wet : MoistureStatus::Dry;
}
5-Pin Relay
At this point you might be thinking that the project is over, because we’ve already figured out how to get the data and how to convert that data into the soil moisture status. If you leave your sensor in the crop for a few days you may see signs of the corrosion traces and the sensor even may even stop working. If you search the web you will find many negative reviews for this sensor, all of which are true. Now you may think that we need to find another more reliable sensor, which is absolutely true for the real world systems, but we are in a learning mode, we should appreciate such drawbacks as they help us to learn something new.
As was mentioned earlier, the YL-69 is the resistive sensor, but I deliberately left out the information that this type of sensor is simple but prone to corrosion. The corrosion problem can be overcome by switching the sensor on only when the actual measurement is made. We need to use a relay to implement this functionality.
For this project I use a 5-pin 3v SPDT relay, e.g. Ningbo Songle Relay SRD-03VDC-SL-C, but you are free to use any other relay. The 3v relay is used because it can be powered up directly from the ESP32 GPIO.
There are good articles explaining how to control relays from Arduino:
5 Pin relay has 5 pins:
- Normally-opened pin, NO
- Normally-closed pin, NC
- Common pin, COM
- Two coil pins
There are many variations of relays, and it depends on the manufacturer if the pin is labelled or not. So it’s good to know how to tell which pin is which. In most cases, two coil pins and the common pin are placed together on one side, NC and NO pins are placed on another side. The COM pin is usually located in the middle between the coil pins. To determine the NO and NC pins, set the multimeter in to circuit tester mode, then connect its one probe to the COM pin and the other probe to the exploring pin, NO or NC. The NC and COM pins would be connected by default and the multimeter would indicate this with a beep sound.
The main operating principle of the relay can be formulated as follows:
- 3V is applied to the coil pins, a relay is energised, there is a connection between the COM and NO pins.
- 0V is applied to the coil pins, a relay is de-energised, there is a connection between the COM and NC pins.
It’s good to remember the following principles when working with a relay:
- Use a normally closed configuration if you want the current to flow most of the time, and you only want to stop it occasionally.
- Use a normally open configuration if you want the current to flow occasionally (for example, to switch a load on and off).
The algorithm for using a relay with the YL-69 is as follows:
- Activate a relay periodically, e.g. once every 30 minutes.
- Each time a relay is activated, the YL-69 should be powered up by the ESP32 3.3V,
- Deactivate the relay, current should stop flowing through the YL-69 contacts, which should increase its lifetime.
Relays allow to control high voltages, usually specified on the relay itself or in its data sheets. For now, due to safety reasons, don’t connect any high voltages to the switching part of the relay, NC - COM or NO - COM lines.
Now that we know how a relay works and have an algorithm in mind, it’s time to connect it to the ESP32 board. Connect the ESP32 GPIO pin to a relay coil pin and connect another relay coil pin to the ESP32 ground. Now we need to extend the firmware, by implementing a relay switch on/off function. See also the ESP-IDF GPIO documentation. Below is a complete example of how to use the ESP32 GPIO to switch a relay on and off.
#include <cstdio>
#include <cstring>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "driver/gpio.h"
namespace {
const gpio_num_t RELAY_GPIO = GPIO_NUM_26;
const unsigned GPIO_OUTPUT_PIN_SEL = ((1ULL << RELAY_GPIO));
void configure_gpio(gpio_config_t& io_conf) {
// disable interrupt
io_conf.intr_type = GPIO_INTR_DISABLE;
// set as output mode
io_conf.mode = GPIO_MODE_OUTPUT;
// bit mask of the pins that you want to set,
io_conf.pin_bit_mask = GPIO_OUTPUT_PIN_SEL;
// enable pull-down mode
io_conf.pull_down_en = GPIO_PULLDOWN_ENABLE;
// disable pull-up mode
io_conf.pull_up_en = GPIO_PULLUP_DISABLE;
// configure GPIO with the given settings
gpio_config(&io_conf);
}
void relay_turn_on() {
gpio_set_level(RELAY_GPIO, true);
}
void relay_turn_off() {
gpio_set_level(RELAY_GPIO, false);
}
} // namespace
extern "C" void app_main(void) {
// zero-initialize the config structure.
gpio_config_t io_conf;
memset(&io_conf, 0, sizeof(io_conf));
configure_gpio(io_conf);
while (true) {
relay_turn_on();
vTaskDelay(pdMS_TO_TICKS(1000));
relay_turn_off();
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
CMakeLists.txt:
# For more information about build system see
# https://docs.espressif.com/projects/esp-idf/en/latest/api-guides/build-system.html
# The following five lines of boilerplate have to be in your project's
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(main)
A few words about the following GPIO configuration lines:
// set as output mode
io_conf.mode = GPIO_MODE_OUTPUT;
// enable pull-down mode
io_conf.pull_down_en = GPIO_PULLDOWN_ENABLE;
// disable pull-up mode
io_conf.pull_up_en = GPIO_PULLUP_DISABLE;
We are only interested in setting the GPIO pin to high or to low, but if you want to know the state of the pin state, you should change its configuration:
io_conf.mode = GPIO_MODE_INPUT_OUTPUT;
The pin can sometimes be in an undefined state due to random noise. This state is known as “floating”. It can cause the following:
- Floating states give unreliable readings
- Floating states can accidentally activate the load due to the random noise
Pull-up or pull-down resistors are used to protect a pin from such “floating” conditions. These resistors can be either internal or external. Internal resistors are usually controlled by software. The main principles when using with pull-up and pull-down resistors are as follows:
- Use a pull-down resistor to ensure that the line is LOW when the GPIO pin is not actively driven HIGH,
- Use a pull-up resistor to ensure that the line is HIGH when the GPIO pin is not actively driven LOW.
It’s worth activating a pull-down resistor to ensure that a relay is not accidentally be activated by the random noise:
io_conf.pull_down_en = GPIO_PULLDOWN_ENABLE;
As we only need a pull-down resistor, we can disable a pull-up resistor.
io_conf.pull_up_en = GPIO_PULLUP_DISABLE;
Now you can flash the updated firmware and check that the relay is working properly, you should hear the clicks. At this point you know almost everything you need to build a soil moisture monitoring system, except for some important safety details, which I’ve decided to leave out at the beginning to keep things simple.
Relay Safety Details
Driving a relay directly from the ESP32 GPIO may not be a good idea.
Problem 1
Most relays require 50mA to 100mA to energise the coil, but ESP32 GPIO provides a maximum current of about 12mA-20mA. Even if you select a relay that can be controlled directly from the ESP32 GPIO, another relay may not work. NPN or MOSFET transistors can be used to overcome the current limitation.
This project uses the N2222A NPN transistor, with a maximum collector current of 0.6A. Let’s change the wiring a little bit. Instead of connecting the relay coil pins directly to the ESP32 GPIO, first connect the ESP32 GPIO first to the transistor base pin through the 1kOm current limiting resistor. Then connect the YL-69 soil moisture sensor to the collector-emitter line. See schematics for more details.
Problem 2
Even when the relay, or any other inductive load, is energised, a magnetic field is built up around the coil. And when the relay is de-energised, this magnetic field collapses very quickly. This change produces a current, that flows backwards. The faster the magnetic field changes, the higher the value of the generated current. The generated current can be much higher than the operating current. If the relay is driven directly from the ESP32 GPIO, such a kickback can damage the GPIO pin or even the entire circuit.
The 1N4001 diode is used to eliminate the generated kickback. It is also known as a flyback diode. It has a maximum reverse voltage of 50V and a maximum forward current of 1A. The examples below show the possible voltage kickback that was measured with the oscilloscope.
-
Without the flyback diode the reverse voltage is about 40V.
-
With the flyback diode it’s about 3.3V.
As you can see, the reverse voltage is almost 10x times lower when the flyback diode is used. You should connect a flyback diode in parallel with the relay coil, with the cathode (marked side) connected to the positive side of the coil.
That’s it! You have everything you need to build a safe and reliable soil moisture control system. In the next section I will show how to monitor the soil moisture characteristics.
Monitoring System
There are many approaches to monitor the embedded system. The simplest, printing the values to the console, has already been implemented. This approach is very convenient during the system development phase, but when the project is finished, it would be less convenient to connect to the module each time via the USB cable and PC just to monitor the soil moisture status. I would rather open a web page on my mobile phone and check the moisture status, or visually check the values on an LCD display. The less action, the better.
HTTP Server
I’ve first decided to integrate the HTTP server into the firmware first, because it’s the most convenient way to monitor the system, and it doesn’t require a lot of additional components. I’ve decided to use WiFi, because I like the module to be less wired. Ethernet can also be considered.
Let’s start developing the firmware. First, we need to integrate the WiFi layer into the application. Have a look at the WiFi STA, station mode, example from the Espressif. It covers all the details of connecting the ESP32 module to the WiFi network.
The next step is to integrate the HTTP server. Espressif also provides the documentation and example on how to do this. The example is a bit complicated. It mixes HTTP server details and WiFi interactions. I suggest separating the WiFi layer from the HTTP layer and introducing some sort of subscribe/notify interface between them. The upper application layers can subscribe to connect/disconnect events from the WiFi layer. The HTTP server subscribes to these events, and when the WiFi is connected (IP address is received) it should be started, when the WiFi is disconnected, it should be stopped.
I have the following C++ interface:
class INetworkHandler {
public:
//! Destroy.
virtual ~INetworkHandler() = default;
//! Network is connected.
virtual void handle_connected() = 0;
//! Network is disconnected.
virtual void handle_disconnected() = 0;
};
And the following class implements it:
class HttpServerPipeline : public INetworkHandler {
public:
void handle_connected() override {
http_server_->start();
}
void handle_disconnected() override {
http_server_->stop();
}
private:
std::unique_ptr<HTTPServer> http_server_;
};
Let’s run idf.py flash monitor
. If the WiFi and HTTP layers are properly integrated into the firmware, you should see something similar to the following:
I (796) wifi:mode : sta (9c:9c:1f:10:2f:a4)
I (796) wifi:enable tsf
I (806) wifi-network: WIFI_EVENT_STA_START
I (816) wifi:new:<6,0>, old:<1,0>, ap:<255,255>, sta:<6,0>, prof:1
I (816) wifi:state: init -> auth (b0)
I (816) wifi:state: auth -> assoc (0)
I (826) wifi:state: assoc -> run (10)
I (836) wifi:<ba-add>idx:0 (ifx:0, 2c:55:d3:8b:2b:a8), tid:0, ssn:0, winSize:64
I (866) wifi:connected with <YOUR_SSI-SSID>, aid = 3, channel 6, BW20, bssid = 2c:55:d3:8b:2b:a8
I (866) wifi:security: WPA2-PSK, phy: bgn, rssi: -54
I (866) wifi:pm start, type: 1
I (866) wifi:dp: 1, bi: 102400, li: 3, scale listen interval from 307200 us to 307200 us
I (956) wifi:AP's beacon interval = 100352 us, DTIM period = 1
I (3876) esp_netif_handlers: sta ip: 192.168.1.137, mask: 255.255.255.0, gw: 192.168.1.1
I (3876) wifi-network: got ip:192.168.1.137
I (3876) wifi-network: connected to AP: SSID=<YOUR-SSID> RSSI=<YOUR-RSSI>
I (4896) console-telemetry-writer: telemetry={"raw":928,"voltage":2990,"status":"dry"}
As you can see, the IP address is 192.168.1.137
. If you are having WiFi problems with WiFi connectivity, see the troubleshooting section below. Now let’s try to get the telemetry from the ESP32 HTTP server. You can open http://192.168.1.137/telemetry in a browser, or use any CLI tool you like. Let’s send the HTTP request using curl:
curl 192.168.1.137/telemetry
And I have the following response:
{"raw":830,"voltage":2794,"status":"dry"}
That’s it. Now we can monitor the system remotely. Every time we want to check if it’s a good time to water the plant, we can open the browser on the mobile phone and check the status of the soil. The HTTP server is an optional component. If a device can’t connect to the router for some reason, it will still work properly.
WiFi Troubleshooting
If you are having problems connecting ESP32 to your router, please to check the following:
- Ensure SSID and password are correct
- Ensure sure ESP32 board have an antenna, onboard or external one
- Check the access point RSSI to which the ESP32 is connected. See the RSSI table below.
RSSI Value | Signal Strength | Description |
---|---|---|
-30 dBm | Excellent | Very close to the router or access point. |
-50 to -60 dBm | Good | Reliable and strong. |
-60 to -70 dBm | Fair | Reliable for most tasks but might be weaker or slower for bandwidth-intensive tasks. |
-70 to -80 dBm | Poor | May experience connectivity issues or slower performance. |
-80 dBm or lower | Unreliable | Likely to experience frequent disconnects and poor performance. |
mDNS
The WiFi layer relies on the router’s DHCP to obtain the IP address. There are two problems with this. Firstly, if the IP address is changed, it will be required to connect to the device to get its new IP address. Secondly, it will be a bit annoying to type the full IP address into the browser address bar each time.
I’ve decided to use mDNS to solve both problems. mDNS allows you to assign a memorable hostname to the device. Later this hostname can be used instead of the IP address to send HTTP requests to the device. See the Espressif protocol documentation and notes on how to integrate mDNS into your project. The complete implementation can be found here.
Let’s check that the mDNS component is properly integrated into the firmware:
curl soil-contorl-system.local/telemetry
And I have the following response:
{"raw":830,"voltage":2794,"status":"dry"}
My device is now accessible via the HTTP protocol. This is possible for the following reasons:
- I have assigned a hostname to the device,
soil-control-system
. This can be configured usingidf.py menuconfig
, see theCONFIG_OCS_NETWORK_MDNS_HOSTNAME
option. - I have registered the mDNS service with the type
_http
and underlying protocol_tcp
. This information is used by other members of the local network to understand the capabilities of the device. See the following RFC for a list of common mDNS service types.
If you are having problems accessing your device using mDNS hostname, please refer to the troubleshooting section below.
mDNS Troubleshooting
- Ensure that the hostname and instance name are set correctly using the mDNS API. See the Espressif example.
- Ensure that the mDNS service is correctly added. See the Espressif example.
Check that the mDNS service can be accessed from the local PC. See the Espressif documentation. I use Debian and avahi for the mDNS service discovery.
Let’s use the avahi-browse CLI tool to find all mDNS services with type _http
and protocol _tcp
:
avahi-browse -r _http._tcp
And I got the following result:
+ wlp2s0 IPv4 Soil Control System Web Site
local
= wlp2s0 IPv4 Soil Control System Web Site
local
hostname = [soil-control-system.local]
address = [192.168.1.137]
port = [80]
txt = ["telemetry=/telemetry"]
The same can be done with the GUI tool, avahi-discover.
Conclusion
There is plenty of room for improvement and further development. This is not the end. I’m planning to add more subsystems to this project later. I hope you enjoy this project. If you have any ideas or comments, feel free to mail me at [email protected].
A complete project is available on the GitHub.