#devices #peripheral #driver #sensor

nightly no-std embedded-devices

Device driver implementations for many embedded sensors and devices

4 releases

new 0.9.10 Sep 26, 2024
0.9.9 Aug 26, 2024
0.9.8 Aug 21, 2024
0.0.1 Nov 4, 2023

#93 in Embedded development

Download history 134/week @ 2024-08-19 153/week @ 2024-08-26 25/week @ 2024-09-16 140/week @ 2024-09-23

170 downloads per month

MIT/Apache

230KB
3K SLoC

Crate API

embedded-devices

WARNING: This crate is currently in experimental state, so anything may change at any time.

Welcome to the embedded-devices project! Here you'll find a collection of drivers for a variety of different embedded sensors and devices, all of which are built with this framework which facilitates building drivers for register based devices. These drivers are designed for async use but also support synchronous contexts via a feature switch. Our goal is to provide feature-complete, up-to-date drivers with an ergonomic interface, catering both to high-level device functions and low-level register access. Please refer to the list below for supported devices.

The ecosystem of embedded-rust device drivers seems quite fragmented, consisting both of high quality implementations but also of several incomplete or outdated drivers, where only a few have first-class async support. This renders them hard (or even impossible) to use in embedded frameworks like embassy, which in my opinion would benefit from having access to more ready-to-use async drivers with frequently updated dependencies.

For the time being, this crate should serve as a proof-of-concept. It shows how sensor and device drivers can benefit from a common framework, allowing new drivers to be added with ease in order to streamline future collaborative efforts and solve some of the aforementioned issues. The main component of our framework is embedded-registers which provides an ergonomic solution to defining and interfacing with device registers over I2C/SPI.

Examples

An example project for the esp32 is included at examples

Supported Devices

Below you will find all supported devices. Please visit their respective documentation links for more information and usage examples.

Manufacturer Device Interface Description Docs
Analog Devices MAX31865 SPI Precision temperature converter for RTDs, NTCs and PTCs Docs
Bosch BME280 I2C/SPI Temperature, pressure and humidity sensor Docs
Bosch BMP280 I2C/SPI Temperature and pressure sensor Docs
Bosch BMP390 I2C/SPI Temperature and pressure sensor Docs
Microchip MCP9808 I2C Digital temperature sensor with ±0.5°C (max.) accuracy Docs
Texas Instruments INA219 I2C 12-bit current shunt and power monitor Docs
Texas Instruments TMP117 I2C Temperature sensor with ±0.1°C to ±0.3°C accuracy depending on the temperature range Docs

Architecture

Driver implementations are organized based on the manufacturer name and device name. Each driver exposes a struct with the name of the device, for example BME280. There are no restrictions on the struct's generics, since devices may require different interface combinations, or own extra pins. Usually they will expose a new_i2c and/or new_spi function to construct the appropriate object from an interface and address.

Codecs

The vast majority of devices use similar "protocols" on top of I2C or SPI to expose their registers, we call these codecs. For both I2C and SPI we provide a simple codec implementation that should already be compatible with most of the device types in existence. If a device (or single register) requires a more involved codec - for example to verify CRC checksums - it can implement it's own codec for that purpose.

Register based devices

The embedded-registers crate provides a generic way to define registers, and an interface implementation to allow reading and writing those registers via I2C or SPI.

A register usually refers to a specific memory address (or consecutive memory region) on the device by specifiying its start address. We also associate each register to a specific device by specifying a marker trait. This prevents the generated API from accepting registers of unrelated devices. In the following, we'll have a short look at how to define and work with register based devices.

Defining a register

First, lets start with a very simple register definition. We will later create a device struct which can read from and write to this register. So to define our very simple register, we

  1. specify which device belongs to using the #[device_register] macro (we will later define this MyDevice),
  2. define the start address and mode (read-only, write-only, read-write) using the #[register] macro,
  3. define the contents of the register using bondrewd, a general purpose bitfield macro.

Most devices support a burst-read/write operations, where you can begin reading from address A and will automatically receive values from the consecutive memory region until you stop reading. This means you can define registers with a size > 1 byte and will get the content you expect.

Let's imagine our MyDevice had a 2-byte read-write register at device address 0x42(,0x43), which contains two u8 values. The corresponding register can be defined like this:

/// Insert explanation of this register from the datasheet.
#[device_register(MyDevice)]
#[register(address = 0x42, mode = "rw")]
#[bondrewd(read_from = "msb0", default_endianness = "be", enforce_bytes = 2)]
pub struct ValueRegister {
    /// Insert explanation of this value from the datasheet.
    pub width: u8,
    /// Insert explanation of this value from the datasheet.
    pub height: u8,
}

This will create two structs called ValueRegister and ValueRegisterBitfield. The first will only contain a byte array [u8; N] to store the packed register contents, and the latter will contain the unpacked actual members as defined above. You will always interface with a device using the packed data, which can be transferred over the bus as-is.

[!NOTE] I find it a bit misleading that the members written in ValueRegister end up in ValueRegisterBitfield. So this might change in the future, but I currently cannot think of another design that is as simple to use as the one we have now. The issue is that we need a struct for the packed data and one for the unpacked data. Since we usually deal with the packed data, and want to allow direct read/write operations on the packed data for performance, the naming gets confusing quite quickly.

Accessing a register

After defining a register, we may access it through MyDevice:

// Imagine we already have constructed a device:
let mut dev = MyDevice::new_i2c(i2c_bus /* the i2c bus from your controller */, 0x12 /* address */);
// We can now retrieve the register
let mut reg = dev.read_register::<ValueRegister>().await?;

// Unpack a specific field from the register and print it
println!("{}", reg.read_width());
// If you need all fields (or are not bound to tight resource constraints),
// you can also unpack all fields and access them more conveniently
let data = reg.read_all();
// All bitfields implement Debug and defmt::Format, so you can conveniently
// print the contents
println!("{:?}", data);

// We can also change a single value
reg.write_height(190);
// Or pack a bitfield and replace everything
reg.write_all(data); // same as reg = ValueRegister::new(data);

// Which we can now write back to the device, given that the register is writable.
dev.write_register(reg).await?;

A more complex register

A more complex register might contain more than just simple values. Often there are enums, or bitflags involved, which we can luckily also represent with bondrewd. Using bondrewd's attribute macros, we can specify exactly which bit corresponds to which field:

#[allow(non_camel_case_types)]
#[derive(BitfieldEnum, Copy, Clone, Default, PartialEq, Eq, Debug, defmt::Format)]
#[bondrewd_enum(u8)]
pub enum TemperatureResolution {
    Deg_0_5C = 0b00,
    Deg_0_25C = 0b01,
    Deg_0_125C = 0b10,
    #[default]
    Deg_0_0625C = 0b11,
}

#[device_register(MyDevice)]
#[register(address = 0x44, mode = "rw")]
#[bondrewd(read_from = "msb0", default_endianness = "be", enforce_bytes = 1)]
pub struct ComplexRegister {
    #[bondrewd(bit_length = 6, reserve)]
    #[allow(dead_code)]
    pub reserved: u8,

    #[bondrewd(enum_primitive = "u8", bit_length = 2)]
    pub temperature_resolution: TemperatureResolution,
}

[!NOTE] Instead of naming all registers *Register, in reality we would likely place all registers in a common registers module for convenience and drop the suffix.

Defining a device

Now we also need to define our device so we can actually use the register. Imagine our simple device would communicate over I2C only.

First of all, we can define a struct for it by using the #[device] macro. This struct stores the runtime state necessary for our device, such as the communication interface. The macro just defines a marker trait which we will need later to define registers, you can ignore it for now.

/// Insert description from datasheet.
#[device]
pub struct MyDevice<I: RegisterInterface> {
    /// The interface to communicate with the device
    interface: I,
}

A register interface is a trait from embedded-registers, which is any object exposing a specific read_register and write_register function. Internally this will be the function we can later call to read or write a specific register.

But before we can do that, we need to add a constructor to our device which allows us to create a new device from a real I2C bus and an address. The embedded-registers crate also provides a struct called I2cDevice that implements RegisterInterface for I2C, so we just use that as our interface.

For very simple devices that need no additional fields there is a convenience macro called simple_device::i2c! that defines the necessary device for you:

simple_device::i2c!(MyDevice, MyAddress, SevenBitAddress, OneByteRegAddrCodec);

The address enum MyAddress should contain all valid addresses for the device, plus a variant to allow specifying arbitrary addresses, in case the user uses an address translation unit. The address can be any type that is convertible to the convertible to the underlying embedded_hal::i2c::AddressMode of the bus (7-bit or 10-bit addressing). For a real example, refer to mcp9808::Address.

The third parameter is either SevenBitAddress or TenBitAddress, depending on the addressing mode of your device. In most cases, devices use seven bit addresses.

The last parameter specifies the codec that is used to read or write registers over I2C. Again, this is usually a simple codec that just prepends the register address before reading or writing data, but depending on the device it can be more complex. This would be the location where you can adjust the required protocol.

Finally, this macro actually just defines a constructor for our device and is equivalent to this impl block below. We'll later see why we need #[maybe_async_cfg].

#[maybe_async_cfg::maybe(
    idents(hal(sync = "embedded_hal", async = "embedded_hal_async")),
    sync(not(feature = "async")),
    async(feature = "async"),
    keep_self
)]
impl<I> MyDevice<I2cDevice<I, hal::i2c::SevenBitAddress, OneByteRegAddrCodec>>
where
    I: hal::i2c::I2c<hal::i2c::SevenBitAddress> + hal::i2c::ErrorType,
{
    /// Initializes a new device with the given address on the specified bus.
    /// This consumes the I2C bus `I`.
    #[inline]
    pub fn new_i2c(interface: I, address: MyAddress) -> Self {
        Self {
            interface: I2cDevice::new(interface, address.into(), OneByteRegAddrCodec::default()),
        }
    }
}

Now that we have a device, we need to be able to read and write registers, and may also implement high-level functions that use these registers. The shortest implementation is just an empty block with the #[device_impl] attribute macro.

#[device_impl]
impl<I: RegisterInterface> MyDevice<I> {
}

Still remember the marker trait from the beginning, which was created by #[device]? This is where it now comes into play; The I2cDevice which we store as our interface is very generic and would allow us to read any valid register definition that we wish. But since there are multiple drivers with valid registers in this crate, we want to prevent registers from being used with unrelated devices.

The #[device_impl] macro therefore defines two functions read_register and write_register that internally just call the similarly named functions on self.interface, but additionally require the passed register types to implement our marker trait. The registers are annotated with the marker trait by #[device_register], so passing unrelated registers will now result in a compilation error.

The user is now already able to interface with the device's registers in a mostly safe way, except of course for things that are not represented by our typed registers (such as implicit device states). The last step is to expose convenience functions for our device. The classics are things like init, reset, measure, configure or others, but in principle you are free to write anything.

Finally, when writing functions for our device, we will always write async code. The maybe_async_cfg macro will automatically derive the sync code from our definition, and replace references to hal with from embedded_hal or embedded_hal_async. This is necessary so we can support both async and sync crate consumers. Take for example this init from the MCP9808:

#[device_impl]
impl<I: RegisterInterface> MyDevice<I> {
    /// Initialize the sensor by verifying its device id and manufacturer id.
    /// Not mandatory, but recommended.
    pub async fn init(&mut self) -> Result<(), InitError<I::Error>> {
        use self::registers::DeviceIdRevision;
        use self::registers::ManufacturerId;

        let device_id = self.read_register::<DeviceIdRevision>().await.map_err(InitError::Bus)?;
        if device_id.read_device_id() != self::registers::DEVICE_ID_VALID {
            return Err(InitError::InvalidDeviceId);
        }

        let manufacturer_id = self.read_register::<ManufacturerId>().await.map_err(InitError::Bus)?;
        if manufacturer_id.read_manufacturer_id() != self::registers::MANUFACTURER_ID_VALID {
            return Err(InitError::InvalidManufacturerId);
        }

        Ok(())
    }
}

Contributing

If you have any suggestions or ideas to improve the current architecture, please feel encouraged to open an issue or reach out via Matrix

Contributions are whole-heartedly welcome! Please feel free to suggest new features, implement device drivers, or generally suggest improvements.

License

Licensed under either of

at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Dependencies

~4MB
~80K SLoC