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
| Rule | Why |
|---|---|
#[repr(C)] in Rust | Ensures C-compatible memory layout |
| Only primitive types + fixed arrays | Vec, String, Box can't cross SHM |
| Same field order in C++ and Rust | Memory layout must match exactly |
Use f32/f64/u8/u16/u32/u64/i32/i64 | Cross-language compatible primitives |
Fixed-size arrays [T; N] only | Variable-length data needs JsonWireMessage |
timestamp_ns: u64 in the same position on both sides | Field 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
| Scenario | Approach |
|---|---|
| Prototyping, schema changing | JsonWireMessage |
| Production, performance-critical | Custom Pod struct |
| Cross-language (C++ + Python + Rust) | Custom Pod struct |
| One-off configuration messages | JsonWireMessage |
| 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_pyas well, which this tutorial does not cover - For a new message, ending with
timestamp_ns: u64is the usual convention — but when mirroring an existing one, copy the Rust field order exactly;horus::msg::CmdVelstarts withtimestamp_ns, and itsstatic_assertincontrol.hpppins it at offset 0 - The
HORUS_TOPIC_IMPLmacro generates the entire Publisher/Subscriber specialization in one line — once the seven C prototypes from Step 4 exist