From af211ca5393dface8ccfd316ebd1ec802c602266 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 14:52:33 +0700 Subject: [PATCH 1/6] P6.2: add exact single-member dynamic DataSet service probe --- .../Mms/MmsDynamicDataSetServiceProbe.cs | 409 ++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsDynamicDataSetServiceProbe.cs diff --git a/src/AR.Iec61850/Mms/MmsDynamicDataSetServiceProbe.cs b/src/AR.Iec61850/Mms/MmsDynamicDataSetServiceProbe.cs new file mode 100644 index 0000000..b504d5e --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsDynamicDataSetServiceProbe.cs @@ -0,0 +1,409 @@ +using AR.Iec61850.Diagnostics; + +namespace AR.Iec61850.Mms; + +public enum MmsDynamicDataSetProbeFailureStage +{ + None, + DefineNamedVariableList, + GetNamedVariableListAttributes, + DeleteNamedVariableList +} + +public sealed class MmsDynamicDataSetProbeServiceEvidence +{ + public string Service { get; init; } = string.Empty; + public bool Attempted { get; init; } + public bool IsSuccess { get; init; } + public int InvokeId { get; init; } + public string DataSetReference { get; init; } = string.Empty; + public string MemberReference { get; init; } = string.Empty; + public string RequestHex { get; init; } = string.Empty; + public string ResponseHex { get; init; } = string.Empty; + public MmsAssociationState StateBefore { get; init; } + public MmsAssociationState StateAfter { get; init; } + public string ReceiveRoutingSummary { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + + public string Summary => + $"{Service}: success={IsSuccess}, invokeID={InvokeId}, state={StateBefore}->{StateAfter}, dataset={DataSetReference}, member={MemberReference}, route={TextOrDash(ReceiveRoutingSummary)}, result={TextOrDash(Message)}"; + + private static string TextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); +} + +public sealed class MmsDynamicDataSetProbeResult +{ + public bool IsSuccess { get; init; } + public MmsDynamicDataSetProbeFailureStage FailureStage { get; init; } + public string DataSetReference { get; init; } = string.Empty; + public string MemberReference { get; init; } = string.Empty; + public MmsDynamicDataSetProbeServiceEvidence DefineEvidence { get; init; } = new(); + public bool DirectoryAttempted { get; init; } + public bool DirectoryVerified { get; init; } + public string DirectoryMessage { get; init; } = string.Empty; + public string DirectoryResponseHex { get; init; } = string.Empty; + public MmsDynamicDataSetProbeServiceEvidence DeleteEvidence { get; init; } = new(); + public IReadOnlyList WriteSteps { get; init; } = Array.Empty(); + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); + + public bool DynamicMutationAttempted => DefineEvidence.Attempted; + public bool CleanupAttempted => DeleteEvidence.Attempted; + public bool CleanupSucceeded => !CleanupAttempted || DeleteEvidence.IsSuccess; + public bool AssociationSurvived => DefineEvidence.StateAfter == MmsAssociationState.MmsInitiated && + (!DeleteEvidence.Attempted || DeleteEvidence.StateAfter == MmsAssociationState.MmsInitiated); + + public string Summary => IsSuccess + ? $"single-member NVL probe succeeded for {DataSetReference}; member={MemberReference}; define/verify/delete all completed on one MMS association." + : $"single-member NVL probe failed at {FailureStage} for {DataSetReference}; member={MemberReference}; associationSurvived={AssociationSurvived}."; +} + +public static class MmsDynamicDataSetProbePolicy +{ + public static bool ShouldProbe(MmsReportSubscriptionPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + return plan.Mode == MmsReportSubscriptionPlanMode.DynamicDataSet && + plan.IsReady && + plan.ReportControl is not null && + !string.IsNullOrWhiteSpace(plan.DataSetReference) && + plan.DynamicPoints.Count > 0; + } + + public static MmsReportActivationFailureReason FailureReason(MmsDynamicDataSetProbeFailureStage stage) + => stage switch + { + MmsDynamicDataSetProbeFailureStage.DefineNamedVariableList => MmsReportActivationFailureReason.DynamicDataSetProbeDefineFailed, + MmsDynamicDataSetProbeFailureStage.GetNamedVariableListAttributes => MmsReportActivationFailureReason.DynamicDataSetProbeVerificationFailed, + MmsDynamicDataSetProbeFailureStage.DeleteNamedVariableList => MmsReportActivationFailureReason.DynamicDataSetProbeDeleteFailed, + _ => MmsReportActivationFailureReason.OtherActivationFailure + }; +} + +public sealed partial class MmsClientSession +{ + /// + /// P6.2 field probe for dynamic DataSet service interoperability. It uses the exact + /// planned DataSet reference but only one already-resolved member, verifies the list + /// through GetNamedVariableListAttributes, then deletes it before any RCB attribute is + /// mutated. A relay that aborts on DefineNamedVariableList therefore fails before a + /// static or dynamic RCB is armed, while the returned evidence preserves the exact + /// invokeID, request/response BER, routing result, and association state transition. + /// + public async Task ProbeDynamicDataSetServiceAsync( + string dataSetReference, + MmsObjectReference member, + MmsIedModelDirectory? directory = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + ArgumentException.ThrowIfNullOrWhiteSpace(dataSetReference); + if (string.IsNullOrWhiteSpace(member.Domain) || string.IsNullOrWhiteSpace(member.Item)) + throw new ArgumentException("Dynamic DataSet probe requires one resolved MMS member.", nameof(member)); + + var memberReference = $"{member.Domain}/{member.Item}"; + var steps = new List(); + var evidence = new List(); + + var define = await SendProbeDefineAsync(dataSetReference, member, cancellationToken).ConfigureAwait(false); + steps.Add(new MmsReportAttributeWriteStep + { + Attribute = "Probe.DefineNamedVariableList", + Reference = dataSetReference, + Attempted = true, + IsSuccess = define.IsSuccess, + Message = define.Message + }); + AppendServiceEvidence(evidence, "DEFINE", define); + + if (!define.IsSuccess || !IsMmsInitiated) + { + return BuildProbeResult( + false, + MmsDynamicDataSetProbeFailureStage.DefineNamedVariableList, + dataSetReference, + memberReference, + define, + false, + false, + string.Empty, + string.Empty, + new MmsDynamicDataSetProbeServiceEvidence(), + steps, + evidence); + } + + MmsDataSetDirectoryResult directoryResult; + try + { + directoryResult = await GetDataSetDirectoryAsync(dataSetReference, directory, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + directoryResult = new MmsDataSetDirectoryResult + { + IsSuccess = false, + DataSetReference = dataSetReference, + Message = $"Probe DataSet directory exception: {ex.GetType().Name}: {ex.Message}" + }; + } + + var directoryVerified = directoryResult.IsSuccess && + directoryResult.Members.Count == 1 && + directoryResult.Members.Any(candidate => + candidate.MmsReference.Equals(memberReference, StringComparison.OrdinalIgnoreCase)); + steps.Add(new MmsReportAttributeWriteStep + { + Attribute = "Probe.GetNamedVariableListAttributes", + Reference = dataSetReference, + Attempted = true, + IsSuccess = directoryVerified, + Message = directoryResult.IsSuccess + ? $"Directory returned {directoryResult.Members.Count} member(s); exactSingleMember={directoryVerified}. {directoryResult.Message}" + : directoryResult.Message + }); + evidence.Add( + $"P6.2 NVL PROBE VERIFY: success={directoryVerified}; dataset={dataSetReference}; expectedMember={memberReference}; returnedMembers={directoryResult.Members.Count}; result={directoryResult.Message}; responseHEX={TextOrNone(directoryResult.ResponseHexPreview)}"); + + var delete = IsMmsInitiated + ? await SendProbeDeleteAsync(dataSetReference, memberReference, cancellationToken).ConfigureAwait(false) + : new MmsDynamicDataSetProbeServiceEvidence + { + Service = "DeleteNamedVariableList", + Attempted = false, + IsSuccess = false, + DataSetReference = dataSetReference, + MemberReference = memberReference, + StateBefore = State, + StateAfter = State, + Message = "Delete probe was not attempted because the MMS association was no longer initiated." + }; + + if (delete.Attempted) + { + steps.Add(new MmsReportAttributeWriteStep + { + Attribute = "Probe.DeleteNamedVariableList", + Reference = dataSetReference, + Attempted = true, + IsSuccess = delete.IsSuccess, + Message = delete.Message + }); + AppendServiceEvidence(evidence, "DELETE", delete); + } + else + { + evidence.Add($"P6.2 NVL PROBE DELETE: skipped; state={State}; dataset={dataSetReference}; reason={delete.Message}"); + } + + if (!directoryVerified) + { + return BuildProbeResult( + false, + MmsDynamicDataSetProbeFailureStage.GetNamedVariableListAttributes, + dataSetReference, + memberReference, + define, + true, + false, + directoryResult.Message, + directoryResult.ResponseHexPreview, + delete, + steps, + evidence); + } + + if (!delete.IsSuccess) + { + return BuildProbeResult( + false, + MmsDynamicDataSetProbeFailureStage.DeleteNamedVariableList, + dataSetReference, + memberReference, + define, + true, + true, + directoryResult.Message, + directoryResult.ResponseHexPreview, + delete, + steps, + evidence); + } + + return BuildProbeResult( + true, + MmsDynamicDataSetProbeFailureStage.None, + dataSetReference, + memberReference, + define, + true, + true, + directoryResult.Message, + directoryResult.ResponseHexPreview, + delete, + steps, + evidence); + } + + private async Task SendProbeDefineAsync( + string dataSetReference, + MmsObjectReference member, + CancellationToken cancellationToken) + { + var stateBefore = State; + var invokeId = NextInvokeId(); + var request = MmsDefineNamedVariableListRequest.Build(invokeId, dataSetReference, [member]); + var requestHex = HexDump.ToCompactString(request); + LastDiscoveryRequestHex = requestHex; + LastDiscoveryResponseHex = string.Empty; + LastReceiveRoutingSummary = string.Empty; + + try + { + var response = await SendConfirmedPresentationPayloadAsync(request, invokeId, cancellationToken).ConfigureAwait(false); + var result = MmsDefineNamedVariableListResponseDecoder.Decode(response, invokeId, dataSetReference); + LastDiscoveryResponseHex = result.ResponseHexPreview; + LastDiscoveryAttemptSummary = result.Message; + return new MmsDynamicDataSetProbeServiceEvidence + { + Service = "DefineNamedVariableList", + Attempted = true, + IsSuccess = result.IsSuccess, + InvokeId = invokeId, + DataSetReference = dataSetReference, + MemberReference = $"{member.Domain}/{member.Item}", + RequestHex = requestHex, + ResponseHex = result.ResponseHexPreview, + StateBefore = stateBefore, + StateAfter = State, + ReceiveRoutingSummary = LastReceiveRoutingSummary, + Message = result.Message + }; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + var route = LastReceiveRoutingSummary; + var responseHex = LastDiscoveryResponseHex; + var message = $"DefineNamedVariableList probe transport fault: {ex.GetType().Name}: {ex.Message}"; + await MarkProtocolFaultAsync().ConfigureAwait(false); + LastDiscoveryAttemptSummary = message; + return new MmsDynamicDataSetProbeServiceEvidence + { + Service = "DefineNamedVariableList", + Attempted = true, + IsSuccess = false, + InvokeId = invokeId, + DataSetReference = dataSetReference, + MemberReference = $"{member.Domain}/{member.Item}", + RequestHex = requestHex, + ResponseHex = responseHex, + StateBefore = stateBefore, + StateAfter = State, + ReceiveRoutingSummary = route, + Message = message + }; + } + } + + private async Task SendProbeDeleteAsync( + string dataSetReference, + string memberReference, + CancellationToken cancellationToken) + { + var stateBefore = State; + var invokeId = NextInvokeId(); + var request = MmsDeleteNamedVariableListRequest.Build(invokeId, dataSetReference); + var requestHex = HexDump.ToCompactString(request); + LastDiscoveryRequestHex = requestHex; + LastDiscoveryResponseHex = string.Empty; + LastReceiveRoutingSummary = string.Empty; + + try + { + var response = await SendConfirmedPresentationPayloadAsync(request, invokeId, cancellationToken).ConfigureAwait(false); + var result = MmsDeleteNamedVariableListResponseDecoder.Decode(response, invokeId, dataSetReference); + LastDiscoveryResponseHex = result.ResponseHexPreview; + LastDiscoveryAttemptSummary = result.Message; + return new MmsDynamicDataSetProbeServiceEvidence + { + Service = "DeleteNamedVariableList", + Attempted = true, + IsSuccess = result.IsSuccess, + InvokeId = invokeId, + DataSetReference = dataSetReference, + MemberReference = memberReference, + RequestHex = requestHex, + ResponseHex = result.ResponseHexPreview, + StateBefore = stateBefore, + StateAfter = State, + ReceiveRoutingSummary = LastReceiveRoutingSummary, + Message = result.Message + }; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + var route = LastReceiveRoutingSummary; + var responseHex = LastDiscoveryResponseHex; + var message = $"DeleteNamedVariableList probe transport fault: {ex.GetType().Name}: {ex.Message}"; + await MarkProtocolFaultAsync().ConfigureAwait(false); + LastDiscoveryAttemptSummary = message; + return new MmsDynamicDataSetProbeServiceEvidence + { + Service = "DeleteNamedVariableList", + Attempted = true, + IsSuccess = false, + InvokeId = invokeId, + DataSetReference = dataSetReference, + MemberReference = memberReference, + RequestHex = requestHex, + ResponseHex = responseHex, + StateBefore = stateBefore, + StateAfter = State, + ReceiveRoutingSummary = route, + Message = message + }; + } + } + + private static MmsDynamicDataSetProbeResult BuildProbeResult( + bool isSuccess, + MmsDynamicDataSetProbeFailureStage failureStage, + string dataSetReference, + string memberReference, + MmsDynamicDataSetProbeServiceEvidence define, + bool directoryAttempted, + bool directoryVerified, + string directoryMessage, + string directoryResponseHex, + MmsDynamicDataSetProbeServiceEvidence delete, + IReadOnlyList steps, + IReadOnlyList evidence) + => new() + { + IsSuccess = isSuccess, + FailureStage = failureStage, + DataSetReference = dataSetReference, + MemberReference = memberReference, + DefineEvidence = define, + DirectoryAttempted = directoryAttempted, + DirectoryVerified = directoryVerified, + DirectoryMessage = directoryMessage, + DirectoryResponseHex = directoryResponseHex, + DeleteEvidence = delete, + WriteSteps = steps.ToArray(), + EvidenceLines = evidence.ToArray() + }; + + private static void AppendServiceEvidence( + ICollection lines, + string label, + MmsDynamicDataSetProbeServiceEvidence evidence) + { + lines.Add($"P6.2 NVL PROBE {label}: {evidence.Summary}"); + lines.Add($"P6.2 NVL PROBE {label} requestHEX={TextOrNone(evidence.RequestHex)}"); + lines.Add($"P6.2 NVL PROBE {label} responseHEX={TextOrNone(evidence.ResponseHex)}"); + } + + private static string TextOrNone(string? value) + => string.IsNullOrWhiteSpace(value) ? "" : value.Trim(); +} From 990557c364396c54336e6248f82eb28b58ad2210 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 14:53:08 +0700 Subject: [PATCH 2/6] P6.2: gate dynamic activation with single-member NVL probe --- ...sPersistentReportMonitorAttemptEvidence.cs | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs index a9c9afb..af6e046 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs @@ -12,6 +12,9 @@ public enum MmsReportActivationFailureReason { None, InvalidPlan, + DynamicDataSetProbeDefineFailed, + DynamicDataSetProbeVerificationFailed, + DynamicDataSetProbeDeleteFailed, DynamicDataSetDefineFailed, DynamicDataSetBindFailed, TriggerOptionsUnavailable, @@ -25,6 +28,8 @@ public enum MmsReportActivationFailureReason /// P4 runtime evidence around persistent-report activation. For a dynamic plan, /// DynamicAttempted is true only when the engine actually issued at least one dynamic /// configuration service/write to the IED. Failed mutated attempts are rolled back best-effort. +/// P6.2 adds a single-member Define/GetAttributes/Delete probation before the full dynamic +/// DataSet is created so vendor/service failures are isolated before any RCB is mutated. /// public sealed class MmsPersistentReportMonitorAttemptResult { @@ -55,6 +60,44 @@ public async Task StartPersistentReport var isDynamic = plan.Mode == MmsReportSubscriptionPlanMode.DynamicDataSet; var originalDataSetReference = plan.ReportControl?.DataSetReference ?? string.Empty; + MmsDynamicDataSetProbeResult? probe = null; + if (isDynamic && MmsDynamicDataSetProbePolicy.ShouldProbe(plan)) + { + probe = await ProbeDynamicDataSetServiceAsync( + plan.DataSetReference, + plan.DynamicPoints[0].ToObjectReference(), + directory, + cancellationToken).ConfigureAwait(false); + + if (!probe.IsSuccess) + { + var failedStart = new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + Message = $"P6.2 dynamic DataSet service probation failed before RCB mutation. {probe.Summary}", + WriteSteps = probe.WriteSteps, + Warnings = probe.EvidenceLines + }; + + return new MmsPersistentReportMonitorAttemptResult + { + StartResult = failedStart, + DynamicAttemptState = probe.DynamicMutationAttempted + ? MmsDynamicReportAttemptState.AttemptedFailed + : MmsDynamicReportAttemptState.NotAttempted, + FailureReason = MmsDynamicDataSetProbePolicy.FailureReason(probe.FailureStage), + CleanupAttempted = probe.CleanupAttempted, + CleanupSucceeded = probe.CleanupSucceeded, + CleanupSteps = probe.WriteSteps + .Where(step => step.Attribute.Equals("Probe.DeleteNamedVariableList", StringComparison.OrdinalIgnoreCase)) + .ToArray(), + CleanupWarnings = probe.CleanupSucceeded + ? Array.Empty() + : ["P6.2 single-member DataSet probe cleanup failed. Fresh association/NamedVariableList evidence is required before another automatic dynamic attempt."] + }; + } + } + var start = await StartPersistentReportMonitorAsync( plan, triggerGeneralInterrogation, @@ -62,6 +105,9 @@ public async Task StartPersistentReport directory, cancellationToken).ConfigureAwait(false); + if (probe is not null) + start = MergeProbeEvidence(start, probe); + if (!isDynamic) { return new MmsPersistentReportMonitorAttemptResult @@ -74,7 +120,7 @@ public async Task StartPersistentReport var dynamicAttempted = start.WriteSteps.Any(step => step.Attempted && - (step.Attribute.Equals("DefineNamedVariableList", StringComparison.OrdinalIgnoreCase) || + (step.Attribute.Contains("DefineNamedVariableList", StringComparison.OrdinalIgnoreCase) || step.Attribute.Equals("DatSet", StringComparison.OrdinalIgnoreCase) || step.Attribute.Equals("TrgOps", StringComparison.OrdinalIgnoreCase) || step.Attribute.Equals("RptEna", StringComparison.OrdinalIgnoreCase))); @@ -108,6 +154,9 @@ public async Task StartPersistentReport { var enabled = SuccessfulStep(start.WriteSteps, "RptEna"); var reserved = SuccessfulStep(start.WriteSteps, "Resv") || SuccessfulStep(start.WriteSteps, "ResvTms"); + // Probe.DefineNamedVariableList is deliberately excluded here: a successful + // probe is deleted before full activation. Only the full DataSet definition + // may need failed-start cleanup below. var dataSetDefined = SuccessfulStep(start.WriteSteps, "DefineNamedVariableList"); var dataSetBound = SuccessfulStep(start.WriteSteps, "DatSet"); @@ -191,6 +240,24 @@ public async Task StartPersistentReport }; } + private static MmsPersistentReportMonitorStartResult MergeProbeEvidence( + MmsPersistentReportMonitorStartResult start, + MmsDynamicDataSetProbeResult probe) + => new() + { + IsSuccess = start.IsSuccess, + Message = start.IsSuccess + ? start.Message + : $"{start.Message} P6.2 probation had already succeeded: {probe.Summary}", + Session = start.Session, + WriteSteps = probe.WriteSteps.Concat(start.WriteSteps).ToArray(), + Warnings = start.IsSuccess + ? start.Warnings + : probe.EvidenceLines.Concat(start.Warnings).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + RcbSnapshots = start.RcbSnapshots, + DataSetSnapshots = start.DataSetSnapshots + }; + private static bool SuccessfulStep(IEnumerable steps, string attribute) => steps.Any(step => step.Attempted && @@ -202,6 +269,12 @@ private static MmsReportActivationFailureReason ClassifyFailure(MmsPersistentRep var failed = result.WriteSteps.LastOrDefault(step => step.Attempted && !step.IsSuccess); if (failed is not null) { + if (failed.Attribute.Equals("Probe.DefineNamedVariableList", StringComparison.OrdinalIgnoreCase)) + return MmsReportActivationFailureReason.DynamicDataSetProbeDefineFailed; + if (failed.Attribute.Equals("Probe.GetNamedVariableListAttributes", StringComparison.OrdinalIgnoreCase)) + return MmsReportActivationFailureReason.DynamicDataSetProbeVerificationFailed; + if (failed.Attribute.Equals("Probe.DeleteNamedVariableList", StringComparison.OrdinalIgnoreCase)) + return MmsReportActivationFailureReason.DynamicDataSetProbeDeleteFailed; if (failed.Attribute.Equals("DefineNamedVariableList", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.DynamicDataSetDefineFailed; if (failed.Attribute.Equals("DatSet", StringComparison.OrdinalIgnoreCase)) From 59467be2fc7f808d4d8c303d66196476035f92a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 14:55:08 +0700 Subject: [PATCH 3/6] P6.2: test single-member NVL probation contract --- .../Mms/MmsDynamicDataSetServiceProbeTests.cs | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs new file mode 100644 index 0000000..93382d4 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs @@ -0,0 +1,131 @@ +using System.Text; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsDynamicDataSetServiceProbeTests +{ + [Fact] + public void ProbePolicy_StaticPlan_IsNeverProbed() + { + var plan = new MmsReportSubscriptionPlan + { + Mode = MmsReportSubscriptionPlanMode.StaticDataSet, + Status = MmsReportSubscriptionPlanStatus.ReadyRequiresWrite, + ReportControl = BuildUrcb(), + DataSetReference = "LD0/LLN0.Static", + DynamicPoints = [BuildPoint()] + }; + + Assert.False(MmsDynamicDataSetProbePolicy.ShouldProbe(plan)); + } + + [Fact] + public void ProbePolicy_ReadyDynamicPlan_WithResolvedPoint_IsProbed() + { + var plan = new MmsReportSubscriptionPlan + { + Mode = MmsReportSubscriptionPlanMode.DynamicDataSet, + Status = MmsReportSubscriptionPlanStatus.ReadyRequiresWrite, + ReportControl = BuildUrcb(), + DataSetReference = "LD0/LLN0.AR_HYB_01", + DynamicPoints = [BuildPoint()] + }; + + Assert.True(MmsDynamicDataSetProbePolicy.ShouldProbe(plan)); + } + + [Fact] + public void ProbePolicy_BlockedDynamicPlan_IsNotProbed() + { + var plan = new MmsReportSubscriptionPlan + { + Mode = MmsReportSubscriptionPlanMode.DynamicDataSet, + Status = MmsReportSubscriptionPlanStatus.Blocked, + ReportControl = BuildUrcb(), + DataSetReference = "LD0/LLN0.AR_HYB_01", + DynamicPoints = [BuildPoint()] + }; + + Assert.False(MmsDynamicDataSetProbePolicy.ShouldProbe(plan)); + } + + [Fact] + public void SingleMemberDefineRequest_IsDeterministicAndCarriesExactNames() + { + var member = new MmsObjectReference("LD0", "GGIO1$ST$Ind1$stVal", "ST"); + + var first = MmsDefineNamedVariableListRequest.Build( + 17, + "LD0/LLN0.AR_HYB_01", + [member]); + var second = MmsDefineNamedVariableListRequest.Build( + 17, + "LD0/LLN0.AR_HYB_01", + [member]); + + Assert.Equal(first, second); + Assert.NotEmpty(first); + Assert.True(ContainsAscii(first, "LD0")); + Assert.True(ContainsAscii(first, "LLN0$AR_HYB_01")); + Assert.True(ContainsAscii(first, "GGIO1$ST$Ind1$stVal")); + } + + [Theory] + [InlineData(MmsDynamicDataSetProbeFailureStage.DefineNamedVariableList, MmsReportActivationFailureReason.DynamicDataSetProbeDefineFailed)] + [InlineData(MmsDynamicDataSetProbeFailureStage.GetNamedVariableListAttributes, MmsReportActivationFailureReason.DynamicDataSetProbeVerificationFailed)] + [InlineData(MmsDynamicDataSetProbeFailureStage.DeleteNamedVariableList, MmsReportActivationFailureReason.DynamicDataSetProbeDeleteFailed)] + public void ProbeFailureStage_MapsToExactActivationReason( + MmsDynamicDataSetProbeFailureStage stage, + MmsReportActivationFailureReason expected) + { + Assert.Equal(expected, MmsDynamicDataSetProbePolicy.FailureReason(stage)); + } + + private static MmsReportControlCandidate BuildUrcb() + => new() + { + Domain = "LD0", + LogicalNode = "LLN0", + FunctionalConstraint = "RP", + Name = "urcb01", + Reference = "LD0/LLN0.RP.urcb01", + Buffered = false, + EnabledState = "false", + Attributes = ["RptEna", "DatSet", "TrgOps", "OptFlds", "Resv", "GI"] + }; + + private static MmsFcResolvedPoint BuildPoint() + => new() + { + Domain = "LD0", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = "Ind1.stVal", + MmsItemName = "GGIO1$ST$Ind1$stVal" + }; + + private static bool ContainsAscii(byte[] source, string text) + { + var needle = Encoding.ASCII.GetBytes(text); + if (needle.Length == 0 || source.Length < needle.Length) + return false; + + for (var offset = 0; offset <= source.Length - needle.Length; offset++) + { + var match = true; + for (var index = 0; index < needle.Length; index++) + { + if (source[offset + index] == needle[index]) + continue; + match = false; + break; + } + + if (match) + return true; + } + + return false; + } +} From 4df84d4e2a7c824e4115c6182ef5d4dd7872d5ae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 14:56:40 +0700 Subject: [PATCH 4/6] P6.2: guard missing DataSet from probation --- .../Mms/MmsDynamicDataSetServiceProbeTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs index 93382d4..b8ac234 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs @@ -50,6 +50,21 @@ public void ProbePolicy_BlockedDynamicPlan_IsNotProbed() Assert.False(MmsDynamicDataSetProbePolicy.ShouldProbe(plan)); } + [Fact] + public void ProbePolicy_DynamicPlanWithoutDataSetReference_IsNotProbed() + { + var plan = new MmsReportSubscriptionPlan + { + Mode = MmsReportSubscriptionPlanMode.DynamicDataSet, + Status = MmsReportSubscriptionPlanStatus.ReadyRequiresWrite, + ReportControl = BuildUrcb(), + DataSetReference = string.Empty, + DynamicPoints = [BuildPoint()] + }; + + Assert.False(MmsDynamicDataSetProbePolicy.ShouldProbe(plan)); + } + [Fact] public void SingleMemberDefineRequest_IsDeterministicAndCarriesExactNames() { From 918d861fd1b50a804acbdc49a7903d9c9ede7f3d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 15:01:22 +0700 Subject: [PATCH 5/6] P6.2: make probe cleanup evidence fail closed --- src/AR.Iec61850/Mms/MmsDynamicDataSetServiceProbe.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsDynamicDataSetServiceProbe.cs b/src/AR.Iec61850/Mms/MmsDynamicDataSetServiceProbe.cs index b504d5e..132643f 100644 --- a/src/AR.Iec61850/Mms/MmsDynamicDataSetServiceProbe.cs +++ b/src/AR.Iec61850/Mms/MmsDynamicDataSetServiceProbe.cs @@ -49,9 +49,16 @@ public sealed class MmsDynamicDataSetProbeResult public bool DynamicMutationAttempted => DefineEvidence.Attempted; public bool CleanupAttempted => DeleteEvidence.Attempted; - public bool CleanupSucceeded => !CleanupAttempted || DeleteEvidence.IsSuccess; + // If Define succeeded, the temporary NamedVariableList may exist on the server. + // Cleanup is therefore successful only when Delete was actually attempted and accepted. + // A failed Define needs no cleanup because the list was never proven created. + public bool CleanupSucceeded => !DefineEvidence.IsSuccess || (CleanupAttempted && DeleteEvidence.IsSuccess); + // If verification was attempted but Delete could not even be issued, the association + // was lost between Define and cleanup. Do not report that path as association-survived. public bool AssociationSurvived => DefineEvidence.StateAfter == MmsAssociationState.MmsInitiated && - (!DeleteEvidence.Attempted || DeleteEvidence.StateAfter == MmsAssociationState.MmsInitiated); + (DeleteEvidence.Attempted + ? DeleteEvidence.StateAfter == MmsAssociationState.MmsInitiated + : !DirectoryAttempted); public string Summary => IsSuccess ? $"single-member NVL probe succeeded for {DataSetReference}; member={MemberReference}; define/verify/delete all completed on one MMS association." From d4612fb7fd5d499a70df17110f6191c452340e0c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 15:02:01 +0700 Subject: [PATCH 6/6] P6.2: test fail-closed probe cleanup semantics --- .../Mms/MmsDynamicDataSetServiceProbeTests.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs index b8ac234..4410e2c 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetServiceProbeTests.cs @@ -65,6 +65,53 @@ public void ProbePolicy_DynamicPlanWithoutDataSetReference_IsNotProbed() Assert.False(MmsDynamicDataSetProbePolicy.ShouldProbe(plan)); } + [Fact] + public void ProbeResult_DefineFailed_NeedsNoCleanup() + { + var result = new MmsDynamicDataSetProbeResult + { + DirectoryAttempted = false, + DefineEvidence = new MmsDynamicDataSetProbeServiceEvidence + { + Attempted = true, + IsSuccess = false, + StateBefore = MmsAssociationState.MmsInitiated, + StateAfter = MmsAssociationState.MmsInitiateFailed + } + }; + + Assert.False(result.CleanupAttempted); + Assert.True(result.CleanupSucceeded); + Assert.False(result.AssociationSurvived); + } + + [Fact] + public void ProbeResult_DefineSucceededButVerificationLostAssociation_FailsCleanupClosed() + { + var result = new MmsDynamicDataSetProbeResult + { + DirectoryAttempted = true, + DefineEvidence = new MmsDynamicDataSetProbeServiceEvidence + { + Attempted = true, + IsSuccess = true, + StateBefore = MmsAssociationState.MmsInitiated, + StateAfter = MmsAssociationState.MmsInitiated + }, + DeleteEvidence = new MmsDynamicDataSetProbeServiceEvidence + { + Attempted = false, + IsSuccess = false, + StateBefore = MmsAssociationState.MmsInitiateFailed, + StateAfter = MmsAssociationState.MmsInitiateFailed + } + }; + + Assert.False(result.CleanupAttempted); + Assert.False(result.CleanupSucceeded); + Assert.False(result.AssociationSurvived); + } + [Fact] public void SingleMemberDefineRequest_IsDeterministicAndCarriesExactNames() {