Skip to content

Tutorial 2: Two objects talking

In the previous tutorial you built a single object and inspected it in the term. Now you will build two objects that find each other at runtime and communicate: one calls a method on the other, and handles the result asynchronously.

What you'll learn:

  • How objects discover each other using subscriptions and interests
  • How to call a method on a remote object and handle the result
  • Why method calls in Sen are always asynchronous, and how to work with that
  • The Subscription<T> pattern and why it must be kept alive

Prerequisites: Completed Tutorial 1, comfortable with STL → C++ → YAML flow, and a source build with the examples enabled. A release install can read this tutorial, but not run it.


What we're building

A Calculator class with add and divide methods, and a Client class that finds a calculator on the bus and calls its methods. Both run in the same component, on the same bus.


The core pattern: discovery and subscription

In Sen, objects do not connect to each other by address. There is no "connect to host:port" or "subscribe to a topic name". Instead:

  1. Objects publish themselves to a bus when they are registered.
  2. Other objects declare an interest (a filter) that describes what they are looking for (by class, name, or both).
  3. Sen gives them a Subscription<T>, a live list that is automatically populated with matching objects as they appear and cleaned up as they disappear.

This is reactive. You don't poll for objects. The list updates itself during the drain stage of each cycle. Your code just reads from the list whenever it needs to.

Subscriptions are live

If a new Calculator appears on the bus mid-execution (because a new component started), it will appear in your subscription list on the next drain. If it disappears (component stopped), it will be removed. You never hold a dangling pointer to a dead object.


Step 1: Define the interface

This tutorial walks through the calculators example, which lives in Sen's own source tree. Every listing below is the real file, so what you read here is what compiles.

You need the examples built. They are off by default, so configure with -o "sen/*:with_examples=True"; see Building Sen from source. If you installed Sen from a release you do not have them, and you can still read this tutorial straight through.

The interface lives in examples/packages/calculators/stl/calculator.stl:

package calculators;

// A simple calculator that can add and divide numbers.
class Calculator
{
  // The model of the calculator
  var model : string [static];

  // The last result, or what is currently presented at the screen.
  var current : f32;

  // Returns "a + b" and sets the last result to it.
  //
  // @param a left-hand operator.
  // @param b right-hand operator.
  fn add(a: f32, b: f32) -> f32;

  // Returns "a + R" where R was the last result.
  //
  // @param a left-hand operator.
  fn addWithCurrent(a: f32) -> f32;

  // Returns "a / b" and sets the last result to it.
  //
  // @param a left-hand operator.
  // @param b right-hand operator. If 0, the divisionByZero event is emitted and the call fails.
  fn divide(a: f32, b: f32) -> f32;

  // Returns "a / R" where R is the last result.
  // If R is 0, the divisionByZero event is emitted and the call fails.
  //
  // @param a left-hand operator.
  fn divideByCurrent(a: f32) -> f32;

  // Emitted when there's an attempt to divide by zero.
  event divisionByZero();
}

class Client
{
  // Where to find the calculator objects.
  var calcBus : string [static];

  // Uses any available calculator.
  fn useCalculator();
}

current stores the running total. It is read-only to other objects, so only the calculator itself can change it. calcBus is the bus name where the client looks for calculators, and it is set in the YAML configuration.

The interface carries a little more than this tutorial uses: divideByCurrent, and a model property that each implementation must be given in the configuration.


Step 2: Implement the Calculator

An implementation inherits from the generated CalculatorBase and fills in the method bodies. The example ships two, CasioCalculator and FaultyCalculator; here is the first.

// === casio_calculator.cpp ============================================================================================
//                                               Sen Infrastructure
//                   Released under the Apache License v2.0 (SPDX-License-Identifier Apache-2.0).
//                                    See the LICENSE.txt file for more information.
//                   © Airbus SAS, Airbus Helicopters, and Airbus Defence and Space SAU/GmbH/SAS.
// =====================================================================================================================

// generated code
#include "stl/calculator.stl.h"

// sen
#include "sen/core/base/compiler_macros.h"
#include "sen/core/base/numbers.h"
#include "sen/core/meta/class_type.h"

// std
#include <stdexcept>

namespace calculators
{

/// Our implementation of a calculator.
/// We inherit from the CalculatorBase class (generated by Sen).
class CasioCalculator: public CalculatorBase
{
public:
  SEN_NOCOPY_NOMOVE(CasioCalculator)

public:
  using CalculatorBase::CalculatorBase;
  ~CasioCalculator() override = default;

protected:
  /// This is the implementation of the "add" method.
  /// We need to provide an implementation to be able to instantiate this class.
  float32_t addImpl(float32_t a, float32_t b) override
  {
    const auto result = a + b;
    setNextCurrent(result);  // Save the result in our "current" property. (1)!
    return result;
  }

  /// Implementation of addWithCurrent.
  float32_t addWithCurrentImpl(float32_t a) override
  {
    // Just call add with what we have in our "current" property.
    return addImpl(a, getCurrent());
  }

  /// Implementation of divide.
  float32_t divideImpl(float32_t a, float32_t b) override
  {
    if (b == 0.0f)
    {
      // The event lets subscribers observe it; the exception is how the caller finds out.
      divisionByZero();
      throw std::runtime_error("division by zero");  // (2)!
    }

    const float32_t result = a / b;

    // Save the result in our "current" property.
    setNextCurrent(result);
    return result;
  }

  /// Implementation of divideWithCurrent.
  float32_t divideByCurrentImpl(float32_t a) override { return divideImpl(a, getCurrent()); }
};

// With this macro we let Sen know that this package provides our class.
// You need to define this only once in a cpp file.
SEN_EXPORT_CLASS(CasioCalculator)

}  // namespace calculators
  1. Stage the new value. It won't be visible until commit, but the return value is delivered to the caller immediately when their next drain processes the method result.
  2. Dividing by zero does both things Sen offers. It emits an event, which suits an error that subscribers may want to observe, and it throws, which is how the caller finds out: Sen catches the exception, wraps it in a MethodResult and delivers it to the caller's callback. Returning a value the caller cannot tell apart from a real answer is the one option it does not take.

Step 3: Implement the Client

This is where the interesting part happens. The Client needs to:

  1. Find Calculator objects on a bus
  2. Call methods on them and handle results
// === client.cpp ======================================================================================================
//                                               Sen Infrastructure
//                   Released under the Apache License v2.0 (SPDX-License-Identifier Apache-2.0).
//                                    See the LICENSE.txt file for more information.
//                   © Airbus SAS, Airbus Helicopters, and Airbus Defence and Space SAU/GmbH/SAS.
// =====================================================================================================================

// generated code
#include "stl/calculator.stl.h"

// sen
#include "sen/core/base/compiler_macros.h"
#include "sen/core/base/numbers.h"
#include "sen/core/meta/class_type.h"
#include "sen/core/obj/callback.h"
#include "sen/core/obj/subscription.h"
#include "sen/kernel/component_api.h"

// std
#include <iostream>
#include <memory>
#include <string>

namespace calculators
{

class ClientImpl: public ClientBase
{
public:
  SEN_NOCOPY_NOMOVE(ClientImpl)
  using ClientBase::ClientBase;
  ~ClientImpl() override = default;

public:
  void registered(sen::kernel::RegistrationApi& api) override  // (1)!
  {
    calculators_ = api.selectAllFrom<CalculatorInterface>(getCalcBus());  // (3)!
  }

protected:
  void useCalculatorImpl() override
  {
    if (const auto& list = calculators_->list.getObjects(); !list.empty())  // (4)!
    {
      auto handleResult = [](sen::MethodResult<float32_t> result)  // (6)!
      {
        if (result.isOk())
        {
          std::cout << "add(3, 4) = " << std::to_string(result.getValue()) << std::endl;
        }
        else
        {
          std::cout << "add failed" << std::endl;
        }
      };

      // Call add(3, 4) and handle the result
      list.front()->add(3.0, 4.0, {this, handleResult});  // (5)!
    }
  }

private:
  std::shared_ptr<sen::Subscription<CalculatorInterface>> calculators_;  // (2)!
};

SEN_EXPORT_CLASS(ClientImpl)

}  // namespace calculators
  1. registered() is called once after the object is added to the bus. This is the right place to set up subscriptions: the object is live and the API is available.
  2. This must be a member variable, not a local. Subscription<T> must stay alive as long as you want to receive updates. If it goes out of scope, the list is destroyed and callbacks stop.
  3. api.selectAllFrom<CalculatorInterface>(getCalcBus()) matches all objects that implement CalculatorInterface. A class in STL generates a <Class>Base to inherit from and a <Class>Interface for everyone else to hold, so class Calculator gives you CalculatorBase and CalculatorInterface. Subscribe to the generated interface, not to CasioCalculator, because a calculator in another process has no implementation on your side to name.
  4. Always guard against an empty list. Objects can disappear between cycles.
  5. list.front() returns a reference to the first matching object. The reference is valid for this entire update cycle (it was frozen during drain).
  6. The callback receives a MethodResult, holding either the value or an exception. float32_t is what f32 means in the generated header, so the types match exactly. It fires during the drain stage of a future cycle, once the result has been committed by the calculator.

Step 4: The async call timeline

This is the part worth getting straight:

Cycle N — Client update:
    calc.add(3.0, 4.0, callback)
    ↑ call is QUEUED, not executed yet. Returns immediately.

Cycle N — Client commit:
    the queued call is transmitted to Calculator

Cycle N+1 — Calculator drain:
    addImpl(3.0, 4.0) executes
    result is staged

Cycle N+1 — Calculator commit:
    result is transmitted back to Client

Cycle N+2 — Client drain:
    callback fires with result = 7.0

Don't expect immediate results

If you call a method and check for results on the very same cycle, you will find nothing. The result arrives 1–2 cycles later depending on scheduling. This is by design. It is what makes Sen thread-safe without locks.

Callbacks can capture this

Your callback lambda can capture this to store the result or trigger further actions:

calc.add(a, b, {this, [this](auto r)
{
    if (r.isOk())
    {
        lastResult_ = r.getValue();
    }
}});
Sen ensures the callback is only invoked while your object is still alive: if your object is destroyed before the result arrives, the callback is not invoked.


Step 5: Configure and run

# $schema: ../base/schema.json
include:
  # bring the term so that we can interact with our objects
  - ../base/term.yaml

build:
  - name: myComponent
    freqHz: 5
    imports: [calculators]
    group: 3
    objects:
      - class: calculators.CasioCalculator
        name: calc1
        bus: my.tutorial
        model: superCalc
      - class: calculators.ClientImpl
        name: client1
        calcBus:  my.tutorial  # (1)!
        bus:  my.tutorial
  1. Tells the client which bus to search for calculators. Must match the bus where calc1 is published.

With the examples built, run this from the examples/ directory of your Sen checkout:

sen run config/1_calculators/4_calculators_client.yaml

The term opens on my.tutorial, because the term configuration pulled in by include: pre-opens that bus. Ask the client to run, and the call finishes with a tick:

✓ my.tutorial.client1.useCalculator

The client writes its own result with std::cout, and the term draws on standard output, so that line is not something you can rely on seeing here. Read the calculator's property instead, which is where the result landed:

❯ my.tutorial.calc1.getCurrent
  current: 7

You can also drive the calculator yourself. current only changes once the call has been through a cycle:

✓ my.tutorial.calc1.add 10, 5
  15
❯ my.tutorial.calc1.getCurrent
  current: 15

inspect prints the whole interface, which is the quickest way to see what the generator made of your STL:

❯ inspect my.tutorial.calc1
calculators.Calculator
A simple calculator that can add and divide numbers.
Properties
├ model : string  The model of the calculator
└ current : f32  The last result, or what is currently presented at the screen.
Methods
├ add(a: f32, b: f32) → f32  Returns "a + b" and sets the last result to it.
├ addWithCurrent(a: f32) → f32  Returns "a + R" where R was the last result.
├ divide(a: f32, b: f32) → f32  Returns "a / b" and sets the last result to it.
└ divideByCurrent(a: f32) → f32  Returns "a / R" where R is the last result. If R is 0, the division
Events
└ divisionByZero()  Emitted when there's an attempt to divide by zero.

The line under the class name is the comment above class Calculator in the STL, so a comment there pays off twice.


Common mistakes

Mistake Symptom Fix
Subscription as a local variable List is always empty Make it a member variable
Reading result on the same cycle as the call Result is never seen Handle it in the callback
Objects on different buses Client list is always empty Ensure calcBus matches the bus in YAML
Missing SEN_EXPORT_CLASS Class not found by kernel Add the macro at the bottom of the .cpp

What just happened?

  • ClientImpl::registered() ran once when the client joined the bus. It set up a subscription pointing at my.tutorial with a filter that matches any Calculator.
  • On the first drain after both objects were registered, Sen populated calculators_.list with a reference to calc1.
  • When someone calls useCalculator on the client, useCalculatorImpl() reads the frozen list, picks the first calculator, and posts an async add call.
  • The call travels through the kernel's queue, executes in CasioCalculator::addImpl one or two cycles later, and the result comes back via the callback.

Next steps