Intro
In this book, we use the ESP32 DevKit v1 with Rust to build simple and fun projects. The ESP32 is a popular microcontroller for IoT applications, and we take a hands-on approach to help you learn by doing. You will explore how to turn on an LED when the room gets darker using an LDR, use an ultrasonic sensor to detect when something is close, control an LED through Wi-Fi, draw images/text on an OLED display, play songs and alarm sound with a buzzer, control a servo motor, and more.
We'll use Rust's no_std
environment. While it's possible to program the ESP32 in a std environment, I feel it's better to start with no_std, as it enables you to apply the same logic when working with other microcontrollers.
Prerequisites
If you haven't already read the "The Rust on ESP Book", I highly recommend doing so first. While this book will cover some aspects of setting up the development environment and basic concepts, it will not go into as much detail to avoid unnecessary repetition, as these topics are already thoroughly explained in the official book.
I also recommend you to read "The Embedded Rust book" - An introductory book about using the Rust on "Bare Metal" embedded systems.
Meet the Hardware
We will be using one of the development board "ESP32 DevKit V1", which comes with built-in Wi-Fi and Bluetooth capabilities, along with an integrated RF module
Datasheets
For detailed technical information, specifications, and guidelines, refer to the official datasheets:
Breadboard
The ESP32 devkit is slightly wider than usual. If you use a standard breadboard, you might struggle to fit the board, as I did. To solve this, I bought two mini breadboards, placed the ESP32 between them, and connected each side.
In the picture, the bottom section shows how I connected the ESP32 to the two breadboards. The top section just displays the breadboards before any connections were made.
License
The impl Rust on ESP32 book(this project) is distributed under the following licenses:
- The code samples and free-standing Cargo projects contained within this book are licensed under the terms of both the MIT License and the Apache License v2.0.
- The written prose contained within this book is licensed under the terms of the Creative Commons CC-BY-SA v4.0 license.
Support this project
You can support this book by starring this project on GitHub or sharing this book with others 😊
Disclaimer:
The experiments and projects shared in this book have worked for me, but results may vary. I'm not responsible for any issues or damage that may occur while you're experimenting. Please proceed with caution and take necessary safety precautions.
ESP32 Family
If you're new and searching for what ESP32 to buy, you might feel overwhelmed by the many choices and variants. In this section, we'll explore these variants.
The ESP32 family, a series of low cost and low power System on a Chip (SoC) microcontrollers created by Espressif. ESP32 is the successor to the ESP8266 and has since expanded with various new variants.
System on Chip(SoC) variants
The ESP32 family consists of the following commonly used SoC variants:
- ESP32
- ESP32 S Series(ESP32-S2, ESP32-S3)
- ESP32 C Series(ESP32-C3, ESP32-C6, ESP32-C5)
- ESP32 H Series(ESP32-H2)
- ESP32-P Series(ESP32-P4)
If you are choosing for a specific project or building a product, use the product comparison tables above to find the one best fit for your needs. You can find a full list of these models and their specs on the Espressif Product Comparison page.
For a simple overview of the most common ESP32 models and their main features, check out this table on Done.land website.
System on Chip (SoC) vs Module vs Development Board (Devkit)
The ESP32 comes in three distinct forms: System on Chip (SoC), Module, and Development Boards (Devkits). Each serves a specific purpose and is suited to different stages of development or integration.
System on Chip (SoC)
The SoC is the core and most fundamental form of the ESP32. A System on Chip integrates essential components of an electronic system or computer into a single Integrated Circuit (IC). In the case of the ESP32 SoC, it includes:
- CPU
- Wi-Fi and Bluetooth
- ROM and SRAM
- Additional peripherals
Use Case:
SoCs are primarily intended for integration into custom hardware designs. They are ideal for manufacturers who want to embed ESP32 functionality into their products. However, since the SoC alone is not pre-certified for regulatory compliance, you will need to obtain certifications (e.g., FCC, CE) when designing custom hardware for wireless communication.
Module
Modules build upon the SoC and provide a more user-friendly, ready-to-use solution. Common examples of ESP32 modules include the popular WROOM and WROVER series. For instance, the ESP32-WROOM-32 is widely used and recognizable as the metallic-cased square component on many development boards.
Why Choose a Module?
- Pre-Certification: Modules are pre-certified for regulatory compliance, saving time and effort.
- Integrated Components: They include PCB antennas, a crystal oscillator, and a flash memory chip.
- Shielded Design: Modules come with an EMI shield to reduce electromagnetic interference.
Use Case:
Modules are designed for integration into custom PCBs and are perfect for applications that require fewer external components and less effort compared to using a raw SoC.
Development Board (Devkit)
Development boards simplify the use of ESP32 modules for prototyping and development. They include additional components to make the ESP32 module easier to work with for beginners and experienced developers alike.
Key Features of Development Boards:
- USB Interface: Enables easy programming and debugging.
- Voltage Regulators: Provides stable operation for the ESP32.
- Pin Breakouts: Makes ESP32 pins accessible for connecting external components like sensors and displays.
- Boot and Reset Buttons: Provides control over module operations.
Use Case:
Devkits are ideal for rapid prototyping and experimentation. Popular examples include the ESP32 DevKit v1 and ESP32-S3-DevkitC, which are widely used by developers and hobbyists.
Why ESP32 DevKit v1?
Confession time: I wasn't aware of the many variants available when I first wanted to try out the ESP32. I searched on an e-commerce website, and most of the results showed ESP32-WROOM-32 with different vendor names. I just went with one of them. Later, I discovered there are other variants. However, at the time of writing this, most of them are either not easily accessible where I live or more expensive than this variant. This remains one of the popular choices for now.
So for this book, we'll keep it simple and choose the popular and affordable one "ESP32 DevKit V1" , perfect for development and learning.
How to find?
If you search on an e-commerce website, you'll most likely find it listed under a name like "ESP32 Development Board (ESP-WROOM-32)" which should have WiFi Bluetooth Dual Core (30 PIN) in specs.
You can compare the specifications and board pins with this.
Specs
The ESP32 is a dual-core 32-bit processor equipped with Wi-Fi and Bluetooth, perfect for creating wireless IoT applications.
The following are basic specs for the ESP32:
- Processor: Xtensa 32-bit LX6
- Number of Cores: 2
- Clock Frequency: 240MHz
- Flash Memory: 4 MB
- ROM: 448 KB (read-only programs essential for the operation of the ESP32)
- SRAM: 520 KB (to store data and instructions)
- ADC: 12-bit SAR ADC, 18 channels, 6 Input Pins
- UARTs: 3
- SPIs: 2
- I2Cs: 3
- Wi-Fi: IEEE 802.11 b/g/n/e/i (802.11n up to 150 Mbps)
- Bluetooth: v4.2 BR/EDR and Bluetooth Low Energy (BLE)
- Operation Voltage: 2.3-3.6V
- Deep Sleep: 100uA
Reference
ESP32 Pinout
License: CC-BY-SA 4.0
The above pinout diagram is derivative from the original diagram created by the CircuitState website. They also have provided detailed explanations for each pin. You can check it out here.
Disclaimer: This book is not affiliated with nor associated with CircuitState. I am including this diagram as i found it helpful during my research.
Keypoints
-
Input-Only GPIOs: GPIO pins 34, 35, 36, and 39 are input-only and cannot be used as output pins. In the pinout diagram, these pins are labeled with a "GPIX" prefix and marked with an "X" to indicate output is not allowed. Whenever possible, prefer using the pins highlighted in purple on the diagram.
-
Flashing and Debugging: GPIO 1 (Tx) and GPIO 3 (Rx) are designated for flashing and debugging purposes.
-
ADC Pins: Pins labeled as
ADC1_[Number]
are associated with ADC1, while those labeled asADC2_[Number]
are associated with ADC2.
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.
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.
#![allow(unused)] fn main() { 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.
#![allow(unused)] fn main() { cargo binstall espflash }
After installation, type the espflash command to verify that it works.
Quick Start
Before diving into the theory and concepts of how everything works, let's jump straight into action. Use this simple code to turn on the onboard LED of the ESP32 DevKit.
Blink LED with ESP HAL
ESP HAL is "no_std Hardware Abstraction Layers for ESP32 microcontrollers"
Setup project
To start the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 blinky
This will open a screen asking you to select options. For now, we dont need to select any options. Just save it by pressing "s" in the keyboard.
Next, navigate to the project folder:
cd blinky
Open the src/bin/main.rs file. It will contain a simple "Hello, World" code. We will modify this code to blink an LED on the board.
Blinking Code
This code creates a blinking effect by toggling an LED connected to a GPIO pin between high and low states.
Import Required Module
Additional import we need to set the LED as output pin
#![allow(unused)] fn main() { use esp_hal::gpio::{Io, Level, Output}; }
Initialize ESP HAL and Delay
Set up the ESP HAL and configure a delay
#![allow(unused)] fn main() { let peripherals = esp_hal::init(esp_hal::Config::default()); let delay = Delay::new(); }
Then lets set the LED GPIO pin "GPIO2" as an output pin with an initial state "High"(LED is turned on):
#![allow(unused)] fn main() { let io = Io::new(peripherals.GPIO, peripherals.IO_MUX); let mut led = Output::new(io.pins.gpio2, Level::High); }
Blinking Loop
Create a loop to toggle the LED state(between High and Low).
#![allow(unused)] fn main() { loop { led.toggle(); delay.delay_millis(500); led.toggle(); delay.delay_millis(500); } }
The full code
#![no_std] #![no_main] use esp_hal::delay::Delay; use esp_hal::prelude::*; use {esp_backtrace as _}; use esp_hal::gpio::{Io, Level, Output}; #[entry] fn main() -> ! { #[allow(unused)] let peripherals = esp_hal::init(esp_hal::Config::default()); let delay = Delay::new(); let io = Io::new(peripherals.GPIO, peripherals.IO_MUX); let mut led = Output::new(io.pins.gpio2, Level::High); loop { led.toggle(); delay.delay_millis(500); led.toggle(); delay.delay_millis(500); } }
Flash - Run Rust Run
All that's left is to flash the code onto our device and watch it go! The onboard LED should start blinking.
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 }
Async
Async programming is a type of concurrent programming that allows tasks to run concurrently without blocking each other. In embedded systems, it enables microcontrollers to handle multiple tasks, such as reading sensors or controlling other peripherals without waiting for each task to finish. You can read the "Asynchronous Programming in Rust" for more details.
Embassy
Embassy is a powerful framework for building safe, efficient, and asynchronous embedded applications in Rust. You can use it with ESP32, Pico and other microcontrollers.
Let's re-setup the same blinky project but with embassy support.
Setup project
To start the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 blinky-embassy
This will open a screen asking you to select options. But, this time we select "Adds embassy
framework support" and save it to generate the project template with support of embassy.
If you notice, the main function is now marked as async, along with a few other changes in the code. However, the core logic for blinking the LED remains the same.
The Full code
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_time::Timer; use esp_backtrace as _; use esp_hal::{ gpio::{Io, Level, Output}, prelude::*, }; use log::info; #[main] async fn main(_spawner: Spawner) { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); esp_println::logger::init_logger_from_env(); let timg0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG0); esp_hal_embassy::init(timg0.timer0); info!("Embassy initialized!"); let io = Io::new(peripherals.GPIO, peripherals.IO_MUX); let mut led = Output::new(io.pins.gpio2, Level::High); loop { led.set_high(); Timer::after_millis(500).await; led.set_low(); Timer::after_millis(500).await; } }
Reference
Core concepts
This section primarily covers theoretical concepts. Feel free to skip it and dive into the exercises instead. As you work through the exercises related to each concept, we'll link back to the theory. This approach may help you understand the material better by showing you exactly where and why these concepts are needed in practice.
Voltage Divider
A voltage divider is a simple circuit that reduces an input voltage \( V_{in} \) to a lower output voltage \( V_{out} \) using two series resistors. The resistor connected to the input voltage \( V_{in} \) is called \( R_{1} \), and the other resistor is \( R_{2} \). The output voltage \( V_{out} \) is taken from the junction between \( R_{1} \) and \( R_{2} \), producing a fraction of \( V_{in} \).
Circuit
The output voltage (Vout) is calculated using this formula:
\[ V_{out} = V_{in} \times \frac{R_2}{R_1 + R_2} \]
Example Calculation for \( V_{out} \)
Given:
- \( V_{in} = 3.3V \)
- \( R_1 = 10 k\Omega \)
- \( R_2 = 10 k\Omega \)
Substitute the values:
\[ V_{out} = 3.3V \times \frac{10 k\Omega}{10 k\Omega + 10 k\Omega} = 3.3V \times \frac{10}{20} = 3.3V \times 0.5 = 1.65V \]
The output voltage \( V_{out} \) is 1.65V.
fn main() { // You can edit the code // You can modify values and run the code let vin: f64 = 3.3; let r1: f64 = 10000.0; let r2: f64 = 10000.0; let vout = vin * (r2 / (r1 + r2)); println!("The output voltage Vout is: {:.2} V", vout); }
Use cases
Voltage dividers are used in applications like potentiometers, where the resistance changes as the knob is rotated, adjusting the output voltage. They are also used to measure resistive sensors such as light sensors and thermistors, where a known voltage is applied, and the microcontroller reads the voltage at the center node to determine sensor values like temperature.
Voltage Divider Simulation
Formula: Vout = Vin × (R2 / (R1 + R2))
Filled Formula: Vout = 3.3 × (10000 / (10000 + 10000))
Output Voltage (Vout): 1.65 V
Simulator in Falstad website
I used the website https://www.falstad.com/circuit/ to create the diagram. It's a great tool for drawing circuits. You can download the file I created, voltage-divider.circuitjs.txt
, and import it to experiment with the circuit.
ADC (Analog to Digital Converter)
An Analog-to-Digital Converter (ADC) is a device used to convert analog signals (continuous signals like sound, light, or temperature) into digital signals (discrete values, typically represented as 1s and 0s). This conversion is necessary for digital systems like microcontrollers (e.g., Raspberry Pi, Arduino) to interact with the real world. For example, sensors that measure temperature or sound produce analog signals, which need to be converted into digital format for processing by digital devices.
ADC Resolution
The resolution of an ADC refers to how precisely the ADC can measure an analog signal. It is expressed in bits, and the higher the resolution, the more precise the measurements.
- 8-bit ADC produces digital values between 0 and 255.
- 10-bit ADC produces digital values between 0 and 1023.
- 12-bit ADC produces digital values between 0 and 4095.
The resolution of the ADC can be expressed as the following formula: \[ \text{Resolution} = \frac{\text{Vref}}{2^{\text{bits}} - 1} \]
ESP32
The ESP32 has 12-bit Analogue to Digital Converter (ADC). So, it provides values ranging from 0 to 4095 (4096 possible values)
\[ \text{Resolution} = \frac{3.3V}{2^{12} - 1} = \frac{3.3V}{4095} \approx 0.000805 \text{V} \approx 0.8 \text{mV} \]
Pins
//TODO: details of ESP32 ADC Pins
ADC Value and LDR Resistance in a Voltage Divider
In a voltage divider with an LDR and a fixed resistor, the output voltage \( V_{\text{out}} \) is given by:
\[ V_{\text{out}} = V_{\text{in}} \times \frac{R_{\text{LDR}}}{R_{\text{LDR}} + R_{\text{fixed}}} \]
It is same formula as explained in the previous chapter, just replaced the \({R_2}\) with \({R_{\text{LDR}}}\) and \({R_1}\) with \({R_{\text{fixed}}}\)
- Bright light (low LDR resistance): \( V_{\text{out}} \) decreases, resulting in a lower ADC value.
- Dim light (high LDR resistance): \( V_{\text{out}} \) increases, leading to a higher ADC value.
Example ADC value calculation:
Bright light:
Let's say the Resistence value of LDR is \(1k\Omega\) in the bright light (and we have \(10k\Omega\) fixed resistor).
\[ V_{\text{out}} = 3.3V \times \frac{1k\Omega}{1k\Omega + 10k\Omega} \approx 0.3V \]
The ADC value is calculated as: \[ \text{ADC value} = \left( \frac{V_{\text{out}}}{V_{\text{ref}}} \right) \times (2^{12} - 1) \approx \left( \frac{0.3}{3.3} \right) \times 4095 \approx 372 \]
Darkness:
Let's say the Resistence value of LDR is \(140k\Omega \) in very low light.
\[ V_{\text{out}} = 3.3V \times \frac{140k\Omega}{140k\Omega + 10k\Omega} \approx 3.08V \]
The ADC value is calculated as: \[ \text{ADC value} = \left( \frac{V_{\text{out}}}{V_{\text{ref}}} \right) \times (2^{12} - 1) \approx \left( \frac{3.08}{3.3} \right) \times 4095 = 3822 \]
Converting ADC value back to voltage:
Now, if we want to convert the ADC value back to the input voltage, we can multiply the ADC value by the resolution (0.8mV).
For example, let's take an ADC value of 3822:
\[ \text{Voltage} = 3822 \times 0.8mV = 3057.6mV \approx 3.06V \]
Reference
Analog to Digital Converter (ADC) in ESP32
The ESP32 comes with two 12-bit SAR ADCs and supports up to 18 measurement channels. However, the devkit we are using has only 15 ADC pins, so it supports only 15 channels.
The first one (ADC1) provides 8 channels, which are mapped to GPIO32 through GPIO39. The second one (ADC2) offers 10 channels, mapped to GPIO0, GPIO2, GPIO4, GPIO12 through GPIO15, and GPIO25 through GPIO27.
You can further read about the ADC in ESP32 in the technical reference manual.
If you are using Wi-Fi on the ESP32, you can't use ADC2 pins since they are used by the Wi-Fi module. You can only use ADC1 pins.
ADC Pins in ESP32 Devkit V1
ADC Vref and Attenuation in ESP32
The ADC needs a reference voltage to compare with the input voltage, in order to calculate digital values (ranging from 0 to 4095 since it is a 12-bit ADC) of the input voltage. This reference voltage, called Vref (Voltage Reference), helps the ADC map input voltages into this range.
The ESP32 uses a Vref of approximately 1.1V. This means it can only map input voltages between 0V and 1.1V. But what happens when the input voltage is higher than 1.1V? That's where attenuation comes into play.
Attenuation, in simple terms, means reducing something. In our case, it helps map higher input voltages into the range of Vref. This way, the ADC can map voltages greater than 1.1V into the 0 to 4095 range.
In esp-hal
, this is represented as enum Attenuation
; we need to configure to use the ADC in the code.
The ESP32 supports four levels of attenuation:
Attenuation Level | Enum in esp-hal | Measurable input voltage range |
---|---|---|
0 dB | Attenuation::Attenuation0dB | 100 mV ~ 950 mV |
2.5 dB | Attenuation::Attenuation2p5dB | 100 mV ~ 1250 mV |
6 dB | Attenuation::Attenuation6dB | 150 mV ~ 1750 mV |
11 dB | Attenuation::Attenuation11dB | 150 mV ~ 2450 mV |
The ADC in the ESP32 is known to have non-linearity issues. However, since most of our exercises do not require high accuracy, we won't get into these details.
Reference
Pulse Width Modulation (PWM)
In this section, we will explore what is PWM and why we need it.
Digital vs Analog
In a digital circuit, signals are either high (such as 5V or 3.3V) or low (0V), with no in-between values. These two distinct states make digital signals ideal for computers and digital devices, as they're easy to store, read, and transmit without losing accuracy.
Analog signals, however, can vary continuously within a range, allowing for any value between a High and Low voltage. This smooth variation is valuable for applications requiring fine control, such as adjusting audio volume or light brightness.
Devices like servo motors and LEDs(for dimming effect) often need gradual, precise control over voltage, which analog signals provide through their continuous range.
Microcontrollers use PWM to bridge this gap.
What is PWM?
PWM stands for Pulse Width Modulation, creates an analog-like signal by rapidly pulsing a digital signal on and off. The average output voltage, controlled by adjusting the pulse's high duration or "duty cycle," can simulate a continuous analog level.
Duty Cycle
The duty cycle is the percentage of time a signal is "on" during one complete cycle.
For example:
- 100% duty cycle means the signal is always on.
- 50% duty cycle means the signal is on half the time and off half the time.
- 0% duty cycle means the signal is always off.
Here is the interactive simulation. Use the sliders to adjust the duty cycle and frequency, and watch how the pulse width and LED brightness change. The upper part of the square wave represents when the signal is high (on). The lower part represents when the signal is low(off)
If you change the duty cycle from "low to high" and "high to low" in the simulation, you should notice the LED kind of giving a dimming effect.
Period and Frequency
Period is the total time for one on-off cycle to complete.
The frequency of a PWM signal is the number of cycles it completes in one second, measured in Hertz (Hz). Frequency is the inverse of the period. So, a higher frequency means a shorter period, resulting in faster switching between HIGH and LOW states.
\[ \text{Frequency (Hz)} = \frac{1}{\text{Period (s)}} \]
So if the period is 1 second, then the frequency will be 1HZ.
\[ 1 \text{Hz} = \frac{1 \text{ cycle}}{1 \text{ second}} = \frac{1}{1 \text{ s}} \]
For example, if the period is 20ms(0.02s), the frequency will be 50Hz.
\[ \text{Frequency} = \frac{1}{20 \text{ ms}} = \frac{1}{0.02 \text{ s}} = 50 \text{ Hz} \]
Calculating Cycle count from Frequency per second
The Formula to calculate cycle count:
\[
\text{Cycle Count} = \text{Frequency (Hz)} \times \text{Total Time (seconds)}
\]
If a PWM signal has a frequency of 50Hz, it means it completes 50 cycles in one second.
In the next chapter, we will go in depth into the PWM and timer.
PWM in Depth
Timer Operation
The timer plays a key role in the PWM generator. It counts from zero to a specified maximum value (stored in a register), then resets and starts the cycle over. This counting process determines the duration of one complete cycle, called the period.
Compare Value
The timer's hardware compares its current count with a compare value (stored in a register). When the count is less than the compare value, the signal stays high; when the count exceeds the compare value, the signal goes low.
PWM Resolution
In PWM (Pulse Width Modulation), resolution refers to how precisely the duty cycle can be controlled. This is determined by the number of bits used in the PWM's compare register.
The timer counts from 0 to a maximum value based on the resolution. The higher the resolution, the more finely the duty cycle can be adjusted.
For a system with n bits of resolution, the timer can count from 0 to \(2^n - 1\), which gives \(2^n\) possible levels for the duty cycle.
For example:
- 8-bit resolution allows the timer to count from 0 to 255, providing 256 possible duty cycle levels.
- 10-bit resolution allows the timer to count from 0 to 1023, providing 1024 possible duty cycle levels.
Higher resolution gives more precise control over the duty cycle but also means the timer must count more values within the same period, which could lower the frequency or require more processing power. Essentially, the resolution defines how many distinct duty cycle values can be set, with more bits offering finer adjustments.
Simulation
You can modify the PWM resolution bits and duty cycle in this simulation. Adjusting the PWM resolution bits increases the maximum count but remains within the time period (it does not affect the duty cycle). Changing the duty cycle adjusts the on and off states accordingly, but it also stays within the period.
Relationship Between Duty Cycle, Frequency, and Resolution
This diagram illustrates the relationship between duty cycle, frequency, period, pulse width, and resolution. While it may seem a bit complex at first glance, breaking it down helps to clarify these concepts.
In this example, the timer resolution is 4 bits, meaning the timer counts from 0 to 15. When the timer reaches its maximum value, an overflow interrupt is triggered (indicated by the blue arrow), and the counter resets to 0. The time it takes for the timer to count from 0 to its maximum value is called as "period".
The duty cycle is configured to 50%, meaning the signal remains high for half the period. At each step in the counting process, the timer compares its current count with the duty cycle's compare value. When the timer count exceeds this compare value (marked by the yellow arrow), the signal transitions from high to low. This triggers the compare interrupt, signaling the state change.
The time during which the signal is high is referred to as the pulse width.
LED PWM Controller(LEDC)
The ESP32 has LED PWM Controller(LEDC) that generates PWM signals for controlling LEDs(example, dimming effect). However, its functionality isn't limited to LEDs;you can use it for other applications as well. If you are not familiar with PWM(Pulse Width Modulation), i recommend you to check intro to the PWM here.
The LEDC includes 16 independent PWM generators and supports a maximum PWM duty cycle resolution of 20 bits. The 16 PWM channels further classified into two types: 8 high speed channel and 8 low speed channels.
High-speed channels use hardware to automatically adjust the PWM duty cycle in a glitch-free manner, ensuring smooth operation. In contrast, low-speed channels rely on software to manually adjust the duty cycle.
The PWM controller can automatically increase or decrease the duty cycle gradually, allowing for smooth fades without using the processor.
Clock Source
A clock source in a microcontroller is like the heartbeat of the system. It gives the microcontroller a regular "tick", which helps it keep track of time and coordinate all its tasks. In the ESP32, you can use different clock sources to manage the timers.
Image is taken from the ESP32 technical reference manualThere are four high-speed clock modules available, which can be assigned to the high-speed channels. The high-speed timer modules in the ESP32 can be clocked by sources such as REF_TICK or APB_CLK. In the esp-hal Rust library, these timers are represented by the timer::Number enum, which includes Timer0
, Timer1
, Timer2
, and Timer3
.
There are also four low-speed clock modules available, which can be assigned to the low-speed channels. These low-speed timers can be clocked from either REF_TICK or SLOW_CLOCK. The SLOW_CLOCK source can be either the APB_CLK (80 MHz) or the 8 MHz internal oscillator, with the selection between these sources managed by the LEDC_APB_CLK_SEL setting.
The esp-hal also defines two enums: one for the high-speed clock source (HSClockSource
) and another for the low-speed clock source (LSClockSource
). Currently, both enums have a single entry, APBClk
.
For more details, refer to page 390 of the ESP32 Technical Reference Manual.
Calculating PWM Duty Resolution
In the ESP-HAL, we need to specify both the duty resolution and the frequency when configuring the timer object. Therefore, it's important to understand how to calculate the duty resolution from a desired frequency and how to determine the frequency based on a given duty resolution.
These are the formulas taken from the ESP32 technical reference manual but the variable names are simplified.
Formula for PWM Signal Frequency
The frequency of the PWM signal \( f_{\text{pwm}} \) can be calculated using the following formula:
\[ f_{\text{pwm}} = \frac{f_{\text{LEDC_CLK}}}{\text{clock_divider} \cdot 2^{\text{res_bits}}} \]
Where:
- \( f_{\text{LEDC_CLK}} \) is the frequency of the clock source for the PWM timer (e.g., APB_CLK, RC_FAST_CLK, REF_TICK).
- \( \text{clock_divider} \) is the division factor for the clock source.
- \( \text{res_bits} \) is the duty resolution in bits.
PWM Duty Resolution Formula
This is the formula derived from the previous formula to calculate the desired duty resolution. \[ \text{res_bits} = \log_2 \left( \frac{f_{\text{LEDC_CLK}}}{f_{\text{pwm}} \cdot \text{clock_divider}} \right) \]
This formula gives the duty resolution in bits, which represents the number of discrete levels available for the duty cycle of the PWM signal.
Calculating the Highest Resolution
The highest resolution is achieved when the clock divisor (\( \text{clock_divider} \)) is set to 1, meaning no division is applied to the clock. It is calculated as:
\[ \text{Highest Resolution} = \log_2 \left( \frac{f_{\text{LEDC_CLK}}}{f_{\text{pwm}} \cdot 1} \right) \]
This calculation gives the maximum number of bits that can be used for the duty cycle at a given clock frequency and PWM signal frequency.
Example:
For a 1 kHz PWM signal with an APB_CLK of 80 MHz:
\[ \text{Highest Resolution} = \log_2 \left( \frac{80,000,000}{1,000 \cdot 1} \right) = \log_2(80,000) \approx 16 \]
Thus, the highest resolution for a 1 kHz PWM signal using an 80 MHz clock is 16 bits.
Calculating the Lowest Resolution
The division factor ranges from 1 ∼ 1023, as per the datasheet. The lowest resolution is calculated when the clock divisor is at its maximum value. In this case, the clock divisor is \( 1023 + \frac{255}{256} \). The lowest resolution is calculated as:
\[ \text{Lowest Resolution} = \log_2 \left( \frac{f_{\text{LEDC_CLK}}}{f_{\text{pwm}} \cdot \left( 1023 + \frac{255}{256} \right)} \right) \]
This calculation gives the minimum number of bits required for the duty cycle control at the specified PWM signal frequency.
Example:
For the same 1 kHz PWM signal with the APB_CLK at 80 MHz:
\[ \text{Lowest Resolution} = \log_2 \left( \frac{80,000,000}{1,000 \cdot 1023.996} \right) = \log_2 \left( \frac{80,000,000}{1,023,996} \right) \approx 6.28 \]
Thus, the lowest resolution for a 1 kHz PWM signal using an 80 MHz clock is 7 bits.
Commonly-used PWM Frequencies and Resolutions
This table is from the datasheet, summarizes the highest and lowest resolutions for common PWM frequencies with different clock sources:
Clock Source | PWM Frequency | Highest Resolution (bits) | Lowest Resolution (bits) |
---|---|---|---|
APB_CLK (80 MHz) | 1 kHz | 16 | 7 |
APB_CLK (80 MHz) | 5 kHz | 13 | 4 |
APB_CLK (80 MHz) | 10 kHz | 12 | 3 |
RC_FAST_CLK (8 MHz) | 1 kHz | 12 | 3 |
RC_FAST_CLK (8 MHz) | 2 kHz | 11 | 2 |
REF_TICK (1 MHz) | 1 kHz | 9 | 1 |
Motor Control Pulse Width Modulator (MCPWM)
The ESP32 has two PWM peripherals: LED Controller and MCPWM. In this chapter, we will introduce the MCPWM.
MCPWM is designed for motor and power control. The ESP32 features two MCPWM units: MCPWM0 and MCPWM1. The diagram below provides an overview of a single MCPWM module.
Each MCPWM has prescaler(clock divider), three timers, three operators, fault handler and a capture module.
Prescaler
The prescaler is used to reduce the base clock frequency before it's applied to the PWM signal. The MCPWM is driven by a clock with a frequency of 160 MHz, meaning it ticks 160 million times per second. This clock serves as the base frequency for the MCPWM module. However, the prescaler modifies this base frequency by dividing it, effectively reducing the clock frequency that the PWM signal uses.
To calculate the duration of each clock cycle, we take the inverse of the frequency, which gives us:
\[ \text{Period} = \frac{1}{160 \times 10^6} \text{ s} = 0.00000000625 \text{ s} = 6.25 \text{ ns} \]
This means each clock cycle lasts 6.25 nanoseconds (ns).
The PWM_CLK_PRESCALE register(8 bits) in the ESP32 allows you to adjust this base clock by dividing it. Once the prescaler is applied to the base clock, it effectively reduces the clock frequency. The resulting PWM period is calculated using the following formula:
\[ \text{PWM Clock Period} = 6.25 \text{ns} \times (\text{Prescaler Value} + 1) \]
Example:
Let's say you set the prescaler value to 159.
\[ \text{PWM Clock Period} = 6.25 \text{ ns} \times (159 + 1) = 6.25 \text{ ns} \times 160 = 1000 \text{ ns} \]
To calculate the PWM frequency, we take the inverse of the period:
\[ \text{PWM Frequency} = \frac{1}{\text{PWM Clock Period}} = \frac{1}{1000 \text{ ns}} = 1 \text{ MHz} \]
Thus, with a prescaler value of 159, the PWM signal will have a period of 1000 ns and a frequency of 1 MHz.
With a maximum prescaler value of 255 (since it's an 8-bit value), you can achieve the PWM frequency of:
\[ \text{PWM Frequency} = \frac{1}{6.25 \text{ ns} \times (255 + 1)} = \frac{1}{1600 \text{ ns}} = 625 \text{ KHz} \]
In esp-hal
The PeripheralClockConfig
struct in esp-hal is responsible for the clock configuration of the MCPWM. It offers two functions to initialize with a prescaler or a frequency.
#![allow(unused)] fn main() { // This functions automatically calculate the prescaler to achieve the 1MHz let clock_cfg = PeripheralClockConfig::with_frequency(1.MHz()).unwrap(); }
Timer
The timer is responsible for counting up to a specified value (referred to as the "period"), at which point it resets and starts counting again. This helps control the timing or frequency of the output signal. Each timer has an 8-bit clock prescaler.
It has 16-bit counter that can operate in three modes: In esp-hal, this is represented as enum PwmWorkingMode.
- PwmWorkingMode::Increase : couting up, where the timer starts at zero and counts upwards to the period value before resetting.
- PwmWorkingMode::Decrease : counting down, where the timer starts at the period value and counts down to zero before resetting
- PwmWorkingMode::UpDown : counting up-down, where the timer alternates between counting up and counting down, creating a symmetrical cycle.
In esp-hal
The esp-hal has a TimerClockConfig
struct which you can initialize via the clock config instance. You need to call the timer_clock_with_frequency
function (or the timer_clock_with_prescaler) with arguments:
- The number of ticks in the period
- The pwm working mode
- The target frequency
Example:
To achieve a 50 Hz frequency (20 ms or 0.02 s) with a base clock frequency of 1 MHz (assuming we initialized clock_cfg with 1 MHz), we need to calculate the cycle count for the period of 0.02 s (20 ms) at 1 MHz. The calculation will be:
\[ \text{Counts} = 1,000,000 \text{ Hz} \times 0.02 \text{ s} = 20,000 \]
So, we can then call the function to initialize the PWM timer:
#![allow(unused)] fn main() { let timer_clock_cfg = clock_cfg .timer_clock_with_frequency(19_999, PwmWorkingMode::Increase, 50.Hz()) .unwrap(); }
We are passing 19,999 instead of 20,000 because the function internally increments it by 1, resulting in 20,000.
Operator
The PWM operator generates the desired output waveform using the timing references from the PWM timer. Each PWM operator features two outputs: PWMxA and PWMxB, with configurable dead-time for both rising and falling edges.
We have configured the MCPWM0 peripheral and selected operator0 to use the PWMxA output.
#![allow(unused)] fn main() { let mut mcpwm = McPwm::new(peripherals.MCPWM0, clock_cfg); let mut pwm_pin = mcpwm .operator0 .with_pin_a(peripherals.GPIO33, PwmPinConfig::UP_ACTIVE_HIGH); }
Here, the UP_ACTIVE_HIGH sets the UP_ACTIVE_HIGH for the PWM action and SYNC_ON_ZERO for the PWM update method. So, what this means? The output signal will be set High when the timer count is less than the timestamp value. Then, the new timestamp will be applied when the timer reaches zero.
set_timestamp function in esp-hal
The set_timestamp function sets the time at which the PWM signal should change. When the timer reaches the value passed to this function, it updates the PWM signal according to the configured PwmUpdateMethod. In our example, we have configured UP_ACTIVE_HIGH in the operator, so the output will stay High until the timer reaches the timestamp value. Once the timestamp is reached, the signal will go Low for the rest of the PWM cycle.
For example, if we set the timestamp value to 500, the signal will remain High until the timer count reaches 500. Once it reaches 500, the signal will be Low for the remaining count of 19,500 (20,000 - 500).
#![allow(unused)] fn main() { pwm_pin.set_timestamp(500); }
LEDC vs MCPWM
LEDC (LED Controller) is designed for controlling LEDs but can also be used for doing general PWM tasks, making it ideal for basic applications like LED dimming or simple motor control.
MCPWM (Motor Control PWM) is specifically built for motor control, with advanced features like dead time insertion and fault handling, making it better suited for precise and complex motor control.
Typical use cases of MCPWM peripheral as per the API Reference document:
- Digital motor control, e.g., brushed/brushless DC motor, RC servo motor
- Switch mode-based digital power conversion
- Power DAC, where the duty cycle is equivalent to a DAC analog value
- Calculate external pulse width, and convert it into other analog values like speed, distance
- Generate Space Vector PWM (SVPWM) signals for Field Oriented Control (FOC)
For controlling a hobby servo motor like the SG90, LEDC is simple and sufficient, but MCPWM can also be used.
Reference
- MCPWM API Reference , latest API Reference
- For more details, refer to page 417 of the ESP32 Technical Reference Manual
- ESP32 MCPWM as SPWM generator
No Standard and Main in Rust
If you haven't read "The Embedded Rust Book" yet, I highly recommend you to check it out. https://docs.rust-embedded.org/book/intro/index.html
#![no_std]
The #![no_std]
attribute disables the use of the standard library (std). This is necessary most of the times for embedded systems development, where the environment typically lacks many of the resources (like an operating system, file system, or heap allocation) that the standard library assumes are available.
Related Resources:
#![no_main]
The #![no_main]
attribute is to indicate that the program won't use the standard entry point (fn main). Instead, it provides a custom entry point, usually required when working with embedded systems where the runtime environment is minimal or non-existent.
Related Resources:
Panic Handler
A panic handler is a function in Rust that defines what happens when your program encounters a panic. In environments without the standard library (when using no_std attribute), you need to create this function yourself using the #[panic_handler] attribute. This function must follow a specific format and can only appear once in your program. It provides details about the error, such as where it happened and why. By setting up a panic handler, you can choose how to respond to errors, like logging them for later review or stopping the program completely.
You don't have to define your own panic handler function; you can use existing crates such as panic_halt or panic_probe instead.
For example, we used the panic_halt crate to halt execution when a panic occurs.
#![allow(unused)] fn main() { use panic_halt as _; }
The program will stop and remain in this infinite loop whenever a panic occurs.
In fact, the panic_halt crate's code implements a simple panic handler, which looks like this:
#![allow(unused)] fn main() { use core::panic::PanicInfo; use core::sync::atomic::{self, Ordering}; #[inline(never)] #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop { atomic::compiler_fence(Ordering::SeqCst); } } }
Related Resources:
LED Fading effect
In this section, we will learn how to create a fading effect(i.e. reducing and increasing the brightness) for an LED using the ESP32. First, we will fade the onboard LED, which is connected to GPIO 2.
Prerequisites
To make the fading effect, we use a technique called PWM (Pulse Width Modulation).
Recommended to read these chapters
We will gradually increment the PWM's duty cycle to increase the brightness, then we gradually decrement the PWM duty cycle to reduce the brightness of the LED. This effectively creates the fading effect on the LED
The Eye
" Come in close... Closer...
Because the more you think you see... The easier it'll be to fool you...
Because, what is seeing?.... You're looking but what you're really doing is filtering, interpreting, searching for meaning... "
In PWM, we basically set a percentage to specify how long the signal stays HIGH (ON) and LOW (OFF) in each cycle. For instance, with a 50% duty cycle, the signal is ON for half the time and OFF for the other half, making the LED light up for just half the cycle.
Now, here's the cool part: when this switching happens super quickly, our eyes can't keep up. Instead of seeing the blinking, it just looks like the brightness changes! The longer the LED stays ON, the brighter it seems, and the shorter it's ON, the dimmer it looks. It's like tricking your brain into thinking the LED is smoothly dimming or brightening. PWM: the magician of electronics!
Writing Rust Code to Create an LED Fading Effect on ESP32
Now comes the fun part; let's dive into the coding!
Generate project using esp-generate
You have done this step already in the quick start section.
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 led-fader
This will open a screen asking you to select options. For now, we dont need to select any options. Just save it by pressing "s" in the keyboard.
Let's start by initializing the peripherals with the default configuration. This function configures the CPU clock and watchdog, and then returns the instance of the peripherals.
#![allow(unused)] fn main() { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); }
Next, we take our desired GPIO from the peripherals instance. In this case, we're turning on the onboard LED of the Devkit, which is connected to GPIO 2.
#![allow(unused)] fn main() { let led = peripherals.GPIO2; }
PWM configuration
In this exercise, we will be using the low-speed PWM channel. First, we need to set the clock source. The esp-hal library defines the LSGlobalClkSource
enum for the low-speed clock source, which currently has only one value: APBClk
.
#![allow(unused)] fn main() { ledc.set_global_slow_clock(LSGlobalClkSource::APBClk); }
Next, we configure the timer. Since we are using the low-speed PWM channel, we obviously need to use the low-speed timer. We also have to specify which low-speed timer to use (from 0 to 3).
#![allow(unused)] fn main() { let mut lstimer0 = ledc.timer::<LowSpeed>(timer::Number::Timer0); }
We need to do a few more configurations before using the timer. We'll set the frequency to 24 kHz. For this frequency with the APB clock, the formula gives a maximum resolution of 12 bits and a minimum resolution of 2 bits. In the esp-hal, a 5-bit PWM resolution is used for this frequency, and we will use the same.
#![allow(unused)] fn main() { lstimer0.configure(timer::config::Config { duty: timer::config::Duty::Duty5Bit, clock_source: timer::LSClockSource::APBClk, frequency: 24.kHz(), }) .unwrap(); }
PWM Channels
Next, we configure the PWM channel. We'll use channel0 and set it up with the selected timer and initial duty percentage "10%". Additionally, we'll set the pin configuration as PushPull.
#![allow(unused)] fn main() { let mut channel0 = ledc.channel(channel::Number::Channel0, led); channel0.configure(channel::config::Config { timer: &lstimer0, duty_pct: 10, pin_config: channel::config::PinConfig::PushPull, }) .unwrap(); }
Fading
The esp-hal has a function called start_duty_fade, which makes our job easier. Otherwise, we would have to manually increment and decrement the duty cycle in a loop at regular intervals. This function gradually changes from one duty cycle percentage to another. It also accepts a third parameter, which specifies how much time it should take to transition from one duty cycle to another.
#![allow(unused)] fn main() { channel0.start_duty_fade(0, 100, 1000).unwrap(); }
We will run this in a loop and use another function provided by the HAL, is_duty_fade_running; It returns boolean value whether the duty fade is complete or not.
#![allow(unused)] fn main() { while channel0.is_duty_fade_running() {} }
The full code
#![no_std] #![no_main] use esp_backtrace as _; use esp_hal::{ ledc::{ channel::{self, ChannelIFace}, timer::{self, TimerIFace}, LSGlobalClkSource, Ledc, LowSpeed, }, prelude::*, }; #[entry] fn main() -> ! { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); let led = peripherals.GPIO2; // let led = peripherals.GPIO5; let mut ledc = Ledc::new(peripherals.LEDC); ledc.set_global_slow_clock(LSGlobalClkSource::APBClk); let mut lstimer0 = ledc.timer::<LowSpeed>(timer::Number::Timer0); lstimer0 .configure(timer::config::Config { duty: timer::config::Duty::Duty5Bit, clock_source: timer::LSClockSource::APBClk, frequency: 24.kHz(), }) .unwrap(); let mut channel0 = ledc.channel(channel::Number::Channel0, led); channel0 .configure(channel::config::Config { timer: &lstimer0, duty_pct: 10, pin_config: channel::config::PinConfig::PushPull, }) .unwrap(); loop { channel0.start_duty_fade(0, 100, 1000).unwrap(); while channel0.is_duty_fade_running() {} channel0.start_duty_fade(100, 0, 1000).unwrap(); while channel0.is_duty_fade_running() {} } }
Clone the existing project
You can also clone (or refer) project I created and navigate to the led-fader
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/led-fader
Flashing
Once you flash the code into the ESP32, you should see the fading effect on the onboard LED.
#![allow(unused)] fn main() { cargo run --release }
Using external LED
You can do the same fading effect with external LED.
Hardware Requirements
- External LED
- Resistor (330 Ohms)
- Jumper wires (optional)
- Breadboard (optional) - You might need two breadboards to fit the ESP32 devkit properly, as it's quite wide. I bought two small breadboards and placed one side of the ESP32 on each.
Circuit
- Connect the anode (longer leg) of the external LED to ESP32's GPIO 5 through the 330-ohm resistor
- Connect the cathode (shorter leg) of the LED to the ground (GND) pin of the ESP32
Code changes
In the code, all you have to do is change the GPIO number from 2 to 5.
#![allow(unused)] fn main() { let led = peripherals.GPIO5; }
High speed channel
There's no fun in just changing one line. Let's use high-speed channel this time. To do that, we have to pass HighSpeed
struct and update the clock source to use the HSClockSource
enum.
#![allow(unused)] fn main() { let ledc = Ledc::new(peripherals.LEDC); let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0); hstimer0 .configure(timer::config::Config { duty: timer::config::Duty::Duty5Bit, clock_source: timer::HSClockSource::APBClk, frequency: 24.kHz(), }) .unwrap(); }
Clone the existing project
You can also clone (or refer) project I created and navigate to the led-highfader
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/led-highfader
Buzzinga
In this section, we will explore some fun activities using the buzzer
. I chose the title "Buzzinga" just for fun (a nod to Sheldon's "Bazinga" in The Big Bang Theory); it's not a technical term.
- Active or Passive Buzzer
- Jumper Wires:
Introduction to Buzzer
A buzzer is an electronic device used to generate sound, beeps, or even melodies, and is commonly found in alarm systems, timers, computers, and for confirming user inputs, such as mouse clicks or keystrokes. Buzzers serve as audio signaling devices, providing audible feedback for various actions.
Active Buzzer vs Passive Buzzer
Active Buzzer:
-
Built-in Oscillator: An active buzzer has an internal oscillator that generates the tone automatically when power is applied. You can identify whether you have active buzzer or not by connecting the buzzer directly to the battery and it will make a sound.
-
Simpler Usage: No need to worry about generating specific frequencies since the buzzer does it internally.
-
Tone: Typically produces a single tone or a fixed frequency.
-
How to identify: Usually has a white covering on top and a black smooth finish at the bottom. It produces sound when connected directly to a battery.
Passive Buzzer:
- External Signal Required: A passive buzzer requires an external signal to generate sound. It does not have an internal oscillator, so it relies on a microcontroller to provide a frequency.
- Flexible Tones: You can control the frequency and create different tones, melodies, or alarms based on the input signal.
- How to identify: Typically has no covering on the top and looks like a PCB-style blue or green covering at the bottom.
Which one ?
Choose Active Buzzer if:
- You need a simple, fixed tone or beep. It's ideal for basic alerts, alarms, or user input confirmation.
Choose Passive Buzzer if:
- You want to generate different tones, melodies, or sound patterns.
Connecting Buzzer with ESP32
The buzzer has two pins: Positive(Signal), Ground; The positive side of the buzzer is typically marked with a + symbol and is the longer pin, while the negative side (ground) is the shorter pin, similar to an LED. However, some passive buzzers may allow for either pin to be connected to ground or signal, depending on the specific model.
By the way, I used an active buzzer in my experiment. A passive buzzer is recommended if you plan to play different sounds, as it provides a better tone.
ESP32 Pin | Wire | Buzzer Pin | Notes |
---|---|---|---|
GPIO 33 |
|
Positive Pin | Receives PWM signals to produce sound. |
GND |
|
Ground Pin | Connects to ground. |
Creating a Beep Sound with an Active Buzzer Using ESP32 and Rust
Since you already know that an active buzzer is simple to use, you can make it beep just by powering it. In this exercise, we'll make it beep with just a little code.
Hardware Requirements
- Active Buzzer
- Female-to-Male or Male-to-Male jumper wires (depending on your setup)
Generate project using esp-generate
You have done this step already in the quick start section.
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 active-buzzer
This will open a screen asking you to select options. For now, we dont need to select any options. Just save it by pressing "s" in the keyboard.
Code
We will set GPIO 33 as our output pin with an initial Low state. This is the pin where we connected the positive pin of the buzzer.
#![allow(unused)] fn main() { let mut buzzer = Output::new(peripherals.GPIO33, Level::Low); }
The logic is straightforward: set the buzzer pin to High for 500 milliseconds, then to Low for another 500 milliseconds in a loop. This causes the buzzer to produce a beeping sound.
#![allow(unused)] fn main() { let delay = Delay::new(); loop { buzzer.set_high(); delay.delay_millis(500); buzzer.set_low(); delay.delay_millis(500); } }
Clone the existing project
You can clone (or refer) project I created and navigate to the active-buzzer
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/active-buzzer
Playing Songs on a Passive Buzzer Using Rust and ESP32
We are going to play songs on the buzzer. If you're unsure about musical notes and sheet music, feel free to check out the quick theory I've provideded here.
I've splitted the code into rust module(you can do it in single file as we have done so far): music
, got
.
A passive buzzer is recommended for this exercise, though you can use either a passive or active buzzer.
PWM
We will use PWM to adjust the frequency of the signal sent to the buzzer, with each frequency corresponding to a musical note. The frequency (musical note) will be held for a specific duration before switching to the next note, as per the music sheet.
For example, the note "A4" is 440Hz and is played for some X duration. We set this frequency in PWM and add a delay for that X duration.
I recommend reading the PWM section to familiarize yourself with how PWM works.
Song Repository
We will playing the Pink Panther theme in this exercise. However, you can refer to the rust-embedded-songs repository i created and use different songs also.
Introduction to Music Notes and Sheet Music
This is a brief guide to music notes and sheet music. While it may not cover everything, it provides a quick reference for key concepts.
Music Sheet
The notes for the music are based on the following sheet. You can refer to this Musescore link for more details.
In music, note durations are represented by the following types, which define how long each note is played:
- Whole note: The longest note duration, lasting for 4 beats.
- Half note: A note that lasts for 2 beats.
- Quarter note: A note that lasts for 1 beat.
- Eighth note: A note that lasts for half a beat, or 1/8th of the duration of a whole note.
- Sixteenth note: A note that lasts for a quarter of a beat, or 1/16th of the duration of a whole note.
Dotted Notes
A dotted note is a note that has a dot next to it. The dot increases the note's duration by half of its original value. For example:
- Dotted half note: A half note with a dot lasts for 3 beats (2 + 1).
- Dotted quarter note: A quarter note with a dot lasts for 1.5 beats (1 + 0.5).
Tempo and BPM (Beats Per Minute)
Tempo refers to the speed at which a piece of music is played. It is usually measured in beats per minute (BPM), indicating how many beats occur in one minute.
Music module
In the music module, we define constants for common notes and their corresponding frequency values.
#![allow(unused)] fn main() { // Note frequencies in Hertz as f64 pub const NOTE_B0: f64 = 31.0; pub const NOTE_C1: f64 = 33.0; pub const NOTE_CS1: f64 = 35.0; pub const NOTE_D1: f64 = 37.0; pub const NOTE_DS1: f64 = 39.0; pub const NOTE_E1: f64 = 41.0; pub const NOTE_F1: f64 = 44.0; pub const NOTE_FS1: f64 = 46.0; pub const NOTE_G1: f64 = 49.0; pub const NOTE_GS1: f64 = 52.0; pub const NOTE_A1: f64 = 55.0; pub const NOTE_AS1: f64 = 58.0; pub const NOTE_B1: f64 = 62.0; pub const NOTE_C2: f64 = 65.0; pub const NOTE_CS2: f64 = 69.0; pub const NOTE_D2: f64 = 73.0; pub const NOTE_DS2: f64 = 78.0; pub const NOTE_E2: f64 = 82.0; pub const NOTE_F2: f64 = 87.0; pub const NOTE_FS2: f64 = 93.0; pub const NOTE_G2: f64 = 98.0; pub const NOTE_GS2: f64 = 104.0; pub const NOTE_A2: f64 = 110.0; pub const NOTE_AS2: f64 = 117.0; pub const NOTE_B2: f64 = 123.0; pub const NOTE_C3: f64 = 131.0; pub const NOTE_CS3: f64 = 139.0; pub const NOTE_D3: f64 = 147.0; pub const NOTE_DS3: f64 = 156.0; pub const NOTE_E3: f64 = 165.0; pub const NOTE_F3: f64 = 175.0; pub const NOTE_FS3: f64 = 185.0; pub const NOTE_G3: f64 = 196.0; pub const NOTE_GS3: f64 = 208.0; pub const NOTE_A3: f64 = 220.0; pub const NOTE_AS3: f64 = 233.0; pub const NOTE_B3: f64 = 247.0; pub const NOTE_C4: f64 = 262.0; pub const NOTE_CS4: f64 = 277.0; pub const NOTE_D4: f64 = 294.0; pub const NOTE_DS4: f64 = 311.0; pub const NOTE_E4: f64 = 330.0; pub const NOTE_F4: f64 = 349.0; pub const NOTE_FS4: f64 = 370.0; pub const NOTE_G4: f64 = 392.0; pub const NOTE_GS4: f64 = 415.0; pub const NOTE_A4: f64 = 440.0; pub const NOTE_AS4: f64 = 466.0; pub const NOTE_B4: f64 = 494.0; pub const NOTE_C5: f64 = 523.0; pub const NOTE_CS5: f64 = 554.0; pub const NOTE_D5: f64 = 587.0; pub const NOTE_DS5: f64 = 622.0; pub const NOTE_E5: f64 = 659.0; pub const NOTE_F5: f64 = 698.0; pub const NOTE_FS5: f64 = 740.0; pub const NOTE_G5: f64 = 784.0; pub const NOTE_GS5: f64 = 831.0; pub const NOTE_A5: f64 = 880.0; pub const NOTE_AS5: f64 = 932.0; pub const NOTE_B5: f64 = 988.0; pub const NOTE_C6: f64 = 1047.0; pub const NOTE_CS6: f64 = 1109.0; pub const NOTE_D6: f64 = 1175.0; pub const NOTE_DS6: f64 = 1245.0; pub const NOTE_E6: f64 = 1319.0; pub const NOTE_F6: f64 = 1397.0; pub const NOTE_FS6: f64 = 1480.0; pub const NOTE_G6: f64 = 1568.0; pub const NOTE_GS6: f64 = 1661.0; pub const NOTE_A6: f64 = 1760.0; pub const NOTE_AS6: f64 = 1865.0; pub const NOTE_B6: f64 = 1976.0; pub const NOTE_C7: f64 = 2093.0; pub const NOTE_CS7: f64 = 2217.0; pub const NOTE_D7: f64 = 2349.0; pub const NOTE_DS7: f64 = 2489.0; pub const NOTE_E7: f64 = 2637.0; pub const NOTE_F7: f64 = 2794.0; pub const NOTE_FS7: f64 = 2960.0; pub const NOTE_G7: f64 = 3136.0; pub const NOTE_GS7: f64 = 3322.0; pub const NOTE_A7: f64 = 3520.0; pub const NOTE_AS7: f64 = 3729.0; pub const NOTE_B7: f64 = 3951.0; pub const NOTE_C8: f64 = 4186.0; pub const NOTE_CS8: f64 = 4435.0; pub const NOTE_D8: f64 = 4699.0; pub const NOTE_DS8: f64 = 4978.0; pub const REST: f64 = 0.0; // No sound, for pauses }
Next, we create small helper struct
to to represent a musical Song and provide some functions to calculate note durations based on tempo.
This struct has a single field whole_note, which will store the duration of a whole note in milliseconds. The reason we store the duration in milliseconds is that musical timing is often based on tempo (beats per minute, BPM), and we need to calculate how long each note lasts in terms of time.
#![allow(unused)] fn main() { pub struct Song { whole_note: u32, } }
The formula (60_000 * 4) / tempo as u32 calculates the duration of a whole note in milliseconds. We use 60_000 because there are 60,000 milliseconds in a minute, and we multiply by 4 because a whole note is typically equivalent to four beats.
#![allow(unused)] fn main() { impl Song { pub fn new(tempo: u16) -> Self { let whole_note = (60_000 * 4) / tempo as u32; Self { whole_note } } }
calc_note_duration
The calc_note_duration
function calculates the duration of a musical note based on its division relative to a whole note. It takes in a divider
parameter, which can be positive or negative, and returns the duration of the note in milliseconds.
#![allow(unused)] fn main() { pub fn calc_note_duration(&self, divider: i16) -> u32 { if divider > 0 { self.whole_note / divider as u32 } else { let duration = self.whole_note / divider.unsigned_abs() as u32; (duration as f64 * 1.5) as u32 } } } }
Logic:
-
When
divider > 0
:- If the
divider
is positive, the function calculates the note's duration by dividing the duration of a whole note by thedivider
. - For example, if
divider = 4
, the function calculates the duration of a quarter note, which is 1/4 of a whole note.
- If the
-
When
divider <= 0
:- If the
divider
is negative, the function first converts thedivider
to a positive value usingunsigned_abs()
. - It divides the whole note's duration by this absolute value, then multiplies the result by 1.5 to account for dotted notes (e.g., dotted quarter note, dotted eighth note), which last 1.5 times the duration of a regular note.
- If the
This positive and negative logic is a custom approach (based on an Arduino example I referred to) to differentiate dotted notes. It is not related to standard musical logic.
Melody Example: Pink Panther Theme
These section contains code snippets for the rust module pink_panther
. If you want other songs/bgms notes, you can refer the Rust Embedded Songs repository
Tempo
we declare the tempo for the song(you can also change and observe the result).
#![allow(unused)] fn main() { pub const TEMPO: u16 = 120; }
Melody Array
We define the melody of the Pink Panther theme using the notes and durations in an array. The melody consists of tuple of note frequencies and their corresponding durations. The duration of each note is represented by an integer, where positive values represent normal notes and negative values represent dotted notes.
#![allow(unused)] fn main() { pub const MELODY: [(f64, i16); 88] = [ (REST, 2), (REST, 4), (REST, 8), (NOTE_DS4, 8), (NOTE_E4, -4), (REST, 8), (NOTE_FS4, 8), (NOTE_G4, -4), (REST, 8), (NOTE_DS4, 8), (NOTE_E4, -8), (NOTE_FS4, 8), (NOTE_G4, -8), (NOTE_C5, 8), (NOTE_B4, -8), (NOTE_E4, 8), (NOTE_G4, -8), (NOTE_B4, 8), (NOTE_AS4, 2), (NOTE_A4, -16), (NOTE_G4, -16), (NOTE_E4, -16), (NOTE_D4, -16), (NOTE_E4, 2), (REST, 4), (REST, 8), (NOTE_DS4, 4), (NOTE_E4, -4), (REST, 8), (NOTE_FS4, 8), (NOTE_G4, -4), (REST, 8), (NOTE_DS4, 8), (NOTE_E4, -8), (NOTE_FS4, 8), (NOTE_G4, -8), (NOTE_C5, 8), (NOTE_B4, -8), (NOTE_G4, 8), (NOTE_B4, -8), (NOTE_E5, 8), (NOTE_DS5, 1), (NOTE_D5, 2), (REST, 4), (REST, 8), (NOTE_DS4, 8), (NOTE_E4, -4), (REST, 8), (NOTE_FS4, 8), (NOTE_G4, -4), (REST, 8), (NOTE_DS4, 8), (NOTE_E4, -8), (NOTE_FS4, 8), (NOTE_G4, -8), (NOTE_C5, 8), (NOTE_B4, -8), (NOTE_E4, 8), (NOTE_G4, -8), (NOTE_B4, 8), (NOTE_AS4, 2), (NOTE_A4, -16), (NOTE_G4, -16), (NOTE_E4, -16), (NOTE_D4, -16), (NOTE_E4, -4), (REST, 4), (REST, 4), (NOTE_E5, -8), (NOTE_D5, 8), (NOTE_B4, -8), (NOTE_A4, 8), (NOTE_G4, -8), (NOTE_E4, -8), (NOTE_AS4, 16), (NOTE_A4, -8), (NOTE_AS4, 16), (NOTE_A4, -8), (NOTE_AS4, 16), (NOTE_A4, -8), (NOTE_AS4, 16), (NOTE_A4, -8), (NOTE_G4, -16), (NOTE_E4, -16), (NOTE_D4, -16), (NOTE_E4, 16), (NOTE_E4, 16), (NOTE_E4, 2), ]; }
Code
Generate project using esp-generate
You have done this step already in the quick start section.
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 buzzer-song
This will open a screen asking you to select options. For now, we dont need to select any options. Just save it by pressing "s" in the keyboard.
Buzzer Pin
We will set GPIO 33 as our output pin with an initial Low state. This is the pin where we connected the positive pin of the buzzer.
#![allow(unused)] fn main() { let mut buzzer = Output::new(peripherals.GPIO33, Level::Low); }
Song instance
Create instance of the Song struct with the tempo of the song we are going to play.
#![allow(unused)] fn main() { let song = Song::new(pink_panther::TEMPO); }
Playing Music Notes with PWM
We will loop through the song's notes, using each note's frequency and duration. We also add a 10% pause to each note's duration. The frequency constants are defined as f64, which we convert to u32 and use with the Hz trait. The timer and PWM channel configurations are as usual, with one difference: in the timer, we set the frequency to match the current note. We set the duty cycle to 50%.
#![allow(unused)] fn main() { for (note, duration_type) in pink_panther::MELODY { let note_duration = song.calc_note_duration(duration_type); let pause_duration = note_duration / 10; // 10% of note_duration if note == music::REST { delay.delay_millis(note_duration); continue; } let freq = (note as u32).Hz(); let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0); hstimer0 .configure(timer::config::Config { duty: timer::config::Duty::Duty10Bit, clock_source: timer::HSClockSource::APBClk, frequency: freq, }) .unwrap(); let mut channel0 = ledc.channel(channel::Number::Channel0, &mut buzzer); channel0 .configure(channel::config::Config { timer: &hstimer0, duty_pct: 50, pin_config: channel::config::PinConfig::PushPull, }) .unwrap(); delay.delay_millis(note_duration - pause_duration); // play 90% channel0.set_duty(0).unwrap(); delay.delay_millis(pause_duration); // Pause for 10% } }
Clone the existing project
You can clone (or refer) project I created and navigate to the buzzer-song
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/buzzer-song
HC-SR04 Ultrasonic Sensor
In this guide, we'll learn how to use the HC-SR04 ultrasonic sensor with the ESP32. Ultrasonic sensors measure distances by emitting ultrasonic sound waves and calculating the time taken for them to return after bouncing off an object.
These kind of sensors you can normally find in the car parking assistance; When you reverse the car for parking, the sensor measures the distance between objects and alert you as you get close to it.
We will build a simple project that gradually increases the LED brightness using PWM, when the ultrasonic sensor detects an object distance of less than 30 cm - You can adjust this value as per your needs.
Prerequisites
Before starting, get familiar with yourself on these topics
🛠 Hardware Requirements
To complete this project, you will need:
- HC-SR04 Ultrasonic Sensor (or HC-SR04+)
- Breadboard
- Jumper wires
- External LED (You can also use the onboard LED, just use the GPIO 2 instead)
HC-SR04 Specs.
The HC-SR04 ultrasonic sensor can measure distances from 2 cm to 400 cm, with an accuracy of up to 3 mm. It needs a 5V power supply to operate.
ESP32 Tolerance
In the next chapter, we'll dive into the details of how the sensor works, but the basic idea is pretty simple: it sends out a signal and waits to receive it back. When powered with 5V, the sensor's Echo pin outputs a 5V signal back to the connected device. But there is a problem, ESP32 GPIOs can only handle up to 3.6V. Anything higher risks damaging the microcontroller.
How do we solve it? Here are a few options:
- The simplest option is to get the HC-SR04+, an upgraded version that works with both 3.3V and 5V. Then use 3.3V to power up the module.
- Another option is to use a voltage divider (just a couple of resistors) between the Echo pin and the ESP32 to drop the voltage to 3.3V.
- The last option is to power the HC-SR04 with 3.3V. It might work, but may not be reliable. Avoid this option unless it's absolutely your last resort and you're fine with any potential side effects.
How Does an Ultrasonic Sensor Work?
Ultrasonic sensors work by emitting sound waves at a frequency too high(40kHz) for humans to hear. These sound waves travel through the air and bounce back when they hit an object. The sensor calculates the distance by measuring how long it takes for the sound waves to return.
- Transmitter: Sends out ultrasonic sound waves.
- Receiver: Detects the sound waves that bounce back from an object.
Formula to calculate distance:
Distance = (Time x Speed of Sound) / 2
The speed of sound is approximately 0.0343 cm/µs (or 343 m/s) at normal air pressure and a temperature of 20°C.
Example Calculation:
Let's say the ultrasonic sensor detects that the sound wave took 2000 µs to return after hitting an object.
Step 1: Calculate the total distance traveled by the sound wave:
Total distance = Time x Speed of Sound
Total distance = 2000 µs x 0343 cm/µs = 68.6 cm
Step 2: Since the sound wave traveled to the object and back, the distance to the object is half of the total distance:
Distance to object = 68.6 cm / 2 = 34.3 cm
Thus, the object is 34.3 cm away from the sensor.
HC-SR04 Pinout
The module has four pins: VCC, Trig, Echo, and GND.
Pin | Function |
---|---|
VCC | Power Supply |
Trig | Trigger Signal |
Echo | Echo Signal |
GND | Ground |
Measuring Distance with the HC-SR04 module
The HC-SR04 module has a transmitter and receiver, responsible for sending ultrasonic waves and detecting the reflected waves. We will use the Trig pin to send sound waves. And read from the Echo pin to measure the distance.
As you can see in the diagram, we connect the Trig and Echo pins to the GPIO pins of the microcontroller (we also connect VCC and GND but left them out to keep the illustration simple). We send ultrasonic waves by setting the Trig pin HIGH for 10 microseconds and then setting it back to LOW. This triggers the module to send 8 consecutive ultrasonic waves at a frequency of 40 kHz. It is recommended to have a minimum gap of 50ms between each trigger.
When the sensor's waves hit an object, they bounce back to the module. As you can see in the diagram, the Echo pin changes the input sent to the microcontroller, with the length of time the signal stays HIGH (pulse width) corresponding to the distance. In the microcontroller, we measure how long the Echo pin stays HIGH; Then, we can use this time duration to calculate the distance to the object.
Pulse width and the distance:
The pulse width (amount of time it stays high) produced by the Echo pin will range from about 150µs to 25,000µs(25ms); this is only if it hits an object. If there is no object, it will produce a pulse width of around 38ms.
Connecting HC-SR04 ultrasonic sensor with ESP32
Why We Need a Voltage Divider?
You can skip this, if you have HC-SR04+ which accepts 3.3V power supply also.
Before diving into the connection, You should familiarize yourself with the concept of a voltage divider; You can refer to this chapter. As we mentioned earlier, we need to power the module using a 5V power supply, which can be supplied through the Vin pin of the ESP32. The module sends back the signal through the Echo pin. However, the ESP32 is only around 3.6V tolerant on its GPIO pins, so we need to reduce the voltage using a voltage divider between the Echo pin and the ESP32's GPIO pin.
To make this work, you'll need two resistors with different values, ensuring the output voltage is approximately 3.3V. For example, you can use a 1kΩ resistor as R1 and a 2kΩ resistor as R2, which will bring the voltage down to around 3.3V.
If you want to experiment with different resistor values, you can use the Falstd website with this voltage-divider circuit text file. It allows you to modify the values of R1 and R2 to see what voltage each combination will output. This way, you can create the voltage divider with the resistors you have on hand.
Circuit for HC-SR04
ESP32 Pin | Wire | HC-SR04 Pin |
---|---|---|
Vin (5V) |
|
VCC |
GPIO 5 |
|
Trig |
GPIO 18 |
|
Echo (via voltage divider) |
GND |
|
GND |
- VCC: Connect the VCC pin on the HC-SR04 to the Vin pin on the ESP32. If you are using HC-SR04+, use 3.3V pin on the ESP32.
- Trig: Connect to GPIO 5 on the ESP32.
- Echo: Connect the Echo pin on the HC-SR04 to GPIO 18 through a voltage divider (1kΩ resistor between Echo and GPIO 18, and 2kΩ resistor between GPIO 18 and GND; means the GPIO 18 basically goes in the middle). I believe this will be easier to understand with the circuit diagram.
- GND: Connect the GND pin on the HC-SR04 to the GND pin on the ESP32.
Circuit for LED
You have to connect the anode (long leg) of the LED to GPIO 33, as in the External LED setup; through the resistor (eg: 330 Ohm resistor) to avoid damaging the LED. And the cathode of the LED(short leg) to Ground.
ESP32 Pin | Wire | Component |
---|---|---|
GPIO 33 |
|
Resistor |
Resistor |
|
Anode (long leg) of LED |
GND |
|
Cathode (short leg) of LED |
Circuit Diagram
I have provided circuit diagrams both with and without a breadboard. To be honest, the breadboard version was a bit confusing when I drew it. If you still find it unclear, please create an issue in the GitHub repository and describe the confusing parts. I'll do my best to improve it.
Diagram without breadboard:
Diagram with breadboard:
Writing Rust Code Use HC-SR04 Ultrasonic Sensor with ESP32
We'll start by generating the project using the template, then modify the code to fit the current project's requirements.
Generate project using esp-generate
You have done this step already in the quick start section.
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 ultrasonic
This will open a screen asking you to select options. For now, we dont need to select any options. Just save it by pressing "s" in the keyboard.
Setup the LED Pin and configure PWM
You should understand this code by now. If not, please complete the Fading LED section first.
Quick recap: Here, we're configuring the PWM for the LED, which allows us to control the brightness by adjusting the duty cycle.
#![allow(unused)] fn main() { let led = peripherals.GPIO33; let ledc = Ledc::new(peripherals.LEDC); let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0); hstimer0 .configure(timer::config::Config { duty: timer::config::Duty::Duty5Bit, clock_source: timer::HSClockSource::APBClk, frequency: 24.kHz(), }) .unwrap(); let mut channel0 = ledc.channel(channel::Number::Channel0, led); channel0 .configure(channel::config::Config { timer: &hstimer0, duty_pct: 10, pin_config: channel::config::PinConfig::PushPull, }) .unwrap(); }
Setup the Trigger Pin
We will configure GPIO 5 as an output pin with its initial state set to LOW. If you're wondering why it's an output, it's because we are sending a signal from the ESP32 to the ultrasonic module. This pin is connected to the Trig pin of the ultrasonic module.
#![allow(unused)] fn main() { let mut trig = Output::new(peripherals.GPIO5, Level::Low); }
Setup the Echo Pin
We will configure GPIO 18 as an input pin since the ultrasonic module sends the signal back to the ESP32. The initial state of this pin will be set to Pull Down to ensure it starts in the low state.
#![allow(unused)] fn main() { let echo = Input::new(peripherals.GPIO18, Pull::Down); }
🦇 Light it Up
Step 1: Send the Trigger Pulse
We will set the trig pin to LOW so that we start fresh. We will set the trig pin to HIGH for 10 microseconds, then turn it back to LOW. This will trigger the module to send ultrasonic waves.
#![allow(unused)] fn main() { // Ensure the Trigger pin is low before starting trig.set_low(); delay.delay_micros(2); // Send a 10-microseconds high pulse trig.set_high(); delay.delay_micros(10); trig.set_low(); }
Step 2: Measure the pulse width
Next, we will use two loops. The first loop will run as long as the echo pin state is LOW. Once it goes HIGH, we will record the current time in a variable. Then, we start the second loop, which will continue as long as the echo pin remains HIGH. When it returns to LOW, we will record the current time in another variable. The difference between these two times gives us the pulse width.
#![allow(unused)] fn main() { // Measure the duration the signal remains high while echo.is_low() {} let time1 = rtc.current_time(); while echo.is_high() {} let time2 = rtc.current_time(); let pulse_width = match (time2 - time1).num_microseconds() { Some(pw) => pw as f64, None => continue, }; }
Step 3: Calculate the Distance
To calculate the distance, we need to use the pulse width. The pulse width tells us how long it took for the ultrasonic waves to travel to an obstacle and return. Since the pulse represents the round-trip time, we divide it by 2 to account for the journey to the obstacle and back.
The speed of sound in air is approximately 0.0343 cm per microsecond. By multiplying the time (in microseconds) by this value and dividing by 2, we obtain the distance to the obstacle in centimeters.
#![allow(unused)] fn main() { let distance = (pulse_width * 0.0343) / 2.0; }
Step 4: PWM Duty cycle for LED
Finally, we adjust the LED brightness based on the measured distance.
The duty cycle percentage is calculated using our own logic, you can modify it to suit your needs. When the object is closer than 30 cm, the LED brightness will increase. The closer the object is to the ultrasonic module, the higher the calculated ratio will be, which in turn adjusts the duty cycle. This results in the LED brightness gradually increasing as the object approaches the sensor.
#![allow(unused)] fn main() { let duty_pct: u8 = if distance < 30.0 { let ratio = (30.0 - distance) / 30.0; let p = (ratio * 100.0) as u8; p.min(100) } else { 0 }; if let Err(e) = channel0.set_duty(duty_pct) { esp_println::println!("Failed to set duty cycle: {:?}", e); } }
Full code
#![no_std] #![no_main] use esp_backtrace as _; use esp_hal::{ delay::Delay, gpio::{Input, Level, Output, Pull}, ledc::{ channel::{self, ChannelIFace}, timer::{self, TimerIFace}, HighSpeed, Ledc, }, prelude::*, rtc_cntl::Rtc, }; #[entry] fn main() -> ! { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); // let led = peripherals.GPIO2; // uses onboard LED let led = peripherals.GPIO33; let ledc = Ledc::new(peripherals.LEDC); let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0); hstimer0 .configure(timer::config::Config { duty: timer::config::Duty::Duty5Bit, clock_source: timer::HSClockSource::APBClk, frequency: 24.kHz(), }) .unwrap(); let mut channel0 = ledc.channel(channel::Number::Channel0, led); channel0 .configure(channel::config::Config { timer: &hstimer0, duty_pct: 10, pin_config: channel::config::PinConfig::PushPull, }) .unwrap(); // For HC-SR04 Ultrasonic let mut trig = Output::new(peripherals.GPIO5, Level::Low); let echo = Input::new(peripherals.GPIO18, Pull::Down); let delay = Delay::new(); let rtc = Rtc::new(peripherals.LPWR); loop { delay.delay_millis(5); // Trigger ultrasonic waves trig.set_low(); delay.delay_micros(2); trig.set_high(); delay.delay_micros(10); trig.set_low(); // Measure the duration the signal remains high while echo.is_low() {} let time1 = rtc.current_time(); while echo.is_high() {} let time2 = rtc.current_time(); let pulse_width = match (time2 - time1).num_microseconds() { Some(pw) => pw as f64, None => continue, }; // Derive distance from the pulse width let distance = (pulse_width * 0.0343) / 2.0; // esp_println::println!("Pulse Width: {}", pulse_width); // esp_println::println!("Distance: {}", distance); // Our own logic to calculate duty cycle percentage for the distance let duty_pct: u8 = if distance < 30.0 { let ratio = (30.0 - distance) / 30.0; let p = (ratio * 100.0) as u8; p.min(100) } else { 0 }; if let Err(e) = channel0.set_duty(duty_pct) { esp_println::println!("Failed to set duty cycle: {:?}", e); } delay.delay_millis(60); } }
Clone the existing project
You can clone (or refer) project I created and navigate to the ultrasonic
folder.
git clone https://github.com/ImplFerris/esp32-projects/ultrasonic
cd esp32-projects/ultrasonic
Buzzer Alert for Object Detection with ESP32 and Ultrasonic Sensor
In our previous exercise, we used an LED that got brighter as the object got closer to the ultrasonic sensor module. Now, instead of the LED, we'll use an active buzzer. The buzzer will make a sound as the object moves closer. While you could use PWM to create different sounds or tones, we'll keep things simple for this project. As the object gets closer to the sensor, the buzzer will produce a beep sound.
Circuit
The circuit is almost the same as before. The only difference is that you need to remove the LED and its associated resistor. Instead, connect the buzzer to GPIO 33. We will connect the positive pin(usually marked with plus sign) of the Buzzer to the GPIO 33 and other pin to the ground.
Code
We will set GPIO 33 as our output pin with an initial Low state. This is the same as the LED code; the only change is the variable name.
#![allow(unused)] fn main() { let mut buzzer = Output::new(peripherals.GPIO33, Level::Low); }
We won't need the timer or PWM configurations we used for the LED. Instead, we will set the buzzer to High (it will make a sound when it is High) if the distance is less than 30cm; otherwise, it will remain Low.
#![allow(unused)] fn main() { if distance < 30.0 { buzzer.set_high(); } else { buzzer.set_low(); } }
Full code
#![no_std] #![no_main] use esp_backtrace as _; use esp_hal::{ delay::Delay, gpio::{Input, Level, Output, Pull}, prelude::*, rtc_cntl::Rtc, }; #[entry] fn main() -> ! { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); let mut buzzer = Output::new(peripherals.GPIO33, Level::Low); // For HC-SR04 Ultrasonic let mut trig = Output::new(peripherals.GPIO5, Level::Low); let echo = Input::new(peripherals.GPIO18, Pull::Down); let delay = Delay::new(); let rtc = Rtc::new(peripherals.LPWR); loop { delay.delay_millis(5); // Trigger ultrasonic waves trig.set_low(); delay.delay_micros(2); trig.set_high(); delay.delay_micros(10); trig.set_low(); // Measure the duration the signal remains high while echo.is_low() {} let time1 = rtc.current_time(); while echo.is_high() {} let time2 = rtc.current_time(); let pulse_width = match (time2 - time1).num_microseconds() { Some(pw) => pw as f64, None => continue, }; // Derive distance from the pulse width let distance = (pulse_width * 0.0343) / 2.0; // esp_println::println!("Pulse Width: {}", pulse_width); // esp_println::println!("Distance: {}", distance); if distance < 30.0 { buzzer.set_high(); } else { buzzer.set_low(); } delay.delay_millis(60); } }
Servo Motor
In this section, we'll connect an SG90 Micro Servo Motor to the ESP32 and control its rotation using PWM. Before moving forward, make sure you've read the PWM introduction section.
Hardware Requirements
- SG90 Micro Servo Motor: It's an affordable servo motor designed for hobbyists.
- Jumper Wires: Male-to-Male(or Female to Male depending on how you are connecting) jumper wires for connecting the ESP32 to the servo motor pins (Ground, Power, and Signal).
Introduction to Servo Motors
Servo motors are widely used in robotics(example: arms of robots), RC vehicles, pan-tilt camera mounts, and other embedded systems. Among the most popular servo motors is the SG90, a small and lightweight servo commonly used in DIY and hobbyist projects. The SG90 offers up to 180° of rotation.
How does servo motors (SG90) work?
A servo motor usually has a DC motor, a control circuit, a gearbox, and a potentiometer. The DC motor is linked to the output shaft through the gearbox, which moves the servo's horn. We won't go into how the motor and gearbox work inside; that's beyond the scope of this book. If you're curious, you can research on it. All we are interested is how to rotate horn with our microcontroller.
To control the horn's position, we send a signal to the servo motor from the microcontroller (MCU) at a frequency of 50Hz, with a pulse every 20 milliseconds. By changing how long the signal stays high during each cycle (pulse width), we can control how far the horn rotates.
For most servos (as per datasheets provided in various sources):
- 1ms pulse moves the horn to 0 degrees.
- 1.5ms pulse moves it to 90 degrees (neutral position).
- 2ms pulse moves it to 180 degrees.
Fine-Tuning Your Servo
After banging my head for several days, I realized that not all servos follow the same pulse width patterns. Through my experiments, I found that my servo needed:
- approximately 0.5ms pulse for 0 degrees.
- approximately 1.5ms pulse for 90 degrees.
- approximately 2.5ms pulse for 180 degrees.
The servo motor holds its position until we change the pulse width. For example, if we keep sending a 1.5 ms pulse width, it will stay in the 90-degree position. When we change the pulse width to 2.0 ms, it will move to 180 degrees. However, we must allow enough time for it to reach its position.
Also, if you send the wrong pulse width (for example, 10 ms), it won't move at all.
I had to test and adjust the pulse durations to find the correct positions. If you're unsure about your servo's behavior, you can use tools like an oscilloscope to fine-tune the pulse widths or experiment with different values to find what works best.
The example in this book uses my servo's configuration. You may need to adjust the values depending on the specific servo you're working with.
Changing Servo motor's Position based on PWM Duty Cycle
The servo motor we're using operates at a 50Hz frequency, which means that a pulse is sent every 20 milliseconds (ms).
Let's break this down further:
- 50Hz Frequency: Frequency refers to how many times an event happens in a given time period. A 50Hz frequency means that the servo expects a pulse to occur 50 times per second. In other words, the servo receives a pulse every 1/50th of a second, which is 20 milliseconds.
- 20ms Time Interval: This 20ms is the time between each pulse. It means that every 20 milliseconds, the servo expects a new pulse to adjust its position. Within this 20ms period, the width of the pulse (how long it stays "high") determines the angle at which the servo will move.
So, when we say the servo operates at 50Hz, it means that the motor is constantly receiving pulses every 20ms to keep it in motion or adjust its position based on the width of each pulse.
Pulse Width and Duty Cycle
Let's dive deeper into how different pulse widths like 0.5ms, 1.5ms, and 2.5ms affect the servo's position.
0.5ms Pulse (Position: 0 degrees)
-
What Happens: A 0.5ms pulse means the signal is "high" for 0.5 milliseconds within each 20ms cycle. The servo interprets this as a command to move to the 0-degree position.
-
Duty Cycle: The duty cycle refers to the percentage of time the signal is "high" in one complete cycle. For a 0.5ms pulse: \[ \text{Duty Cycle (%)} = \frac{0.5 \text{ms}}{20 \text{ms}} \times 100 = 2.5\% \]
This means that for just 2.5% of each 20ms cycle, the signal stays "high" causing the servo to rotate to the 0-degree position.
1.5ms Pulse (Position: 90 degrees)
- What Happens: A 1.5ms pulse means the signal is "high" for 1.5 milliseconds in the 20ms cycle. The servo moves to its neutral position, around 90 degrees (middle position).
- Duty Cycle: For a 1.5ms pulse: \[ \text{Duty Cycle (%)} = \frac{1.5 \text{ms}}{20 \text{ms}} \times 100 = 7.5\% \] Here, the signal stays "high" for 7.5% of the cycle, which positions the servo at 90 degrees (neutral).
2.5ms Pulse (Position: 180 degrees)
- What Happens: A 2.5ms pulse means the signal is "high" for 2.5 milliseconds in the 20ms cycle. The servo will move to its maximum position, typically 180 degrees (full rotation to one side).
- Duty Cycle: For a 2.5ms pulse: \[ \text{Duty Cycle (%)} = \frac{2.5 \text{ms}}{20 \text{ms}} \times 100 = 12.5\% \] In this case, the signal is "high" for 12.5% of the cycle, which causes the servo to rotate to 180 degrees.
Connection Overview
- Ground (GND): Connect the servo's GND pin (typically the brown wire, though it may vary) to any ground pin on the ESP32.
- Power (VCC): Connect the servo's VCC pin (usually the red wire) to the ESP32's 5V (Vin) power pin.
- Signal (PWM): Connect the servo's control (signal) pin to GPIO 33 on the ESP32, configured for PWM. This is commonly the orange wire (may vary).
ESP32 Pin | Wire | Servo Motor | Notes |
---|---|---|---|
VIN |
|
Power (Red Wire) | Supplies 5V power to the servo. |
GND |
|
Ground (Brown Wire) | Connects to ground. |
GPIO 33 |
|
Signal (Orange Wire) | Receives PWM signal to control the servo's position. |
ESP32's LEDC peripheral to control servo motor
The ESP32 has LEDC and Motor Control Pulse Width Modulator (MCPWM) peripherals for PWM control. First, we will use the LEDC to generate the PWM, which we've already used in LED, buzzer, and other exercises.
In our previous exercises, we have been using the set_duty
function, which takes the duty cycle percentage as a u8. But for the servo, we need fractional percentages (like 2.5%, 7.5%, 12.5%), which can't be represented with a u8.
SetDutyCycle Trait
So, what do we do? Thankfully, the LEDC channels in esp-hal implement the embedded-hal trait SetDutyCycle, giving us more control over the PWM.
We will be using two functions in the SetDutyCycle trait: max_duty_cycle and set_duty_cycle.
max_duty_cycle
:
This function will return the maximum duty cycle based on the duty resolution bits we configure in the timer. For example, if we set the resolution to 8 bits, the maximum duty cycle will be 256 (i.e., 28). If we set it to 12 bits, the maximum duty cycle will be 4096 (i.e., 212). We will be using a 12-bit resolution, so the maximum duty cycle will be 4096.
#![allow(unused)] fn main() { // We are converting to u32 (from u16) because we need u32 for the upcoming multiplication. let max_duty_cycle = channel0.max_duty_cycle() as u32; }
set_duty_cycle
:
This function is to set the duty cycle with in the range of 0 to 4096 for 12-bit resolution.
#![allow(unused)] fn main() { let duty = 512; channel0.set_duty_cycle(duty).unwrap(); }
But, How?
These functions take in or return u16 values. But how do we use percentage, which are in fractions? Instead of using the percentage directly, we calculate the corresponding value. For example, 2.5% of 4096 is approximately 102. This value is enough for us to move the servo motor to the correct position; in this case, it moves to 0 degrees.
For calculating the value from the percentage, we won't be using floats. Instead, we will cast the maximum duty cycle value to u32
.
Percentages like 2.5% can't be directly represented in u32
, so we multiply the percentage by 10 to make it fit. For example, 2.5 becomes 25. Then, we divide the final value by 1000 (100 x 10) instead of 100.
\[ \text{min_duty} = \frac{Percent_{u32} \times \text{max_duty_cycle}}{1000} \]
For example:
#![allow(unused)] fn main() { // Minimum duty (2.5%) for servo position // For 12bit -> 25 * 4096 /1000 => ~ 102 // it same as 2.5 *4096 / 100 => ~102 let min_duty = (25 * max_duty_cycle) / 1000; // Maximum duty (12.5%) for servo position // For 12bit -> 125 * 4096 /1000 => 512 let max_duty = (125 * max_duty_cycle) / 1000; }
Calculating Duty cycle from angle
We have a simple helper function that converts the angle to a duty cycle value. We have to pass the degree, min_duty, and the difference between the min_duty and max_duty of the servo position range. Then we cast the final value to u16 because the set_duty_cycle function accepts only u16.
#![allow(unused)] fn main() { let duty_gap = max_duty - min_duty; // 512 - 102 => 410 fn duty_from_angle(deg: u32, min_duty: u32, duty_gap: u32) -> u16 { let duty = min_duty + ((deg * duty_gap) / 180); duty as u16 } }
For example, if the angle is 180 degree. We have already calculated min_duty and max_duty range which is 102 and 512, and the difference between them is 410. Let's substitute in the above equation.
duty = 102 + ((180 * 410) / 180) = 512
For the angle is 90 degree:
duty = 102 + ((90 * 410) / 180) = 307
307 is approximately 7.5% of 4096, which is what we needed for the 90 degree position.
Rotation
Let's rotate the servo's horn from 0 degrees to 180, then back to 0 degrees in a loop. We first calculate the duty from the angle and set the duty cycle. We wait for 1500 milliseconds to allow the servo motor to reach its position. Try reducing the delay to 50ms, and you'll notice that the servo starts making jerky movements and doesn't reach the expected position at all.
#![allow(unused)] fn main() { loop{ let duty = duty_from_angle(0, min_duty, duty_gap); channel0.set_duty_cycle(duty).unwrap(); delay.delay_millis(1500); // allow to reach its position let duty = duty_from_angle(180, min_duty, duty_gap); channel0.set_duty_cycle(duty).unwrap(); delay.delay_millis(1500); // allow to reach its position } }
Don't worry about how to run this code. In the next chapter, we'll look at a code that smoothly moves the servo from 0 to 180 degrees, then move back to 0 degrees in a loop.
Writing Rust code to smoothly rotate Servo motor with ESP32
Generate project using esp-generate
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 servo-motor
Update dependencies
Add the following crate to the Cargo.toml file
embedded-hal = "1.0.0"
Then import the SetDutyCycle trait in the main.rs file. This is required if we want to use the max_duty_cycle, set_duty_cycle functions
#![allow(unused)] fn main() { use embedded_hal::pwm::SetDutyCycle; }
Timer and PWM Channel
Let's initialize the timer with frequency of 50Hz and 12 bit resolution and configure the channel.
#![allow(unused)] fn main() { let mut servo = peripherals.GPIO33; let ledc = Ledc::new(peripherals.LEDC); let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0); hstimer0 .configure(timer::config::Config { duty: timer::config::Duty::Duty12Bit, clock_source: timer::HSClockSource::APBClk, frequency: 50.Hz(), }) .unwrap(); let mut channel0 = ledc.channel(channel::Number::Channel0, &mut servo); channel0 .configure(channel::config::Config { timer: &hstimer0, duty_pct: 12, // not important, we will change it in loop pin_config: channel::config::PinConfig::PushPull, }) .unwrap(); }
Helper function
We have already explained the code in the last chapter.
#![allow(unused)] fn main() { let max_duty_cycle = channel0.max_duty_cycle() as u32; // Minimum duty (2.5%) // For 12bit -> 25 * 4096 /1000 => ~ 102 let min_duty = (25 * max_duty_cycle) / 1000; // Maximum duty (12.5%) // For 12bit -> 125 * 4096 /1000 => 512 let max_duty = (125 * max_duty_cycle) / 1000; // 512 - 102 => 410 let duty_gap = max_duty - min_duty; fn duty_from_angle(deg: u32, min_duty: u32, duty_gap: u32) -> u16 { let duty = min_duty + ((deg * duty_gap) / 180); duty as u16 } }
Rotation
In the main loop, we first rotate from 0 degrees to 180 degrees. We add a 10ms gap to reach its position, which is enough since we are moving in smaller steps. Then, we use the rev() function to reverse the range, so it goes from 180 back to 0.
#![allow(unused)] fn main() { loop { for deg in 0..=180 { let duty = duty_from_angle(deg, min_duty, duty_gap); channel0.set_duty_cycle(duty).unwrap(); delay.delay_millis(10); } delay.delay_millis(500); for deg in (0..=180).rev() { let duty = duty_from_angle(deg, min_duty, duty_gap); channel0.set_duty_cycle(duty).unwrap(); delay.delay_millis(10); } delay.delay_millis(500); } }
The full code
#![no_std] #![no_main] use embedded_hal::pwm::SetDutyCycle; use esp_backtrace as _; use esp_hal::{ delay::Delay, ledc::{ channel::{self, ChannelIFace}, timer::{self, TimerIFace}, HighSpeed, Ledc, }, prelude::*, }; #[entry] fn main() -> ! { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); let mut servo = peripherals.GPIO33; let ledc = Ledc::new(peripherals.LEDC); let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0); hstimer0 .configure(timer::config::Config { duty: timer::config::Duty::Duty12Bit, clock_source: timer::HSClockSource::APBClk, frequency: 50.Hz(), }) .unwrap(); let mut channel0 = ledc.channel(channel::Number::Channel0, &mut servo); channel0 .configure(channel::config::Config { timer: &hstimer0, duty_pct: 12, pin_config: channel::config::PinConfig::PushPull, }) .unwrap(); let delay = Delay::new(); let max_duty_cycle = channel0.max_duty_cycle() as u32; // Minimum duty (2.5%) // For 12bit -> 25 * 4096 /1000 => ~ 102 let min_duty = (25 * max_duty_cycle) / 1000; // Maximum duty (12.5%) // For 12bit -> 125 * 4096 /1000 => 512 let max_duty = (125 * max_duty_cycle) / 1000; // 512 - 102 => 410 let duty_gap = max_duty - min_duty; loop { for deg in 0..=180 { let duty = duty_from_angle(deg, min_duty, duty_gap); channel0.set_duty_cycle(duty).unwrap(); delay.delay_millis(10); } delay.delay_millis(500); for deg in (0..=180).rev() { let duty = duty_from_angle(deg, min_duty, duty_gap); channel0.set_duty_cycle(duty).unwrap(); delay.delay_millis(10); } delay.delay_millis(500); } } fn duty_from_angle(deg: u32, min_duty: u32, duty_gap: u32) -> u16 { let duty = min_duty + ((deg * duty_gap) / 180); duty as u16 }
Clone the existing project
You can clone (or refer) project I created and navigate to the servo-motor
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/servo-motor
Controlling servo motor with ESP32's Motor Control Pulse Width Modulator (MCPWM) peripheral
In the previous exercise, we used the LEDC peripheral of the ESP32 to control the servo motor. In this exercise, we will use the MCPWM to achieve the same. An introduction to the MCPWM, its operation, and the corresponding functions in esp-hal is provided here. Please read that chapter before proceeding further.
Generate project using esp-generate
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 servo-mcpwm
This will open a screen asking you to select options. For now, we dont need to select any options. Just save it by pressing "s" in the keyboard.
Clock Config
Let's create an instance of the peripheral clock. We have chosen 1 MHz as the base clock frequency for the PWM. The function will internally calculate the prescaler and divide the input clock, which is 160 MHz.
#![allow(unused)] fn main() { let clock_cfg = PeripheralClockConfig::with_frequency(1.MHz()).unwrap(); }
For the servo, we need to achieve a final PWM frequency of 50 Hz. So, we need to keep the base clock frequency as low as possible. We can go down to 625 kHz with the maximum prescaler value of 255, but we keep it at 1 MHz to make the calculations easier.
Configure MCPWM and Pin
We will use the MCPWM0 peripheral, will select timer0 and operator0. Next, we will configure it to use GPIO33, and set the PWM signal to stay high until it reaches the timestamp value we specify during the PWM cycle.
#![allow(unused)] fn main() { let mut mcpwm = McPwm::new(peripherals.MCPWM0, clock_cfg); // connect operator0 to timer0 mcpwm.operator0.set_timer(&mcpwm.timer0); // connect operator0 to pin let mut pwm_pin = mcpwm .operator0 .with_pin_a(peripherals.GPIO33, PwmPinConfig::UP_ACTIVE_HIGH); }
Configure the Timer
To achieve a 50 Hz PWM signal for the servo with a 1 MHz clock, the timer needs to count 20,000 ticks in total. Since the timer counts from 0 to 19,999, the period is set to 19_999, which gives a total of 20,000 ticks.
#![allow(unused)] fn main() { let timer_clock_cfg = clock_cfg .timer_clock_with_frequency(19_999, PwmWorkingMode::Increase, 50.Hz()) .unwrap(); mcpwm.timer0.start(timer_clock_cfg); }
Rotation of Servo's horn
To rotate the servo horn, we adjust the PWM signal's timestamp. The timestamp values correspond to the desired angles:
- For 0 degrees, we set the timestamp to 500 (2.5% of 20,000).
- For 90 degrees, we set the timestamp to 1500 (7.5% of 20,000).
- For 180 degrees, we set the timestamp to 2500 (12.5% of 20,000).
After each adjustment, we give enough delay to allow the servo to reach the specified position.
#![allow(unused)] fn main() { loop { // 0 degree (2.5% of 20_000 => 500) pwm_pin.set_timestamp(500); delay.delay(1500.millis()); // 90 degree (7.5% of 20_000 => 1500) pwm_pin.set_timestamp(1500); delay.delay(1500.millis()); // 180 degree (12.5% of 20_000 => 2500) pwm_pin.set_timestamp(2500); delay.delay(1500.millis()); } }
The full code
#![no_std] #![no_main] use esp_backtrace as _; use esp_hal::delay::Delay; use esp_hal::mcpwm::operator::PwmPinConfig; use esp_hal::mcpwm::timer::PwmWorkingMode; use esp_hal::mcpwm::{McPwm, PeripheralClockConfig}; use esp_hal::prelude::*; #[entry] fn main() -> ! { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); esp_println::logger::init_logger_from_env(); let delay = Delay::new(); let clock_cfg = PeripheralClockConfig::with_frequency(1.MHz()).unwrap(); let mut mcpwm = McPwm::new(peripherals.MCPWM0, clock_cfg); // connect operator0 to timer0 mcpwm.operator0.set_timer(&mcpwm.timer0); // connect operator0 to pin let mut pwm_pin = mcpwm .operator0 .with_pin_a(peripherals.GPIO33, PwmPinConfig::UP_ACTIVE_HIGH); // start timer with timestamp values in the range of 0..=19999 and a frequency // of 50 Hz let timer_clock_cfg = clock_cfg .timer_clock_with_frequency(19_999, PwmWorkingMode::Increase, 50.Hz()) .unwrap(); mcpwm.timer0.start(timer_clock_cfg); loop { // 0 degree (2.5% of 20_000 => 500) pwm_pin.set_timestamp(500); delay.delay(1500.millis()); // 90 degree (7.5% of 20_000 => 1500) pwm_pin.set_timestamp(1500); delay.delay(1500.millis()); // 180 degree (12.5% of 20_000 => 2500) pwm_pin.set_timestamp(2500); delay.delay(1500.millis()); } }
Clone the existing project
You can also clone (or refer) project I created and navigate to the servo-mcpwm
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/servo-mcpwm
LDR (Light Dependent Resistor)
In this section, we will use an LDR (Light Dependent Resistor, also referred as photocell or photoresistor) with the ESP32. An LDR changes its resistance based on the amount of light falling on it. The brighter the light, the lower the resistance, and the dimmer the light, the higher the resistance. This makes it ideal for applications like light sensing, automatic lighting, or monitoring ambient light levels.
Components Needed:
- LDR (Light Dependent Resistor)
- Resistor (typically 10kΩ); needed to create voltage divider
- Jumper wires (as usual)
Prerequisite
To work with this, you should get familiar with what a voltage divider is and how it works. You also need to understand what ADC is and how it functions.
How LDR works?
We have already given an introduction to what an LDR is. Let me repeat it again: an LDR changes its resistance based on the amount of light falling on it. The brighter the light, the lower the resistance, and the dimmer the light, the higher the resistance.
Dracula: Think of the LDR as Dracula. In sunlight, he gets weaker (just like the resistance gets lower). But in the dark, he gets stronger (just like the resistance gets higher).
We will not cover what kind of semiconductor materials are used to make an LDR, nor why it behaves this way in depth. I recommend you read this article and do further research if you are interested.
Example output for full brightness
The resistance of the LDR is low when exposed to full brightness, causing the output voltage(\( V_{out} \)) to be significantly lower.
Example output for low light
With less light, the resistance of the LDR increases and the output voltage increase.
Example output for full darkness
In darkness, the LDR's resistance is high, resulting in a higher output voltage (\( V_{out} \)).
Simulation of LDR in Voltage Divider
You can adjust the brightness value and observe how the resistance of R2 (which is the LDR) changes. Also, you can watch how the \( V_{out} \) voltage changes as you increase or decrease the brightness.
50%
Formula: \( V_\text{out} = V_\text{in} \times \frac{R_2}{R_1 + R_2} \)
Filled Formula: Vout = 3.3 × 999 / (10000 + 999)
Output Voltage (Vout): 0.25 V
Circuitjs
The above diagrams are i created using the Falstad website. You can import the circuit file I created, voltage-divider-ldr.circuitjs.txt
, import into the Falstad site and play around.
Turn on LED(or Lamp) in low Light with Pico
In this exercise, we'll control an LED based on ambient light levels. The goal is to automatically turn on the LED in low light conditions.
You can try this in a closed room by turning the room light on and off. When you turn off the room-light, the LED should turn on, given that the room is dark enough, and turn off again when the room-light is switched back on. Alternatively, you can adjust the sensitivity threshold or cover the light sensor (LDR) with your hand or some object to simulate different light levels.
Note: You may need to adjust the ADC threshold based on your room's lighting conditions and the specific LDR you are using.
Hardware Requirements
- LED – Any standard LED (choose your preferred color).
- LDR (Light Dependent Resistor) – Used to detect light intensity.
- Resistors
- 330Ω – For the LED to limit current and prevent damage. (You might have to choose based on your LED)
- 10kΩ – For the LDR, forming a voltage divider in the circuit.
- Jumper Wires – For connecting components on a breadboard or microcontroller.
Circuit to connect LED, LDR with ESP32
Connecting LDR with ESP32:
- One side of the LDR is connected to Ground
- The other side of the LDR is connected to GPIO 4 (ADC2) (i.e. D4 in the Devkit)
- A resistor is connected in series with the LDR to create a voltage divider between the LDR and 3v3.
Connecting LED with ESP32:
This is the usual setup we have done before. You have to connect the cathode (short leg) of the LED to the ground. Then you have to connect the anode (long leg) in series with a 330 Ohm resistor to GPIO 33 (i.e D33 on the Devkit).
Turn on an LED in the Dark with a Photoresistor and ESP32 Using Rust
Generate project using esp-generate
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 ldr-dracula
This will open a screen asking you to select options. For now, we dont need to select any options. Just save it by pressing "s" in the keyboard.
Setup the LED
We have done this before; just set GPIO 33, which is connected to the LED, as an output pin and initialize it to a Low state.
#![allow(unused)] fn main() { let mut led = Output::new(peripherals.GPIO33, Level::Low); }
Configure ADC
We will configure GPIO 4 as an ADC input pin, which is one of the ADC2 channels. We will apply an attenuation level of 11dB, allowing the ADC to measure input voltages ranging from 150 mV to ~2450 mV.
#![allow(unused)] fn main() { let adc_pin = peripherals.GPIO4; let mut adc2_config = AdcConfig::new(); let mut pin = adc2_config.enable_pin(adc_pin, Attenuation::Attenuation11dB); let mut adc1 = Adc::new(peripherals.ADC2, adc2_config); }
Oneshot read
The read_oneshot
function starts a single ADC conversion on the specified pin. It is non-blocking and returns a 16-bit value wrapped in a Result. However, we will use nb::block
to block until the conversion is complete, then we will proceed futher.
#![allow(unused)] fn main() { let pin_value: u16 = nb::block!(adc2.read_oneshot(&mut pin)).unwrap(); }
Toggling LED
Once we get the digital value, turning the LED on or off is simple logic. We will turn on the LED by setting it to High if the pin value is greater than 3500 (you can adjust this threshold as needed). Otherwise, we will turn off the LED. This threshold of 3500 is typically reached when the room is dark, and the LDR's resistance(R2) is high, resulting in a higher voltage.
#![allow(unused)] fn main() { if pin_value > 3500 { led.set_high(); } else { led.set_low(); } }
If you're wondering how the LDR's high resistance leads to a higher voltage, I recommend re-reading the "How It Works" section and experimenting with the simulator.
Final code
#![no_std] #![no_main] use esp_backtrace as _; use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation}; use esp_hal::delay::Delay; use esp_hal::gpio::{Level, Output}; use esp_hal::prelude::*; #[entry] fn main() -> ! { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); let mut led = Output::new(peripherals.GPIO33, Level::Low); let adc_pin = peripherals.GPIO4; let mut adc2_config = AdcConfig::new(); let mut pin = adc2_config.enable_pin(adc_pin, Attenuation::Attenuation11dB); let mut adc2 = Adc::new(peripherals.ADC2, adc2_config); let delay = Delay::new(); loop { let pin_value: u16 = nb::block!(adc2.read_oneshot(&mut pin)).unwrap(); esp_println::println!("{}", pin_value); if pin_value > 3500 { led.set_high(); } else { led.set_low(); } delay.delay_millis(500); } }
Clone the existing project
You can also clone (or refer) project I created and navigate to the ldr-dracula
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/ldr-dracula
Wi-Fi
So far, we haven't discussed one of the greatest strengths of the ESP32 chip: its Wi-Fi support. In this section, we'll explore the capabilities of ESP32's Wi-Fi and what we can achieve with it.
The ESP32 supports standard Wi-Fi communication protocols (802.11 b/g/n) and can operate in two modes: Station (STA) mode and Soft Access Point mode. It is also capable of running in both modes simultaneously.
Station (STA) Mode
In this mode, the ESP32 connects to an existing Wi-Fi network as a client, similar to how your smartphone or laptop connects to a Wi-Fi router. Once connected, the ESP32 can access the internet or communicate with other devices on the same network.
Access Point (AP) Mode
In this mode, the ESP32 acts as a Wi-Fi access point, creating its own network to which other devices can connect. The ESP32 essentially functions as a router, allowing devices like smartphones, laptops, or other microcontrollers (example: ESP32) to communicate with it directly.
How to Connect ESP32 to Wi-Fi and Access Websites
In this exercise, we will configure the ESP32 in STA mode to connect to your Wi-Fi. Then, we will make a request to a web server from the ESP32 and print the response in the system console. This code is based on the esp-hal examples.
Generate project using esp-generate
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 wifi-website
This will open a screen asking you to select options.
- Select the option "Enables Wi-Fi via the esp-wifi crate. Requires alloc". It automatically selects the espa-alloc crate option also
Just save it by pressing "s" in the keyboard.
Update dependencies
Add the following crate to the Cargo.toml file
blocking-network-stack = { git = "https://github.com/bjoernQ/blocking-network-stack.git", rev = "1c581661d78e0cf0f17b936297179b993fb149d7" }
blocking-network-stack is a simple crate that provides Non-async Networking primitives for TCP/UDP communication.
esp-alloc crate
"A simple no_std heap allocator for RISC-V and Xtensa processors from Espressif. Supports all currently available ESP32 devices."
When we choose the Wi-Fi option, it also includes the esp_alloc crate. This crate enables heap allocation for esp-wifi to manage memory dynamically. esp-wifi uses an implementation of malloc() and free(), and it uses Rust's Global allocator. The esp-alloc crate provides the global allocator for ESP SoCs.
We initialize the heap with a size of 72 KiB (72 * 1024) using the esp_alloc::heap_allocator! macro.
#![allow(unused)] fn main() { esp_alloc::heap_allocator!(72 * 1024); }
Initializing the Wi-Fi Controller
Load the Wi-Fi credentials from environment variables:
#![allow(unused)] fn main() { const SSID: &str = env!("SSID"); const PASSWORD: &str = env!("PASSWORD"); }
First, we initialize the TimerGroup and the Random Number Generator (RNG) required for setting up the Wi-Fi controller. The RNG is also used to initialize the non-async TCP/IP network stack, so we will use clone so that we can re-use it.
#![allow(unused)] fn main() { let timg0 = TimerGroup::new(peripherals.TIMG0); let mut rng = Rng::new(peripherals.RNG); let esp_wifi_ctrlr = esp_wifi::init(timg0.timer0, rng.clone(), peripherals.RADIO_CLK).unwrap(); }
Next, we will call the create_network_interface function with the initialized esp_wifi_ctrlr, the Wi-Fi peripheral instance, and the Wi-Fi mode we want to use, which is STA (Station).
#![allow(unused)] fn main() { let mut wifi = peripherals.WIFI; let (iface, device, mut controller) = create_network_interface(&esp_wifi_ctrlr, &mut wifi, WifiStaDevice).unwrap(); }
Wi-Fi Operation Mode:
Next, we configure the Wi-Fi operation mode using the Configuration enum. Since we want the ESP32 to act as a Wi-Fi client, we use the Client variant. We need to provide the SSID (Service Set Identifier, which is the name of your Wi-Fi network) and the Wi-Fi password.
#![allow(unused)] fn main() { let wifi_config = Configuration::Client(ClientConfiguration { ssid: SSID.try_into().unwrap(), password: PASSWORD.try_into().unwrap(), ..Default::default() }); let res = controller.set_configuration(&wifi_config); }
Start the wifi controller
#![allow(unused)] fn main() { controller.start().unwrap(); }
Wi-Fi Scanning and Connecting to the Access Point
Next, we perform a blocking Wi-Fi network scan with the default options. This means the program will pause and wait until the scan is complete. Once the scan is finished, it will return a list of available Wi-Fi access points. We then loop through the results and print each access point's information to the console.
#![allow(unused)] fn main() { let res: Result<(heapless::Vec<AccessPointInfo, 10>, usize), WifiError> = controller.scan_n(); if let Ok((res, _count)) = res { for ap in res { println!("{:?}", ap); } } }
Then, we print the supported capabilities of the controller, which will show only "Client" since that's what we configured.
#![allow(unused)] fn main() { println!("{:?}", controller.capabilities()); }
Finally, we call the connect method on the Wi-Fi controller. This will connect the ESP32 to the Wi-Fi network that we specified earlier.
#![allow(unused)] fn main() { println!("wifi_connect {:?}", controller.connect()); }
We will continuously check in a loop if the Wi-Fi is connected. Once it connects successfully, we will proceed further.
#![allow(unused)] fn main() { loop { match controller.is_connected() { Ok(true) => break, Ok(false) => {} Err(err) => { println!("{:?}", err); loop {} } } } }
At this point, the ESP32 should be successfully connected to the Wi-Fi network. Next, we will set up the necessary code to send an HTTP request to a website. Once the request is sent, the ESP32 will receive the HTML response from the website, and we will print the HTML content to the console.
HTTP Client
To send a web request to a website, we need an HTTP client. For this, we will use the smoltcp crate, to send a raw HTTP request over a TCP socket.
"smoltcp is a standalone, event-driven TCP/IP stack that is designed for bare-metal, real-time systems. smoltcp does not need heap allocation at all, is extensively documented, and compiles on stable Rust 1.80 and later."
SocketSet Initialization
We will create a SocketSet with storage for up to 3 sockets to manage multiple sockets, such as DHCP and TCP, within the stack.
#![allow(unused)] fn main() { let mut socket_set_entries: [SocketStorage; 3] = Default::default(); let mut socket_set = SocketSet::new(&mut socket_set_entries[..]); }
DHCP Socket
We will create a DHCP socket to request an IP address from a DHCP server. We can set the outgoing options, including the hostname using DHCP Client Option 12 (you can read more about DHCP options here). We will also add the socket to the SocketSet we created earlier.
#![allow(unused)] fn main() { let mut dhcp_socket = smoltcp::socket::dhcpv4::Socket::new(); // we can set a hostname here (or add other DHCP options) dhcp_socket.set_outgoing_options(&[DhcpOption { kind: 12, data: b"implrust", }]); socket_set.add(dhcp_socket); }
Initializing the Network Stack
Let's initialize the Stack from the smoltcp crate using the network interface and device (which we obtained from the Wi-Fi network interface we created earlier), the sockets, a closure to get time, and a random number.
#![allow(unused)] fn main() { let now = || time::now().duration_since_epoch().to_millis(); let stack = Stack::new(iface, device, socket_set, now, rng.random()); }
Obtain IP address
We need to obtain an IP address, so we first wait for the DHCP process to complete. We will continuously loop until the interface is up and we have successfully obtained an IP address.
#![allow(unused)] fn main() { // wait for getting an ip address println!("Wait to get an ip address"); loop { stack.work(); if stack.is_iface_up() { println!("got ip {:?}", stack.get_ip_info()); break; } } }
TCP Socket
We will initialize the TCP socket with read and write buffers to handle incoming and outgoing data for the socket.
#![allow(unused)] fn main() { let mut rx_buffer = [0u8; 1536]; let mut tx_buffer = [0u8; 1536]; let mut socket = stack.get_socket(&mut rx_buffer, &mut tx_buffer); }
Send Get Request
Let's send an HTTP request with GET method to the website "www.mobile-j.de". This website is used in the esp-hal examples, which returns "Hello fellow Rustaceans!" with ferris ascii art. We'll use the IP address of the website to open a connection on port 80, then send the HTTP request.
#![allow(unused)] fn main() { socket.work(); socket .open(IpAddress::Ipv4(Ipv4Address::new(142, 250, 185, 115)), 80) .unwrap(); socket .write(b"GET / HTTP/1.0\r\nHost: www.mobile-j.de\r\n\r\n") .unwrap(); socket.flush().unwrap(); }
Read the response
Next, we read the response from the server with a 20-second timeout.
#![allow(unused)] fn main() { let deadline = time::now() + Duration::secs(20); let mut buffer = [0u8; 512]; while let Ok(len) = socket.read(&mut buffer) { let to_print = unsafe { core::str::from_utf8_unchecked(&buffer[..len]) }; print!("{}", to_print); if time::now() > deadline { println!("Timeout"); break; } } println!(); }
Close the socket
After the HTTP request is complete, we close the socket and wait for 5 seconds.
#![allow(unused)] fn main() { socket.disconnect(); let deadline = time::now() + Duration::secs(5); while time::now() < deadline { socket.work(); } }
Clone the existing project
You can also clone (or refer) project I created and navigate to the wifi-website
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/wifi-website
How to run?
Normally, we would simply run cargo run --release
, but this time we also need to pass the environment variables for the Wi-Fi connection.
SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD cargo run --release
Connecting ESP32 to Wi-Fi and Accessing Websites Asynchronously with Embassy
In this exercise, we will once again use the STA mode to access a website. But this time, we will do it asynchronously. We will use the reqwless crate as our HTTP client and the Embassy framework to enable asynchronous capabilities for embedded environments. Along with these, we will include additional helper crates.
Generate project using esp-generate
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 wifi-async-http
This will open a screen asking you to select options.
- Select the option "Enables Wi-Fi via the esp-wifi crate. Requires alloc". It automatically selects the espa-alloc crate option also
- Select the option "Adds embassy framework support".
Just save it by pressing "s" in the keyboard.
Update dependencies
Embassy net
embassy-net, built on the smoltcp crate, provides an async network stack for embedded systems. It offers a higher-level API and supports IPv4, IPv6, Ethernet, bare-IP mediums, TCP, UDP, DNS, DHCPv4, and multicast.
This crate automatically gets added when you select the Wi-Fi and Embassy option while generating the project with esp-generate. However, we need one additional feature: "dns". This is necessary to perform HTTP requests because the website name (e.g., google.com) needs to be resolved into an IP address. Remember, in the previous exercise, we manually provided the IP address of the website.
# Updated embassy-net in Cargo.toml
embassy-net = { version = "0.4.0", features = [
"tcp",
"udp",
"dhcpv4",
"medium-ethernet",
#addition:
"dns",
] }
smoltcp
The smoltcp crate also gets added to the Cargo.toml. We need to add feature "dns-max-server-count-4" in order to use DNS servers.
# Updated smoltcp in Cargo.toml
smoltcp = { version = "0.11.0", default-features = false, features = [
"medium-ethernet",
"proto-dhcpv4",
"proto-igmp",
"proto-ipv4",
"socket-dhcpv4",
"socket-icmp",
"socket-raw",
"socket-tcp",
"socket-udp",
# addition:
"dns-max-server-count-4",
] }
Embassy Executor
embassy-executor crate is an async/await executor specifically designed for embedded systems.
"When the nightly Cargo feature is not enabled, embassy-executor allocates tasks out of an arena (a very simple bump allocator)." We can specify the arena size via environment settings or as a feature flag. When we enabled Embassy support, it added the task-arena-size-12288
feature. But for our task, this won't be enough, so we will use the task-arena-size-32768
feature instead. You can read more about this here.
embassy-executor = { version = "0.6.0", features = ["task-arena-size-32768"] }
Reqwless
The reqwless crate is an HTTP client for embedded systems, working with any transport that implements traits from the embedded-io crate.
reqwless = { version = "0.12.0", default-features = false, features = [
"embedded-tls",
] }
Connecting ESP32 to Wi-Fi with Embassy support
So, we have discussed the dependencies we needed for this exercise and the features that needed to be enabled. Next, we'll focus on the coding. First, we will look at the code to connect Wi-Fi using Embassy.
Helper Macro for StaticCell
In an embedded environment, the StaticCell crate is useful when you need to initialize a variable at runtime but require it to have a static lifetime. We will define a macro to create globally accessible static variables. This macro takes two arguments: the type of the variable and the value to initialize it with. The uninit function provides a mutable reference to the uninitialized memory, and we write the value into it.
#![allow(unused)] fn main() { // If you are okay with using a nightly compiler, you can use the macro provided by the static_cell crate: https://docs.rs/static_cell/2.1.0/static_cell/macro.make_static.html 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 }}; } }
Initialization Steps
We initialize the heap with a size of 72 KiB (72 * 1024) using the esp_alloc::heap_allocator! macro.
#![allow(unused)] fn main() { esp_alloc::heap_allocator!(72 * 1024); }
Let's initialize Embassy with the usual setup:
#![allow(unused)] fn main() { let timer0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG1); esp_hal_embassy::init(timer0.timer0); }
We need a random number for both the TLS configuration and network stack initialization, and both require a u64. However, since rng generates only u32 values, we generate two random numbers and place one in the most significant bits (MSB) and the other in the least significant bits (LSB) using bitwise operation:
#![allow(unused)] fn main() { let mut rng = Rng::new(peripherals.RNG); let net_seed = rng.random() as u64 | ((rng.random() as u64) << 32); let tls_seed = rng.random() as u64 | ((rng.random() as u64) << 32); }
Initializing the Wi-Fi Controller
Load the Wi-Fi credentials from environment variables:
#![allow(unused)] fn main() { const SSID: &str = env!("SSID"); const PASSWORD: &str = env!("PASSWORD"); }
First, we initialize the TimerGroup required for setting up the Wi-Fi controller. This is almost the same as what we did in the non-async version. However, this time we use the mk_static! macro to initialize wifi_init with a static lifetime. Using static ensures that the variable stays alive for the entire duration of the program.
The reason why we do this is that we will be running the Wi-Fi network stack as an async task ("to process network events"), which requires the wifi_init variable to remain available throughout the program's execution.
#![allow(unused)] fn main() { let timg0 = TimerGroup::new(peripherals.TIMG0); let wifi_init = &*mk_static!( EspWifiController<'static>, esp_wifi::init(timg0.timer0, rng.clone(), peripherals.RADIO_CLK).unwrap() ); }
Next, we will call the new_with_mode function with the initialized wifi_init, the Wi-Fi peripheral instance, and the Wi-Fi mode we want to use, which is STA (Station).
#![allow(unused)] fn main() { let mut wifi = peripherals.WIFI; let (wifi_interface, controller) = esp_wifi::wifi::new_with_mode(&wifi_init, wifi, WifiStaDevice).unwrap(); }
Initialize the network stack
Let's initialize the network stack from the embassy_net crate using the network interface obtained from the Wi-Fi controller, a random number as the seed, the DHCP configuration, and stack resources with a size of 3.
#![allow(unused)] fn main() { let dhcp_config = DhcpConfig::default(); // dhcp_config.hostname = Some(String::from_str("implRust").unwrap()); let net_config = embassy_net::Config::dhcpv4(dhcp_config); let stack = &*mk_static!( Stack<WifiDevice<'_, WifiStaDevice>>, Stack::new( wifi_interface, net_config, mk_static!(StackResources<3>, StackResources::<3>::new()), net_seed ) ); }
Next, we will start two background tasks: the connection_task will maintain the Wi-Fi connection, while the net_task will run the network stack and handle network events.
#![allow(unused)] fn main() { spawner.spawn(connection_task(controller)).ok(); spawner.spawn(net_task(stack)).ok(); }
We'll shortly discuss what happens in these two tasks and check these function definitions. But first, let's complete the flow.
Access Website
We will wait for the Wi-Fi link to be up, then obtain the IP address. Finally, we call the access_website function with the network stack reference and the random number we generated for the HTTP client. We will explore the access_website function also in more detail shortly.
#![allow(unused)] fn main() { loop { if stack.is_link_up() { break; } Timer::after(Duration::from_millis(500)).await; } println!("Waiting to get IP address..."); loop { if let Some(config) = stack.config_v4() { println!("Got IP: {}", config.address); break; } Timer::after(Duration::from_millis(500)).await; } access_website(stack, tls_seed).await; }
Wi-Fi connection tasks
The connection_task function manages the Wi-Fi connection by continuously checking the status, configuring the Wi-Fi controller, and attempting to reconnect if the connection is lost or not started.
- First, we check the Wi-Fi state. If it is in StaConnected, we wait there until it gets disconnected. If it gets disconnected, we move to the other steps in the loop.
- We check if the Wi-Fi controller is started. If not, we initialize the Wi-Fi client configuration with the SSID (Wi-Fi name) and password, and start it.
- Finally, we attempt to connect to the Wi-Fi.
#![allow(unused)] fn main() { #[embassy_executor::task] async fn connection_task(mut controller: WifiController<'static>) { println!("start connection task"); println!("Device capabilities: {:?}", controller.capabilities()); loop { match esp_wifi::wifi::wifi_state() { WifiState::StaConnected => { // wait until we're no longer connected controller.wait_for_event(WifiEvent::StaDisconnected).await; Timer::after(Duration::from_millis(5000)).await } _ => {} } if !matches!(controller.is_started(), Ok(true)) { let client_config = Configuration::Client(ClientConfiguration { ssid: SSID.try_into().unwrap(), password: PASSWORD.try_into().unwrap(), ..Default::default() }); controller.set_configuration(&client_config).unwrap(); println!("Starting wifi"); controller.start_async().await.unwrap(); println!("Wifi started!"); } println!("About to connect..."); match controller.connect_async().await { Ok(_) => println!("Wifi connected!"), Err(e) => { println!("Failed to connect to wifi: {e:?}"); Timer::after(Duration::from_millis(5000)).await } } } } }
Run the network stack
#![allow(unused)] fn main() { #[embassy_executor::task] async fn net_task(stack: &'static Stack<WifiDevice<'static, WifiStaDevice>>) { stack.run().await } }
Final: Access the Website
We have covered how to set up the Wi-Fi connection with Embassy and walked through the overall flow leading up to the call to the access_website function. Since the previous chapter became quite lengthy, we didn't dive into the details of the access_website function. In this chapter, we will focus on how to send an HTTP request and receive a response from a website using the reqwless crate, and then we finally will run the code.
Sending HTTP Request
We begin by initializing the DNS socket and the TCP client.
TLS Config:
Next, we set up the TLS configuration using the tls_seed, which is the random number we generated earlier and passed into the function. We configure TLS to skip SSL certificate verification by setting TlsVerify::None. While this is not recommended for production environments, we use this insecure setting for simplicity. In production, you would need to configure TLS with certificate verification and provide the certificate chain so that it can use for verification.
The remaining part is pretty straightforward. We initialize the HTTP client with the TCP client, DNS socket, and TLS config. Then, we send a GET request to the jsonplaceholder website, which returns JSON content. We read the content and then print it to the console.
#![allow(unused)] fn main() { async fn access_website(stack: &'static Stack<WifiDevice<'static, WifiStaDevice>>, tls_seed: u64) { let mut rx_buffer = [0; 4096]; let mut tx_buffer = [0; 4096]; let dns = DnsSocket::new(&stack); let tcp_state = TcpClientState::<1, 4096, 4096>::new(); let tcp = TcpClient::new(stack, &tcp_state); let tls = TlsConfig::new( tls_seed, &mut rx_buffer, &mut tx_buffer, reqwless::client::TlsVerify::None, ); let mut client = HttpClient::new_with_tls(&tcp, &dns, tls); let mut buffer = [0u8; 4096]; let mut http_req = client .request( reqwless::request::Method::GET, "https://jsonplaceholder.typicode.com/posts/1", ) .await .unwrap(); let response = http_req.send(&mut buffer).await.unwrap(); info!("Got response"); let res = response.body().read_to_end().await.unwrap(); let content = core::str::from_utf8(res).unwrap(); println!("{}", content); } }
Clone the existing project
You can also clone (or refer) project I created and navigate to the wifi-async-http
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/wifi-async-http
How to run?
Normally, we would simply run cargo run --release
, but this time we also need to pass the environment variables for the Wi-Fi connection.
SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD cargo run --release
Writing Rust Code to Run a Website on ESP32
In earlier exercises, we accessed websites and printed the response in the console. In this exercise, we'll do the reverse: We will run a web server on the ESP32. This server will be accessible on the local Wi-Fi network. To make it accessible from the internet, additional setup is required. First, we'll focus on accessing the site within the local Wi-Fi network. We will still be working in STA mode only (i.e connecting to existing Wi-Fi).
What We will Be Doing
We'll set up a simple web server to serve a single index.html page. For this example, let's assume the ESP32 has been assigned the IP address "192.168.0.101" (it gets displayed it in the console, when we are running). Once the server is running, you can access the page by navigating to 'http://192.168.0.101/' in your computer's browser. You can either use your own HTML page or the index.html page I created for this exercise, which you can find here.
You can set a static IP address instead of letting the DHCP server assign it. This makes the IP address consistent but adds some extra steps. To keep things simple, we won't do it in this exercise. We’ll show you how to set up a static IP in a later exercise.
No more waiting, let's start right away.
Generate project using esp-generate
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 webserver-html
This will open a screen asking you to select options.
- Select the option "Enables Wi-Fi via the esp-wifi crate. Requires alloc". It automatically selects the espa-alloc crate option also
- Select the option "Adds embassy framework support".
Just save it by pressing "s" in the keyboard.
Update dependencies
picoserve crate
picoserve is a crate that provides an asynchronous HTTP server designed for bare-metal environments, heavily inspired by Axum. As you might have guessed from the name, it was first created with "Raspberry Pi Pico W" and embassy in mind. But it works fine with other embedded runtimes and hardware, including the ESP32. This crate makes our lives much easier. Without it, we would have to build the web server core from scratch, a time-consuming task that would be beyond the scope of this book.
picoserve = { version = "0.13.3", features = ["embassy"] }
Task arena size update
We will update the embassy-executor with the task-arena-size-65536 feature. For more details, refer to the Task Arena Size documentation here.
embassy-executor = { version = "0.6.3", features = ["task-arena-size-65536"] }
Update the embassy-net
To make some functions compatible with the latest picoserve crate, I needed to update embassy-net to version 0.5.0.
embassy-net = { version = "0.5.0", features = [
"tcp",
"udp",
"dhcpv4",
"medium-ethernet",
] }
Project Structure
We will organize the logic by splitting it into modules. Under the lib, we will create two submodules: web.rs and wifi.rs.
├── build.rs
├── Cargo.toml
├── rust-toolchain.toml
├── src
│ ├── bin
│ │ └── async_main.rs
│ ├── index.html
│ ├── lib.rs
│ ├── web.rs
│ └── wifi.rs
Lib Module
We'll relocate the mk_static macro, which allows creating static variables initialized at runtime, into the lib module. Additionally, we'll enable the impl_trait_in_assoc_type feature, as it is required by the picoserve crate.
#![allow(unused)] #![no_std] #![feature(impl_trait_in_assoc_type)] fn main() { pub mod web; 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 }}; } }
The main function (async_main.rs file)
To use the lib module, we would normally have to reference it using the full project name (e.g., webserver::web). However, to keep the references consistent across different exercises, we will alias the import as "lib". This allows us to use it as "lib::web" instead.
In the main function, we start with some boilerplate code to set up the global heap allocator and initialize Embassy.
Next, we create a Wi-Fi controller, which we will pass to the start_wifi function; that we will soon define in the wifi module. This function will return the network stack instance.
We will create a web application instance, configure routing and settings using the picoserve crate. We will then spawn multiple tasks to handle incoming requests based on the defined pool size. Each task receives the task ID, app instance, network stack, and server settings.
use webserver as lib; #[main] async fn main(spawner: Spawner) { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); esp_alloc::heap_allocator!(72 * 1024); esp_println::logger::init_logger_from_env(); let timer0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG1); esp_hal_embassy::init(timer0.timer0); let rng = Rng::new(peripherals.RNG); info!("Embassy initialized!"); let timg0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG0); let wifi_init = lib::mk_static!( EspWifiController<'static>, esp_wifi::init(timg0.timer0, rng, peripherals.RADIO_CLK).unwrap() ); let stack = lib::wifi::start_wifi(wifi_init, peripherals.WIFI, rng, &spawner).await; let web_app = lib::web::WebApp::default(); for id in 0..lib::web::WEB_TASK_POOL_SIZE { spawner.must_spawn(lib::web::web_task( id, *stack, web_app.router, web_app.config, )); } println!("Web server started..."); }
Wi-Fi Module
Wi-Fi connection setup
The Wi-Fi setup code is the same as explained in the "Connecting Wi-Fi with Embassy" chapter on the Access Website. To avoid repetition, I won't explain it again here. Please refer to that chapter if you haven't already.
#![allow(unused)] fn main() { const SSID: &str = env!("SSID"); const PASSWORD: &str = env!("PASSWORD"); }
Start Wi-Fi
The start_wifi
function is responsible for setting up and starting the Wi-Fi connection for the ESP32. Here's a step-by-step explanation:
-
We will create the Wi-Fi interface and controller in STA (Station) mode so the ESP32 can connect to an existing Wi-Fi network.
-
Normally, when a device connects to a Wi-Fi network, the router assigns it an IP address automatically using DHCP Server. In this exercise, we will configure the ESP32 to request DHCP for IP.
To achieve this, we will create anet_config
instance configured for DHCP. Then, we will use this configuration along with the Wi-Fi interface to initialize the network stack and runner instances. -
We will spawn two tasks:
- The connection task to monitor the Wi-Fi connection and reconnect, if it is disconnected.
- The network task to manage all network communications.
-
We will wait until the Wi-Fi link is up. Once the connection is ready, we will print the IP address assigned to our ESP32.
-
Finally, we will return the network stack instance. This will be used later by the web task to handle web server operations.
#![allow(unused)] fn main() { pub async fn start_wifi( wifi_init: &'static EspWifiController<'static>, wifi: esp_hal::peripherals::WIFI, mut rng: Rng, spawner: &Spawner, ) -> &'static Stack<'static> { let (wifi_interface, controller) = esp_wifi::wifi::new_with_mode(&wifi_init, wifi, WifiStaDevice).unwrap(); let net_seed = rng.random() as u64 | ((rng.random() as u64) << 32); let dhcp_config = DhcpConfig::default(); let net_config = embassy_net::Config::dhcpv4(dhcp_config); let (stack, runner) = mk_static!( ( Stack<'static>, Runner<'static, WifiDevice<'static, WifiStaDevice>> ), embassy_net::new( wifi_interface, net_config, mk_static!(StackResources<3>, StackResources::<3>::new()), net_seed ) ); spawner.spawn(connection_task(controller)).ok(); spawner.spawn(net_task(runner)).ok(); loop { if stack.is_link_up() { break; } Timer::after(Duration::from_millis(500)).await; } println!("Waiting to get IP address..."); loop { if let Some(config) = stack.config_v4() { println!("Got IP: {}", config.address); break; } Timer::after(Duration::from_millis(500)).await; } stack } }
Wi-Fi and Network Tasks
There is no major change in the logic of these two tasks. The only difference is that we are now passing the runner instance to the net_task, unlike before.
#![allow(unused)] fn main() { #[embassy_executor::task] async fn connection_task(mut controller: WifiController<'static>) { println!("start connection task"); println!("Device capabilities: {:?}", controller.capabilities()); loop { match esp_wifi::wifi::wifi_state() { WifiState::StaConnected => { // wait until we're no longer connected controller.wait_for_event(WifiEvent::StaDisconnected).await; Timer::after(Duration::from_millis(5000)).await } _ => {} } if !matches!(controller.is_started(), Ok(true)) { let client_config = Configuration::Client(ClientConfiguration { ssid: SSID.try_into().unwrap(), password: PASSWORD.try_into().unwrap(), ..Default::default() }); controller.set_configuration(&client_config).unwrap(); println!("Starting wifi"); controller.start_async().await.unwrap(); println!("Wifi started!"); } println!("About to connect..."); match controller.connect_async().await { Ok(_) => println!("Wifi connected!"), Err(e) => { println!("Failed to connect to wifi: {e:?}"); Timer::after(Duration::from_millis(5000)).await } } } } }
#![allow(unused)] fn main() { #[embassy_executor::task] async fn net_task(runner: &'static mut Runner<'static, WifiDevice<'static, WifiStaDevice>>) -> ! { runner.run().await } }
Web Module - Serve Webpage
We have completed the boilerplate for the Wi-Fi connection. Next, we will use the picoserve crate to set up a route for the root URL ("/") that will serve our HTML page.
impl_trait_in_assoc_type
feature
The picoserve crate requires the use of the impl_trait_in_assoc_type
feature, which is currently an unstable feature in Rust. To enable this feature, you need to add the following line to the top of your lib.rs file(which we did already):
#![allow(unused)] #![feature(impl_trait_in_assoc_type)] fn main() { }
Application and Routing
The picoserve crate provides various traits to configure routing and other features needed for a web application. The AppBuilder
trait is used to create a static router without state, while the AppWithStateBuilder
trait allows for a static router with application state. Since our application only serves a single HTML page and doesn't require state, we will implement the AppBuilder trait. You can find more examples of how to use picoserve here.
#![allow(unused)] fn main() { pub struct Application; impl AppBuilder for Application { type PathRouter = impl routing::PathRouter; fn build_app(self) -> picoserve::Router<Self::PathRouter> { picoserve::Router::new().route( "/", routing::get_service(File::html(include_str!("index.html"))), ) } } }
We have created a simple struct that implements the AppBuilder trait. We need to specify the PathRouter type, and we define it as any type that implements the routing::PathRouter trait.
Then, we need to implement the build_app function, which returns a Router instance. We set up a single route for "/," which serves a static HTML page. The content of the HTML page is embedded into the application at compile time using the include_str!("index.html") macro. Place the "index.html" file in the "src/" folder.
Pool size
We need to start multiple tasks to handle incoming requests. Soon, we'll create a web_task function(an Embassy task) with a pool size set by a constant value we define now. Then, we'll launch tasks in a loop based on this value, which controls how many tasks can run concurrently.
#![allow(unused)] fn main() { pub const WEB_TASK_POOL_SIZE: usize = 2; }
We have set the pool size to 2. In the picoserve's examples, the pool size is set to 8. You can increase the pool size, but keep in mind that you'll also need to adjust resources like sockets and memory arena accordingly.
Web Application
We will define a WebApp struct that contains an instance of the picoserve router and the configuration.
#![allow(unused)] fn main() { pub struct WebApp { pub router: &'static Router<<Application as AppBuilder>::PathRouter>, pub config: &'static picoserve::Config<Duration>, } }
Next, we implement the Default trait for WebApp and initialize the picoserve Router by calling build_app. We also configure server timeouts to control the duration for operations like reading a request, waiting to read, or waiting to write a response. If any operation exceeds the timeout, the connection will be closed.
#![allow(unused)] fn main() { impl Default for WebApp { fn default() -> Self { let router = picoserve::make_static!(AppRouter<Application>, Application.build_app()); let config = picoserve::make_static!( picoserve::Config<Duration>, picoserve::Config::new(picoserve::Timeouts { start_read_request: Some(Duration::from_secs(5)), read_request: Some(Duration::from_secs(1)), write: Some(Duration::from_secs(1)), }) .keep_connection_alive() ); Self { router, config } } } }
Web Task function
We have created an Embassy task and specified the pool size in the attribute. The web server will listen on port 80. For each task, we define the TCP read and write buffers, along with the HTTP buffer. Finally, we call the listen_and_serve function from picoserve to handle incoming requests.
#![allow(unused)] fn main() { #[embassy_executor::task(pool_size = WEB_TASK_POOL_SIZE)] pub async fn web_task( id: usize, stack: Stack<'static>, router: &'static AppRouter<Application>, config: &'static picoserve::Config<Duration>, ) -> ! { let port = 80; let mut tcp_rx_buffer = [0; 1024]; let mut tcp_tx_buffer = [0; 1024]; let mut http_buffer = [0; 2048]; picoserve::listen_and_serve( id, router, config, stack, port, &mut tcp_rx_buffer, &mut tcp_tx_buffer, &mut http_buffer, ) .await } }
Clone the existing project
You can also clone (or refer) project I created and navigate to the webserver-html
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/webserver-html
How to run?
For this example also, we need to pass the environment variables for the Wi-Fi connection. You can either create a .env file or pass them directly, as I am doing here.
SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD cargo run --release
Once you flash the program onto the ESP32, you should see the following output in your console with the IP address assigned by your Wi-Fi router.
Make sure your system is connected to the same Wi-Fi network. You can then access the webpage by navigating to "http://192.168.0.101/" (replace with the IP address you received) in your browser.
Exposing the ESP32 web server to internet
It is generally not recommended to access IoT web servers, like those hosted on ESP32, from the internet without proper security measures. These devices can be vulnerable to attacks such as unauthorized access, being co-opted into botnets or cause other kind of damages depending on the device(i would not take the risk). Additionally, using an ESP32 as a web hosting server is not practical due to challenges like exposing ports, handling traffic loads, and potential security risks. That said, it can be a great learning experience if you want to try it out or showcase it to your friends.
Portforwarding in Router
One option is to set up port forwarding on your router, mapping an external IP and port (e.g., public IP 202.xx.xx.10) to the ESP32's private IP and port (e.g., 192.168.56.101). However, most home networks use dynamic public IP addresses assigned by the ISP, which can change over time. This can make the setup unreliable and may not work in some cases. To ensure consistent access, you may need to configure a static public IP address, which usually involves contacting your ISP and may come with additional costs.
You can also use services like No-IP along with port forwarding and you can use a dynamic IP address. If your router supports it, you can configure DDNS or No-IP directly in the router settings. You can find more details on No-IP's official site here. However, keep in mind that this approach might not work with some ISPs that block open ports on dynamic IP addresses.
Tunneling
In the Tunneling option, you don't need a static IP address, but you will need to use an external service that provides tunneling. To make this work, you must install a client on your PC or Raspberry Pi (not the Pico) and run the client to establish the tunnel (Not fun, i know). You can't run this client on the ESP32. Once the tunnel is set up, you can access the server using the URL generated by the provider.
You can use services like ngrok or Cloudflare Tunnel. To get started with ngrok, you can check out the official guide here. For Cloudflare Tunnel, the official guide is available here.
ngrok example
For example, when using ngrok, I first signed up on their portal and obtained an authentication token. After that, I ran the client on my computer and added the token using the command ngrok config add-authtoken MY_AUTH_TOKEN
.
To establish the tunnel, I ran the following command: ngrok http 192.168.0.101:80
. Here, 192.168.0.101 is the IP address of the ESP32. The client then generated a public URL under the ngrok domain. I was able to access this URL from a different network(e.g: phone network) to confirm that the tunnel was working correctly.
Static IP Address
In previous exercises, we have been relying on the DHCP server to assign an IP address to the ESP32. However, this can be unreliable as the IP address may change over time. In many cases, we want to assign a static IP address to ensure the ESP32 always has a fixed, predictable address. This makes it easier to access the device consistently without having to check or update its IP address every time it reconnects to the network.
Now, we will remove the DHCP-related code and modify the net_config variable to initialize with a static IP address instead.
First, let's define the constants that will load the static IP and gateway IP from environment variables. The IP address should be in CIDR format, which includes both the IP address and the subnet mask. We need to specify the IP address followed by a slash and the subnet mask. For example, if you want to assign the IP address 192.168.0.50 to your ESP32, you should write it as 192.168.0.50/24.
You can't assign just any IP address. You need to find the IP range your Wi-Fi router is using. To do this, you can type `ip a` in the terminal and look for the IP address next to your Wi-Fi interface (typically starting with `wl`). For example, if your system's IP address is 192.168.0.103, you can assign an IP address starting from 192.168.0.2
#![allow(unused)] fn main() { // IP Address/Subnet mask eg: STATIC_IP=192.168.0.50/24 const STATIC_IP: &str = env!("STATIC_IP"); const GATEWAY_IP: &str = env!("GATEWAY_IP"); }
You will also need to configure the gateway, although it's not required for this exercise since we won't be sending requests to the internet. However, it's good practice to configure it for future exercises.
The gateway address is often the first address in your Wi-Fi IP range. For instance, if your IP addresses range from 192.168.0.1 to 192.168.0.255, the gateway is likely to be 192.168.0.1. You can also use the command ip route | grep default
in linux to find your gateway address.
#![allow(unused)] fn main() { //find the `let net_config` part and replace let Ok(ip_addr) = Ipv4Cidr::from_str(STATIC_IP) else { println!("Invalid STATIC_IP"); loop {} }; let Ok(gateway) = Ipv4Addr::from_str(GATEWAY_IP) else { println!("Invalid GATEWAY_IP"); loop {} }; let net_config = embassy_net::Config::ipv4_static(StaticConfigV4 { address: ip_addr, gateway: Some(gateway), dns_servers: Vec::new(), }); // You dont need to change anything in `embassy_net::new` call. }
Project Base
I have reorganized the project by splitting it into modules like wifi and web to keep the main file clean. We will use this project as the base for the upcoming exercise, so I recommend taking a look at how it is organized.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/webserver-base
Project Structure:
├── build.rs
├── Cargo.toml
├── rust-toolchain.toml
├── src
│ ├── bin
│ │ └── async_main.rs
│ ├── index.html
│ ├── lib.rs
│ ├── web.rs
│ └── wifi.rs
How to run?
Normally, we would simply run cargo run --release
, but this time we also need to pass the environment variables for the Wi-Fi connection.
SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD STATIC_IP=ASSIGN_ESP32_IP/24 GATEWAY_IP=WIFI_GATEWAY_IP cargo run --release
Write Rust Code to Control ESP32 LED via Wi-Fi
You can configure a web server on the ESP32 to receive instructions via web requests, allowing you to control connected devices or execute specific actions. For instance, you can use a browser on your computer or mobile phone to send commands to turn an LED on or off, adjust motor speed, or retrieve sensor data.
In this section, we will create a simple web page that allows us to turn an LED on or off.
Project base
This time, we are not going to set up the project with esp-generate. Instead, we will copy the webserver-base project and work on top of that.
I recommend you to read these section before you proceed furhter; This will avoid unnecessary repetition of code and explanations.
git clone https://github.com/ImplFerris/esp32-projects
cp -r esp32-projects/webserver-base ~/YOUR_PROJECT_FOLDER/wifi-led
Project Structure:
├── build.rs
├── Cargo.toml
├── rust-toolchain.toml
├── src
│ ├── bin
│ │ └── async_main.rs
│ ├── index.html
│ ├── led.rs
│ ├── lib.rs
│ ├── web.rs
│ └── wifi.rs
Serde
Serde is a Rust crate used for serializing and deserializing data structures. We will use it to handle the JSON data exchanged between the backend and the frontend.
Update the Cargo.toml with the following:
serde = { version = "1.0.217", default-features = false, features = ["derive"] }
LED Task
I placed these code in the "led.rs" module.
First, we'll create an Embassy task to toggle the onboard LED state based on the value stored in the LED_STATE variable, which will use the AtomicBool type. "Atomic types provide primitive shared-memory communication between threads, and are the building blocks of other concurrent types". To learn more about Atomic types, refer to the Rust standard library documentation on atomics or the Rust Atomics and Locks book.
#![allow(unused)] fn main() { use core::sync::atomic::{AtomicBool, Ordering}; use embassy_time::{Duration, Timer}; use esp_hal::gpio::Output; pub static LED_STATE: AtomicBool = AtomicBool::new(false); #[embassy_executor::task] pub async fn led_task(mut led: Output<'static>) { loop { if LED_STATE.load(Ordering::Relaxed) { led.set_high(); } else { led.set_low(); } Timer::after(Duration::from_millis(50)).await; } } }
In the led_task function, we take an LED pin as argument and continuously checks the value of the LED_STATE variable in a loop. We read the value using the load method with Ordering::Relaxed. If the value is true, we turn on the LED. Otherwise, we turn off the LED.
In the main function, we spawn the led_task to run it in the background. We will pass the GPIO 2 pin(If you want to use an external LED, replace it with the pin to which you connected the LED), which is the onboard LED, and we will set the initial state of the LED to Low.
#![allow(unused)] fn main() { // LED Task spawner.must_spawn(lib::led::led_task(Output::new( peripherals.GPIO2, Level::Low, ))); }
Control ESP32 LED with Webpage
We will create a simple API endpoint that accepts a boolean input to control the LED's state. Along with this, an "index.html" page will be served, displaying two buttons: one to turn the LED on and another to turn it off.
When you press one of the buttons, a request will be sent to the "/led" endpoint with the following JSON payload:
{ "is_on": true }
to turn the LED on{ "is_on": false }
to turn the LED off
Based on the value of the "is_on" field, the "LED_STATE" variable of the led module will be updated. The "led_task" will then turn the LED on or off accordingly.
Routing
In the "build_app" function, we configure the web routes for the application. The root path ("/") will serve the "index.html" content, we have to place this file inside "src/" folder. The "/led" path will accept "POST" requests and be handled by the "led_handler".
#![allow(unused)] fn main() { pub struct Application; impl AppBuilder for Application { type PathRouter = impl routing::PathRouter; fn build_app(self) -> picoserve::Router<Self::PathRouter> { picoserve::Router::new() .route( "/", routing::get_service(File::html(include_str!("index.html"))), ) .route("/led", routing::post(led_handler)) } } }
LED Handler
We will define two structs, one for handling the incoming input and one for sending the response. The LedRequest struct will derive Deserialize to parse the incoming JSON and provide it as a struct instance. The LedResponse struct will derive Serialize to convert the struct instance and send it as a JSON response.
#![allow(unused)] fn main() { #[derive(serde::Deserialize)] struct LedRequest { is_on: bool, } #[derive(serde::Serialize)] struct LedResponse { success: bool, } }
In the led_handler function, the LedRequest is extracted as a parameter. We can directly store the "is_on" value in the LED_STATE since both are boolean. Finally, the handler will return a JSON response with a LedResponse indicating success.
#![allow(unused)] fn main() { async fn led_handler(input: picoserve::extract::Json<LedRequest, 0>) -> impl IntoResponse { crate::led::LED_STATE.store(input.0.is_on, Ordering::Relaxed); picoserve::response::Json(LedResponse { success: true }) } }
WebPage content
You can download the index.html file from here and place it in the "src/" folder, or create your own custom content to send JSON requests.
NOTE:
You need to update the URL "http://192.168.0.50/led" with your ESP32's IP address. I've hardcoded it here for simplicity; otherwise, we would need to use a placeholder and replace it dynamically or adopt a template-based approach.
<div class="button-container">
<button class="btn-on" onclick="sendRequest(true)">Turn on LED</button>
<button class="btn-off" onclick="sendRequest(false)">Turn off LED</button>
</div>
<script>
function sendRequest(is_on) {
const url = 'http://192.168.0.50/led'; // Replace with STATIC IP of ESP32
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ is_on })
})
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('Network response was not ok');
})
.then(data => {
console.log('Success:', data);
//alert(LED turned ${action});
})
.catch(error => {
console.error('Error:', error);
alert('Failed to send the request');
});
}
</script>
Clone the existing project
You can also clone (or refer) project I created and navigate to the wifi-led
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/wifi-led
How to run?
Pass the Wi-Fi name, password, static IP, and gateway IP address as environment variables, then flash the ESP32
SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD STATIC_IP=ASSIGN_ESP32_IP/24 GATEWAY_IP=WIFI_GATEWAY_IP cargo run --release
Access Point - Create Wi-Fi network on ESP32
So far, we have been using an existing Wi-Fi network. However, you can create your own Wi-Fi network with the ESP32 (just don't expect it to provide internet 😉). In this exercise, we will configure the ESP32 as an access point and run the web server.
Generate project using esp-generate
We will create the project with Embassy support to take advantage of its async capabilities, making it a better fit for handling tasks that involve concurrency
To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 wifi-ap
This will open a screen asking you to select options.
- Select the option "Enables Wi-Fi via the esp-wifi crate. Requires alloc". It automatically selects the espa-alloc crate option also
- Select the option "Adds embassy framework support".
Just save it by pressing "s" in the keyboard.
Project structure
We will create two additional modules: web and wifi. These will be similar to what we implemented in the earlier sections while working on station mode. However, this time, the main difference is that we will configure the Wi-Fi to operate in Access Point mode. If you haven't completed the previous sections on Wi-Fi, it's highly recommended to finish them first.
├── build.rs
├── Cargo.toml
├── rust-toolchain.toml
├── src
│ ├── bin
│ │ └── async_main.rs
│ ├── lib.rs
│ ├── web.rs
│ └── wifi.rs
Update dependencies
We will create a simple web server, just like in the previous exercises. Therefore, we will add the same dependency as before.
picoserve crate
picoserve is a crate that provides an asynchronous HTTP server designed for bare-metal environments, heavily inspired by Axum. As you might have guessed from the name, it was first created with "Raspberry Pi Pico W" and embassy in mind. But it works fine with other embedded runtimes and hardware, including the ESP32. This crate makes our lives much easier. Without it, we would have to build the web server core from scratch, a time-consuming task that would be beyond the scope of this book.
picoserve = { version = "0.13.3", features = ["embassy"] }
Task arena size update
We will update the embassy-executor with the task-arena-size-65536 feature. For more details, refer to the Task Arena Size documentation here.
embassy-executor = { version = "0.6.3", features = ["task-arena-size-65536"] }
Update the embassy-net
To make some functions compatible with the latest picoserve crate, I needed to update embassy-net to version 0.5.0.
embassy-net = { version = "0.5.0", features = [
"tcp",
"udp",
"dhcpv4",
"medium-ethernet",
] }
Anyhow
This time, we will use anyhow::Error to handle errors in our code. You can achieve the same result without it, but I want to demonstrate how we can use anyhow for error handling. This library provides anyhow::Error, a trait object based error type that makes error handling in Rust applications easier and more idiomatic.
#![allow(unused)] fn main() { anyhow = { version = "1.0.95", default-features = false } }
Lib Module
We will define a macro to create a static variable that can be dynamically initialized and accessed across program functions. Additionally, we will declare the required modules in lib.rs.
#![allow(unused)] #![no_std] #![feature(impl_trait_in_assoc_type)] fn main() { pub mod web; pub mod wifi; // When you are okay with using a nightly compiler it's better to use https://docs.rs/static_cell/2.1.0/static_cell/macro.make_static.html #[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 }}; } }
Initialize Wi-Fi
First, I will import the library module and alias it as "lib". To access the modules defined within the library, we need to use the full project name. For consistency across different exercises, I will alias the module as "lib" in the import, so we can access them using "lib::web" instead of "wifi_ap::web" or "wifi_led::web" in different exercises.
#![allow(unused)] fn main() { use wifi_ap as lib; }
To initialize the Wi-Fi controller, we first set up the necessary peripherals, including the timer, random number generator, and radio clock.
#![allow(unused)] fn main() { let rng = Rng::new(peripherals.RNG); let timg0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG0); let wifi_init = lib::mk_static!( EspWifiController<'static>, esp_wifi::init( timg0.timer0, rng, peripherals.RADIO_CLK, ) .unwrap() ); }
Spawn tasks
Next, we create the Wi-Fi stack by calling the start_wifi function, which we will define in the next chapter. This function starts the Wi-Fi connection and network tasks in the background. Additionally, we create a WebApp instance and spawn multiple web tasks based on the pool size. These web tasks are responsible for handling incoming web requests.
#![allow(unused)] fn main() { // Configure and Start Wi-Fi tasks let stack = lib::wifi::start_wifi(wifi_init, peripherals.WIFI, rng, &spawner).await.unwrap(); // Web Tasks let web = lib::web::WebApp::default(); for id in 0..lib::web::WEB_TASK_POOL_SIZE { spawner.must_spawn(lib::web::web_task(id, *stack, web.app, web.config)); } println!("Web server started..."); }
Setup ESP32 Wi-Fi (in wifi.rs file)
To create our own Wi-Fi network with ESP32, we need to set a static IP address using CIDR notation(eg: 192.168.2.1/24) and specify the gateway IP(e.g: 192.168.2.1). We also need to give a Wi-Fi name (SSID), which can be anything you like, as long as it's within 32 characters. While the password is optional for the network, we will set it up with a password. The SSID and password will be loaded from environment variables, just like we did in Station mode.
#![allow(unused)] fn main() { // Unlike Station mode, You can give any IP range(private) that you like // IP Address/Subnet mask eg: STATIC_IP=192.168.13.37/24 const STATIC_IP: &str = "192.168.13.37/24"; // Gateway IP eg: GATEWAY_IP="192.168.13.37" const GATEWAY_IP: &str = "192.168.13.37"; const PASSWORD: &str = env!("PASSWORD"); const SSID: &str = env!("SSID"); }
Start Wi-Fi Function
This is the function we called from the main function. This is almost similar to what we have used in previous exercises, except we will use WifiApDevice for the Access Point mode. In this function, we will do the following:
-
We create the Wi-Fi interface and controller in Access Point mode .
-
We parse the static IP and gateway address, which are loaded from environment variables. Then, we create a network configuration instance using these values.
-
Using the network configuration, we create the network stack and the runner instance.
-
We spawn the connection task, which monitors the Wi-Fi network. If the connection stops, it restarts. We also spawn the network task, which handles all network communications.
-
We wait for the link to be up. Once it's ready, we print the IP address, which should be the static IP address we configured.
-
Finally, we will return the stack instance which will be used by the web task
#![allow(unused)] fn main() { pub async fn start_wifi( wifi_init: &'static EspWifiController<'static>, wifi: esp_hal::peripherals::WIFI, mut rng: Rng, spawner: &Spawner, ) -> anyhow::Result<&'static Stack<'static>> { // Create Wifi interface and controller let (wifi_interface, controller) = esp_wifi::wifi::new_with_mode(wifi_init, wifi, WifiApDevice).unwrap(); let net_seed = rng.random() as u64 | ((rng.random() as u64) << 32); // Parse STATIC_IP let ip_addr = Ipv4Cidr::from_str(STATIC_IP).map_err(|_| anyhow!("Invalid STATIC_IP: {}", STATIC_IP))?; // Parse GATEWAY_IP let gateway = Ipv4Addr::from_str(GATEWAY_IP) .map_err(|_| anyhow!("Invalid GATEWAY_IP: {}", GATEWAY_IP))?; // Create Network config with IP details let net_config = embassy_net::Config::ipv4_static(StaticConfigV4 { address: ip_addr, gateway: Some(gateway), dns_servers: Default::default(), }); // alternate approach // let net_config = embassy_net::Config::ipv4_static(StaticConfigV4 { // address: Ipv4Cidr::new(Ipv4Address::new(192, 168, 2, 1), 24), // gateway: Some(Ipv4Address::from_bytes(&[192, 168, 2, 1])), // dns_servers: Default::default(), // }); // Create Network stack and the runner let (stack, runner) = mk_static!( ( Stack<'static>, Runner<'static, WifiDevice<'static, WifiApDevice>> ), embassy_net::new( wifi_interface, net_config, mk_static!(StackResources<3>, StackResources::<3>::new()), net_seed ) ); // spawn the tasks spawner.spawn(connection_task(controller)).ok(); spawner.spawn(net_task(runner)).ok(); // wait for link to be up and print the IP loop { if stack.is_link_up() { break; } Timer::after(Duration::from_millis(500)).await; } println!("Waiting to get IP address..."); loop { if let Some(config) = stack.config_v4() { println!("Got IP: {}", config.address); break; } Timer::after(Duration::from_millis(500)).await; } Ok(stack) } }
Network Task
#![allow(unused)] fn main() { #[embassy_executor::task] async fn net_task(runner: &'static mut Runner<'static, WifiDevice<'static, WifiApDevice>>) -> ! { runner.run().await } }
Connection Task
The main goal of this function is to ensure the Wi-Fi network is running. If it is not running, it will be restarted. The function checks the Wi-Fi state in a loop. If the state is ApStarted
, it waits until the Wi-Fi gets stopped (i.e., the ApStop
event occurs). Once that happens, it moves to the second part of the loop.
If the Wi-Fi is not started, we will configure the Wi-Fi with the SSID, an optional password, and WPA2 Personal authentication mode. Then, we will start the Wi-Fi network.
Note:
If you want to run the Wi-Fi without a password, you can comment out the password
and auth_method
fields in the AccessPointConfiguration
. This will make the Wi-Fi network passwordless, will use the default configuration.
#![allow(unused)] fn main() { #[embassy_executor::task] async fn connection_task(mut controller: WifiController<'static>) { println!("start connection task"); println!("Device capabilities: {:?}", controller.capabilities()); loop { if esp_wifi::wifi::wifi_state() == WifiState::ApStarted { // wait until we're no longer connected controller.wait_for_event(WifiEvent::ApStop).await; Timer::after(Duration::from_millis(5000)).await } if !matches!(controller.is_started(), Ok(true)) { let wifi_config = Configuration::AccessPoint(AccessPointConfiguration { ssid: SSID.try_into().unwrap(), // Use whatever Wi-Fi name you want password: PASSWORD.try_into().unwrap(), // Set your password auth_method: esp_wifi::wifi::AuthMethod::WPA2Personal, ..Default::default() }); controller.set_configuration(&wifi_config).unwrap(); println!("Starting wifi"); controller.start_async().await.unwrap(); println!("Wifi started!"); } } } }
Web Module (web.rs)
This is similar to what we discussed in the WebServer chapter. I recommend reading that chapter for more details.
#![allow(unused)] fn main() { use core::include_str; use embassy_net::Stack; use embassy_time::Duration; use picoserve::{response::File, routing, AppBuilder, AppRouter, Router}; pub struct Application; impl AppBuilder for Application { type PathRouter = impl routing::PathRouter; fn build_app(self) -> picoserve::Router<Self::PathRouter> { picoserve::Router::new().route( "/", routing::get_service(File::html(include_str!("index.html"))), ) } } pub const WEB_TASK_POOL_SIZE: usize = 2; #[embassy_executor::task(pool_size = WEB_TASK_POOL_SIZE)] pub async fn web_task( id: usize, stack: Stack<'static>, app: &'static AppRouter<Application>, config: &'static picoserve::Config<Duration>, ) -> ! { let port = 80; let mut tcp_rx_buffer = [0; 1024]; let mut tcp_tx_buffer = [0; 1024]; let mut http_buffer = [0; 2048]; picoserve::listen_and_serve( id, app, config, stack, port, &mut tcp_rx_buffer, &mut tcp_tx_buffer, &mut http_buffer, ) .await } pub struct WebApp { pub app: &'static Router<<Application as AppBuilder>::PathRouter>, pub config: &'static picoserve::Config<Duration>, } impl Default for WebApp { fn default() -> Self { let app = picoserve::make_static!(AppRouter<Application>, Application.build_app()); let config = picoserve::make_static!( picoserve::Config<Duration>, picoserve::Config::new(picoserve::Timeouts { start_read_request: Some(Duration::from_secs(5)), read_request: Some(Duration::from_secs(1)), write: Some(Duration::from_secs(1)), }) .keep_connection_alive() ); Self { app, config } } } }
How to run?
We need to pass the environment variables for the Wi-Fi connection.
Note: This time, you need to provide the Wi-Fi name and password for the network we will create with the ESP32. You can choose any name up to 32 characters and pass a password (or skip it if you commented out the password section in the code).
SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD cargo run --release
Once you flash the program onto the ESP32, wait for it to print the IP address and say web server is started.
Connect your system to ESP32's Wi-Fi
We are not running any DHCP server on our ESP32, so devices that connect to our Wi-Fi network won't get an IP automatically. Therefore, we need to configure a static IP address when connecting in that device. You can look up how to set a static IP address for Wi-Fi on your operating system and configure it accordingly.
Make sure your system is connected to the Wi-Fi network we created. Then, you can access the webpage by navigating to "http://192.168.13.37/" (replace with the IP address you assigned) in your browser.
Connect your mobile to ESP32's Wi-Fi
When connecting your Android phone to the ESP32 Wi-Fi, select the Wi-Fi network, you can tap on "More details" (this may vary depending on your phone model), set a static IP, and then reconnect.
On Android, you may see an error message like "Internet may not be available" In this case, select either the "Connect only this time" or "Always Connect" option. Once connected, you can access the web server by navigating to the URL.
OLED Display with ESP32
So far, we've been printing output to the system console. While that's fine for debugging, it's not ideal for real-world use cases. If you're reading sensor data, like temperature, or need to show messages to a user (or yourself), a display is the way to go. Options like LCD and OLED are commonly used.
OLED displays consume less power and offer better performance than LCDs since they don't require a backlight. They provide high contrast and superior image quality compared to LCDs. In this section, we'll learn how to use an OLED display module with the ESP32.
Meet the Hardware
OLED, short for Organic Light-Emitting Diode, is a popular display module. These displays come in various sizes and can support different colors. They communicate using either the I²C or SPI protocol.
For this exercise, we'll use a 0.96-inch OLED monochrome module with a resolution of 128 x 64. It operates at 3.3V. We can communicate using I2C communication protocol.
Note: Most of the time, OLED displays come with pin headers included but not soldered. Soldering is a valuable skill to learn, but it requires care and preparation. Before attempting it, watch plenty of tutorials and do your research. It may feel challenging at first, but with practice, it gets easier. If you're not comfortable soldering yet, consider looking for a pre-soldered version of the display, though it may cost slightly more.
SSD1306
The SSD1306 is the integrated controller chip that powers many small OLED displays including the module we are going to use(0.96-inch 128x64 module). This controller handles the communication between the ESP32 and the OLED panel, enabling the display to show text, graphics, and more.
DataSheet: You can find the datasheet for SSD1306 here.
How OLED module works?
We won't dive into the details of how OLED technology works; instead, we'll focus on what's relevant for our exercises. The module has a resolution of 128x64, giving it a total of 128 × 64 = 8192 pixels. Each pixel can be turned on or off independently.
In the datasheet, the 128 columns are referred to as segments, while the 64 rows are called commons (be careful not to confuse "commons" with "columns" due to their similar spelling).
Memory
The OLED display's pixels are arranged in a page structure within GDDRAM (Graphics Display DRAM). GDDRAM is divided into 8 pages (From Page 0 to Page 7), each consisting of 128 columns (segments) and 8 rows(commons).
(This image is taken from the datasheet)A segment is 8 bits of data (one byte), with each bit representing a single pixel. When writing data, you will write an entire segment, meaning the entire byte is written at once.
(This image is taken from the datasheet)We can re-map both segments and commons through software for mechanical flexibility. You can find more details on page 25 of the ssd1306 datasheet.
Pages and Segments
I created an image to show how 128x64 pixels are divided into 8 pages. I then focused on a single page, which contains 128 segments (columns) and 8 rows. Finally, I zoomed in on a single segment to demonstrate how it represents 8 vertically stacked pixels, with each pixel corresponding to one bit.
Library
Don't worry if these concepts are unclear for now - you can always research them later. These details are more relevant if you plan to write your own driver for the SSD1306 or work on more advanced tasks. For now, we already have a great Rust crate that handles these aspects and simplifies the process.
Circuit
We will use I2C for communication between the ESP32 and the OLED display. For I2C, two GPIO pins need to be configured as SDA (Serial Data) and SCL (Serial Clock). On the ESP32, we can use any GPIO pins for I2C. We'll configure GPIO pin 18 as SCL and GPIO pin 23 as SDA. The VCC pin of the OLED will be connected to the 3.3V pin of the ESP32, and the Ground pin will be connected to the ESP32's ground.
ESP32 Pin | Wire | OLED Pin | Notes |
---|---|---|---|
3.3V |
|
VCC | Supplies power to the OLED display. |
GND |
|
Ground | Connects to the ground pin of the OLED. |
GPIO 18 |
|
SCL | Connects the clock signal (SCL) for I2C communication. |
GPIO 23 |
|
SDA | Connects the data signal (SDA) for I2C communication. |
Crates
We will primarily use two crates to control the OLED display: ssd1306 and embedded_graphics.
SSD1306 OLED display driver
This crate offers a driver interface for the SSD1306 monochrome OLED display, supporting both I2C and SPI through the display_interface crate. It also has async support that you have to enable through the feature flag.
Add ssd1306 with Async support
At the time of writing, version 0.9.0 was causing issues. Although the problem has been fixed, the version hasn't been tagged yet. For now, we will use the GitHub commit.
# ssd1306 = { version = "0.9.0", features = ["async"] }
# https://github.com/rust-embedded-community/ssd1306/issues/219
ssd1306 = { git = "https://github.com/rust-embedded-community/ssd1306.git", rev = "f3a2f7aca421fbf3ddda45ecef0dfd1f0f12330e", features = [
"async",
] }
Add ssd1306 without Async support
# ssd1306 = { version = "0.9.0", features = ["async"] }
# https://github.com/rust-embedded-community/ssd1306/issues/219
ssd1306 = { git = "https://github.com/rust-embedded-community/ssd1306.git", rev = "f3a2f7aca421fbf3ddda45ecef0dfd1f0f12330e", features = [] }
Embedded Graphics
To display text or draw images on the OLED display, we will use the embedded_graphics crate in combination with the ssd1306 crate.
"Embedded-graphics is a 2D graphics library that is focused on memory constrained embedded devices."
"A core goal of embedded-graphics is to draw graphics without using any buffers; the crate is no_std compatible and works without a dynamic memory allocator, and without pre-allocating large chunks of memory. To achieve this, it takes an Iterator based approach, where pixel colors and positions are calculated on the fly, with the minimum of saved state. This allows the consuming application to use far less RAM at little to no performance penalty."
You can use this crate with various OLED displays and drivers when working with different types of OLED modules. The documentation provides a detailed explanation of the features and supported drivers. I recommend going through it.
embedded-graphics = "0.8.1"
Display "Hello, Rust!" on OLED with ESP32
This exercise serves as a simple introduction to the OLED display, so we'll keep it straightforward by displaying "Hello, Rust!" on the OLED screen.
Generate project using esp-generate
We will enable async (Embassy) support for this project. To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 hello-oled
This will open a screen asking you to select options.
- Select the option "Adds embassy framework support".
Just save it by pressing "s" in the keyboard.
Update Cargo.toml
ssd1306 = { git = "https://github.com/rust-embedded-community/ssd1306.git", rev = "f3a2f7aca421fbf3ddda45ecef0dfd1f0f12330e", features = [
"async",
] }
embedded-graphics = "0.8.1"
Initialize I2C
We initialize the I2C interface for communication between the ESP32 and the OLED display. The I2C bus is configured with a frequency of 400 kHz and a timeout of 100 bus clock cycles. We assign GPIO18 to the SCL (Serial Clock Line) and GPIO23 to the SDA (Serial Data Line) for I2C communication, and enable async operation for the interface.
#![allow(unused)] fn main() { let i2c0 = esp_hal::i2c::master::I2c::new( peripherals.I2C0, esp_hal::i2c::master::Config { frequency: 400.kHz(), timeout: Some(100), }, ) .with_scl(peripherals.GPIO18) .with_sda(peripherals.GPIO23) .into_async(); }
Initialize ssd1306 driver
First, we will use the helper struct "I2CDisplayInterface" to create a preconfigured I2C interface for the display. Next, we will use the "Ssd1306Async" struct (for non-async, use "Ssd1306") and pass the interface instance we created, the display size, which is "DisplaySize128x64", and the display rotation. Since we don't want any rotation, we will set it to "DisplayRotation::Rotate0".
The ssd1306 crate supports three display modes:
- BasicMode, which offers basic control with lower-level methods
- BufferedGraphicsMode, which uses a framebuffer for advanced drawing and integrates with embedded-graphics
- and TerminalMode, a bufferless mode designed for drawing text and setting cursor positions like a terminal.
We will use the BufferedGraphicsMode for this exercise.
Next, we call the init() function to initialize and clear the display in graphics mode.
#![allow(unused)] fn main() { let interface = I2CDisplayInterface::new(i2c0); // initialize the display let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) .into_buffered_graphics_mode(); display.init().await.unwrap(); }
Text Style and Position
We will use monospaced fonts to display text. The MonoTextStyleBuilder will help us create the text style, and we will use a 6x10 pixel font size. You can find other monospaced fonts here.
If you are using a multi-color OLED display, you can specify different font colors. However, since we are using a monochrome display, we will use "BinaryColor::On" to set the text color to white. This simply turns on those pixels needed to display the text.
#![allow(unused)] fn main() { let text_style = MonoTextStyleBuilder::new() .font(&FONT_6X10) .text_color(BinaryColor::On) .build(); Text::with_baseline("Hello, Rust!", Point::new(0, 16), text_style, Baseline::Top) .draw(&mut display) .unwrap(); }
The baseline is an imaginary line that determines where the text is aligned. We set the baseline, with the x position at 0 and the y position at 16. We also specify how the text should be aligned within this space. Baseline Enum controls how the text is positioned within the baseline. For example, using Baseline::Top aligns the top of the text with the starting point, while Baseline::Bottom aligns the bottom of the text with the starting point. It also has other options like Middle, Alphabetic.
I recommend adjusting the point values and the Baseline value to see how it affects the appearance. The visual changes will provide a better clarity.
Next, we can draw the text on any thing that implements the DrawTarget trait. The ssd1306 BufferedGraphicsMode implements this trait, so we can pass the display as a mutable reference to the draw function.
Flush
Finally, we call the flush
function, which writes the data to the display. Only after this will the updated content appear on the OLED screen.
#![allow(unused)] fn main() { display.flush().await.unwrap(); }
Clone the existing project
You can also clone (or refer) project I created and navigate to the hello-oled
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/hello-oled
Full code
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_time::{Duration, Timer}; use embedded_graphics::{ mono_font::{ascii::FONT_6X10, MonoTextStyleBuilder}, pixelcolor::BinaryColor, prelude::Point, text::{Baseline, Text}, }; use esp_backtrace as _; use esp_hal::prelude::*; use log::info; use ssd1306::{ mode::DisplayConfigAsync, prelude::DisplayRotation, size::DisplaySize128x64, I2CDisplayInterface, Ssd1306Async, }; use embedded_graphics::prelude::*; #[main] async fn main(_spawner: Spawner) { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); esp_println::logger::init_logger_from_env(); let timer0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG1); esp_hal_embassy::init(timer0.timer0); info!("Embassy initialized!"); let i2c0 = esp_hal::i2c::master::I2c::new( peripherals.I2C0, esp_hal::i2c::master::Config { frequency: 400.kHz(), timeout: Some(100), }, ) .with_scl(peripherals.GPIO18) .with_sda(peripherals.GPIO23) .into_async(); let interface = I2CDisplayInterface::new(i2c0); // initialize the display let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) .into_buffered_graphics_mode(); display.init().await.unwrap(); let text_style = MonoTextStyleBuilder::new() .font(&FONT_6X10) .text_color(BinaryColor::On) .build(); Text::with_baseline("Hello, Rust!", Point::new(0, 16), text_style, Baseline::Top) .draw(&mut display) .unwrap(); display.flush().await.unwrap(); loop { Timer::after(Duration::from_secs(1)).await; } }
Draw Raw Image on OLED Display with ESP32
In this exercise, we will draw a raw image using only byte arrays. We will create the Ohm (Ω) symbol in a 1BPP (1 Bit Per Pixel) format.
1BPP Image
The 1BPP (1 bit per pixel) format uses a single bit for each pixel. It can represent only two colors, typically black and white. If the bit value is 0, it will typically be full black. If the bit value is 1, it will typically be full white.
We will create the ohm symbol using an 8x5 pixel grid in 1bpp format. I have highlighted the 1's in the byte array to show how they turn on the pixels to form the ohm symbol.
I chose 8 as the width to keep the example simple. This makes it easy to represent the 8 pixels width using a single byte (8 bits). But if you increase the width, it won't fit in one byte anymore, so it will need to be spread across multiple elements in the byte array. I will explain this in later chapters. For now, let's keep it simple.
Ohm symbol on the OLED Display (128x64)
Let me show you how it looks when the Ohm symbol is positioned on the OLED display (128x64 resolution) at position zero(x is 0 and y is also 0).
This is an enlarged illustration. When you see the symbol on the actual display module, it will be small.
Reference
- Embedded Graphics' ImageRaw Documentation
- Image2Bytes: Convert image to Hex byte array
Code
By now, i hope you understand how the image is represented in the byte array. Now, let's move on to the coding part.
Generate project using esp-generate
We will enable async (Embassy) support for this project. To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 oled-image
This will open a screen asking you to select options.
- Select the option "Adds embassy framework support".
Just save it by pressing "s" in the keyboard.
Update Cargo.toml
ssd1306 = { git = "https://github.com/rust-embedded-community/ssd1306.git", rev = "f3a2f7aca421fbf3ddda45ecef0dfd1f0f12330e", features = [
"async",
] }
embedded-graphics = "0.8.1"
Boilerplate: Initialize I2C and Display instance
We have already explained this part in the previous chapter.
#![allow(unused)] fn main() { let i2c0 = esp_hal::i2c::master::I2c::new( peripherals.I2C0, esp_hal::i2c::master::Config { frequency: 400.kHz(), timeout: Some(100), }, ) .with_scl(peripherals.GPIO18) .with_sda(peripherals.GPIO23) .into_async(); let interface = I2CDisplayInterface::new(i2c0); // initialize the display let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) .into_buffered_graphics_mode(); display.init().await.unwrap(); }
Draw Image
We have created a byte array constant to represent the Ohm symbol.
#![allow(unused)] fn main() { // 8x5 pixels #[rustfmt::skip] const IMG_DATA: &[u8] = &[ 0b00111000, 0b01000100, 0b01000100, 0b00101000, 0b11101110, ]; }
We will create a raw image using the ImageRaw::new function. We need to specify the image width(i.e 8) and pixel color format with turbofish syntax ::<>
. The height of the image will be calculated automatically based on the data length and format. Since the display module we are using is only of two colors, we will use the BinaryColor enum.
Then we will draw the image at the starting position of the display, which is Point zero (x = 0, y = 0). Finally, we will flush the data to the display module.
#![allow(unused)] fn main() { let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 8); let image = Image::new(&raw_image, Point::zero()); image.draw(&mut display).unwrap(); display.flush().await.unwrap(); }
Clone the existing project
You can also clone (or refer) project I created and navigate to the oled-image
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/oled-image
The full code
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_time::{Duration, Timer}; use embedded_graphics::{ image::{Image, ImageRaw}, pixelcolor::BinaryColor, prelude::Point, }; use esp_backtrace as _; use esp_hal::prelude::*; use log::info; use ssd1306::{ mode::DisplayConfigAsync, prelude::DisplayRotation, size::DisplaySize128x64, I2CDisplayInterface, Ssd1306Async, }; use embedded_graphics::prelude::*; #[rustfmt::skip] const IMG_DATA: &[u8] = &[ 0b00111000, 0b01000100, 0b01000100, 0b00101000, 0b11101110, ]; #[main] async fn main(_spawner: Spawner) { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); esp_println::logger::init_logger_from_env(); let timer0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG1); esp_hal_embassy::init(timer0.timer0); info!("Embassy initialized!"); let i2c0 = esp_hal::i2c::master::I2c::new( peripherals.I2C0, esp_hal::i2c::master::Config { frequency: 400.kHz(), timeout: Some(100), }, ) .with_scl(peripherals.GPIO18) .with_sda(peripherals.GPIO23) .into_async(); let interface = I2CDisplayInterface::new(i2c0); // initialize the display let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) .into_buffered_graphics_mode(); display.init().await.unwrap(); let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 8); let image = Image::new(&raw_image, Point::zero()); image.draw(&mut display).unwrap(); display.flush().await.unwrap(); loop { Timer::after(Duration::from_secs(1)).await; } }
Using Multiple Bytes to Represent Wider Pixel Widths
In the previous example, we kept it simple by using an 8-pixel wide image, allowing each row to be represented by a single byte. However, in real scenarios, we might need more pixels. So, how do we represent them? We can use multiple bytes to represent wider pixel widths. But, hold on;if we do that, how will the system differentiate between columns and rows?
This is exactly why we need to specify the exact width in the embedded graphics crate. By specifying the width, the system knows how many array entries to use to represent the width. Based on this width size (and the image format), the system can then determine the height.
For example, let's consider an image with a resolution of 31x7 pixels. The width is 31 pixels, and each pixel is represented by 1 bit. To represent the 31 pixels in terms of bytes, we need to calculate how many bytes are required. Since a byte is 8 bits, we divide the total number of pixels (31) by 8. This gives us 3 full bytes to represent 24 pixels, and we need an additional byte to store the remaining 7 pixels. Therefore, 4 bytes are required to represent the 31 pixels.
The embedded graphics crate uses this snippet internally to calculate the height. We won't be including this code in our own, but I’m showing it here for reference to demonstrate how it works internally:
#![allow(unused)] fn main() { let height = data.len() / bytes_per_row(width, C::Raw::BITS_PER_PIXEL); //... //... const fn bytes_per_row(width: u32, bits_per_pixel: usize) -> usize { (width as usize * bits_per_pixel + 7) / 8 } }
Here, the length of the data is 28 (array entries), the bits per pixel is 1, and the image width is 31. If you apply the logic, you'll get bytes_per_row as 4 and the height as 7.
You can run the following code here itself or in the Rust Playground to understand the logic behind it:
// 31x7 pixel #[rustfmt::skip] const IMG_DATA: &[u8] = &[ // 1st row 0b00000001,0b11111111,0b11111111,0b00000000, // 2nd row 0b00000001,0b11111111,0b11111111,0b00000000, //3rd row 0b00000001,0b10000000,0b00000011,0b00000000, //4th row 0b11111111,0b10000000,0b00000011,0b11111110, //5th row 0b00000001,0b10000000,0b00000011,0b00000000, //6th row 0b00000001,0b11111111,0b11111111,0b00000000, //7th row 0b00000001,0b11111111,0b11111111,0b00000000, ]; const fn bytes_per_row(width: u32, bits_per_pixel: usize) -> usize { (width as usize * bits_per_pixel + 7) / 8 } fn main(){ const BITS_PER_PIXEL: usize = 1; let width = 31; let data = IMG_DATA; println!("Bytes Per Row:{}", bytes_per_row(width,BITS_PER_PIXEL)); let height = data.len() / bytes_per_row(width, BITS_PER_PIXEL); println!("Height: {}", height); }
You dont need to manually create these byte array, you can use an online tool like imag2bytes to generate the byte array for you.
Code
The main changes in the code are the image data and width. This will display the resistor symbol in the IEC-60617 style.
Generate project using esp-generate
We will enable async (Embassy) support for this project. To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 oled-rawimg
This will open a screen asking you to select options.
- Select the option "Adds embassy framework support".
Just save it by pressing "s" in the keyboard.
Update Cargo.toml
ssd1306 = { git = "https://github.com/rust-embedded-community/ssd1306.git", rev = "f3a2f7aca421fbf3ddda45ecef0dfd1f0f12330e", features = [
"async",
] }
embedded-graphics = "0.8.1"
#![allow(unused)] fn main() { // 31x7 pixel #[rustfmt::skip] const IMG_DATA: &[u8] = &[ // 1st row 0b00000001,0b11111111,0b11111111,0b00000000, // 2nd row 0b00000001,0b11111111,0b11111111,0b00000000, //3rd row 0b00000001,0b10000000,0b00000011,0b00000000, //4th row 0b11111111,0b10000000,0b00000011,0b11111110, //5th row 0b00000001,0b10000000,0b00000011,0b00000000, //6th row 0b00000001,0b11111111,0b11111111,0b00000000, //7th row 0b00000001,0b11111111,0b11111111,0b00000000, ]; }
We need to set the width to 31. We'll draw the image at the point (x=35, y=35), though there's no particular reason for choosing these coordinates. I just wanted to show something other than the point zero. Feel free to experiment with different values for the point and explore other options.
#![allow(unused)] fn main() { let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 31); let image = Image::new(&raw_image, Point::new(35, 35)); }
Clone the existing project
You can also clone (or refer) project I created and navigate to the oled-rawimg
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/oled-rawimg
The full code
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_time::{Duration, Timer}; use embedded_graphics::{ image::{Image, ImageRaw}, pixelcolor::BinaryColor, prelude::Point, }; use esp_backtrace as _; use esp_hal::prelude::*; use log::info; use ssd1306::{ mode::DisplayConfigAsync, prelude::DisplayRotation, size::DisplaySize128x64, I2CDisplayInterface, Ssd1306Async, }; use embedded_graphics::prelude::*; // 31x7 pixel #[rustfmt::skip] const IMG_DATA: &[u8] = &[ // 1st row 0b00000001,0b11111111,0b11111111,0b00000000, // 2nd row 0b00000001,0b11111111,0b11111111,0b00000000, //3rd row 0b00000001,0b10000000,0b00000011,0b00000000, //4th row 0b11111111,0b10000000,0b00000011,0b11111110, //5th row 0b00000001,0b10000000,0b00000011,0b00000000, //6th row 0b00000001,0b11111111,0b11111111,0b00000000, //7th row 0b00000001,0b11111111,0b11111111,0b00000000, ]; #[main] async fn main(_spawner: Spawner) { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); esp_println::logger::init_logger_from_env(); let timer0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG1); esp_hal_embassy::init(timer0.timer0); info!("Embassy initialized!"); let i2c0 = esp_hal::i2c::master::I2c::new( peripherals.I2C0, esp_hal::i2c::master::Config { frequency: 400.kHz(), timeout: Some(100), }, ) .with_scl(peripherals.GPIO18) .with_sda(peripherals.GPIO23) .into_async(); let interface = I2CDisplayInterface::new(i2c0); // initialize the display let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) .into_buffered_graphics_mode(); display.init().await.unwrap(); let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 31); let image = Image::new(&raw_image, Point::new(35, 35)); image.draw(&mut display).unwrap(); display.flush().await.unwrap(); loop { Timer::after(Duration::from_secs(1)).await; } }
Using Bitmap Image file
You can use BMP (.bmp) files directly instead of raw image data by utilizing the tinybmp crate. tinybmp is a lightweight BMP parser designed for embedded environments. While it is mainly intended for drawing BMP images to embedded_graphics DrawTargets, it can also be used to parse BMP files for other applications. This is perfect for our purpose.
BMP file
The crate requires the image to be in BMP format. If your image is in another format, you will need to convert it to BMP. For example, you can use the following command on Linux to convert a PNG image to a monochrome BMP:
convert ferris.png -monochrome ferris.bmp
I have created the Ferris BMP file, which you can use for this exercise. Download it from here.
Generate project using esp-generate
We will enable async (Embassy) support for this project. To create the project, use the esp-generate
command. Run the following:
esp-generate --chip esp32 oled-image
This will open a screen asking you to select options.
- Select the option "Adds embassy framework support".
Just save it by pressing "s" in the keyboard.
Update Cargo.toml
ssd1306 = { git = "https://github.com/rust-embedded-community/ssd1306.git", rev = "f3a2f7aca421fbf3ddda45ecef0dfd1f0f12330e", features = [
"async",
] }
embedded-graphics = "0.8.1"
tinybmp = "0.6.0"
Using the BMP File
Place the ferris.bmp file inside the src folder. The code is pretty straightforward: load the image as bytes and pass it to the from_slice function of the Bmp. Then, you can use it with the Image.
#![allow(unused)] fn main() { // the usual boilerplate code goes here... // Include the BMP file data. let bmp_data = include_bytes!("../ferris.bmp"); // Parse the BMP file. let bmp = Bmp::from_slice(bmp_data).unwrap(); // usual code: let image = Image::new(&bmp, Point::new(32, 0)); image.draw(&mut display).unwrap(); display.flush().await.unwrap(); }
Clone the existing project
You can also clone (or refer) project I created and navigate to the oled-bmp
folder.
git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/oled-bmp
Full code
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_time::{Duration, Timer}; use embedded_graphics::{image::Image, prelude::Point}; use esp_backtrace as _; use esp_hal::prelude::*; use log::info; use ssd1306::{ mode::DisplayConfigAsync, prelude::DisplayRotation, size::DisplaySize128x64, I2CDisplayInterface, Ssd1306Async, }; use embedded_graphics::prelude::*; use tinybmp::Bmp; #[main] async fn main(_spawner: Spawner) { let peripherals = esp_hal::init({ let mut config = esp_hal::Config::default(); config.cpu_clock = CpuClock::max(); config }); esp_println::logger::init_logger_from_env(); let timer0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG1); esp_hal_embassy::init(timer0.timer0); info!("Embassy initialized!"); let i2c0 = esp_hal::i2c::master::I2c::new( peripherals.I2C0, esp_hal::i2c::master::Config { frequency: 400.kHz(), timeout: Some(100), }, ) .with_scl(peripherals.GPIO18) .with_sda(peripherals.GPIO23) .into_async(); let interface = I2CDisplayInterface::new(i2c0); // initialize the display let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) .into_buffered_graphics_mode(); display.init().await.unwrap(); // Include the BMP file data. let bmp_data = include_bytes!("../ferris.bmp"); // Parse the BMP file. let bmp = Bmp::from_slice(bmp_data).unwrap(); let image = Image::new(&bmp, Point::new(32, 0)); image.draw(&mut display).unwrap(); display.flush().await.unwrap(); loop { Timer::after(Duration::from_secs(1)).await; } }
Curated List of Projects Written in Rust for ESP32
Here is a curated list of projects I found online that are interesting and related to ESP32 and Rust. If you have some interesting projects to showcase, please send a PR :)
Note: It will contain projects related to all ESP32 families. So may not be exact ESP32 SoC.
ESP32
- ESP32 Rex: Dinosaur Game for the ESP32 with an OLED display, using the Embassy framework.
- ESP32 Wi-Fi Tank: A Wifi-controlled tank/rover built with an ESP32 control board and Rust
- Solar Inverter: Grid-Tie Solar Inverter with MPPT
- Paper train: Displays NMBS train delays on an e-ink display, driven by an esp32
ESP32C3
- ha-vfd-dashboard A Home Assistant dashboard made using a vacuum fluorescent display