ESP Embedded Rust: Ping CLI App Part  1

ESP Embedded Rust: Ping CLI App Part 1

·

11 min read

Introduction

In the last blog post, I demonstrated the basic usage of ping::EspPing. Also in the post of the week before that, I went through the process of creating a command line interface over UART. I figured, why not combine both to create a Ping CLI app replica?! As a result, in this post, a replica of a CLI ping application will be created. So that it's not overwhelming, the process is going to be broken up into two posts. In the first post, the basic framework of the app will be created. Next week, in the second post, more features will be added.

📚 Knowledge Pre-requisites

The content of this post is heavily dependent on the following past posts:

💾 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

👨‍🎨 Software Design

This is a description of the app we're going to build:

Ping is a utility that sends ICMP Echo Request packets to a specified network host 
(either identified by its IP address or hostname) to test connectivity and measure round-trip time.

Usage: ping [options] <hostname/IP>

Options:
  -c, --count <number>     Number of ICMP Echo Request packets to send (default is 4).
  -i, --interval <seconds> Set the interval between successive ping packets in seconds.
  -t, --timeout <seconds>  Specify a timeout value for each ping attempt.
  -s, --size <bytes>       Set the size of the ICMP packets.

Examples:
  ping 192.168.1.1          # Ping the IP address 192.168.1.1
  ping example.com          # Ping the hostname 'example.com'
  ping -c 10 google.com     # Send 10 ping requests to google.com
  ping -i 0.5 -s 100 example.com  # Ping with interval of 0.5 seconds and packet size of 100 bytes to 'example.com'

This description is what will appear when help ping is entered. Also, the following is the type of output we want to recreate:

To create the application, the following steps are followed:

🐾 Step 1: Setup CLI Root Menu & Callback

In this first step, the following tasks will be accomplished:

  1. The ping command Item needs to be added to the root menu Menu struct.

  2. The callback function for ping setup.

🐾 Step 2: CLI Start-Up

The following are the actions that the application needs to take before spawning the CLI interface and invoking any commands:

  1. Configure, Instantiate, and Connect to WiFi.

  2. Configure and Instantiate UART

  3. Instantiate and run the CLI runner with the root menu.

🐾 Step 3: Create Ping App Logic

The app logic will be contained in the ping command callback function. This is the logic that will be executed when the ping command is invoked. The following are the app logic steps:

  1. Retrieve & Process CLI input

  2. Instantiate EspPing

  3. Setup EspPing Configuration

  4. Perform ping and update CLI

For this week's post, in step 1, the only input that will be processed is the ip address. Hostname and option processing capability will be added in the next post. Additionally, ping stats printing will be added next week.

👨‍💻 Code Implementation

📥 Crate Imports

In this implementation, the following crates are required:

  • The esp_idf_hal crate to import the peripherals needed for uart.

  • The esp_idf_svc crate to import the device services needed for wifi and ping.

  • The menu crate for creating the CLI.

  • The std::str crate to import the FromStr abstraction.

use esp_idf_hal::delay::BLOCK;
use esp_idf_hal::gpio;
use esp_idf_hal::peripherals::Peripherals;
use esp_idf_hal::prelude::*;
use esp_idf_hal::uart::*;
use esp_idf_svc::eventloop::EspSystemEventLoop;
use esp_idf_svc::ipv4::Ipv4Addr;
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::ping::{Configuration as PingConfiguration, EspPing};
use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi};
use menu::*;
use std::fmt::Write;
use std::str::FromStr;

🐾 Step 1: Setup CLI Root Menu & Callback

1️⃣ Add thepingcommandItemto root menu: similar to what was done in the CLI post with the hw command for the hello app, a new &Item is added for ping in the ROOT_MENU. Note that the callback function name is ping_app also there's only one parameter_name that will be recognized which is "hostname/IP" . Also, the help message details how the command will work. Be mindful that the options are included in the description although not supported yet.

&Item {
    item_type: ItemType::Callback {
        function: ping_app,
        parameters: &[Parameter::Mandatory {
            parameter_name: "hostname/IP",
            help: Some("IP address or hostname"),
     }],
    },
    command: "ping",
    help: Some("
    Ping is a utility that sends ICMP Echo Request packets to a specified network host 
    (either identified by its IP address or hostname) to test connectivity and measure round-trip time.

    Usage: ping [options] <hostname/IP>

    Options:
      -c, --count <number>     Number of ICMP Echo Request packets to send (default is 4).
      -i, --interval <seconds> Set the interval between successive ping packets in seconds.
      -t, --timeout <seconds>  Specify a timeout value for each ping attempt.
      -s, --size <bytes>       Set the size of the ICMP packets.
      -h, --help               Display this help message and exit.

    Examples:
      ping 192.168.1.1          # Ping the IP address 192.168.1.1
      ping example.com          # Ping the hostname 'example.com'
      ping -c 10 google.com     # Send 10 ping requests to google.com
      ping -i 0.5 -s 100 example.com  # Ping with interval of 0.5 seconds and packet size of 100 bytes to 'example.com'
     "),
}

2️⃣ Create the callback function: The callback function named ping_app for the ping command specified in the ROOT_MENUItem is implemented as follows:

fn ping_app<'a>(
    _menu: &Menu<UartDriver>,
    item: &Item<UartDriver>,
    args: &[&str],
    context: &mut UartDriver,
) {
    // App code goes here
}

We're going to keep it empty for now, and fill in the logic implementation in the last step.

🐾 Step 2: CLI Start-Up

1️⃣ Configure, Instantiate, and Connect to WiFi: This involves the same steps taken in the wifi post to connect to WiFi. Inside the main function, the following code is added:

let peripherals = Peripherals::take().unwrap();
let sysloop = EspSystemEventLoop::take()?;
let nvs = EspDefaultNvsPartition::take()?;

let mut wifi = BlockingWifi::wrap(
    EspWifi::new(peripherals.modem, sysloop.clone(), Some(nvs))?,
    sysloop,
)?;

wifi.set_configuration(&Configuration::Client(ClientConfiguration {
    ssid: "Wokwi-GUEST".try_into().unwrap(),
    bssid: None,
    auth_method: AuthMethod::None,
    password: "".try_into().unwrap(),
    channel: None,
}))?;

// Start Wifi
wifi.start()?;

// Connect Wifi
wifi.connect()?;

// Wait until the network interface is up
wifi.wait_netif_up()?;

println!("Wifi Connected");

2️⃣ Configure and Instantiate UART: This and the following step are identical to what was accomplished in the CLI post. Here is the code associated with this step:

// Configure UART
// Create handle for UART config struct
let config = config::Config::default().baudrate(Hertz(115_200));

// Instantiate UART
let mut uart = UartDriver::new(
    peripherals.uart0,
    peripherals.pins.gpio21,
    peripherals.pins.gpio20,
    Option::<gpio::Gpio0>::None,
    Option::<gpio::Gpio1>::None,
    &config,
)
.unwrap();

3️⃣ Instantiate and run the CLI runner with the root menu: Again, following the the CLI post, this is the associated code:

// Create a buffer to store CLI input
let mut clibuf = [0u8; 64];
// Instantiate CLI runner with root menu, buffer, and uart
let mut r = Runner::new(ROOT_MENU, &mut clibuf, uart);

loop {
    // Create single element buffer for UART characters
    let mut buf = [0_u8; 1];
    // Read single byte from UART
    r.context.read(&mut buf, BLOCK).unwrap();
    // Pass read byte to CLI runner for processing
    r.input_byte(buf[0]);
}

🐾 Step 3: Create Ping App Logic

1️⃣ Retrieve & Process CLI input: In the ping_app callback function, the first order of action will be to recover the user input. This is done using the argument_finder function in the menu crate. For now, the only entry supported is an IP address. Given that the retrieved IP address entry is a &str type, it needs to be converted to an EspPing compatible Ipv4Addr type using the from_str associated method which returns a Result. Finally, in case the user enters incorrect input, this can be handled by doing a pattern match on the from_strResult. Here is the code:

// Retreieve CLI Input
let ip_str = argument_finder(item, args, "hostname/IP").unwrap().unwrap();

// Process Input - Convert &str type to Ipv4Addr
let ip = Ipv4Addr::from_str(ip_str);

// Process Input - Make sure address formant is correct
let addr = match ip {
    Ok(addr) => addr,
    Err(_) => {
        writeln!(context, "Address error, try again").unwrap();
        return;
    }
};

2️⃣ InstantiateEspPing: This is a single-line action same to what was done before:

let mut ping = EspPing::new(0_u32);

3️⃣ SetupEspPingConfiguration: for this post, a default configuration is going to be used. In next week's post, the configuration will be modified to accommodate any user-entered options.

let ping_config = &PingConfiguration::default();

4️⃣ Performpingand update CLI: This is the part where the output of the ping app is replicated. First, we need to print Pinging [IP address] with [bytes sent] bytes of data . The IP address is the one entered by the user and the number of bytes sent is inside the ping_config struct.

// Update CLI
// Pinging {IP} with {x} bytes of data
writeln!(
    context,
    "Pinging {} with {} bytes of data\n",
    ip_str, ping_config.data_size
)
.unwrap();

Afterward, a ping needs to be performed 4 times and the output of each ping is reported in the format Reply from [IP Address]: bytes=[bytes received] time=[response duration] TTL=[timeout duration]. Here's the associated code:

// Ping 4 times and print results
for _n in 1..=4 {
    let summary = ping.ping(addr, ping_config).unwrap();

    writeln!(
        context,
        "Reply from {}: bytes = {}, time = {:?}, TTL = {:?}",
        ip_str, summary.received, summary.time, ping_config.timeout
    )
    .unwrap();
}

Thats it!

🧪 Testing

Since the current version supports only IP addresses, for the sake of testing, local network addresses can be pinged if using physical hardware. If you desire to test with internet addresses or on Wokwi some possible addresses to ping include the following:

  • OpenDNS: 208.67.222.222 and 208.67.220.220

  • Cloudflare: 1.1.1.1 and 1.0.0.1

  • Google DNS: 8.8.8.8 and 8.8.4.4

📱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::BLOCK;
use esp_idf_hal::gpio;
use esp_idf_hal::peripherals::Peripherals;
use esp_idf_hal::prelude::*;
use esp_idf_hal::uart::*;
use esp_idf_svc::eventloop::EspSystemEventLoop;
use esp_idf_svc::ipv4::Ipv4Addr;
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::ping::{Configuration as PingConfiguration, EspPing};
use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi};
use menu::*;
use std::fmt::Write;
use std::str::FromStr;

// CLI Root Menu Struct Initialization
const ROOT_MENU: Menu<UartDriver> = Menu {
    label: "root",
    items: &[
        &Item {
            item_type: ItemType::Callback {
                function: hello_name,
                parameters: &[Parameter::Mandatory {
                    parameter_name: "name",
                    help: Some("Enter your name"),
                }],
            },
            command: "hw",
            help: Some("This is the help for the hello, name hw command!"),
        },
        &Item {
            item_type: ItemType::Callback {
                function: ping_app,
                parameters: &[Parameter::Mandatory {
                    parameter_name: "hostname/IP",
                    help: Some("IP address or hostname"),
                }],
            },
            command: "ping",
            help: Some("
            Ping is a utility that sends ICMP Echo Request packets to a specified network host 
            (either identified by its IP address or hostname) to test connectivity and measure round-trip time.

            Usage: ping [options] <hostname/IP>

            Options:
              -c, --count <number>     Number of ICMP Echo Request packets to send (default is 4).
              -i, --interval <seconds> Set the interval between successive ping packets in seconds.
              -t, --timeout <seconds>  Specify a timeout value for each ping attempt.
              -s, --size <bytes>       Set the size of the ICMP packets.
              -h, --help               Display this help message and exit.

            Examples:
              ping 192.168.1.1          # Ping the IP address 192.168.1.1
              ping example.com          # Ping the hostname 'example.com'
              ping -c 10 google.com     # Send 10 ping requests to google.com
              ping -i 0.5 -s 100 example.com  # Ping with interval of 0.5 seconds and packet size of 100 bytes to 'example.com'
            "),
        },
    ],
    entry: None,
    exit: None,
};

fn main() -> anyhow::Result<()> {
    // Take Peripherals
    let peripherals = Peripherals::take().unwrap();
    let sysloop = EspSystemEventLoop::take()?;
    let nvs = EspDefaultNvsPartition::take()?;

    let mut wifi = BlockingWifi::wrap(
        EspWifi::new(peripherals.modem, sysloop.clone(), Some(nvs))?,
        sysloop,
    )?;

    wifi.set_configuration(&Configuration::Client(ClientConfiguration {
        ssid: "Wokwi-GUEST".try_into().unwrap(),
        bssid: None,
        auth_method: AuthMethod::None,
        password: "".try_into().unwrap(),
        channel: None,
    }))?;

    // Start Wifi
    wifi.start()?;

    // Connect Wifi
    wifi.connect()?;

    // Wait until the network interface is up
    wifi.wait_netif_up()?;

    println!("Wifi Connected");

    // Configure UART
    // Create handle for UART config struct
    let config = config::Config::default().baudrate(Hertz(115_200));

    // Instantiate UART
    let mut uart = UartDriver::new(
        peripherals.uart0,
        peripherals.pins.gpio21,
        peripherals.pins.gpio20,
        Option::<gpio::Gpio0>::None,
        Option::<gpio::Gpio1>::None,
        &config,
    )
    .unwrap();

    // This line is for Wokwi only so that the console output is formatted correctly
    uart.write_str("\x1b[20h").unwrap();

    // Create a buffer to store CLI input
    let mut clibuf = [0u8; 64];
    // Instantiate CLI runner with root menu, buffer, and uart
    let mut r = Runner::new(ROOT_MENU, &mut clibuf, uart);

    loop {
        // Create single element buffer for UART characters
        let mut buf = [0_u8; 1];
        // Read single byte from UART
        r.context.read(&mut buf, BLOCK).unwrap();
        // Pass read byte to CLI runner for processing
        r.input_byte(buf[0]);
    }
}

// Callback function for hw command
fn hello_name<'a>(
    _menu: &Menu<UartDriver>,
    item: &Item<UartDriver>,
    args: &[&str],
    context: &mut UartDriver,
) {
    // Print to console passed "name" argument
    writeln!(
        context,
        "Hello, {}!",
        argument_finder(item, args, "name").unwrap().unwrap()
    )
    .unwrap();
}

// Callback function for ping command
fn ping_app<'a>(
    _menu: &Menu<UartDriver>,
    item: &Item<UartDriver>,
    args: &[&str],
    context: &mut UartDriver,
) {
    // Retreieve CLI Input
    let ip_str = argument_finder(item, args, "hostname/IP").unwrap().unwrap();

    // Process Input - Convert &str type to Ipv4Addr
    let ip = Ipv4Addr::from_str(ip_str);

    // Process Input - Make sure address formant is correct
    let addr = match ip {
        Ok(addr) => addr,
        Err(_) => {
            writeln!(context, "Address error, try again").unwrap();
            return;
        }
    };

    // Create EspPing instance
    let mut ping = EspPing::new(0_u32);

    // Setup Ping Config
    let ping_config = &PingConfiguration::default();

    // Update CLI
    // Pinging {IP} with {x} bytes of data
    writeln!(
        context,
        "Pinging {} with {} bytes of data\n",
        ip_str, ping_config.data_size
    )
    .unwrap();

    // Ping 4 times and print results
    // Reply from {IP}: bytes={summary.recieved} time={summary.time} TTL={summary.timeout}
    for _n in 1..=4 {
        let summary = ping.ping(addr, ping_config).unwrap();

        writeln!(
            context,
            "Reply from {}: bytes = {}, time = {:?}, TTL = {:?}",
            ip_str, summary.received, summary.time, ping_config.timeout
        )
        .unwrap();
    }
}

Conclusion

In this post, a ping CLI application replica was built on a ESP32C3 using Rust and the supporting std library crates. The current version is limited to pinging IP addresses only. In the next blog post, hostnames and options support will be added. Additionally, ping statistics will be reported as part of the output. Have any questions? Share your thoughts in the comments below 👇.

Did you find this article valuable?

Support Omar Hiari by becoming a sponsor. Any amount is appreciated!