My Rust learnings: combining the sensor & networking code

Murray Todd Williams
21 min readOct 25, 2024

--

The time is long overdue to pen another entry in my blog series on Embedded Rust on the Raspberry Pi Pico. If you want to see any of the previous articles, you can check the link here:

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

7 stories

Since June (really, May) I’ve been embarking on the personal journey of using my enthusiasm for electronics and microcontrollers as the use case or entry point for trying to learn Rust. I had decided to write this blog series because I had found Embedded Rust challenging to dive into because of the relative lack of tutorials—and those that I had depended on were either slightly outdated or would miss some details that were important to just get started. There’s an amazing foundation of libraries out there that is like an embedded-specialty version of a Standard Library, and I wanted to take my own notes in this learning path and turn them into something that would help others wanting to consider the same journey.

Most of my code was based directly on the various bits of example code that I copied from these frameworks’ code repositories. I would iteratively try to apply them to my project, get another piece up and running, and then turn to the next snippet of example code. I wasn’t afraid to blog about this because I wasn’t running the risk of giving poorly-written Rust examples.

At the same time, I knew I wasn’t really learning the language as deeply as I would like. More than that, if you’ve been following along with me, you’ve been seeing main.rs get longer and longer. In the sensors branch it grew to 122 lines—not that terrible maybe—but it the networking branch it got up to 194 lines. Our next natural task is to merge these two codebases so we can actually send our sensor telemetry to our server, but how long would the resulting code become? If you look at this snapshot, you’ll see the answer is 280 lines of code!

Well, it’s time to stop kicking that can down the road. Ten weeks ago, on Aug 22, I merged the sensor and networking code branches. (Thus that super long 280 line snapshot of main.rs I mentioned above.) Then I started the task of refactoring things the same way that I’ve approached all of this Rust Embedded development—one tiny bit at a time. I’ve “refactored” the sensor comms and reading portion of the project. I started doing a little bit of networking refactoring, but realized that after six weeks of periodic work, it was probably a good time to stop and write a blog post.

Detour: My new 3-part mini-series on Happy Path Programming

I actually started writing this blog four weeks ago, but after a time I ran into a hitch. As part of my refactoring work, I wanted to start replacing most of my potentially panicky .unwrap() calls with the more proper Result<T,E> type class. Coming from Scala and Functional Programming, I’m very adept at working with things like Result and Option, but a lot of my code was incorporating tricks that I wasn’t certain a beginner would understand.

I started writing about these techniques in an introductory section (right about where this section is currently placed) and it started growing into something that was going to overwhelm this entire article. In the nested spirit of refactoring, I realized I had enough material for a novel 3-part series about Rust in general. (And I was excited to realize I’d graduated to the point where I was ready to write about actual programming in Rust!)

So here’s a link to my mini-series.

Rust series on Option and Result type classes

3 stories

If you’ve never used .map to apply a function to the contents of an Option or the successful result of a Result variable, if you’ve never dealt with the nightmare of unpacking an Option<Option<Option<T>>> or a Result<Result<Result<T,E1>,E2>,E3>, and if you haven’t mastered the use of Rust’s mysterious “?” question-mark operator, then stop here and go to my mini-series first. Otherwise, the rest of this article will be much harder to follow.

If you’re ready to move forward, just note that unlike some of my previous articles, I’m not going to go line-by-line as much. You’re going to see links to a few “diff” views in the github repository to see the changes that I made in case you’re wanting to follow along. To follow along with the code changes, you should look at the v0.7 tag of my github repository. (I suggest you bring it up in another tab or window now.)

Apologies for the length…

This article has gotten a little too long for my comfort, but I felt it was important to give the fellow beginning Rust programmer a sense of the challenges that I ran into when trying to refactor my code and how I addressed them.

I’ll say up-front, that the biggest challenge was getting all the typing and generic parameters right when just starting to write my function signatures. I suspect a lot of this is exacerbated by the level of abstraction embedded-hal has required to be able to support drivers for such a wide variety of architectures.

The second challenge was learning how to really reason through how to work with shared references, declared mutability, lifetimes, and the borrow checker. While everything was on the main.rs module, it seemed relatively straightforward to instantiate the MCP9808 sensor and use it when needed to just call the method to pull a reading. Passing it as a &Result<TempSensor,Error> to a new read_mcp9808 function was an interesting learning experience.

Hopefully the takeaways that you come away with will be these:

  • Refactoring code in Rust may be more challenging to the beginning Rust developer that you might expect. It may take you days or weeks the first time around.
  • If you stick with it, you’ll start to really understand the language itself. If you commit to simplifying and making things look elegant, you can end up with some elegant, concise, robust code.
  • Result can be your friend, but it takes a certain style and approach to keep it from turning into lots of spaghetti-boilerplate.

My Refactoring Strategy

We’re going to break our one mammoth main.rs file out by creating three modules: sensor.rs for the sensor communications, networking.rs for the WIFI/TCPIP communications, and finally I wanted to start thinking about how to do cleaner error handling with my own error class in an error.rs module.

But we don’t only want to move code into other files. We want to see if we can standardize some of the logic. There was a simple helper function fn c_to_f(c: f32) -> f32 that we wrote and stuck up near the top of our main.rs code. But there are also three fairly different approaches to taking sensor inputs and extracting degrees Fahrenheit (the units I think in) from them. I wanted to standardize things a little bit so that I could put some abstracted meaning into the concept of a Temperature Reading.

The other thing we’re going to do—and this isn’t refactoring as much as weaning ourselves of a nasty habit—is to try to avoid invoking .unwrap() so much. In a previous article I had argued that intentionally panicking with an .unwrap()-induced error isn’t the worst thing to do when prototyping a project, but I was also waffling a little bit. I knew I was kicking a can down the road a little bit, keeping the code simple as we built things out, but there’s a concept of happy path programming that Scala folks follow religiously, and if you know what you’re doing, Rust also promotes with its Option and Result types.

Creating the Modules

I started by creating the empty modules, src/sensor.rs, src/networking.rs, and src/error.rs and then in main.rs I simply declared:

use crate::error::Error;
use crate::sensor::*;

mod error;
pub(crate) mod networking;
mod sensor;

A quick note on the funny pub(crate) visibility modifier for networking. The only code I’ve moved for now into the networking module were the two embassy_executor::task sections:

use cyw43_pio::PioSpi;
use embassy_net::{Config as NetConfig, DhcpConfig, IpEndpoint, Stack, StackResources};
use embassy_rp::gpio::Output;
use embassy_rp::peripherals::{DMA_CH0, I2C0, PIN_23, PIN_25, PIO0};

#[embassy_executor::task]
pub async fn wifi_task(
runner: cyw43::Runner<
'static,
Output<'static, PIN_23>,
PioSpi<'static, PIN_25, PIO0, 0, DMA_CH0>,
>,
) -> ! {
runner.run().await
}

#[embassy_executor::task]
pub async fn net_task(stack: &'static Stack<cyw43::NetDriver<'static>>) -> ! {
stack.run().await
}

In main.rs, when I originally tried to pass those tasks to the spawner that Embassy passes into main, the spawner didn’t have permission to see into that networking module I’d created, so with a little experimentation I was able to widen things to making it public within the crate.

For this blog post, that’s all the refactoring I’ve done so far regarding networking code. Let’s go back to the rest of the work.

Starting with the imports…

I’ll admit, it was a little painful just dealing with all the use statements. I don’t know if VS Code has sometime equivalent to the Java/Scala world’s “organize imports” function that I don’t know about, but after starting my git branch merging activity, it took a bit of time to clean the statements up. Essentially, every time I pulled code out of main.rs and placed it into sensor.rs, I had to look at where I was getting unknown type errors, add the appropriate new use statement into sensor.rs, and then see if I was getting an “unused import” error in main.rs in which case I would pull it from the top.

The process seemed pretty tedious, and I don’t know if I have anything misconfigured in my IDE, but the rust-analyzer plugin in VS Code didn’t automatically flag these issues, instead requiring that I externally issue an explicit cargo build to find the error messages.

Out new Custom Error Type

Idiomatic Embedded Rust appears to trend toward super-simple enumerations (union types) for the errors in Result<T,E>. Interestingly, I read that the newest 1.81.0 version of Rust released on September 5th moved the std::error module into core::error in order to make it available to the no-std world of Rust development, but on the chat boards I noticed the embedded-world developers react mostly with a resounding “meh”.

In my recent mini-series , we learned just how fundamental it is when working with Result type classes to conform your error types into a single consolidated type so that you have the ability to flatten nested Result objects created by all our IO operations. I decided to break our application Error enum into these four scenarios:

pub enum Error {
MCP9808RegisterSizeMismatchError(u8),
MCP9808I2cError,
ADCSensorError(ADCError),
NetworkError,
}

I originally tried to create a generic MCP9808Error based on the MCP9808 library’s own error class, but it had an option for wrapping any I2C implementation’s error that was leading to a nightmare of generic parameters leaking into my own codebase. Instead, I just decided to resolve any I2C error into a simple MCP9808I2cError and to allow for the other error situation that could come from the MCP9808 library. We use the From trait to properly manage these conversions.

impl From<MCP9808Error<I2cError>> for Error {
fn from(other: MCP9808Error<I2cError>) -> Self {
match other {
MCP9808Error::I2c(_) => Self::MCP9808I2cError,
MCP9808Error::RegisterSizeMismatch(e) => Self::MCP9808RegisterSizeMismatchError(e),
}
}
}

The ADCError class was more straightforward, so it was easy enough to encapsulate it:

impl From<ADCError> for Error {
fn from(other: ADCError) -> Self {
Self::ADCSensorError(other)
}
}

Sensor Module: moving helper functions & building abstractions

This took a fair amount exploration to get a good, clean-looking abstraction working. From the sensor work from this blog article, we had written a few convenient definitions:

For readability sake (and to experiment with basic Rust features) I created constants REFERENCE_VOLTAGE and STEPS_12BIT so the following formula for converting an ADC reading to voltage would be self-explanatory:

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

/// 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
}

Obviously, that logic (if maybe a bit pedantic) belonged in the sensor module, as did the simple fn f_to_c(f: f32) -> f32 and fn c_to_f(c: f32) -> f32 functions to convert between Fahrenheit to Celsius and vice versa. But rather than leaving it at that and manually making conversions as appropriate for certain sensors, I wanted to try to do something a little more elegant. (And I wanted to play around with the Rust language more.)

So I started with the concept of a TemperatureScale and a TempReading:

#[derive(defmt::Format)]
pub enum TemperatureScale {
C,
F,
}

#[derive(defmt::Format)]
pub struct TempReading {
pub temp: f32,
pub scale: TemperatureScale,
pub sensor: &'static str,
}

(I’ve added the defmt::Format annotation so I can easily use the defmt logging system to debug my readings, rather than being forced to deconstruct the structures in my info!() calls.)

Here’s where I created my first “class” (ta da!) Well, I really it’s still just a structure, but we’ll add method implementations in a second. The idea is pretty basic: I felt like it made a lot of sense for the reading to retain information about which sensor it came from, and I wanted the temperature (a numeric value) to be paired with it’s original scale. Granted, all electronic devices tend to use Celsius natively, but if I use this same model for other things like barometric pressure, I didn’t want to fall under the trap of assuming all devices needed to be converted into the same base scale.

Let’s take a moment to talk about the type declaration pub sensor: &'static str. What is that all about? Well, I want the the TempReading.sensor field to refer to a string that describes the sensor in question. Since we’re working in Embedded Rust, we don’t have the standard alloc library for allocating bits of memory on the heap. (For a refresher, we talked about this in this section of part 6 of the blog series under the section Adding the Type Inclusions, and a discussion about Core.)

The &'static lifetime tells Rust that we’re going to have a reference to memory that is never ever going to go away. We essentially don’t have to check to see if the object is still allocated because we’re going to promise that it’s permanent. Okay, so how are we going have an untouchable permanent string in memory? Because the memory that holds the string is going to be the (static) string declaration in the codebase itself.

I’ll show this by jumping and creating some of my constructors in the the class implementation of TempReading

impl TempReading {
pub fn new_from_tmp36(adc_reading: u16) -> Self {
let voltage: f32 = adc_reading_to_voltage(adc_reading);
let c = (100.0 * voltage) - 50.0;
TempReading {
temp: c,
scale: TemperatureScale::C,
sensor: "TMP36",
}
}
pub fn new_from_internal(adc_reading: u16) -> Self {
let voltage: f32 = adc_reading_to_voltage(adc_reading);
let c: f32 = 27.0 - ((voltage - 0.706) / 0.001721);
TempReading {
temp: c,
scale: TemperatureScale::C,
sensor: "On-chip",
}
}
}

This is where I’m putting my code both for the TMP36 analog sensor and the built-in thermometer in the rp2040 microcontroller itself. These two functions work essentially the same way: you add the reading you took from the appropriate ADC pins, you use the common logic I’d just mentioned for converting a 12-bit ADC voltage reading into a voltage, and then each sensor had its different equation for converting the voltage into a reading in Celsius.

Note the line sensor: "TMP36" line for the TMP36 constructor. There’s the static string that I’m referring to. It’s actually defined in the codebase itself, so we’re just defining the sensor field to hold a reference to that place in RAM where the code resides. The Rust compiler does the magic necessary to turn that into an appropriate string slice.

I’m going to add a couple convenient class methods into the top of the TempReading implementation:

impl TempReading {
pub fn get_celsius(&self) -> f32 {
match self.scale {
TemperatureScale::C => self.temp,
TemperatureScale::F => f_to_c(self.temp),
}
}
pub fn get_fahrenheit(&self) -> f32 {
match self.scale {
TemperatureScale::C => c_to_f(self.temp),
TemperatureScale::F => self.temp,
}
}
.....
}

Now I have some logic so that, regardless of what temperature scale a TempReading object was constructed in, I can ask for a temperature in whatever scale I’m interested in.

Reading from the MCP9808 Sensor

Up to this point, I’ve been able to handle abstractions around simple floats that I assume were pulled from the 12-bit ADC circuitry. I’m going to tackle the MCP9808 sensor a little differently because we start treading into tricky waters—specifically the turbulent waters that had me back away from code abstraction when I first tried (and failed) to make it work.

If you look at the original sensor branch of the codebase, setting up the MCP9808 sensor device was really simple looking:

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

Getting a temperature reading was also pretty straightforward:

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

So my plan was to put both of these abstractions in the sensor.rs module. I figured I would write a method that take any properly configured i2c bus, construct a new MCP9808 object, configure it, and pass it back to the calling program.

I ended up doing that, but because this is type safe Rust, I had to get the type declarations right for the function declaration signature. In my let mut mcp9808 = MCP9808::new(i2c) line, I didn’t have to say what my new variable’s type was. The compiler just did that. Now you may say “What’s the big deal? The IDE will show you want the type class is.” But take a look at my IDE’s hinting…

VS Code screenshot

The challenge with this type hint is that the MCP9808 object’s type info includes exactly how the I²C bus was configured—specifically that it’s I2C0 as opposed to I2C1. And recall, the selection of the I²C bus is dependent on which GPIO pins you decided to use to connect the sensor.

I find this a little bit of a double-edged sword here. On one hand, these Embedded Rust libraries have used the strong type system to make sure it’s programmatically impossible to misconfigure your microcontroller. (At least you can’t try to create I2C0 from the wrong GPIO pins.) On the other hand, I can’t use this typing for write some abstracted generalized code in my modules… or at least I shouldn’t!

So it took a lot of experimentation to get the following method to compile properly:

pub fn new_mcp9808<I: embassy_rp::i2c::Instance>(i2c: I2c<I, Async>) -> MCP9808<I2c<I, Async>> {
let mut mcp9808 = MCP9808::new(i2c);
let mut mcp_9808_conf = mcp9808.read_configuration().unwrap();
mcp_9808_conf.set_shutdown_mode(ShutdownMode::Continuous);
mcp9808
}

I have to create the generic type I that will represent some sort of I2c Instance (I2C0 or I2C1) and then I can type the function parameter as I2c<I, Async> rather than being forced to say I2c<I2C0, Async> like the IDE’s type hinting was suggesting.

Now, it’s obviously worthwhile to move the sausage-making of the sensor code into its own module. This setup stuff will just distract from someone reading through main.rs, and it’s good to have code that is flexible regardless of your configuration. But as a beginner, I have to warn that the complexity of the Rust Embedded type system will take time to master. You’ll end up going through a lot of library source code, and the compiler does offer a lot of good suggestions while it’s giving you back errors.

That said, there is the ultimate reward that I’m being force to understand the type system better. I did want to stop writing stock example code and start learning the language more deeply, right?

Wrapping my sensor in a Result

This code refactoring looks relatively clean, but I said I was going to try to evict as many of the .unwrap() calls as possible. I don’t want panicky code bits all over the place—after all, this is Rust we’re working with! And if you’ve read my mini-series on Option and Result that I’d written, you won’t be surprised by the idea of creating a sort of Result<TempSensor,SensorConfigError> to encapsulate the possibility that the sensor isn’t working. This application has three temperature sensors after all; let’s gracefully accept the possibility that one has failed.

Now interestingly, the MCP9808 library exposes a counter-intuitive interface: you can instantiate your sensor with MCP9808::new(i2c) without any failure situation. It’s when you call .read_configuration() on it that you are presented with a Result<Configuration,Error<I2CError>. It sort of makes sense that the first time you run into trouble is when you try to contact the sensor. Anyway, our new_mcp9808 function is going to handle the configuration step in the background. If that fails, we want to percolate that failure up through our function signature, like this:

pub fn new_mcp9808<I: embassy_rp::i2c::Instance>(
i2c: I2c<I, Async>,
) -> Result<MCP9808<I2c<I, Async>>, Error> {
let mut mcp9808 = MCP9808::new(i2c);
match mcp9808.read_configuration() {
Ok(mut config) => {
config.set_shutdown_mode(ShutdownMode::Continuous);
Ok(mcp9808)
}
Err(e) => Err(Error::from(e)),
}
}

Or, if you’re like me and like the Functional Programming coding style, you’ll replace pattern matching with some map functions:

pub fn new_mcp98082<I: embassy_rp::i2c::Instance>(
i2c: I2c<I, Async>,
) -> Result<MCP9808<I2c<I, Async>>, Error> {
let mut mcp9808 = MCP9808::new(i2c);
mcp9808
.read_configuration()
.map(|mut c| {
c.set_shutdown_mode(ShutdownMode::Continuous);
mcp9808
})
.map_err(Error::from)
}

MCP9808 Reading

Okay, next for the function that actually gets a reading from the MCP9808 sensor. In the codebase, you’ll find this in the TempSensor

Let me jump to the function that reads from the mcp9808. First, let’s look at the original snippet inside the way-too-long main.rs loop:

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

In the original code I called .read_temperature() on the mcp9808 device, receiving. This returns a Result object who’s success type is a “Temperature” class with a single .get_celsius method that I pass the desired sensitivity to. (The higher the sensitivity, the longer it takes to get a reading.) I don’t really need to know anything about this Temperature class from the MCP9808 crate other than the fact that it was returned from .read_temperature and when I call .get_celsius on it, I’ll get a simple float back.

Now let’s look at my refactored code.

pub fn read_mcp9808<I: embassy_rp::i2c::Instance>(
sensor: &mut MCP9808<I2c<I, Async>>,
) -> Result<TempReading, Error> {
sensor
.read_temperature()
.map(|mcp_reading| TempReading {
temp: mcp_reading.get_celsius(ResolutionVal::Deg_0_0625C),
scale: TemperatureScale::C,
sensor: "MCP9808",
})
.map_err(Error::from)
}

Instead of a simple float representing degrees celsius, I’m returning my new TempReading class where I’m annotating the scale (degrees Celsius) and the name of the sensor the reading came from. Instead of lazily calling .unwrap on the returned reading like before, I’m keeping the Result structure intact. Again, if you’ve read my mini-series, you may start to appreciate how in this situation, the FP-style function chaining via .map keeps things clean and readable:

  1. We start with the sensor and call .read_temperature() on it, preserving the potential error situation in the returned Result.
  2. If the reading was successful, we want to replace the old “untyped” temperature reading (that used to be just a float) with our new TempReading class.
  3. We will convert the weird MCP9808 specific error class with our new clean, abstracted Error.

Constructing the JSON Telemetry Message

This step was the one bit of new functionality introduced in the refactoring exercise. Recall that the sensor branch of my project collected sensor readings and displayed them as debug messages. The networking branch established a connection to IoT edge telemetry server and send sample “test” messages to it. Now that I’ve merged the branches together, I want to send the sensor readings to the server rather than a silly “test” message.

In order to do that, we’re going to have to lean on the Heapless crate to build our JSON string without access to alloc-backed string modules since we live in no_std-land.

To keep my code nice and general, I created a function in the sensor.rs module that takes any generalized collection of sensor readings, and iteratively writes them to a string buffer:

pub fn format<const N: usize, T: IntoIterator<Item = Result<TempReading, Error>>>(
s: &mut String<N>,
readings: T,
) -> Result<(), Error> {
s.clear();
s.push('{').map_err(|_| Error::FormattingError)?;

let fmt_success: bool = readings
.into_iter()
.flatten() // Only take the Ok<TempReading> values
.map(|r| {
write!(s, " {s} : {t:.*},", 2, s = r.sensor, t = r.get_fahrenheit()).is_ok()
// if an error occurred, convert any errors into Option<E>
}) // flat_map converts the iterator of readings into an iterator only of the errors.
.all(identity); // each success = true, so this assumes all write ops were successes

let _ = s.pop(); // Drop the trailing comma that we know is at the end, so we can replace with the final "}"
let fmt_success = fmt_success && s.push_str(" }").is_ok();
if fmt_success {
Ok(())
} else {
Err(Error::FormattingError)
}
}

In the Heapless crate, string objects must be defined with their exact size in the type signature. In main.rs we’re going to instantiate an object with 80 bytes of allocated length. Even though this function is just getting a reference (that is, a pointer) to this object, we have to address this is the type signature, thus the generic <const N: usize> so we can specify our string reference is s: &mut String<N>. (Again, I’ve just got to say, the hardest part of refactoring code in Rust is all the typing you’re forced to figure out and declare in any functions you write! Yes, the compiler gives lots of helpful suggestions, but it can be a bit of a slog for the beginning developer.)

For this function, I need to work with a lot of objects (the readings) while carefully not doing a lot of the typical string manipulation one would normally use the standard library strings for. So to create the JSON, I’m going to work additively. I start by using .push to write the opening brace. Then for every sensor reading, I’m going to use the .write! macro to format each TempSensor reading as the appropriate sensor-name / °F key-value pair. The funny {s}: {t:.*} format has us write the name (s) literally while specifying that t will be a formatted number with some number of digits right of the decimal. That value of 2 that’s passed immedately afterwards is that number of digits. It’s a little odd, but it’s also remarkable what hacking can be done with compiler macros! (Remember, for efficiency, we’re not including elaborate C-style “fprint” libraries that would eat up precious memory on our microcontrollers!) At the end of every TempSensor reading, we’re including the comma that’s going to separate the reading from the next one.

At the end of the process, we know we’ve got one too many commas, so we “eat” the character with .pop and finally .push(" }") the end of the JSON bracket.

You’ll notice some of my FP-trickery in the mix. I’m using .flatten on my iterator to convert my collection of Result<TempReading,Error> into simply an iterator on the Ok() TempReading values. I’m using .map to write out each value but then also cleverly convert that resulting Result into a boolean indicating success with an is_ok() on the end. By doing this all this chaining, I’ve effectively created a functional recipe for what I want to do—filter out bad TempReadings, write to the string, and convert into a boolean if successful.

Since I’m working with an iterator rather than a collection, all these mappings still result in an iterator of booleans. If I stopped there after the last .map operation, this code would compile and run but I would never build the string because I haven’t iterated across the iterator yet! I haven’t called .next() or run a for loop across it. But at the end I magically fold the iterator by calling .all(identity) which essentially says “I want to check that all of the final booleans were true.” I could have also written that as .all(|b| b), but where’s the elegance in that?

There’s some other subtle trickery in here going on. Notice the line that I wrote early on:

s.push('{').map_err(|_| Error::FormattingError)?;

The .push() function returns a Result<(),()> indicating success or failure. I use .map_err to convert that empty error value into one of our custom error values, and then I use that question-mark operator to start that process where all the subsequent lines of code focus on the “happy path”. As a consequence, I’m forced to make sure my function returns the expected Result type indicating success or failure.

I’m pretty sure the only thing that could go wrong in this function would be that the push or write commands caused me to exceed the size of the string buffer I had created, so if any error transpires, I need to percolate that down the chain so the calling function knows the string buffer doesn’t have a usable JSON value.

Now, it wouldn’t be hard to write this function differently, using a for loop since we’ve been passed a nice generic iterator. I could initialize a fmt_success mutable variable starting with true and short-circuiting things if my write!() call resulted in any errors. I could simply call an early exit from my function with return false or return Err(Error::FormattingError).

For me, it’s easier to use my approach to carefully reason that the unhappy paths are being handled and cascaded properly. There’s probably an in-between path using nested pattern matching to write the logic.

Using our new Sensor Module

Back in our main.rs, the code that uses the sensors has gotten a bit cleaner. The setup is really just four lines of code, one for the MCP9808, one for the ADC bus in general, and then the two individual channels that we’re using to take voltage readings on the TMP36 and the on-chip internal sensor:

// Setup MCP9808 Temperature Sensor

let mut mcp9808 = new_mcp9808(i2c);

// 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);

In the main loop, the code to read the two ADC voltages is pretty straightforward:

let chip_reading = adc
.read(&mut temp_channel)
.await
.map(TempReading::new_from_internal)
.map_err(Error::from);

let tmp36_reading = adc
.read(&mut adc_channel_pin26)
.await
.map(TempReading::new_from_tmp36)
.map_err(Error::from);

We’re just reading the voltage from the respective channels and converting the 12-bit digital value to our newly abstracted TempChannel objects, converting any ADC errors into our application error class. The code for the MCP9808 is relatively similar now:

let mcp9808_reading = mcp9808
.as_mut()
.map_err(|e| *e)
.and_then(TempReading::read_mcp9808);

All of these sensor readings are now Result<TempReading,Error> so creating the JSON telemetry packet can be succinctly written like this:

let mut json: String<80> = String::new();
let readings = [chip_reading, tmp36_reading, mcp9808_reading];

let format_ok = format(&mut json, readings.into_iter());

In the code, I also added something to log the sensor readings:

for r in &readings {
match r {
Ok(t) => info!("Read {} at {} °F - {}", t.sensor, t.get_fahrenheit(), t),
Err(_) => error!("Sensor reading error"),
}
}

(I stuck that before the call to format, but am showing this to you separately because I want to impress upon you the simplicity our implementation code: just 3 lines!)

That’s it for today!

This article has once again hit the limit of what I consider to be readable. If you’ve stuck with me to this point, hopefully you’ve picked up on a few things. I’m probably not going to write an article about abstracting the networking part of the code. That would get tedious. I’ll just do it, and when I’m finished, I’ll post a link to the github tag that embodies the whole new kitten caboodle.

We’ll see what I try to add next. I would love to abstract some hardware error “recovery and retry” tactics. Or I might play around with low power modes.

--

--

Murray Todd Williams
Murray Todd Williams

Written by Murray Todd Williams

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

No responses yet