| @@ -27,6 +27,8 @@ SOURCES += \ | |||||
| src/services/hmi_editor_service.cpp \ | src/services/hmi_editor_service.cpp \ | ||||
| src/services/logic_editor_service.cpp \ | src/services/logic_editor_service.cpp \ | ||||
| src/services/hmi_runtime_service.cpp \ | src/services/hmi_runtime_service.cpp \ | ||||
| src/services/software_logic_executor.cpp \ | |||||
| src/services/offline_simulation_service.cpp \ | |||||
| src/services/runtime_mode_service.cpp \ | src/services/runtime_mode_service.cpp \ | ||||
| src/infrastructure/json_project_storage.cpp \ | src/infrastructure/json_project_storage.cpp \ | ||||
| src/ui/hmi_editor_widget.cpp \ | src/ui/hmi_editor_widget.cpp \ | ||||
| @@ -45,6 +47,8 @@ HEADERS += \ | |||||
| src/services/hmi_editor_service.h \ | src/services/hmi_editor_service.h \ | ||||
| src/services/logic_editor_service.h \ | src/services/logic_editor_service.h \ | ||||
| src/services/hmi_runtime_service.h \ | src/services/hmi_runtime_service.h \ | ||||
| src/services/software_logic_executor.h \ | |||||
| src/services/offline_simulation_service.h \ | |||||
| src/services/runtime_mode_service.h \ | src/services/runtime_mode_service.h \ | ||||
| src/infrastructure/json_project_storage.h \ | src/infrastructure/json_project_storage.h \ | ||||
| src/ui/hmi_editor_widget.h \ | src/ui/hmi_editor_widget.h \ | ||||
| @@ -75,7 +75,8 @@ enum class ModeTransitionError | |||||
| AlreadyInRequestedMode, // 已经处于目标模式 | AlreadyInRequestedMode, // 已经处于目标模式 | ||||
| MustReturnToEditing, // 必须先返回编辑态 | MustReturnToEditing, // 必须先返回编辑态 | ||||
| InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 | InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 | ||||
| ProjectNotReady // 工程存在未配置或未完成的运行项 | |||||
| ProjectNotReady, // 工程存在未配置或未完成的运行项 | |||||
| SimulationStartFailed // 离线执行器预检或启动失败 | |||||
| }; | }; | ||||
| // 模式切换操作结果 | // 模式切换操作结果 | ||||
| @@ -12,6 +12,7 @@ | |||||
| #include "services/hmi_editor_service.h" | #include "services/hmi_editor_service.h" | ||||
| #include "services/hmi_runtime_service.h" | #include "services/hmi_runtime_service.h" | ||||
| #include "services/logic_editor_service.h" | #include "services/logic_editor_service.h" | ||||
| #include "services/offline_simulation_service.h" | |||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "services/runtime_mode_service.h" | #include "services/runtime_mode_service.h" | ||||
| #include "ui/main_window.h" | #include "ui/main_window.h" | ||||
| @@ -30,7 +31,9 @@ int main(int argc, char *argv[]) | |||||
| // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 | // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 | ||||
| VirtualRegisterRepository virtual_register_repository; | VirtualRegisterRepository virtual_register_repository; | ||||
| HmiRuntimeService hmi_runtime_service(virtual_register_repository); | HmiRuntimeService hmi_runtime_service(virtual_register_repository); | ||||
| RuntimeModeService runtime_mode_service(project_service); | |||||
| OfflineSimulationService offline_simulation_service(virtual_register_repository); | |||||
| RuntimeModeService runtime_mode_service( | |||||
| project_service, offline_simulation_service); | |||||
| MainWindow main_window( | MainWindow main_window( | ||||
| runtime_mode_service, | runtime_mode_service, | ||||
| project_service, | project_service, | ||||
| @@ -0,0 +1,110 @@ | |||||
| #include "offline_simulation_service.h" | |||||
| #include <utility> | |||||
| OfflineSimulationService::OfflineSimulationService( | |||||
| VirtualRegisterRepository &repository, | |||||
| QObject *parent) | |||||
| : QObject(parent), | |||||
| repository_(repository) | |||||
| { | |||||
| timer_.setInterval(kDefaultScanIntervalMs); | |||||
| timer_.setTimerType(Qt::PreciseTimer); | |||||
| connect(&timer_, &QTimer::timeout, | |||||
| this, &OfflineSimulationService::handleTimeout); | |||||
| } | |||||
| SimulationStartResult OfflineSimulationService::start( | |||||
| const std::vector<ControlLogic> &logics) | |||||
| { | |||||
| if (state_ == SimulationState::Running) | |||||
| { | |||||
| return {false, SimulationStartError::AlreadyRunning, {}}; | |||||
| } | |||||
| std::vector<ControlLogic> snapshot = logics; | |||||
| const LogicScanResult validation = executor_.validate(snapshot); | |||||
| if (!validation.succeeded) | |||||
| { | |||||
| last_error_ = validation; | |||||
| return {false, SimulationStartError::InvalidLogic, validation}; | |||||
| } | |||||
| timer_.stop(); | |||||
| repository_.clear(); | |||||
| logic_snapshot_ = std::move(snapshot); | |||||
| successful_scan_count_ = 0; | |||||
| last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; | |||||
| state_ = SimulationState::Running; | |||||
| timer_.start(); | |||||
| emit stateChanged(); | |||||
| return {true, SimulationStartError::None, {true, LogicScanError::None, {}, {}, {}, {}}}; | |||||
| } | |||||
| void OfflineSimulationService::stop() | |||||
| { | |||||
| timer_.stop(); | |||||
| logic_snapshot_.clear(); | |||||
| if (state_ == SimulationState::Stopped) | |||||
| { | |||||
| return; | |||||
| } | |||||
| state_ = SimulationState::Stopped; | |||||
| last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; | |||||
| emit stateChanged(); | |||||
| } | |||||
| LogicScanResult OfflineSimulationService::executeOnce() | |||||
| { | |||||
| if (state_ != SimulationState::Running) | |||||
| { | |||||
| return {false, | |||||
| LogicScanError::InvalidLogic, | |||||
| "offline simulation is not running", | |||||
| {}, | |||||
| {}, | |||||
| {}}; | |||||
| } | |||||
| const LogicScanResult result = executor_.executeScan( | |||||
| logic_snapshot_, repository_); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| enterFault(result); | |||||
| return result; | |||||
| } | |||||
| ++successful_scan_count_; | |||||
| return result; | |||||
| } | |||||
| SimulationState OfflineSimulationService::state() const | |||||
| { | |||||
| return state_; | |||||
| } | |||||
| int OfflineSimulationService::scanIntervalMs() const | |||||
| { | |||||
| return timer_.interval(); | |||||
| } | |||||
| std::uint64_t OfflineSimulationService::successfulScanCount() const | |||||
| { | |||||
| return successful_scan_count_; | |||||
| } | |||||
| const LogicScanResult &OfflineSimulationService::lastError() const | |||||
| { | |||||
| return last_error_; | |||||
| } | |||||
| void OfflineSimulationService::handleTimeout() | |||||
| { | |||||
| executeOnce(); | |||||
| } | |||||
| void OfflineSimulationService::enterFault(const LogicScanResult &error) | |||||
| { | |||||
| timer_.stop(); | |||||
| last_error_ = error; | |||||
| state_ = SimulationState::Faulted; | |||||
| emit stateChanged(); | |||||
| } | |||||
| @@ -0,0 +1,67 @@ | |||||
| #pragma once | |||||
| #include "software_logic_executor.h" | |||||
| #include <QObject> | |||||
| #include <QTimer> | |||||
| #include <cstdint> | |||||
| #include <vector> | |||||
| enum class SimulationState | |||||
| { | |||||
| Stopped, | |||||
| Running, | |||||
| Faulted | |||||
| }; | |||||
| enum class SimulationStartError | |||||
| { | |||||
| None, | |||||
| AlreadyRunning, | |||||
| InvalidLogic | |||||
| }; | |||||
| struct SimulationStartResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| SimulationStartError error = SimulationStartError::None; | |||||
| LogicScanResult detail; | |||||
| }; | |||||
| // 管理离线逻辑快照、固定周期扫描和实际执行状态 | |||||
| class OfflineSimulationService final : public QObject | |||||
| { | |||||
| Q_OBJECT | |||||
| public: | |||||
| static constexpr int kDefaultScanIntervalMs = 50; | |||||
| explicit OfflineSimulationService( | |||||
| VirtualRegisterRepository &repository, | |||||
| QObject *parent = nullptr); | |||||
| SimulationStartResult start(const std::vector<ControlLogic> &logics); | |||||
| void stop(); | |||||
| LogicScanResult executeOnce(); | |||||
| SimulationState state() const; | |||||
| int scanIntervalMs() const; | |||||
| std::uint64_t successfulScanCount() const; | |||||
| const LogicScanResult &lastError() const; | |||||
| signals: | |||||
| void stateChanged(); | |||||
| private: | |||||
| void handleTimeout(); | |||||
| void enterFault(const LogicScanResult &error); | |||||
| VirtualRegisterRepository &repository_; | |||||
| SoftwareLogicExecutor executor_; | |||||
| QTimer timer_; | |||||
| std::vector<ControlLogic> logic_snapshot_; | |||||
| SimulationState state_ = SimulationState::Stopped; | |||||
| std::uint64_t successful_scan_count_ = 0; | |||||
| LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}}; | |||||
| }; | |||||
| @@ -10,8 +10,11 @@ | |||||
| #include "project_service.h" | #include "project_service.h" | ||||
| RuntimeModeService::RuntimeModeService(const ProjectService &project_service) | |||||
| : project_service_(project_service) | |||||
| RuntimeModeService::RuntimeModeService( | |||||
| const ProjectService &project_service, | |||||
| OfflineSimulationService &offline_simulation_service) | |||||
| : project_service_(project_service), | |||||
| offline_simulation_service_(offline_simulation_service) | |||||
| { | { | ||||
| } | } | ||||
| @@ -27,17 +30,37 @@ ModePolicy RuntimeModeService::policy() const | |||||
| ModeTransitionResult RuntimeModeService::enterEditing() | ModeTransitionResult RuntimeModeService::enterEditing() | ||||
| { | { | ||||
| if (state_.mode() == ApplicationMode::OfflineRunning) | |||||
| { | |||||
| // 先停止扫描再开放编辑,防止运行快照继续写寄存器 | |||||
| offline_simulation_service_.stop(); | |||||
| } | |||||
| return state_.enterEditing(); | return state_.enterEditing(); | ||||
| } | } | ||||
| ModeTransitionResult RuntimeModeService::enterOfflineRunning() | ModeTransitionResult RuntimeModeService::enterOfflineRunning() | ||||
| { | { | ||||
| if (state_.mode() != ApplicationMode::Editing) | |||||
| { | |||||
| return state_.enterOfflineRunning(); | |||||
| } | |||||
| std::string error; | std::string error; | ||||
| if (!project_service_.project().validateForRunning(&error)) | if (!project_service_.project().validateForRunning(&error)) | ||||
| { | { | ||||
| return {false, ModeTransitionError::ProjectNotReady}; | return {false, ModeTransitionError::ProjectNotReady}; | ||||
| } | } | ||||
| return state_.enterOfflineRunning(); | |||||
| const SimulationStartResult start_result = offline_simulation_service_.start( | |||||
| project_service_.project().controlLogics); | |||||
| if (!start_result.succeeded) | |||||
| { | |||||
| return {false, ModeTransitionError::SimulationStartFailed}; | |||||
| } | |||||
| const ModeTransitionResult transition = state_.enterOfflineRunning(); | |||||
| if (!transition.succeeded) | |||||
| { | |||||
| offline_simulation_service_.stop(); | |||||
| } | |||||
| return transition; | |||||
| } | } | ||||
| ModeTransitionResult RuntimeModeService::enterOnlineRunning() | ModeTransitionResult RuntimeModeService::enterOnlineRunning() | ||||
| @@ -56,3 +79,23 @@ bool RuntimeModeService::initialPlcReadCompleted() const | |||||
| { | { | ||||
| return initial_plc_read_completed_; | return initial_plc_read_completed_; | ||||
| } | } | ||||
| SimulationState RuntimeModeService::simulationState() const | |||||
| { | |||||
| return offline_simulation_service_.state(); | |||||
| } | |||||
| std::uint64_t RuntimeModeService::successfulScanCount() const | |||||
| { | |||||
| return offline_simulation_service_.successfulScanCount(); | |||||
| } | |||||
| const LogicScanResult &RuntimeModeService::simulationError() const | |||||
| { | |||||
| return offline_simulation_service_.lastError(); | |||||
| } | |||||
| OfflineSimulationService &RuntimeModeService::offlineSimulationService() | |||||
| { | |||||
| return offline_simulation_service_; | |||||
| } | |||||
| @@ -9,6 +9,9 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| #include "offline_simulation_service.h" | |||||
| #include <cstdint> | |||||
| class ProjectService; | class ProjectService; | ||||
| @@ -16,7 +19,9 @@ class ProjectService; | |||||
| class RuntimeModeService | class RuntimeModeService | ||||
| { | { | ||||
| public: | public: | ||||
| explicit RuntimeModeService(const ProjectService &project_service); | |||||
| RuntimeModeService( | |||||
| const ProjectService &project_service, | |||||
| OfflineSimulationService &offline_simulation_service); | |||||
| ApplicationMode mode() const; | ApplicationMode mode() const; | ||||
| /** | /** | ||||
| @@ -54,8 +59,14 @@ public: | |||||
| */ | */ | ||||
| bool initialPlcReadCompleted() const; | bool initialPlcReadCompleted() const; | ||||
| SimulationState simulationState() const; | |||||
| std::uint64_t successfulScanCount() const; | |||||
| const LogicScanResult &simulationError() const; | |||||
| OfflineSimulationService &offlineSimulationService(); | |||||
| private: | private: | ||||
| const ProjectService &project_service_; | const ProjectService &project_service_; | ||||
| OfflineSimulationService &offline_simulation_service_; | |||||
| RuntimeState state_; | RuntimeState state_; | ||||
| // 表示 PLC 缓存是否已通过至少一次有效读取建立 | // 表示 PLC 缓存是否已通过至少一次有效读取建立 | ||||
| bool initial_plc_read_completed_ = false; | bool initial_plc_read_completed_ = false; | ||||
| @@ -0,0 +1,295 @@ | |||||
| #include "software_logic_executor.h" | |||||
| #include <map> | |||||
| #include <type_traits> | |||||
| namespace { | |||||
| LogicScanResult success() | |||||
| { | |||||
| return {true, LogicScanError::None, {}, {}, {}, {}}; | |||||
| } | |||||
| LogicScanResult failure( | |||||
| LogicScanError error, | |||||
| const std::string &message, | |||||
| const std::string &logic_id = {}, | |||||
| const std::string &rung_id = {}, | |||||
| const std::string &node_id = {}) | |||||
| { | |||||
| return {false, error, message, logic_id, rung_id, node_id}; | |||||
| } | |||||
| bool compareWord( | |||||
| std::int16_t actual, | |||||
| ComparisonOperator comparison, | |||||
| std::int16_t expected) | |||||
| { | |||||
| switch (comparison) | |||||
| { | |||||
| case ComparisonOperator::Equal: | |||||
| { | |||||
| return actual == expected; | |||||
| } | |||||
| case ComparisonOperator::NotEqual: | |||||
| { | |||||
| return actual != expected; | |||||
| } | |||||
| case ComparisonOperator::LessThan: | |||||
| { | |||||
| return actual < expected; | |||||
| } | |||||
| case ComparisonOperator::LessThanOrEqual: | |||||
| { | |||||
| return actual <= expected; | |||||
| } | |||||
| case ComparisonOperator::GreaterThan: | |||||
| { | |||||
| return actual > expected; | |||||
| } | |||||
| case ComparisonOperator::GreaterThanOrEqual: | |||||
| { | |||||
| return actual >= expected; | |||||
| } | |||||
| default: | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| } // namespace | |||||
| LogicScanResult SoftwareLogicExecutor::validate( | |||||
| const std::vector<ControlLogic> &logics) const | |||||
| { | |||||
| std::map<int, CoilMode> output_modes; | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| std::string validation_error; | |||||
| if (!logic.validateForRunning(&validation_error)) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| validation_error, | |||||
| logic.id); | |||||
| } | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (!rung.output.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config); | |||||
| if (output == nullptr) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "ladder output is not a coil", | |||||
| logic.id, | |||||
| rung.id, | |||||
| rung.output->id); | |||||
| } | |||||
| const auto existing = output_modes.find(output->address.index()); | |||||
| if (existing != output_modes.end() && existing->second != output->mode) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::ConflictingOutput, | |||||
| "mixed coil modes target " + output->address.toString(), | |||||
| logic.id, | |||||
| rung.id, | |||||
| rung.output->id); | |||||
| } | |||||
| output_modes[output->address.index()] = output->mode; | |||||
| } | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| RegisterRepository &repository) const | |||||
| { | |||||
| const LogicScanResult validation = validate(logics); | |||||
| if (!validation.succeeded) | |||||
| { | |||||
| return validation; | |||||
| } | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (rung.stages.empty() && !rung.output.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| bool rung_value = true; | |||||
| for (const LadderStage &stage : rung.stages) | |||||
| { | |||||
| bool stage_value = false; | |||||
| for (const LogicNode &node : stage.branches) | |||||
| { | |||||
| bool condition_value = false; | |||||
| LogicScanResult result = evaluateCondition( | |||||
| node, repository, &condition_value); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| result.logicId = logic.id; | |||||
| result.rungId = rung.id; | |||||
| return result; | |||||
| } | |||||
| stage_value = stage_value || condition_value; | |||||
| } | |||||
| rung_value = rung_value && stage_value; | |||||
| } | |||||
| LogicScanResult result = writeOutput( | |||||
| *rung.output, rung_value, repository); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| result.logicId = logic.id; | |||||
| result.rungId = rung.id; | |||||
| return result; | |||||
| } | |||||
| } | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| const LogicNode &node, | |||||
| RegisterRepository &repository, | |||||
| bool *value) const | |||||
| { | |||||
| if (value == nullptr) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "condition result target is missing", | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| return std::visit( | |||||
| [&repository, value, &node](const auto &config) -> LogicScanResult | |||||
| { | |||||
| using Config = std::decay_t<decltype(config)>; | |||||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | |||||
| { | |||||
| const BitReadResult read = repository.readBit(config.address); | |||||
| if (!read.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterReadFailed, | |||||
| "failed to read " + config.address.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| *value = config.mode == ContactMode::NormallyOpen | |||||
| ? read.value : !read.value; | |||||
| return success(); | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, CompareNodeConfig>) | |||||
| { | |||||
| const WordReadResult read = repository.readWord(config.address); | |||||
| if (!read.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterReadFailed, | |||||
| "failed to read " + config.address.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| *value = compareWord(read.value, config.comparison, config.value); | |||||
| return success(); | |||||
| } | |||||
| else | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "condition node contains an output coil", | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| }, | |||||
| node.config); | |||||
| } | |||||
| LogicScanResult SoftwareLogicExecutor::writeOutput( | |||||
| const LogicNode &node, | |||||
| bool rung_value, | |||||
| RegisterRepository &repository) const | |||||
| { | |||||
| const auto *config = std::get_if<CoilNodeConfig>(&node.config); | |||||
| if (config == nullptr) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "ladder output is not a coil", | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| bool should_write = true; | |||||
| bool output_value = rung_value; | |||||
| switch (config->mode) | |||||
| { | |||||
| case CoilMode::Normal: | |||||
| { | |||||
| break; | |||||
| } | |||||
| case CoilMode::Set: | |||||
| { | |||||
| should_write = rung_value; | |||||
| output_value = true; | |||||
| break; | |||||
| } | |||||
| case CoilMode::Reset: | |||||
| { | |||||
| should_write = rung_value; | |||||
| output_value = false; | |||||
| break; | |||||
| } | |||||
| default: | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "unsupported coil mode", | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| } | |||||
| if (!should_write) | |||||
| { | |||||
| return success(); | |||||
| } | |||||
| const RegisterWriteResult write = repository.writeBit( | |||||
| config->address, output_value); | |||||
| if (!write.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterWriteFailed, | |||||
| "failed to write " + config->address.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| @@ -0,0 +1,46 @@ | |||||
| #pragma once | |||||
| #include "domain/control_logic_model.h" | |||||
| #include "domain/register_repository.h" | |||||
| #include <string> | |||||
| #include <vector> | |||||
| enum class LogicScanError | |||||
| { | |||||
| None, | |||||
| InvalidLogic, | |||||
| ConflictingOutput, | |||||
| RegisterReadFailed, | |||||
| RegisterWriteFailed | |||||
| }; | |||||
| struct LogicScanResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| LogicScanError error = LogicScanError::None; | |||||
| std::string message; | |||||
| std::string logicId; | |||||
| std::string rungId; | |||||
| std::string nodeId; | |||||
| }; | |||||
| // 按工程顺序执行受限梯形图的一次确定性扫描 | |||||
| class SoftwareLogicExecutor | |||||
| { | |||||
| public: | |||||
| LogicScanResult validate(const std::vector<ControlLogic> &logics) const; | |||||
| LogicScanResult executeScan( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| RegisterRepository &repository) const; | |||||
| private: | |||||
| LogicScanResult evaluateCondition( | |||||
| const LogicNode &node, | |||||
| RegisterRepository &repository, | |||||
| bool *value) const; | |||||
| LogicScanResult writeOutput( | |||||
| const LogicNode &node, | |||||
| bool rung_value, | |||||
| RegisterRepository &repository) const; | |||||
| }; | |||||
| @@ -309,6 +309,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||||
| runtime_active_ = active; | runtime_active_ = active; | ||||
| if (!runtime_active_) | if (!runtime_active_) | ||||
| { | { | ||||
| runtime_write_enabled_ = false; | |||||
| for (QGraphicsItem *item : scene_->items()) | for (QGraphicsItem *item : scene_->items()) | ||||
| { | { | ||||
| HmiGraphicsItem *control_item = asHmiItem(item); | HmiGraphicsItem *control_item = asHmiItem(item); | ||||
| @@ -320,6 +321,11 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||||
| } | } | ||||
| } | } | ||||
| void HmiEditorWidget::setRuntimeWriteEnabled(bool enabled) | |||||
| { | |||||
| runtime_write_enabled_ = runtime_active_ && enabled; | |||||
| } | |||||
| // 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形 | // 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形 | ||||
| void HmiEditorWidget::reloadPage() | void HmiEditorWidget::reloadPage() | ||||
| { | { | ||||
| @@ -474,7 +480,7 @@ void HmiEditorWidget::handleControlMoved( | |||||
| void HmiEditorWidget::handleControlActivated(const std::string &control_id) | void HmiEditorWidget::handleControlActivated(const std::string &control_id) | ||||
| { | { | ||||
| if (!runtime_active_) | |||||
| if (!runtime_active_ || !runtime_write_enabled_) | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -52,6 +52,8 @@ public: | |||||
| * @param active 为 true 时允许按钮和数值输入调用运行时服务 | * @param active 为 true 时允许按钮和数值输入调用运行时服务 | ||||
| */ | */ | ||||
| void setRuntimeActive(bool active); | void setRuntimeActive(bool active); | ||||
| // 故障态保持运行值可见,但禁止按钮和数值输入继续写入 | |||||
| void setRuntimeWriteEnabled(bool enabled); | |||||
| /** | /** | ||||
| * @brief 使用当前页面模型完全重建场景 | * @brief 使用当前页面模型完全重建场景 | ||||
| * | * | ||||
| @@ -115,4 +117,5 @@ private: | |||||
| bool editing_enabled_ = true; | bool editing_enabled_ = true; | ||||
| // 控制是否刷新运行值并响应运行态控件操作 | // 控制是否刷新运行值并响应运行态控件操作 | ||||
| bool runtime_active_ = false; | bool runtime_active_ = false; | ||||
| bool runtime_write_enabled_ = false; | |||||
| }; | }; | ||||
| @@ -78,6 +78,10 @@ QString transitionErrorText(ModeTransitionError error) | |||||
| { | { | ||||
| return MainWindow::tr("工程存在未配置或未完成的 HMI/梯形图节点,暂不能进入离线运行态"); | return MainWindow::tr("工程存在未配置或未完成的 HMI/梯形图节点,暂不能进入离线运行态"); | ||||
| } | } | ||||
| case ModeTransitionError::SimulationStartFailed: | |||||
| { | |||||
| return MainWindow::tr("离线逻辑执行器预检或启动失败"); | |||||
| } | |||||
| case ModeTransitionError::None: | case ModeTransitionError::None: | ||||
| default: | default: | ||||
| { | { | ||||
| @@ -367,8 +371,10 @@ void MainWindow::configureAppearance() | |||||
| mode_status_label_->setMinimumWidth(88); | mode_status_label_->setMinimumWidth(88); | ||||
| mode_status_label_->setAlignment(Qt::AlignCenter); | mode_status_label_->setAlignment(Qt::AlignCenter); | ||||
| register_status_label_ = new QLabel(this); | register_status_label_ = new QLabel(this); | ||||
| register_status_label_->setObjectName(QStringLiteral("registerStatusLabel")); | |||||
| register_status_label_->setMinimumWidth(132); | register_status_label_->setMinimumWidth(132); | ||||
| executor_status_label_ = new QLabel(this); | executor_status_label_ = new QLabel(this); | ||||
| executor_status_label_->setObjectName(QStringLiteral("executorStatusLabel")); | |||||
| executor_status_label_->setMinimumWidth(132); | executor_status_label_->setMinimumWidth(132); | ||||
| statusBar()->addPermanentWidget(mode_status_label_); | statusBar()->addPermanentWidget(mode_status_label_); | ||||
| statusBar()->addPermanentWidget(register_status_label_); | statusBar()->addPermanentWidget(register_status_label_); | ||||
| @@ -426,9 +432,15 @@ void MainWindow::configureHmiEditor() | |||||
| if (runtime_mode_service_.mode() != ApplicationMode::Editing) | if (runtime_mode_service_.mode() != ApplicationMode::Editing) | ||||
| { | { | ||||
| hmi_editor_widget_->refreshRuntimeValues(); | hmi_editor_widget_->refreshRuntimeValues(); | ||||
| updateSimulationUi(false); | |||||
| } | } | ||||
| }); | }); | ||||
| runtime_refresh_timer_->start(); | runtime_refresh_timer_->start(); | ||||
| connect(&runtime_mode_service_.offlineSimulationService(), | |||||
| &OfflineSimulationService::stateChanged, | |||||
| this, | |||||
| [this] { updateSimulationUi(true); }, | |||||
| Qt::QueuedConnection); | |||||
| } | } | ||||
| void MainWindow::configureLogicEditor() | void MainWindow::configureLogicEditor() | ||||
| @@ -1036,7 +1048,16 @@ void MainWindow::requestMode(ApplicationMode requested_mode) | |||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| restoreCurrentModeAction(); | restoreCurrentModeAction(); | ||||
| statusBar()->showMessage(transitionErrorText(result.error), 5000); | |||||
| QString message = transitionErrorText(result.error); | |||||
| if (result.error == ModeTransitionError::SimulationStartFailed) | |||||
| { | |||||
| const LogicScanResult &error = runtime_mode_service_.simulationError(); | |||||
| if (!error.message.empty()) | |||||
| { | |||||
| message += QStringLiteral(": ") + fromUtf8(error.message); | |||||
| } | |||||
| } | |||||
| statusBar()->showMessage(message, 5000); | |||||
| return; | return; | ||||
| } | } | ||||
| updateModeUi(tr("已进入%1").arg(modeText(runtime_mode_service_.mode()))); | updateModeUi(tr("已进入%1").arg(modeText(runtime_mode_service_.mode()))); | ||||
| @@ -1091,13 +1112,63 @@ void MainWindow::updateModeUi(const QString &message) | |||||
| register_status_label_->setText( | register_status_label_->setText( | ||||
| policy.usesVirtualRegisters ? tr("数据源:虚拟 M/D") | policy.usesVirtualRegisters ? tr("数据源:虚拟 M/D") | ||||
| : policy.usesPlcRegisters ? tr("数据源:PLC 缓存") : tr("数据源:未启用")); | : policy.usesPlcRegisters ? tr("数据源:PLC 缓存") : tr("数据源:未启用")); | ||||
| executor_status_label_->setText( | |||||
| policy.runsLogicExecutor ? tr("逻辑执行器:运行") : tr("逻辑执行器:停止")); | |||||
| updateSimulationUi(false); | |||||
| statusBar()->showMessage(message, 4000); | statusBar()->showMessage(message, 4000); | ||||
| ui_->outputList->addItem(message); | ui_->outputList->addItem(message); | ||||
| ui_->outputList->scrollToBottom(); | ui_->outputList->scrollToBottom(); | ||||
| } | } | ||||
| void MainWindow::updateSimulationUi(bool report_fault) | |||||
| { | |||||
| const bool offline = runtime_mode_service_.mode() | |||||
| == ApplicationMode::OfflineRunning; | |||||
| const SimulationState state = runtime_mode_service_.simulationState(); | |||||
| hmi_editor_widget_->setRuntimeWriteEnabled( | |||||
| offline && state == SimulationState::Running); | |||||
| switch (state) | |||||
| { | |||||
| case SimulationState::Running: | |||||
| { | |||||
| executor_status_label_->setText( | |||||
| tr("逻辑执行器:运行(%1 次)") | |||||
| .arg(runtime_mode_service_.successfulScanCount())); | |||||
| break; | |||||
| } | |||||
| case SimulationState::Faulted: | |||||
| { | |||||
| executor_status_label_->setText(tr("逻辑执行器:故障")); | |||||
| if (report_fault) | |||||
| { | |||||
| const LogicScanResult &error = runtime_mode_service_.simulationError(); | |||||
| QString message = tr("离线仿真异常:%1").arg(fromUtf8(error.message)); | |||||
| if (!error.logicId.empty()) | |||||
| { | |||||
| message += tr(",逻辑 %1").arg(fromUtf8(error.logicId)); | |||||
| } | |||||
| if (!error.rungId.empty()) | |||||
| { | |||||
| message += tr(",网络 %1").arg(fromUtf8(error.rungId)); | |||||
| } | |||||
| if (!error.nodeId.empty()) | |||||
| { | |||||
| message += tr(",节点 %1").arg(fromUtf8(error.nodeId)); | |||||
| } | |||||
| statusBar()->showMessage(message); | |||||
| ui_->outputList->addItem(message); | |||||
| ui_->outputList->scrollToBottom(); | |||||
| } | |||||
| break; | |||||
| } | |||||
| case SimulationState::Stopped: | |||||
| default: | |||||
| { | |||||
| executor_status_label_->setText(tr("逻辑执行器:停止")); | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| // 根据当前真实运行模式,同步更新模式工具栏单选按钮选中状态 | // 根据当前真实运行模式,同步更新模式工具栏单选按钮选中状态 | ||||
| void MainWindow::restoreCurrentModeAction() | void MainWindow::restoreCurrentModeAction() | ||||
| { | { | ||||
| @@ -126,6 +126,8 @@ private: | |||||
| * @param message 要显示在状态栏和输出区的模式结果消息 | * @param message 要显示在状态栏和输出区的模式结果消息 | ||||
| */ | */ | ||||
| void updateModeUi(const QString &message); | void updateModeUi(const QString &message); | ||||
| // 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 | |||||
| void updateSimulationUi(bool report_fault); | |||||
| /** | /** | ||||
| * @brief 将互斥模式动作同步到服务层当前状态 | * @brief 将互斥模式动作同步到服务层当前状态 | ||||
| @@ -3,6 +3,7 @@ | |||||
| #include "services/hmi_editor_service.h" | #include "services/hmi_editor_service.h" | ||||
| #include "services/hmi_runtime_service.h" | #include "services/hmi_runtime_service.h" | ||||
| #include "services/logic_editor_service.h" | #include "services/logic_editor_service.h" | ||||
| #include "services/offline_simulation_service.h" | |||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "services/runtime_mode_service.h" | #include "services/runtime_mode_service.h" | ||||
| #include "ui/hmi_editor_widget.h" | #include "ui/hmi_editor_widget.h" | ||||
| @@ -62,7 +63,8 @@ void testModeActionsControlEditingAvailability() | |||||
| LogicEditorService logic_editor_service(project_service); | LogicEditorService logic_editor_service(project_service); | ||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| HmiRuntimeService runtime_service(repository); | HmiRuntimeService runtime_service(repository); | ||||
| RuntimeModeService mode_service(project_service); | |||||
| OfflineSimulationService simulation_service(repository); | |||||
| RuntimeModeService mode_service(project_service, simulation_service); | |||||
| MainWindow window( | MainWindow window( | ||||
| mode_service, | mode_service, | ||||
| project_service, | project_service, | ||||
| @@ -90,6 +92,7 @@ void testModeActionsControlEditingAvailability() | |||||
| QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit"); | QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit"); | ||||
| HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>( | HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>( | ||||
| window, "hmiEditorWidget"); | window, "hmiEditorWidget"); | ||||
| QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel"); | |||||
| const qreal compact_scale = hmi_editor->transform().m11(); | const qreal compact_scale = hmi_editor->transform().m11(); | ||||
| window.resize(1600, 900); | window.resize(1600, 900); | ||||
| @@ -153,6 +156,10 @@ void testModeActionsControlEditingAvailability() | |||||
| offline_action->trigger(); | offline_action->trigger(); | ||||
| require(mode_service.mode() == ApplicationMode::OfflineRunning, | require(mode_service.mode() == ApplicationMode::OfflineRunning, | ||||
| "offline action must enter offline running"); | "offline action must enter offline running"); | ||||
| require(simulation_service.state() == SimulationState::Running, | |||||
| "offline action must start the actual simulation service"); | |||||
| require(executor_status->text().contains(QStringLiteral("运行")), | |||||
| "executor status label must report the actual running state"); | |||||
| require(!project_dock->isEnabled(), | require(!project_dock->isEnabled(), | ||||
| "project dock must be disabled while running"); | "project dock must be disabled while running"); | ||||
| require(!properties_dock->isEnabled(), | require(!properties_dock->isEnabled(), | ||||
| @@ -171,6 +178,10 @@ void testModeActionsControlEditingAvailability() | |||||
| editing_action->trigger(); | editing_action->trigger(); | ||||
| require(mode_service.mode() == ApplicationMode::Editing, | require(mode_service.mode() == ApplicationMode::Editing, | ||||
| "editing action must return to editing"); | "editing action must return to editing"); | ||||
| require(simulation_service.state() == SimulationState::Stopped, | |||||
| "editing action must stop the simulation service"); | |||||
| require(executor_status->text() == QStringLiteral("逻辑执行器:停止"), | |||||
| "executor status label must report the actual stopped state"); | |||||
| require(project_dock->isEnabled(), | require(project_dock->isEnabled(), | ||||
| "project dock must be restored after returning to editing"); | "project dock must be restored after returning to editing"); | ||||
| require(properties_dock->isEnabled(), | require(properties_dock->isEnabled(), | ||||
| @@ -23,6 +23,8 @@ SOURCES += \ | |||||
| ../src/services/hmi_editor_service.cpp \ | ../src/services/hmi_editor_service.cpp \ | ||||
| ../src/services/logic_editor_service.cpp \ | ../src/services/logic_editor_service.cpp \ | ||||
| ../src/services/hmi_runtime_service.cpp \ | ../src/services/hmi_runtime_service.cpp \ | ||||
| ../src/services/software_logic_executor.cpp \ | |||||
| ../src/services/offline_simulation_service.cpp \ | |||||
| ../src/services/runtime_mode_service.cpp | ../src/services/runtime_mode_service.cpp | ||||
| HEADERS += \ | HEADERS += \ | ||||
| @@ -40,6 +42,8 @@ HEADERS += \ | |||||
| ../src/services/hmi_editor_service.h \ | ../src/services/hmi_editor_service.h \ | ||||
| ../src/services/logic_editor_service.h \ | ../src/services/logic_editor_service.h \ | ||||
| ../src/services/hmi_runtime_service.h \ | ../src/services/hmi_runtime_service.h \ | ||||
| ../src/services/software_logic_executor.h \ | |||||
| ../src/services/offline_simulation_service.h \ | |||||
| ../src/services/runtime_mode_service.h | ../src/services/runtime_mode_service.h | ||||
| FORMS += \ | FORMS += \ | ||||
| @@ -0,0 +1,286 @@ | |||||
| #include "domain/hmi_model.h" | |||||
| #include "domain/register_repository.h" | |||||
| #include "services/hmi_runtime_service.h" | |||||
| #include "services/offline_simulation_service.h" | |||||
| #include "services/software_logic_executor.h" | |||||
| #include <QCoreApplication> | |||||
| #include <array> | |||||
| #include <iostream> | |||||
| #include <stdexcept> | |||||
| #include <string> | |||||
| #include <vector> | |||||
| namespace { | |||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| LogicNode contact(const std::string &id, int address, | |||||
| ContactMode mode = ContactMode::NormallyOpen) | |||||
| { | |||||
| return {id, ContactNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true}; | |||||
| } | |||||
| LogicNode comparison(const std::string &id, int address, | |||||
| ComparisonOperator operation, std::int16_t value) | |||||
| { | |||||
| return {id, | |||||
| CompareNodeConfig{RegisterAddress{RegisterArea::D, address}, operation, value}, | |||||
| true}; | |||||
| } | |||||
| LogicNode coil(const std::string &id, int address, | |||||
| CoilMode mode = CoilMode::Normal) | |||||
| { | |||||
| return {id, CoilNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true}; | |||||
| } | |||||
| LadderRung rung(const std::string &id, | |||||
| const std::vector<std::vector<LogicNode>> &stages, | |||||
| const LogicNode &output) | |||||
| { | |||||
| LadderRung result; | |||||
| result.id = id; | |||||
| result.name = id; | |||||
| for (std::size_t index = 0; index < stages.size(); ++index) | |||||
| { | |||||
| result.stages.push_back({id + "-stage-" + std::to_string(index), stages[index]}); | |||||
| } | |||||
| result.output = output; | |||||
| return result; | |||||
| } | |||||
| ControlLogic logic(const std::vector<LadderRung> &rungs) | |||||
| { | |||||
| return {"logic-1", "logic-1", rungs, true}; | |||||
| } | |||||
| bool readBit(RegisterRepository &repository, int address) | |||||
| { | |||||
| const BitReadResult result = repository.readBit( | |||||
| RegisterAddress{RegisterArea::M, address}); | |||||
| require(result.succeeded, "test register read must succeed"); | |||||
| return result.value; | |||||
| } | |||||
| void writeBit(RegisterRepository &repository, int address, bool value) | |||||
| { | |||||
| require(repository.writeBit(RegisterAddress{RegisterArea::M, address}, value).succeeded, | |||||
| "test bit write must succeed"); | |||||
| } | |||||
| void writeWord(RegisterRepository &repository, int address, std::int16_t value) | |||||
| { | |||||
| require(repository.writeWord(RegisterAddress{RegisterArea::D, address}, value).succeeded, | |||||
| "test word write must succeed"); | |||||
| } | |||||
| void testSeriesParallelContactsAndSequentialVisibility() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| const ControlLogic program = logic({ | |||||
| rung("rung-1", | |||||
| {{contact("start", 0), contact("alternate", 1)}, | |||||
| {contact("stop", 2, ContactMode::NormallyClosed)}}, | |||||
| coil("run", 3)), | |||||
| rung("rung-2", {{contact("run-feedback", 3)}}, coil("downstream", 4))}); | |||||
| writeBit(repository, 1, true); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "parallel and series scan must succeed"); | |||||
| require(readBit(repository, 3), "parallel OR and series AND must energize output"); | |||||
| require(readBit(repository, 4), | |||||
| "a later rung must see an earlier rung write in the same scan"); | |||||
| writeBit(repository, 2, true); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "normally closed scan must succeed"); | |||||
| require(!readBit(repository, 3), "normally closed stop contact must open the rung"); | |||||
| require(!readBit(repository, 4), "downstream normal coil must follow the new value"); | |||||
| } | |||||
| void testAllComparisons() | |||||
| { | |||||
| const std::array<ComparisonOperator, 6> operations{ | |||||
| ComparisonOperator::Equal, ComparisonOperator::NotEqual, | |||||
| ComparisonOperator::LessThan, ComparisonOperator::LessThanOrEqual, | |||||
| ComparisonOperator::GreaterThan, ComparisonOperator::GreaterThanOrEqual}; | |||||
| const std::array<std::int16_t, 6> actual_values{10, 9, 9, 10, 11, 10}; | |||||
| for (std::size_t index = 0; index < operations.size(); ++index) | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| writeWord(repository, 0, actual_values[index]); | |||||
| const ControlLogic program = logic({ | |||||
| rung("compare-rung", {{comparison("compare", 0, operations[index], 10)}, | |||||
| }, coil("result", 10))}); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "comparison scan must succeed"); | |||||
| require(readBit(repository, 10), "comparison operator must evaluate true"); | |||||
| } | |||||
| } | |||||
| void testSetResetAndDisabledLogic() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| ControlLogic program = logic({ | |||||
| rung("set-rung", {{contact("set-input", 0)}}, coil("set-output", 5, CoilMode::Set)), | |||||
| rung("reset-rung", {{contact("reset-input", 1)}}, | |||||
| coil("reset-output", 6, CoilMode::Reset))}); | |||||
| writeBit(repository, 0, true); | |||||
| require(executor.executeScan({program}, repository).succeeded, "set scan must succeed"); | |||||
| require(readBit(repository, 5), "set coil must latch true"); | |||||
| writeBit(repository, 0, false); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "inactive set scan must succeed"); | |||||
| require(readBit(repository, 5), "inactive set coil must retain its value"); | |||||
| writeBit(repository, 1, true); | |||||
| writeBit(repository, 6, true); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "reset scan must succeed"); | |||||
| require(!readBit(repository, 6), "reset coil must write false"); | |||||
| program.enabled = false; | |||||
| writeBit(repository, 5, true); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "disabled logic scan must be ignored successfully"); | |||||
| require(readBit(repository, 5), "disabled logic must not change outputs"); | |||||
| } | |||||
| void testConflictingCoilsAreRejected() | |||||
| { | |||||
| SoftwareLogicExecutor executor; | |||||
| const ControlLogic program = logic({ | |||||
| rung("normal-rung", {{contact("normal-input", 0)}}, coil("normal", 8)), | |||||
| rung("set-rung", {{contact("set-input", 1)}}, coil("set", 8, CoilMode::Set))}); | |||||
| const LogicScanResult result = executor.validate({program}); | |||||
| require(!result.succeeded && result.error == LogicScanError::ConflictingOutput, | |||||
| "mixed output modes for one address must be rejected"); | |||||
| require(result.rungId == "set-rung" && result.nodeId == "set", | |||||
| "conflict error must identify the offending rung and node"); | |||||
| } | |||||
| void testHmiSimulationClosedLoop() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| HmiRuntimeService hmi(repository); | |||||
| SoftwareLogicExecutor executor; | |||||
| HmiControl start; | |||||
| start.id = "start-button"; | |||||
| start.type = HmiControlType::Button; | |||||
| start.bounds = {0, 0, 80, 30}; | |||||
| start.text = "start"; | |||||
| start.binding = RegisterAddress{RegisterArea::M, 0}; | |||||
| HmiControl indicator; | |||||
| indicator.id = "run-indicator"; | |||||
| indicator.type = HmiControlType::Indicator; | |||||
| indicator.bounds = {0, 40, 80, 30}; | |||||
| indicator.text = "run"; | |||||
| indicator.binding = RegisterAddress{RegisterArea::M, 2}; | |||||
| const ControlLogic program = logic({ | |||||
| rung("hold-rung", | |||||
| {{contact("stop", 1, ContactMode::NormallyClosed)}, | |||||
| {contact("start", 0), contact("feedback", 2)}}, | |||||
| coil("run", 2))}); | |||||
| require(hmi.toggleButton(start).succeeded, "HMI start button must write virtual M"); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "closed-loop scan must succeed"); | |||||
| require(hmi.readControl(indicator).bit_value, | |||||
| "HMI indicator must observe the logic output"); | |||||
| require(hmi.toggleButton(start).succeeded, "HMI start button must toggle off"); | |||||
| require(executor.executeScan({program}, repository).succeeded, "holding scan must succeed"); | |||||
| require(hmi.readControl(indicator).bit_value, | |||||
| "feedback contact must hold the output after start turns off"); | |||||
| writeBit(repository, 1, true); | |||||
| require(executor.executeScan({program}, repository).succeeded, "stop scan must succeed"); | |||||
| require(!hmi.readControl(indicator).bit_value, | |||||
| "stop contact must clear the HMI run indication"); | |||||
| } | |||||
| void testSimulationLifecycleSnapshotAndFault() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| OfflineSimulationService simulation(repository); | |||||
| ControlLogic program = logic({ | |||||
| rung("snapshot-rung", {{contact("input", 0)}}, coil("output", 1))}); | |||||
| writeBit(repository, 0, true); | |||||
| require(simulation.start({program}).succeeded, "simulation must start"); | |||||
| require(simulation.state() == SimulationState::Running, "simulation must report running"); | |||||
| require(!readBit(repository, 0), "starting a session must clear virtual registers"); | |||||
| require(simulation.scanIntervalMs() == 50, "default scan interval must be 50 ms"); | |||||
| writeBit(repository, 0, true); | |||||
| program.rungs.front().output = coil("changed-output", 9); | |||||
| require(simulation.executeOnce().succeeded, "manual lifecycle scan must succeed"); | |||||
| require(readBit(repository, 1), "simulation must use its start-time snapshot"); | |||||
| require(simulation.successfulScanCount() == 1, "successful scans must be counted"); | |||||
| simulation.stop(); | |||||
| require(simulation.state() == SimulationState::Stopped, "simulation must stop"); | |||||
| require(simulation.start({program}).succeeded, "stopped simulation must support restart"); | |||||
| require(!readBit(repository, 1), "new session must clear prior output values"); | |||||
| simulation.stop(); | |||||
| } | |||||
| class FailingVirtualRegisterRepository final : public VirtualRegisterRepository | |||||
| { | |||||
| public: | |||||
| BitReadResult readBit(const RegisterAddress &) const override | |||||
| { | |||||
| return {false, false, RegisterError::Unavailable}; | |||||
| } | |||||
| }; | |||||
| void testRepositoryFailureEntersFaultState() | |||||
| { | |||||
| FailingVirtualRegisterRepository repository; | |||||
| OfflineSimulationService simulation(repository); | |||||
| const ControlLogic program = logic({ | |||||
| rung("fault-rung", {{contact("fault-input", 0)}}, coil("fault-output", 1))}); | |||||
| require(simulation.start({program}).succeeded, | |||||
| "valid logic must pass simulation startup"); | |||||
| const LogicScanResult result = simulation.executeOnce(); | |||||
| require(!result.succeeded && result.error == LogicScanError::RegisterReadFailed, | |||||
| "repository read failure must fail the scan"); | |||||
| require(simulation.state() == SimulationState::Faulted, | |||||
| "repository failure must enter the fault state"); | |||||
| require(simulation.lastError().logicId == "logic-1" | |||||
| && simulation.lastError().rungId == "fault-rung" | |||||
| && simulation.lastError().nodeId == "fault-input", | |||||
| "fault feedback must retain logic, rung and node context"); | |||||
| } | |||||
| } // namespace | |||||
| int main(int argc, char *argv[]) | |||||
| { | |||||
| QCoreApplication application(argc, argv); | |||||
| try | |||||
| { | |||||
| testSeriesParallelContactsAndSequentialVisibility(); | |||||
| testAllComparisons(); | |||||
| testSetResetAndDisabledLogic(); | |||||
| testConflictingCoilsAreRejected(); | |||||
| testHmiSimulationClosedLoop(); | |||||
| testSimulationLifecycleSnapshotAndFault(); | |||||
| testRepositoryFailureEntersFaultState(); | |||||
| } | |||||
| catch (const std::exception &error) | |||||
| { | |||||
| std::cerr << "offline simulation service tests failed: " << error.what() << '\n'; | |||||
| return 1; | |||||
| } | |||||
| std::cout << "offline simulation service tests passed\n"; | |||||
| return 0; | |||||
| } | |||||
| @@ -0,0 +1,29 @@ | |||||
| QT += core | |||||
| QT -= gui | |||||
| TEMPLATE = app | |||||
| TARGET = offline_simulation_service_tests | |||||
| CONFIG += console c++17 warn_on | |||||
| CONFIG -= app_bundle | |||||
| INCLUDEPATH += ../src | |||||
| SOURCES += \ | |||||
| offline_simulation_service_tests.cpp \ | |||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/services/hmi_runtime_service.cpp \ | |||||
| ../src/services/software_logic_executor.cpp \ | |||||
| ../src/services/offline_simulation_service.cpp | |||||
| HEADERS += \ | |||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/services/hmi_runtime_service.h \ | |||||
| ../src/services/software_logic_executor.h \ | |||||
| ../src/services/offline_simulation_service.h | |||||
| @@ -1,6 +1,8 @@ | |||||
| #include "services/runtime_mode_service.h" | #include "services/runtime_mode_service.h" | ||||
| #include "services/offline_simulation_service.h" | |||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| #include "domain/register_repository.h" | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <stdexcept> | #include <stdexcept> | ||||
| @@ -35,7 +37,9 @@ void testModeTransitions() | |||||
| // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 | // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 | ||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| ProjectService project_service(storage); | ProjectService project_service(storage); | ||||
| RuntimeModeService service(project_service); | |||||
| VirtualRegisterRepository repository; | |||||
| OfflineSimulationService simulation_service(repository); | |||||
| RuntimeModeService service(project_service, simulation_service); | |||||
| require(service.mode() == ApplicationMode::Editing, | require(service.mode() == ApplicationMode::Editing, | ||||
| "service must start in editing mode"); | "service must start in editing mode"); | ||||
| @@ -44,6 +48,8 @@ void testModeTransitions() | |||||
| require(service.enterOfflineRunning().succeeded, | require(service.enterOfflineRunning().succeeded, | ||||
| "editing mode must enter offline running"); | "editing mode must enter offline running"); | ||||
| require(service.simulationState() == SimulationState::Running, | |||||
| "offline mode must start the software executor"); | |||||
| require(service.policy().usesVirtualRegisters, | require(service.policy().usesVirtualRegisters, | ||||
| "offline running must use virtual registers"); | "offline running must use virtual registers"); | ||||
| require(service.enterOnlineRunning().error | require(service.enterOnlineRunning().error | ||||
| @@ -52,6 +58,8 @@ void testModeTransitions() | |||||
| require(service.enterEditing().succeeded, | require(service.enterEditing().succeeded, | ||||
| "offline running must return to editing"); | "offline running must return to editing"); | ||||
| require(service.simulationState() == SimulationState::Stopped, | |||||
| "returning to editing must stop the software executor first"); | |||||
| require(service.enterOnlineRunning().error | require(service.enterOnlineRunning().error | ||||
| == ModeTransitionError::InitialPlcReadRequired, | == ModeTransitionError::InitialPlcReadRequired, | ||||
| "online running must require an initial PLC read"); | "online running must require an initial PLC read"); | ||||
| @@ -61,6 +69,8 @@ void testModeTransitions() | |||||
| "service must retain the initial PLC read state"); | "service must retain the initial PLC read state"); | ||||
| require(service.enterOnlineRunning().succeeded, | require(service.enterOnlineRunning().succeeded, | ||||
| "online running must start after an initial PLC read"); | "online running must start after an initial PLC read"); | ||||
| require(service.simulationState() == SimulationState::Stopped, | |||||
| "online running must never start the software executor"); | |||||
| require(service.policy().usesPlcRegisters, | require(service.policy().usesPlcRegisters, | ||||
| "online running must use PLC registers"); | "online running must use PLC registers"); | ||||
| require(!service.policy().runsLogicExecutor, | require(!service.policy().runsLogicExecutor, | ||||
| @@ -11,19 +11,25 @@ INCLUDEPATH += ../src | |||||
| SOURCES += \ | SOURCES += \ | ||||
| runtime_mode_service_tests.cpp \ | runtime_mode_service_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | ../src/domain/register_address.cpp \ | ||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | ../src/domain/hmi_model.cpp \ | ||||
| ../src/domain/control_logic_model.cpp \ | ../src/domain/control_logic_model.cpp \ | ||||
| ../src/domain/project_model.cpp \ | ../src/domain/project_model.cpp \ | ||||
| ../src/domain/runtime_state.cpp \ | ../src/domain/runtime_state.cpp \ | ||||
| ../src/services/project_service.cpp \ | ../src/services/project_service.cpp \ | ||||
| ../src/services/software_logic_executor.cpp \ | |||||
| ../src/services/offline_simulation_service.cpp \ | |||||
| ../src/services/runtime_mode_service.cpp | ../src/services/runtime_mode_service.cpp | ||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | ../src/domain/register_address.h \ | ||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/hmi_model.h \ | ../src/domain/hmi_model.h \ | ||||
| ../src/domain/control_logic_model.h \ | ../src/domain/control_logic_model.h \ | ||||
| ../src/domain/project_model.h \ | ../src/domain/project_model.h \ | ||||
| ../src/domain/project_storage.h \ | ../src/domain/project_storage.h \ | ||||
| ../src/domain/runtime_state.h \ | ../src/domain/runtime_state.h \ | ||||
| ../src/services/project_service.h \ | ../src/services/project_service.h \ | ||||
| ../src/services/software_logic_executor.h \ | |||||
| ../src/services/offline_simulation_service.h \ | |||||
| ../src/services/runtime_mode_service.h | ../src/services/runtime_mode_service.h | ||||
| @@ -2,7 +2,7 @@ | |||||
| - Goal: 完成综合平台编程器的工程管理和后续编辑功能。 | - Goal: 完成综合平台编程器的工程管理和后续编辑功能。 | ||||
| - Branch: `main`。 | - Branch: `main`。 | ||||
| - Current status: 已完成开发顺序第 7 步,控制逻辑图形编辑、领域校验和工程持久化闭环已完成。 | |||||
| - Current status: 已完成开发顺序第 8 步,HMI、虚拟 M/D 和固定周期软件逻辑执行器的离线闭环已完成。 | |||||
| - Changed files: | - Changed files: | ||||
| - `app/integrated_platform.pro` | - `app/integrated_platform.pro` | ||||
| - `app/src/main.cpp` | - `app/src/main.cpp` | ||||
| @@ -47,6 +47,12 @@ | |||||
| - `app/tests/hmi_editor_service_tests.pro` | - `app/tests/hmi_editor_service_tests.pro` | ||||
| - `app/tests/hmi_editor_service_tests.cpp` | - `app/tests/hmi_editor_service_tests.cpp` | ||||
| - `app/tests/runtime_mode_service_tests.pro` | - `app/tests/runtime_mode_service_tests.pro` | ||||
| - `app/src/services/software_logic_executor.h` | |||||
| - `app/src/services/software_logic_executor.cpp` | |||||
| - `app/src/services/offline_simulation_service.h` | |||||
| - `app/src/services/offline_simulation_service.cpp` | |||||
| - `app/tests/offline_simulation_service_tests.pro` | |||||
| - `app/tests/offline_simulation_service_tests.cpp` | |||||
| - `app/tests/runtime_mode_service_tests.cpp` | - `app/tests/runtime_mode_service_tests.cpp` | ||||
| - `app/tests/main_window_tests.pro` | - `app/tests/main_window_tests.pro` | ||||
| - `app/tests/main_window_tests.cpp` | - `app/tests/main_window_tests.cpp` | ||||
| @@ -98,6 +104,10 @@ | |||||
| - `RuntimeModeService` 依赖 `ProjectService` 的只读工程,在进入离线运行态前执行完整性校验,拒绝未配置或未完成的工程。 | - `RuntimeModeService` 依赖 `ProjectService` 的只读工程,在进入离线运行态前执行完整性校验,拒绝未配置或未完成的工程。 | ||||
| - JSON 节点新增 `configured` 字段;缺失字段的旧版 `1.0` 工程按历史逻辑视为已配置,保持兼容读取。 | - JSON 节点新增 `configured` 字段;缺失字段的旧版 `1.0` 工程按历史逻辑视为已配置,保持兼容读取。 | ||||
| - 梯形图 UI 已按 LAD 可读性优化:普通线圈采用标准 `( )` 形状,导线连接元件端子而不穿透符号,比较条件显示为带数据类型和上下操作数的指令块。 | - 梯形图 UI 已按 LAD 可读性优化:普通线圈采用标准 `( )` 形状,导线连接元件端子而不穿透符号,比较条件显示为带数据类型和上下操作数的指令块。 | ||||
| - 软件逻辑执行器按逻辑、网络顺序扫描,级内 OR、级间 AND,前面网络写入对后面网络同周期可见。 | |||||
| - 普通线圈每周期写结果,置位/复位线圈只在网络成立时写入;同一地址混用不同线圈模式会在启动前被拒绝。 | |||||
| - 离线仿真以 50 ms 为目标扫描周期,启动时复制逻辑快照并清空虚拟 M/D,退出时先停止执行器再返回编辑态。 | |||||
| - 仿真状态使用 `Stopped`、`Running`、`Faulted`,记录扫描次数和逻辑/网络/节点错误上下文;故障后 HMI 保持最终值可见但禁止写入。 | |||||
| - Validation run and results: | - Validation run and results: | ||||
| - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 | - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 | ||||
| - 已启动 `integrated_platform.exe` 并正常退出。 | - 已启动 `integrated_platform.exe` 并正常退出。 | ||||
| @@ -123,6 +133,7 @@ | |||||
| - 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。 | - 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。 | ||||
| - 梯形图结构重构后,领域、逻辑编辑服务、工程管理和主窗口测试重新构建并全部通过。 | - 梯形图结构重构后,领域、逻辑编辑服务、工程管理和主窗口测试重新构建并全部通过。 | ||||
| - 待配置节点改造后,Release 应用构建成功;领域、工程管理、逻辑编辑服务、运行模式和主窗口测试全部通过。 | - 待配置节点改造后,Release 应用构建成功;领域、工程管理、逻辑编辑服务、运行模式和主窗口测试全部通过。 | ||||
| - Remaining work: 进入开发顺序第 8 步,实现离线软件逻辑执行器和 HMI 闭环。 | |||||
| - 离线仿真实现后,Release 应用构建成功;离线仿真、领域、工程管理、HMI 编辑、逻辑编辑、运行模式和主窗口测试全部通过。 | |||||
| - Remaining work: 进入开发顺序第 9 步,实现 Modbus RTU 真机通信。 | |||||
| - Known risks / blockers: 无。 | - Known risks / blockers: 无。 | ||||
| - Suggested next command: 为控制逻辑建立固定周期的软件执行器,按网络顺序执行并计算级间 AND 与级内 OR。 | |||||
| - Suggested next command: 建立异步 Modbus RTU 通信服务,先完成串口配置、连接和 M0/D0 安全读写。 | |||||
| @@ -86,6 +86,17 @@ main.cpp -> UI + Services + Infrastructure | |||||
| 数值显示读取 D 字,数值输入写入 D 字。它不依赖虚拟寄存器实现、串口或 Modbus,因此离线和真机 | 数值显示读取 D 字,数值输入写入 D 字。它不依赖虚拟寄存器实现、串口或 Modbus,因此离线和真机 | ||||
| 运行可替换数据源而无需改变 HMI 模型或画布代码。 | 运行可替换数据源而无需改变 HMI 模型或画布代码。 | ||||
| `services/SoftwareLogicExecutor` 负责一次确定性梯形图扫描。执行顺序为控制逻辑、网络、串联级, | |||||
| 同级并联条件执行 OR,不同串联级执行 AND;普通线圈每周期写入网络结果,置位和复位线圈只在 | |||||
| 网络成立时写入。前面网络的写入对后面网络在同一扫描周期立即可见。启动前拒绝同一 M 地址混用 | |||||
| 不同线圈模式,避免运行结果依赖隐藏顺序。 | |||||
| `services/OfflineSimulationService` 使用 `Qt::PreciseTimer` 以 50 ms 目标周期驱动扫描器。服务启动时 | |||||
| 先校验并复制控制逻辑快照,再清空虚拟 M/D 并开始扫描;退出离线态时先停止扫描再开放工程编辑。 | |||||
| 该周期不提供硬实时保证。服务记录 `Stopped`、`Running`、`Faulted` 实际状态、成功扫描次数和最近 | |||||
| 错误上下文。发生仓库读写或执行错误时停止定时器、保留最终寄存器值和逻辑/网络/节点定位;HMI | |||||
| 仍可读取最终值,但按钮和数值输入进入只读状态。 | |||||
| 控制逻辑节点使用 `std::variant` 组合独立配置类型。初版只包含 M 区触点、M 区线圈和 D 值与 | 控制逻辑节点使用 `std::variant` 组合独立配置类型。初版只包含 M 区触点、M 区线圈和 D 值与 | ||||
| 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 | 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 | ||||
| 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。 | 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。 | ||||