CLI

Arduino Library to build a real-time serial (or network) command-line interface (CLI) to configure or control your microcontroller

public

 

CLI Library

Library to build a real-time serial (or network) command-line interface (CLI) to configure or control your Arduino or compatible microcontroller.

This is Version 1.0, the latest version, documentation and bugtracker are available on my GitLab instance

Copyright (c) 2019 Frederik Lindenaar. free for distribution under the GNU License, see below

Introduction

Frequently need to interact with a microcontroller to, for example:

  • see what's stored in the embedded or an I2C EEPROM
  • check that all I2C devices are detected and responding
  • check or set a connected real-time clock
  • check or set configuration options (I don't hard-code config)

Since I don't like reprogramming the microcontroller every time this is needed, I wrote this library to provide a Command Line Interface over a Serial / USB line. The library includes a number of commands that can be included where applicable (which I expect will grow over time as I build more) so that it can be used as a toolbox to start your development without having to worry about stuff that could be standard. At this moment it includes the following commands:

  • eeprom_dump to display the contents of the built-in EEPROM
  • i2c_scan to scan the I2C bus for slave devices
  • i2c_dump to display the contents of I2C attached EEPROM or Memory
  • reset to restart the microcontroller (software reset)
  • help to display available commands and provided help on how to use them

See below how to use the Library, to get an idea on how to use the library, have a look at the examples included:

  • Blink: control the Blink example (using the built-in LED) using a Serial/USB console to change its blink rate or turn it on or off
  • Debug: CLI with the built-in debug commands listed above
  • DS1307RTC: CLI to read/set an DS1307 Real-Time Clock module (includes the built-in commands to access a module's NVRAM and EEPROM)

Download / Installation

At this moment this library is not yet available directly from the Arduino IDE but has to be installed manually. For this, download the latest distribution .zip file and install it using the following links:

You can also use git to checkout the latest version from my repository with

  git clone https://gitlab.lindenaar.net/arduino/CLI.git

so that it is easy to upgrade in the future. To find where to checkout check the guide for manual Installation.

Using the library

Before adding this library to your code, it is important to realize that by adding the CLI you are builing a so-called real-time system. The microcontroller should be able to perform it's main task (normally not responding to Serial/USB input) and in the background listen to commands given through the CLI. As most microcontrollers have only a single CPU and no OS that can multitask, all logic is handled from the main loop() function. As a consequence, one should avoid writing code that waits (e.g. using the delay() function) but instead create a loop that does not wait/block but determines whether to do something and if not moves on to the next task.

Writing a real-time (i.e. non-blocking) loop

Looking at the Arduino IDE's standard Blink example (probably the starting point for most when starting with the platform), you see the following code in loop:

// the loop function runs over and over again forever
void loop() {
  digitalWrite(LED_BUILTIN, HIGH);   // turn LED on (HIGH is the voltage level)
  delay(1000);                       // wait for a second
  digitalWrite(LED_BUILTIN, LOW);    // turn LED off by making the voltage LOW
  delay(1000);                       // wait for a second
}

While this is perfectly fine code when the microcontroller has nothing else to do, for a background CLI this would cause 1 second delays between each check for input (ignoring more complex interrupt-driven approaches, which have their own challenges). The key for making this loop non-blocking is to change it so that it no longer waits but each time checks if it needs to change the LED status instead, like in the example below:

// variable to be preserved
unsigned long last_blink = 0;

// the loop function runs over and over again forever
void loop() {
  // millis() will wrap every 49 days, below code is wrap-proof
  if (millis() - last_blink > 1000) {  // last_blink was longer ago than delay?
    last_blink = millis();             // led state will change, store when
    if(digitalRead(LED_BUILTIN)) {     // check if the LED is on or off
      digitalWrite(LED_BUILTIN, LOW);  // turn LED off by making the pin LOW
    } else {
      digitalWrite(LED_BUILTIN, HIGH); // turn LED on by making the pin HIGH
    }
  }
}

This code uses the Arduino platform function millis() to obtain how long the code is running (in milliseconds) and keeps track of when the LED state changed last in variable last_blink (stored outside the loop so it is preserved). The loop now simply checks every time whether the last blink was more than 1000 milliseconds ago and if so changes the LED state, otherwise it does nothing. Please note that the above code was kept as much in line with the original Blink example though could also be written as:

// variable to be preserved
unsigned long last_blink = 0;

// the loop function runs over and over again forever
void loop() {
  // millis() will wrap every 49 days, below code is wrap-proof
  if (millis() - last_blink > 1000) {  // last_blink was longer ago than delay?
    last_blink = millis();             // led state will change, store when
    digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));   // invert LED PIN
  }
}

Once the loop of your code is non-blocking, the CLI can be added to your Sketch.

Adding the CLI to a Sketch

Adding the CLI to your sketch is pretty straightforward, first include CLI.h at the top of your sketch like this:

#include <CLI.h>

Next instantiate the CLI object by adding the following above (and outside) your setup() and loop() functions:

// Initialize the Command Line Interface
CLI CLI(Serial);           // Initialize the CLI, telling it to attach to Serial

Directly under the instantation of the CLI object commands can be instantiated (added) like is shown below for the built-in Help command:

Help_Command Help(CLI);    // Initialize/Register (built-in) help command

The constructor of each command requires a CLI to register with to make the implemented command available in the CLI. Next in your loop(), place the following logic at the top:

// handle CLI, if this returns true a command is running so skip code block
if (!CLI.process()) {
  // Code to run when no command is executing, make sure it is non-blocking...
}
// Code to execute every loop goes here, make sure it is non-blocking...

The CLI.process() method handles the Command Line Interpreter; it responds to user input, parses the command and executes the command code. To ensure that this is also non-blocking, each of these steps is executed in smaller chunks so that your main logic can be intertwined with the execution of the CLI logic. The CLI.process() method will return true if a command is still executing so by placing logic inside the if() { ... } block, you ensure it only runs when the CLI is not doing anything while if you put it outside the block it will always be executed. This can also be used to add an LED indicator to display whether the CLI is executing as is shown in the below full sketch example (which shows how a basic full implementation would look like):

#include <CLI.h>

// Initialize the Command Line Interface
CLI CLI(Serial);           // Initialize the CLI, telling it to attach to Serial
Help_Command Help(CLI);    // Initialize/Register (built-in) help command


// the setup function runs once when you reset or power the board
void setup() {
  // Initialize digital pin LED_BUILTIN as an output.
  pinMode(LED_BUILTIN, OUTPUT);

  // Initialize the Serial port for the CLI
  while (!Serial); // For Leonardo: wait for serial USB to connect
  Serial.begin(9600);
}


// the loop function runs over and over again forever
void loop() {
  // handle CLI, if this returns true a command is running so skip code block
  if (CLI.process()) {
    digitalWrite(LED_BUILTIN, HIGH); // turn LED on when processing CLI command
  } else {
    digitalWrite(LED_BUILTIN, LOW); // turn LED off when CLI command not active
    // Code to run when no command is executing, make sure it is non-blocking...
  }
  // Code to execute every loop goes here, make sure it is non-blocking...
}

You can run the above code by creating a new sketch and replacing its contents with the full example above. Please also have a look at the examples provided as they give a better view on what can be done and how to include a CLI in your sketch.

CLI on the Serial port

As you can see in the above example, the CLI object is initiated and on Serial and used to instantiate the Help_Command while Serial.begin() is only called from setup() (i.e. afterwards). Due to the way the initialization of the Arduino platform works, it is not possible to use Serial.begin() before setup() is called as things like interrupts and other boot-strap initialization (including that of the Serial port) has not taken place yet. For this reason the CLI object cannot initialize a Serial port but you have to do this from your setup() routine (which doesn't really matter and also gives you full control over it) but should not be forgotten (as the CLI won't work then). Please note that due to this it is also not possible to use Serial.print() in a constructor (this has nothing do to with this library but is a quirk of the Arduino platform).

CLI object parameters

The CLI object supports printing a banner upon startup and allows to configure the defaults prompt ( >). To reduce the memory usage, the passed string for either must be stored in PROGMEM (program memory, i.e. with the program in Flash). Both parameters can be passed to the constructor when initializing the CLI object like this:

const char CLI_banner[] PROGMEM = "My Microcontroller v1.0 CLI";
const char CLI_prompt[] PROGMEM = "mm> ";
CLI CLI(Serial, CLI_banner, CLI_prompt);

Currently both must hence be hardcoded static strings and there is no way to make them dynamic or change them. This is a conscious design choice to reduce the size of the code and the memory it requires. In case you have a good use case to reconsider this decision, let's discuss and for that please do (raise an issue here)[https://gitlab.lindenaar.net/arduino/CLI/issues].

CLI is a Stream

The CLI Object implements a Stream so it can be used as well to interact with the user, both from a command as well as from your main loop. Besides the CLI implementation described in this document and the print() family of functions, it also provides:

  • print_P (const char *str PROGMEM) to print a string stored in PROGMEM
  • print2digits(uint8_t num, char filler = '0', uint8_t base=10) to print a number with at least 2 digits (useful for HEX bytes)
  • print_mem(uint16_t addr, const uint8_t buff[], uint8_t len, uint8_t width=16) to dump a memory block in HEX and ASCII (addr is the startaddress to print)

Implementing a Command

CLI commands are implemented as separate classes that register themselves with the CLI upon instantiation. The actions to implement for a command are:

  1. instantiate - the command's class is instantiated and registers with a CLI
  2. set parameters - the command parses the user's parameters for the execution
  3. execute - the command performs it's tasks using the parameters provided.

To implement a new CLI command, one should inherit from CLI_Command

class CLI_Command {
  public:
    CLI_Command(CLI &cli, const char *command PROGMEM,
                          const char *description PROGMEM,
                          const char *help PROGMEM = NULL);
    virtual bool setparams(const char *params);
    virtual bool execute(CLI &cli) = 0;
};

and implement it's public methods. At least a constructor (calling the one from CLI_Command) and the execute() method must be implemented. The class has a default implementation for the setparams() method that accepts no parameters that can be used for commands that do need parameters. The details of the implementation steps are covered in the next sections to demonstrate how to implement a "hello" command accepting a parameter and printing that.

Implementation (Class Definition)

The first step for the implementation of our "hello" command is to define a class Hello_Command inheriting from CLI_Command. In this case we implement all three methods as we want to accept parameter(s) and need the private variable _params to store it in setparams() to be used by execute(). The definition of this basic class looks like:

class Hello_Command : CLI_Command {
  const char *_params;
  public:
    Hello_Command(CLI &cli);
    bool setparams(const char *params);
    bool execute(CLI &cli);
};

Although it is possible to define the method in the definition, in this example they will be defined separately first. See the full code at the end of this section that implements the methods inline.

Instantiate (Class constructor)

The implementation of the constructor can be very simple; call the constructor of CLI_Command with the following parameters:

  1. instance of CLI class to register with (should be passed as parameter)
  2. (static) string in PROGMEM with the name of the command
  3. (static) string in PROGMEM with a short (1-line) command description
  4. (static) string in PROGMEM with additional usage information

below the implementation of the example "hello" command with empty constructor (as no functional initiation is required) only calling the parent CLI_Command with the above parameters:

Hello_Command::Hello_Command(CLI &cli) : CLI_Command(&cli,
        PSTR("hello"),
        PSTR("Print an \"Hello\" greeting message"),
        PSTR("Usage:\thello <name>\n"
             "Where:\t<name>\ta string to include in the greeting")) { };

The above uses the PSTR() macro to inline define the static strings for the command name, description and help text. These normally are static and hence hardcoded. The base implementation of the Command class takes care of storing the references and making them available. It will also register the command with the CLI instance provided. As already mentioned, the library assumes these are in PROGMEM so please make sure your sketch stores them there.

Set parameters (setparams(const char *params) method)

When the user invokes a command, the setparams() method is called with the parameters the user provided after the command. This allows the command to parse the parameters provided and do whatever is necessary for the command to use this information (i.e. store it in an efficient way for execute()).

The setparams() method is always called once when the user enters a command so that it can ensure that everything is ready for the command's execute() method to be invoked. It should return true in case the parameters are valid. In case setparams() returns false, the execution of the command is aborted and its execute() is never called but a standard error message is given instead.

The params provided is a pointer to the start of the parameters with trailing spaces removed and terminated with a char(0). In case no (or only whitespace) parameters were provided, this method is called with NULL so an Implementation does not have to check for empty strings. As the CLI should not block the main flow, make sure the parsing is efficient and keep it simple so that this method (which is only called once for each command) does not take long.

A very simple implementation for setparams() is provided below for our hello command. This simply stores the pointer of the parameter string provided an will result in a true result in case a parameter is provided or a false result when the user did not provide any parameters.

bool Hello_Command::setparams(const char *params) {
  _params = params;
  return (params);
}

Often the parse will more complex as it is needs to parse the string provided. The design choice to have this implemented in the command is that this provides the greatest flexibility and does not assume anything w.r.t. how or which parameters are passed. The library does include a number of support functions that can be used to parse parameters and encode them in flags.

Please note that any attribute / variable used to store parameters are global (part of the object memory) so will eat up ram. Use them wisely so you don't run out of RAM on the smaller Arduino platforms that have only 2Kb or RAM.

Execute logic (execute(CLI &cli) method)

The execute() method contains the actual implementation of the command. It is called with a reference to the CLI so that there is no need to store that in the object. To support a real-time implementation even if the command needs a longer time (or is waiting), the implementation can return true to pause the current invocation of the execute method and will be called in the next main loop() cycle again. This allows for returning to the main loop in between of waiting or execution of the command so that the main loop can continue as well.

Before execute() is the first time, setparams() is called once to process parameters from the user and perform initialization or setup needed. As long as execute() returns true it will continue to be invoked again until it returns false to signal that the command's execution is complete. The Command Line Interface will only process user input again when the command is completed (so a command can also prompt the user for additional input)

Below the implementation of the hello command, which is pretty simple as it only prints "Hello" (stored in PROGMEM followed by the string the user provided. It then returns false to signal to the CLI that it is done.

bool Hello_Command::execute(CLI &cli) {
  cli.print_P(PSTR("Hello "));
  cli.println(_params);
  return false;
}

Commands can be as complex as needed. For more extensive examples, please have a look at the provided examples and built-in commands in Commands.cpp.

Full Example Sketch for the Hello command

Below the full example sketch built up in the previous sections with the methods implemented inline:

#include <CLI.h>

class Hello_Command : CLI_Command {
    const char *_params;
  public:
    Hello_Command(CLI &cli) :
      CLI_Command(cli,
                  PSTR("hello"),
                  PSTR("Print an \"Hello\" greeting message"),
                  PSTR("Usage:\thello <name>\n"
                       "Where:\t<name>\tstring to include in greeting")) { };
    bool setparams(const char *params) {
      _params = params;
      return (params);
    }
    bool execute(CLI &cli) {
      cli.print_P(PSTR("Hello "));
      cli.println(_params);
      return false;
    }
};

// Initialize the Command Line Interface
const char CLI_banner[] PROGMEM = "Hello CLI v1.0";
CLI CLI(Serial, CLI_banner); // Initialize CLI, telling it to attach to Serial
Hello_Command Hello(CLI);    // Initialize/Register above defined hello command
Help_Command Help(CLI);      // Initialize/Register (built-in) help command


// the setup function runs once when you reset or power the board
void setup() {
  // Initialize digital pin LED_BUILTIN as an output.
  pinMode(LED_BUILTIN, OUTPUT);

  // Initialize the Serial port for the CLI
  while (!Serial); // For Leonardo: wait for serial USB to connect
  Serial.begin(9600);
}


// the loop function runs over and over again forever
void loop() {
  // handle CLI, if this returns true a command is running so skip code block
  if (CLI.process()) {
    digitalWrite(LED_BUILTIN, HIGH); // turn LED on when processing CLI command
  } else {
    digitalWrite(LED_BUILTIN, LOW); // turn LED off when CLI command not active
    // Code to run when no command is executing, make sure it is non-blocking...
  }
  // Code to execute every loop goes here, make sure it is non-blocking...
}

I hope this clarifies how this library can and should be used. In case you find any issues with the documentation, code or examples, please do raise an issue here.

Parser Support Functions

The library contains a number of support functions to ease with building a parser for command parameters. These still need to be documented but can be found already in CLI_Utils.cpp within the library and are used by the built-in commands found in Commands.cpp and exampled included.

License

This library, documentation and examples are free software: you can redistribute them and/or modify them under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This script, documentation and configuration examples are distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not, download it from http://www.gnu.org/licenses/.