Embedded Rust & Embassy: I2C Temperature Sensing with BMP180

Embedded Rust & Embassy: I2C Temperature Sensing with BMP180

Β·

18 min read

This blog post is the fourth of a multi-part series of posts about Rust embassy for the STM32. This post is going to explore communicating over I2C using the embassy HAL. Please be aware that certain concepts in newer posts could depend on concepts in prior posts.

Prior posts include (in order of publishing):

  1. Embedded Rust & Embassy: GPIO Button Controlled Blinking

  2. Embedded Rust & Embassy: UART Serial Communication

  3. Embedded Rust & Embassy: PWM Generation

  4. Embedded Rust & Embassy: Analog Sensing with ADCs

Introduction

In this post, I will be configuring an STM32 I2C peripheral using the embassy HAL to collect ambient temperature measurement data from the BMP180 Digital Pressure Sensor. Note that the BMP180 provides both pressure and temperature data but I will only be collecting the latter. Temperature measurements will be continuously collected and sent to a PC terminal over UART. This code will be built without using any interrupts. This means that I will be leveraging only the embassy HAL without the executor or the async framework.

🚨 Important Note:

The BMP180 provides both temperature and pressure sensor data which are collected in a very similar manner. I elected to collect only temperature data since the conversion equations are less. My target in this blog post is to focus on operating I2C more than the features of the BMP180 itself. However, the provided code can be easily expanded to collect and convert pressure data as well. I probably will be expanding the code anyway to create a BMP180 device driver at a later time.

πŸ“š Knowledge Pre-requisites

To understand the content of this post, you need the following:

πŸ’Ύ Software Setup

All the code presented in this post in addition to instructions for the environment and toolchain setup are available on the apollolabsdev Nucleo-F401RE git repo. Note that if the code on the git repo is slightly different then it means that it was modified to enhance the code quality or accommodate any HAL/Rust updates.

In addition to the above, you would need to install some sort of serial communication terminal on your host PC. Some recommendations include:

For Windows:

For Mac and Linux:

Apart from Serial Studio, some detailed instructions for the different operating systems are available in the Discovery Book.

For me, Serial Studio comes highly recommended. I personally came across Serial Studio recently and found it to be awesome for two main reasons. First is that you can skip many of those instructions for other tools, especially in Mac and Linux systems. Second, if you are you want to graph data over UART, it has a really nice and easy-to-configure setup. It's also open-source and free to use.

πŸ›  Hardware Setup

πŸ‘” Materials

nucleof401re.jpg

bmp180.jpg

πŸ”Œ Connections

  • BMP180 module SCL pin connected to Nucleo board pin PB8.

  • BMP180 module SDA pin connected to Nucleo board pin PB9.

  • BMP180 module Vcc pin connected to Nucleo board 3.3V.

  • BMP180 module GND pin connected to Nucleo board GND.

  • The UART Tx line that connects to the PC through the onboard USB bridge is via pin PA2 on the microcontroller. This is a hardwired pin, meaning you cannot use any other for this setup. Unless you are using a different board other than the Nucleo-F401RE, you have to check the relevant documentation (reference manual or datasheet) to determine the number of the pin.

πŸ‘¨β€πŸŽ¨Software Design

The software design for this application can be obtained directly from the BMP180 datasheet. The datasheet for the BMP180 provides a full flow chart describing the algorithmic steps. The flow chart also provides a list of conversion formulas needed to calculate the compensated temperature and pressure. The display temperature value state at the end of the flow would be the step where I would send the result over UART.

Algorithm Flow Chart

From the above flow chart, I will only be implementing the parts to do with temperature collection. The same code can be easily expanded to pressure as well. One would only have to implement the additional equations.

πŸ‘¨β€πŸ’» Code Implementation

πŸ“₯ Crate Imports

In this implementation, the following crates are required:

  • The cortex_m_rt crate for startup code and minimal runtime for Cortex-M microcontrollers.

  • The heapless crate to import and create a fixed capacity String.

  • The core::fmt crate will allow us to use the writeln! macro for easy printing.

  • The panic_halt crate to define the panicking behavior to halt on panic.

  • The embassy_time crate to import timekeeping capabilities.

  • The embassy_stm32 crate to import the embassy STM32 series microcontroller device hardware abstractions. The needed abstractions are imported accordingly.

  • The panic_halt crate to define the panicking behavior to halt on panic.

use core::fmt::Write;
use cortex_m::prelude::_embedded_hal_blocking_delay_DelayMs;
use heapless::String;

use cortex_m_rt::entry;
use embassy_stm32::dma::NoDma;
use embassy_stm32::i2c::I2c;
use embassy_stm32::interrupt;
use embassy_stm32::time::hz;
use embassy_stm32::usart::{Config, UartTx};
use embassy_time::Delay;
use panic_halt as _;

πŸŽ› Peripheral Configuration Code

I2C Peripheral Configuration:

1️⃣ Initialize MCU and obtain a handle for the device peripherals: A device peripheral handler p is created:

let p = embassy_stm32::init(Default::default());

2️⃣ Configure the I2C peripheral channel: Looking into the Nucleo-F401RE board pinout, the SDA and SCL lines pins PB8 and PB9 connect to the I2C1 peripheral in the microcontroller device. As such, this means we need to configure I2C1 and somehow pass it to the handles of the pins we want to use.

In the example I am creating in this post, I will be using I2C only in a blocking manner. This would typically mean that no interrupts are required as all tasks would be polled. Though in configuring I2C in embassy, as it stands, it seems that an interrupt still needs to be attached although we will not be using it. This means that an additional step is required ahead of configuring I2C. In order to attach an interrupt to the I2C configuration, a handle for the interrupt source needs to be created using the take! macro. There are also two interrupt sources listed for I2C in embassy, I2C2_EV (for I2C interrupt event) and I2C2_ER (for I2C interrupt error). The interrupt handle irq is created as follows:

let irq = interrupt::take!(I2C1_EV);

In the embassy I2C driver documentation a new method exists to configure I2C and looks as follows:

pub fn new(
    peri: impl Peripheral<P = T> + 'd,
    scl: impl Peripheral<P = impl SclPin<T>> + 'd,
    sda: impl Peripheral<P = impl SdaPin<T>> + 'd,
    irq: impl Peripheral<P = T::Interrupt> + 'd,
    tx_dma: impl Peripheral<P = TXDMA> + 'd,
    rx_dma: impl Peripheral<P = RXDMA> + 'd,
    freq: Hertz
) -> Self

Where peri expects an instance to an I2C peripheral, scl and sda expect instances of a GPIO pin, irq expects a handle to an interrupt, tx_dma and rx_dma expect instances to a DMA channel, freq expects I2C transaction frequency. What I cam to realize is that when using the shown method, I got a compile error that I wasn't including a Config parameter. It turns out that the documentation in this case is outdated and the source code contains a slightly different signature:

pub fn new(
        peri: impl Peripheral<P = T> + 'd,
        scl: impl Peripheral<P = impl SclPin<T>> + 'd,
        sda: impl Peripheral<P = impl SdaPin<T>> + 'd,
        irq: impl Peripheral<P = T::Interrupt> + 'd,
        tx_dma: impl Peripheral<P = TXDMA> + 'd,
        rx_dma: impl Peripheral<P = RXDMA> + 'd,
        freq: Hertz,
        config: Config,
    ) -> Self

The difference includes a Config type which includes an I2C configuration. As such, we can create a handle i2c for I2C1 as follows:

    let mut i2c = I2c::new(
        p.I2C1,
        p.PB8,
        p.PB9,
        irq,
        NoDma,
        NoDma,
        hz(100000),
        Default::default(),
    );

Note how the different handles created earlier are passed as arguments as expected. Also the frequency is set to 100 kHz for operation, and NoDma is passed for DMA channel. The BMP180 datasheet states that the device can handle up to 3.4Mbit/sec so I only chose an arbitrary value under the stated limit. Finally, for the Config type, the default instance is passed, similar to what has been done before. The implementation for Default can be found in the same source code and looks as follows:

impl Default for Config {
    fn default() -> Self {
        Self {
            sda_pullup: false,
            scl_pullup: false,
        }
    }
}

Which from what can be seen is that Config, configures the state of the I2C line pull-up resistors. That's it for I2C configuration, now moving on to UART.

UART Serial Communication Peripheral Configuration:

1️⃣ Configure UART and obtain handle: On the Nucleo-F401RE board pinout, the Tx line pin PA2 connects to the USART2 peripheral in the microcontroller device. Similar to what was done in the embassy UART post, an instance of USART2 is attached to the usart handle as follows:

let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Config::default());

Also, similar to before a String type msg handle is created to store the formatted text that will be transmitted over UART:

let mut msg: String<64> = String::new();

Delay Configuration:

In the algorithm, a blocking delay will need to be introduced to wait for the ADC conversion in the BMP180 to finish. A delay handle is created as follows as follows:

 let mut delay = Delay;

This is it for configuration! Let's now jump into the application code.

πŸ“± Application Code

In the software design described, the first step requires that we read a bunch of calibration data from the EEPROM of BMP180. In order to that, I would need to set up and initialize some sort of a struct to save the calibration data. I called the struct type Coeffs and defined it as follows:

    struct Coeffs {
        ac5: i16,
        ac6: i16,
        mc: i16,
        md: i16,
    }

After which I instantiate calib_coeffs of type Coeffs initialized to all zeros:

    let mut calib_coeffs = Coeffs {
        ac5: 0,
        ac6: 0,
        mc: 0,
        md: 0,
    };

πŸ“ Note:

In the datasheet for the BMP180 there are 11 different calibration coefficients that need to be captured. Here I am capturing only the ones needed for temperature calculation.

Next, I define a bunch of constants that reflect the addresses for the calibration coefficients in the BMP180 EEPROM, the I2C address of the BMP180 itself (BMP180_ADDR), and the address to retrieve the BMP180 device ID (REG_ID_ADDR).

    const BMP180_ADDR: u8 = 0x77;
    const REG_ID_ADDR: u8 = 0xD0;
    const AC5_MSB_ADDR: u8 = 0xB2;
    const AC6_MSB_ADDR: u8 = 0xB4;
    const MC_MSB_ADDR: u8 = 0xBC;
    const MD_MSB_ADDR: u8 = 0xBE;
    const CTRL_MEAS_ADDR: u8 = 0xF4;
    const MEAS_OUT_LSB_ADDR: u8 = 0xF7;
    const MEAS_OUT_MSB_ADDR: u8 = 0xF6;

I also define two variables, a [u8; 2] array I named rx_buffer and an i16 named rx_word. I will be using rx_buffer later to buffer data I read over I2C from the BMP180. rx_word will be used to reconstruct the read bytes into a 16-bit value.

let mut rx_buffer: [u8; 2] = [0; 2];
let mut rx_word: i16;

Before doing anything, we have to understand how the BMP180 communicates over I2C. Essentially, the way the BMP180 communication works, first there is a write (control) cycle indicating what internal BMP180 EEPROM address we want to read. Second, there is a read cycle where the data in the address that was requested in the write cycle is provided.

The first thing I need to read according to the algorithmic steps is the calibration coefficients. The datasheet recommends, however, that before reading any calibration coefficients from the BMP180, the BMP180 device ID is read as a sanity check. The device should provide back the value of 0x55. To do that, digging into the stm32 embassy I2C method documentation, I found a blocking_write and blocking_read method with the following signatures:

pub fn blocking_write(
    &mut self, 
    address: u8, 
    bytes: &[u8]
) -> Result<(), Error>

and

pub fn blocking_read(
    &mut self,
    address: u8,
    buffer: &mut [u8]
) -> Result<(), Error>

Although there wasn't much description in the documentation, it can be seen that both are more or less similar with a minor difference. Both take two arguments and return a Result. The first argument address is the device address in which case for us will be the BMP180 device address BMP180_ADDR. The second argument for the blocking_write method is bytes and is an array slice containing the bytes being written. The second argument for the read method is buffer and is a mutable array slice containing the bytes being returned back by the addressed device.

There was also another method that caught my attention that looked useful. It was the blocking_write_read method. Again, although there wasn't much description, it works similar to the write_read method from the embedded-hal. The blocking_write_read method does a write cycle immediately followed by a read cycle. This will be useful in many contexts and save some lines of code. The blocking_write_read method has the following signature:

pub fn blocking_write_read(
    &mut self,
    address: u8,
    bytes: &[u8],
    buffer: &mut [u8]
) -> Result<(), Error>

The parameters are more or less the same as the earlier methods, only combined in one method.

So, now that I have what I need, to do our sanity check, the first step is reading from the BMP180 the device ID:

i2c.blocking_write(BMP180_ADDR, &[REG_ID_ADDR]).unwrap();
i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();

Analyzing the code, the first argument of the write and read methods is the device address BMP180_ADDR, the second argument of the write method is a slice containing the device ID retrieval address REG_ID_ADDR. In the read method, the second argument is the receive buffer rx_buffer that will contain the recieved data. Note that all the arguments I am passing I have been created at an earlier point in the application. The below if statement checks if the received ID is correct and sends the appropriate message accordingly over UART. Note that from the BMP180 datasheet, since REG_ID_ADDR returns only a single byte, I only had to check the first index in the buffer rx_buffer[0]

if rx_buffer[0] == 0x55 {
        core::writeln!(&mut msg, "Device ID is {}\r", rx_buffer[0]).unwrap();
        usart.blocking_write(msg.as_bytes()).unwrap();
   msg.clear();
    } else {
        core::writeln!(&mut msg, "Device ID Cannot be Detected \r").unwrap();
        usart.blocking_write(msg.as_bytes()).unwrap();
        msg.clear();
    }

Now that its verified that the device can be detected, let's start with the first step in the algorithmic steps which is collecting the calibration coefficients. Here's the code for retrieving calibration coefficient AC5:

i2c.blocking_write_read(BMP180_ADDR, &[AC5_MSB_ADDR], &mut rx_buffer)
        .unwrap();
rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
core::writeln!(&mut msg, "AC5 = {} \r", rx_word).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();
calib_coeffs.ac5 = rx_word;

As might be noticed I used the blocking_write_read method exactly in a simiar manner that I did earlier. There are three differences to note here though:

  1. Recall I mentioned that the BMP180 provides a 16-bit value for each calibration coefficient. Since I2C communicates in bytes, each coefficient is broken down into two bytes MSB and LSB, with each having its own address in the BMP180 EEPROM. This means that technically I would need to retrieve each address separately and reconstruct the 16-bit value. Though it turns out that by sending only the MSB address to the BMP180 (AC5_MSB_ADDR in this case) the device sends back both the MSB followed by the LSB without needing to address the LSB separately. The MSB will be located in the first index of the buffer and the LSB in the second index. So the point here is that I needed to call the read_write method only once using AC5_MSB_ADDR to retrieve both AC5_MSB_ADDR and AC5_LSB_ADDR.

  2. Since an i16 will be provided back, the line rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16; takes the MSB bits from rx_buffer[0], casts them to an i16 and shifts the 1 byte (8 times) to the left using the << operator, then ORs the result using the | operator with the LSB bits in rx_buffer[1] that are also cast as i16. The result is finally stored in rx_word and then sent over the UART channel.

  3. The rx_word is stored in the calib_coeffs struct I had created earlier in the statement calib_coeffs.ac5 = rx_word;. This would also allow me to reuse rx_word for the following operations.

This coefficient obtaining code is repeated in the exact same manner three times to retrieve the AC6, MC, and MD coefficients. Obviously, the only differences would be the address sent to the BMP180, and the name of the member I store in the calib_coeffs struct.

Now that the calibration coefficients are available, the measurement loop can be started. The first step as indicated by the software design is to kick-off the temperature measurement in the BMP180 by writing 0x2E in register with address 0xF4 ( keyed in as CTRL_MEAS_ADDR). This is done using two write cycles first sending 0x2E followed by CTRL_MEAS_ADDR. This can be done in a single line as well. Since the blocking_write method accepts a slice in the bytes parameter in its signature, all the bytes that need to be sent can be included in the slice as follows:

i2c.blocking_write(BMP180_ADDR, &[CTRL_MEAS_ADDR, 0x2E]).unwrap();

This statement will send the CTRL_MEAS_ADDR to the BMP180 followed by the value 0x2E. For those familiar with I2C terms, although I didn't verify at the low level, nor does the documentation specify, but here a "repeated start" should be what is occurring.

After kicking off the BMP180 temperature measurement, the datasheet tells us we need to wait at least 4.5ms. As such, a 5ms is introduced to stay on the safe side. This is done using the delay handle created earlier:

 delay.delay_ms(5_u32);

Now the temperature measurement should be ready to collect by reading the measurement BMP180 MSB and LSB EEPROM addresses. This can be done using the similar approach to before where only the MSB address is sent. Alternatively, here a different approach is adopted by reading the MSB and the LSB separately:

// Read Measurement MSB
i2c.blocking_write(BMP180_ADDR, &[MEAS_OUT_MSB_ADDR]).unwrap();
i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
rx_word = (rx_buffer[0] as i16) << 8;
// Read Measurement LSB
i2c.blocking_write(BMP180_ADDR, &[MEAS_OUT_LSB_ADDR]).unwrap();
i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
rx_word |= rx_buffer[0] as i16;

Finally, since the temperature value received is uncompensated. The datasheet provides us with a bunch of formulas to calculate the compensated temperature based on the calibration coefficients that were collected earlier. The following code implements the formulas in the datasheet to calculate the temperature followed by sending the result over UART:

let x1 = (rx_word as i32 - calib_coeffs.ac6 as i32) * (calib_coeffs.ac5 as i32) >> 15;
let x2 = ((calib_coeffs.mc as i32) << 11) / (x1 + calib_coeffs.md as i32);
let b5 = x1 + x2;
let t = ((b5 + 8) >> 4) / 10;

// Print Temperature Value
core::writeln!(&mut msg, "Temperature = {:} \r", t).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();

Note how values are cast as i32 so to prevent overflow from the multiplication operations.

This is it!

πŸ“€ Full Application Code

Here is the full code for the implementation described in this post. You can additionally find the full project and others available on the apollolabsdev Nucleo-F401RE git repo.

#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]

use core::fmt::Write;
use cortex_m::prelude::_embedded_hal_blocking_delay_DelayMs;
use heapless::String;

use cortex_m_rt::entry;
use embassy_stm32::dma::NoDma;
use embassy_stm32::i2c::I2c;
use embassy_stm32::interrupt;
use embassy_stm32::time::hz;
use embassy_stm32::usart::{Config, UartTx};
use embassy_time::Delay;
use panic_halt as _;

#[entry]
fn main() -> ! {
    // Initialize and create handle for devicer peripherals
    let p = embassy_stm32::init(Default::default());

    let irq = interrupt::take!(I2C1_EV);
    // I2C Configuration
    let mut i2c = I2c::new(
        p.I2C1,
        p.PB8,
        p.PB9,
        irq,
        NoDma,
        NoDma,
        hz(100000),
        Default::default(),
    );

    //Configure UART
    let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Config::default());

    // Create empty String for message
    let mut msg: String<64> = String::new();

    // Delay Handle
    let mut delay = Delay;

    struct Coeffs {
        ac5: i16,
        ac6: i16,
        mc: i16,
        md: i16,
    }

    let mut calib_coeffs = Coeffs {
        ac5: 0,
        ac6: 0,
        mc: 0,
        md: 0,
    };

    const BMP180_ADDR: u8 = 0x77;
    const REG_ID_ADDR: u8 = 0xD0;
    const AC5_MSB_ADDR: u8 = 0xB2;
    const AC6_MSB_ADDR: u8 = 0xB4;
    const MC_MSB_ADDR: u8 = 0xBC;
    const MD_MSB_ADDR: u8 = 0xBE;
    const CTRL_MEAS_ADDR: u8 = 0xF4;
    const MEAS_OUT_LSB_ADDR: u8 = 0xF7;
    const MEAS_OUT_MSB_ADDR: u8 = 0xF6;

    let mut rx_buffer: [u8; 2] = [0; 2];
    let mut rx_word: i16;

    // Read Device ID as Sanity Check
    i2c.blocking_write(BMP180_ADDR, &[REG_ID_ADDR]).unwrap();
    i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();

    if rx_buffer[0] == 0x55 {
        core::writeln!(&mut msg, "Device ID is {}\r", rx_buffer[0]).unwrap();
        // Transmit Message
        usart.blocking_write(msg.as_bytes()).unwrap();
        // Clear String for next message
        msg.clear();
    } else {
        core::writeln!(&mut msg, "Device ID Cannot be Detected \r").unwrap();
        usart.blocking_write(msg.as_bytes()).unwrap();
        msg.clear();
    }

    // Read Calibration Coefficients
    // Read AC5
    i2c.blocking_write_read(BMP180_ADDR, &[AC5_MSB_ADDR], &mut rx_buffer)
        .unwrap();
    rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
    core::writeln!(&mut msg, "AC5 = {} \r", rx_word).unwrap();
    usart.blocking_write(msg.as_bytes()).unwrap();
    msg.clear();
    calib_coeffs.ac5 = rx_word;

    // Read AC6
    i2c.blocking_write_read(BMP180_ADDR, &[AC6_MSB_ADDR], &mut rx_buffer)
        .unwrap();
    rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
    core::writeln!(&mut msg, "AC6 = {} \r", rx_word).unwrap();
    usart.blocking_write(msg.as_bytes()).unwrap();
    msg.clear();
    calib_coeffs.ac6 = rx_word;

    // Read MC
    i2c.blocking_write_read(BMP180_ADDR, &[MC_MSB_ADDR], &mut rx_buffer)
        .unwrap();
    rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
    core::writeln!(&mut msg, "MC = {} \r", rx_word).unwrap();
    usart.blocking_write(msg.as_bytes()).unwrap();
    msg.clear();
    calib_coeffs.mc = rx_word;

    // Read MD
    i2c.blocking_write_read(BMP180_ADDR, &[MD_MSB_ADDR], &mut rx_buffer)
        .unwrap();
    rx_word = ((rx_buffer[0] as i16) << 8) | rx_buffer[1] as i16;
    core::writeln!(&mut msg, "MD = {} \r", rx_word).unwrap();
    usart.blocking_write(msg.as_bytes()).unwrap();
    msg.clear();
    calib_coeffs.md = rx_word;

    // Application Loop
    loop {
        // Kick off Temperature Measurement by writing 0x2E in register 0xF4
        i2c.blocking_write(BMP180_ADDR, &[CTRL_MEAS_ADDR, 0x2E])
            .unwrap();
        // Wait at least 4.5 ms for measurment to complete as specified by the datasheet
        delay.delay_ms(5_u32);

        // Collect Temperature Measurment
        // Read Measurement MSB
        // Achieving same as above using an alternate method syntax here to do a write followed by read
        i2c.blocking_write(BMP180_ADDR, &[MEAS_OUT_MSB_ADDR])
            .unwrap();
        i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
        rx_word = (rx_buffer[0] as i16) << 8;
        // Read Measurement LSB
        i2c.blocking_write(BMP180_ADDR, &[MEAS_OUT_LSB_ADDR])
            .unwrap();
        i2c.blocking_read(BMP180_ADDR, &mut rx_buffer).unwrap();
        rx_word |= rx_buffer[0] as i16;

        // Uncomment following line to print raw uncompenstated temperature value
        //writeln!(tx, "UT = {} \r", rx_word).unwrap();

        // Calculate Temperature According to Datasheet Formulas
        let x1 = (rx_word as i32 - calib_coeffs.ac6 as i32) * (calib_coeffs.ac5 as i32) >> 15;
        let x2 = ((calib_coeffs.mc as i32) << 11) / (x1 + calib_coeffs.md as i32);
        let b5 = x1 + x2;
        let t = ((b5 + 8) >> 4) / 10;

        // Print Temperature Value
        core::writeln!(&mut msg, "Temperature = {:} \r", t).unwrap();
        usart.blocking_write(msg.as_bytes()).unwrap();
        msg.clear();
    }
}

πŸ”¬ Further Experimentation/Ideas:

Some ideas to experiment with include:

  • Expanding the code to collect pressure measurement data including calibration data and calculate barometric pressure.

  • The BMP180 has different accuracy modes in which more accurate measurements can be provided but also different wait times apply as well. You can refactor the code or even create functions that can select one of the desired modes.

Conclusion

In this post, an I2C temperature measurement application was created STM32 using the embassy framework HAL by controlling and collecting data from an external BMP180 sensor. This application leverages an I2C peripheral for the STM32F401RE microcontroller on the Nucleo-F401RE development board. The resulting measurement is also sent over to a host PC over a UART connection. All code was based on polling (without interrupts). Have any questions? Share your thoughts in the comments below πŸ‘‡.

Did you find this article valuable?

Support Omar Hiari by becoming a sponsor. Any amount is appreciated!

Β