Drivers Architecture - uw-advanced-robotics/taproot GitHub Wiki

This page is designed to walk through how to interact with the codebase's drivers architecture.

Overview

In the same way that Java has a global System.out stream to let you print without first constructing an output object, our embedded systems need ways to interact with many types of outside devices. In general, we refer to these as "Input/Output" (I/O) interfaces. For example, turning an LED on or off would be a type of I/O, as is controlling a motor or reading a sensor. These interfaces are called global since all other parts of the codebase interact with the single instance of this interface. In some embedded devices, it is common for an interface to store a global instance of itself that all other parts of the code uses. This becomes cluttered and overwhelming when many global instances are introduced and stored in separate locations. The issues with this system become especially clear when attempting to integrate all the global instances into a unit test environment.

The main drivers class present in this codebase is a container that stores global instances of common hardware and architecture interfaces. Members of this class are intended to be singletons, meaning only one instance of each exists in the system; furthermore, the expectation is that they are instantiated (statically!) once when the system starts and remain until we shut down. All interfaces in this class are used to interact directly with single hardware or architecture components.

Usage

Drivers

Now that an architecture has been laid out, let us consider how this is implemented. First off, consider the Drivers class. As noted above, this is the central location for singletons. This is partially defined as follows:

class Drivers
{
    friend class DriversSingleton;

    // Only in the sim environment can anyone construct a Drivers class
#ifdef ENV_SIMULATOR
public:
#endif
    Drivers()
        : can(),
          ...
          djiMotorTxHandler(this)
    {
    }

#if defined(PLATFORM_HOSTED) && defined(ENV_UNIT_TESTS)
    // Mock instances of all singletons, for sim environment
    CanMock can;
    ...
    DjiMotorTxHandlerMock djiMotorTxHandler;
#else
public:
    // Instance of all singletons
    can::Can can;
    ...
    motor::DjiMotorTxHandler djiMotorTxHandler;
#endif
};  // class Drivers

As you can see, this class stores unique instances of all system-wide drivers. In the Drivers constructor, certain drivers are passed this on construction. Why this is done is explained below. You can also see that when in simulation mode, mock classes replace the actual driver instances. More detail on mock drivers is below as well. While these drivers are meant to be singletons, they are not declared static. This means we can avoid the difficulties of static global variables, which are very cumbersome to work with while writing unit tests. Instead we store a single instance of the Drivers class in DriversSingleton.cpp which is used while running on hardware. The DriversSingleton class is the only place in the non-simulation environment that can declare an instance of the Drivers class. This file defined as follows:

class DriversSingleton
{
public:
    static Drivers drivers;
};  // class DriversSingleton
...
Drivers *DoNotUse_getDrivers() { return &DriversSingleton::drivers; }

The Drivers class is stored statically for optimization and stability purposes as opposed to allocating the single Drivers class dynamically. The function DoNotUse_getDrivers() is meant to be called only in main.cpp and the control define files, *_control.cpp and not in any drivers, subsystems, or commands.

Drivers with and without dependencies

Driver classes which depend on other drivers are passed a this pointer so they can reference their "sibling" objects. The Can class, for example, does not depend on any other drivers. Instead it interacts with modm HALs.

The DjiMotorTxHandler, on the other hand, does rely on other drivers. Instead of using the Drivers class globally, for unit testing it is necessary to pass in a particular instance of the Drivers class to a particular driver. Consider the DjiMotorTxHandler, partially completed below:

class DjiMotorTxHandler
{
public:
    DjiMotorTxHandler(Drivers *drivers) : drivers(drivers) {}
    ...
    void processCanSendData()
    {
        ...
        if (drivers->can.isReadyToSend(can::CanBus::CAN_BUS1))
        {
            drivers->can.sendMessage(can::CanBus::CAN_BUS1, can1MessageLow);
            drivers->can.sendMessage(can::CanBus::CAN_BUS1, can1MessageHigh);
        }
        ...
    }
    ...
};  // class DjiMotorTxHandler

The key here is that in the constructor of the DjiMotorTxHandler, we can pass in a unique Drivers object. This allows us the versatility to construct a unique fake one in our test environments, but use the real ones when running on-robot. It also eliminates the global dependencies that are mentioned above. The same concept of passing around pointers is applied ot the device objects, subsystems, and commands.

Mocking components of the drivers architecture

Removing global references allows for unit tests to be easily written for an isolated driver. Suppose you would like to write unit tests for the DjiMotorTxHandler class. Out of necessity, since the DjiMotorTxHandler in our environment depends on the Can class, we must also create a mock Can object for it to use. While running unit tests, the Drivers object's real drivers have been mocked using the googlemock mocking framework. Mocks have been declared in test/mock, each of which have configurable behavior at runtime that can be defined in a unit test. This allows you to easily mock any function in the can class.

The CanMock class is partially declared below:

class CanMock : public tap::can::Can
{
public:
    ...
    MOCK_METHOD(bool, isReadyToSend, (tap::can::CanBus bus), (const override));
    MOCK_METHOD(bool, sendMessage, (tap::can::CanBus bus, const modm::can::Message &message), (override));
}

All mock classes extend the class being mocked and then declare MOCK_METHODs for all functions they intend to mock. A CanMock object replaces the Can object in the Drivers when compiling the sim environment. The additional step that has to be made in order for the mock to operate as expected is that functions that are mocked must be virtual. To avoid decreasing on-hardware performance that comes with declaring virtual functions, the mockable macro was created. This evaluates to virtual if running in a simulated environment and is otherwise a no-op.

Using the mock classes in the Drivers class, a simple googletest TEST case would look like this:

TEST(DjiMotorTxHandler, processCanSendData_nothing_sent_when_CAN_busy)
{
    // Set up the test
    Drivers drivers;
    DjiMotorTxHandler handler(&drivers);
    EXPECT_CALL(drivers.can, sendMessage(_, _)).Times(0);
    ON_CALL(drivers.can, isReadyToSend(_)).WillByDefault(Return(false));

    // Run the test
    handler.processCanSendData();
}

Since this page is not meant to go into detail about how to write unit tests, refer to the googletest primer and the googlemock cookbook for unit test semantics. The important takeaway is that the Drivers class can be easily integrated into a unit test framework with minimal overhead and on-hardware performance cost.