Tutorial 1: IMU Sensor Node (Rust)
Inertial Measurement Units (IMUs) are one of the most common sensors in robotics. Drones rely on them for stabilization, self-driving cars fuse IMU data with GPS for localization, and warehouse AGVs use them to track heading. In this tutorial you'll build a HORUS node that simulates an IMU — publishing accelerometer and gyroscope readings at 100 Hz — and a second node that subscribes to that stream and displays it. This is the foundational publish/subscribe pattern you'll use for every sensor in HORUS.
Prerequisites
- Quick Start completed
- A Rust toolchain and HORUS installed (
horus --helpworking)
What You'll Build
An IMU sensor node that:
- Generates accelerometer data (x, y, z in m/s²)
- Generates gyroscope data (roll, pitch, yaw rates in rad/s)
- Publishes the standard
Imumessage on a topic at 100 Hz - A display node that prints the data once per second
Time estimate: ~15 minutes
Step 1: Create the Project
horus new imu-demo
cd imu-demo
Rust is the default language, so horus new needs no flag. You should see horus.toml, src/main.rs, and .horus/ in the project directory.
Step 2: Write the Code
HORUS ships a standard Imu message — a 9-axis reading with an orientation quaternion, angular velocity, and linear acceleration. It arrives through horus::prelude, so there is no .msg file and no codegen step. Replace src/main.rs with two nodes: a sensor that publishes it and a display that consumes it.
use horus::prelude::*;
// ── Sensor Node: simulates an IMU at 100 Hz ──────────────────────────────
struct ImuSensor {
imu: Topic<Imu>,
ticks: u64,
}
impl ImuSensor {
fn new() -> Result<Self> {
Ok(Self {
// Publish IMU readings for any downstream consumer.
imu: Topic::new("imu.data")?,
ticks: 0,
})
}
}
impl Node for ImuSensor {
fn name(&self) -> &str {
"imu_sensor"
}
fn tick(&mut self) {
let t = self.ticks as f64 * 0.01; // 100 Hz → 0.01 s per tick
// Imu::new() starts from an identity quaternion and stamps the message
// with the current time, so only the live axes are assigned here.
let mut imu = Imu::new();
// Accelerometer (m/s²): a level robot feels ~1 g on the z axis.
imu.linear_acceleration = [0.0, 0.0, 9.81];
// Gyroscope (rad/s): a gentle yaw oscillation as the robot turns.
imu.angular_velocity = [0.0, 0.0, 0.1 * (t * 0.5).sin()];
self.imu.send(imu);
self.ticks += 1;
}
}
// ── Display Node: prints the IMU stream once per second ──────────────────
struct ImuDisplay {
imu: Topic<Imu>,
samples: u64,
}
impl ImuDisplay {
fn new() -> Result<Self> {
Ok(Self {
imu: Topic::new("imu.data")?,
samples: 0,
})
}
}
impl Node for ImuDisplay {
fn name(&self) -> &str {
"imu_display"
}
fn tick(&mut self) {
// Drain everything published since the last tick.
while let Some(imu) = self.imu.recv() {
self.samples += 1;
// Print every 100th sample → once per second at 100 Hz.
if self.samples % 100 == 0 {
let a = imu.linear_acceleration;
let g = imu.angular_velocity;
println!(
"accel=({:.2}, {:.2}, {:.2}) gyro=({:.3}, {:.3}, {:.3})",
a[0], a[1], a[2], g[0], g[1], g[2]
);
}
}
}
}
// ── Main ─────────────────────────────────────────────────────────────────
fn main() -> Result<()> {
let mut sched = Scheduler::new().tick_rate(100_u64.hz()).name("imu_demo");
// Sensor publishes (order 0) before the display subscribes (order 1).
sched.add(ImuSensor::new()?).order(0).build()?;
sched.add(ImuDisplay::new()?).order(1).build()?;
println!("IMU demo running at 100 Hz (Ctrl+C to stop)");
sched.run()
}
A few things to notice:
- A node is a struct that implements the
Nodetrait. Topics are created once in the constructor —Topic::new()returns aResult, which is whynew()does — and stored as fields. - One type does both jobs. Rust has no separate publisher and subscriber type: both nodes hold a
Topic<Imu>opened on the same name, and HORUS connects them. Whether a topic sends or receives is decided by which methods you call on it. send()/recv()move a message through a lock-free shared-memory ring buffer.recv()returnsNonewhen no new message is waiting, so thewhile letloop drains the ring and stops.- Execution order —
order(0)runs beforeorder(1)each tick, so the sensor publishes fresh data before the display reads it.
Step 3: Build and Run
horus build
horus run
You should see one line per second:
IMU demo running at 100 Hz (Ctrl+C to stop)
accel=(0.00, 0.00, 9.81) gyro=(0.000, 0.000, 0.048)
accel=(0.00, 0.00, 9.81) gyro=(0.000, 0.000, 0.084)
accel=(0.00, 0.00, 9.81) gyro=(0.000, 0.000, 0.097)
Press Ctrl+C to stop.
Step 4: Introspect While Running
With the demo still running, open a second terminal and inspect the live system:
horus topic list -v # active topics, types, and rates
horus topic hz imu.data # confirm the 100 Hz publish rate
horus topic echo imu.data # stream every message to your terminal
horus node list # see the running nodes
horus topic echo is a read-only observer — it never interferes with the running system, which makes it invaluable for debugging. If a subscriber isn't receiving data, use it to confirm the publisher is actually sending.
Key Takeaways
- Nodes are structs implementing the
Nodetrait, creating their topics in a constructor that returnsResult<Self>. - Standard messages like
Imucome fromhorus::prelude— no.msgfiles, no codegen step. Topic<T>is symmetric: the same type publishes and subscribes, connected by name.send()/recv()move data over a zero-copy shared-memory ring buffer;recv()returnsNonewhen there's nothing new.- Execution order (
order(0)beforeorder(1)) guarantees the sensor publishes before the display reads.
Next Steps
- Tutorial 2: Motor Controller (Rust) — subscribe to velocity commands and publish state feedback
See Also
- Sensor Messages — the standard IMU message type
- Topics & Communication — full
Topic<T>reference, including whenrecv()can skip messages