horus_macros

Macros that eliminate boilerplate. Pick the right one:

I need to...UseExample
Define a node with pub/subnode!Sensor reader, motor controller
Define a custom message typemessage!MotorFeedback, WheelOdometry
Define a request/response serviceservice!CalibrateImu, GetMapRegion
Define a long-running taskaction!NavigateTo, PickAndPlace
Use a standard action templatestandard_action!navigate, manipulate, dock
Log from inside a nodehlog!hlog!(info, "Motor started")
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.

What it generates

  • A struct with the fields you define
  • Topic<T> fields for each sub: and pub: declaration
  • Node trait implementation skeleton with publishers() and subscribers() metadata
  • TopicMetadata for the scheduler's topic graph visualization

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::verbose flag (via TUI monitor) 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 verbose flag (via TUI monitor)
let topic: Topic<MyStatus> = Topic::new("status")?;

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.

Flexible Messages (default)

Any field types allowed — uses serialization transport (~167ns):

use horus::prelude::*;

message! {
    /// Robot log entry — contains a String (variable-size)
    LogEntry {
        text: String,
        level: u8,
        timestamp_ns: u64,
    }
}

Fixed Messages (zero-copy)

Add #[fixed] for zero-copy shared memory transport (~50ns). All fields must be Copy — no String, Vec, Box, etc.:

use horus::prelude::*;

message! {
    #[fixed]
    /// Motor command — fixed-size, zero-copy via shared memory
    MotorCommand {
        velocity: f32,
        torque: f32,
    }
}

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

Multiple Messages

Define multiple types in a single block — #[fixed] and flexible can be mixed:

message! {
    #[fixed]
    /// Zero-copy velocity command (~50ns)
    CmdVel {
        linear_x: f64,
        angular_z: f64,
    }

    /// Flexible status with dynamic fields (~167ns)
    StatusReport {
        message: String,
        node_name: String,
        error_count: u32,
    }
}

Generated Code

Without #[fixed] (flexible):

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

With #[fixed] (zero-copy):

#[repr(C)]
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize)]
pub struct Foo { pub x: f32, pub y: f32 }
impl LogSummary for Foo { ... }

#[fixed] adds Copy, Default, and #[repr(C)] — enabling automatic zero-copy POD detection for shared memory transport. If you put a non-Copy field (like String) inside #[fixed], the compiler tells you immediately.

When to Use

  • #[fixed] — For high-frequency control loops (1kHz+) with primitive fields: sensor readings, motor commands, joint states
  • Without #[fixed] — For messages with strings, vectors, or other dynamic data: logs, configs, diagnostics
  • Neither — For standard message 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.


topics!

Define compile-time topic descriptors for type-safe, typo-proof topic names across your codebase.

Syntax

use horus::prelude::*;

topics! {
    pub CMD_VEL: CmdVel = "cmd_vel",
    pub SENSOR_DATA: Imu = "sensor.imu",
    pub MOTOR_STATUS: MotorCommand = "motor.cmd",
}

Each entry creates a TopicDescriptor<T> constant. Use it to create topics with guaranteed name and type consistency:

Usage

// Instead of string literals (typo-prone):
let topic: Topic<CmdVel> = Topic::new("cmd_vel")?;

// Use typed descriptors (compile-time checked):
let topic = CMD_VEL.create()?;  // Topic<CmdVel>, name = "cmd_vel"

Benefits

  • No typos — topic name is defined once, referenced everywhere
  • Type safetySENSOR_DATA.create() always returns Topic<Imu>, never Topic<CmdVel>
  • Discoverability — grep for CMD_VEL to find all publishers and subscribers

register_driver!

Register a hardware driver so the [drivers] section of horus.toml can instantiate it by name. The macro uses ELF .init_array to register the factory at program startup — no manual initialization code needed.

Syntax

use horus::prelude::*;
use horus::drivers::DriverParams;

register_driver!(MyDriver, MyDriver::from_params);

The first argument is the driver type. The second is a factory function with signature fn(&DriverParams) -> Result<Self>.

Full Example

use horus::prelude::*;
use horus::drivers::DriverParams;

struct LidarDriver {
    port: String,
    rpm: u32,
}

impl LidarDriver {
    fn from_params(params: &DriverParams) -> Result<Self> {
        Ok(Self {
            port: params.get::<String>("port")?,
            rpm: params.get_or("rpm", 600u32),
        })
    }
}

impl Node for LidarDriver {
    fn name(&self) -> &str { "lidar" }

    fn tick(&mut self) {
        // Read from hardware, publish scan data
    }
}

register_driver!(LidarDriver, LidarDriver::from_params);

Then in horus.toml:

[drivers.lidar]
node = "LidarDriver"
port = "/dev/ttyUSB0"
rpm = 300

And in main.rs:

fn main() -> Result<()> {
    let mut hw = drivers::load()?;
    let mut sched = Scheduler::new().tick_rate(100_u64.hz());
    sched.add(hw.local("lidar")?).build()?;
    sched.run()?;
    Ok(())
}

DriverParams API

The factory function receives a DriverParams with typed access to the [drivers.NAME] config table:

MethodDescription
params.get::<T>(key)?Required param — errors if missing or wrong type
params.get_or(key, default)Optional param with fallback value
params.has(key)Check if a param exists
params.keys()Iterate over all param names

Supported types: String, bool, u8u64, i8i64, f32, f64, Vec<T>.

How It Works

register_driver! places a constructor function in the ELF .init_array section. When the binary loads, the OS calls it before main(), registering the factory in a global registry. drivers::load() reads [drivers] from horus.toml, looks up each node name in the registry, and calls the factory with the config params.


See Also