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

Building IoT Devices with Embedded Rust - Intro

In this book, we will learn about IoT development using Rust. For this, we will use the ESP32-C5. You will learn how IoT devices communicate over networks, work with sensors, exchange data with other systems using protocols such as MQTT, update firmware over the air (OTA), and work with other common IoT technologies. We will build hands-on examples to understand how the different pieces of an IoT system work together, and much more.

Tip

This is not an introductory book on Embedded Rust. I will explain the concepts needed for the examples, but I will not go into depth on fundamentals such as no_std in Rust or embedded concepts such as peripherals, ADC, and other basic topics.

If you are just getting started with embedded systems and Rust, I recommend starting with my beginner-level book, Embedded Rust with ESP32, before reading this book.

Meet the Hardware

The ESP32-C5 is a RISC-V based System-on-Chip(SoC) from Espressif, with Wi-Fi 6 and Bluetooth Low Energy. It was announced in 2022 and entered mass production in 2025.

Waveshare ESP32-C5

I am using the Waveshare ESP32-C5 WiFi 6 Kit N16R8 development board. You can use any ESP32-C5 development board from Espressif, such as the ESP32-C5-DevKitC-1, or from another manufacturer, as long as it uses the ESP32-C5 chip.

For the smoothest experience, I recommend using the same board or a board with a closely matching pinout and hardware configuration. If your board differs, you may need to adjust the pin assignments or other hardware-specific parts of the examples. We will go into more detail about the pins in the pinout section.

Why ESP32-C5?

When it comes to IoT, the ESP32 family is one of the most popular choices for hobbyists and makers. There are already many books covering variants such as the original ESP32 and ESP32-C3.

For this book, I wanted to work with a newer ESP32 chip. I was initially considering the ESP32-C6 but chose the ESP32-C5. One of the reasons I chose the ESP32-C5 was its support for 5 GHz Wi-Fi. The ESP32-C5 supports both 2.4 GHz and 5 GHz Wi-Fi 6, while the ESP32-C6 supports only 2.4 GHz Wi-Fi 6. The ESP32-C5 also has other advantages, such as PSRAM support, and its CPU frequency is 240 MHz compared to 160 Mhz on the ESP32-C6.

What to Do If You Have Another ESP32 Variant?

If you are not able to get the ESP32-C5 but already have another ESP32 variant, you can still follow along and learn the concepts. And you can try to adapt the examples for the board you have. It may not always be a smooth ride, but it can be a good learning experience.

Work in Progress

This book is currently under active development. New chapters will be added as I work through.

Support this project

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

Datasheets

Here are the main datasheets:

In case a datasheet link is broken, you want to check the latest version, or you want to find other related documents, you can check the Related Documents section in the ESP32-C5-DevKitC-1 User Guide.

License

The “Building IoT Devices with Rust” 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.
  • Circuit diagrams in this book were created with Fritzing.

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.

Additional Hardware

In this section we will look at some of the extra hardware you might use along with the ESP32-C5.

Breadboard

If you have worked with the ESP32 DevKit V1 board, or have seen my other book, you already know that the ESP32 DevKit V1 is too wide to fit on a standard breadboard. I solved this by using two mini breadboards.

The ESP32-C5 board is slightly narrower than the ESP32 DevKit V1, so it seems to fit on a standard breadboard. However, it leaves only one row of pins available on each side, which I don’t feel comfortable working with.

So, I am using my two mini breadboard setup again. I place the ESP32-C5 development board between the two mini breadboards and connect the pins on each side to the respective breadboard.

Waveshare ESP32-C5 on mini breadboard

Pinout

The pinout of the Waveshare ESP32-C5-WIFI6-KIT board:

Waveshare ESP32-C5 Wifi6 Development board Pinout

For the complete pin functions, refer to the Waveshare ESP32-C5-WIFI6-KIT documentation and the Espressif ESP32-C5 documentation.

Development Environment

The official docs provides more comprehensive setup instructions. However, I will quickly cover the essential tools and setup needed for our exercises. If you encounter any issues, refer to the official documentation for troubleshooting.

Rust toolchain

The ESP32-C5 uses a 32-bit RISC-V processor, so we can use the standard Rust toolchain. We do not need espup, which is used for ESP chips that require the Xtensa Rust toolchain.

Install the required Rust components:

rustup component add rust-src
rustup target add riscv32imac-unknown-none-elf

cargo-binstall

This is to install Rust binaries without building from source using cargo install or manually downloading packages, you can use cargo-binstall. We’ll use this tool to install the espflash tool next.

cargo install cargo-binstall

espflash

“espflash is a serial flasher utility, based on esptool.py, for Espressif SoCs and modules.” This will be the tool used (when we are not using probe-rs) to put our code into the device and run it.

cargo binstall espflash

If you encounter any problems, you can try installing the exact version used when this book was written.

cargo binstall espflash@4.6.0

After installation, type the espflash command to verify that it works.

espflash --version

Template by ESP-RS

We will be using the templates provided by ESP-RS, which offer two sets:

  • esp-generate: A no_std template. This is the one we will focus on most of the time.
  • esp-idf-template: A std template.

esp-generate

The esp-generate tool is used for creating no_std applications. Currently, it supports the ESP32, ESP32-C2/C3/C6, ESP32-H2, and ESP32-S2/S3.

cargo install esp-generate --locked

If you want to follow the code exactly as it is in this project, install this esp-generate version used for generating the examples:

cargo install esp-generate@1.4.0 --locked

Creating project with esp-generate

For this book, we will be using the ESP32-C5. I highly recommend using the same hardware to make it easier to follow along.

esp-generate PROJECT_NAME

USB Access

On Linux, your user needs permission to access the USB serial port used by the ESP32-C5. Add your user to the dialout group:

sudo usermod -a -G dialout $USER

Log out and back in for the changes to take effect.

Quick Start - Hello Embedded!

Before getting into other concepts, let’s quickly check that our development setup is working and run our first Rust program on the ESP32-C5.

Setup project

To start the project, use the esp-generate command. Run the following:

esp-generate esp32c5-quick

This will open the configuration menu.

First, select ESP32-C5 as the target chip. Next, select ESP32-C5-WROOM-1/1U (8MB PSRAM) as the chip variant. In the “Flashing, logging and debugging (espflash)” section, enable defmt messaging.

For the remaining options, we will use the default settings. Save the configuration to generate the project. Just press s on the keyboard.

Alternatively, you can use the following command without using the TUI. This comes in handy when you already know all the options you want to use.

esp-generate --headless -o esp32c5 -o defmt -o esp32c5-wroom-1-psram esp32c5-quick

Program

I usually introduce a simple blinky program in the Quick Start section of my other books. However, in the case of the ESP32-C5 board, we can’t do that because the board has an addressable RGB LED instead of a standard LED. It cannot be controlled by simply toggling a GPIO pin between High and Low.

So, for this Quick Start, we will simply print a message using defmt.

We don’t need to make any changes to the generated program. We will only increase the delay between messages from 500 milliseconds to 5 seconds.

#[allow(
    clippy::large_stack_frames,
    reason = "it's not unusual to allocate larger buffers etc. in main"
)]
#[main]
fn main() -> ! {
    // generator version: 1.4.0
    // generator parameters: -o esp32c5 -o esp32c5-wroom-1-psram -o defmt

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

    loop {
        info!("Hello world!");
        let delay_start = Instant::now();
        while delay_start.elapsed() < Duration::from_millis(5000) {}
    }
}

Flash - Run Rust Run

All that’s left is to flash the code onto the ESP32-C5 and see the output.

Connect the ESP32-C5 to your computer using a USB-C cable.

Then, run the following command from your project folder:

#![allow(unused)]
fn main() {
cargo run
}

To run in release mode:

#![allow(unused)]
fn main() {
cargo run --release
}

Once the program starts, you should see the Hello world! message printed every five seconds.

Help & Troubleshooting

If you face any bugs, errors, or other issues while working on the exercises, here are a few ways to troubleshoot and resolve them.

1. Compare with Working Code

Check the complete code examples or clone the reference project for comparison. Carefully review your code and Cargo.toml dependency versions. Look out for any syntax or logic errors. If a required feature is not enabled or there is a feature mismatch, make sure to enable the correct features as shown in the exercise.

If you find a version mismatch, either adjust your code(research and find a solution; it’s a great way for you to learn and understand things better) to work with the newer version or update the dependencies to match the versions used in the tutorial.

2. Search or Report GitHub Issues

Visit the GitHub issues page to see if someone else has encountered the same problem: https://github.com/ImplFerris/iot-book/issues?q=is%3Aissue

If not, you can raise a new issue and describe your problem clearly.

3. Ask the Community

The Rust Embedded community is active in the Matrix Chat. The Matrix chat is an open network for secure, decentralized communication.

Here are some useful Matrix channels related to topics covered in this book:

You can create a Matrix account and join these channels to get help from experienced developers.

You can find more community chat rooms in the Awesome Embedded Rust - Community Chat Rooms section.

4. Discord

There is an unofficial Discord community for Embedded Rust where you can ask questions, discuss topics, share your experiences, and showcase your projects. It is especially useful for learners and general discussion.

Keep in mind that most HAL and embedded ecosystem maintainers are more active on Matrix. Still, this Discord server can be a good place to learn and interact with others.

Join here: https://discord.gg/NHenanPUuG

IoT

Smart IoT Devices
IoT Devices

IoT stands for Internet of Things. IoT is a term used for devices that can sense or interact with the physical world and communicate that information over a network. This can be a small sensor sending temperature readings, a smart light receiving commands, or an industrial machine reporting its status.

The term is commonly attributed to Kevin Ashton. In an article he wrote in 2009, he mentioned that the phrase “Internet of Things” started as the title of a presentation he made at Procter & Gamble (P&G) in 1999. This does not mean that Ashton introduced the concept itself. People were already doing things that we would now describe as IoT, but without a name for it. The term brought these ideas together under one name and helped shape what we now call IoT.

For example, in the early 1980s, David Nichols and other students at Carnegie Mellon University built a system to monitor a Coca-Cola vending machine remotely. The system was connected to a computer on the ARPANET, allowing them to check whether drinks were available and which ones were cold.

Later, companies started bringing this idea into consumer products. In 2000, LG launched an Internet-connected refrigerator.

IoT devices are used in many areas. From smart home devices and wearables to industrial equipment, physical devices can collect information, communicate with other systems, and be controlled remotely.

From IoT Devices to Smart Homes

IoT systems can range from a single connected device to systems where many devices communicate and work together.

For example, a temperature sensor connected to an ESP32 can collect temperature readings and send them over a network. A smart light can receive commands from a smartphone and turn on or off. Users can also interact with smart devices through voice assistants, smart speakers, and other interfaces.

As more devices become connected, they can also work together to automate everyday tasks. Imagine a morning routine in a smart home where the bedroom lights gradually turn on at a scheduled time, the curtains open, and the coffee maker starts preparing coffee. The thermostat can adjust the room temperature if needed. Later, when everyone leaves the house, the system can turn off the lights and other devices that are no longer needed.

These actions can be configured as routines or rules, allowing multiple devices to work together without requiring the user to control each device individually.

IoT systems can also be extended by combining them with artificial intelligence. For example, instead of following only predefined routines, an AI system could analyze data collected from the devices and learn patterns or preferences to make predictions and assist with decisions.

ESP32 and IoT

When it comes to programming IoT devices, ESP32 is one of the first microcontrollers that comes to mind. Espressif introduced the ESP32 with built-in Wi-Fi and Bluetooth, making it well suited for building connected devices.

Since then, Espressif has expanded the ESP32 family with different chips targeting different applications. The ESP32-C5 is one of the newer members of the family and the first ESP32 with dual-band 2.4 GHz and 5 GHz Wi-Fi 6.

Embedded Basics

Embedded systems combine hardware and software to perform a specific task. Unlike typical computers, they are designed for a specific purpose and often have limited resources. Before we get started, let’s refresh some of the basics.

MCU vs Module vs Development Board

A microcontroller (MCU) is a small computer built into a single chip. It contains a processor, memory, and peripherals that allow it to interact with the outside world.

The ESP32-C5 is a microcontroller from Espressif. It integrates a RISC-V processor, memory, peripherals, and wireless connectivity into a single chip.

You might have come across the term “module”. A module is basically the microcontroller with the components needed to use it, such as flash memory and an antenna. The ESP32-C5-WROOM-1 is an example of an ESP32-C5 module.

A development board provides an easy way to work with the module by adding components such as USB connectivity, power circuitry, and accessible pins. The one we are using for this book, the Waveshare ESP32-C5-WIFI6-KIT, is a development board.

Peripherals

Peripherals are hardware components inside the microcontroller that provide specific functions. The ESP32-C5 includes peripherals such as GPIO, UART, I2C, SPI, ADC, and timers.

GPIO

GPIO stands for General Purpose Input/Output. GPIO pins can be configured as inputs or outputs to interact with external hardware. For example, a GPIO can be used to control an LED or detect a button press.

Sensors and Actuators

A microcontroller alone isn’t enough to build an IoT device. It needs sensors to collect information from the physical world and actuators to interact with it. For example, a sensor can detect things such as motion, temperature, light, or pressure, while an actuator can perform actions such as turning on an LED, moving a motor, or sounding a buzzer.

Embedded Rust Ecosystem

Traditionally, languages such as C and C++ have been widely used for embedded development. Rust is becoming another option for embedded development. I believe Rust is a good fit for building secure IoT devices.

Let’s look at some of the main components of the ecosystem that we’ll use in this book.

no_std

When you write a typical Rust application, it uses the std crate, which provides functionality such as collections, file system access, networking, and threads. But many of these features depend on an operating system. Our microcontroller does not have an operating system running underneath our application. Instead, our program runs directly on the microcontroller.

For this reason, embedded Rust applications commonly use #![no_std], which tells Rust not to use the std crate. Instead, we can use the core crate, which provides fundamental Rust functionality without requiring an operating system.

PAC and HAL

PAC stands for Peripheral Access Crate. It gives us direct access to the microcontroller’s peripherals and registers. PACs are usually generated from SVD files that describe the microcontroller’s hardware.

HAL stands for Hardware Abstraction Layer. It works on top of the PAC and provides a higher-level interface for working with the microcontroller hardware without having to access hardware registers directly.

Most of the time, we will be working with the HAL directly. However, sometimes the HAL does not provide access to a particular feature or register we need. In that case, we can use the PAC directly.

esp-hal

We will be using esp-hal throughout this book. esp-hal is a no_std Rust HAL for Espressif’s ESP32 chips. With esp-hal, we can access the underlying PAC when needed.

The esp-hal repository also contains basic examples that you can refer to when needed.

Rust std on ESP32

Another option for Rust development on ESP32 chips is to use Rust’s standard library (std) together with ESP-IDF.

ESP-IDF is Espressif’s framework, primarily written in C, for developing applications for ESP32 chips. The Rust on ESP-IDF repository contains Rust crates that build on top of ESP-IDF.

I usually prefer working with no_std because it makes it easier to move embedded Rust code between different microcontrollers. Many microcontrollers’ HALs do not provide std.

embedded-hal

embedded-hal is not a HAL like esp-hal. It is a foundation for building an ecosystem of platform-agnostic drivers. It provides common interfaces that drivers can use to communicate with external devices, such as sensors or displays.

For example, a sensor driver can use the embedded-hal I2C interface instead of depending on a specific microcontroller or HAL. This allows the same driver to work with different microcontrollers and HALs that implement the required traits.

Embassy

Embassy is an asynchronous framework for embedded Rust. It provides an async executor that allows us to run multiple tasks. When a task is waiting, the executor can run another task that is ready to make progress.

Understanding the Project

We will quickly go through the Quick Start project and look at some of the basics behind the code and tools we are using.

Dependencies

When you generate the project with the esp-generate tool, the following dependencies gets added.

The esp-hal supports many different ESP32 variants. The code for each variant is separated using features. For our ESP32-C5, we need to enable the “esp32c5” feature.

The esp-bootloader-esp-idf crate contains functionality related to the ESP-IDF second-stage bootloader. Once again, we need to enable the “esp32c5” feature for this also.

The critical-section crate provides a common way for embedded libraries to create critical sections. A critical section is used to protect code or data from being accessed concurrently.

The defmt crate provides a logging framework designed for resource-constrained devices such as microcontrollers. We used it in the Quick Start program to print the Hello world! message.

no_std and no_main

We use the #![no_std] attribute to indicate that we won’t be using Rust’s standard library. The #![no_main] attribute tells Rust that we are not using the standard main entry point provided by the standard Rust environment.

#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
}

Panic Handler

In a typical Rust application, the standard library provides a default panic handler. Since we are not using the standard library, we need to define our own panic handler.

#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(panic_info: &core::panic::PanicInfo) -> ! {
    error!("{}", panic_info);
    loop {}
}
}

Main entrypoint

The #[main] attribute comes from esp-hal and marks our entry point:

#[main]
fn main() -> ! {
    //...
}

The ! return type means the function never returns, which is normal for embedded firmware that keeps running.

Peripherals

The peripherals variable gives us access to the ESP32-C5’s peripherals, such as GPIO, ADC, I2C, and SPI.

#![allow(unused)]
fn main() {
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
}

Flashing

Normally, when you run cargo run, Cargo builds and runs the program on your system. In our case, cargo run builds the program for the ESP32-C5 and flashes the firmware to the microcontroller. This was achieved with the help of the .cargo/config.toml file.

The actual tool used to flash the firmware is espflash. The program gets compiled for the riscv32imac-unknown-none-elf target, and the runner is configured to use espflash.

This makes the flashing process much easier. Instead of running the full command with all the required options, we can simply run cargo run.

Onboard RGB LED

The ESP32-C5 board does not have a standard onboard LED. Instead, it has an addressable RGB LED connected to GPIO27. In a way, it is actually cool that our blinky program will be colorful. On the other side, for simple feedback where I use an onboard LED, I can’t use that simple way.

ESP32-C5 Onboard RGB LED
ESP32-C5 Onboard RGB LED

Let’s generate the project with esp-generate in headless mode.

esp-generate --headless -o esp32c5 -o defmt -o esp32c5-wroom-1-psram rgb-led

Dependencies

We will need two crates to work with the onboard RGB LED. The smart-leds crate provides a common interface for addressable LEDs such as WS2812 (NeoPixel) and APA102 (DotStar).

The esp-hal-smartled crate provides the ESP-HAL-specific implementation that uses the ESP32-C5’s RMT peripheral to generate the precise timing required by the WS2812B.

Add the following dependencies to your Cargo.toml:

esp-hal-smartled = "0.18.0"
smart-leds = "0.4.0"

Additional Imports

We will add the necessary imports. In future exercises, I may not add instructions to import when they are obvious. I always provide the finished project link, so you can check the imports there as well.

#![allow(unused)]
fn main() {
use esp_hal::rmt::Rmt;
use esp_hal::time::Rate;
use esp_hal::delay::Delay;
use esp_hal_smartled::{RmtSmartLeds, buffer_size, color_order};
use smart_leds::{RGB8, SmartLedsWrite};
}

Initialize RMT

First, let’s initialize the RMT peripheral. We will use an 80 MHz frequency.

#![allow(unused)]
fn main() {
let freq = Rate::from_mhz(80);
let rmt = Rmt::new(peripherals.RMT, freq).unwrap();
}

Configure the RGB LED

Next, we will configure the RMT channel to drive the WS2812B LED connected to GPIO27.

#![allow(unused)]
fn main() {
let mut led =
    RmtSmartLeds::<{ buffer_size::<RGB8>(1) }, _, RGB8, color_order::Rgb>::new_with_memsize(
        esp_hal_smartled::WS2812_TIMING,
        rmt.channel0,
        peripherals.GPIO27,
        2,
        freq,
    )
    .unwrap();
}

The WS2812_TIMING provides the timing required by the WS2812B, while color_order::Rgb specifies the order in which the color values are sent to the LED.

Main loop

Now we can use the write method to send RGB values to the LED. Let’s cycle through red, green, and blue with a 500 millisecond delay between each color.

#![allow(unused)]
fn main() {
 let delay = Delay::new();

loop {
    led.write([RGB8::new(255, 0, 0)]).unwrap();
    delay.delay_millis(500);

    led.write([RGB8::new(0, 255, 0)]).unwrap();
    delay.delay_millis(500);

    led.write([RGB8::new(0, 0, 255)]).unwrap();
    delay.delay_millis(500);
}
}

Clone the existing project

You can clone (or refer) project I created and navigate to the rgb-led folder.

git clone https://github.com/ImplFerris/esp32c5-projects
cd esp32c5-projects/rgb-led/

Run the program

Let’s flash the firmware and run it. If everything works, you should see the RGB LED cycle through red, green, and blue.

cargo run --release

I will avoid repeating this step in future exercises. If an exercise requires additional input, such as an environment variable, I will include the required command at that point.

Create a Burglar Alarm with ESP32-C5 and Rust

Let’s get straight into a practical project. We will create a simple burglar alarm using our first sensor, a PIR (Passive Infrared) sensor.

Imagine you want to know when someone enters a room while you are away. The PIR sensor can detect movement in the room, and the ESP32-C5 can respond. For this demo, we will turn on the RGB LED and sound a buzzer. In a real scenario, you might want to send an alert to your phone instead. Of course, a PIR sensor would not be enough on its own for a complete security system. You would typically use other sensors and cameras as well. But this project will give us a basic idea.

Requirements

For this project, you will need:

  • ESP32-C5 development board
  • PIR motion sensor
  • Jumper wires
  • Active buzzer (optional)

HC-SR501 PIR sensor module

The HC-SR501 is a PIR motion sensor module commonly used for motion detection in projects such as burglar alarms, automatic lighting, and home automation. It has a pyroelectric sensor and a dome-shaped Fresnel lens that help it detect changes in infrared radiation within its sensing area.

HC-SR501 PIR sensor module

Active Buzzer

If you don’t have an active buzzer, you can leave it out of the circuit and skip the relevant parts of the code. If you decide to use a buzzer, make sure you get an active buzzer, not a passive buzzer. An active buzzer can produce a sound when you simply turn it on, which keeps things simple for this example.

Active Buzzer

HC-SR501 PIR Sensor Module

Everything around us emits infrared radiation based on its temperature, but warm objects such as people emit more infrared radiation than their surroundings. When a person moves across the sensor’s field of view, the amount of infrared radiation reaching the sensor changes. The HC-SR501 detects this change and uses it to determine that motion has occurred.

Sensitivity Adjustment

The sensitivity control adjusts the detection range of the PIR sensor. Turning the control changes how far away the sensor can detect motion, with the range typically reaching around 3 to 7 meters.

HC-SR501 PIR sensor module Sensitivity and Delay Adjustment

When you keep the PIR sensor with the dome pointing upward, the potentiometer on the right adjusts the sensitivity. Use a small screwdriver or a similar tool to turn the potentiometer.

Time Delay Adjustment

The delay control determines how long the sensor’s output remains active after motion is detected. The delay can typically be adjusted from around 3 seconds to 300 seconds (5 minutes).

Note

The sensor also has a short blocking period of a few seconds after the output goes LOW. During this period, it will not detect new motion.

Pinout

The HC-SR501 has three pins: VCC, OUT, and GND. The sensor is powered through the VCC and GND pins. The middle pin is the digital output of the sensor. It produces a HIGH signal when motion is detected and a LOW signal when no motion is detected.

HC-SR501 PIR sensor Pinout

Trigger Selection Jumper

The trigger selection jumper controls how the sensor responds when additional motion is detected during the configured delay period.

Trigger Selection Jumper

First, let’s look at the L mode, which is simpler.

In L mode, the sensor uses a single trigger. Once motion is detected, the output goes HIGH and the module starts waiting for the configured delay period. If motion is detected again during this period, it does not restart the timer. The output goes LOW when the delay period expires.

PIR Sensor in L Mode

For example, suppose the Time Delay is set to 10 seconds. When the module detects motion, the output goes HIGH and the module starts a 10-second delay. If the person moves again during those 10 seconds, the timer continues running from the original detection. After 10 seconds, the output goes LOW.

In H mode, the sensor uses a repeatable trigger. If motion is detected again while it is waiting, the module restarts the waiting period.

PIR Sensor in H Mode

Let’s say you have configured the Time Delay for 10 seconds. The module detects someone moving and starts waiting for 10 seconds. If the person moves again within that period, the module resets the waiting time and waits another 10 seconds from that detection. If no more motion is detected, the module waits for the full 10 seconds after the last detection before the output goes LOW.

For our project, we will use H mode to keep the output active when repeated motion is detected during the delay period.

Circuit

Burglar Alarm with ESP32-C5 and PIR Sensor

Connecting PIR Sensor with ESP32

We will connect the HC-SR501 PIR sensor to the ESP32-C5 as shown below. The sensor is powered from the 5V pin, while its OUT signal is connected to GPIO 10. Whenever motion is detected, the sensor outputs a HIGH signal on GPIO 10.

ESP32-C5 Pin Wire PIR Sensor Pin
GPIO 10
OUT (middle pin)
5V
VCC
GND
GND

Buzzer Pin Connection:

We will connect the positive pin of the buzzer to GPIO 24, while the negative pin connects to GND. This allows us to control the buzzer and produce a sound when motion is detected.

ESP32-C5 Pin Wire Buzzer Pin
GPIO 24
Positive Pin
GND
Negative Pin

Code

Let’s generate the project with esp-generate in headless mode.

esp-generate --headless -o esp32c5 -o defmt -o esp32c5-wroom-1-psram burglar-alarm

Tip

If you get stuck or run into any import errors, you can refer to my project and navigate to the burglar-alarm folder

https://github.com/ImplFerris/esp32c5-projects

Dependencies

We are going to change the color of the onboard LED to RED when motion is detected. To control the onboard LED, we need to add the following dependencies to Cargo.toml:

esp-hal-smartled = "0.18.0"
smart-leds = "0.4.0"

Initializing the Onboard LED

We already saw this code snippet to initialize the onboard LED using the RMT peripheral in the previous section. We will use the same snippet here.

#![allow(unused)]
fn main() {
let freq = Rate::from_mhz(80);
let rmt = Rmt::new(peripherals.RMT, freq).unwrap();
let mut led =
    RmtSmartLeds::<{ buffer_size::<RGB8>(1) }, _, RGB8, color_order::Rgb>::new_with_memsize(
        esp_hal_smartled::WS2812_TIMING,
        rmt.channel0,
        peripherals.GPIO27,
        2,
        freq,
    )
    .unwrap();
}

Initializing the PIR Sensor and Buzzer

Next, we will configure GPIO 10 to receive the signal from the PIR sensor. The PIR sensor sends its output signal through the OUT pin. When motion is detected, the sensor drives this pin HIGH. From the ESP32-C5’s perspective, GPIO 10 is an input because it receives that signal. So, we will initialize this pin with the Input struct.

We will configure this pin with an internal pull-down resistor. This keeps the pin LOW when the PIR sensor is not driving it.

#![allow(unused)]
fn main() {
let sensor_pin = Input::new(
    peripherals.GPIO10,
    InputConfig::default().with_pull(Pull::Down),
);
}

Since the buzzer is controlled by the ESP32-C5, we will configure GPIO 24 as an output using the Output struct. We initialize it with Level::Low so that the buzzer remains off initially.

#![allow(unused)]
fn main() {
let mut buzzer_pin = Output::new(peripherals.GPIO24, Level::Low, OutputConfig::default());
}

The Main Loop

The main loop is very simple. We will continuously check if we get a HIGH signal on the sensor pin. If so, we will turn on the buzzer and change the onboard LED to RED. If no motion is detected, we turn off both the buzzer and the LED.

#![allow(unused)]
fn main() {
blocking_delay(Duration::from_secs(60)); // PIR warm-up

loop {
    if sensor_pin.is_high() {
        info!("Motion detected");
        buzzer_pin.set_high();
        led.write([RGB8::new(255, 0, 0)]).unwrap();
    } else {
        buzzer_pin.set_low();
        led.write([RGB8::new(0, 0, 0)]).unwrap();
    }

    blocking_delay(Duration::from_millis(100));
}
}

The “Motion detected” message will be printed continuously while motion is detected. We could add a state flag to print the message only once until the motion stops, but I wanted to keep the code simple and easy to understand. You can extend this code to make it better.

Running the Project

Connect the ESP32-C5 to your computer and run the project:

cargo run --release

Note

The PIR sensor needs some time to stabilize after power-up. Give it a short period before testing motion detection.

Once the project is running, move in front of the PIR sensor. When motion is detected, the buzzer will turn on and the onboard LED will change to red.

You might want to adjust the time delay and sensitivity according to your preference.

You can take this project a step further by sending motion notifications to your phone through Telegram. Before doing that, we recommend completing the Wi-Fi chapter, as we will use the Wi-Fi setup and concepts covered there to connect the ESP32-C5 to the internet.

Wi-Fi

Wi-Fi plays a significant role in the IoT ecosystem. It allows devices to connect to wireless networks and communicate with other devices and Internet-based services. For example, our device can collect data from sensors and send it to a server over Wi-Fi, or receive commands from a remote application.

The ESP32-C5 supports dual-band Wi-Fi 6 (802.11ax), operating on both the 2.4 GHz and 5 GHz bands. It is also compatible with earlier Wi-Fi standards, making it possible to connect to a wide range of existing wireless networks.

The 2.4 GHz band generally provides better range, while the 5 GHz band can provide higher performance with less congestion in many environments. The ESP32-C5’s dual-band capability allows an application to use either band depending on the requirements of the network and device.

Wi-Fi Modes

The ESP32-C5 can operate in different Wi-Fi modes depending on how it communicates with other devices.

  • In Station (STA) mode, the ESP32-C5 connects to an existing Wi-Fi network.
  • In Access Point (AP) mode, it creates its own Wi-Fi network that other devices can connect to.

The ESP32-C5 can also operate in Station + Access Point (STA+AP) mode, where it connects to an existing network while simultaneously providing its own Wi-Fi network.

Station Mode

In Station mode (STA), the ESP32-C5 connects to an existing Wi-Fi network through an access point, such as the Wi-Fi router in your home. The ESP32-C5 acts as a Wi-Fi client, similar to a mobile phone, tablet, or laptop. Once connected, it can communicate with other devices on the network and access services available through the network.

ESP32-C5 WiFi6 Kit in Wi-Fi Station Mode

Access Point Mode

In Access Point mode (AP), the ESP32-C5 creates its own Wi-Fi network, allowing other devices such as laptops, tablets, smartphones, and other microcontrollers to connect to it. Unlike Station mode, the ESP32-C5 does not connect to an existing Wi-Fi network; instead, it acts as the access point for other devices.

ESP32-C5 WiFi6 Kit in Wi-Fi Access Point Mode

Connect ESP32-C5 to a Wi-Fi Network Using Rust

In this exercise, we will connect the ESP32-C5 to an existing Wi-Fi network in Station mode using Rust. Once connected, we will query a website, fetch some data, and print it to the system console.

Prerequisites

For this exercise, you will need a Wi-Fi network. You can use either your home Wi-Fi network or a mobile hotspot. Note down the SSID (Wi-Fi network name) and its password.

Project Setup

With esp-generate, enable “Unstable HAL” because the Wi-Fi APIs are currently unstable. Also enable “alloc”, which is required by the Wi-Fi implementation. The Wi-Fi support also requires the Embassy, so we need to enable the Embassy option as well.

Run the following command to create the access-website project:

esp-generate --headless \
  -o esp32c5 \
  -o esp32c5-wroom-1-psram \
  -o unstable-hal \
  -o alloc \
  -o embassy \
  -o wifi \
  -o defmt \
  access-website

Wi-Fi Boilerplate Code

The esp-generate tool generates the following boilerplate code when the Wi-Fi option is enabled. This code provides the basic Wi-Fi setup, but we need to modify it to initialize Wi-Fi in station mode and provide the Wi-Fi credentials.

#![allow(unused)]
fn main() {
esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 65536);

let timg0 = TimerGroup::new(peripherals.TIMG0);
esp_rtos::start(timg0.timer0, peripherals.FROM_CPU_INTR0);

info!("Embassy initialized!");

let _wifi_controller =
    esp_radio::wifi::WifiController::new(peripherals.WIFI, Default::default())
        .expect("Failed to initialize Wi-Fi controller");
let _wifi_interface = esp_radio::wifi::Interface::station();

// TODO: Spawn some tasks
let _ = spawner;

loop {
    info!("Hello world!");
    Timer::after(Duration::from_secs(1)).await;
}
}

Project structure

The Wi-Fi initialization setup will be too big and I don’t like it to live in the main.rs file. So instead we will create a separate wifi.rs module where we will initialize the Wi-Fi and do the necessary boilerplate setup, then return the Wi-Fi stack. In this way, we can easily copy the wifi.rs module to other projects and use it.

The project structure will look like this:

├── src
│   ├── bin
│   │   └── main.rs
│   ├── lib.rs
│   └── wifi.rs

Additional Crate

Add the following dependencies to your Cargo.toml:

reqwless = { version = "0.14.0", default-features = false, features = [
    "defmt",
    ] }

We will use the reqwless crate to send HTTP requests. It provides an HTTP client that can be used in a no_std environment with any transport that implements the traits from the embedded-io crate. It does not require alloc.

We will use DNS to resolve the domain name of the website we are accessing. DNS support is not enabled by default in embassy-net, so enable the dns feature:

embassy-net = { version = "0.9.1", features = [
  "defmt",
  "dhcpv4",
  "medium-ethernet",
  "tcp",
  "udp",
  "dns", # <= Addition
] }

Using StaticCell

Some values in our program are created at runtime but need to live for the entire lifetime of the program. For this, we will use StaticCell, which allows us to initialize these values at runtime and get a &'static mut reference to them. We use the mk_static! macro provided in the esp-hal examples to make this easier to reuse.

Filename: src/lib.rs

#![allow(unused)]
#![no_std]

fn main() {
pub mod wifi;

#[macro_export]
macro_rules! mk_static {
    ($t:ty,$val:expr) => {{
        static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
        #[deny(unused_attributes)]
        let x = STATIC_CELL.uninit().write($val);
        x
    }};
}
}

In addition, you create the wifi.rs module and add it to lib.rs, which we will work on in the next section.

Wi-Fi Module

Let’s start working with the Wi-Fi module. We need to provide the Wi-Fi credentials to connect to the network. Instead of hardcoding the credentials into the source code, we will load them from environment variables during compilation and store them in constants.

#![allow(unused)]
fn main() {
const SSID: &str = env!("SSID");
const PASSWORD: &str = env!("PASSWORD");
}

Function to Initialize Wi-Fi

Next, we will create an init_wifi function. We will call this function from main.rs to initialize Wi-Fi. It takes the Embassy spawner and Wi-Fi peripheral as arguments, sets up everything needed for Wi-Fi, and returns the network stack handle.

#![allow(unused)]
fn main() {
pub async fn init_wifi(spawner: Spawner, wifi_peripheral: WIFI<'static>) -> Stack<'static> {
// ...
    stack
}
}

Let’s begin by creating the Wi-Fi station configuration. We will use WPA2 (Wi-Fi Protected Access 2) for authentication, which is commonly used for password-protected Wi-Fi networks.

#![allow(unused)]
fn main() {
let station_config = wifi::Config::Station(
    StationConfig::default()
        .with_ssid(SSID.try_into().unwrap())
        .with_authentication(AuthenticationMethodConfig::Wpa2Personal(
            PASSWORD.try_into().unwrap(),
        )),
);
}

Next, we create the Wi-Fi controller using the station configuration.

#![allow(unused)]
fn main() {
let mut controller = esp_radio::wifi::WifiController::new(
    wifi_peripheral,
    wifi::ControllerConfig::default().with_initial_config(station_config),
)
.expect("Failed to initialize Wi-Fi controller");
}

Next, we create the network stack by calling embassy_net::new, which returns the network stack handle along with the network stack runner.

#![allow(unused)]
fn main() {
let rng = Rng::new();
let seed = (rng.random() as u64) << 32 | rng.random() as u64;

let config = embassy_net::Config::dhcpv4(Default::default());
let wifi_interface = esp_radio::wifi::Interface::station();

// Init network stack
let (stack, runner) = embassy_net::new(
    wifi_interface,
    config,
    mk_static!(StackResources<3>, StackResources::<3>::new()),
    seed,
);
}

Next we spawn two Embassy tasks. The connection task handles the Wi-Fi connection, while the net_task processes network events. We will define these two functions shortly.

#![allow(unused)]
fn main() {
spawner.spawn(connection(controller).unwrap());
spawner.spawn(net_task(runner).unwrap());
}

Now we wait for the network configuration to be ready. Once we get an IPv4 address, we print it.

#![allow(unused)]
fn main() {
stack.wait_config_up().await;

if let Some(config) = stack.config_v4() {
    info!("Got IP: {}", config.address);
}
}

This may not be needed for you. The esp-hal example does not set this, but in my case, I got a DNS error, so I set the DNS entries manually. We will also define this function shortly.

#![allow(unused)]
fn main() {
set_dns_servers(stack);
}

With this setup, we have reached the end of the init_wifi function. At the end of the function, we return the stack variable.

Set DNS Servers

I will be using Google and Cloudflare DNS servers.

#![allow(unused)]
fn main() {
fn set_dns_servers(stack: Stack<'static>) {
    // Custom DNS Servers
    if let Some(mut cfg) = stack.config_v4() {
        cfg.dns_servers.copy_from_slice(&[
            embassy_net::Ipv4Address::new(1, 1, 1, 1),
            embassy_net::Ipv4Address::new(8, 8, 8, 8),
        ]);

        stack.set_config_v4(embassy_net::ConfigV4::Static(cfg));
        info!("Overrode DNS servers, keeping DHCP address/gateway");
    }
}
}

Network Task

This task runs the network stack in the background and processes network events.

#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn net_task(mut runner: Runner<'static, Interface>) {
    runner.run().await
}
}

Wi-Fi Connection Task

This task handles connecting to the Wi-Fi network and reconnecting when the connection is lost.

#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn connection(mut controller: WifiController<'static>) {
    info!("start connection task");

    loop {
        info!("About to connect...");

        match controller.connect_async().await {
            Ok(info) => {
                info!("Wifi connected to {:?}", info);

                // wait until we're no longer connected
                let info = controller.wait_for_disconnect_async().await.ok();
                info!("Disconnected: {:?}", info);
            }
            Err(e) => {
                info!("Failed to connect to wifi: {:?}", e);
            }
        }

        Timer::after(Duration::from_millis(5000)).await
    }
}
}

Web Request

In the main function, we will call our function to initialize Wi-Fi. Once it is initialized, we will call send_http_request, which we will define shortly.

#![allow(unused)]
fn main() {
let wifi_stack = access_website::wifi::init_wifi(spawner, peripherals.WIFI).await;

info!("Wi-Fi Initialized");
Timer::after(Duration::from_millis(1000)).await;

send_http_request(wifi_stack).await;
}

Web Request and Response

We will work on the send_http_request function.

#![allow(unused)]
fn main() {
async fn send_http_request(wifi_stack: embassy_net::Stack<'static>) {
    // ...
}
}

First, we create the TCP and DNS clients, which we then use to create the HTTP client.

#![allow(unused)]
fn main() {
let tcp_client = TcpClient::new(
    wifi_stack,
    mk_static!(
        TcpClientState
        <1, 1500, 1500>,
        TcpClientState::<1, 1500, 1500>::new()
    ),
);
let dns_client = DnsSocket::new(wifi_stack);

let mut client = HttpClient::new(&tcp_client, &dns_client);
}

We will send the web request to the httpbin.org website. To receive the response, we create a buffer. We then send the request using the buffer to store the response.

#![allow(unused)]
fn main() {
let mut rx_buf = [0u8; 4096];

let mut builder = client
    .request(Method::GET, "http://httpbin.org/get?hello=Hello+esp-hal")
    .await
    .inspect_err(|e| error!("Request Build Error: {:?}", e))
    .unwrap();

// let mut builder = builder.headers(&[("Host", "httpbin.org"), ("Connection", "close")]);

info!("Sending HTTP Request");
let response = builder.send(&mut rx_buf).await.unwrap();
}

We then read the response body and print it.

#![allow(unused)]
fn main() {
match response.body().read_to_end().await {
    Ok(data) => {
        if let Ok(st) = core::str::from_utf8(data) {
            info!("Body: {}", st);
        }
    }
    Err(e) => info!("Body error: {:?}", e),
}
}

We can further process the response body as JSON using crates like serde, but we will stop here and cover this in later chapters.

Run the Program

Unlike the previous examples, we need to pass the Wi-Fi credentials as environment variables. You can either set them in your shell beforehand or prefix the command with the variables like this:

SSID='YOUR_WIFI_NAME' PASSWORD='YOUR_WIFI_PASSWORD' cargo run --release

Clone the existing project

You can clone the project I created or refer to the existing project and navigate to the access-website folder.

git clone https://github.com/ImplFerris/esp32c5-projects
cd esp32c5-projects/access-website/

Telegram Notifications

We created the burglar alarm project, but currently, it only triggers the alarm and turns the onboard LED red. In a real scenario, we would mostly like to receive a notification on our phone. In this chapter, we will use the Telegram Bot API to send notifications from the ESP32-C5 over Wi-Fi. This gives us a simple way to add remote notifications without introducing another protocol yet.

Tip

If you get stuck or run into any import errors, you can refer to my project and navigate to the telegram-notification folder

ESP32-C5 Projects

Telegram API

The Telegram Bot API is free to use and provides an HTTP-based interface for interacting with Telegram bots. Before writing the code, we need to create a Telegram bot and get its bot token.

  1. Open Telegram and search for BotFather. It will show a welcome message and a Start button. Click the Start button.
Telegram BotFather
Telegram BotFather
  1. You will be presented with a list of options. You have to click /newbot for creating new bot.

  2. It will ask you to enter a name for your bot.

  3. Next, it will ask for a username for your bot. The username must end with bot, such as my_esp32_bot.

  4. It will create the bot and provide a token to access the HTTP API.

  5. Save this token somewhere secure. Anyone with the token can control your bot, so do not share it publicly.

We will use this token later when making requests to the Telegram Bot API.

Get Chat ID

We also need the chat ID where the bot will send messages.

  1. Open the bot you created and click Start to start a conversation with it.

  2. Send any message to the bot, such as Hello.

  3. Then, use the curl to call the getUpdates API, replacing <BOT_TOKEN> with the token you received before:

curl "https://api.telegram.org/bot<BOT_TOKEN>/getUpdates"
  1. The response will contain information about the message you sent. Look for the chat object and its id field:
{
  ..
  "chat": {
    "id": 123456789, // Example ID
    ...
  }
}
  1. The number in the id field is your chat ID.

Project Setup

Run the following command to create the telegram-notification project:

esp-generate --headless \
  -o esp32c5 \
  -o esp32c5-wroom-1-psram \
  -o unstable-hal \
  -o alloc \
  -o embassy \
  -o wifi \
  -o defmt \
  telegram-notification

Add the following dependencies to your Cargo.toml:

reqwless = { git = "https://github.com/drogue-iot/reqwless", rev = "b761705cc654e6f31bc064183e219aa09b33968a", default-features = false, features = [
  "defmt",
  "embedded-tls",
  "rsa",
] }

The Telegram Bot API uses HTTPS, so we need to enable the embedded-tls and rsa features in reqwless.

At the time of writing, the latest reqwless version was 0.14.0. I faced two issues. I solved the first issue by adding the der dependency manually. But when I tried to verify the server certificate, I got an InvalidSignatureScheme error. So instead, I decided to use the latest code from the GitHub repository. To make it reproducible, we pin the dependency to a specific commit rather than automatically using the latest commit.

Enable dns feature in embassy-net:

embassy-net = { version = "0.9.1", features = [
  "defmt",
  "dhcpv4",
  "medium-ethernet",
  "tcp",
  "udp",
  "dns", # <= Addition
] }

Project Base

Next, copy the PIR sensor setup and detection code from the previous burglar alarm chapter. We do not need the buzzer or RGB LED code for this project.

#![allow(unused)]
fn main() {
// just this: 
let sensor_pin = Input::new(
    peripherals.GPIO10,
    InputConfig::default().with_pull(Pull::Down),
);
}

Since the ESP32-C5 needs an Internet connection to communicate with Telegram, we will use Wi-Fi. So, copy the Wi-Fi module and Wi-Fi initialization code from the previous chapters.

Tip

Whenever I say to copy the Wi-Fi module, make sure you also copy the mk_static macro from lib.rs into your current project’s lib.rs.

Once you have copied both, the base setup should be something like this:

Telegram notification project with ESP32-C5 and Rust
Project Base Setup

Now create a new module called telegram.rs. We will use this module to work with the Telegram API.

Your project structure should now look like this:

├── src
│   ├── bin
│   │   └── main.rs
│   ├── lib.rs
│   ├── telegram.rs
│   └── wifi.rs

Imports

Add the necessary imports to the telegram module:

#![allow(unused)]
fn main() {
use defmt::{error, info};

use embassy_net::Stack;
use embassy_time::{Duration, Instant, with_timeout};

use embassy_net::{dns::DnsSocket, tcp::client::TcpClient};
use reqwless::client::{HttpClient, TlsConfig, TlsVerify};
use reqwless::request::Method;

use alloc::format;
use reqwless::headers::ContentType;
use reqwless::request::RequestBuilder;

use crate::mk_static;

extern crate alloc;
}

TCP Configuration

We will define a few constants used by the TCP client. We set the maximum number of concurrent connections to 1, and the transmit and receive buffer sizes to 1500 bytes. Finally, we create an alias for the TCP client state:

#![allow(unused)]
fn main() {
// TCP Config
const MAX_CONCURRENT_CONNECTIONS: usize = 1;
const TCP_TX_BUFFER_SIZE: usize = 1500;
const TCP_RX_BUFFER_SIZE: usize = 1500;

type TcpClientState = embassy_net::tcp::client::TcpClientState<
    MAX_CONCURRENT_CONNECTIONS,
    TCP_TX_BUFFER_SIZE,
    TCP_RX_BUFFER_SIZE,
>;
}

Telegram Configuration

We will load the Telegram token and chat ID from environment variables and put them into constants. We also define a notification interval to control how much time to wait between Telegram messages. Without this, if there is an issue in our logic or some other problem, we might end up sending too many Telegram messages. We also set the API request timeout to 10 seconds. You can increase this value if needed.

#![allow(unused)]
fn main() {
const TELEGRAM_TOKEN: &str = env!("TELEGRAM_TOKEN");
const CHAT_ID: &str = env!("TELEGRAM_CHAT_ID");
const NOTIFICATION_INTERVAL: Duration = Duration::from_secs(120);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
}

Telegram Error Handling

The reqwless can return errors when a request fails, but we also need to handle errors returned by the Telegram API itself, such as a 403 status code. For example, you may get a 403 error if the chat ID is incorrect or you have not started a conversation with the bot. We create a custom error type to handle both types of errors.

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum TelegramError {
    Http(reqwless::Error),
    Status(u16),
}

impl From<reqwless::Error> for TelegramError {
    fn from(e: reqwless::Error) -> Self {
        TelegramError::Http(e)
    }
}
}

Telegram Client

We will create a struct for the Telegram client with the following fields:

#![allow(unused)]
fn main() {
pub struct TelegramClient {
    stack: Stack<'static>,
    tcp_state: &'static TcpClientState,
    last_sent: Option<Instant>,

    // Buffers
    tls_rx: [u8; 16_640], // 16640 required for the TLS read buffer
    tls_tx: [u8; 4096],
    http_rx: [u8; 4096],
}

impl TelegramClient {
    pub fn new(stack: Stack<'static>) -> Self {
        Self {
            stack,
            tcp_state: mk_static!(TcpClientState, TcpClientState::new()),
            last_sent: None,

            // Buffers
            tls_rx: [0; 16_640],
            tls_tx: [0; 4096],
            http_rx: [0; 4096],
        }
    }

    //...
    // We will define a few more functions here
}
}

The tls_rx and tls_tx are buffers used by TLS, while http_rx is the HTTP receive buffer. We also have the last_sent field to track when we last sent a Telegram message.

Sending a Telegram Message

Most of the code here is similar to what we used in the Access a Website section. This time, however, we need TLS because the Telegram API uses HTTPS. Unlike a desktop machine, the ESP32-C5 does not have a system certificate store that we can use. To keep things simple, we will initially disable TLS certificate verification. This is insecure and makes the connection vulnerable to a MITM (man-in-the-middle) attack. In the next section, we will download the Telegram certificate and use it to verify the server.

#![allow(unused)]
fn main() {
async fn send(&mut self, text: &str) -> Result<(), TelegramError> {
    let rng = esp_hal::rng::Rng::new();
    let tls_seed = {
        let mut bytes = [0u8; 8];
        rng.read(&mut bytes);
        u64::from_le_bytes(bytes)
    };

    let tls_config = TlsConfig::new(
        tls_seed,
        &mut self.tls_rx,
        &mut self.tls_tx,
        TlsVerify::None, // Insecure but ok for now
    );

    let tcp_client = TcpClient::new(self.stack, self.tcp_state);
    let dns_client = DnsSocket::new(self.stack);
    let mut client = HttpClient::new_with_tls(&tcp_client, &dns_client, tls_config);

    let url = format!("https://api.telegram.org/bot{}/sendMessage", TELEGRAM_TOKEN);
    let body = format!(r#"{{"chat_id":"{}","text":"{}"}}"#, CHAT_ID, text);

    let mut request = client
        .request(Method::POST, &url)
        .await?
        .body(body.as_bytes())
        .content_type(ContentType::ApplicationJson);

    let response = request.send(&mut self.http_rx).await?;

    let status = response.status.0;
    let resp_body = response.body().read_to_end().await?;
    let resp_text = core::str::from_utf8(resp_body).unwrap_or("invalid UTF-8");

    if status != 200 {
        error!("Telegram error body: {}", resp_text);
        return Err(TelegramError::Status(status));
    }

    info!("Telegram response: {}", resp_text);

    Ok(())
}
}

The Telegram API endpoint for sending a message is “sendMessage”. It expects a POST request with a JSON body containing the chat ID and message. After receiving the response, we check the HTTP status code. If it is not 200, we consider the request an error.

We are using format! to build the JSON body because the request is simple. If the JSON becomes more complicated or we need to parse the JSON response, we will need to use a crate such as serde.

Notification Logic

First, we check how much time has passed since the last message was sent. If the waiting time has not passed, we skip sending the message. We also set a timeout for the request so it does not wait forever. Finally, we handle the result and update last_sent only when the message is sent successfully.

#![allow(unused)]
fn main() {
pub async fn notify(&mut self, text: &str) {
    if let Some(t) = self.last_sent {
        if t.elapsed() < NOTIFICATION_INTERVAL {
            info!("Cooldown active, skipping");
            return;
        }
    }

    match with_timeout(REQUEST_TIMEOUT, self.send(text)).await {
        Ok(result) => self.handle_result(result),
        Err(_) => {
            error!("Telegram request timed out");
        }
    }
}

fn handle_result(&mut self, result: Result<(), TelegramError>) {
    match result {
        Ok(()) => {
            info!("Message sent successfully");
            self.last_sent = Some(Instant::now());
        }
        Err(TelegramError::Http(e)) => {
            error!("HTTP request failed: {:?}", e);
        }
        Err(TelegramError::Status(s)) => {
            error!("Telegram returned HTTP status: {}", s);
        }
    }
}
}

Start Monitoring

We create the Telegram client and wait for 60 seconds to allow the PIR sensor to warm up. In the previous example, we used an if statement to check whether the sensor pin was high and then took action. This time, we use the wait_for_high function to wait for the pin to go high. Then, we send the Telegram notification. Once the notification is sent, we wait for the sensor pin to go low again.

#![allow(unused)]
fn main() {
let mut telegram = TelegramClient::new(wifi_stack);

Timer::after(Duration::from_secs(60)).await; // PIR warm-up
info!("Monitoring...");

loop {
    sensor_pin.wait_for_high().await;
    info!("Motion detected!");

    telegram.notify("Motion Detected").await;

    sensor_pin.wait_for_low().await;
}
}

Run the Program

Apart from the Wi-Fi credentials, we also need to pass the Telegram token and chat ID as environment variables.

SSID='YOUR_WIFI_NAME' \
PASSWORD='YOUR_WIFI_PASSWORD' \
TELEGRAM_TOKEN='YOUR_BOT_TOKEN' \
TELEGRAM_CHAT_ID='YOUR_CHAT_ID' \
cargo run --release

If you find this command too long, you can set the environment variables before running the program and then simply run cargo run --release

Wait for the PIR sensor to warm up and then make a movement in front of the sensor. If everything works as expected, you should receive a message in Telegram.

TLS Verification

In the previous version, we disabled TLS certificate verification, which made the connection vulnerable to MITM attacks. In this chapter, we will work on the same project but properly verify the server certificate. You can update your existing project for the relevant parts.

To verify the server certificate, we could embed the api.telegram.org certificate itself, but that would tightly couple our firmware to the current server certificate. Telegram can replace its certificate when it expires or when its certificate configuration changes. Instead, we store the root CA certificate. The TLS library uses this root certificate to verify the server certificate.

Tip

If you get stuck or run into any import errors, you can refer to my project and navigate to the telegram-tls folder

ESP32-C5 Projects

Check the Certificate Chain

To download the correct root certificate, we first need to find out which CA issued the certificate used by api.telegram.org. You can skip this step also. If you are curious, you can run the following command to see which certificate authority issued the certificate for the Telegram API domain.

echo | openssl s_client \
    -connect api.telegram.org:443 \
    -servername api.telegram.org 2>/dev/null |
    grep -E " s:| i:"

If you run the command, you should see that the certificate chain uses GoDaddy certificates.

Download the Root CA Certificate

Since the certificate authority is GoDaddy, we can download the GoDaddy G2 root CA certificate from its official certificate repository.

mkdir -p certs

curl -o certs/root.pem \
    https://certs.godaddy.com/repository/gdroot-g2.crt

# Verify the certificate
openssl x509 \
    -in certs/root.pem \
    -noout \
    -subject \
    -enddate

reqwless expects the CA certificate in DER format, so convert the downloaded PEM certificate to DER:

openssl x509 \
    -in certs/root.pem \
    -outform DER \
    -out certs/root.der

Store the certificate in a certs directory at the project root:

.
├── build.rs
├── Cargo.toml
├── certs
│   └── root.der
├── rust-toolchain.toml
├── src
│   ├── bin
│   │   └── main.rs
│   ├── lib.rs
│   ├── telegram.rs
│   └── wifi.rs

Configure Certificate Verification

Now we can include the DER certificate in the firmware and use it to verify the server certificate. Replace TlsVerify::None with the TlsVerify::Certificate block:

#![allow(unused)]
fn main() {
const ROOT_CERT: &[u8] = include_bytes!("../certs/root.der");

let tls_config = TlsConfig::new(
    tls_seed,
    &mut self.tls_rx,
    &mut self.tls_tx,
    TlsVerify::Certificate {
        // Verifying the certificate
        ca: ROOT_CERT,
        cert: None,
        key: None,
    },
);
}

The ca field contains the root CA certificate that we trust. The TLS library uses it to verify the server certificate.

Clone the existing project

You can clone the project I created or refer to the existing project and navigate to the telegram-tls folder.

git clone https://github.com/ImplFerris/esp32c5-projects
cd esp32c5-projects/telegram-tls/

Web Server on ESP32-C5

In this section, let’s learn how to create a web server and run it on the ESP32-C5.

Heads up: don’t have high hopes when I say “web server”. We are not going to expose it to the internet. That would be a fun little exercise, but in my opinion, it is not very practical to run a website from an MCU.

Instead, we will run a web server that can be accessed only within our local network. For example, you can connect your phone or laptop to the same Wi-Fi network as the ESP32-C5 and open the ESP32-C5’s IP address in a browser.

Then, what is the purpose of a web server on the ESP32-C5?

Imagine you have an ESP32-C5 connected to a light, motor, or some sensors. You could run a small web interface on the ESP32-C5 and use your phone or laptop to monitor and control the device through a browser.

Also, instead of serving the web interface from the device, you could keep the ESP32-C5 lightweight and expose an HTTP API while a separate mobile or desktop app provides the user interface. Both approaches can work entirely within your local network without relying on a cloud service.

Tip

If you get stuck or run into any import errors, you can refer to my project and navigate to the web-server folder

ESP32-C5 Projects

Project Setup

Run the following command to create the web-server project:

esp-generate --headless \
  -o esp32c5 \
  -o esp32c5-wroom-1-psram \
  -o unstable-hal \
  -o alloc \
  -o embassy \
  -o wifi \
  -o defmt \
  web-server

Once project is created, copy the Wi-Fi module along with the mk_static macro and Wi-Fi initialization code from the previous chapters.

Picoserve

The main crate we are going to use is picoserve. It is an async no_std HTTP server suitable for bare-metal environments, heavily inspired by Axum. We will use it to run our web server.

Add the following to Cargo.toml:

picoserve = { version = "0.20.1", features = ["embassy"] }

The picoserve repository has many nice examples to get started. You can check them out here.

Assets

We will use a simple HTML page with a Ferris image on it. You can create pretty much any web page you like and use it instead. If you want to use mine, download the following two files and put them in a new assets directory in your project root.

Project Structure

Our project structure will look like this:

.
├── assets
│   ├── index.html
│   └── logo.svg
├── build.rs
├── Cargo.toml
├── rust-toolchain.toml
├── src
│   ├── bin
│   │   └── main.rs
│   ├── lib.rs
│   ├── server.rs
│   └── wifi.rs

I have also created a server module where we will handle the routing and run the web server.

Server Configuration

First, import the picoserve types we will use to create the router and serve our HTML files.

#![allow(unused)]
fn main() {
use picoserve::{Router, response::File, routing};

pub const WEB_TASK_POOL_SIZE: usize = 2;
static CONFIG: picoserve::Config = picoserve::Config::const_default().keep_connection_alive();
}

WEB_TASK_POOL_SIZE controls how many web server tasks we can run concurrently; two is enough for us. CONFIG is the picoserve configuration. We just set everything to the default and keep HTTP connections alive so the client can reuse the same connection for multiple requests.

Web Server Task

Now we can create the web server task. The task receives the network stack and a task_id, then creates the buffers needed by picoserve.

As you can see, WEB_TASK_POOL_SIZE specifies how many instances of web_task can run concurrently. We need multiple instances so picoserve can handle multiple HTTP connections concurrently. We use the same value when spawning the tasks, so we create exactly as many tasks as the pool allows.

#![allow(unused)]
fn main() {
#[embassy_executor::task(pool_size = WEB_TASK_POOL_SIZE)]
pub async fn web_task(task_id: usize, stack: embassy_net::Stack<'static>) -> ! {
    let port = 80;
    let mut tcp_rx = [0; 1024];
    let mut tcp_tx = [0; 1024];
    let mut http_buffer = [0; 2048];

    let app = Router::new()
        .route(
            "/",
            routing::get_service(File::html(include_str!("../assets/index.html"))),
        )
        .route(
            "/logo.svg",
            routing::get_service(File::with_content_type(
                "image/svg+xml",
                include_bytes!("../assets/logo.svg"),
            )),
        );
        
    picoserve::Server::new(&app, &CONFIG, &mut http_buffer)
        .listen_and_serve(task_id, stack, port, &mut tcp_rx, &mut tcp_tx)
        .await
        .into_never()
}
}

The / route serves our index.html file, while /logo.svg serves the SVG image. If you are serving CSS or JavaScript, you can also use File::css or File::javascript. For SVG or other images, we can use with_content_type instead to set the content type to image/svg+xml.

Finally, listen_and_serve starts the server on port 80 and waits for incoming HTTP connections.

Start Web Server Tasks

From main, we spawn the web server tasks using the same WEB_TASK_POOL_SIZE we defined in server.rs.

#![allow(unused)]
fn main() {
for task_id in 0..server::WEB_TASK_POOL_SIZE {
    spawner.spawn(server::web_task(task_id, wifi_stack).unwrap());
}

info!("Started web tasks");
}

The loop gives each task a unique task_id, which picoserve uses to identify the server task. With WEB_TASK_POOL_SIZE set to 2, this starts two web server tasks that can handle connections concurrently.

Run the Program

Now let’s run the program and connect the ESP32-C5 to your Wi-Fi network:

SSID='YOUR_WIFI_NAME' PASSWORD='YOUR_WIFI_PASSWORD' cargo run --release

You should see output similar to this, including the IP address:

[INFO ] Got IP: 192.168.0.101/24 ...
...
[INFO ] Started web tasks ...

Once the ESP32-C5 has connected to the network and received an IP address, open that IP address in a browser from another device (mobile or laptop) connected to the same Wi-Fi network.

http://192.168.0.101

Controlling the RGB LED via Wi-Fi

In this chapter, let’s extend the web server from the previous chapter. Instead of serving just a simple HTML page, we will create a web interface with a color picker that allows us to choose a color and change the onboard RGB LED.

Tip

If you get stuck or run into any import errors, you can refer to my project and navigate to the rgb-web-control folder

ESP32-C5 Projects

Project Setup

You can clone the previous web-server project and build on top of it. We will additionally need the smart led crates for controlling the onboard RGB LED. The web page will send the selected color as JSON from JavaScript, so we need serde to deserialize the JSON in the backend. Update the dependencies as follows:

picoserve = { version = "0.20.1", features = ["embassy", "json"] }
serde = { version = "1.0.229", default-features = false, features = ["derive"] }
esp-hal-smartled = "0.18.0"
smart-leds = "0.4.0"

embassy-sync = "0.8.0"

The embassy-sync crate provides synchronization primitives and data structures with async support. We will use Signal from this crate. I will explain shortly why we need it, so for now, just add it.

We will also create an onboard_led module to make it easier to control the RGB LED on the board. The final project structure will look like this:

.
├── assets
│   └── index.html
├── build.rs
├── Cargo.toml
├── rust-toolchain.toml
├── src
│   ├── bin
│   │   └── main.rs
│   ├── lib.rs
│   ├── onboard_led.rs
│   ├── server.rs
│   └── wifi.rs

I have also removed the old index.html and logo.svg files from the assets directory and replaced them with a new index.html. You can download it from here.

Controlling Onboard RGB LED

We already worked with the onboard RGB LED in an earlier chapter. This time, we will wrap the LED setup in an OnboardLed struct so we can work with the RGB LED without dealing with the RMT or Smart LED configuration directly.

We used this code earlier in main when we initialized the Smart LED. I have extracted the values into constants to keep the code clean and easy to read. Instead of using the verbose RmtSmartLeds type directly, I created a type alias using the constants we need.

Filename: src/onboard_led.rs

#![allow(unused)]
fn main() {
use esp_hal::peripherals;
use esp_hal::rmt::Rmt;
use esp_hal::time::Rate;
use esp_hal_smartled::{buffer_size, color_order};
use serde::Deserialize;
use smart_leds::{RGB8, SmartLedsWrite};

const RMT_FREQ: Rate = Rate::from_mhz(80);
const BUFFER_SIZE: usize = buffer_size::<RGB8>(1);
type RmtSmartLeds =
    esp_hal_smartled::RmtSmartLeds<'static, BUFFER_SIZE, esp_hal::Blocking, RGB8, color_order::Rgb>;
}

Next, we define the structure of the color we will receive as JSON:

#![allow(unused)]
fn main() {
#[derive(Deserialize)]
pub struct Color {
    red: u8,
    green: u8,
    blue: u8,
}
}

The Deserialize derive allows Serde to convert the JSON request into a Color value.

We will create an OnboardLed struct to keep everything related to controlling the onboard RGB LED together. This gives us a simple interface for working with the LED while keeping the RMT and Smart LED details inside the module.

#![allow(unused)]
fn main() {
pub struct OnboardLed {
    led: RmtSmartLeds,
}

impl OnboardLed {
    pub fn new(
        rmt_peripheral: peripherals::RMT<'static>,
        led_peripheral: peripherals::GPIO27<'static>,
    ) -> Self {
        let rmt = Rmt::new(rmt_peripheral, RMT_FREQ).unwrap();
        let led = RmtSmartLeds::new_with_memsize(
            esp_hal_smartled::WS2812_TIMING,
            rmt.channel0,
            led_peripheral,
            2,
            RMT_FREQ,
        )
        .unwrap();

        Self { led }
    }

    pub fn set_color(&mut self, color: Color) {
        self.led
            .write([RGB8::new(color.red, color.green, color.blue)])
            .unwrap();
    }
}
}

LED Task

In Embedded Rust, peripherals are provided as singletons, so we can’t directly share the same peripheral across multiple tasks. In our case, this means the web server can’t directly control the RGB LED.

To solve this, we can use an embassy_sync::Signal to pass the selected color between tasks. A Signal stores the latest value sent to it and allows another task to wait for and receive that value.

Filename: src/onboard_led.rs

#![allow(unused)]
fn main() {

use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal};

pub static LED_SIGNAL: Signal<CriticalSectionRawMutex, Color> = Signal::new();

#[embassy_executor::task]
pub async fn led_task(mut led: OnboardLed) -> ! {
    loop {
        let color = LED_SIGNAL.wait().await;
        led.set_color(color);
    }
}
}

We create a Signal that can hold a Color. The web server will send the selected color through this signal, and the LED task will receive it.

The led_task takes the OnboardLed as an argument, so it is the task that owns the LED peripheral. It waits for a new color using LED_SIGNAL.wait().await. Once a color is received, it passes it to the set_color() function.

The task then goes back to waiting for the next color.

Then in the main.rs file, we first create an OnboardLed instance using the RMT and GPIO27 peripherals. We then pass this instance to led_task when spawning the task.

Filename: src/bin/main.rs

#![allow(unused)]
fn main() {
let onboard_led = OnboardLed::new(peripherals.RMT, peripherals.GPIO27);
spawner.spawn(onboard_led::led_task(onboard_led).unwrap());
}

Sending the Color to the LED Task

Now, we will update the web server code. We will remove the old /logo.svg route and add a POST route for /api/onboard-led:

Filename: src/server.rs

#![allow(unused)]
fn main() {
let app = Router::new()
    .route(
        "/",
        routing::get_service(File::html(include_str!("../assets/index.html"))),
    )
    .route("/api/onboard-led", routing::post(handle_color));
}

When the browser sends a POST request to /api/onboard-led, picoserve will call the handle_color function.

#![allow(unused)]
fn main() {
async fn handle_color(extract::Json(color): extract::Json<onboard_led::Color>) {
    onboard_led::LED_SIGNAL.signal(color);
}
}

The Json extractor deserializes the JSON request body into our Color struct. We then send the color through LED_SIGNAL using signal(). The LED task receives the color from the signal and updates the RGB LED.

Frontend Javascript snippet

This is the JavaScript part of the index.html file. It helps us send the selected color from the color picker to the ESP32-C5. The full code and logic are in the index.html file if you want to refer to them.

async function sendColor() {
    const [red, green, blue] = getRgb();

    status.textContent = "Updating...";

    try {
        const response = await fetch("/api/onboard-led", {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify({ red, green, blue })
        });

        if (!response.ok) {
            throw new Error();
        }

        status.textContent = "LED updated";
    } catch {
        status.textContent = "Failed to update LED";
    }
}

Run the Program

Once you run the program, you will see the web page shown below. Click the color picker and choose a color with your mouse. Once you release the mouse button, the selected color will automatically be sent to the ESP32-C5. You should see the onboard LED change to the selected color.

Control ESP32-C5 Onboard RGB LED via Wi-Fi with Rust
Control ESP32-C5 Onboard RGB LED via Wi-Fi with Rust

If the web page does not seem to load or the request fails, close and reopen the browser. We have only two web server tasks, so an existing browser connection may prevent a new connection from being established.

MQTT

So far, we have used Wi-Fi to connect the ESP32-C5 to a network and communicate directly with a website. This works well when a device needs to communicate with a specific server, but IoT systems often need a different approach.

Imagine you have several devices in your home. A device with a temperature sensor could periodically send temperature readings. A phone application could display those readings. Another device could use the same readings to control a fan.

One way to build this would be to make every device communicate directly with every other device. As the number of devices grows, this quickly becomes complicated.

MQTT simplifies this by allowing devices to communicate through a central broker instead of directly with each other. The broker is a server that receives messages and delivers them to devices that are interested in them.

MQTT is a lightweight messaging protocol designed for IoT and other environments where devices may have limited resources or network bandwidth. It uses a publish/subscribe model.

MQTT Publish / Subscribe Architecture
MQTT Publish / Subscribe Architecture

Instead of sending a message directly to another device, a device publishes a message to a topic. A topic is a name that identifies a category of messages. Devices that want to receive those messages subscribe to the topic. The MQTT broker then delivers the messages to those subscribers.