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:
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(), and optionalinit()/shutdown()impl NodeNamealso gainspublishers()andsubscribers()returningVec<TopicMetadata>(inherent methods, not trait methods) when apub/subsection is presentimpl 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 '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
- node! Macro Guide - Detailed tutorial
- horus_core API - Core types reference
- Actions - action! macro reference
- Services - service! macro reference