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: IMUSensorname: "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 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;
    }
}

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 loops
  • 100_u64.hz() - 100Hz for sensor processing
  • 30_u64.hz() - 30Hz for vision processing
  • 1_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, not data.
  • Keep tick fast. It runs on the scheduler's thread; a sleep or a blocking read in tick stalls every node sharing that executor.
  • Allocate in init, not in tick. 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:

  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. When a pub or sub section is present, this same inherent impl also gets publishers() / subscribers() methods returning Vec<TopicMetadata> (each one is omitted entirely when its section is absent)
  3. impl Node for NodeName — with name(), tick(), and optional init() / shutdown()
  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:.

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