Tutorial 1: IMU Sensor Node (C++)

Prefer another language? The same node in Rust is covered by the Rust Guide, and in Python by Nodes and Topics.

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 --help working)

What You'll Build

An IMU sensor node that:

  1. Generates accelerometer data (x, y, z in m/s²)
  2. Generates gyroscope data (roll, pitch, yaw rates in rad/s)
  3. Publishes the standard horus::msg::Imu message on a topic at 100 Hz
  4. 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 with advertise<T>() / subscribe<T>(); the returned Publisher<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 orderorder(0) runs before order(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

  1. Nodes are classes that derive from horus::Node and create their topics in the constructor.
  2. Standard messages like horus::msg::Imu are ready to use — no .msg files, no codegen step.
  3. send() / recv() move data over a zero-copy shared-memory ring buffer; recv() returns an empty optional when there's nothing new.
  4. Execution order (order(0) before order(1)) guarantees the sensor publishes before the display reads.

Next Steps

See Also