© 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_12

12. Project: Monitoring your Environment

Charles Bell1  
(1)
Warsaw, VA, USA
 

One of the most common examples of electronics projects is a project to monitor the environment. Given the current ongoing health crisis, this project may be something useful to help us understand the conditions of our indoor environment. There are several products you can buy to monitor indoor air quality, and for those with severe allergies and similar health conditions (some can be life-threatening), an indoor air monitor may be a requirement to treat their condition.

In this chapter, we will see how to create a simple indoor environment monitor that detects air quality (the presence of harmful gases), dust concentration, barometric pressure, and temperature, displaying the data on a small OLED. We’ll see more analog and digital modules as well as the use of multiple I2C Grove modules.

Let’s get started.

Project Overview

The project for this chapter is designed to demonstrate how to use analog, digital, and multiple I2C devices on the same Grove host adapter to build an indoor environment monitor. It uses several sensors to sample the air for gases and dust as well as sampling the temperature and barometric pressure. As you will see, this is the most challenging of the projects in this book not only for the number of modules used but also for the complexity of the code.

Caution

The project for this chapter should not be used for treating life-threatening health disorders. It is meant to be a demonstration of what is possible and should not be relied upon for critical health choices.

We will use a simple loop to sample the sensors every minute. For most uses, that is actually too frequent as indoor air quality may not change quickly. If you choose to install this project for long-term use, you may want to experiment with longer sampling times especially if you plan to log the data.

If you haven’t read and worked on the other projects, you may want to work on the earlier chapters first and save this one until you’ve mastered a few of the others.

For those with access to a 3D printer, we will also see a simple mounting plate you can print and install the modules onto to protect them and make it easier to use in running the project.

Let’s see what hardware we will need.

Required Components

The hardware needed for this project is listed in Table 12-1. URLs for each component are included for ease of ordering including duplicate entries for alternative vendors. We will use a Grove OLED 0.96 and Buzzer along with Grove High Accuracy Temperature, Barometer, Air Quality, and Dust sensors. While this project doesn’t include any Qwiic components, three of these sensors use I2C.
Table 12-1

Hardware Needed for the Environment Monitor Project

Component

Qty

Description

Cost

Links

Grove OLED 0.96 v1.3

1

Display

$16.40

www.seeedstudio.com/Grove-OLED-Display-0-96.html

Grove Buzzer

1

Buzzer

$2.10

www.seeedstudio.com/Grove-Buzzer.html

Grove I2C High Accuracy Temperature Sensor

1

MCP9808

$5.20

www.seeedstudio.com/Grove-I2C-High-Accuracy-Temperature-Sensor-MCP9808.html

Grove Temperature and Barometer Sensor

1

BMP280

$9.80

www.seeedstudio.com/Grove-Barometer-Sensor-BMP280.html

Grove Air Quality Sensor

1

Air quality

$10.90

www.seeedstudio.com/Grove-Air-Quality-Sensor-v1-3-Arduino-Compatible.html

Grove Dust Sensor

1

Dust

$12.70

www.seeedstudio.com/Grove-Dust-Sensor-PPD42NS.html

Grove – I2C Hub (6 Port)

1*

I2C Hub

$1.70

www.seeedstudio.com/Grove-I2C-Hub-6-Port-p-4349.html

$6.95

shop.switchdoc.com/collections/grove/products/grove-6-port-12c-hub

Grove Cable

7

Cables

 

www.sparkfun.com/products/14426

Grove Shield for Pi Pico V1.0

1

Host board

$4.30

www.seeedstudio.com/Grove-Shield-for-Pi-Pico-v1-0-p-4846.html

* You need only one of these.</Para></Footnote>

About the Hardware

Let’s discuss these components briefly. We will discover how to work with the hardware in more detail later in the chapter. We saw the buzzer in Chapter 11, but the remaining five modules are new.

Grove OLED 0.96

Since we have more data than can fit on two short lines, we must change our display of choice to use a small OLED module. The Grove OLED 0.96 is a monochrome 128×64 dot matrix display with high brightness and contrast ratio and low power consumption. You can address all of the pixels (dots) on the screen too. Note that there are several versions of this module. We will be using the version that uses the SSD1308 chip. If you use a different version, you may need to use a different software library. Figure 12-1 shows the Grove OLED Display.
Figure 12-1

Grove OLED Display (courtesy of seeedstudio.com)

Grove I2C High Accuracy Temperature Sensor (MCP9808)

The Grove – I2C High Accuracy Temperature Sensor (or simply MCP9808) is a high accuracy digital module based on the MCP9808 microchip. It features high accuracy measuring temperatures ranging from –40 to 125 degrees Celsius. While there are other temperature sensors available for use, this module is not only reliable and accurate, but it also uses I2C for easy integration into our environment monitor. Figure 12-2 shows the Grove I2C High Accuracy Temperature Sensor (MCP9808).
Figure 12-2

Grove I2C High Accuracy Temperature Sensor (courtesy of seeedstudio.com)

If you recall from our Qwiic modules, most permit you to alter the I2C address and other features using jumpers. This module is similar, and you can change the I2C address by soldering the jumpers on the back of the module. Figure 12-3 shows what the jumpers look like. Notice the labels for each.
Figure 12-3

Grove I2C Jumpers – Temperature Sensor (courtesy of seeedstudio.com)

You can change the I2C address by soldering across the jumpers as shown in Table 12-2.
Table 12-2

I2C Address Map for the Grove High Accuracy Temperature Sensor

A0

A1

A2

Address

0

0

0

0x18

0

0

1

0x19

0

1

0

0x1A

0

1

1

0x1B

1

0

0

0x1C

1

0

1

0x1D

1

1

0

0x1E

1

1

1

0x1F

You may need to change the address if you add another I2C module with the same address or if you want to use multiple Grove I2C High Accuracy Temperature Sensors.

Grove Temperature and Barometer Sensor (BMP280)

Since we are capturing temperature, we may also want to measure the barometric pressure. The Grove Temperature and Barometer Sensor (or simply BMP280) is an excellent choice for that data. While it can also measure temperature and can be used to determine altitude, we will use it solely for the barometric pressure. If you’d like to see how to do that, visit www.seeedstudio.com/Grove-Barometer-Sensor-BMP280.html for more information. Figure 12-4 shows the Grove Temperature and Barometer Sensor.
Figure 12-4

Grove Temperature and Barometer Sensor (courtesy of seeedstudio.com)

Like the High Accuracy Temperature Sensor, you can also change the I2C address for this module using the jumpers on the back as shown in Figure 12-5.
Figure 12-5

Grove I2C Jumpers – Barometric Pressure Sensor (courtesy of seeedstudio.com)

Here, our choices are a bit narrower. We can use the jumpers to change the address from 0x76 (default) to 0x77.

Grove Air Quality Sensor

The Grove Air Quality Sensor is an analog sensor designed for indoor air quality testing and measures certain gases including carbon monoxide, alcohol, acetone, thinner, formaldehyde, and similar slightly toxic gases. While it does not differentiate among the gases, it provides a general value that you can use to determine thresholds for “safe” air quality. In fact, we will write the code to determine ranges for good, fair, and poor air quality. Figure 12-6 shows the Grove Air Quality Sensor.
Figure 12-6

Grove Air Quality Sensor (courtesy of seeedstudio.com)

Grove Dust Sensor

We will also be measuring the dust or particles in the air. The Grove Dust Sensor is a digital module and an excellent choice because it provides a percentage of particles found in the air. We can therefore write our code to test for a threshold of particulates in the air to determine dusty or even smoky conditions. Figure 12-7 shows the Grove Dust Sensor.
Figure 12-7

Grove Dust Sensor (courtesy of seeedstudio.com)

There is one more unexpected component that we will need. We have three modules that use I2C connections. Most Pico adapter boards (shields) offer only two I2C connections: I2C0 and I2C1. However, recall that I2C connections are not limited to only one per bus. Rather, we can connect multiple modules to the same I2C bus where each module is referenced by its address.

To achieve this, we will need a little help from another component. We need a Grove I2C Hub. Seeed Studio sells one that is the same 40mm format as the sensors we will be using (www.seeedstudio.com/Grove-I2C-Hub-6-Port-p-4349.html). SwitchDoc Labs also offers a Grove I2C Hub (https://shop.switchdoc.com/collections/grove/products/grove-6-port-12c-hub) which is a bit larger but has the same number of connectors for Grove I2C modules. Figure 12-8 shows the Grove I2C Hub.
Figure 12-8

Grove I2C Hub (courtesy of seeedstudio.com)

Figure 12-9 shows the SwitchDoc Labs I2C Hub.
Figure 12-9

SwitchDoc Labs I2C Hub (courtesy of switchdoc.com)

As you may surmise, we will be connecting all of our I2C modules to the hub and then the hub to I2C0 on the Pico Grove Shield.

Set Up the Hardware

For this project, we need six connections for the six modules we will using. The connections and their types are shown in Table 12-3.
Table 12-3

Environment Monitor Connections

Module

Description

Pico Shield Connector

I2C Hub

Hub

I2C0

OLED

Display

I2C Hub

MCP9808

Temperature

I2C Hub

BMP280

Barometer

I2C Hub

Buzzer

Sound

A0

Dust Sensor

Dust

D20/21

Air Quality

Air quality

A1

Thus, we will need seven Grove cables. Figure 12-10 shows how the connections will look once all of the modules are connected to the Pico Shield and I2C Hub.
Figure 12-10

Environment monitor project Grove connections

Using a Mounting Plate

Since we have so many components and a bunch of cables connecting them all together, using the project can take a little bit of space, and with all of those modules dangling by their cables tethered only to the Grove Pico Shield, you run the risk of accidentally unplugging a module, or, worse, the electronics on the module may come into contact with conductive material. You can mitigate this somewhat by using a double-sided tape to tape them to your desk, but a better solution is to create a mounting plate. We could create a full enclosure, but as you will see, leaving the modules exposed gives the project a genuine cool factor.

If you have your own 3D printer or have access to a 3D printer, you can print a mounting plate. The source code for this chapter includes the 3D printing files you need to create a simple mounting plate to mount the modules arranged in a space-efficient manner. Figure 12-11 shows the mounting plate.
Figure 12-11

3D mounting plate design for the environment monitor project

Notice there are mounting points for all of the sensor modules along with the OLED, Pico Shield (on the left side), and an I2C Hub for both the Seeed Studio and SwitchDoc Labs versions (above the Pico Shield on the left).

If you’re thinking this resembles a simple plank of wood (which would work equally as well), there are feet on the bottom of the plate and places for the nuts on the bottom as well. In fact, you will need to print this plate upside down.

There is also a set of spacers you will need to print as shown in Figure 12-12.
Figure 12-12

3D spacers design for the Simon Says project

Notice from left to right, there are (11) short M2 spacers for the MCP9808, BMP280, air quality, and buzzer modules. There are (3) long M2 spacers for the OLED module. Finally, there is (1) M4 spacer for the dust sensor.

To mount the modules, you will need the following hardware:
  • (19) M2 nuts

  • (1) M4 nut

  • (16) M2x8mm bolts

  • (3) M2x19mm bolts

  • (1) M4x5mm bolt

To assemble the mounting plate, begin by mounting the dust sensor in the center, the buzzer below it, the OLED in the upper left, and the air quality, BMP280, and MCP9808 on the bottom and right (any order is fine). Finally, mount the I2C Hub in the upper left and the Pico Shield on the left. Figure 12-13 shows the completed project with the cables routed to the top.
Figure 12-13

Mounting the modules to the 3D printed plate

Before you celebrate by plugging all of your modules into your host adapter, take a few moments to carefully label each of the cables using a piece of masking or painter’s tape. Write the connector label on the tape so you don’t have to worry about connecting them to the wrong connectors. The I2C cables can be plugged into any of the I2C connectors. Figure 12-14 shows a close-up of how the I2C Hub is mounted.
Figure 12-14

I2C Hub mounting on the 3D printed plate

If you have experience creating 3D models for printing, feel free to experiment with creating your own enclosure – perhaps one that also includes a battery and a small form factor host board. If you decide to build a complete enclosure, make sure to place holes or a grid opening over the sensors for airflow. The dust and air quality sensors are the modules that need openings most.

Now that we know more about the hardware for this chapter, let’s write the code!

Write the Code

The code for this project involves following the usual pattern. For this project, that means using analog and digital modules as well as multiple I2C devices. The air quality sensor is an analog sensor, the buzzer and dust sensors are digital modules, and the MCP9808, BMP280, and OLED are I2C devices.

Like the previous projects, we will use a class to wrap our functionality.

Let’s first look at the software libraries we will need to download.

Install Software Libraries

We will need to download three libraries. Specifically, we need a library for the BMP280, OLED, and MCP9808. However, there is no MicroPython library for the Dust Sensor, so we will be writing our own library for that sensor.

We can get the BMP280 library from https://github.com/dafvid/micropython-bmp280, the OLED library from https://github.com/micropython/micropython/tree/master/drivers/display, and the MCP9808 library from https://github.com/adafruit/Adafruit_CircuitPython_MCP9808. You can install them with the following commands:
$ pip3 install bmp280
$ pip3 install adafruit-circuitpython-mcp9808

Once you have those libraries installed, we’re ready to write the code.

Create the Class Modules

While we will not dive into every line of code, we will see some of the more complex code and those areas discussed that differ significantly from what you may have experienced thus far in your MicroPython journey. You can read the code and learn more about how it works at your leisure.

Since there are several code modules (files) for this project, it is recommended that you create a project folder (e.g., named project6) and save all of the files there. It will make copying them to your Pico easier later.

Let’s start with the most difficult: a solution to read the dust sensor. As mentioned, there is no library currently available to read this sensor on the Pico (but there are some available for other platforms), so we must write the code ourselves. As you will see, it is a bit tricky.

DustSensor Class

While this module is a class, it contains a single function. This may seem strange, and you may tend to implement the single function in either the main code file or as a separate code module. That will work just fine, but it is not the recommended mechanism.

A class module is a very useful tool for developers because it allows us to place code in a single container that works on a common set of data. Thus, it models an object or concept in our projects. It also allows us to keep state (a set of assignments or initializations) for the object during its lifetime.

For example, if you have a single function that initializes some data variables based on data passed as parameters and then initializes another class instance variable (or several), each time the function is called, it repeats all of the setup, which is wasteful. Using a class allows us to do that setup code once.

Thus, the code for the dust sensor is placed in a class module named dust_sensor.py, and the class is named DustSensor. You can create the file now in Thonny.

As mentioned, there is a single function named read() that reads the data from the dust sensor. Unlike other read functions you may be accustomed to, this function cannot simply query another class or abstract function to get the data. In this case, we are reading directly from the hardware via a digital pin on the Pico. We will allow the caller to specify the pin to use in the constructor, but we also have a default value of the pin number (DUST_SENSOR_PIN = 20) for the dust sensor data.

But there is a catch. We cannot simply read the value once. It doesn’t work like that. The sensor is designed to emit a pulse over a period of time during which it will turn “on” and “off” in a variable frequency. Note this is essentially the pulse-width modulation (PWM) that we’ve seen in controlling LEDs and other devices to limit power to the device.

To determine what this “pulse” means for this sensor, we learn from the data sheet that we can determine the amount of dust read by counting the number of times the value is set to “on” (high) over a period of 30 seconds. Figure 12-15 shows an excerpt of the data sheet that documents this process.
Figure 12-15

Excerpt of the Grove Dust Sensor data sheet (LPO)

The data sheet for the dust sensor can be found at

https://raw.githubusercontent.com/SeeedDocument/Grove_Dust_Sensor/master/resource/Grove_-_Dust_sensor.pdf

What we are seeing there is the dust sensor sends a pulse of low (off) over a period of 30 seconds. These pulses can occur at various times and can last for a short period of time. The total of the time spent in the “off” state over the interval (30 seconds) is called the low pulse occupancy time (LPO) and is represented as a percentage. Easy, right? Well, sort of.

There are two implications for our code we must adhere. First, we must read the sensor over a 30-second period. Thus, there will not be anything else going on during that time (unless you want to do some form of threading or interrupts). This means, at a minimum, our read function will run for 30 seconds. Second, we must write our code to quickly capture when the pin goes to 0 (off), and total the time spent in that state. This is the most difficult aspect of the code.

Fortunately, we can view the other libraries for the dust sensor to learn how it was done for other platforms. In fact, the code from a similar Python implementation will work just fine. Cool. The following is an excerpt for the code to capture the time spent in the off state:
while time.time() - starttime <= SAMPLETIME_S:  # in sampling window
    if self.dust_sensor_pin.value() == 0:
        start = time.ticks_us()
    elif start > 0:
        value = time.ticks_diff(time.ticks_us(), start)
        # Low Pulse Occupancy Time (LPO Time) in microseconds
        low_pulse_occupancy += value
        start = 0

As you can see, we simply sum the time for a variable named low_pulse_occupancy. Now, we need to convert that value into a percentage concentration, and that is where it gets tricky.

We have to take the LPO calculated and form a ratio and then take the ratio and use a formula to convert it. We find this formula on the data sheet in the form of a graph that describes the performance of the dust sensor under controlled conditions. Figure 12-16 shows an excerpt from the data sheet with this data.
Figure 12-16

Excerpt of the Grove Dust Sensor data sheet (graph)

Formulating a formula to model this graph is a bit complex, but once again we can copy what others have done and implement the same in our code. The following shows the formula used in a Python library for the Raspberry Pi. It is not necessary to understand every nuance of the formula; rather, for our uses, it is sufficient to understand the source of the information – the graph on the data sheet.
# ratio: percentage of low pulses over the sampling window
ratio = low_pulse_occupancy / (SAMPLETIME_S * 1e+6)
concentration = 1.1 * (ratio ** 3) - 3.8 * (ratio ** 2) + 520 * ratio + 0.62

OK, that’s the hard part of this code. The rest are techniques you’ve seen before, so we won’t go through every line. However, you should examine the class member variables to see the use of the self.last_data variable. We use this to store the last known reading. When we calculate the concentration, we either return the concentration calculated or return the last known reading in case the dust sensor doesn’t produce enough samples (pulses) to calculate the reading.

Listing 12-1 shows the complete code for the DustSensor class with comments removed for brevity.
import machine
import time
SAMPLETIME_S = 30
DUST_SENSOR_PIN = 20
class DustSensor:
    """DustSensor Class"""
    def __init__(self, sensor_pin=DUST_SENSOR_PIN):
        """Constructor"""
        # Setup dust sensor
        self.dust_sensor_pin = machine.Pin(sensor_pin, machine.Pin.IN)
        self.last_reading = 0.62 # Minimal value possible from data sheet
    def read(self):
        """Read dust sensor"""
        # start time of sampling window in seconds
        starttime = time.time()
        # ratio of LPO time over the entire sampling window
        ratio = 0
        #  Low Pulse Occupancy Time (LPO Time) in microseconds
        low_pulse_occupancy = 0
        # concentration based on LPO time and characteristics graph (datasheet)
        concentration = 0
        start = 0
        while time.time() - starttime <= SAMPLETIME_S:  # in sampling window
            if self.dust_sensor_pin.value() == 0:
                start = time.ticks_us()
            elif start > 0:
                value = time.ticks_diff(time.ticks_us(), start)
                # Low Pulse Occupancy Time (LPO Time) in microseconds
                low_pulse_occupancy += value
                start = 0
        # ratio: percentage of low pulses over the sampling window
        ratio = low_pulse_occupancy / (SAMPLETIME_S * 1e+6)
        concentration = 1.1 * (ratio ** 3) - 3.8 * (ratio ** 2) + 520 * ratio + 0.62
        if concentration != 0.62:
            print("PM concentration: {} pcs/0.01cf".format(concentration))
            self.last_reading = concentration
        else:
            concentration = self.last_reading
            print("PM last reading: {} pcs/0.01cf".format(concentration))
        return concentration
if __name__ == '__main__':
    try:
        # Setup dust sensor
        dust_sensor = DustSensor()
        while True:
            print("Reading dust sensor...")
            print("Dust = {}".format(dust_sensor.read()))
            time.sleep(5)
    except (KeyboardInterrupt, SystemExit) as err:
        print(" bye! ")
Listing 12-1

DustSensor Class Module

Notice what we have at the bottom of the class. This is another example of how to write some rudimentary testing code. With this, we can execute the code module by itself and test our read function. It is recommended you do that once you have your hardware wired and before you attempt to run the entire project. You should see output similar to the following:
Reading dust sensor...
PM concentration: 0.634378 pcs/0.01cf
Dust = 0.634378
Reading dust sensor...
Reading dust sensor...
PM concentration: 0.621001 pcs/0.01cf
Dust = 0.621001
Reading dust sensor...
PM last reading: 0.621001 pcs/0.01cf
Dust = 0.621001
Reading dust sensor...
PM concentration: 0.649432 pcs/0.01cf
Dust = 0.649432
Reading dust sensor...
PM concentration: 0.7309418 pcs/0.01cf
Dust = 0.7309418
...

Notice we see one case where the sensor did not generate enough samples to compute the dust concentration. This illustrates the technique of saving the last reading in case the next reading is out of bounds or there is an error.

Now, let’s look at the next class module, a class to manage reading from all of the sensors.

AirMonitor Class

One of the things you may encounter in building projects with many sensors is that it can sometimes be a challenge to ensure all of the sensors are read in a timely manner, especially if the sampling rate of the sensors varies. More specifically, some sensors may need time to “warm up” or simply need time to reset before reading the next value. Making your main code accommodate all of these nuances may be a challenge.

Choosing a Sample Rate

One of the things that you must consider when writing IoT solutions is how often you need to read data called the sample rate (or sampling rate). There are several factors you must consider, all of which should help you determine how often you should read data.

First, you must consider how often you can get data from the sensors. Some sensors may require as much as several minutes to refresh values. Most of those either let you read stale data (the last value read) or emit an error if you read the data too frequently.

Aside from the sensors, you also need to consider how often the data changes or how often you need to check/retrieve the data. The application will play a big factor in determining an optimum rate. For example, if you are sampling a sensor for data that doesn’t change often, there is no point in reading it more frequently.

Another factor to consider concerns storing the data. If you are planning to store the data, reading the data every second could generate more data than your storage mechanism can handle.

Finally, the criticality of the data may also be a factor. More specifically, if the data is used to make critical decisions for industrial, mechanical, or health decisions, the sample rate may need to be high (fast). For example, it would be far too late to detect oncoming vehicles every 30 seconds.

When choosing a sample rate, you must consider all of these elements: refresh rates of your sensors, how often the data will change, how much data you want to store, and the criticality of the data.

Fortunately, we can move coordination like this to a helper class. That is, we can create a class to read all of the sensors and provide data to the main code for display. For this project, we will create a class module named air_monitor.py that contains a class named AirMonitor. You can create the file now in Thonny.

In this class, we will set up all of the sensors initializing any libraries needed and create two functions for use by our main module, a function to read the data named read_data() and a function to retrieve the data named get_data(). All of the work to read the sensors appears in the read_data() function. The data returned by the get_data() function is a dictionary that contains the four sensor values, making it easy to display the data.

We will also need a number of helper functions to read from the sensors as described in the following:
  • read_pressure(): Read the pressure from the BMP280

  • read_temperature(): Read the temperature from the BMP280

  • translate(): Translate a range of values from one range to another. Used to transform the values from the air quality sensor from 1–65535 to 1–1024 to classify the air quality.

Let’s return to the sample time for the class. Recall, our dust sensor needs 30 seconds to read, but other sensors need additional time. Specifically, the air quality sensor requires a 20-second startup and 2+ seconds of read time. Thus, the minimal sample rate we can accommodate is 52–60 seconds. Now you can see why it is important to group all of your sensor read mechanisms into a single class/function.

Now, let’s go through some of the details of the class implementation. Once again, we won’t see every detail, so you should read through the code on your own to discover how it works.

We use a dictionary in the class to store the sensor data and return it with the get_data() function. The following shows the new dictionary. Once returned to the main sketch, we simply use the key to retrieve the sensor data. For example, data["temperature"] fetches the temperature value:
data = {
    "temperature": 0.0,
    "pressure": 0.0,
    "dust_concentration": 0.0,
    "air_quality": AIR_GOOD,
}
Notice we will use a series of constants for the air quality. The following shows the constants used that map to relative quality values. We can use these values in the main code to print text to match the constant value:
AIR_POOR = 0
AIR_FAIR = 1
AIR_GOOD = 2
AIR_ERR = 3
Listing 12-2 shows the complete code for the class with documentation removed for brevity. Take a few moments to read through the code so that you understand all of the parts of the code. As you will see, it is not overly complicated; rather, just a lot of code due to the number of sensors we are reading.
from machine import ADC, I2C, Pin
import time
from project6.bmx280x import BMX280
from project6.mcp9808 import MCP9808
from project6.dust_sensor import DustSensor
# Constants
DUST_SAMPLE_RATE = 60 # 60 seconds
AIR_SENSOR_PIN = 1
AIR_POOR = 0
AIR_FAIR = 1
AIR_GOOD = 2
AIR_ERR = 3
class AirMonitor:
    """AirMonitor Class"""
    data = {
        "temperature": 0.0,
        "pressure": 0.0,
        "dust_concentration": 0.0,
        "air_quality": AIR_GOOD,
    }
    def __init__(self, i2c):
        """Constructor"""
        # Initialize the BMP280
        self.i2c = i2c
        # Setup BMP280
        self.bmp280 = BMX280(i2c, 0x77)
        # Setup MCP9808
        self.mcp9808 = MCP9808(i2c)
        # Setup dust sensor
        self.dust_sensor = DustSensor()
        # Setup air quality sensor
        self.air_quality = ADC(28)
        self.to_volts = 5.0 / 1024
    def read_pressure(self):
        """Read the pressure from the BMP280."""
        return self.bmp280.pressure/100
    def read_temperature(self):
        """Read the temperature from the BMP280."""
        return self.bmp280.temperature
    def translate(self, x, in_min=1, in_max=65535, out_min=1, out_max=1024):
        """Translate from range 1-65353 to 1-1024."""
        return int((x - in_min) * (out_max - out_min) /
                   (in_max - in_min) + out_min)
    def read_data(self):
        """read_data"""
        print(" >> Reading Data <<")
        # Read temperature
        try:
            print("> Reading temperature = ", end="")
            self.data["temperature"] = self.mcp9808.read_temp()
            print(self.data["temperature"])
        except Exception as err:
            print("ERROR: Cannot read temperature: {}".format(err))
            return False
        # Read pressure
        try:
            print("> Reading pressure = ", end="")
            self.data["pressure"] = self.read_pressure()
            print(self.data["pressure"])
        except Exception as err:
            print("ERROR: Cannot read pressure: {}".format(err))
            return False
        # Read dust
        self.data["dust_concentration"] = 0.10
        try:
            print("> Reading dust concentration")
            self.data["dust_concentration"] = self.dust_sensor.read()
            print("> Dust concentration = {}".format(self.data["dust_concentration"]))
        except Exception as err:
            print("ERROR: Cannot read dust concentration: {}".format(err))
            return False
        # Read air quality
        try:
            print("> Reading air quality = ", end="")
            raw_value = self.air_quality.read_u16()
            sensor_value = self.translate(raw_value)
            if sensor_value > 700:
                self.data["air_quality"] = AIR_POOR
            elif sensor_value > 300:
                self.data["air_quality"] = AIR_FAIR
            else:
                self.data["air_quality"] = AIR_GOOD
            print(self.data["air_quality"])
        except Exception as err:
            print("ERROR: cannot read air quality: {0}".format(err))
            self.data["air_quality"] = AIR_ERR
            return False
        return True
    def get_data(self):
        """get_data"""
        return self.data
Listing 12-2

AirMonitor Class Module

Notice there are a number of debugging print statements in the code. You may want to take those out once you get everything working.

Tip

If you have any trouble getting the code to work, you may want to comment out the lines of code for reading the dust sensor so that you do not have to wait 30 seconds for each read. Similarly, you can comment out the read to other sensors to give you a chance to solve any problems using the class in your project.

Now we can write our main code.

Main Code Module

Now we can write the main code. Open a new file and name it main.py. Since we are placing all of the sensor work in the AirMonitor class, all we need to do here is set up the I2C bus, instantiate the new class instance (stored in a variable named air_quality), initialize the OLED and buzzer, and then print the greeting.

The main() function simply calls the read_data() method, and if it returns true, we get the data and display it on the OLED. The only extra work we need to do is determine what the air monitor sensor is returning and print the correct value and examine the data to ensure it is below established levels (to determine air quality). If the air quality is low, we display a message and play an alarm sequence on the buzzer.

We will also use a helper function to sound a tone on the buzzer. This isn’t absolutely necessary, but it does help reduce the amount of code, and this concept will help you understand the way we use the buzzer in the next chapter.

However, the software library we will be using for the OLED doesn’t contain methods that allow macro functions like writing a string to the display or setting up (initializing) the display. We will create those helper functions named oled_write() and setup_oled().

The following shows the code for the oled_write() function:
def oled_write(oled, column, row, message):
    """oled_write"""
    oled.text(message, column*8, row*8, 255)
    oled.show()
The following shows the code for the setup_oled() function:
def setup_oled(i2c):
    """setup_oled"""
    # Setup OLED
    display = SSD1306_I2C(128, 64, i2c)  # Grove OLED Display
    display.text('Hello World', 0, 0, 255)
    display.show()
    oled_write(display, 0, 1, "Environment")
    oled_write(display, 0, 2, "Monitor")
    oled_write(display, 0, 4, "Starting...")
    return display
Since we will only use the buzzer to sound if there is a poor air quality condition, rather than use the Buzzer class we created in Chapter 11, we will create a more simplistic beep() helper function that simply plays a tone using the same PWM we created in the Buzzer class. The following shows the simplified beep() function. We will use pin 26 (an analog pin) for the buzzer.
def beep(buzzer_pin, duration=0.150):
    """beep"""
    buzzer_pin.on()
    time.sleep(duration)
    buzzer_pin.off()

Finally, let’s discuss the sampling rate again. Recall from the AirMonitor class, we have a minimal sampling rate of 60 seconds hard-coded. In the main() function, we also have a sampling rate constant. This should be used to control how often we call the read_data() function rather than how long it takes to read the data. A minor distinction, but the result is we must consider both how long it takes to read the data and how often we want to start the read.

Listing 12-3 shows the complete code for the main script for this project. You can read through it to see how all of the code works.
# Import libraries
from machine import Pin, I2C
import time
from project6.air_monitor import AirMonitor, AIR_POOR, AIR_FAIR, AIR_GOOD, AIR_ERR
from project6.ssd1306 import SSD1306_I2C
# Constants
SAMPLING_RATE = 5 # 5 second wait to start next read
BUZZER_PIN = 26
WARNING_BEEPS = 5
HIGH = 1
LOW = 0
# Constants for environmental quality
MAX_TEMP = 30.0
MAX_DUST = 40.0
def beep(buzzer_pin, duration=0.150):
    """beep"""
    buzzer_pin.on()
    time.sleep(duration)
    buzzer_pin.off()
def oled_write(oled, column, row, message):
    """oled_write"""
    oled.text(message, column*8, row*8, 255)
    oled.show()
def setup_oled(i2c):
    """setup_oled"""
    # Setup OLED
    display = SSD1306_I2C(128, 64, i2c)  # Grove OLED Display
    display.text('Hello World', 0, 0, 255)
    display.show()
    oled_write(display, 0, 1, "Environment")
    oled_write(display, 0, 2, "Monitor")
    oled_write(display, 0, 4, "Starting...")
    return display
def main():
    """Main"""
    print("Welcome to the Environment Monitor!")
    # Setup buzzer
    buzzer = Pin(BUZZER_PIN, Pin.OUT)
    i2c = I2C(0,scl=Pin(9), sda=Pin(8), freq=100000)
    print("Hello. I2C devices found: {}".format(i2c.scan()))
    oled = setup_oled(i2c)
    # Start the AirMonitor
    air_quality = AirMonitor(i2c)
    time.sleep(3)
    oled_write(oled, 11, 4, "done")
    beep(buzzer)
    oled.fill(0)
    oled.show()
    while True:
        if air_quality.read_data():
            # Retrieve the data
            env_data = air_quality.get_data()
            oled_write(oled, 0, 0, "ENVIRONMENT DATA")
            oled_write(oled, 0, 2, "Temp: ")
            oled_write(oled, 6, 2, "{:3.2f}C".format(env_data["temperature"]))
            oled_write(oled, 0, 3, "Pres: ")
            oled_write(oled, 6, 3, "{:05.2f}hPa".format(env_data["pressure"]))
            oled_write(oled, 0, 4, "Dust: ")
            if env_data["dust_concentration"] == 0.0:
                oled_write(oled, 6, 4, "--         ")
            else:
                oled_write(oled, 6, 4, "{:06.2f}%".format(env_data["dust_concentration"]))
            oled_write(oled, 0, 5, "airQ: ")
            if env_data["air_quality"] in {AIR_ERR, AIR_POOR}:
                oled_write(oled, 6, 5, "POOR")
            elif env_data["air_quality"] == AIR_FAIR:
                oled_write(oled, 6, 5, "FAIR")
            elif env_data["air_quality"] == AIR_GOOD:
                oled_write(oled, 6, 5, "GOOD")
            else:
                oled_write(oled, 6, 5, "--       ")
            # Check for environmental quality
            if ((env_data["dust_concentration"] > MAX_DUST) or
                    (env_data["temperature"] > MAX_TEMP) or
                    (env_data["air_quality"] == AIR_POOR) or
                    (env_data["air_quality"] == AIR_ERR)):
                #pylint: disable=unused-variable
                for i in range(0, WARNING_BEEPS):
                    oled_write(oled, 3, 7, "ENV NOT OK")
                    beep(0.250)
                    time.sleep(0.250)
                    oled_write(oled, 3, 7, "          ")
                    time.sleep(0.250)
        else:
            oled.fill(0)
            oled.show()
            oled_write(oled, 0, 2, "ERROR! CANNOT")
            oled_write(oled, 0, 3, "READ DATA")
        time.sleep(SAMPLING_RATE)
if __name__ == '__main__':
    try:
        main()
    except (KeyboardInterrupt, SystemExit) as err:
        print(" bye! ")
sys.exit(0)
Listing 12-3

Main Code Module

OK, that’s it! We’ve written the code and we’re now ready to execute the project!

Execute

Now it is time to test the project by executing (running) it. Recall, we can copy all of our code to our Pico. If you haven’t already done so, you should create a folder named project6 on your Pico and then upload the air_monitor.py, dust_sensor.py, bmx280x.py, mcp98008.py, and ssd1306.py files to the project6 folder. Next, upload the main.py file to the root folder of your Pico.

Recall, there are two ways to test or execute the code. We could use Thonny to connect to the Pico and simply run the main.py script. Or we can reboot the Pico by unplugging it and plugging it back in to the USB port on your PC.

The difference is if you run the main.py file manually, you will see the debug statements show in the output at the bottom of Thonny. Running the script automatically may not show those statements if you do not use Thonny or a similar application to connect to the Pico.

Once the program starts, you will see some diagnostic messages written to the terminal. You will also see a welcome message appear on the LCD. When you run the code from Thonny, you will see output similar to the following:
Welcome to the Environment Monitor!
Hello. I2C devices found: [24, 60, 119]
>> Reading Data <<
> Reading temperature = 20.8125
> Reading pressure = 1022.359
> Reading dust concentration
> Dust concentration = 0.649432
> Reading air quality = 2
>> Reading Data <<
> Reading temperature = 20.8125
> Reading pressure = 1022.394
> Reading dust concentration
> Dust concentration = 0.649432
> Reading air quality = 2
...
Figure 12-17 shows examples of the OLED output.
Figure 12-17

Executing the environment monitor project

If everything worked as executed, congratulations! If something isn’t working, check your connections to ensure you’ve connected everything correctly.

Since we named the main code file main.py, you can restart the Pico and run the project on boot. If you connect a power supply to the Pico, or a 5V battery pack, you can run it continuously without your PC.

Going Further

While we didn’t discuss them in this chapter, there are some ideas where you could make this project into an IoT project. Here are just a few suggestions you can try once we have learned how to take our projects to the cloud. Put your skills to work!
  • Environment portal: You can display the values of the last sensor(s) read in a web page to allow you to see the condition of your environment from anywhere in the world. If you also add the date and time, you can see how your environment changes over time. See Chapter 13 for ideas on how to implement this suggestion.

  • Additional sensors: Implement additional sensors to read more data such as specific gases such as CO2, O2, and a light sensor to detect day and night cycles. You could also include a vibration sensor if you live in areas prone to seismic tremors. Interestingly, you can use vibration sensors to detect when someone walks into the room.

  • Sampling rate: Adjust the sampling rate to match your environmental needs. For example, if you live in a very clean apartment or house with good climate control, your sampling rate may be lower than if you live in a dusty area prone to temperature changes such as an RV or typical rustic cabin.

Summary

In this chapter, we got more hands-on experience making projects with Grove analog and digital modules as well as multiple I2C devices. We used these modules to create an environment monitor that displays the temperature, barometric pressure, air quality, and dust (particle) concentration in the air – in other words, an indoor air monitoring solution.

Along the way, we learned more about how to work with Grove modules including how to write our own classes for managing multiple sensors. We also saw how to use alternative software libraries in both the Arduino and Python versions of our project. Finally, we saw some potential to make this project better as well as some ideas for how to adapt the project for practical uses.

In the next chapter, we will see how to extend our projects into an exciting new realm – the Internet. We will see how to connect our Pico to the Internet and learn how to make our projects into an Internet of Things (IoT) project.

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

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