Tutorial 4: Custom Messages (C++)

HORUS provides 50+ built-in message types, but you'll often need your own. This tutorial shows how to define custom #[repr(C)] Pod messages usable across C++, Rust, and Python.

What You'll Learn

  • Defining #[repr(C)] structs that work as Pod messages
  • Using custom types with Publisher<T> / Subscriber<T>
  • Layout requirements for cross-language compatibility
  • When to use custom messages vs JsonWireMessage

The Two Approaches

Approach 1: Use JsonWireMessage (Quick, Flexible)

For prototyping or when the message schema changes frequently, use JSON.

The JSON wire type is reached through the C API, not Publisher<T> — there is no Publisher<HorusJsonWireMsg> specialization, so advertise<HorusJsonWireMsg>() does not compile. Use horus_publisher_json_wire_new / _send:

#include <horus/horus.hpp>
#include <cstring>
using namespace horus::literals;

int main() {
    horus::Scheduler sched;

    // JSON wire message — sends arbitrary JSON through SHM.
    HorusPublisher* json_pub = horus_publisher_json_wire_new("custom.data");

    sched.add("sender")
        .tick([&] {
            // Pack custom data as JSON
            HorusJsonWireMsg msg{};
            const char* json = R"({"temperature": 25.3, "humidity": 60, "location": "lab"})";
            std::memcpy(msg.data, json, std::strlen(json));
            msg.data_len = static_cast<uint32_t>(std::strlen(json));
            msg.msg_id = 1;
            horus_publisher_json_wire_send(json_pub, &msg);
        })
        .build();

    sched.spin();
    horus_publisher_json_wire_destroy(json_pub);
}

Pros: No Rust changes needed, any JSON schema, works immediately. Cons: 3968-byte payload max, serialization overhead, no compile-time type checking.

Approach 2: Define a Pod Struct (Production, Zero-Copy)

For production use, define a #[repr(C)] struct in Rust, add it to the FFI pipeline, and create a matching C++ struct. This gives you zero-copy SHM transfer.

Step 1: Define in Rust (your message crate)

/// Custom sensor reading from a weather station
#[repr(C)]
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct WeatherData {
    pub temperature: f32,     // Celsius
    pub humidity: f32,        // 0-100%
    pub pressure: f32,        // hPa
    pub wind_speed: f32,      // m/s
    pub wind_direction: f32,  // degrees (0=N, 90=E)
    pub timestamp_ns: u64,
}

Step 2: Add to FFI pipeline

In horus_cpp/src/topic_ffi.rs:

// simplified
impl_topic_ffi!(weather_data, WeatherData, my_messages::WeatherData);

In horus_cpp/src/c_api.rs:

// simplified
impl_pod_topic_c_api!(weather_data, my_messages::WeatherData);

Step 3: Define matching C++ struct

// In your project or in horus_cpp/include/horus/msg/
namespace horus { namespace msg {

struct WeatherData {
    float temperature;      // Celsius
    float humidity;         // 0-100%
    float pressure;         // hPa
    float wind_speed;       // m/s
    float wind_direction;   // degrees
    uint64_t timestamp_ns;
};

}} // namespace horus::msg

Step 4: Declare the C prototypes

horus_c.h is a hand-maintained mirror of c_api.rs — nothing generates it (horus_cpp/build.rs is an empty stub). The specialization added in Step 5 calls seven extern "C" functions (horus_publisher_weather_data_new/_destroy/_send and horus_subscriber_weather_data_new/_destroy/_recv/_has_msg), so they must be declared by hand first, or the build fails with use of undeclared identifier 'horus_publisher_weather_data_new'.

In horus_cpp/include/horus/horus_c.h, add a line alongside the existing HORUS_DECLARE_TOPIC(...) invocations:

HORUS_DECLARE_TOPIC(weather_data)

The macro is defined at line 369 and #undef'd at line 414, so the line must land between them. Placed anywhere else — for example next to the explicit horus_publisher_temperature_* prototypes higher up in the file — it will not compile. (Equivalently, you can write the seven prototypes out longhand in the style of those temperature declarations.)

Step 5: Add C++ template specialization

In horus_cpp/include/horus/impl/topic_impl.hpp:

HORUS_TOPIC_IMPL(msg::WeatherData, weather_data)

If you put the struct from Step 3 under horus_cpp/include/horus/msg/, #include it here too — topic_impl.hpp currently pulls in only control.hpp, sensor.hpp, geometry.hpp, navigation.hpp and diagnostics.hpp.

Step 6: Use it

#include <horus/horus.hpp>
using namespace horus::literals;

class WeatherStation : public horus::Node {
public:
    WeatherStation() : Node("weather_station") {
        pub_ = advertise<horus::msg::WeatherData>("weather.data");
    }

    void tick() override {
        horus::msg::WeatherData data{};
        data.temperature = read_temperature();
        data.humidity = read_humidity();
        data.pressure = read_pressure();
        data.timestamp_ns = 0;  // horus_cpp ships no clock helper — fill from std::chrono
        pub_->send(data);
    }

private:
    horus::Publisher<horus::msg::WeatherData>* pub_;
    float read_temperature() { return 22.5f; }
    float read_humidity() { return 55.0f; }
    float read_pressure() { return 1013.25f; }
};

The same HORUS_TOPIC_IMPL line also gave you Subscriber<horus::msg::WeatherData>, so the receiving side needs no extra registration:

#include <horus/horus.hpp>
#include <cstdio>

class WeatherLogger : public horus::Node {
public:
    WeatherLogger() : Node("weather_logger") {
        sub_ = subscribe<horus::msg::WeatherData>("weather.data");
    }

    void tick() override {
        // recv() returns std::optional<BorrowedSample<WeatherData>> —
        // empty when no new sample arrived since the last tick.
        auto sample = sub_->recv();
        if (!sample) return;
        const horus::msg::WeatherData& w = **sample;
        std::printf("%.1f C, %.0f%% RH, %.1f hPa\n", w.temperature, w.humidity, w.pressure);
    }

private:
    horus::Subscriber<horus::msg::WeatherData>* sub_;
};

Use sub_->has_msg() if you only want to know whether a sample is waiting without consuming it.

Layout Rules for Custom Types

RuleWhy
#[repr(C)] in RustEnsures C-compatible memory layout
Only primitive types + fixed arraysVec, String, Box can't cross SHM
Same field order in C++ and RustMemory layout must match exactly
Use f32/f64/u8/u16/u32/u64/i32/i64Cross-language compatible primitives
Fixed-size arrays [T; N] onlyVariable-length data needs JsonWireMessage
timestamp_ns: u64 in the same position on both sidesField order must match byte-for-byte. Most HORUS messages end with it, but not all — CmdVel puts it first, and LandmarkArray, TrackingHeader, SegmentationMask and AudioFrame place it mid-struct. When mirroring an existing message, copy the Rust definition rather than the convention.

When To Use Each Approach

ScenarioApproach
Prototyping, schema changingJsonWireMessage
Production, performance-criticalCustom Pod struct
Cross-language (C++ + Python + Rust)Custom Pod struct
One-off configuration messagesJsonWireMessage
High-frequency sensor data (>100 Hz)Custom Pod struct (zero-copy)

Key Takeaways

  • JsonWireMessage is the fastest path for custom data — no Rust changes, JSON payload, 3968-byte max
  • Custom Pod structs give zero-copy SHM but require adding to the FFI pipeline (6 steps)
  • Both approaches work between C++ and Rust; reaching Python needs a matching binding in horus_py as well, which this tutorial does not cover
  • For a new message, ending with timestamp_ns: u64 is the usual convention — but when mirroring an existing one, copy the Rust field order exactly; horus::msg::CmdVel starts with timestamp_ns, and its static_assert in control.hpp pins it at offset 0
  • The HORUS_TOPIC_IMPL macro generates the entire Publisher/Subscriber specialization in one line — once the seven C prototypes from Step 4 exist