Building A Weather Station with a Raspberry Pi and Python - Part Two

dev_neil_a - Oct 29 - - Dev Community

Table of Contents

YouTube Video

If you would prefer to watch a video of this article, there is a video available on YouTube below:

Introduction

Hello and welcome. In this second part of a three-part series of articles on building a weather station with a Raspberry Pi, were going to be making the code base more modular. In addition, environment variables will be added to the program to make it easier to configure for different hardware configurations.

Lastly, logging will be added to the program so that it logs any errors incurred to a log file to help with making diagnosing issues easier.

With that said, let's crack on.

Creating The Modules Folders

To start, rename the main.py file to main-part-one.py and create a new file named main.py. The reason for this is that due to the number of changes that will be made, it will be easier to start with a new file, but a good amount of what was done in part one will be used again.

Next, create a new folder at the root of the project folder called modules. In that folder, create four new folders called:

  • displays
  • env
  • logging
  • sensors

Next, in each of those four folders, create a file named __init__.py. Close each file when they open as there will be no changes to be made to them. These files are used by Python to indicate the folder contains a module.

With the folders created, let's get started with building the first module.

Setting Up the Logging Module

The first module that will need to be setup is the logging module. This will allow the program to record any errors that are encountered in the program to a file so that in the event of an error occurring, it can be used to diagnose what caused the error and why.

First, create a new file in the logging folder called log_config.py and open it if it doesn't do so automatically.

In the file, paste in the following code:

# --- Import the required libraries / modules:
import logging
import os


def load_logging():
    """_summary_
    This function will setup the logging facility for the program.

    Returns:
        None: Nothing is returned.
    """

    # --- Get the root folder path that the app is stored in:
    LOG_FOLDER_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..", "logs"))

    # --- Check if the log folder exists. If not, create it:
    if os.path.exists(LOG_FOLDER_PATH) == False:
        os.mkdir(path = LOG_FOLDER_PATH)

    # --- Setup the logging:    
    log_file = f"{LOG_FOLDER_PATH}/error.log"

    # --- Define the configuration for the logging:
    logging.basicConfig(filename = log_file, 
                        encoding = "utf-8", 
                        level = logging.ERROR,
                        format='%(levelname)s:%(asctime)s:%(name)s:%(message)s')
Enter fullscreen mode Exit fullscreen mode

Now, let's review what was put into the file:

  • First, there are the modules that need to be imported for this module
  • Next, there is a function called load_logging. In that function, it will:
    • Create a variable called LOG_FOLDER_PATH that points to a folder in the root of the project called logs
    • After that, if the “logs” folder does not exist, it will be created
    • Then, a variable called log_file is defined for the location, along with the name of the log file to use
    • Lastly, some additional settings for the logging are defined. These are:
      • Where to store the logs
      • The text encoding format to use
      • The starting level for capturing logs (in this case, error or above) and
      • The format for each log entry. In this case it will be:
        • The level (error for example)
        • The date and time
        • The name of the module that caused the error and finally
        • The error message

Save the file and close it.

With the logging module created, let's move onto setting up the environment variables module.

Setting Up the Environment Variables Module

For this module, there is an additional library that needs to be installed called python-dotenv. This is used to create environment variables from within a Python program. To install it, run the below in the terminal:

pip3 install python-dotenv
Enter fullscreen mode Exit fullscreen mode

Next, In the env folder, create a new file called .env. This file will contain the environment variables that will be used in the program. Open the file if it didn’t open automatically and paste in the below:

OUTPUT_TO_CONSOLE="true"
LCD_SCREEN_CONNECTED="true"
LCD_SCREEN_TYPE="20x4"
ENV_SENSOR_TYPE="bme280"
Enter fullscreen mode Exit fullscreen mode

The four environment variables are self-explanatory in what they are used for.

Save .env and close the file.

Next, create a new file called env_vars.py in the env folder. Once the file is open, paste in the below code:

# --- Import the required libraries / modules:
from dotenv import load_dotenv    

import os
import logging


# --- Get the currently active logger:
log = logging.getLogger(__name__)


def load_env_vars() -> None:
    """_summary_
    This function is used to load the environment variables that are stored in the .env file
    in the same folder as this file.

    Returns:
        None: Nothing is returned.
    """

    # --- Load environment variables from .env file:
    try:
        # load_dotenv(dotenv_path = f"{BASE_DIR}/modules/env/.env", verbose=False)
        load_dotenv(dotenv_path = f"{os.path.abspath(os.path.join(os.path.dirname(__file__)))}/.env", 
                    verbose=False)
    except OSError as error:
        message = "Could not locate the .env file. Please check that the file is present."
        log.error(message)
        raise Exception(message)

    # --- Check to see if one of the environment variables is present. If not, raise an exception:
    if os.getenv("LCD_SCREEN_CONNECTED") == None:
        message = "No environment variables were loaded. Please check the .env file exists."
        log.error(message)
        raise Exception(message)
Enter fullscreen mode Exit fullscreen mode

Now, let's review what was put into the file:

  • First, there are the modules that need to be imported for this module
  • Then, there is a variable called log that will be used to interact with the logger that is running. That will be setup in the main.py file later
  • After that, there is a function named load_env_vars which will be called when the program runs to setup all the environment variable in the .env file. In this function, it will:
    • Attempt to load the environment variables and if it can't, it will write an error to the log file and exit the program.
    • If the environment variables do get loaded, it will then check one to see if it loaded and if it has a value. If that value is None, again, a log entry will be written, and the program will close.

Save the file and close it.

That covers setting up the environment variables module. Next up is the sensor's module.

Setting Up the Sensors Module

The sensors module will be a little different from the previous two, in that it has two functions in the file, rather than just one. I will add each function one-by-one and then go over them.

To start with, create a new file in the sensors folder called sensors.py and open it if it didn’t do so automatically.

First, copy and paste the below code. It will be the modules and libraries that are required for all functions in this file, along with the code for the first function named initialise_sensor.

# --- Import the required libraries / modules:
from adafruit_bme280 import basic as adafruit_bme280

import board
import logging


# --- Get the currently active logger:
log = logging.getLogger(__name__)


# --- Attempt to initialise the sensor on the i2c bus using the default values:
def initialise_sensor() -> object:
    """_summary_
    This function provides the application with the methods required to
    interact with an Adafruit bme280 sensor.

    Returns:
        class: An object that contains the methods for interacting with 
               an Adafruit bme280 sensor.
    """

    # --- Initialise the i2c interface for the sensor:
    try:
        i2c = board.I2C()  # --- uses board.SCL and board.SDA. Add i2c interface number.
    except OSError as error:
        message = "Unable to connect to the i2c interface. Please check that it is enabled."
        log.error(message)
        raise Exception(message)
    except ValueError as error:
        message = "Unable to connect to the i2c interface. Please check that it is enabled."
        log.error(message)
        raise Exception(message)

    # --- Initialise the bme280 sensor:
    try:
        sensor = adafruit_bme280.Adafruit_BME280_I2C(i2c)
    except OSError as error:
        message = "No sensor board was found. Please check that it is connected."
        log.critical(message)
        raise Exception(message)
    except ValueError as error:
        message = "No sensor board was found. Please check that it is connected."
        log.critical(message)
        raise Exception(message)

    # --- change this to match the location's pressure (hPa) at sea level.
    sensor.sea_level_pressure = 1027

    return sensor
Enter fullscreen mode Exit fullscreen mode

Let's go over what was put in the file.

First, there is the libraries and modules needed for the contents of the file, along with the log variable again.

Then there is the first function. The purpose for this one is to initialise the sensor and return an object with all the settings to work with the sensor back to the caller. Most of the code you may remember seeing in part one as it is mostly the same but summarise it:

  • The first try block will attempt to initialise the i2c interface on the Raspberry Pi. If it can't, it will write an error to the log file and exit the program
  • The last try block will attempt to initialise the sensor board on the i2c interface. If it can't, it will write an error to the log file and exit the program
  • If all is well, then the sea level pressure is set on the sensor object and
  • Finally, the sensor object is returned to the caller

Add three empty lines and then paste in the below code:

def get_readings(sensor: object) -> dict:
    """_summary_
    This function will make a call to the sensor to get the current sensor readings and
    store them in a dictionary that will be returned to the caller.

    Args:
        sensor (object): This is the object containing the initialised sensor object.

    Returns:
        dict: Returns a dictionary with the keys / values for the temperature, 
              humidity, pressure and altitude. All values are floats, rounded to
              two decimal places.
    """

    try:
        readings = {
            "temperature": round(float(sensor.temperature), 2),
            "humidity": round(float(sensor.humidity), 2),
            "pressure": round(float(sensor.pressure), 2),
            "altitude": round(float(sensor.altitude), 2)
        }
    except OSError as error:
        message = f"Unable to get readings from the sensor. Please check the sensor is connected and active."
        log.error(message)
        raise Exception(message)

    return readings
Enter fullscreen mode Exit fullscreen mode

This final sensor function is very simple in that it will take a sensor object as an argument and uses that to get the readings from the sensor board. The readings are stored in a dictionary and then that is returned to the caller.

If, for whatever reason it can't get the readings, such as a defective sensor board, it will write an error to the log file and exit the program.

Save the file and close it.

With that done, it's time to move on to the displays module.

Setting Up the Displays Module

With the logging, environment variables and sensors modules setup, the last module to create is the displays module.

Just as before in the sensor's module, there will be multiple functions.

First, create a new file in the displays folder called config.json and open it.

Next, paste in the below:

{
    "20x4": {
        "i2c_expander": "PCF8574",
        "address": "0x27",
        "port": 1,
        "cols": 20,
        "rows": 4,
        "dotsize": 8
    },
    "16x2": {
        "i2c_expander": "PCF8574",
        "address": "0x27",
        "port": 1,
        "cols": 16,
        "rows": 2,
        "dotsize": 8
    }
}
Enter fullscreen mode Exit fullscreen mode

The purpose of this file is to list the settings for each display size. As you can see, there is an entry for a 20x4 screen and another for a 16x2 with each having the relevant settings for that screen.

Save the file and close it.

Next, create a file called displays.py and open it.

Paste in the below code:

# --- Import the required libraries / modules:
from datetime import datetime as dt
from RPLCD.i2c import CharLCD

import json
import logging
import os


# --- Get currently active logger:
log = logging.getLogger(__name__)


def initialise_lcd():
    """_summary_
    This function will attempt to initialise the LCD screen that is attached to the system.

    _Arguments_
    No arguments are required.

    Returns:
        class: A class called lcd that has the settings for an initialised LCD display.
    """

    # --- Specify the path to the display’s config file:
    config_file_path = f"{os.path.abspath(os.path.join(os.path.dirname(__file__)))}/config.json"

    # --- Check if the config.json file is present:
    if os.path.exists(config_file_path) == False:
        message = f"The config.json file was not found. Please check that this file exists."
        log.error(message)
        raise Exception(message)

    # --- Load the config file to a dictionary:
    with open(config_file_path) as config_file:
        config = json.load(config_file)

    # --- Get the screen type:
    screen_type = os.getenv("LCD_SCREEN_TYPE")

    # --- If the screen_type matches an entry in config (config.json), initialise the display
    # --- Otherwise, raise an error:
    if screen_type in config.keys():

        # --- Create a screen object:
        try:
            lcd = CharLCD(i2c_expander = config[screen_type]["i2c_expander"], 
                        address = int(config[screen_type]["address"], 0),
                        port = config[screen_type]["port"], 
                        cols = config[screen_type]["cols"], 
                        rows = config[screen_type]["rows"], 
                        dotsize = config[screen_type]["dotsize"])

        except OSError as error:
            message = f"Display could not be found. Please check the display is connected."
            log.error(message)
            raise Exception(message)

        except NotImplementedError as error:
            message = f"The display expander is not supported. Please check the display configuration."
            log.error(message)
            raise Exception(message)

    else:
        message = f"Display could not be found. Please check the display is connected."
        log.error(message)
        raise Exception(message)

    return lcd
Enter fullscreen mode Exit fullscreen mode

Ok, so to go over it, there is the usual sections for importing modules / libraries and getting the active logger.

After that, there is the first function called initialise_lcd. The purpose of this function, when called, is to setup a configuration for a display that is specified in the LCD_SCREEN_TYPE environment variable. For example, 20x4.

To cover off what the function does:

  • First, specify where the config.json file is located and assign it to a variable named config_file_path. This is in the same directory as displays.py
  • Check to ensure that the config.json file exists in the config_file_path. If not, write an error to the log and raise an exception to exit the program
  • All being well, the contents of config.json are loaded into a variable named config. This will contain a Python dictionary
  • Next, get the value of the LCD_SCREEN_TYPE environment variable and assign it to a variable called screen_type
  • Then, check the value of screen_type matches a key in the config variable. In this case, does it match either “20x4” or “16x2”
  • If it matches, it will create a new object called lcd that has the configuration for the LCD screen and then return it
  • If it doesn't match, you guessed it, write an error to the log file and then exit the program.

Paste in the below code on a new line below the end initialise_lcd function. Don't paste it into that function:

def output_to_lcd(readings: dict, display: object) -> None:
    """_summary_

    Args:
        temperature (float): The temperature to display.
        humidity (float): The humidity to display.
        pressure (float): The pressure to display.
        altitude (float): The altitude to display.
        display (_type_): The display settings to use.
    Returns:
        None: Nothing is returned.
    """
    try:   
        display.clear()

        if display.lcd.cols == 20 and display.lcd.rows == 4:
            display.write_string(f"Temp:     {readings['temperature']}{chr(223)}C\r\n")
            display.write_string(f"Humidity: {readings['humidity']}%\r\n")
            display.write_string(f"Pressure: {readings['pressure']}hpa\r\n")
            display.write_string(f"Altitude: {readings['altitude']}m")
        else:
            display.write_string(f"Temp: {readings['temperature']}{chr(223)}C\r\n")
            display.write_string(f"Humi: {readings['humidity']}%\r\n")
    except OSError as error:
        message = f"Display could not be found. Please check the display is connected."
        log.error(message)
        raise Exception(message)


def output_to_console(readings: dict) -> None:
    """_summary_
    This function will display the readings of the sensor to the console.

    Args:
        readings (dict): The dictionary containing the readings from the sensor

    Returns:
        None: Nothing is returned.
    """

    # --- Clear the output on the terminal console:
    os.system('clear')

    # --- Display the values to the terminal console:
    print(dt.now().strftime("%d/%m/%Y, %H:%M:%S\n"))
    print(f"Temperature: {readings['temperature']}°C")
    print(f"Humidity: {readings['humidity']}%")
    print(f"Pressure: {readings['pressure']}hPa")
    print(f"Altitude: {readings['altitude']}m")
Enter fullscreen mode Exit fullscreen mode

There are two functions in this block of code, both of which are very simple.

First, output_to_lcd will take two arguments, the first being readings in the form of a Python dictionary, along with the display settings. After that, it will attempt to display the relevant text on the screen and if it can't, for example the screen became disconnected, it will write an error to the log and then exit the program.

Lastly, there is the output_to_console function.

This function is very simple. It takes a single argument called readings, which again, is the dictionary containing the sensor readings. It then uses those readings to output to the console / terminal from where the program was run. Nice and simple.

Save the file.

That is all the modules created. The next and final file to setup is the main.py that will bring all the modules together and get the program working.

Bringing the Program Together

Now onto the final file!

Open main.py that was created at the start and paste in the below code:

# --- Import the required libraries / modules:
from time import sleep

from modules.displays.displays import initialise_lcd, output_to_lcd, output_to_console
from modules.env.env_vars import load_env_vars
from modules.logging.log_config import load_logging
from modules.sensors.sensors import initialise_sensor, get_readings

import logging
import os


def main() -> None:
    """_summary_
    This is the main function that controls the execution flow of the program.

    Returns:
        None: Nothing is returned.
    """

    # --- Load the environment variables:
    load_env_vars()

    # --- Create a sensor object:
    sensor = initialise_sensor()

    # --- Determine if LCD screen output is required:
    if os.getenv("LCD_SCREEN_CONNECTED") in ["True", "true", "TRUE", "1"]:
        OUTPUT_TO_LCD_REQUIRED: bool = True

        # --- Initialise the LCD screen:
        lcd_screen = initialise_lcd()
    else:
        OUTPUT_TO_LCD_REQUIRED: bool = False

    # --- Determine if console output is required:
    if os.getenv("OUTPUT_TO_CONSOLE") in ["True", "true", "TRUE", "1"]:
        OUTPUT_TO_CONSOLE_REQUIRED: bool = True
    else:
        OUTPUT_TO_CONSOLE_REQUIRED: bool = False

    # --- Show the results on the displays (if set to do so):
    if OUTPUT_TO_LCD_REQUIRED == True or OUTPUT_TO_CONSOLE_REQUIRED == True:
        while True:
            # --- Get a new set of readings from the sensor:
            readings: dict = get_readings(sensor = sensor)

            # --- If console output is required, output the values to the console:
            if OUTPUT_TO_CONSOLE_REQUIRED == True:
                output_to_console(readings = readings)

            # --- If LCD screen output is required, output the values to the LCD screen:
            if OUTPUT_TO_LCD_REQUIRED == True:
                # --- Pass the values of the sensor to the output_to_lcd function:
                output_to_lcd(readings = readings,
                              display = lcd_screen)

            sleep(3)
Enter fullscreen mode Exit fullscreen mode

This new main.py file will control the flow of the program, rather than have all the code implemented inside of it, like was done in part one. Instead, it will import the code from the modules and their functions that have been created.

Now, let's now go over the code to outline what it will do:

  • First, there is the modules and libraries that need to be imported for this program to work. Unlike the other files this one will import all the custom modules that were previously created in the modules folder
  • Next, there is a function called main. This is where the flow logic for the program is placed. The steps it performs are:
    • Load the environment variables by calling the load_env_vars function
    • Next, create a sensor object by calling the initialise_sensor function. It is then assigned to a variable called sensor
    • Next, check if the LCD_SCREEN_CONNECTED environment variable is set to true. If so, it will set OUTPUT_TO_LCD_REQUIRED to True. It will then call the initialise_lcd() function and assign it to a variable called lcd_screen
    • The same applies to the OUTPUT_TO_CONSOLE environment variable but there's no further function calls
    • After that, a check to see if either OUTPUT_TO_LCD_REQUIRED or OUTPUT_TO_LCD_REQUIRED are set to True. If neither are set to True, there will be no output to either. If one of them is set to True then a while loop is run, and the following happens:
      • A call to the get_readings function is made and the returned dictionary is assigned to the readings variable
      • The next two blocks work the same by checking if an output to the LCD screen and / or the console is needed. If so, call the relevant function do so.
      • Lastly, sleep for 3 seconds
    • It will keep running until the program is manually stopped by pressing CTRL+C in the console / terminal.

Now, if the main.py file is run, nothing will happen as the main function is not being called.

An additional block of code needs to be added to call the main function. Paste in the below code after the main function. Again, make sure that the code isn't in the main function:

# --- Start the program:
if __name__ == "__main__":
    # --- Initialise logging:
    load_logging()

    # --- Get the currently active logger:
    log = logging.getLogger(__name__)

    # --- Attempt to run the main function:
    try:
        main()
    except NameError:
        message = "Unable to locate or run main function. Please check the program is setup correctly."
        log.critical(message)
        raise Exception(message)
Enter fullscreen mode Exit fullscreen mode

This is a typical way that Python uses to execute a main function. It checks if the name is main and then it calls the main function.

Now, this does that but prior to that it calls the load_logging function to setup logging and gets the logger, just like in the modules. It then calls the main function and the program runs.

The below image provides a sample of the output to both the terminal / console and to a 20x4 LCD Screen:

Screen shot of output

Error Log Examples

After running the program a few times and caused some deliberate errors, the below shows an example of some of the errors recorded by the program:

ERROR:2024-10-24 18:56:07,718:modules.displays.displays:Display could not be found. Please check the display is connected.
ERROR:2024-10-24 18:56:34,885:modules.sensors.sensors:Unable to get readings from the sensor. Please check the sensor is connected and active.
ERROR:2024-10-24 18:57:00,059:modules.displays.displays:The config.json file was not found. Please check that this file exists.
ERROR:2024-10-24 18:58:03,472:modules.env.env_vars:No environment variables were loaded. Please check the .env file exists.
CRITICAL:2024-10-24 18:58:29,347:__main__:Unable to locate or run main function. Please check the program is setup correctly.
ERROR:2024-10-24 19:02:47,685:modules.sensors.sensors:No sensor board was found. Please check that it is connected.
Enter fullscreen mode Exit fullscreen mode

As you can see, there is a mixture of errors, including:

  • The log level (ERROR or CRITICAL)
  • The date and time
  • Which module caused the issue
  • The message that was recorded

Wrapping-up Part Two Plus What's Next

This completes part two of this project. There were a lot of changes to make from part one, but I hope that you can see that the program is much more modular, easier to switch between configurations using the .env and config.json files and that the error logging will be useful for diagnosing any issues encountered.

In the next part, a SQL database will be added to the program so that all the readings can be stored for further analysis. This will be an additional module so you can see the benefits of the code being modular.

In addition, a simple Python-based web server program will be added that will display the ten most recent entries in the database and provide an option to download all the entries to a CSV / Excel file.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . .