© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2022
C. BellBeginning MicroPython with the Raspberry Pi Picohttps://doi.org/10.1007/978-1-4842-8135-2_8

8. Project: Soil Moisture Monitor

Charles Bell1  
(1)
Warsaw, VA, USA
 

One of the most common forms of electronics projects is those that monitor events using sensors providing the data either to another machine, cloud service, or local server (like a web server). One way to do that is to wire your Pico up to a set of sensors and then log the data. You can find several examples of general data loggers on the Internet, but few combine the logging of data with a visualization component. Indeed, making sense of the data is the key to making a successful project.

In this chapter, we won’t jump directly into making our project run on the Internet. Rather, we will start with the basics and explore combining data logging with data visualization. We will use a different OLED made specifically for the Pico using a third-party host board. We will also see how to use an analog sensor that produces analog data that we will then have to interpret. In fact, we will rely on the analog-to-digital conversion (ADC) capabilities of our Pico to change the voltage reading to a value we can use. Finally, we will be reusing the RTC module from Chapter 6.

However, this chapter includes a few hardware challenges that are great examples of incompatibilities among components that you may encounter in your own projects. We will explore these issues in detail along with solutions and ways to mitigate the issues. With that comes added complexity that makes this project the most complex so far in the book.

As you will see, the code used in this chapter is more modular and uses more functions than previous projects, but not much more than the previous examples. However, the use of a third-party host board makes the project quite different. As you will see, the code isn’t difficult to learn and uses concepts we have seen in previous chapters. It is the hardware that is the most challenging.

Overview

In this chapter, we will implement a plant soil moisture monitoring solution (plant monitor for brevity). This will involve using one or more soil moisture sensors connected to our Pico. We will set up a timer alarm (an interrupt) to run periodically to read the data from the sensors and store it in a comma-separated value (CSV) file as well as display the last value read and average over time.

The project also supports a rudimentary user interface that includes four buttons whose functions include turning the display off, turning it back on, and clearing the data log (the extra button is used to confirm the delete). We will also use an LED to indicate when a sensor is being read.

We will be separating the code for reading the sensor from the display. This means we can reuse or modify either without confusing ourselves as we dig into the code. For example, so long as the visualization component reads the sensor data from the file, it doesn’t matter to the sensor reading code how it is used. The only interface or connection between these two parts is the format of the file, and since we’re using a CSV file, it is very easy to read and use in our code.

To make things more interesting and to make it easier to code, we will place all the sensor code in a separate code module. Recall, this is a technique used to help reduce the amount of code in any one module, thereby making it easier to write and maintain.

Now let’s see what components are needed for this project, and then we will see how to wire everything together.

Note

Since we are well into our third project and have seen many of the techniques employed in this project, some topics such as wiring and setup of the hardware shall be brief in favor of discussing the hardware details.

Required Components

The components for this project include a new host board that you can plug your Pico into that supports two additions (copies) of the GPIO headers, allowing you to use up to two modules made for the Pico.

One of those modules is called the Pico Display, which has a nice RGB OLED screen that is about 75% of the size of the Pico. Onboard that module are four buttons and an RGB LED, making this module very handy in creating simple user interfaces like the one for this project.

However, as mentioned previously, we will encounter some problems using the Pico Display without our RTC and soil moisture sensors. Before we look at those details, Table 8-1 lists the components you will need in addition to your Pico and USB cable. Links to vendors are provided should you want to purchase the components.
Table 8-1

Required Components

Component

Qty

Description

Cost

Links

Soil moisture

1+

Sensor

$6.95

www.sparkfun.com/products/13637

RTC breakout board

1

RTC module with battery backup

$15.95

www.sparkfun.com/products/12708

$7.50

www.adafruit.com/product/3296

Coin cell battery

1

CR1225 (SparkFun RTC)

$1.95

www.sparkfun.com/products/337

CR1220 (Adafruit RTC)

$0.95

www.adafruit.com/product/380

Host board

1

Omnibus

$7.75

https://thepihut.com/collections/pico/products/pico-omnibus-dual-expander

OLED

1

Pico Display

$14.00

https://thepihut.com/collections/pico/products/pico-display-pack

Jumper wires

3

M/M jumper wires, 7” (set of 30)

$2.25

https://www.sparkfun.com/products/11026

M/M jumper wires, 6” (set of 20)

$1.95

https://www.adafruit.com/product/1956

Jumper wires

4

F/F jumper wires, 6” (set of 20)

$1.95

www.sparkfun.com/products/11709

F/F jumper wires, 6” (set of 40)

$3.95

www.adafruit.com/product/266

You can purchase the components separately from Adafruit (adafruit.com), SparkFun (sparkfun.com), or any electronics store that carries electronic components. Costs shown are estimates and do not include any shipping costs.

Notice we are using two different forms of jumper cables. The usual we’ve seen before with a male connector on each end as well as a female/female cable, which we will use to connect to the RTC from the host board. However, the number of M/M jumper wires needed will vary depending on how many sensors you plan to use.

Now, let’s discuss the new components we will be using.

Pico Omnibus

The host board (sometimes called a host adapter) is the Pico Omnibus from Pimoroni (https://shop.pimoroni.com/products/pico-omnibus). The board has two sets of male headers that replicate the Pico GPIO. Together with modules that have female headers, you can place your Pico in the center and one module on either side. Or, as we will do in this project, one module and use the other headers for connecting to other hardware. Figure 8-1 shows the Pico Omnibus with a Pico in the center and two popular modules installed.
Figure 8-1

Pico Omnibus (courtesy of thepihut.com)

Pimoroni sells a variety of modules you can connect to the Pico Omnibus. See https://shop.pimoroni.com/collections/pico for more details and the latest offerings. You can purchase Pimoroni components at adafruit.com, sparkfun.com, and thepihut.com or directly from pimoroni.com.

Pico Display

The Pico Display shown on the left is the OLED we will use for this project. Figure 8-2 shows the Pico Display. Notice it has four buttons as well as a single red, green, and blue (RGB) LED that you can use as an indicator. Nice.
Figure 8-2

Pico Display (courtesy of thepihut.com)

Soil Moisture Sensor

Soil moisture sensors come in a variety of formats, but most have two prongs that are inserted into the soil and, using a small electrical charge, measure the resistance between the prongs. The higher the value read, the more moisture is in the soil. However, there is a bit of configuration needed to obtain reliable or realistic thresholds. While the manufacturer will have threshold recommendations, some experimentation may be needed to find the right values.

These sensors can also be affected by environmental factors including the type of pot the plant is in, the soil composition, and other factors. Thus, experimenting with a known overwatered soil, dry soil, and properly tended soil will help you narrow down the thresholds for your environment.

Figure 8-3 shows a soil moisture sensor from SparkFun that has a terminal mount instead of pins. You can find several varieties of these sensors. Just pick the one you want to use, keeping in mind you may need different jumpers to connect it to your board.
Figure 8-3

Soil moisture sensor (courtesy of sparkfun.com)

Of special note is how these soil moisture sensors work. If you were to leave the sensors powered on, they can degrade over time. The metal on the prongs can become degraded due to electrolysis, thereby dramatically reducing its lifespan. You can use a technique of a GPIO pin to power the sensor by turning the pin on when you want to read a value. Keep in mind there will be a small delay while the sensor settles, but we can use a simple delay to wait and then read the value and turn the sensor off. In this way, we can extend the life of the sensor greatly.

The soil moisture sensors come using a variety of connectors from a terminal block to one of several connectors with pins. Be sure to check your soil moisture sensors to ensure you use the correct jumper wires. For example, you can use a male-to-female jumper wire for the terminal block version or a female-to-female connector for those using standard pins.

Potential Hardware Conflicts

Now, let’s talk about a subject that occurs more often than you think – conflicts between hardware components. Most times, conflicts can be resolved by changing the software libraries we use like using SoftI2C or SoftSPI or even a different driver, but other times it’s simply because of how the hardware is wired internally.

In this case, we have a potential conflict between the Pico Display and the pins needed for the soil moisture sensors as well as the RTC module. Yes, all three have a potential to make your project miserable! Since this can happen in other projects, we need to examine the issue in more detail to prepare you to diagnose and overcome the situation.

Let’s begin by looking at the interface pins that the Pico Display uses. The nice folks at Pimoroni have provided us with an excellent color-coded chart that has on the left a view from the top of the module (looking at the OLED), and on the right is a view from the underside.

Looking at the left side, notice the blocks that have a text box next to them. These are the pins the Pico Display uses. To be safe, we should avoid using these pins for other hardware. That is normally an easy thing to do, but in this case, we will need three pins for each soil moisture sensor (although you can combine the ground pins) and four for the I2C interface for the RTC. Since we must avoid using the same pins as the Pico Display, we must make our choices for the power and signal pins for the soil moisture carefully.

Figure 8-4 shows the pinout chart of the Pico Display.
Figure 8-4

Pico Display GPIO (courtesy of pimoroni.com)

For example, if you were to use pins numbered 9 and 10 for the power pins on the soil moisture sensors, these are wired to the RGB LED on the Pico Display. So, each time you power the soil moisture on, you will see the RGB LED turn on. That might be fine if you want to turn the LED on when reading, but it is a good example of hardware conflicts.

Another thing to consider is the signal pins for the soil moisture sensors require analog-to-digital (ADC) pins for the soil moisture sensors. However, the Pico has only three pins that can do ADC conversions, GPIO26, GPIO27, and GPIO28, which limits us to at most three soil moisture sensors.

However, you can employ an external ADC module like those from Adafruit and SparkFun. These modules provide additional pins with ADC capabilities, allowing you to make more ADC pins available for your project. For example, the ADS1015 12-Bit ADC – 4 Channel from Adafruit (www.adafruit.com/product/1083) uses an I2C interface, and a driver is available for use with MicroPython. Figure 8-5 shows the ADS1015 12-Bit ADC – 4 Channel.
Figure 8-5

ADS1015 12-Bit ADC – 4 Channel (courtesy of adafruit.com)

Now, let’s see how to wire the components together.

Set Up the Hardware

Since we are using the Pico Omnibus, we need to take a slight detour and load a custom image provided by Pimoroni. It is much easier to load the custom image than to try and install all of the libraries needed to use the Omnibus and Pico Display. We will need the same library we used in Chapter 6 for the RTC, but the custom image has all of the other libraries we will need.

Another thing that complicates our hardware setup is the layout of the Pico Omnibus. Recall, this host board has two GPIO headers for modules with male pins that you can use to mount modules with female headers soldered on the bottom of the board. However, the pinout for the GPIO headers on the Omnibus is reversed. That means you cannot start counting the physical pin number starting in the upper-left corner; rather, it is numbered from one starting in the upper-right corner. Figure 8-6 shows the correct layout of the GPIO module headers on the Omnibus enlarged for clarity.
Figure 8-6

Pico Omnibus GPIO pinout

Fortunately, the pins are labeled on the Omnibus, so you can find them without having a map like before. Figure 8-7 shows the Omnibus GPIO. Notice I’ve mounted the Pico Display on the left. You can mount it on either side.
Figure 8-7

Pico Omnibus module GPIO

Now that we’re aware of the limitation of the pins we need to use and the layout change for the Omnibus, let’s first discuss how to install the custom image before we discuss how to connect the hardware.

Load the Pimoroni Image on the Pico

Pimoroni has prepared a special, custom image that includes all of the libraries we will need to use the host board and the display. Recall from Chapter 1, to install an image, we first download the .uf2 file and then copy the file to our Pico in boot select mode.

For the Pimoroni image, begin by visiting https://github.com/pimoroni/pimoroni-pico/releases/ and click the link to download the MicroPython .uf2 image for the latest version. For example, the latest version at the time of this writing was version 0.2.5, and the link to the .uf2 file is https://github.com/pimoroni/pimoroni-pico/releases/download/v0.2.5/pimoroni-pico-v0.2.5-micropython-v1.16.uf2.

Next, unplug your Pico from your PC and hold down the BOOTSEL button and reconnect to your PC. Release the BOOTSEL button and then drag and drop the .uf2 file to the RPI-RP2 drive. Once the copy is finished, you can then disconnect and reconnect the Pico.

Finally, open Thonny and verify the new image has loaded. You may not see any banner that identifies the MicroPython image as the Pimoroni custom image. However, you can check that you are using the correct image by using the REPL console to import the Pimoroni library. This command will succeed if you have the Pimoroni custom image and fail for others. The following shows a successful test with the Pimoroni custom image. Notice there wasn’t an error when the import statement was executed:
MicroPython v1.16 on 2021-08-19; Raspberry Pi Pico with RP2040
Type "help()" for more information.
>>> import pimoroni
>>>

Now that we have our custom image installed, let’s see how to connect the hardware.

Connecting the Hardware

Table 8-2 shows the connections needed for this project. This shows the use of two soil moisture sensors, but you can use a single sensor or three if you’d like. However, it is recommended you start with one sensor until you get the project working and then add additional sensors.
Table 8-2

Connections for the Plant Monitor

Omnibus

Pin Number

Component

Pin

VBUS

40

RTC

5V

GND

38

RTC

GND

GP11

15

RTC

SCL

GP10

14

RTC

SDA

GND

8

Soil #1

GND

GP21

27

Soil #1

VCC

GP27

32

Soil #1

SIG

GND

3

Soil #2

GND

GP22

29

Soil #2

VCC

GP28

34

Soil #2

SIG

Of course, you must insert the soil moisture sensors into the soil of your plants. If your plants are located further away from your power source, you may need to use longer wires to connect the sensors. You should start with a single, small plant and one sensor (or for testing, two sensors in one plant) that you can place close to your PC (or power source).

Caution

You will need soil moisture sensors that can operate at 3.3–5V. Some MicroPython boards may limit output on the pins to 3.3V. The sensors from SparkFun are compatible.

To connect the wiring, start by installing the Pico in the center of the Omnibus with the Pico Display installed to the left (or right if you prefer). Once you’ve done that, lay out the RTC and soil moisture sensors as well as your kit of jumper wires. Figure 8-8 shows a pictorial representation of how the modules are wired with the correct Omnibus GPIO header enlarged for clarity.
Figure 8-8

Wiring the plant monitor

Once again, always make sure to double-check your connections before powering the board on. Now, let’s talk about the code we need to write. Don’t power on your board just yet – there is a fair amount of discussion needed before we’re ready to test the project.

Write the Code

Now it’s time to write the code for our project. The code is longer than what we’ve seen thus far, and due to all the bits and bobs we’re working with, it is best to divide the project into parts. So, we are going to write the code in stages. We won’t have a working project until the end, so most of the discussion will be about the individual parts. We will put it all together before testing the project.

For this project, we will rely more on classes than we did in previous examples. We will create a main code file (main.py) that we can use to download to the Pico for automatic execution, which will set up the sensors for reading by a dedicated class module and display the data using a different class module. We will use a third class to control how often we read the sensor(s). Thus, we will create three class modules as follows. We will see the details of each of these in a later section:
  • ReadTimer: A class to control how often the code reads the sensors. Recall, the soil moisture sensors need some time to power on, stabilize, and read.

  • SoilMoisture: A class to read one or more soil moisture sensors and return the data. The class will also save the data collected to a comma-separated value file (CSV).

  • PlantDisplay: A class to display the data to the Pico Display.

However, before we examine the class modules, we need to work on calibrating our sensors.

Calibrating the Sensor

Calibration of sensors is very important. This is especially true for soil moisture sensors because there are so many different versions available. These sensors are also very sensitive to the soil composition, temperature, and even the type of pot in which the plant lives. Thus, we should experiment with known soil moisture, so we know what ranges to use in our code.

More specifically, we want to classify the observation from the sensor so that we can determine if the plant needs watering. We will use the values “dry,” “Ok,” and “wet” to classify the value read from the sensor. Seeing these labels is much easier for us to determine – at a glance – whether the plant needs watering. In this case, the raw data such as a value of 1756 may not mean much, but if we see “dry,” we know it needs water.

Since the sensors are analog sensors, we will use the analog-to-digital conversion on the board. When we read the data from the pin, we will get a value in a range starting at zero. This value is related to the resistance the sensor reads in the soil. Low values indicate dry soil, and high values indicate wet soil.

However, the sensors from different vendors can vary widely in the values read. For example, sensors from SparkFun tend to read values in the range 0–32768, but sensors from other vendors can read as high as 65535. Fortunately, they all seem to be consistent in that the lower the value, the drier the soil.

So, we must determine thresholds for the three classifications. Again, there are several factors that can influence the values read from the sensor. Thus, you should select several pots of soil including one that you feel is dry, another that is correctly watered, and a third that is overwatered. The best thing to do is select one that is dry, take measurements, then water it until the soil moisture is correct, measure that, then water it again until there is too much water.

To determine the threshold, we must first write a short bit of code to set up our board for reading values from the sensor. This includes choosing a GPIO pin that supports ADC. We also need to choose a pin to use to power the board. This is also an analog output pin. We will use GP27 for the sensor signal pin to read data and GP21 for the power pin. The ground for the sensor can be connected to any of the ground pins on the Pico.

Finally, we will write a loop to read several values every five seconds and then average them. Five seconds is an arbitrary value, and it was derived from reading the data sheet for the sensor. Check your sensors to see how much time is needed for the read to settle (maybe under the heading of frequency of reads).

Listing 8-1 shows the code needed to set up the analog-to-digital channel, a pin to use for powering the sensor, and a loop for reading ten values and averaging them.
# Import libraries
from machine import ADC, Pin
from utime import sleep
print("Beginning MicroPython - Soil Moisture threshold test.")
# Setup the GPIO pin for powering the sensor. We use Pin 19
power = Pin(21, Pin.OUT)
# Setup the ADC for the signal pin
adc = ADC(Pin(27))
# Turn sensor off
power.low()
# Loop 10 times and average the values read
print("Reading 10 values.")
total = 0
for i in range (0,10):
    # Turn power on
    power.high()
    # Wait for sensor to power on and settle
    sleep(5)
    # Read the value
    value = adc.read_u16()
    print("Value read ({0:02}): {1}".format(i+1, value))
    total += value
    # Turn sensor off
    power.low()
# Now average the values
print("The average value read is: {0}".format(total/10))
Listing 8-1

Calibrating the Soil Moisture Threshold

If you enter this code in a file named threshold.py, you can download it to your Pico and execute it. Listing 8-2 shows the output of running this calibration code in a plant that is correctly watered.
Beginning MicroPython - Soil Moisture threshold test.
Reading 10 values.
Value read (01): 752
Value read (02): 720
Value read (02): 752
Value read (04): 784
Value read (05): 832
Value read (06): 736
Value read (07): 800
Value read (08): 784
Value read (09): 784
Value read (10): 752
The average value read is: 769.6
Listing 8-2

Running the Calibration Code

Here, we see an average value of 770 (always round the number – you need integers). Further tests running the code on dry soil resulted in a value of 425 and for a wet plant, 3100. Thus, the thresholds for this example are 500 for dry and 2500 for wet. However, your results may vary greatly, so make sure to run this code with your sensors, board, and plant of choice.

Tip

To make things easier for calibrating the thresholds, use sensors from the same vendor. Otherwise, you may have to use a different set of thresholds for each sensor supported.

Notice the values read. As you can see, the values can vary from one moment to another. This is normal for these sensors. They are known for producing some jumpy values. Thus, you should consider sampling the sensor more than once to get an average over a short period rather than a single value. Even taking an average can be skewed slightly if one or more of the samples is off by a large margin. However, sampling even ten values and averaging will help reduce the possibility of getting an anomalous reading. We will do this in our project code.

Now that we have our threshold values for our sensors, we can begin with the code modules for the classes.

Class Modules

The first part of the project will be to create the code modules to contain the new classes that contain all the functionality to read data from the sensors, save the data to a file, and display the information on a display. In this section, we will see how to write the code for the class modules starting with the timer.

ReadTimer

We will use a new class named ReadTimer to create a hardware timer that we can use to read the values from the sensor. Since we will use a loop to read the sensor waiting 5 seconds for each read, we will need a minimum of 50–55 seconds to read ten values. Thus, we cannot set the update frequency to anything less than about one minute. While you may want to set this to a low value for testing, you certainly do not want to check the soil moisture of your plants every minute. That is, how often do you check your plants normally? Once every few days or once a day? Why check it sooner than normal?

Sampling Frequency

How often you sample data from a sensor (also called sampling rate) is often overlooked when designing sensor networks. The tendency is to store as many values as you can, thinking more data is better. But that is not applicable in the general case. Consider the plant monitoring project. If you normally check your plants once per day, how can sampling the sensors once every five minutes benefit you? It won’t!

Sampling rate must be calculated carefully to deliver the data you need to draw conclusions without creating too much data. While more data is always better than too little data, saving data too often at unrealistic frequencies can generate so much data that it could exceed the storage capacity of your device.

You should carefully consider the sampling rate when designing projects that sample sensors. Choose a sampling rate that is based on realistic expectations. Generally, if you are sampling data that can change very slowly, the sampling rate should be long. Sampling data that can change more quickly should have a higher (shorter time between samples) sampling rate.

For this project, we will set the frequency at two minutes (120,000 microseconds).

The design of this class is a bit new and may seem a bit unorthodox at first. Rather than use a timer as a callback function and assign it to a hardware timer, we will use the hardware timer to set a variable named data_read_event to True when the timer fires. We can then create a function to get the value of that variable as well as reset it (set it to False). This way, we can use the hardware timer to periodically set the data_read_event to True, and once we’ve read the data, set it to False. This allows the code for our soil moisture class to run independently from the timer.

In all, we will need three functions aside from the constructor as follows:
  • read_data_event(): The callback function as described earlier to set the read data event variable.

  • time_to_read(): A function callers can use to get the read data event variable.

  • reset(): A function callers can use to reset the read data event variable.

We will use this class in the soil moisture class where we only read the data when the data read event has fired (the variable is True). We will name the code module read_timer.py and place it on our Pico in the project3 folder (or similar). Listing 8-3 shows the completed code for the ReadTimer class. Take a moment to read through the code to see how it works.
# Import libraries
from machine import Timer
# Constants
DATA_READ_INTERVAL = 120000          # Increase this interval as needed
# Class to control reading data with a timer
class ReadEvent:
    def __init__(self):
        # Create and start the timer interrupt to read data
        self.data_read_event = True
        self.read_timer = Timer()
        self.read_timer.init(period=DATA_READ_INTERVAL,
            mode=Timer.PERIODIC, callback=self.read_data_event)
    # Callback for reading the data on the interval.
    def read_data_event(self, timer_obj):
        self.data_read_event = True
    # Check to see if it is time to read
    def time_to_read(self):
        return self.data_read_event
    # Reset the read event boolean (timer doesn't reset)
    def reset(self):
        self.data_read_event = False
Listing 8-3

The ReadTimer Class

SoilMoisture

This class is where we will read the soil moisture sensors and record the data in a CSV file. We will write the class so that most of the work in writing data to the CSV file will be functions used only within the class, but we will expose one function to clear the CSV file. Recall, the user interface has a button that clears the log file. The code is designed to create the file even if it doesn’t exist.

Constructor

The class is designed to read any number of sensors via a list of dictionaries passed when the class is instantiated. Thus, we will write the constructor to accept the list and set up the sensors. To do so, we will use a new list of dictionaries that contain the Pin class instantiations for controlling the power (turning on or off) and the ADC class instantiations for reading data (signal pin).

The dictionary required for the list passed to the constructor is defined as follows. Notice we specify the pin for reading the data, the power pin, as well as a nickname (for the display) and a location or description (for the log file):
sensor = {
  'pin': sensor_pin,
  'power': power_pin,
  'location': location or description
  'nick': nickname for the sensor
}
The code we will use in the main code to pass the data to the SoilMoisture class is as follows. Here, we define two sensors:
sensor_list = [
    {
        'pin': 27,
        'power': 21,
        'location': 'Green ceramic pot on top shelf',
        'nick': 'Ivy',
    },
    {
        'pin': 28,
        'power': 22,
        'location': 'Fern on bottom shelf',
        'nick': 'Fern',
    }
]
The code for the constructor is easy to follow, but one portion that needs examination is the code for creating a new list from the dictionaries passed to the constructor. We will use one GPIO pin to turn on the power for the sensor and another pin to read it from the ADC class. Thus, we create the instances of the Pin and ADC classes as we build the new list of dictionaries as follows. This piece of code is a good example of how dictionaries can help keep multiple instantiations of classes organized:
self.sensors = []
for sensor in sensor_list:
    # Setup the dictionary for each soil moisture sensor
    soil_moisture = {
        'sensor': ADC(Pin(sensor['pin'])),
        'power': Pin(sensor['power'], Pin.OUT),
        'location': sensor['location'],
        'nick': sensor['nick']
    }
    self.sensors.append(soil_moisture)
We also need to pass in the RTC class and the file name of the log file. Thus, calling the constructor requires passing three values as follows:
plants = SoilMoisture(rtc, DATA_FILENAME, sensor_list)
Public Functions
Other than the constructor, we need the following functions that will be called from our main code. Recall, we call these functions “public” functions since they can be used outside the class (by the caller):
  • clear_log(): Clears the log file (erases all data in the file)

  • get_values(): Returns the values read

  • read_sensors(): Reads the data from the sensor if the read timer has fired (data_read_event is True)

The code for these functions are simple enough, but some explanation is needed for the read_sensors() function. In this function, we use a private function to read the value from the sensor by passing the power and signal pin variables to the private function named _get_value() as defined in the following:
# Read the sensor 10 times and average the values read
def _get_value(self, adc, power):
    total = 0
    # Turn power on
    power.high()
    for i in range (0,10):
        # Wait for sensor to power on and settle
        sleep(5)
        # Read the value
        value = adc.read_u16()
        total += value
    # Turn sensor off
    power.low()
    return int(total/10)

Notice we use the same code from the threshold.py example to turn on the power pin, wait five seconds, then read the value using the ADC class. We do this ten times and then average the values.

Private Functions
There are a number of other functions used only within the class. Recall, we name these with an underscore as the first character in the name signifying them as private. The following lists the functions and their uses. We leave the examination of the code for these functions as an exercise:
  • _format_time(): Format the time (epoch) for a better view

  • _get_value(): Read the sensor ten times and average the values read

  • _convert_value(): Convert the raw sensor value to an enumeration

If you are wondering about the data file, you need not worry. The following shows a mock-up of data you can use in your tests:
9/6/2021 6:22    Ivy    760    ok    Green ceramic pot on top shelf
9/6/2021 6:22    Fern   772    ok    Fern on bottom shelf
9/6/2021 6:23    Ivy    742    ok    Green ceramic pot on top shelf
9/6/2021 6:23    Fern   756    ok    Fern on bottom shelf
9/6/2021 6:25    Ivy    761    ok    Green ceramic pot on top shelf
9/6/2021 6:25    Fern   763    ok    Fern on bottom shelf
9/6/2021 6:26    Ivy    768    ok    Green ceramic pot on top shelf
9/6/2021 6:26    Fern   760    ok    Fern on bottom shelf
9/6/2021 6:27    Ivy    763    ok    Green ceramic pot on top shelf
9/6/2021 6:27    Fern   756    ok    Fern on bottom shelf
9/6/2021 6:28    Ivy    760    ok    Green ceramic pot on top shelf
9/6/2021 6:28    Fern   753    ok    Fern on bottom shelf
9/6/2021 6:29    Ivy    753    ok    Green ceramic pot on top shelf

If you want to start with some sample data, you can do so, but just make sure it is comma separated with no spaces and one line of data per row.

We will name the code module soil_moisture.py and place it on our Pico in the project3 folder (or similar). Listing 8-4 shows the completed code for the SoilMoisture class. Take a moment to read through the code to see how it works. Notice the constants that define the thresholds for wet and dry soil measurements. Recall, we got these through experimenting with the preceding threshold example code:
# Import libraries
from machine import ADC, Pin
from utime import sleep
import os
# Thresholds for the sensors
LOWER_THRESHOLD = 500
UPPER_THRESHOLD = 2500
UPDATE_FREQ = 120   # seconds
class SoilMoisture:
    # Initialization for the class (the constructor)
    def __init__(self, rtc, csv_filename, sensor_list):
        self.rtc = rtc
        # Try to access the file system and make the new path
        self.sensor_file = csv_filename
        # Loop through the sensors specified and setup a new dictionary
        # for each sensor that includes the power and ADC pins defined.
        self.sensors = []
        for sensor in sensor_list:
            # Setup the dictionary for each soil moisture sensor
            soil_moisture = {
                'sensor': ADC(Pin(sensor['pin'])),
                'power': Pin(sensor['power'], Pin.OUT),
                'location': sensor['location'],
                'nick': sensor['nick']
            }
            self.sensors.append(soil_moisture)
        self.values_read = None
        print("Soil moisture sensors are ready...")
    # Clear the log
    def clear_log(self):
        log_file = open(self.sensor_file, 'w')
        log_file.close()
    # Get the values read
    def get_values(self):
        return self.values_read
    # Format the time (epoch) for a better view
    def _format_time(self):
        # Get datetime
        dt = self.rtc.datetime()
        return "{0:02}/{1:02}/{2:04} "
               "{3:02}:{4:02}:{5:02}".format(dt[1], dt[2], dt[0], dt[4], dt[5], dt[6])
    # Read the sensor 10 times and average the values read
    def _get_value(self, adc, power):
        total = 0
        # Turn power on
        power.high()
        for i in range (0,10):
            # Wait for sensor to power on and settle
            sleep(5)
            # Read the value
            value = adc.read_u16()
            total += value
        # Turn sensor off
        power.low()
        return int(total/10)
    # Monitor the sensors, read the values and save them
    def read_sensors(self):
        log_file = open(self.sensor_file, 'a')
        self.values_read = []
        for sensor in self.sensors:
            # Read the data from the sensor and convert the value
            value = self._get_value(sensor['sensor'], sensor['power'])
            print("Value read: {0}".format(value))
            # datetime,num,value,enum,location
            message = ("{0},{1},{2},{3},{4}"
                       "".format(self._format_time(),
                                 sensor['nick'], value,
                                 self._convert_value(value),
                                 sensor['location']))
            log_file.write("{0} ".format(message))
            value_read = {
                'timestamp': self._format_time(),
                'raw_value': value,
                'value': self._convert_value(value),
                'location': sensor['location'],
                'nick': sensor['nick']
            }
            self.values_read.append(value_read)
        log_file.close()
    # Convert the raw sensor value to an enumeration
    def _convert_value(self, value):
        # If value is less than lower threshold, soil is dry else if it
        # is greater than upper threshold, it is wet, else all is well.
        if (value <= LOWER_THRESHOLD):
            return "dry"
        elif (value >= UPPER_THRESHOLD):
            return "wet"
        return "ok"
Listing 8-4

The SoilMoisture Class

PlantDisplay

The last class we will create is a class to display data to the Pico Display. We place this code in a separate class to keep the display portion of the code separate from the data. There are no surprises in this code other than how to initialize and communicate with the Pico Display.

Recall, the user interface allows the user to turn the screen off and back on, so this class will need to take care of those operations. Also, there are cases where we want to display a message to the user, so the class will provide that feature as well.

The class has the following functions. The code for these functions is easy to understand, and the discovery of how the code works is left as an exercise:
  • clear_screen(): Clear the screen

  • _write_text(): Write data to the screen

  • screen_on(): Turn the screen on

  • screen_off(): Turn the screen off

  • show_data(): Show the data on the OLED

  • show_message(): Clear the screen and write a message

  • is_screen_on(): Return True if the display is turned on

  • button_pressed(): Return the button pressed or None if no buttons are pressed

We will name the code module plant_display.py and place it on our Pico in the project3 folder (or similar). Listing 8-5 shows the completed code for the PlantDisplay class. Take a moment to read through the code to see how it works.
# Import libraries
from utime import sleep
import picodisplay as display
# Constants
DEFAULT_FONT_SCALE = 2
WRAP_SIZE = 240
BUTTON_A = 10
BUTTON_B = 20
BUTTON_X = 30
BUTTON_Y = 40
class PlantDisplay:
    """
    This class displays data from one or more soil moisture sensors.
    """
    # Initialization for the class (the constructor)
    def __init__(self):
        # Setup the Pico Display with a bytearray display buffer
        buf = bytearray(display.get_width() * display.get_height() * 2)
        display.init(buf)
        display.set_backlight(0.5)
        self.clear_screen()
        self.display_on = True
        self.led_on = False
    # Function to clear the screen
    def clear_screen(self):
        display.set_pen(0, 0, 0)
        display.clear()
        display.update()
        display.set_pen(255, 255, 255)
    # Function to write data to the screen
    def _write_text(self, message, x, y, scale=DEFAULT_FONT_SCALE):
        self.clear_screen()
        display.text(message, x, y, WRAP_SIZE, scale)
        display.update()
    # Turn screen on
    def screen_on(self):
        # Turns on the display and reads the data
        display.set_backlight(0.5)
        self._write_text("Display ON", 10, 10, 3)
        sleep(2)
        self.display_on = True
    # Turn screen off
    def screen_off(self):
        # Turns off the display
        self._write_text("Display OFF", 10, 10, 3)
        sleep(2)
        self.clear_screen()
        display.set_backlight(0)
        self.display_on = False
    # Show the data on the LED
    def show_data(self, soil_data):
        y = 40
        self.clear_screen()
        display.text("Plant Monitor", 10, 10, WRAP_SIZE, 3)
        for data in soil_data:
            display.text(data['nick'], 10, y, WRAP_SIZE, 3)
            display.text(str(data["value"]), 105, y, WRAP_SIZE, 3)
            display.text(str(data["raw_value"]), 160, y, WRAP_SIZE, 3)
            y = y + 20
        display.update()
    # Clear the screen and write a message.
    def show_message(self, message):
        self._write_text(message, 10, 10, 3)
    # Return True if the display is turned on
    def is_screen_on(self):
        return self.display_on
    # Return the button pressed or None if no buttons are pressed
    def button_pressed(self):
        if display.is_pressed(display.BUTTON_A):
            return BUTTON_A
        if display.is_pressed(display.BUTTON_B):
            return BUTTON_B
        if display.is_pressed(display.BUTTON_X):
            return BUTTON_X
        if display.is_pressed(display.BUTTON_Y):
            return BUTTON_Y
        return None
Listing 8-5

The PlantDisplay Class

Now, let’s see the main code for this project.

Main Code

The main code for this project is stored in a file named main.py. It is a continuation of the pattern we saw previously where we create a function named main() and call it from the conditional at the bottom of the file. So, there’s nothing new there, but it is best to take a slower walk through this code as it defines how the project works.

What is new for this project is the use of the three classes we created along with the RTC class we saw in Chapter 6. Thus, the import section is a bit longer. In fact, we will need to import a number of things from our classes as well as the I2C and Pin classes from the machine library. The following shows the imports for the main code:
from machine import Pin, SoftI2C
from utime import sleep
from project3.plant_display import PlantDisplay, BUTTON_A, BUTTON_B, BUTTON_X, BUTTON_Y
from project3.urtc import DS1307
from project3.soil_moisture import SoilMoisture
from project3.read_timer import ReadEvent
import sys
We also create a constant to store the log file name that we pass to the SoilMoisture class when we instantiate it:
DATA_FILENAME = 'plant_data.csv'
The instantiation of the class variables is a bit longer but not difficult. The following shows how we create each of the class variables:
# Setup the display
display = PlantDisplay()
display.clear_screen()
# Setup I2C for RTC
sda = Pin(10)
scl = Pin(11)
# Software I2C (bit-banging) for the RTC
i2c = SoftI2C(sda=sda, scl=scl, freq=100000)
# Initialize class instance variables for the RTC
rtc = DS1307(i2c)
Next, we create the list of dictionaries that define the sensors we want to use where we specify the pin numbers for the power and signal lines as well as a description (location) and nickname. We then pass that to the soil moisture class to complete the instantiation:
# Setup the sensors
sensor_list = [
    {
        'pin': 27,
        'power': 21,
        'location': 'Green ceramic pot on top shelf',
        'nick': 'Ivy',
    },
    {
        'pin': 28,
        'power': 22,
        'location': 'Fern on bottom shelf',
        'nick': 'Fern',
    }
]
# Setup the soil moisture object instance from the SoilMoisture class
plants = SoilMoisture(rtc, DATA_FILENAME, sensor_list)
After that, we enter a loop that simply calls the data_read_event.time_to_read() function, and if it returns True, we read the sensors and display the data. We also call the display.button_pressed() function, and if it returns a value other than null, we act for the specific button press detected. Listing 8-6 shows the complete code for the main.py code file. Take a moment and scan the code to see how the button features are implemented.
# Import libraries
from machine import Pin, SoftI2C
from utime import sleep
from project3.plant_display import PlantDisplay, BUTTON_A, BUTTON_B, BUTTON_X, BUTTON_Y
from project3.urtc import DS1307
from project3.soil_moisture import SoilMoisture
from project3.read_timer import ReadEvent
import sys
# Constants
DATA_FILENAME = 'plant_data.csv'
def main():
    # Global variables
    data_read_event = False
    print("Hello! Welcome to the plant monitor program.")
    # Setup the Pico Display
    display = PlantDisplay()
    display.clear_screen()
    # Setup I2C for RTC
    # Note: RGB LED is on 6, 7, and 8. If you use these, the LED will blink when you read the sensor
    sda = Pin(10)
    scl = Pin(11)
    # Software I2C (bit-banging) for the RTC
    i2c = SoftI2C(sda=sda, scl=scl, freq=100000)
    # Initialize class instance variables for the RTC
    rtc = DS1307(i2c)
    #start_datetime = (2021, 08, 12, 5, 14, 54, 22)
    #rtc.datetime(start_datetime)
    # Setup the sensors
    sensor_list = [
        {
            'pin': 27,
            'power': 21,
            'location': 'Green ceramic pot on top shelf',
            'nick': 'Ivy',
        },
        {
            'pin': 28,
            'power': 22,
            'location': 'Fern on bottom shelf',
            'nick': 'Fern',
        }
    ]
    # Setup the soil moisture object instance from the SoilMoisture class
    plants = SoilMoisture(rtc, DATA_FILENAME, sensor_list)
    display.show_message("Reading data...")
    data_read_event = ReadEvent()
    while True:
        # Check to see if it is time to read the data
        if data_read_event.time_to_read():
            data_read_event.reset()
            if display.is_screen_on():
                print("Reading data...")
                plants.read_sensors()
                values = plants.get_values()
                display.show_data(values)
        sleep(1)
        # Check to see if a button was pressed
        button_pressed = display.button_pressed()
        if not button_pressed:
            continue
        print("Button pressed", button_pressed)
        # Turning the log off only works when the screen is on.
        if button_pressed == BUTTON_A and display.is_screen_on():
            # Clear the log.
            display.show_message("Press B to clear the log.")
            # wait for 5 seconds then ignore the call
            for i in range(10):
                if display.button_pressed() == BUTTON_B:
                    display.show_message("Log cleared.")
                    print('Requesting clear log.')
                    plants.clear_log()
                    sleep(2)
                    display.show_message("Reading data...")
                    data_read_event.reset()
                    break
                else:
                    sleep(0.5)
        # Allow user to turn on the screen if it is off
        elif button_pressed == BUTTON_X and not display.is_screen_on():
                display.screen_on()
                display.show_message("Reading data...")
                data_read_event.reset()
        # Allow user to turn off the screen if it is on
        elif button_pressed == BUTTON_Y and display.is_screen_on():
                display.screen_off()
        sleep(1)
if __name__ == '__main__':
    try:
        main()
    except (KeyboardInterrupt, SystemExit) as err:
        print(" bye!")
        sys.exit(0)
Listing 8-6

Plant Monitor Complete Code (main.py)

Now, let’s run this project!

Execute

Now is the fun part! We’ve got the code all set up to read soil moisture from our plants and display the data. But first, we have to copy all of our files to the Pico. Go ahead and create a folder named project3 on the Pico and then copy the soil_moisture.py, plant_display.py, read_event.py, and urtc.py (from Chapter 6) to the project3 folder on the Pico. Finally, copy the main.py file to the root folder of the Pico.

Next, we need to insert our soil moisture sensors into our plants. If you need to relocate the plants to your work area, go ahead and do so while you test the project. You may find you will need longer jumper wires if you plan to mount your Pico near the normal location for your plants. Both Adafruit and SparkFun sell longer jumper wires (or you can make your own).

Once those files are copied and the soil moisture sensors are inserted into your plants, you can test the main.py code by running it from Thonny. You should see data appear on the screen after about two minutes similar to Figure 8-9.
Figure 8-9

Plant monitor project

The code has some debug statements inserted which you can view in the REPL console if you run the main.py from Thonny. The following shows an example of the output you will see:
Hello! Welcome to the plant monitor program.
Soil moisture sensors are ready...
Reading data...
Value read: 760
Value read: 764
Reading data...
Value read: 752
Value read: 764
...

If you do not see the output or the Pico Display does not show any data, be sure to double-check all of your wiring and make sure you’ve copied all of the files to the proper locations on the Pico.

Once everything is working, you can disconnect your Pico and connect it to a 5V power supply to run the project on boot. Cool!

Taking It Further

This project, like the last one, shows excellent prospects for reusing the techniques in other projects. If you liked seeing your sensor data on the display or want to examine the soil moisture data collected, you should consider taking time to explore some embellishments. Here are a few you may want to consider. Some are easy and some may be a challenge or require more research:
  • Add more sensors to expand your project to more plants.

  • Add LEDs to your board to illuminate when the plants need watering.

  • Change the color of the text where OK is green, dry is red, and wet is blue.

  • Make RGB light each time a sensor is read. Use a different color for each sensor.

  • Change the frequency of the sensor read.

  • Make the B button force a new sensor read.

  • Save the sensor configuration to a file and read it from the main application instead of hard-coding the data.

  • Move the log write/read to a new class and control it from the main.py module.

Of course, if you want to press on to the next project, you’re welcome to do so, but take some time to explore these potential embellishments – it will be a good practice.

Summary

One of the more common forms of electronics or IoT projects is those that generate data (sometimes called data collectors). The implementation of data collectors can vary greatly, but they generally store the data in some location and provide a way to view the data. The simplest forms are those that log the data locally (sometimes called data loggers), as opposed to these that transmitted to a remote server, where the data is stored in a database or a cloud service.

In this chapter, we saw a MicroPython project that logs data read from a series of soil moisture sensors. We created a plant monitoring solution that saved the data to the local SD card. The project also displayed the data on a Pico Display so that we can see the data at any time. This project can be used as a template for a host of data collection projects. You can simply follow the pattern established in this chapter and build your own data logging project.

In the next chapter, we will take a look at a technology that makes creating electronics projects easier using a component system called Grove.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.236.100.210