I²C Driver Exercise - Easy Version

We're not going to write an entire driver, merely the first step: the hello world of driver writing: reading the device ID of the sensor. This version is labelled easy, because we explain the code fragments, and you only have to copy and paste the fragments into the right place. Use this version if you have very little previous experience with Rust, if these workshops are your first in the embedded domain, or if you found the hard version too hard. You can work in the same file with either version.

i2c-driver/src/icm42670p.rs is a gap text of a very basic I²C IMU sensor driver. The task is to complete the file, so that running main.rs will log the device ID of the driver. This gap text driver is based on the version of the same name that lives in common, but provides a little bit more functionality.

i2c-driver/src/icm42670p_solution.rs provides the solution to this exercise. If you want to run it, the imports need to be changed in main.rs and lib.rs. The imports are already there, you only need to comment the current imports out and uncomment the solutions as marked in the line comments.

Driver

Instance of the Sensor

To use a peripheral sensor first you must get an instance of it. The sensor is represented as a struct that contains both its device address, and an object representing the I²C bus itself. This is done using traits defined in the embedded-hal crate. The struct is public as it needs to be accessible from outside this crate, but its fields are private.


#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct ICM42670P<I2C> {
    /// The concrete I²C device implementation.
    i2c: I2C,

    /// Device address
    address: DeviceAddr,
}

// ...
}

We add an impl block that will contain all the methods that can be used on the sensor instance. It also defines the Error Handling. In this block we also implement an instantiating method. Methods can also be public or private. This method needs to be accessible from outside, so it's labelled pub. Note that written this way, the sensor instance takes ownership of the I²C bus.


#![allow(unused)]
fn main() {
impl<I2C, E>ICM42670P<I2C>
where
    I2C: i2c::WriteRead<Error = E> + i2c::Write<Error = E>,
{
    /// Create a new instance of the ICM42670P.
    pub fn new(i2c: I2C, address: DeviceAddr) -> Result<Self, E> {
        Ok(Self{ i2c, address })
    }
// ...
}

Device address

  • the device's addresses are available in the code:

#![allow(unused)]
fn main() {
AD0 = 0b110_1000, // or 0x68
AD1 = 0b110_1001, // or 0x69
}
  • This I²C device has two possible addresses - 0x68 and 0x69. We tell the device which one we want it to use by applying either 0V or 3.3V to the AP_AD0 pin on the device. If we apply 0V, it listens to address 0x68. If we apply 3.3V it listens to address 0x69. You can therefore think of pin AD_AD0 as being a one-bit input which sets the least-significant bit of the device address. More information is available in the data sheet, section 9.3

Representation of Registers

The sensor's registers are represented as enums. Each variant has the register's address as value. The type Register implements a method that exposes the variant's address.

#[derive(Clone, Copy)]
pub enum Register {
    WhoAmI = 0x75,
}

impl Register {
    fn address(&self) -> u8 {
        *self as u8
    }
}

read_register() and write_register()

We define a read and a write method, based on methods provided by the embedded-hal crate. They serve as helpers for more specific methods and as an abstraction that is adapted to a sensor with 8-bit registers. Note how the read_register() method is based on a write_read() method. The reason for this lies in the characteristics of the I²C protocol: We first need to write a command over the I²C bus to specify which register we want to read from. Helper methods can remain private as they don't need to be accessible from outside this crate.


#![allow(unused)]
fn main() {
impl<I2C, E>ICM42670P<I2C>
where
    I2C: i2c::WriteRead<Error = E> + i2c::Write<Error = E>,
{    
    //...
    fn write_register(&mut self, register: Register, value: u8) -> Result<(), E> {
        let byte = value as u8;
        self.i2c
            .write(self.address as u8, &[register.address(), byte])
    }

    fn read_register(&mut self, register: Register) -> Result<u8, E> {
        let mut data = [0];
        self.i2c
            .write_read(self.address as u8, &[register.address()], &mut data)?;
        Ok(u8::from_le_bytes(data))
    }
}
}

✅ Implement a public method that reads the WHOAMI register with the address 0x0F. Make use of the the above read_register() method.

✅ Optional: Implement further methods that add features to the driver. Check the documentation for the respective registers and their addresses. Some ideas:

* switching the the gyroscope sensor or the accelerometer on
* starting measurements
* reading measurements

🔎 General info about peripheral registers

Registers can have different meanings; in essence they are a location that can store a value.

In this specific context we are using an external device (since it is a sensor, even if it is on the same PCB). It is addressable by I2C and we are reading and writing to its register addresses. The addresses each identify a unique location that contains some information. In this case, we want the address for the location that contains the current temperature, as read by the sensor.

You can find the register map of the ICM-42670 in section 14 should you want to try to get other interesting data from this sensor.