diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index a34c93a..bce6eaa 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -27,6 +27,8 @@ SOURCES += \ src/services/hmi_editor_service.cpp \ src/services/logic_editor_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/infrastructure/json_project_storage.cpp \ src/ui/hmi_editor_widget.cpp \ @@ -45,6 +47,8 @@ HEADERS += \ src/services/hmi_editor_service.h \ src/services/logic_editor_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/infrastructure/json_project_storage.h \ src/ui/hmi_editor_widget.h \ diff --git a/app/src/domain/runtime_state.h b/app/src/domain/runtime_state.h index abc31f1..a5187c1 100644 --- a/app/src/domain/runtime_state.h +++ b/app/src/domain/runtime_state.h @@ -75,7 +75,8 @@ enum class ModeTransitionError AlreadyInRequestedMode, // 已经处于目标模式 MustReturnToEditing, // 必须先返回编辑态 InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 - ProjectNotReady // 工程存在未配置或未完成的运行项 + ProjectNotReady, // 工程存在未配置或未完成的运行项 + SimulationStartFailed // 离线执行器预检或启动失败 }; // 模式切换操作结果 diff --git a/app/src/main.cpp b/app/src/main.cpp index 9817e00..1b2abbf 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -12,6 +12,7 @@ #include "services/hmi_editor_service.h" #include "services/hmi_runtime_service.h" #include "services/logic_editor_service.h" +#include "services/offline_simulation_service.h" #include "services/project_service.h" #include "services/runtime_mode_service.h" #include "ui/main_window.h" @@ -30,7 +31,9 @@ int main(int argc, char *argv[]) // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 VirtualRegisterRepository 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( runtime_mode_service, project_service, diff --git a/app/src/services/offline_simulation_service.cpp b/app/src/services/offline_simulation_service.cpp new file mode 100644 index 0000000..c40c76d --- /dev/null +++ b/app/src/services/offline_simulation_service.cpp @@ -0,0 +1,110 @@ +#include "offline_simulation_service.h" + +#include + +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 &logics) +{ + if (state_ == SimulationState::Running) + { + return {false, SimulationStartError::AlreadyRunning, {}}; + } + + std::vector 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(); +} diff --git a/app/src/services/offline_simulation_service.h b/app/src/services/offline_simulation_service.h new file mode 100644 index 0000000..8366564 --- /dev/null +++ b/app/src/services/offline_simulation_service.h @@ -0,0 +1,67 @@ +#pragma once + +#include "software_logic_executor.h" + +#include +#include + +#include +#include + +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 &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 logic_snapshot_; + SimulationState state_ = SimulationState::Stopped; + std::uint64_t successful_scan_count_ = 0; + LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}}; +}; diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index 32f7b54..af69ead 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -10,8 +10,11 @@ #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() { + if (state_.mode() == ApplicationMode::OfflineRunning) + { + // 先停止扫描再开放编辑,防止运行快照继续写寄存器 + offline_simulation_service_.stop(); + } return state_.enterEditing(); } ModeTransitionResult RuntimeModeService::enterOfflineRunning() { + if (state_.mode() != ApplicationMode::Editing) + { + return state_.enterOfflineRunning(); + } std::string error; if (!project_service_.project().validateForRunning(&error)) { 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() @@ -56,3 +79,23 @@ bool RuntimeModeService::initialPlcReadCompleted() const { 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_; +} diff --git a/app/src/services/runtime_mode_service.h b/app/src/services/runtime_mode_service.h index f1a02b0..1fa64e2 100644 --- a/app/src/services/runtime_mode_service.h +++ b/app/src/services/runtime_mode_service.h @@ -9,6 +9,9 @@ #pragma once #include "domain/runtime_state.h" +#include "offline_simulation_service.h" + +#include class ProjectService; @@ -16,7 +19,9 @@ class ProjectService; class RuntimeModeService { public: - explicit RuntimeModeService(const ProjectService &project_service); + RuntimeModeService( + const ProjectService &project_service, + OfflineSimulationService &offline_simulation_service); ApplicationMode mode() const; /** @@ -54,8 +59,14 @@ public: */ bool initialPlcReadCompleted() const; + SimulationState simulationState() const; + std::uint64_t successfulScanCount() const; + const LogicScanResult &simulationError() const; + OfflineSimulationService &offlineSimulationService(); + private: const ProjectService &project_service_; + OfflineSimulationService &offline_simulation_service_; RuntimeState state_; // 表示 PLC 缓存是否已通过至少一次有效读取建立 bool initial_plc_read_completed_ = false; diff --git a/app/src/services/software_logic_executor.cpp b/app/src/services/software_logic_executor.cpp new file mode 100644 index 0000000..ebd3209 --- /dev/null +++ b/app/src/services/software_logic_executor.cpp @@ -0,0 +1,295 @@ +#include "software_logic_executor.h" + +#include +#include + +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 &logics) const +{ + std::map 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(&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 &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; + if constexpr (std::is_same_v) + { + 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) + { + 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(&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(); +} diff --git a/app/src/services/software_logic_executor.h b/app/src/services/software_logic_executor.h new file mode 100644 index 0000000..e3bc484 --- /dev/null +++ b/app/src/services/software_logic_executor.h @@ -0,0 +1,46 @@ +#pragma once + +#include "domain/control_logic_model.h" +#include "domain/register_repository.h" + +#include +#include + +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 &logics) const; + LogicScanResult executeScan( + const std::vector &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; +}; diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index 6439758..f53f333 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -309,6 +309,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) runtime_active_ = active; if (!runtime_active_) { + runtime_write_enabled_ = false; for (QGraphicsItem *item : scene_->items()) { 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 渲染成画面上图形 void HmiEditorWidget::reloadPage() { @@ -474,7 +480,7 @@ void HmiEditorWidget::handleControlMoved( void HmiEditorWidget::handleControlActivated(const std::string &control_id) { - if (!runtime_active_) + if (!runtime_active_ || !runtime_write_enabled_) { return; } diff --git a/app/src/ui/hmi_editor_widget.h b/app/src/ui/hmi_editor_widget.h index e028e37..1ded0c1 100644 --- a/app/src/ui/hmi_editor_widget.h +++ b/app/src/ui/hmi_editor_widget.h @@ -52,6 +52,8 @@ public: * @param active 为 true 时允许按钮和数值输入调用运行时服务 */ void setRuntimeActive(bool active); + // 故障态保持运行值可见,但禁止按钮和数值输入继续写入 + void setRuntimeWriteEnabled(bool enabled); /** * @brief 使用当前页面模型完全重建场景 * @@ -115,4 +117,5 @@ private: bool editing_enabled_ = true; // 控制是否刷新运行值并响应运行态控件操作 bool runtime_active_ = false; + bool runtime_write_enabled_ = false; }; diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 7b51768..48c454a 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -78,6 +78,10 @@ QString transitionErrorText(ModeTransitionError error) { return MainWindow::tr("工程存在未配置或未完成的 HMI/梯形图节点,暂不能进入离线运行态"); } + case ModeTransitionError::SimulationStartFailed: + { + return MainWindow::tr("离线逻辑执行器预检或启动失败"); + } case ModeTransitionError::None: default: { @@ -367,8 +371,10 @@ void MainWindow::configureAppearance() mode_status_label_->setMinimumWidth(88); mode_status_label_->setAlignment(Qt::AlignCenter); register_status_label_ = new QLabel(this); + register_status_label_->setObjectName(QStringLiteral("registerStatusLabel")); register_status_label_->setMinimumWidth(132); executor_status_label_ = new QLabel(this); + executor_status_label_->setObjectName(QStringLiteral("executorStatusLabel")); executor_status_label_->setMinimumWidth(132); statusBar()->addPermanentWidget(mode_status_label_); statusBar()->addPermanentWidget(register_status_label_); @@ -426,9 +432,15 @@ void MainWindow::configureHmiEditor() if (runtime_mode_service_.mode() != ApplicationMode::Editing) { hmi_editor_widget_->refreshRuntimeValues(); + updateSimulationUi(false); } }); runtime_refresh_timer_->start(); + connect(&runtime_mode_service_.offlineSimulationService(), + &OfflineSimulationService::stateChanged, + this, + [this] { updateSimulationUi(true); }, + Qt::QueuedConnection); } void MainWindow::configureLogicEditor() @@ -1036,7 +1048,16 @@ void MainWindow::requestMode(ApplicationMode requested_mode) if (!result.succeeded) { 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; } updateModeUi(tr("已进入%1").arg(modeText(runtime_mode_service_.mode()))); @@ -1091,13 +1112,63 @@ void MainWindow::updateModeUi(const QString &message) register_status_label_->setText( policy.usesVirtualRegisters ? tr("数据源:虚拟 M/D") : policy.usesPlcRegisters ? tr("数据源:PLC 缓存") : tr("数据源:未启用")); - executor_status_label_->setText( - policy.runsLogicExecutor ? tr("逻辑执行器:运行") : tr("逻辑执行器:停止")); + updateSimulationUi(false); statusBar()->showMessage(message, 4000); ui_->outputList->addItem(message); 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() { diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index dc4084f..c5ac1f3 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -126,6 +126,8 @@ private: * @param message 要显示在状态栏和输出区的模式结果消息 */ void updateModeUi(const QString &message); + // 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 + void updateSimulationUi(bool report_fault); /** * @brief 将互斥模式动作同步到服务层当前状态 diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp index 7aca532..3433a25 100644 --- a/app/tests/main_window_tests.cpp +++ b/app/tests/main_window_tests.cpp @@ -3,6 +3,7 @@ #include "services/hmi_editor_service.h" #include "services/hmi_runtime_service.h" #include "services/logic_editor_service.h" +#include "services/offline_simulation_service.h" #include "services/project_service.h" #include "services/runtime_mode_service.h" #include "ui/hmi_editor_widget.h" @@ -62,7 +63,8 @@ void testModeActionsControlEditingAvailability() LogicEditorService logic_editor_service(project_service); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); - RuntimeModeService mode_service(project_service); + OfflineSimulationService simulation_service(repository); + RuntimeModeService mode_service(project_service, simulation_service); MainWindow window( mode_service, project_service, @@ -90,6 +92,7 @@ void testModeActionsControlEditingAvailability() QLineEdit *text_edit = requiredChild(window, "controlTextEdit"); HmiEditorWidget *hmi_editor = requiredChild( window, "hmiEditorWidget"); + QLabel *executor_status = requiredChild(window, "executorStatusLabel"); const qreal compact_scale = hmi_editor->transform().m11(); window.resize(1600, 900); @@ -153,6 +156,10 @@ void testModeActionsControlEditingAvailability() offline_action->trigger(); require(mode_service.mode() == ApplicationMode::OfflineRunning, "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(), "project dock must be disabled while running"); require(!properties_dock->isEnabled(), @@ -171,6 +178,10 @@ void testModeActionsControlEditingAvailability() editing_action->trigger(); require(mode_service.mode() == ApplicationMode::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(), "project dock must be restored after returning to editing"); require(properties_dock->isEnabled(), diff --git a/app/tests/main_window_tests.pro b/app/tests/main_window_tests.pro index da6884f..33f190b 100644 --- a/app/tests/main_window_tests.pro +++ b/app/tests/main_window_tests.pro @@ -23,6 +23,8 @@ SOURCES += \ ../src/services/hmi_editor_service.cpp \ ../src/services/logic_editor_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 HEADERS += \ @@ -40,6 +42,8 @@ HEADERS += \ ../src/services/hmi_editor_service.h \ ../src/services/logic_editor_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 FORMS += \ diff --git a/app/tests/offline_simulation_service_tests.cpp b/app/tests/offline_simulation_service_tests.cpp new file mode 100644 index 0000000..8418a0e --- /dev/null +++ b/app/tests/offline_simulation_service_tests.cpp @@ -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 + +#include +#include +#include +#include +#include + +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> &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 &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 operations{ + ComparisonOperator::Equal, ComparisonOperator::NotEqual, + ComparisonOperator::LessThan, ComparisonOperator::LessThanOrEqual, + ComparisonOperator::GreaterThan, ComparisonOperator::GreaterThanOrEqual}; + const std::array 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; +} diff --git a/app/tests/offline_simulation_service_tests.pro b/app/tests/offline_simulation_service_tests.pro new file mode 100644 index 0000000..5c5aca7 --- /dev/null +++ b/app/tests/offline_simulation_service_tests.pro @@ -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 diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index 2000d18..3be4689 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -1,6 +1,8 @@ #include "services/runtime_mode_service.h" +#include "services/offline_simulation_service.h" #include "services/project_service.h" #include "domain/project_storage.h" +#include "domain/register_repository.h" #include #include @@ -35,7 +37,9 @@ void testModeTransitions() // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 TestProjectStorage 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, "service must start in editing mode"); @@ -44,6 +48,8 @@ void testModeTransitions() require(service.enterOfflineRunning().succeeded, "editing mode must enter offline running"); + require(service.simulationState() == SimulationState::Running, + "offline mode must start the software executor"); require(service.policy().usesVirtualRegisters, "offline running must use virtual registers"); require(service.enterOnlineRunning().error @@ -52,6 +58,8 @@ void testModeTransitions() require(service.enterEditing().succeeded, "offline running must return to editing"); + require(service.simulationState() == SimulationState::Stopped, + "returning to editing must stop the software executor first"); require(service.enterOnlineRunning().error == ModeTransitionError::InitialPlcReadRequired, "online running must require an initial PLC read"); @@ -61,6 +69,8 @@ void testModeTransitions() "service must retain the initial PLC read state"); require(service.enterOnlineRunning().succeeded, "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, "online running must use PLC registers"); require(!service.policy().runsLogicExecutor, diff --git a/app/tests/runtime_mode_service_tests.pro b/app/tests/runtime_mode_service_tests.pro index e2d7b05..34de72a 100644 --- a/app/tests/runtime_mode_service_tests.pro +++ b/app/tests/runtime_mode_service_tests.pro @@ -11,19 +11,25 @@ INCLUDEPATH += ../src SOURCES += \ runtime_mode_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/domain/project_model.cpp \ ../src/domain/runtime_state.cpp \ ../src/services/project_service.cpp \ + ../src/services/software_logic_executor.cpp \ + ../src/services/offline_simulation_service.cpp \ ../src/services/runtime_mode_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/domain/project_model.h \ ../src/domain/project_storage.h \ ../src/domain/runtime_state.h \ ../src/services/project_service.h \ + ../src/services/software_logic_executor.h \ + ../src/services/offline_simulation_service.h \ ../src/services/runtime_mode_service.h diff --git a/docs/ai/handoff.md b/docs/ai/handoff.md index f84a08a..469a3da 100644 --- a/docs/ai/handoff.md +++ b/docs/ai/handoff.md @@ -2,7 +2,7 @@ - Goal: 完成综合平台编程器的工程管理和后续编辑功能。 - Branch: `main`。 -- Current status: 已完成开发顺序第 7 步,控制逻辑图形编辑、领域校验和工程持久化闭环已完成。 +- Current status: 已完成开发顺序第 8 步,HMI、虚拟 M/D 和固定周期软件逻辑执行器的离线闭环已完成。 - Changed files: - `app/integrated_platform.pro` - `app/src/main.cpp` @@ -47,6 +47,12 @@ - `app/tests/hmi_editor_service_tests.pro` - `app/tests/hmi_editor_service_tests.cpp` - `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/main_window_tests.pro` - `app/tests/main_window_tests.cpp` @@ -98,6 +104,10 @@ - `RuntimeModeService` 依赖 `ProjectService` 的只读工程,在进入离线运行态前执行完整性校验,拒绝未配置或未完成的工程。 - JSON 节点新增 `configured` 字段;缺失字段的旧版 `1.0` 工程按历史逻辑视为已配置,保持兼容读取。 - 梯形图 UI 已按 LAD 可读性优化:普通线圈采用标准 `( )` 形状,导线连接元件端子而不穿透符号,比较条件显示为带数据类型和上下操作数的指令块。 + - 软件逻辑执行器按逻辑、网络顺序扫描,级内 OR、级间 AND,前面网络写入对后面网络同周期可见。 + - 普通线圈每周期写结果,置位/复位线圈只在网络成立时写入;同一地址混用不同线圈模式会在启动前被拒绝。 + - 离线仿真以 50 ms 为目标扫描周期,启动时复制逻辑快照并清空虚拟 M/D,退出时先停止执行器再返回编辑态。 + - 仿真状态使用 `Stopped`、`Running`、`Faulted`,记录扫描次数和逻辑/网络/节点错误上下文;故障后 HMI 保持最终值可见但禁止写入。 - Validation run and results: - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 - 已启动 `integrated_platform.exe` 并正常退出。 @@ -123,6 +133,7 @@ - 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。 - 梯形图结构重构后,领域、逻辑编辑服务、工程管理和主窗口测试重新构建并全部通过。 - 待配置节点改造后,Release 应用构建成功;领域、工程管理、逻辑编辑服务、运行模式和主窗口测试全部通过。 -- Remaining work: 进入开发顺序第 8 步,实现离线软件逻辑执行器和 HMI 闭环。 + - 离线仿真实现后,Release 应用构建成功;离线仿真、领域、工程管理、HMI 编辑、逻辑编辑、运行模式和主窗口测试全部通过。 +- Remaining work: 进入开发顺序第 9 步,实现 Modbus RTU 真机通信。 - Known risks / blockers: 无。 -- Suggested next command: 为控制逻辑建立固定周期的软件执行器,按网络顺序执行并计算级间 AND 与级内 OR。 +- Suggested next command: 建立异步 Modbus RTU 通信服务,先完成串口配置、连接和 M0/D0 安全读写。 diff --git a/docs/architecture.md b/docs/architecture.md index b541d7a..42d27af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,6 +86,17 @@ main.cpp -> UI + Services + Infrastructure 数值显示读取 D 字,数值输入写入 D 字。它不依赖虚拟寄存器实现、串口或 Modbus,因此离线和真机 运行可替换数据源而无需改变 HMI 模型或画布代码。 +`services/SoftwareLogicExecutor` 负责一次确定性梯形图扫描。执行顺序为控制逻辑、网络、串联级, +同级并联条件执行 OR,不同串联级执行 AND;普通线圈每周期写入网络结果,置位和复位线圈只在 +网络成立时写入。前面网络的写入对后面网络在同一扫描周期立即可见。启动前拒绝同一 M 地址混用 +不同线圈模式,避免运行结果依赖隐藏顺序。 + +`services/OfflineSimulationService` 使用 `Qt::PreciseTimer` 以 50 ms 目标周期驱动扫描器。服务启动时 +先校验并复制控制逻辑快照,再清空虚拟 M/D 并开始扫描;退出离线态时先停止扫描再开放工程编辑。 +该周期不提供硬实时保证。服务记录 `Stopped`、`Running`、`Faulted` 实际状态、成功扫描次数和最近 +错误上下文。发生仓库读写或执行错误时停止定时器、保留最终寄存器值和逻辑/网络/节点定位;HMI +仍可读取最终值,但按钮和数值输入进入只读状态。 + 控制逻辑节点使用 `std::variant` 组合独立配置类型。初版只包含 M 区触点、M 区线圈和 D 值与 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。