| @@ -112,6 +112,40 @@ RegisterMonitorResult RegisterMonitorService::clear() | |||
| return {true, RegisterMonitorError::None, {}, removed_count}; | |||
| } | |||
| RegisterMonitorWriteResult RegisterMonitorService::writeBit( | |||
| const RegisterAddress &address, bool value) | |||
| { | |||
| const RegisterWriteResult result = repository_.writeBit(address, value); | |||
| if (result.succeeded) | |||
| { | |||
| return {true, RegisterError::None, {}}; | |||
| } | |||
| return {false, result.error, result.error == RegisterError::AreaMismatch | |||
| ? "位写入只允许访问 M 区" | |||
| : result.error == RegisterError::InvalidAddress | |||
| ? "写入地址无效" | |||
| : result.error == RegisterError::Unavailable | |||
| ? "当前寄存器源不可用" | |||
| : "寄存器写入请求被拒绝"}; | |||
| } | |||
| RegisterMonitorWriteResult RegisterMonitorService::writeWord( | |||
| const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| const RegisterWriteResult result = repository_.writeWord(address, value); | |||
| if (result.succeeded) | |||
| { | |||
| return {true, RegisterError::None, {}}; | |||
| } | |||
| return {false, result.error, result.error == RegisterError::AreaMismatch | |||
| ? "字写入只允许访问 D 区" | |||
| : result.error == RegisterError::InvalidAddress | |||
| ? "写入地址无效" | |||
| : result.error == RegisterError::Unavailable | |||
| ? "当前寄存器源不可用" | |||
| : "寄存器写入请求被拒绝"}; | |||
| } | |||
| const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | |||
| { | |||
| return model_.addresses(); | |||
| @@ -3,6 +3,7 @@ | |||
| #include "domain/register_monitor_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include <cstdint> | |||
| #include <functional> | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -24,6 +25,13 @@ struct RegisterMonitorResult | |||
| int affectedCount = 0; | |||
| }; | |||
| struct RegisterMonitorWriteResult | |||
| { | |||
| bool succeeded = false; | |||
| RegisterError error = RegisterError::Unavailable; | |||
| std::string message; | |||
| }; | |||
| class RegisterMonitorService | |||
| { | |||
| public: | |||
| @@ -32,6 +40,9 @@ public: | |||
| RegisterMonitorResult addRange(const std::string &start_address, int count); | |||
| RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); | |||
| RegisterMonitorResult clear(); | |||
| RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value); | |||
| RegisterMonitorWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value); | |||
| const std::vector<RegisterAddress> &addresses() const; | |||
| std::vector<MonitorValue> values(bool communication_fault) const; | |||
| void setAddressesChangedCallback(std::function<void()> callback); | |||
| @@ -4,10 +4,15 @@ | |||
| #include "toolbar_icon_factory.h" | |||
| #include "ui_free_monitor_widget.h" | |||
| #include <QComboBox> | |||
| #include <QHeaderView> | |||
| #include <QIntValidator> | |||
| #include <QLineEdit> | |||
| #include <QMessageBox> | |||
| #include <QPushButton> | |||
| #include <QTableWidgetItem> | |||
| #include <cstdint> | |||
| #include <set> | |||
| namespace { | |||
| @@ -38,6 +43,8 @@ FreeMonitorWidget::FreeMonitorWidget( | |||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); | |||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch); | |||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); | |||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents); | |||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch); | |||
| ui_->monitorTable->verticalHeader()->setVisible(false); | |||
| connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); | |||
| connect(ui_->addressEdit, &QLineEdit::returnPressed, | |||
| @@ -51,6 +58,12 @@ FreeMonitorWidget::FreeMonitorWidget( | |||
| FreeMonitorWidget::~FreeMonitorWidget() = default; | |||
| void FreeMonitorWidget::setWriteEnabled(bool enabled) | |||
| { | |||
| write_enabled_ = enabled; | |||
| updateWriteControls(); | |||
| } | |||
| void FreeMonitorWidget::refreshValues( | |||
| ApplicationMode mode, PlcConnectionState plc_state) | |||
| { | |||
| @@ -75,7 +88,7 @@ void FreeMonitorWidget::refreshValues( | |||
| for (int row = 0; row < ui_->monitorTable->rowCount(); ++row) | |||
| { | |||
| QTableWidgetItem *value_item = ui_->monitorTable->item(row, 2); | |||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 3); | |||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); | |||
| if (!values_available || static_cast<std::size_t>(row) >= values.size()) | |||
| { | |||
| value_item->setText(QStringLiteral("--")); | |||
| @@ -97,6 +110,7 @@ void FreeMonitorWidget::refreshValues( | |||
| state_item->setText(value.state == MonitorValueState::CommunicationFault | |||
| ? tr("通信故障,最后有效值") : tr("有效")); | |||
| } | |||
| updateWriteControls(); | |||
| } | |||
| void FreeMonitorWidget::reloadAddresses() | |||
| @@ -112,7 +126,97 @@ void FreeMonitorWidget::reloadAddresses() | |||
| row, 1, new QTableWidgetItem(address.area() == RegisterArea::M | |||
| ? tr("位") : tr("16 位整数"))); | |||
| ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--"))); | |||
| ui_->monitorTable->setItem(row, 3, new QTableWidgetItem(tr("未运行"))); | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| auto *target = new QComboBox(ui_->monitorTable); | |||
| target->addItem(tr("OFF"), false); | |||
| target->addItem(tr("ON"), true); | |||
| ui_->monitorTable->setCellWidget(row, 3, target); | |||
| } | |||
| else | |||
| { | |||
| auto *target = new QLineEdit(ui_->monitorTable); | |||
| target->setValidator(new QIntValidator(-32768, 32767, target)); | |||
| target->setText(QStringLiteral("0")); | |||
| target->setAlignment(Qt::AlignRight | Qt::AlignVCenter); | |||
| ui_->monitorTable->setCellWidget(row, 3, target); | |||
| } | |||
| auto *write_button = new QPushButton(tr("写入"), ui_->monitorTable); | |||
| write_button->setProperty("monitorRow", row); | |||
| connect(write_button, &QPushButton::clicked, this, | |||
| [this, write_button] | |||
| { | |||
| writeRow(write_button->property("monitorRow").toInt()); | |||
| }); | |||
| ui_->monitorTable->setCellWidget(row, 4, write_button); | |||
| ui_->monitorTable->setItem(row, 5, new QTableWidgetItem(tr("未运行"))); | |||
| } | |||
| updateWriteControls(); | |||
| } | |||
| void FreeMonitorWidget::writeRow(int row) | |||
| { | |||
| if (!write_enabled_ || row < 0 | |||
| || static_cast<std::size_t>(row) >= service_.addresses().size()) | |||
| { | |||
| return; | |||
| } | |||
| const RegisterAddress address = service_.addresses().at(static_cast<std::size_t>(row)); | |||
| RegisterMonitorWriteResult result; | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| auto *target = qobject_cast<QComboBox *>(ui_->monitorTable->cellWidget(row, 3)); | |||
| if (target == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| result = service_.writeBit(address, target->currentData().toBool()); | |||
| } | |||
| else | |||
| { | |||
| auto *target = qobject_cast<QLineEdit *>(ui_->monitorTable->cellWidget(row, 3)); | |||
| if (target == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| bool converted = false; | |||
| const int value = target->text().toInt(&converted); | |||
| if (!converted || value < -32768 || value > 32767) | |||
| { | |||
| ui_->monitorTable->item(row, 5)->setText(tr("目标值无效")); | |||
| emit operationMessage(tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); | |||
| return; | |||
| } | |||
| result = service_.writeWord(address, static_cast<std::int16_t>(value)); | |||
| } | |||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); | |||
| if (result.succeeded) | |||
| { | |||
| state_item->setText(tr("写入请求已发送")); | |||
| emit operationMessage(tr("%1 写入请求已发送,等待当前值刷新") | |||
| .arg(addressText(address))); | |||
| return; | |||
| } | |||
| state_item->setText(tr("写入失败")); | |||
| handleResult(tr("写入 %1").arg(addressText(address)), false, result.message); | |||
| } | |||
| void FreeMonitorWidget::updateWriteControls() | |||
| { | |||
| for (int row = 0; row < ui_->monitorTable->rowCount(); ++row) | |||
| { | |||
| QWidget *target = ui_->monitorTable->cellWidget(row, 3); | |||
| if (target != nullptr) | |||
| { | |||
| target->setEnabled(write_enabled_); | |||
| } | |||
| QWidget *write_button = ui_->monitorTable->cellWidget(row, 4); | |||
| if (write_button != nullptr) | |||
| { | |||
| write_button->setEnabled(write_enabled_); | |||
| } | |||
| } | |||
| } | |||
| @@ -23,6 +23,7 @@ public: | |||
| QWidget *parent = nullptr); | |||
| ~FreeMonitorWidget() override; | |||
| void setWriteEnabled(bool enabled); | |||
| void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | |||
| void reloadAddresses(); | |||
| @@ -34,8 +35,11 @@ private: | |||
| void addAddresses(); | |||
| void removeSelectedAddresses(); | |||
| void clearAddresses(); | |||
| void writeRow(int row); | |||
| void updateWriteControls(); | |||
| void handleResult(const QString &action, bool succeeded, const std::string &message); | |||
| std::unique_ptr<Ui::FreeMonitorWidget> ui_; | |||
| RegisterMonitorService &service_; | |||
| bool write_enabled_ = false; | |||
| }; | |||
| @@ -3,7 +3,7 @@ | |||
| <class>FreeMonitorWidget</class> | |||
| <widget class="QWidget" name="FreeMonitorWidget"> | |||
| <property name="minimumSize"> | |||
| <size><width>420</width><height>190</height></size> | |||
| <size><width>620</width><height>190</height></size> | |||
| </property> | |||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||
| <property name="spacing"><number>6</number></property> | |||
| @@ -31,12 +31,14 @@ | |||
| <property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property> | |||
| <property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property> | |||
| <property name="showGrid"><bool>true</bool></property> | |||
| <property name="columnCount"><number>4</number></property> | |||
| <property name="columnCount"><number>6</number></property> | |||
| <property name="rowCount"><number>0</number></property> | |||
| <column><property name="text"><string>地址</string></property></column> | |||
| <column><property name="text"><string>类型</string></property></column> | |||
| <column><property name="text"><string>当前值</string></property></column> | |||
| <column><property name="text"><string>状态</string></property></column> | |||
| <column><property name="text"><string>地址</string></property></column> | |||
| <column><property name="text"><string>类型</string></property></column> | |||
| <column><property name="text"><string>当前值</string></property></column> | |||
| <column><property name="text"><string>目标值</string></property></column> | |||
| <column><property name="text"><string>写入</string></property></column> | |||
| <column><property name="text"><string>状态</string></property></column> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| @@ -127,8 +127,7 @@ void RuntimeMonitorWidget::setMode( | |||
| const bool online = mode == ApplicationMode::OnlineRunning; | |||
| ui_->logicFrame->setVisible(offline); | |||
| hmi_view_->setRuntimeActive(offline || online); | |||
| hmi_view_->setRuntimeWriteEnabled( | |||
| offline || (online && plc_state == PlcConnectionState::Connected)); | |||
| setHmiWriteEnabled(offline || (online && plc_state == PlcConnectionState::Connected)); | |||
| if (!offline) | |||
| { | |||
| logic_view_->clearRuntimeTrace(); | |||
| @@ -145,6 +144,7 @@ void RuntimeMonitorWidget::setMode( | |||
| void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled) | |||
| { | |||
| hmi_view_->setRuntimeWriteEnabled(enabled); | |||
| free_monitor_widget_->setWriteEnabled(enabled); | |||
| } | |||
| void RuntimeMonitorWidget::refreshValues( | |||
| @@ -1339,6 +1339,49 @@ void testRepeatedPlcStatusNotificationsAreCoalesced() | |||
| "repeated PLC status callbacks must append one coalesced output message"); | |||
| } | |||
| void testFreeMonitorWritesSingleValues() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| RegisterMonitorService monitor_service(repository); | |||
| FreeMonitorWidget widget(monitor_service); | |||
| widget.show(); | |||
| QApplication::processEvents(); | |||
| QLineEdit *address_edit = widget.findChild<QLineEdit *>("addressEdit"); | |||
| QPushButton *add_button = widget.findChild<QPushButton *>("addButton"); | |||
| QTableWidget *table = widget.findChild<QTableWidget *>("monitorTable"); | |||
| require(address_edit != nullptr && add_button != nullptr && table != nullptr, | |||
| "free monitor UI test requires the address, add and table controls"); | |||
| address_edit->setText(QStringLiteral("M0")); | |||
| add_button->click(); | |||
| require(table->rowCount() == 1, "free monitor UI must add an M row before writing"); | |||
| auto *bit_target = qobject_cast<QComboBox *>(table->cellWidget(0, 3)); | |||
| auto *bit_write = qobject_cast<QPushButton *>(table->cellWidget(0, 4)); | |||
| require(bit_target != nullptr && bit_write != nullptr && !bit_write->isEnabled(), | |||
| "free monitor writes must be disabled outside a running mode"); | |||
| widget.setWriteEnabled(true); | |||
| bit_target->setCurrentIndex(bit_target->findData(true)); | |||
| bit_write->click(); | |||
| require(repository.readBit({RegisterArea::M, 0}).succeeded | |||
| && repository.readBit({RegisterArea::M, 0}).value, | |||
| "free monitor UI must write the selected M value"); | |||
| address_edit->setText(QStringLiteral("D0")); | |||
| add_button->click(); | |||
| require(table->rowCount() == 2, "free monitor UI must add a D row before writing"); | |||
| auto *word_target = qobject_cast<QLineEdit *>(table->cellWidget(1, 3)); | |||
| auto *word_write = qobject_cast<QPushButton *>(table->cellWidget(1, 4)); | |||
| require(word_target != nullptr && word_write != nullptr && word_write->isEnabled(), | |||
| "D monitor rows must expose an enabled write action when permitted"); | |||
| word_target->setText(QStringLiteral("-321")); | |||
| word_write->click(); | |||
| require(repository.readWord({RegisterArea::D, 0}).succeeded | |||
| && repository.readWord({RegisterArea::D, 0}).value == -321, | |||
| "free monitor UI must write signed D values"); | |||
| } | |||
| void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -1393,6 +1436,7 @@ void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() | |||
| "online running must not show a misleading local ladder runtime trace"); | |||
| QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit"); | |||
| QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton"); | |||
| QTableWidget *monitor_table = requiredChild<QTableWidget>(window, "monitorTable"); | |||
| monitor_address->setText(QStringLiteral("D9")); | |||
| monitor_add->click(); | |||
| require(std::find( | |||
| @@ -1400,6 +1444,9 @@ void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() | |||
| gateway.poll_addresses.end(), | |||
| RegisterAddress{RegisterArea::D, 9}) != gateway.poll_addresses.end(), | |||
| "online free monitor addresses must join the active PLC poll set immediately"); | |||
| auto *monitor_write = qobject_cast<QPushButton *>(monitor_table->cellWidget(0, 4)); | |||
| require(monitor_write != nullptr && monitor_write->isEnabled(), | |||
| "online Connected mode must enable free monitor writes"); | |||
| QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction"); | |||
| QAction *disconnect_action = requiredChild<QAction>(window, "disconnectPlcAction"); | |||
| @@ -1410,6 +1457,8 @@ void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() | |||
| require(mode_service.mode() == ApplicationMode::Editing, | |||
| "a PLC timeout must return online running to editing"); | |||
| require(!monitor_write->isEnabled(), | |||
| "a PLC communication fault must disable free monitor writes"); | |||
| require(disconnect_action->isEnabled(), | |||
| "a timeout must keep disconnect available while the local port remains open"); | |||
| require(configure_action->isEnabled() | |||
| @@ -1599,6 +1648,7 @@ int main(int argc, char *argv[]) | |||
| testEdgeTimerPropertyEditingAndParallelMenu(); | |||
| testPlcConfigurationUsesDialog(); | |||
| testRepeatedPlcStatusNotificationsAreCoalesced(); | |||
| testFreeMonitorWritesSingleValues(); | |||
| testOnlineWorkspaceShowsHmiAndFreeMonitorOnly(); | |||
| testMultiPageAndLogicMainWindowIntegration(); | |||
| } | |||
| @@ -76,6 +76,35 @@ void testSharedActiveRepositoryValues() | |||
| "valid cached values must be marked stale during a communication fault"); | |||
| } | |||
| void testRegisterWrites() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| RegisterMonitorService service(repository); | |||
| const RegisterAddress m0{RegisterArea::M, 0}; | |||
| const RegisterAddress d0{RegisterArea::D, 0}; | |||
| const RegisterMonitorWriteResult bit_write = service.writeBit(m0, true); | |||
| require(bit_write.succeeded && repository.readBit(m0).value, | |||
| "monitor service must write M values through the active repository"); | |||
| const RegisterMonitorWriteResult word_write = service.writeWord(d0, -123); | |||
| require(word_write.succeeded && repository.readWord(d0).value == -123, | |||
| "monitor service must write signed D values through the active repository"); | |||
| const RegisterMonitorWriteResult wrong_bit = service.writeBit(d0, true); | |||
| require(!wrong_bit.succeeded && wrong_bit.error == RegisterError::AreaMismatch, | |||
| "monitor bit writes must reject D addresses"); | |||
| const RegisterMonitorWriteResult wrong_word = service.writeWord(m0, 123); | |||
| require(!wrong_word.succeeded && wrong_word.error == RegisterError::AreaMismatch, | |||
| "monitor word writes must reject M addresses"); | |||
| require(service.writeWord(d0, static_cast<std::int16_t>(-32768)).succeeded | |||
| && repository.readWord(d0).value == -32768 | |||
| && service.writeWord(d0, static_cast<std::int16_t>(32767)).succeeded | |||
| && repository.readWord(d0).value == 32767, | |||
| "monitor word writes must preserve signed 16-bit boundary values"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| @@ -84,6 +113,7 @@ int main() | |||
| { | |||
| testRangeManagement(); | |||
| testSharedActiveRepositoryValues(); | |||
| testRegisterWrites(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||