diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 359b6d1ef..9cc5c1a4d 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,58 @@ 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 + + (modelData.truncated === true + ? " … [truncated]" : "") + 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 || ({}) + } } } } diff --git a/apps/ied_simulator/src/BrowserReportMemberProjection.hpp b/apps/ied_simulator/src/BrowserReportMemberProjection.hpp index 99a664e9b..5e005a915 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,37 @@ 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 (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(); 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 +156,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; 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 diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 758001e55..da5825f24 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -478,6 +478,66 @@ void probeContextReportControls( } } +// 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, + const QString& selectedDataSet, + std::set& attemptedRoots, + std::stop_token token) { + const mms::MmsDataSetDirectoryResponse* directory{}; + for (const auto& candidate : discovery.data_set_directories) { + if (candidate.directory && sameDataSetReference( + QString::fromStdString(candidate.candidate.reference), selectedDataSet)) { + directory = &*candidate.directory; + break; + } + } + if (!directory) return; + std::size_t attempts{}; + for (const auto& member : directory->members) { + // 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; + 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 == name.domain && + existing.variable.item == root; + }); + if (known) continue; + ++attempts; + try { + mms::MmsVariableTypeEvidence evidence; + 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 encoded = + mms::MmsServiceCodec::encode_variable_access_attributes_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed(encoded, invoke, token); + evidence.attributes = + mms::MmsServiceCodec::decode_variable_access_attributes_response( + confirmedPayload(exchange), invoke); + discovery.variable_types.push_back(std::move(evidence)); + } catch (const std::exception&) { + break; // Unsupported GVAA must not fail RCB activation. + } + } +} + struct ReportUi final { QVariantList reports; QStringList events; @@ -491,7 +551,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 +598,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 +618,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 +643,8 @@ 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::set attemptedTypeRoots; std::unique_ptr report; std::unique_ptr authoredReport; std::unique_ptr dynamicDataSets; @@ -794,6 +862,8 @@ bool MmsReportController::connectToIed() { if (worker->session) worker->session->disconnect(); worker->session.reset(); worker->discovery.reset(); + worker->verifiedTypes.clear(); + worker->attemptedTypeRoots.clear(); mms::MmsAssociationOptions associationOptions; associationOptions.connect_timeout = std::chrono::milliseconds{5'000}; @@ -838,6 +908,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 +940,8 @@ bool MmsReportController::connectToIed() { worker->dynamicDataSets.reset(); 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] { @@ -922,6 +997,8 @@ void MmsReportController::disconnectFromIed() { worker->authoredReport.reset(); 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. @@ -1484,6 +1561,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 && @@ -1547,7 +1632,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 +1661,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); } @@ -1717,8 +1802,25 @@ 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()); + const auto ui = buildReportUi(report->snapshot(), &worker->verifiedTypes); worker->report = std::move(report); // A new active subscription invalidates the previous idle @@ -1779,11 +1881,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 +1952,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 +2047,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 +2099,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()); 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..326bcd85c 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,105 @@ 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") + }) + }) + }); + // 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 = + 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); + 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() != + 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 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() || + !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 diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 41fadf5b2..6a43609c2 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") @@ -281,8 +281,51 @@ ): 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. +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", + "attemptedRoots.size() >= 16U", + "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 [ @@ -414,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", ): @@ -424,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(