From 6e037b5f179b2c9c8453178c3cf3e874b4730072 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:10:52 +0700 Subject: [PATCH 01/17] feat(workbench): fail-closed verified GVAA type-bound report member projection --- .../src/BrowserVerifiedReportTypes.hpp | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 apps/ied_simulator/src/BrowserVerifiedReportTypes.hpp diff --git a/apps/ied_simulator/src/BrowserVerifiedReportTypes.hpp b/apps/ied_simulator/src/BrowserVerifiedReportTypes.hpp new file mode 100644 index 000000000..fa1dcbe94 --- /dev/null +++ b/apps/ied_simulator/src/BrowserVerifiedReportTypes.hpp @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "BrowserBoundedMmsPreview.hpp" +#include "MmsEngineeringEvidence.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace arstack::iedsim { +namespace verified_report_detail { +namespace mms = ar::iec61850::mms; + +// Only association-scoped, successful GVAA TypeSpecification responses can +// supply positional wire names. SCL guesses and inferred CDCs are not proof +// of field order. Keep pointers into the worker-owned discovery snapshot. +using WireTypeIndex = std::map>; +inline constexpr std::size_t kMaximumTypedLeaves = 64U; +inline constexpr std::size_t kMaximumTypeChildren = 64U; +inline constexpr unsigned kMaximumTypeDepth = 6U; + +[[nodiscard]] inline std::string key( + const std::string_view domain, const std::string_view item) { + std::string result; + result.reserve(domain.size() + 1U + item.size()); + result.append(domain); + result.push_back('\x1f'); + result.append(item); + return result; +} + +[[nodiscard]] inline WireTypeIndex indexTypes( + const std::vector& evidence) { + WireTypeIndex result; + for (const auto& item : evidence) { + if (!item.success() || item.variable.kind != mms::MmsObjectNameKind::domain_specific || + item.variable.domain.empty() || item.variable.item.empty()) + continue; + const auto identifier = key(item.variable.domain, item.variable.item); + const auto [slot, inserted] = result.emplace(identifier, &item.attributes->type); + if (!inserted) slot->second = nullptr; // conflicting/duplicate authority + } + return result; +} + +[[nodiscard]] inline const mms::MmsTypeSpecification* findSchema( + const WireTypeIndex& types, const mms::MmsDataSetDirectoryMember& member) { + const auto& object = member.object_name; + if (object.kind != mms::MmsObjectNameKind::domain_specific || + object.domain.empty() || object.item.empty()) + return nullptr; + // Longest available exact path, then walk the actual named GVAA tree. + // Prefix boundary is '$', never arbitrary string-prefix matching. + std::string current = object.item; + for (;;) { + const auto found = types.find(key(object.domain, current)); + if (found != types.end()) { + auto* node = found->second; + if (!node) return nullptr; + auto offset = current.size(); + while (offset < object.item.size()) { + if (object.item[offset] != '$') return nullptr; + const auto end = object.item.find('$', offset + 1U); + const auto name = object.item.substr(offset + 1U, + end == std::string::npos ? std::string::npos : end - offset - 1U); + if (name.empty()) return nullptr; + const mms::MmsTypeSpecification* match{}; + for (const auto& child : node->children) { + if (child.name == name) { + if (match) return nullptr; + match = &child; + } + } + if (!match) return nullptr; + node = match; + offset = end == std::string::npos ? object.item.size() : end; + } + return node; + } + const auto cut = current.rfind('$'); + if (cut == std::string::npos) return nullptr; + current.resize(cut); + } +} + +[[nodiscard]] inline bool matchingKind( + const mms::MmsTypeKind type, const mms::MmsDataKind value) noexcept { + using T = mms::MmsTypeKind; + using V = mms::MmsDataKind; + switch (type) { + case T::structure: return value == V::structure; + case T::boolean: return value == V::boolean; + case T::bit_string: return value == V::bit_string; + case T::integer: return value == V::integer; + case T::unsigned_integer: return value == V::unsigned_integer; + case T::floating_point: return value == V::floating_point; + case T::octet_string: return value == V::octet_string; + case T::visible_string: return value == V::visible_string; + case T::binary_time: return value == V::binary_time; + case T::bcd: return value == V::bcd; + case T::boolean_array: return value == V::boolean_array; + case T::object_id: return value == V::object_id; + case T::mms_string: return value == V::mms_string; + case T::utc_time: return value == V::utc_time; + // MMS arrays require an explicit element/length contract not stored here. + case T::array: case T::unknown: return false; + } + return false; +} + +[[nodiscard]] inline bool validate( + const mms::MmsTypeSpecification& type, + const mms::MmsDataValue& value, + std::size_t& leafCount, + const unsigned depth = 0U) { + if (depth > kMaximumTypeDepth || !matchingKind(type.kind, value.kind())) + return false; + if (type.kind != mms::MmsTypeKind::structure) { + return ++leafCount <= kMaximumTypedLeaves; + } + if (type.children.empty() || type.children.size() != value.children().size() || + type.children.size() > kMaximumTypeChildren) + return false; + // Both order and uniqueness must be evidenced by the GVAA tree. + for (std::size_t i = 0; i < type.children.size(); ++i) { + const auto& child = type.children[i]; + if (child.name.empty() || child.name.size() > 128U) return false; + for (std::size_t previous = 0; previous < i; ++previous) + if (type.children[previous].name == child.name) return false; + if (!validate(child, value.children()[i], leafCount, depth + 1U)) + return false; + } + return true; +} + +inline void appendRows( + QVariantList& rows, + const mms::MmsTypeSpecification& type, + const mms::MmsDataValue& value, + const QString& relative, + const unsigned depth = 0U) { + if (type.kind == mms::MmsTypeKind::structure) { + for (std::size_t i = 0; i < type.children.size(); ++i) { + const auto childName = QString::fromStdString(type.children[i].name); + const auto path = relative.isEmpty() ? childName + : relative + QLatin1Char('.') + childName; + appendRows(rows, type.children[i], value.children()[i], path, depth + 1U); + } + return; + } + const auto preview = previewMmsValue(value, 1024); + const auto evidence = decodeEngineeringEvidence(relative, value); + const QString display = evidence.value(QStringLiteral("summary")).toString(); + rows.push_back(QVariantMap{ + {QStringLiteral("label"), relative}, + {QStringLiteral("value"), display.isEmpty() + ? QString::fromStdString(preview.text) : display}, + {QStringLiteral("engineeringEvidence"), evidence}, + {QStringLiteral("verified"), true}, + {QStringLiteral("depth"), static_cast(depth)}, + {QStringLiteral("truncated"), preview.truncated} + }); +} + +[[nodiscard]] inline QVariantList project( + const mms::MmsReportValue& member, + const WireTypeIndex& verifiedTypes) { + if (!member.member || !member.value || + member.value->kind() != mms::MmsDataKind::structure) + return {}; + const auto* type = findSchema(verifiedTypes, *member.member); + if (!type) return {}; + std::size_t leaves{}; + if (!validate(*type, *member.value, leaves) || leaves == 0U) + return {}; + QVariantList rows; + rows.reserve(static_cast(leaves)); + appendRows(rows, *type, *member.value, QString{}); + return rows; +} +} // namespace verified_report_detail +} // namespace arstack::iedsim From 5ae40d55e28dc49778cb636d0065fc68a42e0e35 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:11:33 +0700 Subject: [PATCH 02/17] feat(workbench): prefer verified IEC type labels over positional report fallback --- .../src/BrowserReportMemberProjection.hpp | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp index 99a664e9b..7345f8431 100644 --- a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp +++ b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp @@ -3,6 +3,7 @@ #include "MmsEngineeringEvidence.hpp" #include "BrowserBoundedMmsPreview.hpp" +#include "BrowserVerifiedReportTypes.hpp" #include "ariec61850/mms/reporting.hpp" #include @@ -64,7 +65,8 @@ inline constexpr int kBrowserReportDetailChildren = 16; const std::vector& members, int& remainingTypedEvidence, const int requestedVisibleMembers = kBrowserReportVisibleMemberLimit, - const bool includeWireDetails = false) { + const bool includeWireDetails = false, + const verified_report_detail::WireTypeIndex* verifiedTypes = nullptr) { BrowserReportMemberProjection projection; const int visibleLimit = std::clamp(requestedVisibleMembers, 0, kBrowserReportVisibleMemberLimit); const auto visibleCount = std::min(members.size(), static_cast(visibleLimit)); @@ -100,20 +102,29 @@ inline constexpr int kBrowserReportDetailChildren = 16; valueTruncated = preview.truncated; } QVariantList detailRows; + bool exactType = false; + if (includeWireDetails && verifiedTypes && + ordinal < kBrowserReportDetailMembers) { + detailRows = verified_report_detail::project(member, *verifiedTypes); + exactType = !detailRows.isEmpty(); + } if (member.value) { const auto kind = member.value->kind(); if (kind == mms::MmsDataKind::structure || kind == mms::MmsDataKind::array) { const auto& children = member.value->children(); // Wire structure members are positional; without an SCL/type // binding never invent ST/q/t names or IEC enum meanings. - displayValue = QStringLiteral("%1 · %2 field(s)%3") - .arg(kind == mms::MmsDataKind::structure - ? QStringLiteral("Structure") : QStringLiteral("Array")) - .arg(static_cast(children.size())) - .arg(includeWireDetails && ordinal < kBrowserReportDetailMembers - ? QStringLiteral(" — select for details") - : QString{}); - if (includeWireDetails && ordinal < kBrowserReportDetailMembers) { + displayValue = exactType + ? QStringLiteral("%1 verified IEC leaf(s) — expand") + .arg(detailRows.size()) + : QStringLiteral("%1 · %2 field(s)%3") + .arg(kind == mms::MmsDataKind::structure + ? QStringLiteral("Structure") : QStringLiteral("Array")) + .arg(static_cast(children.size())) + .arg(includeWireDetails && ordinal < kBrowserReportDetailMembers + ? QStringLiteral(" — select for details") : QString{}); + if (!exactType && includeWireDetails && + ordinal < kBrowserReportDetailMembers) { const auto count = std::min(children.size(), static_cast(kBrowserReportDetailChildren)); for (std::size_t child = 0; child < count; ++child) { @@ -137,6 +148,7 @@ inline constexpr int kBrowserReportDetailChildren = 16; if (!detailRows.isEmpty()) projection.details.push_back(QVariantMap{ {QStringLiteral("index"), static_cast(ordinal)}, + {QStringLiteral("typed"), exactType}, {QStringLiteral("rows"), detailRows}}); QString reasons; bool reasonsTruncated = false; From 84f34bbd9dd8fae892ac677ff378496dc8fd6303 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:14:49 +0700 Subject: [PATCH 03/17] feat(workbench): association-scoped GVAA wire type cache feeds report projection --- .../ied_simulator/src/MmsReportController.cpp | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 758001e55..dc7ded9df 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -491,7 +491,8 @@ struct ReportUi final { ReportUi buildSubscriptionUi( const mms::MmsReportSubscriptionSnapshot& subscription, - const bool active) { + const bool active, + const arstack::iedsim::verified_report_detail::WireTypeIndex* verifiedTypes) { ReportUi ui; ui.active = active; ui.receivedCount = subscription.received_reports; @@ -537,7 +538,7 @@ ReportUi buildSubscriptionUi( const auto projected = arstack::iedsim::projectBrowserReportMembers( frame.values, typedEvidenceBudget, arstack::iedsim::kBrowserReportVisibleMemberLimit, - includeWireDetails); + includeWireDetails, verifiedTypes); map.insert(QStringLiteral("values"), projected.values); map.insert(QStringLiteral("valueEvidence"), projected.evidence); map.insert(QStringLiteral("valueDetails"), projected.details); @@ -557,19 +558,24 @@ ReportUi buildSubscriptionUi( return ui; } -ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { +ReportUi buildReportUi( + const mms::MmsStaticReportSessionSnapshot& snapshot, + const arstack::iedsim::verified_report_detail::WireTypeIndex* verifiedTypes = nullptr) { if (!snapshot.subscription) { ReportUi ui; ui.active = snapshot.active; return ui; } - return buildSubscriptionUi(*snapshot.subscription, snapshot.active); + return buildSubscriptionUi(*snapshot.subscription, snapshot.active, verifiedTypes); } -ReportUi buildReportUi(const mms::MmsReportSubscriptionSnapshot& snapshot) { +ReportUi buildReportUi( + const mms::MmsReportSubscriptionSnapshot& snapshot, + const arstack::iedsim::verified_report_detail::WireTypeIndex* verifiedTypes = nullptr) { return buildSubscriptionUi( snapshot, - snapshot.state == mms::MmsReportSubscriptionState::active); + snapshot.state == mms::MmsReportSubscriptionState::active, + verifiedTypes); } } // namespace @@ -577,6 +583,7 @@ ReportUi buildReportUi(const mms::MmsReportSubscriptionSnapshot& snapshot) { struct MmsReportController::WorkerState final { std::unique_ptr session; std::shared_ptr discovery; + arstack::iedsim::verified_report_detail::WireTypeIndex verifiedTypes; std::unique_ptr report; std::unique_ptr authoredReport; std::unique_ptr dynamicDataSets; @@ -794,6 +801,7 @@ bool MmsReportController::connectToIed() { if (worker->session) worker->session->disconnect(); worker->session.reset(); worker->discovery.reset(); + worker->verifiedTypes.clear(); mms::MmsAssociationOptions associationOptions; associationOptions.connect_timeout = std::chrono::milliseconds{5'000}; @@ -838,6 +846,9 @@ bool MmsReportController::connectToIed() { worker->dynamicDataSets = std::make_unique( session->association(), dynamicOptions); worker->discovery = discovery; + worker->verifiedTypes = + arstack::iedsim::verified_report_detail::indexTypes( + discovery->variable_types); worker->session = std::move(session); if (self) { @@ -867,6 +878,7 @@ bool MmsReportController::connectToIed() { worker->dynamicDataSets.reset(); worker->session.reset(); worker->discovery.reset(); + worker->verifiedTypes.clear(); if (self) { const auto message = QString::fromUtf8(exception.what()); QMetaObject::invokeMethod(self, [self, generation, message] { @@ -922,6 +934,7 @@ void MmsReportController::disconnectFromIed() { worker->authoredReport.reset(); worker->dynamicDataSets.reset(); worker->discovery.reset(); + worker->verifiedTypes.clear(); worker->session.reset(); // The GUI has already discarded its report history on disconnect; // do not retain a second bounded history in the idle worker. @@ -1547,7 +1560,7 @@ bool MmsReportController::enableSelectedAuthored( *directory, options); report->start(stop->get_token()); - ui = buildReportUi(report->snapshot()); + ui = buildReportUi(report->snapshot(), &worker->verifiedTypes); worker->authoredReport = std::move(report); } else { mms::MmsStaticReportSessionOptions options; @@ -1576,7 +1589,7 @@ bool MmsReportController::enableSelectedAuthored( options); report->prepare(stop->get_token()); report->start(stop->get_token()); - ui = buildReportUi(report->snapshot()); + ui = buildReportUi(report->snapshot(), &worker->verifiedTypes); worker->report = std::move(report); } @@ -1718,7 +1731,7 @@ bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) worker->session->association(), *worker->discovery, options); report->prepare(stop->get_token()); report->start(stop->get_token()); - const auto ui = buildReportUi(report->snapshot()); + const auto ui = buildReportUi(report->snapshot(), &worker->verifiedTypes); worker->report = std::move(report); // A new active subscription invalidates the previous idle @@ -1779,11 +1792,11 @@ bool MmsReportController::disableSelected() { ReportUi ui; if (worker->report) { worker->report->stop(stop->get_token()); - ui = buildReportUi(worker->report->snapshot()); + ui = buildReportUi(worker->report->snapshot(), &worker->verifiedTypes); if (!ui.cleanupRequired) worker->report.reset(); } else if (worker->authoredReport) { worker->authoredReport->stop(stop->get_token()); - ui = buildReportUi(worker->authoredReport->snapshot()); + ui = buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); if (!ui.cleanupRequired) worker->authoredReport.reset(); } else { throw std::runtime_error("No report subscription is active."); @@ -1850,12 +1863,12 @@ bool MmsReportController::retryCleanup() { ReportUi ui; if (worker->report) { worker->report->stop(stop->get_token()); - ui = buildReportUi(worker->report->snapshot()); + ui = buildReportUi(worker->report->snapshot(), &worker->verifiedTypes); if (!ui.cleanupRequired) worker->report.reset(); } else if (worker->authoredReport) { static_cast(worker->authoredReport->retry_cleanup( stop->get_token())); - ui = buildReportUi(worker->authoredReport->snapshot()); + ui = buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); if (!ui.cleanupRequired) worker->authoredReport.reset(); } else { throw std::runtime_error( @@ -1945,8 +1958,8 @@ void MmsReportController::schedulePoll() { return; } const ReportUi ui = staticActive - ? buildReportUi(worker->report->snapshot()) - : buildReportUi(worker->authoredReport->snapshot()); + ? buildReportUi(worker->report->snapshot(), &worker->verifiedTypes) + : buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); // Compare complete bounded report presentation on the existing // serial I/O worker, never on the GUI thread. Counter-only checks // would lose event-only and retained-frame evidence changes. @@ -1997,10 +2010,10 @@ void MmsReportController::schedulePoll() { ReportUi ui; if (worker->report) { worker->report->stop(); - ui = buildReportUi(worker->report->snapshot()); + ui = buildReportUi(worker->report->snapshot(), &worker->verifiedTypes); } else if (worker->authoredReport) { worker->authoredReport->stop(); - ui = buildReportUi(worker->authoredReport->snapshot()); + ui = buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); } if (self) { const auto message = QString::fromUtf8(exception.what()); From 493c89d81e12f8d0eb35e54f7c1cb4d96a717bec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:16:26 +0700 Subject: [PATCH 04/17] feat(workbench): selected DataSet LN-root metadata GVAA preflight, bounded and fail-closed --- .../ied_simulator/src/MmsReportController.cpp | 1619 +++++++++++++++++ 1 file changed, 1619 insertions(+) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index dc7ded9df..7a9e483a9 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -478,6 +478,1625 @@ void probeContextReportControls( } } +// Read-only, bounded GVAA enrichment borrowed from the ARIEC61850 +// LN-root strategy. It never uses another association or a per-DA Read. +void primeSelectedReportTypes( + mms::MmsAssociationRuntime& association, + mms::MmsLiveDiscoveryResult& discovery, + const QString& selectedDataSet, + std::set& attemptedRoots, + std::stop_token token) { + const mms::MmsDataSetDirectoryResponse* directory{}; + for (const auto& item : discovery.data_set_directories) { + if (item.directory && sameDataSetReference( + QString::fromStdString(item.candidate.reference), selectedDataSet)) { + directory = &*item.directory; + break; + } + } + if (!directory) return; + std::size_t attempts{}; + for (const auto& member : directory->members) { + if (attempts >= 8U || token.stop_requested()) break; + const auto& item = member.object_name; + if (item.kind != mms::MmsObjectNameKind::domain_specific || item.domain.empty()) + continue; + const auto pos = item.item.find(' + QVariantList reports; + QStringList events; + qulonglong receivedCount{}; + QString subscriptionRcbReference; + qulonglong rejectedCount{}; + qulonglong decodeFailureCount{}; + bool active{}; + bool cleanupRequired{}; +}; + +ReportUi buildSubscriptionUi( + const mms::MmsReportSubscriptionSnapshot& subscription, + const bool active, + const arstack::iedsim::verified_report_detail::WireTypeIndex* verifiedTypes) { + ReportUi ui; + ui.active = active; + ui.receivedCount = subscription.received_reports; + ui.subscriptionRcbReference = QString::fromStdString( + subscription.active_rcb_reference); + ui.rejectedCount = subscription.rejected_reports; + ui.decodeFailureCount = subscription.decode_failures; + ui.cleanupRequired = subscription.cleanup_required; + if (subscription.rejected_reports != 0U) + ui.events.push_back(QStringLiteral("%1 foreign/stale InformationReport(s) withheld from values.") + .arg(subscription.rejected_reports)); + for (const auto& event : subscription.events) { + ui.events.push_back(QString::fromStdString(event.message)); + } + while (ui.events.size() > 256) ui.events.removeFirst(); + + int typedEvidenceBudget = kMaximumTypedReportEvidence; + 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("rcbReference"), + QString::fromStdString(subscription.active_rcb_reference)); + 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())); + // A frame can include far more DataSet members than the 256 GUI + // rows. Project only retained rows, preserving actual total count + // and a shared typed-evidence budget across recent frames. + // BrowserReportPane inspects only the newest matching frame. + // Historical frames retain bounded scalar summaries and typed q/t + // evidence; never allocate 32 x 16 child previews per old frame. + const bool includeWireDetails = ui.reports.isEmpty(); + const auto projected = arstack::iedsim::projectBrowserReportMembers( + frame.values, typedEvidenceBudget, + arstack::iedsim::kBrowserReportVisibleMemberLimit, + includeWireDetails, verifiedTypes); + map.insert(QStringLiteral("values"), projected.values); + map.insert(QStringLiteral("valueEvidence"), projected.evidence); + map.insert(QStringLiteral("valueDetails"), projected.details); + map.insert(QStringLiteral("valueEvidenceTruncated"), projected.evidenceTruncated); + map.insert(QStringLiteral("valuesTruncated"), projected.rowsTruncated); + map.insert(QStringLiteral("valueTextTruncated"), projected.textTruncated); + map.insert(QStringLiteral("valuesSummary"), + projected.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, + const arstack::iedsim::verified_report_detail::WireTypeIndex* verifiedTypes = nullptr) { + if (!snapshot.subscription) { + ReportUi ui; + ui.active = snapshot.active; + return ui; + } + return buildSubscriptionUi(*snapshot.subscription, snapshot.active, verifiedTypes); +} + +ReportUi buildReportUi( + const mms::MmsReportSubscriptionSnapshot& snapshot, + const arstack::iedsim::verified_report_detail::WireTypeIndex* verifiedTypes = nullptr) { + return buildSubscriptionUi( + snapshot, + snapshot.state == mms::MmsReportSubscriptionState::active, + verifiedTypes); +} + +} // namespace + +struct MmsReportController::WorkerState final { + std::unique_ptr session; + std::shared_ptr discovery; + arstack::iedsim::verified_report_detail::WireTypeIndex verifiedTypes; + std::unique_ptr report; + std::unique_ptr authoredReport; + std::unique_ptr dynamicDataSets; + // Worker-thread-owned copy of the last GUI report projection. Qt's + // implicitly shared containers keep this cheap to retain; deep compare + // must never run on the QML/controller thread. + std::optional lastPublishedReportUi; +}; + +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::authoring || 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(); + selectedRcbDataSetMembers_.clear(); + receivedReports_.clear(); + events_.clear(); + receivedReportCount_ = 0; + subscriptionRcbReference_.clear(); + rejectedReportCount_ = 0; + reportDecodeFailureCount_ = 0; + emit inventoryChanged(); + emit selectionChanged(); + emit reportsChanged(); +} + +void MmsReportController::adoptEngineeringInventory() { + if (!engineeringContext_ || !engineeringContext_->loaded() || + engineeringContext_->selectionRequired() || + engineeringContext_->modelSnapshot() == nullptr) { + clearInventory(); + return; + } + + const auto* const canonical = engineeringContext_->modelSnapshot(); + const auto seed = buildContextDiscoverySeed(*canonical); + const auto ui = buildDiscoveryUi(seed, QString{}); + dataSets_ = ui.dataSets; + reportControls_ = ui.reportControls; + + // SCL contains configured RCB bindings even without live attribute reads. + // Carry only engineering metadata; never synthesize active state or values. + QHash byReference; + byReference.reserve(static_cast(canonical->report_controls.size())); + for (const auto& control : canonical->report_controls) + byReference.insert(QString::fromStdString(control.reference), &control); + for (auto& row : reportControls_) { + auto map = row.toMap(); + const auto* control = byReference.value( + map.value(QStringLiteral("reference")).toString(), nullptr); + if (!control) continue; + map.insert(QStringLiteral("dataSet"), QString::fromStdString(control->data_set_reference)); + map.insert(QStringLiteral("canonicalDataSet"), QString::fromStdString(control->data_set_reference)); + map.insert(QStringLiteral("dataSetBindingStatus"), QString::fromStdString(control->data_set_binding_status)); + map.insert(QStringLiteral("reportId"), QString::fromStdString(control->report_id)); + map.insert(QStringLiteral("confRev"), QString::fromStdString(control->configuration_revision)); + map.insert(QStringLiteral("bufTm"), QString::fromStdString(control->buffer_time_ms)); + map.insert(QStringLiteral("intgPd"), QString::fromStdString(control->integrity_period_ms)); + row = map; + } + staticCandidates_.clear(); + dynamicCandidates_.clear(); + associationProfile_.clear(); + selectedRcbIndex_ = reportControls_.isEmpty() ? -1 : 0; + selectedDataSetIndex_ = dataSets_.isEmpty() ? -1 : 0; + receivedReports_.clear(); + events_.clear(); + receivedReportCount_ = 0; + subscriptionRcbReference_.clear(); + rejectedReportCount_ = 0; + reportDecodeFailureCount_ = 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 { + worker->lastPublishedReportUi.reset(); + 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(); + worker->verifiedTypes.clear(); + + 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->verifiedTypes = + arstack::iedsim::verified_report_detail::indexTypes( + discovery->variable_types); + 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(); + worker->verifiedTypes.clear(); + 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->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->verifiedTypes.clear(); + worker->session.reset(); + // The GUI has already discarded its report history on disconnect; + // do not retain a second bounded history in the idle worker. + worker->lastPublishedReportUi.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(); + selectedRcbDataSetMembers_.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(); + selectedRcbDataSetMembers_ = selectedDataSetMembers_; + 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 == -1) { + if (selectedDataSetIndex_ == -1 && selectedDataSetMembers_.isEmpty()) return true; + selectedDataSetIndex_ = -1; + selectedDataSetMembers_.clear(); + emit selectionChanged(); + return true; + } + 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::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->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->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, + static_cast(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::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 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(); + 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. " + "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; + } + + 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, restoringCanonicalBinding, bindingChanged, + writeTriggerOptions, writeOptionalFields, + 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 (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 : + 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 = writeTriggerOptions; + options.trigger_options = trgOps; + options.write_optional_fields = writeOptionalFields; + 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->verifiedTypes); + 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 = writeTriggerOptions; + options.subscription.trigger_options = trgOps; + options.subscription.write_optional_fields = writeOptionalFields; + 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->verifiedTypes); + worker->report = std::move(report); + } + + // Authored RCB activation also starts a new report generation. + worker->lastPublishedReportUi.reset(); + if (self) { + QMetaObject::invokeMethod( + self, + [self, generation, selectedRow, ui, targetDataSet, + triggerOptions, optionalFields, + requestGeneralInterrogation, targetOwnedDynamic, + restoringCanonicalBinding] { + 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->subscriptionRcbReference_ = ui.subscriptionRcbReference; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; + 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( + restoringCanonicalBinding + ? QStringLiteral( + "Authored RCB restored to canonical static DataSet; configuration verified and enabled.") + : (requestGeneralInterrogation + ? QStringLiteral( + "Authored RCB enabled; GI requested (inspect GI write outcome and received reports).") + : 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(); + 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(); + } + 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(); + 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->verifiedTypes); + worker->report = std::move(report); + + // A new active subscription invalidates the previous idle + // projection baseline even before the first wire report arrives. + worker->lastPublishedReportUi.reset(); + 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->subscriptionRcbReference_ = ui.subscriptionRcbReference; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; + self->state_ = ui.cleanupRequired ? State::cleanup_required : State::active; + self->appendDiagnostic(requestGeneralInterrogation + ? QStringLiteral("RCB enabled; GI requested (inspect GI write outcome; report not yet proven).") + : 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 { + ReportUi ui; + if (worker->report) { + worker->report->stop(stop->get_token()); + ui = buildReportUi(worker->report->snapshot(), &worker->verifiedTypes); + if (!ui.cleanupRequired) worker->report.reset(); + } else if (worker->authoredReport) { + worker->authoredReport->stop(stop->get_token()); + ui = buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); + if (!ui.cleanupRequired) worker->authoredReport.reset(); + } else { + throw std::runtime_error("No report subscription is active."); + } + // The subscription is no longer actively polled; do not retain + // its previous GUI projection as an extra history copy. + worker->lastPublishedReportUi.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->subscriptionRcbReference_ = ui.subscriptionRcbReference; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; + 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()) { + throw std::runtime_error("Cleanup retry requires the original active MMS association."); + } + ReportUi ui; + if (worker->report) { + worker->report->stop(stop->get_token()); + ui = buildReportUi(worker->report->snapshot(), &worker->verifiedTypes); + if (!ui.cleanupRequired) worker->report.reset(); + } else if (worker->authoredReport) { + static_cast(worker->authoredReport->retry_cleanup( + stop->get_token())); + ui = buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); + if (!ui.cleanupRequired) worker->authoredReport.reset(); + } else { + throw std::runtime_error( + "Cleanup retry has no retained report runtime."); + } + // The subscription is no longer actively polled; do not retain + // its previous GUI projection as an extra history copy. + worker->lastPublishedReportUi.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->subscriptionRcbReference_ = ui.subscriptionRcbReference; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; + 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()) { + 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())); + // The native receive pump runs on every tick. Only the report + // runtime's bounded queue drain mutates active report observations. + // With no queued frame and a published baseline, do not deep-copy + // retained MMS frames or rebuild QML strings on an idle IED. + // Foreign, rejected and malformed frames still count as drained: + // their diagnostics/counters must reach the operator. + const auto drained = staticActive + ? worker->report->drain_queued_reports() + : worker->authoredReport->drain_queued_reports(); + if (drained == 0U && worker->lastPublishedReportUi) { + if (self) { + QMetaObject::invokeMethod(self, [self, generation] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + }, Qt::QueuedConnection); + } + return; + } + const ReportUi ui = staticActive + ? buildReportUi(worker->report->snapshot(), &worker->verifiedTypes) + : buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); + // Compare complete bounded report presentation on the existing + // serial I/O worker, never on the GUI thread. Counter-only checks + // would lose event-only and retained-frame evidence changes. + const auto& previous = worker->lastPublishedReportUi; + const bool presentationChanged = + !previous || + previous->active != ui.active || + previous->cleanupRequired != ui.cleanupRequired || + previous->receivedCount != ui.receivedCount || + previous->subscriptionRcbReference != ui.subscriptionRcbReference || + previous->rejectedCount != ui.rejectedCount || + previous->decodeFailureCount != ui.decodeFailureCount || + previous->events != ui.events || + previous->reports != ui.reports; + if (!presentationChanged) { + if (self) { + QMetaObject::invokeMethod(self, [self, generation] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + }, Qt::QueuedConnection); + } + return; + } + worker->lastPublishedReportUi = ui; + 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; + self->subscriptionRcbReference_ = ui.subscriptionRcbReference; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; + 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(), &worker->verifiedTypes); + } else if (worker->authoredReport) { + worker->authoredReport->stop(); + ui = buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); + } + 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->subscriptionRcbReference_ = ui.subscriptionRcbReference; + self->rejectedReportCount_ = ui.rejectedCount; + self->reportDecodeFailureCount_ = ui.decodeFailureCount; + 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 (pos == std::string::npos || pos == 0U) continue; + const auto root = item.item.substr(0U, pos); + const auto identifier = arstack::iedsim::verified_report_detail::key( + item.domain, root); + if (!attemptedRoots.insert(identifier).second) continue; + const bool known = std::any_of( + discovery.variable_types.begin(), discovery.variable_types.end(), + [&](const auto& existing) { + return existing.success() && existing.variable.domain == item.domain && + existing.variable.item == root; + }); + if (known) continue; + ++attempts; + try { + mms::MmsVariableTypeEvidence evidence; + evidence.variable = mms::MmsObjectName::domain_specific(item.domain, root); + const auto invoke = association.next_invoke_id(); + const mms::MmsVariableAccessAttributesRequest request{invoke, evidence.variable}; + const auto packet = + mms::MmsServiceCodec::encode_variable_access_attributes_request_p_data( + request, association.negotiated().presentation_context_id); + const auto response = association.exchange_confirmed(packet, invoke, token); + evidence.attributes = + mms::MmsServiceCodec::decode_variable_access_attributes_response( + confirmedPayload(response), invoke); + discovery.variable_types.push_back(std::move(evidence)); + } catch (const std::exception&) { + // Unsupported type discovery is optional. Never block report + // subscription or invent IEC attributes from array positions. + break; + } + } +} + struct ReportUi final { QVariantList reports; QStringList events; From ee6904616ec6c218460e140b146bcaeaa70e0d37 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:19:48 +0700 Subject: [PATCH 05/17] fix(workbench): clean controller and attach bounded exact-LN GVAA metadata to selected RCB --- .../ied_simulator/src/MmsReportController.cpp | 1630 +---------------- 1 file changed, 48 insertions(+), 1582 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 7a9e483a9..8f0487997 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -478,8 +478,9 @@ void probeContextReportControls( } } -// Read-only, bounded GVAA enrichment borrowed from the ARIEC61850 -// LN-root strategy. It never uses another association or a per-DA Read. +// Only read-only GVAA metadata for at most eight distinct LN roots from +// the selected DataSet, once per association. Failure preserves the MMS +// subscription and positional fallback; no second association or point polls. void primeSelectedReportTypes( mms::MmsAssociationRuntime& association, mms::MmsLiveDiscoveryResult& discovery, @@ -487,10 +488,10 @@ void primeSelectedReportTypes( std::set& attemptedRoots, std::stop_token token) { const mms::MmsDataSetDirectoryResponse* directory{}; - for (const auto& item : discovery.data_set_directories) { - if (item.directory && sameDataSetReference( - QString::fromStdString(item.candidate.reference), selectedDataSet)) { - directory = &*item.directory; + for (const auto& candidate : discovery.data_set_directories) { + if (candidate.directory && sameDataSetReference( + QString::fromStdString(candidate.candidate.reference), selectedDataSet)) { + directory = &*candidate.directory; break; } } @@ -498,1601 +499,38 @@ void primeSelectedReportTypes( std::size_t attempts{}; for (const auto& member : directory->members) { if (attempts >= 8U || token.stop_requested()) break; - const auto& item = member.object_name; - if (item.kind != mms::MmsObjectNameKind::domain_specific || item.domain.empty()) + const auto& name = member.object_name; + if (name.kind != mms::MmsObjectNameKind::domain_specific || name.domain.empty()) continue; - const auto pos = item.item.find(' - QVariantList reports; - QStringList events; - qulonglong receivedCount{}; - QString subscriptionRcbReference; - qulonglong rejectedCount{}; - qulonglong decodeFailureCount{}; - bool active{}; - bool cleanupRequired{}; -}; - -ReportUi buildSubscriptionUi( - const mms::MmsReportSubscriptionSnapshot& subscription, - const bool active, - const arstack::iedsim::verified_report_detail::WireTypeIndex* verifiedTypes) { - ReportUi ui; - ui.active = active; - ui.receivedCount = subscription.received_reports; - ui.subscriptionRcbReference = QString::fromStdString( - subscription.active_rcb_reference); - ui.rejectedCount = subscription.rejected_reports; - ui.decodeFailureCount = subscription.decode_failures; - ui.cleanupRequired = subscription.cleanup_required; - if (subscription.rejected_reports != 0U) - ui.events.push_back(QStringLiteral("%1 foreign/stale InformationReport(s) withheld from values.") - .arg(subscription.rejected_reports)); - for (const auto& event : subscription.events) { - ui.events.push_back(QString::fromStdString(event.message)); - } - while (ui.events.size() > 256) ui.events.removeFirst(); - - int typedEvidenceBudget = kMaximumTypedReportEvidence; - 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("rcbReference"), - QString::fromStdString(subscription.active_rcb_reference)); - 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())); - // A frame can include far more DataSet members than the 256 GUI - // rows. Project only retained rows, preserving actual total count - // and a shared typed-evidence budget across recent frames. - // BrowserReportPane inspects only the newest matching frame. - // Historical frames retain bounded scalar summaries and typed q/t - // evidence; never allocate 32 x 16 child previews per old frame. - const bool includeWireDetails = ui.reports.isEmpty(); - const auto projected = arstack::iedsim::projectBrowserReportMembers( - frame.values, typedEvidenceBudget, - arstack::iedsim::kBrowserReportVisibleMemberLimit, - includeWireDetails, verifiedTypes); - map.insert(QStringLiteral("values"), projected.values); - map.insert(QStringLiteral("valueEvidence"), projected.evidence); - map.insert(QStringLiteral("valueDetails"), projected.details); - map.insert(QStringLiteral("valueEvidenceTruncated"), projected.evidenceTruncated); - map.insert(QStringLiteral("valuesTruncated"), projected.rowsTruncated); - map.insert(QStringLiteral("valueTextTruncated"), projected.textTruncated); - map.insert(QStringLiteral("valuesSummary"), - projected.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, - const arstack::iedsim::verified_report_detail::WireTypeIndex* verifiedTypes = nullptr) { - if (!snapshot.subscription) { - ReportUi ui; - ui.active = snapshot.active; - return ui; - } - return buildSubscriptionUi(*snapshot.subscription, snapshot.active, verifiedTypes); -} - -ReportUi buildReportUi( - const mms::MmsReportSubscriptionSnapshot& snapshot, - const arstack::iedsim::verified_report_detail::WireTypeIndex* verifiedTypes = nullptr) { - return buildSubscriptionUi( - snapshot, - snapshot.state == mms::MmsReportSubscriptionState::active, - verifiedTypes); -} - -} // namespace - -struct MmsReportController::WorkerState final { - std::unique_ptr session; - std::shared_ptr discovery; - arstack::iedsim::verified_report_detail::WireTypeIndex verifiedTypes; - std::unique_ptr report; - std::unique_ptr authoredReport; - std::unique_ptr dynamicDataSets; - // Worker-thread-owned copy of the last GUI report projection. Qt's - // implicitly shared containers keep this cheap to retain; deep compare - // must never run on the QML/controller thread. - std::optional lastPublishedReportUi; -}; - -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::authoring || 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(); - selectedRcbDataSetMembers_.clear(); - receivedReports_.clear(); - events_.clear(); - receivedReportCount_ = 0; - subscriptionRcbReference_.clear(); - rejectedReportCount_ = 0; - reportDecodeFailureCount_ = 0; - emit inventoryChanged(); - emit selectionChanged(); - emit reportsChanged(); -} - -void MmsReportController::adoptEngineeringInventory() { - if (!engineeringContext_ || !engineeringContext_->loaded() || - engineeringContext_->selectionRequired() || - engineeringContext_->modelSnapshot() == nullptr) { - clearInventory(); - return; - } - - const auto* const canonical = engineeringContext_->modelSnapshot(); - const auto seed = buildContextDiscoverySeed(*canonical); - const auto ui = buildDiscoveryUi(seed, QString{}); - dataSets_ = ui.dataSets; - reportControls_ = ui.reportControls; - - // SCL contains configured RCB bindings even without live attribute reads. - // Carry only engineering metadata; never synthesize active state or values. - QHash byReference; - byReference.reserve(static_cast(canonical->report_controls.size())); - for (const auto& control : canonical->report_controls) - byReference.insert(QString::fromStdString(control.reference), &control); - for (auto& row : reportControls_) { - auto map = row.toMap(); - const auto* control = byReference.value( - map.value(QStringLiteral("reference")).toString(), nullptr); - if (!control) continue; - map.insert(QStringLiteral("dataSet"), QString::fromStdString(control->data_set_reference)); - map.insert(QStringLiteral("canonicalDataSet"), QString::fromStdString(control->data_set_reference)); - map.insert(QStringLiteral("dataSetBindingStatus"), QString::fromStdString(control->data_set_binding_status)); - map.insert(QStringLiteral("reportId"), QString::fromStdString(control->report_id)); - map.insert(QStringLiteral("confRev"), QString::fromStdString(control->configuration_revision)); - map.insert(QStringLiteral("bufTm"), QString::fromStdString(control->buffer_time_ms)); - map.insert(QStringLiteral("intgPd"), QString::fromStdString(control->integrity_period_ms)); - row = map; - } - staticCandidates_.clear(); - dynamicCandidates_.clear(); - associationProfile_.clear(); - selectedRcbIndex_ = reportControls_.isEmpty() ? -1 : 0; - selectedDataSetIndex_ = dataSets_.isEmpty() ? -1 : 0; - receivedReports_.clear(); - events_.clear(); - receivedReportCount_ = 0; - subscriptionRcbReference_.clear(); - rejectedReportCount_ = 0; - reportDecodeFailureCount_ = 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 { - worker->lastPublishedReportUi.reset(); - 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(); - worker->verifiedTypes.clear(); - - 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->verifiedTypes = - arstack::iedsim::verified_report_detail::indexTypes( - discovery->variable_types); - 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(); - worker->verifiedTypes.clear(); - 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->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->verifiedTypes.clear(); - worker->session.reset(); - // The GUI has already discarded its report history on disconnect; - // do not retain a second bounded history in the idle worker. - worker->lastPublishedReportUi.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(); - selectedRcbDataSetMembers_.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(); - selectedRcbDataSetMembers_ = selectedDataSetMembers_; - 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 == -1) { - if (selectedDataSetIndex_ == -1 && selectedDataSetMembers_.isEmpty()) return true; - selectedDataSetIndex_ = -1; - selectedDataSetMembers_.clear(); - emit selectionChanged(); - return true; - } - 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::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->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->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, - static_cast(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::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 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(); - 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. " - "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; - } - - 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, restoringCanonicalBinding, bindingChanged, - writeTriggerOptions, writeOptionalFields, - 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 (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 : - 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 = writeTriggerOptions; - options.trigger_options = trgOps; - options.write_optional_fields = writeOptionalFields; - 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->verifiedTypes); - 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 = writeTriggerOptions; - options.subscription.trigger_options = trgOps; - options.subscription.write_optional_fields = writeOptionalFields; - 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->verifiedTypes); - worker->report = std::move(report); - } - - // Authored RCB activation also starts a new report generation. - worker->lastPublishedReportUi.reset(); - if (self) { - QMetaObject::invokeMethod( - self, - [self, generation, selectedRow, ui, targetDataSet, - triggerOptions, optionalFields, - requestGeneralInterrogation, targetOwnedDynamic, - restoringCanonicalBinding] { - 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->subscriptionRcbReference_ = ui.subscriptionRcbReference; - self->rejectedReportCount_ = ui.rejectedCount; - self->reportDecodeFailureCount_ = ui.decodeFailureCount; - 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( - restoringCanonicalBinding - ? QStringLiteral( - "Authored RCB restored to canonical static DataSet; configuration verified and enabled.") - : (requestGeneralInterrogation - ? QStringLiteral( - "Authored RCB enabled; GI requested (inspect GI write outcome and received reports).") - : 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(); - 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(); - } - 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(); - 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->verifiedTypes); - worker->report = std::move(report); - - // A new active subscription invalidates the previous idle - // projection baseline even before the first wire report arrives. - worker->lastPublishedReportUi.reset(); - 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->subscriptionRcbReference_ = ui.subscriptionRcbReference; - self->rejectedReportCount_ = ui.rejectedCount; - self->reportDecodeFailureCount_ = ui.decodeFailureCount; - self->state_ = ui.cleanupRequired ? State::cleanup_required : State::active; - self->appendDiagnostic(requestGeneralInterrogation - ? QStringLiteral("RCB enabled; GI requested (inspect GI write outcome; report not yet proven).") - : 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 { - ReportUi ui; - if (worker->report) { - worker->report->stop(stop->get_token()); - ui = buildReportUi(worker->report->snapshot(), &worker->verifiedTypes); - if (!ui.cleanupRequired) worker->report.reset(); - } else if (worker->authoredReport) { - worker->authoredReport->stop(stop->get_token()); - ui = buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); - if (!ui.cleanupRequired) worker->authoredReport.reset(); - } else { - throw std::runtime_error("No report subscription is active."); - } - // The subscription is no longer actively polled; do not retain - // its previous GUI projection as an extra history copy. - worker->lastPublishedReportUi.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->subscriptionRcbReference_ = ui.subscriptionRcbReference; - self->rejectedReportCount_ = ui.rejectedCount; - self->reportDecodeFailureCount_ = ui.decodeFailureCount; - 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()) { - throw std::runtime_error("Cleanup retry requires the original active MMS association."); - } - ReportUi ui; - if (worker->report) { - worker->report->stop(stop->get_token()); - ui = buildReportUi(worker->report->snapshot(), &worker->verifiedTypes); - if (!ui.cleanupRequired) worker->report.reset(); - } else if (worker->authoredReport) { - static_cast(worker->authoredReport->retry_cleanup( - stop->get_token())); - ui = buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); - if (!ui.cleanupRequired) worker->authoredReport.reset(); - } else { - throw std::runtime_error( - "Cleanup retry has no retained report runtime."); - } - // The subscription is no longer actively polled; do not retain - // its previous GUI projection as an extra history copy. - worker->lastPublishedReportUi.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->subscriptionRcbReference_ = ui.subscriptionRcbReference; - self->rejectedReportCount_ = ui.rejectedCount; - self->reportDecodeFailureCount_ = ui.decodeFailureCount; - 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()) { - 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())); - // The native receive pump runs on every tick. Only the report - // runtime's bounded queue drain mutates active report observations. - // With no queued frame and a published baseline, do not deep-copy - // retained MMS frames or rebuild QML strings on an idle IED. - // Foreign, rejected and malformed frames still count as drained: - // their diagnostics/counters must reach the operator. - const auto drained = staticActive - ? worker->report->drain_queued_reports() - : worker->authoredReport->drain_queued_reports(); - if (drained == 0U && worker->lastPublishedReportUi) { - if (self) { - QMetaObject::invokeMethod(self, [self, generation] { - if (!self || self->generation_ != generation) return; - self->pollPending_ = false; - }, Qt::QueuedConnection); - } - return; - } - const ReportUi ui = staticActive - ? buildReportUi(worker->report->snapshot(), &worker->verifiedTypes) - : buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); - // Compare complete bounded report presentation on the existing - // serial I/O worker, never on the GUI thread. Counter-only checks - // would lose event-only and retained-frame evidence changes. - const auto& previous = worker->lastPublishedReportUi; - const bool presentationChanged = - !previous || - previous->active != ui.active || - previous->cleanupRequired != ui.cleanupRequired || - previous->receivedCount != ui.receivedCount || - previous->subscriptionRcbReference != ui.subscriptionRcbReference || - previous->rejectedCount != ui.rejectedCount || - previous->decodeFailureCount != ui.decodeFailureCount || - previous->events != ui.events || - previous->reports != ui.reports; - if (!presentationChanged) { - if (self) { - QMetaObject::invokeMethod(self, [self, generation] { - if (!self || self->generation_ != generation) return; - self->pollPending_ = false; - }, Qt::QueuedConnection); - } - return; - } - worker->lastPublishedReportUi = ui; - 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; - self->subscriptionRcbReference_ = ui.subscriptionRcbReference; - self->rejectedReportCount_ = ui.rejectedCount; - self->reportDecodeFailureCount_ = ui.decodeFailureCount; - 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(), &worker->verifiedTypes); - } else if (worker->authoredReport) { - worker->authoredReport->stop(); - ui = buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes); - } - 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->subscriptionRcbReference_ = ui.subscriptionRcbReference; - self->rejectedReportCount_ = ui.rejectedCount; - self->reportDecodeFailureCount_ = ui.decodeFailureCount; - 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 (pos == std::string::npos || pos == 0U) continue; - const auto root = item.item.substr(0U, pos); - const auto identifier = arstack::iedsim::verified_report_detail::key( - item.domain, root); + const auto end = name.item.find('$'); + if (end == std::string::npos || end == 0U) continue; + const std::string root = name.item.substr(0U, end); + const auto identifier = + arstack::iedsim::verified_report_detail::key(name.domain, root); if (!attemptedRoots.insert(identifier).second) continue; const bool known = std::any_of( discovery.variable_types.begin(), discovery.variable_types.end(), [&](const auto& existing) { - return existing.success() && existing.variable.domain == item.domain && + return existing.success() && existing.variable.domain == name.domain && existing.variable.item == root; }); if (known) continue; ++attempts; try { mms::MmsVariableTypeEvidence evidence; - evidence.variable = mms::MmsObjectName::domain_specific(item.domain, root); + evidence.variable = mms::MmsObjectName::domain_specific(name.domain, root); const auto invoke = association.next_invoke_id(); const mms::MmsVariableAccessAttributesRequest request{invoke, evidence.variable}; - const auto packet = + const auto encoded = mms::MmsServiceCodec::encode_variable_access_attributes_request_p_data( request, association.negotiated().presentation_context_id); - const auto response = association.exchange_confirmed(packet, invoke, token); + const auto exchange = association.exchange_confirmed(encoded, invoke, token); evidence.attributes = mms::MmsServiceCodec::decode_variable_access_attributes_response( - confirmedPayload(response), invoke); + confirmedPayload(exchange), invoke); discovery.variable_types.push_back(std::move(evidence)); } catch (const std::exception&) { - // Unsupported type discovery is optional. Never block report - // subscription or invent IEC attributes from array positions. - break; + break; // Unsupported GVAA must not fail RCB activation. } } } @@ -2203,6 +641,7 @@ struct MmsReportController::WorkerState final { std::unique_ptr session; std::shared_ptr discovery; arstack::iedsim::verified_report_detail::WireTypeIndex verifiedTypes; + std::set attemptedTypeRoots; std::unique_ptr report; std::unique_ptr authoredReport; std::unique_ptr dynamicDataSets; @@ -2421,6 +860,7 @@ bool MmsReportController::connectToIed() { worker->session.reset(); worker->discovery.reset(); worker->verifiedTypes.clear(); + worker->attemptedTypeRoots.clear(); mms::MmsAssociationOptions associationOptions; associationOptions.connect_timeout = std::chrono::milliseconds{5'000}; @@ -2498,6 +938,7 @@ bool MmsReportController::connectToIed() { worker->session.reset(); worker->discovery.reset(); worker->verifiedTypes.clear(); + worker->attemptedTypeRoots.clear(); if (self) { const auto message = QString::fromUtf8(exception.what()); QMetaObject::invokeMethod(self, [self, generation, message] { @@ -2554,6 +995,7 @@ void MmsReportController::disconnectFromIed() { worker->dynamicDataSets.reset(); worker->discovery.reset(); worker->verifiedTypes.clear(); + worker->attemptedTypeRoots.clear(); worker->session.reset(); // The GUI has already discarded its report history on disconnect; // do not retain a second bounded history in the idle worker. @@ -3158,6 +1600,13 @@ bool MmsReportController::enableSelectedAuthored( "Owned dynamic DataSet directory is unavailable or empty."); } + primeSelectedReportTypes( + worker->session->association(), *worker->discovery, + targetDataSet, worker->attemptedTypeRoots, stop->get_token()); + worker->verifiedTypes = + arstack::iedsim::verified_report_detail::indexTypes( + worker->discovery->variable_types); + mms::MmsReportSubscriptionOptions options; options.trigger_general_interrogation = requestGeneralInterrogation; @@ -3349,6 +1798,23 @@ bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) auto report = std::make_unique( worker->session->association(), *worker->discovery, options); report->prepare(stop->get_token()); + QString boundDataSet; + for (const auto& candidate : worker->discovery->report_controls) { + if (candidate.candidate.reference == reference.toStdString() && + candidate.state) { + boundDataSet = QString::fromStdString( + candidate.state->data_set_reference); + break; + } + } + if (!boundDataSet.isEmpty()) { + primeSelectedReportTypes( + worker->session->association(), *worker->discovery, + boundDataSet, worker->attemptedTypeRoots, stop->get_token()); + worker->verifiedTypes = + arstack::iedsim::verified_report_detail::indexTypes( + worker->discovery->variable_types); + } report->start(stop->get_token()); const auto ui = buildReportUi(report->snapshot(), &worker->verifiedTypes); worker->report = std::move(report); From bf2ad56495d3eafc38d6842e7189bed6cac990bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:20:57 +0700 Subject: [PATCH 06/17] ui(workbench): show verified MMS attribute names and inline per-leaf Quality/TimeQuality --- apps/ied_simulator/qml/BrowserReportPane.qml | 85 +++++++++++++------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 359b6d1ef..131aad1f0 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -57,6 +57,15 @@ Rectangle { } return [] } + function selectedReportMemberTypeVerified() { + if (!hasLastReport || selectedReportMemberIndex < 0 || + !latestReport.valueDetails) return false + const entries = latestReport.valueDetails + for (let i = 0; i < entries.length; ++i) + if (entries[i].index === selectedReportMemberIndex) + return entries[i].typed === true + return false + } function selectedReportMemberEvidence() { if (!hasLastReport || selectedReportMemberIndex < 0 || !latestReport.valueEvidence) return ({}) @@ -682,7 +691,9 @@ Rectangle { Layout.fillWidth: true } Label { - text: "Positional · not SCL-labelled" + text: root.selectedReportMemberTypeVerified() + ? "Verified MMS type · exact field order" + : "Positional · not SCL-labelled" font.pixelSize: 10 color: theme.muted } @@ -691,40 +702,56 @@ Rectangle { Repeater { model: root.selectedReportMemberIndex === receivedMember.index ? root.selectedReportMemberDetails() : [] - delegate: Rectangle { + delegate: ColumnLayout { required property var modelData required property int index Layout.fillWidth: true - implicitHeight: 27 - color: index % 2 ? theme.surfaceSoft : theme.surface - RowLayout { - anchors.fill: parent - anchors.leftMargin: 18 - anchors.rightMargin: 9 - spacing: 10 - Label { - text: modelData.label - Layout.preferredWidth: 180 - elide: Text.ElideRight - font.pixelSize: 11 - color: theme.textSoft - } - Label { - text: modelData.value - Layout.fillWidth: true - elide: Text.ElideMiddle - font.pixelSize: 11 - color: theme.text - ToolTip.visible: detailMouse.containsMouse && truncated - ToolTip.text: text - MouseArea { - id: detailMouse - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.NoButton + spacing: 0 + Rectangle { + Layout.fillWidth: true + implicitHeight: 29 + color: index % 2 ? theme.surfaceSoft : theme.surface + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 9 + spacing: 10 + Label { + text: modelData.label + Layout.preferredWidth: 180 + elide: Text.ElideRight + font.pixelSize: 11 + color: theme.textSoft + ToolTip.visible: labelHover.containsMouse && truncated + ToolTip.text: text + MouseArea { + id: labelHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } + Label { + text: modelData.value + Layout.fillWidth: true + elide: Text.ElideMiddle + font.pixelSize: 11 + color: theme.text + ToolTip.visible: detailMouse.containsMouse && truncated + ToolTip.text: text + MouseArea { + id: detailMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } } } } + BrowserEngineeringEvidence { + theme: root.theme + engineeringEvidence: modelData.engineeringEvidence || ({}) + } } } } From c73ca2bbe383ca005cdd1e55b159b32e25f0559a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:21:56 +0700 Subject: [PATCH 07/17] test(workbench): verified GVAA stVal/q/t report labels and strict negative fallback gates --- .../src/ied_browser_large_model_qa.cpp | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index 3f767bbb5..c99fe7709 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -482,6 +482,96 @@ int main(int argc, char** argv) { return 28; } + // P0: transport order becomes IEC attribute identity only after an exact + // LN-root GVAA TypeSpecification matches every nested kind/cardinality. + // No ARIEC61850 renderer child-count guessing and no fake q/t. + { + namespace mms = ar::iec61850::mms; + const auto typed = [](mms::MmsTypeKind kind, std::string name, + std::vector children = {}) { + mms::MmsTypeSpecification node; + node.kind = kind; + node.name = std::move(name); + node.children = std::move(children); + return node; + }; + mms::MmsVariableTypeEvidence evidence; + evidence.variable = mms::MmsObjectName::domain_specific("LD4", "XCBR1"); + evidence.attributes.emplace(); + evidence.attributes->type = typed(mms::MmsTypeKind::structure, "XCBR1", { + typed(mms::MmsTypeKind::structure, "ST", { + typed(mms::MmsTypeKind::structure, "Pos", { + typed(mms::MmsTypeKind::bit_string, "stVal"), + typed(mms::MmsTypeKind::bit_string, "q"), + typed(mms::MmsTypeKind::utc_time, "t") + }) + }) + }); + const auto typeIndex = arstack::iedsim::verified_report_detail::indexTypes( + {evidence}); + mms::MmsReportValue reportMember; + reportMember.member.emplace(); + reportMember.member->object_name = + mms::MmsObjectName::domain_specific("LD4", "XCBR1$ST$Pos"); + reportMember.member->functional_constraint = "ST"; + reportMember.data_reference = "LD4/XCBR1.Pos"; + const std::array dbposBytes{0x80U}; + reportMember.value = mms::MmsDataValue::structure({ + mms::MmsDataValue::bit_string(6U, dbposBytes), + mms::MmsDataValue::bit_string(3U, bits), + mms::MmsDataValue::utc_time(utc) + }); + auto reportTypes = arstack::iedsim::verified_report_detail::project( + reportMember, typeIndex); + int semanticBudget = 4; + const auto semantic = arstack::iedsim::projectBrowserReportMembers( + {reportMember}, semanticBudget, 8, true, &typeIndex); + const auto details = semantic.details.constFirst().toMap(); + if (reportTypes.size() != 3 || semantic.details.size() != 1 || + !details.value(QStringLiteral("typed")).toBool() || + !semantic.values.constFirst().contains(QStringLiteral("verified IEC")) || + reportTypes.at(0).toMap().value(QStringLiteral("label")).toString() != + QStringLiteral("stVal") || + reportTypes.at(1).toMap().value(QStringLiteral("label")).toString() != + QStringLiteral("q") || + reportTypes.at(1).toMap().value(QStringLiteral("engineeringEvidence")) + .toMap().value(QStringLiteral("kind")).toString() != QStringLiteral("quality") || + reportTypes.at(2).toMap().value(QStringLiteral("engineeringEvidence")) + .toMap().value(QStringLiteral("kind")).toString() != QStringLiteral("timestamp")) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL verified_semantic_report_type\n"; + return 91; + } + // Wrong cardinality, data kind, device/domain and duplicate GVAA + // authority all fail closed without promoting Field N to semantic DA. + auto mismatch = reportMember; + mismatch.value = mms::MmsDataValue::structure({ + mms::MmsDataValue::boolean(true), + mms::MmsDataValue::bit_string(3U, bits) + }); + auto stale = reportMember; + stale.member->object_name = + mms::MmsObjectName::domain_specific("LD_WRONG", "XCBR1$ST$Pos"); + const auto ambiguous = arstack::iedsim::verified_report_detail::indexTypes( + {evidence, evidence}); + if (!arstack::iedsim::verified_report_detail::project(mismatch, typeIndex).isEmpty() || + !arstack::iedsim::verified_report_detail::project(stale, typeIndex).isEmpty() || + !arstack::iedsim::verified_report_detail::project(reportMember, ambiguous).isEmpty() || + !arstack::iedsim::verified_report_detail::project( + reportMember, arstack::iedsim::verified_report_detail::WireTypeIndex{}).isEmpty()) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL semantic_report_fallback\n"; + return 92; + } + int fallbackBudget = 4; + const auto fallback = arstack::iedsim::projectBrowserReportMembers( + {mismatch}, fallbackBudget, 8, true, &typeIndex); + if (fallback.details.size() != 1 || + fallback.details.constFirst().toMap().value(QStringLiteral("typed")).toBool() || + fallback.values.constFirst().contains(QStringLiteral("verified IEC"))) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL semantic_mismatch_claim\n"; + return 93; + } + } + using ar::iec61850::mms::MmsDataCodec; using ar::iec61850::mms::MmsDataValue; // M2F: the GUI must not allocate multi-megabyte intermediary strings From 6247d67274447c7d31395bb9a39f77007563cb09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:22:37 +0700 Subject: [PATCH 08/17] test(workbench): protect exact GVAA owner, bounded schema and truthful inline IEC labels --- .../ied_simulator/test_browser_convergence.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 41fadf5b2..58c76fed6 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -284,6 +284,45 @@ if report_inspector.count("BrowserEngineeringEvidence {") != 1: raise SystemExit("BROWSER_REPORT_INLINE_TREE_FAIL duplicate or detached q/t inspector") +# P0 exact wire-schema semantics: names may only come from successful +# GetVariableAccessAttributes TypeSpecification on the same MMS association. +verified_types = ( + workspace_path.parent.parent / "src" / "BrowserVerifiedReportTypes.hpp" +).read_text(encoding="utf-8") +for token in ( + "indexTypes(", + "MmsObjectNameKind::domain_specific", + "findSchema(", + "if (!validate(*type, *member.value, leaves)", + "type.children.size() != value.children().size()", + "child.name.empty()", + "matchingKind(", + "decodeEngineeringEvidence(relative, value)", + "kMaximumTypedLeaves = 64U", + "kMaximumTypeDepth = 6U", +): + if token not in verified_types: + raise SystemExit("BROWSER_REPORT_P0_SCHEMA_FAIL missing=" + token) +for token in ( + "primeSelectedReportTypes(", + "attemptedTypeRoots", + "attempts >= 8U", + "MmsVariableAccessAttributesRequest request", + "indexTypes(", + "buildSubscriptionUi(", + "includeWireDetails, verifiedTypes", +): + if token not in report_controller: + raise SystemExit("BROWSER_REPORT_P0_BINDING_FAIL missing=" + token) +for token in ( + "function selectedReportMemberTypeVerified()", + "Verified MMS type · exact field order", + "modelData.engineeringEvidence || ({})", + "Positional · not SCL-labelled", +): + if token not in report_inspector: + raise SystemExit("BROWSER_REPORT_P0_INLINE_FAIL missing=" + token) + value_preview = (workspace_path.parent.parent / "src" / "BrowserBoundedMmsPreview.hpp").read_text(encoding="utf-8") for token in [ 'BoundedMmsPreview', From a9c4df706a1c1cf9adebc4862c6853b3e8d84d1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:27:18 +0700 Subject: [PATCH 09/17] test(workbench): update bounded fallback contract for verified-type short circuit --- apps/ied_simulator/test_browser_convergence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 58c76fed6..90ac75e9f 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -233,7 +233,7 @@ 'kBrowserReportVisibleMemberLimit,\n includeWireDetails' not in report_controller): raise SystemExit("BROWSER_REPORT_PERF_FAIL details projected for old frames") if 'const bool includeWireDetails = false' not in projection_header or ( - 'if (includeWireDetails && ordinal < kBrowserReportDetailMembers)' not in projection_header): + 'if (!exactType && includeWireDetails &&' not in projection_header): raise SystemExit("BROWSER_REPORT_PERF_FAIL detail projection not opt-in") if 'map.insert(QStringLiteral("valueDetails"), projected.details)' not in report_controller: raise SystemExit("BROWSER_REPORT_READABLE_FAIL lost structured detail presentation") From d20faabe2c029b69d8841e13172e3323e4e1eece Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:27:32 +0700 Subject: [PATCH 10/17] test(workbench): keep GVAA evidence owner alive while borrowed type index is inspected --- .../ied_simulator/src/ied_browser_large_model_qa.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index c99fe7709..8a6c28665 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -507,8 +507,10 @@ int main(int argc, char** argv) { }) }) }); - const auto typeIndex = arstack::iedsim::verified_report_detail::indexTypes( - {evidence}); + // Index pointers borrow the discovery evidence: keep the owner alive. + const std::vector verifiedEvidence{evidence}; + const auto typeIndex = + arstack::iedsim::verified_report_detail::indexTypes(verifiedEvidence); mms::MmsReportValue reportMember; reportMember.member.emplace(); reportMember.member->object_name = @@ -551,8 +553,10 @@ int main(int argc, char** argv) { auto stale = reportMember; stale.member->object_name = mms::MmsObjectName::domain_specific("LD_WRONG", "XCBR1$ST$Pos"); - const auto ambiguous = arstack::iedsim::verified_report_detail::indexTypes( - {evidence, evidence}); + const std::vector duplicatedEvidence{ + evidence, evidence}; + const auto ambiguous = + arstack::iedsim::verified_report_detail::indexTypes(duplicatedEvidence); if (!arstack::iedsim::verified_report_detail::project(mismatch, typeIndex).isEmpty() || !arstack::iedsim::verified_report_detail::project(stale, typeIndex).isEmpty() || !arstack::iedsim::verified_report_detail::project(reportMember, ambiguous).isEmpty() || From 4d414e5001b0fe71b6dea7a50c07e122603cbcc5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:27:58 +0700 Subject: [PATCH 11/17] fix(workbench): prime exact report types for unchanged authored static DataSet too --- apps/ied_simulator/src/MmsReportController.cpp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 8f0487997..0d0ba2fc5 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -1558,6 +1558,14 @@ bool MmsReportController::enableSelectedAuthored( worker->authoredReport.reset(); } + // Common authoring entry: static and owned-dynamic selected + // DataSets must use the same bounded metadata enrichment. + primeSelectedReportTypes( + worker->session->association(), *worker->discovery, + targetDataSet, worker->attemptedTypeRoots, stop->get_token()); + worker->verifiedTypes = + arstack::iedsim::verified_report_detail::indexTypes( + worker->discovery->variable_types); ReportUi ui; if (bindingChanged || targetOwnedDynamic) { if (targetOwnedDynamic && @@ -1600,13 +1608,6 @@ bool MmsReportController::enableSelectedAuthored( "Owned dynamic DataSet directory is unavailable or empty."); } - primeSelectedReportTypes( - worker->session->association(), *worker->discovery, - targetDataSet, worker->attemptedTypeRoots, stop->get_token()); - worker->verifiedTypes = - arstack::iedsim::verified_report_detail::indexTypes( - worker->discovery->variable_types); - mms::MmsReportSubscriptionOptions options; options.trigger_general_interrogation = requestGeneralInterrogation; From eb05f80301713c5a3759c080ff4f8b66ddb3f663 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:28:17 +0700 Subject: [PATCH 12/17] fix(workbench): propagate truncated verified leaf previews to report operator warning --- apps/ied_simulator/src/BrowserReportMemberProjection.hpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp index 7345f8431..5e005a915 100644 --- a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp +++ b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp @@ -107,6 +107,14 @@ inline constexpr int kBrowserReportDetailChildren = 16; ordinal < kBrowserReportDetailMembers) { detailRows = verified_report_detail::project(member, *verifiedTypes); exactType = !detailRows.isEmpty(); + if (exactType) { + for (const auto& child : detailRows) { + if (child.toMap().value(QStringLiteral("truncated")).toBool()) { + projection.textTruncated = true; + break; + } + } + } } if (member.value) { const auto kind = member.value->kind(); From 638feba06f97ff86af7cf7905318b3b0ba54793e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:28:29 +0700 Subject: [PATCH 13/17] ui(workbench): visibly mark truncated typed MMS leaf values --- apps/ied_simulator/qml/BrowserReportPane.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 131aad1f0..9cc5c1a4d 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -733,6 +733,8 @@ Rectangle { } Label { text: modelData.value + + (modelData.truncated === true + ? " … [truncated]" : "") Layout.fillWidth: true elide: Text.ElideMiddle font.pixelSize: 11 From 1b17786719cfa555b4caf9eb3001ffb405fe8754 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:34:19 +0700 Subject: [PATCH 14/17] test(workbench): recognize lazy per-leaf q/t and association-wide GVAA budget --- apps/ied_simulator/test_browser_convergence.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 90ac75e9f..657ef249b 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -281,8 +281,11 @@ ): if token not in report_inspector: raise SystemExit("BROWSER_REPORT_INLINE_TREE_FAIL compact selection=" + token) -if report_inspector.count("BrowserEngineeringEvidence {") != 1: - raise SystemExit("BROWSER_REPORT_INLINE_TREE_FAIL duplicate or detached q/t inspector") +# The first is member-level q/t; the second is verified leaf q/t and +# must remain beneath the selected-only Loader, not at table root. +if report_inspector.count("BrowserEngineeringEvidence {") != 2 or ( + member_tree.count("BrowserEngineeringEvidence {") != 2): + raise SystemExit("BROWSER_REPORT_INLINE_TREE_FAIL missing/duplicate typed leaf inspector") # P0 exact wire-schema semantics: names may only come from successful # GetVariableAccessAttributes TypeSpecification on the same MMS association. @@ -307,6 +310,7 @@ "primeSelectedReportTypes(", "attemptedTypeRoots", "attempts >= 8U", + "attemptedRoots.size() >= 16U", "MmsVariableAccessAttributesRequest request", "indexTypes(", "buildSubscriptionUi(", From f0285cf39d9bf096b7e03d919c70bcbdaa159c55 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:34:32 +0700 Subject: [PATCH 15/17] perf(workbench): enforce 16-LN lifetime cap for selected-DataSet GVAA probes --- apps/ied_simulator/src/MmsReportController.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 0d0ba2fc5..da5825f24 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -498,7 +498,10 @@ void primeSelectedReportTypes( if (!directory) return; std::size_t attempts{}; for (const auto& member : directory->members) { - if (attempts >= 8U || token.stop_requested()) break; + // Total authority expansion is bounded for the entire MMS association, + // even if an operator switches between thousands of RCB/DataSets. + if (attempts >= 8U || attemptedRoots.size() >= 16U || + token.stop_requested()) break; const auto& name = member.object_name; if (name.kind != mms::MmsObjectNameKind::domain_specific || name.domain.empty()) continue; From 70b97238445df33c8d5fc9ea35abdae17dc94065 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:38:41 +0700 Subject: [PATCH 16/17] test(workbench): keep idle poll regression matched to typed projection signature --- apps/ied_simulator/test_browser_convergence.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 657ef249b..6a43609c2 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -457,8 +457,8 @@ "worker->authoredReport->drain_queued_reports()", "if (drained == 0U && worker->lastPublishedReportUi) {", "self->pollPending_ = false;", - "buildReportUi(worker->report->snapshot())", - "buildReportUi(worker->authoredReport->snapshot())", + "buildReportUi(worker->report->snapshot(), &worker->verifiedTypes)", + "buildReportUi(worker->authoredReport->snapshot(), &worker->verifiedTypes)", "previous->events != ui.events", "previous->reports != ui.reports", ): @@ -467,7 +467,7 @@ if not (report_poll.index("try_poll_once_for(") < report_poll.index("const auto drained = staticActive") < report_poll.index("if (drained == 0U && worker->lastPublishedReportUi)") - < report_poll.index("buildReportUi(worker->report->snapshot())")): + < report_poll.index("buildReportUi(worker->report->snapshot(), &worker->verifiedTypes)")): raise SystemExit("BROWSER_REPORT_IDLE_FAIL snapshot built on unchanged receive tick") unchanged = report_poll.split( "if (drained == 0U && worker->lastPublishedReportUi) {", 1)[1].split( From 80bd9003b459bcd6fa09ccce02363c6fc8572108 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 16:38:45 +0700 Subject: [PATCH 17/17] test(workbench): check semantic projection cardinality before accessing first row --- apps/ied_simulator/src/ied_browser_large_model_qa.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index 8a6c28665..326bcd85c 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -528,8 +528,13 @@ int main(int argc, char** argv) { int semanticBudget = 4; const auto semantic = arstack::iedsim::projectBrowserReportMembers( {reportMember}, semanticBudget, 8, true, &typeIndex); - const auto details = semantic.details.constFirst().toMap(); if (reportTypes.size() != 3 || semantic.details.size() != 1 || + semantic.values.isEmpty()) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL verified_semantic_report_empty\n"; + return 91; + } + const auto details = semantic.details.constFirst().toMap(); + if ( !details.value(QStringLiteral("typed")).toBool() || !semantic.values.constFirst().contains(QStringLiteral("verified IEC")) || reportTypes.at(0).toMap().value(QStringLiteral("label")).toString() !=