port-expander

2021-12-15 rust embedded 5 min read

I have recently(!) released port-expander, a small crate to make access to I²C port-expanders as easy as possible.

GitHub

crates.io

A lot of times, when working with port-expanders, you'll have pins from other devices connected to them. For those other devices, a driver might already exist, which expects an embedded_hal::digital::OutputPin to control said pin. To make this work, an abstraction is needed which allows "splitting" the port-expander into several separate "objects", each representing a single pin. Additionally, this of course means we need some sort of synchronization between them, to ensure we cannot get any race-conditions between accesses to two different pins.

port-expander does just that and aims to be a single crate supporting many different I²C port-expander ICs.

API

Here is an example of how it works, shown using the PCA9536 4-bit port-expander:

// Initialize I2C peripheral from HAL
let i2c = todo!();

// A0: HIGH, A1: LOW, A2: LOW
let mut pca9536 = port_expander::Pca9536::new(i2c);
let pca_pins = pca9536.split();

let io0_0 = pca_pins.io0_0.into_output().unwrap();
let io0_1 = pca_pins.io0_1; // default is input

io0_0.set_high().unwrap();
assert!(io0_1.is_high().unwrap());

The magic part is the .split() method: It returns a Parts struct which contains one member for each pin:

pub struct Parts<'a, /* ... */> {
    pub io0: crate::Pin<'a, crate::mode::Input, /* ... */>,
    pub io1: crate::Pin<'a, crate::mode::Input, /* ... */>,
    pub io2: crate::Pin<'a, crate::mode::Input, /* ... */>,
    pub io3: crate::Pin<'a, crate::mode::Input, /* ... */>,
}

These pins can (should) be moved out of Parts, configured into their appropriate mode and can then be used for whatever purpose you need. For configuration, the into_output() and into_input() methods are available:

let input_pin = parts.io0;  // default is input
let output_pin = parts.io0.into_output();

// an output can later be configured as an input again
let input_pin2 = output_pin.into_input();

Note that, depending on the specific device you're using, configuration might not be necessary. For example, some port-expanders have quasi-bidirectional IOs which can works as input or output with no configuration change necessary. For such devices, port-expander models this mode by implementing input and output methods on the same Pin object at the same time.

The API provided by each pin closely mirrors what you'd find in an MCU HAL for its GPIO pins:

// Input pins (or quasi-bidirectional)
assert!(input_pin.is_high().unwrap());
assert!(!input_pin.is_low().unwrap());

// Output pins (or quasi-bidirectional)
output_pin.set_high().unwrap();
assert!(output_pin.is_set_high().unwrap());
output_pin.set_low().unwrap();
assert!(output_pin.is_set_low().unwrap());

output_pin.toggle().unwrap();

Additionally, the pins of course implement the relevant traits from embedded-hal so they can be passed to generic drivers.

Device Support

As of writing this post, 4 devices are supported:

The idea of port-expander is to make it super easy to expand this list: Because most devices are very similar, driver implementations can be shared in a lot of places. Contributions are welcome!

Atomic Access to multiple Pins

Sometimes, you need to access multiple pins in a single operation to ensure as little skew between sampling them. port-expander provides two functions to help with that: read_multiple() and write_multiple(). Each operates on an array of pins and will perform the operation in a single bus transaction (to the point where this is supported by the IC). As an example:

// read multiple pins in a single transaction
let values: [bool; 2] = port_expander::read_multiple([&io0, &io1]).unwrap();

// write multiple pins in a single transaction
port_expander::write_multiple(
    [&mut io0, &mut io1],
    [true, false],
).unwrap();

Synchronization

I mentioned this in the beginning: Due to splitting the driver "apart", we need to ensure pin accesses are synchronized with each other. To do this, port-expander leverages the BusMutex from shared-bus.

For making the common case easy, the ::new() constructor always uses a thread-local mutex (essentially a RefCell) which has the least possible overhead. This will work fine for any situation where all "users" of pins are contained within the same execution context.

For more complex situations where pin "users" are spread across multiple tasks or threads and where pin accesses can thus race against each other, a "proper" mutex is needed. You can force a custom mutex type by using the ::with_mutex() constructor instead:

let pca9536: port_expander::Pca9536<std::sync::Mutex<_>>
    = port_expander::Pca9536::with_mutex(i2c);

Interrupts

Some port-expanders provide an interrupt signal which is asserted whenever one of the inputs changes state. port-expander does not handle this signal for you, you should check it separately and then query the input pins.