АК
Қаз. 23, 2017, 1:22 Т.Қ.

Сериялық портты блоктау. QSerialPort + QThread.

Мне представилась задача написать ПО для управления излучателем рентгена. А именно: реализовать протокол передачи данных между ПК и излучателем рентгена и создать пользовательские функции "Установить параметры", "Включить рентген", "Выключить рентген".

Управление излучателем рентгена осуществляется благодаря передаче данных по последовательному порту, который еще называют COM-портом, но применимо это только в отношении ОС Windows.

Qt имеет класс QSerialPort, который предоставляет функции для доступа к последовательному порту.

Хочу продемонстрировать вам свою реализацию данной задачи.


Структура проекта

Emitter.pro - профайл проекта

Headers:

  • emitter.h - заголовочный файл класса излучателя.
  • mainwindow.h - заголовочный файл основного окна программы.

Sources:

  • emitter.cpp - файл реализации класса излучателя.
  • main.cpp
  • mainwindow.cpp - файл реализации основного окна программы.

Forms:

  • mainwindow.ui - файл формы основного окна программы

Класс излучателя

В профайл проекта добавляем serialport для возможности использовать модуль QtSerialPort.

QT       += core gui serialport

emitter.hpp

#ifndef EMITTER_H
#define EMITTER_H

#include <QSerialPort>
#include <QTimer>

class Emitter : public QObject
{
    Q_OBJECT
public:
    explicit Emitter(QObject *parent = 0);
    // Функция проверки соединения.
    bool isConnected() const;

public slots:
    // Слот установки параметров.
    void setFeatures(int voltage, int current, int workTime, int coolTime);
    // Слот включения рентгена.
    void turnOnXRay();
    // Слот выключения рентгена.
    void turnOffXRay();

private:
    enum class Command: quint8; // Объявление перечисления
    // Функция подключения.
    void connectToEmitter();
    // Важнейшая функция, реализующая протокол обмена с излучателем.
    QByteArray writeAndRead(Command com, quint8 y1 = 0, quint8 y2 = 0, quint8 *m = nullptr);
    // Команда излучателя: Статус излучателя.
    bool commandS();
    // Команда излучателя: Установка параметров.
    void commandP(quint16 volt, quint16 curr, quint16 workTime, quint16 coolTime);
    // Команда излучателя: Включить излучатель.
    void commandN();
    // Команда излучателя: Выключить излучатель.
    void commandF();

    const quint8 STARTBYTE = 0x40;  // Начало посылки.
    const quint8 DEV = 0;  // ID - излучателя.

    // Доступные нам команды, отправляемые излучателю. Отправляется третьим байтом в посылке.
    enum class Command : quint8
    {
        S = 0x53, // Команда запрос статуса.
        P = 0x50, // Команда установки параметров.
        N = 0x4e, // Команда включения рентгена.
        F = 0x46  // Команда выключения рентгена.
    };

    // Сообщения излучателя.
    enum class MessageCommandS : quint8
    {
        OK = 0x00, // Все ок.
        XRAY_ON = 0x01, // Излучение включено.
        XRAY_STARTING = 0x02, // Выход излучателя на режим.
        XRAY_TRAIN = 0x03, // Идет тренировка.
        COOLING = 0x04 // Охлаждение.
    };

    // Ошибки излучателя.
    enum class ErrorCommandS : quint8
    {
        NO_ERROR = 0x00, // Нет ошибок.
        XRAY_CONTROL_ERROR = 0x01, // Ошибка управления рентгеновской трубкой.
        MODE_ERROR = 0x02, // Ошибка установки заданного режима.
        VOLTAGE_ERROR = 0x03, // Превышение порога напряжения.
        CURRENT_ERROR = 0x04, // Превышение порога по току.
        PROTECTIVE_BOX_ERROR = 0x05, // Защитный бокс открыт.
        LOW_SUPPLY_VOLTAGE = 0x06, // Низкое питающее напряжение.
        DISCONNECTION = 0x07, // Отсутствие подтверждения соединения (более 1 с)ю
        OVERHEAT = 0x08 // Перегрев.
    };

    QSerialPort *m_pSerialPort;
    bool m_isConnected;

    QTimer *m_pTimerCheckConnection;
};

#endif // EMITTER_H

"Общение" с излучателем происходит по определенному протоколу.

Краткое описание протокола излучателя :

  1. Ведущим является компьютер. Он всегда первым посылает команду. При приеме каждой посылки, микропроцессор аппарата должен послать назад соответствующую команду ответа.
  2. Каждая посылка выглядит следующим образом:
    @ dev com y1 y2 m1 ... mn CRC
    @ - стартовый байт, признак начала передачи (всегда равен 0x40).
    dev - ID устройства. ID - контроллера и компьютера должны отличаться.
    com - команда.
    y1 - младший байт слова, определяющего число посылаемых байт данных.
    y2 - старший байт слова. определяющего число посылаемых байт данных.
    m1 - первый байт данных.
    mn - последний байт данных.
    CRC - байт контрольной суммы. Контрольная сумма считается как сумма всех байт, начиная с первого байта команды и заканчивая последним байтом данных.

Работа с последовательным портом будет в синхронном режиме, т.е. используя функции write и waitForBytesWritten для записи и waitForReadyRead и readAll для чтения.

emitter.cpp

#include "emitter.h"

#include <QDebug>
#include <QThread>

Emitter::Emitter(QObject *parent) : QObject(parent)
{
    // Инициализация последовательного порта.
    m_pSerialPort = new QSerialPort(this);
    m_pSerialPort->setPortName("COM3");
    // Скорость передачи данных. 19200 бит/с.
    m_pSerialPort->setBaudRate(QSerialPort::Baud19200);
    m_pSerialPort->setDataBits(QSerialPort::Data8);
    m_pSerialPort->setParity(QSerialPort::NoParity);
    m_pSerialPort->setStopBits(QSerialPort::OneStop);
    m_pSerialPort->setFlowControl(QSerialPort::NoFlowControl);

    /* В данном излучателе при включенном рентгене нужно обязательно, хотя бы раз в секунду,
    посылать команду на запрос статуса, чтобы подтвердить, что соединение не разорвано.
    Иначе рентген будет выключен. Для этого создаем таймер с интервалом в 1 секунду. */
    m_pTimerCheckConnection = new QTimer(this);
    m_pTimerCheckConnection->setInterval(1000);

    /* По истечении времени 1 с вызывается команда запроса статуса.
    Здесь используется именно лямбда-функция, чтобы не создавать слот.
    Можно было бы сделать commandS private slot, но в этом случае при connect
    через старую форму записи (на макросах SIGNAL, SLOT)
    этот слот был бы доступен внешним объектам. */
    connect(m_pTimerCheckConnection, &QTimer::timeout, [this](){
        commandS();
    });

    // Подключаем последовательный порт.
    connectToEmitter();
}

bool Emitter::isConnected() const
{
    return m_isConnected;
}

void Emitter::setFeatures(int voltage, int current, int workTime, int coolTime)
{
    if (isConnected())
    {
        commandP(voltage, current, workTime, coolTime);
    }
}

void Emitter::turnOnXRay()
{
    if (isConnected())
    {
        commandN();
    }
}

void Emitter::turnOffXRay()
{
    if (isConnected())
    {
        commandF();
    }
}

void Emitter::connectToEmitter()
{
    if (m_pSerialPort->open(QSerialPort::ReadWrite))
    {
        // Убеждаемся, что в последовательный порт подключен именно излучатель рентгена.
        m_isConnected = commandS();
        if (m_isConnected)
        {
            qDebug() << "Излучатель рентгена подключен.";
        }
        else
        {
            qDebug() << "В последовательный порт излучателя рентгена подключено другое устройство!";
        }
    }
    else
    {
        qDebug() << "Последовательный порт не подключен.";
        m_isConnected = false;
    }
}

QByteArray Emitter::writeAndRead(Command com, quint8 y1, quint8 y2, quint8 *m)
{
    quint16 y = y2 * 256 + y1;

    // Данные, посылаемые в последовательный порт.
    QByteArray sentData;
    sentData.resize(6 + y);
    sentData[0] = STARTBYTE;
    sentData[1] = DEV;

    // Контрольная сумма = сумма всех байтов, начиная с байта команды.
    quint8 crc = 0;
    crc += sentData[2] = static_cast<quint8>(com);
    crc += sentData[3] = y1;
    crc += sentData[4] = y2;

    // Если есть байты данных, то их тоже включаем в контрольную сумму.
    if (y && m)
    {
        for (int i = 0; i < y; ++i)
        {
            crc += sentData[5 + i] = m[i];
        }
    }

    // Последний байт и есть контрольная сумма.
    sentData[5 + y] = crc;

    // Записываем в последовательный порт и ждем до 100 мс, пока запись не будет произведена.
    m_pSerialPort->write(sentData);
    m_pSerialPort->waitForBytesWritten(100);

    // Засыпаем, ожидая, пока микроконтроллер излучателя обработает данные и ответит.
    this->thread()->msleep(50);
    // Читаем данные от излучателя.
    m_pSerialPort->waitForReadyRead(50);
    return m_pSerialPort->readAll();
}

bool Emitter::commandS() // Команда запрос статуса.
{
    // Отправляем излучателю.
    QByteArray receivedData = writeAndRead(Command::S);

    /* Должно придти именно 8 байт.
    Ответ должен быть таким: @ dev com 2 0 s e CRC.
    Иначе ответ пришел не от излучателя. */
    if (receivedData.size() == 8)
    {
        quint8 crc = 0;
        for (int i = 1; i < receivedData.size() - 1; ++i)
        {
            crc += receivedData[i];
        }
        if (crc != static_cast<quint8>(receivedData[receivedData.size() - 1]))
            qDebug() << "CRC не совпадает!" << crc << static_cast<quint8>(receivedData[receivedData.size() - 1]);

        // Если условие верно, то нам ответил наш излучатель рентгена.
        if (receivedData[0] == STARTBYTE &&
            static_cast<Command>(receivedData.at(2)) == Command::S &&
            receivedData[3] == 2 &&
            receivedData[4] == 0)
        {
            MessageCommandS message = static_cast<MessageCommandS>(receivedData.at(5));
            switch (message)
            {
            case MessageCommandS::OK:
                qDebug() << "Все в порядке.";
                break;
            case MessageCommandS::XRAY_ON:
                qDebug() << "Излучение включено.";
                break;
            case MessageCommandS::XRAY_STARTING:
                qDebug() << "Выход излучения на режим.";
                break;
            case MessageCommandS::XRAY_TRAIN:
                qDebug() << "Идет тренировка.";
                break;
            case MessageCommandS::COOLING:
                qDebug() << "Охлаждение.";
                break;
            }
            ErrorCommandS error = static_cast<ErrorCommandS>(receivedData.at(6));
            switch (error)
            {
            case ErrorCommandS::NO_ERROR:
                qDebug() << "Нет ошибок";
                break;
            case ErrorCommandS::XRAY_CONTROL_ERROR:
                qDebug() << "Ошибка управления рентгеновской трубкой.";
                break;
            case ErrorCommandS::MODE_ERROR:
                qDebug() << "Невозможно установить заданный режим на трубке.";
                break;
            case ErrorCommandS::VOLTAGE_ERROR:
                qDebug() << "Превышен порог напряжения.";
                break;
            case ErrorCommandS::CURRENT_ERROR:
                qDebug() << "Превышен порог по току.";
                break;
            case ErrorCommandS::PROTECTIVE_BOX_ERROR:
                qDebug() << "Защитный бокс открыт.";
                break;
            case ErrorCommandS::LOW_SUPPLY_VOLTAGE:
                qDebug() << "Низкое питающее напряжение.";
                break;
            case ErrorCommandS::DISCONNECTION:
                qDebug() << "Отсутствие связи с хостом (более 1с).";
                break;
            case ErrorCommandS::OVERHEAT:
                qDebug() << "Перегрев.";
                break;
            }
            // Возвращаем true, т.к. ответ пришел от излучателя. 
            return true;
        }
    }
    else
    {
        qDebug() << "Ошибка. Ответ не соответствует ожиданиям.";
    }
    return false;
}

// Команда установить параметры.
void Emitter::commandP(quint16 volt, quint16 curr, quint16 workTime, quint16 coolTime)
{
    quint8 m[8];

    // 8 байт данных, 4 слова. Первый младший байт слова, второй старший байт слова.
    m[0] = volt - (volt/256) * 256;
    m[1] = volt/256;
    m[2] = curr - (curr/256) * 256;
    m[3] = curr/256;
    m[4] = workTime - (workTime/256) * 256;
    m[5] = workTime/256;
    m[6] = coolTime - (coolTime/256) * 256;
    m[7] = coolTime/256;
    QByteArray receivedData = writeAndRead(Command::P, 8, 0, m);

    /* Должно придти именно 14 байт.
    Ответ должен быть таким: @ dev com 8 0 volt curr workTime coolTime CRC.
    volt, curr, workTime, coolTime - слова в 2 байта.
    Иначе ответ пришел не от излучателя. */
    if (receivedData.size() == 14)
    {
        quint8 crc = 0;
        for (int i = 1; i < receivedData.size() - 1; ++i)
        {
            crc += receivedData[i];
        }
        if (crc != static_cast<quint8>(receivedData[receivedData.size() - 1]))
            qDebug() << "CRC не совпадает!" << crc << static_cast<quint8>(receivedData[receivedData.size() - 1]);

        // Ответом от излучателя являются текущие значения параметров.
        if (receivedData[0] == STARTBYTE &&
            static_cast<Command>(receivedData.at(2)) == Command::P &&
            receivedData[3] == 8 &&
            receivedData[4] == 0)
        {
            quint16 receivedVolt = receivedData[5] + receivedData[6] * 256;
            quint16 receivedCurr = receivedData[7] + receivedData[8] * 256;
            quint16 receivedWorkTime = receivedData[9] + receivedData[10] * 256;
            quint16 receivedCoolTime = receivedData[11] + receivedData[12] * 256;
            qDebug() << "Данные : " << receivedVolt << receivedCurr << receivedWorkTime << receivedCoolTime;
        }
    }
    else
    {
        qDebug() << "Ошибка. Ответ не соответствует ожиданиям.";
    }
}

// Команда выключения рентгена.
void Emitter::commandN()
{
    QByteArray receivedData = writeAndRead(Command::N);

    /* Должно придти именно 6 байт.
    Ответ должен быть таким: @ dev com 0 0 CRC.
    Иначе ответ пришел не от излучателя. */
    if (receivedData.size() == 6)
    {
        quint8 crc = 0;
        for (int i = 1; i < receivedData.size() - 1; ++i)
        {
            crc += receivedData[i];
        }
        if (crc != static_cast<quint8>(receivedData[receivedData.size() - 1]))
            qDebug() << "CRC не совпадает!" << crc << static_cast<quint8>(receivedData[receivedData.size() - 1]);

        // Включаем таймер подверждения соединения, если рентген включен.
        if (receivedData[0] == STARTBYTE &&
            static_cast<Command>(receivedData.at(2)) == Command::N &&
            receivedData[3] == 0 &&
            receivedData[4] == 0)
        {
            qDebug() << "Рентген включен.";
            m_pTimerCheckConnection->start();
        }
    }
    else
    {
        qDebug() << "Ошибка. Ответ не соответствует ожиданиям.";
    }
}

void Emitter::commandF() //Выключить рентген.
{
    QByteArray receivedData = writeAndRead(Command::F);

    /* Должно придти именно 6 байт.
    Ответ должен быть таким: @ dev com 0 0 CRC.
    Иначе ответ пришел не от излучателя. */
    if (receivedData.size() == 6)
    {
        quint8 crc = 0;
        for (int i = 1; i < receivedData.size() - 1; ++i)
        {
            crc += receivedData[i];
        }
        if (crc != static_cast<quint8>(receivedData[receivedData.size() - 1]))
            qDebug() << "CRC не совпадает!" << crc << static_cast<quint8>(receivedData[receivedData.size() - 1]);

        // Выключаем таймер подтверждения соединения.
        if (receivedData[0] == STARTBYTE &&
            static_cast<Command>(receivedData.at(2)) == Command::F &&
            receivedData[3] == 0 &&
            receivedData[4] == 0)
        {
            qDebug() << "Рентген выключен.";
            m_pTimerCheckConnection->stop();
        }
    }
    else
    {
        qDebug() << "Ошибка. Ответ не соответствует ожиданиям.";
    }
}

mainwindow.h

Здесь объявляется один сигнал для передачи параметров, и создается пара объект-излучатель - объект-поток.

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QThread>

#include "emitter.h"

namespace Ui {
class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = 0);
    ~MainWindow();

signals:
    // Этот сигнал будет высылаться при нажатии на кнопку "Задать", чтобы передать параметры в излучатель.
    void setFeaturesRequested(int voltage, int current, int workTime, int coolTime);

private:
    Ui::MainWindow *ui;

    // Излучатель и отдельный поток, в котором он будет работать.
    QThread *m_pThread;
    Emitter *m_pEmitter;
};

#endif // MAINWINDOW_H

mainwindow.ui

Примитивный дизайн. Но лучшего для демонстрации работы излучателя и не требуется.
Виджеты spinBox, spinBox_2, spinBox_3, spinBox_4 переименованы в spinBoxVoltage, spinBoxCurrent, spinBoxWorkTime, spinBoxCoolTime соответственно. Кнопки pushButton, pushButton_2, pushButton_3 переименованы в pushButtonSet, pushButtonTurnOn, pushButtonTurnOff соответственно. Сделано это для удобства обращения к данным объектам в файле реализации mainwindow.cpp.

mainwindow.cpp

Здесь объект излучателя перемещается в отдельный поток. Также описана работа пользовательского интерфейса.

#include "mainwindow.h"
#include "ui_mainwindow.h"

#include <QMessageBox>

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow)
{
    ui->setupUi(this);

    m_pThread = new QThread(this);
    // Указывать родителя нет необходимости. Родителем станет поток, когда переместим в него наш объект излучателя.
    m_pEmitter = new Emitter;

    /* Перемещаем объект излучателя в отдельный поток, чтобы синхронные ожидающие операции не блокировали
    основной GUI-поток. Создаем соединение: Удаляем объект излучателя при окончании работы потока.
    Запускаем поток.*/
    m_pEmitter->moveToThread(m_pThread);
    connect(m_pThread, SIGNAL(finished()), m_pEmitter, SLOT(deleteLater()));
    m_pThread->start();

    // Проверка соединения.
    if (m_pEmitter->isConnected())
    {
        ui->pushButtonTurnOff->setEnabled(false);
    }
    else
    {
        ui->pushButtonSet->setEnabled(false);
        ui->pushButtonTurnOn->setEnabled(false);
        ui->pushButtonTurnOff->setEnabled(false);
        QMessageBox::critical(this, "Ошибка подключения", "Подключите излучатель рентгена в последовательный порт COM3"
                              " и перезапустите программу.", QMessageBox::Ok);
    }

    // При нажатии на кнопку "Включить" включать рентген и переключать состояние кнопок.
    connect(ui->pushButtonTurnOn, &QPushButton::clicked, m_pEmitter, &Emitter::turnOnXRay);
    connect(ui->pushButtonTurnOn, &QPushButton::clicked, [this](){
        ui->pushButtonTurnOn->setEnabled(false);
        ui->pushButtonTurnOff->setEnabled(true);
    });

    // При нажатии на кнопку "Выключить" выключать рентген и переключать состояние кнопок.
    connect(ui->pushButtonTurnOff, &QPushButton::clicked, m_pEmitter, &Emitter::turnOffXRay);
    connect(ui->pushButtonTurnOff, &QPushButton::clicked, [this](){
        ui->pushButtonTurnOn->setEnabled(true);
        ui->pushButtonTurnOff->setEnabled(false);
    });

    // При нажатии на кнопку "Задать", передаются все соответствующие параметры выставленные в spinBox'ах.
    connect(ui->pushButtonSet, &QPushButton::clicked, [this](){
        emit setFeaturesRequested(ui->spinBoxVoltage->value(),
                                  ui->spinBoxCurrent->value(),
                                  ui->spinBoxWorkTime->value(),
                                  ui->spinBoxCoolTime->value());
    });
    connect(this, &MainWindow::setFeaturesRequested, m_pEmitter, &Emitter::setFeatures);
}

MainWindow::~MainWindow()
{
    // Перед удалением основного окна, дожидаемся завершения работы потока.
    m_pThread->quit();
    m_pThread->wait(1000);

    delete ui;
}

Итог

Как результат: Создан класс излучателя, где соблюдена инкапсуляция. Есть 4 публичных функции, 3 из которых слоты. Есть внутренние функции излучателя, работа которых реализована в соответствие с описанным синхронным протоколом. Показано использование объекта излучателя в паре с объектом потока, что дает возможность не блокировать основной поток даже если операции чтения-записи требуют задержек.

Ол саған ұнайды ма? Әлеуметтік желілерде бөлісіңіз!

a
  • Мамыр 3, 2018, 4:49 Т.Қ.

Nice article, in my opinion it's much clearer than the official example at http://doc.qt.io/qt-5/qtserialport-blockingmaster-example.html.
I have the following question: what would be the most elegant way to programmatically set the port name, given that the application may offer a QComboBox that lists every serial port reachable by the host device? How would you do it?

АК
  • Мамыр 3, 2018, 9:19 Т.Қ.

Thanks for reply. This is not hard to do. This described in "Terminal" example by Qt http://doc.qt.io/qt-5/qtserialport-terminal-example.html ,  in SettingsDialog http://doc.qt.io/qt-5/qtserialport-terminal-settingsdialog-cpp.html

void SettingsDialog::fillPortsInfo()
{
    m_ui->serialPortInfoListBox->clear();
    QString description;
    QString manufacturer;
    QString serialNumber;
    const auto infos = QSerialPortInfo::availablePorts();
    for (const QSerialPortInfo &info : infos) {
        QStringList list;
        description = info.description();
        manufacturer = info.manufacturer();
        serialNumber = info.serialNumber();
        list << info.portName()
             << (!description.isEmpty() ? description : blankString)
             << (!manufacturer.isEmpty() ? manufacturer : blankString)
             << (!serialNumber.isEmpty() ? serialNumber : blankString)
             << info.systemLocation()
             << (info.vendorIdentifier() ? QString::number(info.vendorIdentifier(), 16) : blankString)
             << (info.productIdentifier() ? QString::number(info.productIdentifier(), 16) : blankString);

        m_ui->serialPortInfoListBox->addItem(list.first(), list);
    }

    m_ui->serialPortInfoListBox->addItem(tr("Custom"));
}
a
  • Мамыр 3, 2018, 9:34 Т.Қ.

Thanks for the answer. But I meant to ask a different thing. I already knew about QSerialPortInfo, but I wanted to know what would be the best way of setting the current portname in the Emitter class. I'd like the update to be thread-safe, since Emiter extends QThread. What I'm asking is if something like the following is the right thing to do:

// emitter.h
void Emitter::setPortName(const QString& portName) {
    m_pSerialPort->setPortName(portName);
}

// inside some view file with a pointer to an instance of Emitter
Emitter* emitter = ...
QComboBox* portNamesComboBox = ... // assume it contains a list of portNames extracted from QSerialPortInfo
QPushButton* changePortBtn = new QPushButton(tr("Change"));

connect(changePortBtn , QPushButton::clicked, this, [this,portNamesComboBox]() {
    auto newPortName  = portNamesComboBox.currentText();
    emitter->setPortName(newPortName);
});
АК
  • Мамыр 4, 2018, 12:36 Т.Қ.

Class Emitter does not extend QThread. They work in pair. They do not inherit each other.

In the article i do so:

connect(ui->pushButtonTurnOn, &QPushButton::clicked, m_pEmitter, &Emitter::turnOnXRay);
connect(ui->pushButtonTurnOn, &QPushButton::clicked, [this](){
        ui->pushButtonTurnOn->setEnabled(false);
        ui->pushButtonTurnOff->setEnabled(true);
});
This works.

Пікірлер

Тек рұқсаты бар пайдаланушылар ғана пікір қалдыра алады.
Кіріңіз немесе Тіркеліңіз