horus_macros
Macros that eliminate boilerplate. Pick the right one:
| I need to... | Use | Example |
|---|---|---|
| Define a node with pub/sub | node! | Sensor reader, motor controller |
| Define a custom message type | message! | MotorFeedback, WheelOdometry |
| Define a request/response service | service! | CalibrateImu, GetMapRegion |
| Define a long-running task | action! | NavigateTo, PickAndPlace |
| Use a standard action template | standard_action! | navigate, manipulate, dock |
| Log from inside a node | hlog! | 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 eachsub:andpub:declarationNodetrait implementation skeleton withpublishers()andsubscribers()metadataTopicMetadatafor 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:
pub struct NodeNamewithTopic<T>fields for publishers/subscribers and your data fieldsimpl NodeName { pub fn new() -> Self }constructor that creates all Topicsimpl Node for NodeNamewithname(),tick(), optionalinit(),shutdown(),publishers(),subscribers(), andrate()impl Default for NodeNamethat callsSelf::new()impl NodeName { ... }for any methods from theimplsection
// 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 safety —
SENSOR_DATA.create()always returnsTopic<Imu>, neverTopic<CmdVel> - Discoverability — grep for
CMD_VELto 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:
| Method | Description |
|---|---|
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, u8–u64, i8–i64, 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
- node! Macro Guide - Detailed tutorial
- API Reference - Core types reference
- Actions - action! macro reference
- Services - service! macro reference
- Drivers - Hardware driver system