Goal
I needed to interact with microcontrollers (ESP8266 & ESP32) via the Serial interface, so I wrote a small interactive shell with the command pattern.
The defined commands can either accept no parameter or one int32 parameter.
The API should be clear and easy to use, the code should only rely on Arduino.h
, and data should not be stored in the heap, in order to avoid fragmentation.
Code
The complete code is available on this GitHub repository.
Header
The command_invoker.h
header is:
#ifndef COMMAND_INVOKER_H_INCLUDED
#define COMMAND_INVOKER_H_INCLUDED
#include <Arduino.h>
#define MAX_COMMAND_SIZE 30
/** Other scripts can use this invoker, in order to define commands, via callbacks.
* Those callbacks can then be used to send commands to the sensor (e.g. reset, calibrate, night mode, ...)
* The callbacks can either have no parameter, or one int32_t parameter.
*/
namespace command_invoker {
void defineCommand(const char *name, void (*function)(void), const __FlashStringHelper *doc_fstring);
void defineIntCommand(const char *name, void (*function)(int32_t), const __FlashStringHelper *doc_fstring);
void execute(const char *command_line);
}
#endif
Library
the corresponding command_invoker.cpp
is:
#include "command_invoker.h"
namespace command_invoker {
const uint8_t MAX_COMMANDS = 20;
uint8_t commands_count = 0;
struct Command {
const char *name;
union {
void (*intFunction)(int32_t);
void (*voidFunction)(void);
};
const char *doc;
bool has_parameter;
};
Command commands[MAX_COMMANDS];
//NOTE: Probably possible to DRY (with templates?)
void defineCommand(const char *name, void (*function)(void), const __FlashStringHelper *doc_fstring) {
const char *doc = (const char*) doc_fstring;
if (commands_count < MAX_COMMANDS) {
commands[commands_count].name = name;
commands[commands_count].voidFunction = function;
commands[commands_count].doc = doc;
commands[commands_count].has_parameter = false;
commands_count++;
} else {
Serial.println(F("Too many commands have been defined."));
}
}
void defineIntCommand(const char *name, void (*function)(int32_t), const __FlashStringHelper *doc_fstring) {
const char *doc = (const char*) doc_fstring;
if (commands_count < MAX_COMMANDS) {
commands[commands_count].name = name;
commands[commands_count].intFunction = function;
commands[commands_count].doc = doc;
commands[commands_count].has_parameter = true;
commands_count++;
} else {
Serial.println(F("Too many commands have been defined."));
}
}
/*
* Tries to split a string command (e.g. 'mqtt 60' or 'show_csv') into a function_name and an argument.
* Returns 0 if both are found, 1 if there is a problem and 2 if no argument is found.
*/
uint8_t parseCommand(const char *command, char *function_name, int32_t &argument) {
char split_command[MAX_COMMAND_SIZE];
strlcpy(split_command, command, MAX_COMMAND_SIZE);
char *arg;
char *part1;
part1 = strtok(split_command, " ");
if (!part1) {
Serial.println(F("Received empty command"));
// Empty string
return 1;
}
strlcpy(function_name, part1, MAX_COMMAND_SIZE);
arg = strtok(NULL, " ");
uint8_t code = 0;
if (arg) {
char *end;
argument = strtol(arg, &end, 10);
if (*end) {
// Second argument isn't a number
code = 2;
}
} else {
// No argument
code = 2;
}
return code;
}
int compareCommandNames(const void *s1, const void *s2) {
struct Command *c1 = (struct Command*) s1;
struct Command *c2 = (struct Command*) s2;
return strcmp(c1->name, c2->name);
}
void listAvailableCommands() {
qsort(commands, commands_count, sizeof(commands[0]), compareCommandNames);
for (uint8_t i = 0; i < commands_count; i++) {
Serial.print(" ");
Serial.print(commands[i].name);
Serial.print(commands[i].doc);
Serial.println(".");
}
}
/*
* Tries to find the corresponding callback for a given command. Name and number of arguments should fit.
*/
void execute(const char *command_line) {
char function_name[MAX_COMMAND_SIZE];
int32_t argument = 0;
bool has_argument;
has_argument = (parseCommand(command_line, function_name, argument) == 0);
for (uint8_t i = 0; i < commands_count; i++) {
if (!strcmp(function_name, commands[i].name) && has_argument == commands[i].has_parameter) {
Serial.print(F("Calling : "));
Serial.print(function_name);
if (has_argument) {
Serial.print(F("("));
Serial.print(argument);
Serial.println(F(")"));
commands[i].intFunction(argument);
} else {
Serial.println(F("()"));
commands[i].voidFunction();
}
return;
}
}
Serial.print(F("'"));
Serial.print(command_line);
Serial.println(F("' not supported. Available commands :"));
listAvailableCommands();
}
}
Sketch
Finally, here is a Arduino sketch in order to use this library:
/***************************************************************************************************
* Small interactive shell via Serial interface for ESP32 and ESP8266, based on the command pattern.
***************************************************************************************************/
#include "command_invoker.h"
/**
* Some example functions, which could be defined in separate libraries.
*/
void multiplyBy2(int32_t x) {
Serial.print(x);
Serial.print(" * 2 = ");
Serial.println(2 * x);
}
void controlLED(int32_t onOff) {
digitalWrite(LED_BUILTIN, onOff);
}
/**
* Setup
*/
void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
// Define commands. Could be done in separate libraries (e.g. MQTT, LoRa, Webserver, ...)
command_invoker::defineIntCommand("led", controlLED, F(" 1/0 (LED on/off)"));
command_invoker::defineIntCommand("double", multiplyBy2, F(" 123 (Doubles the input value)"));
// Commands can also be created with lambdas.
command_invoker::defineCommand("reset", []() {
ESP.restart();
}, F(" (restarts the microcontroller)"));
// Simple example. Turn LED on at startup.
command_invoker::execute("led 1");
Serial.println(F("Console is ready!"));
Serial.print(F("> "));
}
/*
* Saves bytes from Serial.read() until enter is pressed, and tries to run the corresponding command.
* http://www.gammon.com.au/serial
*/
void processSerialInput(const byte input_byte) {
static char input_line[MAX_COMMAND_SIZE];
static unsigned int input_pos = 0;
switch (input_byte) {
case '\n': // end of text
Serial.println();
input_line[input_pos] = 0;
command_invoker::execute(input_line);
input_pos = 0;
Serial.print(F("> "));
break;
case '\r': // discard carriage return
break;
case '\b': // backspace
if (input_pos > 0) {
input_pos--;
Serial.print(F("\b \b"));
}
break;
default:
// keep adding if not full ... allow for terminating null byte
if (input_pos < (MAX_COMMAND_SIZE - 1)) {
input_line[input_pos++] = input_byte;
Serial.print((char) input_byte);
}
break;
}
}
/**
* Loop and wait for serial input. Commands could also come from webserver or MQTT, for example.
*/
void loop() {
while (Serial.available() > 0) {
processSerialInput(Serial.read());
}
delay(50);
}
Usage
It's now possible to send commands directly in PlatformIo monitor or in Arduino IDE Serial Monitor:
Console is ready!
> test
'test' not supported. Available commands :
double 123 (Doubles value).
led 1/0 (LED on/off).
reset (restarts the microcontroller).
> led 1
Calling : led(1)
> led 0
Calling : led(0)
> double 12345
Calling : double(12345)
12345 * 2 = 24690
> double
'double' not supported. Available commands :
double 123 (Doubles value).
led 1/0 (LED on/off).
reset (restarts the microcontroller).
> reset
Calling : reset()
Questions
This library works fine, but I'm still a C/C++ beginner and would appreciate any feedback.
In particular, I'd be happy to :
- remove the duplicate code in
defineCommand
anddefineIntCommand
- allow commands to have a string argument
- allow commands to have a specific numerical argument (e.g. a
bool
or auint8_t
and not just a castint32_t
) - be sure there is no buffer overflow anywhere
- use as little memory as possible.
Sadly, many good C++ suggestions (e.g. with std::string
, std::vector
or std::map
) don't seem to apply to this project, because the data types are not available on Arduino. That's why the code might look more like C than C++.