The node! Macro
The problem: Writing HORUS nodes manually requires lots of boilerplate code.
The solution: The node! macro generates all the boilerplate for you!
Why Use It?
Without the macro:
pub struct SensorNode {
temperature: Topic<f32>,
counter: u32,
}
impl SensorNode {
pub fn new() -> Self {
Self {
temperature: Topic::new("temperature")
.expect("Failed to create publisher 'temperature'"),
counter: 0,
}
}
}
impl Node for SensorNode {
fn name(&self) -> &str {
"sensor_node"
}
fn tick(&mut self) {
let temp = 20.0 + (self.counter as f32 * 0.1);
self.temperature.send(temp);
self.counter += 1;
}
}
impl Default for SensorNode {
fn default() -> Self {
Self::new()
}
}
With the macro (12 lines):
node! {
SensorNode {
pub { temperature: f32 -> "temperature" }
data { counter: u32 = 0 }
tick {
let temp = 20.0 + (self.counter as f32 * 0.1);
self.temperature.send(temp);
self.counter += 1;
}
}
}
The macro generates the struct, new() constructor, Node trait impl, and Default impl automatically.
Basic Syntax
node! {
NodeName {
name: "custom_name", // Explicit node name (optional)
pub { ... } // Publishers (optional)
sub { ... } // Subscribers (optional)
data { ... } // Internal state (optional)
tick { ... } // Main loop (required)
init { ... } // Startup (optional)
shutdown { ... } // Cleanup (optional)
impl { ... } // Custom methods (optional)
}
}
Only the node name and tick are required! Everything else is optional.
Tick rate is not a section — it is set on the scheduler when you register the node. See Tick Rate below.
Sections Explained
name: - Explicit Node Name (Optional)
Override the auto-generated node name with a custom identifier:
node! {
FlightControllerNode {
name: "flight_controller", // Custom name instead of "flight_controller_node"
pub { status: String -> "fc.status" }
tick { ... }
}
}
By default, the node name is auto-generated from the struct name using snake_case:
SensorNode→"sensor_node"IMUProcessor→"imuprocessor"(runs of capitals are not split;IMUNode→"imunode")MyRobotController→"my_robot_controller"
With explicit naming, you control the exact name used everywhere:
- Scheduler registration and execution logs
- Monitor TUI display
- Log messages (
[flight_controller] ...) - Diagnostics and metrics
Use cases for explicit naming:
- Multiple similar nodes, each its own type:
name: "imu_front",name: "imu_rear" - Cleaner names for acronyms:
IMUSensor→name: "imu_sensor"(instead of"imusensor") - Match existing naming conventions in your robot system
- Shorter names for logging readability
// Example: Two IMU sensors with explicit names
node! {
ImuFrontSensor {
name: "imu_front",
pub { data: Imu -> "sensors.imu_front" }
tick { ... }
}
}
node! {
ImuRearSensor { // A separate type...
name: "imu_rear", // ...with its own runtime identity
pub { data: Imu -> "sensors.imu_rear" }
tick { ... }
}
}
pub - Send Messages
Define what this node sends:
pub {
// Syntax: name: Type -> "topic"
velocity: f32 -> "robot.velocity",
status: String -> "robot.status"
}
This creates:
- A
Topic<f32>field calledvelocity - A
Topic<String>field calledstatus - Both connected to their respective topics
sub - Receive Messages
Define what this node receives:
sub {
// Syntax: name: Type -> "topic"
commands: String -> "user.commands",
sensors: f32 -> "sensors.temperature"
}
This creates:
- A
Topic<String>field calledcommands - A
Topic<f32>field calledsensors - Both listening to their respective topics
data - Internal State
Store data inside your node:
data {
counter: u32 = 0,
buffer: Vec<f32> = Vec::new(),
last_time: Instant = Instant::now()
}
Access these as self.counter, self.buffer, etc.
tick - Main Loop
This runs repeatedly at the node's tick rate:
tick {
// Read inputs
if let Some(cmd) = self.commands.recv() {
// Process
let result = process(cmd);
// Send outputs
self.status.send(result);
}
// Update state
self.counter += 1;
}
Keep this fast! It runs every frame.
init - Startup (Optional)
Runs once when your node starts:
init {
hlog!(info, "Starting up");
self.buffer.reserve(1000); // Pre-allocate
Ok(())
}
The init block must return Ok(()) on success (it generates fn init(&mut self) -> Result<()>).
Use this for:
- Opening files/connections
- Pre-allocating memory
- One-time setup
shutdown - Cleanup (Optional)
Runs once when your node stops:
shutdown {
hlog!(info, "Processed {} messages", self.counter);
// Save state, close files, etc.
Ok(())
}
impl - Custom Methods (Optional)
Add helper functions:
impl {
fn calculate(&self, x: f32) -> f32 {
x * 2.0 + self.counter as f32
}
fn reset(&mut self) {
self.counter = 0;
}
}
Tick Rate (set on the scheduler)
Rate is not a node! section — a rate line inside node! { } is a compile error. Specify how often a node ticks when you register it with the scheduler:
let mut scheduler = Scheduler::new().tick_rate(1000_u64.hz());
scheduler.add(MyNode::new())
.order(0)
.rate(100_u64.hz()) // this node ticks at 100 Hz
.build()?;
.rate() takes a Frequency (from DurationExt, e.g. 100_u64.hz()), not a bare f64. Common values:
1000_u64.hz()- 1kHz for motor control loops100_u64.hz()- 100Hz for sensor processing30_u64.hz()- 30Hz for vision processing1_u64.hz()- 1Hz for slow monitoring
If not specified, the node runs at the scheduler's global tick rate (default 100 Hz).
Complete Examples
High-Rate Motor Controller
node! {
MotorController {
sub { target_velocity: f32 -> "motor.target" }
pub { pwm_output: f32 -> "motor.pwm" }
data {
kp: f32 = 0.5,
current_velocity: f32 = 0.0
}
tick {
if let Some(target) = self.target_velocity.recv() {
// Simple P controller
let error = target - self.current_velocity;
let output = (self.kp * error).clamp(-1.0, 1.0);
self.pwm_output.send(output);
}
}
}
}
Register it with the rate it should run at:
scheduler.add(MotorController::new())
.order(0)
.rate(1000_u64.hz()) // 1 kHz control loop
.build()?;
Simple Publisher
node! {
HeartbeatNode {
pub { alive: bool -> "system.heartbeat" }
tick {
self.alive.send(true);
}
}
}
Simple Subscriber
node! {
LoggerNode {
sub { messages: String -> "logs" }
tick {
if let Some(msg) = self.messages.recv() {
hlog!(info, "[LOG] {}", msg);
}
}
}
}
Pipeline (Sub + Pub)
node! {
DoubleNode {
sub { input: f32 -> "numbers" }
pub { output: f32 -> "doubled" }
tick {
if let Some(num) = self.input.recv() {
self.output.send(num * 2.0);
}
}
}
}
With State
node! {
AverageNode {
sub { input: f32 -> "values" }
pub { output: f32 -> "average" }
data {
buffer: Vec<f32> = Vec::new(),
max_size: usize = 10
}
tick {
if let Some(value) = self.input.recv() {
self.buffer.push(value);
// Keep only last 10 values
if self.buffer.len() > self.max_size {
self.buffer.remove(0);
}
// Calculate average
let avg: f32 = self.buffer.iter().sum::<f32>() / self.buffer.len() as f32;
self.output.send(avg);
}
}
}
}
With Lifecycle
node! {
FileLoggerNode {
sub { data: String -> "logs" }
data { file: Option<std::fs::File> = None }
init {
use std::fs::OpenOptions;
self.file = OpenOptions::new()
.create(true)
.append(true)
.open("log.txt")
.ok();
hlog!(info, "File opened");
Ok(())
}
tick {
if let Some(msg) = self.data.recv() {
if let Some(file) = &mut self.file {
use std::io::Write;
writeln!(file, "{}", msg).ok();
}
}
}
shutdown {
hlog!(info, "Closing file");
self.file = None; // Closes the file
Ok(())
}
}
}
Tips and Tricks
Three rules matter enough to repeat here; the worked examples are in the Rust macro reference under Best Practices, which is where they are maintained.
- Name topics for what they carry, not for where they came from:
motors.speed, notdata. - Keep
tickfast. It runs on the scheduler's thread; asleepor a blocking read intickstalls every node sharing that executor. - Allocate in
init, not intick. Reserve buffers once, then reuse them.
Common Questions
Do I need to import anything?
Yes, import the prelude:
use horus::prelude::*;
node! {
MyNode { ... }
}
Can I have multiple publishers?
Yes!
pub {
speed: f32 -> "speed",
direction: f32 -> "direction",
status: String -> "status"
}
Can I skip sections I don't need?
Yes! Only NodeName and tick are required:
node! {
MinimalNode {
tick {
hlog!(info, "Hello!");
}
}
}
How do I use the node?
Create it and add it to the scheduler:
let mut scheduler = Scheduler::new();
scheduler.add(MyNode::new()).order(0).build()?;
scheduler.run()?;
The macro generates new() -> Self (not Result). Topic creation failures panic with a descriptive error message.
Troubleshooting
Compiler errors from node! — "cannot find type in scope", "expected ,, found
{", the CamelCase warning — are listed with their fixes in
Troubleshooting the macros.
One correction worth stating here, because this page used to say the opposite: a
node name does not have to be CamelCase. The macro accepts a snake_case name
and emits the struct verbatim, so what you get is rustc's
non_camel_case_types warning and an awkward type name — not a rejection.
// Compiles. Warns, and gives you a type called `my_node`.
node! { my_node { tick { } } }
How do I give my node a custom name?
Use the name: section:
node! {
MyNode {
name: "robot1_controller", // Custom runtime name
tick { ... }
}
}
// Now node.name() returns "robot1_controller" instead of "my_node"
This is useful for:
- Naming multiple similar nodes, each its own type
- Avoiding ugly auto-generated names (e.g.,
IMUNode→"imunode") - Matching external naming conventions
What the Macro Generates
For reference, the node! macro expands to:
pub struct NodeName— withTopic<T>fields for publishers/subscribers and your data fieldsimpl NodeName { pub fn new() -> Self }— constructor that creates all topics and initializes data with defaults. When apuborsubsection is present, this same inherent impl also getspublishers()/subscribers()methods returningVec<TopicMetadata>(each one is omitted entirely when its section is absent)impl Node for NodeName— withname(),tick(), and optionalinit()/shutdown()impl Default for NodeName— callsSelf::new()impl NodeName { ... }— any methods from theimplsection
The struct name is converted to snake_case for the node name (e.g., SensorNode becomes "sensor_node"), unless overridden with name:.
Tick rate is not part of the expansion — it is set on the scheduler with scheduler.add(node).rate(100_u64.hz()).build()?.
See Also
- Core Concepts: Nodes — Full node model and lifecycle
- Basic Examples — Real applications using the macro
- Message Types — Available message types for pub/sub
- Topic — Communication API details