Недавно мне на аутсорсе предложили выполнить небольшую работу: разработать ПО с графическим интерфейсом для управления источником питания по протоколу ModBus RTU. Не раздумывая я согласился решить поставленную задачу, однако меня ждал “интересный” сюрприз, вызванный моей же невнимательностью. В этой заметке я приведу пример работы с протоколом ModBus RTU в Qt с подробными коментариями, а заодно расскажу о своей досадной оплошности допущенной при решении этой задачи.
Задача и начальные данные
Прежде всего определим задачу: разработать ПО для управления источником питания по протоколу ModBus RTU с возможностью указания адреса устройства, параметров COM-порта, и управлением некоторым количеством параметров (запись и чтение заданных регистров).
Для отладки мне выдали какую-то железяку с поднятым на ней модбасом и возможностью хранить записанные ранее данные. Всё! Больше никакого функционала, полезного действия и смысловой нагрузки она не несёт. В прошлом это была некая плата “универсального интерфейса” – преобразователя различных протоколов обмена данных в привычный UART.
Начальные данные: перед этой задачей я уже делал подобное ПО для этой компании с одним лишь отличием – там управление осуществлялось целой сетью устройств с индивидуальным управлением каждым источником. Таким образом, я рассчитывал значительно упростить себе жизнь используя модули, созданные для решения прошлой задачи.
Поспешишь – людей насмешишь. Или лишний код напишешь…
Народная мудрость….. дополненная мною 🙂
Однако, в предыдущей задаче я допустил досадную для меня оплошность, суть которой в том, что я использовал лишь библиотеки для работы с последовательным портом, а реализацию протокола 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).
Теперь у нас есть методы для организации подключения и отключения связи, реализации запросов чтения и записи, обработчики считанных данных. Эврика! Вот мы и реализовали с вами минимально необходимый набор методов для общения с устройством. Запускаем программу и проверяем работу на железе. Исходный код небольшой программы представлен ниже.