Table of Contents
- Table of Contents
- YouTube Video
- Introduction
- Creating The Modules Folders
- Setting Up the Logging Module
- Setting Up the Environment Variables Module
- Setting Up the Sensors Module
- Setting Up the Displays Module
- Bringing the Program Together
- Error Log Examples
- Wrapping-up Part Two Plus What's Next
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')
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
- Create a variable called
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
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"
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)
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 themain.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
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
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
}
}
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
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 namedconfig_file_path
. This is in the same directory asdisplays.py
- Check to ensure that the
config.json
file exists in theconfig_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 namedconfig
. This will contain a Python dictionary - Next, get the value of the
LCD_SCREEN_TYPE
environment variable and assign it to a variable calledscreen_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")
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)
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 calledsensor
- Next, check if the
LCD_SCREEN_CONNECTED
environment variable is set to true. If so, it will setOUTPUT_TO_LCD_REQUIRED
toTrue
. It will then call theinitialise_lcd()
function and assign it to a variable calledlcd_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
orOUTPUT_TO_LCD_REQUIRED
are set toTrue
. If neither are set toTrue
, there will be no output to either. If one of them is set toTrue
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 thereadings
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
- A call to the
- It will keep running until the program is manually stopped by pressing CTRL+C in the console / terminal.
- Load the environment variables by calling the
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)
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:
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.
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.
Top comments (0)