Просмотр исходного кода

feat: 添加 PLC 串口自动搜索

main
suyu 3 недель назад
Родитель
Сommit
839ce495de
16 измененных файлов: 1235 добавлений и 67 удалений
  1. +4
    -0
      app/integrated_platform.pro
  2. +304
    -0
      app/src/infrastructure/plc_discovery_service.cpp
  3. +68
    -0
      app/src/infrastructure/plc_discovery_service.h
  4. +5
    -1
      app/src/main.cpp
  5. +126
    -0
      app/src/services/plc_discovery_gateway.cpp
  6. +53
    -0
      app/src/services/plc_discovery_gateway.h
  7. +89
    -5
      app/src/ui/main_window.cpp
  8. +5
    -0
      app/src/ui/main_window.h
  9. +178
    -17
      app/src/ui/plc_connection_dialog.cpp
  10. +22
    -0
      app/src/ui/plc_connection_dialog.h
  11. +80
    -40
      app/src/ui/plc_connection_dialog.ui
  12. +203
    -0
      app/tests/plc_connection_dialog_tests.cpp
  13. +18
    -0
      app/tests/plc_connection_dialog_tests.pro
  14. +70
    -0
      app/tests/plc_runtime_tests.cpp
  15. +8
    -4
      app/tests/pri/test_layers.pri
  16. +2
    -0
      app/tests/tests.pro

+ 4
- 0
app/integrated_platform.pro Просмотреть файл

@@ -48,11 +48,13 @@ SOURCES += \
src/services/software_logic_executor.cpp \
src/services/offline_simulation_service.cpp \
src/services/runtime_mode_service.cpp \
src/services/plc_discovery_gateway.cpp \
src/services/register_monitor_service.cpp \
src/services/register_comment_service.cpp \
src/infrastructure/plc_register_repository.cpp \
src/infrastructure/plc_communication_error_classifier.cpp \
src/infrastructure/plc_communication_service.cpp \
src/infrastructure/plc_discovery_service.cpp \
src/infrastructure/json_project_storage.cpp \
src/ui/hmi_editor_widget.cpp \
src/ui/logic_editor_widget.cpp
@@ -94,12 +96,14 @@ HEADERS += \
src/services/software_logic_executor.h \
src/services/offline_simulation_service.h \
src/services/runtime_mode_service.h \
src/services/plc_discovery_gateway.h \
src/services/register_monitor_service.h \
src/services/register_comment_service.h \
src/services/plc_communication_gateway.h \
src/infrastructure/plc_register_repository.h \
src/infrastructure/plc_communication_error_classifier.h \
src/infrastructure/plc_communication_service.h \
src/infrastructure/plc_discovery_service.h \
src/infrastructure/json_project_storage.h \
src/ui/hmi_editor_widget.h \
src/ui/logic_editor_widget.h


+ 304
- 0
app/src/infrastructure/plc_discovery_service.cpp Просмотреть файл

@@ -0,0 +1,304 @@
#include "plc_discovery_service.h"

#include <QModbusDataUnit>
#include <QModbusPdu>
#include <QModbusReply>
#include <QModbusRtuSerialMaster>
#include <QSerialPort>
#include <QSerialPortInfo>
#include <QTimer>
#include <QVariant>

#include <string>
#include <utility>

namespace {

// 本地 RTU 探测无需沿用正式轮询的长超时和重试,否则多端口搜索会过慢
constexpr int kDiscoveryResponseTimeoutMs = 200;
// 正式连接可能刚被配置窗口断开,先留出时间让操作系统完全释放串口句柄
constexpr int kDiscoveryStartDelayMs = 100;

std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

} // namespace

PlcDiscoveryService::PlcDiscoveryService(QObject *parent)
: QObject(parent),
master_(std::make_unique<QModbusRtuSerialMaster>())
{
connect(
master_.get(), &QModbusClient::stateChanged,
this, &PlcDiscoveryService::handleDeviceStateChanged);
}

PlcDiscoveryService::~PlcDiscoveryService()
{
progress_changed_callback_ = {};
discovery_finished_callback_ = {};
if (master_->state() != QModbusDevice::UnconnectedState)
{
master_->disconnectDevice();
}
}

PlcCommunicationResult PlcDiscoveryService::startDiscovery(
const PlcSerialConfiguration &preferred)
{
if (discovering_)
{
return {false, "PLC 自动搜索正在进行"};
}
PlcSerialConfiguration validation_configuration = preferred;
validation_configuration.portName = "DISCOVERY";
const PlcCommunicationResult validation =
validatePlcSerialConfiguration(validation_configuration);
if (!validation.succeeded)
{
return validation;
}

std::vector<std::string> available_ports;
for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts())
{
available_ports.push_back(toUtf8(port.portName()));
}
candidates_ = buildPlcDiscoveryCandidates(preferred, available_ports);
if (candidates_.empty())
{
return {false, "没有检测到可用串口"};
}
if (master_->state() != QModbusDevice::UnconnectedState)
{
return {false, "自动搜索串口尚未释放,请稍后重试"};
}

current_candidate_ = 0;
pending_reply_.clear();
phase_ = Phase::Idle;
discovering_ = true;
cancel_requested_ = false;
found_pending_ = false;
++attempt_generation_;
QTimer::singleShot(
kDiscoveryStartDelayMs,
this,
&PlcDiscoveryService::tryCurrentCandidate);
return {true, {}};
}

void PlcDiscoveryService::cancelDiscovery()
{
if (!discovering_ || cancel_requested_)
{
return;
}
cancel_requested_ = true;
found_pending_ = false;
++attempt_generation_;
disconnectCurrent(false);
}

bool PlcDiscoveryService::isDiscovering() const
{
return discovering_;
}

void PlcDiscoveryService::setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished)
{
progress_changed_callback_ = std::move(progress_changed);
discovery_finished_callback_ = std::move(discovery_finished);
}

void PlcDiscoveryService::tryCurrentCandidate()
{
if (!discovering_)
{
return;
}
if (cancel_requested_)
{
disconnectCurrent(false);
return;
}
if (current_candidate_ >= candidates_.size())
{
finishDiscovery({
false, false, {},
"未找到可响应的 PLC,请检查站号、接线和 PLC RTU 设置"});
return;
}

const PlcSerialConfiguration &candidate = candidates_.at(current_candidate_);
if (progress_changed_callback_)
{
progress_changed_callback_({
candidate, current_candidate_ + 1U, candidates_.size()});
}
++attempt_generation_;
phase_ = Phase::Connecting;
master_->setConnectionParameter(
QModbusDevice::SerialPortNameParameter,
QString::fromUtf8(candidate.portName.c_str()));
master_->setConnectionParameter(
QModbusDevice::SerialBaudRateParameter, candidate.baudRate);
master_->setConnectionParameter(
QModbusDevice::SerialDataBitsParameter,
static_cast<QSerialPort::DataBits>(candidate.dataBits));
master_->setConnectionParameter(
QModbusDevice::SerialParityParameter,
static_cast<QSerialPort::Parity>(candidate.parity));
master_->setConnectionParameter(
QModbusDevice::SerialStopBitsParameter,
static_cast<QSerialPort::StopBits>(candidate.stopBits));
master_->setTimeout(kDiscoveryResponseTimeoutMs);
master_->setNumberOfRetries(0);
if (!master_->connectDevice())
{
QTimer::singleShot(
0, this,
[this]
{
if (discovering_ && phase_ == Phase::Connecting)
{
disconnectCurrent(false);
}
});
}
}

void PlcDiscoveryService::sendProbe()
{
if (!discovering_ || cancel_requested_
|| current_candidate_ >= candidates_.size())
{
disconnectCurrent(false);
return;
}
phase_ = Phase::Probing;
const PlcSerialConfiguration &candidate = candidates_.at(current_candidate_);
const QModbusDataUnit request(
QModbusDataUnit::HoldingRegisters, 0, 1);
QModbusReply *reply = master_->sendReadRequest(
request, candidate.serverAddress);
if (reply == nullptr)
{
disconnectCurrent(false);
return;
}
pending_reply_ = reply;
const std::uint64_t generation = attempt_generation_;
connect(
reply, &QModbusReply::finished,
this,
[this, reply, generation]
{
handleProbeFinished(reply, generation);
});
}

void PlcDiscoveryService::handleProbeFinished(
QModbusReply *reply,
std::uint64_t attempt_generation)
{
const bool is_current_attempt = discovering_
&& !cancel_requested_
&& phase_ == Phase::Probing
&& attempt_generation == attempt_generation_;
if (pending_reply_ == reply)
{
pending_reply_.clear();
}
const bool valid_response = reply->error() == QModbusDevice::NoError
|| reply->rawResult().isException();
reply->deleteLater();
if (is_current_attempt)
{
disconnectCurrent(valid_response);
}
}

void PlcDiscoveryService::disconnectCurrent(bool found)
{
if (!discovering_)
{
return;
}
found_pending_ = found && !cancel_requested_;
phase_ = Phase::Disconnecting;
if (master_->state() == QModbusDevice::UnconnectedState)
{
QTimer::singleShot(0, this, &PlcDiscoveryService::continueAfterDisconnect);
return;
}
master_->disconnectDevice();
}

void PlcDiscoveryService::continueAfterDisconnect()
{
if (!discovering_ || phase_ != Phase::Disconnecting)
{
return;
}
if (cancel_requested_)
{
finishDiscovery({false, true, {}, {}});
return;
}
if (found_pending_)
{
finishDiscovery({
true, false, candidates_.at(current_candidate_),
"已找到 PLC,正在连接"});
return;
}
++current_candidate_;
phase_ = Phase::Idle;
QTimer::singleShot(0, this, &PlcDiscoveryService::tryCurrentCandidate);
}

void PlcDiscoveryService::finishDiscovery(const PlcDiscoveryOutcome &outcome)
{
discovering_ = false;
cancel_requested_ = false;
found_pending_ = false;
pending_reply_.clear();
phase_ = Phase::Idle;
++attempt_generation_;
if (discovery_finished_callback_)
{
discovery_finished_callback_(outcome);
}
candidates_.clear();
current_candidate_ = 0;
}

void PlcDiscoveryService::handleDeviceStateChanged(QModbusDevice::State state)
{
if (!discovering_)
{
return;
}
if (state == QModbusDevice::ConnectedState && phase_ == Phase::Connecting)
{
sendProbe();
return;
}
if (state == QModbusDevice::UnconnectedState)
{
if (phase_ == Phase::Disconnecting)
{
QTimer::singleShot(0, this, &PlcDiscoveryService::continueAfterDisconnect);
}
else if (phase_ == Phase::Connecting || phase_ == Phase::Probing)
{
disconnectCurrent(false);
}
}
}

+ 68
- 0
app/src/infrastructure/plc_discovery_service.h Просмотреть файл

@@ -0,0 +1,68 @@
#pragma once

#include "services/plc_discovery_gateway.h"

#include <QModbusDevice>
#include <QObject>
#include <QPointer>

#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <vector>

class QModbusReply;
class QModbusRtuSerialMaster;

// 使用独立 Qt Modbus RTU 主站逐项探测串口参数,不污染正式 PLC 连接状态
class PlcDiscoveryService final : public QObject, public PlcDiscoveryGateway
{
public:
explicit PlcDiscoveryService(QObject *parent = nullptr);
~PlcDiscoveryService() override;

PlcCommunicationResult startDiscovery(
const PlcSerialConfiguration &preferred) override;
void cancelDiscovery() override;
bool isDiscovering() const override;
void setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) override;

private:
enum class Phase
{
Idle,
Connecting,
Probing,
Disconnecting
};

// 尝试当前候选参数并等待串口异步打开
void tryCurrentCandidate();
// 串口打开后发送只读 D0 探测请求
void sendProbe();
// 处理当前 D0 探测结果
void handleProbeFinished(QModbusReply *reply, std::uint64_t attempt_generation);
// 释放当前串口,释放完成后进入下一个候选或报告结果
void disconnectCurrent(bool found);
// 当前串口完全释放后完成状态迁移
void continueAfterDisconnect();
// 报告最终结果并清理搜索状态
void finishDiscovery(const PlcDiscoveryOutcome &outcome);
// 处理 Qt Modbus 主站连接状态变化
void handleDeviceStateChanged(QModbusDevice::State state);

std::unique_ptr<QModbusRtuSerialMaster> master_;
std::vector<PlcSerialConfiguration> candidates_;
std::size_t current_candidate_ = 0;
QPointer<QModbusReply> pending_reply_;
Phase phase_ = Phase::Idle;
bool discovering_ = false;
bool cancel_requested_ = false;
bool found_pending_ = false;
std::uint64_t attempt_generation_ = 0;
std::function<void(const PlcDiscoveryProgress &)> progress_changed_callback_;
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished_callback_;
};

+ 5
- 1
app/src/main.cpp Просмотреть файл

@@ -10,6 +10,7 @@

#include "infrastructure/json_project_storage.h"
#include "infrastructure/plc_communication_service.h"
#include "infrastructure/plc_discovery_service.h"
#include "infrastructure/plc_register_repository.h"
#include "domain/active_register_repository.h"
#include "domain/virtual_register_repository.h"
@@ -57,6 +58,8 @@ int main(int argc, char *argv[])
ActiveRegisterRepository active_register_repository(virtual_register_repository);
// 负责通过串口异步连接、读取和写入真实 PLC
PlcCommunicationService plc_communication_service(plc_register_repository);
// 使用独立串口会话自动搜索 PLC,不影响正式通信状态机
PlcDiscoveryService plc_discovery_service;
// 处理运行画面读取和写入寄存器,数据来自当前选中的寄存器入口
HmiRuntimeService hmi_runtime_service(active_register_repository);

@@ -91,7 +94,8 @@ int main(int argc, char *argv[])
alarm_service,
hmi_navigation_service,
register_comment_service,
register_monitor_service);
register_monitor_service,
plc_discovery_service);
// 把主窗口显示到屏幕上
main_window.show();



+ 126
- 0
app/src/services/plc_discovery_gateway.cpp Просмотреть файл

@@ -0,0 +1,126 @@
#include "plc_discovery_gateway.h"

#include <algorithm>
#include <array>
#include <tuple>
#include <utility>

namespace {

struct SerialFrame
{
int dataBits = 8;
int parity = 2;
int stopBits = 1;
};

bool isSupportedBaudRate(int baud_rate)
{
return baud_rate == 9600
|| baud_rate == 19200
|| baud_rate == 38400
|| baud_rate == 57600
|| baud_rate == 115200;
}

bool isSupportedFrame(const SerialFrame &frame)
{
return (frame.dataBits == 7 || frame.dataBits == 8)
&& (frame.parity == 0 || frame.parity == 2 || frame.parity == 3)
&& (frame.stopBits == 1 || frame.stopBits == 2);
}

bool sameFrame(const SerialFrame &left, const SerialFrame &right)
{
return std::tie(left.dataBits, left.parity, left.stopBits)
== std::tie(right.dataBits, right.parity, right.stopBits);
}

template<typename Value, typename Equals>
void appendUnique(std::vector<Value> *values, Value value, Equals equals)
{
if (std::none_of(
values->cbegin(), values->cend(),
[&value, &equals](const Value &existing) { return equals(existing, value); }))
{
values->push_back(std::move(value));
}
}

} // namespace

std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates(
const PlcSerialConfiguration &preferred,
const std::vector<std::string> &available_ports)
{
std::vector<std::string> ports;
ports.reserve(available_ports.size());
for (const std::string &port : available_ports)
{
if (!port.empty())
{
appendUnique(
&ports, port,
[](const std::string &left, const std::string &right) {
return left == right;
});
}
}
const auto preferred_port = std::find(
ports.begin(), ports.end(), preferred.portName);
if (preferred_port != ports.end())
{
std::rotate(ports.begin(), preferred_port, preferred_port + 1);
}

std::vector<int> baud_rates;
if (isSupportedBaudRate(preferred.baudRate))
{
baud_rates.push_back(preferred.baudRate);
}
constexpr std::array<int, 5> kSupportedBaudRates{
9600, 19200, 38400, 57600, 115200};
for (int baud_rate : kSupportedBaudRates)
{
appendUnique(
&baud_rates, baud_rate,
[](int left, int right) { return left == right; });
}

std::vector<SerialFrame> frames;
const SerialFrame preferred_frame{
preferred.dataBits, preferred.parity, preferred.stopBits};
if (isSupportedFrame(preferred_frame))
{
frames.push_back(preferred_frame);
}
constexpr std::array<SerialFrame, 12> kSupportedFrames{{
{8, 2, 1}, {8, 0, 1}, {8, 3, 1},
{8, 0, 2}, {8, 2, 2}, {8, 3, 2},
{7, 2, 1}, {7, 3, 1}, {7, 0, 1},
{7, 0, 2}, {7, 2, 2}, {7, 3, 2}}};
for (const SerialFrame &frame : kSupportedFrames)
{
appendUnique(&frames, frame, sameFrame);
}

std::vector<PlcSerialConfiguration> candidates;
candidates.reserve(ports.size() * baud_rates.size() * frames.size());
for (const SerialFrame &frame : frames)
{
for (int baud_rate : baud_rates)
{
for (const std::string &port : ports)
{
PlcSerialConfiguration candidate = preferred;
candidate.portName = port;
candidate.baudRate = baud_rate;
candidate.dataBits = frame.dataBits;
candidate.parity = frame.parity;
candidate.stopBits = frame.stopBits;
candidates.push_back(std::move(candidate));
}
}
}
return candidates;
}

+ 53
- 0
app/src/services/plc_discovery_gateway.h Просмотреть файл

@@ -0,0 +1,53 @@
#pragma once

#include "plc_communication_gateway.h"

#include <cstddef>
#include <functional>
#include <string>
#include <vector>

// 自动搜索正在尝试的串口参数和总体进度
struct PlcDiscoveryProgress
{
PlcSerialConfiguration configuration;
std::size_t currentAttempt = 0;
std::size_t totalAttempts = 0;
};

// 自动搜索结束结果;found 和 cancelled 不会同时为 true
struct PlcDiscoveryOutcome
{
bool found = false;
bool cancelled = false;
PlcSerialConfiguration configuration;
std::string message;
};

/**
* 按优先级生成搜索组合
*
* 站号和通信时序沿用界面当前值,只遍历可用端口及项目支持的串口参数
*/
std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates(
const PlcSerialConfiguration &preferred,
const std::vector<std::string> &available_ports);

// UI 使用的异步 PLC 自动搜索契约,具体串口实现位于 infrastructure
class PlcDiscoveryGateway
{
public:
virtual ~PlcDiscoveryGateway() = default;

// 开始搜索,实际进度和结果通过回调通知
virtual PlcCommunicationResult startDiscovery(
const PlcSerialConfiguration &preferred) = 0;
// 取消当前搜索;串口释放后通过结束回调通知
virtual void cancelDiscovery() = 0;
// 返回当前是否正在搜索或等待释放搜索串口
virtual bool isDiscovering() const = 0;
// 替换进度和结束回调,传入空函数可取消通知
virtual void setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) = 0;
};

+ 89
- 5
app/src/ui/main_window.cpp Просмотреть файл

@@ -29,6 +29,7 @@
#include <QAbstractSpinBox>
#include <QApplication>
#include <QCloseEvent>
#include <QDialogButtonBox>
#include <QEvent>
#include <QFileDialog>
#include <QGraphicsScene>
@@ -41,6 +42,7 @@
#include <QMessageBox>
#include <QMenu>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QStatusBar>
#include <QTabWidget>
#include <QToolBar>
@@ -68,6 +70,55 @@ bool isTextEditingObject(QObject *object)
return false;
}

QString suggestedProjectFileName(QString project_name)
{
project_name = project_name.trimmed();
const QString invalid_characters = QStringLiteral("<>:\"/\\|?*");
for (QChar &character : project_name)
{
if (character.unicode() < 0x20U || invalid_characters.contains(character))
{
character = QLatin1Char('_');
}
}

constexpr int kMaximumSuggestedBaseNameLength = 240;
if (project_name.size() > kMaximumSuggestedBaseNameLength)
{
project_name.truncate(kMaximumSuggestedBaseNameLength);
if (!project_name.isEmpty()
&& project_name.at(project_name.size() - 1).isHighSurrogate())
{
project_name.chop(1);
}
}
while (project_name.endsWith(QLatin1Char(' '))
|| project_name.endsWith(QLatin1Char('.')))
{
project_name.chop(1);
}
if (project_name.isEmpty())
{
project_name = QStringLiteral("未命名工程");
}

const QString device_name = project_name.section(QLatin1Char('.'), 0, 0).toUpper();
const bool is_reserved_device_name = device_name == QStringLiteral("CON")
|| device_name == QStringLiteral("PRN")
|| device_name == QStringLiteral("AUX")
|| device_name == QStringLiteral("NUL")
|| (device_name.size() == 4
&& (device_name.startsWith(QStringLiteral("COM"))
|| device_name.startsWith(QStringLiteral("LPT")))
&& device_name.back() >= QLatin1Char('1')
&& device_name.back() <= QLatin1Char('9'));
if (is_reserved_device_name)
{
project_name.prepend(QLatin1Char('_'));
}
return project_name + QStringLiteral(".json");
}

bool isEditorShortcut(const QKeyEvent &event)
{
return event.matches(QKeySequence::Undo)
@@ -210,6 +261,7 @@ MainWindow::MainWindow(
AlarmService &alarm_service,
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
QWidget *parent)
: QMainWindow(parent),
ui_(std::make_unique<Ui::MainWindow>()),
@@ -222,6 +274,7 @@ MainWindow::MainWindow(
alarm_service_(alarm_service),
register_comment_service_(register_comment_service),
register_monitor_service_(register_monitor_service),
plc_discovery_gateway_(plc_discovery_gateway),
owned_hmi_navigation_service_(
std::make_unique<HmiNavigationService>(project_service)),
hmi_navigation_service_(owned_hmi_navigation_service_.get())
@@ -240,6 +293,7 @@ MainWindow::MainWindow(
HmiNavigationService &hmi_navigation_service,
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
QWidget *parent)
: QMainWindow(parent),
ui_(std::make_unique<Ui::MainWindow>()),
@@ -252,6 +306,7 @@ MainWindow::MainWindow(
alarm_service_(alarm_service),
register_comment_service_(register_comment_service),
register_monitor_service_(register_monitor_service),
plc_discovery_gateway_(plc_discovery_gateway),
hmi_navigation_service_(&hmi_navigation_service)
{
initializeUi();
@@ -1204,7 +1259,11 @@ void MainWindow::updateProjectTreeActions()

void MainWindow::connectPlc()
{
PlcConnectionDialog dialog(plc_configuration_, this);
PlcConnectionDialog dialog(
plc_configuration_,
plc_discovery_gateway_,
[this] { runtime_mode_service_.disconnectPlc(); },
this);
if (dialog.exec() != QDialog::Accepted)
{
return;
@@ -1466,12 +1525,32 @@ void MainWindow::createNewProject()
{
return;
}
const QString name = QInputDialog::getText(
this, tr("新建工程"), tr("工程名称"));
if (name.isEmpty())

QInputDialog name_dialog(this);
name_dialog.setWindowTitle(tr("新建工程"));
name_dialog.setLabelText(tr("工程名称"));
name_dialog.setInputMode(QInputDialog::TextInput);

QDialogButtonBox *button_box = name_dialog.findChild<QDialogButtonBox *>();
QPushButton *ok_button = button_box != nullptr
? button_box->button(QDialogButtonBox::Ok)
: nullptr;
if (ok_button != nullptr)
{
ok_button->setEnabled(false);
connect(&name_dialog,
&QInputDialog::textValueChanged,
&name_dialog,
[ok_button](const QString &text) {
ok_button->setEnabled(!text.trimmed().isEmpty());
});
}

if (name_dialog.exec() != QDialog::Accepted)
{
return;
}
const QString name = name_dialog.textValue().trimmed();
const ProjectOperationResult result = project_service_.createNewProject(toUtf8(name));
if (!result.succeeded)
{
@@ -1512,8 +1591,13 @@ void MainWindow::saveProject()

void MainWindow::saveProjectAs()
{
const QString suggested_file_name = suggestedProjectFileName(
fromUtf8(project_service_.project().metadata.name));
const QString path = QFileDialog::getSaveFileName(
this, tr("工程另存为"), {}, tr("工程文件 (*.json)"));
this,
tr("工程另存为"),
suggested_file_name,
tr("工程文件 (*.json)"));
if (path.isEmpty())
{
return;


+ 5
- 0
app/src/ui/main_window.h Просмотреть файл

@@ -43,6 +43,7 @@ class HmiEditorWidget;
class LogicEditorService;
class LogicEditorWidget;
class RegisterMonitorService;
class PlcDiscoveryGateway;
class RuntimeMonitorWidget;
class ProjectWorkspaceController;
class PropertyPanelController;
@@ -77,6 +78,7 @@ public:
AlarmService &alarm_service,
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
QWidget *parent = nullptr);
/**
* @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务
@@ -94,6 +96,7 @@ public:
HmiNavigationService &hmi_navigation_service,
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
QWidget *parent = nullptr);

/** 释放窗口拥有的控制器和可选导航服务 */
@@ -251,6 +254,8 @@ private:
RegisterCommentService &register_comment_service_;
/** 非拥有的寄存器监控服务依赖 */
RegisterMonitorService &register_monitor_service_;
/** 非拥有的 PLC 自动搜索服务依赖 */
PlcDiscoveryGateway &plc_discovery_gateway_;
/** 当调用方未提供导航服务时由主窗口负责拥有的实例 */
std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_;
/** 当前使用的 HMI 页面导航服务 */


+ 178
- 17
app/src/ui/plc_connection_dialog.cpp Просмотреть файл

@@ -3,9 +3,17 @@
#include "ui_plc_connection_dialog.h"

#include <QComboBox>
#include <QDialogButtonBox>
#include <QMessageBox>
#include <QProgressBar>
#include <QPushButton>
#include <QSerialPortInfo>
#include <QStyle>
#include <QTimer>
#include <QToolButton>

#include <initializer_list>
#include <utility>

namespace {

@@ -32,9 +40,13 @@ void selectData(QComboBox *combo_box, int value)

PlcConnectionDialog::PlcConnectionDialog(
const PlcSerialConfiguration &configuration,
PlcDiscoveryGateway &discovery_gateway,
std::function<void()> prepare_discovery,
QWidget *parent)
: QDialog(parent),
ui_(std::make_unique<Ui::PlcConnectionDialog>())
ui_(std::make_unique<Ui::PlcConnectionDialog>()),
discovery_gateway_(discovery_gateway),
prepare_discovery_(std::move(prepare_discovery))
{
ui_->setupUi(this);
setItemData(ui_->baudRateComboBox, {9600, 19200, 38400, 57600, 115200});
@@ -42,22 +54,8 @@ PlcConnectionDialog::PlcConnectionDialog(
setItemData(ui_->parityComboBox, {0, 3, 2});
setItemData(ui_->stopBitsComboBox, {1, 2});

for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts())
{
ui_->serialPortComboBox->addItem(
port.portName(),
port.description().isEmpty() ? port.portName() : port.description());
}
const QString port_name = QString::fromUtf8(configuration.portName.c_str());
if (!port_name.isEmpty())
{
const int port_index = ui_->serialPortComboBox->findText(port_name);
if (port_index < 0)
{
ui_->serialPortComboBox->addItem(port_name);
}
ui_->serialPortComboBox->setCurrentText(port_name);
}
refreshSerialPorts(port_name);

ui_->serverAddressSpinBox->setValue(configuration.serverAddress);
selectData(ui_->baudRateComboBox, configuration.baudRate);
@@ -67,9 +65,36 @@ PlcConnectionDialog::PlcConnectionDialog(
ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs);
ui_->retriesSpinBox->setValue(configuration.retries);
ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs);

ui_->autoSearchButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
ui_->refreshPortsButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
ui_->discoveryProgressBar->setVisible(false);
connect(
ui_->refreshPortsButton, &QToolButton::clicked,
this,
[this]
{
refreshSerialPorts(ui_->serialPortComboBox->currentText().trimmed());
});
connect(
ui_->autoSearchButton, &QPushButton::clicked,
this, &PlcConnectionDialog::toggleAutomaticDiscovery);
discovery_gateway_.setCallbacks(
[this](const PlcDiscoveryProgress &progress)
{
showDiscoveryProgress(progress);
},
[this](const PlcDiscoveryOutcome &outcome)
{
handleDiscoveryFinished(outcome);
});
}

PlcConnectionDialog::~PlcConnectionDialog() = default;
PlcConnectionDialog::~PlcConnectionDialog()
{
discovery_gateway_.setCallbacks({}, {});
discovery_gateway_.cancelDiscovery();
}

PlcSerialConfiguration PlcConnectionDialog::configuration() const
{
@@ -86,3 +111,139 @@ PlcSerialConfiguration PlcConnectionDialog::configuration() const
result.pollIntervalMs = ui_->pollIntervalSpinBox->value();
return result;
}

void PlcConnectionDialog::refreshSerialPorts(const QString &preferred_port)
{
ui_->serialPortComboBox->clear();
for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts())
{
ui_->serialPortComboBox->addItem(
port.portName(),
port.description().isEmpty() ? port.portName() : port.description());
}
const int preferred_index = ui_->serialPortComboBox->findText(preferred_port);
if (preferred_index >= 0)
{
ui_->serialPortComboBox->setCurrentIndex(preferred_index);
}
else if (ui_->serialPortComboBox->count() > 0)
{
ui_->serialPortComboBox->setCurrentIndex(0);
}
else
{
ui_->serialPortComboBox->clearEditText();
}
}

void PlcConnectionDialog::toggleAutomaticDiscovery()
{
if (discovery_gateway_.isDiscovering())
{
ui_->autoSearchButton->setEnabled(false);
ui_->autoSearchButton->setText(tr("正在停止"));
discovery_gateway_.cancelDiscovery();
return;
}
const PlcCommunicationResult result = discovery_gateway_.startDiscovery(
configuration());
if (!result.succeeded)
{
const QString message = QString::fromUtf8(result.message.c_str());
ui_->discoveryStatusLabel->setText(message);
QMessageBox::information(this, tr("自动搜索 PLC"), message);
return;
}
if (prepare_discovery_)
{
prepare_discovery_();
}
setDiscoveryUiActive(true);
}

void PlcConnectionDialog::showDiscoveryProgress(
const PlcDiscoveryProgress &progress)
{
const PlcSerialConfiguration &candidate = progress.configuration;
const QChar parity = candidate.parity == 2
? QLatin1Char('E')
: candidate.parity == 3 ? QLatin1Char('O') : QLatin1Char('N');
ui_->discoveryProgressBar->setMaximum(
static_cast<int>(progress.totalAttempts));
ui_->discoveryProgressBar->setValue(
static_cast<int>(progress.currentAttempt));
ui_->discoveryStatusLabel->setText(
tr("正在搜索 %1:站号 %2,%3,%4%5%6(%7/%8)")
.arg(QString::fromUtf8(candidate.portName.c_str()))
.arg(candidate.serverAddress)
.arg(candidate.baudRate)
.arg(candidate.dataBits)
.arg(parity)
.arg(candidate.stopBits)
.arg(progress.currentAttempt)
.arg(progress.totalAttempts));
}

void PlcConnectionDialog::handleDiscoveryFinished(
const PlcDiscoveryOutcome &outcome)
{
setDiscoveryUiActive(false);
if (outcome.cancelled)
{
ui_->discoveryStatusLabel->setText(tr("已停止自动搜索"));
return;
}
if (!outcome.found)
{
const QString message = outcome.message.empty()
? tr("未找到可响应的 PLC")
: QString::fromUtf8(outcome.message.c_str());
ui_->discoveryStatusLabel->setText(message);
QMessageBox::information(this, tr("自动搜索 PLC"), message);
return;
}
applyConfiguration(outcome.configuration);
ui_->discoveryStatusLabel->setText(tr("已找到 PLC,正在连接"));
QTimer::singleShot(0, this, &QDialog::accept);
}

void PlcConnectionDialog::setDiscoveryUiActive(bool active)
{
ui_->discoveryProgressBar->setVisible(active);
ui_->serialPortComboBox->setEnabled(!active);
ui_->refreshPortsButton->setEnabled(!active);
ui_->serverAddressSpinBox->setEnabled(!active);
ui_->baudRateComboBox->setEnabled(!active);
ui_->dataBitsComboBox->setEnabled(!active);
ui_->parityComboBox->setEnabled(!active);
ui_->stopBitsComboBox->setEnabled(!active);
ui_->timingGroupBox->setEnabled(!active);
QPushButton *ok_button = ui_->buttonBox->button(QDialogButtonBox::Ok);
if (ok_button != nullptr)
{
ok_button->setEnabled(!active);
}
ui_->autoSearchButton->setEnabled(true);
ui_->autoSearchButton->setText(active ? tr("停止搜索") : tr("自动搜索"));
ui_->autoSearchButton->setIcon(style()->standardIcon(
active ? QStyle::SP_BrowserStop : QStyle::SP_BrowserReload));
}

void PlcConnectionDialog::applyConfiguration(
const PlcSerialConfiguration &configuration)
{
const QString port_name = QString::fromUtf8(configuration.portName.c_str());
if (ui_->serialPortComboBox->findText(port_name) < 0)
{
ui_->serialPortComboBox->addItem(port_name);
}
ui_->serialPortComboBox->setCurrentText(port_name);
ui_->serverAddressSpinBox->setValue(configuration.serverAddress);
selectData(ui_->baudRateComboBox, configuration.baudRate);
selectData(ui_->dataBitsComboBox, configuration.dataBits);
selectData(ui_->parityComboBox, configuration.parity);
selectData(ui_->stopBitsComboBox, configuration.stopBits);
ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs);
ui_->retriesSpinBox->setValue(configuration.retries);
ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs);
}

+ 22
- 0
app/src/ui/plc_connection_dialog.h Просмотреть файл

@@ -9,9 +9,12 @@
#pragma once

#include "services/plc_communication_gateway.h"
#include "services/plc_discovery_gateway.h"

#include <QDialog>
#include <QString>

#include <functional>
#include <memory>

namespace Ui {
@@ -27,6 +30,8 @@ public:
/** 使用现有配置初始化 PLC 参数表单 */
explicit PlcConnectionDialog(
const PlcSerialConfiguration &configuration,
PlcDiscoveryGateway &discovery_gateway,
std::function<void()> prepare_discovery,
QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~PlcConnectionDialog() override;
@@ -35,6 +40,23 @@ public:
PlcSerialConfiguration configuration() const;

private:
/** 重新枚举当前实际存在的串口,并尽量保留仍然可用的选择 */
void refreshSerialPorts(const QString &preferred_port);
/** 开始自动搜索或请求停止当前搜索 */
void toggleAutomaticDiscovery();
/** 根据异步探测进度刷新界面 */
void showDiscoveryProgress(const PlcDiscoveryProgress &progress);
/** 处理找到、未找到和用户取消三种结束结果 */
void handleDiscoveryFinished(const PlcDiscoveryOutcome &outcome);
/** 切换搜索期间的表单编辑权限和按钮状态 */
void setDiscoveryUiActive(bool active);
/** 把一组串口配置回填到界面 */
void applyConfiguration(const PlcSerialConfiguration &configuration);

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::PlcConnectionDialog> ui_;
/** 异步 PLC 自动搜索服务 */
PlcDiscoveryGateway &discovery_gateway_;
/** 搜索前释放正式 PLC 连接占用的串口 */
std::function<void()> prepare_discovery_;
};

+ 80
- 40
app/src/ui/plc_connection_dialog.ui Просмотреть файл

@@ -6,54 +6,94 @@
<string>PLC 通信配置</string>
</property>
<property name="minimumSize">
<size><width>420</width><height>0</height></size>
<size><width>440</width><height>0</height></size>
</property>
<layout class="QVBoxLayout" name="dialogLayout">
<item>
<widget class="QGroupBox" name="serialGroupBox">
<property name="title"><string>Modbus RTU 串口</string></property>
<layout class="QFormLayout" name="serialFormLayout">
<property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property>
<item row="0" column="0"><widget class="QLabel" name="serialPortLabel"><property name="text"><string>端口</string></property></widget></item>
<item row="0" column="1">
<widget class="QComboBox" name="serialPortComboBox">
<property name="editable"><bool>true</bool></property>
<property name="placeholderText"><string>选择或输入 COM 端口</string></property>
</widget>
</item>
<item row="1" column="0"><widget class="QLabel" name="serverAddressLabel"><property name="text"><string>PLC 站号</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="serverAddressSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>247</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="baudRateLabel"><property name="text"><string>波特率</string></property></widget></item>
<item row="2" column="1">
<widget class="QComboBox" name="baudRateComboBox">
<item><property name="text"><string>9600</string></property><property name="userData"><string notr="true">9600</string></property></item>
<item><property name="text"><string>19200</string></property><property name="userData"><string notr="true">19200</string></property></item>
<item><property name="text"><string>38400</string></property><property name="userData"><string notr="true">38400</string></property></item>
<item><property name="text"><string>57600</string></property><property name="userData"><string notr="true">57600</string></property></item>
<item><property name="text"><string>115200</string></property><property name="userData"><string notr="true">115200</string></property></item>
</widget>
<layout class="QVBoxLayout" name="serialGroupLayout">
<item>
<layout class="QHBoxLayout" name="discoveryControlLayout">
<item>
<widget class="QPushButton" name="autoSearchButton">
<property name="text"><string>自动搜索</string></property>
</widget>
</item>
<item>
<widget class="QProgressBar" name="discoveryProgressBar">
<property name="minimum"><number>0</number></property>
<property name="maximum"><number>100</number></property>
<property name="value"><number>0</number></property>
<property name="textVisible"><bool>false</bool></property>
</widget>
</item>
</layout>
</item>
<item row="3" column="0"><widget class="QLabel" name="dataBitsLabel"><property name="text"><string>数据位</string></property></widget></item>
<item row="3" column="1">
<widget class="QComboBox" name="dataBitsComboBox">
<item><property name="text"><string>7</string></property><property name="userData"><string notr="true">7</string></property></item>
<item><property name="text"><string>8</string></property><property name="userData"><string notr="true">8</string></property></item>
<item>
<widget class="QLabel" name="discoveryStatusLabel">
<property name="text"><string>自动搜索使用当前 PLC 站号</string></property>
<property name="wordWrap"><bool>true</bool></property>
</widget>
</item>
<item row="4" column="0"><widget class="QLabel" name="parityLabel"><property name="text"><string>校验方式</string></property></widget></item>
<item row="4" column="1">
<widget class="QComboBox" name="parityComboBox">
<item><property name="text"><string>无校验</string></property><property name="userData"><string notr="true">0</string></property></item>
<item><property name="text"><string>奇校验</string></property><property name="userData"><string notr="true">3</string></property></item>
<item><property name="text"><string>偶校验</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
</item>
<item row="5" column="0"><widget class="QLabel" name="stopBitsLabel"><property name="text"><string>停止位</string></property></widget></item>
<item row="5" column="1">
<widget class="QComboBox" name="stopBitsComboBox">
<item><property name="text"><string>1</string></property><property name="userData"><string notr="true">1</string></property></item>
<item><property name="text"><string>2</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
<item>
<layout class="QFormLayout" name="serialFormLayout">
<property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property>
<item row="0" column="0"><widget class="QLabel" name="serialPortLabel"><property name="text"><string>端口</string></property></widget></item>
<item row="0" column="1">
<layout class="QHBoxLayout" name="serialPortEditorLayout">
<item>
<widget class="QComboBox" name="serialPortComboBox">
<property name="editable"><bool>true</bool></property>
<property name="placeholderText"><string>选择或输入 COM 端口</string></property>
</widget>
</item>
<item>
<widget class="QToolButton" name="refreshPortsButton">
<property name="minimumSize"><size><width>28</width><height>28</height></size></property>
<property name="maximumSize"><size><width>28</width><height>28</height></size></property>
<property name="toolTip"><string>刷新串口</string></property>
<property name="accessibleName"><string>刷新串口</string></property>
<property name="autoRaise"><bool>true</bool></property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0"><widget class="QLabel" name="serverAddressLabel"><property name="text"><string>PLC 站号</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="serverAddressSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>247</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="baudRateLabel"><property name="text"><string>波特率</string></property></widget></item>
<item row="2" column="1">
<widget class="QComboBox" name="baudRateComboBox">
<item><property name="text"><string>9600</string></property><property name="userData"><string notr="true">9600</string></property></item>
<item><property name="text"><string>19200</string></property><property name="userData"><string notr="true">19200</string></property></item>
<item><property name="text"><string>38400</string></property><property name="userData"><string notr="true">38400</string></property></item>
<item><property name="text"><string>57600</string></property><property name="userData"><string notr="true">57600</string></property></item>
<item><property name="text"><string>115200</string></property><property name="userData"><string notr="true">115200</string></property></item>
</widget>
</item>
<item row="3" column="0"><widget class="QLabel" name="dataBitsLabel"><property name="text"><string>数据位</string></property></widget></item>
<item row="3" column="1">
<widget class="QComboBox" name="dataBitsComboBox">
<item><property name="text"><string>7</string></property><property name="userData"><string notr="true">7</string></property></item>
<item><property name="text"><string>8</string></property><property name="userData"><string notr="true">8</string></property></item>
</widget>
</item>
<item row="4" column="0"><widget class="QLabel" name="parityLabel"><property name="text"><string>校验方式</string></property></widget></item>
<item row="4" column="1">
<widget class="QComboBox" name="parityComboBox">
<item><property name="text"><string>无校验</string></property><property name="userData"><string notr="true">0</string></property></item>
<item><property name="text"><string>奇校验</string></property><property name="userData"><string notr="true">3</string></property></item>
<item><property name="text"><string>偶校验</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
</item>
<item row="5" column="0"><widget class="QLabel" name="stopBitsLabel"><property name="text"><string>停止位</string></property></widget></item>
<item row="5" column="1">
<widget class="QComboBox" name="stopBitsComboBox">
<item><property name="text"><string>1</string></property><property name="userData"><string notr="true">1</string></property></item>
<item><property name="text"><string>2</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
</item>
</layout>
</item>
</layout>
</widget>


+ 203
- 0
app/tests/plc_connection_dialog_tests.cpp Просмотреть файл

@@ -0,0 +1,203 @@
#include "services/plc_discovery_gateway.h"
#include "support/test_support.h"
#include "ui/plc_connection_dialog.h"

#include <QApplication>
#include <QComboBox>
#include <QCoreApplication>
#include <QEventLoop>
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
#include <QToolButton>

#include <functional>
#include <iostream>
#include <stdexcept>
#include <utility>

namespace {

using TestSupport::require;

class FakePlcDiscoveryGateway final : public PlcDiscoveryGateway
{
public:
PlcCommunicationResult startDiscovery(
const PlcSerialConfiguration &preferred) override
{
++start_count;
last_preferred = preferred;
discovering = true;
return {true, {}};
}

void cancelDiscovery() override
{
if (!discovering)
{
return;
}
++cancel_count;
discovering = false;
if (finished_callback)
{
finished_callback({false, true, {}, {}});
}
}

bool isDiscovering() const override
{
return discovering;
}

void setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) override
{
progress_callback = std::move(progress_changed);
finished_callback = std::move(discovery_finished);
}

void reportProgress(
const PlcSerialConfiguration &candidate,
std::size_t current,
std::size_t total)
{
if (progress_callback)
{
progress_callback({candidate, current, total});
}
}

void reportFound(const PlcSerialConfiguration &configuration)
{
discovering = false;
if (finished_callback)
{
finished_callback({true, false, configuration, "found"});
}
}

bool discovering = false;
int start_count = 0;
int cancel_count = 0;
PlcSerialConfiguration last_preferred;
std::function<void(const PlcDiscoveryProgress &)> progress_callback;
std::function<void(const PlcDiscoveryOutcome &)> finished_callback;
};

void testDiscoveryFindsAndAcceptsConfiguration()
{
FakePlcDiscoveryGateway gateway;
PlcSerialConfiguration initial;
initial.portName = "COM1";
int prepare_count = 0;
PlcConnectionDialog dialog(
initial, gateway, [&prepare_count] { ++prepare_count; });

QPushButton *search_button = dialog.findChild<QPushButton *>("autoSearchButton");
QProgressBar *progress_bar = dialog.findChild<QProgressBar *>(
"discoveryProgressBar");
QLabel *status_label = dialog.findChild<QLabel *>("discoveryStatusLabel");
require(search_button != nullptr && progress_bar != nullptr
&& status_label != nullptr,
"PLC dialog must expose automatic discovery controls");

search_button->click();
require(prepare_count == 1 && gateway.start_count == 1 && gateway.discovering,
"automatic discovery must release the formal connection and start once");
require(search_button->text() == QStringLiteral("停止搜索")
&& !progress_bar->isHidden(),
"the dialog must show searchable progress and a stop command");

PlcSerialConfiguration found = initial;
found.portName = "COM3";
found.serverAddress = 1;
found.baudRate = 19200;
found.dataBits = 8;
found.parity = 0;
found.stopBits = 1;
gateway.reportProgress(found, 4, 180);
require(progress_bar->maximum() == 180 && progress_bar->value() == 4
&& status_label->text().contains(QStringLiteral("COM3"))
&& status_label->text().contains(QStringLiteral("8N1")),
"discovery progress must identify the active port and serial frame");

gateway.reportFound(found);
QCoreApplication::processEvents(QEventLoop::AllEvents);
const PlcSerialConfiguration selected = dialog.configuration();
require(dialog.result() == QDialog::Accepted,
"a successful discovery must automatically accept the dialog");
require(selected.portName == found.portName
&& selected.serverAddress == found.serverAddress
&& selected.baudRate == found.baudRate
&& selected.dataBits == found.dataBits
&& selected.parity == found.parity
&& selected.stopBits == found.stopBits,
"a successful discovery must copy every detected serial parameter");
}

void testDiscoveryCanBeCancelled()
{
FakePlcDiscoveryGateway gateway;
PlcSerialConfiguration initial;
initial.portName = "COM1";
PlcConnectionDialog dialog(initial, gateway, [] {});
QPushButton *search_button = dialog.findChild<QPushButton *>("autoSearchButton");
QLabel *status_label = dialog.findChild<QLabel *>("discoveryStatusLabel");

search_button->click();
search_button->click();
require(gateway.cancel_count == 1 && !gateway.discovering,
"the stop command must cancel the active discovery");
require(search_button->isEnabled()
&& search_button->text() == QStringLiteral("自动搜索")
&& status_label->text() == QStringLiteral("已停止自动搜索"),
"the dialog must return to an editable state after cancellation");
}

void testPortRefreshRemovesUnavailableSelection()
{
FakePlcDiscoveryGateway gateway;
PlcSerialConfiguration initial;
initial.portName = "COM_STALE_TEST_999";
PlcConnectionDialog dialog(initial, gateway, [] {});
QComboBox *port_combo = dialog.findChild<QComboBox *>("serialPortComboBox");
QToolButton *refresh_button = dialog.findChild<QToolButton *>(
"refreshPortsButton");
require(port_combo != nullptr && refresh_button != nullptr,
"PLC dialog must expose a serial port refresh command");
require(port_combo->findText(QString::fromLatin1(initial.portName.c_str())) < 0,
"opening the dialog must not restore an unavailable saved port");

const QString stale_port = QStringLiteral("COM_STALE_TEST_998");
port_combo->addItem(stale_port);
port_combo->setCurrentText(stale_port);
refresh_button->click();
require(port_combo->findText(stale_port) < 0
&& port_combo->currentText() != stale_port,
"refreshing ports must remove an unplugged or unavailable selection");
}

} // namespace

int main(int argc, char *argv[])
{
qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
QApplication application(argc, argv);
try
{
testDiscoveryFindsAndAcceptsConfiguration();
testDiscoveryCanBeCancelled();
testPortRefreshRemovesUnavailableSelection();
}
catch (const std::exception &error)
{
std::cerr << "PLC connection dialog tests failed: "
<< error.what() << '\n';
return 1;
}
std::cout << "PLC connection dialog tests passed\n";
return 0;
}

+ 18
- 0
app/tests/plc_connection_dialog_tests.pro Просмотреть файл

@@ -0,0 +1,18 @@
include(pri/test_defaults.pri)

TARGET = plc_connection_dialog_tests
QT += core gui widgets serialport
CONFIG += testcase

SOURCES += \
plc_connection_dialog_tests.cpp \
../src/ui/plc_connection_dialog.cpp

HEADERS += \
../src/services/plc_communication_gateway.h \
../src/services/plc_discovery_gateway.h \
../src/ui/plc_connection_dialog.h \
support/test_support.h

FORMS += \
../src/ui/plc_connection_dialog.ui

+ 70
- 0
app/tests/plc_runtime_tests.cpp Просмотреть файл

@@ -8,12 +8,15 @@
#include "domain/virtual_register_repository.h"
#include "services/offline_simulation_service.h"
#include "services/plc_communication_gateway.h"
#include "services/plc_discovery_gateway.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"

#include <functional>
#include <iostream>
#include <set>
#include <stdexcept>
#include <tuple>
#include <utility>

namespace {
@@ -330,6 +333,72 @@ void testPlcConfigurationBoundaries()
"a response timeout of 100 ms must be accepted");
}

void testPlcDiscoveryCandidatePriorityAndCoverage()
{
PlcSerialConfiguration preferred;
preferred.portName = "COM3";
preferred.serverAddress = 17;
preferred.baudRate = 38400;
preferred.dataBits = 7;
preferred.parity = 3;
preferred.stopBits = 2;
preferred.responseTimeoutMs = 1500;
preferred.retries = 4;
preferred.pollIntervalMs = 350;

const std::vector<PlcSerialConfiguration> candidates =
buildPlcDiscoveryCandidates(
preferred, {"COM1", "COM2", "COM3", "COM2", ""});
require(candidates.size() == 180U,
"discovery must cover 60 serial settings on every unique available port");
require(candidates.front().portName == "COM3"
&& candidates.front().serverAddress == 17
&& candidates.front().baudRate == 38400
&& candidates.front().dataBits == 7
&& candidates.front().parity == 3
&& candidates.front().stopBits == 2,
"discovery must try the complete current configuration first");
require(candidates.at(1).portName == "COM1"
&& candidates.at(2).portName == "COM2",
"the preferred port must be first without dropping other ports");

std::set<std::tuple<std::string, int, int, int, int>> unique_candidates;
bool contains_standard_8e1 = false;
bool contains_full_supported_edge = false;
for (const PlcSerialConfiguration &candidate : candidates)
{
require(candidate.serverAddress == preferred.serverAddress
&& candidate.responseTimeoutMs == preferred.responseTimeoutMs
&& candidate.retries == preferred.retries
&& candidate.pollIntervalMs == preferred.pollIntervalMs,
"discovery must preserve the selected station and normal timing settings");
unique_candidates.emplace(
candidate.portName,
candidate.baudRate,
candidate.dataBits,
candidate.parity,
candidate.stopBits);
contains_standard_8e1 = contains_standard_8e1
|| (candidate.portName == "COM2"
&& candidate.baudRate == 9600
&& candidate.dataBits == 8
&& candidate.parity == 2
&& candidate.stopBits == 1);
contains_full_supported_edge = contains_full_supported_edge
|| (candidate.portName == "COM1"
&& candidate.baudRate == 115200
&& candidate.dataBits == 7
&& candidate.parity == 0
&& candidate.stopBits == 2);
}
require(unique_candidates.size() == candidates.size(),
"discovery candidates must not contain duplicate serial settings");
require(contains_standard_8e1 && contains_full_supported_edge,
"discovery must cover common settings and every supported boundary");
require(buildPlcDiscoveryCandidates(preferred, {}).empty(),
"discovery must stop immediately when no serial port is available");
}

void testPlcPollQuantityBoundaries()
{
PlcRegisterRepository repository;
@@ -374,6 +443,7 @@ int main()
testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect();
testPlcCommunicationErrorClassification();
testPlcConfigurationBoundaries();
testPlcDiscoveryCandidatePriorityAndCoverage();
testPlcPollQuantityBoundaries();
}
catch (const std::exception &error)


+ 8
- 4
app/tests/pri/test_layers.pri Просмотреть файл

@@ -104,10 +104,12 @@ SERVICE_OFFLINE_HEADERS = \
../src/services/offline_simulation_service.h

SERVICE_RUNTIME_SOURCES = \
../src/services/runtime_mode_service.cpp
../src/services/runtime_mode_service.cpp \
../src/services/plc_discovery_gateway.cpp

SERVICE_RUNTIME_HEADERS = \
../src/services/runtime_mode_service.h
../src/services/runtime_mode_service.h \
../src/services/plc_discovery_gateway.h

SERVICE_MONITOR_SOURCES = \
../src/services/register_monitor_service.cpp \
@@ -126,9 +128,11 @@ INFRASTRUCTURE_JSON_HEADERS = \
INFRASTRUCTURE_PLC_SOURCES = \
../src/infrastructure/plc_communication_error_classifier.cpp \
../src/infrastructure/plc_register_repository.cpp \
../src/infrastructure/plc_communication_service.cpp
../src/infrastructure/plc_communication_service.cpp \
../src/infrastructure/plc_discovery_service.cpp

INFRASTRUCTURE_PLC_HEADERS = \
../src/infrastructure/plc_communication_error_classifier.h \
../src/infrastructure/plc_register_repository.h \
../src/infrastructure/plc_communication_service.h
../src/infrastructure/plc_communication_service.h \
../src/infrastructure/plc_discovery_service.h

+ 2
- 0
app/tests/tests.pro Просмотреть файл

@@ -12,6 +12,7 @@ SUBDIRS += \
register_monitor \
runtime_mode \
runtime_panel_controller \
plc_connection_dialog \
plc_runtime

domain.file = domain_tests.pro
@@ -23,4 +24,5 @@ project_management.file = project_management_tests.pro
register_monitor.file = register_monitor_service_tests.pro
runtime_mode.file = runtime_mode_service_tests.pro
runtime_panel_controller.file = runtime_panel_controller_tests.pro
plc_connection_dialog.file = plc_connection_dialog_tests.pro
plc_runtime.file = plc_runtime_tests.pro

Загрузка…
Отмена
Сохранить