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 (13 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)
rate 100.0 // Tick rate in Hz (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.
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→"i_m_u_processor"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 instances of the same node type:
name: "imu_front",name: "imu_rear" - Cleaner names for acronyms:
IMUSensor→name: "imu_sensor"(instead of"i_m_u_sensor") - Match existing naming conventions in your robot system
- Shorter names for logging readability
// Example: Multiple IMU sensors with explicit names
node! {
IMUSensor {
name: "imu_front",
pub { data: Imu -> "sensors.imu_front" }
tick { ... }
}
}
node! {
IMUSensor { // Same struct definition...
name: "imu_rear", // ...but different runtime identity
pub { data: Imu -> "sensors.imu_rear" }
tick { ... }
}
}
rate - Tick Rate (Optional)
Specify how often this node should tick:
rate 100.0 // Run at 100 Hz (100 times per second)
This sets the node's preferred tick rate. Common values:
1000.0- 1kHz for motor control loops100.0- 100Hz for sensor processing30.0- 30Hz for vision processing1.0- 1Hz for slow monitoring
If not specified, the node runs at the scheduler's global tick rate (default ~60Hz).
The rate can also be overridden at runtime using scheduler.set_node_rate("node_name", 200.0).
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;
}
}
Complete Examples
High-Rate Motor Controller
node! {
MotorController {
rate 1000.0 // 1kHz control loop
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);
}
}
}
}
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
Use Descriptive Names
// Good
pub { motor_speed: f32 -> "motors.speed" }
// Bad
pub { x: f32 -> "data" }
Keep tick Fast
// Good - quick operation
tick {
if let Some(x) = self.input.recv() {
let y = x * 2.0;
self.output.send(y);
}
}
// Bad - slow operation
tick {
std::thread::sleep(Duration::from_secs(1)); // Blocks everything!
}
Pre-allocate in init()
init {
self.buffer.reserve(1000); // Do this once
Ok(())
}
tick {
// Don't allocate in tick - do it in init!
}
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
"Cannot find type in scope"
Import your message types:
use horus::prelude::*;
node! {
MyNode {
pub { cmd: CmdVel -> "cmd_vel" }
...
}
}
"Expected ,, found {"
Check your syntax:
// Wrong
pub { cmd: f32 "topic" }
// Right
pub { cmd: f32 -> "topic" }
Node name must be CamelCase
// Wrong
node! { my_node { ... } }
// Right
node! { MyNode { ... } }
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:
- Running multiple instances of the same node type
- Avoiding ugly auto-generated names (e.g.,
IMU→"i_m_u") - 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 defaultsimpl Node for NodeName— withname(),tick(), optionalinit(),shutdown(),publishers(),subscribers(), andrate()methodsimpl 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:.
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