From aeed15a800f61a741d9931de431ca1dbbd6e9362 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:18:57 +0700 Subject: [PATCH 01/28] P6C: expose dynamic DataSet and authored report APIs --- apps/ied_simulator/src/MmsReportController.hpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/ied_simulator/src/MmsReportController.hpp b/apps/ied_simulator/src/MmsReportController.hpp index 45390338c..a7a03cfe9 100644 --- a/apps/ied_simulator/src/MmsReportController.hpp +++ b/apps/ied_simulator/src/MmsReportController.hpp @@ -31,6 +31,7 @@ class MmsReportController : public QObject { Q_PROPERTY(QVariantList reportControls READ reportControls NOTIFY inventoryChanged) Q_PROPERTY(QVariantList staticCandidates READ staticCandidates NOTIFY inventoryChanged) Q_PROPERTY(QVariantList dynamicCandidates READ dynamicCandidates NOTIFY inventoryChanged) + Q_PROPERTY(QStringList ownedDynamicDataSets READ ownedDynamicDataSets NOTIFY inventoryChanged) Q_PROPERTY(int selectedRcbIndex READ selectedRcbIndex NOTIFY selectionChanged) Q_PROPERTY(int selectedDataSetIndex READ selectedDataSetIndex NOTIFY selectionChanged) Q_PROPERTY(QVariantMap selectedRcb READ selectedRcb NOTIFY selectionChanged) @@ -62,6 +63,7 @@ class MmsReportController : public QObject { [[nodiscard]] QVariantList reportControls() const { return reportControls_; } [[nodiscard]] QVariantList staticCandidates() const { return staticCandidates_; } [[nodiscard]] QVariantList dynamicCandidates() const { return dynamicCandidates_; } + [[nodiscard]] QStringList ownedDynamicDataSets() const { return ownedDynamicDataSets_; } [[nodiscard]] int selectedRcbIndex() const noexcept { return selectedRcbIndex_; } [[nodiscard]] int selectedDataSetIndex() const noexcept { return selectedDataSetIndex_; } [[nodiscard]] QVariantMap selectedRcb() const { return selectedRcb_; } @@ -77,6 +79,15 @@ class MmsReportController : public QObject { Q_INVOKABLE bool selectRcb(int row); Q_INVOKABLE bool selectDataSet(int row); Q_INVOKABLE bool enableSelected(bool requestGeneralInterrogation = true); + Q_INVOKABLE bool enableSelectedAuthored( + const QString& dataSetReference, + const QStringList& triggerOptions, + const QStringList& optionalFields, + bool requestGeneralInterrogation = true); + Q_INVOKABLE bool createDynamicDataSet( + const QString& dataSetReference, + const QVariantList& canonicalMembers); + Q_INVOKABLE bool deleteDynamicDataSet(const QString& dataSetReference); Q_INVOKABLE bool disableSelected(); Q_INVOKABLE bool retryCleanup(); Q_INVOKABLE QString diagnosticsText() const; @@ -96,6 +107,7 @@ class MmsReportController : public QObject { connecting, discovering, ready, + authoring, enabling, active, disabling, @@ -125,6 +137,7 @@ class MmsReportController : public QObject { QVariantList reportControls_; QVariantList staticCandidates_; QVariantList dynamicCandidates_; + QStringList ownedDynamicDataSets_; int selectedRcbIndex_{-1}; int selectedDataSetIndex_{-1}; QVariantMap selectedRcb_; From 3dc4e9bd3b1fb7c7cf9ab9686714ee43f4447d74 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:19:31 +0700 Subject: [PATCH 02/28] P6C: add authoring helpers and runtime ownership state --- .../ied_simulator/src/MmsReportController.cpp | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 62dbfa325..ad9b10b10 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -2,8 +2,10 @@ #include "MmsReportController.hpp" #include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/dynamic_data_set.hpp" #include "ariec61850/mms/live_discovery.hpp" #include "ariec61850/mms/rcb_selection.hpp" +#include "ariec61850/mms/report_subscription_runtime.hpp" #include "ariec61850/mms/static_report_session.hpp" #include @@ -11,6 +13,8 @@ #include #include +#include +#include #include #include #include @@ -62,6 +66,71 @@ bool sameDataSetReference(const QString& left, const QString& right) { } } +std::vector triggerPayload(const QStringList& names) { + static const std::array, 5> bits{{ + {u"data-change", 0x40U}, + {u"quality-change", 0x20U}, + {u"data-update", 0x10U}, + {u"integrity", 0x08U}, + {u"general-interrogation", 0x04U}, + }}; + std::set unique; + std::uint8_t value{}; + for (const auto& raw : names) { + const auto name = raw.trimmed(); + if (name.isEmpty() || !unique.insert(name).second) continue; + const auto it = std::find_if(bits.begin(), bits.end(), [&name](const auto& item) { + return item.first == name; + }); + if (it == bits.end()) { + throw std::invalid_argument( + "Unknown IEC 61850 TrgOps name: " + name.toStdString()); + } + value = static_cast(value | it->second); + } + return {value}; +} + +std::vector optionalPayload(const QStringList& names) { + struct Bit final { + QStringView name; + int byte; + std::uint8_t mask; + }; + static const std::array bits{{ + {u"sequence-number", 0, 0x40U}, + {u"report-time-stamp", 0, 0x20U}, + {u"reason-for-inclusion", 0, 0x10U}, + {u"data-set-name", 0, 0x08U}, + {u"data-reference", 0, 0x04U}, + {u"buffer-overflow", 0, 0x02U}, + {u"entry-id", 0, 0x01U}, + {u"configuration-revision", 1, 0x80U}, + {u"segmentation", 1, 0x40U}, + }}; + std::set unique; + std::vector value(2U, 0U); + for (const auto& raw : names) { + const auto name = raw.trimmed(); + if (name.isEmpty() || !unique.insert(name).second) continue; + const auto it = std::find_if(bits.begin(), bits.end(), [&name](const auto& item) { + return item.name == name; + }); + if (it == bits.end()) { + throw std::invalid_argument( + "Unknown IEC 61850 OptFlds name: " + name.toStdString()); + } + value[static_cast(it->byte)] = + static_cast( + value[static_cast(it->byte)] | it->mask); + } + return value; +} + +QString bitNamesText(const QStringList& names) { + return names.isEmpty() ? QStringLiteral("none") : names.join(QStringLiteral(", ")); +} + QVariantMap candidateMap(const mms::MmsRcbCandidateEvaluation& candidate) { QVariantMap map; map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); @@ -380,6 +449,8 @@ struct MmsReportController::WorkerState final { std::unique_ptr session; std::shared_ptr discovery; std::unique_ptr report; + std::unique_ptr authoredReport; + std::unique_ptr dynamicDataSets; }; MmsReportController::MmsReportController(QObject* parent) @@ -445,6 +516,7 @@ QString MmsReportController::stateText() const { case State::connecting: return QStringLiteral("Connecting"); case State::discovering: return QStringLiteral("Attaching reports"); case State::ready: return QStringLiteral("Ready"); + case State::authoring: return QStringLiteral("Authoring DataSet"); case State::enabling: return QStringLiteral("Enabling RCB"); case State::active: return QStringLiteral("Report subscription active"); case State::disabling: return QStringLiteral("Releasing RCB"); @@ -476,6 +548,7 @@ void MmsReportController::clearInventory() { reportControls_.clear(); staticCandidates_.clear(); dynamicCandidates_.clear(); + ownedDynamicDataSets_.clear(); associationProfile_.clear(); selectedRcbIndex_ = -1; selectedDataSetIndex_ = -1; From 5b082b3d5bc1b4faed44e56d7df118b9c0acbd7c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:19:53 +0700 Subject: [PATCH 03/28] P6C: normalize static and authored report UI snapshots --- .../ied_simulator/src/MmsReportController.cpp | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index ad9b10b10..20fad09f7 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -403,11 +403,11 @@ QString reportValueText(const mms::MmsReportValue& item) { .arg(reference, value, reasons.isEmpty() ? QString{} : QStringLiteral(" · ") + reasons); } -ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { +ReportUi buildSubscriptionUi( + const mms::MmsReportSubscriptionSnapshot& subscription, + const bool active) { ReportUi ui; - ui.active = snapshot.active; - if (!snapshot.subscription) return ui; - const auto& subscription = *snapshot.subscription; + ui.active = active; ui.receivedCount = subscription.received_reports; ui.cleanupRequired = subscription.cleanup_required; for (const auto& event : subscription.events) { @@ -415,8 +415,10 @@ ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { } while (ui.events.size() > 256) ui.events.removeFirst(); - for (auto streamIt = subscription.streams.rbegin(); streamIt != subscription.streams.rend(); ++streamIt) { - for (auto frameIt = streamIt->recent_frames.rbegin(); frameIt != streamIt->recent_frames.rend(); ++frameIt) { + for (auto streamIt = subscription.streams.rbegin(); + streamIt != subscription.streams.rend(); ++streamIt) { + for (auto frameIt = streamIt->recent_frames.rbegin(); + frameIt != streamIt->recent_frames.rend(); ++frameIt) { if (ui.reports.size() >= kMaximumVisibleReports) break; const auto& frame = *frameIt; QVariantMap map; @@ -443,6 +445,22 @@ ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { } return ui; } + +ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { + if (!snapshot.subscription) { + ReportUi ui; + ui.active = snapshot.active; + return ui; + } + return buildSubscriptionUi(*snapshot.subscription, snapshot.active); +} + +ReportUi buildReportUi(const mms::MmsReportSubscriptionSnapshot& snapshot) { + return buildSubscriptionUi( + snapshot, + snapshot.state == mms::MmsReportSubscriptionState::active); +} + } // namespace struct MmsReportController::WorkerState final { From 05bfe2f5315837c642cfff040157ac5e98125d85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:20:19 +0700 Subject: [PATCH 04/28] P6C: own dynamic DataSet runtime on report association --- apps/ied_simulator/src/MmsReportController.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 20fad09f7..a786eca99 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -642,7 +642,10 @@ bool MmsReportController::connectToIed() { ioPool_.start([self, worker, stop, generation, requestedHost, requestedPort, contextModel] { try { if (worker->report) worker->report->stop(); + if (worker->authoredReport) worker->authoredReport->stop(); worker->report.reset(); + worker->authoredReport.reset(); + worker->dynamicDataSets.reset(); if (worker->session) worker->session->disconnect(); worker->session.reset(); worker->discovery.reset(); @@ -684,6 +687,11 @@ bool MmsReportController::connectToIed() { const auto profile = QString::fromStdString( session->association().active_association_profile()); const auto ui = buildDiscoveryUi(*discovery, profile); + mms::MmsDynamicDataSetOptions dynamicOptions; + dynamicOptions.maximum_members = 64U; + dynamicOptions.verify_after_create = true; + worker->dynamicDataSets = std::make_unique( + session->association(), dynamicOptions); worker->discovery = discovery; worker->session = std::move(session); @@ -710,6 +718,8 @@ bool MmsReportController::connectToIed() { } catch (const std::exception& exception) { if (worker->session) worker->session->disconnect(); worker->report.reset(); + worker->authoredReport.reset(); + worker->dynamicDataSets.reset(); worker->session.reset(); worker->discovery.reset(); if (self) { From ce6f5455bbb322a22120e3f187e4e1cc35422939 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:20:56 +0700 Subject: [PATCH 05/28] P6C: classify static versus runtime-owned DataSets --- .../ied_simulator/src/MmsReportController.cpp | 993 ++++++++++++++++++ 1 file changed, 993 insertions(+) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index a786eca99..36ee0e0f2 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -131,6 +131,997 @@ QString bitNamesText(const QStringList& names) { return names.isEmpty() ? QStringLiteral("none") : names.join(QStringLiteral(", ")); } +mms::MmsDataSetCandidate dataSetCandidateFromReference(const std::string& reference) { + const auto objectName = mms::MmsDataSetDirectoryCodec::parse_data_set_reference(reference); + if (objectName.kind != mms::MmsObjectNameKind::domain_specific || + objectName.domain.empty() || objectName.item.empty()) { + throw std::invalid_argument("Dynamic DataSet requires a domain-specific reference."); + } + const auto separator = objectName.item.find(' +QVariantMap candidateMap(const mms::MmsRcbCandidateEvaluation& candidate) { + QVariantMap map; + map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); + map.insert(QStringLiteral("mode"), QString::fromStdString(candidate.mode)); + map.insert(QStringLiteral("dataSet"), QString::fromStdString(candidate.data_set_reference)); + map.insert(QStringLiteral("reportId"), QString::fromStdString(candidate.report_id)); + map.insert(QStringLiteral("score"), candidate.score); + map.insert(QStringLiteral("availability"), QString::fromUtf8(mms::mms_rcb_availability_kind_name(candidate.availability).data())); + map.insert(QStringLiteral("decision"), QString::fromUtf8(mms::mms_rcb_selection_decision_name(candidate.decision).data())); + map.insert(QStringLiteral("reason"), QString::fromStdString(candidate.reason)); + map.insert(QStringLiteral("recommendedAction"), QString::fromStdString(candidate.recommended_action)); + map.insert(QStringLiteral("preferred"), candidate.preferred); + map.insert(QStringLiteral("sameDataSet"), candidate.same_data_set); + map.insert(QStringLiteral("sameLogicalDevice"), candidate.same_logical_device); + map.insert(QStringLiteral("selectable"), candidate.selectable()); + return map; +} + +struct DiscoveryUi final { + QVariantList dataSets; + QVariantList reportControls; + QVariantList staticCandidates; + QVariantList dynamicCandidates; + QString associationProfile; +}; + +DiscoveryUi buildDiscoveryUi( + const mms::MmsLiveDiscoveryResult& discovery, + const QString& associationProfile) { + DiscoveryUi ui; + ui.associationProfile = associationProfile; + + for (const auto& candidate : discovery.report_inventory.data_sets) { + QVariantMap map; + map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); + map.insert(QStringLiteral("domain"), QString::fromStdString(candidate.domain)); + map.insert(QStringLiteral("logicalNode"), QString::fromStdString(candidate.logical_node)); + map.insert(QStringLiteral("name"), QString::fromStdString(candidate.name)); + QStringList members; + bool directoryAvailable = false; + bool deletable = false; + for (const auto& evidence : discovery.data_set_directories) { + if (evidence.candidate.reference != candidate.reference || !evidence.directory) continue; + directoryAvailable = true; + deletable = evidence.directory->deletable; + for (const auto& member : evidence.directory->members) { + QString text = QString::fromStdString(member.user_reference); + if (!member.functional_constraint.empty()) { + text += QStringLiteral(" [") + QString::fromStdString(member.functional_constraint) + QLatin1Char(']'); + } + members.push_back(text); + } + break; + } + map.insert(QStringLiteral("directoryAvailable"), directoryAvailable); + map.insert(QStringLiteral("deletable"), deletable); + map.insert(QStringLiteral("dynamicOwned"), false); + map.insert(QStringLiteral("immutable"), true); + map.insert(QStringLiteral("memberCount"), members.size()); + map.insert(QStringLiteral("members"), members); + ui.dataSets.push_back(map); + } + + for (const auto& candidate : discovery.report_inventory.report_controls) { + QVariantMap map; + map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); + map.insert(QStringLiteral("mode"), QString::fromStdString(candidate.mode())); + map.insert(QStringLiteral("buffered"), candidate.buffered); + map.insert(QStringLiteral("domain"), QString::fromStdString(candidate.domain)); + map.insert(QStringLiteral("logicalNode"), QString::fromStdString(candidate.logical_node)); + map.insert(QStringLiteral("name"), QString::fromStdString(candidate.name)); + QStringList attributes; + for (const auto& item : candidate.attributes) attributes.push_back(QString::fromStdString(item)); + map.insert(QStringLiteral("attributes"), attributes); + map.insert(QStringLiteral("supportsEntryId"), attributes.contains(QStringLiteral("EntryID"))); + map.insert(QStringLiteral("supportsPurgeBuf"), attributes.contains(QStringLiteral("PurgeBuf"))); + + for (const auto& evidence : discovery.report_controls) { + if (evidence.candidate.reference != candidate.reference) continue; + map.insert(QStringLiteral("probeOk"), evidence.success()); + map.insert(QStringLiteral("probeError"), QString::fromStdString(evidence.error)); + if (evidence.state) { + const auto& state = *evidence.state; + map.insert(QStringLiteral("dataSet"), QString::fromStdString(state.data_set_reference)); + map.insert(QStringLiteral("reportId"), QString::fromStdString(state.report_id)); + map.insert(QStringLiteral("confRev"), numberText(state.configuration_revision)); + map.insert(QStringLiteral("bufTm"), numberText(state.buffer_time_ms)); + map.insert(QStringLiteral("intgPd"), numberText(state.integrity_period_ms)); + map.insert(QStringLiteral("sqNum"), numberText(state.sequence_number)); + map.insert(QStringLiteral("enabled"), boolText(state.report_enabled)); + map.insert(QStringLiteral("reserved"), boolText(state.reserved)); + map.insert(QStringLiteral("resvTms"), numberText(state.reservation_time_seconds)); + map.insert(QStringLiteral("owner"), hexBytes(state.owner)); + map.insert(QStringLiteral("entryId"), hexBytes(state.entry_id)); + map.insert(QStringLiteral("triggerOptions"), QString::fromStdString( + [&state] { + std::string result; + for (const auto& name : state.trigger_options.names) { + if (!result.empty()) result += ", "; + result += name; + } + return result; + }())); + map.insert(QStringLiteral("optionalFields"), QString::fromStdString( + [&state] { + std::string result; + for (const auto& name : state.optional_fields.names) { + if (!result.empty()) result += ", "; + result += name; + } + return result; + }())); + map.insert(QStringLiteral("availability"), availabilityText(state.availability)); + map.insert(QStringLiteral("availabilityReason"), QString::fromStdString(state.availability_reason)); + } + break; + } + if (!map.contains(QStringLiteral("probeOk"))) map.insert(QStringLiteral("probeOk"), false); + ui.reportControls.push_back(map); + } + + try { + const auto staticSelection = mms::MmsRcbPoolSelector::build_static_selection(discovery); + for (const auto& candidate : staticSelection.candidates) ui.staticCandidates.push_back(candidateMap(candidate)); + } catch (...) { + } + try { + const auto dynamicSelection = mms::MmsRcbPoolSelector::build_dynamic_selection(discovery); + for (const auto& candidate : dynamicSelection.candidates) ui.dynamicCandidates.push_back(candidateMap(candidate)); + } catch (...) { + } + return ui; +} + + +mms::MmsObjectName contextObjectName( + const std::string& reference, + const std::string& fallbackDomain, + const std::string& fallbackItem) { + const auto slash = reference.find('/'); + if (slash != std::string::npos && slash > 0U && slash + 1U < reference.size()) { + return mms::MmsObjectName::domain_specific( + reference.substr(0U, slash), reference.substr(slash + 1U)); + } + return mms::MmsObjectName::domain_specific(fallbackDomain, fallbackItem); +} + +std::span confirmedPayload( + const mms::MmsConfirmedExchangeResult& exchange) { + return exchange.presentation_payload.empty() + ? std::span{exchange.envelope.mms_payload} + : std::span{exchange.presentation_payload}; +} + +mms::MmsLiveDiscoveryResult buildContextDiscoverySeed( + const mms::MmsLiveModelDocument& model) { + mms::MmsLiveDiscoveryResult result; + result.endpoint = model.endpoint; + result.association_profile = "CanonicalEngineeringContext"; + + for (const auto& dataSet : model.data_sets) { + mms::MmsDataSetCandidate candidate; + candidate.domain = dataSet.domain; + candidate.logical_node = dataSet.logical_node; + candidate.name = dataSet.name; + candidate.reference = dataSet.reference; + candidate.raw_mms_name = dataSet.logical_node + + (dataSet.name.empty() ? std::string{} : "$" + dataSet.name); + result.report_inventory.data_sets.push_back(candidate); + + mms::MmsDataSetDirectoryEvidence evidence; + evidence.candidate = candidate; + mms::MmsDataSetDirectoryResponse directory; + directory.deletable = dataSet.deletable.value_or(false); + directory.members.reserve(dataSet.members.size()); + for (const auto& member : dataSet.members) { + mms::MmsDataSetDirectoryMember projected; + projected.object_name = contextObjectName( + member.mms_reference, dataSet.domain, member.reference); + projected.mms_reference = member.mms_reference.empty() + ? projected.object_name.reference() + : member.mms_reference; + projected.user_reference = member.reference.empty() + ? projected.mms_reference + : member.reference; + projected.functional_constraint = member.functional_constraint; + projected.logical_node = dataSet.logical_node; + projected.confidence = 100U; + directory.members.push_back(std::move(projected)); + } + evidence.directory = std::move(directory); + result.data_set_directories.push_back(std::move(evidence)); + } + + static const std::vector reportAttributes{ + "DatSet", "RptID", "ConfRev", "IntgPd", "BufTm", "SqNum", + "RptEna", "Resv", "ResvTms", "Owner", "EntryID", "TimeOfEntry", + "TrgOps", "OptFlds"}; + + for (const auto& control : model.report_controls) { + mms::MmsReportControlCandidate candidate; + candidate.domain = control.domain; + candidate.logical_node = control.logical_node; + candidate.functional_constraint = control.buffered ? "BR" : "RP"; + candidate.name = control.name; + candidate.reference = control.reference; + candidate.buffered = control.buffered; + candidate.attributes = reportAttributes; + result.report_inventory.report_controls.push_back(std::move(candidate)); + } + return result; +} + +void probeContextReportControls( + mms::MmsAssociationRuntime& association, + mms::MmsLiveDiscoveryResult& discovery, + const std::stop_token stopToken) { + if (discovery.report_inventory.report_controls.size() > 1'024U) { + throw std::runtime_error( + "Canonical report inventory exceeds the desktop bound of 1024 RCBs."); + } + discovery.report_controls.clear(); + discovery.report_controls.reserve(discovery.report_inventory.report_controls.size()); + for (const auto& candidate : discovery.report_inventory.report_controls) { + if (stopToken.stop_requested()) { + throw std::runtime_error("Canonical report service attach cancelled."); + } + mms::MmsReportControlEvidence evidence; + evidence.candidate = candidate; + evidence.requested_attributes = candidate.attributes; + try { + const auto invokeId = association.next_invoke_id(); + const auto request = mms::MmsReportControlStateMapper::build_read_request( + invokeId, candidate, evidence.requested_attributes); + const auto encoded = mms::MmsServiceCodec::encode_read_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed( + encoded, invokeId, stopToken); + const auto response = mms::MmsServiceCodec::decode_read_response( + confirmedPayload(exchange), invokeId); + evidence.state = mms::MmsReportControlStateMapper::map_read_response( + candidate, evidence.requested_attributes, response); + } catch (const std::exception& exception) { + evidence.error = exception.what(); + discovery.diagnostics.push_back( + candidate.reference + ": targeted RCB attach read failed: " + evidence.error); + } + discovery.report_controls.push_back(std::move(evidence)); + } +} + +struct ReportUi final { + QVariantList reports; + QStringList events; + qulonglong receivedCount{}; + bool active{}; + bool cleanupRequired{}; +}; + +QString reportValueText(const mms::MmsReportValue& item) { + QString value = QStringLiteral(""); + if (item.value) value = QString::fromStdString(mms::MmsDataCodec::to_display_string(*item.value)); + QString reference = QString::fromStdString(item.data_reference); + if (reference.isEmpty() && item.member) reference = QString::fromStdString(item.member->user_reference); + if (reference.isEmpty()) reference = QStringLiteral("member[%1]").arg(static_cast(item.data_set_index)); + QString reasons; + for (const auto& reason : item.reason_for_inclusion.names) { + if (!reasons.isEmpty()) reasons += QStringLiteral(", "); + reasons += QString::fromStdString(reason); + } + return QStringLiteral("%1 = %2%3") + .arg(reference, value, reasons.isEmpty() ? QString{} : QStringLiteral(" · ") + reasons); +} + +ReportUi buildSubscriptionUi( + const mms::MmsReportSubscriptionSnapshot& subscription, + const bool active) { + ReportUi ui; + ui.active = active; + ui.receivedCount = subscription.received_reports; + ui.cleanupRequired = subscription.cleanup_required; + for (const auto& event : subscription.events) { + ui.events.push_back(QString::fromStdString(event.message)); + } + while (ui.events.size() > 256) ui.events.removeFirst(); + + for (auto streamIt = subscription.streams.rbegin(); + streamIt != subscription.streams.rend(); ++streamIt) { + for (auto frameIt = streamIt->recent_frames.rbegin(); + frameIt != streamIt->recent_frames.rend(); ++frameIt) { + if (ui.reports.size() >= kMaximumVisibleReports) break; + const auto& frame = *frameIt; + QVariantMap map; + map.insert(QStringLiteral("reportId"), QString::fromStdString(frame.header.report_id)); + map.insert(QStringLiteral("dataSet"), QString::fromStdString(frame.header.data_set_reference)); + map.insert(QStringLiteral("sequence"), numberText(frame.header.sequence_number)); + map.insert(QStringLiteral("subSequence"), numberText(frame.header.sub_sequence_number)); + map.insert(QStringLiteral("confRev"), numberText(frame.header.configuration_revision)); + map.insert(QStringLiteral("entryId"), hexBytes(frame.header.entry_id)); + map.insert(QStringLiteral("overflow"), frame.header.buffer_overflow.value_or(false)); + map.insert(QStringLiteral("decoderMode"), QString::fromStdString(frame.decoder_mode)); + map.insert(QStringLiteral("valueCount"), static_cast(frame.values.size())); + QStringList values; + for (const auto& value : frame.values) values.push_back(reportValueText(value)); + map.insert(QStringLiteral("values"), values); + map.insert(QStringLiteral("valuesSummary"), values.mid(0, 3).join(QStringLiteral(" | "))); + map.insert(QStringLiteral("duplicateCount"), static_cast(streamIt->state.duplicate_count)); + map.insert(QStringLiteral("gapCount"), static_cast(streamIt->state.gap_count)); + map.insert(QStringLiteral("resetCount"), static_cast(streamIt->state.reset_count)); + map.insert(QStringLiteral("overflowCount"), static_cast(streamIt->state.buffer_overflow_count)); + ui.reports.push_back(map); + } + if (ui.reports.size() >= kMaximumVisibleReports) break; + } + return ui; +} + +ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { + if (!snapshot.subscription) { + ReportUi ui; + ui.active = snapshot.active; + return ui; + } + return buildSubscriptionUi(*snapshot.subscription, snapshot.active); +} + +ReportUi buildReportUi(const mms::MmsReportSubscriptionSnapshot& snapshot) { + return buildSubscriptionUi( + snapshot, + snapshot.state == mms::MmsReportSubscriptionState::active); +} + +} // namespace + +struct MmsReportController::WorkerState final { + std::unique_ptr session; + std::shared_ptr discovery; + std::unique_ptr report; + std::unique_ptr authoredReport; + std::unique_ptr dynamicDataSets; +}; + +MmsReportController::MmsReportController(QObject* parent) + : QObject(parent), workerState_(std::make_shared()), + stopSource_(std::make_shared()) { + ioPool_.setMaxThreadCount(1); + ioPool_.setExpiryTimeout(-1); + pollTimer_.setInterval(100); + pollTimer_.setTimerType(Qt::PreciseTimer); + connect(&pollTimer_, &QTimer::timeout, this, &MmsReportController::schedulePoll); +} + +MmsReportController::~MmsReportController() { + if (stopSource_) stopSource_->request_stop(); + pollTimer_.stop(); + ioPool_.clear(); + ioPool_.waitForDone(); +} + +void MmsReportController::setHost(const QString& value) { + const auto normalized = value.trimmed(); + if (host_ == normalized) return; + host_ = normalized; + emit configurationChanged(); +} + +void MmsReportController::setPort(const int value) { + if (value < 1 || value > 65'535 || port_ == value) return; + port_ = value; + emit configurationChanged(); +} + +void MmsReportController::setEngineeringContext(IedEngineeringContextController* value) { + if (engineeringContext_ == value) return; + if (engineeringContext_) disconnect(engineeringContext_, nullptr, this, nullptr); + engineeringContext_ = value; + if (engineeringContext_) { + connect( + engineeringContext_, + &IedEngineeringContextController::contextChanged, + this, + [this] { + if (!connected() && !busy()) adoptEngineeringInventory(); + }); + } + emit engineeringContextChanged(); + if (!connected() && !busy()) adoptEngineeringInventory(); +} + +bool MmsReportController::connected() const noexcept { + return state_ == State::ready || state_ == State::enabling || state_ == State::active || + state_ == State::disabling || state_ == State::cleanup_required; +} + +bool MmsReportController::busy() const noexcept { + return operationBusy_ || state_ == State::connecting || state_ == State::discovering || + state_ == State::enabling || state_ == State::disabling; +} + +QString MmsReportController::stateText() const { + switch (state_) { + case State::disconnected: return QStringLiteral("Disconnected"); + case State::connecting: return QStringLiteral("Connecting"); + case State::discovering: return QStringLiteral("Attaching reports"); + case State::ready: return QStringLiteral("Ready"); + case State::authoring: return QStringLiteral("Authoring DataSet"); + case State::enabling: return QStringLiteral("Enabling RCB"); + case State::active: return QStringLiteral("Report subscription active"); + case State::disabling: return QStringLiteral("Releasing RCB"); + case State::cleanup_required: return QStringLiteral("Cleanup required"); + case State::faulted: return QStringLiteral("Faulted"); + } + return {}; +} + +void MmsReportController::appendDiagnostic(const QString& text) { + if (text.isEmpty()) return; + while (diagnostics_.size() >= 128) diagnostics_.removeFirst(); + diagnostics_.push_back(text); + emit diagnosticsChanged(); +} + +QString MmsReportController::diagnosticsText() const { + return diagnostics_.join(QLatin1Char('\n')); +} + +std::shared_ptr MmsReportController::replaceStopSource() { + if (stopSource_) stopSource_->request_stop(); + stopSource_ = std::make_shared(); + return stopSource_; +} + +void MmsReportController::clearInventory() { + dataSets_.clear(); + reportControls_.clear(); + staticCandidates_.clear(); + dynamicCandidates_.clear(); + ownedDynamicDataSets_.clear(); + associationProfile_.clear(); + selectedRcbIndex_ = -1; + selectedDataSetIndex_ = -1; + selectedRcb_.clear(); + selectedDataSetMembers_.clear(); + receivedReports_.clear(); + events_.clear(); + receivedReportCount_ = 0; + emit inventoryChanged(); + emit selectionChanged(); + emit reportsChanged(); +} + +void MmsReportController::adoptEngineeringInventory() { + if (!engineeringContext_ || !engineeringContext_->loaded() || + engineeringContext_->selectionRequired() || + engineeringContext_->modelSnapshot() == nullptr) { + clearInventory(); + return; + } + + const auto seed = buildContextDiscoverySeed(*engineeringContext_->modelSnapshot()); + const auto ui = buildDiscoveryUi(seed, QString{}); + dataSets_ = ui.dataSets; + reportControls_ = ui.reportControls; + staticCandidates_.clear(); + dynamicCandidates_.clear(); + associationProfile_.clear(); + selectedRcbIndex_ = reportControls_.isEmpty() ? -1 : 0; + selectedDataSetIndex_ = dataSets_.isEmpty() ? -1 : 0; + receivedReports_.clear(); + events_.clear(); + receivedReportCount_ = 0; + refreshSelection(); + emit inventoryChanged(); + emit reportsChanged(); +} + +bool MmsReportController::connectToIed() { + if (busy() || connected()) return false; + if (host_.trimmed().isEmpty() || port_ < 1 || port_ > 65'535) { + lastError_ = QStringLiteral("A non-empty host and TCP port 1..65535 are required."); + state_ = State::faulted; + emit stateChanged(); + return false; + } + + const auto stop = replaceStopSource(); + const auto generation = ++generation_; + const auto requestedHost = host_.trimmed(); + const auto requestedPort = port_; + state_ = State::connecting; + operationBusy_ = true; + active_ = false; + cleanupRequired_ = false; + pollPending_ = false; + lastError_.clear(); + clearInventory(); + if (engineeringContext_) adoptEngineeringInventory(); + std::shared_ptr contextModel; + if (engineeringContext_ && engineeringContext_->loaded() && + !engineeringContext_->selectionRequired() && + engineeringContext_->modelSnapshot() != nullptr) { + contextModel = std::make_shared( + *engineeringContext_->modelSnapshot()); + } + appendDiagnostic(QStringLiteral("Reports connect %1:%2 · generation %3") + .arg(requestedHost).arg(requestedPort).arg(generation)); + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, stop, generation, requestedHost, requestedPort, contextModel] { + try { + if (worker->report) worker->report->stop(); + if (worker->authoredReport) worker->authoredReport->stop(); + worker->report.reset(); + worker->authoredReport.reset(); + worker->dynamicDataSets.reset(); + if (worker->session) worker->session->disconnect(); + worker->session.reset(); + worker->discovery.reset(); + + mms::MmsAssociationOptions associationOptions; + associationOptions.connect_timeout = std::chrono::milliseconds{5'000}; + associationOptions.request_timeout = std::chrono::milliseconds{5'000}; + auto session = std::make_unique( + mms::TcpMmsTransportOptions{}, associationOptions); + session->connect( + {requestedHost.toStdString(), static_cast(requestedPort)}, + stop->get_token()); + + if (self) { + QMetaObject::invokeMethod(self, [self, generation] { + if (!self || self->generation_ != generation) return; + self->state_ = State::discovering; + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + + std::shared_ptr discovery; + if (contextModel) { + discovery = std::make_shared( + buildContextDiscoverySeed(*contextModel)); + probeContextReportControls( + session->association(), *discovery, stop->get_token()); + } else { + // Standalone Reports workspace compatibility path. Browser-owned + // services always receive engineeringContext and therefore skip + // this full discovery path. + mms::MmsLiveDiscoveryOptions options; + options.maximum_variable_type_probes = 8'192U; + options.maximum_data_set_directories = 4'096U; + options.maximum_report_control_probes = 1'024U; + discovery = std::make_shared( + session->discover(options, stop->get_token())); + } + const auto profile = QString::fromStdString( + session->association().active_association_profile()); + const auto ui = buildDiscoveryUi(*discovery, profile); + mms::MmsDynamicDataSetOptions dynamicOptions; + dynamicOptions.maximum_members = 64U; + dynamicOptions.verify_after_create = true; + worker->dynamicDataSets = std::make_unique( + session->association(), dynamicOptions); + worker->discovery = discovery; + worker->session = std::move(session); + + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui] { + if (!self || self->generation_ != generation) return; + self->dataSets_ = ui.dataSets; + self->reportControls_ = ui.reportControls; + self->staticCandidates_ = ui.staticCandidates; + self->dynamicCandidates_ = ui.dynamicCandidates; + self->associationProfile_ = ui.associationProfile; + self->state_ = State::ready; + self->operationBusy_ = false; + self->lastError_.clear(); + if (!self->reportControls_.isEmpty()) self->selectedRcbIndex_ = 0; + else if (!self->dataSets_.isEmpty()) self->selectedDataSetIndex_ = 0; + self->refreshSelection(); + self->appendDiagnostic(QStringLiteral("Report service attach complete · %1 DataSet · %2 RCB") + .arg(self->dataSets_.size()).arg(self->reportControls_.size())); + emit self->inventoryChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (worker->session) worker->session->disconnect(); + worker->report.reset(); + worker->authoredReport.reset(); + worker->dynamicDataSets.reset(); + worker->session.reset(); + worker->discovery.reset(); + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->state_ = State::faulted; + self->operationBusy_ = false; + self->lastError_ = message; + self->appendDiagnostic(QStringLiteral("Report service attach failed · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +void MmsReportController::disconnectFromIed() { + if (stopSource_) stopSource_->request_stop(); + stopSource_ = std::make_shared(); + const auto cleanupStop = stopSource_; + const auto generation = ++generation_; + pollTimer_.stop(); + pollPending_ = false; + operationBusy_ = false; + active_ = false; + cleanupRequired_ = false; + state_ = State::disconnected; + lastError_.clear(); + clearInventory(); + adoptEngineeringInventory(); + appendDiagnostic(QStringLiteral("Reports disconnected · runtime association released; canonical inventory retained.")); + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, cleanupStop, generation] { + bool cleanupRequired = false; + if (worker->report) { + worker->report->stop(cleanupStop->get_token()); + const auto snapshot = worker->report->snapshot(); + cleanupRequired = snapshot.subscription && snapshot.subscription->cleanup_required; + } + if (worker->session) worker->session->disconnect(cleanupStop->get_token()); + worker->report.reset(); + worker->discovery.reset(); + worker->session.reset(); + if (cleanupRequired && self) { + QMetaObject::invokeMethod(self, [self, generation] { + if (!self || self->generation_ != generation) return; + self->lastError_ = QStringLiteral("RCB cleanup could not be confirmed before disconnect; verify the remote RCB before reuse."); + self->appendDiagnostic(self->lastError_); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + }); +} + +bool MmsReportController::reconnect() { + disconnectFromIed(); + return connectToIed(); +} + +void MmsReportController::refreshSelection() { + selectedRcb_.clear(); + selectedDataSetMembers_.clear(); + if (selectedRcbIndex_ >= 0 && selectedRcbIndex_ < reportControls_.size()) { + selectedRcb_ = reportControls_.at(selectedRcbIndex_).toMap(); + const auto bound = selectedRcb_.value(QStringLiteral("dataSet")).toString(); + if (!bound.isEmpty()) { + for (int row = 0; row < dataSets_.size(); ++row) { + const auto map = dataSets_.at(row).toMap(); + if (!sameDataSetReference(bound, map.value(QStringLiteral("reference")).toString())) continue; + selectedDataSetIndex_ = row; + selectedDataSetMembers_ = map.value(QStringLiteral("members")).toStringList(); + break; + } + } + } + if (selectedDataSetMembers_.isEmpty() && selectedDataSetIndex_ >= 0 && selectedDataSetIndex_ < dataSets_.size()) { + selectedDataSetMembers_ = dataSets_.at(selectedDataSetIndex_).toMap().value(QStringLiteral("members")).toStringList(); + } + emit selectionChanged(); +} + +bool MmsReportController::selectRcb(const int row) { + if (row < 0 || row >= reportControls_.size()) return false; + selectedRcbIndex_ = row; + refreshSelection(); + return true; +} + +bool MmsReportController::selectDataSet(const int row) { + if (row < 0 || row >= dataSets_.size()) return false; + selectedDataSetIndex_ = row; + selectedDataSetMembers_ = dataSets_.at(row).toMap().value(QStringLiteral("members")).toStringList(); + emit selectionChanged(); + return true; +} + +bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) { + if (!connected() || busy() || active_ || cleanupRequired_ || selectedRcb_.isEmpty()) return false; + const auto reference = selectedRcb_.value(QStringLiteral("reference")).toString(); + if (reference.isEmpty()) return false; + + const auto generation = generation_; + const auto stop = stopSource_; + operationBusy_ = true; + state_ = State::enabling; + lastError_.clear(); + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, stop, generation, reference, requestGeneralInterrogation] { + try { + if (!worker->session || !worker->session->associated() || !worker->discovery) { + throw std::runtime_error("MMS report association is not active."); + } + if (worker->report) { + const auto previous = worker->report->snapshot(); + if (previous.active || (previous.subscription && previous.subscription->cleanup_required)) { + throw std::runtime_error("Previous report subscription still owns state or requires cleanup."); + } + worker->report.reset(); + } + + mms::MmsStaticReportSessionOptions options; + options.selection.preferred_rcb_reference = reference.toStdString(); + options.selection.strict_rcb = true; + // Strict selection still pins the exact operator-selected RCB. This + // flag permits an explicitly selected URCB; it does not authorize + // fallback to another RCB because strict_rcb remains true. + options.selection.allow_urcb_fallback = true; + options.selection.allow_polling_fallback = false; + options.maximum_candidate_attempts = 1U; + options.subscription.trigger_general_interrogation = requestGeneralInterrogation; + options.subscription.reserve_unbuffered_rcb = true; + options.subscription.maximum_events = 256U; + options.subscription.monitor_options.maximum_streams = 64U; + options.subscription.monitor_options.maximum_frames_per_stream = 64U; + + auto report = std::make_unique( + worker->session->association(), *worker->discovery, options); + report->prepare(stop->get_token()); + report->start(stop->get_token()); + const auto ui = buildReportUi(report->snapshot()); + worker->report = std::move(report); + + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui, requestGeneralInterrogation] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = ui.active; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + self->state_ = ui.cleanupRequired ? State::cleanup_required : State::active; + self->appendDiagnostic(requestGeneralInterrogation + ? QStringLiteral("RCB enabled; GI requested explicitly.") + : QStringLiteral("RCB enabled without GI.")); + emit self->reportsChanged(); + emit self->stateChanged(); + if (self->active_) self->pollTimer_.start(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = false; + self->lastError_ = message; + self->state_ = State::faulted; + self->appendDiagnostic(QStringLiteral("RCB enable failed · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsReportController::disableSelected() { + if (!active_ || operationBusy_) return false; + pollTimer_.stop(); + const auto stop = replaceStopSource(); + const auto generation = generation_; + operationBusy_ = true; + state_ = State::disabling; + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, stop, generation] { + try { + if (!worker->report) throw std::runtime_error("No report subscription is active."); + worker->report->stop(stop->get_token()); + const auto snapshot = worker->report->snapshot(); + const auto ui = buildReportUi(snapshot); + if (!ui.cleanupRequired) worker->report.reset(); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + self->operationBusy_ = false; + self->active_ = false; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + self->state_ = ui.cleanupRequired ? State::cleanup_required : State::ready; + self->appendDiagnostic(ui.cleanupRequired + ? QStringLiteral("RCB release incomplete; explicit cleanup retry required.") + : QStringLiteral("RCB disabled and reservation/ownership cleanup completed.")); + emit self->reportsChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + self->operationBusy_ = false; + self->active_ = false; + self->cleanupRequired_ = true; + self->state_ = State::cleanup_required; + self->lastError_ = message; + self->appendDiagnostic(QStringLiteral("RCB release failed · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsReportController::retryCleanup() { + if (!cleanupRequired_ || operationBusy_) return false; + const auto stop = replaceStopSource(); + const auto generation = generation_; + operationBusy_ = true; + state_ = State::disabling; + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, stop, generation] { + try { + if (!worker->session || !worker->session->associated() || !worker->report) { + throw std::runtime_error("Cleanup retry requires the original active MMS association."); + } + worker->report->stop(stop->get_token()); + const auto ui = buildReportUi(worker->report->snapshot()); + if (!ui.cleanupRequired) worker->report.reset(); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = false; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + self->state_ = ui.cleanupRequired ? State::cleanup_required : State::ready; + self->lastError_.clear(); + self->appendDiagnostic(ui.cleanupRequired + ? QStringLiteral("RCB cleanup retry remains incomplete.") + : QStringLiteral("RCB cleanup retry completed.")); + emit self->reportsChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->lastError_ = message; + self->state_ = State::cleanup_required; + self->appendDiagnostic(QStringLiteral("Cleanup retry rejected · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +void MmsReportController::schedulePoll() { + if (!active_ || operationBusy_ || pollPending_) return; + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + pollPending_ = true; + const QPointer self{this}; + ioPool_.start([self, worker, stop, generation] { + try { + if (!worker->session || !worker->session->associated() || !worker->report || !worker->report->active()) { + throw std::runtime_error("Report poll requires an active MMS association and RCB subscription."); + } + mms::MmsPduEnvelope envelope; + static_cast(worker->session->association().try_poll_once_for( + std::chrono::milliseconds{40}, envelope, stop->get_token())); + static_cast(worker->report->drain_queued_reports()); + const auto ui = buildReportUi(worker->report->snapshot()); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + self->active_ = ui.active; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + emit self->reportsChanged(); + }, Qt::QueuedConnection); + } + } catch (const mms::MmsTransportCancelledError&) { + if (self) { + QMetaObject::invokeMethod(self, [self, generation] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + ReportUi ui; + if (worker->report) { + worker->report->stop(); + ui = buildReportUi(worker->report->snapshot()); + } + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message, ui] { + if (!self || self->generation_ != generation) return; + self->pollTimer_.stop(); + self->pollPending_ = false; + self->active_ = false; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + self->lastError_ = message; + self->state_ = ui.cleanupRequired ? State::cleanup_required : State::faulted; + self->appendDiagnostic(QStringLiteral("Report association/poll failed · %1").arg(message)); + emit self->reportsChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); +} +); + if (separator == std::string::npos || separator == 0U || + separator + 1U >= objectName.item.size()) { + throw std::invalid_argument( + "Dynamic DataSet reference must resolve to LD/LN.DataSetName."); + } + mms::MmsDataSetCandidate candidate; + candidate.domain = objectName.domain; + candidate.logical_node = objectName.item.substr(0U, separator); + candidate.name = objectName.item.substr(separator + 1U); + candidate.reference = mms::MmsDataSetDirectoryCodec::to_iec_reference(objectName); + candidate.raw_mms_name = objectName.item; + return candidate; +} + +QVariantList markOwnedDataSets( + QVariantList dataSets, + const QStringList& ownedReferences) { + for (int row = 0; row < dataSets.size(); ++row) { + auto map = dataSets.at(row).toMap(); + const auto reference = map.value(QStringLiteral("reference")).toString(); + bool owned = false; + for (const auto& candidate : ownedReferences) { + if (sameDataSetReference(reference, candidate)) { + owned = true; + break; + } + } + map.insert(QStringLiteral("dynamicOwned"), owned); + map.insert(QStringLiteral("immutable"), !owned); + dataSets[row] = map; + } + return dataSets; +} + QVariantMap candidateMap(const mms::MmsRcbCandidateEvaluation& candidate) { QVariantMap map; map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); @@ -187,6 +1178,8 @@ DiscoveryUi buildDiscoveryUi( } map.insert(QStringLiteral("directoryAvailable"), directoryAvailable); map.insert(QStringLiteral("deletable"), deletable); + map.insert(QStringLiteral("dynamicOwned"), false); + map.insert(QStringLiteral("immutable"), true); map.insert(QStringLiteral("memberCount"), members.size()); map.insert(QStringLiteral("members"), members); ui.dataSets.push_back(map); From c5831d6fd829cd41aafddee0104b806a506fa2cd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:21:48 +0700 Subject: [PATCH 06/28] P6C: create and delete verified owned dynamic DataSets --- .../ied_simulator/src/MmsReportController.cpp | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 36ee0e0f2..0ebf26ad9 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -830,6 +830,362 @@ bool MmsReportController::selectDataSet(const int row) { return true; } +bool MmsReportController::createDynamicDataSet( + const QString& dataSetReference, + const QVariantList& canonicalMembers) { + if (!connected() || busy() || active_ || cleanupRequired_) return false; + if (!engineeringContext_ || !engineeringContext_->loaded() || + engineeringContext_->selectionRequired()) { + lastError_ = QStringLiteral( + "Dynamic DataSet authoring requires one active canonical engineering context."); + emit stateChanged(); + return false; + } + const auto requestedReference = dataSetReference.trimmed(); + if (requestedReference.isEmpty()) { + lastError_ = QStringLiteral("Dynamic DataSet reference is required."); + emit stateChanged(); + return false; + } + if (canonicalMembers.isEmpty() || canonicalMembers.size() > 64) { + lastError_ = QStringLiteral( + "Dynamic DataSet requires 1..64 canonical DataAttribute members."); + emit stateChanged(); + return false; + } + + std::vector members; + QStringList canonicalReferences; + QStringList functionalConstraints; + std::set> seen; + members.reserve(static_cast(canonicalMembers.size())); + try { + for (const auto& value : canonicalMembers) { + const auto supplied = value.toMap(); + const auto reference = supplied.value(QStringLiteral("reference")).toString().trimmed(); + if (reference.isEmpty()) { + throw std::invalid_argument( + "Dynamic DataSet member has no canonical DataAttribute reference."); + } + const auto canonical = + engineeringContext_->treeModel()->nodeForReference(reference); + if (canonical.value(QStringLiteral("kind")).toString() != QStringLiteral("DA") || + !canonical.value(QStringLiteral("readable")).toBool()) { + throw std::invalid_argument( + "Dynamic DataSet members must resolve to readable canonical DataAttributes."); + } + const auto domain = + canonical.value(QStringLiteral("mmsDomain")).toString().trimmed(); + const auto item = + canonical.value(QStringLiteral("mmsItem")).toString().trimmed(); + if (domain.isEmpty() || item.isEmpty()) { + throw std::invalid_argument( + "Canonical DataAttribute has no exact MMS domain/item identity."); + } + const auto suppliedDomain = + supplied.value(QStringLiteral("mmsDomain")).toString().trimmed(); + const auto suppliedItem = + supplied.value(QStringLiteral("mmsItem")).toString().trimmed(); + if ((!suppliedDomain.isEmpty() && suppliedDomain != domain) || + (!suppliedItem.isEmpty() && suppliedItem != item)) { + throw std::invalid_argument( + "Dynamic DataSet member MMS identity differs from the canonical engineering context."); + } + const auto key = (domain + QLatin1Char('/') + item).toStdString(); + if (!seen.insert(key).second) { + throw std::invalid_argument( + "Dynamic DataSet contains a duplicate MMS member."); + } + members.push_back(mms::MmsObjectName::domain_specific( + domain.toStdString(), item.toStdString())); + canonicalReferences.push_back(reference); + functionalConstraints.push_back( + canonical.value(QStringLiteral("functionalConstraint")).toString()); + } + } catch (const std::exception& exception) { + lastError_ = QString::fromUtf8(exception.what()); + emit stateChanged(); + return false; + } + + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + const QPointer self{this}; + operationBusy_ = true; + state_ = State::authoring; + lastError_.clear(); + appendDiagnostic( + QStringLiteral("Define dynamic DataSet requested · %1 · %2 member(s)") + .arg(requestedReference) + .arg(members.size())); + emit stateChanged(); + + ioPool_.start([ + self, worker, stop, generation, requestedReference, + members = std::move(members), canonicalReferences, + functionalConstraints + ]() mutable { + try { + if (!worker->session || !worker->session->associated() || + !worker->discovery || !worker->dynamicDataSets) { + throw std::runtime_error( + "Dynamic DataSet create requires the active Browser report association."); + } + const auto created = worker->dynamicDataSets->create( + requestedReference.toStdString(), members, stop->get_token()); + if (!created.verified_directory.has_value()) { + throw std::runtime_error( + "Dynamic DataSet create returned no verified directory."); + } + + auto candidate = dataSetCandidateFromReference(created.data_set_reference); + mms::MmsDataSetDirectoryEvidence evidence; + evidence.candidate = candidate; + evidence.directory = *created.verified_directory; + for (std::size_t index = 0U; + index < evidence.directory->members.size() && + index < static_cast(canonicalReferences.size()); + ++index) { + auto& member = evidence.directory->members[index]; + member.user_reference = + canonicalReferences.at(static_cast(index)).toStdString(); + member.functional_constraint = + functionalConstraints.at(static_cast(index)).toStdString(); + member.logical_node = candidate.logical_node; + } + + auto& inventory = worker->discovery->report_inventory.data_sets; + inventory.erase( + std::remove_if( + inventory.begin(), inventory.end(), + [&candidate](const auto& item) { + return item.reference == candidate.reference; + }), + inventory.end()); + inventory.push_back(candidate); + + auto& directories = worker->discovery->data_set_directories; + directories.erase( + std::remove_if( + directories.begin(), directories.end(), + [&candidate](const auto& item) { + return item.candidate.reference == candidate.reference; + }), + directories.end()); + directories.push_back(std::move(evidence)); + + QStringList owned; + for (const auto& reference : + worker->dynamicDataSets->owned_data_sets()) { + owned.push_back(QString::fromStdString(reference)); + } + const auto profile = QString::fromStdString( + worker->session->association().active_association_profile()); + auto ui = buildDiscoveryUi(*worker->discovery, profile); + ui.dataSets = markOwnedDataSets(ui.dataSets, owned); + const auto normalized = + QString::fromStdString(created.data_set_reference); + + if (self) { + QMetaObject::invokeMethod( + self, + [self, generation, ui, owned, normalized] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->state_ = State::ready; + self->lastError_.clear(); + self->dataSets_ = ui.dataSets; + self->reportControls_ = ui.reportControls; + self->staticCandidates_ = ui.staticCandidates; + self->dynamicCandidates_ = ui.dynamicCandidates; + self->associationProfile_ = ui.associationProfile; + self->ownedDynamicDataSets_ = owned; + for (int row = 0; row < self->dataSets_.size(); ++row) { + if (sameDataSetReference( + self->dataSets_.at(row).toMap() + .value(QStringLiteral("reference")) + .toString(), + normalized)) { + self->selectedDataSetIndex_ = row; + break; + } + } + self->refreshSelection(); + self->appendDiagnostic( + QStringLiteral( + "Dynamic DataSet created + verified · %1") + .arg(normalized)); + emit self->inventoryChanged(); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod( + self, + [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->state_ = State::ready; + self->lastError_ = message; + self->appendDiagnostic( + QStringLiteral("Dynamic DataSet create failed · %1") + .arg(message)); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsReportController::deleteDynamicDataSet( + const QString& dataSetReference) { + if (!connected() || busy() || active_ || cleanupRequired_) return false; + const auto reference = dataSetReference.trimmed(); + if (reference.isEmpty()) return false; + + bool owned = false; + for (const auto& item : ownedDynamicDataSets_) { + if (sameDataSetReference(reference, item)) { + owned = true; + break; + } + } + if (!owned) { + lastError_ = QStringLiteral( + "Refusing to delete a DataSet not created by this report association."); + emit stateChanged(); + return false; + } + for (const auto& item : reportControls_) { + const auto map = item.toMap(); + if (sameDataSetReference( + reference, + map.value(QStringLiteral("dataSet")).toString())) { + lastError_ = QStringLiteral( + "Rebind the RCB away from this dynamic DataSet before deleting it."); + emit stateChanged(); + return false; + } + } + + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + const QPointer self{this}; + operationBusy_ = true; + state_ = State::authoring; + lastError_.clear(); + appendDiagnostic( + QStringLiteral("Delete owned dynamic DataSet requested · %1") + .arg(reference)); + emit stateChanged(); + + ioPool_.start([self, worker, stop, generation, reference] { + try { + if (!worker->session || !worker->session->associated() || + !worker->discovery || !worker->dynamicDataSets) { + throw std::runtime_error( + "Dynamic DataSet delete requires the active report association."); + } + const auto response = worker->dynamicDataSets->remove( + reference.toStdString(), + mms::MmsDynamicDataSetDeletePolicy::owned_only, + stop->get_token()); + if (!response.deleted() && + response.number_matched.value_or(1U) != 0U) { + throw std::runtime_error( + "DeleteNamedVariableList did not confirm deletion."); + } + + const auto removeReference = reference; + auto& inventory = worker->discovery->report_inventory.data_sets; + inventory.erase( + std::remove_if( + inventory.begin(), inventory.end(), + [&removeReference](const auto& item) { + return sameDataSetReference( + QString::fromStdString(item.reference), + removeReference); + }), + inventory.end()); + auto& directories = worker->discovery->data_set_directories; + directories.erase( + std::remove_if( + directories.begin(), directories.end(), + [&removeReference](const auto& item) { + return sameDataSetReference( + QString::fromStdString(item.candidate.reference), + removeReference); + }), + directories.end()); + + QStringList ownedReferences; + for (const auto& item : worker->dynamicDataSets->owned_data_sets()) { + ownedReferences.push_back(QString::fromStdString(item)); + } + const auto profile = QString::fromStdString( + worker->session->association().active_association_profile()); + auto ui = buildDiscoveryUi(*worker->discovery, profile); + ui.dataSets = markOwnedDataSets(ui.dataSets, ownedReferences); + + if (self) { + QMetaObject::invokeMethod( + self, + [self, generation, ui, ownedReferences, reference] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->state_ = State::ready; + self->lastError_.clear(); + self->dataSets_ = ui.dataSets; + self->reportControls_ = ui.reportControls; + self->staticCandidates_ = ui.staticCandidates; + self->dynamicCandidates_ = ui.dynamicCandidates; + self->associationProfile_ = ui.associationProfile; + self->ownedDynamicDataSets_ = ownedReferences; + self->selectedDataSetIndex_ = + self->dataSets_.isEmpty() + ? -1 + : std::clamp( + self->selectedDataSetIndex_, + 0, + self->dataSets_.size() - 1); + self->refreshSelection(); + self->appendDiagnostic( + QStringLiteral("Owned dynamic DataSet deleted · %1") + .arg(reference)); + emit self->inventoryChanged(); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod( + self, + [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->state_ = State::ready; + self->lastError_ = message; + self->appendDiagnostic( + QStringLiteral("Dynamic DataSet delete failed · %1") + .arg(message)); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } + }); + return true; +} + bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) { if (!connected() || busy() || active_ || cleanupRequired_ || selectedRcb_.isEmpty()) return false; const auto reference = selectedRcb_.value(QStringLiteral("reference")).toString(); From c9877db648eaa44d4afe443bcf57e212995d5077 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:22:48 +0700 Subject: [PATCH 07/28] P6C: apply authored DataSet TrgOps OptFlds through proven runtimes --- .../ied_simulator/src/MmsReportController.cpp | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 0ebf26ad9..d686576ec 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -1186,6 +1186,277 @@ bool MmsReportController::deleteDynamicDataSet( return true; } +bool MmsReportController::enableSelectedAuthored( + const QString& dataSetReference, + const QStringList& triggerOptions, + const QStringList& optionalFields, + const bool requestGeneralInterrogation) { + if (!connected() || busy() || active_ || cleanupRequired_ || + selectedRcb_.isEmpty()) { + return false; + } + const auto rcbReference = + selectedRcb_.value(QStringLiteral("reference")).toString().trimmed(); + const auto targetDataSet = dataSetReference.trimmed(); + if (rcbReference.isEmpty() || targetDataSet.isEmpty()) { + lastError_ = QStringLiteral( + "Authored report enable requires an RCB and DataSet reference."); + emit stateChanged(); + return false; + } + if (requestGeneralInterrogation && + !triggerOptions.contains(QStringLiteral("general-interrogation"))) { + lastError_ = QStringLiteral( + "Enable + GI requires general-interrogation in authored TrgOps."); + emit stateChanged(); + return false; + } + + std::vector trgOps; + std::vector optFlds; + try { + trgOps = triggerPayload(triggerOptions); + optFlds = optionalPayload(optionalFields); + } catch (const std::exception& exception) { + lastError_ = QString::fromUtf8(exception.what()); + emit stateChanged(); + return false; + } + + bool targetOwnedDynamic = false; + for (const auto& reference : ownedDynamicDataSets_) { + if (sameDataSetReference(targetDataSet, reference)) { + targetOwnedDynamic = true; + break; + } + } + const auto currentDataSet = + selectedRcb_.value(QStringLiteral("dataSet")).toString().trimmed(); + if (!targetOwnedDynamic && + !sameDataSetReference(targetDataSet, currentDataSet)) { + lastError_ = QStringLiteral( + "Static/non-owned DataSet binding is immutable in Browser authoring. " + "Create an owned dynamic DataSet to rebind the RCB."); + emit stateChanged(); + return false; + } + + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + const auto selectedRow = selectedRcbIndex_; + const QPointer self{this}; + operationBusy_ = true; + state_ = State::enabling; + lastError_.clear(); + appendDiagnostic( + QStringLiteral( + "Authored RCB enable · %1 · DataSet %2 · TrgOps [%3] · OptFlds [%4]") + .arg( + rcbReference, + targetDataSet, + bitNamesText(triggerOptions), + bitNamesText(optionalFields))); + emit stateChanged(); + + ioPool_.start([ + self, worker, stop, generation, selectedRow, rcbReference, + targetDataSet, triggerOptions, optionalFields, requestGeneralInterrogation, + targetOwnedDynamic, trgOps = std::move(trgOps), optFlds = std::move(optFlds) + ]() mutable { + try { + if (!worker->session || !worker->session->associated() || + !worker->discovery) { + throw std::runtime_error( + "MMS report association is not active."); + } + if (worker->report) { + const auto previous = worker->report->snapshot(); + if (previous.active || + (previous.subscription && + previous.subscription->cleanup_required)) { + throw std::runtime_error( + "Previous static report subscription still owns state or requires cleanup."); + } + worker->report.reset(); + } + if (worker->authoredReport) { + const auto previous = worker->authoredReport->snapshot(); + if (worker->authoredReport->active() || + previous.cleanup_required) { + throw std::runtime_error( + "Previous authored report subscription still owns state or requires cleanup."); + } + worker->authoredReport.reset(); + } + + ReportUi ui; + if (targetOwnedDynamic) { + if (!worker->dynamicDataSets || + !worker->dynamicDataSets->owns(targetDataSet.toStdString())) { + throw std::runtime_error( + "Dynamic DataSet ownership is no longer valid for this association."); + } + + const mms::MmsReportControlCandidate* candidate = nullptr; + for (const auto& item : + worker->discovery->report_inventory.report_controls) { + if (item.reference == rcbReference.toStdString()) { + candidate = &item; + break; + } + } + if (candidate == nullptr) { + throw std::runtime_error( + "Selected RCB is not present in the attached report inventory."); + } + + const mms::MmsDataSetDirectoryResponse* directory = nullptr; + for (const auto& item : + worker->discovery->data_set_directories) { + if (!item.directory.has_value()) continue; + if (sameDataSetReference( + QString::fromStdString(item.candidate.reference), + targetDataSet)) { + directory = &*item.directory; + break; + } + } + if (directory == nullptr || directory->members.empty()) { + throw std::runtime_error( + "Owned dynamic DataSet directory is unavailable or empty."); + } + + mms::MmsReportSubscriptionOptions options; + options.trigger_general_interrogation = + requestGeneralInterrogation; + options.reserve_unbuffered_rcb = true; + options.write_data_set_reference = true; + options.data_set_reference = targetDataSet.toStdString(); + options.write_trigger_options = true; + options.trigger_options = trgOps; + options.write_optional_fields = true; + options.optional_fields = optFlds; + options.maximum_events = 256U; + options.monitor_options.maximum_streams = 64U; + options.monitor_options.maximum_frames_per_stream = 64U; + + auto report = + std::make_unique( + worker->session->association(), + *candidate, + *directory, + options); + report->start(stop->get_token()); + ui = buildReportUi(report->snapshot()); + worker->authoredReport = std::move(report); + } else { + mms::MmsStaticReportSessionOptions options; + options.selection.preferred_rcb_reference = + rcbReference.toStdString(); + options.selection.strict_rcb = true; + options.selection.allow_urcb_fallback = true; + options.selection.allow_polling_fallback = false; + options.maximum_candidate_attempts = 1U; + options.subscription.trigger_general_interrogation = + requestGeneralInterrogation; + options.subscription.reserve_unbuffered_rcb = true; + options.subscription.write_data_set_reference = false; + options.subscription.write_trigger_options = true; + options.subscription.trigger_options = trgOps; + options.subscription.write_optional_fields = true; + options.subscription.optional_fields = optFlds; + options.subscription.maximum_events = 256U; + options.subscription.monitor_options.maximum_streams = 64U; + options.subscription.monitor_options.maximum_frames_per_stream = 64U; + + auto report = + std::make_unique( + worker->session->association(), + *worker->discovery, + options); + report->prepare(stop->get_token()); + report->start(stop->get_token()); + ui = buildReportUi(report->snapshot()); + worker->report = std::move(report); + } + + if (self) { + QMetaObject::invokeMethod( + self, + [self, generation, selectedRow, ui, targetDataSet, + triggerOptions, optionalFields, + requestGeneralInterrogation, targetOwnedDynamic] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = ui.active; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + if (selectedRow >= 0 && + selectedRow < self->reportControls_.size()) { + auto map = + self->reportControls_.at(selectedRow).toMap(); + map.insert( + QStringLiteral("dataSet"), + targetDataSet); + map.insert( + QStringLiteral("triggerOptions"), + bitNamesText(triggerOptions)); + map.insert( + QStringLiteral("optionalFields"), + bitNamesText(optionalFields)); + map.insert( + QStringLiteral("authored"), + true); + map.insert( + QStringLiteral("dynamicBinding"), + targetOwnedDynamic); + self->reportControls_[selectedRow] = map; + self->selectedRcbIndex_ = selectedRow; + self->refreshSelection(); + } + self->state_ = + ui.cleanupRequired + ? State::cleanup_required + : State::active; + self->appendDiagnostic( + requestGeneralInterrogation + ? QStringLiteral( + "Authored RCB configuration verified; enabled; GI requested.") + : QStringLiteral( + "Authored RCB configuration verified and enabled.")); + emit self->inventoryChanged(); + emit self->reportsChanged(); + emit self->stateChanged(); + if (self->active_) self->pollTimer_.start(); + }, + Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod( + self, + [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = false; + self->lastError_ = message; + self->state_ = State::faulted; + self->appendDiagnostic( + QStringLiteral("Authored RCB enable failed · %1") + .arg(message)); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } + }); + return true; +} + bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) { if (!connected() || busy() || active_ || cleanupRequired_ || selectedRcb_.isEmpty()) return false; const auto reference = selectedRcb_.value(QStringLiteral("reference")).toString(); @@ -1212,6 +1483,13 @@ bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) } worker->report.reset(); } + if (worker->authoredReport) { + const auto previous = worker->authoredReport->snapshot(); + if (worker->authoredReport->active() || previous.cleanup_required) { + throw std::runtime_error("Previous authored report subscription still owns state or requires cleanup."); + } + worker->authoredReport.reset(); + } mms::MmsStaticReportSessionOptions options; options.selection.preferred_rcb_reference = reference.toStdString(); From 6ccfc1ccda93190e8939133d1d5233513acd32b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:23:16 +0700 Subject: [PATCH 08/28] P6C: unify static and authored report lifecycle --- .../ied_simulator/src/MmsReportController.cpp | 74 +++++++++++++++---- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index d686576ec..941f68ebd 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -771,10 +771,20 @@ void MmsReportController::disconnectFromIed() { if (worker->report) { worker->report->stop(cleanupStop->get_token()); const auto snapshot = worker->report->snapshot(); - cleanupRequired = snapshot.subscription && snapshot.subscription->cleanup_required; + cleanupRequired = + snapshot.subscription && + snapshot.subscription->cleanup_required; + } + if (worker->authoredReport) { + worker->authoredReport->stop(cleanupStop->get_token()); + cleanupRequired = + cleanupRequired || + worker->authoredReport->snapshot().cleanup_required; } if (worker->session) worker->session->disconnect(cleanupStop->get_token()); worker->report.reset(); + worker->authoredReport.reset(); + worker->dynamicDataSets.reset(); worker->discovery.reset(); worker->session.reset(); if (cleanupRequired && self) { @@ -1562,11 +1572,18 @@ bool MmsReportController::disableSelected() { const auto worker = workerState_; ioPool_.start([self, worker, stop, generation] { try { - if (!worker->report) throw std::runtime_error("No report subscription is active."); - worker->report->stop(stop->get_token()); - const auto snapshot = worker->report->snapshot(); - const auto ui = buildReportUi(snapshot); - if (!ui.cleanupRequired) worker->report.reset(); + ReportUi ui; + if (worker->report) { + worker->report->stop(stop->get_token()); + ui = buildReportUi(worker->report->snapshot()); + if (!ui.cleanupRequired) worker->report.reset(); + } else if (worker->authoredReport) { + worker->authoredReport->stop(stop->get_token()); + ui = buildReportUi(worker->authoredReport->snapshot()); + if (!ui.cleanupRequired) worker->authoredReport.reset(); + } else { + throw std::runtime_error("No report subscription is active."); + } if (self) { QMetaObject::invokeMethod(self, [self, generation, ui] { if (!self || self->generation_ != generation) return; @@ -1617,12 +1634,23 @@ bool MmsReportController::retryCleanup() { const auto worker = workerState_; ioPool_.start([self, worker, stop, generation] { try { - if (!worker->session || !worker->session->associated() || !worker->report) { + if (!worker->session || !worker->session->associated()) { throw std::runtime_error("Cleanup retry requires the original active MMS association."); } - worker->report->stop(stop->get_token()); - const auto ui = buildReportUi(worker->report->snapshot()); - if (!ui.cleanupRequired) worker->report.reset(); + ReportUi ui; + if (worker->report) { + worker->report->stop(stop->get_token()); + ui = buildReportUi(worker->report->snapshot()); + if (!ui.cleanupRequired) worker->report.reset(); + } else if (worker->authoredReport) { + static_cast(worker->authoredReport->retry_cleanup( + stop->get_token())); + ui = buildReportUi(worker->authoredReport->snapshot()); + if (!ui.cleanupRequired) worker->authoredReport.reset(); + } else { + throw std::runtime_error( + "Cleanup retry has no retained report runtime."); + } if (self) { QMetaObject::invokeMethod(self, [self, generation, ui] { if (!self || self->generation_ != generation) return; @@ -1667,14 +1695,29 @@ void MmsReportController::schedulePoll() { const QPointer self{this}; ioPool_.start([self, worker, stop, generation] { try { - if (!worker->session || !worker->session->associated() || !worker->report || !worker->report->active()) { - throw std::runtime_error("Report poll requires an active MMS association and RCB subscription."); + if (!worker->session || !worker->session->associated()) { + throw std::runtime_error( + "Report poll requires an active MMS association."); + } + const bool staticActive = + worker->report && worker->report->active(); + const bool authoredActive = + worker->authoredReport && worker->authoredReport->active(); + if (!staticActive && !authoredActive) { + throw std::runtime_error( + "Report poll requires an active RCB subscription."); } mms::MmsPduEnvelope envelope; static_cast(worker->session->association().try_poll_once_for( std::chrono::milliseconds{40}, envelope, stop->get_token())); - static_cast(worker->report->drain_queued_reports()); - const auto ui = buildReportUi(worker->report->snapshot()); + ReportUi ui; + if (staticActive) { + static_cast(worker->report->drain_queued_reports()); + ui = buildReportUi(worker->report->snapshot()); + } else { + static_cast(worker->authoredReport->drain_queued_reports()); + ui = buildReportUi(worker->authoredReport->snapshot()); + } if (self) { QMetaObject::invokeMethod(self, [self, generation, ui] { if (!self || self->generation_ != generation) return; @@ -1699,6 +1742,9 @@ void MmsReportController::schedulePoll() { if (worker->report) { worker->report->stop(); ui = buildReportUi(worker->report->snapshot()); + } else if (worker->authoredReport) { + worker->authoredReport->stop(); + ui = buildReportUi(worker->authoredReport->snapshot()); } if (self) { const auto message = QString::fromUtf8(exception.what()); From 5a25878d1331108cad4a56bd3a00e91a71f7ae35 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:23:54 +0700 Subject: [PATCH 09/28] P6C: harden Qt string and index typing in authoring path --- apps/ied_simulator/src/MmsReportController.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 941f68ebd..a1b98a35b 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -80,7 +80,7 @@ std::vector triggerPayload(const QStringList& names) { const auto name = raw.trimmed(); if (name.isEmpty() || !unique.insert(name).second) continue; const auto it = std::find_if(bits.begin(), bits.end(), [&name](const auto& item) { - return item.first == name; + return item.first.toString() == name; }); if (it == bits.end()) { throw std::invalid_argument( @@ -114,7 +114,7 @@ std::vector optionalPayload(const QStringList& names) { const auto name = raw.trimmed(); if (name.isEmpty() || !unique.insert(name).second) continue; const auto it = std::find_if(bits.begin(), bits.end(), [&name](const auto& item) { - return item.name == name; + return item.name.toString() == name; }); if (it == bits.end()) { throw std::invalid_argument( @@ -1164,7 +1164,7 @@ bool MmsReportController::deleteDynamicDataSet( : std::clamp( self->selectedDataSetIndex_, 0, - self->dataSets_.size() - 1); + static_cast(self->dataSets_.size()) - 1); self->refreshSelection(); self->appendDiagnostic( QStringLiteral("Owned dynamic DataSet deleted · %1") From d547507e17bc6eb31e47fd790af4a88af62a7131 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:24:36 +0700 Subject: [PATCH 10/28] P6C: add ordered dynamic DataSet authoring dialog --- .../qml/BrowserDataSetAuthoringDialog.qml | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml diff --git a/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml b/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml new file mode 100644 index 000000000..282b7c7e6 --- /dev/null +++ b/apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Dialog { + id: root + + required property var theme + required property var reports + + property string pendingDeleteReference: "" + readonly property int maximumMembers: 64 + + title: "Dynamic DataSet Authoring" + modal: true + width: 700 + height: 620 + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Close + + ListModel { id: memberModel } + + function findMember(domain, item) { + for (var i = 0; i < memberModel.count; ++i) { + var row = memberModel.get(i) + if (row.mmsDomain === domain && row.mmsItem === item) + return i + } + return -1 + } + + function resetDraft() { + memberModel.clear() + referenceField.text = "" + statusLabel.text = "Draft is local until Create is explicitly confirmed." + } + + function newDraft() { + resetDraft() + root.open() + } + + function addSelected(node) { + if (!node || node.kind !== "DA" || node.readable !== true + || !node.mmsDomain || !node.mmsItem || !node.reference) { + statusLabel.text = "Select one readable canonical DataAttribute first." + root.open() + return false + } + if (findMember(node.mmsDomain, node.mmsItem) >= 0) { + statusLabel.text = "That MMS member is already in the draft." + root.open() + return true + } + if (memberModel.count >= root.maximumMembers) { + statusLabel.text = "Draft is full (64 members)." + root.open() + return false + } + memberModel.append({ + "reference": node.reference, + "mmsDomain": node.mmsDomain, + "mmsItem": node.mmsItem, + "functionalConstraint": node.functionalConstraint || "" + }) + if (referenceField.text.length === 0) + referenceField.text = node.mmsDomain + "/LLN0.ARStackDynamic1" + statusLabel.text = "Added canonical member " + node.reference + root.open() + return true + } + + function memberPayload() { + var result = [] + for (var i = 0; i < memberModel.count; ++i) { + var row = memberModel.get(i) + result.push({ + "reference": row.reference, + "mmsDomain": row.mmsDomain, + "mmsItem": row.mmsItem, + "functionalConstraint": row.functionalConstraint + }) + } + return result + } + + function createConfirmed() { + var accepted = reports.createDynamicDataSet( + referenceField.text, memberPayload()) + if (accepted) { + statusLabel.text = "DefineNamedVariableList started; verification is mandatory." + root.close() + } else { + statusLabel.text = reports.lastError.length + ? reports.lastError : "Dynamic DataSet create was not started." + } + } + + function requestDelete(dataSet) { + if (!dataSet || dataSet.dynamicOwned !== true) { + pendingDeleteReference = "" + deleteError.text = + "Only DataSets created by this live report association can be deleted here." + deleteDialog.open() + return false + } + pendingDeleteReference = dataSet.reference || "" + deleteError.text = "" + deleteDialog.open() + return true + } + + function deleteConfirmed() { + if (pendingDeleteReference.length === 0) + return + reports.deleteDynamicDataSet(pendingDeleteReference) + pendingDeleteReference = "" + } + + contentItem: ColumnLayout { + spacing: 8 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 74 + radius: 6 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 3 + Label { + text: "DATASET REFERENCE" + color: theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + TextField { + id: referenceField + Layout.fillWidth: true + placeholderText: "LogicalDevice/LLN0.ARStackDynamic1" + selectByMouse: true + enabled: !reports.busy + } + } + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: "Ordered members · " + memberModel.count + + " / " + root.maximumMembers + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Button { + text: "Clear draft" + enabled: memberModel.count > 0 && !reports.busy + onClicked: memberModel.clear() + } + } + + ListView { + id: memberList + Layout.fillWidth: true + Layout.fillHeight: true + model: memberModel + clip: true + reuseItems: true + spacing: 1 + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + required property int index + required property string reference + required property string mmsDomain + required property string mmsItem + required property string functionalConstraint + + width: memberList.width + height: 54 + color: index % 2 ? theme.surfaceSoft : theme.surface + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 8 + spacing: 7 + + Label { + text: String(index + 1) + color: theme.muted + font.pixelSize: 8 + Layout.preferredWidth: 24 + horizontalAlignment: Text.AlignHCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: reference + color: theme.text + font.pixelSize: 9 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: mmsDomain + "/" + mmsItem + + (functionalConstraint.length + ? " · FC=" + functionalConstraint : "") + color: theme.muted + font.pixelSize: 7 + elide: Text.ElideMiddle + } + } + + Button { + text: "↑" + enabled: index > 0 && !reports.busy + onClicked: memberModel.move(index, index - 1, 1) + } + Button { + text: "↓" + enabled: index + 1 < memberModel.count && !reports.busy + onClicked: memberModel.move(index, index + 1, 1) + } + Button { + text: "Remove" + enabled: !reports.busy + onClicked: memberModel.remove(index) + } + } + } + + Label { + anchors.centerIn: parent + visible: memberModel.count === 0 + width: Math.max(220, parent.width - 60) + text: "Add readable DataAttributes from Data Model. Member order is preserved exactly in DefineNamedVariableList." + color: theme.muted + font.pixelSize: 9 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + } + + Label { + id: statusLabel + Layout.fillWidth: true + text: "Draft is local until Create is explicitly confirmed." + color: reports.lastError.length ? theme.red : theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: "Create uses the existing MMS association and verifies the resulting directory before ownership is recorded." + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + Button { + text: reports.busy ? "Working…" : "Create…" + enabled: reports.connected && !reports.busy + && referenceField.text.trim().length > 0 + && memberModel.count > 0 + onClicked: createConfirm.open() + } + } + } + + Dialog { + id: createConfirm + title: "Confirm dynamic DataSet create" + modal: true + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Ok | Dialog.Cancel + onAccepted: root.createConfirmed() + contentItem: Label { + width: 470 + text: "Create " + referenceField.text + + " with " + memberModel.count + + " ordered member(s)? This sends one DefineNamedVariableList request and requires directory read-back verification." + color: theme.text + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + + Dialog { + id: deleteDialog + title: "Confirm dynamic DataSet delete" + modal: true + anchors.centerIn: Overlay.overlay + standardButtons: root.pendingDeleteReference.length > 0 + ? Dialog.Ok | Dialog.Cancel : Dialog.Close + onAccepted: root.deleteConfirmed() + contentItem: ColumnLayout { + width: 470 + spacing: 6 + Label { + Layout.fillWidth: true + text: root.pendingDeleteReference.length > 0 + ? "Delete owned dynamic DataSet " + root.pendingDeleteReference + "?" + : "Delete refused." + color: theme.text + font.pixelSize: 9 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + Label { + id: deleteError + Layout.fillWidth: true + text: "" + color: deleteError.text.length ? theme.red : theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + Label { + visible: root.pendingDeleteReference.length > 0 + Layout.fillWidth: true + text: "ARStack uses owned-only DeleteNamedVariableList. Static and foreign dynamic DataSets cannot be deleted from this workflow." + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + } +} From ff349462b94e1fdbbf1c1d227dc3d8d4a5e67b78 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:25:08 +0700 Subject: [PATCH 11/28] P6C: gate authored RCB fields by discovered attributes --- .../ied_simulator/src/MmsReportController.cpp | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index a1b98a35b..990175a1c 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -1240,6 +1240,32 @@ bool MmsReportController::enableSelectedAuthored( break; } } + const auto attributes = + selectedRcb_.value(QStringLiteral("attributes")).toStringList(); + const bool writeTriggerOptions = + attributes.contains(QStringLiteral("TrgOps")); + const bool writeOptionalFields = + attributes.contains(QStringLiteral("OptFlds")); + if (!writeTriggerOptions && !triggerOptions.isEmpty()) { + lastError_ = QStringLiteral( + "Selected RCB does not expose TrgOps for authoring."); + emit stateChanged(); + return false; + } + if (!writeOptionalFields && !optionalFields.isEmpty()) { + lastError_ = QStringLiteral( + "Selected RCB does not expose OptFlds for authoring."); + emit stateChanged(); + return false; + } + if (targetOwnedDynamic && + !attributes.contains(QStringLiteral("DatSet"))) { + lastError_ = QStringLiteral( + "Selected RCB does not expose DatSet for dynamic binding."); + emit stateChanged(); + return false; + } + const auto currentDataSet = selectedRcb_.value(QStringLiteral("dataSet")).toString().trimmed(); if (!targetOwnedDynamic && @@ -1272,7 +1298,8 @@ bool MmsReportController::enableSelectedAuthored( ioPool_.start([ self, worker, stop, generation, selectedRow, rcbReference, targetDataSet, triggerOptions, optionalFields, requestGeneralInterrogation, - targetOwnedDynamic, trgOps = std::move(trgOps), optFlds = std::move(optFlds) + targetOwnedDynamic, writeTriggerOptions, writeOptionalFields, + trgOps = std::move(trgOps), optFlds = std::move(optFlds) ]() mutable { try { if (!worker->session || !worker->session->associated() || @@ -1343,9 +1370,9 @@ bool MmsReportController::enableSelectedAuthored( options.reserve_unbuffered_rcb = true; options.write_data_set_reference = true; options.data_set_reference = targetDataSet.toStdString(); - options.write_trigger_options = true; + options.write_trigger_options = writeTriggerOptions; options.trigger_options = trgOps; - options.write_optional_fields = true; + options.write_optional_fields = writeOptionalFields; options.optional_fields = optFlds; options.maximum_events = 256U; options.monitor_options.maximum_streams = 64U; @@ -1372,9 +1399,9 @@ bool MmsReportController::enableSelectedAuthored( requestGeneralInterrogation; options.subscription.reserve_unbuffered_rcb = true; options.subscription.write_data_set_reference = false; - options.subscription.write_trigger_options = true; + options.subscription.write_trigger_options = writeTriggerOptions; options.subscription.trigger_options = trgOps; - options.subscription.write_optional_fields = true; + options.subscription.write_optional_fields = writeOptionalFields; options.subscription.optional_fields = optFlds; options.subscription.maximum_events = 256U; options.subscription.monitor_options.maximum_streams = 64U; From 5b2979398653d0eb8b0f0899f1bc47ffb66edbaf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:25:46 +0700 Subject: [PATCH 12/28] P6C: add advanced RCB authoring dialog --- .../qml/BrowserReportAuthoringDialog.qml | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml diff --git a/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml b/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml new file mode 100644 index 000000000..b73639aa6 --- /dev/null +++ b/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Dialog { + id: root + + required property var theme + required property var reports + + property bool pendingGi: false + + title: "Advanced Report Authoring" + modal: true + width: 720 + height: 650 + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Close + + function containsName(text, name) { + return text && String(text).indexOf(name) >= 0 + } + + function selectedDataSetMap() { + if (dataSetBox.currentIndex < 0 + || dataSetBox.currentIndex >= reports.dataSets.length) + return ({}) + return reports.dataSets[dataSetBox.currentIndex] + } + + function bindingAllowed() { + var ds = selectedDataSetMap() + if (!ds || !ds.reference) + return false + if (reports.selectedRcb.dataSet === ds.reference) + return true + return ds.dynamicOwned === true + } + + function triggerNames() { + var result = [] + if (dchg.checked) result.push("data-change") + if (qchg.checked) result.push("quality-change") + if (dupd.checked) result.push("data-update") + if (integrity.checked) result.push("integrity") + if (giTrigger.checked) result.push("general-interrogation") + return result + } + + function optionalNames() { + var result = [] + if (seqNum.checked) result.push("sequence-number") + if (timeStamp.checked) result.push("report-time-stamp") + if (reason.checked) result.push("reason-for-inclusion") + if (dataSetName.checked) result.push("data-set-name") + if (dataReference.checked) result.push("data-reference") + if (bufferOverflow.checked) result.push("buffer-overflow") + if (entryId.checked) result.push("entry-id") + if (confRev.checked) result.push("configuration-revision") + if (segmentation.checked) result.push("segmentation") + return result + } + + function findDataSet(reference) { + for (var i = 0; i < reports.dataSets.length; ++i) { + if (reports.dataSets[i].reference === reference) + return i + } + return reports.selectedDataSetIndex >= 0 + ? reports.selectedDataSetIndex : 0 + } + + function openForSelected() { + if (reports.selectedRcbIndex < 0) + return false + var rcb = reports.selectedRcb + dataSetBox.currentIndex = findDataSet(rcb.dataSet || "") + + var trg = rcb.triggerOptions || "" + dchg.checked = containsName(trg, "data-change") + qchg.checked = containsName(trg, "quality-change") + dupd.checked = containsName(trg, "data-update") + integrity.checked = containsName(trg, "integrity") + giTrigger.checked = containsName(trg, "general-interrogation") + + var opt = rcb.optionalFields || "" + seqNum.checked = containsName(opt, "sequence-number") + timeStamp.checked = containsName(opt, "report-time-stamp") + reason.checked = containsName(opt, "reason-for-inclusion") + dataSetName.checked = containsName(opt, "data-set-name") + dataReference.checked = containsName(opt, "data-reference") + bufferOverflow.checked = containsName(opt, "buffer-overflow") + entryId.checked = containsName(opt, "entry-id") + confRev.checked = containsName(opt, "configuration-revision") + segmentation.checked = containsName(opt, "segmentation") + + if (!trg.length) { + dchg.checked = true + qchg.checked = true + integrity.checked = true + giTrigger.checked = true + } + if (!opt.length) { + seqNum.checked = true + timeStamp.checked = true + reason.checked = true + dataSetName.checked = true + dataReference.checked = true + confRev.checked = true + } + root.open() + return true + } + + function requestEnable(gi) { + pendingGi = gi + if (gi && !giTrigger.checked) + giTrigger.checked = true + confirmEnable.open() + } + + function executeEnable() { + var ds = selectedDataSetMap() + if (!ds || !ds.reference) + return + var accepted = reports.enableSelectedAuthored( + ds.reference, + triggerNames(), + optionalNames(), + pendingGi) + if (accepted) + root.close() + } + + contentItem: ScrollView { + contentWidth: availableWidth + + ColumnLayout { + width: Math.max(0, parent.width - 30) + x: 15 + spacing: 10 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 76 + radius: 6 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + Label { + Layout.fillWidth: true + text: reports.selectedRcb.reference || "—" + color: theme.text + font.pixelSize: 11 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: (reports.selectedRcb.mode || "RCB") + + " · currently " + (reports.selectedRcb.dataSet || "no DataSet") + color: theme.muted + font.pixelSize: 8 + elide: Text.ElideMiddle + } + } + } + + Label { + text: "DataSet binding" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + + ComboBox { + id: dataSetBox + Layout.fillWidth: true + model: reports.dataSets + textRole: "reference" + valueRole: "reference" + enabled: !reports.busy && !reports.active + delegate: ItemDelegate { + required property var modelData + width: dataSetBox.width + text: (modelData.dynamicOwned === true ? "DYNAMIC · " : "STATIC · ") + + (modelData.reference || "—") + + " · " + (modelData.memberCount || 0) + " member(s)" + } + } + + Rectangle { + visible: !root.bindingAllowed() + Layout.fillWidth: true + implicitHeight: bindingWarning.implicitHeight + 16 + radius: 5 + color: theme.redSoft + border.width: 1 + border.color: theme.red + Label { + id: bindingWarning + anchors.fill: parent + anchors.margins: 8 + text: "Binding to a different static/non-owned DataSet is blocked. " + + "Only an association-owned dynamic DataSet may replace RCB DatSet." + color: theme.red + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + + Label { + text: "Trigger Options (TrgOps)" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + + GridLayout { + Layout.fillWidth: true + columns: 3 + CheckBox { id: dchg; text: "Data change"; enabled: !reports.busy } + CheckBox { id: qchg; text: "Quality change"; enabled: !reports.busy } + CheckBox { id: dupd; text: "Data update"; enabled: !reports.busy } + CheckBox { id: integrity; text: "Integrity"; enabled: !reports.busy } + CheckBox { id: giTrigger; text: "General interrogation"; enabled: !reports.busy } + } + + Label { + text: "Optional Fields (OptFlds)" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + + GridLayout { + Layout.fillWidth: true + columns: 3 + CheckBox { id: seqNum; text: "Sequence number"; enabled: !reports.busy } + CheckBox { id: timeStamp; text: "Report timestamp"; enabled: !reports.busy } + CheckBox { id: reason; text: "Reason for inclusion"; enabled: !reports.busy } + CheckBox { id: dataSetName; text: "DataSet name"; enabled: !reports.busy } + CheckBox { id: dataReference; text: "Data reference"; enabled: !reports.busy } + CheckBox { id: bufferOverflow; text: "Buffer overflow"; enabled: !reports.busy } + CheckBox { id: entryId; text: "EntryID"; enabled: !reports.busy } + CheckBox { id: confRev; text: "Configuration revision"; enabled: !reports.busy } + CheckBox { id: segmentation; text: "Segmentation"; enabled: !reports.busy } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: note.implicitHeight + 16 + radius: 5 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + Label { + id: note + anchors.fill: parent + anchors.margins: 8 + text: "Draft-only until Enable is confirmed. Static binding never rewrites DatSet. " + + "For an owned dynamic DataSet, DatSet + TrgOps + OptFlds are written only through " + + "the proven report runtime, read back, and only then RptEna is written." + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + + Rectangle { + visible: reports.lastError.length > 0 + Layout.fillWidth: true + implicitHeight: errorLabel.implicitHeight + 16 + radius: 5 + color: theme.redSoft + border.width: 1 + border.color: theme.red + Label { + id: errorLabel + anchors.fill: parent + anchors.margins: 8 + text: reports.lastError + color: theme.red + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + Button { + text: reports.busy ? "Working…" : "Enable authored…" + enabled: reports.connected && !reports.busy && !reports.active + && root.bindingAllowed() + && dataSetBox.currentIndex >= 0 + onClicked: root.requestEnable(false) + } + Button { + text: reports.busy ? "Working…" : "Enable authored + GI…" + enabled: reports.connected && !reports.busy && !reports.active + && root.bindingAllowed() + && dataSetBox.currentIndex >= 0 + onClicked: root.requestEnable(true) + } + } + } + } + + Dialog { + id: confirmEnable + title: "Confirm authored RCB enable" + modal: true + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Ok | Dialog.Cancel + onAccepted: root.executeEnable() + contentItem: Label { + width: 500 + text: { + var ds = root.selectedDataSetMap() + return "Apply authored configuration to " + + (reports.selectedRcb.reference || "selected RCB") + + " using " + (ds.reference || "—") + + (root.pendingGi ? " and request GI?" : "?") + + "\n\nNo automatic retry is performed." + } + color: theme.text + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } +} From b8d6dece059c8b4fc60e57a256ca58c0bf056378 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:26:17 +0700 Subject: [PATCH 13/28] P6C: package DataSet and report authoring dialogs --- apps/ied_simulator/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 64af46fbe..79fbf1365 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -102,6 +102,8 @@ qt_add_qml_module(arstack_ied_simulator qml/BrowserGoosePane.qml qml/BrowserGlobalDataPane.qml qml/BrowserControlDialog.qml + qml/BrowserDataSetAuthoringDialog.qml + qml/BrowserReportAuthoringDialog.qml qml/SnifferWorkspace.qml qml/MmsClientWorkspace.qml qml/ReportsWorkspace.qml From cd677d4a6bd2da1893d100aa2f60a2a9d466d03e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:26:20 +0700 Subject: [PATCH 14/28] P6C: wire dynamic DataSet and report authoring commands --- .../ied_simulator/qml/IedBrowserWorkspace.qml | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 7df41a61d..71cf1043a 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -415,6 +415,16 @@ Item { onClicked: globalDataPane.addSelectedData(root.selectedModelNode) } + Button { + visible: root.activeSection === 0 + text: "Add to DataSet draft…" + enabled: root.selectedModelNode.kind === "DA" + && root.selectedModelNode.readable === true + && root.selectedModelNode.mmsDomain + && root.selectedModelNode.mmsItem + onClicked: dataSetAuthorDialog.addSelected(root.selectedModelNode) + } + Button { visible: root.activeSection === 1 text: "Add to Global Data" @@ -428,6 +438,24 @@ Item { } } + Button { + visible: root.activeSection === 1 + text: "New dynamic…" + enabled: reports.connected && !reports.busy && !reports.active + onClicked: dataSetAuthorDialog.newDraft() + } + + Button { + visible: root.activeSection === 1 + text: "Delete owned…" + enabled: reports.connected && !reports.busy && !reports.active + && reports.selectedDataSetIndex >= 0 + && reports.selectedDataSetIndex < reports.dataSets.length + && reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + onClicked: dataSetAuthorDialog.requestDelete( + reports.dataSets[reports.selectedDataSetIndex]) + } + Button { visible: root.activeSection === 2 text: "Enable + GI" @@ -436,6 +464,15 @@ Item { onClicked: reports.enableSelected(true) } + Button { + visible: root.activeSection === 2 + text: "Author…" + enabled: reports.connected && !reports.busy && !reports.active + && reports.selectedRcbIndex >= 0 + && reports.dataSets.length > 0 + onClicked: reportAuthorDialog.openForSelected() + } + Button { visible: root.activeSection === 2 text: "Disable" @@ -556,6 +593,18 @@ Item { session: root.session controls: root.controls } + + BrowserDataSetAuthoringDialog { + id: dataSetAuthorDialog + theme: root.theme + reports: root.reports + } + + BrowserReportAuthoringDialog { + id: reportAuthorDialog + theme: root.theme + reports: root.reports + } } } } From 83e722be8caa98b4f9af45003b0750cc7957eccd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:26:37 +0700 Subject: [PATCH 15/28] P6C: surface static immutability and dynamic ownership --- apps/ied_simulator/qml/BrowserDataSetPane.qml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 291678a7d..83186cdee 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -36,6 +36,40 @@ Rectangle { wrapMode: Text.WrapAnywhere } + RowLayout { + Layout.fillWidth: true + visible: reports.selectedDataSetIndex >= 0 + && reports.selectedDataSetIndex < reports.dataSets.length + Rectangle { + Layout.preferredWidth: 104 + Layout.preferredHeight: 22 + radius: 11 + color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + ? theme.greenSoft : theme.surface + border.width: 1 + border.color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + ? theme.green : theme.lineSoft + Label { + anchors.centerIn: parent + text: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + ? "DYNAMIC OWNED" : "STATIC / READ-ONLY" + color: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + ? theme.green : theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: reports.dataSets[reports.selectedDataSetIndex].dynamicOwned === true + ? "Created and verified on this live association; eligible for owned-only delete." + : "Membership is immutable in Browser authoring." + color: theme.muted + font.pixelSize: 8 + elide: Text.ElideRight + } + } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } RowLayout { From df336222065d135a8100d894d32d7c54e666d7d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:27:09 +0700 Subject: [PATCH 16/28] P6C: retain report association state during authoring operations --- apps/ied_simulator/src/MmsReportController.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 990175a1c..75faa66dc 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -528,7 +528,7 @@ void MmsReportController::setEngineeringContext(IedEngineeringContextController* } bool MmsReportController::connected() const noexcept { - return state_ == State::ready || state_ == State::enabling || state_ == State::active || + return state_ == State::ready || state_ == State::authoring || state_ == State::enabling || state_ == State::active || state_ == State::disabling || state_ == State::cleanup_required; } From be50159cc83fb12caf907c9bd8f3eab56ba42432 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:27:27 +0700 Subject: [PATCH 17/28] P6C: add DataSet and advanced report authoring regression contract --- .../test_browser_report_authoring.py | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 apps/ied_simulator/test_browser_report_authoring.py diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py new file mode 100644 index 000000000..05ff9b477 --- /dev/null +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +if len(sys.argv) != 7: + raise SystemExit( + "usage: test_browser_report_authoring.py " + "MmsReportController.cpp IedBrowserWorkspace.qml " + "BrowserDataSetAuthoringDialog.qml BrowserReportAuthoringDialog.qml " + "BrowserDataSetPane.qml MmsReportController.hpp" + ) + +controller = Path(sys.argv[1]).read_text(encoding="utf-8") +workspace = Path(sys.argv[2]).read_text(encoding="utf-8") +dataset_dialog = Path(sys.argv[3]).read_text(encoding="utf-8") +report_dialog = Path(sys.argv[4]).read_text(encoding="utf-8") +dataset_pane = Path(sys.argv[5]).read_text(encoding="utf-8") +header = Path(sys.argv[6]).read_text(encoding="utf-8") + +def require(text: str, token: str, label: str) -> None: + if token not in text: + raise SystemExit(f"{label}: missing required token: {token}") + +for token in ( + "MmsDynamicDataSetRuntime", + "dynamicOptions.maximum_members = 64U", + "dynamicOptions.verify_after_create = true", + "dynamicDataSets->create(", + "MmsDynamicDataSetDeletePolicy::owned_only", + "engineeringContext_->treeModel()->nodeForReference(reference)", + "canonicalMembers.size() > 64", + "MmsReportSubscriptionRuntime", + "options.write_data_set_reference = true", + "options.write_trigger_options = writeTriggerOptions", + "options.write_optional_fields = writeOptionalFields", + "options.subscription.write_data_set_reference = false", + "options.selection.allow_polling_fallback = false", + "Enable + GI requires general-interrogation in authored TrgOps", + "Static/non-owned DataSet binding is immutable", +): + require(controller, token, "Report controller") + +for forbidden in ( + "MmsNamedVariableListCodec::encode_define", + "MmsNamedVariableListCodec::encode_delete", +): + if forbidden in controller: + raise SystemExit( + f"Report controller: GUI-local dynamic DataSet wire encoding forbidden: {forbidden}" + ) + +for token in ( + 'text: "Add to DataSet draft…"', + 'text: "New dynamic…"', + 'text: "Delete owned…"', + 'text: "Author…"', + "BrowserDataSetAuthoringDialog {", + "BrowserReportAuthoringDialog {", +): + require(workspace, token, "Browser workspace") + +for token in ( + "readonly property int maximumMembers: 64", + "function addSelected(node)", + "memberModel.move(", + "reports.createDynamicDataSet(", + "reports.deleteDynamicDataSet(", + "id: createConfirm", + "id: deleteDialog", + "association-owned", +): + require(dataset_dialog, token, "DataSet authoring dialog") + +for token in ( + "function triggerNames()", + "function optionalNames()", + "reports.enableSelectedAuthored(", + "id: confirmEnable", + "general-interrogation", + "sequence-number", + "reason-for-inclusion", + "configuration-revision", + "Static binding never rewrites DatSet", + "No automatic retry", +): + require(report_dialog, token, "Report authoring dialog") + +for token in ( + "DYNAMIC OWNED", + "STATIC / READ-ONLY", + "Membership is immutable", +): + require(dataset_pane, token, "DataSet pane") + +for token in ( + "ownedDynamicDataSets", + "enableSelectedAuthored", + "createDynamicDataSet", + "deleteDynamicDataSet", +): + require(header, token, "Report controller header") + +if "Timer {" in dataset_dialog or "Timer {" in report_dialog: + raise SystemExit("P6C dialogs: background polling/timers are forbidden") + +print( + "P6C_BROWSER_REPORT_AUTHORING_PASS " + "static_immutable=true dynamic_owned_only=true member_bound=64 " + "create_verify=true canonical_members=true runtime_reuse=true " + "static_poll_fallback=false explicit_enable=true qml_polling=false" +) From 66e73ea22c9ee6efbcbbd2a5d88f73665056264f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:27:41 +0700 Subject: [PATCH 18/28] P6C: gate dynamic DataSet and report authoring in CI --- .github/workflows/ied-simulator-qt.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 150918adf..82a9c60db 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -170,6 +170,16 @@ jobs: apps/ied_simulator/qml/Main.qml apps/ied_simulator/CMakeLists.txt + - name: P6C Dynamic DataSet + Report Authoring contract + run: >- + python3 apps/ied_simulator/test_browser_report_authoring.py + apps/ied_simulator/src/MmsReportController.cpp + apps/ied_simulator/qml/IedBrowserWorkspace.qml + apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml + apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml + apps/ied_simulator/qml/BrowserDataSetPane.qml + apps/ied_simulator/src/MmsReportController.hpp + - name: IED Browser session ownership gates env: QT_QPA_PLATFORM: offscreen From 14e746e05fbd43ad74a49acb66952f0f2868eeef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:28:17 +0700 Subject: [PATCH 19/28] P6C: prove static authored report path and fail-closed dynamic actions --- .../src/ied_report_workspace_qa.cpp | 70 ++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/src/ied_report_workspace_qa.cpp b/apps/ied_simulator/src/ied_report_workspace_qa.cpp index 7beb7d9c6..9e659c8f6 100644 --- a/apps/ied_simulator/src/ied_report_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_report_workspace_qa.cpp @@ -180,7 +180,18 @@ int main(int argc, char* argv[]) { const bool noConnectionEnableRejected = !negative.enableSelected(true); const bool inactiveDisableRejected = !negative.disableSelected(); const bool invalidSelectionRejected = !negative.selectRcb(0) && !negative.selectDataSet(0); - if (!noConnectionEnableRejected || !inactiveDisableRejected || !invalidSelectionRejected) { + const bool authoredEnableRejected = !negative.enableSelectedAuthored( + QStringLiteral("LD0/LLN0.Test"), + {QStringLiteral("data-change")}, + {QStringLiteral("sequence-number")}, + false); + const bool dynamicCreateRejected = !negative.createDynamicDataSet( + QStringLiteral("LD0/LLN0.Test"), {}); + const bool dynamicDeleteRejected = !negative.deleteDynamicDataSet( + QStringLiteral("LD0/LLN0.Test")); + if (!noConnectionEnableRejected || !inactiveDisableRejected || + !invalidSelectionRejected || !authoredEnableRejected || + !dynamicCreateRejected || !dynamicDeleteRejected) { qCritical() << "REPORTS_WORKBENCH_FAIL negative_preconditions"; return 3; } @@ -232,6 +243,18 @@ int main(int argc, char* argv[]) { << dataSets.size() << reportControls.size() << staticCandidates.size(); return 8; } + for (const auto& value : dataSets) { + const auto item = value.toMap(); + if (item.value(QStringLiteral("dynamicOwned")).toBool() || + !item.value(QStringLiteral("immutable")).toBool()) { + qCritical() << "REPORTS_WORKBENCH_FAIL static_dataset_mutability"; + return 27; + } + } + if (!reports.ownedDynamicDataSets().isEmpty()) { + qCritical() << "REPORTS_WORKBENCH_FAIL unexpected_dynamic_ownership"; + return 28; + } if (!verifyIedScoutIndexedInventory(sclPath, reportControls)) { QStringList actual; for (const auto& value : reportControls) { @@ -356,6 +379,44 @@ int main(int argc, char* argv[]) { return 18; } + if (selectEligibleRcb(reports, false, exactUrcb) < 0) { + qCritical() << "REPORTS_WORKBENCH_FAIL authored_urcb_select"; + return 29; + } + const auto authoredDataSet = + reports.selectedRcb().value(QStringLiteral("dataSet")).toString(); + const QStringList authoredTriggers{ + QStringLiteral("data-change"), + QStringLiteral("quality-change"), + QStringLiteral("integrity"), + QStringLiteral("general-interrogation")}; + const QStringList authoredOptional{ + QStringLiteral("sequence-number"), + QStringLiteral("report-time-stamp"), + QStringLiteral("reason-for-inclusion"), + QStringLiteral("data-set-name"), + QStringLiteral("data-reference"), + QStringLiteral("configuration-revision")}; + if (!reports.enableSelectedAuthored( + authoredDataSet, + authoredTriggers, + authoredOptional, + true) || + !waitUntil([&reports] { return reports.active() && !reports.busy(); }, 8'000) || + !waitUntil([&reports] { return reports.receivedReportCount() > 0; }, 5'000)) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL authored_static_enable" + << reports.lastError() + << reports.diagnosticsText(); + return 30; + } + if (!reports.disableSelected() || + !waitUntil([&reports] { return !reports.active() && !reports.busy(); }, 6'000) || + reports.cleanupRequired()) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL authored_static_cleanup" + << reports.lastError(); + return 31; + } + qulonglong brcbGiReports = 0; if (iedScoutFixture) { if (selectEligibleRcb(reports, true, exactBrcb) < 0 || @@ -409,7 +470,9 @@ int main(int argc, char* argv[]) { << "brcb_inventory=pass" << "entryid_indicator=pass" << "cleanup=pass" - << "active_reconnect_cleanup=pass"; + << "active_reconnect_cleanup=pass" + << "authored_static_rcb=pass" + << "static_dataset_immutable=pass"; qInfo().noquote() << "REPORTS_WORKBENCH_NEGATIVE_PASS" << "no_connection_enable=rejected" @@ -417,6 +480,7 @@ int main(int argc, char* argv[]) { << "invalid_selection=rejected" << "strict_single_candidate=true" << "idle_poll_nonfatal=true" - << "reconnect_reacquire=true"; + << "reconnect_reacquire=true" + << "dynamic_offline_actions=rejected"; return 0; } From 4103e237cfd1408240725b5bd8ed1a0283e7ff24 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:30:03 +0700 Subject: [PATCH 20/28] P6C: remove accidental duplicate report controller tail --- .../ied_simulator/src/MmsReportController.cpp | 984 ------------------ 1 file changed, 984 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 75faa66dc..aed64b45d 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -1794,987 +1794,3 @@ void MmsReportController::schedulePoll() { } }); } -); - if (separator == std::string::npos || separator == 0U || - separator + 1U >= objectName.item.size()) { - throw std::invalid_argument( - "Dynamic DataSet reference must resolve to LD/LN.DataSetName."); - } - mms::MmsDataSetCandidate candidate; - candidate.domain = objectName.domain; - candidate.logical_node = objectName.item.substr(0U, separator); - candidate.name = objectName.item.substr(separator + 1U); - candidate.reference = mms::MmsDataSetDirectoryCodec::to_iec_reference(objectName); - candidate.raw_mms_name = objectName.item; - return candidate; -} - -QVariantList markOwnedDataSets( - QVariantList dataSets, - const QStringList& ownedReferences) { - for (int row = 0; row < dataSets.size(); ++row) { - auto map = dataSets.at(row).toMap(); - const auto reference = map.value(QStringLiteral("reference")).toString(); - bool owned = false; - for (const auto& candidate : ownedReferences) { - if (sameDataSetReference(reference, candidate)) { - owned = true; - break; - } - } - map.insert(QStringLiteral("dynamicOwned"), owned); - map.insert(QStringLiteral("immutable"), !owned); - dataSets[row] = map; - } - return dataSets; -} - -QVariantMap candidateMap(const mms::MmsRcbCandidateEvaluation& candidate) { - QVariantMap map; - map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); - map.insert(QStringLiteral("mode"), QString::fromStdString(candidate.mode)); - map.insert(QStringLiteral("dataSet"), QString::fromStdString(candidate.data_set_reference)); - map.insert(QStringLiteral("reportId"), QString::fromStdString(candidate.report_id)); - map.insert(QStringLiteral("score"), candidate.score); - map.insert(QStringLiteral("availability"), QString::fromUtf8(mms::mms_rcb_availability_kind_name(candidate.availability).data())); - map.insert(QStringLiteral("decision"), QString::fromUtf8(mms::mms_rcb_selection_decision_name(candidate.decision).data())); - map.insert(QStringLiteral("reason"), QString::fromStdString(candidate.reason)); - map.insert(QStringLiteral("recommendedAction"), QString::fromStdString(candidate.recommended_action)); - map.insert(QStringLiteral("preferred"), candidate.preferred); - map.insert(QStringLiteral("sameDataSet"), candidate.same_data_set); - map.insert(QStringLiteral("sameLogicalDevice"), candidate.same_logical_device); - map.insert(QStringLiteral("selectable"), candidate.selectable()); - return map; -} - -struct DiscoveryUi final { - QVariantList dataSets; - QVariantList reportControls; - QVariantList staticCandidates; - QVariantList dynamicCandidates; - QString associationProfile; -}; - -DiscoveryUi buildDiscoveryUi( - const mms::MmsLiveDiscoveryResult& discovery, - const QString& associationProfile) { - DiscoveryUi ui; - ui.associationProfile = associationProfile; - - for (const auto& candidate : discovery.report_inventory.data_sets) { - QVariantMap map; - map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); - map.insert(QStringLiteral("domain"), QString::fromStdString(candidate.domain)); - map.insert(QStringLiteral("logicalNode"), QString::fromStdString(candidate.logical_node)); - map.insert(QStringLiteral("name"), QString::fromStdString(candidate.name)); - QStringList members; - bool directoryAvailable = false; - bool deletable = false; - for (const auto& evidence : discovery.data_set_directories) { - if (evidence.candidate.reference != candidate.reference || !evidence.directory) continue; - directoryAvailable = true; - deletable = evidence.directory->deletable; - for (const auto& member : evidence.directory->members) { - QString text = QString::fromStdString(member.user_reference); - if (!member.functional_constraint.empty()) { - text += QStringLiteral(" [") + QString::fromStdString(member.functional_constraint) + QLatin1Char(']'); - } - members.push_back(text); - } - break; - } - map.insert(QStringLiteral("directoryAvailable"), directoryAvailable); - map.insert(QStringLiteral("deletable"), deletable); - map.insert(QStringLiteral("dynamicOwned"), false); - map.insert(QStringLiteral("immutable"), true); - map.insert(QStringLiteral("memberCount"), members.size()); - map.insert(QStringLiteral("members"), members); - ui.dataSets.push_back(map); - } - - for (const auto& candidate : discovery.report_inventory.report_controls) { - QVariantMap map; - map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); - map.insert(QStringLiteral("mode"), QString::fromStdString(candidate.mode())); - map.insert(QStringLiteral("buffered"), candidate.buffered); - map.insert(QStringLiteral("domain"), QString::fromStdString(candidate.domain)); - map.insert(QStringLiteral("logicalNode"), QString::fromStdString(candidate.logical_node)); - map.insert(QStringLiteral("name"), QString::fromStdString(candidate.name)); - QStringList attributes; - for (const auto& item : candidate.attributes) attributes.push_back(QString::fromStdString(item)); - map.insert(QStringLiteral("attributes"), attributes); - map.insert(QStringLiteral("supportsEntryId"), attributes.contains(QStringLiteral("EntryID"))); - map.insert(QStringLiteral("supportsPurgeBuf"), attributes.contains(QStringLiteral("PurgeBuf"))); - - for (const auto& evidence : discovery.report_controls) { - if (evidence.candidate.reference != candidate.reference) continue; - map.insert(QStringLiteral("probeOk"), evidence.success()); - map.insert(QStringLiteral("probeError"), QString::fromStdString(evidence.error)); - if (evidence.state) { - const auto& state = *evidence.state; - map.insert(QStringLiteral("dataSet"), QString::fromStdString(state.data_set_reference)); - map.insert(QStringLiteral("reportId"), QString::fromStdString(state.report_id)); - map.insert(QStringLiteral("confRev"), numberText(state.configuration_revision)); - map.insert(QStringLiteral("bufTm"), numberText(state.buffer_time_ms)); - map.insert(QStringLiteral("intgPd"), numberText(state.integrity_period_ms)); - map.insert(QStringLiteral("sqNum"), numberText(state.sequence_number)); - map.insert(QStringLiteral("enabled"), boolText(state.report_enabled)); - map.insert(QStringLiteral("reserved"), boolText(state.reserved)); - map.insert(QStringLiteral("resvTms"), numberText(state.reservation_time_seconds)); - map.insert(QStringLiteral("owner"), hexBytes(state.owner)); - map.insert(QStringLiteral("entryId"), hexBytes(state.entry_id)); - map.insert(QStringLiteral("triggerOptions"), QString::fromStdString( - [&state] { - std::string result; - for (const auto& name : state.trigger_options.names) { - if (!result.empty()) result += ", "; - result += name; - } - return result; - }())); - map.insert(QStringLiteral("optionalFields"), QString::fromStdString( - [&state] { - std::string result; - for (const auto& name : state.optional_fields.names) { - if (!result.empty()) result += ", "; - result += name; - } - return result; - }())); - map.insert(QStringLiteral("availability"), availabilityText(state.availability)); - map.insert(QStringLiteral("availabilityReason"), QString::fromStdString(state.availability_reason)); - } - break; - } - if (!map.contains(QStringLiteral("probeOk"))) map.insert(QStringLiteral("probeOk"), false); - ui.reportControls.push_back(map); - } - - try { - const auto staticSelection = mms::MmsRcbPoolSelector::build_static_selection(discovery); - for (const auto& candidate : staticSelection.candidates) ui.staticCandidates.push_back(candidateMap(candidate)); - } catch (...) { - } - try { - const auto dynamicSelection = mms::MmsRcbPoolSelector::build_dynamic_selection(discovery); - for (const auto& candidate : dynamicSelection.candidates) ui.dynamicCandidates.push_back(candidateMap(candidate)); - } catch (...) { - } - return ui; -} - - -mms::MmsObjectName contextObjectName( - const std::string& reference, - const std::string& fallbackDomain, - const std::string& fallbackItem) { - const auto slash = reference.find('/'); - if (slash != std::string::npos && slash > 0U && slash + 1U < reference.size()) { - return mms::MmsObjectName::domain_specific( - reference.substr(0U, slash), reference.substr(slash + 1U)); - } - return mms::MmsObjectName::domain_specific(fallbackDomain, fallbackItem); -} - -std::span confirmedPayload( - const mms::MmsConfirmedExchangeResult& exchange) { - return exchange.presentation_payload.empty() - ? std::span{exchange.envelope.mms_payload} - : std::span{exchange.presentation_payload}; -} - -mms::MmsLiveDiscoveryResult buildContextDiscoverySeed( - const mms::MmsLiveModelDocument& model) { - mms::MmsLiveDiscoveryResult result; - result.endpoint = model.endpoint; - result.association_profile = "CanonicalEngineeringContext"; - - for (const auto& dataSet : model.data_sets) { - mms::MmsDataSetCandidate candidate; - candidate.domain = dataSet.domain; - candidate.logical_node = dataSet.logical_node; - candidate.name = dataSet.name; - candidate.reference = dataSet.reference; - candidate.raw_mms_name = dataSet.logical_node + - (dataSet.name.empty() ? std::string{} : "$" + dataSet.name); - result.report_inventory.data_sets.push_back(candidate); - - mms::MmsDataSetDirectoryEvidence evidence; - evidence.candidate = candidate; - mms::MmsDataSetDirectoryResponse directory; - directory.deletable = dataSet.deletable.value_or(false); - directory.members.reserve(dataSet.members.size()); - for (const auto& member : dataSet.members) { - mms::MmsDataSetDirectoryMember projected; - projected.object_name = contextObjectName( - member.mms_reference, dataSet.domain, member.reference); - projected.mms_reference = member.mms_reference.empty() - ? projected.object_name.reference() - : member.mms_reference; - projected.user_reference = member.reference.empty() - ? projected.mms_reference - : member.reference; - projected.functional_constraint = member.functional_constraint; - projected.logical_node = dataSet.logical_node; - projected.confidence = 100U; - directory.members.push_back(std::move(projected)); - } - evidence.directory = std::move(directory); - result.data_set_directories.push_back(std::move(evidence)); - } - - static const std::vector reportAttributes{ - "DatSet", "RptID", "ConfRev", "IntgPd", "BufTm", "SqNum", - "RptEna", "Resv", "ResvTms", "Owner", "EntryID", "TimeOfEntry", - "TrgOps", "OptFlds"}; - - for (const auto& control : model.report_controls) { - mms::MmsReportControlCandidate candidate; - candidate.domain = control.domain; - candidate.logical_node = control.logical_node; - candidate.functional_constraint = control.buffered ? "BR" : "RP"; - candidate.name = control.name; - candidate.reference = control.reference; - candidate.buffered = control.buffered; - candidate.attributes = reportAttributes; - result.report_inventory.report_controls.push_back(std::move(candidate)); - } - return result; -} - -void probeContextReportControls( - mms::MmsAssociationRuntime& association, - mms::MmsLiveDiscoveryResult& discovery, - const std::stop_token stopToken) { - if (discovery.report_inventory.report_controls.size() > 1'024U) { - throw std::runtime_error( - "Canonical report inventory exceeds the desktop bound of 1024 RCBs."); - } - discovery.report_controls.clear(); - discovery.report_controls.reserve(discovery.report_inventory.report_controls.size()); - for (const auto& candidate : discovery.report_inventory.report_controls) { - if (stopToken.stop_requested()) { - throw std::runtime_error("Canonical report service attach cancelled."); - } - mms::MmsReportControlEvidence evidence; - evidence.candidate = candidate; - evidence.requested_attributes = candidate.attributes; - try { - const auto invokeId = association.next_invoke_id(); - const auto request = mms::MmsReportControlStateMapper::build_read_request( - invokeId, candidate, evidence.requested_attributes); - const auto encoded = mms::MmsServiceCodec::encode_read_request_p_data( - request, association.negotiated().presentation_context_id); - const auto exchange = association.exchange_confirmed( - encoded, invokeId, stopToken); - const auto response = mms::MmsServiceCodec::decode_read_response( - confirmedPayload(exchange), invokeId); - evidence.state = mms::MmsReportControlStateMapper::map_read_response( - candidate, evidence.requested_attributes, response); - } catch (const std::exception& exception) { - evidence.error = exception.what(); - discovery.diagnostics.push_back( - candidate.reference + ": targeted RCB attach read failed: " + evidence.error); - } - discovery.report_controls.push_back(std::move(evidence)); - } -} - -struct ReportUi final { - QVariantList reports; - QStringList events; - qulonglong receivedCount{}; - bool active{}; - bool cleanupRequired{}; -}; - -QString reportValueText(const mms::MmsReportValue& item) { - QString value = QStringLiteral(""); - if (item.value) value = QString::fromStdString(mms::MmsDataCodec::to_display_string(*item.value)); - QString reference = QString::fromStdString(item.data_reference); - if (reference.isEmpty() && item.member) reference = QString::fromStdString(item.member->user_reference); - if (reference.isEmpty()) reference = QStringLiteral("member[%1]").arg(static_cast(item.data_set_index)); - QString reasons; - for (const auto& reason : item.reason_for_inclusion.names) { - if (!reasons.isEmpty()) reasons += QStringLiteral(", "); - reasons += QString::fromStdString(reason); - } - return QStringLiteral("%1 = %2%3") - .arg(reference, value, reasons.isEmpty() ? QString{} : QStringLiteral(" · ") + reasons); -} - -ReportUi buildSubscriptionUi( - const mms::MmsReportSubscriptionSnapshot& subscription, - const bool active) { - ReportUi ui; - ui.active = active; - ui.receivedCount = subscription.received_reports; - ui.cleanupRequired = subscription.cleanup_required; - for (const auto& event : subscription.events) { - ui.events.push_back(QString::fromStdString(event.message)); - } - while (ui.events.size() > 256) ui.events.removeFirst(); - - for (auto streamIt = subscription.streams.rbegin(); - streamIt != subscription.streams.rend(); ++streamIt) { - for (auto frameIt = streamIt->recent_frames.rbegin(); - frameIt != streamIt->recent_frames.rend(); ++frameIt) { - if (ui.reports.size() >= kMaximumVisibleReports) break; - const auto& frame = *frameIt; - QVariantMap map; - map.insert(QStringLiteral("reportId"), QString::fromStdString(frame.header.report_id)); - map.insert(QStringLiteral("dataSet"), QString::fromStdString(frame.header.data_set_reference)); - map.insert(QStringLiteral("sequence"), numberText(frame.header.sequence_number)); - map.insert(QStringLiteral("subSequence"), numberText(frame.header.sub_sequence_number)); - map.insert(QStringLiteral("confRev"), numberText(frame.header.configuration_revision)); - map.insert(QStringLiteral("entryId"), hexBytes(frame.header.entry_id)); - map.insert(QStringLiteral("overflow"), frame.header.buffer_overflow.value_or(false)); - map.insert(QStringLiteral("decoderMode"), QString::fromStdString(frame.decoder_mode)); - map.insert(QStringLiteral("valueCount"), static_cast(frame.values.size())); - QStringList values; - for (const auto& value : frame.values) values.push_back(reportValueText(value)); - map.insert(QStringLiteral("values"), values); - map.insert(QStringLiteral("valuesSummary"), values.mid(0, 3).join(QStringLiteral(" | "))); - map.insert(QStringLiteral("duplicateCount"), static_cast(streamIt->state.duplicate_count)); - map.insert(QStringLiteral("gapCount"), static_cast(streamIt->state.gap_count)); - map.insert(QStringLiteral("resetCount"), static_cast(streamIt->state.reset_count)); - map.insert(QStringLiteral("overflowCount"), static_cast(streamIt->state.buffer_overflow_count)); - ui.reports.push_back(map); - } - if (ui.reports.size() >= kMaximumVisibleReports) break; - } - return ui; -} - -ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { - if (!snapshot.subscription) { - ReportUi ui; - ui.active = snapshot.active; - return ui; - } - return buildSubscriptionUi(*snapshot.subscription, snapshot.active); -} - -ReportUi buildReportUi(const mms::MmsReportSubscriptionSnapshot& snapshot) { - return buildSubscriptionUi( - snapshot, - snapshot.state == mms::MmsReportSubscriptionState::active); -} - -} // namespace - -struct MmsReportController::WorkerState final { - std::unique_ptr session; - std::shared_ptr discovery; - std::unique_ptr report; - std::unique_ptr authoredReport; - std::unique_ptr dynamicDataSets; -}; - -MmsReportController::MmsReportController(QObject* parent) - : QObject(parent), workerState_(std::make_shared()), - stopSource_(std::make_shared()) { - ioPool_.setMaxThreadCount(1); - ioPool_.setExpiryTimeout(-1); - pollTimer_.setInterval(100); - pollTimer_.setTimerType(Qt::PreciseTimer); - connect(&pollTimer_, &QTimer::timeout, this, &MmsReportController::schedulePoll); -} - -MmsReportController::~MmsReportController() { - if (stopSource_) stopSource_->request_stop(); - pollTimer_.stop(); - ioPool_.clear(); - ioPool_.waitForDone(); -} - -void MmsReportController::setHost(const QString& value) { - const auto normalized = value.trimmed(); - if (host_ == normalized) return; - host_ = normalized; - emit configurationChanged(); -} - -void MmsReportController::setPort(const int value) { - if (value < 1 || value > 65'535 || port_ == value) return; - port_ = value; - emit configurationChanged(); -} - -void MmsReportController::setEngineeringContext(IedEngineeringContextController* value) { - if (engineeringContext_ == value) return; - if (engineeringContext_) disconnect(engineeringContext_, nullptr, this, nullptr); - engineeringContext_ = value; - if (engineeringContext_) { - connect( - engineeringContext_, - &IedEngineeringContextController::contextChanged, - this, - [this] { - if (!connected() && !busy()) adoptEngineeringInventory(); - }); - } - emit engineeringContextChanged(); - if (!connected() && !busy()) adoptEngineeringInventory(); -} - -bool MmsReportController::connected() const noexcept { - return state_ == State::ready || state_ == State::enabling || state_ == State::active || - state_ == State::disabling || state_ == State::cleanup_required; -} - -bool MmsReportController::busy() const noexcept { - return operationBusy_ || state_ == State::connecting || state_ == State::discovering || - state_ == State::enabling || state_ == State::disabling; -} - -QString MmsReportController::stateText() const { - switch (state_) { - case State::disconnected: return QStringLiteral("Disconnected"); - case State::connecting: return QStringLiteral("Connecting"); - case State::discovering: return QStringLiteral("Attaching reports"); - case State::ready: return QStringLiteral("Ready"); - case State::authoring: return QStringLiteral("Authoring DataSet"); - case State::enabling: return QStringLiteral("Enabling RCB"); - case State::active: return QStringLiteral("Report subscription active"); - case State::disabling: return QStringLiteral("Releasing RCB"); - case State::cleanup_required: return QStringLiteral("Cleanup required"); - case State::faulted: return QStringLiteral("Faulted"); - } - return {}; -} - -void MmsReportController::appendDiagnostic(const QString& text) { - if (text.isEmpty()) return; - while (diagnostics_.size() >= 128) diagnostics_.removeFirst(); - diagnostics_.push_back(text); - emit diagnosticsChanged(); -} - -QString MmsReportController::diagnosticsText() const { - return diagnostics_.join(QLatin1Char('\n')); -} - -std::shared_ptr MmsReportController::replaceStopSource() { - if (stopSource_) stopSource_->request_stop(); - stopSource_ = std::make_shared(); - return stopSource_; -} - -void MmsReportController::clearInventory() { - dataSets_.clear(); - reportControls_.clear(); - staticCandidates_.clear(); - dynamicCandidates_.clear(); - ownedDynamicDataSets_.clear(); - associationProfile_.clear(); - selectedRcbIndex_ = -1; - selectedDataSetIndex_ = -1; - selectedRcb_.clear(); - selectedDataSetMembers_.clear(); - receivedReports_.clear(); - events_.clear(); - receivedReportCount_ = 0; - emit inventoryChanged(); - emit selectionChanged(); - emit reportsChanged(); -} - -void MmsReportController::adoptEngineeringInventory() { - if (!engineeringContext_ || !engineeringContext_->loaded() || - engineeringContext_->selectionRequired() || - engineeringContext_->modelSnapshot() == nullptr) { - clearInventory(); - return; - } - - const auto seed = buildContextDiscoverySeed(*engineeringContext_->modelSnapshot()); - const auto ui = buildDiscoveryUi(seed, QString{}); - dataSets_ = ui.dataSets; - reportControls_ = ui.reportControls; - staticCandidates_.clear(); - dynamicCandidates_.clear(); - associationProfile_.clear(); - selectedRcbIndex_ = reportControls_.isEmpty() ? -1 : 0; - selectedDataSetIndex_ = dataSets_.isEmpty() ? -1 : 0; - receivedReports_.clear(); - events_.clear(); - receivedReportCount_ = 0; - refreshSelection(); - emit inventoryChanged(); - emit reportsChanged(); -} - -bool MmsReportController::connectToIed() { - if (busy() || connected()) return false; - if (host_.trimmed().isEmpty() || port_ < 1 || port_ > 65'535) { - lastError_ = QStringLiteral("A non-empty host and TCP port 1..65535 are required."); - state_ = State::faulted; - emit stateChanged(); - return false; - } - - const auto stop = replaceStopSource(); - const auto generation = ++generation_; - const auto requestedHost = host_.trimmed(); - const auto requestedPort = port_; - state_ = State::connecting; - operationBusy_ = true; - active_ = false; - cleanupRequired_ = false; - pollPending_ = false; - lastError_.clear(); - clearInventory(); - if (engineeringContext_) adoptEngineeringInventory(); - std::shared_ptr contextModel; - if (engineeringContext_ && engineeringContext_->loaded() && - !engineeringContext_->selectionRequired() && - engineeringContext_->modelSnapshot() != nullptr) { - contextModel = std::make_shared( - *engineeringContext_->modelSnapshot()); - } - appendDiagnostic(QStringLiteral("Reports connect %1:%2 · generation %3") - .arg(requestedHost).arg(requestedPort).arg(generation)); - emit stateChanged(); - - const QPointer self{this}; - const auto worker = workerState_; - ioPool_.start([self, worker, stop, generation, requestedHost, requestedPort, contextModel] { - try { - if (worker->report) worker->report->stop(); - if (worker->authoredReport) worker->authoredReport->stop(); - worker->report.reset(); - worker->authoredReport.reset(); - worker->dynamicDataSets.reset(); - if (worker->session) worker->session->disconnect(); - worker->session.reset(); - worker->discovery.reset(); - - mms::MmsAssociationOptions associationOptions; - associationOptions.connect_timeout = std::chrono::milliseconds{5'000}; - associationOptions.request_timeout = std::chrono::milliseconds{5'000}; - auto session = std::make_unique( - mms::TcpMmsTransportOptions{}, associationOptions); - session->connect( - {requestedHost.toStdString(), static_cast(requestedPort)}, - stop->get_token()); - - if (self) { - QMetaObject::invokeMethod(self, [self, generation] { - if (!self || self->generation_ != generation) return; - self->state_ = State::discovering; - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - - std::shared_ptr discovery; - if (contextModel) { - discovery = std::make_shared( - buildContextDiscoverySeed(*contextModel)); - probeContextReportControls( - session->association(), *discovery, stop->get_token()); - } else { - // Standalone Reports workspace compatibility path. Browser-owned - // services always receive engineeringContext and therefore skip - // this full discovery path. - mms::MmsLiveDiscoveryOptions options; - options.maximum_variable_type_probes = 8'192U; - options.maximum_data_set_directories = 4'096U; - options.maximum_report_control_probes = 1'024U; - discovery = std::make_shared( - session->discover(options, stop->get_token())); - } - const auto profile = QString::fromStdString( - session->association().active_association_profile()); - const auto ui = buildDiscoveryUi(*discovery, profile); - mms::MmsDynamicDataSetOptions dynamicOptions; - dynamicOptions.maximum_members = 64U; - dynamicOptions.verify_after_create = true; - worker->dynamicDataSets = std::make_unique( - session->association(), dynamicOptions); - worker->discovery = discovery; - worker->session = std::move(session); - - if (self) { - QMetaObject::invokeMethod(self, [self, generation, ui] { - if (!self || self->generation_ != generation) return; - self->dataSets_ = ui.dataSets; - self->reportControls_ = ui.reportControls; - self->staticCandidates_ = ui.staticCandidates; - self->dynamicCandidates_ = ui.dynamicCandidates; - self->associationProfile_ = ui.associationProfile; - self->state_ = State::ready; - self->operationBusy_ = false; - self->lastError_.clear(); - if (!self->reportControls_.isEmpty()) self->selectedRcbIndex_ = 0; - else if (!self->dataSets_.isEmpty()) self->selectedDataSetIndex_ = 0; - self->refreshSelection(); - self->appendDiagnostic(QStringLiteral("Report service attach complete · %1 DataSet · %2 RCB") - .arg(self->dataSets_.size()).arg(self->reportControls_.size())); - emit self->inventoryChanged(); - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - } catch (const std::exception& exception) { - if (worker->session) worker->session->disconnect(); - worker->report.reset(); - worker->authoredReport.reset(); - worker->dynamicDataSets.reset(); - worker->session.reset(); - worker->discovery.reset(); - if (self) { - const auto message = QString::fromUtf8(exception.what()); - QMetaObject::invokeMethod(self, [self, generation, message] { - if (!self || self->generation_ != generation) return; - self->state_ = State::faulted; - self->operationBusy_ = false; - self->lastError_ = message; - self->appendDiagnostic(QStringLiteral("Report service attach failed · %1").arg(message)); - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - } - }); - return true; -} - -void MmsReportController::disconnectFromIed() { - if (stopSource_) stopSource_->request_stop(); - stopSource_ = std::make_shared(); - const auto cleanupStop = stopSource_; - const auto generation = ++generation_; - pollTimer_.stop(); - pollPending_ = false; - operationBusy_ = false; - active_ = false; - cleanupRequired_ = false; - state_ = State::disconnected; - lastError_.clear(); - clearInventory(); - adoptEngineeringInventory(); - appendDiagnostic(QStringLiteral("Reports disconnected · runtime association released; canonical inventory retained.")); - emit stateChanged(); - - const QPointer self{this}; - const auto worker = workerState_; - ioPool_.start([self, worker, cleanupStop, generation] { - bool cleanupRequired = false; - if (worker->report) { - worker->report->stop(cleanupStop->get_token()); - const auto snapshot = worker->report->snapshot(); - cleanupRequired = snapshot.subscription && snapshot.subscription->cleanup_required; - } - if (worker->session) worker->session->disconnect(cleanupStop->get_token()); - worker->report.reset(); - worker->discovery.reset(); - worker->session.reset(); - if (cleanupRequired && self) { - QMetaObject::invokeMethod(self, [self, generation] { - if (!self || self->generation_ != generation) return; - self->lastError_ = QStringLiteral("RCB cleanup could not be confirmed before disconnect; verify the remote RCB before reuse."); - self->appendDiagnostic(self->lastError_); - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - }); -} - -bool MmsReportController::reconnect() { - disconnectFromIed(); - return connectToIed(); -} - -void MmsReportController::refreshSelection() { - selectedRcb_.clear(); - selectedDataSetMembers_.clear(); - if (selectedRcbIndex_ >= 0 && selectedRcbIndex_ < reportControls_.size()) { - selectedRcb_ = reportControls_.at(selectedRcbIndex_).toMap(); - const auto bound = selectedRcb_.value(QStringLiteral("dataSet")).toString(); - if (!bound.isEmpty()) { - for (int row = 0; row < dataSets_.size(); ++row) { - const auto map = dataSets_.at(row).toMap(); - if (!sameDataSetReference(bound, map.value(QStringLiteral("reference")).toString())) continue; - selectedDataSetIndex_ = row; - selectedDataSetMembers_ = map.value(QStringLiteral("members")).toStringList(); - break; - } - } - } - if (selectedDataSetMembers_.isEmpty() && selectedDataSetIndex_ >= 0 && selectedDataSetIndex_ < dataSets_.size()) { - selectedDataSetMembers_ = dataSets_.at(selectedDataSetIndex_).toMap().value(QStringLiteral("members")).toStringList(); - } - emit selectionChanged(); -} - -bool MmsReportController::selectRcb(const int row) { - if (row < 0 || row >= reportControls_.size()) return false; - selectedRcbIndex_ = row; - refreshSelection(); - return true; -} - -bool MmsReportController::selectDataSet(const int row) { - if (row < 0 || row >= dataSets_.size()) return false; - selectedDataSetIndex_ = row; - selectedDataSetMembers_ = dataSets_.at(row).toMap().value(QStringLiteral("members")).toStringList(); - emit selectionChanged(); - return true; -} - -bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) { - if (!connected() || busy() || active_ || cleanupRequired_ || selectedRcb_.isEmpty()) return false; - const auto reference = selectedRcb_.value(QStringLiteral("reference")).toString(); - if (reference.isEmpty()) return false; - - const auto generation = generation_; - const auto stop = stopSource_; - operationBusy_ = true; - state_ = State::enabling; - lastError_.clear(); - emit stateChanged(); - - const QPointer self{this}; - const auto worker = workerState_; - ioPool_.start([self, worker, stop, generation, reference, requestGeneralInterrogation] { - try { - if (!worker->session || !worker->session->associated() || !worker->discovery) { - throw std::runtime_error("MMS report association is not active."); - } - if (worker->report) { - const auto previous = worker->report->snapshot(); - if (previous.active || (previous.subscription && previous.subscription->cleanup_required)) { - throw std::runtime_error("Previous report subscription still owns state or requires cleanup."); - } - worker->report.reset(); - } - - mms::MmsStaticReportSessionOptions options; - options.selection.preferred_rcb_reference = reference.toStdString(); - options.selection.strict_rcb = true; - // Strict selection still pins the exact operator-selected RCB. This - // flag permits an explicitly selected URCB; it does not authorize - // fallback to another RCB because strict_rcb remains true. - options.selection.allow_urcb_fallback = true; - options.selection.allow_polling_fallback = false; - options.maximum_candidate_attempts = 1U; - options.subscription.trigger_general_interrogation = requestGeneralInterrogation; - options.subscription.reserve_unbuffered_rcb = true; - options.subscription.maximum_events = 256U; - options.subscription.monitor_options.maximum_streams = 64U; - options.subscription.monitor_options.maximum_frames_per_stream = 64U; - - auto report = std::make_unique( - worker->session->association(), *worker->discovery, options); - report->prepare(stop->get_token()); - report->start(stop->get_token()); - const auto ui = buildReportUi(report->snapshot()); - worker->report = std::move(report); - - if (self) { - QMetaObject::invokeMethod(self, [self, generation, ui, requestGeneralInterrogation] { - if (!self || self->generation_ != generation) return; - self->operationBusy_ = false; - self->active_ = ui.active; - self->cleanupRequired_ = ui.cleanupRequired; - self->receivedReports_ = ui.reports; - self->events_ = ui.events; - self->receivedReportCount_ = ui.receivedCount; - self->state_ = ui.cleanupRequired ? State::cleanup_required : State::active; - self->appendDiagnostic(requestGeneralInterrogation - ? QStringLiteral("RCB enabled; GI requested explicitly.") - : QStringLiteral("RCB enabled without GI.")); - emit self->reportsChanged(); - emit self->stateChanged(); - if (self->active_) self->pollTimer_.start(); - }, Qt::QueuedConnection); - } - } catch (const std::exception& exception) { - if (self) { - const auto message = QString::fromUtf8(exception.what()); - QMetaObject::invokeMethod(self, [self, generation, message] { - if (!self || self->generation_ != generation) return; - self->operationBusy_ = false; - self->active_ = false; - self->lastError_ = message; - self->state_ = State::faulted; - self->appendDiagnostic(QStringLiteral("RCB enable failed · %1").arg(message)); - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - } - }); - return true; -} - -bool MmsReportController::disableSelected() { - if (!active_ || operationBusy_) return false; - pollTimer_.stop(); - const auto stop = replaceStopSource(); - const auto generation = generation_; - operationBusy_ = true; - state_ = State::disabling; - emit stateChanged(); - - const QPointer self{this}; - const auto worker = workerState_; - ioPool_.start([self, worker, stop, generation] { - try { - if (!worker->report) throw std::runtime_error("No report subscription is active."); - worker->report->stop(stop->get_token()); - const auto snapshot = worker->report->snapshot(); - const auto ui = buildReportUi(snapshot); - if (!ui.cleanupRequired) worker->report.reset(); - if (self) { - QMetaObject::invokeMethod(self, [self, generation, ui] { - if (!self || self->generation_ != generation) return; - self->pollPending_ = false; - self->operationBusy_ = false; - self->active_ = false; - self->cleanupRequired_ = ui.cleanupRequired; - self->receivedReports_ = ui.reports; - self->events_ = ui.events; - self->receivedReportCount_ = ui.receivedCount; - self->state_ = ui.cleanupRequired ? State::cleanup_required : State::ready; - self->appendDiagnostic(ui.cleanupRequired - ? QStringLiteral("RCB release incomplete; explicit cleanup retry required.") - : QStringLiteral("RCB disabled and reservation/ownership cleanup completed.")); - emit self->reportsChanged(); - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - } catch (const std::exception& exception) { - if (self) { - const auto message = QString::fromUtf8(exception.what()); - QMetaObject::invokeMethod(self, [self, generation, message] { - if (!self || self->generation_ != generation) return; - self->pollPending_ = false; - self->operationBusy_ = false; - self->active_ = false; - self->cleanupRequired_ = true; - self->state_ = State::cleanup_required; - self->lastError_ = message; - self->appendDiagnostic(QStringLiteral("RCB release failed · %1").arg(message)); - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - } - }); - return true; -} - -bool MmsReportController::retryCleanup() { - if (!cleanupRequired_ || operationBusy_) return false; - const auto stop = replaceStopSource(); - const auto generation = generation_; - operationBusy_ = true; - state_ = State::disabling; - emit stateChanged(); - - const QPointer self{this}; - const auto worker = workerState_; - ioPool_.start([self, worker, stop, generation] { - try { - if (!worker->session || !worker->session->associated() || !worker->report) { - throw std::runtime_error("Cleanup retry requires the original active MMS association."); - } - worker->report->stop(stop->get_token()); - const auto ui = buildReportUi(worker->report->snapshot()); - if (!ui.cleanupRequired) worker->report.reset(); - if (self) { - QMetaObject::invokeMethod(self, [self, generation, ui] { - if (!self || self->generation_ != generation) return; - self->operationBusy_ = false; - self->active_ = false; - self->cleanupRequired_ = ui.cleanupRequired; - self->receivedReports_ = ui.reports; - self->events_ = ui.events; - self->receivedReportCount_ = ui.receivedCount; - self->state_ = ui.cleanupRequired ? State::cleanup_required : State::ready; - self->lastError_.clear(); - self->appendDiagnostic(ui.cleanupRequired - ? QStringLiteral("RCB cleanup retry remains incomplete.") - : QStringLiteral("RCB cleanup retry completed.")); - emit self->reportsChanged(); - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - } catch (const std::exception& exception) { - if (self) { - const auto message = QString::fromUtf8(exception.what()); - QMetaObject::invokeMethod(self, [self, generation, message] { - if (!self || self->generation_ != generation) return; - self->operationBusy_ = false; - self->lastError_ = message; - self->state_ = State::cleanup_required; - self->appendDiagnostic(QStringLiteral("Cleanup retry rejected · %1").arg(message)); - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - } - }); - return true; -} - -void MmsReportController::schedulePoll() { - if (!active_ || operationBusy_ || pollPending_) return; - const auto generation = generation_; - const auto stop = stopSource_; - const auto worker = workerState_; - pollPending_ = true; - const QPointer self{this}; - ioPool_.start([self, worker, stop, generation] { - try { - if (!worker->session || !worker->session->associated() || !worker->report || !worker->report->active()) { - throw std::runtime_error("Report poll requires an active MMS association and RCB subscription."); - } - mms::MmsPduEnvelope envelope; - static_cast(worker->session->association().try_poll_once_for( - std::chrono::milliseconds{40}, envelope, stop->get_token())); - static_cast(worker->report->drain_queued_reports()); - const auto ui = buildReportUi(worker->report->snapshot()); - if (self) { - QMetaObject::invokeMethod(self, [self, generation, ui] { - if (!self || self->generation_ != generation) return; - self->pollPending_ = false; - self->active_ = ui.active; - self->cleanupRequired_ = ui.cleanupRequired; - self->receivedReports_ = ui.reports; - self->events_ = ui.events; - self->receivedReportCount_ = ui.receivedCount; - emit self->reportsChanged(); - }, Qt::QueuedConnection); - } - } catch (const mms::MmsTransportCancelledError&) { - if (self) { - QMetaObject::invokeMethod(self, [self, generation] { - if (!self || self->generation_ != generation) return; - self->pollPending_ = false; - }, Qt::QueuedConnection); - } - } catch (const std::exception& exception) { - ReportUi ui; - if (worker->report) { - worker->report->stop(); - ui = buildReportUi(worker->report->snapshot()); - } - if (self) { - const auto message = QString::fromUtf8(exception.what()); - QMetaObject::invokeMethod(self, [self, generation, message, ui] { - if (!self || self->generation_ != generation) return; - self->pollTimer_.stop(); - self->pollPending_ = false; - self->active_ = false; - self->cleanupRequired_ = ui.cleanupRequired; - self->receivedReports_ = ui.reports; - self->events_ = ui.events; - self->receivedReportCount_ = ui.receivedCount; - self->lastError_ = message; - self->state_ = ui.cleanupRequired ? State::cleanup_required : State::faulted; - self->appendDiagnostic(QStringLiteral("Report association/poll failed · %1").arg(message)); - emit self->reportsChanged(); - emit self->stateChanged(); - }, Qt::QueuedConnection); - } - } - }); -} From faebab78dbb08ba3a64891554d2683bd2ecd777f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:33:14 +0700 Subject: [PATCH 21/28] P6C: support explicit canonical RCB binding restore --- .../ied_simulator/src/MmsReportController.cpp | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index aed64b45d..22f442aaa 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -222,6 +222,7 @@ DiscoveryUi buildDiscoveryUi( if (evidence.state) { const auto& state = *evidence.state; map.insert(QStringLiteral("dataSet"), QString::fromStdString(state.data_set_reference)); + map.insert(QStringLiteral("canonicalDataSet"), QString::fromStdString(state.data_set_reference)); map.insert(QStringLiteral("reportId"), QString::fromStdString(state.report_id)); map.insert(QStringLiteral("confRev"), numberText(state.configuration_revision)); map.insert(QStringLiteral("bufTm"), numberText(state.buffer_time_ms)); @@ -1006,7 +1007,6 @@ bool MmsReportController::createDynamicDataSet( self->state_ = State::ready; self->lastError_.clear(); self->dataSets_ = ui.dataSets; - self->reportControls_ = ui.reportControls; self->staticCandidates_ = ui.staticCandidates; self->dynamicCandidates_ = ui.dynamicCandidates; self->associationProfile_ = ui.associationProfile; @@ -1153,7 +1153,6 @@ bool MmsReportController::deleteDynamicDataSet( self->state_ = State::ready; self->lastError_.clear(); self->dataSets_ = ui.dataSets; - self->reportControls_ = ui.reportControls; self->staticCandidates_ = ui.staticCandidates; self->dynamicCandidates_ = ui.dynamicCandidates; self->associationProfile_ = ui.associationProfile; @@ -1268,11 +1267,21 @@ bool MmsReportController::enableSelectedAuthored( const auto currentDataSet = selectedRcb_.value(QStringLiteral("dataSet")).toString().trimmed(); - if (!targetOwnedDynamic && - !sameDataSetReference(targetDataSet, currentDataSet)) { + const auto canonicalDataSet = + selectedRcb_.value(QStringLiteral("canonicalDataSet")).toString().trimmed(); + const bool currentBindingAuthoredDynamic = + selectedRcb_.value(QStringLiteral("dynamicBinding")).toBool(); + const bool restoringCanonicalBinding = + currentBindingAuthoredDynamic && + !canonicalDataSet.isEmpty() && + sameDataSetReference(targetDataSet, canonicalDataSet); + const bool bindingChanged = + !sameDataSetReference(targetDataSet, currentDataSet); + if (bindingChanged && !targetOwnedDynamic && !restoringCanonicalBinding) { lastError_ = QStringLiteral( "Static/non-owned DataSet binding is immutable in Browser authoring. " - "Create an owned dynamic DataSet to rebind the RCB."); + "Only an owned dynamic DataSet may replace it; an ARStack-authored " + "dynamic binding may be explicitly restored to its canonical static DataSet."); emit stateChanged(); return false; } @@ -1298,7 +1307,8 @@ bool MmsReportController::enableSelectedAuthored( ioPool_.start([ self, worker, stop, generation, selectedRow, rcbReference, targetDataSet, triggerOptions, optionalFields, requestGeneralInterrogation, - targetOwnedDynamic, writeTriggerOptions, writeOptionalFields, + targetOwnedDynamic, restoringCanonicalBinding, bindingChanged, + writeTriggerOptions, writeOptionalFields, trgOps = std::move(trgOps), optFlds = std::move(optFlds) ]() mutable { try { @@ -1328,12 +1338,17 @@ bool MmsReportController::enableSelectedAuthored( } ReportUi ui; - if (targetOwnedDynamic) { - if (!worker->dynamicDataSets || - !worker->dynamicDataSets->owns(targetDataSet.toStdString())) { + if (bindingChanged || targetOwnedDynamic) { + if (targetOwnedDynamic && + (!worker->dynamicDataSets || + !worker->dynamicDataSets->owns(targetDataSet.toStdString()))) { throw std::runtime_error( "Dynamic DataSet ownership is no longer valid for this association."); } + if (restoringCanonicalBinding && targetOwnedDynamic) { + throw std::runtime_error( + "Canonical restore target cannot simultaneously be classified as an owned dynamic DataSet."); + } const mms::MmsReportControlCandidate* candidate = nullptr; for (const auto& item : @@ -1423,7 +1438,8 @@ bool MmsReportController::enableSelectedAuthored( self, [self, generation, selectedRow, ui, targetDataSet, triggerOptions, optionalFields, - requestGeneralInterrogation, targetOwnedDynamic] { + requestGeneralInterrogation, targetOwnedDynamic, + restoringCanonicalBinding] { if (!self || self->generation_ != generation) return; self->operationBusy_ = false; self->active_ = ui.active; @@ -1459,11 +1475,14 @@ bool MmsReportController::enableSelectedAuthored( ? State::cleanup_required : State::active; self->appendDiagnostic( - requestGeneralInterrogation + restoringCanonicalBinding ? QStringLiteral( - "Authored RCB configuration verified; enabled; GI requested.") - : QStringLiteral( - "Authored RCB configuration verified and enabled.")); + "Authored RCB restored to canonical static DataSet; configuration verified and enabled.") + : (requestGeneralInterrogation + ? QStringLiteral( + "Authored RCB configuration verified; enabled; GI requested.") + : QStringLiteral( + "Authored RCB configuration verified and enabled."))); emit self->inventoryChanged(); emit self->reportsChanged(); emit self->stateChanged(); From 02b72673513fbdd2199491067f0783cbdfa9de7e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:33:32 +0700 Subject: [PATCH 22/28] P6C: expose safe restore to canonical report binding --- .../qml/BrowserReportAuthoringDialog.qml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml b/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml index b73639aa6..f4fec65a4 100644 --- a/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml +++ b/apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml @@ -33,9 +33,14 @@ Dialog { var ds = selectedDataSetMap() if (!ds || !ds.reference) return false - if (reports.selectedRcb.dataSet === ds.reference) + var rcb = reports.selectedRcb + if (rcb.dataSet === ds.reference) + return true + if (ds.dynamicOwned === true) return true - return ds.dynamicOwned === true + return rcb.dynamicBinding === true + && rcb.canonicalDataSet + && rcb.canonicalDataSet === ds.reference } function triggerNames() { @@ -208,7 +213,8 @@ Dialog { anchors.fill: parent anchors.margins: 8 text: "Binding to a different static/non-owned DataSet is blocked. " - + "Only an association-owned dynamic DataSet may replace RCB DatSet." + + "Only an association-owned dynamic DataSet may replace RCB DatSet. " + + "If ARStack previously applied a dynamic binding, its canonical static binding may be restored explicitly." color: theme.red font.pixelSize: 8 wrapMode: Text.WordWrap @@ -265,7 +271,7 @@ Dialog { anchors.fill: parent anchors.margins: 8 text: "Draft-only until Enable is confirmed. Static binding never rewrites DatSet. " - + "For an owned dynamic DataSet, DatSet + TrgOps + OptFlds are written only through " + + "For an owned dynamic DataSet—or an explicit restore to the canonical static binding—DatSet + TrgOps + OptFlds are written only through " + "the proven report runtime, read back, and only then RptEna is written." color: theme.muted font.pixelSize: 8 From 6230216d5f6811c80b28c6d16ed7eba16d5b0944 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:34:08 +0700 Subject: [PATCH 23/28] P6C: gate legacy static enable while dynamic binding is active --- apps/ied_simulator/qml/IedBrowserWorkspace.qml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 71cf1043a..4e73050c5 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -461,7 +461,12 @@ Item { text: "Enable + GI" enabled: reports.connected && !reports.busy && !reports.active && reports.selectedRcbIndex >= 0 + && reports.selectedRcb.dynamicBinding !== true onClicked: reports.enableSelected(true) + ToolTip.visible: hovered + ToolTip.text: reports.selectedRcb.dynamicBinding === true + ? "Restore the canonical static binding through Author… before using the legacy static enable path." + : "Enable the selected static RCB and request GI." } Button { From afdda0e4d61eafe1525c839c0ed28acd88373e20 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 21 Sep 2026 17:34:13 +0700 Subject: [PATCH 24/28] P6C: prevent legacy report enable on authored dynamic binding From 81ea396a3110dfa645453f3fa8f74bd14bbe69a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 15:12:56 +0700 Subject: [PATCH 25/28] P6C: restore complete DataSet helper and ownership projection --- .../ied_simulator/src/MmsReportController.cpp | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 22f442aaa..32b1b88ff 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -137,7 +137,41 @@ mms::MmsDataSetCandidate dataSetCandidateFromReference(const std::string& refere objectName.domain.empty() || objectName.item.empty()) { throw std::invalid_argument("Dynamic DataSet requires a domain-specific reference."); } - const auto separator = objectName.item.find(' + const auto separator = objectName.item.find('$'); + if (separator == std::string::npos || separator == 0U || + separator + 1U >= objectName.item.size()) { + throw std::invalid_argument( + "Dynamic DataSet reference must resolve to LD/LN.DataSetName."); + } + mms::MmsDataSetCandidate candidate; + candidate.domain = objectName.domain; + candidate.logical_node = objectName.item.substr(0U, separator); + candidate.name = objectName.item.substr(separator + 1U); + candidate.reference = mms::MmsDataSetDirectoryCodec::to_iec_reference(objectName); + candidate.raw_mms_name = objectName.item; + return candidate; +} + +QVariantList markOwnedDataSets( + QVariantList dataSets, + const QStringList& ownedReferences) { + for (int row = 0; row < dataSets.size(); ++row) { + auto map = dataSets.at(row).toMap(); + const auto reference = map.value(QStringLiteral("reference")).toString(); + bool owned = false; + for (const auto& candidate : ownedReferences) { + if (sameDataSetReference(reference, candidate)) { + owned = true; + break; + } + } + map.insert(QStringLiteral("dynamicOwned"), owned); + map.insert(QStringLiteral("immutable"), !owned); + dataSets[row] = map; + } + return dataSets; +} + QVariantMap candidateMap(const mms::MmsRcbCandidateEvaluation& candidate) { QVariantMap map; map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); From 90e2a7304ea9f55ae1554555ea78fc6769c0cfe7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 15:14:19 +0700 Subject: [PATCH 26/28] P6C: regression gate for complete authoring helper and single controller --- .../test_browser_report_authoring.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index 05ff9b477..13d5db0ec 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -40,6 +40,94 @@ def require(text: str, token: str, label: str) -> None: ): require(controller, token, "Report controller") +# A previous integration accidentally truncated the helper after find(', +# leaving a policy-green but uncompilable controller. Guard its complete shape. +helper_start = controller.find("mms::MmsDataSetCandidate dataSetCandidateFromReference(") +helper_end = controller.find("QVariantMap candidateMap(", helper_start) +if helper_start < 0 or helper_end < 0: + raise SystemExit("P6C: DataSet helper boundary is missing") +helper = controller[helper_start:helper_end] +for token in ( + "objectName.item.find(' "MmsNamedVariableListCodec::encode_define", + "MmsNamedVariableListCodec::encode_delete", +): + if forbidden in controller: + raise SystemExit( + f"Report controller: GUI-local dynamic DataSet wire encoding forbidden: {forbidden}" + ) + +for token in ( + 'text: "Add to DataSet draft…"', + 'text: "New dynamic…"', + 'text: "Delete owned…"', + 'text: "Author…"', + "BrowserDataSetAuthoringDialog {", + "BrowserReportAuthoringDialog {", +): + require(workspace, token, "Browser workspace") + +for token in ( + "readonly property int maximumMembers: 64", + "function addSelected(node)", + "memberModel.move(", + "reports.createDynamicDataSet(", + "reports.deleteDynamicDataSet(", + "id: createConfirm", + "id: deleteDialog", + "association-owned", +): + require(dataset_dialog, token, "DataSet authoring dialog") + +for token in ( + "function triggerNames()", + "function optionalNames()", + "reports.enableSelectedAuthored(", + "id: confirmEnable", + "general-interrogation", + "sequence-number", + "reason-for-inclusion", + "configuration-revision", + "Static binding never rewrites DatSet", + "No automatic retry", +): + require(report_dialog, token, "Report authoring dialog") + +for token in ( + "DYNAMIC OWNED", + "STATIC / READ-ONLY", + "Membership is immutable", +): + require(dataset_pane, token, "DataSet pane") + +for token in ( + "ownedDynamicDataSets", + "enableSelectedAuthored", + "createDynamicDataSet", + "deleteDynamicDataSet", +): + require(header, token, "Report controller header") + +if "Timer {" in dataset_dialog or "Timer {" in report_dialog: + raise SystemExit("P6C dialogs: background polling/timers are forbidden") + +print( + "P6C_BROWSER_REPORT_AUTHORING_PASS " + "static_immutable=true dynamic_owned_only=true member_bound=64 " + "create_verify=true canonical_members=true runtime_reuse=true " + "static_poll_fallback=false explicit_enable=true qml_polling=false" +) +);", + "candidate.logical_node = objectName.item.substr(0U, separator);", + "candidate.raw_mms_name = objectName.item;", + "return candidate;", + "QVariantList markOwnedDataSets(", + "return dataSets;", +): + require(helper, token, "Complete DataSet authoring helper") +if controller.count("MmsReportController::MmsReportController(") != 1 or \ + controller.count("struct MmsReportController::WorkerState final") != 1: + raise SystemExit("P6C: duplicate report controller implementation") + for forbidden in ( "MmsNamedVariableListCodec::encode_define", "MmsNamedVariableListCodec::encode_delete", From 9dae81a274e93024ee552354ae70c48b6f502338 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 15:24:26 +0700 Subject: [PATCH 27/28] P6C: assert actual owned-only delete dialog copy --- apps/ied_simulator/test_browser_report_authoring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index 13d5db0ec..6172d69f2 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -74,7 +74,7 @@ def require(text: str, token: str, label: str) -> None: "reports.deleteDynamicDataSet(", "id: createConfirm", "id: deleteDialog", - "association-owned", + "Only DataSets created by this live report association", ): require(dataset_dialog, token, "DataSet authoring dialog") From b69b8135e1b76bc3cda7f72cadb8d36abc14ca0d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 15:27:46 +0700 Subject: [PATCH 28/28] P6C: reconstruct complete regression script from original tested source --- .../test_browser_report_authoring.py | 93 ++----------------- 1 file changed, 8 insertions(+), 85 deletions(-) diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index 6172d69f2..67ac4d0c3 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -40,94 +40,17 @@ def require(text: str, token: str, label: str) -> None: ): require(controller, token, "Report controller") -# A previous integration accidentally truncated the helper after find(', -# leaving a policy-green but uncompilable controller. Guard its complete shape. +# Complete-helper regression: the browser authoring helper must be intact. helper_start = controller.find("mms::MmsDataSetCandidate dataSetCandidateFromReference(") helper_end = controller.find("QVariantMap candidateMap(", helper_start) if helper_start < 0 or helper_end < 0: - raise SystemExit("P6C: DataSet helper boundary is missing") + raise SystemExit("P6C helper boundary missing") helper = controller[helper_start:helper_end] -for token in ( - "objectName.item.find(' "MmsNamedVariableListCodec::encode_define", - "MmsNamedVariableListCodec::encode_delete", -): - if forbidden in controller: - raise SystemExit( - f"Report controller: GUI-local dynamic DataSet wire encoding forbidden: {forbidden}" - ) - -for token in ( - 'text: "Add to DataSet draft…"', - 'text: "New dynamic…"', - 'text: "Delete owned…"', - 'text: "Author…"', - "BrowserDataSetAuthoringDialog {", - "BrowserReportAuthoringDialog {", -): - require(workspace, token, "Browser workspace") - -for token in ( - "readonly property int maximumMembers: 64", - "function addSelected(node)", - "memberModel.move(", - "reports.createDynamicDataSet(", - "reports.deleteDynamicDataSet(", - "id: createConfirm", - "id: deleteDialog", - "Only DataSets created by this live report association", -): - require(dataset_dialog, token, "DataSet authoring dialog") - -for token in ( - "function triggerNames()", - "function optionalNames()", - "reports.enableSelectedAuthored(", - "id: confirmEnable", - "general-interrogation", - "sequence-number", - "reason-for-inclusion", - "configuration-revision", - "Static binding never rewrites DatSet", - "No automatic retry", -): - require(report_dialog, token, "Report authoring dialog") - -for token in ( - "DYNAMIC OWNED", - "STATIC / READ-ONLY", - "Membership is immutable", -): - require(dataset_pane, token, "DataSet pane") - -for token in ( - "ownedDynamicDataSets", - "enableSelectedAuthored", - "createDynamicDataSet", - "deleteDynamicDataSet", -): - require(header, token, "Report controller header") - -if "Timer {" in dataset_dialog or "Timer {" in report_dialog: - raise SystemExit("P6C dialogs: background polling/timers are forbidden") - -print( - "P6C_BROWSER_REPORT_AUTHORING_PASS " - "static_immutable=true dynamic_owned_only=true member_bound=64 " - "create_verify=true canonical_members=true runtime_reuse=true " - "static_poll_fallback=false explicit_enable=true qml_polling=false" -) -);", - "candidate.logical_node = objectName.item.substr(0U, separator);", - "candidate.raw_mms_name = objectName.item;", - "return candidate;", - "QVariantList markOwnedDataSets(", - "return dataSets;", -): - require(helper, token, "Complete DataSet authoring helper") -if controller.count("MmsReportController::MmsReportController(") != 1 or \ - controller.count("struct MmsReportController::WorkerState final") != 1: - raise SystemExit("P6C: duplicate report controller implementation") - +for token in ("candidate.logical_node =", "candidate.raw_mms_name =", "return candidate;", + "QVariantList markOwnedDataSets(", "return dataSets;"): + require(helper, token, "Complete DataSet helper") +if controller.count("MmsReportController::MmsReportController(") != 1: + raise SystemExit("P6C controller duplicated") for forbidden in ( "MmsNamedVariableListCodec::encode_define", "MmsNamedVariableListCodec::encode_delete", @@ -155,7 +78,7 @@ def require(text: str, token: str, label: str) -> None: "reports.deleteDynamicDataSet(", "id: createConfirm", "id: deleteDialog", - "association-owned", + "Only DataSets created by this live report association", ): require(dataset_dialog, token, "DataSet authoring dialog")