Взаимодействие с устройствами через ModBus RTU в Qt

Недавно мне на аутсорсе предложили выполнить небольшую работу: разработать ПО с графическим интерфейсом для управления источником питания по протоколу ModBus RTU. Не раздумывая я согласился решить поставленную задачу, однако меня ждал “интересный” сюрприз, вызванный моей же невнимательностью. В этой заметке я приведу пример работы с протоколом ModBus RTU в Qt с подробными коментариями, а заодно расскажу о своей досадной оплошности допущенной при решении этой задачи.

Задача и начальные данные

Прежде всего определим задачу: разработать ПО для управления источником питания по протоколу ModBus RTU с возможностью указания адреса устройства, параметров COM-порта, и управлением некоторым количеством параметров (запись и чтение заданных регистров). 

Для отладки мне выдали какую-то железяку с поднятым на ней модбасом и возможностью хранить записанные ранее данные. Всё! Больше никакого функционала, полезного действия и смысловой нагрузки она не несёт. В прошлом это была некая плата “универсального интерфейса” – преобразователя различных протоколов обмена данных в привычный UART.

Симулятор устройства работающего про протоколу Modbus RTU
Моя отладочная железка с модбасом

Начальные данные: перед этой задачей я уже делал подобное ПО для этой компании с одним лишь отличием – там управление осуществлялось целой сетью устройств с индивидуальным управлением каждым источником. Таким образом, я рассчитывал значительно упростить себе жизнь используя модули, созданные для решения прошлой задачи.

Поспешишь – людей насмешишь. Или лишний код напишешь…

Народная мудрость….. дополненная мною 🙂

Однако, в предыдущей задаче я допустил досадную для меня оплошность, суть которой в том, что я использовал лишь библиотеки для работы с последовательным портом, а реализацию протокола ModBus взял на себя. Почему я так сделал? *смех* В общем: “поспешишь – людей насмешишь!”, или свой код напишешь. Всё уже давно изобретено за нас. На помощь пришла встроенная библиотека, а именно класс QModBusRtuSerialMaster, наследуемый от QModBusDevice. Приступим!

Пишем код!

При реализации были использованы примеры программного кода из открытой документации Qt с ресурса doc.qt.io, которые были адаптированы для решения поставленной мне задачи.

Запускаем среду, создаем проект и приступаем к редактированию файла *.pro. Дополним параметр QT параметрами “serialbus serialport“. Должно получиться нечто такое:

QT       += core gui serialbus serialport

Закрываем файл .pro и перейдем к форме главного окна программы QMainWindow. Начнем с заголовочного файла. Прототипы функций я пояснять сейчас не буду, а сделаю это позже, когда будем описывать реализацию класса.

// mainwindow.h
#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QSerialPort>
#include <QModbusDataUnit>
#include <QModbusRtuSerialMaster>

class QModbusClient;

namespace Ui {
class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = nullptr);
    ~MainWindow();
    QModbusDataUnit readRequest(int, int) const;
    QModbusDataUnit writeRequest(int, int) const;

private:
    Ui::MainWindow *ui;
    QModbusClient *modbusDevice;

private slots:
    void onStateChanged(int);
    void readReady();
    void prepareWrite(int, int);
    void prepareRead(int, int);
    void errorMessage(QString);
    void on_connectButton_clicked();
};

#endif // MAINWINDOW_H

Далее будем реализовывать интерфейс класса. Конструктор и деструктор класса.

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow),
    modbusDevice(nullptr) // Предварительно инициализируем указатель "нулевым" указателем
{
    ui->setupUi(this);

    /*
    * Создаем экземпляр класса QModbusRtuSerialMaster
    * Связываем сигнал errorOccurred с методом класса errorString(), 
    * обрабатывающим ошибки.
    */
    modbusDevice = new QModbusRtuSerialMaster(this);
    connect(modbusDevice, &QModbusClient::errorOccurred, [this](QModbusDevice::Error) {
        errorMessage(modbusDevice->errorString());
    });

    /*
    * Также соединяем сигнал изменения состояния объекта modbusDevice
    * с методом onStateChanged, где мы будем обрабатывать изменения состояния (подключение или отключение порта)
    */
    if(modbusDevice) {
        connect(modbusDevice, &QModbusClient::stateChanged, this, &MainWindow::onStateChanged);
    }
}

MainWindow::~MainWindow() {
    /* При завершении работы окна вызываем функцию отключения порта, 
    * если указатель на объект modbusDevice существует. Если соединение
    * отсутствует, то функция не сделает ничего. 
    * После чего удаляем экземпляр объекта.
    */
    if(modbusDevice)
        modbusDevice->disconnectDevice();
    delete modbusDevice;

    delete ui;
}

Опишем используемые в коде выше методы: errorMessage(..) и onStateChanged(..). Метод errorMessage(…) выводит переданное в него сообщение в статус-баре окна программы и как отладочную информацию. onStateChanged(…) в зависимости от состояния порта (подключен или отключен)  изменяет текст кнопки подключения connectButton и также выводит отладочное сообщение.

void MainWindow::errorMessage(QString msg) {
    /* STATUSBAR_MESSAGE_TIMEOUT - константа, определяющая время 
    * "свечения" сообщения об ошибке в статус баре приложения
    */
    ui->statusBar->showMessage(msg, STATUSBAR_MESSAGE_TIMEOUT);
    qInfo() << "Error message: " << msg;
}

void MainWindow::onStateChanged(int state) {
    if(state == QModbusDevice::UnconnectedState) {
        ui->connectButton->setText("Подключить COM-порт");
        qInfo() << "Порт закрыт";
    } else if (state == QModbusDevice::ConnectedState) {
        ui->connectButton->setText(settings->getPortName() + QString("   ") + QString::number(settings->getBaudRate()) + " bps");
        qInfo() << "Порт открыт: " << settings->getPortName() + QString("   ") + QString::number(settings->getBaudRate()) + " bps";
    } else {
        return; // exit from function
    }
}

Далее возникает закономерный вопрос: “Если у нас есть метод обработки изменения состояния подключения, то как мы создадим или уничтожим это подключение?”. Вот для этого давайте и займёмся реализацией следующего метода-обработчика нажатия кнопки connect.

void MainWindow::on_connectButton_clicked()
{
    /*
    * Если экземпляр объекта по каким-либо причинам не существует, 
    * то во избежание критических ошибок и проблем покидаем функцию
    */
    if(!modbusDevice) return;

    /* Если порт не подключен, то настраиваем его и пытаемся установить соединение.
    * Если соединение установлено, то по нажатию кнопки мы разрываем соединение
    * Функция setConnectionParameter принимает 2 параметра:
    * 1) название устанавливаемого параметра
    * 2) значение параметра
    */
    if(modbusDevice->state() != QModbusDevice::ConnectedState) {
        modbusDevice->setConnectionParameter(QModbusDevice::SerialPortNameParameter, settings->getPortName());
        // Объект settings в моей программе хранит и возвращает заданные пользователем параметры COM-порта
        modbusDevice->setConnectionParameter(QModbusDevice::SerialDataBitsParameter, QSerialPort::Data8);
        modbusDevice->setConnectionParameter(QModbusDevice::SerialParityParameter, QSerialPort::NoParity);
        modbusDevice->setConnectionParameter(QModbusDevice::SerialBaudRateParameter, settings->getBaudRate());
        modbusDevice->setConnectionParameter(QModbusDevice::SerialStopBitsParameter, QSerialPort::TwoStop);
        // COM_TIMEOUT - константа. Задает время таймаута в милисекундах.
        modbusDevice->setTimeout(COM_TIMEOUT);
        // Пробуем установить соединение и в случае ошибки выводим соответствующее сообщеие
        if(!modbusDevice->connectDevice()) {
            errorMessage("Подключение неудалось: " + modbusDevice->errorString());
        }
    } else {
        modbusDevice->disconnectDevice();
    }
}

Далее реализуем функционал чтения данных с устройства. Нам понадобиться 3 функции. Функция readRequest(…) формирует “единицу данных”, которая определяет тип запрашиваемого элемента согласно протоколу Modbus (holding register в моём случае), стартовый адрес и количество считываемых элементов. В моём случае это достаточно тривиальная функция, так что её можно было и упразднить, подставив возвращаемое выражение напрямую в функцию prepareRead().

Аналогичную ситуацию вы увидите позже при рассмотрении реализации функционала записи данных.

// Входные параметры: startAddress - адрес первого элемента, count - количество элементов (1 или более).
QModbusDataUnit MainWindow::readRequest(int startAddress, int count) const {
    return QModbusDataUnit(QModbusDataUnit::HoldingRegisters, startAddress, count);
}

// Входные параметры: startAddress - адрес первого элемента, count - количество элементов (1 или более).
void MainWindow::prepareRead(int startAddress, int count) {
    if(!modbusDevice) return;
    /* "Единица данных" передается в качестве параметра в функцию sendReadRequest(..) совместно с адресом устройства.
     * Если запрос произведен - удаляем ссылку на указатель ответа.
     * Если запрос ещё не закончен (в процессе), то связываем сигнал об окончании запроса
     * с функцией обработчика прочитанных данных
     */
    if(auto *lastRequest = modbusDevice->sendReadRequest(readRequest(startAddress, count), settings->getAddress())) {
        if(!lastRequest->isFinished()) {
            connect(lastRequest, &QModbusReply::finished, this, &MainWindow::readReady);
        } else {
            delete lastRequest;
        }
    } else {
        errorMessage(tr("Ошибка чтения: ") + modbusDevice->errorString());
    }
}

Третьей функцией, завершающей реализацию функционала чтения будет обработчик поступивших данных readReady(..)

void MainWindow::readReady()
{
    auto reply = qobject_cast<QModbusReply *>(sender());
    if (!reply)
        return;

    if (reply->error() == QModbusDevice::NoError) {
        const QModbusDataUnit unit = reply->result();
        QStringList resultsList;
        // Формируем список результатов в виде строк entry И складываем их в контейнер resultsList
        for (uint i = 0; i < unit.valueCount(); i++) {
            QString entry = tr("Address: %1, Value: %2").arg(unit.startAddress())
                                     .arg(QString::number(unit.value(i));
           results += entry;
        }
    } else if (reply->error() == QModbusDevice::ProtocolError) {
        // Выводим сообщение об ошибке протокола используя объект reply->errorString();
    } else {
        // Выводим сообщение об остальных типах ошибки используя объект reply->errorString();
        // В принципе, можно убрать столь подробное деление обработчика ошибок, оставив только лишь этот блок.
    }

    reply->deleteLater();
}

Реализация функционала записи требует меньшего количества функций, а именно две, хотя, повторюсь, в данной задаче, можно упразднить одну из функций, подставив возвращаемое выражение функции writeRequest(…) вместо её вызова.

// Входные параметры: startAddress - адрес первого элемента, count - количество элементов (1 или более).
QModbusDataUnit MainWindow::writeRequest(int startAddress, int count) const {
    return QModbusDataUnit(QModbusDataUnit::HoldingRegisters, startAddress, count); 
}

// Входные параметры: startAddress - адрес первого элемента, count - количество элементов (1 или более).
void MainWindow::prepareWrite(int startAddress, int count) {
    if(!modbusDevice) return;

    QModbusDataUnit writeUnit = writeRequest(startAddress, count);
    for(uint i = 0; i < unit.valueCount(); ++i) {
        /* setValue(int arg1, quint16 arg2) требует двух параметров
        * 1) arg1 - номер ячейки для записи. Нумерация идёт с нуля, т.к. при записи этот "ноль"
        * будет отсчитываться от startAddress.
        * arg2 - значение элемента для записи.
        * value - массив данных, которые будут записаны в регистры.
        */
        writeUnit.setValue(i, value[i]);
    }

    if(auto *lastRequest = modbusDevice->sendWriteRequest(writeUnit, settings->getAddress())) {
        if(!lastRequest->isFinished()) {
            connect(lastRequest, &QModbusReply::finished, this, [this, lastRequest]() {
                if(lastRequest->error() == QModbusDevice::ProtocolError) {
                    // Обрабатываем сообщение ошибки проткола используя reply-errorString();
                } else if(reply->error() == QModbusDevice::TimeoutError) {
                    // В случае таймаута отправляем сообщение об ошибки в метод-обработчик errorString();
                    errorString(modbusDevice->errorString());
                } else if (reply->error() != QModbusDevice::NoError) {
                    // Обрабатываем сообщение других ошибок используя reply-errorString();
                } else if(reply->error() == QModbusDevice::NoError) {
                    // Здесь мы можем обработать факт успешной записи данных.
                }
                reply->deleteLater();
            });
        } else {
            reply->deleteLater();
        }
        
    } else {
        errorMessage("Ошибка записи: " + modbusDevice->errorString());
    }
}

Прошу обратить внимание, что формировать список параметров для записи можно не по штучно функцией setValue(..), а импортируя вектор значений функцией void QModbusDataUnit::setValues(const QVector &values).

Теперь у нас есть методы для организации подключения и отключения связи, реализации запросов чтения и записи, обработчики считанных данных. Эврика! Вот мы и реализовали с вами минимально необходимый набор методов для общения с устройством. Запускаем программу и проверяем работу на железе. Исходный код небольшой программы представлен ниже.

Исходный код