Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rust Embedded Drivers (RED) Book

In this book, we will learn how to create simple drivers for hardware devices that work within the embedded Rust ecosystem.

Prerequisites

  • Rust basics: You should have a basic understanding of Rust. This book doesn't cover the fundamentals of the language. If you're new to Rust, I recommend starting with the official Rust book. You can also find other resources here.

This book is slightly more advanced than the others in the "impl Rust for" series and is intended for readers who already have some basic experience with embedded Rust.

If you're a beginner and haven't read the other books yet, you may want to start with one of the following:

Other Learning Resources

  • The Embedded Rust Book : This is a great resource if you're just getting into embedded Rust. You don't have to read it before jumping into this book, but it's a good place to start. I'll do my best to explain things as we go, but if I miss something or don't cover it clearly, this book can really come in handy. One way or another, I definitely recommend giving it a read.

License

The "Rust Embedded Drivers" book(this project) is distributed under the following licenses:

  • The code samples and free-standing Cargo projects contained within this book are licensed under the terms of both the MIT License and the Apache License v2.0.
  • The written prose contained within this book is licensed under the terms of the Creative Commons CC-BY-SA v4.0 license.

Support this project

You can support this book by starring this project on GitHub or sharing this book with others 😊

Disclaimer:

The experiments and projects shared in this book have worked for me, but results may vary. I'm not responsible for any issues or damage that may occur while you're experimenting. Please proceed with caution and take necessary safety precautions.

Writing an Embedded Rust Driver for the DHT22

The DHT22, also known as AM2302, is a digital sensor used for measuring both temperature and humidity. If you're building a device that needs to detect environmental conditions like room temperature or humidity levels, this sensor is a great choice. You can easily purchase it online from stores at an affordable price. The older version is DHT11 which is slighly less accurate than DHT22.

The DHT22 uses a capacitive humidity sensor and a thermistor to measure the surrounding air. It then sends this data as a digital signal over a single data line, so you do not need any analog input pins.

DHT22 Sensor

Here are some key features of the DHT22:

  • Measures humidity from 0 to 100 percent RH with 2 to 5 percent accuracy
  • Measures temperature from -40 to +80 degrees Celsius with around ±0.5 degree accuracy
  • Communicates over a single-wire digital protocol

In this chapter, we will learn how to write a driver for this sensor. I chose the DHT22 because it uses a simple communication method, making it suitable for learning how to write embedded Rust drivers. It is also low-cost and widely available.

Note: While we're building our own driver from scratch for educational purposes, there are already existing crates available for the DHT22 sensor, such as dht-sensor. You can also explore those crates.

DHT22 Sensor and Module

The DHT22 is available in two forms: the bare sensor and a ready-to-use module. The bare sensor comes in a 4-pin package, but only three of those pins are needed. The module version is typically provided with just three pins, which are the ones actually needed for operation.

  1. VCC - Connects to 3.3V or 5V
  2. Data - Signal pin connected to a GPIO (pull-up resistor is already present on the board)
  3. GND - Ground

One important difference between the DHT22 module and the bare sensor is the presence of an onboard pull-up resistor. On the module, this resistor is already connected between the data line and VCC, ensuring that the line reliably returns to a HIGH state when neither the microcontroller nor the sensor is actively pulling it LOW. This is essential for correct timing and stable communication.

If you're using the raw 4-pin DHT22 sensor (without a breakout board), it does not include a pull-up resistor. In that case, you'll need to add an external resistor (typically 10kΩ) between the data pin and VCC to ensure proper operation.

How the DHT22 Sensor Communicates Over a Single Wire

The DHT22 communicates with the microcontroller using a custom single-wire protocol. It uses only one data line for transferring data. The microcontroller must follow strict timing rules to read the temperature and humidity data correctly from the sensor.

So, where do we get these details? From the datasheet. If you are moving beyond basic examples in embedded programming, learning how to read datasheets is an important skill.

To be honest, the first time I looked at a microcontroller datasheet, it felt scary. Even now, I still find them a bit overwhelming - just slightly less than before. But once you start using them regularly, it gets easier. The good thing is that sensor datasheets are usually much simpler compared to full MCU datasheets.

You can find the DHT22 datasheet here. It describes how the communication between the DHT22 sensor and the microcontroller works. But I have to admit, the explanation is a bit cryptic and not very beginner-friendly. So, I will try to break it down step by step and explain the communication process in a simpler way.

Communication Overview

Here's a high-level view of the communication between the MCU and the DHT22:

+-----------+           +--------+
|   MCU     |           | DHT22  |
+-----------+           +--------+
      |                      |
      |--- Start Request --->|
      |                      |
      |<---- Response -------|
      |                      |
      |<-- 40-bit Data ------|
      |                      |
      |<- Transmission Ends -|
      |                      |
      |   Idle / Processing  |
      |                      |

The diagram above shows the high-level flow of communication between the microcontroller (MCU) and the DHT22 sensor. The process begins with the MCU sending a start request by pulling the data line low for a specified duration. This signals the DHT22 to begin its response. The sensor then replies with a short response signal, indicating it is ready to transmit data.

Following that, the DHT22 sends 40 bits of data which include humidity, temperature, and a checksum for validation. Once all bits are transmitted, the sensor sends an end signal and then releases the line. The communication ends with the bus returning to an idle high state.

Communication Sequence

The diagram below shows how the MCU and the DHT22 sensor talk to each other using just one data wire. This communication follows a specific pattern. If the timing is wrong, the data might not be received correctly.

DHT22 Sensor
  1. Idle State
    The data line is initially in an idle HIGH state, held high by a pull-up resistor.

  2. Start Signal (MCU to DHT22)
    The microcontroller begins communication by pulling the line LOW for at least 1 ms. This tells the DHT22 to prepare for data exchange.

  3. MCU Release (20-40 us HIGH)
    After the LOW signal, the MCU releases the line. It goes HIGH for 20 to 40 microseconds, indicating that the MCU is ready to receive a response.

  4. Sensor Response (DHT22 to MCU)
    The DHT22 responds by pulling the line LOW for 80 us, then HIGH for 80 us. This signals the beginning of the sensor's data transmission.

  5. Data Transmission - 40 Bits
    The sensor sends a total of 40 bits of data, which are divided into three parts: 16 bits represent the humidity value, 16 bits represent the temperature value, and the remaining 8 bits are used as a checksum to verify data integrity.

    Each bit starts with a 50 us LOW pulse. After that:

    • If the line is HIGH for ~26-28 us, the bit is a 0
    • If the line is HIGH for ~70 us, the bit is a 1
  6. End of Transmission
    After sending all 40 bits, the DHT22 pulls the line LOW for about 50 us, then releases it. The line goes HIGH and remains idle.

Data Bits

Once the DHT22 completes its initial response, it sends a total of 40 bits of data to the microcontroller. These 40 bits are structured as five consecutive bytes, transmitted one bit at a time over the single-wire bus. The data is sent most significant bit (MSB) first.

Relative Humidity Value

The first two bytes represent the humidity value. The first byte is the high byte (most significant 8 bits) and the second is the low byte. Together, they form a 16-bit unsigned integer. To convert this to a percentage, the value must be divided by 10. For example, if the bytes are 0x01 and 0x90, the raw 16-bit value is 0x0190, which equals 400 in decimal. Dividing by 10 gives a humidity reading of 40.0%.

Temperature Value

The next two bytes represent the temperature, again as a 16-bit value with the high byte first. This value is also divided by 10 to get the temperature in Celsius. For instance, if the temperature bytes are 0x00 and 0xFA, the combined value is 0x00FA or 250 in decimal, giving a temperature of 25.0°C. If the most significant bit of the temperature high byte is set (i.e., bit 15 is 1), it indicates a negative temperature. In that case, the temperature value should be interpreted as a signed 16-bit number, and the final result divided by 10 will be negative.

Checksum

The fifth and final byte is a checksum. This is used to verify that the data was received correctly. The checksum is calculated by summing the first four bytes and taking only the least significant 8 bits of the result (i.e., modulo 256). If the checksum sent by the sensor matches the calculated value, the data is considered valid.

Here is an example of a full 40-bit transmission represented in ASCII bit layout:

+--------+--------+--------+--------+--------+
|00000001|10010000|00000000|11111010|10001011|
+--------+--------+--------+--------+--------+
HumidH   HumidL   TempH    TempL     CRC

(0x01)   (0x90)   (0x00)   (0xFA)   (0x8B)

In this example, the humidity is 40.0%, the temperature is 25.0°C, and the checksum byte 0x8B matches the calculated sum: 0x01 + 0x90 + 0x00 + 0xFA = 0x18B.

Since the checksum is only one byte (8 bits), we only care about the lower 8 bits of this sum. To extract them, we apply a bitwise AND with 0xFF, which keeps just the least significant byte: 0x18B & 0xFF = 0x8B. This result matches the checksum sent by the sensor; on the microcontroller side(in our driver), we can perform this check to ensure the data was received correctly.

In Rust, instead of summing the values as a larger type like u16 and then applying a bitwise AND with 0xFF, we can just use wrapping_add function, which automatically keeps the result within 8 bits by discarding any overflow. Since our incoming data is already u8, using wrapping_add is more efficient and better suited for our case.

For example:

fn main() {
    let data: [u8; 4] = [0x01, 0x90, 0x00, 0xFA];
    let expected_checksum: u8 = 0x8B;

    let calculated_checksum = data.iter().fold(0u8, |sum, v| sum.wrapping_add(*v));
    println!("calculated checksum: 0x{:02X}", calculated_checksum);
    println!("is valid: {}", expected_checksum == calculated_checksum);
}

Handling Negative Temperatures

The DHT22 encodes negative temperatures using the most significant bit (MSB) of the 16-bit temperature value as a sign bit. If this bit is set (i.e., bit 15 of the 16-bit value is 1), the temperature is negative. To extract the correct value, you must first detect the sign, then mask off the sign bit and apply the negative sign after conversion.

For example, suppose the DHT22 returns the following two bytes for temperature:

Temp High Byte: 10000000 (0x80)
Temp Low Byte:  00001010 (0x0A)

Combined, these form the 16-bit value 0x800A. The MSB is 1, indicating a negative temperature.

To compute the temperature:

Mask off the sign bit: 0x800A & 0x7FFF = 0x000A

Mask off sign bit calculation represented with binary for better clarity

   10000000 00001010   (original, 0x800A)
&  01111111 11111111   (mask,     0x7FFF)
----------------------
   00000000 00001010   (result,   0x000A)

Convert to decimal: 0x000A = 10

Divide by 10: 10 / 10 = 1.0

Apply the sign: -1.0 °C

So, this bit sequence indicates a temperature of –1.0 °C.

Create Project

Now that we have a solid understanding of the DHT22 sensor's communication protocol and data format, let's begin implementing the driver in Rust for a no_std environment.

We'll start by creating a new Rust library crate. This crate will be minimal and portable, relying only on embedded-hal traits so it can run on any platform that implements them.

Create new library

cargo new dht22-sensor --lib
cd dht22-sensor

This creates a basic Rust library named dht22-sensor with a src/lib.rs entry point.

Mark the Crate as no_std

To make our library compatible with embedded systems that don't have access to the standard library, we need to mark it as no_std.

Open the src/lib.rs file, remove all lines and add the following line at the very top:

#![allow(unused)]
#![cfg_attr(not(test), no_std)]
fn main() {
}

This tells the Rust compiler to avoid linking the standard library when we are not running tests. Instead, the crate will rely only on the core crate, which provides essential Rust features like basic types and operations. As a result, we won't be able to use any features from the std crate - only what's available in core.

The cfg_attr is a conditional attribute. It means "if not testing, then apply no_std." This way, we can still write normal unit tests using std, but the compiled library will work in no_std environments.

Add Dependencies

Next, open Cargo.toml and add the embedded-hal crate under [dependencies]. This allows your driver to work with any compatible GPIO or delay implementation:

[dependencies]
embedded-hal = "1.0.0"

Optional: defmt Logging Support

We also add optional defmt support so that our driver can integrate with the defmt logging ecosystem.

To enable this, users must explicitly activate the defmt feature.

Add the following to your Cargo.toml:

Defmt

defmt = { version = "1.0.1", optional = true }

[features]
defmt = ["dep:defmt"]

Goal

By the end of this tutorial, we will have the following file structure for our DHT22 driver. While this is a relatively simple driver and could be implemented entirely in lib.rs, we prefer a more modular structure for clarity and maintainability.

The lib.rs file is the main entry point of a Rust library crate. When someone adds your crate as a dependency and uses use your_crate::..., the items they access come from what you expose in lib.rs. In our case, lib.rs will include and publicly re-export the dht22 module and the error module, making them accessible to users of the crate. Think of it as the public API surface of your driver.

.
├── Cargo.toml
└── src
    ├── dht22.rs
    ├── error.rs
    └── lib.rs

Completed Project

If you ever get stuck or feel unsure about how everything fits together, you can refer to the complete working project I have created for this chapter. You'll find it on GitHub at implferris/dht22-sensor. It contains all the source code, tests, and structure exactly as discussed here. Feel free to explore it and compare with your own implementation.

Error Handling

The DHT22 sensor communication is time-sensitive and error-prone. To deal with possible failures, we define a custom DhtError enum that describes what can go wrong during a sensor read.

Let's create the error module. Add the following line in lib.rs:

#![allow(unused)]
fn main() {
pub mod error;

// re-export the DhtError for library users
pub use error::DhtError;
}

Contents of error.rs file

Create an error.rs file inside the src folder and add the following code:

#![allow(unused)]
fn main() {
/// Possible errors from the DHT22 driver.
#[derive(Debug, PartialEq, Eq)]
pub enum DhtError<E> {
    /// Timed out waiting for a pin state change.
    Timeout,
    /// Checksum did not match the received data.
    ChecksumMismatch,
    /// Error from the GPIO pin (input/output).
    PinError(E),
}

impl<E> From<E> for DhtError<E> {
    fn from(value: E) -> Self {
        Self::PinError(value)
    }
}
}

The DhtError enum represents three different kinds of errors that can occur during sensor communication.

  • Timeout: If the sensor does not respond within the expected time during any stage of communication, we return DhtError::Timeout. This can happen, for example, when we wait for the sensor to pull the data line low or high but it never happens.

  • ChecksumMismatch: After receiving the 5 bytes of data, we calculate the checksum from the first 4 bytes and compare it with the checksum byte sent by the sensor. If they do not match, we return DhtError::ChecksumMismatch. This typically means that some bits were corrupted during transmission.

  • PinError: Sometimes, GPIO operations themselves can fail. For example, setting a pin high or low, or reading its level, may return an error depending on the HAL used. We wrap such errors using DhtError::PinError so that they can be reported along with the rest.

We also implement From for DhtError. This allows any pin-level error to be automatically converted into our error type. It enables us to use the ? operator when calling GPIO methods inside the driver, without having to convert the error manually every time.

Driver Implementation

We will now create the dht22 module and start implementing the driver logic inside it.

This module will contain the core functionality for communicating with the DHT22 sensor, including sending the start signal, reading bits, verifying the checksum, and returning the temperature and humidity data using the Reading struct.

To begin, add the following line in your lib.rs file to include the dht22 module:

#![allow(unused)]
fn main() {
pub mod dht22;

// re-export the Dht22 struct and Reading struct for library users
pub use dht22::{Dht22, Reading};
}

This makes the Dht22 driver and the Reading struct available to users of your library as your_crate::Dht22 and your_crate::Reading, instead of requiring them to manually access the submodule.

Define the Reading Struct

Before we implement the driver logic, let's start with what we expect out of our module. The final goal of the driver is to return sensor readings.

We will define a simple Reading struct to hold the output values. It contains two fields: temperature (in Celsius) and relative humidity (as a percentage). This is what the user of our library will receive when they call the read method.

Create this inside your dht22.rs file:

#![allow(unused)]
fn main() {
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Reading {
    /// Temperature in degrees Celsius.
    pub temperature: f32,
    /// Relative humidity in percent.
    pub relative_humidity: f32,
}
}

We use some useful traits like Clone, Copy, Debug, and PartialEq to make the Reading struct easy to work with. Along with these, we also derive defmt::Format, but only when the user enables the "defmt" feature. This allows the struct to be logged using the defmt crate. If the feature is not enabled, the derive is skipped automatically.

Define the Dht22 Struct

The Dht22 struct is the core of our driver. It stores the pin used to communicate with the sensor and a delay object used to control timing.

The DHT22 sensor uses a single-wire data line, which means the pin must switch between output mode (to send the start signal) and input mode (to read the response). That's why our "PIN" type must implement both the InputPin and OutputPin traits. Similarly, we need sub-millisecond delays to follow the timing requirements of the DHT22 protocol, so we use a delay object that implements the DelayNs trait.

Let's start by importing the required traits from embedded-hal:

#![allow(unused)]
fn main() {
use embedded_hal::{
    delay::DelayNs,
    digital::{InputPin, OutputPin},
};
}

Now define the struct:

#![allow(unused)]
fn main() {
pub struct Dht22<PIN, D> {
    pin: PIN,
    delay: D,
}
}

This struct is generic over the types "PIN" and "D". As mentioned, the pin must support both input and output modes, and the delay must support fine-grained timing.

Finally, we implement a simple constructor:

#![allow(unused)]
fn main() {
impl<PIN, DELAY, E> Dht22<PIN, DELAY>
where
    PIN: InputPin<Error = E> + OutputPin<Error = E>,
    DELAY: DelayNs,
{
     pub fn new(pin: PIN, delay: DELAY) -> Self {
        Dht22 { pin, delay }
    }

}
}

This allows the user to create a new instance of the driver by passing a pin and a delay object. The trait bounds ensure that the types they use can perform the required operations.

Timing Utilities for Pin State Changes

Before we implement the DHT22 communication protocol, we need a few utility functions to handle precise timing and pin state monitoring. These functions help us wait for the data line to go high or low, with a timeout to avoid getting stuck if the sensor becomes unresponsive.

The TIMEOUT_US constant defines the maximum number of microseconds to wait before giving up. It is used by the helper function to avoid waiting forever when the sensor does not switch to the expected pin state.

#![allow(unused)]
fn main() {
const TIMEOUT_US: u8 = 100;
}

All the functions shown below will be part of the Dht22 struct implementation, including the ones we will define in the next chapter.

Next, we define a generic helper that waits for a pin to reach a specific state:

#![allow(unused)]
fn main() {
fn wait_for_state<F>(delay: &mut DELAY, mut condition: F) -> Result<(), DhtError<E>>
where
    F: FnMut() -> Result<bool, E>,
{
    for _ in 0..TIMEOUT_US {
        if condition()? {
            return Ok(());
        }
        delay.delay_us(1);
    }
    Err(DhtError::Timeout)
}
}

This function retries the given condition for up to TIMEOUT_US microseconds. If the condition becomes true within that time, it returns Ok(()). Otherwise, it returns a timeout error. The condition is a Rust closure, which makes it easy to pass different pin checks like is_high() or is_low().

Now we define two simple wrappers that wait for the data line to go high or low:

#![allow(unused)]
fn main() {
fn wait_for_high(&mut self) -> Result<(), DhtError<E>> {
    Self::wait_for_state(&mut self.delay, || self.pin.is_high())
}
}
#![allow(unused)]
fn main() {
fn wait_for_low(&mut self) -> Result<(), DhtError<E>> {
    Self::wait_for_state(&mut self.delay, || self.pin.is_low())
}
}

These functions call wait_for_state and pass the appropriate closure. They wrap the is_high() and is_low() methods and help make the main protocol code more readable.

In simple words, it helps us wait until the pin becomes high or low, but only for a short time. If the pin doesn't change in time, we stop and return an error. This way, the program doesn't hang if the sensor stops responding.

Writing Tests for Embedded Rust Using Embedded HAL Mock

Before moving further into the chapter, let me introduce a nice crate: embedded-hal-mock. This crate lets us mock the Embedded HAL traits. The implementations don't touch any real hardware. Instead, they use mock or no-op versions of the traits.

The idea is simple: we can write and run tests for our drivers, even in CI, without needing real hardware. We'll use this crate to test our DHT22 driver.

Dev Dependency

To use embedded-hal-mock for testing, we'll add it as a dev-dependency in Cargo.toml. A dev-dependency is a crate that's only needed while testing or building examples. It won't be included when someone uses your library as a dependency.

Add this lines to your Cargo.toml:

[dev-dependencies]
embedded-hal-mock = { version = "0.11.1", default-features = false, features = [
    # eh1: Provide module eh1 that mocks embedded-hal version 1.x (enabled by default)
    "eh1",
] }

We are using embedded-hal 1.x, so we'll enable the "eh1" feature.

With embedded-hal-mock, we can mock things like pin states, I2C, SPI, PWM, delay, and serial. For our driver, we'll be using the pin and delay mocks.

Writing Our First Test

Let's write our first test using embedded-hal-mock. This test checks if our wait_for_high and wait_for_low helpers work as expected. We simulate a pin that stays low for two cycles before going high, then goes low again. The delay_us calls simulate tiny pauses between checks.

Final code for the first test is shown below. You should place this inside your dht22.rs module. Don't worry if it looks overwhelming right now. In the upcoming section, we will go through it step by step and explain how everything works.

#![allow(unused)]

fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use embedded_hal_mock::eh1::delay::CheckedDelay;
    use embedded_hal_mock::eh1::delay::Transaction as DelayTx;
    use embedded_hal_mock::eh1::digital::{
        Mock as PinMock, State as PinState, Transaction as PinTx,
    };
    
    #[test]
    fn test_wait_for_state() {
        let mut expect = vec![];

        expect.extend_from_slice(&[
            // pin setting high
            PinTx::set(PinState::High),
            // wait_for_high
            PinTx::get(PinState::Low), // Triggers Delay 1us
            PinTx::get(PinState::Low), // Triggers Delay 1us
            PinTx::get(PinState::High),
            // wait_for_low
            PinTx::get(PinState::Low),
        ]);

        let mut pin = PinMock::new(&expect);
        pin.set_high().unwrap();

        let delay_transactions = vec![DelayTx::delay_us(1), DelayTx::delay_us(1)];
        let mut delay = CheckedDelay::new(&delay_transactions);

        let mut dht = Dht22::new(pin.clone(), &mut delay);
        dht.wait_for_high().unwrap();
        dht.wait_for_low().unwrap();

        pin.done();
        delay.done();
    }
}
}

Mocking Transactions

One of the reasons I gave you the full test code earlier was to give you a clear picture of what we’re aiming for.

In this section, we will focus on understanding PinTx and DelayTx (these are just aliases for Transaction types). In embedded-hal-mock, a transaction is simply a list of expected actions that we think our code will perform.

Here are some examples:

  • PinTx::set(PinState::High) means we expect the code to call set_high() on the pin.

  • PinTx::get(PinState::Low) means we expect the code to check the pin's state, and we will return Low from the mock. If we want to return High, we can mock it with PinTx::get(PinState::High)

  • DelayTx::delay_us(1) means we expect the code to wait for 1 microsecond.

Understanding the Sequence

Let's take a closer look at this important sequence of code: it first sets the pin high, then waits for the pin to become high, and finally waits for it to become low.

#![allow(unused)]
fn main() {
Code                         |  Mock Expectations
-----------------------------|-------------------------------
pin.set_high().unwrap();     |  PinTx::set(PinState::High)
                             |
dht.wait_for_high().unwrap();|  PinTx::get(PinState::High)
                             |
dht.wait_for_low().unwrap(); |  PinTx::get(PinState::Low)
}

Here's the fun part: we can control how the mock behaves for wait_for_high() and wait_for_low(). It's up to us whether we return the expected pin state immediately or introduce a delay. Sounds a bit confusing? I know - it took me a while to fully get it too. Take your time, try out different setups, and experiment.

To help you understand better, let's walk through two scenarios: one where the expected state is returned immediately (the "happy path"), and another with a delay.

Happy Path

In this scenario, we immediately return the expected pin state. That means there will be no delay.

#![allow(unused)]
fn main() {
Driver Code                      -->  Mock Response
-----------------------------------------------------------
pin.set_high().unwrap();         -->  PinTx::set(High)

dht.wait_for_high().unwrap();    -->  PinTx::get(High) ✅
                                     (No delay needed)

dht.wait_for_low().unwrap();     -->  PinTx::get(Low) ✅
                                     (No delay needed)
}

So, we will define the vector of expected pin transactions like this:

#![allow(unused)]
fn main() {
let mut expect = vec![];
expect.extend_from_slice(&[
    // pin setting high
    PinTx::set(PinState::High),
    // wait_for_high -> we give it high immediately
    PinTx::get(PinState::High),
    // wait_for_low -> we give it low immediately
    PinTx::get(PinState::Low),
]);

// Initialize the Pin Mock
let mut pin = PinMock::new(&expect);
}

Since in this happy path we immediately get what we want, there will be no delay (i.e. the helper function wait_for_state never calls delay_us), so we will keep the delay transactions vector empty.

#![allow(unused)]
fn main() {
let delay_transactions = vec![];

// Initialize the Delay Mock 
let mut delay = CheckedDelay::new(&delay_transactions);
}

Finally, we assert that both the pin and delay mocks completed as expected:

#![allow(unused)]
fn main() {
pin.done();
delay.done();
}

This test will pass without issue.

Delayed Path

In a real scenario, the pin is not going to immediately change the state. As you already know, the DHT22 protocol uses timing to transmit bits, so we should expect some delays.

Now we slightly tweak our previous test. Instead of giving the High state immediately when wait_for_high runs, we simulate two failed attempts (i.e., we give it two Low states first). This means our function will poll the pin multiple times before finally getting the expected High.

#![allow(unused)]
fn main() {
let mut expect = vec![];
expect.extend_from_slice(&[
    // pin setting high
    PinTx::set(PinState::High),
    // wait_for_high
    PinTx::get(PinState::Low), // 1st try, not high
    PinTx::get(PinState::Low), // 2nd try, still not high
    PinTx::get(PinState::High), // expected state: high
    // wait_for_low -> we give it low immediately
    PinTx::get(PinState::Low),
]);
}

And here's how the sequence plays out:

#![allow(unused)]
fn main() {
Driver Code                      -->  Mock Response
-----------------------------------------------------------
pin.set_high().unwrap();         -->  PinTx::set(High)

dht.wait_for_high().unwrap();    -->  PinTx::get(Low) ❌ // we return Low, so driver delays
                                      DelayTx::delay_us(1)
                                 -->  PinTx::get(Low) ❌ // we still give Low, so driver delays again
                                      DelayTx::delay_us(1)
                                 -->  PinTx::get(High) ✅ // finally we return High

dht.wait_for_low().unwrap();     -->  PinTx::get(Low) ✅
                                     (No delay needed)
}

If you try to run the test with only the pin transactions updated, it will fail. That's because we're not giving the expected state immediately, so the driver will call delay; but our delay mock isn’t expecting that yet.

So let's fix that by updating the delay transactions:

#![allow(unused)]
fn main() {
let delay_transactions = vec![
    DelayTx::delay_us(1), 
    DelayTx::delay_us(1)
];
let mut delay = CheckedDelay::new(&delay_transactions);
}

With this change, the test will now pass. You can now start tweaking the values, try adding more delay entries or changing them to see how the behavior changes. That's the best way to understand how it all fits together.

Reading Data

The user of our driver will basically call dht.read() to get the sensor data. This function returns a Reading struct that contains both temperature and humidity values.

When you call read(), it first sends a start signal to the sensor. Then it waits for the DHT22 to respond. After that, it reads 5 bytes from the sensor. This reading happens through the read_byte() function, which in turn reads 8 individual bits using read_bit().

Once all the bytes are received, the driver checks whether the checksum (5th byte) matches the sum of the first 4 bytes. If the data is valid, it then parses the values and returns the final Reading.

Basically, these are the functions that read() will eventually call internally:

#![allow(unused)]
fn main() {
read() --> start()
       --> read_bytes() --> uses read_bit()
       --> checksum check
       --> parse_data() --> Reading
}

We will not define the read function yet. We will define the dependent functions first. That way, we can run tests on them directly. Otherwise, we would have to put todo!() or deal with compilation errors. Instead, we finish each of these individual parts and finally integrate them together in the read() function.

Start Sequence

Before the sensor sends any data, the microcontroller must initiate the communication by sending a start signal. This is done by pulling the data line low for at least 1 ms, then releasing the line so it returns to a high state, and waiting briefly(20-40 us). After that, the DHT22 will respond with a specific low-high pattern, confirming it's ready to send data.

Here's how we implement that logic in the start() function:

#![allow(unused)]
fn main() {
fn start(&mut self) -> Result<(), DhtError<E>> {
    // MCU sends start request
    self.pin.set_low()?;
    self.delay.delay_ms(1);
    self.pin.set_high()?;
    self.delay.delay_us(40);

    // Waiting for DHT22 Response
    self.wait_for_low()?
    self.wait_for_high()?; 
    Ok(())
}
}

Testing the Start sequence

Inside the mod tests, we will first define a helper function. This returns the expected pin transactions for the start sequence. Since we will use this same pattern in multiple tests, it makes sense to extract it once here.

#![allow(unused)]
fn main() {
 fn start_sequence() -> Vec<PinTx> {
    vec![
        // MCU initiates communication by pulling the data line low, then releasing it (pulling it high)
        PinTx::set(PinState::Low),
        PinTx::set(PinState::High),
        // Sensor responds
        PinTx::get(PinState::Low),
        PinTx::get(PinState::High),
    ]
}
}

Now we test the start function. If you look at the start implementation and the expected pin transaction sequence above, they will match step-by-step.

We are sending a 1 millisecond delay after pulling the pin low, so the mock must expect a delay_ms(1) transaction. Then we send a 40 microsecond delay after setting the pin high, so the mock must also expect a delay_us(40) transaction.

When we call wait_for_low() and wait_for_high(), we immediately return the expected pin state in the mock setup, so no delay is triggered for those steps.

#![allow(unused)]
fn main() {
 #[test]
    fn test_start_sequence() {
        let mut expect = vec![];
        expect.extend_from_slice(&start_sequence());

        let mut pin = PinMock::new(&expect);

        let delay_transactions = vec![DelayTx::delay_ms(1), DelayTx::delay_us(40)];
        let mut delay = CheckedDelay::new(&delay_transactions);

        let mut dht = Dht22::new(pin.clone(), &mut delay);
        dht.start().unwrap();

        pin.done();
        delay.done();
    }
}

Read Bit

Let's implement the read_bit function. If you remember the DHT protocol we explained earlier, the DHT sensor always pulls the line low to indicate the start of each bit. Then it switches the line to high, and the duration of that high signal determines whether the bit is a 0 or a 1.

In the DHT protocol, if the high signal lasts around 26-28 microseconds, the bit is considered to be 0. If the high duration is more than 70 microseconds, it is considered to be 1.

Some implementations try to measure exactly how long the signal stays high, and based on that, decide if the bit is 0 or 1. I also thought about doing this. But there is a much simpler approach, used by the dht-sensor crate.

Instead of measuring the time precisely, we just wait for 35 microseconds and then check the pin. If the pin is still high after 35us, that means the DHT22 kept the signal high for more than 28 microseconds, which indicates a 1. On the other hand, if the pin has already gone low before we check, then the high duration was short, which means the bit is a 0 (because for a bit to be 1, the high signal must last at least around 70 microseconds).

Here is the implementation:

#![allow(unused)]
fn main() {
fn read_bit(&mut self) -> Result<bool, DhtError<E>> {
    // Wait for DHT pulls line low
    self.wait_for_low()?; // ~50us

    // Step 2: DHT pulls line high
    self.wait_for_high()?;

    // Step 3: Delay ~35us, then sample pin
    self.delay.delay_us(35);

    // If it is still High, then the bit value is 1
    let bit_is_one = self.pin.is_high()?;
    self.wait_for_low()?;

    Ok(bit_is_one)
}
}

Testing the read_bit Function

We are now testing the read_bit function, which determines whether a bit from the DHT22 is a 1 or a 0 based on how long the signal stays high.

There are no new concepts here. The way we construct the expected pin and delay transactions is exactly like we did before. We're simulating the behavior of the DHT22 using mock pins and mock delays. That way, we can test the logic without actual hardware.

We have two tests:

Test for Bit Value 1

In this case, we simulate the DHT pulling the line low, then high, and keeping it high long enough that when we sample the pin after 35 microseconds, it is still high. That tells us the bit value is 1.

#![allow(unused)]
fn main() {
 #[test]
fn test_read_bit_one() {
    let mut pin = PinMock::new(&[
        // wait_for_low
        PinTx::get(PinState::Low), // Mimicks DHT pulling low to signal start of data bit
        // wait_for_high
        PinTx::get(PinState::High), // Then pulls high - duration determines bit value
        // delay_us(35) -> handled in delay
        // Sample pin after delay
        PinTx::get(PinState::High), // is it still High? (High -> 1)
        // Final wait_for_low
        PinTx::get(PinState::Low), // End of bit
    ]);

    let delay_transactions = vec![
        // wait_for_low
        DelayTx::delay_us(35),
    ];
    let mut delay = CheckedDelay::new(&delay_transactions);

    let mut dht = Dht22::new(pin.clone(), &mut delay);

    let bit = dht.read_bit().unwrap();
    assert!(bit);

    pin.done();
    delay.done();
}
}

Test for Bit Value 0

This one is a bit more elaborate. I admit it's a bit overdone for what we need, but it works. The idea is to simulate the signal going high and then falling back low before the 35us delay is over. That indicates a short high pulse, so the bit is 0.

#![allow(unused)]
fn main() {
#[test]
fn test_read_bit_zero() {
    let mut pin = PinMock::new(&[
        // wait_for_low
        PinTx::get(PinState::High), // To trigger Delay of 1 us, we keep it High first
        PinTx::get(PinState::Low),
        // wait_for_high
        PinTx::get(PinState::Low), // To trigger Delay of 1 us, we keep it Low first
        PinTx::get(PinState::High), // now high
        // sample bit after delay (35us)
        PinTx::get(PinState::Low), // We will set it Low to indicate bit value is "0"
        // final wait_for_low
        PinTx::get(PinState::High), // To trigger Delay of 1 us, we keep it High first
        PinTx::get(PinState::Low),  // now low
    ]);

    let delay_transactions = vec![
        DelayTx::delay_us(1),  // after 1st pin high during wait_for_low
        DelayTx::delay_us(1),  // after 1st pin low during wait_for_high
        DelayTx::delay_us(35), // sampling delay
        DelayTx::delay_us(1),  // after 1st high in final wait_for_low
    ];
    let mut delay = CheckedDelay::new(&delay_transactions);

    let mut dht = Dht22::new(pin.clone(), &mut delay);

    let bit = dht.read_bit().unwrap();
    assert!(!bit);

    pin.done();
    delay.done();
}
}

Test timeout

This test checks whether the driver correctly handles a timeout situation. We simulate a case where the DHT22 never pulls the signal low by keeping the pin state high for a long time. This should trigger a timeout in the read_bit function, which waits for a state change.

We provide 100 high states and 100 microsecond delays to simulate this. When read_bit doesn't detect the expected transition in time, it returns a Timeout error. The test then asserts that this error is indeed returned.

#![allow(unused)]
fn main() {
#[test]
fn test_read_timeout() {
    let pin_expects: Vec<PinTx> = (0..100).map(|_| PinTx::get(PinState::High)).collect();
    let mut pin = PinMock::new(&pin_expects);

    let delay_expects: Vec<DelayTx> = (0..100).map(|_| DelayTx::delay_us(1)).collect();

    let mut delay = CheckedDelay::new(&delay_expects);

    let mut dht = Dht22::new(pin.clone(), &mut delay);

    assert_eq!(dht.read_bit().unwrap_err(), DhtError::Timeout);

    pin.done();
    delay.done();
}
}

Read Byte

Now let's implement the read_byte function. This function reads 8 bits one after another and combines them into a single u8 byte.

#![allow(unused)]
fn main() {
 fn read_byte(&mut self) -> Result<u8, DhtError<E>> {
    let mut byte: u8 = 0;

    for i in 0..8 {
        let bit_mask = 1 << (7 - i);
        if self.read_bit()? {
            byte |= bit_mask;
        }
    }

    Ok(byte)
}
}

We start by initializing the byte to 0. Then, in a loop that runs 8 times (once for each bit), we calculate a bit mask for the current position using 1 << (7 - i) so that the first bit read goes into the highest bit position.

For each iteration, we call read_bit() to get the next bit from the sensor. If the bit is 1, we use the bitwise OR operation to set the corresponding bit in the byte. If it is 0, we leave the bit as-is (it's already 0). After all 8 bits are read and assembled, the final byte is returned. This approach matches how the DHT22 sends data: one bit at a time, from the most significant bit to the least significant bit.

Testing read_byte function

To simulate the sensor's behavior, we use a helper function called encode_byte, which takes a byte like 0b10111010 and expands it into a sequence of PinTx transactions for each of the 8 bits. Each bit involves four pin states: first low (bit start), then high (bit signal), then the actual sample level (high for 1, low for 0), and finally low again (end of bit).

#![allow(unused)]
fn main() {
// Helper to encode one byte into 8 bits (MSB first)
fn encode_byte(byte: u8) -> Vec<PinTx> {
    (0..8)
        .flat_map(|i| {
            // Extract bit (MSB first: bit 7 to bit 0)
            let bit = (byte >> (7 - i)) & 1;
            vec![
                PinTx::get(PinState::Low),  // wait_for_low
                PinTx::get(PinState::High), // wait_for_high
                PinTx::get(if bit == 1 {
                    // sample
                    PinState::High
                } else {
                    PinState::Low
                }),
                PinTx::get(PinState::Low), // end of bit
            ]
        })
        .collect()
}
}

We then set up a PinMock with these transactions and prepare the delay mock with eight 35us delays; one for each bit sample timing. When read_byte() is called, it internally calls read_bit() eight times, and each read_bit() checks the pin after a 35us delay to determine the bit value.

#![allow(unused)]
fn main() {
#[test]
fn test_read_byte() {
    let pin_states = encode_byte(0b10111010);

    let mut pin = PinMock::new(&pin_states);
    let delay_expects = vec![DelayTx::delay_us(35); 8];
    let mut delay = CheckedDelay::new(&delay_expects);

    let mut dht = Dht22::new(pin.clone(), &mut delay);
    let byte = dht.read_byte().unwrap();
    assert_eq!(byte, 0b10111010);

    pin.done();
    delay.done();
}
}

We check that the byte returned by read_byte() is exactly 0b10111010, the same one we encoded into the mock. This confirms that our function correctly reads each bit in order and assembles them into a byte. At the end, we call done() on both the pin and delay mocks to make sure all the expected steps actually happened.


[Optional] How read_byte Uses Bitmasks to Build a Byte

If you're not sure how the bitmask and the loop work together to build the byte, this appendix will explain it step by step. If you already know this, feel free to skip this.

The read_byte function reads 8 bits from the DHT22 sensor and builds a u8 by placing each bit in its correct position. It starts from the most significant bit (bit 7) and moves down to the least significant bit (bit 0).

Step by Step Bitmask Calculation

We will assume the bits received from the sensor are: 1, 0, 1, 1, 1, 0, 1, 0 (which is 0b10111010).

We start with a byte set to all zeros:

#![allow(unused)]
fn main() {
let mut byte: u8 = 0; //0b00000000 in Rust binary notation
}

Now, for each bit we read, we calculate a bit mask by shifting 1 to the left by (7 - i) positions. This places the "1" in the correct position for that bit inside the final byte.

Let's go step by step:

Iteration 0: Received bit = 1

We shift 1 by 7 places to the left to reach the most significant bit. Since, we received the value "1" from the sensor for this position, so we enter the if block and use the OR (|) operator to set that corresponding bit in the byte.

The current value of the "byte" is 0b00000000. When we apply the OR operation with the bit mask, the result becomes 0b10000000.

#![allow(unused)]
fn main() {
i = 0: bit = 1
bit_mask = 1 << (7 - 0) = 0b10000000
byte |= 0b10000000 => 0b10000000
}

Here's how the OR operation sets the bit in the correct position:

Bit Position76543210
byte00000000
bit_mask10000000
result10000000

Iteration 1: Received bit = 0

We received the bit value "0" from the sensor. Since it's already zero, there's no need to update the byte. The if block is skipped, and the byte remains unchanged.

i = 1: bit = 0
(skip update since bit is already 0)

Iteration 2: Received bit = 1

We shift 1 by 5 places to create a mask for bit 5. Since we received a 1, we update the byte using the OR operation. The current byte is 0b10000000, and after the OR operation, it becomes 0b10100000.

#![allow(unused)]
fn main() {
i = 2: bit = 1
bit_mask = 1 << (7 - 2) = 0b00100000
byte |= 0b00100000 => 0b10100000
}

Bitwise OR Operation Breakdown:

  10000000
| 00100000
------------
  10100000

Iteration 3: Received bit = 1

We shift 1 by 4 places. The bit is 1, so we set bit 4.

#![allow(unused)]
fn main() {
i = 3: bit = 1
bit_mask = 1 << (7 - 3) = 0b00010000
byte |= 0b00010000 => 0b10110000
}

This is the same as the previous bitwise OR operations we did. To think of it simply, we are turning bit 4 to 1 without affecting the other bits.

Iteration 4: Received bit = 1

Same as before, this sets the bit at position 3 to the value 1. Other bits remain unchanged.

#![allow(unused)]
fn main() {
i = 4: bit = 1
bit_mask = 1 << (7 - 4) = 0b00001000
byte |= 0b00001000 => 0b10111000
}

Iteration 5: Received bit = 0

This bit is 0, so no update is made.

#![allow(unused)]
fn main() {
i = 5: bit = 0
(skip update since bit is 0)
}

Iteration 6: Received bit = 1

Same as before, this sets the bit at position 1 to the value 1. Other bits remain unchanged.

#![allow(unused)]
fn main() {
i = 6: bit = 1
bit_mask = 1 << (7 - 6) = 0b00000010
byte |= 0b00000010 => 0b10111010
}

Iteration 7: Received bit = 0

Since we received bit value "0", so the we wont update the byte.

#![allow(unused)]
fn main() {
i = 7: bit = 0
(skip update since bit is 0)
}

The final result after processing all 8 bits is 0b10111010. This binary value is equal to 0xBA in hexadecimal and 186 in decimal.

Data Parser

The parse_data function takes the raw data bytes from the sensor and converts them into meaningful temperature and humidity values. The DHT22 sends two bytes for humidity and two for temperature.

#![allow(unused)]
fn main() {
fn parse_data(&self, data: [u8; 4]) -> Reading {
    let [hum_hi, hum_lo, temp_hi, temp_lo] = data;

    let joined_humidity = u16::from_be_bytes([hum_hi, hum_lo]);
    let relative_humidity = joined_humidity as f32 / 10.0;

    let is_temp_negative = (temp_hi >> 7) != 0;
    let temp_hi = temp_hi & 0b0111_1111;
    let joined_temp = u16::from_be_bytes([temp_hi, temp_lo]);
    let mut temperature = joined_temp as f32 / 10.0;
    if is_temp_negative {
        temperature = -temperature;
    }

    Reading {
        temperature,
        relative_humidity,
    }
}
}

Decode Humidity

The humidity is received as two bytes: hum_hi and hum_lo. These two bytes together form a 16-bit number. We join them using:

#![allow(unused)]
fn main() {
let joined_humidity = u16::from_be_bytes([hum_hi, hum_lo]);
}

The actual humidity is this value divided by 10.0:

#![allow(unused)]
fn main() {
let relative_humidity = joined_humidity as f32 / 10.0;
}

For example, if the sensor sends 0x02 and 0x58 (which is 600 in decimal), the humidity will be 600 / 10 = 60.0%.

Decode Temperature

The temperature is also received as two bytes: temp_hi and temp_lo.

However, the sensor uses the highest bit of "temp_hi" to indicate if the temperature is negative. So first, we check if that bit is set:

#![allow(unused)]
fn main() {
let is_temp_negative = (temp_hi >> 7) != 0;
}

We then clear the sign bit (bit 7) before combining the bytes:

#![allow(unused)]
fn main() {
let temp_hi = temp_hi & 0b0111_1111;
let joined_temp = u16::from_be_bytes([temp_hi, temp_lo]);
let mut temperature = joined_temp as f32 / 10.0;
}

If the temperature was marked as negative, we flip the sign:

#![allow(unused)]
fn main() {
 if is_temp_negative {
    temperature = -temperature;
}
}

Finally, we return the result:

#![allow(unused)]
fn main() {
 Reading {
    temperature,
    relative_humidity,
}
}

Testing parser

There is nothing complex going on in these tests. We are simply checking if the parse_data function correctly converts raw bytes into meaningful temperature and humidity values.

In the first test, we pass a sample input representing 55.5% humidity and 24.6°C temperature and assert that the output matches.

In the second test, we use a special case where the temperature is negative (-1.0°C). The most significant bit (bit 7) in the temperature high byte is set, which signals a negative temperature.

#![allow(unused)]
fn main() {
#[test]
fn test_parse_data_positive_temp() {
    let mut pin = PinMock::new(&[]);

    let dht = Dht22::new(pin.clone(), NoopDelay);
    // Humidity: 55.5% -> [0x02, 0x2B] => 555
    // Temperature: 24.6C -> [0x00, 0xF6] => 246
    let data = [0x02, 0x2B, 0x00, 0xF6];

    let reading = dht.parse_data(data);

    assert_eq!(
        reading,
        Reading {
            relative_humidity: 55.5,
            temperature: 24.6,
        }
    );
    pin.done();
}

#[test]
fn test_parse_data_negative_temp() {
    let mut pin = PinMock::new(&[]);

    let dht = Dht22::new(pin.clone(), NoopDelay);

    // Humidity: 40.0% -> [0x01, 0x90] => 400
    // Temperature: -1.0C -> [0x80, 0x0A]
    // Bit 7 of temp_hi is 1 => negative
    // Clear sign bit: 0x80 & 0x7F = 0x00, so [0x00, 0x0A] = 10 => 1.0 then negated
    let data = [0x01, 0x90, 0x80, 0x0A];

    let reading = dht.parse_data(data);

    assert_eq!(
        reading,
        Reading {
            relative_humidity: 40.0,
            temperature: -1.0,
        }
    );
    pin.done();
}
}

Final Boss

We have now implemented all the building blocks of our DHT22 driver. The last piece is the read function, which brings everything together and is the main function users will call to get a sensor reading.

First, it sends the start signal to the DHT22. Then, it reads four bytes of data, which include the humidity and temperature values. After that, it reads one more byte, which is the checksum. The checksum is simply the sum of the previous four bytes, and it's used to detect transmission errors. If the calculated checksum doesn't match the one sent by the sensor, we return a ChecksumMismatch error. Otherwise, we pass the data to our parse_data function to convert it into a Reading.

#![allow(unused)]
fn main() {
 pub fn read(&mut self) -> Result<Reading, DhtError<E>> {
    self.start()?;

    let mut data = [0; 4];

    for b in data.iter_mut() {
        *b = self.read_byte()?;
    }

    let checksum = self.read_byte()?;
    if data.iter().fold(0u8, |sum, v| sum.wrapping_add(*v)) != checksum {
        Err(DhtError::ChecksumMismatch)
    } else {
        Ok(self.parse_data(data))
    }
}
}

Testing the read function

Now we test the complete behavior of the read function from start to finish. The sensor is expected to send four data bytes followed by a checksum byte. In this case, the values represent 40.0% humidity and 24.6°C temperature. We also include the required delays: one for the initial start request, and then one 35 microsecond delay for each of the 40 data bits.

#![allow(unused)]
fn main() {
#[test]
fn test_read_valid() {
    // Data to simulate: [0x01, 0x90, 0x00, 0xF6], checksum = 0x87

    // Start sequence
    let mut pin_states = start_sequence();

    let data_bytes = [0x01, 0x90, 0x00, 0xF6];
    let checksum = 0x87;

    for byte in data_bytes.iter().chain(std::iter::once(&checksum)) {
        pin_states.extend(encode_byte(*byte));
    }

    let mut pin = PinMock::new(&pin_states);

    // Delays: start = 1ms + 40us
    let mut delay_transactions = vec![DelayTx::delay_ms(1), DelayTx::delay_us(40)];
    // Delay for data bit transfer: 40 bits * 35us delay
    delay_transactions.extend(std::iter::repeat_n(DelayTx::delay_us(35), 40));

    let mut delay = CheckedDelay::new(&delay_transactions);

    let mut dht = Dht22::new(pin.clone(), &mut delay);
    let reading = dht.read().unwrap();

    assert_eq!(
        reading,
        Reading {
            relative_humidity: 40.0,
            temperature: 24.6,
        }
    );

    pin.done();
    delay.done();
}
}

The test_read_invalid function is similar, but instead of using the correct checksum (0x87), we intentionally provide an incorrect one (0x81). This test checks whether the driver correctly detects a checksum mismatch and returns an appropriate error. Both tests use pin and delay mocks to simulate real hardware behavior without requiring an actual sensor, allowing us to fully test the driver's logic in isolation.

#![allow(unused)]
fn main() {
#[test]
fn test_read_invalid() {
    // Data to simulate: [0x01, 0x90, 0x00, 0xF6], checksum = 0x87

    // Start sequence
    let mut pin_states = start_sequence();

    let data_bytes = [0x01, 0x90, 0x00, 0xF6];
    let checksum = 0x81; // Wrong checksum value

    for byte in data_bytes.iter().chain(std::iter::once(&checksum)) {
        pin_states.extend(encode_byte(*byte));
    }

    let mut pin = PinMock::new(&pin_states);

    // Delays: start = 1ms + 40us
    let mut delay_transactions = vec![DelayTx::delay_ms(1), DelayTx::delay_us(40)];
    // Delay for data bit transfer: 40 bits * 35us delay
    delay_transactions.extend(std::iter::repeat_n(DelayTx::delay_us(35), 40));

    let mut delay = CheckedDelay::new(&delay_transactions);

    let mut dht = Dht22::new(pin.clone(), &mut delay);
    assert_eq!(dht.read().unwrap_err(), DhtError::ChecksumMismatch);

    pin.done();
    delay.done();
}
}

That's all, folks! We finally did it. Our very first embedded Rust driver is complete.

Using Our DHT22 Crate with ESP32

Now that we've successfully built the driver for the DHT22 sensor and verified it using mock tests, it's time to test it on actual hardware. In this section, we'll connect the DHT22 sensor to the ESP32 and run a complete example.

I won't be covering project setup or wiring basics for the ESP32 in this section. If you're new to embedded Rust or ESP32 development, I recommend checking out the book "impl Rust for ESP32".

Circuit

DHT22 PinLabel on ModuleConnects To (ESP32)Description
1VCC (+)3.3VPower Supply
2DATAGPIO4Data Line
3GND (-)GNDGround

Note: DHT22 modules already include a 10kΩ pull-up resistor on the data line. If you're using a bare DHT22 sensor, you must add an external resistor (10kΩ) between VCC and DATA.

Add the Crate as a Dependency

Create new project with esp-generate tool and add your Git repository as a dependency in your Cargo.toml:

[dependencies]
dht22-sensor = { git = "https://github.com/your-username/dht22-sensor" }

Full Code

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use dht22_sensor::{Dht22, DhtError};
use esp_hal::clock::CpuClock;
use esp_hal::delay::Delay;
use esp_hal::gpio::{self, Flex, Level};
use esp_hal::main;
use esp_println as _;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 0.4.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let mut dht_pin = Flex::new(peripherals.GPIO4);

    let output_config = gpio::OutputConfig::default()
        .with_drive_mode(gpio::DriveMode::OpenDrain)
        .with_pull(gpio::Pull::None);
    dht_pin.apply_output_config(&output_config);
    dht_pin.set_input_enable(true);
    dht_pin.set_output_enable(true);
    dht_pin.set_level(Level::High);

    let mut delay = Delay::new();
    let delay1 = Delay::new();
    delay1.delay_millis(2000);

    let mut sensor = Dht22::new(&mut dht_pin, &mut delay);
    loop {
        match sensor.read() {
            Ok(reading) => {
                info!(
                    "Temperature: {:?}, Humidity: {:?}",
                    reading.temperature, reading.relative_humidity
                );
            }
            Err(err) => match err {
                DhtError::ChecksumMismatch => {
                    info!("checksum error");
                }
                DhtError::Timeout => {
                    info!("Timeout error");
                }
                DhtError::PinError(e) => {
                    info!("Pin error:{}", e);
                }
            },
        }
        delay1.delay_millis(5000);
    }

}

Clone Existing Project

If you want to get started quickly, you can clone a ready-to-use example project from my repository:

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/non-async/dht-temphum

In that case, don't forget to remove the dht22-sensor crate from the Cargo.toml and add your Git repository instead.