Skip to content

Message Format

All communication in Sensor Net uses a single Protocol Buffer (protobuf) message type called SensorReport. This page explains what protobuf is, why Sensor Net uses it, and what each field in the message means.

Protocol Buffers (protobuf) is a language-neutral binary serialization format developed by Google. You define your message fields in a .proto file using a simple syntax, and a code generator produces fast encoder and decoder code in your target language.

Protobuf encodes data as compact binary bytes — much smaller than JSON or plain text. This is important for Sensor Net because LoRa packets should be as small as possible to minimize airtime and maximize reliability.

You could manually pack sensor data into a fixed byte layout (like a C struct), but protobuf offers several advantages:

  • Self-describing and versioned. Adding a new field to the message does not break nodes running older firmware. Unknown fields are simply skipped.
  • Handles encoding details. Protobuf manages endianness, variable-length integers, and optional fields automatically.
  • Cross-language. The same .proto file can generate code for C, Python, Go, Rust, and many other languages. This means the monitor application (or any other tool) can decode the data without reimplementing a custom parser.

Standard protobuf libraries are too large for microcontrollers with limited RAM. Sensor Net uses Nanopb, a C implementation of protobuf designed for embedded systems. Nanopb generates plain C structs and encode/decode functions with no dynamic memory allocation.

The generated code lives in firmware/node/lib/proto/:

  • messages.pb.h — struct definitions
  • messages.pb.c — encode and decode functions

The message definition in proto/messages.proto:

syntax = "proto3";
enum SensorType {
SENSOR_UNKNOWN = 0;
SENSOR_TEMPERATURE = 1;
SENSOR_PRESSURE = 2;
}
message SensorReport {
uint32 node_id = 1;
uint32 uptime_s = 2;
uint32 tx_count = 3;
SensorType sensor_type = 4;
float value = 5;
string firmware_version = 6;
uint32 msg_id = 7;
uint32 hops = 8;
}
FieldTypeExampleDescription
node_iduint320xABCD1234Lower 32 bits of the ESP32’s factory MAC address. Unique per chip.
uptime_suint323600Seconds since the node last booted. Resets to 0 on reboot.
tx_countuint32720Total number of reports this node has sent. Useful for detecting missed packets.
sensor_typeenumSENSOR_TEMPERATUREWhat kind of sensor produced this reading.
valuefloat23.45The actual measurement (degrees C or hPa).
firmware_versionstring"2.0.0"Encoded as a max 16-byte fixed string (set by a nanopb option).
msg_iduint320xABCD00A3Unique packet identifier for deduplication. Computed as node_id XOR sequence_counter.
hopsuint321How many mesh relay hops this packet has traveled. 0 means received directly.

The msg_id is generated by XOR-ing the node’s ID with a monotonically increasing sequence counter:

return nodeId ^ (++_seqCounter);

This guarantees uniqueness because:

  • Two different nodes with the same sequence number XOR with different node IDs, producing different msg_ids.
  • The same node generates different msg_ids on each call because the counter increments.

Protobuf uses variable-length integer encoding and skips fields with zero/default values. A typical temperature report with all 8 fields encodes in roughly 22 to 30 bytes. This compares favorably to a raw C struct of 28 bytes, but with the added benefits of self-description and forward compatibility.

The file proto/messages.options contains Nanopb-specific constraints:

SensorReport.firmware_version max_size:16

This tells Nanopb to allocate a fixed 16-byte char array for the firmware version string instead of using dynamic memory. This is essential for microcontroller environments where heap allocation should be avoided.

In the firmware, encoding a report looks like:

SensorReport report = SensorReport_init_zero;
report.node_id = nodeId;
report.uptime_s = millis() / 1000;
report.tx_count = txCount;
report.sensor_type = SensorType_SENSOR_TEMPERATURE;
report.value = sensorValue;
// ... set other fields ...
uint8_t buffer[128];
pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
pb_encode(&stream, SensorReport_fields, &report);
// stream.bytes_written contains the encoded length

Decoding an incoming packet is the reverse:

SensorReport report = SensorReport_init_zero;
pb_istream_t stream = pb_istream_from_buffer(data, length);
pb_decode(&stream, SensorReport_fields, &report);
// report.node_id, report.value, etc. are now populated

If you modify messages.proto (for example, to add a new sensor type or field), regenerate the C bindings:

Terminal window
pip install nanopb
./scripts/generate_proto.sh

The updated .pb.h and .pb.c files are placed in firmware/node/lib/proto/.