Why would you want to use a language other than MicroPython for an embedded system like a Pi Pico? It is so easy!
- You, maybe
Yes, it is easy. But I decided my goals lie in a loftier place. I learned regular std Rust. I read the books (The Rust Book and Rust For Rustaceans) and I felt like I was ready to take the next step. I figured it would be difficult, and complex, because Rust is supposed to be a “real” systems language. As it turns out when you stand on the shoulders of giants things are a lot easier than they seemed they would be at first glance.
Embassy (or embassy-rs) is an async framework for embedded Rust applications. This means that we can have a fully asynchronous application running on bare metal, or no_std at the very least. Additionally, there are several actively maintained packages for common microcontroller CPUs, including the Pi Pico 1/2 with embassy-rp. This means we have a way to run async programs without an OS that is easy to use, actively maintained, and has a large community.
What is cool is that embassy is popular. Because it is popular, we have some cargo generate templates floating around that can utilize it. My personal favorite at time of writing is the one by ImplFerris, here.
We can get started with the following command:
cargo generate --git https://github.com/ImplFerris/pico2-template.git
Great! I have some software that can print debug logs in an async environment using no_std but what do I want to drive? I purchased a few gas detectors from Adafruit, so I will write a “driver” for the AGS02MA. Lucky for me, the hard working people at Adafruit already have a C++ based driver, so it seems I just need to port this to async Rust. This is very convenient because it has the addresses already stored in the .h file.
So in my first attempt to get this working I just sort of cobbled together the most basic version of i2c.read_write_async using the Embassy I2C module. But when I tried this I got zeros for everything except reading the version from the version register. Why was that? Well…. Looking at the code from Adafruit we see some things such as “data delay” and “command delay”. At first I was unsure what that was about until I scoped out the data-sheet for the AGS02MA. At the bottom of page 5 we see the following:
Note:
- The host needs a 30 ms interval after sending a write command before sending the next write command or read command.
- After the host sends the “set measurement mode” command, it needs to wait for 2 s for the sensor to finish acquisition before sending the “data acquisition” command.
- The “Data Acquisition” command must not be sent too often, otherwise the sensor will not be able to collect data properly, and the status bit RDY of STATUS BYTE will always be 1. The “Data Acquisition” command must not be sent at intervals of less than 1.5 s.
Ok, cool, so that might explain why my code did not work. Let us try to get this going with a delay:
// ADDR DEFINITIONS
// Thank You Adafruit: https://github.com/adafruit/Adafruit_AGS02MA/blob/main/Adafruit_AGS02MA.h
pub const AGS02MA_ADDR: u8 = 0x1A;
pub const TVOC_REG: u8 = 0x00;
pub const VERSION_REG: u8 = 0x11;
pub const GASES_REG: u8 = 0x20;
pub const MODE_DELAY: i32 = 2000;
pub const DATA_DELAY: i32 = 1500;
pub const COMMAND_DELAY: u8 = 30;
/////
pub async fn read_tvoc_i2c0(i2c: &mut I2c<'_, I2C0, Async>) -> String {
let mut buffer = [0u8; 5];
i2c.write_async(AGS02MA_ADDR, [TVOC_REG]).await;
Timer::after_millis(30).await;
i2c.read_async(AGS02MA_ADDR, &mut buffer).await;
let s = show_tvoc(&mut buffer).await;
s
}
pub async fn show_tvoc(b: &mut [u8; 5]) -> String {
let value = u32::from_be_bytes([b[0], b[1], b[2], b[3]]);
let v = value & 0xFFFFF;
return format!("{}", v)
}
And this worked! Well, it works as long as you do not call read_tcov_i2c0 any more than once per 1.5 seconds, which we can control in the main loop of our program pretty easily. I then write the contents to an SSD1306 display which is outside of the scope of this blog post because I used somebody else’s driver for that.
loop{
// Wait a tiny bit to give the sensor a chance to refresh (1.5 seconds per the datasheet)
Timer::after_millis(ags02ma::DATA_DELAY as u64).await;
display.clear(BinaryColor::Off).unwrap();
let s1 = format!("TVOC: {}", ags02ma::read_tvoc_i2c0(&mut i2c0).await);
let tick = format!("TICK: {}", count);
// Draw on display
Text::with_baseline(s1.as_str(), Point::zero(), style, Baseline::Top).draw(&mut display).unwrap();
Text::with_baseline(tick.as_str(), Point::new(0,20), style, Baseline::Top).draw(&mut display).unwrap();
// Flush display buffer to screen
display.flush().unwrap();
// Tick count
count = count + 1;
}
no_std environment, and that is totally possible on the Pico 1/2 as it comes with RAM! We just needed to initialize a heap, which is outside of the scope of this post as well. But for completeness sake I used use embedded_alloc::LlffHeap. Now you can use Vecs and all of that good stuff!
And that is really it, which is kind of wild how easy that was. I foresee myself writing a lot more embedded Rust, especially for the Pi Pico 1/2 family of microcontrollers. I love this stuff! One goal for the future would be to figure out how to write the functions for reading the TVOC to take either I2C1 or I2C0 from embassy, but just writing two functions works for me for now.