Как стать автором
Обновить
611.01
OTUS
Цифровые навыки от ведущих экспертов

Эффективная передача данных: используем Protocol Buffers для коммуникации между ESP32 и QT/QML

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров2.1K
Автор оригинала: Mehmet Topuz

В этой статье я хочу рассказать вам о том, как можно эффективно использовать Protocol Buffers в сочетании с ESP32 и Qt Framework. Для наглядности я сделаю это на примере собственного хобби‑проекта, который представляет из себя гидропонную систему. В этом проекте мы попытаемся наладить коммуникацию между ESP32 и приложением Qt/QML, используя Protocol Buffers через UDP.

ESP32 выступает в роли встроенной системы гидропонной установки, а Qt/QML отвечает за мониторинг данных и управление. Но прежде чем мы приступим к реализации, давайте разберемся, что такое Protocol Buffers.

Что такое Protobuf?

Protocol Buffers, или Protobuf — это уникальный протокол сериализации данных, разработанный компанией Google. В отличие от традиционных текстовых форматов, таких как XML или JSON, Protobuf использует двоичный код для передачи данных, что обеспечивает высокую скорость за (счет легкости данных). Одна из ключевых особенностей Protobuf заключается в том, что он, подобно языку программирования, имеет свой собственный синтаксис и компилятор. Это позволяет легко передавать данные между различными платформами и делает его независимым от языков программирования. Protobuf поддерживает множество языков, включая C++, Java, Python, GO и многие другие. Мы можем использовать Protobuf в любом приложении, где нам требуется собственный метод коммуникации или структура пакетов. Однако наиболее часто он применяется в gRPC в качестве языка описания интерфейса. Сообщения Protobuf хранятся в файле с расширением .proto (например, person.proto) и описываются следующим образом:

syntax = "proto3"

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

Как мы уже упоминали выше, у Protobuf есть свой собственный компилятор, который позволяет нам создавать сообщения protobuf для любого языка, как показано ниже.

protoc --cpp_out=. person.proto

В результате сборки генерируются библиотечные файлы. Для приведенного выше proto‑файла они будут называться person_pb.cc и person_pb.h.

В этом примере я хочу изменить структуру сообщений, чтобы они стали более реалистичными, а также добавить вложенные сообщения. Proto‑файл под названием hydroponic_data.proto, который я буду использовать в этом примере, выглядит следующим образом:

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;
}

Еще один важный аспект, о котором стоит упомянуть, — это вложенные сообщения. Как вы могли заметить, выше было описано несколько сообщений, которые находились внутри других. В языке Protobuf такое расположение одного сообщения внутри другого называется вложенным. Ключевое слово oneof позволяет нам ограничить количество вложенных сообщений в одном сообщении. То есть, мы можем отправлять только одно из вложенных сообщений за раз.

Что такое Nanopb?

Для работы с Protocol Buffers на ESP32 мы будем использовать библиотеку Nanopb. Nanopb — это легковесная и эффективная библиотека на языке C для кодирования и декодирования Protobuf‑сообщений. Nanopb, разработанная компанией Espressif Systems, оптимизирована специально для микроконтроллеров. У нее есть собственный protobuf‑компилятор под названием protoc, который также генерирует библиотеки на языке C после сборки. Для использования вложенных сообщений с Nanopb нам нужно добавить следующие строки в наш proto‑файл.

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

После этого proto‑файл будет выглядеть следующим образом:

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;
}

Чтобы скомпилировать proto‑файл в соответствии с требованиями Nanopb, нам нужно использовать protoc, который находится в каталоге, откуда мы загрузили Nanopb.

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

Кроме того, для использования Protocol Buffers с ESP32, нам нужно добавить в проект библиотеки Nanopb, которые находятся в каталоге, куда мы скачали Nanopb.

Также нам необходимо реализовать коллбеки для кодирования и декодирования строк и вложенных сообщений на стороне Nanopb. Я реализовал эти функции в файлах protobuf_callbacks.h и protobuf_callbacks.c.

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};
    
    /* Мы можем читать блок за блоком, чтобы избежать большого размера буфера... */
    if (stream->bytes_left > sizeof(buffer) - 1)
        return false;
    if (!pb_read(stream, buffer, stream->bytes_left))
        return false;
    /* Выводим строку в формате, сравнимом с protoc --decode.
     * Формат берется из arg, определенного в 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;
}

После этого мы готовы использовать Protocol Buffers с ESP32.

...
hydroponic_Hydroponic messageToSend = hydroponic_Hydroponic_init_zero;

messageToSend.messageType = hydroponic_MessageType_MSG_DATA; 
messageToSend.which_msg = hydroponic_Hydroponic_dataPackage_tag;  // Решаем, какое сообщение будет отправлено. 

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;
...

Таким образом мы можем создавать сообщения. После создания сообщения и назначения данных определенным полям, мы можем сериализовать сообщение с помощью функции кодирования следующим образом:

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

Затем мы можем отправить buffer, представляющий сериализованное сообщение, используя любой предпочитаемый нами протокол связи:

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

Если кто‑то отправит protobuf‑сообщение на ESP32, мы получим его в виде массивов байтов. Для десериализации сообщения мы можем воспользоваться коллбеком декодирования:

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);
}
...
...

Поддержка на стороне QT

Чтобы использовать Protocol Buffers в нашем Qt‑проекте, нам необходимо сначала скомпилировать их под C++. Вы можете ознакомиться с документацией по сборке Protocol Buffers для C++ на странице проекта Protobuf на GitHub. Qt также добавила поддержку Protocol Buffers с версии 6.6.1. В этой версии появилась возможность создавать классы protobuf на основе Qt, но я пока не пробовал этот метод. Вместо этого я покажу вам, как добавить статическую библиотеку protobuf в ваш Qt‑проект.

GitHub - protocolbuffers/protobuf: Protocol Buffers — формат обмена данными от Google

Для начала нам необходимо добавить в наш Qt‑проект статическую библиотеку (lib‑файл) и заголовочные файлы, которые были созданы при сборке из исходного кода. Чтобы сделать это, добавьте следующие строки в .pro‑файл Qt‑проекта:

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

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

Также необходимо добавить следующую строку для активации режима отладки библиотеки во время выполнения:

QMAKE_CXXFLAGS_DEBUG += /MTd

Теперь мы готовы создать файл hydroponic_data.proto для C++. В этом proto‑файле я удалил строки, касающиеся Nanopb, поскольку мы будем использовать собранный нами компилятор protobuf, скачанный с Github. Если этого не сделать, то компилятор будет выдавать ошибки на строках, связанных с Nanopb.

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

После того как сгенерированная нами библиотека protobuf‑сообщений будет добавлена, мы сможем использовать Protocol Buffers в нашем Qt‑проекте.

#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(); // изменим тип возвращаемого значения позже
    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;

    //Объявление классов protobuf-сообщений
    Hydroponic hydroponicMessage;    // Сообщение верхнего уровня
    DataPackage dataMessage;
    HeartBeat heartBeatMessage;
    MessageOk messageOk;
    MessageError messageError;
    MessageTimeout messageTimeout;

    HydroponicMessageType messageType;

    bool parseProtobuf(const QByteArray arr);

    /*
     * Мы не можем получить доступ к enum, определенному в классе Hydroponic, из QML.
     * Поэтому я хочу выполнить преобразование enum через look-up таблицу.
     * */
    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

Я написал класс с именем ProtobufManager, который станет нашим главным инструментом для работы с Protocol Buffers. Этот класс будет отвечать за кодирование и декодирование сообщений на строне бэкенда, а также за отправку их в ESP32, когда это необходимо. Кроме того, мы сможем использовать этот класс и на стороне QML, добавив следующую строку в файл main.cpp:

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

Теперь давайте рассмотрим, как мы можем применить сгенерированные protobuf‑классы в нашей программе. Прежде всего, мы определим класс сообщения верхнего уровня:

hydroponic::Hydroponic hydroponicMessage;

После этого мы определим еще один класс для вложенного сообщения:

hydroponic::Command cmdMessage;

Далее, зададим поля вложенного сообщения и сообщения верхнего уровня, если они существуют:

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

Для сообщения верхнего уровня мы установим, с каким вложенным сообщением оно связано:

hydroponicMessage.set_allocated_cmd(&cmdMessage);

После этого сообщение можно сериализовать:

QByteArray arr;
arr.resize(hydroponicMessage.ByteSizeLong());
// сериализация в массив
hydroponicMessage.SerializeToArray(arr.data(), arr.size());

Теперь мы готовы отправить сериализованное сообщение, используя любой доступный протокол связи. В нашем примере, поскольку на стороне ESP32 мы использовали протокол UDP, сериализованное hydroponicMessage, содержащее cmdMessage, также будет отправлено по протоколу UDP.

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

Чтобы декодировать сообщение, полученное от ESP32, нужно выполнить следующие действия.

Мы можем использовать следующую функцию‑парсер после того, как закодированные данные были успешно получены по UDP.

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

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

switch (this->hydroponicMessage.messagetype()) { // или мы можем использовать метод hydroponicMessage.has_datapackage().
    case MessageType::MSG_DATA:
         qInfo() << "data packet received";
         this->dataMessage = this->hydroponicMessage.datapackage();
         this->messageType = HydroponicMessageType::DATA;
         break;
...
}

Мы также можем использовать метод has класса Hydroponic, чтобы определить, какое сообщение было получено.

hydroponicMessage.has_datapackage()

Наконец, мы можем получить данные вложенного сообщения с помощью метода get. Вот и все. Работать с Protocol Buffers в C++, как видите, довольно просто. И вы можете использовать полученные данные в любом месте вашего Qt‑приложения.

Отображение данных в QML

Давайте отобразим данные, декодированные из protobuf‑сообщения, в пользовательском интерфейсе, созданном с помощью QML. Мы можем использовать класс ProtobufManager в QML с тем же именем, что и компонент QML, поскольку мы объявили его с тем же именем ранее, используя qmlRegisterType.

ProtobufManager{
    id: protobufManager

    property  int xVal: 0
    onMessageReceived: {    // Срабатывает при получении сообщения.
        // Проверяем тип сообщения

        switch(protobufManager.getMessageType()){
        case ProtobufManager.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:

            // Делаем что-то
            break;

        case ProtobufManager.MESSAGE_OK:

            // Делаем что-то
            break;

        case ProtobufManager.MESSAGE_ERROR:

            // Делаем что-то
            break;

        case ProtobufManager.MESSAGE_TIMEOUT:

            // Делаем что-то
            break;

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

Мы можем воспользоваться механизмом сигналов и слотов на стороне C++, чтобы понять, было ли получено сообщение.

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

Protobuf‑сообщение можно отправлять в различных условиях, которые нам необходимы.

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)
    }

}

Я разработал временный пользовательский интерфейс для мониторинга данных, получаемых от ESP32. Он выглядит довольно просто, но я все еще работаю над ним🙂

Примечание: Все изображения и иконки, используемые в этом пользовательском интерфейсе, были взяты с сайтов freepik.com и flaticon.com.

Пользовательский интерфейс для гидропонной системы

Hydroponic UI
Hydroponic UI

Заключение

Как мы увидели, Protocol Buffers предлагает более компактный формат для обмена данными по сравнению с JSON и XML. Его преимущество заключается в более высокой скорости передачи данных благодаря уменьшенному размеру. Protocol Buffers могут быть использован с различными протоколами связи, такими как UART, SPI и I2C, между двумя встроенными системными устройствами, где требуется создание пользовательских пакетов, а не только для связи между серверами или клиентами. Это позволяет сэкономить время на разработке структуры пакета, его создании, синтаксическом анализе и других аспектах, связанных с обменом данными между двумя устройствами. Но, конечно, всегда стоит учитывать время, необходимое для интеграции Protocol Buffers во встроенные системы.

В рамках этой статьи невозможно продемонстрировать весь код, поэтому я постарался разобрать как можно больше примеров, связанных с Protocol Buffers. Если вы хотите увидеть полный код для ESP32 и Qt/QML, вы можете посетить мою страницу на Github.


В завершение нашего погружения в мир Protocol Buffers и эффективной передачи данных между ESP32 и Qt/QML, я рад пригласить вас на открытый урок по теме «Путешествие в мир Авроры. Искусство создания приложений с Qt/QML», где мы получим практический опыт создания простого приложения на QML, взаимодействующего с графической подсистемой. Урок пройдет 22 апреля, записывайтесь по ссыке.

Открытые уроки в Otus проходят каждый день — переходите в календарь мероприятий и выбирайте интересующие темы.

Теги:
Хабы:
Всего голосов 12: ↑6 и ↓6+2
Комментарии1

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS