| @@ -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 | |||
| @@ -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/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(); | |||
| @@ -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 <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 ®ister_comment_service, | |||
| RegisterMonitorService ®ister_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 ®ister_comment_service, | |||
| RegisterMonitorService ®ister_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; | |||
| @@ -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 ®ister_comment_service, | |||
| RegisterMonitorService ®ister_monitor_service, | |||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||
| QWidget *parent = nullptr); | |||
| /** | |||
| * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 | |||
| @@ -94,6 +96,7 @@ public: | |||
| HmiNavigationService &hmi_navigation_service, | |||
| RegisterCommentService ®ister_comment_service, | |||
| RegisterMonitorService ®ister_monitor_service, | |||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||
| QWidget *parent = nullptr); | |||
| /** 释放窗口拥有的控制器和可选导航服务 */ | |||
| @@ -251,6 +254,8 @@ private: | |||
| RegisterCommentService ®ister_comment_service_; | |||
| /** 非拥有的寄存器监控服务依赖 */ | |||
| RegisterMonitorService ®ister_monitor_service_; | |||
| /** 非拥有的 PLC 自动搜索服务依赖 */ | |||
| PlcDiscoveryGateway &plc_discovery_gateway_; | |||
| /** 当调用方未提供导航服务时由主窗口负责拥有的实例 */ | |||
| std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | |||
| /** 当前使用的 HMI 页面导航服务 */ | |||
| @@ -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); | |||
| } | |||
| @@ -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_; | |||
| }; | |||
| @@ -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> | |||
| @@ -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 "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) | |||
| @@ -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 | |||
| @@ -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 | |||