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)
        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; override with Scheduler::new().tick_rate(500_u64.hz()) or per-node .rate(...)).

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(), and optional init() / shutdown()
  4. impl NodeName also gains publishers() and subscribers() returning Vec<TopicMetadata> (inherent methods, not trait methods) when a pub/sub section is present
  5. impl Default for NodeName that calls Self::new()
  6. 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 'data'"),
            count: 0,
        }
    }

    // Present because of the `pub` section — an inherent method, not a trait method
    pub fn publishers(&self) -> Vec<TopicMetadata> { /* one entry per `pub` field */ }
}

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).rate(100_u64.hz()).done();
    scheduler.run()
}

There is no rate section inside node! — writing one is a compile error. Set the tick rate per node with .rate(100_u64.hz()) on the scheduler builder (Frequency/DurationExt are in the prelude).


#[derive(LogSummary)]

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

When to Use

LogSummary produces the one-line summary used by hlog! and by verbose content logging. Verbose content logging is toggled per-topic at runtime from the horus monitor TUI — it is not enabled from code. LogSummary is never required for Topic::new(); the only bound Topic<T> needs is Clone + Send + Sync + Serialize + DeserializeOwned (satisfied by the blanket TopicMessage impl).

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,
}

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" }

Account for Dropped Messages

tick {
    // send() returns () — there is no Result to handle.
    self.status.send("ok".to_string());

    // But send() is not lossless. On a full ring it retries briefly
    // (immediate, then 64 spins, then 4 yields) and then gives up:
    //   - point-to-point (1 pub / 1 sub, SpscShm): the NEW message is dropped
    //     and counted by self.critical.dropped_count()
    //   - broadcast (1 pub / N subs, Fanout/PodShm): the OLDEST slot is
    //     overwritten and slow subscribers fast-forward to the newest window
    // Where loss is unacceptable, use send_blocking(msg, timeout) instead.
    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 names should be CamelCase

The macro does not reject a snake_case name, but it emits the struct verbatim, so you get rustc's non_camel_case_types warning and an awkward type name.

// 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: 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. Useful for one-time initialization messages or first-occurrence warnings.

fn tick(&mut self) {
    hlog_once!(info, "Sensor calibration complete (model: {})", self.model);
    // Only prints on the first tick — silent on all subsequent ticks
}

hlog_every!

Throttled logging — emits at most once per interval_ms milliseconds. Prevents log flooding from high-frequency loops.

fn tick(&mut self) {
    // At most once per 5 seconds
    hlog_every!(5000, warn, "Battery low: {}%", self.battery_pct);

    // At most once per second
    hlog_every!(1000, debug, "Processing at {:.1} Hz", self.actual_rate);
}

action! and service! Macros

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


See Also