DEV Community

Serjio
Serjio

Posted on

A small C++ library for sending structured commands and telemetry between devices — no schema files, just add your parameters and serialize

If you've ever tried to build a simple command/telemetry protocol between a PC and a fleet of SDR receivers, sensors, or embedded devices, you know the usual options aren't great:

  • Roll your own binary format — fast, but you end up writing and maintaining custom serialization code for every device type, and debugging mismatched structs across machines is painful.

  • Protobuf / FlatBuffers — robust, but require you to define your message layout in a schema file upfront, run a code generator as part of your build, and commit to a fixed structure. Adding a new device type or a new parameter means editing the schema, regenerating, recompiling everything.

  • JSON over the wire — easy to debug, but heavy for anything real-time or bandwidth-constrained.

I ran into this while working on a multi-SDR receiver system and ended up writing MessageFrame — a small C++17 library that lets you build structured messages dynamically, without any schema files or code generation.

The basic idea

Instead of defining a struct for each device type, you address each parameter with two strings — a device name and a parameter name — and the library handles the rest:

// One message, multiple devices, assembled at runtime
msgframe::MessageFrame msg(MSG_TELEMETRY, TYPE_PERIODIC, src=1, tgt=2);

msg.add("sdr_1", "rx_gain",      VALUE(30.0));
msg.add("sdr_1", "center_freq",  VALUE(915'000'000.0));
msg.add("sdr_1", "sample_rate",  VALUE(2'000'000.0));
msg.add("sdr_2", "rx_gain",      VALUE(25.0));
msg.add("sdr_2", "lock_status",  VALUE(true));
msg.add("psu_1", "voltage",      VALUE(12.04));
msg.add("psu_1", "temp_c",       VALUE(47.3));

// Attach raw IQ data alongside the parameters
std::vector<uint8_t> iq_buffer = {0x01, 0x02, 0x03, 0x04};
msg.add_attachment("raw_iq", std::move(iq_buffer));

// Serialize into a buffer, send over whatever transport you use
std::vector<uint8_t> out;
msg.serialize(out);
send_udp(out.data(), out.size(), target_addr);
Enter fullscreen mode Exit fullscreen mode

On the receiving end:

msgframe::MessageFrame received;
received.deserialize(buf.data(), buf.size());

auto gain = received.find("sdr_1", "rx_gain");
if (gain) std::cout << "SDR-1 gain: " << gain->toString() << "\n";
Enter fullscreen mode Exit fullscreen mode

No generated structs, no schema to maintain. A new device shows up in your network — you just start adding its parameters to the message with its own device key, and the receiver can read them by name immediately.

What's under the hood (briefly)

  • Wire format is MessagePack — compact binary, not JSON, so it's reasonable for real-time use. Any MessagePack-compatible parser on the receiving end can decode it, not just this library.

  • Parameters are stored in a flat std::vector for small messages (≤128 parameters) — good CPU L1 cache behavior for the common case. Past that the container automatically switches to a hash map. For typical device telemetry (a handful of parameters per device, a handful of devices) you stay in the fast vector path the whole time.

  • The header is fixed-size and sits at the front of the packet, so a router or dispatcher can read message ID, source, destination, and timestamp without parsing the full payload.

  • Binary attachments (IQ samples, spectrum snapshots, raw byte streams) travel alongside parameters in the same packet without going through the key/value store.

What it's not

It's not a transport layer — you still bring your own socket/queue/serial port. It's not a replacement for Protobuf if you need cross-language compatibility out of the box or have very tight bandwidth constraints. It's a lightweight way to structure the payload when your message content is dynamic and you don't want to maintain a schema file every time your device lineup changes.

Repo with examples and benchmarks: GitHub link

Top comments (0)