© Joseph Coburn 2020
J. CoburnBuild Your Own Car Dashboard with a Raspberry Pihttps://doi.org/10.1007/978-1-4842-6080-7_8

8. Boot Sensor

Chapter goal: Install a boot sensor, and read its state into your application.
Joseph Coburn1 
(1)
Alford, UK
 

The boot sensor serves an important function – it lets you know if the boot is open! You’ll use a momentary switch for this purpose. If so desired, you could expand or change this project to encompass car doors or even a bonnet sensor.

Hardware Configuration

Remember to disconnect the Pi’s power supply when connecting or disconnecting any circuits.

This circuit uses a miniature momentary tactile switch. These cost a few cents and measure roughly a quarter of an inch in diameter. They are designed to bridge the gap between a solderless breadboard – as visible in Figure 8-1 – or for integrating into your own circuits. Each button has four legs, arranged in two pairs. Orient the button such that the legs are facing horizontally, as if it was installed on a breadboard with the dividing channel running vertically. The top-left and top-right legs are connected as one pair. The bottom right and bottom left are also connected as the second pair. This arrangement allows the buttons to span the gap in a breadboard and connect two otherwise unconnected sides.
../images/488914_1_En_8_Chapter/488914_1_En_8_Fig1_HTML.jpg
Figure 8-1

Boot sensor completed circuit on a breadboard

When the button is pressed, the pairs join up. Current is allowed to flow from the top to the bottom pairs, and complete whatever circuit you are using the button in. These buttons are only momentary acting. The circuit is only completed when you hold the button down. Release the button, and the connection is closed. You’ll need to handle any latching action in the code (but that isn’t required for this project).

For this project, you won’t need a pull-down or pull-up resistor. The Pi has built-in resistors for this purpose. The resistor used in the previous circuits exists to help protect the sensor. As this is an extremely cheap component, and it only serves one simple mechanical purpose, the Pi’s built-in resistor is more than sufficient.
../images/488914_1_En_8_Chapter/488914_1_En_8_Fig2_HTML.jpg
Figure 8-2

Wiring diagram for the boot sensor

You only need two pins for this project – the Pi’s ground and the GPIO pin, which will detect the change. Figure 8-2 shows the wiring diagram for this circuit. Connect the following:
  • 6 (physical) or ground (BCM) – Ground or GND

  • 8 (physical) or 14 (BCM) – GPIO 14

The GPIO pin will supply the current necessary to detect the button press. When you are not pressing the button, GPIO pin 14 detects the Pi’s internal voltage through its in-built pull-up resistor. This value is seen as HIGH, 1, or ON. When you complete the circuit by pressing the button, the signal changes to the ground. This state changes to LOW, 0, or OFF. This is basic binary logic – each pin can have one of two states: on or off, high or low, one or zero. Changes between these states are detected by the Pi.

Note

The Pi uses a mixture of pull-down and pull-up resistors for its GPIO pins. While you can reconfigure each one to serve your specific needs, always double-check the Pi’s default values for pins.

After double-checking your work, apply power and boot up your Pi.

Flask Boot Sensor Logic

This project requires no changes to the Pi’s operating system – it’s handled entirely by Flask and your application logic. Most modern installations on the Pi come with the required GPIO library preinstalled, but should that not be the case on your Pi (for whatever reason), you’ll specify an additional Python package in your Pipfile to avoid issues.

This button is used by your boot. When installed, the boot should rest on the button, effectively completing the circuit. Therefore, when the button is pressed, the boot is closed. When the boot is open, it’s no longer pressing the button. By coding for these states accordingly, you’ll have a functional boot sensor.

Begin by entering your project’s Pipenv shell and installing the two packages used to interface Python and the GPIO pins. GPIOZero is a modern wrapper for several older GPIO modules. It makes GPIO communication in Python easier than ever. It’s well supported and has a large community following. This internally uses several other libraries for GPIO communication. If a certain library is not available on your Pi, it’s capable of failing over to another library – and it can do this several times. By default, the first GPIO communication library GPIOZero uses is RPi.GPIO – which is the default library on Raspbian. PiGPIO is one of the fail-over libraries, so by installing it, you have the best possible chance of avoiding issues, regardless of your Pi’s operating system. Install the two Python packages:
pipenv install gpiozero
pipenv install pigpio

PyPI is the default Python package repository. Whenever you download a package using PIP or Pyenv, your Python interpreter searches PyPI. Why am I telling you this now? Packages hosted on PyPI are not case sensitive. It’s a common practice to use case-insensitive package names on PyPI (although it makes no difference). It’s important to clarify this fact, as both of these packages use mixed-case naming conventions, yet the installation commands are all in lowercase.

Once again, when running the application on your computer, the Pi’s GPIO pins are not available. It’s possible to access them remotely through one of the Python GPIO communication libraries, but it’s not necessary. With your build process, and the speed at which the Pi can install new changes, you can write code on your computer and ensure it runs and then run it on the Pi to ensure it works fully.

Begin by modifying the Sensors class (inside sensors.py) by importing the previously installed library:
from gpiozero import Button, exc

This imports the Button class, and the exc class from the gpiozero module. It’s not necessary to import pigpio – gpiozero uses this internally if there are errors with any other GPIO libraries it needs.

Create a new static method called get_boot_status . This function will handle all aspects of the boot sensor. It will return a string outlining the status of the boot. This function returns three different strings. If there was a problem (such as the GPIO pins not existing, in the case of your local development computer), the function returns “Unknown”. When the boot is closed (the button is pressed), the function returns “Closed”. Finally, when the boot is open and the button is no longer held down, this function returns “Open”.

Here’s the code you need:
@staticmethod
def get_boot_status():
    """
    Safely read the boot sensor and calculate the state - open/closed/unknown
    :return: String - boot status
    """
    app.logger.info("Starting to read boot sensor")
    result = None
    status = None
    try:
        button = Button(pin=14)
        status = button.is_pressed
    except exc.BadPinFactory as e:
        app.logger.warning(f"Unable to use boot sensor in this environment: {e}")
        result = "Unknown"
    except Exception as e:
        app.logger.error(f"Unknown problem with boot sensor: {e}")
        result = "Unknown"
    if not result:
        if status:
            result = "Closed"
        else:
            result = "Open"
    app.logger.debug(f"Boot: {result}")
    app.logger.info("Finished reading boot sensor")
    return result

Once again, notice the liberal use of logging at various levels. Even in the case of a total failure, the function still continues to work. The overall application may provide a diminished experience, but it still works. Failure with the boot sensor should not and will not cause other sensors or the whole system to fall over.

There are two main lines of code here. Create a new button, based on the Button class from the gpiozero library:
button = Button(pin=14)
The pin parameter is the GPIO pin you connected your button to. Pin 14 is the BCM naming convention for the GPIO pin 14 you connected to earlier. Now detect your button status using the is_pressed attribute:
status = button.is_pressed

This isn’t a function – it’s a variable from your button object. It’s updated by the library whenever the button changes state. It’s a Boolean value – True or False.

These objects are wrapped in a try/catch block, to handle any exceptions. The Button class raises an exc.BadPinFactory exception if there is a problem with the underlying GPIO libraries. This is a custom exception implemented by GPIOZero. After handling this exception, a generic Exception is handled. Handling generic exceptions like this is not a bad practice, providing you have handled at least one specific error. Just blindly wrapping code in generic exception handling is not a smart move, but it’s perfectly fine to do once you’ve considered the most likely errors. In either error condition, the status is logged, and the final value is set to “Unknown”.

At this point, the function could return – it’s finished its work, there’s no point running any more code. Well, to ensure the final log statements run, the function continues to its completion. Notice how the button value checking is wrapped inside the check for result. If the result is None, then there are no errors, and the final logic can execute.

Inside your data.py Flask route, add your new boot sensor call to the show function:
boot_status = Sensors.get_boot_status()
Modify the resulting dictionary to contain the new value:
result = {"temperature": temperature, "boot": boot_status}

Your existing imports and logging are already sufficient for this new change.

Start Flask on your computer, and load the main route in your web browser. Your JSON payload should now contain information from the boot sensor, like this:
{"boot":"Unknown","temperature":0}
Your logs will contain errors and warnings about pin libraries and other boot sensor–related problems, yet the application continues to run, despite a full failure in the boot sensor. These logs are polluted as there is no boot sensor on your computer, and even if there was, the GPIO libraries are designed to work on a Pi, so will need configuring on your computer. This is the beauty of defensive programming. Despite running in an unsuitable environment without access to the GPIO pins, your application continues to work in “limp mode” – it may have reduced usefulness, but it refuses to let a major error hold it back. Figure 8-3 shows the application logs for a failing sensor.
../images/488914_1_En_8_Chapter/488914_1_En_8_Fig3_HTML.jpg
Figure 8-3

Boot sensor application logs, with a failed boot sensor

Commit your code and restart the Pi to pick up the latest changes. Visit the Pi’s main application route, and observe the new sensor status:
{"boot":"Open","temperature":0}
As the button is not held down, your code is detecting this as an open boot. In the real world, the boot will hold down this button. Hold the button with your finger, and reload the page. The status should change to “Closed”:
{"boot":"Closed","temperature":0}
If you don’t see this status, don’t panic. Go and check your application logs – what do they say? Has the Pi completed its restart procedure? Have the new libraries installed and is the latest code on the Pi? You can check this through SSH. Navigate to the Pi-Car directory and check the status of Git:
cd home/pi/Documents/Pi-Car
git pull
If up to date, git should inform you of the fact:
Already up to date.

Turn the Pi off and triple-check your wiring. Are the wires fully inserted into the breadboard? Are you connected to the correct pins, ensuring your pins match either the physical or Broadcom pin layout specific earlier?

First Unit Tests

Way back in the software development primer, I discussed test-driven development (TDD) and how writing unit tests before application logic produces better code. You may be wondering where the unit tests are then? That’s a fair question, and it’s one with a simple answer – there are none yet! I’m a firm believer in unit testing, but up until this point, you’re still getting your feet wet with Python, Flask, and this application. It’s difficult to write fully TDD code if you don’t yet understand what the code will do or how it should behave. Unit tests can help you determine this, but for simplicity, these first two projects have omitted the tests thus far. The projects following this chapter utilize test-driven development, but for this project, let’s write your first tests.

Let’s begin by breaking down what needs unit tests. Unit tests serve to test small independent components. The Flask route inside data.py has little logic to unit test. It ties together logic from the sensor class. This file would be a good candidate for integration testing, as it ultimately displays data to the outside world. App.py does little outside of Flask config and implementing logging. There’s little purpose unit testing other code which is already unit tested. As a general rule, you can rely on third-party libraries to work and (hopefully) have unit tests. You don’t need to test these libraries.

Therefore, sensors.py is the core file left. This is starting to grow and implement your business logic. It’s the perfect file to write unit tests for. As covered in the previous chapter, the get_external_temp function is not getting tests in this project. The benefits from testing its tiny amount of logic are far outweighed by the difficulties of working with the temperature library. The get_boot_status function, however, is perfect. It implements its own logic, with several simple conditions. Any reliance on third-party logic is easy to replace in the tests, and it serves one purpose.

This function returns the status of the boot sensor as a string. There are three possible conditions:
  • Open

  • Closed

  • Unknown

These conditions are determined by the error handling logic, or the output of button.is_pressed. To tightly control the conditions inside this test, you need to understand mocking. Mocking is the process of replacing a component inside your code. This component is not the piece under test at a given moment in time. This could be an API call, which costs you $1 every time it runs, a function to launch a missile, or some other expensive logic to run. You can imagine the trouble that may arise from running expensive or dangerous logic regularly in your tests. For this reason, mocking external dependencies (external to the code under test) is an essential part of unit testing.

For these tests, you’ll use the Pytest unit testing framework. This is an extension to the older unittest library. Both are excellent and well-used choices, although Pytest is fast becoming the go-to library for unit testing in Python. It’s possible to unit test without a framework such as Pytest, but it’s a lot more work. Pytest handles running the tests, ensuring they all pass, showing you the error message for failures, and lots more. There’s no need to reinvent the wheel and write your own test framework.

Inside your tests folder, delete the test_practice.py file. This fake test is no longer required now that you are writing a real test. Tests and test files should reflect the logic they are testing as far as possible. It’s OK to have long names for unit test functions, providing they provide clarity to you and other developers. Create a new file inside tests called test_sensors.py :
touch tests/test_sensors.py
This file will contain all the tests for the sensors class, but it won’t work just yet. Create two more files:
touch tests/conftest.py
touch Pi_Car/config/test_config.py
Conftest.py is a Pytest configuration file. Here’s where you can store code and common test variables, accessible to all unit tests. The config file test_config.py is another configuration file, just like your local_config.py. This config file stores configs for when your app is running tests. You may not always need the same configuration for tests as when running the application. Start with the test_config.py. Here are the three lines you need:
TESTING = True
LOGGER_LEVEL = "DEBUG"
FILE_LOGGING = False

You don’t need any other configuration options when running tests. You may notice a new config item at the bottom of this file called FILE_LOGGING. This is a new config item. Pytest will tell you what the problem is and show the logs from STDOUT when the tests fail, so it’s a bit redundant writing these to a log file as well. Making this a new config item lets you disable file logging for tests and enable it again for the normal application uses.

Add this new logging option to your local_config.py:
FILE_LOGGING = True
Now you need to implement it in your create_app app factory. Add an if statement to your app.py:
if app.config.get("FILE_LOGGING"):
Wrap all of the file based log handling inside this if. When this config option is True, the file-based logs will get written. When False, the logs will not get written. Here’s the new and improved create_app function:
def create_app(config_file="config/local_config.py"):
    app = Flask(__name__)  # Initialize app
    app.config.from_pyfile(config_file, silent=False)  # Read in config from file
    if app.config.get("FILE_LOGGING"):
        # Configure file based log handler
        log_file_handler = RotatingFileHandler(
            filename=app.config.get("LOG_FILE_NAME", "config/pi-car.log"),
            maxBytes=10000000,
            backupCount=4,
        )
        log_file_handler.setFormatter(
            logging.Formatter("[%(asctime)s] %(levelname)s in %(module)s: %(message)s")
        )
        app.logger.addHandler(log_file_handler)
    app.logger.setLevel(app.config.get("LOGGER_LEVEL", "ERROR"))
    app.logger.info("----- STARTING APP ------")
    app.register_blueprint(data_blueprint)
    app.logger.info("----- FINISHED STARTING APP -----")
    return app
Back in your unit tests, here are the contents of conftest.py :
import pytest
from Pi_Car.app import create_app
@pytest.fixture(scope="session", autouse=True)
def build_testing_app():
    """
    Builds a testing app with basic application context
    """
    app = create_app(config_file="config/test_config.py")
    app.app_context().push()
    yield app

This introduces several Pytest patterns. The build_testing_app function is a Pytest fixture. Fixtures let you share code between tests. This fixture builds a Flask app using your application factory (create_app). To run tests against Flask code, or code which uses Flask features, you need an application running. This logic builds an app. The app_context is a Flask object that gets populated when Flask runs. This gets populated with all kinds of information which Flask needs to work. By pushing a new context, you’re configuring Flask with everything it needs to handle normal operations during your tests.

The yield statement is essentially the same as a return statement right now, but yield serves a powerful purpose. Pytest will run this code until the yield statement – it will build and return a Flask app. Once the tests have finished executing, Pytest will return and run any code after the yield statement. This lets you write logic to build requirements for tests, and then more logic to destroy or reset these requirements. The yield statement is a great way to reduce boilerplate code and get on with writing useful, valuable code.

Finally, the Pytest fixture decorator tells Pytest to use this function as a fixture:
@pytest.fixture(scope="session", autouse=True)

Pytest fixtures are blocks of code that you can use in your tests. The scope parameter configures when to run this code. The session value means Pytest will run this code once at the start of your tests (and at the end). Other scopes exist such as running code for every test, every class, and more. Finally, the autouse parameter tells Pytest to run this code automatically, rather than explicitly waiting for you to call the function. Your conftest is now ready to start assisting your tests.

Back in your test_sensors.py, here are the first tests you need:
from unittest.mock import patch
from Pi_Car.sensors import Sensors
from gpiozero import exc
class TestSensors:
    @patch("Pi_Car.sensors.Button")
    def test_get_boot_status_bad_pin_factory(self, mock_button):
        mock_button.side_effect = exc.BadPinFactory
        result = Sensors.get_boot_status()
        assert result == "Unknown"
    @patch("Pi_Car.sensors.Button")
    def test_get_boot_status_other_pin_error(self, mock_button):
        mock_button.side_effect = TypeError
        result = Sensors.get_boot_status()
        assert result == "Unknown"
    @patch("Pi_Car.sensors.Button")
    def test_get_boot_status_closed(self, mock_button):
        mock_button.return_value = type("Button", (), {"is_pressed": True})
        result = Sensors.get_boot_status()
        assert result == "Closed"
    @patch("Pi_Car.sensors.Button")
    def test_get_boot_status_open(self, mock_button):
        mock_button.return_value = type("Button", (), {"is_pressed": False})
        result = Sensors.get_boot_status()
        assert result == "Open"
Run these from your console using the Pytest command:
pytest
If you’d like to see the logs as well, use the -s flag (although Pytest will only show logs when the tests fail):
pytest -s
There are four tests here, each with a unique and detailed name. Pytest automatically runs any test files inside the test folder. Files beginning with the word “test”, along with classes and functions, are run as tests. Any function, file, or class which does not begin with “test” will not run.
../images/488914_1_En_8_Chapter/488914_1_En_8_Fig4_HTML.png
Figure 8-4

Successful unit test run

Note how Pytest provides you a test status – number of tests run and the total number of passes. If they are all successful, the Pytest status bar goes green (Figure 8-4). If there are any failures, Pytest indicates this with a red status bar (Figure 8-5) and by pointing out the exact number of failures and the condition.
../images/488914_1_En_8_Chapter/488914_1_En_8_Fig5_HTML.jpg
Figure 8-5

Failing unit test run

Play around with your sensor code by changing it to fail the tests – change return types, and exception handling, and notice how the tests fail. This is what makes well-written unit tests so powerful. By covering the edge cases and core functionality, you can be confident that your tests will pick up any existing functionality you may break when implementing new features.

After importing the testing libraries, and some of the modules to test, there are four test functions residing inside the TestSensors class. Each function tests a specific condition of the get_boot_status function. These four conditions are as follows:
  1. 1.

    Bad pin factory – The GPIO library cannot read the pins.

     
  2. 2.

    Other pin error – The exception handling caught an error when reading the GPIO pins.

     
  3. 3.

    Closed – The function returns “Closed” when the button is pressed.

     
  4. 4.

    Open – The function returns “Open” when the button is not pressed.

     

Each test functions in a similar way. They begin by configuring the conditions required for the code under test to reach a certain state. They then run the code and use assert statements to verify that the output of the function is as expected when the code runs under the predefined conditions.

Each test uses the patch decorator to mock the sensor library:
@patch("Pi_Car.sensors.Button")

This function is part of the unittest library, which Pytest extends. This is a mock. As mentioned earlier, mocking lets you replace parts of your code to make it run a certain way under test. Tests should be as repeatable as possible. Right now, your defensive programming kicks in and manipulates the logic when certain libraries are not available. This is great for ensuring your logic still works when running in different environments, but it’s not consistent enough for a reliable test suite.

By mocking the GPIO library, you can control when, what, and how this library works, to achieve the desired result. The first two functions use side effects, with the side_effect property. This is a way to make your code raise specific errors. As your function returns “Unknown” in two error conditions, mocking these functions to raise both exc.BadPinFactory and TypeError ensures these conditions are met during the tests.

The final two tests use the return_value attribute to force the Button class to return a specific value for the is_pressed attribute. Remember, you’re not testing that the external libraries work, you’re testing your specific logic operates under given conditions. Because is_pressed is an attribute and not a function (that is to say, it’s a variable assigned to instances of the Button class), the return_value attribute lets you force a function or attribute to return what you say it should.

You may be wondering why you can’t set the return value like this:
mock_button.is_pressed.return_value = True
Ordinarily, this would work. For functions, you can specify what they should return exactly like this. However, as the is_pressed attribute is a variable and not a function, it needs a little more assistance. By using the type function (part of the Python core library), it’s possible to construct entire fake objects to really manipulate your code under test:
mock_button.return_value = type("Button", (), {"is_pressed": False})

The third parameter passed to this function is a dictionary. This dictionary is essentially the name and return values for any attribute you want to mock. You can see that test_get_boot_status_closed and test_get_boot_status_open return different values for is_pressed, according to their test requirements.

Asserts are a special utility in Python. Any assert statement is used by tests or test-like logic to ensure that a condition meets the criteria. It’s a bit like an if statement but uses far less lines of code, and it is automatically picked up by test frameworks such as Pytest. Assert statements are like saying “make sure this thing returns some specific value.” This logic checks that whatever Sensors.get_boot_status() returns is a string with a value of “Open”:
result = Sensors.get_boot_status()
assert result == "Open"

If this condition is not met, the tests will fail.

It’s very important to ensure that you only use assert statements inside test code. They may look like a shortcut to improve your code, but they are not designed to run inside production code and will come back to hurt you at some point in the future. It’s possible to disable assert statements altogether in Python. This nets you a modest performance increase, and as you shouldn’t use them for production code, it shouldn’t be a problem. With asserts disabled, they will not evaluate, so any code actually relying on them will fail.

As you progress with these projects, I’ll show you some Pytest tricks to reduce the lines of test code you need to write. I’ll also cover writing tests in a fully TDD way, which can improve your overall code quality.

Chapter Summary

In this chapter you installed and developed the code for a simple boot sensor. You can expand this project to cover your car doors, bonnet, or any other opening. You developed your first unit tests using Pytest and learned how to write assertions, along with when not to use them. Once again, you developed this code defensively, such that the application can continue to work even with a total sensor failure.

In the next chapter, you’ll expand on your unit testing skills by developing a light sensor.

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

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