| @@ -37,6 +37,7 @@ SOURCES += \ | |||
| src/services/runtime_mode_service.cpp \ | |||
| src/services/register_monitor_service.cpp \ | |||
| src/infrastructure/plc_register_repository.cpp \ | |||
| src/infrastructure/plc_communication_error_classifier.cpp \ | |||
| src/infrastructure/plc_communication_service.cpp \ | |||
| src/infrastructure/json_project_storage.cpp \ | |||
| src/ui/hmi_editor_widget.cpp \ | |||
| @@ -66,6 +67,7 @@ HEADERS += \ | |||
| src/services/register_monitor_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/json_project_storage.h \ | |||
| src/ui/hmi_editor_widget.h \ | |||
| @@ -0,0 +1,124 @@ | |||
| #include "plc_communication_error_classifier.h" | |||
| namespace { | |||
| QString displayPortName(const QString &port_name) | |||
| { | |||
| const QString trimmed = port_name.trimmed(); | |||
| return trimmed.isEmpty() ? QStringLiteral("当前端口") : trimmed; | |||
| } | |||
| bool nativeErrorReportsRemovedDevice(const QString &native_error) | |||
| { | |||
| const QString error = native_error.toLower(); | |||
| return error.contains(QStringLiteral("device has been disconnected")) | |||
| || error.contains(QStringLiteral("device is not connected")) | |||
| || error.contains(QStringLiteral("device not found")) | |||
| || error.contains(QStringLiteral("no such file")) | |||
| || error.contains(QStringLiteral("设备已断开")) | |||
| || error.contains(QStringLiteral("设备不存在")) | |||
| || error.contains(QStringLiteral("找不到指定的文件")); | |||
| } | |||
| } // namespace | |||
| PlcCommunicationFailure classifyPlcCommunicationError( | |||
| QModbusDevice::Error error, | |||
| const PlcCommunicationErrorContext &context) | |||
| { | |||
| const QString port_name = displayPortName(context.portName); | |||
| const bool adapter_removed = context.serialSessionOpened | |||
| && (!context.portAvailable | |||
| || nativeErrorReportsRemovedDevice(context.nativeError)); | |||
| if (adapter_removed | |||
| && error != QModbusDevice::ConfigurationError | |||
| && error != QModbusDevice::ProtocolError) | |||
| { | |||
| return { | |||
| PlcCommunicationError::UsbSerialAdapterRemoved, | |||
| QStringLiteral("USB 转串口 %1 已从电脑移除;本地串口会话已失效") | |||
| .arg(port_name)}; | |||
| } | |||
| switch (error) | |||
| { | |||
| case QModbusDevice::ConnectionError: | |||
| { | |||
| if (!context.serialSessionOpened) | |||
| { | |||
| return { | |||
| PlcCommunicationError::SerialPortOpenFailed, | |||
| QStringLiteral( | |||
| "PLC 连接失败:串口 %1 未能打开;请检查端口是否存在或是否被其他程序占用") | |||
| .arg(port_name)}; | |||
| } | |||
| return { | |||
| PlcCommunicationError::SerialConnectionLost, | |||
| QStringLiteral("PLC 串口连接异常,本地端口 %1 仍存在;请重新连接") | |||
| .arg(port_name)}; | |||
| } | |||
| case QModbusDevice::TimeoutError: | |||
| { | |||
| if (!context.receivedValidResponse) | |||
| { | |||
| return { | |||
| PlcCommunicationError::PlcNotResponding, | |||
| QStringLiteral( | |||
| "串口 %1 已打开,但 PLC 未响应;请检查站号、串口参数和 RS-485 A/B 接线") | |||
| .arg(port_name)}; | |||
| } | |||
| return { | |||
| PlcCommunicationError::CommunicationTimeout, | |||
| QStringLiteral( | |||
| "PLC 通信超时,本地串口 %1 仍处于打开状态;请检查 PLC 供电和 RS-485 接线") | |||
| .arg(port_name)}; | |||
| } | |||
| case QModbusDevice::ProtocolError: | |||
| { | |||
| return { | |||
| PlcCommunicationError::ProtocolError, | |||
| QStringLiteral( | |||
| "PLC 返回 Modbus 协议异常响应;请检查站号、功能码和 PLC 通信配置")}; | |||
| } | |||
| case QModbusDevice::ReadError: | |||
| { | |||
| return { | |||
| PlcCommunicationError::ReadFailed, | |||
| QStringLiteral("读取 PLC 数据失败,本地串口 %1 仍处于打开状态") | |||
| .arg(port_name)}; | |||
| } | |||
| case QModbusDevice::WriteError: | |||
| { | |||
| return { | |||
| PlcCommunicationError::WriteFailed, | |||
| QStringLiteral("写入 PLC 数据失败,本地串口 %1 仍处于打开状态") | |||
| .arg(port_name)}; | |||
| } | |||
| case QModbusDevice::ConfigurationError: | |||
| { | |||
| return { | |||
| PlcCommunicationError::ConfigurationError, | |||
| QStringLiteral( | |||
| "PLC 串口参数配置错误;请检查波特率、数据位、校验位和停止位")}; | |||
| } | |||
| case QModbusDevice::ReplyAbortedError: | |||
| { | |||
| return { | |||
| PlcCommunicationError::RequestAborted, | |||
| QStringLiteral("PLC 通信请求已取消")}; | |||
| } | |||
| case QModbusDevice::UnknownError: | |||
| { | |||
| return { | |||
| PlcCommunicationError::Unknown, | |||
| QStringLiteral("PLC 通信发生未知错误")}; | |||
| } | |||
| case QModbusDevice::NoError: | |||
| default: | |||
| { | |||
| return { | |||
| PlcCommunicationError::Unknown, | |||
| QStringLiteral("PLC 通信失败")}; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| #pragma once | |||
| #include "services/plc_communication_gateway.h" | |||
| #include <QModbusDevice> | |||
| #include <QString> | |||
| struct PlcCommunicationErrorContext | |||
| { | |||
| QString portName; | |||
| QString nativeError; | |||
| bool serialSessionOpened = false; | |||
| bool portAvailable = false; | |||
| bool receivedValidResponse = false; | |||
| }; | |||
| struct PlcCommunicationFailure | |||
| { | |||
| PlcCommunicationError type = PlcCommunicationError::Unknown; | |||
| QString message; | |||
| }; | |||
| PlcCommunicationFailure classifyPlcCommunicationError( | |||
| QModbusDevice::Error error, | |||
| const PlcCommunicationErrorContext &context); | |||
| @@ -1,5 +1,6 @@ | |||
| #include "plc_communication_service.h" | |||
| #include "plc_communication_error_classifier.h" | |||
| #include "plc_register_repository.h" | |||
| #include <QModbusDataUnit> | |||
| @@ -7,6 +8,7 @@ | |||
| #include <QModbusReply> | |||
| #include <QModbusRtuSerialMaster> | |||
| #include <QSerialPort> | |||
| #include <QSerialPortInfo> | |||
| #include <QVariant> | |||
| #include <algorithm> | |||
| @@ -28,32 +30,6 @@ QModbusDataUnit::RegisterType registerType(RegisterArea area) | |||
| ? QModbusDataUnit::Coils : QModbusDataUnit::HoldingRegisters; | |||
| } | |||
| QString modbusErrorText(QModbusDevice::Error error) | |||
| { | |||
| switch (error) | |||
| { | |||
| case QModbusDevice::ReadError: | |||
| return QStringLiteral("读取 PLC 数据失败"); | |||
| case QModbusDevice::WriteError: | |||
| return QStringLiteral("写入 PLC 数据失败"); | |||
| case QModbusDevice::ConnectionError: | |||
| return QStringLiteral("PLC 串口连接失败"); | |||
| case QModbusDevice::ConfigurationError: | |||
| return QStringLiteral("PLC 串口参数配置错误"); | |||
| case QModbusDevice::TimeoutError: | |||
| return QStringLiteral("PLC 通信超时,请检查站号、串口参数和 RS-485 接线"); | |||
| case QModbusDevice::ProtocolError: | |||
| return QStringLiteral("PLC 返回了无效或异常的 Modbus 响应"); | |||
| case QModbusDevice::ReplyAbortedError: | |||
| return QStringLiteral("PLC 通信请求已取消"); | |||
| case QModbusDevice::UnknownError: | |||
| return QStringLiteral("PLC 通信发生未知错误"); | |||
| case QModbusDevice::NoError: | |||
| default: | |||
| return QStringLiteral("PLC 通信失败"); | |||
| } | |||
| } | |||
| } // namespace | |||
| PlcCommunicationService::PlcCommunicationService( | |||
| @@ -79,18 +55,26 @@ PlcCommunicationService::PlcCommunicationService( | |||
| { | |||
| if (device_state == QModbusDevice::ConnectedState) | |||
| { | |||
| serial_session_opened_ = true; | |||
| setState(PlcConnectionState::Connected); | |||
| poll_timer_.start(configuration_.pollIntervalMs); | |||
| pollNextBlock(); | |||
| } | |||
| else if (device_state == QModbusDevice::ConnectingState) | |||
| { | |||
| serial_session_opened_ = false; | |||
| setState(PlcConnectionState::Connecting); | |||
| } | |||
| else if (device_state == QModbusDevice::UnconnectedState | |||
| && state_ != PlcConnectionState::Faulted) | |||
| else if (device_state == QModbusDevice::UnconnectedState) | |||
| { | |||
| setState(PlcConnectionState::Disconnected); | |||
| if (disconnecting_) | |||
| { | |||
| serial_session_opened_ = false; | |||
| } | |||
| if (!disconnecting_ && state_ != PlcConnectionState::Faulted) | |||
| { | |||
| setState(PlcConnectionState::Disconnected); | |||
| } | |||
| } | |||
| }); | |||
| connect(master_.get(), &QModbusClient::errorOccurred, | |||
| @@ -99,7 +83,7 @@ PlcCommunicationService::PlcCommunicationService( | |||
| { | |||
| if (error != QModbusDevice::NoError) | |||
| { | |||
| setError(modbusErrorText(error)); | |||
| handleModbusError(error); | |||
| } | |||
| }); | |||
| } | |||
| @@ -119,6 +103,11 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| return {false, "PLC 连接已经启动,请先断开当前连接"}; | |||
| } | |||
| configuration_ = configuration; | |||
| ++connection_generation_; | |||
| disconnecting_ = false; | |||
| serial_session_opened_ = false; | |||
| received_valid_response_ = false; | |||
| last_error_type_ = PlcCommunicationError::None; | |||
| last_error_.clear(); | |||
| master_->setConnectionParameter( | |||
| QModbusDevice::SerialPortNameParameter, | |||
| @@ -137,17 +126,14 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| master_->setTimeout(configuration.responseTimeoutMs); | |||
| master_->setNumberOfRetries(configuration.retries); | |||
| repository_.invalidate(); | |||
| initial_read_completed_ = false; | |||
| emit initialReadCompletedChanged(false); | |||
| if (initial_read_changed_callback_) | |||
| { | |||
| initial_read_changed_callback_(false); | |||
| } | |||
| updateInitialReadCompleted(false, true); | |||
| rebuildPollBlocks(); | |||
| setState(PlcConnectionState::Connecting); | |||
| if (!master_->connectDevice()) | |||
| { | |||
| setError(modbusErrorText(master_->error())); | |||
| const QModbusDevice::Error error = master_->error() == QModbusDevice::NoError | |||
| ? QModbusDevice::ConnectionError : master_->error(); | |||
| handleModbusError(error); | |||
| return {false, last_error_}; | |||
| } | |||
| return {true, {}}; | |||
| @@ -155,16 +141,19 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| void PlcCommunicationService::disconnectDevice() | |||
| { | |||
| ++connection_generation_; | |||
| disconnecting_ = true; | |||
| poll_timer_.stop(); | |||
| master_->disconnectDevice(); | |||
| pending_reply_ = nullptr; | |||
| poll_update_pending_ = false; | |||
| pending_poll_addresses_.clear(); | |||
| serial_session_opened_ = false; | |||
| received_valid_response_ = false; | |||
| repository_.invalidate(); | |||
| initial_read_completed_ = false; | |||
| emit initialReadCompletedChanged(false); | |||
| if (initial_read_changed_callback_) | |||
| { | |||
| initial_read_changed_callback_(false); | |||
| } | |||
| updateInitialReadCompleted(false, true); | |||
| setState(PlcConnectionState::Disconnected); | |||
| disconnecting_ = false; | |||
| } | |||
| void PlcCommunicationService::setPollAddresses( | |||
| @@ -189,6 +178,11 @@ bool PlcCommunicationService::initialReadCompleted() const | |||
| return initial_read_completed_; | |||
| } | |||
| PlcCommunicationError PlcCommunicationService::lastErrorType() const | |||
| { | |||
| return last_error_type_; | |||
| } | |||
| const std::string &PlcCommunicationService::lastError() const | |||
| { | |||
| return last_error_; | |||
| @@ -291,14 +285,20 @@ void PlcCommunicationService::pollNextBlock() | |||
| QModbusReply *reply = master_->sendReadRequest(request, configuration_.serverAddress); | |||
| if (reply == nullptr) | |||
| { | |||
| setError(modbusErrorText(master_->error())); | |||
| handleModbusError(master_->error()); | |||
| return; | |||
| } | |||
| pending_reply_ = reply; | |||
| const std::uint64_t generation = connection_generation_; | |||
| connect(reply, &QModbusReply::finished, | |||
| this, | |||
| [this, reply, block, block_index] | |||
| [this, reply, block, block_index, generation] | |||
| { | |||
| if (generation != connection_generation_) | |||
| { | |||
| reply->deleteLater(); | |||
| return; | |||
| } | |||
| handleReadFinished(reply, block); | |||
| if (state_ == PlcConnectionState::Connected | |||
| && reply->error() == QModbusDevice::NoError | |||
| @@ -312,12 +312,7 @@ void PlcCommunicationService::pollNextBlock() | |||
| [](bool read) { return read; }); | |||
| if (completed && !initial_read_completed_) | |||
| { | |||
| initial_read_completed_ = true; | |||
| emit initialReadCompletedChanged(true); | |||
| if (initial_read_changed_callback_) | |||
| { | |||
| initial_read_changed_callback_(true); | |||
| } | |||
| updateInitialReadCompleted(true); | |||
| } | |||
| } | |||
| if (pending_reply_ == reply) | |||
| @@ -341,7 +336,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock | |||
| } | |||
| if (reply->error() != QModbusDevice::NoError) | |||
| { | |||
| setError(modbusErrorText(reply->error())); | |||
| handleModbusError(reply->error()); | |||
| return; | |||
| } | |||
| const QModbusDataUnit result = reply->result(); | |||
| @@ -357,6 +352,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock | |||
| repository_.updateWord(address, static_cast<std::int16_t>(result.value(index))); | |||
| } | |||
| } | |||
| received_valid_response_ = true; | |||
| last_error_.clear(); | |||
| emit cacheUpdated(); | |||
| if (cache_updated_callback_) | |||
| @@ -377,16 +373,21 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| if (reply == nullptr) | |||
| { | |||
| setError(modbusErrorText(master_->error())); | |||
| handleModbusError(master_->error()); | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| connect(reply, &QModbusReply::finished, | |||
| this, | |||
| [this, reply] | |||
| [this, reply, generation = connection_generation_] | |||
| { | |||
| if (generation != connection_generation_) | |||
| { | |||
| reply->deleteLater(); | |||
| return; | |||
| } | |||
| if (reply->error() != QModbusDevice::NoError) | |||
| { | |||
| setError(modbusErrorText(reply->error())); | |||
| handleModbusError(reply->error()); | |||
| } | |||
| reply->deleteLater(); | |||
| }); | |||
| @@ -405,22 +406,77 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| if (reply == nullptr) | |||
| { | |||
| setError(modbusErrorText(master_->error())); | |||
| handleModbusError(master_->error()); | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| connect(reply, &QModbusReply::finished, | |||
| this, | |||
| [this, reply] | |||
| [this, reply, generation = connection_generation_] | |||
| { | |||
| if (generation != connection_generation_) | |||
| { | |||
| reply->deleteLater(); | |||
| return; | |||
| } | |||
| if (reply->error() != QModbusDevice::NoError) | |||
| { | |||
| setError(modbusErrorText(reply->error())); | |||
| handleModbusError(reply->error()); | |||
| } | |||
| reply->deleteLater(); | |||
| }); | |||
| return {true, RegisterError::None}; | |||
| } | |||
| bool PlcCommunicationService::configuredPortAvailable() const | |||
| { | |||
| const QString configured_port = QString::fromStdString( | |||
| configuration_.portName).trimmed(); | |||
| if (configured_port.isEmpty()) | |||
| { | |||
| return false; | |||
| } | |||
| const QList<QSerialPortInfo> ports = QSerialPortInfo::availablePorts(); | |||
| return std::any_of( | |||
| ports.cbegin(), ports.cend(), | |||
| [&configured_port](const QSerialPortInfo &port) | |||
| { | |||
| return port.portName().compare(configured_port, Qt::CaseInsensitive) == 0; | |||
| }); | |||
| } | |||
| void PlcCommunicationService::updateInitialReadCompleted( | |||
| bool completed, bool force_notification) | |||
| { | |||
| if (initial_read_completed_ == completed && !force_notification) | |||
| { | |||
| return; | |||
| } | |||
| initial_read_completed_ = completed; | |||
| emit initialReadCompletedChanged(completed); | |||
| if (initial_read_changed_callback_) | |||
| { | |||
| initial_read_changed_callback_(completed); | |||
| } | |||
| } | |||
| void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) | |||
| { | |||
| if (disconnecting_ | |||
| || (error == QModbusDevice::ReplyAbortedError | |||
| && state_ == PlcConnectionState::Disconnected) | |||
| || state_ == PlcConnectionState::Faulted) | |||
| { | |||
| return; | |||
| } | |||
| const PlcCommunicationErrorContext context{ | |||
| QString::fromStdString(configuration_.portName), | |||
| master_->errorString(), | |||
| serial_session_opened_, | |||
| configuredPortAvailable(), | |||
| received_valid_response_}; | |||
| setError(classifyPlcCommunicationError(error, context)); | |||
| } | |||
| void PlcCommunicationService::setState(PlcConnectionState state) | |||
| { | |||
| if (state_ == state) | |||
| @@ -435,12 +491,14 @@ void PlcCommunicationService::setState(PlcConnectionState state) | |||
| } | |||
| } | |||
| void PlcCommunicationService::setError(const QString &message) | |||
| void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) | |||
| { | |||
| last_error_ = toUtf8(message); | |||
| last_error_type_ = failure.type; | |||
| last_error_ = toUtf8(failure.message); | |||
| poll_timer_.stop(); | |||
| updateInitialReadCompleted(false); | |||
| setState(PlcConnectionState::Faulted); | |||
| emit communicationError(message); | |||
| emit communicationError(failure.message); | |||
| if (error_reported_callback_) | |||
| { | |||
| error_reported_callback_(last_error_); | |||
| @@ -3,9 +3,11 @@ | |||
| #include "services/plc_communication_gateway.h" | |||
| #include "domain/register_repository.h" | |||
| #include <QModbusDevice> | |||
| #include <QObject> | |||
| #include <QTimer> | |||
| #include <cstdint> | |||
| #include <memory> | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -13,6 +15,7 @@ | |||
| class PlcRegisterRepository; | |||
| class QModbusReply; | |||
| class QModbusRtuSerialMaster; | |||
| struct PlcCommunicationFailure; | |||
| class PlcCommunicationService final : public QObject, public PlcCommunicationGateway | |||
| { | |||
| @@ -31,6 +34,7 @@ public: | |||
| PlcConnectionState state() const override; | |||
| bool initialReadCompleted() const override; | |||
| PlcCommunicationError lastErrorType() const override; | |||
| const std::string &lastError() const override; | |||
| const PlcSerialConfiguration &configuration() const; | |||
| void setCallbacks( | |||
| @@ -60,8 +64,11 @@ private: | |||
| RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value); | |||
| RegisterWriteResult sendWordWrite( | |||
| const RegisterAddress &address, std::int16_t value); | |||
| bool configuredPortAvailable() const; | |||
| void updateInitialReadCompleted(bool completed, bool force_notification = false); | |||
| void handleModbusError(QModbusDevice::Error error); | |||
| void setState(PlcConnectionState state); | |||
| void setError(const QString &message); | |||
| void setError(const PlcCommunicationFailure &failure); | |||
| PlcRegisterRepository &repository_; | |||
| std::unique_ptr<QModbusRtuSerialMaster> master_; | |||
| @@ -73,9 +80,14 @@ private: | |||
| std::size_t next_poll_block_ = 0; | |||
| QModbusReply *pending_reply_ = nullptr; | |||
| bool poll_update_pending_ = false; | |||
| bool disconnecting_ = false; | |||
| bool serial_session_opened_ = false; | |||
| bool received_valid_response_ = false; | |||
| std::uint64_t connection_generation_ = 0; | |||
| PlcConnectionState state_ = PlcConnectionState::Disconnected; | |||
| bool initial_read_completed_ = false; | |||
| std::vector<bool> initial_blocks_read_; | |||
| PlcCommunicationError last_error_type_ = PlcCommunicationError::None; | |||
| std::string last_error_; | |||
| std::function<void()> state_changed_callback_; | |||
| std::function<void(bool)> initial_read_changed_callback_; | |||
| @@ -27,6 +27,22 @@ enum class PlcConnectionState | |||
| Faulted | |||
| }; | |||
| enum class PlcCommunicationError | |||
| { | |||
| None, | |||
| SerialPortOpenFailed, | |||
| PlcNotResponding, | |||
| UsbSerialAdapterRemoved, | |||
| SerialConnectionLost, | |||
| CommunicationTimeout, | |||
| ProtocolError, | |||
| ReadFailed, | |||
| WriteFailed, | |||
| ConfigurationError, | |||
| RequestAborted, | |||
| Unknown | |||
| }; | |||
| struct PlcCommunicationResult | |||
| { | |||
| bool succeeded = false; | |||
| @@ -45,6 +61,7 @@ public: | |||
| const std::vector<RegisterAddress> &addresses) = 0; | |||
| virtual PlcConnectionState state() const = 0; | |||
| virtual bool initialReadCompleted() const = 0; | |||
| virtual PlcCommunicationError lastErrorType() const = 0; | |||
| virtual const std::string &lastError() const = 0; | |||
| virtual void setCallbacks( | |||
| std::function<void()> state_changed, | |||
| @@ -87,7 +87,7 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||
| ModeTransitionResult RuntimeModeService::enterOnlineRunning() | |||
| { | |||
| const ModeTransitionResult result = state_.enterOnlineRunning( | |||
| initial_plc_read_completed_); | |||
| initialPlcReadCompleted()); | |||
| if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) | |||
| { | |||
| active_repository_->use(*plc_repository_); | |||
| @@ -103,7 +103,10 @@ void RuntimeModeService::setInitialPlcReadCompleted(bool completed) | |||
| bool RuntimeModeService::initialPlcReadCompleted() const | |||
| { | |||
| return initial_plc_read_completed_; | |||
| return initial_plc_read_completed_ | |||
| && plc_gateway_ != nullptr | |||
| && plc_gateway_->state() == PlcConnectionState::Connected | |||
| && plc_gateway_->initialReadCompleted(); | |||
| } | |||
| SimulationState RuntimeModeService::simulationState() const | |||
| @@ -139,13 +142,17 @@ void RuntimeModeService::configurePlc( | |||
| gateway.setCallbacks( | |||
| [this] | |||
| { | |||
| if (plc_gateway_ != nullptr | |||
| && plc_gateway_->state() == PlcConnectionState::Disconnected) | |||
| if (plc_gateway_ != nullptr) | |||
| { | |||
| setInitialPlcReadCompleted(false); | |||
| if (state_.mode() == ApplicationMode::OnlineRunning) | |||
| const PlcConnectionState plc_state = plc_gateway_->state(); | |||
| if (plc_state == PlcConnectionState::Disconnected | |||
| || plc_state == PlcConnectionState::Faulted) | |||
| { | |||
| enterEditing(); | |||
| setInitialPlcReadCompleted(false); | |||
| if (state_.mode() == ApplicationMode::OnlineRunning) | |||
| { | |||
| enterEditing(); | |||
| } | |||
| } | |||
| } | |||
| if (plc_status_changed_callback_) | |||
| @@ -178,6 +185,10 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||
| { | |||
| return {false, "PLC 通信服务尚未配置"}; | |||
| } | |||
| if (plc_gateway_->state() == PlcConnectionState::Faulted) | |||
| { | |||
| plc_gateway_->disconnectDevice(); | |||
| } | |||
| refreshPlcPollAddresses(); | |||
| setInitialPlcReadCompleted(false); | |||
| return plc_gateway_->connectDevice(configuration); | |||
| @@ -1205,8 +1205,16 @@ void MainWindow::updateModeUi(const QString &message) | |||
| policy.usesVirtualRegisters ? tr("数据源:虚拟 M/D") | |||
| : policy.usesPlcRegisters ? tr("数据源:PLC 缓存") : tr("数据源:未启用")); | |||
| const PlcConnectionState plc_state = runtime_mode_service_.plcConnectionState(); | |||
| const bool plc_configuration_available = plc_state == PlcConnectionState::Disconnected | |||
| || plc_state == PlcConnectionState::Faulted; | |||
| ui_->configurePlcAction->setEnabled( | |||
| policy.allowsProjectEditing && plc_state == PlcConnectionState::Disconnected); | |||
| policy.allowsProjectEditing && plc_configuration_available); | |||
| ui_->configurePlcAction->setText( | |||
| plc_state == PlcConnectionState::Faulted ? tr("PLC 重新配置") : tr("PLC 配置")); | |||
| ui_->configurePlcAction->setToolTip( | |||
| plc_state == PlcConnectionState::Faulted | |||
| ? tr("清理故障会话后重新配置并连接真实 PLC") | |||
| : tr("配置参数并连接真实 PLC")); | |||
| ui_->disconnectPlcAction->setEnabled(plc_state != PlcConnectionState::Disconnected); | |||
| if (plc_state == PlcConnectionState::Connecting) | |||
| { | |||
| @@ -1239,15 +1247,16 @@ void MainWindow::updateSimulationUi(bool report_fault) | |||
| == ApplicationMode::OfflineRunning; | |||
| const bool online = runtime_mode_service_.mode() | |||
| == ApplicationMode::OnlineRunning; | |||
| const bool plc_connected = runtime_mode_service_.plcConnectionState() | |||
| == PlcConnectionState::Connected; | |||
| const SimulationState state = runtime_mode_service_.simulationState(); | |||
| hmi_editor_widget_->setRuntimeWriteEnabled( | |||
| online || (offline && state == SimulationState::Running)); | |||
| (online && plc_connected) | |||
| || (offline && state == SimulationState::Running)); | |||
| if (runtime_monitor_widget_ != nullptr) | |||
| { | |||
| runtime_monitor_widget_->setHmiWriteEnabled( | |||
| (online | |||
| && runtime_mode_service_.plcConnectionState() | |||
| == PlcConnectionState::Connected) | |||
| (online && plc_connected) | |||
| || (offline && state == SimulationState::Running)); | |||
| runtime_monitor_widget_->refreshValues( | |||
| runtime_mode_service_.mode(), runtime_mode_service_.plcConnectionState()); | |||
| @@ -76,6 +76,10 @@ public: | |||
| void setPollAddresses(const std::vector<RegisterAddress> &) override {} | |||
| PlcConnectionState state() const override { return connection_state; } | |||
| bool initialReadCompleted() const override { return false; } | |||
| PlcCommunicationError lastErrorType() const override | |||
| { | |||
| return PlcCommunicationError::None; | |||
| } | |||
| const std::string &lastError() const override { return last_error; } | |||
| void setCallbacks( | |||
| @@ -123,6 +127,15 @@ public: | |||
| void disconnectDevice() override | |||
| { | |||
| connection_state = PlcConnectionState::Disconnected; | |||
| initial_read = false; | |||
| if (initial_read_changed) | |||
| { | |||
| initial_read_changed(false); | |||
| } | |||
| if (state_changed) | |||
| { | |||
| state_changed(); | |||
| } | |||
| } | |||
| void setPollAddresses(const std::vector<RegisterAddress> &addresses) override | |||
| @@ -132,6 +145,7 @@ public: | |||
| PlcConnectionState state() const override { return connection_state; } | |||
| bool initialReadCompleted() const override { return initial_read; } | |||
| PlcCommunicationError lastErrorType() const override { return last_error_type; } | |||
| const std::string &lastError() const override { return last_error; } | |||
| void setCallbacks( | |||
| @@ -155,8 +169,30 @@ public: | |||
| } | |||
| } | |||
| void failCommunication( | |||
| PlcCommunicationError error_type, const std::string &message) | |||
| { | |||
| initial_read = false; | |||
| last_error_type = error_type; | |||
| last_error = message; | |||
| connection_state = PlcConnectionState::Faulted; | |||
| if (initial_read_changed) | |||
| { | |||
| initial_read_changed(false); | |||
| } | |||
| if (state_changed) | |||
| { | |||
| state_changed(); | |||
| } | |||
| if (error_reported) | |||
| { | |||
| error_reported(last_error); | |||
| } | |||
| } | |||
| PlcConnectionState connection_state = PlcConnectionState::Disconnected; | |||
| bool initial_read = false; | |||
| PlcCommunicationError last_error_type = PlcCommunicationError::None; | |||
| std::string last_error; | |||
| std::vector<RegisterAddress> poll_addresses; | |||
| std::function<void()> state_changed; | |||
| @@ -644,6 +680,30 @@ void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() | |||
| gateway.poll_addresses.end(), | |||
| RegisterAddress{RegisterArea::D, 9}) != gateway.poll_addresses.end(), | |||
| "online free monitor addresses must join the active PLC poll set immediately"); | |||
| QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction"); | |||
| QAction *disconnect_action = requiredChild<QAction>(window, "disconnectPlcAction"); | |||
| QListWidget *output = requiredChild<QListWidget>(window, "outputList"); | |||
| gateway.failCommunication( | |||
| PlcCommunicationError::CommunicationTimeout, | |||
| "PLC 通信超时,本地串口 COM9 仍处于打开状态;请检查 PLC 供电和 RS-485 接线"); | |||
| QApplication::processEvents(); | |||
| require(mode_service.mode() == ApplicationMode::Editing, | |||
| "a communication fault must return the UI from online running to editing"); | |||
| require(configure_action->isEnabled() | |||
| && configure_action->text() == QStringLiteral("PLC 重新配置"), | |||
| "faulted PLC state must expose direct reconfiguration"); | |||
| require(disconnect_action->isEnabled(), | |||
| "faulted PLC state must still allow explicit serial session cleanup"); | |||
| require(output->count() > 0 | |||
| && output->item(output->count() - 1)->text().contains( | |||
| QStringLiteral("PLC 通信超时")), | |||
| "communication fault details must remain in the output log"); | |||
| online_action->trigger(); | |||
| require(mode_service.mode() == ApplicationMode::Editing, | |||
| "faulted PLC state must not re-enter online running without a fresh read"); | |||
| } | |||
| } // namespace | |||
| @@ -1,5 +1,6 @@ | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/project_storage.h" | |||
| #include "infrastructure/plc_communication_error_classifier.h" | |||
| #include "infrastructure/plc_register_repository.h" | |||
| #include "services/offline_simulation_service.h" | |||
| #include "services/plc_communication_gateway.h" | |||
| @@ -34,6 +35,9 @@ public: | |||
| const PlcSerialConfiguration &configuration) override | |||
| { | |||
| last_configuration = configuration; | |||
| last_error_type = PlcCommunicationError::None; | |||
| last_error.clear(); | |||
| initial_read = false; | |||
| connection_state = PlcConnectionState::Connected; | |||
| if (state_changed) | |||
| { | |||
| @@ -44,6 +48,7 @@ public: | |||
| void disconnectDevice() override | |||
| { | |||
| ++disconnect_count; | |||
| connection_state = PlcConnectionState::Disconnected; | |||
| initial_read = false; | |||
| if (initial_read_changed) | |||
| @@ -63,6 +68,7 @@ public: | |||
| PlcConnectionState state() const override { return connection_state; } | |||
| bool initialReadCompleted() const override { return initial_read; } | |||
| PlcCommunicationError lastErrorType() const override { return last_error_type; } | |||
| const std::string &lastError() const override { return last_error; } | |||
| void setCallbacks( | |||
| @@ -86,8 +92,31 @@ public: | |||
| } | |||
| } | |||
| void failCommunication( | |||
| PlcCommunicationError error_type, const std::string &message) | |||
| { | |||
| initial_read = false; | |||
| last_error_type = error_type; | |||
| last_error = message; | |||
| connection_state = PlcConnectionState::Faulted; | |||
| if (initial_read_changed) | |||
| { | |||
| initial_read_changed(false); | |||
| } | |||
| if (state_changed) | |||
| { | |||
| state_changed(); | |||
| } | |||
| if (error_reported) | |||
| { | |||
| error_reported(last_error); | |||
| } | |||
| } | |||
| PlcConnectionState connection_state = PlcConnectionState::Disconnected; | |||
| bool initial_read = false; | |||
| int disconnect_count = 0; | |||
| PlcCommunicationError last_error_type = PlcCommunicationError::None; | |||
| std::string last_error; | |||
| PlcSerialConfiguration last_configuration; | |||
| std::vector<RegisterAddress> poll_addresses; | |||
| @@ -209,6 +238,82 @@ void testRuntimeRepositorySwitchingAndDisconnect() | |||
| "editing after disconnect must switch back to the virtual repository"); | |||
| } | |||
| void testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| VirtualRegisterRepository virtual_repository; | |||
| PlcRegisterRepository plc_repository; | |||
| ActiveRegisterRepository active_repository(virtual_repository); | |||
| OfflineSimulationService simulation_service(virtual_repository); | |||
| FakePlcGateway gateway; | |||
| RuntimeModeService service(project_service, simulation_service); | |||
| service.configurePlc( | |||
| gateway, active_repository, virtual_repository, plc_repository); | |||
| require(service.connectPlc( | |||
| {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, | |||
| "PLC connection must start before testing a communication fault"); | |||
| gateway.completeInitialRead(); | |||
| require(service.enterOnlineRunning().succeeded, | |||
| "completed initial read must allow online running before a fault"); | |||
| gateway.failCommunication( | |||
| PlcCommunicationError::CommunicationTimeout, | |||
| "PLC communication timed out while the serial port remained open"); | |||
| require(service.mode() == ApplicationMode::Editing, | |||
| "a PLC communication fault must return online running to editing"); | |||
| require(!service.initialPlcReadCompleted(), | |||
| "a PLC communication fault must revoke initial read readiness"); | |||
| require(service.enterOnlineRunning().error | |||
| == ModeTransitionError::InitialPlcReadRequired, | |||
| "faulted PLC state must not reuse readiness from the previous connection"); | |||
| const int disconnect_count = gateway.disconnect_count; | |||
| require(service.connectPlc( | |||
| {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, | |||
| "faulted PLC state must support direct reconnect"); | |||
| require(gateway.disconnect_count == disconnect_count + 1, | |||
| "reconnecting from a fault must clean the previous serial session first"); | |||
| } | |||
| void testPlcCommunicationErrorClassification() | |||
| { | |||
| PlcCommunicationErrorContext context; | |||
| context.portName = QStringLiteral("COM3"); | |||
| PlcCommunicationFailure failure = classifyPlcCommunicationError( | |||
| QModbusDevice::ConnectionError, context); | |||
| require(failure.type == PlcCommunicationError::SerialPortOpenFailed | |||
| && failure.message.contains(QStringLiteral("未能打开")), | |||
| "connection failure before opening the port must be classified explicitly"); | |||
| context.serialSessionOpened = true; | |||
| context.portAvailable = true; | |||
| failure = classifyPlcCommunicationError(QModbusDevice::TimeoutError, context); | |||
| require(failure.type == PlcCommunicationError::PlcNotResponding | |||
| && failure.message.contains(QStringLiteral("PLC 未响应")), | |||
| "initial timeout with an open serial port must report a non-responsive PLC"); | |||
| context.receivedValidResponse = true; | |||
| failure = classifyPlcCommunicationError(QModbusDevice::TimeoutError, context); | |||
| require(failure.type == PlcCommunicationError::CommunicationTimeout | |||
| && failure.message.contains(QStringLiteral("仍处于打开状态")), | |||
| "timeout after valid traffic must report an open local serial session"); | |||
| context.portAvailable = false; | |||
| failure = classifyPlcCommunicationError(QModbusDevice::ConnectionError, context); | |||
| require(failure.type == PlcCommunicationError::UsbSerialAdapterRemoved | |||
| && failure.message.contains(QStringLiteral("已从电脑移除")), | |||
| "a missing port after connection must report USB serial adapter removal"); | |||
| context.portAvailable = true; | |||
| failure = classifyPlcCommunicationError(QModbusDevice::ProtocolError, context); | |||
| require(failure.type == PlcCommunicationError::ProtocolError | |||
| && failure.message.contains(QStringLiteral("Modbus 协议异常")), | |||
| "protocol errors must remain distinct from timeouts and disconnections"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| @@ -217,6 +322,8 @@ int main() | |||
| { | |||
| testPlcCacheAndWriteForwarding(); | |||
| testRuntimeRepositorySwitchingAndDisconnect(); | |||
| testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); | |||
| testPlcCommunicationErrorClassification(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||
| @@ -1,4 +1,4 @@ | |||
| QT += core | |||
| QT += core serialbus | |||
| TEMPLATE = app | |||
| TARGET = plc_runtime_tests | |||
| @@ -22,6 +22,7 @@ SOURCES += \ | |||
| ../src/services/software_logic_executor.cpp \ | |||
| ../src/services/offline_simulation_service.cpp \ | |||
| ../src/services/runtime_mode_service.cpp \ | |||
| ../src/infrastructure/plc_communication_error_classifier.cpp \ | |||
| ../src/infrastructure/plc_register_repository.cpp | |||
| HEADERS += \ | |||
| @@ -39,4 +40,5 @@ HEADERS += \ | |||
| ../src/services/offline_simulation_service.h \ | |||
| ../src/services/runtime_mode_service.h \ | |||
| ../src/services/plc_communication_gateway.h \ | |||
| ../src/infrastructure/plc_communication_error_classifier.h \ | |||
| ../src/infrastructure/plc_register_repository.h | |||
| @@ -1,12 +1,15 @@ | |||
| #include "services/runtime_mode_service.h" | |||
| #include "services/offline_simulation_service.h" | |||
| #include "services/project_service.h" | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/project_storage.h" | |||
| #include "domain/register_repository.h" | |||
| #include <functional> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| #include <utility> | |||
| namespace { | |||
| @@ -24,6 +27,65 @@ public: | |||
| } | |||
| }; | |||
| class ReadyPlcGateway final : public PlcCommunicationGateway | |||
| { | |||
| public: | |||
| PlcCommunicationResult connectDevice(const PlcSerialConfiguration &) override | |||
| { | |||
| connection_state = PlcConnectionState::Connected; | |||
| if (state_changed) | |||
| { | |||
| state_changed(); | |||
| } | |||
| return {true, {}}; | |||
| } | |||
| void disconnectDevice() override | |||
| { | |||
| connection_state = PlcConnectionState::Disconnected; | |||
| initial_read = false; | |||
| } | |||
| void setPollAddresses(const std::vector<RegisterAddress> &) override {} | |||
| PlcConnectionState state() const override { return connection_state; } | |||
| bool initialReadCompleted() const override { return initial_read; } | |||
| PlcCommunicationError lastErrorType() const override | |||
| { | |||
| return PlcCommunicationError::None; | |||
| } | |||
| const std::string &lastError() const override { return last_error; } | |||
| void setCallbacks( | |||
| std::function<void()> state_callback, | |||
| std::function<void(bool)> initial_callback, | |||
| std::function<void()> cache_callback, | |||
| std::function<void(const std::string &)> error_callback) override | |||
| { | |||
| state_changed = std::move(state_callback); | |||
| initial_read_changed = std::move(initial_callback); | |||
| cache_updated = std::move(cache_callback); | |||
| error_reported = std::move(error_callback); | |||
| } | |||
| void completeInitialRead() | |||
| { | |||
| initial_read = true; | |||
| if (initial_read_changed) | |||
| { | |||
| initial_read_changed(true); | |||
| } | |||
| } | |||
| private: | |||
| PlcConnectionState connection_state = PlcConnectionState::Disconnected; | |||
| bool initial_read = false; | |||
| std::string last_error; | |||
| std::function<void()> state_changed; | |||
| std::function<void(bool)> initial_read_changed; | |||
| std::function<void()> cache_updated; | |||
| std::function<void(const std::string &)> error_reported; | |||
| }; | |||
| void require(bool condition, const std::string &message) | |||
| { | |||
| if (!condition) | |||
| @@ -37,9 +99,14 @@ void testModeTransitions() | |||
| // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| VirtualRegisterRepository repository; | |||
| OfflineSimulationService simulation_service(repository); | |||
| VirtualRegisterRepository virtual_repository; | |||
| VirtualRegisterRepository plc_repository; | |||
| ActiveRegisterRepository active_repository(virtual_repository); | |||
| OfflineSimulationService simulation_service(virtual_repository); | |||
| ReadyPlcGateway gateway; | |||
| RuntimeModeService service(project_service, simulation_service); | |||
| service.configurePlc( | |||
| gateway, active_repository, virtual_repository, plc_repository); | |||
| require(service.mode() == ApplicationMode::Editing, | |||
| "service must start in editing mode"); | |||
| @@ -64,7 +131,10 @@ void testModeTransitions() | |||
| == ModeTransitionError::InitialPlcReadRequired, | |||
| "online running must require an initial PLC read"); | |||
| service.setInitialPlcReadCompleted(true); | |||
| require(service.connectPlc( | |||
| {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, | |||
| "PLC connection must be established before its initial read can complete"); | |||
| gateway.completeInitialRead(); | |||
| require(service.initialPlcReadCompleted(), | |||
| "service must retain the initial PLC read state"); | |||
| require(service.enterOnlineRunning().succeeded, | |||