Program a Connected Device using Raft, Part 4

Let’s now turn our attention to the IMU and work on getting some data from the accelerometer & gyroscope. The source code is available on Github – see the Releases for the code at each step if you want to follow along.

RaftI2C

The conventional way to communicate with an I2C device from an embedded application is to use a library which is specific to the I2C device, and generally involves the user generated code in the polling activity. This approach works ok for simple situations but it makes polling at regular intervals more difficult and doesn’t handle situations like bus failures or more complex scenarios with multiple devices very well.

The RaftI2C library handles things rather differently as explained in this earlier post and all of the detection, initialization, polling and bus failure recovery is handled by the RaftI2C library and using a FreeRTOS task dedicated to the management of the bus. To get started we need to first include the RaftI2C library in our application.

# Set the target Espressif chip
set(IDF_TARGET "esp32s3")

# System version
add_compile_definitions(SYSTEM_VERSION="1.0.0")

# Raft components
set(RAFT_COMPONENTS
    RaftSysMods@main
    RaftWebServer@main
    RaftI2C@main
)

# File system
set(FS_TYPE "littlefs")
set(FS_IMAGE_PATH "../Common/FSImage")

We have added line 11 which includes the RaftI2C library (main branch) in the project.

Let’s follow the pattern we’ve used previously and add a new SysMod for the accelerometer. If you are adding multiple devices or want to handle devices more dynamically then a good alternative to this would be to create a single SysMod as a “device manager” and have this handle all of the devices.

IMUSysMod

The code presented below has strong similarities to the LEDRingSysMod code. It derives from RaftSysMod and has the same setup() and loop() functions that all SysMods have:

////////////////////////////////////////////////////////////////////////////////
// IMUSysMod.h
////////////////////////////////////////////////////////////////////////////////

#pragma once

#include "RaftArduino.h"
#include "RaftSysMod.h"
#include "BusManager.h"

class IMUSysMod : public RaftSysMod
{
public:
    IMUSysMod(const char *pModuleName, RaftJsonIF& sysConfig);
    virtual ~IMUSysMod();

    // Create function (for use by SysManager factory)
    static RaftSysMod* create(const char* pModuleName, RaftJsonIF& sysConfig)
    {
        return new IMUSysMod(pModuleName, sysConfig);
    }

protected:

    // Setup
    virtual void setup() override final;

    // Loop (called frequently)
    virtual void loop() override final;

private:
    // Bus manager
    BusManager _busManager;

    // Bus operation and status functions
    void busElemStatusCB(BusBase& bus, const std::vector<BusElemAddrAndStatus>& statusChanges);
    void busOperationStatusCB(BusBase& bus, BusOperationStatus busOperationStatus);

    // Last report time
    uint32_t _debugLastReportTimeMs = 0;
};

We have added a BusManager member called _busManager and a couple of functions busElemStatusCB and busOperationStatusCB which will be used as callbacks so the I2C can tell us about devices that have been added and whether the bus is working correctly. We’ve also added a member variable called _debugLastReportTimeMs which we’ll use for timing of debug output.

The code to implement the IMUSysMod should also look somewhat familiar:

////////////////////////////////////////////////////////////////////////////////
// IMUSysMod.cpp
////////////////////////////////////////////////////////////////////////////////

#include "IMUSysMod.h"
#include "RaftUtils.h"
#include "BusI2C.h"

static const char *MODULE_PREFIX = "IMUSysMod";

IMUSysMod::IMUSysMod(const char *pModuleName, RaftJsonIF& sysConfig)
    : RaftSysMod(pModuleName, sysConfig)
{
}

IMUSysMod::~IMUSysMod()
{
}

void IMUSysMod::setup()
{
    // Register BusI2C
    _busManager.registerBus("I2C", BusI2C::createFn);

    // Setup buses
    _busManager.setup("Buses", modConfig(),
            std::bind(&IMUSysMod::busElemStatusCB, this, std::placeholders::_1, std::placeholders::_2),
            std::bind(&IMUSysMod::busOperationStatusCB, this, std::placeholders::_1, std::placeholders::_2)
    );    
}

void IMUSysMod::loop()
{
    // Service the buses
    _busManager.loop();

    // Debug: report device data (100ms intervals)
    if (Raft::isTimeout(millis(), _debugLastReportTimeMs, 100))
    {
        LOG_I(MODULE_PREFIX, "loop debugging");
        _debugLastReportTimeMs = millis();
    }
}

/// @brief Bus operation status callback
/// @param bus
/// @param busOperationStatus - indicates bus ok/failing
void IMUSysMod::busOperationStatusCB(BusBase& bus, BusOperationStatus busOperationStatus)
{
    // Debug
    LOG_I(MODULE_PREFIX, "busOperationStatusInfo %s %s", bus.getBusName().c_str(), 
        BusBase::busOperationStatusToString(busOperationStatus));
}

/// @brief Bus element status callback
/// @param bus 
/// @param statusChanges - an array of status changes (online/offline) for bus elements
void IMUSysMod::busElemStatusCB(BusBase& bus, const std::vector<BusElemAddrAndStatus>& statusChanges)
{
    // Debug
    for (const auto& el : statusChanges)
    {
        LOG_I(MODULE_PREFIX, "busElemStatusInfo %s %s %s", bus.getBusName().c_str(), 
            bus.addrToString(el.address).c_str(), el.isChangeToOnline ? "Online" : ("Offline" + String(el.isChangeToOffline ? " (was online)" : "")).c_str());
    }
}

The main things to focus on are:

  • the setup() function:
    • registers an I2C bus with the BusManager object
    • it then calls the BusManager’s setup() function which:
      • this initialize all of the buses of types that have been registered as desribed below
      • the first parameter to setup() must exactly correspond to a section of the SysTypes.json configuration file inside this SysMod’s section
      • the second parameter is this SysMod’s configuration
      • the third and fourth parameters are callback functions for bus element and bus operation status changes as described above
  • the loop() function:
    • calls the BusManager’s loop function
    • prints a diagnostic message once per 100ms – for now it just says “loop debugging”

In order for the setup() function to correctly initialise the I2C bus we need to ensure that the bus parameters are defined in the SysTypes.json file. Here is the section required to define the IMUSysMod configuration which includes the I2C bus (with SCL on GPIO9, SDA on GPIO8 and a I2C clock speed of 100KHz):

    "IMUSysMod": {
        "Buses": {
            "buslist":
            [
                {
                    "name": "I2CA",
                    "type": "I2C",
                    "sdaPin": 8,
                    "sclPin": 9,
                    "i2cFreq": 100000
                }
            ]
        }
    }

Finally the CMakeLists.txt file in the IMUSysMod folder needs to include a reference to the RaftI2C library in the REQUIRES section:

idf_component_register(
    SRCS 
        "IMUSysMod.cpp"
    INCLUDE_DIRS
        "."
    REQUIRES
        RaftCore
        RaftI2C
)

I2C Device Data

Since the accelerometer that we are using (LSM6DS3) is a device which is directly supported by the RaftI2C library, it will be detected, initialized and polled automatically based on the information in the DeviceTypeRecords.json which is part of the RaftI2C library.

There are two main alternative ways to access the information coming from poll responses depending on what we want to do with the information:

  • decode-off-device: request a JSON document (or binary blob) of data which can be sent to another computer (such as a connected tablet, PC, chromebook or cloud service) without decoding on the device – this is useful when you want to create a device that collects data and sends it to a bigger computer for processing – it has a lower overhead on the embedded microcontroller
  • decode-on-device: create an object (or collection of objects) to represent the data from the device and then extract, convert and decode the data using the RaftI2C library – this is useful when you want to use the device data on the embedded device (e.g. to display it on a screen or act upon it with program logic), however it does place an overhead on the embedded microcontroller

Decoding Off-Device

For off-device decoding we have the choice between directly getting a JSON document with the latest poll response information or getting a binary “blob” which contains the same info. We’ll concentrate here on the JSON approach:

void IMUSysMod::loop()
{
    // Service the buses
    _raftBusSystem.loop();

    // Check if we should report device data (100ms intervals)
    if (Raft::isTimeout(millis(), _debugLastReportTimeMs, 100))
    {
        String jsonStr;
        for (RaftBus* pBus : _raftBusSystem.getBusList())
        {
            if (!pBus)
                continue;

            // Get device interface
            RaftBusDevicesIF* pDevicesIF = pBus->getBusDevicesIF();
            if (!pDevicesIF)
                continue; 
            String jsonRespStr = pDevicesIF->getPollResponsesJson();
            if (jsonRespStr.length() > 0)
            {
                jsonStr += (jsonStr.length() == 0 ? "{\"" : ",\"") + pBus->getBusName() + "\":" + jsonRespStr;
            }
        }

        LOG_I(MODULE_PREFIX, "loop %s", (jsonStr.length() == 0 ? "{}" : (jsonStr + "}").c_str()));
        _debugLastReportTimeMs = millis();
    }
}

The operation of this function (inside the timed section) is:

  • loop through the buses (there should only be one in our case)
    • get a pointer to its RaftBusDevicesIF object (this allows us to access devices that are attached to the bus)
    • call the getPollResponsesJson() method to get the poll response information
    • add this to the list of JSON responses
  • display the combined responses in JSON format

If we now build the run the application (build, flash, monitor) then we should see something like the following repeatedly output:

I (113289) IMUSysMod: loop {"I2CA":{"0x6a@0":{"x":"ba2dfffffeff0000aa0a5700a41e","_o":1,"_t":"LSM6DS3"}}}
I (113395) IMUSysMod: loop {"I2CA":{"0x6a@0":{"x":"ba92fcff060002008f0a5e00ba1e","_o":1,"_t":"LSM6DS3"}}}
I (113503) IMUSysMod: loop {"I2CA":{"0x6a@0":{"x":"baf6fdff060002009d0a4500cc1ebb5b0200fbfffeffa30a4b00b11e","_o":1,"_t":"LSM6DS3"}}}
I (113607) IMUSysMod: loop {"I2CA":{"0x6a@0":{"x":"bbc30100feffffff8d0a2c00851e","_o":1,"_t":"LSM6DS3"}}}

These are produced once per 100 milliseconds and should contain 0,1 or 2 responses (since the IMU is also being polled around 10 times per second and there is no synchronization between polling and display).

The data in the “x” record is a hex encoded respresentation of:

  • the timestamp for the record (currently a 16 bit big-endian value)
  • the data from the poll response just as it came in from the device

In this scenario the data is generally decoded on a different device using the schema information that is available through the device interface. In a later post we’ll close the loop on this and actually decode the data.

Decoding On-Device

To decode on-device a C struct of a device-specific poll record (which is auto generated as explained in this blog post) is used. These struct definitions are contained in the DevicePollRecords_generated.h which is in the build/SysTypeMain folder.

#include "DevicePollRecords_generated.h"

...

void IMUSysMod::loop()
{
    // Service the buses
    _raftBusSystem.loop();

    // Check if we should report device data (100ms intervals)
    if (Raft::isTimeout(millis(), _debugLastReportTimeMs, 100))
    {
        for (RaftBus* pBus : _raftBusSystem.getBusList())
        {
            if (!pBus)
                continue;

            // Get device interface
            RaftBusDevicesIF* pDevicesIF = pBus->getBusDevicesIF();
            if (!pDevicesIF)
                continue; 

            // Get list of devices that have data
            std::vector<uint32_t> addresses;
            pDevicesIF->getDeviceAddresses(addresses, true);

            // Loop through devices
            for (uint32_t address : addresses)
            {
                // Since there is only one device we're just going to get the data
                // in the format for the LSM6DS3
                std::vector<poll_LSM6DS3> pollResponses;
                pollResponses.resize(2);

                // Get decoded poll responses
                uint32_t numDecoded = pDevicesIF->getDecodedPollResponses(address, pollResponses.data(), 
                            sizeof(poll_LSM6DS3)*pollResponses.size(),
                            pollResponses.size(), _decodeState);

                // Log the decoded data
                for (uint32_t i = 0; i < numDecoded; i++)
                {
                    auto& pollResponse = pollResponses[i];
                    LOG_I(MODULE_PREFIX, "loop time %lu addr %s ax %.2fg ay %.2fg az %.2fg gx %.2fdps gy %.2fdps gz %.2fdps",
                        pollResponse.timeMs, pBus->addrToString(address).c_str(),
                        pollResponse.ax, pollResponse.ay, pollResponse.az,
                        pollResponse.gx, pollResponse.gy, pollResponse.gz
                    );
                }
            }
        }
        _debugLastReportTimeMs = millis();
    }
}

The operation of this function (inside the timed section) is:

  • loop through the buses (there should only be one in our case)
    • get a pointer to its RaftBusDevicesIF object (this allows us to access devices that are attached to the bus)
    • create an array of decoded poll responses (pollResponses) and resize to 2 (this should be enough for the reasons explained above)
    • call the getDecodedPollResponses() method to get the poll response information for each address
    • Log out the decoded data

If we now build the run the application (build, flash, monitor) then we should see something like the following repeatedly output:

I (71764) IMUSysMod: loop time 6213 addr 0x6a@0 ax 0.00g ay 0.01g az 1.01g gx 0.24dps gy -1.53dps gz -1.65dps
I (71868) IMUSysMod: loop time 6314 addr 0x6a@0 ax 0.00g ay 0.01g az 1.02g gx 0.12dps gy -1.16dps gz -1.53dps
I (71970) IMUSysMod: loop time 6418 addr 0x6a@0 ax 0.00g ay 0.01g az 1.02g gx 0.37dps gy -1.40dps gz -1.77dps

The accelerometer units are g (earth gravity) and the IMU is flat on the table so az (the acceleration vector perpendicular to the table in this case) is approximately 1. The IMU is not moving so the readings from the gyro are small.