Using Protocol Buffers Between ESP32 and QT/QML: Efficient Data Communication

Mehmet Topuz
11 min readFeb 16, 2024

--

In this article, I want to demonstrate how to use Protocol Buffers with ESP32 and the Qt Framework. I want to show this example on my hobby project that is Hydroponic System. In this example, We will try to communicate between ESP32 and Qt/QML application using Protocol Buffers over UDP.

ESP32 represents the embedded system side of hydroponic system, while Qt/QML represents operations such as data monitoring and control. First of all, let’s talk about what Protocol Buffers is.

What is Protobuf?

Protocol Buffers aka Protobuf is a serialization method developed by Google. Protobuf uses a binary format for serialization, unlike text-based formats such as XML or JSON. Therefore, it provides lightweight and faster communication. One of the main features of protobuf is that it has own syntax and compiler, similar to a programming language. Consequently, it facilitates easy data transfer between different platforms and independence from programming languages. Protobuf supports many languages like C++, Java, Python, GO etc. We can use protobuf in any application where we need our own communication method or packet structure, even though it’s mostly used with gRPC. Protobuf messages can be described as follows, and these messages must be written in a .proto file, such as person.proto.

syntax = "proto3"

message Person {
optional string name = 1;
optional int32 id = 2;
optional string email = 3;
}

We know that Protobuf has its own compiler, so we can build protobuf messages for the target language as described below.

protoc --cpp_out=. person.proto

Library files are generated after the build. The library files will be named person_pb.cc and person_pb.h for the example proto file above.

In this example, I want to change the message structure to be more realistic, including submessages. The protobuf file, named as hydroponic_data.proto, which I will use in this example, is as follows.

syntax = "proto3";

package hydroponic;

enum MessageType {
MSG_HEART_BEAT = 0;
MSG_OK = 1;
MSG_ERROR = 2;
MSG_DATA = 3;
MSG_TIMEOUT = 4;
MSG_CMD = 5;
}

enum CMD {
CMD_VALVE_ON = 0;
CMD_VALVE_OFF = 1;
CMD_PUMP_ON = 2;
CMD_PUMP_OFF = 3;
CMD_LED_ON = 4;
CMD_LED_OFF = 5;
}

message Hydroponic {
MessageType messageType = 1;

oneof msg {
DataPackage dataPackage = 2;
HeartBeat heartBeat = 3;
MessageOk messageOk = 4;
MessageError messageError = 5;
MessageTimeout messageTimeout = 6;
Command cmd = 7;
}
}

message DataPackage {
uint32 deviceID = 2;
string sector = 3;
float eConductivity = 4;
float ph = 5;
float moisture = 6;
float temperature = 7;
uint32 waterLevel = 8;
bool valveState = 9;
bool pumpState = 10;
bool ledStatus = 11;
}

message HeartBeat {
uint32 elapsedTime = 1;
}

message MessageOk {
string responseMessage = 1;
}

message MessageError {
string errorType = 1;
}

message MessageTimeout {
string timeoutMessage = 1;
}

message Command {
CMD command = 1;
}

Another issue that I want to mention about is the submessages. Above, you can see more than one message described within another message. The message inside of another message is called submessage in Protobuf. The purpose of using the oneof keyword is that the message has only one submessage. In other words, we can send one of the submessages at a time.

What is Nanopb?

We will use the Nanopb library on the ESP32 for Protocol Buffers. Nanopb is a lightweight and efficient Protocol Buffers library that can be used with the C language. Nanopb, developed by Espressif Systems, is optimized especially for microcontrollers. It has its own protobuf compiler, which uses the same name protoc and generates C libraries after the build. We have to add following lines to proto file to use submessages with Nanopb.

import 'nanopb.proto';
...
option (nanopb_msgopt).submsg_callback = true;

After that, the proto file looks like the following.

syntax = "proto3";

import 'nanopb.proto';

package hydroponic;

enum MessageType {
MSG_HEART_BEAT = 0;
MSG_OK = 1;
MSG_ERROR = 2;
MSG_DATA = 3;
MSG_TIMEOUT = 4;
MSG_CMD = 5;
}

enum CMD {
CMD_VALVE_ON = 0;
CMD_VALVE_OFF = 1;
CMD_PUMP_ON = 2;
CMD_PUMP_OFF = 3;
CMD_LED_ON = 4;
CMD_LED_OFF = 5;
}

message Hydroponic {
MessageType messageType = 1;

option (nanopb_msgopt).submsg_callback = true;

oneof msg {
DataPackage dataPackage = 2;
HeartBeat heartBeat = 3;
MessageOk messageOk = 4;
MessageError messageError = 5;
MessageTimeout messageTimeout = 6;
Command cmd = 7;
}
}

message DataPackage {
uint32 deviceID = 2;
string sector = 3;
float eConductivity = 4;
float ph = 5;
float moisture = 6;
float temperature = 7;
uint32 waterLevel = 8;
bool valveState = 9;
bool pumpState = 10;
bool ledStatus = 11;
}

message HeartBeat {
uint32 elapsedTime = 1;
}

message MessageOk {
string responseMessage = 1;
}

message MessageError {
string errorType = 1;
}

message MessageTimeout {
string timeoutMessage = 1;
}

message Command {
CMD command = 1;
}

To compile the proto file according to Nanopb, we need to use the protoc located in the directory where we downloaded Nanopb.

pathto\generator-bin\protoc.exe --nanopb_out=. hydroponic_data.proto

We need to add the Nanopb libraries, located in the same directory where we downloaded Nanopb, to the project in order to use protobuf with ESP32.

Also, we need to implement callback functions to encode/decode strings and submesssages on the Nanopb side. I implemented these functions in the protobuf_callbacks.h and protobuf_callbacks.c files.

bool write_string(pb_ostream_t *stream, const pb_field_iter_t *field, void * const *arg)
{
if (!pb_encode_tag_for_field(stream, field))
return false;

return pb_encode_string(stream, (uint8_t*)*arg, strlen((char*)*arg));
}

bool read_string(pb_istream_t *stream, const pb_field_t *field, void **arg)
{
uint8_t buffer[128] = {0};

/* We could read block-by-block to avoid the large buffer... */
if (stream->bytes_left > sizeof(buffer) - 1)
return false;
if (!pb_read(stream, buffer, stream->bytes_left))
return false;
/* Print the string, in format comparable with protoc --decode.
* Format comes from the arg defined in main().
*/
//printf((char*)*arg, buffer);
strcpy((char*)*arg, (char*)buffer);
return true;
}

bool msg_callback(pb_istream_t *stream, const pb_field_t *field, void **arg)
{

// hydroponic_Hydroponic *topmsg = field->message;
// ESP_LOGI(TAG,"Message Type: %d" , (int)topmsg->messageType);

if (field->tag == hydroponic_Hydroponic_dataPackage_tag)
{
hydroponic_DataPackage *message = field->pData;

message->sector.funcs.decode =& read_string;
message->sector.arg = malloc(10*sizeof(char));

}

else if (field->tag == hydroponic_Hydroponic_messageOk_tag)
{
hydroponic_MessageOk *message = field->pData;

message->responseMessage.funcs.decode =& read_string;
message->responseMessage.arg = malloc(50*sizeof(char));

}

else if (field->tag == hydroponic_Hydroponic_messageError_tag)
{
hydroponic_MessageError *message = field->pData;

message->errorType.funcs.decode =& read_string;
message->errorType.arg = malloc(50*sizeof(char));

}

else if (field->tag == hydroponic_Hydroponic_messageTimeout_tag)
{
hydroponic_MessageTimeout *message = field->pData;

message->timeoutMessage.funcs.decode =& read_string;
message->timeoutMessage.arg = malloc(50*sizeof(char));

}

return true;
}

After that, we are ready to use protobuf with the ESP32.

...
hydroponic_Hydroponic messageToSend = hydroponic_Hydroponic_init_zero;

messageToSend.messageType = hydroponic_MessageType_MSG_DATA;
messageToSend.which_msg = hydroponic_Hydroponic_dataPackage_tag; // Decide which message will be sent.

messageToSend.msg.dataPackage.deviceID = 10;
messageToSend.msg.dataPackage.sector.arg = "Sector-1";
messageToSend.msg.dataPackage.sector.funcs.encode =& write_string;
messageToSend.msg.dataPackage.temperature = 10.0f;
...

A message can be created as above. After creation a message and assigned the data to specific fields. We can serialize the message using encode function as follows.

uint8_t buffer[128] = {0};
...
pb_ostream_t ostream = pb_ostream_from_buffer(buffer, sizeof(buffer));
pb_encode(&ostream, hydroponic_Hydroponic_fields, &messageToSend);
...

We can send the buffer that represents serialized message over any communication protocol we want.

sendto(socket, buffer, ostream.bytes_written, 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr));

We receive the protobuf messages as byte arrays if someone sends the protobuf messages to ESP32. We can use decode and callback functions to deserialize the message.

hydroponic_Hydroponic receivedMessage = hydroponic_Hydroponic_init_zero;
...
pb_istream_t istream = pb_istream_from_buffer(buffer, len);
receivedMessage.cb_msg.funcs.decode = &msg_callback;

bool ret = pb_decode(&istream, hydroponic_Hydroponic_fields, receivedMessage);
...
if(receivedMessage.which_msg == hydroponic_Hydroponic_dataPackage_tag){

ESP_LOGI(TAG, "Data Package Received.");
ESP_LOGI(TAG, "Device ID: %ld", receivedMessage.msg.dataPackage.deviceID);
...
}
else if(receivedMessage.which_msg == hydroponic_Hydroponic_heartBeat_tag){
ESP_LOGI(TAG, "Heartbeat Package Received.");
ESP_LOGI(TAG, "Elapsed time %ld.", receivedMessage.msg.heartBeat.elapsedTime);
}
...
...

How to use with QT?

To be able to use Protocol Buffers on the Qt side, we need to compile it according to C++. You can read the documentation of how to build Protobuf for C++ on the Protobuf GitHub page. Also, Qt has started to support Protocol Buffers with the Qt 6.6.1 version. It can generate Qt-based protobuf classes, but I have not tried it yet. Therefore, I will show you how to add the static library of protobuf to a Qt project.

We need to add the static library, such as .lib file, and header files that are generated after the build from the source to the Qt project. In order to do that, we need to add the following lines to .pro file of the Qt project.

LIBS += -L$$PWD/protobuf/ -llibprotobufd

INCLUDEPATH += $$PWD/protobuf
INCLUDEPATH += $$PWD/protobuf/include
DEPENDPATH += $$PWD/protobuf

Also we need to add the following line necessary for the Run-Time Library Debug mode.

QMAKE_CXXFLAGS_DEBUG += /MTd

We are ready to build hydroponic_data.proto file for C++. In this proto file, I deleted the lines related to Nanopb because we will use the protobuf compiler downloaded and built form Github. This compiler will generate errors at lines related to Nanopb.

pathto\protobuf\install\bin\protoc.exe --cpp_out=. hydroponic_data.proto

After including the generated protobuf message library, we can use protobuf in the Qt project.

#ifndef PROTOBUFMANAGER_H
#define PROTOBUFMANAGER_H

#include <QObject>
#include <QMap>
#include "hydroponic_data.pb.h"
#include "udphandler.h"

using namespace hydroponic;

class ProtobufManager : public QObject
{
Q_OBJECT
public:
explicit ProtobufManager(QObject *parent = nullptr);
~ProtobufManager();

enum HydroponicMessageType{
DATA = 0,
HEART_BEAT,
MESSAGE_OK,
MESSAGE_ERROR,
MESSAGE_TIMEOUT
};
Q_ENUM(HydroponicMessageType)

enum HydroponicCMD{
CMD_VALVE_ON = 0,
CMD_VALVE_OFF = 1,
CMD_PUMP_ON = 2,
CMD_PUMP_OFF = 3,
CMD_LED_ON = 4,
CMD_LED_OFF = 5,
};

Q_ENUM(HydroponicCMD)

Q_INVOKABLE ProtobufManager::HydroponicMessageType getMessageType();
Q_INVOKABLE int getDeviceId();
Q_INVOKABLE QString getSectorName();
Q_INVOKABLE float getECval();
Q_INVOKABLE float getPh();
Q_INVOKABLE float getMoisture(); // change return type later
Q_INVOKABLE float getTemperature();
Q_INVOKABLE int getWaterLevel();
Q_INVOKABLE bool getValveState();
Q_INVOKABLE bool getPumpState();
Q_INVOKABLE bool getLedState();
Q_INVOKABLE void sendCommand(ProtobufManager::HydroponicCMD command);


signals:
void messageReceived();

public slots:
void packageReceived();

private:
UdpHandler *udpHandler = nullptr;

//Class declaration of protobuf messages
Hydroponic hydroponicMessage; // Top level message
DataPackage dataMessage;
HeartBeat heartBeatMessage;
MessageOk messageOk;
MessageError messageError;
MessageTimeout messageTimeout;

HydroponicMessageType messageType;

bool parseProtobuf(const QByteArray arr);

/*
* We cannot access an enum defined inside the Hydroponic class from QML.
* Therefore, I want to perform an enum conversion through a look-up table.
* */
QMap<HydroponicCMD,hydroponic::CMD> cmdLookUpTable = {
{HydroponicCMD::CMD_VALVE_ON, hydroponic::CMD::CMD_VALVE_ON},
{HydroponicCMD::CMD_VALVE_OFF, hydroponic::CMD::CMD_VALVE_OFF},
{HydroponicCMD::CMD_PUMP_ON, hydroponic::CMD::CMD_PUMP_ON},
{HydroponicCMD::CMD_PUMP_OFF, hydroponic::CMD::CMD_PUMP_OFF},
{HydroponicCMD::CMD_LED_ON, hydroponic::CMD::CMD_LED_ON},
{HydroponicCMD::CMD_LED_OFF, hydroponic::CMD::CMD_LED_OFF}
};
};

#endif // PROTOBUFMANAGER_H

I created a class named ProtobufManager for protobuf operations. This class will encode/decode the messages at the back-end and send the messages to ESP32 if necessary. Also, we will be able to use this class on the QML side by adding the following line to main.cpp.

qmlRegisterType<ProtobufManager>("com.protobuf", 1, 0, "ProtobufManager");

Let’s see how we can use generated protobuf classes. First of all, we create a top-level message class.

hydroponic::Hydroponic hydroponicMessage;

After that, we create another class for the submessage.

hydroponic::Command cmdMessage;

We set the fields of submessage or top-level message if exists.

cmdMessage.set_command(hydroponic::CMD::CMD_VALVE_ON);

The top-level message is configured with which submessage it has.

hydroponicMessage.set_allocated_cmd(&cmdMessage);

The message can be serialized now.

QByteArray arr;
arr.resize(hydroponicMessage.ByteSizeLong());
//serialize to array
hydroponicMessage.SerializeToArray(arr.data(), arr.size());

We can send the serialized message using any communication protocol. In this example, since we used UDP on the ESP32 side, the serialized hydroponic message containing the cmd message has been sent over UDP here as well.

this->udpHandler->sendBytes(arr, this->udpHandler->getSenderAddress(), this->udpHandler->getSenderPort());

The following steps can be followed to decode the message received from ESP32.

We can use the following parse function after the encoded data has been successfully received over UDP.

...
auto result = this->hydroponicMessage.ParseFromArray(arr.data(), arr.size());

if(!result){
qInfo() << "Protobuf Parse Error";
return false;
}

switch (this->hydroponicMessage.messagetype()) { // or we can use hydroponicMessage.has_datapackage() method.
case MessageType::MSG_DATA:
qInfo() << "data packet received";
this->dataMessage = this->hydroponicMessage.datapackage();
this->messageType = HydroponicMessageType::DATA;
break;
...
}

We can also use the has methods of Hydroponic class in order to determine which submessage is received.

hydroponicMessage.has_datapackage()

After that we can obtain the data of the submessage using the get methods of the submessage class. That’s all. Using protobubuf on the C++ side is as simple as that. We can use the data we obtained anywhere in our Qt app.

Displaying the data on the QML

Let’s display the data decoded from the protobuf message on the UI designed with QML. We can use ProtobufManager class on the QML with the same name like a QML component because we declared with the same name like previously using qmlRegisterType.

ProtobufManager{
id: protobufManager

property int xVal: 0
onMessageReceived: { // It will be triggered when a message is received.
// check message type

switch(protobufManager.getMessageType()){
case ProtobufManager.DATA:
// get data
sectorText.txt = protobufManager.getSectorName()
deviceIdText.txt = protobufManager.getDeviceId()
waterLevel.level = protobufManager.getWaterLevel()
temperature.temperatureVal = protobufManager.getTemperature()
ph.phVal = protobufManager.getPh()
humidity.humidityVal = protobufManager.getMoisture()
//eConductivity.eConductivityVal = protobufManager.getECval()
eConductivity.appendData(xVal++,protobufManager.getECval())
waterPumpOfTank.pumpState = protobufManager.getPumpState()
valveOfTank.valveState = protobufManager.getValveState()
ledButton.buttonState = protobufManager.getLedState()
break;

case ProtobufManager.HEART_BEAT:

// do stuff
break;

case ProtobufManager.MESSAGE_OK:

// do stuff
break;

case ProtobufManager.MESSAGE_ERROR:

// do stuff
break;

case ProtobufManager.MESSAGE_TIMEOUT:

// do stuff
break;

default:
console.log("Invalid Message Type.")
break;
}
}
}

We can use the signal/slot mechanism on the C++ side to determine whether a message is received or not.

// protobuf_manager.h
...
signals:
void messageReceived();
...

The protobuf message can be sent as follows under any desired condition.

PumpIndicator{
id: waterPumpOfTank
width: 300
height: 300
anchors.top: eConductivity.top
anchors.left: eConductivity.right
anchors.leftMargin: 50

pumpState: true
pumpText: "Tank Water Pump"

onPumpClicked: {
if(pumpState)
protobufManager.sendCommand(ProtobufManager.CMD_PUMP_ON)
else
protobufManager.sendCommand(ProtobufManager.CMD_PUMP_OFF)
}

}

I designed a temporary UI as follows to monitor the data received from ESP32. It looks simple, but I am still working on it🙂

Note: All the pictures and icons used on this UI have been taken from the freepik.com and flaticon.com websites.

Hydroponic UI

Conclusion

As seen, Protocol Buffer utilizes a smaller size in communication compared to JSON and XML. It has the advantage of faster communication due to its smaller size. It can be used on the desired communication protocol (UART, SPI, I2C, etc.) between two embedded system devices where we need a custom packet, not just for server-server or server-client communication. Thus, we can gain time saved from designing the packet structure, creating the packet, parsing, etc. required to communicate between two devices. Of course, not counting the time spent on integrating protobuf into embedded systems🙂

It is not possible to show all the codes in this article. Therefore, I tried to show the codes related to Protobuf as much as possible. You can visit my Github page to see all the ESP32 and Qt/QML codes.

References

--

--