Skip to content
Merged
61 changes: 57 additions & 4 deletions apps/ied_simulator/qml/BrowserReportPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Rectangle {
property bool attributesExpanded: true
property bool informationExpanded: true
property bool dataExpanded: true
property bool eventsExpanded: true
property int selectedReportMemberIndex: -1
// Preserve selection through repeat snapshots of the same report frame;
// reset only when the actual ReportID/sequence/entry identity changes.
Expand Down Expand Up @@ -51,13 +52,18 @@ Rectangle {
function newestMatchingReport() {
// A DataSet can be shared by multiple RCBs. Never attribute a received
// frame to an RCB using only its DataSet or its list position.
if (!hasSelection || !rcb.reportId || !reports.receivedReports)
if (!hasSelection || !rcb.reference || !reports.receivedReports)
return ({})
const wanted = String(rcb.reportId)
const expectedRcb = String(rcb.reference)
const expectedRptId = rcb.reportId ? String(rcb.reportId) : ""
for (let index = 0; index < reports.receivedReports.length; ++index) {
const frame = reports.receivedReports[index]
if (frame.reportId && String(frame.reportId) === wanted)
return frame
// Do not attribute a frame by RptID alone: indexed RCBs can share it.
if (!frame.rcbReference || String(frame.rcbReference) !== expectedRcb)
continue
if (expectedRptId.length > 0 && String(frame.reportId) !== expectedRptId)
continue
return frame
}
return ({})
}
Expand Down Expand Up @@ -474,6 +480,53 @@ Rectangle {
}
}

SectionHeading {
heading: "RCB lifecycle & InformationReport events"
evidence: reports.events.length + " bounded event(s)"
opened: root.eventsExpanded
visible: reports.events.length > 0
onToggleRequested: root.eventsExpanded = !root.eventsExpanded
}
Repeater {
model: root.eventsExpanded
? reports.events.slice(Math.max(0, reports.events.length - 6))
: []
delegate: Label {
required property string modelData
Layout.fillWidth: true
leftPadding: 9
rightPadding: 9
topPadding: 3
bottomPadding: 3
text: modelData
color: theme.textSoft
font.pixelSize: 9
wrapMode: Text.WordWrap
}
}

Rectangle {
visible: reports.rejectedReportCount > 0 || reports.reportDecodeFailureCount > 0
Layout.fillWidth: true
implicitHeight: reportRejections.implicitHeight + 16
color: theme.surface
border.width: 1
border.color: theme.amber
Label {
id: reportRejections
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 8
font.pixelSize: 10
wrapMode: Text.WordWrap
color: theme.amber
text: reports.rejectedReportCount + " foreign/stale report(s) withheld from process values · "
+ reports.reportDecodeFailureCount + " report decode failure(s). "
+ "Inspect report events for exact diagnostics."
}
}

Rectangle {
visible: reports.cleanupRequired || (reports.lastError && reports.lastError.length > 0)
Layout.fillWidth: true
Expand Down
36 changes: 34 additions & 2 deletions apps/ied_simulator/src/MmsReportController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ mms::MmsLiveDiscoveryResult buildContextDiscoverySeed(
static const std::vector<std::string> reportAttributes{
"DatSet", "RptID", "ConfRev", "IntgPd", "BufTm", "SqNum",
"RptEna", "Resv", "ResvTms", "Owner", "EntryID", "TimeOfEntry",
"TrgOps", "OptFlds"};
"TrgOps", "OptFlds", "GI"};

for (const auto& control : model.report_controls) {
mms::MmsReportControlCandidate candidate;
Expand Down Expand Up @@ -405,7 +405,14 @@ void probeContextReportControls(
}
mms::MmsReportControlEvidence evidence;
evidence.candidate = candidate;
evidence.requested_attributes = candidate.attributes;
// The canonical inventory lists readable fields AND supported
// trigger/write surfaces. GI is a command, not a mandatory Read.
// Do not fail live attach on IEDs that expose GI as write-only.
evidence.requested_attributes.reserve(candidate.attributes.size());
for (const auto& attribute : candidate.attributes) {
if (attribute != "GI")
evidence.requested_attributes.push_back(attribute);
}
try {
const auto invokeId = association.next_invoke_id();
const auto request = mms::MmsReportControlStateMapper::build_read_request(
Expand All @@ -431,6 +438,8 @@ struct ReportUi final {
QVariantList reports;
QStringList events;
qulonglong receivedCount{};
qulonglong rejectedCount{};
qulonglong decodeFailureCount{};
bool active{};
bool cleanupRequired{};
};
Expand All @@ -441,7 +450,12 @@ ReportUi buildSubscriptionUi(
ReportUi ui;
ui.active = active;
ui.receivedCount = subscription.received_reports;
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));
}
Expand All @@ -456,6 +470,8 @@ ReportUi buildSubscriptionUi(
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));
Expand Down Expand Up @@ -617,6 +633,8 @@ void MmsReportController::clearInventory() {
receivedReports_.clear();
events_.clear();
receivedReportCount_ = 0;
rejectedReportCount_ = 0;
reportDecodeFailureCount_ = 0;
emit inventoryChanged();
emit selectionChanged();
emit reportsChanged();
Expand Down Expand Up @@ -664,6 +682,8 @@ void MmsReportController::adoptEngineeringInventory() {
receivedReports_.clear();
events_.clear();
receivedReportCount_ = 0;
rejectedReportCount_ = 0;
reportDecodeFailureCount_ = 0;
refreshSelection();
emit inventoryChanged();
emit reportsChanged();
Expand Down Expand Up @@ -1510,6 +1530,8 @@ bool MmsReportController::enableSelectedAuthored(
self->receivedReports_ = ui.reports;
self->events_ = ui.events;
self->receivedReportCount_ = ui.receivedCount;
self->rejectedReportCount_ = ui.rejectedCount;
self->reportDecodeFailureCount_ = ui.decodeFailureCount;
if (selectedRow >= 0 &&
selectedRow < self->reportControls_.size()) {
auto map =
Expand Down Expand Up @@ -1641,6 +1663,8 @@ bool MmsReportController::enableSelected(const bool requestGeneralInterrogation)
self->receivedReports_ = ui.reports;
self->events_ = ui.events;
self->receivedReportCount_ = ui.receivedCount;
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 explicitly.")
Expand Down Expand Up @@ -1703,6 +1727,8 @@ bool MmsReportController::disableSelected() {
self->receivedReports_ = ui.reports;
self->events_ = ui.events;
self->receivedReportCount_ = ui.receivedCount;
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.")
Expand Down Expand Up @@ -1769,6 +1795,8 @@ bool MmsReportController::retryCleanup() {
self->receivedReports_ = ui.reports;
self->events_ = ui.events;
self->receivedReportCount_ = ui.receivedCount;
self->rejectedReportCount_ = ui.rejectedCount;
self->reportDecodeFailureCount_ = ui.decodeFailureCount;
self->state_ = ui.cleanupRequired ? State::cleanup_required : State::ready;
self->lastError_.clear();
self->appendDiagnostic(ui.cleanupRequired
Expand Down Expand Up @@ -1836,6 +1864,8 @@ void MmsReportController::schedulePoll() {
self->receivedReports_ = ui.reports;
self->events_ = ui.events;
self->receivedReportCount_ = ui.receivedCount;
self->rejectedReportCount_ = ui.rejectedCount;
self->reportDecodeFailureCount_ = ui.decodeFailureCount;
emit self->reportsChanged();
}, Qt::QueuedConnection);
}
Expand Down Expand Up @@ -1866,6 +1896,8 @@ void MmsReportController::schedulePoll() {
self->receivedReports_ = ui.reports;
self->events_ = ui.events;
self->receivedReportCount_ = ui.receivedCount;
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));
Expand Down
6 changes: 6 additions & 0 deletions apps/ied_simulator/src/MmsReportController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class MmsReportController : public QObject {
Q_PROPERTY(QVariantList receivedReports READ receivedReports NOTIFY reportsChanged)
Q_PROPERTY(QStringList events READ events NOTIFY reportsChanged)
Q_PROPERTY(qulonglong receivedReportCount READ receivedReportCount NOTIFY reportsChanged)
Q_PROPERTY(qulonglong rejectedReportCount READ rejectedReportCount NOTIFY reportsChanged)
Q_PROPERTY(qulonglong reportDecodeFailureCount READ reportDecodeFailureCount NOTIFY reportsChanged)
Q_PROPERTY(qulonglong generation READ generation NOTIFY stateChanged)

public:
Expand Down Expand Up @@ -73,6 +75,8 @@ class MmsReportController : public QObject {
[[nodiscard]] QVariantList receivedReports() const { return receivedReports_; }
[[nodiscard]] QStringList events() const { return events_; }
[[nodiscard]] qulonglong receivedReportCount() const noexcept { return receivedReportCount_; }
[[nodiscard]] qulonglong rejectedReportCount() const noexcept { return rejectedReportCount_; }
[[nodiscard]] qulonglong reportDecodeFailureCount() const noexcept { return reportDecodeFailureCount_; }
[[nodiscard]] qulonglong generation() const noexcept { return generation_; }

Q_INVOKABLE bool connectToIed();
Expand Down Expand Up @@ -149,6 +153,8 @@ class MmsReportController : public QObject {
QStringList events_;
QStringList diagnostics_;
qulonglong receivedReportCount_{};
qulonglong rejectedReportCount_{};
qulonglong reportDecodeFailureCount_{};
qulonglong generation_{};
QThreadPool ioPool_;
QTimer pollTimer_;
Expand Down
8 changes: 8 additions & 0 deletions apps/ied_simulator/src/ied_report_workspace_qa.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ bool reportProjectionPresent(const MmsReportController& reports) {
if (frames.isEmpty()) return false;
const auto frame = frames.first().toMap();
return !frame.value(QStringLiteral("reportId")).toString().isEmpty() &&
!frame.value(QStringLiteral("rcbReference")).toString().isEmpty() &&
!frame.value(QStringLiteral("dataSet")).toString().isEmpty() &&
!frame.value(QStringLiteral("values")).toStringList().isEmpty() &&
frame.contains(QStringLiteral("entryId")) &&
Expand Down Expand Up @@ -356,6 +357,13 @@ int main(int argc, char* argv[]) {
return 12;
}
const auto reportCountAfterGi = reports.receivedReportCount();
if (reports.rejectedReportCount() != 0U ||
reports.reportDecodeFailureCount() != 0U) {
qCritical() << "REPORTS_WORKBENCH_FAIL accepted_gi_report_authority"
<< reports.rejectedReportCount()
<< reports.reportDecodeFailureCount();
return 31;
}
if (!reportProjectionPresent(reports)) {
qCritical() << "REPORTS_WORKBENCH_FAIL report_projection";
return 13;
Expand Down
5 changes: 4 additions & 1 deletion apps/ied_simulator/test_browser_convergence.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,15 @@
'BrowserEngineeringEvidence {',
'root.latestReport.valuesTruncated === true',
'root.latestReport.valueTextTruncated === true',
'String(frame.reportId) === wanted',
'String(frame.rcbReference) !== expectedRcb',
'String(frame.reportId) !== expectedRptId',
'Offline values are not fabricated.',
]
missing_report = [term for term in report_required if term not in report_inspector]
if missing_report:
raise SystemExit("BROWSER_REPORT_INSPECTOR_FAIL missing=" + ",".join(missing_report))
if 'String(frame.reportId) === wanted' in report_inspector:
raise SystemExit("BROWSER_REPORT_INSPECTOR_FAIL report ID alone is not exact RCB provenance")
if 'title: modelData[0]' in report_inspector or 'rawValue: modelData[1]' in report_inspector:
raise SystemExit("BROWSER_REPORT_INSPECTOR_FAIL nested-array attribute labels")
if "ReportsWorkspace {" in workspace:
Expand Down
9 changes: 9 additions & 0 deletions include/ariec61850/mms/report_subscription_runtime.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ enum class MmsReportSubscriptionEventKind : std::uint8_t {
attribute_written,
enabled,
general_interrogation_sent,
general_interrogation_not_sent,
report_received,
report_rejected,
report_decode_failed,
disabled,
reservation_released,
Expand All @@ -63,6 +65,10 @@ struct MmsReportSubscriptionOptions final {
std::vector<std::uint8_t> trigger_options;
std::vector<std::uint8_t> optional_fields;
std::string data_set_reference;
// Pin the DataSet whose directory labels the values. Used by static
// subscriptions so another client cannot silently rebind between
// discovery and the first RCB enable.
std::string expected_data_set_reference;
std::size_t maximum_events{1'024U};
MmsOfflineReportMonitorOptions monitor_options{};
};
Expand All @@ -73,7 +79,9 @@ struct MmsReportSubscriptionSnapshot final {
bool reservation_touched{};
bool cleanup_required{};
std::uint64_t received_reports{};
std::uint64_t rejected_reports{};
std::uint64_t decode_failures{};
std::string active_rcb_reference;
std::optional<MmsReportControlState> last_rcb_state;
std::vector<MmsOfflineReportStreamSnapshot> streams;
std::vector<MmsReportSubscriptionEvent> events;
Expand Down Expand Up @@ -129,6 +137,7 @@ class MmsReportSubscriptionRuntime final {
bool restore_enabled_required_{};
bool cleanup_required_{};
std::uint64_t received_reports_{};
std::uint64_t rejected_reports_{};
std::uint64_t decode_failures_{};
};

Expand Down
10 changes: 10 additions & 0 deletions include/ariec61850/mms/reporting.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,16 @@ struct MmsReportControlState final {
std::string availability_reason;
};

// Secondary RCB authority validation. BER validity alone cannot assign
// process values to the selected DataSet/RCB.
class MmsReportFrameBindingValidator final {
public:
[[nodiscard]] static std::string rejection_reason(
const MmsReportFrame& frame,
const MmsReportControlState& verified_rcb,
const MmsDataSetDirectoryResponse& directory);
};

class MmsReportControlStateMapper final {
public:
[[nodiscard]] static MmsReadRequest build_read_request(
Expand Down
Loading
Loading