The node! Macro

The problem: Writing HORUS nodes manually in Rust requires lots of boilerplate code.

The solution: The node! macro generates all the boilerplate for you.

Python users: You don't need macros. Python's horus.Node() is already concise — see the comparison below.

Why Use It?

Without the macro (48 lines):

// simplified
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):

// simplified
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

// simplified
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. Set tick rate via the scheduler builder: .rate(100.hz()).

Sections Explained

name: - Explicit Node Name (Optional)

Override the auto-generated node name with a custom identifier:

// simplified
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
// simplified
// 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 { ... }
    }
}

Tick Rate

Set the tick rate via the scheduler builder, not in the macro:

// simplified
// Set rate when adding to scheduler
scheduler.add(MyNode::new())
    .rate(100.hz())  // 100 Hz
    .build()?;

The rate keyword inside node! {} is not supported — it was removed in favor of the builder API.

pub - Send Messages

Define what this node sends:

// simplified
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:

// simplified
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:

// simplified
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:

// simplified
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:

// simplified
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:

// simplified
shutdown {
    hlog!(info, "Processed {} messages", self.counter);
    // Save state, close files, etc.
    Ok(())
}

impl - Custom Methods (Optional)

Add helper functions:

// simplified
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

// simplified
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);
            }
        }
    }
}

// Set rate on the scheduler builder:
// scheduler.add(MotorController::new()).rate(1000.hz()).build()?;

Simple Publisher

// simplified
node! {
    HeartbeatNode {
        pub { alive: bool -> "system.heartbeat" }

        tick {
            self.alive.send(true);
        }
    }
}

Simple Subscriber

// simplified
node! {
    LoggerNode {
        sub { messages: String -> "logs" }

        tick {
            if let Some(msg) = self.messages.recv() {
                hlog!(info, "[LOG] {}", msg);
            }
        }
    }
}

Pipeline (Sub + Pub)

// simplified
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

// simplified
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

// simplified
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

// simplified
// Good
pub { motor_speed: f32 -> "motors.speed" }

// Bad
pub { x: f32 -> "data" }

Keep tick Fast

// simplified
// 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()

// simplified
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:

// simplified
use horus::prelude::*;

node! {
    MyNode { ... }
}

Can I have multiple publishers?

Yes!

// simplified
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:

// simplified
node! {
    MinimalNode {
        tick {
            hlog!(info, "Hello!");
        }
    }
}

How do I use the node?

Create it and add it to the scheduler:

// simplified
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:

// simplified
use horus::prelude::*;

node! {
    MyNode {
        pub { cmd: CmdVel -> "cmd_vel" }
        ...
    }
}

"Expected ,, found {"

Check your syntax:

// simplified
// Wrong
pub { cmd: f32 "topic" }

// Right
pub { cmd: f32 -> "topic" }

Node name must be CamelCase

// simplified
// Wrong
node! { my_node { ... } }

// Right
node! { MyNode { ... } }

How do I give my node a custom name?

Use the name: section:

// simplified
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:.

Design Decisions

Why a Procedural Macro Instead of Derive Macros

Derive macros (e.g., #[derive(Node)]) operate on existing struct definitions — they can add trait implementations but cannot generate the struct itself, create constructors, or reorganize fields by role. The node! macro is a procedural macro that takes a domain-specific syntax and generates the entire struct, constructor, trait impl, and Default impl from a single declaration. This lets users declare publishers, subscribers, and state in separate labeled sections (pub {}, sub {}, data {}) rather than mixing Topic<T> fields with plain data fields in a flat struct. The macro assigns meaning to each section and generates the correct wiring code for each.

Why Generate the Constructor Automatically

Topic creation requires calling Topic::new("name") for every publisher and subscriber, and each call can fail. In hand-written code, users must write a new() function that mirrors the struct field-by-field, creating topics and initializing defaults — pure boilerplate that adds no information. The macro generates new() -> Self (panicking on topic creation failure) because topic names are string literals known at compile time. A panic during construction surfaces immediately at startup rather than hiding behind Result chains that obscure the actual error.

Why Auto-Generate Publishers/Subscribers Metadata

The macro generates publishers() and subscribers() methods that return the list of topic names the node uses. This metadata powers the scheduler's topology awareness, the monitor TUI's wiring diagram, and horus node info introspection — all without requiring users to manually keep a metadata list in sync with their actual topic declarations. Since the macro already knows every pub and sub entry, generating this metadata is free and always correct.

Trade-offs

AreaBenefitCost
Boilerplate reduction13 lines vs 48 lines for the same node; no hand-written constructor, trait impl, or DefaultCustom DSL syntax to learn — not standard Rust
Topic wiringPublishers and subscribers are declared once and auto-connectedTopic creation panics at startup instead of returning Result — errors are loud but not recoverable
Compile-time safetyType mismatches caught at compile time via generated Topic<T> fieldsMacro error messages can be harder to read than normal Rust compiler errors
IntrospectionAuto-generated publishers()/subscribers() metadata is always accurateUsers cannot override the generated metadata without escaping to manual impl Node
FlexibilityCovers the common case (pub/sub nodes with state and lifecycle) conciselyComplex nodes needing custom Node trait methods or non-standard constructors must use manual impl Node
IDE supportCode inside tick {}, init {}, impl {} blocks has full autocompleteSome IDEs struggle with macro-generated fields until the project is built once

See Also