| @@ -48,11 +48,13 @@ SOURCES += \ | |||||
| src/services/software_logic_executor.cpp \ | src/services/software_logic_executor.cpp \ | ||||
| src/services/offline_simulation_service.cpp \ | src/services/offline_simulation_service.cpp \ | ||||
| src/services/runtime_mode_service.cpp \ | src/services/runtime_mode_service.cpp \ | ||||
| src/services/plc_discovery_gateway.cpp \ | |||||
| src/services/register_monitor_service.cpp \ | src/services/register_monitor_service.cpp \ | ||||
| src/services/register_comment_service.cpp \ | src/services/register_comment_service.cpp \ | ||||
| src/infrastructure/plc_register_repository.cpp \ | src/infrastructure/plc_register_repository.cpp \ | ||||
| src/infrastructure/plc_communication_error_classifier.cpp \ | src/infrastructure/plc_communication_error_classifier.cpp \ | ||||
| src/infrastructure/plc_communication_service.cpp \ | src/infrastructure/plc_communication_service.cpp \ | ||||
| src/infrastructure/plc_discovery_service.cpp \ | |||||
| src/infrastructure/json_project_storage.cpp \ | src/infrastructure/json_project_storage.cpp \ | ||||
| src/ui/hmi_editor_widget.cpp \ | src/ui/hmi_editor_widget.cpp \ | ||||
| src/ui/logic_editor_widget.cpp | src/ui/logic_editor_widget.cpp | ||||
| @@ -94,12 +96,14 @@ HEADERS += \ | |||||
| src/services/software_logic_executor.h \ | src/services/software_logic_executor.h \ | ||||
| src/services/offline_simulation_service.h \ | src/services/offline_simulation_service.h \ | ||||
| src/services/runtime_mode_service.h \ | src/services/runtime_mode_service.h \ | ||||
| src/services/plc_discovery_gateway.h \ | |||||
| src/services/register_monitor_service.h \ | src/services/register_monitor_service.h \ | ||||
| src/services/register_comment_service.h \ | src/services/register_comment_service.h \ | ||||
| src/services/plc_communication_gateway.h \ | src/services/plc_communication_gateway.h \ | ||||
| src/infrastructure/plc_register_repository.h \ | src/infrastructure/plc_register_repository.h \ | ||||
| src/infrastructure/plc_communication_error_classifier.h \ | src/infrastructure/plc_communication_error_classifier.h \ | ||||
| src/infrastructure/plc_communication_service.h \ | src/infrastructure/plc_communication_service.h \ | ||||
| src/infrastructure/plc_discovery_service.h \ | |||||
| src/infrastructure/json_project_storage.h \ | src/infrastructure/json_project_storage.h \ | ||||
| src/ui/hmi_editor_widget.h \ | src/ui/hmi_editor_widget.h \ | ||||
| src/ui/logic_editor_widget.h | src/ui/logic_editor_widget.h | ||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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_; | |||||
| }; | |||||
| @@ -10,6 +10,7 @@ | |||||
| #include "infrastructure/json_project_storage.h" | #include "infrastructure/json_project_storage.h" | ||||
| #include "infrastructure/plc_communication_service.h" | #include "infrastructure/plc_communication_service.h" | ||||
| #include "infrastructure/plc_discovery_service.h" | |||||
| #include "infrastructure/plc_register_repository.h" | #include "infrastructure/plc_register_repository.h" | ||||
| #include "domain/active_register_repository.h" | #include "domain/active_register_repository.h" | ||||
| #include "domain/virtual_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); | ActiveRegisterRepository active_register_repository(virtual_register_repository); | ||||
| // 负责通过串口异步连接、读取和写入真实 PLC | // 负责通过串口异步连接、读取和写入真实 PLC | ||||
| PlcCommunicationService plc_communication_service(plc_register_repository); | PlcCommunicationService plc_communication_service(plc_register_repository); | ||||
| // 使用独立串口会话自动搜索 PLC,不影响正式通信状态机 | |||||
| PlcDiscoveryService plc_discovery_service; | |||||
| // 处理运行画面读取和写入寄存器,数据来自当前选中的寄存器入口 | // 处理运行画面读取和写入寄存器,数据来自当前选中的寄存器入口 | ||||
| HmiRuntimeService hmi_runtime_service(active_register_repository); | HmiRuntimeService hmi_runtime_service(active_register_repository); | ||||
| @@ -91,7 +94,8 @@ int main(int argc, char *argv[]) | |||||
| alarm_service, | alarm_service, | ||||
| hmi_navigation_service, | hmi_navigation_service, | ||||
| register_comment_service, | register_comment_service, | ||||
| register_monitor_service); | |||||
| register_monitor_service, | |||||
| plc_discovery_service); | |||||
| // 把主窗口显示到屏幕上 | // 把主窗口显示到屏幕上 | ||||
| main_window.show(); | main_window.show(); | ||||
| @@ -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; | |||||
| } | |||||
| @@ -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; | |||||
| }; | |||||
| @@ -29,6 +29,7 @@ | |||||
| #include <QAbstractSpinBox> | #include <QAbstractSpinBox> | ||||
| #include <QApplication> | #include <QApplication> | ||||
| #include <QCloseEvent> | #include <QCloseEvent> | ||||
| #include <QDialogButtonBox> | |||||
| #include <QEvent> | #include <QEvent> | ||||
| #include <QFileDialog> | #include <QFileDialog> | ||||
| #include <QGraphicsScene> | #include <QGraphicsScene> | ||||
| @@ -41,6 +42,7 @@ | |||||
| #include <QMessageBox> | #include <QMessageBox> | ||||
| #include <QMenu> | #include <QMenu> | ||||
| #include <QPlainTextEdit> | #include <QPlainTextEdit> | ||||
| #include <QPushButton> | |||||
| #include <QStatusBar> | #include <QStatusBar> | ||||
| #include <QTabWidget> | #include <QTabWidget> | ||||
| #include <QToolBar> | #include <QToolBar> | ||||
| @@ -68,6 +70,55 @@ bool isTextEditingObject(QObject *object) | |||||
| return false; | 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) | bool isEditorShortcut(const QKeyEvent &event) | ||||
| { | { | ||||
| return event.matches(QKeySequence::Undo) | return event.matches(QKeySequence::Undo) | ||||
| @@ -210,6 +261,7 @@ MainWindow::MainWindow( | |||||
| AlarmService &alarm_service, | AlarmService &alarm_service, | ||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||||
| QWidget *parent) | QWidget *parent) | ||||
| : QMainWindow(parent), | : QMainWindow(parent), | ||||
| ui_(std::make_unique<Ui::MainWindow>()), | ui_(std::make_unique<Ui::MainWindow>()), | ||||
| @@ -222,6 +274,7 @@ MainWindow::MainWindow( | |||||
| alarm_service_(alarm_service), | alarm_service_(alarm_service), | ||||
| register_comment_service_(register_comment_service), | register_comment_service_(register_comment_service), | ||||
| register_monitor_service_(register_monitor_service), | register_monitor_service_(register_monitor_service), | ||||
| plc_discovery_gateway_(plc_discovery_gateway), | |||||
| owned_hmi_navigation_service_( | owned_hmi_navigation_service_( | ||||
| std::make_unique<HmiNavigationService>(project_service)), | std::make_unique<HmiNavigationService>(project_service)), | ||||
| hmi_navigation_service_(owned_hmi_navigation_service_.get()) | hmi_navigation_service_(owned_hmi_navigation_service_.get()) | ||||
| @@ -240,6 +293,7 @@ MainWindow::MainWindow( | |||||
| HmiNavigationService &hmi_navigation_service, | HmiNavigationService &hmi_navigation_service, | ||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||||
| QWidget *parent) | QWidget *parent) | ||||
| : QMainWindow(parent), | : QMainWindow(parent), | ||||
| ui_(std::make_unique<Ui::MainWindow>()), | ui_(std::make_unique<Ui::MainWindow>()), | ||||
| @@ -252,6 +306,7 @@ MainWindow::MainWindow( | |||||
| alarm_service_(alarm_service), | alarm_service_(alarm_service), | ||||
| register_comment_service_(register_comment_service), | register_comment_service_(register_comment_service), | ||||
| register_monitor_service_(register_monitor_service), | register_monitor_service_(register_monitor_service), | ||||
| plc_discovery_gateway_(plc_discovery_gateway), | |||||
| hmi_navigation_service_(&hmi_navigation_service) | hmi_navigation_service_(&hmi_navigation_service) | ||||
| { | { | ||||
| initializeUi(); | initializeUi(); | ||||
| @@ -1204,7 +1259,11 @@ void MainWindow::updateProjectTreeActions() | |||||
| void MainWindow::connectPlc() | 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) | if (dialog.exec() != QDialog::Accepted) | ||||
| { | { | ||||
| return; | return; | ||||
| @@ -1466,12 +1525,32 @@ void MainWindow::createNewProject() | |||||
| { | { | ||||
| return; | 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; | return; | ||||
| } | } | ||||
| const QString name = name_dialog.textValue().trimmed(); | |||||
| const ProjectOperationResult result = project_service_.createNewProject(toUtf8(name)); | const ProjectOperationResult result = project_service_.createNewProject(toUtf8(name)); | ||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| @@ -1512,8 +1591,13 @@ void MainWindow::saveProject() | |||||
| void MainWindow::saveProjectAs() | void MainWindow::saveProjectAs() | ||||
| { | { | ||||
| const QString suggested_file_name = suggestedProjectFileName( | |||||
| fromUtf8(project_service_.project().metadata.name)); | |||||
| const QString path = QFileDialog::getSaveFileName( | const QString path = QFileDialog::getSaveFileName( | ||||
| this, tr("工程另存为"), {}, tr("工程文件 (*.json)")); | |||||
| this, | |||||
| tr("工程另存为"), | |||||
| suggested_file_name, | |||||
| tr("工程文件 (*.json)")); | |||||
| if (path.isEmpty()) | if (path.isEmpty()) | ||||
| { | { | ||||
| return; | return; | ||||
| @@ -43,6 +43,7 @@ class HmiEditorWidget; | |||||
| class LogicEditorService; | class LogicEditorService; | ||||
| class LogicEditorWidget; | class LogicEditorWidget; | ||||
| class RegisterMonitorService; | class RegisterMonitorService; | ||||
| class PlcDiscoveryGateway; | |||||
| class RuntimeMonitorWidget; | class RuntimeMonitorWidget; | ||||
| class ProjectWorkspaceController; | class ProjectWorkspaceController; | ||||
| class PropertyPanelController; | class PropertyPanelController; | ||||
| @@ -77,6 +78,7 @@ public: | |||||
| AlarmService &alarm_service, | AlarmService &alarm_service, | ||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** | /** | ||||
| * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 | * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 | ||||
| @@ -94,6 +96,7 @@ public: | |||||
| HmiNavigationService &hmi_navigation_service, | HmiNavigationService &hmi_navigation_service, | ||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放窗口拥有的控制器和可选导航服务 */ | /** 释放窗口拥有的控制器和可选导航服务 */ | ||||
| @@ -251,6 +254,8 @@ private: | |||||
| RegisterCommentService ®ister_comment_service_; | RegisterCommentService ®ister_comment_service_; | ||||
| /** 非拥有的寄存器监控服务依赖 */ | /** 非拥有的寄存器监控服务依赖 */ | ||||
| RegisterMonitorService ®ister_monitor_service_; | RegisterMonitorService ®ister_monitor_service_; | ||||
| /** 非拥有的 PLC 自动搜索服务依赖 */ | |||||
| PlcDiscoveryGateway &plc_discovery_gateway_; | |||||
| /** 当调用方未提供导航服务时由主窗口负责拥有的实例 */ | /** 当调用方未提供导航服务时由主窗口负责拥有的实例 */ | ||||
| std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | ||||
| /** 当前使用的 HMI 页面导航服务 */ | /** 当前使用的 HMI 页面导航服务 */ | ||||
| @@ -3,9 +3,17 @@ | |||||
| #include "ui_plc_connection_dialog.h" | #include "ui_plc_connection_dialog.h" | ||||
| #include <QComboBox> | #include <QComboBox> | ||||
| #include <QDialogButtonBox> | |||||
| #include <QMessageBox> | |||||
| #include <QProgressBar> | |||||
| #include <QPushButton> | |||||
| #include <QSerialPortInfo> | #include <QSerialPortInfo> | ||||
| #include <QStyle> | |||||
| #include <QTimer> | |||||
| #include <QToolButton> | |||||
| #include <initializer_list> | #include <initializer_list> | ||||
| #include <utility> | |||||
| namespace { | namespace { | ||||
| @@ -32,9 +40,13 @@ void selectData(QComboBox *combo_box, int value) | |||||
| PlcConnectionDialog::PlcConnectionDialog( | PlcConnectionDialog::PlcConnectionDialog( | ||||
| const PlcSerialConfiguration &configuration, | const PlcSerialConfiguration &configuration, | ||||
| PlcDiscoveryGateway &discovery_gateway, | |||||
| std::function<void()> prepare_discovery, | |||||
| QWidget *parent) | QWidget *parent) | ||||
| : QDialog(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); | ui_->setupUi(this); | ||||
| setItemData(ui_->baudRateComboBox, {9600, 19200, 38400, 57600, 115200}); | setItemData(ui_->baudRateComboBox, {9600, 19200, 38400, 57600, 115200}); | ||||
| @@ -42,22 +54,8 @@ PlcConnectionDialog::PlcConnectionDialog( | |||||
| setItemData(ui_->parityComboBox, {0, 3, 2}); | setItemData(ui_->parityComboBox, {0, 3, 2}); | ||||
| setItemData(ui_->stopBitsComboBox, {1, 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()); | 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); | ui_->serverAddressSpinBox->setValue(configuration.serverAddress); | ||||
| selectData(ui_->baudRateComboBox, configuration.baudRate); | selectData(ui_->baudRateComboBox, configuration.baudRate); | ||||
| @@ -67,9 +65,36 @@ PlcConnectionDialog::PlcConnectionDialog( | |||||
| ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs); | ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs); | ||||
| ui_->retriesSpinBox->setValue(configuration.retries); | ui_->retriesSpinBox->setValue(configuration.retries); | ||||
| ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs); | 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 | PlcSerialConfiguration PlcConnectionDialog::configuration() const | ||||
| { | { | ||||
| @@ -86,3 +111,139 @@ PlcSerialConfiguration PlcConnectionDialog::configuration() const | |||||
| result.pollIntervalMs = ui_->pollIntervalSpinBox->value(); | result.pollIntervalMs = ui_->pollIntervalSpinBox->value(); | ||||
| return result; | 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); | |||||
| } | |||||
| @@ -9,9 +9,12 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "services/plc_communication_gateway.h" | #include "services/plc_communication_gateway.h" | ||||
| #include "services/plc_discovery_gateway.h" | |||||
| #include <QDialog> | #include <QDialog> | ||||
| #include <QString> | |||||
| #include <functional> | |||||
| #include <memory> | #include <memory> | ||||
| namespace Ui { | namespace Ui { | ||||
| @@ -27,6 +30,8 @@ public: | |||||
| /** 使用现有配置初始化 PLC 参数表单 */ | /** 使用现有配置初始化 PLC 参数表单 */ | ||||
| explicit PlcConnectionDialog( | explicit PlcConnectionDialog( | ||||
| const PlcSerialConfiguration &configuration, | const PlcSerialConfiguration &configuration, | ||||
| PlcDiscoveryGateway &discovery_gateway, | |||||
| std::function<void()> prepare_discovery, | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放 Designer 界面对象 */ | /** 释放 Designer 界面对象 */ | ||||
| ~PlcConnectionDialog() override; | ~PlcConnectionDialog() override; | ||||
| @@ -35,6 +40,23 @@ public: | |||||
| PlcSerialConfiguration configuration() const; | PlcSerialConfiguration configuration() const; | ||||
| private: | 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 生成的界面对象 */ | /** Qt Designer 生成的界面对象 */ | ||||
| std::unique_ptr<Ui::PlcConnectionDialog> ui_; | std::unique_ptr<Ui::PlcConnectionDialog> ui_; | ||||
| /** 异步 PLC 自动搜索服务 */ | |||||
| PlcDiscoveryGateway &discovery_gateway_; | |||||
| /** 搜索前释放正式 PLC 连接占用的串口 */ | |||||
| std::function<void()> prepare_discovery_; | |||||
| }; | }; | ||||
| @@ -6,54 +6,94 @@ | |||||
| <string>PLC 通信配置</string> | <string>PLC 通信配置</string> | ||||
| </property> | </property> | ||||
| <property name="minimumSize"> | <property name="minimumSize"> | ||||
| <size><width>420</width><height>0</height></size> | |||||
| <size><width>440</width><height>0</height></size> | |||||
| </property> | </property> | ||||
| <layout class="QVBoxLayout" name="dialogLayout"> | <layout class="QVBoxLayout" name="dialogLayout"> | ||||
| <item> | <item> | ||||
| <widget class="QGroupBox" name="serialGroupBox"> | <widget class="QGroupBox" name="serialGroupBox"> | ||||
| <property name="title"><string>Modbus RTU 串口</string></property> | <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> | ||||
| <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> | </widget> | ||||
| </item> | </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> | </item> | ||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| @@ -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; | |||||
| } | |||||
| @@ -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 | |||||
| @@ -8,12 +8,15 @@ | |||||
| #include "domain/virtual_register_repository.h" | #include "domain/virtual_register_repository.h" | ||||
| #include "services/offline_simulation_service.h" | #include "services/offline_simulation_service.h" | ||||
| #include "services/plc_communication_gateway.h" | #include "services/plc_communication_gateway.h" | ||||
| #include "services/plc_discovery_gateway.h" | |||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "services/runtime_mode_service.h" | #include "services/runtime_mode_service.h" | ||||
| #include <functional> | #include <functional> | ||||
| #include <iostream> | #include <iostream> | ||||
| #include <set> | |||||
| #include <stdexcept> | #include <stdexcept> | ||||
| #include <tuple> | |||||
| #include <utility> | #include <utility> | ||||
| namespace { | namespace { | ||||
| @@ -330,6 +333,72 @@ void testPlcConfigurationBoundaries() | |||||
| "a response timeout of 100 ms must be accepted"); | "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() | void testPlcPollQuantityBoundaries() | ||||
| { | { | ||||
| PlcRegisterRepository repository; | PlcRegisterRepository repository; | ||||
| @@ -374,6 +443,7 @@ int main() | |||||
| testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); | testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); | ||||
| testPlcCommunicationErrorClassification(); | testPlcCommunicationErrorClassification(); | ||||
| testPlcConfigurationBoundaries(); | testPlcConfigurationBoundaries(); | ||||
| testPlcDiscoveryCandidatePriorityAndCoverage(); | |||||
| testPlcPollQuantityBoundaries(); | testPlcPollQuantityBoundaries(); | ||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| @@ -104,10 +104,12 @@ SERVICE_OFFLINE_HEADERS = \ | |||||
| ../src/services/offline_simulation_service.h | ../src/services/offline_simulation_service.h | ||||
| SERVICE_RUNTIME_SOURCES = \ | SERVICE_RUNTIME_SOURCES = \ | ||||
| ../src/services/runtime_mode_service.cpp | |||||
| ../src/services/runtime_mode_service.cpp \ | |||||
| ../src/services/plc_discovery_gateway.cpp | |||||
| SERVICE_RUNTIME_HEADERS = \ | SERVICE_RUNTIME_HEADERS = \ | ||||
| ../src/services/runtime_mode_service.h | |||||
| ../src/services/runtime_mode_service.h \ | |||||
| ../src/services/plc_discovery_gateway.h | |||||
| SERVICE_MONITOR_SOURCES = \ | SERVICE_MONITOR_SOURCES = \ | ||||
| ../src/services/register_monitor_service.cpp \ | ../src/services/register_monitor_service.cpp \ | ||||
| @@ -126,9 +128,11 @@ INFRASTRUCTURE_JSON_HEADERS = \ | |||||
| INFRASTRUCTURE_PLC_SOURCES = \ | INFRASTRUCTURE_PLC_SOURCES = \ | ||||
| ../src/infrastructure/plc_communication_error_classifier.cpp \ | ../src/infrastructure/plc_communication_error_classifier.cpp \ | ||||
| ../src/infrastructure/plc_register_repository.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 = \ | INFRASTRUCTURE_PLC_HEADERS = \ | ||||
| ../src/infrastructure/plc_communication_error_classifier.h \ | ../src/infrastructure/plc_communication_error_classifier.h \ | ||||
| ../src/infrastructure/plc_register_repository.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 | |||||
| @@ -12,6 +12,7 @@ SUBDIRS += \ | |||||
| register_monitor \ | register_monitor \ | ||||
| runtime_mode \ | runtime_mode \ | ||||
| runtime_panel_controller \ | runtime_panel_controller \ | ||||
| plc_connection_dialog \ | |||||
| plc_runtime | plc_runtime | ||||
| domain.file = domain_tests.pro | domain.file = domain_tests.pro | ||||
| @@ -23,4 +24,5 @@ project_management.file = project_management_tests.pro | |||||
| register_monitor.file = register_monitor_service_tests.pro | register_monitor.file = register_monitor_service_tests.pro | ||||
| runtime_mode.file = runtime_mode_service_tests.pro | runtime_mode.file = runtime_mode_service_tests.pro | ||||
| runtime_panel_controller.file = runtime_panel_controller_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 | plc_runtime.file = plc_runtime_tests.pro | ||||