Protocol schemas
A schema is a rho file that describes what crosses a link — between a node and
an edge, between an edge and the cloud — rather than what a program does. It is
written once and generated for both ends, so the two can only disagree if the
schema disagrees with itself.
The output of a schema is not a translation of a program. It is an interface:
fixed bytes for the node, typed values and a handler for the service. It is
generated by its own command, apart from a project's build, because the two ends
are built by different toolchains on different machines.
Declarations
module telemetry
namespace telemetry.v1
enum Severity {
INFO = 0
WARN = 1
ERROR = 2
}
message MotorTelemetry {
timestamp_ms: u32
current_amps: f32
rpm: f32
status: Severity
}
rpc SetMotorSpeed {
request {
motor_id: u8
target_rpm: f32
}
response {
success: bool
actual_rpm: u16
}
}
namespace a.b.vN;names the schema and its version. The version is the
number in the last component, or 1, and it is written into every message.enumvalues are written, not counted. A wire protocol numbers its values,
and a reader can only trust a number it can see.messageis an ordered set of fields. It is a struct that makes a promise
a struct does not: a byte layout and a version.rpcis a request and a response, each a set of fields.
message, rpc and namespace are contextual: they name a declaration only
where one begins, so a field or a variable may still be called one.
The wire
A message is its version byte and its fields in declaration order, each at a
fixed width. Scalars are little-endian; an enum is a 32-bit value; a string[N]
is N bytes and a bare string is 64; a nested message is its own version byte
and fields; an array is its elements end to end.
Little-endian is not the host's byte order but the schema's, so a big-endian node
and a little-endian one read each other's messages. Reading and writing a message
allocates nothing, on either side.
A message that contains itself cannot be packed and is reported where it is
found.
Generating
# C for the node: protocol.h/.c (the bytes) and protocol_bus.h/.c (transport)
rho protocol generate --target c --out build/protocol/c .
# Go for the edge: protocol.go
rho protocol generate --target go --out build/protocol/go --package telemetry .
# Rho for an edge written in Rho: protocol.rho, included from the program
rho protocol generate --target rho --out src/gateway schemas/telemetry.rho
Every .rho under the given paths is read into one schema, so messages may live
in one file and rpcs in another.
The C side
protocol.h carries a typedef enum per enum, a __attribute__((packed))
struct per message, a _WIRE_SIZE macro, and zero-allocation
Name_serialize/Name_deserialize. protocol_bus.h carries, per rpc, a
publish_request/publish_response and a subscribe/unsubscribe over the
runtime's event bus (robohub_core_event_publish and friends), so a node's
handler is reached by an event rather than a call.
The Go side
protocol.go carries a struct per message with json tags, typed constants per
enum value, and MarshalBinary/UnmarshalBinary that write the same little-endian
bytes the C side reads. Each rpc gets a RegisterName(mux, path, handler) that
puts the handler on an http.ServeMux, JSON on the outside and the wire beneath.
The Rho side
protocol.rho is a Rho module a program includes. Each message becomes a
struct, its _marshal/_unmarshal written field by field with the same
little-endian order, and _valid to reject a payload that is short or of another
schema version. An enum becomes u32 constants, because the wire is a 32-bit
value and Rho has no cast from an enum to a number.
It also carries the transport — _frame and _unframe, with COBS and the
Fletcher-16 check — so a Rho edge speaks the same framing a node's
robohub-transport library does, from one description rather than from a second
implementation that must be kept in step. A TransportFrame returns ok, the
header and the payload, and the program decides what each message id means.