Using Rust Embedded to capture sensor data

Murray Todd Williams
23 min readJun 20, 2024

--

Our fully wired project

In this fifth part in my series, we read sensor information both digitally via an I2C channel and via analog readings from the specialized ADC pins of a Raspberry Pi Pico. We’ll write our sample programs based both on the pico-rp BSD library and the newer async Embassy library to compare and contrast approaches. If this is your first exposure to this series, you can click on the card below to visit the earlier articles.

Murray's (Raspberry Pi Pico W) Embedded Rust Series

7 stories

In my second article, I had recommended some sensors that I would be working with:

  • The BMP390, the latest in a long series of humidity sensors made by Bosch.
  • The old classic MCP9808 temperature sensor.
  • The timeless TMP36 analog temperature sensor that had been included in my very first Arduino Starter Kit about a decade ago.

I will provide code to read from all three of these devices and try to set things up so you can comment-out any sensor you might not have on-hand.

When it comes to doing the actual querying of the first two boards, I’ll admit I’m going to “push the easy button” and leverage pre-written libraries, simply passing a reference to the I2C bus to each. That’s one of the whole points of using Rust: the vast wealth of libraries that people have contributed! But that said, I wanted to also show some of the lower-level details of the I2C communication so you can see it’s not so difficult, so I’ll verify one of the boards using a simple part of its API.

The TMP36 has no driver, nor does it need one. The device is cleverly designed to report the temperature by returning a specific voltage on its line. You just read the voltage, do some simple math and voila!

Wiring things up

I’ve shown this Pico W pinout diagram in every blog article for a good reason: you will always want to have this on hand. As of the blinky base, we’re using pins 1, 2, and 3 as connectors for our debug probe, and we’re using pin 29 (GPIO 22) for our external LED. Also in my wiring diagram from the fourth article, I connected the pin 38 ground to my breadboard’s grounding rail.

By the way, I’ve got no idea why 8 of the 40 pins are connected to the ground. I suspect its just because a project with many components will need to provide convenient grounding paths to all of them. Suffice to say, any time you need a ground, you’re welcome to use any of those pins with the black GND boxes. They’re all connected to the same grounding plane!

We want to provision one of the two I2C lines (I2C0 or I2C1), so we 10 pairs of pins to choose from! I’m going to avoid using pins 31 and 32 because they are also our precious 3 ADC lines, so I’ll use pins 21 and 22. Why? Because they are along the edge of the board so I don’t have to count carefully to figure out which pins to put my jumper into. (Counting up 9 pins to find GPIO 22 for the LED was a little bit of a pain.)

I’m also going to note that I’m going to use the I2C0 bus as opposed to the I2C1 bus. This is arbitrary. If I’d picked pins 19 and 20 on the other side of the board, I would have to use the I2C1 bus instead.

Mind the Voltage!

When working with electrical components, you need to always keep an eye on what voltage they expect. The original Arduino Uno I learned electronics on had a base voltage of 5V coming out of all of its pins, and any component I worked with needed to either accept that 5V voltage or I would have to step the voltage down to the more modern 3.3V that has become standard.

The USB port that powers your Raspberry Pi Pico W provides a standardized 5V of power, while the internal RP2040 chip at the heart is a 3.3V component. The board can be powered either via the standard 5V from the USB port (which is linked to the pin 40 “VBUS”) or from any other input, like a battery pack or a solar panel, via the neighboring pin 39 or “VSYS”.

A cool bit about the board is that it has a voltage regulator than can “step up” or “step down” the voltage to accept inputs in a remarkable range of 1.8V to 5.5V! Regardless, if you are powering via VSYS, the input range is flexible.

The other important pin is the “3V3(OUT)” pin 36 that provides the same consistent and reliable 3.3V power that the RP2040 microcontroller is expecting.

For our projects, we are going to want to connect that 3V3(OUT) pin 36 to the red “+” rails on the breadboard, just like we had connected the pin 38 ground to the blue “-” rail. (Again, we could have used any of the other 8 GND pins for this purpose.)

VIN vs 3Vo pins: which to use for power?

When we buy a sensor, we want to look at the documentation to see what voltage it is expecting. The Adafruit BMP390 sensor, for example, builds in a similar voltage regulator so that you can either use the “3Vo” connector to feed a reliable 3.3V or the “VIN” to accept a range of 3–5V. If you know you have 3.3V, it’s better to use the “3Vo” connector because there’s a little bit of power efficiency gain by bypassing the regulator.

So as we wire our BMP380 and MCP9808 sensors, we just have to focus on these connections:

  • 3Vo (or VIN if there isn’t a dedicated 3Vo) to the breadboard’s red (+) rail
  • GND to the breadboard’s blue (-) rail
  • SDA to the Pico W’s pin 21
  • SCL to the Pico W’s pin 22

Looking at some wiring pictures

In case you’re feeling any intimidation about wiring things up, let me provide some pictures and talk some of this stuff through. It’s really not hard to do this. (Especially if you have a lot of multi-colored jumper wires!)

Partial view from one side of the breadboard

From the view above, you can see how the side rails work. All the holes along the blue line are connected internally and are meant for all of your grounds to connect to. Similarly, anything needing 3.3V power will plug into any available hole along the red line. In this image, one of my sensors (the BMP390) has a Qwiic connector, which just makes it easier to connect the power and I2C lines.

On the left side of the image and on the back / hidden side of the breadboard I’ve wired up my MCP9808 sensor using soldered headers instead of a Qwiic system. Here’s a closer view so you can see what I’ve done. (With an overlay of the sensor so you see the “VDD”, “GND”, “SCL”, and “SDA” lines plugged into the red, black, yellow, and blue wires respectively.)

Other side of the board where I’m wiring the sensor by header

Finally, here’s where I’m wiring the TMP36 sensor. The left and right (3.3V and GND) are wired straight to the rails to the right, and the green ADC voltage line is going to the ADC0 (pin 31) line on the Pico board.

TMP36 sensor wired to 3.3V (red), ADC pin 0 (green), and GND (black)

Pressing the easy button with the Qwiic connectors

I’m used to soldering headers to my sensors, sticking them onto the breadboard, and wiring them up manually to the appropriate pins as I just mentioned. But I’ve wanted this series to be accessible to someone who doesn’t have soldering equipment yet, so in my second article, I recommended the boards that had the Qwiic connectors on both ends. If you got the STEMMA QT/Qwiic JST to male headers cable then you’ll do this instead with the cables lines (going left to right)

  1. Black for GND to the breadboard’s blue (-) rail
  2. Red for V+ to the breadboard’s red (+) rail
  3. Blue for SDA to pin 21
  4. Yellow for SCL to pin 22

If you got both sensors plus additionally the Qwiic QT to QT cable, then connect one sensor as I just mentioned, and use the QT to QT cable to daisy-chain the sensors together!

For my photographed board, I’ll show one of each, where my new BMP390 uses the Qwiic cables and my older MCP9808 uses the older “headers in the breadboard” approach.

Last step, the TMP36

For this, we are going to use the special ADC (Analog to Digital Converter) pins to measure voltage from the TMP36 sensor.

We’ll connect the breadboard’s 3.3V (+) rail to the left lead, the Pico W’s ADC0 (pin 31), and apparently for ADC applications, there’s a special ADC ground pin 33 that we should be using for better accuracy instead of using the breadboard’s universal grounding rail.

Two paths diverged in a coding wood… (sic)

Just like with did with the original blinky program, we are going to solve this two different ways: once with the embedded-hal libraries that come with the rp2040-hal hardware abstraction library + the rp-pico Board Support Package. Then we’ll take a look at the code implementations via the Embassy project’s API.

When I embarked on writing this blog article, I thought everything was going to be relatively straightforward, with certain tasks looking a little different based on which libraries (a lower-level embedded-hal abstraction versus a higher-level and fuller-featured Embassy approach. What I learned is that there’s a lot more nuance out there, and I’m going to do by best to share my learnings and newer understanding of things.

In an earlier post, I referred to the migrating-from-0.2-to-1.0.md document that I had found very illuminating. I highly recommend bookmarking this document and returning to it periodically as you start working with these libraries. An imporant exerpt at the top:

In embedded-hal 0.2, the traits had dual goals:

* Standardize the API of HAL crates, so HAL crate authors get guidance on how to design their APIs and end users writing code directly against one HAL get a familiar API.

* Allowing writing generic drivers using the traits, so they work on top of any HAL crate.

For embedded-hal 1.0, we decided to drop the first goal, targeting only the second.

* Standardizing HAL APIs is difficult, because hardware out there has wildly different sets of capabilities. Modeling all capabilities required many different variants of the traits, and required “customization points” like associated types, significantly increasing complexity.

* There is a tension between both goals. “Customization points” like associated types make the traits hard to use in generic HAL-independent drivers.

* The second goal delivers much more value. Being able to use any driver together with any HAL crate, out of the box, and across the entire Rust Embedded ecosystem, is just plain awesome.

We are going to run into an example of this with our ADC variable-voltage reading. I’m beginning to learn that different microcontrollers approach ADC in extremely different ways. Apparently, they tried to standardize things in v0.2 before abandoning certain specific ADC functions, including the “OneShot” approach to taking a single voltage reading from an ADC pin. adc::OneShot was removed from embedded-hal v1.0, so we’ll pull in that part of the v0.2 specification in order to use it.

This shouldn’t be any cause for alarm. Just understand that some functionality specific to the rp2040 architecture will some from the rp2040-hal create while not necessarily being defined against any embedded-hal hardware abstraction library API.

Learning how to do things with embedded-hal for RP2040

Since I’ve been learning all this stuff, step-by-step, as I write these articles, it’s worthwhile mentioning how I learned to do these things and where all these code lines come from! For all of these fundamental functional examples, I looked at this examples directory from the rp-hal/rp2040-hal GitHub repository.

ONE CAUTIONARY NOTE: as the rp2040-hal library is developed, code changes are immediately made to the examples code to reflect any API changes. I (and others) have run into challenges where example code doesn’t seem to compile because I am using the latest published version of a crate (e.g. v0.10.2 or rp2040-hal) whereas the example code I’m looking at is from the most recent commit from the master branch. So if you’re about to cut-and-paste any code from an example, make sure you’re looking at the example codebase from the correct tag! For example, this is the i2c.rs example code from the v0.10.2 tag.

This can’t be overstated enough. All of these libraries are undergoing a lot of great development at a rapid clip.

Reading Temperature from the MCP9808 Sensor and I²C

I will be building on top of the previous rp2040-blinky project that we wrote in the previous article. I’ve created a “sensors” branch for my sensor-reading breakout work and specific v0.2 tag for this article. We’ll look at both Cargo.toml and main.rs for the changes we make.

For Cargo.toml, things are pretty simple: the MCP9808 sensor has a device driver, so we can just add it.

mcp9808 = "0.4.0"

At the top of our main.rs file, there are some additional “use” statements that we’ll need to include. First, there are the types associated with the MCP9808 driver:

use mcp9808::{
reg_conf::Configuration, reg_res::ResolutionVal, reg_temp_generic::ReadableTempRegister,
MCP9808,
};

And then we need to expand the API types from our BSP’s HAL (our Board Specific Package’s Hardware Abstraction Layer). We used to have the following:

use bsp::hal::{
clocks::{init_clocks_and_plls, Clock},
pac,
sio::Sio,
watchdog::Watchdog,
};

And it’s going to get expanded to this:

use bsp::hal::{
adc::AdcPin,
clocks::{init_clocks_and_plls, Clock},
fugit::RateExtU32,
gpio::{FunctionI2C, Pin, PullUp},
pac,
sio::Sio,
watchdog::Watchdog,
};

(The second line is going to be for the Analog sensor readings in the next section, but I’m including it here for simplicity.)

In our main.rs program, we first have to setup the I²C bus. This includes specifying which pins we want to reconfigure for I²C:

let sda_pin: Pin<_, FunctionI2C, PullUp> = pins.gpio16.reconfigure();
let scl_pin: Pin<_, FunctionI2C, PullUp> = pins.gpio17.reconfigure();
let i2c = bsp::hal::I2C::i2c0(
pac.I2C0,
sda_pin,
scl_pin,
400.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);

Note a few specific things we’re doing:

  • We are reconfiguring GPIO16 and GPIO17 to be used for I2C. (That shows up in the type definition where was specify FunctionI2C.)
  • We are specifying that we are defining the I2C0 bus.

I’m goingt to consider the passing of the system clock and pac.RESETS to be boilerplate. Also specifying the 400 kHz speed is pretty standard. There are some other possible I2C speed specifications, but using those would be an edge case at this point.

An intersting thing: if you look at the Pico pinup diagram, you’ll see that it explicitly says that those two pins can only be used for I2C0. If you tried to configure them for I2C1, the compiler would throw an error. This is part of the beauty of Rust’s type system: it is designed to make it almost impossible to configure your Pico incorrectly! Or at least, if you do something wrong, the compiler won’t build and run the binary, and hopefully you’ll get some good compiler diagnostics to point you in the right direction.

Next we configure the actual MCP9808 device:

let mut mcp9808 = MCP9808::new(i2c);
let mut mcp9808_conf = mcp9808.read_configuration().unwrap();
mcp9808_conf.set_shutdown_mode(mcp9808::reg_conf::ShutdownMode::Continuous);
mcp9808.write_register(mcp9808_conf).unwrap();

For any device you want to use, you’ll need to go to their crate’s documentation for examples of how to configure and use them. In this case, we just need to explicitly tell the device to be in power-up mode before we take any readings.

Finally, we have the line in our main loop to take the actual readings:

let mcp9808_reading_c: f32 = mcp9808
.read_temperature()
.unwrap()
.get_celsius(ResolutionVal::Deg_0_0625C);

There are a lot of sensors for which you don’t really need a driver. The chips’ documentation is often pretty clear about what values you need to send to which registers to configure the devices and pull data, but diving into the idiosyncrasies would take us off track for this blog posting.

Reading voltages off of analog sensors

It’s not all that far out to think of some use cases where you might want to be able to read individual voltages. Maybe you want to experiment with testing soil moisture by pulling to metal spikes in the soil next to each other and see if a voltage can be detected conducting through the moisture. (There are actually more clever ways to do this with soil sensors via impedance, but it’s still a valid experiment.)

There are some simple but effective temperature sensors that measure temperature via voltage changes through a component. The RP2040 actually has a built-in sensor that you can use to measure chip’s temperature—I guess so you can detect if it’s overheating or if it’s in an environment that’s likely to cause it to start failing. There’s also the TMP36 sensor that we already wired up in the beginning of this blog, and we’re going to use the same approach to read from both.

As I was first setting up my dependencies, I ran into my first hitch. ADCs are often used to read large samples of voltages, like reading an audio waveform from a microphone. Reading a single voltage required a type adc::OneShot that was part of the embedded-hal v0.2 but was dropped from v1.0. My guess is that the way different chip’s ADCs handle single voltage readings had enough variation that it was interfering with a unified embedded model.

In order to use this class, we will need to import the embedded-hal v0.2 package in addition to the v1.0 one. We do this by putting the following in our Cargo.toml:

embedded_hal_0_2 = { package = "embedded-hal", version = "0.2.5" }

We will import the needed type with:

use embedded_hal_0_2::adc::OneShot;

(I think alternatively, you could explicitly import the rp2040-hal that is automatically getting included via the rp-pico BSP, and then I think we could do the import as rp2040-hal::adc::OneShot. If that’s the case, I think it’s a valid question whether it’s more idiomatically more correct to do things that way since the developers meant to reduce v1.0’s scope and have non-v1.0 stuff be relegated to the chip-specific libraries. Since this article has already taken over a week to write, I’ll leave further exploration on this topic for a later rewrite!)

Next, I’m going to write some constants and functions out to make the example more readable. As we convert our ADC voltages into temperatures, let’s define two constants. I want to be explicit that the reference voltage I’m measuring against is the 3.3V system voltage of the Pico board. Also, I want to be explicit that the 12 bit ADC readings have 2¹²=4096 distinct levels.

const REFERENCE_VOLTAGE: f32 = 3.3;
const STEPS_12BIT: f32 = 4096 as f32;

My 12-bit readings are going to be reported as a u16 value, so my function for turning a reading into a voltage will look like this:

/// Convert ADC binary value to a float voltage value.
///
/// The ADC has a 12-bit resolution of voltage, meaning that there
/// are 2^12 or 4096 unique levels from OFF (0V) to FULL (3V). This
/// function converts the ADC reading into a float measurement in volts.
fn adc_reading_to_voltage(reading_12bit: u16) -> f32 {
(reading_12bit as f32 / STEPS_12BIT) * REFERENCE_VOLTAGE
}

Since I live in the United States, I experience temperature in degrees Fahrenheit. So for convenience:

/// Basic Celsius-to-Fahrenheit conversion
fn c_to_f(c: f32) -> f32 {
(c * 9.0 / 5.0) + 32.0
}

I read the specs for the onboard chip sensor in section 4.9.5 of the rp2040-datasheet.pdf document and those for the TMP36 sensor here in order to derive the different formulas for converting their voltage readings into temperatures:

/// Convert the voltage from a TMP36 sensor into a temperature reading.
///
/// The sensor returns 0.5V at 0°C and voltage changes ±0.01V for every
/// degree Celcius with higher temps resolting in higher voltages within
/// the range of -40°C to 125°C.
fn tmp36_f(adc_reading: u16) -> f32 {
let voltage: f32 = adc_reading_to_voltage(adc_reading);
let c = (100.0 * voltage) - 50.0;
c_to_f(c)
}

/// Convert the voltage from the onboard temp sensor into a temp reading.
///
/// From §4.9.5 from the rp2040-datasheet.pdf, the temperature can be
/// approximated as T = 27 - (ADC_voltage - 0.706) / 0.001721.
fn chip_f(adc_reading: u16) -> f32 {
let voltage: f32 = adc_reading_to_voltage(adc_reading);
let c: f32 = 27.0 - ((voltage - 0.706) / 0.001721);
c_to_f(c)
}

Configuring the two ADC inputs involed these simple lines:

let mut adc = bsp::hal::Adc::new(pac.ADC, &mut pac.RESETS);
let mut rp2040_temp_sensor = adc.take_temp_sensor().unwrap();
let mut adc_pin_0 = AdcPin::new(pins.gpio26).unwrap();

Pulling the readings involved these simple lines, placed in the main program loop:

let chip_voltage_24bit: u16 = adc.read(&mut rp2040_temp_sensor).unwrap();
let tmp36_voltage_24bit: u16 = adc.read(&mut adc_pin_0).unwrap();

Finally, I am going to write all three temperature readings to the debug console, leveraging the helper functions I had written earlier:

info!(
"Temp readings: MCP9808: {}°F, OnChip: {}°F, TMP36: {}°F",
c_to_f(mcp9808_reading_c),
chip_f(chip_voltage_24bit),
tmp36_f(tmp36_voltage_24bit)
);

Shifting to the Embassy API

As with the previous article where we started with our blinky example with the rp-pico Board Specific Package and embedded-hal v1.0 followed by the same example using the Embassy API, it’s time to take our two operations—taking a digital reading via i²C and a MCP9808 sensor driver and then taking the two ADC temperature readings—and implement them in Embassy. We’ll start to see some subtle similarities and differences between the libraries as we go.

Learning how to do things with Embassy RP2040

Just like with the earlier section, when you want to figure out how to do the fundamental things (GPIO, I²C, etc.) with Embassy, the first place to look is not the online documentation but the proper examples directory. I go to the examples directory of the embassy-rs/embassy GitHub repository. And as before, if you run into issues getting example code to compile, make sure you are looking at a tag that matches the library you are using.

Embassy I²C Reading

So that you can see what we’re doing, here is my embassy-rp-blinky repository with the v0.2 tag where we’re expanding blinky into doing the I²C reading. Follow this link to see the individual changes to Cargo.toml and main.rs.

For Cargo.toml, we’re just going to add the driver for the MCP9808 sensor, just like we did in the rp2040-blinky project:

mcp9808 = "0.4.0"

For the library declarations in the top of main.rs, we are going to add some I²C-specific types as well as those we’ll be using for the MCP9808 driver:

use embassy_rp::bind_interrupts;

use embassy_rp::i2c::{Config, I2c, InterruptHandler};
use embassy_rp::peripherals::I2C0;

use mcp9808::reg_conf::{Configuration,ShutdownMode};
use mcp9808::reg_res::ResolutionVal;
use mcp9808::reg_temp_generic::*;
use mcp9808::MCP9808;

Let me pause for a moment and talk about sync (blocking) versus async paradigms. Many of the libraries and drivers (and/or create features) that you’ll run into will have to do with sync vs async capabilities. I’m going to over-simplify the following explanation a little bit:

  • Synchronous (blocking) code is simpler to write, understand, and use. If you need to do any I/O operations, the CPU will essentiallly wait around until a task is done. For something simple like taking a sensor reading via I²C, this is almost instantaneous, so this isn’t really a problem.
  • Asynchronous code requires more nuance, but it introduces a bunch of efficiencies where you the CPU doesn’t wait around for I/O to complete. It makes it easier to leverage multi-core systems (like our 2-core RP2040!) and write performant code.

When we create our I²C instance in a moment, we will have the choice of creating a blocking instance or an asynchronous one. For this example, I tested both cases, and they worked equivalently. That is, I was able to pass either a blocking or async instance of the I²C bus to the MCP9808 driver. I will show the code variations for both, but ultimately I’m going to stick with the asynchronous code because I’ll later add a BMP390 barometric sensor driver that is written for asynchronous operation only.

For our helper constants (REFERENCE_VOLTAGE and STEPS_12BIT) and functions (c_to_f(f32), adc_reading_to_voltage(u16), tmp36_f(u16) , and chip_f(u16)) I just copied the code from the rp2040-blinky project.

Immediately following that, I added the following code:

bind_interrupts!(struct Irqs {
I2C0_IRQ => InterruptHandler<I2C0>;
});

This is a prerequisite for using the asynchronous-capable version of the I²C bus. In a nutshell, when there is some activity on the I²C bus (e.g. data coming in from a sensor) that the CPU can act on, an IRQ interrupt will occur. This will help the Embassy library to kick into gear to handle that new data appropriately. In future examples, you’ll see us add other interrupts here—like the PIO interrupts needed for talking to the Wifi chip. Note that if you wanted to use only the synchronous I²C library, these lines could be omitted.

Next we need to initialize the I²C sensor:

// I2C Setup
info!("Starting I2C Setup");
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = Config::default();
i2c_config.frequency = 400_000;
let i2c = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
info!("I2C Initialized");

The first thing I’d like to mention is that this looks notably different from the rp-pico and rp2040-hal based code. For convenience, let’s take a look again at what that had looked like:

let sda_pin: Pin<_, FunctionI2C, PullUp> = pins.gpio16.reconfigure();
let scl_pin: Pin<_, FunctionI2C, PullUp> = pins.gpio17.reconfigure();
let i2c = bsp::hal::I2C::i2c0(
pac.I2C0,
sda_pin,
scl_pin,
400.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);

Interestingly, the naming conventions of the pins (p.PIN_16 versus pins.gpio16) are different, and whereas we had to use some generics type declaration to declare that the pins was going to be used for I²C in the earlier code, our API looks simpler for the Embassy project.

One other thing I noticed: the Embassy sample code had me use Config::default() directly in my I²C initialization code, but that setting defaults to I²C running at 100 kHz (see line 56 of the codebase here) whereas our rp2040-hal example had us setting I²C to 400 kHz. I wanted to make these two examples as equivalent as possible, so I had to do the following lines to create a configuration using the I²C 400 kHz frequency:

let mut i2c_config = Config::default();
i2c_config.frequency = 400_000;

As a Scala developer moving to Rust, I have to say I cringe at being made to create a mutable structure and manually change one of its fields rather than having a more straightforward constructor. I suspect this is due to the fact that the developers are writing their libraries as quickly as possible, and the eventually they will add a more straightforward way to set the bus frequency. I also noticed that in the earlier code, the rp2040-hal library pulled in a fugit library that made it possible to write 400.kHz() for the frequency, as opposed to the straight u16 value being used here.

Regardless, both of these code examples create the same i2c object that we feed to our MCP9808 driver:

let mut mcp9808 = MCP9808::new(i2c);
let mut mcp9808_conf = mcp9808.read_configuration().unwrap();
mcp9808_conf.set_shutdown_mode(mcp9808::reg_conf::ShutdownMode::Continuous);
mcp9808.write_register(mcp9808_conf).unwrap();

This reinforces the fact that, despite a lot of different-looking code, the synchronous rp2040-hal library and the asynchronous-forward Embassy library both derive from the same embedded-hal foundation that makes it possible for driver developers to focus on creating universal code!

In the main loop, we also write the same simple line to get our sensor reading:

let mcp9808_reading_c: f32 = mcp9808
.read_temperature()
.unwrap()
.get_celsius(ResolutionVal::Deg_0_0625C);

Reading voltages off of analog sensors

Remember that the ADC reading in our rp2040-hal-based project was a little weird because it was using an embedded-hal v0.2 abstraction that had been abandoned in the v1.0 standard. We’ll see here that ADC reading here in the Embassy API is similar but a little different. You can see the final code for this section here with my v0.3 tag off my sensors branch.

We need to include the types Adc, Channel, Config, and InterruptHandler from the embassy_rp::adc namespace, but here we’re going to run into our first snag, as we’ve already included embassy_rp::i2c::Config and embassy_rp::i2c::InterruptHandler. In order to avoid this name collusion, I’m going to use the following approach to rename the colluding types:

use embassy_rp::adc::{Adc, Channel, Config as AdcConfig, InterruptHandler as AdcInterruptHandler};
use embassy_rp::i2c::{Config as I2cConfig, I2c, InterruptHandler as I2cInterruptHandler};

This way, I’ll refer to an I2cConfig or AdcConfig explicitly in my code, as well as I2cInterruptHandler and AdcInterruptHandler. Obviously, I’ll have to change a few lines where I had referred to the I²C’s Config and InterruptHandler. You can look at this git commit change file to see where the changes were made. (It’s pretty trivial.)

Before we setup the ADC, we’ll need to add the IRQ interrupt handler to the collection where we had originally only had the I²C handler. (Here’s where we had to do one of the type name changes for I2cInterruptHandler, by the way.)

bind_interrupts!(struct Irqs {
I2C0_IRQ => I2cInterruptHandler<I2C0>;
ADC_IRQ_FIFO => AdcInterruptHandler;
});

Now the ADC setup code is pretty straightforward:

// ADC Setup

let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
let mut temp_channel = Channel::new_temp_sensor(&mut p.ADC_TEMP_SENSOR);
let mut adc_channel_pin26 = Channel::new_pin(&mut p.PIN_26, Pull::None);

This code is a little different from the rp2040-hal API. Here we are passing the Irqs structure plus an AdcConfig object. One thing that I think is a little funny: AdcConfig is actually an empty structure. If you look at the source code, it’s implementation looks like this:

/// ADC config.
#[non_exhaustive]
pub struct Config {}

impl Default for Config {
fn default() -> Self {
Self {}
}
}

So I guess the authors have created a future placeholder, but right now it doesn’t do anything.

The other thing I wanted to mention was the Pull::None setting on the third line. In general, when reading a voltage from the regular (non-ADC) GPIO pins, you’ll want to configure the pins so that they expect either full voltage or no voltage by default. You are essentially asking the RP2040 to connect the GPIO pin to either the system v3.3 voltage or to the ground via a resister. For ADC applications, you’re generally not expecting either, so we’re not engaging either resister on the pin. (There may be some edge cases where you might want to manually pull the pin up or down, but I can’t think of any right now.)

The final code for taking the readings in the main loop and printing the results is straightforward, the latter being identical to the other project.

let chip_voltage_24bit: u16 = adc.blocking_read(&mut temp_channel).unwrap();
let tmp36_voltage_24bit: u16 = adc.blocking_read(&mut adc_channel_pin26).unwrap();

...

info!(
"Temp readings: MCP9808: {}°F, OnChip: {}°F, TMP36: {}°F",
c_to_f(mcp9808_reading_c),
chip_f(chip_voltage_24bit),
tmp36_f(tmp36_voltage_24bit)
);

There is also an adc.read() function that performs a read operation but does it asynchronously. Since our Embassy’s main function is itself an asynchronous function (it’s signature is async fn main(_spawner: Spawner) {}), we can and really should use asynchronous calls whenever possible so we can yield the CPU to the executer. I wanted to keep things synchronous at first for simplicity, but it’s probably more idiomatically proper to rewrite those two lines as:

let chip_voltage_24bit: u16 = adc.read(&mut temp_channel).await.unwrap();
let tmp36_voltage_24bit: u16 = adc.read(&mut adc_channel_pin26).await.unwrap();

One last note: I was sort of surprised and a little disappointed that my three temperature readings ended up varying more than I’d expected. The last run I did, I got the following:

Temp readings:  MCP9808: 77.675°F, OnChip: 67.366875°F, TMP36: 80.34863°F

I would expect the on-chip temperature sensor to read at least a little warmer since the chip is actually running and thus generating some internal heat. Well, I don’t really expect the on-chip sensor to have that much accuracy. It would take some experimentation to see if the TMP36 is consistently reading 3 degrees warmer than the MCP9808. Maybe some calibration will be needed. It’s also possible that some of the challenges lie with the ADC itself.

Doing some reading on the ADC in preparation for this blog series, I learned that when the RP2040 was first released, there were some design flaws discovered in the ADC circuitry that led to periodic spikes in readings. I saw some discussion about on-MCU ADC functionality being problematic by nature, and that good precision measurements will require a standalone ADC component. At the moment, I’m going to shrug my shoulders a bit and call this “good enough” for now.

I also know that my BMP390 (which requires Embassy’s async support) has another thermometer that I can leverage, so I’ll probably revisit this in the future.

Up Next: Wifi!

This article was probably longer than necessary, but I think it was a good exercise to compare both libraries and to start to see that both are really derived from the embedded-hal system. Both can leverage the same universal sensor drivers (as long as they accept a blocking API), which is really the important thing.

But as a reminder: the whole reason I embarked on this blog series was to utilize the “W” in my Raspberry Pico W board. I want my projects to be “untethered” and to be able to send telemetry wirelessly. (This will make it possible for me to take readings outside and further explore the calibration question between my sensors!) In the next article, we’re going to add wifi to our project!

It’s already been a long journey, but hopefully it’s been helpful for someone completely new to the Rust Embedded space. From this point, in part 6 and part 7 of the series, we’re going to abandon the rp2040-blinky project because our networking code is going to depend on Embassy’s embassy-net library and Embassy-rp’s logic for talking to the CYW43 wifi chip via the PIO system.

--

--

Murray Todd Williams
Murray Todd Williams

Written by Murray Todd Williams

Life-long learner, foodie and wine enthusiast, living in Austin, Texas.

Responses (4)