Skip to content
David Beechey edited this page Oct 18, 2024 · 1 revision

Abstraction

To make our implementations of sensors board-agnostic (i.e., they should work regardless of what board we use them on), we cannot use the embassy_stm32 crate since this requires us to enable exactly one board feature.

Instead, we create our own traits to represent different IO protocols. These traits can then be implemented for a particular board (typically a very short piece of code) when required.

This means that sensor implementations are defined for a struct that implements the trait for the protocol that they require, so have no dependency on embassy_stm32.

This has the added benefit of making it easy to create mock IO implementations which allow us to write tests suites to check that the behaviour of our sensor implementation is correct for particular inputs.

So far, we have the following traits defined:

  • HypedI2c - for I2C
  • HypedGpioPin - for GPIO, we also define MockGpio which can be used for testing

For example, the HypedI2c trait looks like this at time of writing:

pub trait HypedI2c {
    fn read_byte(&mut self, device_address: u8, register_address: u8) -> Option<u8>;
    fn write_byte_to_register(
        &mut self,
        device_address: u8,
        register_address: u8,
        data: u8,
    ) -> Result<(), I2cError>;
}

As you can see, we are provided with two functions: read_byte and write_byte_to_register. These are probably the only two functions an I2C sensor implementation will require, but if it isn't then you can add a new function to it.

Note that by adding a new function to this trait, you will have to create a hardware implementation for every board that currently has an I2C struct which implements HypedI2c.

Hardware Implementations

Once we have our traits defined, we can implement them for a particular board.

We have the following hardware implementations of HypedI2c and HypedGpioPin for the STM32L476 board (found in boards/stm32l476/src/io):

  • Stm32l476rgGpio - A GPIO implementation for the STM32L476 board, which implements the HypedGpioPin trait. It works by taking in an input pin from embassy_stm32 and mapping its API to the one defined by the HypedGpioPin trait.
  • Stm32l476rgI2c - An I2C implementation for the STM32L476 board, which implements the HypedI2c trait. It works by taking in an embassy_stm32::i2c:I2c struct and mapping its API to the one defined by the HypedI2c trait.

An example hardware implementation is Stm32l476rgI2c, which looks like this at the time of writing:

use embassy_stm32::{i2c::I2c, mode::Blocking};
use hyped_io::i2c::{HypedI2c, I2cError};

pub struct Stm32l476rgI2c<'d> {
    i2c: I2c<'d, Blocking>,
}

impl<'d> HypedI2c for Stm32l476rgI2c<'d> {
    /// Read a byte from a register on a device
    fn read_byte(&mut self, device_address: u8, register_address: u8) -> Option<u8> {
        let mut read = [0];
        let result =
            self.i2c
                .blocking_write_read(device_address, [register_address].as_ref(), &mut read);
        match result {
            Ok(_) => Some(read[0]),
            Err(_) => None,
        }
    }

    /// Write a byte to a register on a device
    fn write_byte_to_register(
        &mut self,
        device_address: u8,
        register_address: u8,
        data: u8,
    ) -> Result<(), I2cError> {
        let result = self
            .i2c
            .blocking_write(device_address, [register_address, data].as_ref());

        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(match e {
                embassy_stm32::i2c::Error::Bus => I2cError::Bus,
                embassy_stm32::i2c::Error::Arbitration => I2cError::Arbitration,
                embassy_stm32::i2c::Error::Nack => I2cError::Nack,
                embassy_stm32::i2c::Error::Timeout => I2cError::Timeout,
                embassy_stm32::i2c::Error::Crc => I2cError::Crc,
                embassy_stm32::i2c::Error::Overrun => I2cError::Overrun,
                embassy_stm32::i2c::Error::ZeroLengthTransfer => I2cError::ZeroLengthTransfer,
            }),
        }
    }
}

impl<'d> Stm32l476rgI2c<'d> {
    /// Create a new instance of our I2C implementation for the STM32L476RG
    pub fn new(i2c: I2c<'d, Blocking>) -> Self {
        Self { i2c }
    }
}

This can be broken up into the following:

Defining the struct

We define a struct to hold anything required for our implementation, typically the embassy_stm32 struct for whatever sensor we are implementing:

pub struct Stm32l476rgI2c<'d> {
    i2c: I2c<'d, Blocking>,
}

Here we are taking in a blocking version of embassy_stm32::i2c::I2c.

Implementing the IO trait

We can then implement the IO trait by using impl _ for _ which tells Rust that our struct will have the functions from the IO trait implemented on it, which Rust will guarantee at compile time.

impl<'d> HypedI2c for Stm32l476rgI2c<'d> {
    /// Read a byte from a register on a device
    fn read_byte(&mut self, device_address: u8, register_address: u8) -> Option<u8> {
        ...
    }

    /// Write a byte to a register on a device
    fn write_byte_to_register(
        &mut self,
        device_address: u8,
        register_address: u8,
        data: u8,
    ) -> Result<(), I2cError> {
        ...
    }
}

Adding a new function

The last step is to define a function for creating an instance of our struct.

impl<'d> Stm32l476rgI2c<'d> {
    /// Create a new instance of our I2C implementation for the STM32L476RG
    pub fn new(i2c: I2c<'d, Blocking>) -> Self {
        Self { i2c }
    }
}

In this example, our new function takes in an embassy_stm32::i2c::I2c struct.

Note that we are not passing in a reference, instead we are taking ownership of this IO