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_stdin 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.
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.
Pinout
The pinout of the Waveshare ESP32-C5-WIFI6-KIT board:
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_stdtemplate. This is the one we will focus on most of the time. - esp-idf-template: A
stdtemplate.
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:
-
Embedded Devices Working Group
#rust-embedded:matrix.org
General discussions around using Rust for embedded development. -
ESP32 Development
#esp-rs:matrix.org
Focused on Rust development for the ESP32 family of chips. -
Debugging with Probe-rs
#probe-rs:matrix.org
For support and discussion around the probe-rs debugging toolkit. -
Embedded Graphics
#rust-embedded-graphics:matrix.org
For working withembedded-graphics, a drawing library for embedded systems.
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
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.
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.
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.
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.
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.
Trigger Selection Jumper
The trigger selection jumper controls how the sensor responds when additional motion is detected during the configured delay period.
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.
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.
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
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-alarmfolder
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.
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.
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-notificationfolder
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.
- Open Telegram and search for BotFather. It will show a welcome message and a Start button. Click the Start button.
-
You will be presented with a list of options. You have to click
/newbotfor creating new bot. -
It will ask you to enter a name for your bot.
-
Next, it will ask for a username for your bot. The username must end with
bot, such asmy_esp32_bot. -
It will create the bot and provide a token to access the HTTP API.
-
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.
-
Open the bot you created and click Start to start a conversation with it.
-
Send any message to the bot, such as Hello.
-
Then, use the
curlto call thegetUpdatesAPI, replacing<BOT_TOKEN>with the token you received before:
curl "https://api.telegram.org/bot<BOT_TOKEN>/getUpdates"
- The response will contain information about the message you sent. Look for the
chatobject and itsidfield:
{
..
"chat": {
"id": 123456789, // Example ID
...
}
}
- The number in the
idfield 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_staticmacro fromlib.rsinto your current project’slib.rs.
Once you have copied both, the base setup should be something like this:
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-tlsfolder
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-serverfolder
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-controlfolder
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.
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.
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.