ESP Embedded Rust: Command Line Interface
Omar Hiari
Posted on February 3, 2024
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:
Basic knowledge of coding in Rust.
Familiarity with UART and how to set it up for the ESP32C3.
πΎ 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!
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:
The
Menu
struct: EachMenu
has a name orlabel
, optional functions to call on entry and exit of theMenu
and, most importantly, a collection ofItem
s.The
Item
struct:Item
s can be viewed as command buckets within aMenu
.Item
members include anItemType
which specifies the type of theItem
, a command string, and a help message associated with the command.ItemType
is also a struct that specifies whether theItem
opens up another subMenu
, or calls a function upon the invocation of the command.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, inRunner
instantiation, the theMenu
would be required in addition to a source for the incoming bytes (Ex. UART channel).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:
Define the Root Menu
Implement any Callback functions
Configure peripherals in
main
Instantiate the
Runner
inmain
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 theWrite
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;
π 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>>,
}
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>,
}
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,
}
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,
};
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);
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();
}
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();
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>
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);
π Note: The generic type
T
that appears in all prior structs seen earlier is defined by the context object that theRunner
carries around. In our case its theUartDriver
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]);
}
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();
}
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:
Posted on February 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.