Copyright 2023 Travis J. West, https://traviswest.ca, Input Devices and Music Interaction Laboratory (IDMIL), Centre for Interdisciplinary Research in Music Media and Technology (CIRMMT), McGill University, Montréal, Canada, and Univ. Lille, Inria, CNRS, Centrale Lille, UMR 9189 CRIStAL, F-59000 Lille, France
SPDX-License-Identifier: MIT
Motivation
Especially while debugging, testing, or prototyping, the easiest available interface to interact with a device is often a text-based console. It is generally easier to spit ASCII across a serial interface than to work with USB, Bluetooth, Wi-Fi, Ethernet, or any other transmission protocol, both in terms of setting up the hardware, and connecting to it from a PC. A text-based console can also be used as a test environment when embedded hardware processors are not involved. For these reasons, one of the first bindings to be implemented was this command line interface.
Design Reasoning
What we would like to achieve is for the endpoints of a simple processor, or multiple such processors, e.g. the components of the one-bit bongo described in another directory of the project, to have their endpoints exposed over a text-based command line interface.
Portability
We would like the code that implements this binding to reflect generically over the endpoints of the component without having to explicitly name them in source code, so that the binding can readily adapt to arbitrary components. We would like the binding to work with minimal repetition when running the component in a simulated environment, where all input and output come through the command line, and when running it as a hardware device, where some inputs and outputs are generated by external hardware events, and where the command line is physically realized across a serial port interface. Eventually, other interfaces for command-line interaction are easy to imagine, such as a web-based command line for advanced debugging.
Basic Functionality
We would like to be able to query the current value of an endpoint, and set the value of destination endpoints. For instance, we might issue the command get Button-Edge-Detector button-state
and receive the response true
or false
. We could change the button state by issuing the command set button-edge-detector button-state true
. For testing and debugging, we would like to be able to trigger execution of a components's processing, e.g. by issuing the command trigger button-edge-detector
.
We would like to be able to query the metadata of a component and its endoints:
> describe button-gesture-model
inputs:
button-state: bool
# etc.
outputs:
rising-edge: bool
falling-edge: bool
any-edge: bool
# etc.
We would like to be able to get a list of all the components that can be interacted with by the command line interface:
> list
gpio-pin
button-gesture-model
# etc.
And finally, we would like to be able to get a list of all the CLI commands:
> help
# @='help text'
list
Get a list of componentss.
describe <component>
Print the metadata associated with a component
get <component> <endpoint>
Print the current state of an endpoint
set <component> <endpoint> <value>
Change the current state of an endpoint
trigger <component>
Run a component's process
help
Print a helpful list of commands
# @/
Commands
Commands as Shell Programs
For maximum portability, it seems likely that a character-by-character approach will provide the easiest adaptability to different environments. The most obvious approach to me is to accumulate characters until a full line of input is collected (or the buffer overflows), and then tokenize that line of input and attempt to match it to known commands. If a match is found, then control can flow to that command, passing the whole command line as input to it.
So I am imagining a pipeline roughly like this:
accumulate inputs(character)
buffer[write_position++] = character
if character == '\n'
parse line
write_position = 0
else if write_position == buffer size
complain about buffer overflow
write_position = 0
parse line(line)
command = get_first_token()
switch (command)
execute command with line as input
Notice that the commands are effectively self contained programs. In principle, each command could do almost anything. Given this, we may wish to adopt a familiar convention for the interface to a command. We can imagine, for instance, the following abstract base class for all commands:
struct ShellProgram
{
virtual int main(int argc, char ** argv) = 0;
};
This implies that we are treating our CLI like an OS shell, and our CLI commands as executable programs. This analogy is a bit strained under the actual expected environment, where the commands are compiled into the CLI, which runs as a binding in e.g. the firmware of a digital musical instrument. This is a far cry from the shell of an OS, where the commands are separately compiled programs in the path of the shell. For one thing, our ShellProgram
s can't reasonably assume that there are any common OS facilities available, like printf
or std::cout
, which would normally be included by a shell program's source code and dynamically linked to a library on the system.
Printing Through Ports
With respect to our main target environment (i.e. firmware) and its constraints, our CLI commands might more reasonably adopt an interface that acknowledges the requirements we're trying to address. We only need our CLI commands to interact with the bound components, to parse their command-specific CLI arguments, and to write text to the text output. We should also try not to get ahead of ourselves. Our immediate need is a simple CLI for basic debugging and testing purposes. It would be inappropriate to start building a general-purpose shell scripting environment at this point. It's tempting to try to develop a ShellProgram
API that is independent of this basic CLI, but to do so would be premature at this time.
At the same time, some of the facilities we develop here may be useful across other components. Many components, like our CLI commands, may require some way of outputting errors, error messages, and other text logs. We might consider treating these as outputs
in the same way we treat other output signals:
struct CliCommand
{
static _consteval auto name() { return "Example Command"; }
struct outputs_t
{
struct stdout_t
{
static _consteval auto name() { return "standard text output"; }
const char * value;
} stdout;
} outputs;
void main(int argc, char ** argv);
};
The main problem with this approach is that the memory for string-type outputs needs to be managed somehow, even though in principle there should be no real need to store whole messages. In an embedded context, bytes can most likely be sent more or less as needed, and buffering should in any case be handled by the serial interface's drivers, not the component sending the message. In an environment with an operating system, the OS should handle buffering of messages. In general, the component is not well suited to deal with this concern. Particularly in an embedded context, where it may be inappropriate to use automatically memory-managed containers from the standard library (e.g. std::string_stream
seems obviously relevant), the burden of memory management can be significant.
For simple components with very basic logging needs, e.g. if all messages that may ever be logged are known at compile time, a string-typed output port may still be useful. But in case the messages incorporate runtime information, this approach is probably not satisfactory.
For some commands, generating their entire possible output at compile time is very likely achievable. However, while this might work well enough in case of a component running on a PC, where program memory is abundant, the resulting duplication of strings is likely not tolerable in many embedded systems. For instance, if the message for the list
command were statically generated at compile time, it would result in the duplication of the names of every component bound to the CLI. This kind of duplication would very likely add up quickly until it starts to crowd out more important functionality.
Printing Through Feature Injection
What is needed is a way to pass in a function that can accept a string and immediately print it or buffer it for printing with the appropriate driver. In the current version of Avendish at the time of writing, this is accomplished through a dependency injection mechanism where the component or command in need of logging functionality accepts a template parameter that provides a logger type that the component can instantiate to access the needed functionality through a member function call of the instantiated type. This exposes the core of the only reasonable approach. Effectively, the only way to explicitly pass the thread of execution from one place to another is through a function call. So here are some of the options
- the component author includes the function(s) for printing
- the component user inherits from the component and overrides its virtual printing method(s)
- the component stores a callback function pointer or pointer to class that is called on for printing
- the printing function or class is passed as a template parameter to the component
- the printing function or class is passed as an argument to the component's main subroutine
In all cases, the component author and binding author are forced to agree on a convention for the printing calls. There's no getting around that. In the first case, the hardware specific printer is determined at link time; we would prefer to make this choice explicit in source code. In the second case, an abundance of near-identical subclassing is likely to abound, as the binding author subclasses every component to work with every feature that needs to be injected–quadratic glue code in other words, and we are not willing to accept that. A class pointer could work; the component author would include an abstract base class, and then the binding author can provide subclasses for each hardware specific driver. There's no glue code, but this approach inextricably links the component implementation to that abstract base, which becomes pulled in as an explicit physical dependency of the component. This is a level of frameworkization that we would prefer to avoid. A function callback could work, except it would require the component author to match all material for printing to one function call signature, e.g. void (*print)(char * str)
. This means that the responsibility for converting any data that needs to be printed into a char * str
falls on the component author. We would prefer for them to be able to focus on writing their component without having to think about string formatting conversions.
The approach taken in Avendish at the time of writing is the template class parameter. The dependency is passed as a type in the scope of a class parameter of the component, e.g. template<class Config> struct mycomponent {...};
, and an instance of the depency type is instantiated as a member variable of the component e.g. [[no_unique_address]] typename Config::logger_type log;
. This is somewhat better than a pointer to a class. In both cases, the component author admits a dependency on the call signature requirements of the logger. The template parameter option has these main advantages: the dependency is implicit, meaning that it is simpler for binding authors to meet the requirements without modifying the component's implementation or satisfy a certain binary-level API, and the component and binding implementations aren't required to share any source code; the injection of the feature imposes no runtime cost, since the full signature of the printer and component are both known at compile time, so there's no indirection through a base class; and compile time injection also enables stronger type checking, and potentially allows the binding author to enable optimization through compile-time programming and template metaprogramming, if applicable. The cost is likely in compilation time, since the component code has to be recompiled for every different printer. However, in this context that cost is negligible, since we're already committed to recompiling our components for each new binding. It's also a bit awkward to instantiate the dependency, requiring the attribute [[no_unique_address]]
to advise the compiler that the instance may not take any space, and the type declaration typename Config::deptype
to access the nested type name. This approach is what is termed an internal plugin in sygaldry
, describe at a higher level in concepts/README.md
.
The design of the printer is addressed in its own directory.
Reading Through Dependency Injection
Just as sending text to the serial console output is best achieved through an internal plugin, so it reading text from the console input. The serial reader is defined in its own directory.
Summary
So we have moved from considering our CLI commands as general purpose shell programs, to restricting that view to see them as mere text outputters, to once again broadening our view to see them as general purpose reflectable-aggregate-type components with textual output ports, to recognizing that text output requires feature injection, preferably through a template parameter, to avoid glue code, forcing explicit dependencies on the component, and added runtime costs. This leads to the following plan, demonstrated with a simple echo
command, for the commands' general interface, borrowing the conceptual design of the logger feature injection scheme from Avendish:
struct Echo
{
static _consteval auto name() { return "/echo"; }
static _consteval auto description() { return "Repeats its arguments, separated by spaces, to the output"; }
int main(int argc, char ** argv, auto& log, auto&)
{
for (int i = 1; i < argc; ++i)
{
log.print(argv[i]);
if (i + 1 < argc) log.print(" ");
}
log.println();
return 0;
};
};
CLI
As described above, the CLI provides a fairly limited amount of behavior. It's main role is to simply accumulate inputs until a full line is available, try to match the first token in the line to a command, and execute it if a match is found.
Tests
We'll define a function to facilitate testing that accepts a string as input to the CLI and returns a string representing whatever the CLI prints in response. We also define some test components;
void test_cli(auto& cli, auto& components, string input, string expected_output)
{
cli.log.put.ss.str("");
cli.reader.ss.str(input);
cli.external_sources(components);
REQUIRE(cli.log.put.ss.str() == expected_output);
}
struct Component1 {
static _consteval auto name() { return "Test Component A"; }
void main() {}
};
struct Component2 {
static _consteval auto name() { return "Test Component B"; }
void main() {}
};
struct TestComponents
{
Component1 cpt1;
Component2 cpt2;
sygaldry::sygbp::TestComponent tc;
};
We can then test the CLI using a few trivial commands, such as the echo
command defined above, and the following:
struct HelloWorld
{
static _consteval auto name() { return "/hello"; }
static _consteval auto description() { return "Say's 'Hello world!' Useful for testing the CLI"; }
int main(int argc, char ** argv, auto& log, auto&)
{
log.println("Hello world!");
return 0;
};
};
struct CliCommands
{
Echo echo;
HelloWorld hello;
};
TEST_CASE("sygaldry CLI", "[bindings][cli]")
{
auto components = TestComponents{};
auto cli = CustomCli<TestReader, sygup::TestLogger, TestComponents, CliCommands>{};
static_assert(Component<decltype(cli)>);
SECTION("Hello world")
{
test_cli(cli, components, "/hello\n", "Hello world!\n> ");
}
SECTION("Echo")
{
test_cli(cli, components, "/echo foo bar baz\n", "foo bar baz\n> ");
}
}
Implementation
Buffers
We'll statically allocate some buffers as class member variables to hold the incoming characters, a count of tokens found, and pointers to the beginnings of the tokens. For now, the size of the buffers is determined heuristically. Ideally we should iterate over the commands at compile time and determine exactly how big these buffers need to be, but this is left as future work for now:
static constexpr size_t MAX_ARGS = 5;
static constexpr size_t BUFFER_SIZE = 128;
int argc = 0;
char * argv[MAX_ARGS];
unsigned char write_pos = 0;
char buffer[BUFFER_SIZE];
Process loop
With these resources, we can outline the process function. The plan is to keep track of the onset of arguments in argv
, and to convert whitespace to null characters so that the arguments are automatically null terminated.
At the end of each line, we try to match the first command line argument to one of the commands known to the CLI. Then, regardless of the command's exit status, we reset the buffers, print a new prompt, and return to the normal process loop. The same reset routine is called if our input buffer overflows.
Notice that we accept the list of components which the CLI interacts with as an argument. We assume that this is in the form of a reflectable simple-aggregate struct, which we will discuss further below.
We need to echo user input back to the log output so that the user has feedback as they write. Ideally we would implement a full readline REPL, but this remains as future work for now.
void process(const char c, Components& components)
{
if (_is_whitespace(c))
buffer[write_pos++] = 0;
else
{
buffer[write_pos] = c;
buffer[write_pos+1] = 0;
if (_new_arg())
argv[argc++] = &buffer[write_pos];
write_pos++;
}
if (c == '\n' || c == '\r')
{
log.print("\r\n");
_try_to_match_and_execute(components);
_reset();
}
if (_overflow())
{
log.println("CLI line buffer overflow!");
_reset();
}
}
void external_sources(Components& components)
{
while(reader.ready()) process(reader.getchar(), components);
}
An earlier version of the CLI used a purpose-specific name-matching dispatcher that has since been removed in favor of using the `osc_match_pattern` subroutine defined in the document with the same name. Command names are treated as OSC strings, and any command that matches the first CLI argument is invoked, passing the CLI arguments, log, and components. The help command is a special case; it requires the list of commands be passed rather than the list of components.
void _try_to_match_and_execute(Components& components)
{
boost::pfr::for_each_field(commands, [&](auto& command)
{
int retcode;
if constexpr (std::is_same_v<decltype(command), Help&>)
{
retcode = command.main(log, commands);
}
else retcode = command.main(argc, argv, log, components);
if (retcode != 0) _complain_about_command_failure(retcode);
});
}
bool osc_match_pattern(const char *pattern, const char *address)
Match an OSC address pattern against the given address.
Definition sygbp-osc_match_pattern.cpp:12
Instantiation
The CLI is a template that can accept an arbitrary number of components and commands, and requires a logger that is passed through to all of its commands. Being able to instantiate the CLI without having to restate the names all of the components and commands is essential.
To facilitate this, we require the components and commands to be wrapped in a reflectable struct. We further assume that the list of commands will, by default, be all available commands, but that (particularly for testing) some clients will want to specify their own list of commands. For this reason, the main CLI implementation is contained in a class CustomCli
, for which a template alias is provided Cli
that specifies the default list of commands. By the same sort of reasoning, we also provide a template alias that incorporates the cstdio
input/output plugins.
struct DefaultCommands
{
@{default commands}
};
template<typename Reader, typename Logger, typename Components>
using Cli = CustomCli<Reader, Logger, Components, DefaultCommands>;
Details
bool _is_whitespace(char c)
{
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') return true;
else return false;
}
bool _new_arg() const
{
return write_pos == 0 || buffer[write_pos-1] == 0;
}
bool _overflow() const
{
return argc == MAX_ARGS || write_pos == BUFFER_SIZE-1;
}
void _prompt()
{
log.print("\r> ");
for (int i = 0; i < argc - 1; ++i) log.print(argv[i], " ");
if (argc > 0) log.print(argv[argc - 1]);
}
void _reset()
{
argc = 0;
write_pos = 0;
}
void _complain_about_command_failure(int retcode)
{
log.println("command failed!");
}
CLI Summary
#pragma once
#include <memory>
#include <string_view>
#include <concepts>
#include <cstdlib>
#include "sygah-consteval.hpp"
#include "sygah-metadata.hpp"
#include "sygbp-osc_match_pattern.hpp"
@{commands headers}
namespace sygaldry { namespace sygbp {
template<typename Reader, typename Logger, typename Components, typename Commands>
struct CustomCli : name_<"CLI">
, author_<"Travis J. West">
, description_<"Generate a simple command line interface for inspecting and sending data to the bound components.">
, version_<"0.0.0">
, copyright_<"Copyright 2023 Sygaldry contributors">
, license_<"SPDX-License-Identifier: MIT">
{
[[no_unique_address]] Logger log;
[[no_unique_address]] Reader reader;
[[no_unique_address]] Commands commands;
void init()
{
log.println("CLI enabled. Write `/help` for a list of available commands.");
_prompt();
}
@{cli buffers}
@{cli implementation details}
@{cli process}
};
@{cli default type alias}
} }
#pragma once
#include "sygbp-cli.hpp"
#include "sygup-cstdio_logger.hpp"
#include "sygbp-cstdio_reader.hpp"
namespace sygaldry { namespace sygbp {
template<typename Components>
using CstdioCli = Cli<CstdioReader, sygup::CstdioLogger, Components>;
} }
Cli< CstdioReader, sygup::CstdioLogger, Components > CstdioCli
Definition sygbp-cstdio_cli.hpp:24
Testing Commands
The remainder of this document describes the commands available on the CLI. The tests for these commands generally follow the same format, so a single test function, similar to the one used for testing the CLI, is provided to simplify the implementation and presentation of the tests. The command and components are passed by reference to allow the main test case to check for expected side-effects of running the command.
void test_command(auto&& command, auto&& components, int expected_retcode, string expected_output, auto ... args)
{
int argc = 0;
char * argv[sizeof...(args)];
auto set_arg = [&](auto arg) {argv[argc++] = (char *)arg;};
( set_arg(args), ... );
sygup::TestLogger logger{};
logger.put.ss.str("");
int retcode = command.main(argc, argv, logger, components);
REQUIRE(retcode == expected_retcode);
REQUIRE(logger.put.ss.str() == string(expected_output));
};
First Commands: <tt>list</tt> and <tt>help</tt>
Recalling our earlier discussion of the basic functionality we require, two commands, list
and help
stand out as particularly simple, since they don't need to actually interact much with the components.
Help Command
The help command is a bit unusual, since it actually doesn't care about components, but rather commands. It should print each command's name, usage, and a brief description. The name is easy enough, as is the description, since it's reasonable to expect the command to provide both of these considering there's no reasonable way to deduce them. It would be ideal if the usage text could be derived by reflecting on the command, but enabling this would require us to make assumptions about the way commands are shaped, since it's not possible to deduce anything from the signature int main(argc, argv, log,
components)
. For now, we'll take the easiest route and require commands to provide their usage text if the command takes arguments.
struct Command1 {
static _consteval auto name() { return "/test-command-1"; }
static _consteval auto usage() { return "foo bar"; }
static _consteval auto description() { return "Description 1"; }
};
struct Command2 {
static _consteval auto name() { return "/test-command-2"; }
static _consteval auto description() { return "Description 2"; }
};
struct TestCommands
{
Command1 cmd1;
Command2 cmd2;
};
TEST_CASE("sygaldry Help command", "[cli][commands][help]")
{
Help command;
sygup::TestLogger logger{};
logger.put.ss.str("");
auto commands = TestCommands{};
auto retcode = command.main(logger, commands);
REQUIRE(logger.put.ss.str() == string("/test-command-1 foo bar\n Description 1\n/test-command-2\n Description 2\n/help\n Describe the available commands and their usage\n"));
REQUIRE(retcode == 0);
}
#pragma once
#include <boost/pfr.hpp>
#include "sygah-consteval.hpp"
namespace sygaldry { namespace sygbp {
struct Help
{
static _consteval auto name() { return "/help"; }
static _consteval auto usage() { return ""; }
static _consteval auto description() { return "Describe the available commands and their usage"; }
void _print(auto& log, auto&& command)
{
if constexpr (requires {command.usage();})
log.println(command.name(), " ", command.usage());
else
log.println(command.name());
log.println(" ", command.description());
}
int main(auto& log, auto& commands)
{
boost::pfr::for_each_field(commands, [&](auto&& command)
{
_print(log, command);
});
log.println(name());
log.println(" ", description());
return 0;
}
};
} }
#include "commands/help.hpp"
Help help;
List Command
The list command should output a new-line separated list of component names:
TEST_CASE("sygaldry List command outputs", "[cli][commands][list]")
{
test_command(List{}, TestComponents{},
0, "/Test_Component_A\n/Test_Component_B\n/Test_Component_1\n",
"list");
}
Notice that we expect the component names to be converted to OSC addresses. An earlier version of the CLI used lower kebab case, which is arguably more idiomatic to a CLI, but OSC addresses are preferred to enable reuse of the osc_match_pattern
subroutine for dispatching commands.
Since component names are _consteval
, we can generate the whole expected output at compile time. However, doing so is noticeably more complicated than merely printing the correct output at runtime, and also imposes an increased program size to statically store the generated strings, which needlessly duplicates the names of components. Instead, we'll iterate over the component types using a fold expression and print each one's name using the injected logger.
#pragma once
#include <type_traits>
#include "sygah-consteval.hpp"
#include "sygac-components.hpp"
#include "sygbp-osc_string_constants.hpp"
namespace sygaldry { namespace sygbp {
struct List
{
static _consteval auto name() { return "/list"; }
static _consteval auto usage() { return ""; }
static _consteval auto description() { return "List the components available to interact with through the CLI"; }
int main(int argc, char** argv, auto& log, auto& components)
{
for_each_component(components, [&](const auto& component)
{
log.println(osc_path_v< std::remove_cvref_t<decltype(component)>
, std::remove_cvref_t<decltype(components)>
>);
});
return 0;
}
};
} }
#include "commands/list.hpp"
List list;
Component Commands
Commands in this section reflect over components to access their metadata, set their values, and simulate an operational runtime.
Describe
This command is used to get the metadata associated with an entity, as well as its current value if it has one. The first argument is the component to study. The second optional argument narrows the examination to a single endpoint.
TEST_CASE("sygaldry Descibe", "[bindings][cli][commands][describe]")
{
auto components = TestComponents{};
components.tc.inputs.button_in = 1;
components.tc.inputs.bang_in();
test_command(Describe{}, components, 0,
R"DESCRIBEDEVICE(entity: /Test_Component_1
name: "Test Component 1"
type: component
input: /Test_Component_1/button_in
name: "button in"
type: occasional int
range: 0 to 1 (init: 0)
value: (! 1 !)
input: /Test_Component_1/toggle_in
name: "toggle in"
type: persistent int
range: 0 to 1 (init: 0)
value: 0
input: /Test_Component_1/slider_in
name: "slider in"
type: persistent float
range: 0 to 1 (init: 0)
value: 0
input: /Test_Component_1/bang_in
name: "bang in"
type: bang
value: (! bang !)
input: /Test_Component_1/text_in
name: "text in"
type: persistent text
value: ""
input: /Test_Component_1/text_message_in
name: "text message in"
type: occasional text
value: ()
input: /Test_Component_1/array_in
name: "array in"
type: array of float
range: 0 to 1 (init: 0)
value: [0 0 0]
output: /Test_Component_1/button_out
name: "button out"
type: occasional int
range: 0 to 1 (init: 0)
value: (0)
output: /Test_Component_1/toggle_out
name: "toggle out"
type: persistent int
range: 0 to 1 (init: 0)
value: 0
output: /Test_Component_1/slider_out
name: "slider out"
type: persistent float
range: 0 to 1 (init: 0)
value: 0
output: /Test_Component_1/bang_out
name: "bang out"
type: bang
value: ()
output: /Test_Component_1/text_out
name: "text out"
type: persistent text
value: ""
output: /Test_Component_1/text_message_out
name: "text message out"
type: occasional text
value: ()
output: /Test_Component_1/array_out
name: "array out"
type: array of float
range: 0 to 1 (init: 0)
value: [0 0 0]
)DESCRIBEDEVICE", "describe", "/Test_Component_1");
test_command(Describe{}, TestComponents{}, 0,
R"DESCRIBEENDPOINT(entity: /Test_Component_1/slider_out
name: "slider out"
type: persistent float
range: 0 to 1 (init: 0)
value: 0
)DESCRIBEENDPOINT", "describe", "/Test_Component_1/slider_out");
components.tc.inputs.text_in = "hello";
CHECK(components.tc.inputs.text_in.value == string("hello"));
test_command(Describe{}, components, 0,
R"DESCRIBEENDPOINT(entity: /Test_Component_1/text_in
name: "text in"
type: persistent text
value: "hello"
)DESCRIBEENDPOINT", "describe", "/Test_Component_1/text_in");
}
Depending on whether there is one or two arguments besides the name of the command, we either describe a component (recursively including its endpoints), or we describe a single endpoint.
template<typename Components>
int main(int argc, char** argv, auto& log, Components& components)
{
if (argc < 2) return 2;
for_each_node(components, [&]<typename T>(T& node, auto)
{
if constexpr (has_name<T>)
describe_entity<T, Components>(log, "entity: ", node);
});
return 0;
};
We generically describe both components and endpoints using the same methods, which reflect on the input entity using concepts, recursing over nested entities where they are found. Note that Bang
related branches need to appear before others, since a Bang
is currently implemented as a PersistentValue
. Similarly, since OccasionalValue
merely imposes additional constraints on those of PersistentValue
, it should be checked before the latter.
template<typename T>
void describe_entity_type(auto& log, T& entity)
{
if constexpr (Bang<T>) log.println("bang");
else if constexpr (has_value<T>)
{
if constexpr (OccasionalValue<T>)
{
if constexpr (array_like<value_t<T>>)
log.print("array of ");
else log.print("occasional ");
}
else if constexpr (PersistentValue<T>)
{
if constexpr (array_like<value_t<T>>)
log.print("array of ");
else log.print("persistent ");
}
if constexpr (std::integral<element_t<T>>)
log.println("int");
else if constexpr (std::floating_point<element_t<T>>)
log.println("float");
else if constexpr (string_like<element_t<T>>)
log.println("text");
else log.println("unknown value type");
}
else if constexpr (Component<T>) log.println("component");
else log.println("unknown");
}
template<typename T>
void describe_entity_value(auto& log, T& entity)
{
if constexpr (Bang<T>)
{
if (flag_state_of(entity)) log.println("(! bang !)");
else log.println("()");
}
else if constexpr (OccasionalValue<T>)
{
if (flag_state_of(entity)) log.println("(! ", value_of(entity), " !)");
else log.println("(", value_of(entity), ")");
}
else if constexpr (PersistentValue<T>)
{
if constexpr (tagged_write_only<T>) log.println("WRITE ONLY");
else if constexpr (string_like<value_t<T>>)
log.println("\"", value_of(entity), "\"");
else log.println(value_of(entity));
}
}
template<typename T, typename Components>
void describe_entity(auto& log, auto preface, T& entity, auto ... indents)
{
static_assert(has_name<T>);
log.println(indents..., preface, (const char *)osc_path_v<T, Components>);
log.println(indents..., " name: \"", entity.name(), "\"");
log.print(indents..., " type: ");
describe_entity_type(log, entity);
if constexpr (has_range<T>)
{
log.print(indents..., " range: ");
auto range = get_range<T>();
log.println(range.min, " to ", range.max, " (init: ", range.init, ")");
}
if constexpr (has_value<T>)
{
log.print(indents..., " value: ");
describe_entity_value(log, entity);
}
if constexpr (Component<T>)
{
auto describe_group = [&](auto& group, auto groupname)
{
boost::pfr::for_each_field(group, [&]<typename Y>(Y& endpoint)
{
describe_entity<Y, Components>(log, groupname, endpoint, " ", indents...);
});
};
if constexpr (has_inputs<T>) describe_group(inputs_of(entity), "input: ");
if constexpr (has_outputs<T>) describe_group(outputs_of(entity), "output: ");
}
}
Boilerplate
#pragma once
#include <boost/pfr.hpp>
#include "sygah-consteval.hpp"
#include "sygac-components.hpp"
#include "sygac-metadata.hpp"
#include "sygac-endpoints.hpp"
#include "sygbp-osc_string_constants.hpp"
#include "sygbp-osc_match_pattern.hpp"
namespace sygaldry { namespace sygbp {
struct Describe
{
static _consteval auto name() { return "/describe"; }
static _consteval auto usage() { return "osc-address-pattern"; }
static _consteval auto description() { return "Convey metadata about entities that match the given address pattern"; }
@{describe implementation details}
@{describe main}
};
} }
Describe describe;
#include "commands/describe.hpp"
Set
This command sets the value of an endpoint, specified by the name of its parent component and its own name (in that order). Note that this does not cause the component's main subroutine to trigger under any circumstance, which is instead done by the following command.
TEST_CASE("sygaldry Set", "[bindings][cli][commands][set]")
{
auto components = TestComponents{};
SECTION("set slider")
{
test_command(Set{}, components, 0, "", "/set", "/Test_Component_1/slider_in", "0.31459");
REQUIRE(components.tc.inputs.slider_in.value == 0.31459f);
}
SECTION("set toggle")
{
REQUIRE(components.tc.inputs.toggle_in.value == 0);
test_command(Set{}, components, 0, "", "/set", "/Test_Component_1/toggle_in", "1");
REQUIRE(components.tc.inputs.toggle_in.value == 1);
}
SECTION("set button")
{
REQUIRE(not components.tc.inputs.button_in.updated);
test_command(Set{}, components, 0, "", "/set", "/Test_Component_1/button_in", "1");
REQUIRE(components.tc.inputs.button_in.updated);
REQUIRE(components.tc.inputs.button_in.value() == 1);
}
SECTION("set bang")
{
test_command(Set{}, components, 0, "", "/set", "/Test_Component_1/bang_in");
REQUIRE(components.tc.inputs.bang_in.value == true);
}
SECTION("set string")
{
test_command(Set{}, components, 0, "", "/set", "/Test_Component_1/text_in", "helloworld");
REQUIRE(components.tc.inputs.text_in.value == string("helloworld"));
}
SECTION("set array")
{
test_command(Set{}, components, 0, "", "/set", "/Test_Component_1/array_in", "1", "2", "3");
REQUIRE(components.tc.inputs.array_in.value == std::array<float, 3>{1,2,3});
}
}
The main subroutine simply checks the arguments and tries to dispatch to the right endpoint, deferring the main logic of the command to another subroutine.
template<typename Components>
int main(int argc, char** argv, auto& log, Components& components)
{
if (argc < 2)
{
log.println("usage: ", usage());
return 2;
}
for_each_endpoint(components, [&]<typename T>(T& endpoint) {
set_endpoint_value(log, endpoint, argc-2, argv+2);
});
return 0;
}
Setting the value depends on the type of value. Bangs are set no matter what the input. Otherwise, we require more information to parse in order to set the value.
@{parse and set}
template<typename T>
int set_endpoint_value(auto& log, T& endpoint, int argc, char ** argv)
{
if constexpr (Bang<T>)
{
if (argc != 0) log.println("Note: no arguments are required to set a bang.");
set_value(endpoint, true);
return 0;
}
else if constexpr (array_like<value_t<T>>)
{
if (argc < size<value_t<T>>())
{
log.println("Not enough arguments to set this endpoint.");
return 2;
}
else return parse_and_set(log, endpoint, argc, argv);
}
else if constexpr (has_value<T>)
{
if (argc < 1)
{
log.println("Not enough arguments to set this endpoint.");
return 2;
}
else return parse_and_set<value_t<T>>(log, endpoint, argv[0]);
}
else return 2;
}
parse_and_set
is called in case a value needs to be parsed from the input tokens. Most of these methods consists of checking for errors and returning a failure code in case we can't parse the input token.
The case for a single-valued endpoint simply parses the given input string:
@{from_chars}
template<typename T>
int parse_and_set(auto& log, auto& endpoint, const char * argstart)
{
auto argend = argstart;
for (int i = 0; *argend != 0 && i < 256; ++i, ++argend) {}
if (*argend != 0)
{
log.println("Unable to parse number, couldn't find end of token");
return 2;
}
bool success = false;
T val = from_chars<T>(argstart, argend, success);
if (success)
{
endpoint = val;
return 0;
}
else
{
log.println("Unable to parse token '", argstart, "'");
return 2;
}
}
The case for an array-like endpoint needs to iterate over the input strings:
int parse_and_set(auto& log, auto& endpoint, int argc, char ** argv)
{
for (int i = 0; i < argc; ++i)
{
using T = decltype(value_of(endpoint)[0]);
auto ret = parse_and_set<std::remove_cvref_t<T>>(log, value_of(endpoint)[i], argv[i]);
if (ret != 0) return ret;
}
return 0;
}
For converting from a token string to a number, we would like to simply use the standard library from_chars
, but the ESP-IDF doesn't appear to have implemented the floating point or bool overloads for this function at the time of writing. We define our own from_chars
function that wraps the standard library when available, and falls back to other methods such as strtoX
otherwise.
template<typename T>
requires std::integral<T>
T from_chars(const char * start, const char * end, bool& success)
{
T ret{};
auto [ptr, ec] = std::from_chars(start, end, ret);
if (ec == std::errc{}) success = true;
else success = false;
return ret;
}
template<typename T>
requires std::floating_point<T>
T from_chars(const char * start, const char * end, bool& success)
{
#ifdef ESP_PLATFORM
char * e;
T ret;
if constexpr (std::is_same_v<T, float>)
ret = std::strtof(start, &e);
else if constexpr (std::is_same_v<T, double>)
ret = std::strtod(start, &e);
else if constexpr (std::is_same_v<T, long double>)
ret = std::strtold(start, &e);
if (start == e) success = false;
else success = true;
return ret;
#else
T ret;
auto [ptr, ec] = std::from_chars(start, end, ret);
if (ec == std::errc{}) success = true;
else success = false;
return ret;
#endif
}
For "parsing" a string, we simply return the token itself.
template<typename T>
requires requires (T t, const char * s) {t = s;}
T from_chars(const char * start, const char *, bool& success)
{
success = true;
return start;
}
Boilerplate
#pragma once
#include <charconv>
#include "sygac-endpoints.hpp"
#include "sygbp-osc_string_constants.hpp"
#include "sygbp-osc_match_pattern.hpp"
namespace sygaldry { namespace sygbp {
struct Set
{
static _consteval auto name() { return "/set"; }
static _consteval auto usage() { return "component-name endpoint-name [value] [value] [...]"; }
static _consteval auto description() { return "Change the current value of the given endoint"; }
@{set implementation details}
@{set main}
};
} }
#include "commands/set.hpp"
Set set;
Summary
Building Tests
#include <string>
#include <memory>
#include <catch2/catch_test_macros.hpp>
#include "sygah-consteval.hpp"
#include "sygac-components.hpp"
#include "sygbp-test_component.hpp"
#include "sygup-test_logger.hpp"
#include "sygbp-test_reader.hpp"
#include "sygbp-cli.hpp"
using std::string;
using namespace sygaldry::sygbp;
using namespace sygaldry::sygbp;
using namespace sygaldry;
@{cli tests logger}
@{cli test wrapper}
@{command test wrapper}
@{test commands}
@{test components}
@{tests}
# @#'CMakeLists.txt'
set(lib sygbp-cli)
add_library(${lib} INTERFACE)
target_include_directories(${lib}
INTERFACE .
INTERFACE ./commands
)
target_link_libraries(${lib}
INTERFACE Boost::pfr
INTERFACE sygah-consteval
INTERFACE sygac-endpoints
INTERFACE sygac-components
INTERFACE sygac-metadata
INTERFACE sygah-metadata
INTERFACE sygbp-cstdio_reader
INTERFACE sygup-cstdio_logger
INTERFACE sygbp-osc_match_pattern
INTERFACE sygbp-osc_string_constants
)
if (SYGALDRY_BUILD_TESTS)
add_executable(${lib}-test ${lib}.test.cpp)
target_link_libraries(${lib}-test PRIVATE Catch2::Catch2WithMain)
target_link_libraries(${lib}-test
PRIVATE ${lib}
PRIVATE sygbp-test_component
PRIVATE sygup-test_logger
PRIVATE sygbp-test_reader
)
catch_discover_tests(${lib}-test)
endif()
# @/
CLI Component
The CLI can also be treated as a component. There are a few clear approaches that could be taken here to get the character inputs, broadly similar to the possibilities for sending text to the output. In keeping with that previous discussion, we will model the character input as a plugin component.