Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6e037b5
feat(workbench): fail-closed verified GVAA type-bound report member p…
masarray Oct 17, 2026
5ae40d5
feat(workbench): prefer verified IEC type labels over positional repo…
masarray Oct 17, 2026
84f34bb
feat(workbench): association-scoped GVAA wire type cache feeds report…
masarray Oct 17, 2026
493c89d
feat(workbench): selected DataSet LN-root metadata GVAA preflight, bo…
masarray Oct 17, 2026
ee69046
fix(workbench): clean controller and attach bounded exact-LN GVAA met…
masarray Oct 17, 2026
bf2ad56
ui(workbench): show verified MMS attribute names and inline per-leaf …
masarray Oct 17, 2026
c73ca2b
test(workbench): verified GVAA stVal/q/t report labels and strict neg…
masarray Oct 17, 2026
6247d67
test(workbench): protect exact GVAA owner, bounded schema and truthfu…
masarray Oct 17, 2026
a9c4df7
test(workbench): update bounded fallback contract for verified-type s…
masarray Oct 17, 2026
d20faab
test(workbench): keep GVAA evidence owner alive while borrowed type i…
masarray Oct 17, 2026
4d414e5
fix(workbench): prime exact report types for unchanged authored stati…
masarray Oct 17, 2026
eb05f80
fix(workbench): propagate truncated verified leaf previews to report …
masarray Oct 17, 2026
638feba
ui(workbench): visibly mark truncated typed MMS leaf values
masarray Oct 17, 2026
1b17786
test(workbench): recognize lazy per-leaf q/t and association-wide GVA…
masarray Oct 17, 2026
f0285cf
perf(workbench): enforce 16-LN lifetime cap for selected-DataSet GVAA…
masarray Oct 17, 2026
70b9723
test(workbench): keep idle poll regression matched to typed projectio…
masarray Oct 17, 2026
80bd900
test(workbench): check semantic projection cardinality before accessi…
masarray Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 58 additions & 29 deletions apps/ied_simulator/qml/BrowserReportPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({})
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Comment thread
masarray marked this conversation as resolved.
theme: root.theme
engineeringEvidence: modelData.engineeringEvidence || ({})
}
}
}
}
Expand Down
38 changes: 29 additions & 9 deletions apps/ied_simulator/src/BrowserReportMemberProjection.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#include "MmsEngineeringEvidence.hpp"
#include "BrowserBoundedMmsPreview.hpp"
#include "BrowserVerifiedReportTypes.hpp"
#include "ariec61850/mms/reporting.hpp"

#include <QString>
Expand Down Expand Up @@ -64,7 +65,8 @@ inline constexpr int kBrowserReportDetailChildren = 16;
const std::vector<mms::MmsReportValue>& 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<std::size_t>(visibleLimit));
Expand Down Expand Up @@ -100,20 +102,37 @@ inline constexpr int kBrowserReportDetailChildren = 16;
valueTruncated = preview.truncated;
}
QVariantList detailRows;
bool exactType = false;
if (includeWireDetails && verifiedTypes &&
ordinal < kBrowserReportDetailMembers) {
Comment thread
masarray marked this conversation as resolved.
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<qulonglong>(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<qulonglong>(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<std::size_t>(kBrowserReportDetailChildren));
for (std::size_t child = 0; child < count; ++child) {
Expand All @@ -137,6 +156,7 @@ inline constexpr int kBrowserReportDetailChildren = 16;
if (!detailRows.isEmpty())
projection.details.push_back(QVariantMap{
{QStringLiteral("index"), static_cast<int>(ordinal)},
{QStringLiteral("typed"), exactType},
{QStringLiteral("rows"), detailRows}});
QString reasons;
bool reasonsTruncated = false;
Expand Down
190 changes: 190 additions & 0 deletions apps/ied_simulator/src/BrowserVerifiedReportTypes.hpp
Original file line number Diff line number Diff line change
@@ -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 <QVariantList>
#include <QVariantMap>

#include <algorithm>
#include <cstddef>
#include <map>
#include <string>
#include <string_view>
#include <utility>

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<std::string, const mms::MmsTypeSpecification*, std::less<>>;
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<mms::MmsVariableTypeEvidence>& 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<int>(depth)},
{QStringLiteral("truncated"), preview.truncated}
Comment thread
masarray marked this conversation as resolved.
});
}

[[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<qsizetype>(leaves));
appendRows(rows, *type, *member.value, QString{});
return rows;
}
} // namespace verified_report_detail
} // namespace arstack::iedsim
Loading
Loading