Custom Messages
A message defined in msgs/*.hmsg becomes a Rust struct, a C++ struct and a
Python ctypes.Structure — all with the same layout and the same layout hash.
mkdir msgs
msgs/weather.hmsg:
/// A reading from the weather mast.
#[topic = "weather.data"]
WeatherData {
/// Nanoseconds since the epoch.
timestamp_ns: u64,
/// Degrees Celsius.
temperature: f32,
humidity: f32,
/// Last sixteen samples, oldest first.
history: [f32; 16],
heater_on: bool,
}
horus msg gen
* Generated 1 message(s) from 1 file(s)
WeatherData 0x8b734db7 88 bytes (7 padding)
.horus/generated/msgs/Cargo.toml
.horus/generated/msgs/src/lib.rs
.horus/generated/msgs_ffi/Cargo.toml
.horus/generated/msgs_ffi/src/lib.rs
.horus/generated/include/demo/msgs.hpp
.horus/generated/python/msgs.py
Using it
Rust — the generated crate is already a dependency of your project:
use demo_msgs::WeatherData;
let topic: Topic<WeatherData> = Topic::new(WeatherData::TOPIC)?;
topic.send(WeatherData { temperature: 21.5, ..Default::default() });
C++ — the generated include path is already on your target's include path:
#include <demo/msgs.hpp>
demo::msg::WeatherData w{};
w.temperature = 21.5f;
w.set_heater_on(true); // Rust's bool is one byte; the field is uint8_t
Publishing and subscribing use the generated wrappers, which are in the same header:
demo::msg::WeatherDataPublisher pub(demo::msg::WEATHERDATA_TOPIC);
if (pub.valid()) {
pub.send(w);
}
demo::msg::WeatherDataSubscriber sub(demo::msg::WEATHERDATA_TOPIC);
demo::msg::WeatherData got{};
if (sub.recv(got)) {
// got.temperature is whatever the publisher sent, in any language
}
Both are move-only — they own a handle, and copying one would release it twice —
and a subscriber is single-threaded, because the transport's consumer contract
is one reader per handle. valid() is false when the topic could not be opened.
The entry points behind them are generated into .horus/generated/msgs_ffi/
and linked automatically. They cannot come from libhorus_cpp.a the way the
built-in types' do: adding symbols to that archive would mean editing and
rebuilding the HORUS source tree.
Python:
import sys; sys.path.insert(0, ".horus/generated/python")
from msgs import WeatherData
w = WeatherData(temperature=21.5)
The layout hash
Every artifact carries the same number, and horus msg hash prints it:
$ horus msg hash WeatherData
0x8b734db7
It is FNV-1a over a canonical rendering of the definition:
WeatherData|timestamp_ns:u64|temperature:f32|humidity:f32|history:[f32; 16]|heater_on:bool
That string is embedded in the generated Rust as LAYOUT_CANONICAL rather than
rebuilt with stringify!, which reproduces source spacing — [u8;32] and
[u8; 32] would otherwise hash differently.
The generated C++ header asserts its own layout against the Rust one, field by field:
static_assert(sizeof(WeatherData) == 88, "WeatherData size differs from Rust");
static_assert(offsetof(WeatherData, humidity) == 12, "...");
If the two ever disagree, the C++ build stops. This is not hypothetical: the
hand-maintained Rust and C++ definitions of JointCommand once drifted to 928
bytes against 88 — an 840-byte overrun on every receive, with no compiler
anywhere in a position to notice.
What a message may contain
Everything crossing the boundary is #[repr(C)] and is read and written with a
raw pointer copy, so a message must be plain data:
| Allowed | Not allowed |
|---|---|
u8–u64, i8–i64, f32, f64, bool | String, &str |
[T; N] — a fixed-length array | Vec<T> |
another message declared in msgs/ | Option<T>, Box<T>, HashMap |
| references, generics, enums |
A rejected type is reported with the reason and what to write instead:
msgs/weather.hmsg:4:11: `String` cannot be used in a message
help: a String owns a heap allocation, which cannot be shared between
processes. Use a fixed array like `[u8; 64]`.
Built-in types
.hmsg cannot yet reference built-in types such as Vector3. Write the fields
out — Vector3 is three f64s. The restriction is deliberate: sizing a
built-in would mean this generator carrying a table of their layouts, and a
table that is wrong produces a header whose static_assert passes against the
wrong number. That is the failure the layout contract exists to catch.
In CI
--check verifies the generated files match the definitions without rewriting
them, so a commit that edits a .hmsg and forgets to regenerate fails:
horus msg gen --check