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: IMUSensorname: "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 loops
  • 100.0 - 100Hz for sensor processing
  • 30.0 - 30Hz for vision processing
  • 1.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 called velocity
  • A Topic<String> field called status
  • 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 called commands
  • A Topic<f32> field called sensors
  • 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:

  1. pub struct NodeName — with Topic<T> fields for publishers/subscribers and your data fields
  2. impl NodeName { pub fn new() -> Self } — constructor that creates all topics and initializes data with defaults
  3. impl Node for NodeName — with name(), tick(), optional init(), shutdown(), publishers(), subscribers(), and rate() methods
  4. impl Default for NodeName — calls Self::new()
  5. impl NodeName { ... } — any methods from the impl section

The struct name is converted to snake_case for the node name (e.g., SensorNode becomes "sensor_node"), unless overridden with name:.

See Also