diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs index bd60b925..0d9cb2db 100644 --- a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs @@ -150,7 +150,7 @@ private static CanonicalIedModel Build( dataSets[i] = new CanonicalDataSet { - Reference = RemapReference(dataSet.Reference, context.DomainAliases), + Reference = NormalizeConfiguredReference(dataSet.Reference, context.DomainAliases), MmsDomain = RemapDomain(dataSet.Domain, context.DomainAliases), LogicalNode = dataSet.LogicalNode, Name = dataSet.Name, @@ -167,12 +167,12 @@ private static CanonicalIedModel Build( var report = source.ReportControls[i]; reportControls[i] = new CanonicalReportControl { - Reference = RemapReference(report.Reference, context.DomainAliases), + Reference = NormalizeConfiguredReference(report.Reference, context.DomainAliases), MmsDomain = RemapDomain(report.Domain, context.DomainAliases), LogicalNode = report.LogicalNode, Name = report.Name, Buffered = report.Buffered, - DataSetReference = RemapReference(report.DataSetReference, context.DomainAliases), + DataSetReference = NormalizeConfiguredReference(report.DataSetReference, context.DomainAliases), ReportId = report.ReportId, ConfRev = report.ConfRev, TriggerOptions = report.TriggerOptions, @@ -232,6 +232,17 @@ private static CanonicalIedModel Build( }; } + /// + /// Canonical DataSet/ReportControl identity uses one source-neutral dotted form. + /// SCL commonly carries '$' MMS component separators while live discovery may expose + /// the same configured resource with dots. Source projections retain their original + /// spelling; the shared CanonicalIedModel does not. + /// + private static string NormalizeConfiguredReference( + string value, + IReadOnlyDictionary aliases) + => RemapReference(value, aliases).Replace('$', '.'); + private static string RemapDomain(string value, IReadOnlyDictionary aliases) { var normalized = value?.Trim() ?? string.Empty; diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs new file mode 100644 index 00000000..c1e4ecba --- /dev/null +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs @@ -0,0 +1,282 @@ +namespace AR.Iec61850.Engineering.Canonical; + +public enum CanonicalStaticReportSignalCoverageStatus +{ + Covered, + SignalNotFound, + AmbiguousSignalIdentity, + NoStaticDataSetMembership, + StaticDataSetWithoutReportControl +} + +public sealed class CanonicalStaticReportSelection +{ + public string Reference { get; init; } = string.Empty; + public string FunctionalConstraint { get; init; } = string.Empty; +} + +public sealed class CanonicalStaticReportDataSetCandidate +{ + public string DataSetReference { get; init; } = string.Empty; + public string SourceDataSetReference { get; init; } = string.Empty; + public int[] MemberIndexes { get; init; } = Array.Empty(); + public string[] ReportControlReferences { get; init; } = Array.Empty(); +} + +public sealed class CanonicalStaticReportSignalCoverage +{ + public string RequestedReference { get; init; } = string.Empty; + public string RequestedFunctionalConstraint { get; init; } = string.Empty; + public string CanonicalReference { get; init; } = string.Empty; + public string CanonicalFunctionalConstraint { get; init; } = string.Empty; + public CanonicalStaticReportSignalCoverageStatus Status { get; init; } + public CanonicalStaticReportDataSetCandidate[] DataSetCandidates { get; init; } = Array.Empty(); + + public bool IsCovered => Status == CanonicalStaticReportSignalCoverageStatus.Covered; +} + +public sealed class CanonicalStaticReportCoverageSegment +{ + public string DataSetReference { get; init; } = string.Empty; + public string SourceDataSetReference { get; init; } = string.Empty; + public CanonicalDataSetMember[] OrderedMembers { get; init; } = Array.Empty(); + public CanonicalReportControl[] ReportControls { get; init; } = Array.Empty(); + public string[] SelectedSignalReferences { get; init; } = Array.Empty(); +} + +public sealed class CanonicalStaticReportCoveragePlan +{ + public CanonicalIngressKind Ingress { get; init; } + public CanonicalStaticReportSignalCoverage[] Signals { get; init; } = Array.Empty(); + public CanonicalStaticReportCoverageSegment[] Segments { get; init; } = Array.Empty(); + + public int RequestedSignalCount => Signals.Length; + public int CoveredSignalCount => Signals.Count(signal => signal.IsCovered); + public int UncoveredSignalCount => RequestedSignalCount - CoveredSignalCount; +} + +/// +/// Source-neutral static-report coverage over the shared CanonicalIedModel. +/// +/// This resolver never chooses an operational RCB instance and performs no IED writes. +/// It answers one question only: which configured static DataSet/ReportControl resources +/// already cover each exact canonical signal? Discovery and Open-SCL therefore enter the +/// same coverage logic after canonicalization. +/// +/// Structured DataSet members are handled by exact reference-boundary containment. A +/// member such as A.phsA covers its typed descendants but never sibling phases. Full +/// ordered DataSet membership is retained in every segment because InformationReport +/// projection remains positional even when the application selected only a subset. +/// +public static class CanonicalStaticReportCoverageResolver +{ + public static CanonicalStaticReportCoveragePlan Resolve( + CanonicalIedModel model, + IEnumerable selections) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + + var requested = selections.ToArray(); + var signalIndex = BuildSignalIndex(model); + var reportByDataSet = model.ReportControls + .Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference)) + .GroupBy(report => NormalizeConfiguredReference(report.DataSetReference), StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group + .OrderBy(report => NormalizeConfiguredReference(report.Reference), StringComparer.Ordinal) + .ToArray(), + StringComparer.Ordinal); + + var results = new CanonicalStaticReportSignalCoverage[requested.Length]; + for (var index = 0; index < requested.Length; index++) + results[index] = ResolveSignal(model, requested[index], signalIndex, reportByDataSet); + + var segmentDataSets = results + .Where(result => result.IsCovered) + .SelectMany(result => result.DataSetCandidates) + .Select(candidate => candidate.DataSetReference) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + var segments = segmentDataSets + .Select(dataSetReference => BuildSegment( + model, + dataSetReference, + results, + reportByDataSet)) + .Where(segment => segment is not null) + .Cast() + .ToArray(); + + return new CanonicalStaticReportCoveragePlan + { + Ingress = model.Source.Ingress, + Signals = results, + Segments = segments + }; + } + + private static CanonicalStaticReportSignalCoverage ResolveSignal( + CanonicalIedModel model, + CanonicalStaticReportSelection selection, + IReadOnlyDictionary signalIndex, + IReadOnlyDictionary reportByDataSet) + { + var requestedReference = selection.Reference?.Trim() ?? string.Empty; + var requestedFc = NormalizeFc(selection.FunctionalConstraint); + + if (!signalIndex.TryGetValue(requestedReference, out var referenceMatches)) + { + return new CanonicalStaticReportSignalCoverage + { + RequestedReference = requestedReference, + RequestedFunctionalConstraint = requestedFc, + Status = CanonicalStaticReportSignalCoverageStatus.SignalNotFound + }; + } + + var matches = string.IsNullOrWhiteSpace(requestedFc) + ? referenceMatches + : referenceMatches + .Where(signal => string.Equals( + NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint)), + requestedFc, + StringComparison.Ordinal)) + .ToArray(); + + if (matches.Length != 1) + { + return new CanonicalStaticReportSignalCoverage + { + RequestedReference = requestedReference, + RequestedFunctionalConstraint = requestedFc, + Status = matches.Length == 0 + ? CanonicalStaticReportSignalCoverageStatus.SignalNotFound + : CanonicalStaticReportSignalCoverageStatus.AmbiguousSignalIdentity + }; + } + + var signal = matches[0]; + var canonicalReference = model.Strings.Resolve(signal.ObjectReference); + var canonicalFc = NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint)); + + var candidates = new List(); + var anyDataSet = false; + foreach (var dataSet in model.DataSets) + { + var memberIndexes = dataSet.Members + .Where(member => MemberCoversSignal(member, canonicalReference, canonicalFc)) + .Select(member => member.Index) + .OrderBy(memberIndex => memberIndex) + .ToArray(); + if (memberIndexes.Length == 0) + continue; + + anyDataSet = true; + var canonicalDataSetReference = NormalizeConfiguredReference(dataSet.Reference); + reportByDataSet.TryGetValue(canonicalDataSetReference, out var reports); + candidates.Add(new CanonicalStaticReportDataSetCandidate + { + DataSetReference = canonicalDataSetReference, + SourceDataSetReference = dataSet.Reference, + MemberIndexes = memberIndexes, + ReportControlReferences = reports? + .Select(report => NormalizeConfiguredReference(report.Reference)) + .ToArray() ?? Array.Empty() + }); + } + + var reportBacked = candidates + .Where(candidate => candidate.ReportControlReferences.Length > 0) + .OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal) + .ToArray(); + + return new CanonicalStaticReportSignalCoverage + { + RequestedReference = requestedReference, + RequestedFunctionalConstraint = requestedFc, + CanonicalReference = canonicalReference, + CanonicalFunctionalConstraint = canonicalFc, + Status = reportBacked.Length > 0 + ? CanonicalStaticReportSignalCoverageStatus.Covered + : anyDataSet + ? CanonicalStaticReportSignalCoverageStatus.StaticDataSetWithoutReportControl + : CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership, + DataSetCandidates = reportBacked.Length > 0 + ? reportBacked + : candidates.OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal).ToArray() + }; + } + + private static CanonicalStaticReportCoverageSegment? BuildSegment( + CanonicalIedModel model, + string dataSetReference, + IReadOnlyList results, + IReadOnlyDictionary reportByDataSet) + { + var dataSet = model.DataSets.SingleOrDefault(candidate => + string.Equals( + NormalizeConfiguredReference(candidate.Reference), + dataSetReference, + StringComparison.Ordinal)); + if (dataSet is null) + return null; + + reportByDataSet.TryGetValue(dataSetReference, out var reports); + reports ??= Array.Empty(); + + var selected = results + .Where(result => result.IsCovered && + result.DataSetCandidates.Any(candidate => + string.Equals(candidate.DataSetReference, dataSetReference, StringComparison.Ordinal))) + .Select(result => result.CanonicalReference) + .Distinct(StringComparer.Ordinal) + .OrderBy(reference => reference, StringComparer.Ordinal) + .ToArray(); + + return new CanonicalStaticReportCoverageSegment + { + DataSetReference = dataSetReference, + SourceDataSetReference = dataSet.Reference, + OrderedMembers = dataSet.Members.OrderBy(member => member.Index).ToArray(), + ReportControls = reports, + SelectedSignalReferences = selected + }; + } + + private static IReadOnlyDictionary BuildSignalIndex(CanonicalIedModel model) + => model.Signals + .GroupBy(signal => model.Strings.Resolve(signal.ObjectReference), StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + + private static bool MemberCoversSignal( + CanonicalDataSetMember member, + string signalReference, + string signalFc) + { + var memberReference = member.Reference?.Trim() ?? string.Empty; + if (memberReference.Length == 0 || signalReference.Length == 0) + return false; + + var memberFc = NormalizeFc(member.FunctionalConstraint); + if (memberFc.Length > 0 && + !string.Equals(memberFc, signalFc, StringComparison.Ordinal)) + { + return false; + } + + return string.Equals(signalReference, memberReference, StringComparison.Ordinal) || + signalReference.StartsWith(memberReference + ".", StringComparison.Ordinal); + } + + private static string NormalizeConfiguredReference(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? string.Empty : text.Replace('$', '.'); + } + + private static string NormalizeFc(string? value) + => (value ?? string.Empty).Trim().ToUpperInvariant(); +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs index 5fb5da12..5a5ec185 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs @@ -45,15 +45,27 @@ public static IReadOnlyList BuildBrcbEnableFa public sealed partial class MmsClientSession { /// - /// Starts a persistent monitor for a static DataSet already trusted from SCL. + /// Backward-compatible SCL entry point. Once a caller supplies an authoritative + /// ordered DataSet and configured RCB, activation is source-neutral: Discovery and + /// Open-SCL must execute the same wire lifecycle rather than maintain parallel + /// reporting implementations. + /// + public Task StartStaticSclReportMonitorAsync( + MmsReportSubscriptionPlan plan, + bool triggerGeneralInterrogation = false, + CancellationToken cancellationToken = default) + => StartConfiguredStaticReportMonitorAsync(plan, triggerGeneralInterrogation, cancellationToken); + + /// + /// Starts a persistent monitor for an authoritative configured static DataSet. /// The primary wire sequence is deliberately minimal: whole-RCB Read, optional /// URCB Resv=true, RptEna=true, then two whole-RCB verification Reads. BRCB /// ResvTms is a retry-only compatibility fallback when direct RptEna is rejected. - /// No DataSet browsing/creation and no GI are performed unless explicitly requested. - /// When explicit GI is requested, GI acceptance is part of startup success so a - /// caller cannot report an active initial-image monitor when the GI write was rejected. + /// No DataSet browsing/creation is performed here. When explicit GI is requested, + /// GI acceptance is part of startup success so a caller cannot report an active + /// initial-image monitor when the GI write was rejected. /// - public async Task StartStaticSclReportMonitorAsync( + public async Task StartConfiguredStaticReportMonitorAsync( MmsReportSubscriptionPlan plan, bool triggerGeneralInterrogation = false, CancellationToken cancellationToken = default) @@ -66,7 +78,7 @@ public async Task StartStaticSclReportMon return new MmsPersistentReportMonitorStartResult { IsSuccess = false, - Message = "Trusted-SCL static report activation requires a ready plan with selected RCB." + Message = "Configured static report activation requires a ready plan with selected RCB." }; } @@ -75,7 +87,7 @@ public async Task StartStaticSclReportMon return new MmsPersistentReportMonitorStartResult { IsSuccess = false, - Message = "Trusted-SCL static report activation never creates or mutates a dynamic DataSet." + Message = "Configured static report activation never creates or mutates a dynamic DataSet." }; } @@ -107,10 +119,8 @@ public async Task StartStaticSclReportMon if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync( - rcb, - "Resv", - MmsDataValue.Boolean(true), + var reserve = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = reserve.IsSuccess; @@ -119,10 +129,8 @@ public async Task StartStaticSclReportMon warnings.Add("URCB Resv=true was not accepted; direct RptEna will still be attempted once."); } - var enable = await WriteReportAttributeAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(true), + var enable = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabled = enable.IsSuccess; @@ -131,10 +139,8 @@ public async Task StartStaticSclReportMon rcb.Buffered && rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase)) { - var reserveBrcb = await WriteReportAttributeAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(60), + var reserveBrcb = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(60), cancellationToken).ConfigureAwait(false); writes.Add(reserveBrcb); reservationTouched = reserveBrcb.IsSuccess; @@ -142,10 +148,8 @@ public async Task StartStaticSclReportMon if (reserveBrcb.IsSuccess) { - var retryEnable = await WriteReportAttributeAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(true), + var retryEnable = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(retryEnable); enabled = retryEnable.IsSuccess; @@ -160,15 +164,11 @@ public async Task StartStaticSclReportMon if (reservationTouched) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync( - rcb, - "Resv", - MmsDataValue.Boolean(false), + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); } @@ -179,7 +179,7 @@ public async Task StartStaticSclReportMon WriteSteps = writes, Warnings = warnings, RcbSnapshots = snapshots, - Message = "RptEna=true was not accepted; trusted-SCL static report monitor was not started." + Message = "RptEna=true was not accepted; configured static report monitor was not started." }; } @@ -196,10 +196,8 @@ public async Task StartStaticSclReportMon if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync( - rcb, - "GI", - MmsDataValue.Boolean(true), + var gi = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); @@ -208,10 +206,8 @@ public async Task StartStaticSclReportMon warnings.Add("Explicit GI=true request was rejected; initial-image startup failed closed."); UnregisterPersistentReportMonitor(monitor); - writes.Add(await TryWriteReportAttributeForCleanupAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(false), + writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false)); enabled = false; monitor.EnabledByThisClient = false; @@ -219,15 +215,11 @@ public async Task StartStaticSclReportMon if (reservationTouched) { writes.Add(rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync( - rcb, - "Resv", - MmsDataValue.Boolean(false), + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false)); reservationTouched = false; monitor.ReservationTouched = false; @@ -252,8 +244,8 @@ public async Task StartStaticSclReportMon Warnings = warnings, RcbSnapshots = snapshots, Message = triggerGeneralInterrogation - ? $"Trusted-SCL static report monitor armed for {rcb.Reference}; explicit one-shot GI=true was accepted after activation." - : $"Trusted-SCL static report monitor armed for {rcb.Reference}; no DataSet discovery/mutation or GI was performed." + ? $"Configured static report monitor armed for {rcb.Reference}; explicit one-shot GI=true was accepted after activation." + : $"Configured static report monitor armed for {rcb.Reference}; no DataSet discovery/mutation or GI was performed." }; } catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) @@ -263,25 +255,19 @@ public async Task StartStaticSclReportMon if (enabled) { - writes.Add(await TryWriteReportAttributeForCleanupAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(false), + writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false)); } if (reservationTouched) { writes.Add(rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync( - rcb, - "Resv", - MmsDataValue.Boolean(false), + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false)); } @@ -291,7 +277,7 @@ public async Task StartStaticSclReportMon WriteSteps = writes, Warnings = warnings, RcbSnapshots = snapshots, - Message = $"Trusted-SCL static report activation failed: {ex.GetType().Name}: {ex.Message}" + Message = $"Configured static report activation failed: {ex.GetType().Name}: {ex.Message}" }; } } diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs index 237333a7..d84e22c5 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.cs @@ -765,6 +765,7 @@ private async Task AssociateAsync(bool resetAssociationDiagnostics, Cancellation { State = MmsAssociationState.MmsInitiated; LastHandshakeMessage = result.Message; + ResetReportSemanticTypeEvidence(); _receivePump.Start(cancellationToken); return; } diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs new file mode 100644 index 00000000..d83140ba --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs @@ -0,0 +1,131 @@ +namespace AR.Iec61850.Mms; + +public enum MmsConfiguredStaticRcbEligibilityKind +{ + Blocked, + CallerOwned, + ExplicitFree, + ReducedMissingReservationEvidence +} + +public sealed class MmsConfiguredStaticRcbEligibility +{ + public MmsConfiguredStaticRcbEligibilityKind Kind { get; init; } + public string Reason { get; init; } = string.Empty; + public bool IsEligible => Kind != MmsConfiguredStaticRcbEligibilityKind.Blocked; + public bool RequiresWrite => Kind is + MmsConfiguredStaticRcbEligibilityKind.ExplicitFree or + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence; + public bool UsesReducedEvidence => + Kind == MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence; +} + +/// +/// Evaluates only configured static RCB activation eligibility. +/// +/// Exact-free evidence remains preferred. A separately gated reduced state may be emitted +/// only when the configured DataSet binding is freshly read and populated, RptEna is +/// explicitly false, no positive foreign ownership/reservation evidence exists, and the +/// reservation field is genuinely absent/unexposed. Malformed or contradictory values are +/// blockers, never "missing evidence". +/// +/// This policy never relaxes dynamic DataSet mutation eligibility. +/// +public static class MmsConfiguredStaticRcbEligibilityPolicy +{ + public static MmsConfiguredStaticRcbEligibility Evaluate( + MmsRcbAvailabilitySnapshot snapshot, + bool allowCallerOwned, + bool allowReducedMissingReservationEvidence) + { + ArgumentNullException.ThrowIfNull(snapshot); + + if (snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller) + { + var callerReady = allowCallerOwned && + HasVerifiedStaticDataSet(snapshot); + return callerReady + ? Eligible( + MmsConfiguredStaticRcbEligibilityKind.CallerOwned, + "The configured static RCB is already active in this caller association and its populated DataSet is verified.") + : Blocked("Caller-owned RCB reuse is disabled or its configured DataSet is not fully verified."); + } + + if (!HasVerifiedStaticDataSet(snapshot)) + return Blocked("Configured static RCB requires a successful live DatSet read and a populated DataSet directory."); + + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != false) + return Blocked("Configured static RCB requires explicit RptEna=false before a new activation write."); + + if (HasPositiveBusyEvidence(snapshot)) + return Blocked("Positive RCB ownership/reservation evidence indicates the configured static RCB is not free."); + + if (snapshot.Availability == MmsRcbOperationalAvailability.Available && + HasExplicitFreeReservation(snapshot)) + { + return Eligible( + MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, + $"Fresh evidence confirms a populated configured DataSet, RptEna=false, and explicit free reservation state (confidence={snapshot.Confidence})."); + } + + if (!allowReducedMissingReservationEvidence) + return Blocked("Exact free reservation evidence is unavailable and reduced configured-static activation is not enabled."); + + if (snapshot.Availability != MmsRcbOperationalAvailability.Unknown) + return Blocked($"Reduced configured-static activation accepts only missing reservation evidence; availability={snapshot.Availability} is not an unknown-reservation state."); + + if (!ReservationEvidenceIsGenuinelyMissing(snapshot)) + return Blocked("Reservation evidence is present but is neither explicitly free nor safely classifiable as missing."); + + return Eligible( + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence, + "Configured static DataSet and RptEna=false are verified and no positive busy evidence exists, but the IED does not expose usable reservation metadata. Activation may proceed only under the explicit reduced-evidence policy and must still prove success by readback/report traffic."); + } + + private static bool HasVerifiedStaticDataSet(MmsRcbAvailabilitySnapshot snapshot) + => snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded && + snapshot.DataSetDirectorySuccess && + snapshot.DataSetMembers.Count > 0 && + !string.IsNullOrWhiteSpace(snapshot.DataSetReference); + + private static bool HasPositiveBusyEvidence(MmsRcbAvailabilitySnapshot snapshot) + { + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) == true) + return true; + if (MmsRcbAvailabilityEvaluator.HasOwner(snapshot.Owner)) + return true; + + return snapshot.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0 + : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == true; + } + + private static bool HasExplicitFreeReservation(MmsRcbAvailabilitySnapshot snapshot) + => snapshot.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) == 0 + : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == false; + + private static bool ReservationEvidenceIsGenuinelyMissing(MmsRcbAvailabilitySnapshot snapshot) + => snapshot.Buffered + ? IsMissingToken(snapshot.ReservationTimeSeconds) + : IsMissingToken(snapshot.ReservationState); + + private static bool IsMissingToken(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 || + text == "-" || + text == "[]" || + text.Equals("null", StringComparison.OrdinalIgnoreCase) || + text.Equals("unsupported", StringComparison.OrdinalIgnoreCase) || + text.Equals("not-exposed", StringComparison.OrdinalIgnoreCase); + } + + private static MmsConfiguredStaticRcbEligibility Eligible( + MmsConfiguredStaticRcbEligibilityKind kind, + string reason) + => new() { Kind = kind, Reason = reason }; + + private static MmsConfiguredStaticRcbEligibility Blocked(string reason) + => new() { Kind = MmsConfiguredStaticRcbEligibilityKind.Blocked, Reason = reason }; +} diff --git a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs index 12bb99f8..a39baf6c 100644 --- a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs +++ b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs @@ -47,6 +47,13 @@ public sealed class MmsHybridReportAcquisitionOptions public bool AllowCallerOwnedReports { get; init; } = true; public bool AllowStaticBrcb { get; init; } = true; public bool AllowStaticUrcb { get; init; } = true; + /// + /// Explicit interoperability opt-in for configured static RCBs whose live DataSet + /// and RptEna=false are verified but whose reservation attribute is genuinely + /// unexposed. Default false preserves the exact-evidence production contract. + /// This option never applies to dynamic DataSet mutation. + /// + public bool AllowConfiguredStaticWithMissingReservationEvidence { get; init; } public bool AllowDynamicBrcb { get; init; } = true; public bool AllowDynamicUrcb { get; init; } = true; public bool AllowPollingFallback { get; init; } = true; @@ -347,11 +354,12 @@ private static void PlanStaticCoverage( .Select(snapshot => new { Snapshot = snapshot, + Eligibility = EvaluateStaticEligibility(snapshot, options), Covered = remaining.Where(signal => StaticDataSetCovers(snapshot, signal)).ToArray() }) .Where(item => item.Covered.Length > 0) .OrderByDescending(item => item.Covered.Length) - .ThenBy(item => item.Snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller ? 0 : 1) + .ThenBy(item => StaticEligibilityRank(item.Eligibility.Kind)) .ThenBy(item => item.Snapshot.Buffered ? 0 : 1) .ThenBy(item => item.Snapshot.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -361,7 +369,8 @@ private static void PlanStaticCoverage( var selected = scored[0]; var snapshot = selected.Snapshot; - var plan = snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller + var eligibility = selected.Eligibility; + var plan = eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned ? BuildCallerOwnedStaticPlan(snapshot, inventory) : BuildFreshStaticPlan(snapshot); @@ -373,7 +382,14 @@ private static void PlanStaticCoverage( } var kind = snapshot.Buffered ? MmsHybridAcquisitionKind.StaticBrcb : MmsHybridAcquisitionKind.StaticUrcb; - var callerOwned = snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller; + var callerOwned = eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned; + var reducedEvidence = eligibility.UsesReducedEvidence; + if (reducedEvidence) + { + warnings.Add( + $"Configured static candidate {snapshot.Reference} is using the explicit reduced reservation-evidence policy: {eligibility.Reason}"); + } + var segment = new MmsHybridAcquisitionSegment { Kind = kind, @@ -387,7 +403,9 @@ private static void PlanStaticCoverage( IsAlreadyActiveByCaller = callerOwned, Reason = callerOwned ? "Fresh availability evidence identifies this report as already active in the caller's current session; reuse it without reconfiguration." - : "Fresh exact availability evidence confirms a populated static DataSet and a free RCB; enable the existing report without changing its DataSet membership." + : reducedEvidence + ? eligibility.Reason + : "Fresh availability evidence confirms a populated static DataSet, RptEna=false, and explicit free reservation state; enable the existing report without changing its DataSet membership." }; segments.Add(segment); @@ -681,23 +699,38 @@ private static bool IsStaticUsable(MmsRcbAvailabilitySnapshot snapshot, MmsHybri return false; if (!snapshot.Buffered && !options.AllowStaticUrcb) return false; - if (snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller) - return options.AllowCallerOwnedReports && - snapshot.DataSetDirectorySuccess && - snapshot.DataSetMembers.Count > 0 && - !string.IsNullOrWhiteSpace(snapshot.DataSetReference); - if (snapshot.Availability != MmsRcbOperationalAvailability.Available) - return false; - if (options.RequireExactAvailabilityEvidence && snapshot.Confidence != MmsRcbAvailabilityConfidence.Exact) + + var eligibility = EvaluateStaticEligibility(snapshot, options); + if (!eligibility.IsEligible) return false; - return snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded && - snapshot.DataSetDirectorySuccess && - snapshot.DataSetMembers.Count > 0 && - !string.IsNullOrWhiteSpace(snapshot.DataSetReference) && - ParseBool(snapshot.EnabledState) == false && - HasExplicitFreeReservation(snapshot); + + if (eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned) + return true; + + if (eligibility.UsesReducedEvidence) + return options.AllowConfiguredStaticWithMissingReservationEvidence; + + return !options.RequireExactAvailabilityEvidence || + snapshot.Confidence == MmsRcbAvailabilityConfidence.Exact; } + private static MmsConfiguredStaticRcbEligibility EvaluateStaticEligibility( + MmsRcbAvailabilitySnapshot snapshot, + MmsHybridReportAcquisitionOptions options) + => MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + options.AllowCallerOwnedReports, + options.AllowConfiguredStaticWithMissingReservationEvidence); + + private static int StaticEligibilityRank(MmsConfiguredStaticRcbEligibilityKind kind) + => kind switch + { + MmsConfiguredStaticRcbEligibilityKind.CallerOwned => 0, + MmsConfiguredStaticRcbEligibilityKind.ExplicitFree => 1, + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence => 2, + _ => 9 + }; + private static bool IsDynamicUsable(MmsRcbAvailabilitySnapshot snapshot, MmsHybridReportAcquisitionOptions options) { if (snapshot.Buffered && !options.AllowDynamicBrcb) diff --git a/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs b/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs new file mode 100644 index 00000000..df6b08ef --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs @@ -0,0 +1,71 @@ +namespace AR.Iec61850.Mms; + +/// +/// Protocol-level failure categories used by planning/execution code. These are derived +/// from structured MMS DataAccessError codes, never from human-readable message text. +/// +public enum MmsInteropFailureKind +{ + None, + ObjectInvalidated, + HardwareFault, + TemporarilyUnavailable, + AccessDenied, + ObjectUndefined, + InvalidAddress, + TypeUnsupported, + TypeMismatch, + AttributeInconsistent, + AccessUnsupported, + ObjectNotFound, + InvalidValue, + Unknown +} + +/// +/// Evidence-oriented recovery hint. This does not authorize an automatic retry; it tells +/// higher layers what must be revalidated before any bounded follow-up operation. +/// +public enum MmsInteropRecoveryHint +{ + None, + RevalidateType, + RevalidateRuntimeState, + RevalidateObjectIdentity, + RetryLaterAfterStateRefresh, + FailClosed +} + +public readonly record struct MmsInteropFailureClassification( + MmsInteropFailureKind Kind, + MmsInteropRecoveryHint RecoveryHint) +{ + public static MmsInteropFailureClassification Success => + new(MmsInteropFailureKind.None, MmsInteropRecoveryHint.None); +} + +public static class MmsInteropFailureClassifier +{ + public static MmsInteropFailureClassification Classify(MmsWriteAccessResult? access) + { + if (access is null || access.IsSuccess) + return MmsInteropFailureClassification.Success; + + return access.FailureCode switch + { + 0 => new(MmsInteropFailureKind.ObjectInvalidated, MmsInteropRecoveryHint.RevalidateRuntimeState), + 1 => new(MmsInteropFailureKind.HardwareFault, MmsInteropRecoveryHint.FailClosed), + 2 => new(MmsInteropFailureKind.TemporarilyUnavailable, MmsInteropRecoveryHint.RetryLaterAfterStateRefresh), + 3 => new(MmsInteropFailureKind.AccessDenied, MmsInteropRecoveryHint.FailClosed), + 4 => new(MmsInteropFailureKind.ObjectUndefined, MmsInteropRecoveryHint.RevalidateObjectIdentity), + 5 => new(MmsInteropFailureKind.InvalidAddress, MmsInteropRecoveryHint.RevalidateObjectIdentity), + 6 => new(MmsInteropFailureKind.TypeUnsupported, MmsInteropRecoveryHint.RevalidateType), + 7 => new(MmsInteropFailureKind.TypeMismatch, MmsInteropRecoveryHint.RevalidateType), + 8 => new(MmsInteropFailureKind.AttributeInconsistent, MmsInteropRecoveryHint.RevalidateRuntimeState), + 9 => new(MmsInteropFailureKind.AccessUnsupported, MmsInteropRecoveryHint.FailClosed), + 10 => new(MmsInteropFailureKind.ObjectNotFound, MmsInteropRecoveryHint.RevalidateObjectIdentity), + 11 => new(MmsInteropFailureKind.InvalidValue, MmsInteropRecoveryHint.FailClosed), + _ => new(MmsInteropFailureKind.Unknown, MmsInteropRecoveryHint.FailClosed) + }; + } +} diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index b09f7b45..6dcb6c0a 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -233,14 +233,14 @@ public async Task StartPersistentReportMo } else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = reserve.IsSuccess; if (!reserve.IsSuccess) warnings.Add("URCB Resv write failed. Continuing only if RptEna=true is accepted by the IED."); } - var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabledByThisClient = enable.IsSuccess; if (!enable.IsSuccess) @@ -261,7 +261,7 @@ public async Task StartPersistentReportMo if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(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."); @@ -363,7 +363,7 @@ public async Task StopPersistentReportMoni if (session.EnabledByThisClient) { - var disable = await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + var disable = await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); writes.Add(disable); success &= disable.IsSuccess; } @@ -410,8 +410,8 @@ public async Task StopPersistentReportMoni if (session.ReservationTouched) { var release = session.ReportControl.Buffered - ? await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); success &= release.IsSuccess; } @@ -509,10 +509,8 @@ private async Task ReceiveAndDispatchPersistentReportsAsync( if (giPending) { - writes.Add(await WriteReportAttributeAsync( - requestedSession.ReportControl, - "GI", - MmsDataValue.Boolean(true), + writes.Add(await WriteReportSemanticAttributeAsync( + requestedSession.ReportControl, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false)); giPending = false; continue; diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs index af6e046c..a2942421 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs @@ -19,7 +19,10 @@ public enum MmsReportActivationFailureReason DynamicDataSetBindFailed, TriggerOptionsUnavailable, TriggerOptionsWriteFailed, + RcbFieldTypeConflict, + ReservationWriteFailed, ReportEnableFailed, + GeneralInterrogationFailed, ActivationException, OtherActivationFailure } @@ -98,12 +101,22 @@ public async Task StartPersistentReport } } - var start = await StartPersistentReportMonitorAsync( - plan, - triggerGeneralInterrogation, - deleteDynamicDataSetOnStop, - directory, - cancellationToken).ConfigureAwait(false); + // A fresh configured static plan must use the same source-neutral activation + // lifecycle regardless of whether its model originated from live Discovery or SCL: + // receiver registration -> direct RptEna -> bounded BRCB ResvTms fallback -> + // readback -> optional GI. Dynamic plans retain the dynamic transaction path below. + var start = !isDynamic && + plan.Status == MmsReportSubscriptionPlanStatus.ReadyRequiresWrite + ? await StartConfiguredStaticReportMonitorAsync( + plan, + triggerGeneralInterrogation, + cancellationToken).ConfigureAwait(false) + : await StartPersistentReportMonitorAsync( + plan, + triggerGeneralInterrogation, + deleteDynamicDataSetOnStop, + directory, + cancellationToken).ConfigureAwait(false); if (probe is not null) start = MergeProbeEvidence(start, probe); @@ -162,10 +175,8 @@ public async Task StartPersistentReport if (enabled) { - var disable = await TryWriteReportAttributeForCleanupAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(false), + var disable = await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); cleanupSteps.Add(disable); cleanupSucceeded &= disable.IsSuccess; @@ -218,8 +229,8 @@ public async Task StartPersistentReport if (reserved) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); cleanupSteps.Add(release); cleanupSucceeded &= release.IsSuccess; } @@ -266,9 +277,15 @@ private static bool SuccessfulStep(IEnumerable step private static MmsReportActivationFailureReason ClassifyFailure(MmsPersistentReportMonitorStartResult result) { - var failed = result.WriteSteps.LastOrDefault(step => step.Attempted && !step.IsSuccess); + var failed = result.WriteSteps.LastOrDefault(step => !step.IsSuccess); if (failed is not null) { + if (failed.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.ExactMismatch || + failed.FailureKind is MmsInteropFailureKind.TypeMismatch or MmsInteropFailureKind.TypeUnsupported) + { + return MmsReportActivationFailureReason.RcbFieldTypeConflict; + } + if (failed.Attribute.Equals("Probe.DefineNamedVariableList", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.DynamicDataSetProbeDefineFailed; if (failed.Attribute.Equals("Probe.GetNamedVariableListAttributes", StringComparison.OrdinalIgnoreCase)) @@ -281,8 +298,13 @@ private static MmsReportActivationFailureReason ClassifyFailure(MmsPersistentRep return MmsReportActivationFailureReason.DynamicDataSetBindFailed; if (failed.Attribute.Equals("TrgOps", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.TriggerOptionsWriteFailed; + if (failed.Attribute.Equals("Resv", StringComparison.OrdinalIgnoreCase) || + failed.Attribute.Equals("ResvTms", StringComparison.OrdinalIgnoreCase)) + return MmsReportActivationFailureReason.ReservationWriteFailed; if (failed.Attribute.Equals("RptEna", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.ReportEnableFailed; + if (failed.Attribute.Equals("GI", StringComparison.OrdinalIgnoreCase)) + return MmsReportActivationFailureReason.GeneralInterrogationFailed; } if (result.Message.Contains("requires a writable TrgOps", StringComparison.OrdinalIgnoreCase)) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs index b0fb63d2..2a4d50ce 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs @@ -26,10 +26,8 @@ public async Task StartPersistentReport !MmsReportSubscriptionPlanner.IsExplicitlyEnabled(rcb) && !MmsReportSubscriptionPlanner.IsReservedByOtherClient(rcb)) { - reservationStep = await WriteReportAttributeAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(60), + reservationStep = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(60), cancellationToken).ConfigureAwait(false); if (!reservationStep.IsSuccess) @@ -66,10 +64,8 @@ public async Task StartPersistentReport if (reservationStep?.IsSuccess == true && rcb is not null) { - var release = await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + var release = await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false); cleanupSteps.Add(release); cleanupAttempted = true; @@ -95,10 +91,8 @@ public async Task StartPersistentReport if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync( - start.Session.ReportControl, - "GI", - MmsDataValue.Boolean(true), + var gi = await WriteReportSemanticAttributeAsync( + start.Session.ReportControl, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); if (!gi.IsSuccess) diff --git a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs new file mode 100644 index 00000000..e7f2002e --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs @@ -0,0 +1,291 @@ +using System.Collections.Concurrent; + +namespace AR.Iec61850.Mms; + +/// +/// Binds a well-known IEC 61850 Report Control Block scalar field to the exact MMS +/// value kind used on the wire. This is the semantic boundary between reporting +/// policy and MMS encoding: callers request an IEC field/value, not a raw MMS scalar. +/// +/// The contract intentionally covers only scalar mutation fields whose semantics are +/// stable across BRCB/URCB implementations. Bit-string and object-reference fields keep +/// their dedicated codecs. +/// +public readonly record struct MmsReportControlSemanticWrite +{ + private MmsReportControlSemanticWrite( + string attribute, + MmsDataValue value, + MmsDataKind expectedKind, + string expectedMmsType) + { + Attribute = attribute; + Value = value; + ExpectedKind = expectedKind; + ExpectedMmsType = expectedMmsType; + } + + public string Attribute { get; } + public MmsDataValue Value { get; } + public MmsDataKind ExpectedKind { get; } + public string ExpectedMmsType { get; } + + public static MmsReportControlSemanticWrite ReportEnable(bool enabled) + => Boolean("RptEna", enabled); + + public static MmsReportControlSemanticWrite GeneralInterrogation(bool requested) + => Boolean("GI", requested); + + public static MmsReportControlSemanticWrite Reservation(bool reserved) + => Boolean("Resv", reserved); + + /// + /// IEC 61850 BRCB ResvTms is mapped to MMS INTEGER with signed INT16 wire semantics. + /// Policy about particular reservation-time values belongs above this codec; this + /// boundary validates only the standardized scalar representation and width. + /// + public static MmsReportControlSemanticWrite ReservationTime(int seconds) + { + if (seconds is < short.MinValue or > short.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(seconds), + seconds, + $"BRCB ResvTms must fit the signed IEC INT16 range {short.MinValue}..{short.MaxValue}."); + } + + return new MmsReportControlSemanticWrite( + "ResvTms", + MmsDataValue.Integer(seconds), + MmsDataKind.Integer, + "integer"); + } + + public bool IsCompatibleWith(MmsTypeSpecificationNode? liveType) + => liveType is not null && + Value.Kind == ExpectedKind && + string.Equals(liveType.MmsType, ExpectedMmsType, StringComparison.OrdinalIgnoreCase); + + private static MmsReportControlSemanticWrite Boolean(string attribute, bool value) + => new( + attribute, + MmsDataValue.Boolean(value), + MmsDataKind.Boolean, + "boolean"); +} + +public enum MmsReportSemanticTypeEvidenceStatus +{ + NotChecked, + ExactMatch, + ExactMismatch, + Unavailable +} + +/// +/// Evidence for one concrete RCB scalar field on the current MMS association. +/// A successful live GetVariableAccessAttributes result is exact protocol evidence; +/// a failed/unsupported probe remains missing evidence and is not converted into a +/// false blocker. An exact contradiction is a positive blocker and prevents mutation. +/// +public sealed class MmsReportSemanticTypeEvidence +{ + public MmsReportSemanticTypeEvidenceStatus Status { get; init; } + public string Reference { get; init; } = string.Empty; + public string Attribute { get; init; } = string.Empty; + public string ExpectedMmsType { get; init; } = string.Empty; + public string LiveMmsType { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + + public bool IsExact => Status is + MmsReportSemanticTypeEvidenceStatus.ExactMatch or + MmsReportSemanticTypeEvidenceStatus.ExactMismatch; + + public bool AllowsMutation => Status != MmsReportSemanticTypeEvidenceStatus.ExactMismatch; +} + +public static class MmsReportSemanticTypePolicy +{ + public static MmsReportSemanticTypeEvidence Evaluate( + MmsReportControlSemanticWrite write, + string reference, + MmsVariableAccessAttributesResult? live) + { + if (live?.IsSuccess != true || live.TypeSpecification is null) + { + return new MmsReportSemanticTypeEvidence + { + Status = MmsReportSemanticTypeEvidenceStatus.Unavailable, + Reference = reference, + Attribute = write.Attribute, + ExpectedMmsType = write.ExpectedMmsType, + LiveMmsType = live?.MmsType ?? string.Empty, + Source = live?.Source ?? "IEC semantic contract", + Message = live is null + ? "No live TypeSpecification probe was available; the IEC semantic contract remains the write authority." + : $"Live TypeSpecification was unavailable ({live.Message}); the IEC semantic contract remains the write authority." + }; + } + + var exactMatch = write.IsCompatibleWith(live.TypeSpecification); + return new MmsReportSemanticTypeEvidence + { + Status = exactMatch + ? MmsReportSemanticTypeEvidenceStatus.ExactMatch + : MmsReportSemanticTypeEvidenceStatus.ExactMismatch, + Reference = reference, + Attribute = write.Attribute, + ExpectedMmsType = write.ExpectedMmsType, + LiveMmsType = live.MmsType, + Source = live.Source, + Message = exactMatch + ? $"Live MMS TypeSpecification confirms {write.Attribute} as {live.MmsType}." + : $"Live MMS TypeSpecification reports {write.Attribute} as {live.MmsType}, conflicting with IEC semantic type {write.ExpectedMmsType}." + }; + } +} + +public sealed partial class MmsClientSession +{ + private readonly ConcurrentDictionary _reportSemanticTypeEvidence = + new(StringComparer.Ordinal); + + /// + /// Association-stable type evidence must never cross an MMS re-association. + /// Volatile RCB state is intentionally not cached here. + /// + private void ResetReportSemanticTypeEvidence() + => _reportSemanticTypeEvidence.Clear(); + + /// + /// Normal RCB scalar mutation path. The first mutation of each exact field on an + /// association performs a bounded exact GetVariableAccessAttributes check. Exact + /// matching evidence is cached for that association; missing evidence does not become + /// a false blocker, while an exact type contradiction fails closed before mutation. + /// + private async Task WriteReportSemanticAttributeAsync( + MmsReportControlCandidate rcb, + MmsReportControlSemanticWrite write, + CancellationToken cancellationToken) + { + var reference = BuildReportAttributeReference(rcb, write.Attribute); + var typeEvidence = await GetReportSemanticTypeEvidenceAsync( + reference, + write, + forceRefresh: false, + cancellationToken).ConfigureAwait(false); + + if (!typeEvidence.AllowsMutation) + { + return new MmsReportAttributeWriteStep + { + Attribute = write.Attribute, + Reference = reference.ToString(), + Attempted = false, + IsSuccess = false, + TypeEvidence = typeEvidence, + Message = $"RCB write blocked before mutation: {typeEvidence.Message}" + }; + } + + var step = await WriteReportAttributeAsync( + rcb, + write.Attribute, + write.Value, + cancellationToken).ConfigureAwait(false); + + if (!step.IsSuccess && + step.FailureKind == MmsInteropFailureKind.TypeMismatch && + IsMmsInitiated) + { + typeEvidence = await GetReportSemanticTypeEvidenceAsync( + reference, + write, + forceRefresh: true, + cancellationToken).ConfigureAwait(false); + } + + return WithSemanticTypeEvidence(step, typeEvidence); + } + + /// + /// Cleanup never adds a new network preflight. It reuses any exact type evidence + /// already learned on this association and otherwise uses the standardized semantic + /// encoder so cleanup is not delayed by optional diagnostics. + /// + private async Task TryWriteReportSemanticAttributeForCleanupAsync( + MmsReportControlCandidate rcb, + MmsReportControlSemanticWrite write, + CancellationToken cancellationToken) + { + var reference = BuildReportAttributeReference(rcb, write.Attribute); + _reportSemanticTypeEvidence.TryGetValue(ReportSemanticTypeCacheKey(reference), out var typeEvidence); + + var step = await TryWriteReportAttributeForCleanupAsync( + rcb, + write.Attribute, + write.Value, + cancellationToken).ConfigureAwait(false); + + return typeEvidence is null ? step : WithSemanticTypeEvidence(step, typeEvidence); + } + + private async Task GetReportSemanticTypeEvidenceAsync( + MmsObjectReference reference, + MmsReportControlSemanticWrite write, + bool forceRefresh, + CancellationToken cancellationToken) + { + var key = ReportSemanticTypeCacheKey(reference); + if (!forceRefresh && _reportSemanticTypeEvidence.TryGetValue(key, out var cached)) + return cached; + + MmsVariableAccessAttributesResult? live = null; + try + { + live = await GetVariableAccessAttributesAsync(reference, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + live = new MmsVariableAccessAttributesResult + { + IsSuccess = false, + Reference = reference, + Source = "GetVariableAccessAttributes", + Message = $"Type preflight failed safely: {ex.GetType().Name}: {ex.Message}" + }; + } + + var evidence = MmsReportSemanticTypePolicy.Evaluate(write, reference.ToString(), live); + if (evidence.IsExact) + _reportSemanticTypeEvidence[key] = evidence; + + return evidence; + } + + private static MmsObjectReference BuildReportAttributeReference( + MmsReportControlCandidate rcb, + string attribute) + => MmsObjectReference.Parse($"{rcb.Reference}.{attribute}", rcb.FunctionalConstraint); + + private static string ReportSemanticTypeCacheKey(MmsObjectReference reference) + => $"{reference.Domain}/{reference.Item}"; + + private static MmsReportAttributeWriteStep WithSemanticTypeEvidence( + MmsReportAttributeWriteStep step, + MmsReportSemanticTypeEvidence typeEvidence) + => new() + { + Attribute = step.Attribute, + Reference = step.Reference, + Attempted = step.Attempted, + IsSuccess = step.IsSuccess, + FailureCode = step.FailureCode, + FailureName = step.FailureName, + FailureKind = step.FailureKind, + RecoveryHint = step.RecoveryHint, + TypeEvidence = typeEvidence, + Message = $"{step.Message} Type evidence: {typeEvidence.Message}" + }; +} diff --git a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs index 64dbbc2b..08047c57 100644 --- a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs +++ b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs @@ -6,6 +6,11 @@ public sealed class MmsReportAttributeWriteStep public string Reference { get; init; } = string.Empty; public bool Attempted { get; init; } public bool IsSuccess { get; init; } + public int? FailureCode { get; init; } + public string FailureName { get; init; } = string.Empty; + public MmsInteropFailureKind FailureKind { get; init; } = MmsInteropFailureKind.None; + public MmsInteropRecoveryHint RecoveryHint { get; init; } = MmsInteropRecoveryHint.None; + public MmsReportSemanticTypeEvidence? TypeEvidence { get; init; } public string Message { get; init; } = string.Empty; } @@ -1084,14 +1089,14 @@ public async Task RunGuardedStaticReportSessionAsy } else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = true; if (!reserve.IsSuccess) warnings.Add("URCB Resv write failed. Proceeding guarded only if RptEna is accepted by the IED."); } - var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabledByThisClient = enable.IsSuccess; if (!enable.IsSuccess) @@ -1113,7 +1118,7 @@ public async Task RunGuardedStaticReportSessionAsy if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(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."); @@ -1126,7 +1131,7 @@ public async Task RunGuardedStaticReportSessionAsy { periodicGiWriter = async token => { - var step = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), token).ConfigureAwait(false); + var step = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), token).ConfigureAwait(false); return new MmsReportAttributeWriteStep { Attribute = "GI(periodic)", @@ -1158,7 +1163,7 @@ public async Task RunGuardedStaticReportSessionAsy { if (enabledByThisClient) { - var disable = await TryWriteReportAttributeForCleanupAsync(rcb, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + var disable = await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); writes.Add(disable); if (!disable.IsSuccess) verificationChecks.Add(FailCheck("after-cleanup", $"{rcb.Reference}.RptEna", "write false accepted", disable.Message, "RptEna=false cleanup write failed.")); @@ -1171,8 +1176,8 @@ public async Task RunGuardedStaticReportSessionAsy if (reservationTouched) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); } } @@ -1273,7 +1278,7 @@ public async Task RunGuardedDynamicReportSessionAs } else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = true; if (!reserve.IsSuccess) @@ -1300,7 +1305,7 @@ public async Task RunGuardedDynamicReportSessionAs rcbSnapshots.Add(afterBindSnapshot); AddRcbStateChecks(verificationChecks, afterBindSnapshot, expectedRptEna: false, expectedDataSet: plan.DataSetReference, stage: "after-bind"); - var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabledByThisClient = enable.IsSuccess; if (!enable.IsSuccess) @@ -1322,7 +1327,7 @@ public async Task RunGuardedDynamicReportSessionAs if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(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."); @@ -1336,7 +1341,7 @@ public async Task RunGuardedDynamicReportSessionAs { if (enabledByThisClient) { - var disable = await TryWriteReportAttributeForCleanupAsync(rcb, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + var disable = await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); writes.Add(disable); if (!disable.IsSuccess) verificationChecks.Add(FailCheck("after-cleanup", $"{rcb.Reference}.RptEna", "write false accepted", disable.Message, "RptEna=false cleanup write failed.")); @@ -1360,8 +1365,8 @@ public async Task RunGuardedDynamicReportSessionAs if (reservationTouched) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); } @@ -1917,14 +1922,20 @@ private async Task WriteReportAttributeAsync( MmsDataValue value, CancellationToken cancellationToken) { - var reference = MmsObjectReference.Parse($"{rcb.Reference}.{attribute}", rcb.FunctionalConstraint); + var reference = BuildReportAttributeReference(rcb, attribute); var result = await WriteSingleVariableAsync(reference, value, cancellationToken).ConfigureAwait(false); + var failure = result.AccessResults.FirstOrDefault(access => !access.IsSuccess); + var classification = MmsInteropFailureClassifier.Classify(failure); return new MmsReportAttributeWriteStep { Attribute = attribute, Reference = reference.ToString(), Attempted = true, IsSuccess = result.IsSuccess, + FailureCode = failure?.FailureCode, + FailureName = failure?.FailureName ?? string.Empty, + FailureKind = classification.Kind, + RecoveryHint = classification.RecoveryHint, Message = result.Message }; } @@ -1950,6 +1961,11 @@ private async Task TryWriteReportAttributeForCleanu Reference = first.Reference, Attempted = true, IsSuccess = false, + FailureCode = first.FailureCode, + FailureName = first.FailureName, + FailureKind = first.FailureKind, + RecoveryHint = first.RecoveryHint, + TypeEvidence = first.TypeEvidence, Message = $"cleanup reconnect failed. First attempt: {first.Message}" }; } @@ -1961,6 +1977,11 @@ private async Task TryWriteReportAttributeForCleanu Reference = retry.Reference, Attempted = true, IsSuccess = retry.IsSuccess, + FailureCode = retry.FailureCode, + FailureName = retry.FailureName, + FailureKind = retry.FailureKind, + RecoveryHint = retry.RecoveryHint, + TypeEvidence = retry.TypeEvidence, Message = retry.IsSuccess ? $"cleanup retry after reconnect succeeded. First attempt: {first.Message}" : $"cleanup retry after reconnect failed: {retry.Message}. First attempt: {first.Message}" @@ -1979,6 +2000,11 @@ private async Task TryWriteReportAttributeForCleanu Reference = retry.Reference, Attempted = true, IsSuccess = retry.IsSuccess, + FailureCode = retry.FailureCode, + FailureName = retry.FailureName, + FailureKind = retry.FailureKind, + RecoveryHint = retry.RecoveryHint, + TypeEvidence = retry.TypeEvidence, Message = retry.IsSuccess ? $"cleanup retry after reconnect succeeded. First exception: {ex.GetType().Name}: {ex.Message}" : $"cleanup retry after reconnect failed: {retry.Message}. First exception: {ex.GetType().Name}: {ex.Message}" diff --git a/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs b/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs index 8ad5286d..c100a44b 100644 --- a/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs +++ b/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs @@ -11,6 +11,11 @@ public sealed class MmsReportSessionDiagnostics public int ValueCount { get; init; } public int WriteStepCount { get; init; } public int WriteFailureCount { get; init; } + public int WritePreflightBlockedCount { get; init; } + public int WriteTypeMismatchCount { get; init; } + public int WriteAccessDeniedCount { get; init; } + public int WriteTypeEvidenceConflictCount { get; init; } + public int WriteTypeEvidenceUnavailableCount { get; init; } public int PollReadCount { get; init; } public int PollReadSuccessCount { get; init; } public int PollReadFailureCount { get; init; } @@ -67,7 +72,8 @@ public IReadOnlyList WarningMessages public string Summary => $"diagnostics={OverallStatus}, reports={ReportCount}, values={ValueCount}, mappedFailures={MappingFailureCount}, partialMappings={PartialMappingFailureCount}, " + - $"pollReads={PollReadSuccessCount}/{PollReadCount}, writeFailures={WriteFailureCount}, " + + $"pollReads={PollReadSuccessCount}/{PollReadCount}, writeFailures={WriteFailureCount}, preflightBlocked={WritePreflightBlockedCount}, " + + $"typeMismatch={WriteTypeMismatchCount}, typeEvidenceConflict={WriteTypeEvidenceConflictCount}, " + $"seqGaps={SequenceGapCount}, seqResets={SequenceResetCount}, seqRegressions={SequenceRegressionCount}, " + $"entryIdGaps={EntryIdGapCount}, entryIdRegressions={EntryIdRegressionCount}, " + $"duplicates={DuplicateReportKeyCount}, bufOvfl={BufferOverflowObserved.ToString().ToLowerInvariant()}"; @@ -177,6 +183,13 @@ public static MmsReportSessionDiagnostics Analyze( ValueCount = reports.Sum(x => x.Values.Count), WriteStepCount = writeSteps.Count, WriteFailureCount = writeSteps.Count(x => !x.IsSuccess), + WritePreflightBlockedCount = writeSteps.Count(x => !x.Attempted && !x.IsSuccess), + WriteTypeMismatchCount = writeSteps.Count(x => x.FailureKind == MmsInteropFailureKind.TypeMismatch), + WriteAccessDeniedCount = writeSteps.Count(x => x.FailureKind == MmsInteropFailureKind.AccessDenied), + WriteTypeEvidenceConflictCount = writeSteps.Count(x => + x.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.ExactMismatch), + WriteTypeEvidenceUnavailableCount = writeSteps.Count(x => + x.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.Unavailable), PollReadCount = pollReads.Count, PollReadSuccessCount = pollReads.Count(x => x.IsSuccess), PollReadFailureCount = pollReads.Count(x => !x.IsSuccess), diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs new file mode 100644 index 00000000..6d3a7170 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs @@ -0,0 +1,475 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Scl.Engineering; + +namespace AR.Iec61850.Tests.Engineering; + +public sealed class CanonicalStaticReportCoverageResolverTests +{ + [Fact] + public void Discovery_And_Scl_Ingress_Use_The_Same_Static_Coverage_Contract() + { + var discovery = BuildCanonical(); + var scl = WithIngress(discovery, CanonicalIngressKind.SclFile); + + var selection = new[] + { + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + }; + + var fromDiscovery = CanonicalStaticReportCoverageResolver.Resolve(discovery, selection); + var fromScl = CanonicalStaticReportCoverageResolver.Resolve(scl, selection); + + Assert.Equal(CanonicalIngressKind.LiveMmsDiscovery, fromDiscovery.Ingress); + Assert.Equal(CanonicalIngressKind.SclFile, fromScl.Ingress); + Assert.Equal(fromDiscovery.CoveredSignalCount, fromScl.CoveredSignalCount); + Assert.Equal( + fromDiscovery.Signals[0].DataSetCandidates.Select(candidate => candidate.DataSetReference), + fromScl.Signals[0].DataSetCandidates.Select(candidate => candidate.DataSetReference)); + Assert.Equal( + fromDiscovery.Signals[0].DataSetCandidates[0].ReportControlReferences, + fromScl.Signals[0].DataSetCandidates[0].ReportControlReferences); + } + + [Fact] + public void Real_Discovery_And_OpenScl_Builders_Converge_On_Static_Coverage() + { + var discoveryModel = BuildStatusDiscoveryCanonical(); + var imported = SclCanonicalImporter.Import( + XDocument.Parse(BuildEquivalentStatusScl()), + new SclCanonicalImportOptions + { + IedName = "IEDA", + AccessPointName = "P1", + SourceName = "equivalent.cid" + }); + + Assert.True(imported.IsSuccess, string.Join(" | ", imported.Errors)); + var sclModel = Assert.IsType(imported.Model); + + Assert.Equal( + discoveryModel.DataSets.Single().Reference, + sclModel.DataSets.Single().Reference); + Assert.Equal( + "IEDALD0/LLN0.Indications", + sclModel.DataSets.Single().Reference); + Assert.Equal( + discoveryModel.ReportControls.Single().Reference, + sclModel.ReportControls.Single().Reference); + Assert.Equal( + "IEDALD0/LLN0.BR.Rpt01", + sclModel.ReportControls.Single().Reference); + Assert.Equal( + discoveryModel.ReportControls.Single().DataSetReference, + sclModel.ReportControls.Single().DataSetReference); + + var selection = new[] + { + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/GGIO1.Alm.stVal", + FunctionalConstraint = "ST" + } + }; + + var discoveryCoverage = CanonicalStaticReportCoverageResolver.Resolve(discoveryModel, selection); + var sclCoverage = CanonicalStaticReportCoverageResolver.Resolve(sclModel, selection); + + Assert.Equal(1, discoveryCoverage.CoveredSignalCount); + Assert.Equal(1, sclCoverage.CoveredSignalCount); + Assert.Equal( + discoveryCoverage.Signals[0].DataSetCandidates[0].DataSetReference, + sclCoverage.Signals[0].DataSetCandidates[0].DataSetReference); + Assert.Equal( + "IEDALD0/LLN0.Indications", + sclCoverage.Signals[0].DataSetCandidates[0].DataSetReference); + Assert.Equal( + discoveryCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences, + sclCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences); + Assert.Equal( + ["IEDALD0/LLN0.BR.Rpt01"], + sclCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences); + } + + [Fact] + public void Structured_Member_Covers_Only_Exact_Boundary_Descendants() + { + var model = BuildCanonical(); + var plan = CanonicalStaticReportCoverageResolver.Resolve( + model, + [ + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + }, + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsB.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + Assert.Equal(CanonicalStaticReportSignalCoverageStatus.Covered, plan.Signals[0].Status); + Assert.Equal([0], plan.Signals[0].DataSetCandidates[0].MemberIndexes); + Assert.Equal( + CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership, + plan.Signals[1].Status); + } + + [Fact] + public void Segment_Preserves_Full_Ordered_DataSet_And_All_Configured_Rcbs() + { + var model = BuildCanonical(); + var plan = CanonicalStaticReportCoverageResolver.Resolve( + model, + [ + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + var segment = Assert.Single(plan.Segments); + Assert.Equal("IEDALD0/LLN0.Analog", segment.DataSetReference); + Assert.Equal(2, segment.OrderedMembers.Length); + Assert.Equal(0, segment.OrderedMembers[0].Index); + Assert.Equal(1, segment.OrderedMembers[1].Index); + Assert.Equal( + new[] + { + "IEDALD0/LLN0.BR.Buffer01", + "IEDALD0/LLN0.RP.Unbuffer01" + }, + segment.ReportControls.Select(report => report.Reference)); + Assert.Equal( + ["IEDALD0/MMXU1.A.phsA.cVal.mag.f"], + segment.SelectedSignalReferences); + } + + [Fact] + public void Canonical_Identity_Remains_Exact_Case() + { + var model = BuildCanonical(); + var plan = CanonicalStaticReportCoverageResolver.Resolve( + model, + [ + new CanonicalStaticReportSelection + { + Reference = "iedald0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + Assert.Equal(CanonicalStaticReportSignalCoverageStatus.SignalNotFound, plan.Signals[0].Status); + Assert.Equal(0, plan.CoveredSignalCount); + } + + [Fact] + public void DataSet_Without_Rcb_Is_Not_Promoted_To_Report_Coverage() + { + var model = BuildCanonical(); + var withoutReports = new CanonicalIedModel + { + SchemaVersion = model.SchemaVersion, + GeneratedAtUtc = model.GeneratedAtUtc, + Identity = model.Identity, + Communication = model.Communication, + Source = model.Source, + Strings = model.Strings, + AccessPoints = model.AccessPoints, + LogicalDevices = model.LogicalDevices, + LogicalNodes = model.LogicalNodes, + DataObjects = model.DataObjects, + Signals = model.Signals, + DataSets = model.DataSets, + ReportControls = Array.Empty(), + Diagnostics = model.Diagnostics + }; + + var plan = CanonicalStaticReportCoverageResolver.Resolve( + withoutReports, + [ + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + Assert.Equal( + CanonicalStaticReportSignalCoverageStatus.StaticDataSetWithoutReportControl, + plan.Signals[0].Status); + Assert.Empty(plan.Segments); + } + + private static CanonicalIedModel BuildStatusDiscoveryCanonical() + { + var live = new LiveIedModelDiscoveryDocument + { + Source = "LiveMmsDiscovery", + IedName = "IEDA", + AccessPointName = "P1", + IedIdentity = new LiveIedIdentity + { + IedName = "IEDA", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }, + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDALD0", + Inst = "LD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDALD0/GGIO1.Alm", + Name = "Alm", + InferredCdc = "SPS", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = "IEDALD0/GGIO1.Alm.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + SclBType = "BOOLEAN", + MmsType = "boolean", + MmsTypeSignature = "boolean", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "IEDALD0/LLN0.Indications", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Indications", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 1, + Reference = "IEDALD0/GGIO1.Alm", + FunctionalConstraint = "ST" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "IEDALD0/LLN0.BR.Rpt01", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Rpt01", + Buffered = true, + DataSetReference = "IEDALD0/LLN0.Indications" + } + ] + }; + + return CanonicalLiveModelAdapter.FromLiveDiscovery(live); + } + + private static string BuildEquivalentStatusScl() + => """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + private static CanonicalIedModel BuildCanonical() + { + var live = new LiveIedModelDiscoveryDocument + { + Source = "LiveMmsDiscovery", + IedName = "IEDA", + IedIdentity = new LiveIedIdentity + { + IedName = "IEDA", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }, + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDALD0", + Inst = "LD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MMXU1", + LnClass = "MMXU", + LnInst = "1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDALD0/MMXU1.A", + Name = "A", + InferredCdc = "WYE", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = + [ + Attribute("IEDALD0/MMXU1.A.phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute("IEDALD0/MMXU1.A.phsB.cVal.mag.f", "phsB.cVal.mag.f") + ] + }, + new LiveIedDataObjectModel + { + Reference = "IEDALD0/MMXU1.TotW", + Name = "TotW", + InferredCdc = "MV", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = + [ + Attribute("IEDALD0/MMXU1.TotW.mag.f", "mag.f") + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "IEDALD0/LLN0.Analog", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Analog", + MemberCount = 2, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = "IEDALD0/MMXU1.A.phsA", + FunctionalConstraint = "MX" + }, + new LiveIedDataSetMemberModel + { + Index = 1, + Reference = "IEDALD0/MMXU1.TotW", + FunctionalConstraint = "MX" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "IEDALD0/LLN0.BR.Buffer01", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Buffer01", + Buffered = true, + DataSetReference = "IEDALD0/LLN0.Analog" + }, + new LiveIedReportControlModel + { + Reference = "IEDALD0/LLN0.RP.Unbuffer01", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Unbuffer01", + Buffered = false, + DataSetReference = "IEDALD0/LLN0.Analog" + } + ] + }; + + return CanonicalLiveModelAdapter.FromLiveDiscovery(live); + } + + private static LiveIedDataAttributeModel Attribute(string reference, string path) + => new() + { + ObjectReference = reference, + AttributePath = path, + FunctionalConstraint = "MX", + SclBType = "FLOAT32", + MmsType = "floating-point", + MmsTypeSignature = "floating-point", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + private static CanonicalIedModel WithIngress( + CanonicalIedModel model, + CanonicalIngressKind ingress) + => new() + { + SchemaVersion = model.SchemaVersion, + GeneratedAtUtc = model.GeneratedAtUtc, + Identity = model.Identity, + Communication = model.Communication, + Source = new CanonicalSourceEnvelope + { + Ingress = ingress, + SourceName = ingress == CanonicalIngressKind.SclFile ? "sample.scd" : "LiveMmsDiscovery" + }, + Strings = model.Strings, + AccessPoints = model.AccessPoints, + LogicalDevices = model.LogicalDevices, + LogicalNodes = model.LogicalNodes, + DataObjects = model.DataObjects, + Signals = model.Signals, + DataSets = model.DataSets, + ReportControls = model.ReportControls, + Diagnostics = model.Diagnostics + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs b/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs index 7e42dc89..e4c3e154 100644 --- a/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs @@ -47,4 +47,40 @@ public void StandardMmsDataAccessErrors_HaveExplicitNames(int code, string expec [Fact] public void VendorSpecificUnknownAccessError_RemainsNumericallyVisible() => Assert.Equal("data-access-error-99", MmsWriteResponseDecoder.NameDataAccessError(99)); + [Theory] + [InlineData(2, MmsInteropFailureKind.TemporarilyUnavailable, MmsInteropRecoveryHint.RetryLaterAfterStateRefresh)] + [InlineData(3, MmsInteropFailureKind.AccessDenied, MmsInteropRecoveryHint.FailClosed)] + [InlineData(6, MmsInteropFailureKind.TypeUnsupported, MmsInteropRecoveryHint.RevalidateType)] + [InlineData(7, MmsInteropFailureKind.TypeMismatch, MmsInteropRecoveryHint.RevalidateType)] + [InlineData(8, MmsInteropFailureKind.AttributeInconsistent, MmsInteropRecoveryHint.RevalidateRuntimeState)] + [InlineData(10, MmsInteropFailureKind.ObjectNotFound, MmsInteropRecoveryHint.RevalidateObjectIdentity)] + [InlineData(11, MmsInteropFailureKind.InvalidValue, MmsInteropRecoveryHint.FailClosed)] + public void DataAccessErrors_Map_To_Structured_Interop_Recovery( + int code, + MmsInteropFailureKind expectedKind, + MmsInteropRecoveryHint expectedRecovery) + { + var access = new MmsWriteAccessResult + { + IsSuccess = false, + FailureCode = code, + FailureName = MmsWriteResponseDecoder.NameDataAccessError(code) + }; + + var classified = MmsInteropFailureClassifier.Classify(access); + + Assert.Equal(expectedKind, classified.Kind); + Assert.Equal(expectedRecovery, classified.RecoveryHint); + } + + [Fact] + public void Successful_Access_Has_No_Recovery_Action() + { + var classified = MmsInteropFailureClassifier.Classify( + new MmsWriteAccessResult { IsSuccess = true }); + + Assert.Equal(MmsInteropFailureKind.None, classified.Kind); + Assert.Equal(MmsInteropRecoveryHint.None, classified.RecoveryHint); + } + } diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs new file mode 100644 index 00000000..5031f57d --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs @@ -0,0 +1,185 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsConfiguredStaticRcbEligibilityPolicyTests +{ + [Fact] + public void Exact_Explicit_Free_Brcb_Remains_Preferred() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Available, + confidence: MmsRcbAvailabilityConfidence.Exact, + reservationTime: "0"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: false); + + Assert.True(result.IsEligible); + Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, result.Kind); + Assert.False(result.UsesReducedEvidence); + } + + [Fact] + public void Missing_Brcb_Reservation_Evidence_Is_Blocked_By_Default() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: string.Empty); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: false); + + Assert.False(result.IsEligible); + Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.Blocked, result.Kind); + } + + [Fact] + public void Explicit_OptIn_Allows_Only_Genuinely_Missing_Brcb_Reservation_Evidence() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: "-"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.True(result.IsEligible); + Assert.True(result.RequiresWrite); + Assert.True(result.UsesReducedEvidence); + Assert.Equal( + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence, + result.Kind); + } + + [Fact] + public void Malformed_Reservation_Evidence_Is_Not_Downgraded_To_Missing() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: "vendor-weird"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.False(result.IsEligible); + Assert.Contains("neither explicitly free", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Positive_Owner_Evidence_Always_Blocks_Reduced_Path() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: string.Empty, + owner: "01020304"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.False(result.IsEligible); + Assert.Contains("ownership/reservation", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Missing_Urcb_Resv_Can_Use_The_Same_Explicit_Reduced_Policy() + { + var snapshot = StaticSnapshot( + buffered: false, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationState: string.Empty); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.True(result.IsEligible); + Assert.Equal( + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence, + result.Kind); + } + + [Fact] + public void Dynamic_Empty_Rcb_Is_Never_Promoted_By_Configured_Static_Policy() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.NoDataSet, + confidence: MmsRcbAvailabilityConfidence.Exact, + reservationTime: string.Empty, + dataSetReference: string.Empty, + members: Array.Empty()); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.False(result.IsEligible); + Assert.Contains("configured static", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + private static MmsRcbAvailabilitySnapshot StaticSnapshot( + bool buffered, + MmsRcbOperationalAvailability availability, + MmsRcbAvailabilityConfidence confidence, + string reservationTime = "", + string reservationState = "", + string owner = "", + string dataSetReference = "LD0/LLN0.dsA", + IReadOnlyList? members = null) + { + members ??= + [ + new MmsDataSetDirectoryMember + { + Domain = "LD0", + MmsItemName = "GGIO1$ST$Ind1", + UserReference = "LD0/GGIO1.Ind1", + FunctionalConstraint = "ST" + } + ]; + + return new MmsRcbAvailabilitySnapshot + { + Reference = buffered ? "LD0/LLN0.BR.B01" : "LD0/LLN0.RP.U01", + Domain = "LD0", + LogicalNode = "LLN0", + Name = buffered ? "B01" : "U01", + Buffered = buffered, + DataSetReference = dataSetReference, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + EnabledState = "false", + ReservationTimeSeconds = reservationTime, + ReservationState = reservationState, + Owner = owner, + DataSetDirectoryRead = true, + DataSetDirectorySuccess = members.Count > 0, + DataSetMemberCount = members.Count, + DataSetMembers = members, + Availability = availability, + Confidence = confidence + }; + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs index 1d112a7c..b709fbd8 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs @@ -17,6 +17,7 @@ public void DefaultOptions_AreBoundedAndFailClosed() Assert.True(options.AllowCallerOwnedReports); Assert.True(options.AllowStaticBrcb); Assert.True(options.AllowStaticUrcb); + Assert.False(options.AllowConfiguredStaticWithMissingReservationEvidence); Assert.True(options.AllowDynamicBrcb); Assert.True(options.AllowDynamicUrcb); Assert.True(options.AllowPollingFallback); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs index a8c9ee3a..0634f2f4 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs @@ -213,6 +213,76 @@ public void PollingDisabled_LeavesResidualUncovered_AndNeverCallsItMissing() Assert.All(plan.Assignments, x => Assert.Contains("No absence conclusion", x.Reason, StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void ConfiguredStatic_WithMissingReservationEvidence_RemainsPollingByDefault() + { + var signal = Signal( + "LD0/GGIO1.Ind1.stVal", + "LD0/GGIO1$ST$Ind1$stVal", + "ST", + "LD0/LLN0.dsA", + "LD0/GGIO1.Ind1"); + var inventory = Inventory(Rcb("LD0/LLN0.BR.B01", true, "LD0/LLN0.dsA")); + var unknown = Copy( + StaticAvailable( + "LD0/LLN0.BR.B01", + true, + "LD0/LLN0.dsA", + Member("LD0", "GGIO1$ST$Ind1", "LD0/GGIO1.Ind1", "ST")), + availability: MmsRcbOperationalAvailability.Unknown, + reservationTimeSeconds: string.Empty, + confidence: MmsRcbAvailabilityConfidence.Reduced); + + var plan = Build([signal], inventory, Availability(unknown), EmptyDirectory()); + + Assert.Equal(MmsHybridAcquisitionPlanStatus.PollingOnly, plan.Status); + Assert.Equal(0, plan.StaticBrcbSignalCount); + Assert.Equal(1, plan.PollingFallbackSignalCount); + Assert.Equal(0, plan.Capability.StaticUsableCount); + } + + [Fact] + public void Explicit_Reduced_Static_OptIn_Uses_ConfiguredBrcb_BeforePolling() + { + var signal = Signal( + "LD0/GGIO1.Ind1.stVal", + "LD0/GGIO1$ST$Ind1$stVal", + "ST", + "LD0/LLN0.dsA", + "LD0/GGIO1.Ind1"); + var inventory = Inventory(Rcb("LD0/LLN0.BR.B01", true, "LD0/LLN0.dsA")); + var unknown = Copy( + StaticAvailable( + "LD0/LLN0.BR.B01", + true, + "LD0/LLN0.dsA", + Member("LD0", "GGIO1$ST$Ind1", "LD0/GGIO1.Ind1", "ST")), + availability: MmsRcbOperationalAvailability.Unknown, + reservationTimeSeconds: string.Empty, + confidence: MmsRcbAvailabilityConfidence.Reduced); + + var plan = Build( + [signal], + inventory, + Availability(unknown), + EmptyDirectory(), + new MmsHybridReportAcquisitionOptions + { + AllowConfiguredStaticWithMissingReservationEvidence = true + }); + + Assert.Equal(MmsHybridAcquisitionPlanStatus.FullReportCoverage, plan.Status); + Assert.Equal(1, plan.StaticBrcbSignalCount); + Assert.Equal(0, plan.PollingFallbackSignalCount); + Assert.Equal(1, plan.Capability.StaticUsableCount); + var segment = Assert.Single(plan.Segments); + Assert.Equal(MmsHybridAcquisitionKind.StaticBrcb, segment.Kind); + Assert.True(segment.RequiresWrite); + Assert.Contains("reduced", segment.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains(plan.Warnings, warning => + warning.Contains("reduced reservation-evidence", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Capability_DistinguishesDiscoveredCheckedUsableBusyAndUnknownRcbs() { @@ -389,7 +459,10 @@ private static MmsRcbAvailabilitySnapshot Copy( MmsRcbAvailabilitySnapshot source, MmsRcbOperationalAvailability? availability = null, string? enabledState = null, - string? reservationState = null) + string? reservationState = null, + string? reservationTimeSeconds = null, + string? owner = null, + MmsRcbAvailabilityConfidence? confidence = null) => new() { CheckedAtUtc = source.CheckedAtUtc, @@ -410,15 +483,15 @@ private static MmsRcbAvailabilitySnapshot Copy( OptionalFields = source.OptionalFields, EnabledState = enabledState ?? source.EnabledState, ReservationState = reservationState ?? source.ReservationState, - ReservationTimeSeconds = source.ReservationTimeSeconds, - Owner = source.Owner, + ReservationTimeSeconds = reservationTimeSeconds ?? source.ReservationTimeSeconds, + Owner = owner ?? source.Owner, DataSetDirectoryRead = source.DataSetDirectoryRead, DataSetDirectorySuccess = source.DataSetDirectorySuccess, DataSetIsDeletable = source.DataSetIsDeletable, DataSetMemberCount = source.DataSetMemberCount, DataSetMembers = source.DataSetMembers, Availability = availability ?? source.Availability, - Confidence = source.Confidence, + Confidence = confidence ?? source.Confidence, Reason = source.Reason, ProbeDiagnostics = source.ProbeDiagnostics }; diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs new file mode 100644 index 00000000..0c16a681 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs @@ -0,0 +1,158 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsReportControlSemanticWriteTests +{ + [Fact] + public void ResvTms_Uses_Signed_Mms_Integer_On_Wire() + { + var write = MmsReportControlSemanticWrite.ReservationTime(60); + + Assert.Equal("ResvTms", write.Attribute); + Assert.Equal(MmsDataKind.Integer, write.ExpectedKind); + Assert.Equal(MmsDataKind.Integer, write.Value.Kind); + Assert.Equal(60L, write.Value.Value); + + var encoded = MmsDataCodec.Encode(write.Value); + + Assert.NotEmpty(encoded); + Assert.Equal(0x85, encoded[0]); // MMS Data integer [5], never unsigned [6] (0x86). + } + + [Theory] + [InlineData(-32768)] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + [InlineData(60)] + [InlineData(32767)] + public void ResvTms_Accepts_Signed_Int16_Range(int seconds) + { + var write = MmsReportControlSemanticWrite.ReservationTime(seconds); + + Assert.Equal(MmsDataKind.Integer, write.Value.Kind); + Assert.Equal((long)seconds, write.Value.Value); + } + + [Theory] + [InlineData(-32769)] + [InlineData(32768)] + public void ResvTms_Rejects_Values_Outside_Signed_Int16_Range(int seconds) + { + Assert.Throws( + () => MmsReportControlSemanticWrite.ReservationTime(seconds)); + } + + [Fact] + public void Boolean_Rcb_Fields_Are_Bound_To_Mms_Boolean() + { + var values = new[] + { + MmsReportControlSemanticWrite.ReportEnable(true), + MmsReportControlSemanticWrite.Reservation(true), + MmsReportControlSemanticWrite.GeneralInterrogation(true) + }; + + Assert.Equal(new[] { "RptEna", "Resv", "GI" }, values.Select(x => x.Attribute)); + Assert.All(values, write => + { + Assert.Equal(MmsDataKind.Boolean, write.ExpectedKind); + Assert.Equal(MmsDataKind.Boolean, write.Value.Kind); + Assert.Equal(true, write.Value.Value); + Assert.Equal(0x83, MmsDataCodec.Encode(write.Value)[0]); // MMS Data boolean [3]. + }); + } + + [Fact] + public void Live_Type_Compatibility_Is_Exact_And_Does_Not_Guess() + { + var integer = new MmsTypeSpecificationNode { MmsType = "integer" }; + var unsigned = new MmsTypeSpecificationNode { MmsType = "unsigned" }; + var boolean = new MmsTypeSpecificationNode { MmsType = "boolean" }; + + var resvTms = MmsReportControlSemanticWrite.ReservationTime(60); + Assert.True(resvTms.IsCompatibleWith(integer)); + Assert.False(resvTms.IsCompatibleWith(unsigned)); + Assert.False(resvTms.IsCompatibleWith(boolean)); + + var rptEna = MmsReportControlSemanticWrite.ReportEnable(true); + Assert.True(rptEna.IsCompatibleWith(boolean)); + Assert.False(rptEna.IsCompatibleWith(integer)); + Assert.False(rptEna.IsCompatibleWith(null)); + } + [Fact] + public void Exact_Live_Type_Mismatch_Is_A_Positive_Blocker() + { + var write = MmsReportControlSemanticWrite.ReservationTime(60); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$ResvTms", "BR"), + TypeSpecification = new MmsTypeSpecificationNode + { + MmsType = "unsigned", + SclBType = "INT32U" + } + }; + + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + "LD0/LLN0.BR.Brcb01.ResvTms [BR]", + live); + + Assert.Equal(MmsReportSemanticTypeEvidenceStatus.ExactMismatch, evidence.Status); + Assert.True(evidence.IsExact); + Assert.False(evidence.AllowsMutation); + Assert.Equal("integer", evidence.ExpectedMmsType); + Assert.Equal("unsigned", evidence.LiveMmsType); + } + + [Fact] + public void Missing_Live_Type_Evidence_Does_Not_Become_A_False_Blocker() + { + var write = MmsReportControlSemanticWrite.ReportEnable(true); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = false, + Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$RptEna", "BR"), + Message = "GetVariableAccessAttributes unsupported by this server." + }; + + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + "LD0/LLN0.BR.Brcb01.RptEna [BR]", + live); + + Assert.Equal(MmsReportSemanticTypeEvidenceStatus.Unavailable, evidence.Status); + Assert.False(evidence.IsExact); + Assert.True(evidence.AllowsMutation); + Assert.Contains("IEC semantic contract", evidence.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Exact_Live_Type_Match_Confirms_Semantic_Mutation() + { + var write = MmsReportControlSemanticWrite.ReportEnable(true); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$RptEna", "BR"), + TypeSpecification = new MmsTypeSpecificationNode + { + MmsType = "boolean", + SclBType = "BOOLEAN" + } + }; + + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + "LD0/LLN0.BR.Brcb01.RptEna [BR]", + live); + + Assert.Equal(MmsReportSemanticTypeEvidenceStatus.ExactMatch, evidence.Status); + Assert.True(evidence.IsExact); + Assert.True(evidence.AllowsMutation); + } + +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs index e1e84ef1..eabc10d6 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs @@ -108,4 +108,70 @@ public void Diagnostics_treats_sequence_zero_after_nonzero_as_reset_warning_not_ Assert.Contains(diagnostics.WarningMessages, x => x.Contains("reset-to-zero", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void Diagnostics_surfaces_structured_RCB_type_and_preflight_failures() + { + var writes = new[] + { + new MmsReportAttributeWriteStep + { + Attribute = "ResvTms", + Reference = "LD0/LLN0.BR.B01.ResvTms", + Attempted = true, + IsSuccess = false, + FailureCode = 7, + FailureName = "type-inconsistent", + FailureKind = MmsInteropFailureKind.TypeMismatch, + RecoveryHint = MmsInteropRecoveryHint.RevalidateType, + TypeEvidence = new MmsReportSemanticTypeEvidence + { + Status = MmsReportSemanticTypeEvidenceStatus.Unavailable, + Attribute = "ResvTms", + ExpectedMmsType = "integer", + Message = "Live TypeSpecification unavailable." + } + }, + new MmsReportAttributeWriteStep + { + Attribute = "RptEna", + Reference = "LD0/LLN0.BR.B01.RptEna", + Attempted = false, + IsSuccess = false, + TypeEvidence = new MmsReportSemanticTypeEvidence + { + Status = MmsReportSemanticTypeEvidenceStatus.ExactMismatch, + Attribute = "RptEna", + ExpectedMmsType = "boolean", + LiveMmsType = "integer" + } + }, + new MmsReportAttributeWriteStep + { + Attribute = "GI", + Reference = "LD0/LLN0.BR.B01.GI", + Attempted = true, + IsSuccess = false, + FailureCode = 3, + FailureName = "object-access-denied", + FailureKind = MmsInteropFailureKind.AccessDenied, + RecoveryHint = MmsInteropRecoveryHint.FailClosed + } + }; + + var diagnostics = MmsReportSessionDiagnostics.Analyze( + Array.Empty(), + writeSteps: writes); + + Assert.Equal("FAIL", diagnostics.OverallStatus); + Assert.Equal(3, diagnostics.WriteFailureCount); + Assert.Equal(1, diagnostics.WritePreflightBlockedCount); + Assert.Equal(1, diagnostics.WriteTypeMismatchCount); + Assert.Equal(1, diagnostics.WriteAccessDeniedCount); + Assert.Equal(1, diagnostics.WriteTypeEvidenceConflictCount); + Assert.Equal(1, diagnostics.WriteTypeEvidenceUnavailableCount); + Assert.Contains("typeMismatch=1", diagnostics.Summary, StringComparison.Ordinal); + Assert.Contains("preflightBlocked=1", diagnostics.Summary, StringComparison.Ordinal); + } + + } diff --git a/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs index d3235ef4..e048ebc5 100644 --- a/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs @@ -57,6 +57,17 @@ public void Brcb_Explicit_Reservation_Is_Retry_Only() fallback); } + [Fact] + public void ConfiguredStaticActivation_Is_SourceNeutral_PublicEntryPoint() + { + var configured = typeof(MmsClientSession).GetMethod(nameof(MmsClientSession.StartConfiguredStaticReportMonitorAsync)); + var scl = typeof(MmsClientSession).GetMethod(nameof(MmsClientSession.StartStaticSclReportMonitorAsync)); + + Assert.NotNull(configured); + Assert.NotNull(scl); + Assert.Equal(configured!.ReturnType, scl!.ReturnType); + } + [Fact] public void Urcb_Has_No_Brcb_Reservation_Fallback() {