Tutorial 1: IMU Sensor Node (C++)
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 C++17 compiler 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
horus::msg::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 --cpp
cd imu-demo
You should see horus.toml, src/main.cpp, and .horus/ in the project directory.
Step 2: Write the Code
HORUS ships a standard horus::msg::Imu message — a 9-axis reading with an orientation quaternion, angular velocity, and linear acceleration. Replace src/main.cpp with two nodes: a sensor that publishes it and a display that consumes it.
#include <horus/horus.hpp>
#include <cmath>
#include <cstdio>
using namespace horus::literals;
// ── Sensor Node: simulates an IMU at 100 Hz ──────────────────────────────
class ImuSensor : public horus::Node {
public:
ImuSensor() : Node("imu_sensor") {
// Publish IMU readings for any downstream consumer.
imu_pub_ = advertise<horus::msg::Imu>("imu.data");
}
void tick() override {
double t = tick_count_ * 0.01; // 100 Hz → 0.01 s per tick
horus::msg::Imu imu{};
// Accelerometer (m/s²): a level robot feels ~1 g on the z axis.
imu.linear_acceleration[0] = 0.0;
imu.linear_acceleration[1] = 0.0;
imu.linear_acceleration[2] = 9.81;
// Gyroscope (rad/s): a gentle yaw oscillation as the robot turns.
imu.angular_velocity[0] = 0.0;
imu.angular_velocity[1] = 0.0;
imu.angular_velocity[2] = 0.1 * std::sin(t * 0.5);
// Orientation quaternion (qx, qy, qz, qw) — identity for now.
imu.orientation[3] = 1.0;
imu.timestamp_ns = static_cast<uint64_t>(t * 1e9);
imu_pub_->send(imu);
tick_count_++;
}
private:
horus::Publisher<horus::msg::Imu>* imu_pub_;
uint64_t tick_count_ = 0;
};
// ── Display Node: prints the IMU stream once per second ──────────────────
class ImuDisplay : public horus::Node {
public:
ImuDisplay() : Node("imu_display") {
imu_sub_ = subscribe<horus::msg::Imu>("imu.data");
}
void tick() override {
// Call recv() every tick to drain the topic buffer.
auto data = imu_sub_->recv();
if (!data) return; // no new message this tick
const auto* imu = data->get();
// Print every 100th sample → once per second at 100 Hz.
if (++sample_count_ % 100 == 0) {
std::printf(
"accel=(%.2f, %.2f, %.2f) gyro=(%.3f, %.3f, %.3f)\n",
imu->linear_acceleration[0], imu->linear_acceleration[1],
imu->linear_acceleration[2],
imu->angular_velocity[0], imu->angular_velocity[1],
imu->angular_velocity[2]);
}
}
private:
horus::Subscriber<horus::msg::Imu>* imu_sub_;
uint64_t sample_count_ = 0;
};
// ── Main ─────────────────────────────────────────────────────────────────
int main() {
horus::Scheduler sched;
sched.tick_rate(100_hz).name("imu_demo");
// Sensor publishes (order 0) before the display subscribes (order 1).
ImuSensor sensor;
sched.add(sensor).order(0).build();
ImuDisplay display;
sched.add(display).order(1).build();
std::printf("IMU demo running at 100 Hz (Ctrl+C to stop)\n");
sched.spin();
}
A few things to notice:
- A node is a class deriving from
horus::Node. Topics are created once in the constructor withadvertise<T>()/subscribe<T>(); the returnedPublisher<T>*/Subscriber<T>*are stored as members. send()/recv()move a message through a lock-free shared-memory ring buffer.recv()returns an empty optional when no new message is waiting, so always check it.- 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 classes that derive from
horus::Nodeand create their topics in the constructor. - Standard messages like
horus::msg::Imuare ready to use — no.msgfiles, no codegen step. send()/recv()move data over a zero-copy shared-memory ring buffer;recv()returns an empty optional when there's nothing new.- Execution order (
order(0)beforeorder(1)) guarantees the sensor publishes before the display reads.
Next Steps
- Tutorial 2: Motor Controller (C++) — subscribe to velocity commands and publish state feedback
See Also
- Sensor Messages — the standard IMU message type
- C++ Topic API — full Publisher/Subscriber reference