Tutorial.md 19 KB

Tutorial

blink with LED

This is platform-neural tutorial, although hardware implementation refers to arduino. The full code can be found at examples/atmega328p/blink-led.cpp. In this example the blinking with LED is performed using rotor-light.

Let's start from the inclusion of headers:

#include <rotor-light.hpp>

All further interactions with the framework is done via rl namespace

namespace rl = rotor_light;

In this example, there is only single message - BlinkCommand. It is good practice to define messages in it's own namespace, as it is much more convenient to deal with messages later (handle and send).

namespace message {

struct BlinkCommand : rl::Message {            // (1)
  static constexpr auto type_id = __LINE__;    // (2)
  using rl::Message::Message;                  // (3)
} // namespace message

The BlinkCommand message have to be derived from rotor_light Message (1), and as the BlinkCommand has no payload, it just reuses its parent constructor (using rl::Message::Message, (3)).

As the rotor_light does not use RTTI it should somehow identify message types at runtime, the type_id (2) field helps with that. rotor_light does not care how you, as the user, define your message type, the only requirement is the uniqueness of them. The easiest way to achieve that is just have line (2) in every message type definition, and group all used messages in a single header file.

Let's move to the blinker actor code; as it is rather small, it is shown entirely:

struct Blinker : rl::Actor<2> {  // (5)
using Parent = Actor<2>;         // (6)

void initialize() override {              // (7)
  subscribe(&Blinker::on_blink_command);  // (8)
  Parent::initialize();                   // (9)
}


  void advance_start() override {  // (10)
    Parent::advance_start();       // (11)
    blink();                       // (12)
  }

  void blink() {
    toggle_led();                                                         // (13)
    add_event<rl::ctx::thread>(delay, [](void *data) {                    // (14)
        auto self = static_cast<Blinker *>(data);                         // (15)
        self->send<rl::ctx::thread, message::BlinkCommand>(0, self->id);  // (16)
    }, this);
  }

void on_blink_command(message::BlinkCommand &msg) {  // (17)
  blink();
}

rl::Duration delay;  // (18)
};

First, your own actor have to inherit from rl::Actor (5). The magic number 2 is used for preallocation of space for actor's message handlers: one handler from the base class defined by the framework (might be changed in the future), and the other one use the user-provided on_blink_command. The alias (6) makes it more convenient to refer the base class in the future.

To react on the BlinkCommand message, the actor should subscribe to it (8). The proper place to do that is to override initialize() method (7) of the parent class. Of course, parent class must be initialized too, that's why don't forget to invoke its initialization (9).

When actor is ready, its advance_start() (10) is invoked; as usually corresponding parent method should be called (11) and the initial blink (12) is performed. In the implementation of blink() the platform-dependent LED toggle is done at (13), and then delayed the same method invocation is scheduled (14-16). In (14) the capture-less lambda is scheduled to be invoked with this as the parameter when delay (18) amount of time passes. The lambda must be capture-less. After casting back void* to the actor class (15), it sends self a BlinkCommand message (16) using zero-priority queue. The id parameter is the destination actor address; which is the Blinker (self) address here. The destination address can also be multiple actors (i.e. it is mask).

The rl::ctx::thread in (13) and (16) gives hints, wether interrupts should (ctx::thread) or not (ctx::interrupt) be masked during methods invokations. The ctx::thread masks and then unmasks all interrupts, so, generally it should not be called from interrupt context. The last one (ctx::interrupt) does not touches CPU interrupt enablence flag.

Each time the BlinkCommand is received (17), the blink() method is invoked, and the procedure above repeats again.

Next, the application-wide compile-time configuration should be made:

using Storage = rl::traits::MessageStorage<rl::message::ChangeState,    // (18)
                                           rl::message::ChangeStateAck,
                                           message::BlinkCommand>;
using Queue = rl::Queue<Storage, 5>; /* upto 5 messages in 1 queue */   // (19)
using Planner = rl::Planner<2>;      /* upto 2 time events */           // (20)
using Supervisor = rl::Supervisor<                                      // (21)
                            rl::SupervisorBase::min_handlers_amount,
                            Blinker>;

rotor-light uses queues to store and process messages. There are no dynamic allocations, so, all sizes have to be known at compile-time, including the sizes of all used messages. The Storage helper (18) is used to determine the maximum allowed space size per message: here all messages in the application should be enumerated, i.e. messages from the framework and user-defined.

Next, the application queue (or master queue) should be defined (19). It uses Storage and the magic number 5, which pre-allocates space for queue with zero-priority for 5 messages. All rotor-light messages are put into that queue by default, user-defined messages might use different queues. Queues with higher priorities are processed earlier than queues with lower priorities. So, to define master queue with two subqueues (priorities 0 and 1) with sizes enough to store 15 and 10 messages respectively, the following code should be used

using Queue = rl::Queue<Storage, 15, 10>;

What is the recommended queue sizes? Well, it is completely application defined, but for rotor-light messages there should be enough space to store N * 2 messages, where N is the total number of actors (including supervisors).

What happens, if a queue is full? The next send message is simply discarded, you'll be notified about that as the send() method returns false. If the discarded message is rotor-light message, then the framework behavior is undefined (more strictly speaking, overridable void rotor_light::on_queue_full() method is invoked). So, either do not overload default queue (i.e. with zero-priority), or use different queues for user-messages. NB: there is a spike of rotor-light messages only during (re)starting supervisor; when everything is operational there is almost no framework messages.

The Planner (20) is used to schedule future events, like it is shown at add_event (13). The number in angle braces (20) defines planner capacity at compile-time. When event time arrives, it is removed from the planner. Please note, that rotor-light does not schedules any events into the planner, so its capacity and usage is completely controlled by the user code.

How the framework interacts with the planner? If the root supervisor is used in poll mode, then, when there are no more messages left, the current time is requested, and all timed out event handlers are executed. Then the special message refresh-time is send to the supervisor and the procedure repeats endlessly. Indirectly, this is similar to the code like:

while (true) {
  while (has_messages()) {
    auto& message = get_front_message();
    process(message);
    pop_front_message();
  }
  // (22)
  auto now = get_now();
  while (has_expired_events(now)) {
    auto& event = get_next_expired_event(now);
    fire_event(event);
    pop_event(event);
  }
    

If the await mode is used, then user code should implement the whole while loop above and the event awaiting (22) code. This makes it possible to enter into power-save mode and wake up on external timer or interrupt. See *-await.cpp examples.

In the line (21) the used supervisor is defined: the amount of handlers is specified (the same as for actor at (5), as each supervisor is an actor) and all its child actors (including other supervisors) are enumerated. The only Blinker child actor is specified in the current example.

Let's move forward. In the following code rotor-light global variables are allocated:

Queue queue;
Planner planner;
rl::Context context{&queue, &planner, &get_now};
Supervisor sup;

All of them can be allocated on the stack, but for the supervisor it is, probably, the bad idea, because most likely you'll need to access actors from some other global functions, i.e. ISR, and that can be done only via global supervisor instance. That might be not obvious, that the supervisor above allocates space for all its child actors, i.e. supervisor contains and owns child actors.

The get_now() function pointer refers to a function with the following signature:

using TimePoint = int64_t;
using NowFunction = TimePoint (*)();

It is a link to the underlying hardware or system. The function should return current time; the meaning of "current time" (TimePoint) is completely user specific (i.e. it can point to seconds, milliseconds, microseconds etc.). The only requirement to the function, that it should be monotonic, i.e. do not decrease nor wrap each next TimePoint compared to the previous one. The implementation of the function is completely platform- or hardware-specific. For the arduino example it returns the number of microseconds passed since board boot.

The final piece of the current example is:

int main(int, char **) {
  app_hw_init();

  /* setup */
  sup.bind(context);                        // (23)
  auto blinker = sup.get_child<0>();        // (24)
  blinker->delay = rl::Duration{250000};    // (25)
  /* let it polls timer */
  sup.start(true);                          // (26)

  /* main cycle */
  sup.process();                            // (27)
  return 0;
}

The context binding (23) let the supervisor know pre-allocated queues, planner and the get_now() function. During context binding all actors get their unique ids, which can be used. After that individual actors can be accessed (24) with zero runtime overhead (as it uses std::get under the hood) and additional actors setup can be performed here (26), i.e. delay 1/4 of seconds between LED toggle.

Please note, that setup phase (23-26) need to be performed only once despite of possible multiple actors restarts, i.e. actor identities are preserved during object lifetimes.

Then, the whole machinery receive initial impulse (26). The true value here means usage of the "poll mode" (see above), i.e. endlessly send self a refresh-timer message, get time, and fire the timed-out events.

In the line (27) supervisor actually starts processing messages, and probably never exits as soon as everything is going as expected. In the case of failure escalation, i.e. as it tried all possible restarts of actors to recover the failure, there is nothing left to do than exit and the whole board restart.

ping-pong messaging, poll timer

In this example how to do messaging with rotor-light is demonstrated: ping message is sent from pinger actor to ponger actor. The ponger actor will reply back with pong message, then after some delay pinger actor repeats the same procedure. The full code can be found at examples/atmega328p/ping-pong-poll.cpp.

First of all used messages should be defined:

namespace message {
struct Ping : rl::Message {
  using Message::Message;
  static constexpr auto type_id = __LINE__;
};

struct Pong : rl::Message {
  using Message::Message;
  static constexpr auto type_id = __LINE__;
};
} // namespace message

The ping and pong messages are content-less, why there is need of them for all? Because there is need to demonstrate how to send and receive messages and distinguish them by type.

The Pinger actor code is:

struct Pinger : rl::Actor<2> {
  using Parent = Actor<2>;

  void initialize() override {
    subscribe(&Pinger::on_pong); // (28)
    Parent::initialize();
  }

  void advance_start() override {  // (29)
    Parent::advance_start();       // (30)
    ping();                        // (31)
  }

  void ping() {                                          // (32)
    Board::toggle_led();
    send<rl::ctx::thread, message::Ping>(0, ponger_id);  // (33)
  }

  void on_pong(message::Pong &) {                           // (34)
    add_event<rl::ctx::thread>(500000, [](void *data) {     // (35)
      static_cast<Pinger *>(data)->ping();
    }, this);
  }

  rl::ActorId ponger_id;   // (36)
};

As usually, the initialize() should be overridden to subscribe on pong messages (28). The pinger actor plays an active role, i.e. it sends initial ping message. This is performed in the overridden advance_start() method (29), which is invoked as soon as the actor is ready: the default implementation machinery is invoked (30), and for convenience ping() (31) method is called. The ping() (32) method implementation is simple: after LED toggle, it sends the ping message (33).

The send method parameters are: the message type (message::Ping) template parameter, message priority (aka destination queue) - 0, and the destination actor(s) - ponger actor id, defined at (36). If there are additional message params, specified in the message type constructor, they should go here.

As soon as pong reply is received (34), the ping procedure with LED toggle is rescheduled after 500000 microseconds (i.e. 0.5 second) at (35).

The ponger actor code is rather trivial:

struct Ponger : rl::Actor<2> {
  using Parent = Actor<2>;

  void initialize() override {
    subscribe(&Ponger::on_ping);
    Parent::initialize();
  }
  void on_ping(message::Ping &) {
    send<rl::ctx::thread, message::Pong>(0, pinger_id);  // (37)
  }
  rl::ActorId pinger_id;   // (38)
};

as soon ping message is received, it replies back (37) with pong message. Please note, while conceptually it "replies back", technically it just sends a new pong message to pinger address, defined at (38). It is important, because there is no request-response pattern, i.e. it "knows" whom to send back the "reply".

Lets move forward.

using Supervisor = rl::Supervisor<
    rl::SupervisorBase::min_handlers_amount,
    Pinger,
    Ponger>;
    
using Storage = rl::traits::MessageStorage<rl::message::ChangeState,
                                           rl::message::ChangeStateAck,
                                           message::Ping,
                                           message::Pong>;

The standard supervisor is used; it owns pinger and ponger actors. The Storage allocates enough space for Ping and Pong messages.

int main(int, char **) {

  app_hw_init();

  /* setup */
  sup.bind(context);
  auto pinger = sup.get_child<0>();      // (39)
  auto ponger = sup.get_child<1>();      // (40)
  pinger->ponger_id = ponger->get_id();  // (41)
  ponger->pinger_id = pinger->get_id();  // (42)
  /* let it polls timer */
  sup.start(true);

  /* main cycle */
  sup.process();   // (43)
  return 0;
}

The most interesting part is the setup-phase (39-40). pinger and ponger actors should know each others addresses, and the addresses are available only after context binding.

When everything is ready, it enters into main loop (43). In the loop it either delivers rotor-light messages or waits, until the next event time occurs. It uses busy waiting by actively polling (querying) timer, whether the next event time happend. As usually for busy waiting, it consumes 100% CPU time, which is common strategy for embedded/real-time applications.

It is possible however to do something else, instead of endless timer polling, e.g. do CPU sleep (and consume less current and do less CO2 emission). See the next section.

ping-pong messaging, await the next event in power-save mode

The code for this example is located at examples/atmega328p/ping-pong-await.cpp. From user point of view the example does the same as the previous one: it sends ping message, receives pong message, blinks with LED and after some delay the procedure repeats.

However, the notable difference is in the delay: in the previous example it endlessly polls the timer whether the time arrives for the next event, burning CPU cycles, in the current example it does energy-efficient sleeping while there is nothing to do (i.e. no messages) between events.

As the main logic is the same, the actor-related code is also the same; only main() function differs.

int main(int, char **) {
  app_hw_init();

  /* allocate */
  Queue queue;
  Planner planner;
  rl::Context context{&queue, &planner, &get_now};
  Supervisor sup;

  /* setup */
  sup.bind(context);
  auto pinger = sup.get_child<0>();
  auto ponger = sup.get_child<1>();
  pinger->ponger_id = ponger->get_id();
  ponger->pinger_id = pinger->get_id();
  /* let it polls timer */
  sup.start(false);  // (44)

  /* main cycle */
  while (true) {                                   // (45)
    sup.process();                                 // (46)
    auto next_event_time = planner.next_event();   // (47)
    if (next_event_time) {                         // (48)
      Board::sleep(next_event_time);               // (49)
    }
  }
  return 0;
}

Firstly, the timer poll via recursively sending self refresh-timer message should be disabled (44). Then the infinite loop (45) should be started, as the supervisor exits, when it has no more messages to process (46). The first nearby event should be extracted from planner (47), if any (48), and then perform platform-specific sleep until the event. The event handler will be actually processed upon the next loop iteration in (46).

integration

Lets suppose, that there is a need to read a custom port on idle or perform some I/O activity, maybe indirectly via calling some other library on each iteration. How can that be done?

One of the ways to accomplish that is shown above: disable timer poll and do the needed activity instead of entering into powersave mode, or in addition to the powersave mode.

Then second way is to have a custom supervisor with on_refhesh_timer method overridden, like that:

struct MySupervisor : rl::Supervisor<3, MyActor1, MyActor2, ...> {
using Parent = rl::Supervisor<3, MyActor1, MyActor2, ...>; 

void on_refhesh_timer(rl::message::RefreshTime &message) override {
  Parent::on_refhesh_timer(message);
  third_party_library_poll();
}

};