Table of contents
This blog post is the fifth of a multi-part series of posts where I explore various peripherals in the ESP32C3 using standard library embedded Rust and the esp-idf-hal. Please be aware that certain concepts in newer posts could depend on concepts in prior posts.
Prior posts include (in order of publishing):
Introduction
In this post, I will be exploring the generating PWM for the ESP32C3 using the Rust esp32c3-hal. Implementing hardware-based PWM in the ESP32C3 is a bit non-conventional. Meaning that I expected the timer peripheral to have a PWM function similar to other microcontrollers you might use. ESP32s rather seem to have three types of application-driven peripherals that enable PWM implementation; the LED controller (LEDC) peripheral, the motor control (MCPWM) peripheral, and the Remote Control Peripheral (RMT). The ESP32C3 in particular does not have an MCPWM peripheral, so the choices come down to two. In this post, I use the LEDC peripheral. As such, I will configure and set up the LEDC peripheral to do a servo motor sweep. This will lead the servo motor to swing back and forth continuously.
π Knowledge Pre-requisites
To understand the content of this post, you need the following:
Basic knowledge of coding in Rust.
Basic knowledge of Servos and PWMs
πΎ Software Setup
All the code presented in this post is available on the apollolabs ESP32C3 git repo. Note that if the code on the git repo is slightly different then it means that it was modified to enhance the code quality or accommodate any HAL/Rust updates.
Additionally, the full project (code and simulation) is available on Wokwi here.
π Hardware Setup
Materials
-
SG90 Servo Motor
π Connections
π Note
All connection details are also shown in the Wokwi example.
Connections include the following:
Gpio7 wired to the PWM pin of the servo.
Servo V+ connected to ESP 5V
Servo Gnd connected to ESP GND
π¨βπ¨ Software Design
A servo motor's control involves transmitting a sequence of pulses along the signal line. This comes in the form of a PWM signal. The control signal's frequency should ideally be 50Hz, equivalent to, a period of 20 ms, or in other words, a pulse recurring every 20 ms. The width of each pulse dictates the servo's angular position, typically within a range of 180 degrees (constrained by physical limits of movement).
In general, pulses lasting 1 ms correspond to a 0-degree position, 1.5 ms to a 90-degree angle, and 2 ms to a full 180-degree rotation. However, variations may exist among different brands, leading to potential differences in the minimum and maximum pulse durations. Some servos could utilize 0.5ms for 0-degree positioning and 2.5ms for a 180-degree orientation. This typically can be addressed through calibration.
A servo motor sweep algorithm would make a servo motor smoothly sweep its output shaft back and forth over a specified range of angles. This creates a controlled motion pattern, often used for purposes like scanning, testing, or showcasing. Here's how a servo motor sweep algorithm typically works:
Define Parameters: Determine the range of PWM duty cycles you want the servo to sweep through. These would map to actual angles. This could be from a minimum angle (e.g., 0 degrees) corresponding to 0.5 ms to a maximum angle (e.g., 180 degrees) corresponding to 2.5 ms.
Initialization: Position the servo motor at the starting angle of the sweep range (e.g., 0 degrees). You may also need to introduce a delay to allow the servo to reach this position smoothly.
Sweeping Loop: Create a loop that gradually increases the angle in increments until the maximum angle is reached. During each iteration of the loop, update the servo's position by changing the PWM duty cycle to move it to the new angle.
Direction Change: Once the servo reaches the maximum angle, reverse the direction of the sweep. Decrease the angle in increments back to the minimum angle.
Loop Continuation: Repeat the loop, incrementing and decrementing the angle, until the servo returns to its starting position (minimum angle).
One thing to keep in mind is delay and smoothing. To create a smooth motion, one can introduce a small delay between angle changes. This prevents rapid jerking of the motor and allows it to move at a controlled pace.
On to coding!
π¨βπ» Code Implementation
π₯ Crate Imports
In this implementation, one crate is required as follows:
- The
esp_idf_hal
crate to import the needed device hardware abstractions.
use esp_idf_hal::delay::FreeRtos;
use esp_idf_hal::ledc::{config::TimerConfig, LedcDriver, LedcTimerDriver, Resolution};
use esp_idf_hal::peripherals::Peripherals;
use esp_idf_hal::prelude::*;
π Peripheral Configuration Code
Ahead of our application code, peripherals are configured through the following steps:
1οΈβ£ Obtain a handle for the device peripherals: In embedded Rust, as part of the singleton design pattern, we first have to take
the device peripherals. This is done using the take()
method. Here I create a device peripheral handler named peripherals
as follows:
let peripherals = Peripherals::take().unwrap();
2οΈβ£ Configure the LEDC Timer Driver: The ESP programming guide for LEDC control specifies the steps for configuration. Configuration is done in three steps:
Timer Configuration by specifying the PWM signalβs frequency and duty cycle resolution.
Channel Configuration by associating it with the timer and GPIO to output the PWM signal.
Change PWM Signal that drives the output.
In the esp-idf-hal API for Rust, the second and third steps are combined in one. As such, we need to configure the LEDC timer.
To configure the LEDC Timer, there exists the LedcTimerDriver
struct with a new
method allowing us to create an instance of the driver. The new
method has the following signature:
pub fn new<T: LedcTimer>(
_timer: impl Peripheral<P = T> + 'd,
config: &TimerConfig
) -> Result<Self, EspError>
Note that all new
needs is a timer peripheral instance and a timer configuration as parameters. TimerConfig
is a struct in the ledc::config
module that allows us to define the timer frequency, resolution, and speed mode. Following that we can define a timer_driver
handle and using the new
method of the LedcTimerDriver
and configure the timer as follows:
// Configure Pins that Will Read the Square Wave as Inputs
let timer_driver = LedcTimerDriver::new(
peripherals.ledc.timer0,
&TimerConfig::default()
.frequency(50.Hz())
.resolution(Resolution::Bits14),
)
.unwrap();
Note that I chose the timer0
peripheral to drive the LEDC peripheral. Additionally, I adjusted the frequency to 50 Hz as its the desired frequency. Finally, I chose a resolution of 14-bits for the timer. The resolution defines how accurate the duty cycle/on time can be (for more insight refer to the ESP IDF technical documentation).
3οΈβ£ Obtain a handle and configure the LEDC peripheral: One step remains in configuring the LEDC is creating an instance to drive the peripheral. There exists an LedcDriver
struct with a new
method allowing us to create an instance of the driver. The new
method requires three parameters; a ledc peripheral channel, a timer driver (already created in the previous step), and a gpio pin. This results in the following code:
let mut driver = LedcDriver::new(
peripherals.ledc.channel0,
timer_driver,
peripherals.pins.gpio7,
)
.unwrap();
That's it for configuration. On to coding the application!
π± Application Code
Recovering the algorithmic steps from the software design section, the following need to take place ahead of the program loop:
1οΈβ£ Define Parameters: It was mentioned earlier that we need to determine the range of PWM duty cycles you want the servo to sweep through. Also that they would map to actual angles. This means that we would need to map the range of angles to the range of duty cycle values. As such, it would be useful to create a function that we can use later for that. After the main function, I defined a map
function as follows:
// Function that maps one range to another
fn map(x: u32, in_min: u32, in_max: u32, out_min: u32, out_max: u32) -> u32 {
(x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
}
x
is the input value we wish to map, in_min
and in_max
define the minimum and maximum limits of the range for the input value, out_min
and out_max
define the minimum and maximum limits of the range for the output value. Consequently, the function returns a u32
value with a value mapped to the output range.
Now using the get_max_duty
LedcTimerDriver
method, we can retrieve the maximum duty value and store it in max_duty
. After that we can calculate the min_limit
and max_limit
that correspond to the 0.5 ms (2.5 % Duty Cycle) and 2.5 ms (12.5 % Duty Cycle) on time values, respectively. Note that I scaled the numerators and denominators to avoid floating point math.
// Get Max Duty and Calculate Upper and Lower Limits for Servo
let max_duty = driver.get_max_duty();
let min_limit = max_duty * 25 / 1000;
let max_limit = max_duty * 125 / 1000;
2οΈβ£ Motor Position Initialization: Now we need to position the servo motor at the starting angle of the sweep range (e.g., 0 degrees). Driving the motor to the zero angle is done using the set_duty
LedcTimerDriver
method and the map
function created earlier:
// Define Starting Position
driver
.set_duty(map(0, 0, 180, min_limit, max_limit))
.unwrap();
// Give servo some time to update
FreeRtos::delay_ms(500);
Note that a 0.5s delay has also been added to allow the servo some time to adjust.
π The Application Loop
Following the software design steps:
- Create a Sweeping Loop: We need to create a loop that gradually increases the angle in increments until the maximum angle is reached. During each iteration of the loop, we can update the servo's position by using the same
set_duty
method along with themap
function. This can be done using afor
loop with a0..180
range as follows:
// Sweep from 0 degrees to 180 degrees
for angle in 0..180 {
// Print Current Angle for visual verification
println!("Current Angle {} Degrees", angle);
// Set the desired duty cycle
driver
.set_duty(map(angle, 0, 180, min_limit, max_limit))
.unwrap();
// Give servo some time to update
FreeRtos::delay_ms(12);
}
Note that the 0..180
Range
as defined in Rust, will go up to 179
. As a result, the end value can be changed to 181
if the 180 angle value is required to be achieved
- Change the Sweep Direction: To change the sweep direction the code is identical to the previous step with one minor modification. This can be achieved by using the
rev
method on the0..180
Range
.
// Sweep from 180 degrees to 0 degrees
for angle in (0..180).rev() {
// Print Current Angle for visual verification
println!("Current Angle {} Degrees", angle);
// Set the desired duty cycle
driver
.set_duty(map(angle, 0, 180, min_limit, max_limit))
.unwrap();
// Give servo some time to update
FreeRtos::delay_ms(12);
}
π± Full Application Code
Here is the full code for the implementation described in this post. You can additionally find the full project and others available on the apollolabs ESP32C3 git repo. Also, the Wokwi project can be accessed here.
use esp_idf_hal::delay::FreeRtos;
use esp_idf_hal::ledc::{config::TimerConfig, LedcDriver, LedcTimerDriver, Resolution};
use esp_idf_hal::peripherals::Peripherals;
use esp_idf_hal::prelude::*;
fn main() {
esp_idf_sys::link_patches();
// Take Peripherals
let peripherals = Peripherals::take().unwrap();
// Configure and Initialize LEDC Timer Driver
let timer_driver = LedcTimerDriver::new(
peripherals.ledc.timer0,
&TimerConfig::default()
.frequency(50.Hz())
.resolution(Resolution::Bits14),
)
.unwrap();
// Configure and Initialize LEDC Driver
let mut driver = LedcDriver::new(
peripherals.ledc.channel0,
timer_driver,
peripherals.pins.gpio7,
)
.unwrap();
// Get Max Duty and Calculate Upper and Lower Limits for Servo
let max_duty = driver.get_max_duty();
println!("Max Duty {}", max_duty);
let min_limit = max_duty * 25 / 1000;
println!("Min Limit {}", min_limit);
let max_limit = max_duty * 125 / 1000;
println!("Max Limit {}", max_limit);
// Define Starting Position
driver
.set_duty(map(0, 0, 180, min_limit, max_limit))
.unwrap();
// Give servo some time to update
FreeRtos::delay_ms(500);
loop {
// Sweep from 0 degrees to 180 degrees
for angle in 0..180 {
// Print Current Angle for visual verification
println!("Current Angle {} Degrees", angle);
// Set the desired duty cycle
driver
.set_duty(map(angle, 0, 180, min_limit, max_limit))
.unwrap();
// Give servo some time to update
FreeRtos::delay_ms(12);
}
// Sweep from 180 degrees to 0 degrees
for angle in (0..180).rev() {
// Print Current Angle for visual verification
println!("Current Angle {} Degrees", angle);
// Set the desired duty cycle
driver
.set_duty(map(angle, 0, 180, min_limit, max_limit))
.unwrap();
// Give servo some time to update
FreeRtos::delay_ms(12);
}
}
}
// Function that maps one range to another
fn map(x: u32, in_min: u32, in_max: u32, out_min: u32, out_max: u32) -> u32 {
(x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
}
Conclusion
In this post, a PWM application creating a servo motor sweep effect was created. The application leverages the LEDC peripheral for the ESP32C3 microcontroller. The code was also created using an embedded std
development environment supported by the esp-idf-hal
. Have any questions? Share your thoughts in the comments below π.