Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
153 changes: 153 additions & 0 deletions src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
namespace AR.Iec61850.Mms;

/// <summary>
/// Client-compatibility activation wrapper for persistent reporting.
///
/// Mature IEC 61850 clients normally reserve a BRCB when ResvTms is exposed,
/// enable reporting, install/retain the report receiver, and only then request GI.
/// Some servers also support implicit BRCB reservation through RptEna=true, so an
/// explicit ResvTms rejection is non-fatal and the baseline activation is still tried.
///
/// This wrapper does not create dynamic DataSets and does not schedule cyclic process
/// reads. It only hardens the RCB control-plane sequence used by report acquisition.
/// </summary>
public sealed partial class MmsClientSession
{
public async Task<MmsPersistentReportMonitorAttemptResult> StartPersistentReportMonitorClientCompatibleAsync(
MmsReportSubscriptionPlan plan,
bool triggerGeneralInterrogation = true,
bool deleteDynamicDataSetOnStop = true,
MmsIedModelDirectory? directory = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plan);

var rcb = plan.ReportControl;
MmsReportAttributeWriteStep? reservationStep = null;
var compatibilityWarnings = new List<string>();

if (rcb is { Buffered: true } &&
rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase) &&
!MmsReportSubscriptionPlanner.IsExplicitlyEnabled(rcb) &&
!MmsReportSubscriptionPlanner.IsReservedByOtherClient(rcb))
{
reservationStep = await WriteReportAttributeAsync(
rcb,
"ResvTms",
MmsDataValue.Unsigned(60),
cancellationToken).ConfigureAwait(false);

if (!reservationStep.IsSuccess)
{
compatibilityWarnings.Add(
$"BRCB ResvTms=60 explicit reservation was not accepted ({reservationStep.Message}). Continuing with standards-compatible implicit reservation through RptEna=true.");
}
}

// Deliberately suppress GI inside the baseline start. The baseline method registers
// the persistent monitor only after RptEna succeeds. Requesting GI below guarantees
// that the report receiver/session is already registered when the server emits the
// initial InformationReport, while the receive router still preserves any earlier
// unconfirmed traffic that arrived during confirmed writes.
var attempt = await StartPersistentReportMonitorWithAttemptEvidenceAsync(
plan,
triggerGeneralInterrogation: false,
deleteDynamicDataSetOnStop,
directory,
cancellationToken).ConfigureAwait(false);

var start = attempt.StartResult;
var writes = new List<MmsReportAttributeWriteStep>();
if (reservationStep is not null)
writes.Add(reservationStep);
writes.AddRange(start.WriteSteps);

var warnings = start.Warnings
.Where(warning => !warning.Contains("ResvTms pre-reserve was skipped", StringComparison.OrdinalIgnoreCase))
.Concat(compatibilityWarnings)
.ToList();

if (!attempt.IsSuccess || start.Session is null)
{
var cleanupSteps = attempt.CleanupSteps.ToList();
var cleanupWarnings = attempt.CleanupWarnings.ToList();
var cleanupAttempted = attempt.CleanupAttempted;
var cleanupSucceeded = attempt.CleanupSucceeded;

if (reservationStep?.IsSuccess == true && rcb is not null)
{
var release = await TryWriteReportAttributeForCleanupAsync(
rcb,
"ResvTms",
MmsDataValue.Unsigned(0),
CancellationToken.None).ConfigureAwait(false);
cleanupSteps.Add(release);
cleanupAttempted = true;
cleanupSucceeded &= release.IsSuccess;
if (!release.IsSuccess)
cleanupWarnings.Add($"BRCB ResvTms cleanup after failed activation was not accepted: {release.Message}");
}

return new MmsPersistentReportMonitorAttemptResult
{
StartResult = CopyStartResult(start, writes, warnings),
DynamicAttemptState = attempt.DynamicAttemptState,
FailureReason = attempt.FailureReason,
CleanupAttempted = cleanupAttempted,
CleanupSucceeded = cleanupSucceeded,
CleanupSteps = cleanupSteps,
CleanupWarnings = cleanupWarnings
};
}

if (reservationStep?.IsSuccess == true)
start.Session.ReservationTouched = true;

if (triggerGeneralInterrogation)
{
var gi = await WriteReportAttributeAsync(
start.Session.ReportControl,
"GI",
MmsDataValue.Boolean(true),
cancellationToken).ConfigureAwait(false);
writes.Add(gi);
if (!gi.IsSuccess)
warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only.");
}

var compatibilityMessage = reservationStep?.IsSuccess == true
? "BRCB explicitly reserved with ResvTms=60 before RptEna; GI was requested only after the persistent receiver was registered."
: "GI was requested only after the persistent receiver was registered.";

return new MmsPersistentReportMonitorAttemptResult
{
StartResult = CopyStartResult(
start,
writes,
warnings,
$"{start.Message} {compatibilityMessage}"),
DynamicAttemptState = attempt.DynamicAttemptState,
FailureReason = attempt.FailureReason,
CleanupAttempted = attempt.CleanupAttempted,
CleanupSucceeded = attempt.CleanupSucceeded,
CleanupSteps = attempt.CleanupSteps,
CleanupWarnings = attempt.CleanupWarnings
};
}

private static MmsPersistentReportMonitorStartResult CopyStartResult(
MmsPersistentReportMonitorStartResult source,
IReadOnlyList<MmsReportAttributeWriteStep> writes,
IReadOnlyList<string> warnings,
string? message = null)
=> new()
{
IsSuccess = source.IsSuccess,
Message = message ?? source.Message,
Session = source.Session,
WriteSteps = writes.ToArray(),
Warnings = warnings.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
RcbSnapshots = source.RcbSnapshots,
DataSetSnapshots = source.DataSetSnapshots
};
}
105 changes: 85 additions & 20 deletions src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,22 @@ internal bool TryExpand(
MmsReportValue reportValue,
out IReadOnlyList<MmsReportValue> expanded,
out string reason)
=> TryExpand(
dataSetReference,
reportValue,
out expanded,
out _,
out reason);

internal bool TryExpand(
string dataSetReference,
MmsReportValue reportValue,
out IReadOnlyList<MmsReportValue> expanded,
out string resolvedMemberReference,
out string reason)
{
expanded = Array.Empty<MmsReportValue>();
resolvedMemberReference = string.Empty;
reason = string.Empty;

if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array))
Expand Down Expand Up @@ -127,6 +141,7 @@ internal bool TryExpand(
ReasonForInclusion = reportValue.ReasonForInclusion
})
.ToArray();
resolvedMemberReference = schema.MemberReference;
reason = $"expanded {schema.MemberReference} into {expanded.Count} scalar semantic descendant(s)";
return true;
}
Expand Down Expand Up @@ -281,8 +296,9 @@ private sealed record ExpandedLeaf(string Reference, MmsDataValue Value);
}

/// <summary>
/// Backward-compatible overlay for structures that the established report projector
/// intentionally leaves raw. Existing known CDC projections remain untouched.
/// Model-backed overlay for structured report members. Exact static DataSet/SCL schema is
/// authoritative when it can expand the structure safely; the established generic projector
/// remains the fail-closed fallback when no unique semantic schema matches.
/// </summary>
public static class MmsSemanticReportValueProjector
{
Expand All @@ -294,23 +310,36 @@ public static MmsReportValueProjection Project(
ArgumentNullException.ThrowIfNull(context);

var baseline = MmsReportValueProjector.Project(frame);
var replacementParents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var semanticReplacementPositions = new HashSet<int>();
var semanticUpdates = new List<MmsReportSignalUpdate>();
var semanticWarnings = new List<string>();

foreach (var reportValue in frame.Values)
for (var valuePosition = 0; valuePosition < frame.Values.Count; valuePosition++)
{
var reportValue = frame.Values[valuePosition];
if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array))
continue;

var parentReference = reportValue.MemberReference;
var rawPrefix = $"REPORT_RAW_STRUCT: {parentReference} ";
if (!baseline.Warnings.Any(warning => warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase)))
continue;

if (!context.TryExpand(frame.Header.DataSetReference, reportValue, out var expanded, out var expansionReason))
var reportedMemberReference = reportValue.MemberReference;
var rawPrefix = $"REPORT_RAW_STRUCT: {reportedMemberReference} ";
var baselineWasRaw = baseline.Warnings.Any(warning =>
warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase));

// Static DataSet identity + exact SCL/live schema is stronger evidence than a
// generic shape heuristic. Try semantic expansion first for every structured
// member, including structures the baseline recognizes as instMag/mag pairs.
// If the exact schema cannot prove the mapping, preserve baseline behavior.
if (!context.TryExpand(
frame.Header.DataSetReference,
reportValue,
out var expanded,
out var resolvedMemberReference,
out var expansionReason))
{
semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible.");
if (baselineWasRaw)
{
semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible.");
}
continue;
}

Expand All @@ -325,11 +354,20 @@ public static MmsReportValueProjection Project(
var projected = MmsReportValueProjector.Project(synthetic);
if (projected.Updates.Count == 0 || projected.Warnings.Any(warning => warning.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase)))
{
semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} expansion was not publishable; baseline raw projection was preserved.");
if (baselineWasRaw)
{
semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} expansion was not publishable; baseline raw projection was preserved.");
}
continue;
}

replacementParents.Add(Normalize(parentReference));
// Replace the generic projection by report-value position, not by a guessed parent
// reference. Some valid InformationReports omit member identity and are resolved
// only by the exact static DataSet + member index. In that case the generic
// projector can emit unrooted heuristic leaves, so descendant-name filtering is
// neither sufficient nor safe. A successful semantic projection owns this report
// value completely; generic projection remains available only for other values.
semanticReplacementPositions.Add(valuePosition);
semanticUpdates.AddRange(projected.Updates.Select(update => new MmsReportSignalUpdate
{
Reference = update.Reference,
Expand All @@ -347,10 +385,10 @@ public static MmsReportValueProjection Project(
IsProjectedChild = true,
ProjectionStatus = "semantic-structured-leaf"
}));
semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {parentReference} {expansionReason}; static DataSet membership identity was preserved.");
semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {resolvedMemberReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics.");
}

if (replacementParents.Count == 0)
if (semanticReplacementPositions.Count == 0)
{
return new MmsReportValueProjection
{
Expand All @@ -359,16 +397,34 @@ public static MmsReportValueProjection Project(
};
}

var updates = baseline.Updates
.Where(update => !replacementParents.Contains(Normalize(update.Reference)))
// Re-project only report values that were not replaced semantically. This preserves
// normal generic scalar/companion behavior for unrelated members while guaranteeing
// that no heuristic output from a successfully resolved structured member survives,
// even when the wire report omitted MemberReference entirely.
var retainedFrame = new MmsReportFrame
{
ReceivedAt = frame.ReceivedAt,
Header = frame.Header,
Values = frame.Values
.Where((_, index) => !semanticReplacementPositions.Contains(index))
.ToArray(),
DecoderMode = frame.DecoderMode,
Message = frame.Message
};
var retainedBaseline = MmsReportValueProjector.Project(retainedFrame);

var updates = retainedBaseline.Updates
.Concat(semanticUpdates)
.GroupBy(update => Normalize(update.Reference) + "|" + update.FunctionalConstraint, StringComparer.OrdinalIgnoreCase)
.Select(group => group.Last())
.OrderBy(update => update.Reference, StringComparer.OrdinalIgnoreCase)
// q/t companions are intentionally delivered before scalar values. Consumers can
// therefore attach report-native quality/timestamp to semantic value leaves without
// inventing defaults or issuing a separate MMS read.
.OrderBy(update => CompanionPriority(update.Reference))
.ThenBy(update => update.Reference, StringComparer.OrdinalIgnoreCase)
.ToArray();

var warnings = baseline.Warnings
.Where(warning => !replacementParents.Any(parent => warning.StartsWith($"REPORT_RAW_STRUCT: {parent} ", StringComparison.OrdinalIgnoreCase)))
var warnings = retainedBaseline.Warnings
.Concat(semanticWarnings)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
Expand All @@ -380,6 +436,15 @@ public static MmsReportValueProjection Project(
};
}

private static int CompanionPriority(string reference)
{
var normalized = Normalize(reference);
return normalized.EndsWith(".q", StringComparison.OrdinalIgnoreCase) ||
normalized.EndsWith(".t", StringComparison.OrdinalIgnoreCase)
? 0
: 1;
}

private static string Normalize(string value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty
Expand Down
Loading