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 (47 lines):

struct SensorNode {
    publisher: Hub<f32>,
    counter: u32,
}

impl SensorNode {
    fn new() -> HorusResult<Self> {
        Ok(Self {
            publisher: Hub::new("temperature")?,
            counter: 0,
        })
    }
}

impl Node for SensorNode {
    fn name(&self) -> &'static str {
        "SensorNode"
    }

    fn tick(&mut self, ctx: Option<&mut NodeInfo>) {
        let temp = 20.0 + (self.counter as f32 * 0.1);
        self.publisher.send(temp, ctx).ok();
        self.counter += 1;
    }
}

impl Default for SensorNode {
    fn default() -> Self {
        Self::new().expect("Failed to create SensorNode")
    }
}

With the macro (13 lines):

node! {
    SensorNode {
        pub { temperature: f32 -> "temperature" }
        data { counter: u32 = 0 }

        tick(ctx) {
            let temp = 20.0 + (self.counter as f32 * 0.1);
            self.temperature.send(temp, ctx).ok();
            self.counter += 1;
        }
    }
}

73% less code! And it's easier to read.

Basic Syntax

node! {
    NodeName {
        pub { ... }    // Publishers (optional)
        sub { ... }    // Subscribers (optional)
        data { ... }   // Internal state (optional)
        tick(ctx) { ... }   // Main loop (required)
        init(ctx) { ... }   // Startup (optional)
        shutdown(ctx) { ... } // Cleanup (optional)
        impl { ... }   // Custom methods (optional)
    }
}

Only the node name and tick are required! Everything else is optional.

Sections Explained

pub - Send Messages

Define what this node sends:

pub {
    // Syntax: name: Type -> "topic"
    velocity: f32 -> "robot/velocity",
    status: String -> "robot/status"
}

This creates:

  • A Hub<f32> field called velocity
  • A Hub<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 Hub<String> field called commands
  • A Hub<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(ctx) - Main Loop

This runs repeatedly (about 60 times per second):

tick(ctx) {
    // Read inputs
    if let Some(cmd) = self.commands.recv(ctx) {
        // Process
        let result = process(cmd);
        // Send outputs
        self.status.send(result, ctx).ok();
    }

    // Update state
    self.counter += 1;
}

Keep this fast! It runs every frame.

init(ctx) - Startup (Optional)

Runs once when your node starts:

init(ctx) {
    ctx.log_info("Starting up");
    self.buffer.reserve(1000); // Pre-allocate
}

Use this for:

  • Opening files/connections
  • Pre-allocating memory
  • One-time setup

shutdown(ctx) - Cleanup (Optional)

Runs once when your node stops:

shutdown(ctx) {
    ctx.log_info(&format!("Processed {} messages", self.counter));
    // Save state, close files, etc.
}

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

Simple Publisher

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

        tick(ctx) {
            self.alive.send(true, ctx).ok();
        }
    }
}

Simple Subscriber

node! {
    LoggerNode {
        sub { messages: String <- "logs" }

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

Pipeline (Sub + Pub)

node! {
    DoubleNode {
        sub { input: f32 <- "numbers" }
        pub { output: f32 -> "doubled" }

        tick(ctx) {
            if let Some(num) = self.input.recv(ctx) {
                self.output.send(num * 2.0, ctx).ok();
            }
        }
    }
}

With State

node! {
    AverageNode {
        sub { input: f32 <- "values" }
        pub { output: f32 -> "average" }
        data {
            buffer: Vec<f32> = Vec::new(),
            max_size: usize = 10
        }

        tick(ctx) {
            if let Some(value) = self.input.recv(ctx) {
                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, ctx).ok();
            }
        }
    }
}

With Lifecycle

node! {
    FileLoggerNode {
        sub { data: String <- "logs" }
        data { file: Option<std::fs::File> = None }

        init(ctx) {
            use std::fs::OpenOptions;
            self.file = OpenOptions::new()
                .create(true)
                .append(true)
                .open("log.txt")
                .ok();
            ctx.log_info("File opened");
        }

        tick(ctx) {
            if let Some(msg) = self.data.recv(ctx) {
                if let Some(file) = &mut self.file {
                    use std::io::Write;
                    writeln!(file, "{}", msg).ok();
                }
            }
        }

        shutdown(ctx) {
            ctx.log_info("Closing file");
            self.file = None; // Closes the file
        }
    }
}

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(ctx) {
    if let Some(x) = self.input.recv(ctx) {
        let y = x * 2.0;
        self.output.send(y, ctx).ok();
    }
}

// Bad - slow operation
tick(ctx) {
    std::thread::sleep(Duration::from_secs(1)); // Blocks everything!
}

Pre-allocate in init()

init(ctx) {
    self.buffer.reserve(1000); // Do this once
}

tick(ctx) {
    // Not here - would allocate every tick!
}

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(ctx) {
            println!("Hello!");
        }
    }
}

How do I use the node?

Just create it and register it:

let mut scheduler = Scheduler::new();
scheduler.register(Box::new(MyNode::new()?), 0, Some(true));
scheduler.tick_all()?;

The macro generates a new() method automatically.

Troubleshooting

"Cannot find type in scope"

Import your message types:

use horus::prelude::*;
use horus_library::messages::CmdVel; // If using library types

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

Next Steps

Now that you can write nodes quickly:

  1. Try some examples - See real applications
  2. Learn about Messages - Work with complex data
  3. Master the Hub - Understand communication

The macro makes HORUS fast to write. Shared memory makes it fast to run!