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:
- Objects publish themselves to a bus when they are registered.
- Other objects declare an interest (a filter) that describes what they are looking for (by class, name, or both).
- 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
- 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.
- 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
MethodResultand 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:
- Find
Calculatorobjects on a bus - 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
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.- 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. api.selectAllFrom<CalculatorInterface>(getCalcBus())matches all objects that implementCalculatorInterface. A class in STL generates a<Class>Baseto inherit from and a<Class>Interfacefor everyone else to hold, soclass Calculatorgives youCalculatorBaseandCalculatorInterface. Subscribe to the generated interface, not toCasioCalculator, because a calculator in another process has no implementation on your side to name.- Always guard against an empty list. Objects can disappear between cycles.
list.front()returns a reference to the first matching object. The reference is valid for this entire update cycle (it was frozen during drain).- The callback receives a
MethodResult, holding either the value or an exception.float32_tis whatf32means 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:
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
- Tells the client which bus to search for calculators. Must match the bus where
calc1is published.
With the examples built, run this from the examples/ directory of your Sen checkout:
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:
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:
You can also drive the calculator yourself. current only changes once the call has been through a
cycle:
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 atmy.tutorialwith a filter that matches anyCalculator.- On the first drain after both objects were registered, Sen populated
calculators_.listwith a reference tocalc1. - When someone calls
useCalculatoron the client,useCalculatorImpl()reads the frozen list, picks the first calculator, and posts an asyncaddcall. - The call travels through the kernel's queue, executes in
CasioCalculator::addImplone or two cycles later, and the result comes back via the callback.
Next steps¶
- Tutorial 3: Two processes talking: the same calculator, with the caller in a separate process.
- Understanding Sen: a mental model: a deeper look at why the async model works the way it does.
- Working with objects: full reference for subscriptions, callbacks, events, and property flags.
- Interests and Filtering: how to write more precise interest queries to match only specific objects.