Skip to content

cisstMultiTask tutorial

Anton Deguet edited this page Oct 13, 2020 · 15 revisions

Introduction

In this tutorial we will create two simple components. The first component is a counter. Its main function is to periodically increment an internal counter. When the counter overflows it should send an event. One should be able to query the current value and set the increment. If the user requests an incorrect value for the increment, the counter component will throw an event with an informative message.

The second component user is designed to be connected to the counter component. In this example, we're avoiding using a GUI toolkit to make the code as simple as possible. The user interface is text based.

The latest version of the code for this tutorial can be compiled along the cisst using the CMake options CISST_BUILD_EXAMPLES and CISST_cisstMultiTask_EXAMPLES. The code itself can be found in cisst/cisstMultiTask/examples/tutorial

Counter component

Periodic task

For this component, we're using a the base class mtsTaskPeriodic, i.e. the Run method will be called periodically to perform all user defined computations and the library will attempt to maintain a constant frequency between calls to the Run method. The amount of jitter depends on the services provided by the Operating System (the cisstOSAbstraction library provides an abstraction layer to the different OS features).

One of the parameters provided to the constructor is the desired periodicity provided as a double representing the time in seconds. To make sure the code is readable, cisst has a set of constants used to indicate the units used, e.g. 5.0 * cmm_ms indicates 5 milliseconds.

It is possible to use different types of components: continuous, event based, triggered by an external event, ... See all component and task types in cisstMultiTask concepts.

In the header file:

#include <cisstMultiTask/mtsTaskPeriodic.h>

class counter: public mtsTaskPeriodic {
    ...
};

In the implementation file:

counter::counter(const std::string & componentName, double periodInSeconds):
    // base constructor, same component name and period.  Third
    // parameter is "false" because we don't need hard realtime.  Last
    // parameter, 500, is the size of the default state table
    mtsTaskPeriodic(componentName, periodInSeconds, false, 500),
    ...
{
}

The Run method of a task contains the user computations, in our example:

void counter::Run(void)
{
    // process the commands received
    ProcessQueuedCommands();

    Counter += Increment;
    if (Counter > 100.0) {
        Counter = 0.0;
        // it's good practice to check the returned value
        mtsExecutionResult result = OverflowEvent();
        if (!result) {
            CMN_LOG_CLASS_RUN_ERROR << "SetIncrement: trigger \"OverflowEvent\" returned "
                                    << result << std::endl;
        }
    }
}

Since most components use queued commands to be thread safe, one must remember to empty the queues of commands using the method ProcessQueuedCommands.

State table

All cisstMultiTask components own at least one state table. A state table can be seen as a matrix where the columns represent a state data object and the rows the values of that data object over time. It is implemented as a circular buffer and is used to provide a thread safe mechanism to publish the component's data (one writer, multiple readers). For historical reasons, all components have a default state table member (StateTable) but users can add as many state tables as they need (using AddStateTable). One can add more state tables to handle:

  • different refresh rates, i.e. some data doesn't change over time. In this example, the increment changes only when set by the user so we don't need to save the data at the same refresh rate as the counter value.
  • similar groups of data, i.e. a component can maintain multiple similar interfaces corresponding to similar devices (e.g. left and right arms of a robot). In this case, one can use two tables containing the exact same data.

In the header file, declare data to be added to state table(s) and optionally user defined state tables:

    // internal counter data
    double Counter;

    // increment used for the counter
    mtsStateTable ConfigurationStateTable;
    double Increment;

In implementation file, configuring the state tables:

    // state table variables
    StateTable.AddData(Counter, "Counter");

    // user defined configuration state table
    // first you need to add the state table to the component
    AddStateTable(&ConfigurationStateTable);
    // second, make sure we control when the table "advances"
    ConfigurationStateTable.SetAutomaticAdvance(false);
    // finally, add data to the state table
    ConfigurationStateTable.AddData(Increment, "Increment");

For the user defined state table, since we turned off Automatic Advance, we need to Start and Advance manually:

    // now we can add to the state table
    ConfigurationStateTable.Start();
    Increment = increment;
    // make the circular buffer move one step
    ConfigurationStateTable.Advance();

There is no need to Start and Advance the default state table StateTable; this is performed automatically before and after the calls to the Run method. This applies to all state tables with Automatic Advance turned on (default behavior).

Commands

cisstMultiTask uses the command pattern, i.e. the user doesn't directly call the C++ methods of a component. All methods are encapsulated in command objects (e.g. mtsCommandVoid). These command objects are grouped in interfaces and can be retrieved by name at runtime (see also cisstMultiTask concepts). In practice, the first step is to create an interface that will contain some of the provided features of the component (i.e. commands and events). To add an mtsInterfaceProvided:

    // add a provided interface
    mtsInterfaceProvided * interfaceProvided = AddInterfaceProvided("User");

    // for applications dynamically creating interfaces, the user
    // should make sure the interface has been added properly.
    // AddInterfaceProvided could fail if there is already an
    // interface with the same name.
    if (!interfaceProvided) {
        CMN_LOG_CLASS_INIT_ERROR << "failed to add \"User\" to component \""
                                 << this->GetName() << "\"" << std::endl;
        return;
    }

The next step is to add some commands to the newly created provided interface. For the counter component, we're going to use 3 different types of commands:

  • Void: command that doesn't require any payload, usually encapsulating a method with the signature void method(void). Void commands usually change the state of the component and therefore encapsulate non const methods. If the component owns its own thread, void commands are queued.
  • Write: command that requires a payload, usually encapsulating a method with the signature void method(const payloadType & payload). Write commands usually change the state of the component of the state and therefore encapsulate non const methods. If the component owns its own thread, write commands are queued.
  • Read: command used to get some information from the component, usually encapsulating a method with the signature void method(payloadType & placeHolder) const. Read commands shouldn't change the state of the component and therefore encapsulate const methods. Read commands are never queued, so make sure the encapsulated method is thread safe. For this example, we're using a built-in mechanism to read from the state table which happens to be thread safe.

As for the AddInterfaceProvided, one should test if the command has been added properly. This is unlikely to fail for a hard-coded list of commands.

In the header file, declaration of encapsulated methods:

protected:
    // internal methods used for the provided commands
    void SetIncrement(const double & increment);
    void Reset(void);

In your implementation file, add the provided interface and commands:

    // add a provided interface
    mtsInterfaceProvided * interfaceProvided = AddInterfaceProvided("User");

    // for applications dynamically creating interfaces, the user
    // should make sure the interface has been added properly.
    // AddInterfaceProvided could fail if there is already an
    // interface with the same name.
    if (!interfaceProvided) {
        CMN_LOG_CLASS_INIT_ERROR << "failed to add \"User\" to component \""
                                 << this->GetName() << "\"" << std::endl;
        return;
    }

    // add a void command.  The signature of the method used should be
    // "void method(void)".  As for the interface, it is possible to
    // check if a command has been added properly using the returned
    // value.
    if (!interfaceProvided->AddCommandVoid(&counter::Reset, this, "Reset")) {
        CMN_LOG_CLASS_INIT_ERROR << "failed to add command to interface \""
                                 << interfaceProvided->GetFullName()
                                 << "\"" << std::endl;
    }

    // in this example, all the commands have a different name so
    // there is really no need to check the returned value for
    // AddCommand ...

    // add a write command.  The signature of the method used should
    // be "void method(const type & payload)".  We also need to
    // provide the default value expected by this command.
    interfaceProvided->AddCommandWrite(&counter::SetIncrement, this,
                                       "SetIncrement", 1.0);

    // add a command to read the latest value from a state table
    interfaceProvided->AddCommandReadState(StateTable, Counter,
                                           "GetValue");
    interfaceProvided->AddCommandReadState(ConfigurationStateTable, Increment,
                                           "GetIncrement");

Events

Commands are always triggered by the other component, i.e. the component connected to the provided interface (in our example, counter component has the provided interface User). It is also possible to add events to a provided interface. Events are triggered by the component that owns the provided interface. As for the commands, it is possible to declare an event with or without payload (write event and void event).

In the header file:

    // overflow event
    mtsFunctionVoid OverflowEvent;

    // event thrown if the increment value is invalid, sends current increment
    mtsFunctionWrite InvalidIncrementEvent;

In the implementation file, to add the events to the provided interface:

    // add a void event.  We need to provide the function
    // (mtsFunction) that will be used to trigger the event.
    interfaceProvided->AddEventVoid(OverflowEvent, "Overflow");

    // add a write event, i.e. an event with a payload.  We need to
    // provide the function (mtsFunction) that will be used to trigger
    // the event as well as the default value/type of the payload.
    interfaceProvided->AddEventWrite(InvalidIncrementEvent, "InvalidIncrement",
                                     std::string());

To trigger an event:

        // it's good practice to check the returned value
        mtsExecutionResult result = OverflowEvent();
        if (!result) {
            CMN_LOG_CLASS_RUN_ERROR << "SetIncrement: trigger \"OverflowEvent\" returned "
                                    << result << std::endl;
        }

For events, the two possible errors are:

  • mtsFunction is not bound, i.e. the user forgot to use this function as an event trigger, i.e. it was not used with AddEvent
  • Invalid data type for a write event, the payload doesn't correspond to the type declared with AddEventWrite.

User component

Our user component uses the terminal to display its state and key hits to trigger the different commands. The default commands are:

Press ...
 [g] to get current counter value
 [r] to reset counter
 [i] to set a new counter increment
 [q] to quit

Since we want to continuously observe the user's key hits, the user component is derived from mtsTaskContinuous. In the Run method, we use the cisstCommon functions cmnKbHit and cmnGetChar to capture the key hits in a non-blocking manner. We need to make sure listening to keyboard hits is not blocking because the Run method also calls ProcessQueuedEvents. In general, programmers should make sure computations in the Run methods are non-blocking.

Functions

The user component needs a required interface to group all the features it requires. Each feature is declared as a function (object of type mtsFunction...). Overall, when the two components are connected, the user's required interface is connected to the counter's provided interface. Each function declared in the user's required interface is connected to the corresponding command in the counter's provided interface. The two sets of features (commands and events of a provided interface vs. functions and event handlers of a required interface) can be subsets of each other (see also cisstMultiTask concepts).

In the header file:

    // functions used in the interface required to send commands to counter
    mtsFunctionVoid Reset;
    mtsFunctionRead GetValue;
    mtsFunctionWrite SetIncrement;

In the implementation file:

    // add an interface required.
    mtsInterfaceRequired * interfaceRequired = this->AddInterfaceRequired("Counter");
    if (!interfaceRequired) {
        CMN_LOG_CLASS_INIT_ERROR << "failed to add \"Counter\" to component \"" << this->GetName() << "\"" << std::endl;
        return;
    }

    // add a void function, i.e. send a request without payload
    interfaceRequired->AddFunction("Reset", this->Reset);

    // add a read function, i.e. function to retrieve data
    interfaceRequired->AddFunction("GetValue", this->GetValue);

    // add a write function, i.e. send a request with a payload
    interfaceRequired->AddFunction("SetIncrement", this->SetIncrement);

Event handlers

Event handlers are similar to commands, i.e. they also encapsulate C++ methods and come in two flavors, void and write. One must first declare the methods used to handle the events, most likely private or protected.

In the header file:

    // methods used as event handlers for events coming from counter
    void OverflowHandler(void);
    void InvalidIncrementHandler(const std::string & message);

Event handlers must be added to an existing provided interface when the component is being constructed or configured:

    // add a void event handler, i.e. handle an event without a
    // payload.  The method used should have the signature "void
    // method(void)"
    interfaceRequired->AddEventHandlerVoid(&user::OverflowHandler, this, "Overflow");

    // add a write event handler, i.e. handle an event with a payload.
    // The method used should have the signature "void method(const
    // type & payload)"
    interfaceRequired->AddEventHandlerWrite(&user::InvalidIncrementHandler, this, "InvalidIncrement");

If the component owns its thread, events will most likely be queued on the required interface. To make sure the queues don't get filled, one has to process all the queued events. In the Run method:

void user::Run(void)
{
    // process the events received
    ProcessQueuedEvents();
    // other things to do
    ...
}

Connecting components

Once the two components have been implemented, they need to be connected together using their interfaces. A required interface can be connected to one and only one provided interface. On the other hand, it is possible to connect multiple required interfaces to a single provided interface.

When connecting a required interface to a provided interface:

  • The interface names don't have to match
  • Name and types of commands and events must match
  • The provided interface can have more commands and events than the required interface (unused features)
  • Functions and event handlers of a required interface can be tagged as optional. If so, the required interface can still be connected to a provided interface that doesn't provide said features

To manage all the components, use the mtsComponentManager object. The manager is implemented as a singleton so we need to call the static mtsComponentManager::GetInstance() method. The following steps are:

  • Add the components to the manager
  • Connect the components
  • Create the components; create the component threads and call their Startup method
  • Start the components; the Run method will be called
  • Kill the components; stop the threads and call the Cleanup method

In the main file:

    // component manager is a singleton
    mtsComponentManager * componentManager = mtsComponentManager::GetInstance();

    // create counter and user components
    counter * counterPointer = new counter("counter", 1.0 * cmn_s);
    user * userPointer = new user("user");

    // add the components to the component manager
    componentManager->AddComponent(counterPointer);
    componentManager->AddComponent(userPointer);

    // connect the components, task.RequiresInterface -> task.ProvidesInterface
    componentManager->Connect("user", "Counter", "counter", "User");

    // create the components
    componentManager->CreateAll();
    componentManager->WaitForStateAll(mtsComponentState::READY, 2.0 * cmn_s);

    // start the periodic Run
    componentManager->StartAll();
    componentManager->WaitForStateAll(mtsComponentState::ACTIVE, 2.0 * cmn_s);

    // loop until the user tells us to quit
    while (!userPointer->Quit) {
        osaSleep(100.0 * cmn_ms);
    }

    // cleanup
    componentManager->KillAll();
    componentManager->WaitForStateAll(mtsComponentState::FINISHED, 2.0 * cmn_s);

    componentManager->Cleanup();
Clone this wiki locally