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:
IMUSensor→name: "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 calledvelocity - A
Topic<String>field calledstatus - 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 calledcommands - A
Topic<f32>field calledsensors - 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:
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:.
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
| Area | Benefit | Cost |
|---|---|---|
| Boilerplate reduction | 13 lines vs 48 lines for the same node; no hand-written constructor, trait impl, or Default | Custom DSL syntax to learn — not standard Rust |
| Topic wiring | Publishers and subscribers are declared once and auto-connected | Topic creation panics at startup instead of returning Result — errors are loud but not recoverable |
| Compile-time safety | Type mismatches caught at compile time via generated Topic<T> fields | Macro error messages can be harder to read than normal Rust compiler errors |
| Introspection | Auto-generated publishers()/subscribers() metadata is always accurate | Users cannot override the generated metadata without escaping to manual impl Node |
| Flexibility | Covers the common case (pub/sub nodes with state and lifecycle) concisely | Complex nodes needing custom Node trait methods or non-standard constructors must use manual impl Node |
| IDE support | Code inside tick {}, init {}, impl {} blocks has full autocomplete | Some IDEs struggle with macro-generated fields until the project is built once |
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