Writing embedded drivers in Rust isn't that hard. Part 2
Now that we implemented a small program that is able to read the ID field of the AT42QT1070, we will first write a small library that generalizes this implementation to every HAL that provides an I2C implementation and then expand upon that implementation with more and more functionality.
A simple driver structure
First off we define a simple Driver
struct which just contains a generic I2C object as well as
an Error
enum that, for now, only has the I2cError
variant which in turn contains a generic type,
this is because different HALs do of course return different I2C errors and we want to be prepared for all of them.
#![no_std]
use embedded_hal::blocking::i2c::WriteRead;
#[derive(Clone, Copy, Debug)]
pub enum Error<I2cError> {
I2cError(I2cError),
}
pub struct Driver<I2C> {
i2c: I2C,
}
As our I2C struct is purely generic, as of now our implementation will of course have to constrain it
to implementing the WriteRead
trait, this is quite simply done with:
impl<I2C, I2cError> Driver<I2C>
where
I2C: WriteRead<Error = I2cError>,
{
}
This declaration now also allows us to have a Result containing the I2C error from the HAL as we
have now bound this type to our generic parameter I2cError
, hence we can define a new
function
in order to (surprise) create a new instance of our struct.
pub fn new(i2c: I2C) -> Result<Driver<I2C>, Error<I2cError>> {
let mut driver = Driver {
i2c: i2c,
};
}
Integrating the ID getter
However when calling new
we of course do want to verify that the chip is actually on the I2C bus we
just got passed, this is quite simply done by trying to read the ID from our chip. If the chip is not
attached to the bus it will not respond which should cause the HAL to throw an error, if a chip with
the same address from another manufacturer (remember, I2C has per default only got 7 bits of address
space, collisions will happen) is on the bus it (hopefully) respond with something that is not equal
to the ID we expect. We already implemented the logic behind this last time so now we just got to
wrap it into our generic:
fn get_id(&mut self) -> Result<u8, Error<I2cError>> {
let mut buffer = [0u8; 1];
self.i2c.write_read(chip::I2C, &[chip::ID_ADDR], &mut buffer)?;
Ok(buffer[0])
}
// This will just contain all our addresses and constants related to the chip
mod chip {
pub const I2C: u8 = 0x1B << 1;
pub const ID: u8 = 0x2E;
pub const ID_ADDR: u8 = 0;
}
However this piece of code will actually not work:
error[E0277]: the trait bound `Error<I2cError>: core::convert::From<I2cError>` is not satisfied
--> src/lib.rs:38:70
|
38 | self.i2c.write_read(Chip::I2C, &[Chip::ID_ADDR], &mut buffer)?;
| ^ the trait `core::convert::From<I2cError>` is not implemented for `Error<I2cError>`
|
= note: required by `core::convert::From::from`
In order to solve this we basically have two options, we could either do a map_err()
every time we
try to write something on the I2C bus or we simply implement the From trait for our Error
as follows:
impl<E> From<E> for Error<E> {
fn from(error: E) -> Self {
Error::I2cError(error)
}
}
Of course now this piece of code will convert every error that we try to return as our Error
enum
into an I2cError
variant, that means we have to be careful if we should include more third party
libraries later as these errors too will be converted into I2cError
, however as long as we don’t do
that this solution is actually really convenient.
Now where we got the get_id()
stuff down we can rewrite our new()
in order to do the ID check:
pub fn new(i2c: I2C) -> Result<Driver<I2C>, Error<I2cError>> {
let mut driver = Driver {
i2c: i2c,
};
let id = driver.get_id()?;
if id != chip::ID {
return Err(Error::IdMismatch(id));
}
Ok(driver)
}
// And of course extend the Error enum
#[derive(Clone, Copy, Debug)]
pub enum Error<I2cError> {
I2cError(I2cError),
IdMismatch(u8)
}
Initializing the chip properly
In order to make the chip work properly we have to configure it, the datasheet notes this can be done by writing a non zero value to address 56 (see Address 56: Calibrate), which will set the calibrate bit in the status register at address 2, once the bit is celared the calibration is done. The status information is represented as an 8 bit long value with 3 relevant and 5 reserved bits (see 5.4 Address 2: Detection Status). In order to represent this bit field in rust we will use the very commonly used bitfield (TODO crates.io link) crate which gives us a really nice macro in order to automatically generate such bitfield structs, so lets just include it in our Cargo.toml quickly
[dependencies]
embedded-hal = "0.2.3"
bitfield = "0.13.2"
And stick to the docs in order to denote the information we got from the datasheet in rust, using said bitfield macro:
use bitfield::bitfield;
bitfield!{
pub struct Status(u8);
impl Debug;
pub calibrate, _: 7;
pub overflow, _: 6;
pub touch, _: 0;
}
Now where we got our struct down we can write another simple method that fetches the status for us, and puts it into the struct so we can use it to, for example, find out wether the pads on the PCB have been touched yet using the touch bit.
pub fn get_status(&mut self) -> Result<Status, Error<I2cError>> {
let mut buffer = [0u8; 1];
self.i2c.write_read(chip::I2C, &[chip::STATUS_ADDR], &mut buffer)?
Ok(Status(buffer[0]))
}
// And as always, extend our chip mod
mod chip {
pub const I2C: u8 = 0x1B << 1;
pub const ID: u8 = 0x2E;
pub const ID_ADDR: u8 = 0;
pub const STATUS_ADDR: u8 = 2;
}
So all that is missing now is a routine that performs the calibration write before polling the status register until the calibration is done and we can start reading actual values!
Before we write this implementation though, we have to add another constraint on the generic I2C parameter as we are only supposed to write a non zero value into the calibrate register but not actually read a response, hence our implementation looks as follows:
use embedded_hal::blocking::i2c::{Write, WriteRead};
impl<I2C, I2cError> Driver<I2C>
where
I2C: WriteRead<Error = I2cError> + Write<Error = I2cError>,
{
pub fn calibrate(&mut self) -> Result<(), Error<I2cError>> {
self.i2c.write(chip::I2C, &[chip::CALIBRATE_ADDR, 0xFF])?;
loop {
let status = self.get_status()?;
if !status.calibrate() {
break;
}
}
Ok(())
}
}
// More constants!
mod chip {
pub const I2C: u8 = 0x1B << 1;
pub const ID: u8 = 0x2E;
pub const ID_ADDR: u8 = 0;
pub const STATUS_ADDR: u8 = 2;
pub const CALIBRATE_ADDR: u8 = 56;
}
Read the touch values!
This is actually surprisingly simple and similar to what we just did before, we just define another bitfield struct according to the specifications in chapter 5.5 Address 3: Key Status, read that one just like we read the previous status register and we are done
bitfield! {
pub struct KeyStatus(u8);
impl Debug;
pub key6, _: 6;
pub key5, _: 5;
pub key4, _: 4;
pub key3, _: 3;
pub key2, _: 2;
pub key1, _: 1;
pub key0, _: 0;
}
pub fn get_key_status(&mut self) -> Result<KeyStatus, Error<I2cError>> {
let mut buffer = [0u8; 1];
self.i2c.write_read(chip::I2C, &[chip::KEY_STATUS_ADDR], &mut buffer)?;
Ok(KeyStatus(buffer[0]))
}
mod chip {
pub const I2C: u8 = 0x1B << 1;
pub const ID: u8 = 0x2E;
pub const ID_ADDR: u8 = 0;
pub const STATUS_ADDR: u8 = 2;
pub const KEY_STATUS_ADDR: u8 = 3;
pub const CALIBRATE_ADDR: u8 = 56;
}
And…we are already done, quite simple once you got it down for the first time, right?
A small example on the stm32l4
In order to verify our blindly written implementation works, we can come up with a simple example for the stm32l4 chip from last time. First of all, we will, of course, take over the big blob that initializes our chip properly from last time:
#![no_main]
#![no_std]
extern crate panic_semihosting;
use cortex_m_rt::entry;
use stm32l4xx_hal::i2c::I2c;
use stm32l4xx_hal::prelude::*;
use at42qt1070::Driver;
#[entry]
fn main() -> ! {
let dp = stm32l4xx_hal::stm32::Peripherals::take().unwrap();
let mut flash = dp.FLASH.constrain();
let mut rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.hclk(8.mhz()).freeze(&mut flash.acr);
let mut gpiob = dp.GPIOB.split(&mut rcc.ahb2);
let scl = gpiob
.pb10
.into_open_drain_output(&mut gpiob.moder, &mut gpiob.otyper);
let scl = scl.into_af4(&mut gpiob.moder, &mut gpiob.afrh);
let sda = gpiob
.pb11
.into_open_drain_output(&mut gpiob.moder, &mut gpiob.otyper);
let sda = sda.into_af4(&mut gpiob.moder, &mut gpiob.afrh);
let i2c = I2c::i2c2(dp.I2C2, (scl, sda), 400.khz(), clocks, &mut rcc.apb1r1);
}
On top of that we can now just use our driver:
let mut driver = Driver::new(i2c).unwrap();
driver.calibrate().unwrap();
And now write a simple routine that checks wether the touch bit (which indicates that one of the pads has been touched) from the status register has been set yet and if it has read the key status register in order to find out which pad:
loop {
let status = driver.get_status().unwrap();
if status.touch() {
break;
}
}
let key_status = driver.get_key_status().unwrap();
let all_pads = [
key_status.key0(),
key_status.key1(),
key_status.key2(),
key_status.key3(),
key_status.key4(),
key_status.key5(),
key_status.key6(),
];
// We have to loop in the end again so our main function never returns
loop {}
And if we quickly build and flash this onto the microcontroller, set a breakpoint at the proper spot,
run the example (while of course touching one of the pads) and print out the all_pads
variable
we will be greeted with:
(gdb) p all_pads
$1 = [false, true, false, false, false, false, false]
Which, according to the schematic, is the exact key we touched \o/.
Conclusion
The chip does have a few more settings such as the AKS groups and a few measurement sensitivity ones, however they will just require a few more register writes and thus don’t add anything new conceptually to what we learned in this 2 part series, hence I’ll just implement them in private and publish the crate once I’m done (consider it as an exercise for the reader wink) . I hope you learned something reading this little series. If you have any feedback etc. for me you can just send it to the address at the bottom of the web page.