horus_macros

Procedural macros for reducing boilerplate in HORUS applications.

use horus::prelude::*;  // Includes all macros

node!

Declarative macro for creating HORUS nodes with minimal boilerplate.

Syntax

node! {
    NodeName {
        name: "custom_name",  // Custom 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 { ... }   // Initialization (optional)
        shutdown { ... } // Cleanup (optional)
        impl { ... }   // Custom methods (optional)
    }
}

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

Sections

pub - Publishers

Define topics this node publishes to.

pub {
    // Syntax: name: Type -> "topic_name"
    velocity: f32 -> "robot.velocity",
    status: String -> "robot.status",
    pose: Pose2D -> "robot.pose"
}

Generated code:

  • Topic<Type> field for each publisher
  • Automatic initialization in new()

sub - Subscribers

Define topics this node subscribes to.

sub {
    // Syntax: name: Type -> "topic_name"
    commands: String -> "user.commands",
    sensors: f32 -> "sensors.temperature"
}

Generated code:

  • Topic<Type> field for each subscriber
  • Automatic initialization in new()

data - Internal State

Define internal fields with default values.

data {
    counter: u32 = 0,
    buffer: Vec<f32> = Vec::new(),
    last_time: Instant = Instant::now(),
    config: MyConfig = MyConfig::default()
}

tick - Main Loop

Required. Called every scheduler cycle (~100 Hz by default).

tick {
    // Read from subscribers
    if let Some(cmd) = self.commands.recv() {
        // Process
    }

    // Write to publishers
    self.velocity.send(1.0);

    // Access internal state
    self.counter += 1;
}

init - Initialization

Called once before the first tick. The block must return Ok(()) on success (it generates fn init(&mut self) -> Result<()>).

init {
    hlog!(info, "Node starting");
    self.buffer.reserve(1000);
    Ok(())
}

shutdown - Cleanup

Called once when the scheduler stops. Must return Ok(()) on success (generates fn shutdown(&mut self) -> Result<()>).

shutdown {
    hlog!(info, "Node stopping");
    // Close files, save state, etc.
    Ok(())
}

impl - Custom Methods

Add helper methods to the node.

impl {
    fn calculate(&self, x: f32) -> f32 {
        x * 2.0 + self.offset
    }

    fn reset(&mut self) {
        self.counter = 0;
    }
}

Generated Code

The macro generates:

  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
  3. impl Node for NodeName with name(), tick(), optional init(), shutdown(), publishers(), subscribers(), and rate()
  4. impl Default for NodeName that calls Self::new()
  5. impl NodeName { ... } for any methods from the impl section
// This macro call:
node! {
    SensorNode {
        pub { data: f32 -> "sensor" }
        data { count: u32 = 0 }
        tick { self.count += 1; }
    }
}

// Generates approximately:
pub struct SensorNode {
    data: Topic<f32>,
    count: u32,
}

impl SensorNode {
    pub fn new() -> Self {
        Self {
            data: Topic::new("sensor").expect("Failed to create publisher 'sensor'"),
            count: 0,
        }
    }
}

impl Node for SensorNode {
    fn name(&self) -> &str { "sensor_node" }  // Auto snake_case
    fn tick(&mut self) {
        self.count += 1;
    }
}

impl Default for SensorNode {
    fn default() -> Self {
        Self::new()
    }
}

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

Examples

Minimal Node

node! {
    MinimalNode {
        tick {
            // Called every tick
        }
    }
}

Publisher Only

node! {
    HeartbeatNode {
        pub { alive: bool -> "system.heartbeat" }
        data { count: u64 = 0 }

        tick {
            self.alive.send(true);
            self.count += 1;
        }
    }
}

Subscriber Only

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

        tick {
            while let Some(msg) = self.messages.recv() {
                hlog!(info, "{}", msg);
            }
        }
    }
}

Full Pipeline

node! {
    ProcessorNode {
        sub { input: f32 -> "raw_data" }
        pub { output: f32 -> "processed_data" }
        data {
            scale: f32 = 2.0,
            offset: f32 = 10.0
        }

        tick {
            if let Some(value) = self.input.recv() {
                let result = value * self.scale + self.offset;
                self.output.send(result);
            }
        }

        impl {
            fn set_scale(&mut self, scale: f32) {
                self.scale = scale;
            }
        }
    }
}

With Lifecycle

node! {
    StatefulNode {
        pub { status: String -> "status" }
        data {
            initialized: bool = false,
            tick_count: u64 = 0
        }

        init {
            hlog!(info, "Initializing...");
            self.initialized = true;
            Ok(())
        }

        tick {
            self.tick_count += 1;
            let msg = format!("Tick {}", self.tick_count);
            self.status.send(msg);
        }

        shutdown {
            hlog!(info, "Total ticks: {}", self.tick_count);
            Ok(())
        }
    }
}

Usage

use horus::prelude::*;

node! {
    MyNode {
        pub { output: f32 -> "data" }
        tick {
            self.output.send(42.0);
        }
    }
}

fn main() -> Result<()> {
    let mut scheduler = Scheduler::new();
    scheduler.add(MyNode::new()).order(0).build()?;
    scheduler.run()
}

#[derive(LogSummary)]

Derive macro for implementing the LogSummary trait with default Debug formatting.

When to Use

Use #[derive(LogSummary)] when you need Topic::with_logging() on a custom message type. LogSummary is not required for basic Topic::new() — only for the opt-in introspection mode.

The derive requires Debug on the type since it generates a Debug-based implementation.

use horus::prelude::*;

#[derive(Debug, Clone, Serialize, Deserialize, LogSummary)]
pub struct MyStatus {
    pub temperature: f32,
    pub voltage: f32,
}

// Now you can use with_logging()
let topic: Topic<MyStatus> = Topic::new("status")?.with_logging();

The derive generates:

impl LogSummary for MyStatus {
    fn log_summary(&self) -> String {
        format!("{:?}", self)
    }
}

Custom LogSummary

For large types (images, point clouds) where Debug output would be too verbose, implement LogSummary manually instead of deriving:

use horus::prelude::*;

impl LogSummary for MyLargeData {
    fn log_summary(&self) -> String {
        format!("MyLargeData({}x{}, {} bytes)", self.width, self.height, self.data.len())
    }
}

Best Practices

Keep tick Fast

// Good - non-blocking
tick {
    if let Some(x) = self.input.recv() {
        self.output.send(x * 2.0);
    }
}

// Bad - blocking operation
tick {
    std::thread::sleep(Duration::from_secs(1));  // Blocks scheduler!
}

Pre-allocate in init

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

tick {
    // Don't allocate here - runs every tick
}

Use Descriptive Names

// Good
pub { motor_velocity: f32 -> "motors.velocity" }

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

Handle Errors Gracefully

tick {
    // send() is infallible — always succeeds
    self.status.send("ok".to_string());

    // No error handling needed — ring buffer overwrites oldest on full
    self.critical.send(data);
}

Troubleshooting

"Cannot find type in scope"

Import message types:

use horus::prelude::*;

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

"Expected ,, found {"

Check arrow syntax:

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

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

"Node name must be CamelCase"

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

// Correct
node! { MyNode { ... } }

Use hlog! for logging

tick {
    // Use hlog! macro for logging
    hlog!(info, "test");
    hlog!(debug, "value = {}", some_value);
    hlog!(warn, "potential issue");
    hlog!(error, "something went wrong");
}

Logging Macros

hlog!

Node-aware logging that publishes to the shared memory log buffer (visible in monitor) and emits to stderr with ANSI colors.

hlog!(info, "Sensor initialized");
hlog!(debug, "Value: {}", some_value);
hlog!(warn, "Battery low: {}%", battery_pct);
hlog!(error, "Failed to read sensor: {}", err);

Levels: trace, debug, info, warn, error

The scheduler automatically sets the current node context, so log messages include the node name:

[INFO] [SensorNode] Sensor initialized

hlog_once!

Log a message once per callsite. Subsequent calls from the same source location are silently ignored. Uses a per-callsite AtomicBool — zero overhead after the first call.

fn tick(&mut self) {
    // Log when sensor first produces valid data
    hlog_once!(info, "Sensor online — first reading: {:.2}", value);

    // Warn about a condition the first time it's detected
    if self.error_count > 0 {
        hlog_once!(warn, "Sensor errors detected — check wiring");
    }
}

Common uses: first-connection notifications, one-time calibration messages, feature availability checks at startup.

hlog_every!

Throttled logging — emits at most once per interval_ms milliseconds. Uses a per-callsite AtomicU64 timestamp — zero overhead when the interval hasn't elapsed. Essential for nodes running at high frequencies (100Hz+) where per-tick logging would flood the system.

fn tick(&mut self) {
    // Status heartbeat every 5 seconds
    hlog_every!(5000, info, "Motor controller OK — speed: {:.1} rad/s", self.velocity);

    // Battery warnings every second (not every tick at 1kHz)
    if self.battery_pct < 20.0 {
        hlog_every!(1000, warn, "Battery low: {:.0}%", self.battery_pct);
    }

    // Periodic performance stats every 10 seconds
    hlog_every!(10_000, debug, "Avg latency: {:.1}us, ticks: {}", self.avg_latency_us, self.tick_count);
}

message!

Declarative macro for defining custom message types for use with Topic<T>. Auto-derives all required traits so messages work with zero configuration.

Syntax

use horus::prelude::*;

message! {
    /// Motor command sent to actuators
    MotorCommand {
        velocity: f32,
        torque: f32,
    }
}

// Ready to use with Topic
let topic: Topic<MotorCommand> = Topic::new("motor.cmd")?;
topic.send(MotorCommand { velocity: 1.0, torque: 0.5 });

Multiple Messages

Define multiple message types in a single block:

message! {
    /// Velocity command
    CmdVel {
        linear_x: f64,
        angular_z: f64,
    }

    /// Battery status
    BatteryStatus {
        voltage: f32,
        current: f32,
        percentage: f32,
    }
}

Generated Code

For message! { Foo { x: f32, y: f32 } }, the macro generates:

#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Foo {
    pub x: f32,
    pub y: f32,
}

impl LogSummary for Foo {
    fn log_summary(&self) -> String {
        format!("{:?}", self)
    }
}

The struct automatically satisfies TopicMessage via the blanket impl — no additional trait implementation is needed.

When to Use

Use message! when HORUS's standard message types don't cover your domain. For standard robotics types (Twist, Pose2D, Imu, etc.), use the prelude types instead.


action! and service! Macros

See Actions and Services for the action! and service! macros that generate typed communication patterns.


See Also