funbiscuit / embedded-cli-rs

CLI in Rust with autocompletion, subcommands, options, help and history for embedded systems (like Arduino or STM32)
Apache License 2.0
79 stars 4 forks source link

embedded-cli

Command Line Interface for embedded systems

Crates.io License License Build Status Coverage Status

Demo of CLI running on Arduino Nano. Memory usage: 16KiB of ROM and 0.6KiB of static RAM. Most of static RAM is used by help strings.

Arduino Demo

Dual-licensed under Apache 2.0 or MIT.

This library is not stable yet, meaning it's API is likely to change. Some of the API might be a bit ugly, but I don't see a better solution for now. If you have suggestions - open an Issue or a Pull Request.

Features

How to use

Add dependencies

Add embedded-cli and necessary crates to your app:

[dependencies]
embedded-cli = "0.2.1"
embedded-io = "0.6.1"
ufmt = "0.2.0"

Implement byte writer

Define a writer that will be used to output bytes:

struct Writer {
    // necessary fields (for example, uart tx handle)
};

impl embedded_io::ErrorType for Writer {
    // your error type
}

impl embedded_io::Write for Writer {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Self::Error> {
        todo!()
    }

    fn flush(&mut self) -> Result<(), Self::Error> {
        todo!()
    }
}

Build CLI instance

Build a CLI, specifying how much memory to use for command buffer (where bytes are stored until user presses enter) and history buffer (so user can navigate with up/down keypress):

let (command_buffer, history_buffer) = unsafe {
        static mut COMMAND_BUFFER: [u8; 32] = [0; 32];
        static mut HISTORY_BUFFER: [u8; 32] = [0; 32];
        (COMMAND_BUFFER.as_mut(), HISTORY_BUFFER.as_mut())
    };
let mut cli = CliBuilder::default()
    .writer(writer)
    .command_buffer(command_buffer)
    .history_buffer(history_buffer)
    .build()
    .ok()?;

In this example static mut buffers were used, so we don't use stack memory. Note that we didn't call unwrap(). It's quite important to keep embedded code without panics since every panic adds quite a lot to RAM and ROM usage. And most embedded systems don't have a lot of it.

Describe your commands

Define you command structure with enums and derive macro:

use embedded_cli::Command;

#[derive(Command)]
enum Base<'a> {
    /// Say hello to World or someone else
    Hello {
        /// To whom to say hello (World by default)
        name: Option<&'a str>,
    },

    /// Stop CLI and exit
    Exit,
}

Doc-comments will be used in generated help.

Pass input to CLI and process commands

And you're ready to provide all incoming bytes to cli and handle commands:

use ufmt::uwrite;

// read byte from somewhere (for example, uart)
// let byte = nb::block!(rx.read()).void_unwrap();

let _ = cli.process_byte::<Base, _>(
    byte,
    &mut Base::processor(|cli, command| {
        match command {
            Base::Hello { name } => {
                // last write in command callback may or may not
                // end with newline. so both uwrite!() and uwriteln!()
                // will give identical results
                uwrite!(cli.writer(), "Hello, {}", name.unwrap_or("World"))?;
            }
            Base::Exit => {
                // We can write via normal function if formatting not needed
                cli.writer().write_str("Cli can't shutdown now")?;
            }
        }
        Ok(())
    }),
);

Split commands into modules

If you have a lot of commands it may be useful to split them into multiple enums and place their logic into multiple modules. This is also supported via command groups.

Create extra command enum:

#[derive(Command)]
#[command(help_title = "Manage Hardware")]
enum GetCommand {
    /// Get current LED value
    GetLed {
        /// ID of requested LED
        led: u8,
    },

    /// Get current ADC value
    GetAdc {
        /// ID of requested ADC
        adc: u8,
    },
}

Group commands into new enum:

#[derive(CommandGroup)]
enum Group<'a> {
    Base(Base<'a>),
    Get(GetCommand),

    /// This variant will capture everything, that
    /// other commands didn't parse. You don't need
    /// to add it, just for example
    Other(RawCommand<'a>),
}

And then process it in similar way:

let _ = cli.process_byte::<Group, _>(
    byte,
    &mut Group::processor(|cli, command| {
        match command {
            Group::Base(cmd) => todo!("process base command"),
            Group::Get(cmd) => todo!("process get command"),
            Group::Other(cmd) => todo!("process all other, not parsed commands"),
        }
        Ok(())
    }),
);

You can check full arduino example here. There is also a desktop example that runs in normal terminal. So you can play with CLI without flashing a real device.

Argument parsing

Command can have any number of arguments. Types of argument must implement FromArgument trait:

struct CustomArg<'a> {
    // fields
}

impl<'a> embedded_cli::arguments::FromArgument<'a> for CustomArg<'a> {
    fn from_arg(arg: &'a str) -> Result<Self, &'static str>
    where
        Self: Sized {
        todo!()
    }
}

Library provides implementation for following types:

Open an issue if you need some other type.

Input tokenization

CLI uses whitespace (normal ASCII whitespace with code 0x20) to split input into command and its arguments. If you want to provide argument, that contains spaces, just wrap it with quotes.

Input Argument 1 Argument 2 Notes
cmd abc def abc def Space is treated as argument separator
cmd "abc def" abc def To use space inside argument, surround it with quotes
cmd "abc\" d\\ef" abc" d\ef To use quotes or slashes, escape them with \
cmd "abc def" test abc def test You can mix quoted arguments and non-quoted
cmd "abc def"test abc def test Space between quoted args is optional
cmd "abc def""test 2" abc def test 2 Space between quoted args is optional

Generated help

When using Command derive macro, it automatically generates help from doc comments:

#[derive(Command)]
enum Base<'a> {
    /// Say hello to World or someone else
    Hello {
        /// To whom to say hello (World by default)
        name: Option<&'a str>,
    },

    /// Stop CLI and exit
    Exit,
}

List all commands with help:

$ help
Commands:
  hello  Say hello to World or someone else
  exit   Stop CLI and exit

Get help for specific command with help <COMMAND>:

$ help hello
Say hello to World or someone else

Usage: hello [NAME]

Arguments:
  [NAME]  To whom to say hello (World by default)

Options:
  -h, --help  Print help

Or with <COMMAND> --help or <COMMAND> -h:

$ exit --help
Stop CLI and exit

Usage: exit

Options:
  -h, --help  Print help

User Guide

You'll need to begin communication (usually through a UART) with a device running a CLI. Terminal is required for correct experience. Following control sequences are supported:

If you run CLI through a serial port (like on Arduino with its UART-USB converter), you can use for example PuTTY or tio.

Memory usage

Memory usage depends on version of crate, enabled features and complexity of your commands. Below is memory usage of arduino example when different features are enabled. Memory usage might change in future versions, but I'll try to keep this table up to date.

Features ROM, bytes Static RAM, bytes
10182 274
autocomplete 12112 290
history 12032 315
autocomplete history 13586 331
help 14412 544
autocomplete help 16110 556
history help 16402 585
autocomplete history help 16690 597

This table is generated using this script. As table shows, enabling help adds quite a lot to memory usage since help usually requires a lot of text to be stored. Also enabling all features almost doubles ROM usage comparing to all features disabled.