Node API
Every component in a HORUS system -- sensor drivers, controllers, planners, loggers -- is a node. The C++ API provides three styles: inline lambdas (simplest), horus::Node subclasses (most control), and horus::LambdaNode (declarative builder). All three are ticked by the same Scheduler, though their lifecycle hooks differ -- see Lifecycle.
Rust: See
Nodefor the Rust trait. Same lifecycle semantics. Python: Seehorus.Nodefor the Python wrapper.
// simplified
#include <horus/node.hpp>
#include <horus/messages.hpp>
Quick Reference -- Node (Base Class)
| Method | Virtual | Default | Description |
|---|---|---|---|
Node(string_view name) | -- | -- | Constructor with node name |
tick() | Pure | -- | Main execution, called every cycle |
init() | Yes | no-op | One-time initialization at startup |
enter_safe_state() | Yes | no-op | Queued by the watchdog on critical timeout — runs on the node's own thread, so a node hung in tick() may never reach it |
on_shutdown() | Yes | no-op | Cleanup on scheduler exit |
name() | No | -- | Returns the node name |
advertise<T>(topic) | No | -- | Create a Publisher<T>* (call in constructor) |
subscribe<T>(topic) | No | -- | Create a Subscriber<T>* (call in constructor) |
publishers() | No | -- | List of published topic names |
subscriptions() | No | -- | List of subscribed topic names |
Quick Reference -- LambdaNode
| Method | Returns | Description |
|---|---|---|
LambdaNode(string_view name) | LambdaNode | Constructor with node name |
.pub<T>(topic) | LambdaNode& | Declare a publisher (builder) |
.sub<T>(topic) | LambdaNode& | Declare a subscriber (builder) |
.on_tick(fn) | LambdaNode& | Set tick callback: void(LambdaNode&) |
.on_init(fn) | LambdaNode& | Set init callback: void(LambdaNode&) |
.send<T>(topic, msg) | void | Send a message by copy (call from tick) |
.recv<T>(topic) | optional<BorrowedSample<T>> | Receive a message (call from tick) |
.has_msg<T>(topic) | bool | Check if a message is available |
.name() | const string& | Returns the node name |
.publishers() | const vector<string>& | List of published topic names |
.subscriptions() | const vector<string>& | List of subscribed topic names |
Lifecycle
The scheduler manages the node lifecycle in a strict order:
Construction --> Registration --> Init --> Tick Loop --> Shutdown
you scheduler once repeated once
- Construction -- You create the node, declare topics, set initial state
- Registration --
sched.add(node).build()validates and registers the node - Initialization -- On first
spin()ortick_once(), the scheduler callsinit()once - Tick Loop -- Each cycle: feed watchdog, call
tick(), measure timing, check budget/deadline - Shutdown -- On Ctrl+C, SIGTERM, or
sched.stop():on_shutdown()called on each node
Exception -- LambdaNode: its on_init callback runs immediately inside sched.add(node), not at first spin(). LambdaNode also has no safe-state or shutdown hook -- Scheduler::add(LambdaNode&) wires only tick. Use a horus::Node subclass or NodeBuilder::init()/.safe_state()/.shutdown() if you need scheduler-driven lifecycle callbacks.
tick() must not let an exception escape. C++ exceptions cannot unwind through the extern "C" FFI boundary -- doing so is undefined behaviour. Catch everything inside tick(). (The runtime's catch_unwind guard covers Rust panics only; a node that trips it is marked failed and its later ticks are skipped while other nodes keep running.)
Style 1: horus::Node Subclass
The most powerful style. Subclass horus::Node, override lifecycle methods, and create topics in the constructor. This is the C++ equivalent of Rust's impl Node for T.
#include <horus/horus.hpp>
class ObstacleDetector : public horus::Node {
public:
ObstacleDetector() : Node("obstacle_detector") {
scan_sub_ = subscribe<horus::msg::LaserScan>("lidar.scan");
alert_pub_ = advertise<horus::msg::DiagnosticStatus>("safety.alert");
}
void init() override {
printf("[%s] initialized, min_range=%.2f\n", name().c_str(), min_range_);
}
void tick() override {
auto scan = scan_sub_->recv();
if (!scan) return;
for (int i = 0; i < 360; ++i) {
if (scan->get()->ranges[i] > 0.0f && scan->get()->ranges[i] < min_range_) {
auto alert = alert_pub_->loan();
alert->level = 1; // WARN
alert_pub_->publish(std::move(alert));
return;
}
}
}
void enter_safe_state() override {
// Called by watchdog on timeout -- publish emergency alert
auto alert = alert_pub_->loan();
alert->level = 3; // FATAL
alert_pub_->publish(std::move(alert));
}
void on_shutdown() override {
printf("[%s] shutting down\n", name().c_str());
}
private:
horus::Subscriber<horus::msg::LaserScan>* scan_sub_;
horus::Publisher<horus::msg::DiagnosticStatus>* alert_pub_;
float min_range_ = 0.3f;
};
Register with the scheduler:
ObstacleDetector detector;
sched.add(detector)
.rate(50_hz)
.budget(5_ms)
.on_miss(horus::Miss::Skip)
.build();
Topic Creation in Constructor
Call advertise<T>() and subscribe<T>() in the constructor. They return raw pointers that the Node base class owns internally (via shared_ptr). The pointers are valid for the lifetime of the node:
class MyNode : public horus::Node {
public:
MyNode() : Node("my_node") {
// Create topics in constructor -- Node base class owns them
pub_ = advertise<horus::msg::CmdVel>("cmd");
sub_ = subscribe<horus::msg::Imu>("imu");
}
void tick() override {
auto imu = sub_->recv();
if (!imu) return;
pub_->send(horus::msg::CmdVel{0, 0.5f, 0.0f});
}
private:
horus::Publisher<horus::msg::CmdVel>* pub_;
horus::Subscriber<horus::msg::Imu>* sub_;
};
Style 2: Lambda Node (Inline)
For simple nodes that do not need their own class. Pass a lambda to NodeBuilder::tick():
auto cmd_pub = sched.advertise<horus::msg::CmdVel>("motor.cmd");
auto scan_sub = sched.subscribe<horus::msg::LaserScan>("lidar.scan");
sched.add("reactive_driver")
.rate(50_hz)
.budget(2_ms)
.init([&] {
printf("Reactive driver ready\n");
})
.tick([&] {
auto scan = scan_sub.recv();
if (!scan) return;
float front = scan->get()->ranges[0];
float speed = front > 1.0f ? 0.5f : 0.0f;
cmd_pub.send(horus::msg::CmdVel{0, speed, 0.0f});
})
.safe_state([&] {
cmd_pub.send(horus::msg::CmdVel{0, 0.0f, 0.0f});
})
.build();
Lambda nodes capture topics from the enclosing scope by reference, so the topic objects must outlive sched.spin(). sched.advertise()/sched.subscribe() return owning RAII handles by value -- the scheduler does not keep them alive. Declare them in the same scope as the Scheduler (or at a longer-lived scope); if the handle is destroyed the captured reference dangles.
Style 3: LambdaNode (Declarative Builder)
LambdaNode combines topic declaration and callbacks in a single builder chain. It is the C++ equivalent of Python's horus.Node(name=..., tick=..., pubs=[...], subs=[...]):
auto controller = horus::LambdaNode("controller")
.sub<horus::msg::LaserScan>("lidar.scan")
.sub<horus::msg::Imu>("imu.data")
.pub<horus::msg::CmdVel>("motor.cmd")
.on_init([](horus::LambdaNode& self) {
printf("[%s] ready\n", self.name().c_str());
})
.on_tick([](horus::LambdaNode& self) {
auto scan = self.recv<horus::msg::LaserScan>("lidar.scan");
if (!scan) return;
float speed = scan->get()->ranges[0] > 1.0f ? 0.3f : 0.0f;
self.send("motor.cmd", horus::msg::CmdVel{0, speed, 0.0f});
});
sched.add(controller)
.rate(50_hz)
.budget(5_ms)
.build();
LambdaNode Runtime API
Inside the on_tick callback, use self.send(), self.recv(), and self.has_msg():
.on_tick([](horus::LambdaNode& self) {
// Check before consuming
if (self.has_msg<horus::msg::Imu>("imu.data")) {
auto imu = self.recv<horus::msg::Imu>("imu.data");
// process...
}
// Send by copy (simple, slight overhead for large messages)
self.send("motor.cmd", horus::msg::CmdVel{0, 0.5f, 0.1f});
})
Introspection
Both Node and LambdaNode expose their topic lists for monitoring and debugging:
// Node subclass
ObstacleDetector detector;
for (const auto& topic : detector.publishers()) {
printf("publishes: %s\n", topic.c_str());
}
for (const auto& topic : detector.subscriptions()) {
printf("subscribes: %s\n", topic.c_str());
}
// LambdaNode
auto node = horus::LambdaNode("nav")
.pub<horus::msg::CmdVel>("cmd")
.sub<horus::msg::Odometry>("odom");
// node.publishers() -> ["cmd"]
// node.subscriptions() -> ["odom"]
These lists are local bookkeeping for your own monitoring and debugging code -- the scheduler does not read them. The topic graph shown by horus monitor and recorded by the BlackBox flight recorder comes from the runtime topic registry, which each node populates lazily on its first send/recv of a topic.
Exception Safety and Error Handling
C++ exceptions cannot cross the FFI boundary. Your tick callback is invoked through an extern "C" function pointer; if an exception unwinds out of it, behaviour is undefined (the process typically aborts). You must catch every exception inside tick().
The std::panic::catch_unwind in the runtime's node wrapper protects only against Rust panics originating below the boundary -- it does not and cannot catch a C++ exception. When a Rust panic is caught:
- The panic is logged and the node is marked as failed
- Subsequent ticks on that node become no-ops
- Other nodes continue running normally
So the isolation you want for your own code has to be written by you:
void tick() override {
auto scan = scan_sub_->recv();
if (!scan) return;
try {
process_scan(*scan->get()); // const LaserScan&
} catch (const std::exception& e) {
printf("[%s] scan processing failed: %s\n", name().c_str(), e.what());
}
}
This is a hard requirement, not a style preference: handle every error locally inside tick(), and never let an exception propagate out of a node callback.
Common Patterns
Stateful Node with Configuration
class PidController : public horus::Node {
public:
PidController(float kp, float ki, float kd)
: Node("pid_controller"), kp_(kp), ki_(ki), kd_(kd)
{
error_sub_ = subscribe<horus::msg::Pose2D>("nav.error");
cmd_pub_ = advertise<horus::msg::CmdVel>("motor.cmd");
}
void tick() override {
auto error = error_sub_->recv();
if (!error) return;
float e = static_cast<float>(error->get()->x);
integral_ += e;
float derivative = e - last_error_;
last_error_ = e;
float output = kp_ * e + ki_ * integral_ + kd_ * derivative;
auto cmd = cmd_pub_->loan();
cmd->linear = output;
cmd->angular = 0.0f;
cmd_pub_->publish(std::move(cmd));
}
void enter_safe_state() override {
// Zero output on safety event
cmd_pub_->send(horus::msg::CmdVel{0, 0.0f, 0.0f});
integral_ = 0.0f;
}
private:
horus::Subscriber<horus::msg::Pose2D>* error_sub_;
horus::Publisher<horus::msg::CmdVel>* cmd_pub_;
float kp_, ki_, kd_;
float integral_ = 0.0f;
float last_error_ = 0.0f;
};
// Usage:
PidController pid(1.0f, 0.01f, 0.1f);
sched.add(pid).rate(100_hz).budget(500_us).build();
Multiple Nodes Sharing Topics
Nodes communicate through shared topics. Create topics on the scheduler, then reference them from multiple nodes:
auto odom_pub = sched.advertise<horus::msg::Odometry>("odom");
auto odom_sub = sched.subscribe<horus::msg::Odometry>("odom");
auto cmd_pub = sched.advertise<horus::msg::CmdVel>("cmd");
sched.add("localizer")
.rate(100_hz)
.tick([&] {
auto odom = odom_pub.loan();
odom->pose.x = compute_x();
odom->pose.y = compute_y();
odom_pub.publish(std::move(odom));
})
.build();
sched.add("controller")
.rate(50_hz)
.tick([&] {
auto odom = odom_sub.recv();
if (!odom) return;
cmd_pub.send(horus::msg::CmdVel{0, 0.3f, 0.0f});
})
.build();
Choosing a Node Style
| Style | Best For | Topic Creation | State |
|---|---|---|---|
| Lambda (inline) | Simple nodes, prototyping | On scheduler | Captured variables |
horus::Node subclass | Complex nodes, reusable components | In constructor | Member variables |
horus::LambdaNode | Declarative, Python-like ergonomics | Builder chain | Via callback closure |
See Also
- Scheduler API -- Node registration and execution
- Publisher and Subscriber API -- Zero-copy messaging details
- Services and Actions API -- RPC and long-running tasks
- C++ Real-Time Guide -- Budget, deadline, miss policies
- C++ API Overview -- All classes at a glance