Addding a New Action

Trigger actions can be added in your Cloe plugin in the enroll method:

class YourPlugin : public cloe::Controller {
   // ...

   void enroll(cloe::Registrar& r) override {
      // This is where you add your calls to register various things with
      // the cloe engine, including actions.
      r.register_action<YourActionFactory>();
   }
}

And it looks like what register_action wants is a cloe::ActionFactoryPtr, but why? Well, trigger actions are most often created in stackfiles, as something compatible with JSON. The ActionFactory takes a JSON object or a string, and makes an Action out of it.

Defining an Action

Let’s look at a simple action defined in the runtime package in the file cloe/trigger/example_actions.hpp: Log.

We can see there is an include for <cloe/trigger.hpp>. This file can also be found in the runtime package, and you should have a look at the documentation there for the Action interface.

Then we come to the source for the Log action:

class Log : public Action {
 public:
  Log(const std::string& name, LogLevel level, const std::string& msg)
      : Action(name), level_(level), msg_(msg) {}
  ActionPtr clone() const override { return std::make_unique<Log>(name(), level_, msg_); }
  CallbackResult operator()(const Sync&, TriggerRegistrar&) override {
    logger()->log(level_, msg_.c_str());
    return CallbackResult::Ok;
  }
  bool is_significant() const override { return false; }

 protected:
  void to_json(Json& j) const override {
    j = Json{
        {"level", logger::to_string(level_)},
        {"msg", msg_},
    };
  }

 private:
  LogLevel level_;
  std::string msg_;
};

The constructor is only really relevant for the LogFactory, so we’ll ignore that for now.

Then there is the clone() method. This should return another new instance of this action that can do the same thing again. This is important because the user can request that actions be repeated, which results in clones occurring.

Now we come to the operator() method. This is where the real work of the action occurs. It takes two arguments, which you will probably never use. If you need them, you’ll know. Inside the body of this function you can do what the action says it will do.

For the is_significant() method, we tell Cloe whether this action can affect the simulation. If in doubt, answer yes. The Log action is one of the few actions that is not significant.

Finally, we need to implement the to_json() method. This should provide the extra fields in the JSON representation that are needed to recreate this object with the LogFactory. Note that there is no name of this action. That’s because that is added automatically by Cloe, in order to prevent errors from slipping in.

Defining the ActionFactory

Defining the ActionFactory is often more work than the action itself. We need to think about how we want to allow the action to be created. At first glance, the implmentation looks simple:

class LogFactory : public ActionFactory {
 public:
  using ActionType = Log;
  LogFactory() : ActionFactory("log", "log a message with a severity") {}
  TriggerSchema schema() const override;
  ActionPtr make(const Conf& c) const override;
  ActionPtr make(const std::string& s) const override;
};

But then we see that the implementation for three methods is not inline. We’ll have a look at each of these in turn, but first: the using statement. This statement defines what the output type of this action factory is, and is necessary for properly registering the action factory.

The constructor of the action factory simply calls the super-constructor and supplies the default name and the description of the action.

The schema() method is used to define the trigger schema, which among other things, lets Cloe validate input and also document the action.

TriggerSchema LogFactory::schema() const {
  return TriggerSchema{
      this->name(),
      this->description(),
      InlineSchema("level and message to send", "[level:] msg", true),
      Schema{
          {"level", make_prototype<std::string>("logging level to use")},
          {"msg", make_prototype<std::string>("message to send").require()},
      },
  };
}

The make(const Conf&) method takes the object configuration, and reads the variables that are necessary for configuration.

ActionPtr LogFactory::make(const Conf& c) const {
  auto level = logger::into_level(c.get_or<std::string>("level", "info"));
  return std::make_unique<Log>(name(), level, c.get<std::string>("msg"));
}

The make(const std::string&) method takes a string, and tries to parse this into something that it can fill into a Conf and pass on to the method above. This is often the most complex function, but it makes using triggers by hand much much easier. This just takes a string in the format: level: message and packs it into the JSON object structure the simpler make() method needs.

ActionPtr LogFactory::make(const std::string& s) const {
  auto level = spdlog::level::info;
  auto pos = s.find(":");
  std::string msg;
  if (pos != std::string::npos) {
    try {
      level = logger::into_level(s.substr(0, pos));
      if (s[++pos] == ' ') {
        ++pos;
      }
      msg = s.substr(pos);
    } catch (...) {
      msg = s;
    }
  } else {
    msg = s;
  }

  auto c = Conf{Json{
      {"level", logger::to_string(level)},
      {"msg", msg},
  }};
  if (msg.size() == 0) {
    throw TriggerInvalid(c, "cannot log an empty message");
  }
  return make(c);
}