ESP Embedded Rust: Command Line Interface

theembeddedrustacean

Omar Hiari

Posted on February 3, 2024

ESP Embedded Rust: Command Line Interface

Introduction

Command line interfaces (CLI) in embedded applications are probably not as common as non-embedded ones. One reason is that embedded applications do not often require direct user input from a terminal, but rather through some other physical interface. As such, if a CLI is required in embedded there are different considerations.

In a typical non-embedded CLI, there is an underlying operating system (OS) with a CLI (in a shell) meant for accepting user input and passing commands to the OS itself. The OS in turn processes the input appropriately. As such, a command signifies a precompiled executable stored in some directory followed by a collection of arguments. The passed arguments are then processed by the named program when the OS executes it. The arguments are also typically allocated to dynamic memory.

In case a CLI is needed in embedded, things take a different turn. First, an underlying OS with a shell interface doesn't necessarily exist, but rather an already running application or RTOS. Second, the interface itself could be on another host device. Third, dynamic allocation is not desirable. This means that to create a CLI, logic is incorporated within an existing running application. This comes in the form of accepting commands over some logging interface like UART and processing them in the board.

In this post, I'm going to create a simple embedded CLI interface on an ESP32C3 device. However, the CLI logic not going to be created from scratch, but rather leveraging an existing crate. There are several no-std CLI crates that I encountered including; terminal-cli , embedded-cli , light-cli , and menu . Looking at each of the crates, they all had friendly abstractions and great features. I ended up going for menu based on the number of downloads since it seemed to be the most popular.

If you find this post useful, and if Embedded Rust interests you, stay in the know by subscribing to The Embedded Rustacean newsletter:

Subscribe Now to The Embedded Rustacean

πŸ“š Knowledge Pre-requisites

To understand the content of this post, you need the following:

πŸ’Ύ 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, 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

πŸ’» The Application

For this post, I'm going to create a simple application called hello with a command hw. hw takes one argument and its a person's name. The application would then print hello followed by the name given. Here's a sample:

> hw Omar
Hello, Omar!
Enter fullscreen mode Exit fullscreen mode

Also, the application would support a help command to provide a summary of commands the terminal supports. Also there is a help option for each command providing the user with information about the command.

πŸ“¦ The menu Crate

The menu crate consists of four main components:

  1. The Menu struct: Each Menu has a name or label , optional functions to call on entry and exit of the Menu and, most importantly, a collection of Items.

  2. The Item struct: Items can be viewed as command buckets within a Menu. Item members include an ItemType which specifies the type of the Item , a command string, and a help message associated with the command. ItemType is also a struct that specifies whether the Item opens up another sub Menu, or calls a function upon the invocation of the command.

  3. The Runner struct: This is the struct that handles all incoming input. When our application is executing incoming byte characters will be passed to an instantiation of this struct for processing. Obviously, in Runner instantiation, the the Menu would be required in addition to a source for the incoming bytes (Ex. UART channel).

  4. The Callback Functions: These are the functions that are called upon the invocation of a command. In these functions the logic associated with each command will be executed.

πŸ‘£ Implementation Steps

Following the earlier description, these are the steps we need to take:

  1. Define the Root Menu

  2. Implement any Callback functions

  3. Configure peripherals in main

  4. Instantiate the Runner in main

  5. Create the Application loop

πŸ‘¨β€πŸ’» Code Implementation

πŸ“₯ Crate Imports

In this implementation the crates required are as follows:

  • The esp_idf_hal crate to import the needed device hardware abstractions.

  • menu to provide the menu CLI structs and abstractions.

  • std::fmt to import the Write trait.

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 menu::*;
use std::fmt::Write;
Enter fullscreen mode Exit fullscreen mode

πŸ“ Define the Root Menu

Before implementing the logic, we need to define our root Menu. Since this this a simple application, the Menu will have one Item that contains the hw command. Menu has the following definition:

pub struct Menu<'a, T>
where
    T: 'a,
{
    pub label: &'a str,
    pub items: &'a [&'a Item<'a, T>],
    pub entry: Option<MenuCallbackFn<T>>,
    pub exit: Option<MenuCallbackFn<T>>,
}
Enter fullscreen mode Exit fullscreen mode

label is the label of the menu, entry and exit specify functions to call on entry and exit of the Menu , and items is an array of Item s. Also each Item has the following definition:

pub struct Item<'a, T>
where
    T: 'a,
{
    pub command: &'a str,
    pub help: Option<&'a str>,
    pub item_type: ItemType<'a, T>,
}
Enter fullscreen mode Exit fullscreen mode

where command is the command text for a particular Item , help is the help message associated with command, and item_type is an enum that specifies what the type of the item is. ItemType is defined as follows:

pub enum ItemType<'a, T>
where
    T: 'a,
{
    Callback {
        function: ItemCallbackFn<T>,
        parameters: &'a [Parameter<'a>],
    },
    Menu(&'a Menu<'a, T>),
    _Dummy,
}
Enter fullscreen mode Exit fullscreen mode

One can see that Item can be either a Callback that would call a function on the invocation of an Item command, and parameters that are passed to that command. Alternatively, an Item could be another Menu which specifies a sub-menu.

Given the above, for our application, we define a const Menu called ROOT_MENU as follows:

// 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 an embedded CLI terminal. Check the summary for the list of supported commands"),
    }],
    entry: None,
    exit: None,
};
Enter fullscreen mode Exit fullscreen mode

Note here that ROOT_MENU is given a label "root". Additionally, our application would have only one Item with command text "hw" that, when entered by the user, would invoke a function called hello_name .The application Item also takes only one parameter defined as name . Also I've decided to not call any functions on entry and exit of the root Menu. The associated help messages are also defined at the Menu and Item level.

πŸ‘¨β€πŸ’» Implement the Callback Function

Given the earlier step, we now need to define the behaviour when the callback function hello_name associated with hw is called. However, we still need to know what are the arguments that will be passed to this function. As such, the menu crate defines the signature of the callback function ItemCallbackFn as follows:

pub type ItemCallbackFn<T> = fn(menu: &Menu<'_, T>, item: &Item<'_, T>, args: &[&str], context: &mut T);
Enter fullscreen mode Exit fullscreen mode

Following this signature, we implement the function hello_name as follows:

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();
}
Enter fullscreen mode Exit fullscreen mode

Note that all the function is doing is using the writeln macro to print to the console. writeln accepts a β€˜writer’, a format string, and a list of arguments. Arguments will be formatted according to the specified format string and the result will be passed to the writer. The writer is any type that implements the Write trait in which case its the UartDriver type for the context thats being passed to hello_name by the runner. Note how for the list of arguments and argument_finder function is being used. argument_finder is a menu crate function and it looks for the named parameter in the parameter list of the Item, then finds the correct argument. Note how argument_finder takes three arguments, the Item we want to look for an argument in, the list of arguments that have been passed to the CLI (args), and the argument we want to look for ("name").

πŸŽ› Configure Peripherals

In the main the following code is used to instantiate and configure UART:

// Take Peripherals
let peripherals = Peripherals::take().unwrap();

// 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();
Enter fullscreen mode Exit fullscreen mode

The detail behind how this code works has been covered in a prior post here. If anything seems unclear I recommend referring back to the past post. Only thing to note here is that uart0 is used with gpio21 and gpio20 for Tx and Rx pins. This is because this is the peripheral and the pins used to connect to a host on the ESP32-C3-DevKitM.

πŸƒβ€β™‚οΈ Instantiate the Runner

In the menu crate documentation, Runner has a new method with the following signature:

pub fn new(menu: Menu<'a, T>, buffer: &'a mut [u8], context: T) -> Runner<'a, T>
Enter fullscreen mode Exit fullscreen mode

The first parameter is a root Menu (defined in the first step!), the second is a buffer that the Runner can use (clibuf), finally, context could be any type that implements Write (this is our instantiated uart handle) As a result, the runner is instantiated with the following 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);
Enter fullscreen mode Exit fullscreen mode

πŸ“ Note: The generic type T that appears in all prior structs seen earlier is defined by the context object that the Runner carries around. In our case its the UartDriver type.

πŸ”„ The Main Loop

In the main loop, all that needs to be done is read one byte at a time from the UART channel. Remember that the UART channel is now part of the runner context. This is done using the UART read method (again, for a refresher check out this post). Every read byte is passed to the instantiated runner using the input_byte runner method.

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]);
}
Enter fullscreen mode Exit fullscreen mode

That's it for code!

πŸ“±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 menu::*;
use std::fmt::Write;

// 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 an embedded CLI terminal. Check the summary for the list of supported commands"),
    }],
    entry: None,
    exit: None,
};

fn main() {
    // Take Peripherals
    let peripherals = Peripherals::take().unwrap();

    // 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 commans
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();
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

This post showed how to create a simple embedded command line interface over a UART channel. In the post, the application was created on the ESP32C3 using the menu crate. The application also leveraged the esp-idf-hal asbtractions. Have any questions? Share your thoughts in the comments below πŸ‘‡.

If you found this post useful, and if Embedded Rust interests you, stay in the know by subscribing to The Embedded Rustacean newsletter:

Subscribe Now to The Embedded Rustacean

πŸ’– πŸ’ͺ πŸ™… 🚩
theembeddedrustacean
Omar Hiari

Posted on February 3, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related