From da00c2f8168a6ff9ffa7a00fbe93eeff80101764 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:01:27 +0700 Subject: [PATCH 01/19] fix(connect): classify bounded transient retry for initial IEC 61850 discovery association --- Services/SmartReconnectPolicy.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Services/SmartReconnectPolicy.cs b/Services/SmartReconnectPolicy.cs index bc410baea..64cf82b9a 100644 --- a/Services/SmartReconnectPolicy.cs +++ b/Services/SmartReconnectPolicy.cs @@ -11,6 +11,14 @@ public static class SmartReconnectPolicy public static TimeSpan ClientCleanupBudget => TimeSpan.FromMilliseconds(500); public static TimeSpan ConnectBudget => TimeSpan.FromSeconds(2); public static TimeSpan InitialAssociationRetryDelay => TimeSpan.FromMilliseconds(500); + + // First full discovery had no retry, while a cached-model connection already + // had a bounded transient retry. Never replay discovery or any RCB operation: + // this policy only permits one extra attempt to establish TCP/ACSE/MMS. + public const int InitialDiscoveryAssociationMaxAttempts = 2; + + public static bool CanRetryInitialDiscoveryAssociation(string? failureKind) + => failureKind is "TCP_TIMEOUT" or "TCP_TRANSPORT_CLOSED" or "TCP_CONNECTION_REFUSED"; public static TimeSpan ReportRearmDelay => TimeSpan.FromSeconds(1); public static TimeSpan ReportRearmDeadline => TimeSpan.FromSeconds(3); public static TimeSpan RecoveryWarmupDuration => TimeSpan.FromSeconds(3); From 5760d089bdd5fd03e7ada3c48cc92ab62167e126 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:02:26 +0700 Subject: [PATCH 02/19] fix(connect): bounded retry after transient initial association failure with fresh client and evidence --- Services/Iec61850MonitorRuntime.cs | 62 +++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 4888f59eb..9786586b2 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -567,7 +567,8 @@ public async Task> ConnectAndDiscoverAsync( try { progress?.Report(new IedDiscoveryProgress(IedDiscoveryStage.OpeningTcp, $"Opening TCP {device.IpAddress}:{device.Port}…", 8d, 2, 15)); - await session.Client.ConnectAsync(device.IpAddress, device.Port, cancellationToken).ConfigureAwait(false); + await ConnectInitialDiscoveryAssociationWithRetryAsync( + session, cancellationToken).ConfigureAwait(false); if (!session.Client.IsConnected) { device.Status = "Connection failed"; @@ -3036,6 +3037,65 @@ private void EmitStatusSnapshot( }); } + /// + /// Only initial MMS association is retried, never discovery, RCB writes, + /// or controls. Denied ACSE/MMS associations and unknown errors fail closed. + /// + private async Task ConnectInitialDiscoveryAssociationWithRetryAsync( + DeviceSession session, + CancellationToken cancellationToken) + { + for (var attempt = 1; + attempt <= SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts; + attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var started = Stopwatch.GetTimestamp(); + await session.Client.ConnectAsync( + session.Device.IpAddress, + session.Device.Port, + cancellationToken).ConfigureAwait(false); + var elapsed = Stopwatch.GetElapsedTime(started); + + if (session.Client.IsConnected) + { + if (attempt > 1) + Log("INFO", session.Device.Name, + $"Initial MMS connection recovered on attempt #{attempt} in {elapsed.TotalMilliseconds:0} ms; " + + "full discovery will run once on this association."); + return; + } + + var kind = session.Client.LastConnectionFailureKind; + var description = string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) + ? $"native state={session.Client.NativeState}" + : session.Client.LastErrorMessage; + session.Device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + $"Initial MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms"); + + var willRetry = attempt < SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts && + SmartReconnectPolicy.CanRetryInitialDiscoveryAssociation(kind); + Log("WARN", session.Device.Name, + $"Initial MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms " + + $"(kind={kind}, retry={willRetry}). {description}"); + + if (!willRetry) + return; + + // Never inherit failed ACSE/MMS state across attempt boundaries. + var failedClient = session.Client; + session.Client = new NativeIec61850Client(); + await DisposeClientForReconnectAsync( + failedClient, + session.Device.Name, + SmartReconnectPolicy.ClientCleanupBudget, + cancellationToken).ConfigureAwait(false); + await Task.Delay( + SmartReconnectPolicy.InitialAssociationRetryDelay, + cancellationToken).ConfigureAwait(false); + } + } + private async Task ConnectCachedAssociationWithRetryAsync( DeviceSession session, CancellationToken cancellationToken) From 97041f4389a7babbbc800a1d461675703f97363f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:02:52 +0700 Subject: [PATCH 03/19] fix(ui): render exact MMS two-bit status as meaningful significant bits without guessing its semantics --- Services/Iec61850ValueFormatter.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Services/Iec61850ValueFormatter.cs b/Services/Iec61850ValueFormatter.cs index 2c1646b60..8a1b5071a 100644 --- a/Services/Iec61850ValueFormatter.cs +++ b/Services/Iec61850ValueFormatter.cs @@ -86,6 +86,15 @@ public static string FormatReportProcessValue( return boolean ? "True [1]" : "False [0]"; } + // MMS two-bit BIT STRING is not necessarily a DPC position: the same + // wire representation is also used for vendor-specific status leaves + // (e.g. USER2.LOCREM). Render only the two significant bits, never + // expose 'unused=6' as an operator value or invent Local/Remote. + // The position-only branch above retains the established DPC labels. + if (normalizedValue is string wireBits && + TryNormalizeTwoBitWireStatus(wireBits, out var code)) + return $"Bits [{Convert.ToString(code, 2).PadLeft(2, '0')}]"; + return FormatNormalizedValue(normalizedValue, dataType, unit); } From 98a507b0af72cae191a9d867b5d77b8e8d05419f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:03:22 +0700 Subject: [PATCH 04/19] test(P8): regression for two-bit MMS presentation and safe first-connect retry --- .../Iec61850ValueFormatterTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs index bb3308120..ecb90aec6 100644 --- a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs +++ b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs @@ -83,6 +83,37 @@ public void FormatReportProcessValue_LeavesNumericMeasurementUnchanged() Assert.Equal("123.45 V", formatted); } + [Theory] + [InlineData("bits(80, unused=6)", "Bits [10]")] + [InlineData("bits(40, unused=6)", "Bits [01]")] + [InlineData("bits(00, unused=6)", "Bits [00]")] + [InlineData("bits(C0, unused=6)", "Bits [11]")] + public void ReportTwoBitStatus_ShowsActualSignificantBits_WithoutInventingLocalRemote( + string wireValue, string expected) + { + Assert.Equal(expected, Iec61850ValueFormatter.FormatReportProcessValue( + wireValue, "BitString", "", "Status", "SIEBCUUD1/USER2.LOCREM.stVal")); + } + + [Fact] + public void TwoBitStatus_IsOnlyDpcWhenExplicitPositionSemanticsArePresent() + { + Assert.Equal("Close [10]", Iec61850ValueFormatter.FormatReportProcessValue( + "bits(80, unused=6)", "DPC", "", "Position", "SIEBCUCB3/CSWI1.Pos.stVal")); + Assert.Equal("Bits [10]", Iec61850ValueFormatter.FormatReportProcessValue( + "bits(80, unused=6)", "Unknown", "", "Status", "SIEBCUUD1/USER2.LOCREM.stVal")); + } + + [Theory] + [InlineData("bits(81, unused=6)")] + [InlineData("bits(80, unused=5)")] + [InlineData("bits(FFFF, unused=6)")] + public void MalformedWireBitString_RemainsUnmodifiedAndCannotFabricateStatus(string raw) + { + Assert.Equal(raw, Iec61850ValueFormatter.FormatReportProcessValue( + raw, "BitString", "", "Status", "IED/GGIO1.VendorState.stVal")); + } + [Fact] public void Format_Extracts_Boolean_StVal_From_Legacy_Report_Structure() { From e9cd9e5e90532f63e0752e7cb055c83b32767f15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:03:26 +0700 Subject: [PATCH 05/19] test(P8): regression for two-bit MMS presentation and safe first-connect retry --- .../P62CSmartReconnectRegressionTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs b/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs index 11e1c1847..2531d4a69 100644 --- a/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs +++ b/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs @@ -15,6 +15,46 @@ public void RetryBackoff_IsBoundedAndDeterministic(int failureCount, int expecte TimeSpan.FromMilliseconds(expectedMilliseconds), SmartReconnectPolicy.GetRetryDelay(failureCount)); + [Theory] + [InlineData("TCP_TIMEOUT", true)] + [InlineData("TCP_TRANSPORT_CLOSED", true)] + [InlineData("TCP_CONNECTION_REFUSED", true)] + [InlineData("NETWORK_UNREACHABLE", false)] + [InlineData("ACSE_MMS_ASSOCIATION_REJECTED", false)] + [InlineData("IEC61850_CONNECTION_FAILED", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void InitialDiscovery_RetriesOnlyKnownTransientTransportFailures( + string? failureKind, bool expected) + { + Assert.Equal(expected, SmartReconnectPolicy.CanRetryInitialDiscoveryAssociation(failureKind)); + Assert.Equal(2, SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts); + } + + [Fact] + public void InitialDiscoveryTransportRetry_CannotTriggerDuplicateDiscoveryOrRCBWrite() + { + // The helper belongs exclusively to the association stage before + // discovering directories or attempting any RCB activation. + var runtime = ReadRepoFile("Services/Iec61850MonitorRuntime.cs"); + var connect = runtime.IndexOf("await ConnectInitialDiscoveryAssociationWithRetryAsync(", + StringComparison.Ordinal); + var discovery = runtime.IndexOf("await session.Client.DiscoverSignalsAsync(", + StringComparison.Ordinal); + var helper = runtime.IndexOf("private async Task ConnectInitialDiscoveryAssociationWithRetryAsync(", + StringComparison.Ordinal); + var nextHelper = runtime.IndexOf("private async Task ConnectCachedAssociationWithRetryAsync(", + StringComparison.Ordinal); + Assert.True(connect >= 0 && discovery > connect && helper > 0 && nextHelper > helper); + var body = runtime[helper..nextHelper]; + Assert.DoesNotContain("DiscoverSignalsAsync(", body, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReport", body, StringComparison.Ordinal); + Assert.DoesNotContain("WriteReport", body, StringComparison.Ordinal); + Assert.Contains("new NativeIec61850Client()", body, StringComparison.Ordinal); + Assert.Contains("InitialAssociationRetryDelay", body, StringComparison.Ordinal); + Assert.Contains("cancellationToken.ThrowIfCancellationRequested()", body, StringComparison.Ordinal); + } + [Fact] public void RecoveryWarmup_RemainsResponsiveForCommissioningReconnect() { From c99791c52b2fc1bd4206ad42eed4f62e6bc187b8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:05:20 +0700 Subject: [PATCH 06/19] fix(connect): distinguish internal association cancellation from user cancellation in bounded first-connect recovery --- Services/Iec61850MonitorRuntime.cs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 9786586b2..da3bb2b87 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -3051,10 +3051,20 @@ private async Task ConnectInitialDiscoveryAssociationWithRetryAsync( { cancellationToken.ThrowIfCancellationRequested(); var started = Stopwatch.GetTimestamp(); - await session.Client.ConnectAsync( - session.Device.IpAddress, - session.Device.Port, - cancellationToken).ConfigureAwait(false); + var nativeAttemptCancelled = false; + try + { + await session.Client.ConnectAsync( + session.Device.IpAddress, + session.Device.Port, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Only an internal/native deadline cancelled: operator shutdown or + // explicit external cancellation still propagates immediately. + nativeAttemptCancelled = true; + } var elapsed = Stopwatch.GetElapsedTime(started); if (session.Client.IsConnected) @@ -3066,8 +3076,12 @@ await session.Client.ConnectAsync( return; } - var kind = session.Client.LastConnectionFailureKind; - var description = string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) + var kind = nativeAttemptCancelled + ? "NATIVE_ASSOCIATION_CANCELLED" + : session.Client.LastConnectionFailureKind; + var description = nativeAttemptCancelled + ? "Native association attempt was cancelled before completion while the application cancellation token remained active." + : string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) ? $"native state={session.Client.NativeState}" : session.Client.LastErrorMessage; session.Device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( From 6306d057dd3dc0b74b0dfdb14c339809265a585e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:05:32 +0700 Subject: [PATCH 07/19] test(connect): bounded native deadline retry remains distinct from user cancellation --- Services/SmartReconnectPolicy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/SmartReconnectPolicy.cs b/Services/SmartReconnectPolicy.cs index 64cf82b9a..308acae0b 100644 --- a/Services/SmartReconnectPolicy.cs +++ b/Services/SmartReconnectPolicy.cs @@ -18,7 +18,7 @@ public static class SmartReconnectPolicy public const int InitialDiscoveryAssociationMaxAttempts = 2; public static bool CanRetryInitialDiscoveryAssociation(string? failureKind) - => failureKind is "TCP_TIMEOUT" or "TCP_TRANSPORT_CLOSED" or "TCP_CONNECTION_REFUSED"; + => failureKind is "TCP_TIMEOUT" or "TCP_TRANSPORT_CLOSED" or "TCP_CONNECTION_REFUSED" or "NATIVE_ASSOCIATION_CANCELLED"; public static TimeSpan ReportRearmDelay => TimeSpan.FromSeconds(1); public static TimeSpan ReportRearmDeadline => TimeSpan.FromSeconds(3); public static TimeSpan RecoveryWarmupDuration => TimeSpan.FromSeconds(3); From 61e3c10338b2aad66be79cb93d06c68b1b07dbdf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:05:35 +0700 Subject: [PATCH 08/19] test(connect): bounded native deadline retry remains distinct from user cancellation --- tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs b/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs index 2531d4a69..f123d76fb 100644 --- a/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs +++ b/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs @@ -19,6 +19,7 @@ public void RetryBackoff_IsBoundedAndDeterministic(int failureCount, int expecte [InlineData("TCP_TIMEOUT", true)] [InlineData("TCP_TRANSPORT_CLOSED", true)] [InlineData("TCP_CONNECTION_REFUSED", true)] + [InlineData("NATIVE_ASSOCIATION_CANCELLED", true)] [InlineData("NETWORK_UNREACHABLE", false)] [InlineData("ACSE_MMS_ASSOCIATION_REJECTED", false)] [InlineData("IEC61850_CONNECTION_FAILED", false)] From 0d4e38923d2e0c0b7a1396d0c168214c85a825a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:07:25 +0700 Subject: [PATCH 09/19] fix(diagnostics): use exact per-RCB frame proof to prevent false no-report warning after valid ingress --- MainWindow.IoTesting.AutoConnect.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index 915ce49f1..c61e4a778 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -524,23 +524,32 @@ await Task.Delay(remaining, _applicationCancellation.Token) // This observer never drives MMS IO, GI or a cyclic fallback. LogStaticRcbImageTriage(device); - if (trafficObserved && device.HasReportStream) + // The per-RCB decoder-backed stream evidence is authoritative. + // The UI pulse can be false (e.g. event lost at monitor startup) + // even after all seven RCBs delivered genuine InformationReports. + var exactRcbRows = ArIED61850Tester.Services.StaticDataSetRcbImageTriage.Evaluate( + device.Points, device.ReportContinuityEvidence, maxGroups: 16); + var routedRcbs = exactRcbRows.Count(row => row.HasActualReportFrames); + if (routedRcbs > 0) { var reportPoints = device.Points.Count(point => IsReportSource(point.SourceMode) && !point.SourceMode.Contains("pending", StringComparison.OrdinalIgnoreCase)); - var firstReportLatency = DateTime.UtcNow - startedUtc; AddLog( "INFO", device.Name, - $"Static DataSet report evidence: actual InformationReport traffic observed after {firstReportLatency.TotalMilliseconds:0} ms; report-backed runtime point(s)={reportPoints}. At least one RCB delivered a frame; each other RCB remains independently unverified until its actual ingress is observed."); + $"Static DataSet report evidence: actual InformationReport traffic observed within {proofWindow.TotalSeconds:0.#} s; " + + $"exact routed RCBs={routedRcbs}/{exactRcbRows.Count}; report-backed runtime point(s)={reportPoints}. " + + "Each RCB remains independently verified by its own frames; zero-frame peers are not assumed healthy."); return; } AddLog( "WARN", device.Name, - $"Static DataSet report evidence: configured RCB setup/GI was requested, but no InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s. Activation remains armed/report-pending; absence of traffic alone is not treated as RCB activation failure and ARSAS will NOT switch process values to cyclic MMS polling."); + $"Static DataSet report evidence: no exact routed RCB InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s " + + $"(uiPulse={trafficObserved && device.HasReportStream}, configuredGroups={exactRcbRows.Count}). " + + "Activation remains armed/report-pending; lack of exact routed ingress is not proof of IED silence and ARSAS will NOT switch process values to cyclic MMS polling."); MarkDiagnosticAlert(); } catch (OperationCanceledException) From 6b321459fe9b7c1a4ef23be7ae59e4e645ed53de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:07:52 +0700 Subject: [PATCH 10/19] test(ui): distinguish raw two-bit process values and exact RCB frame evidence --- .../StaticDataSetReportOnlyModeRegressionTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index bb56d251e..e19318344 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -194,7 +194,11 @@ public void SharedStaticFat_EmitsCausalReportEvidenceWithoutMmsFallback() Assert.Contains("LogStaticRcbImageTriage(device);", source, StringComparison.Ordinal); Assert.Contains("waiting for actual InformationReport traffic", source, StringComparison.Ordinal); Assert.Contains("actual InformationReport traffic observed", source, StringComparison.Ordinal); - Assert.Contains("no InformationReport traffic was observed", source, StringComparison.Ordinal); + Assert.Contains("no exact routed RCB InformationReport traffic was observed", source, StringComparison.Ordinal); + Assert.Contains("var exactRcbRows = ArIED61850Tester.Services.StaticDataSetRcbImageTriage.Evaluate(", source, StringComparison.Ordinal); + Assert.Contains("var routedRcbs = exactRcbRows.Count(row => row.HasActualReportFrames)", source, StringComparison.Ordinal); + Assert.Contains("if (routedRcbs > 0)", source, StringComparison.Ordinal); + Assert.DoesNotContain("if (trafficObserved && device.HasReportStream)", source, StringComparison.Ordinal); Assert.Contains("will NOT switch process values to cyclic MMS polling", source, StringComparison.Ordinal); } From bf9a5004446d9954d355cd697a207453294c29ca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:07:56 +0700 Subject: [PATCH 11/19] test(ui): distinguish raw two-bit process values and exact RCB frame evidence --- Models/Iec61850ValueStatePresentation.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Models/Iec61850ValueStatePresentation.cs b/Models/Iec61850ValueStatePresentation.cs index 012cf4842..4355c0d96 100644 --- a/Models/Iec61850ValueStatePresentation.cs +++ b/Models/Iec61850ValueStatePresentation.cs @@ -96,6 +96,9 @@ public static string TypeToken(string? dataType) return "F"; } + if (normalized is "BITSTRING" or "BITS") + return "BIT"; + if (normalized.StartsWith("UINT", StringComparison.Ordinal) || normalized is "INT8U" or "INT16U" or "INT24U" or "INT32U" or "INT64U") { From 9e1564ed4577fe2fdb74263bf116887be2ce5e51 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:08:14 +0700 Subject: [PATCH 12/19] test(ui): preserve metadata-only BIT badge for true MMS bit-string leaves --- tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs b/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs index bba5c4fea..7901ab723 100644 --- a/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs +++ b/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs @@ -74,6 +74,8 @@ public void VisualKind_DoesNotTurnGenericEnumOffIntoPosition() [InlineData("Enum", "E")] [InlineData("Dbpos", "DP")] [InlineData("DPC", "DP")] + [InlineData("BitString", "BIT")] + [InlineData("BITS", "BIT")] [InlineData("DPS", "DP")] [InlineData("Counter", "")] [InlineData("Unknown", "")] From 91f52ad41256f10196c46566ee8e55bce721dc58 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:08:49 +0700 Subject: [PATCH 13/19] docs(P8): record SIEBCU physical evidence, bounded transport retry and non-invented bit-string display --- ...EBCU_FIRST_CONNECT_BITS_REPORT_20261105.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/audits/P8_SIEBCU_FIRST_CONNECT_BITS_REPORT_20261105.md diff --git a/docs/audits/P8_SIEBCU_FIRST_CONNECT_BITS_REPORT_20261105.md b/docs/audits/P8_SIEBCU_FIRST_CONNECT_BITS_REPORT_20261105.md new file mode 100644 index 000000000..a89b256b6 --- /dev/null +++ b/docs/audits/P8_SIEBCU_FIRST_CONNECT_BITS_REPORT_20261105.md @@ -0,0 +1,19 @@ +# P8 SIEBCU first-connect / BIT STRING / false reporting warning audit (2026-11-05) + +## Operator evidence (P8 trial, SHA 37165a0c / engine ae1ce23d) + +Diagnostics generated at 10:53:49 WIB (2026-11-05) for SIEBCU 192.16.1.192:102. Log starts association at 10:53:25.055 and again at 10:53:35.128, with MMS initiated at 10:53:35.182. This establishes two attempts, **not the cause of the first failure**; the retained log does not include a first-attempt exception/rejection. The accepted route is SAS-Port 3 with 192.16.1.92/24, same subnet. The successful smart discovery took ~1756 ms for 211 requests. The trial received 14 Report frames across seven exactly routed RCBs, with seven visible selected values. + +At 10:53:42.741, an observer still logged "no InformationReport traffic" despite decoder-based per-RCB evidence showing 7/7 streams with frames at 10:53:41. This is an application diagnostic contradiction, not a proof of wire SOE loss. + +The UI displayed `USER2.LOCREM.stVal` as `bits(80, unused=6)`. This raw MMS renderer means one byte 0x80 with six unused padding bits, therefore two significant bits `10`. It does **not** establish a vendor-specific Local/Remote label nor general DPC/Close semantics. + +## Source-supported defects and changes + +1. Full discovery started its MMS association only once; the cached reconnect route already had a bounded retry. Initial discovery now retries at most once **only** after TCP timeout, connection refused, transport closure, or internal native association cancellation with the parent request still active. It uses a new native client, observes a 500 ms backoff, preserves failure-kind and elapsed-attempt logs, and performs discovery only after the successful association. Explicit cancellation, ACSE rejection, unknown failure and unreachable-network errors do not provoke hidden retries. No reporting/RCB write or control is triggered by retry. +2. The report value formatter already understood valid `bits(HH, unused=6)` but used the decoding only for DPC/position. Exact two-bit non-position values now show `Bits [10]`, without a guessed semantic label; DPC positions retain `Close [10]`. Malformed padding and non-two-bit representations remain unmodified. The optional `BIT` badge is based only on an explicit BitString data-type declaration. +3. The three-second static-report observer used `trafficObserved && HasReportStream` to decide whether to log "no traffic", even when exact per-RCB continuity evidence contained real frames. Now it examines exact routed frame evidence for each RCB in the current association before deciding the verdict; zero-frame peers remain independently unverified. It performs no MMS I/O. + +## Safety and acceptance + +Changes are scoped to the existing P8 trial branch consumer; ARSAS main, integrated RC, stable v1.6.42, pinned engine baseline, and ARIEC staging/P9 remain unchanged. This change does not prove the first attempt's physical cause, instant first-attempt success, uninterrupted SOE, or SG7 77/77, 4/4 recovery. Validate exact-head CI, then create a separately identifiable Windows nonrelease trial before field retest. Retain raw MMS typed evidence alongside operator display. From 377bda2ba86ea2e0b4087454523a05a28309c1de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:12:27 +0700 Subject: [PATCH 14/19] fix(ui): never paint generic BIT STRING 10 as DPC closed/active; add neutral tone regressions --- Models/Iec61850ValueStatePresentation.cs | 5 +++++ .../ProcessValueStatePresentationTests.cs | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/Models/Iec61850ValueStatePresentation.cs b/Models/Iec61850ValueStatePresentation.cs index 4355c0d96..0584b4874 100644 --- a/Models/Iec61850ValueStatePresentation.cs +++ b/Models/Iec61850ValueStatePresentation.cs @@ -25,6 +25,11 @@ public static string Classify(string? value, string? dataType = null) var normalized = text.ToLowerInvariant(); + // A raw typed BIT STRING's bits are data, not a DPC/Boolean assertion. + // In particular Bits [10] must not inherit the DPC Closed/Active tone. + if (normalized.StartsWith("bits [", StringComparison.Ordinal)) + return Neutral; + // DPC state codes are process states, not health/severity. Intermediate and // bad are the only forms that deserve the amber attention tone. if (normalized.Contains("intermediate", StringComparison.Ordinal) || diff --git a/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs b/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs index 7901ab723..99c23cd90 100644 --- a/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs +++ b/tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs @@ -18,6 +18,19 @@ public sealed class ProcessValueStatePresentationTests public void DiscreteStates_AreClassifiedWithoutSeveritySemantics(string value, string dataType, string expected) => Assert.Equal(expected, Iec61850ValueStatePresentation.Classify(value, dataType)); + [Theory] + [InlineData("Bits [10]", "BitString")] + [InlineData("Bits [11]", "BitString")] + [InlineData("Bits [00]", "Unknown")] + public void RawTwoBitNonDpc_NeverAcquiresPositionTone(string value, string dataType) + { + Assert.Equal(Iec61850ValueStatePresentation.Neutral, + Iec61850ValueStatePresentation.Classify(value, dataType)); + Assert.Equal(Iec61850ValueStatePresentation.Neutral, + Iec61850ValueStatePresentation.ClassifyVisualKind( + value, dataType, "Status", "SIEBCUUD1/USER2.LOCREM.stVal")); + } + [Theory] [InlineData("1", "Boolean", Iec61850ValueStatePresentation.Active)] [InlineData("0", "Boolean", Iec61850ValueStatePresentation.Inactive)] From 240716f335c80210c01ad518be72ddc6b7a2600c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:21:02 +0700 Subject: [PATCH 15/19] fix(P8): decode two-bit display only for exact status stVal; keep raw measurement bits unchanged --- Services/Iec61850ValueFormatter.cs | 322 +++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) diff --git a/Services/Iec61850ValueFormatter.cs b/Services/Iec61850ValueFormatter.cs index 8a1b5071a..0194a3b19 100644 --- a/Services/Iec61850ValueFormatter.cs +++ b/Services/Iec61850ValueFormatter.cs @@ -91,7 +91,329 @@ public static string FormatReportProcessValue( // (e.g. USER2.LOCREM). Render only the two significant bits, never // expose 'unused=6' as an operator value or invent Local/Remote. // The position-only branch above retains the established DPC labels. + // Only the exact stVal status leaf qualifies for generic operator + // presentation. Raw Measurement/quality/substitution bit strings retain + // their original evidence shape, even when they happen to be two bits. if (normalizedValue is string wireBits && + (category ?? string.Empty).Trim().Equals("Status", StringComparison.OrdinalIgnoreCase) && + (reference ?? string.Empty).Trim().Replace(' + + return FormatNormalizedValue(normalizedValue, dataType, unit); + } + + public static bool IsBscStepPosition(string? reference, string? dataType) + { + var path = (reference ?? string.Empty).Trim().Replace('$', '.'); + return path.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase) || + (string.Equals(dataType?.Trim(), "BSC", StringComparison.OrdinalIgnoreCase) && + path.EndsWith(".posVal", StringComparison.OrdinalIgnoreCase)); + } + + public static string FormatBscStepPosition(object? value, string? unit = null) + { + string? number = value switch + { + sbyte x when x is >= -64 and <= 63 => x.ToString(CultureInfo.InvariantCulture), + byte x when x <= 63 => x.ToString(CultureInfo.InvariantCulture), + short x when x is >= -64 and <= 63 => x.ToString(CultureInfo.InvariantCulture), + ushort x when x <= 63 => x.ToString(CultureInfo.InvariantCulture), + int x when x is >= -64 and <= 63 => x.ToString(CultureInfo.InvariantCulture), + uint x when x <= 63 => x.ToString(CultureInfo.InvariantCulture), + long x when x is >= -64 and <= 63 => x.ToString(CultureInfo.InvariantCulture), + ulong x when x <= 63 => x.ToString(CultureInfo.InvariantCulture), + string text when sbyte.TryParse(text.Trim(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var step) && + step is >= -64 and <= 63 => step.ToString(CultureInfo.InvariantCulture), + _ => null + }; + if (number != null) + return AppendUnit(number, unit ?? string.Empty); + // Never make up a step number from a raw Boolean shape mismatch. + return value switch + { + null => "-", + bool boolean => boolean + ? "true (BSC INT8 type mismatch)" + : "false (BSC INT8 type mismatch)", + _ => value.ToString() ?? "-" + }; + } + + private static bool IsPositionSemantic(string dataType, string category, string reference) + { + if (IsDbposDataType(dataType)) + return true; + + if ((category ?? string.Empty).Trim().Equals("Position", StringComparison.OrdinalIgnoreCase)) + return true; + + var normalizedReference = (reference ?? string.Empty) + .Trim() + .Replace((char)36, '.') + .ToLowerInvariant(); + + return normalizedReference.Contains(".pos.stval", StringComparison.Ordinal) || + normalizedReference.EndsWith(".pos", StringComparison.Ordinal); + } + + private static bool TryNormalizeBoolean(object? value, out bool boolean) + { + switch (value) + { + case bool typed: + boolean = typed; + return true; + case string text when bool.TryParse(text.Trim(), out var parsed): + boolean = parsed; + return true; + default: + boolean = false; + return false; + } + } + + private static bool IsBooleanDataType(string? dataType) + { + var normalized = (dataType ?? string.Empty) + .Trim() + .Replace("_", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal) + .ToUpperInvariant(); + + return normalized is "BOOL" or "BOOLEAN" or "SPS" or "SPC" or "SINGLEPOINTSTATUS"; + } + + private static bool TryNormalizeBooleanCode(object? value, out bool boolean) + { + switch (value) + { + case byte b when b <= 1: + boolean = b == 1; + return true; + case sbyte b when b is 0 or 1: + boolean = b == 1; + return true; + case short s when s is 0 or 1: + boolean = s == 1; + return true; + case ushort s when s <= 1: + boolean = s == 1; + return true; + case int i when i is 0 or 1: + boolean = i == 1; + return true; + case uint i when i <= 1: + boolean = i == 1; + return true; + case long l when l is 0 or 1: + boolean = l == 1; + return true; + case ulong l when l <= 1: + boolean = l == 1; + return true; + case string text when text.Trim() is "0" or "1": + boolean = text.Trim() == "1"; + return true; + default: + boolean = false; + return false; + } + } + + public static string FormatOperatorDbpos(int code) => code switch + { + 0 => "Intermediate [00]", + 1 => "Open [01]", + 2 => "Close [10]", + 3 => "Bad state [11]", + _ => code.ToString(CultureInfo.InvariantCulture) + }; + + public static bool TryNormalizeDbpos(object? value, out int code) + { + code = 0; + switch (value) + { + case byte b when b <= 3: code = b; return true; + case sbyte b when b is >= 0 and <= 3: code = b; return true; + case short s when s is >= 0 and <= 3: code = s; return true; + case ushort s when s <= 3: code = s; return true; + case int i when i is >= 0 and <= 3: code = i; return true; + case uint i when i <= 3: code = (int)i; return true; + case long l when l is >= 0 and <= 3: code = (int)l; return true; + case ulong l when l <= 3: code = (int)l; return true; + case bool b: code = b ? 2 : 1; return true; + case string text: return TryParseDbposText(text, out code); + default: return false; + } + } + + private static bool IsDbposDataType(string dataType) + => dataType.Equals("Dbpos", StringComparison.OrdinalIgnoreCase) || + dataType.Equals("DPC", StringComparison.OrdinalIgnoreCase) || + dataType.Equals("DPS", StringComparison.OrdinalIgnoreCase) || + dataType.Equals("DoublePointStatus", StringComparison.OrdinalIgnoreCase); + + private static bool IsNumericDataType(string dataType) + { + var normalized = (dataType ?? string.Empty).Trim().ToLowerInvariant(); + return normalized.Contains("int", StringComparison.Ordinal) || + normalized.Contains("uint", StringComparison.Ordinal) || + normalized.Contains("float", StringComparison.Ordinal) || + normalized.Contains("double", StringComparison.Ordinal) || + normalized.Contains("decimal", StringComparison.Ordinal) || + normalized.Contains("counter", StringComparison.Ordinal) || + normalized.Contains("bcr", StringComparison.Ordinal); + } + + /// + /// An actual two-bit MMS BIT STRING renders as one octet with exactly + /// six unused low bits. Only this wire-proven representation can rescue a + /// missing CDC type, and ONLY with an exact model ST.stVal/q declaration. + /// Reject trailing text, wrong bit length and nonzero padding. Raw evidence + /// is never changed; this function is presentation-only. + /// + public static bool TryNormalizeTwoBitWireStatus(string? text, out int code) + { + code = 0; + // Raw IED presentation can be malformed or adversarial. The canonical + // two-bit renderer is a few dozen characters, so bound regex work. + if (string.IsNullOrWhiteSpace(text) || text.Length > 64) return false; + var rendered = Regex.Match( + text, + @"^\s*bits\(\s*(?:0x)?([0-9a-f]{2})\s*,\s*unused\s*=\s*6\s*\)\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!rendered.Success || + !byte.TryParse(rendered.Groups[1].Value, NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var octet) || + (octet & 0x3f) != 0) + return false; + code = octet >> 6; + return true; + } + + private static bool TryParseDbposText(string text, out int code) + { + code = 0; + if (string.IsNullOrWhiteSpace(text)) return false; + + var bracketCode = Regex.Match(text, @"\[(00|01|10|11)\]", RegexOptions.CultureInvariant); + if (bracketCode.Success) + return TryParseBits(bracketCode.Groups[1].Value, out code); + + if (TryNormalizeTwoBitWireStatus(text, out code)) + return true; + + var compact = text.Trim().Replace(" ", "").Replace("_", "").Replace("-", "").ToLowerInvariant(); + switch (compact) + { + case "0": + case "00": + case "intermediate": + case "intermediatestate": code = 0; return true; + case "1": + case "01": + case "open": + case "off": code = 1; return true; + case "2": + case "10": + case "closed": + case "close": + case "on": code = 2; return true; + case "3": + case "11": + case "bad": + case "badstate": + case "invalid": code = 3; return true; + default: return false; + } + } + + private static bool TryExtractStructuredScalar(object? value, string dataType, out object? scalar) + { + scalar = null; + if (value is not string text || string.IsNullOrWhiteSpace(text)) + return false; + + var stVal = Regex.Match( + text, + @"^\s*Structure\(\s*\d+\s*\)\s*\{\s*stVal\s*=\s*([^,}]+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (stVal.Success) + return TryParseRenderedScalar(stVal.Groups[1].Value, out scalar); + + if (!IsNumericDataType(dataType)) + return false; + + var indexed = Regex.Match( + text, + @"^\s*Structure\(\s*\d+\s*\)\s*\{\s*\[0\]\s*=\s*([^,}]+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return indexed.Success && TryParseRenderedScalar(indexed.Groups[1].Value, out scalar); + } + + private static bool TryParseRenderedScalar(string raw, out object? scalar) + { + scalar = null; + var text = raw.Trim(); + if (bool.TryParse(text, out var boolean)) + { + scalar = boolean; + return true; + } + + if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer)) + { + scalar = integer; + return true; + } + + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var floating)) + { + scalar = floating; + return true; + } + + // Keep bit-string text intact so Dbpos decoding can interpret it safely. + if (text.StartsWith("bits(", StringComparison.OrdinalIgnoreCase)) + { + scalar = text; + return true; + } + + return false; + } + + private static bool TryParseInvariantNumber(string text, out string formatted) + { + formatted = string.Empty; + if (string.IsNullOrWhiteSpace(text)) + return false; + if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + return false; + formatted = number.ToString("0.######", CultureInfo.InvariantCulture); + return true; + } + + private static bool TryParseBits(string bits, out int code) + { + code = bits switch + { + "00" => 0, + "01" => 1, + "10" => 2, + "11" => 3, + _ => -1 + }; + return code >= 0; + } + + private static string AppendUnit(string value, string unit) + => string.IsNullOrWhiteSpace(unit) ? value : $"{value} {unit}"; +} +, '.') + .EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) && TryNormalizeTwoBitWireStatus(wireBits, out var code)) return $"Bits [{Convert.ToString(code, 2).PadLeft(2, '0')}]"; From fe265e92f95e9f3c81b3dd338b9e05072dbe6feb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:21:16 +0700 Subject: [PATCH 16/19] test(P72): assert per-RCB ingress evidence instead of obsolete UI first-report timer --- .../ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs b/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs index 54c59134c..32386b150 100644 --- a/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs +++ b/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs @@ -55,7 +55,12 @@ public void StaticTrafficProof_WaitsForPropertyChangeInsteadOfFixedSleep() Assert.Contains("WaitForReportStreamAsync", source, StringComparison.Ordinal); Assert.Contains("device.PropertyChanged += handler;", source, StringComparison.Ordinal); Assert.Contains(".WaitAsync(timeout, cancellationToken)", source, StringComparison.Ordinal); - Assert.Contains("firstReportLatency.TotalMilliseconds", source, StringComparison.Ordinal); + // A timed UI pulse cannot disprove actual decoder-backed RCB frames. + // This was the false-negative cause in the physical SIEBCU trial. + Assert.Contains("StaticDataSetRcbImageTriage.Evaluate(", source, StringComparison.Ordinal); + Assert.Contains("row.HasActualReportFrames", source, StringComparison.Ordinal); + Assert.Contains("if (routedRcbs > 0)", source, StringComparison.Ordinal); + Assert.DoesNotContain("if (trafficObserved && device.HasReportStream)", source, StringComparison.Ordinal); Assert.DoesNotContain( "await Task.Delay(proofWindow", source, From 79789491c1d290aac819a301a578cb29187755ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:21:31 +0700 Subject: [PATCH 17/19] test(P8): explicitly lock raw measurement, q and non-status bit-string preservation --- tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs index ecb90aec6..fbbce7bcb 100644 --- a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs +++ b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs @@ -95,6 +95,20 @@ public void ReportTwoBitStatus_ShowsActualSignificantBits_WithoutInventingLocalR wireValue, "BitString", "", "Status", "SIEBCUUD1/USER2.LOCREM.stVal")); } + [Theory] + [InlineData("Measurement", "IEDLD/GGIO1.RawBits")] + [InlineData("Status", "IEDLD/GGIO1.RawBits")] + [InlineData("Status", "IEDLD/GGIO1.RawBits.q")] + [InlineData("Status", "IEDLD/GGIO1.RawBits.subVal")] + [InlineData("Quality", "IEDLD/GGIO1.RawBits.stVal")] + public void TwoBitRawEvidence_OutsideExactStatusStVal_IsNotMutated( + string category, string reference) + { + const string wire = "bits(80,unused=6)"; + Assert.Equal(wire, Iec61850ValueFormatter.FormatReportProcessValue( + wire, "BitString", "", category, reference)); + } + [Fact] public void TwoBitStatus_IsOnlyDpcWhenExplicitPositionSemanticsArePresent() { From f9980888e02e987c062496d4dcd46015c8a87247 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:36:44 +0700 Subject: [PATCH 18/19] refactor(P8): restore canonical formatter and isolate metadata-safe two-bit presentation Replace corrupted duplicate-tail formatter with baseline-preserving source and small pure presentation policy. Avoid numeric status inference on typed measurement/quality data. Add type-conflict regressions and preflight single-declaration invariant. --- Services/Iec61850TwoBitStatusPresentation.cs | 58 +++ Services/Iec61850ValueFormatter.cs | 337 +----------------- .../Iec61850ValueFormatterTests.cs | 22 ++ 3 files changed, 87 insertions(+), 330 deletions(-) create mode 100644 Services/Iec61850TwoBitStatusPresentation.cs diff --git a/Services/Iec61850TwoBitStatusPresentation.cs b/Services/Iec61850TwoBitStatusPresentation.cs new file mode 100644 index 000000000..da61fd9f7 --- /dev/null +++ b/Services/Iec61850TwoBitStatusPresentation.cs @@ -0,0 +1,58 @@ +namespace ArIED61850Tester.Services; + +/// +/// Single, side-effect-free operator presentation boundary for two-bit MMS +/// status values. The raw association ledger, quality flags, event ordering and +/// typed decoder are not changed. DPC positions use their own CDC-aware path. +/// +internal static class Iec61850TwoBitStatusPresentation +{ + internal static bool TryFormat( + string wireValue, + string? dataType, + string? category, + string? reference, + out string display) + { + display = string.Empty; + + // Metadata authority is required. A Measurement that happens to be two + // bits must not inherit a process-state meaning from its byte shape. + if (!string.Equals(category?.Trim(), "Status", StringComparison.OrdinalIgnoreCase)) + return false; + + var path = reference.AsSpan().Trim(); + if (!path.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) && + !path.EndsWith("$stVal", StringComparison.OrdinalIgnoreCase)) + return false; + + // Fail closed when declared MMS/IEC type contradicts a two-bit status. + // Unknown is a legacy incomplete-model state, not a vendor exception. + if (!IsBitStringOrIncompleteType(dataType)) + return false; + + if (!Iec61850ValueFormatter.TryNormalizeTwoBitWireStatus(wireValue, out var code)) + return false; + + display = code switch + { + 0 => "Bits [00]", + 1 => "Bits [01]", + 2 => "Bits [10]", + 3 => "Bits [11]", + _ => string.Empty + }; + return display.Length != 0; + } + + private static bool IsBitStringOrIncompleteType(string? dataType) + { + var type = dataType?.Trim(); + return string.IsNullOrEmpty(type) || + type.Equals("Unknown", StringComparison.OrdinalIgnoreCase) || + type.Equals("BitString", StringComparison.OrdinalIgnoreCase) || + type.Equals("Bit String", StringComparison.OrdinalIgnoreCase) || + type.Equals("Bits", StringComparison.OrdinalIgnoreCase) || + type.Equals("MmsBitString", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Services/Iec61850ValueFormatter.cs b/Services/Iec61850ValueFormatter.cs index 0194a3b19..8c75b2d7e 100644 --- a/Services/Iec61850ValueFormatter.cs +++ b/Services/Iec61850ValueFormatter.cs @@ -86,336 +86,13 @@ public static string FormatReportProcessValue( return boolean ? "True [1]" : "False [0]"; } - // MMS two-bit BIT STRING is not necessarily a DPC position: the same - // wire representation is also used for vendor-specific status leaves - // (e.g. USER2.LOCREM). Render only the two significant bits, never - // expose 'unused=6' as an operator value or invent Local/Remote. - // The position-only branch above retains the established DPC labels. - // Only the exact stVal status leaf qualifies for generic operator - // presentation. Raw Measurement/quality/substitution bit strings retain - // their original evidence shape, even when they happen to be two bits. - if (normalizedValue is string wireBits && - (category ?? string.Empty).Trim().Equals("Status", StringComparison.OrdinalIgnoreCase) && - (reference ?? string.Empty).Trim().Replace(' - - return FormatNormalizedValue(normalizedValue, dataType, unit); - } - - public static bool IsBscStepPosition(string? reference, string? dataType) - { - var path = (reference ?? string.Empty).Trim().Replace('$', '.'); - return path.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase) || - (string.Equals(dataType?.Trim(), "BSC", StringComparison.OrdinalIgnoreCase) && - path.EndsWith(".posVal", StringComparison.OrdinalIgnoreCase)); - } - - public static string FormatBscStepPosition(object? value, string? unit = null) - { - string? number = value switch - { - sbyte x when x is >= -64 and <= 63 => x.ToString(CultureInfo.InvariantCulture), - byte x when x <= 63 => x.ToString(CultureInfo.InvariantCulture), - short x when x is >= -64 and <= 63 => x.ToString(CultureInfo.InvariantCulture), - ushort x when x <= 63 => x.ToString(CultureInfo.InvariantCulture), - int x when x is >= -64 and <= 63 => x.ToString(CultureInfo.InvariantCulture), - uint x when x <= 63 => x.ToString(CultureInfo.InvariantCulture), - long x when x is >= -64 and <= 63 => x.ToString(CultureInfo.InvariantCulture), - ulong x when x <= 63 => x.ToString(CultureInfo.InvariantCulture), - string text when sbyte.TryParse(text.Trim(), NumberStyles.Integer, - CultureInfo.InvariantCulture, out var step) && - step is >= -64 and <= 63 => step.ToString(CultureInfo.InvariantCulture), - _ => null - }; - if (number != null) - return AppendUnit(number, unit ?? string.Empty); - // Never make up a step number from a raw Boolean shape mismatch. - return value switch - { - null => "-", - bool boolean => boolean - ? "true (BSC INT8 type mismatch)" - : "false (BSC INT8 type mismatch)", - _ => value.ToString() ?? "-" - }; - } - - private static bool IsPositionSemantic(string dataType, string category, string reference) - { - if (IsDbposDataType(dataType)) - return true; - - if ((category ?? string.Empty).Trim().Equals("Position", StringComparison.OrdinalIgnoreCase)) - return true; - - var normalizedReference = (reference ?? string.Empty) - .Trim() - .Replace((char)36, '.') - .ToLowerInvariant(); - - return normalizedReference.Contains(".pos.stval", StringComparison.Ordinal) || - normalizedReference.EndsWith(".pos", StringComparison.Ordinal); - } - - private static bool TryNormalizeBoolean(object? value, out bool boolean) - { - switch (value) - { - case bool typed: - boolean = typed; - return true; - case string text when bool.TryParse(text.Trim(), out var parsed): - boolean = parsed; - return true; - default: - boolean = false; - return false; - } - } - - private static bool IsBooleanDataType(string? dataType) - { - var normalized = (dataType ?? string.Empty) - .Trim() - .Replace("_", string.Empty, StringComparison.Ordinal) - .Replace("-", string.Empty, StringComparison.Ordinal) - .Replace(" ", string.Empty, StringComparison.Ordinal) - .ToUpperInvariant(); - - return normalized is "BOOL" or "BOOLEAN" or "SPS" or "SPC" or "SINGLEPOINTSTATUS"; - } - - private static bool TryNormalizeBooleanCode(object? value, out bool boolean) - { - switch (value) - { - case byte b when b <= 1: - boolean = b == 1; - return true; - case sbyte b when b is 0 or 1: - boolean = b == 1; - return true; - case short s when s is 0 or 1: - boolean = s == 1; - return true; - case ushort s when s <= 1: - boolean = s == 1; - return true; - case int i when i is 0 or 1: - boolean = i == 1; - return true; - case uint i when i <= 1: - boolean = i == 1; - return true; - case long l when l is 0 or 1: - boolean = l == 1; - return true; - case ulong l when l <= 1: - boolean = l == 1; - return true; - case string text when text.Trim() is "0" or "1": - boolean = text.Trim() == "1"; - return true; - default: - boolean = false; - return false; - } - } - - public static string FormatOperatorDbpos(int code) => code switch - { - 0 => "Intermediate [00]", - 1 => "Open [01]", - 2 => "Close [10]", - 3 => "Bad state [11]", - _ => code.ToString(CultureInfo.InvariantCulture) - }; - - public static bool TryNormalizeDbpos(object? value, out int code) - { - code = 0; - switch (value) - { - case byte b when b <= 3: code = b; return true; - case sbyte b when b is >= 0 and <= 3: code = b; return true; - case short s when s is >= 0 and <= 3: code = s; return true; - case ushort s when s <= 3: code = s; return true; - case int i when i is >= 0 and <= 3: code = i; return true; - case uint i when i <= 3: code = (int)i; return true; - case long l when l is >= 0 and <= 3: code = (int)l; return true; - case ulong l when l <= 3: code = (int)l; return true; - case bool b: code = b ? 2 : 1; return true; - case string text: return TryParseDbposText(text, out code); - default: return false; - } - } - - private static bool IsDbposDataType(string dataType) - => dataType.Equals("Dbpos", StringComparison.OrdinalIgnoreCase) || - dataType.Equals("DPC", StringComparison.OrdinalIgnoreCase) || - dataType.Equals("DPS", StringComparison.OrdinalIgnoreCase) || - dataType.Equals("DoublePointStatus", StringComparison.OrdinalIgnoreCase); - - private static bool IsNumericDataType(string dataType) - { - var normalized = (dataType ?? string.Empty).Trim().ToLowerInvariant(); - return normalized.Contains("int", StringComparison.Ordinal) || - normalized.Contains("uint", StringComparison.Ordinal) || - normalized.Contains("float", StringComparison.Ordinal) || - normalized.Contains("double", StringComparison.Ordinal) || - normalized.Contains("decimal", StringComparison.Ordinal) || - normalized.Contains("counter", StringComparison.Ordinal) || - normalized.Contains("bcr", StringComparison.Ordinal); - } - - /// - /// An actual two-bit MMS BIT STRING renders as one octet with exactly - /// six unused low bits. Only this wire-proven representation can rescue a - /// missing CDC type, and ONLY with an exact model ST.stVal/q declaration. - /// Reject trailing text, wrong bit length and nonzero padding. Raw evidence - /// is never changed; this function is presentation-only. - /// - public static bool TryNormalizeTwoBitWireStatus(string? text, out int code) - { - code = 0; - // Raw IED presentation can be malformed or adversarial. The canonical - // two-bit renderer is a few dozen characters, so bound regex work. - if (string.IsNullOrWhiteSpace(text) || text.Length > 64) return false; - var rendered = Regex.Match( - text, - @"^\s*bits\(\s*(?:0x)?([0-9a-f]{2})\s*,\s*unused\s*=\s*6\s*\)\s*$", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - if (!rendered.Success || - !byte.TryParse(rendered.Groups[1].Value, NumberStyles.HexNumber, - CultureInfo.InvariantCulture, out var octet) || - (octet & 0x3f) != 0) - return false; - code = octet >> 6; - return true; - } - - private static bool TryParseDbposText(string text, out int code) - { - code = 0; - if (string.IsNullOrWhiteSpace(text)) return false; - - var bracketCode = Regex.Match(text, @"\[(00|01|10|11)\]", RegexOptions.CultureInvariant); - if (bracketCode.Success) - return TryParseBits(bracketCode.Groups[1].Value, out code); - - if (TryNormalizeTwoBitWireStatus(text, out code)) - return true; - - var compact = text.Trim().Replace(" ", "").Replace("_", "").Replace("-", "").ToLowerInvariant(); - switch (compact) - { - case "0": - case "00": - case "intermediate": - case "intermediatestate": code = 0; return true; - case "1": - case "01": - case "open": - case "off": code = 1; return true; - case "2": - case "10": - case "closed": - case "close": - case "on": code = 2; return true; - case "3": - case "11": - case "bad": - case "badstate": - case "invalid": code = 3; return true; - default: return false; - } - } - - private static bool TryExtractStructuredScalar(object? value, string dataType, out object? scalar) - { - scalar = null; - if (value is not string text || string.IsNullOrWhiteSpace(text)) - return false; - - var stVal = Regex.Match( - text, - @"^\s*Structure\(\s*\d+\s*\)\s*\{\s*stVal\s*=\s*([^,}]+)", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - if (stVal.Success) - return TryParseRenderedScalar(stVal.Groups[1].Value, out scalar); - - if (!IsNumericDataType(dataType)) - return false; - - var indexed = Regex.Match( - text, - @"^\s*Structure\(\s*\d+\s*\)\s*\{\s*\[0\]\s*=\s*([^,}]+)", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - return indexed.Success && TryParseRenderedScalar(indexed.Groups[1].Value, out scalar); - } - - private static bool TryParseRenderedScalar(string raw, out object? scalar) - { - scalar = null; - var text = raw.Trim(); - if (bool.TryParse(text, out var boolean)) - { - scalar = boolean; - return true; - } - - if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer)) - { - scalar = integer; - return true; - } - - if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var floating)) - { - scalar = floating; - return true; - } - - // Keep bit-string text intact so Dbpos decoding can interpret it safely. - if (text.StartsWith("bits(", StringComparison.OrdinalIgnoreCase)) - { - scalar = text; - return true; - } - - return false; - } - - private static bool TryParseInvariantNumber(string text, out string formatted) - { - formatted = string.Empty; - if (string.IsNullOrWhiteSpace(text)) - return false; - if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) - return false; - formatted = number.ToString("0.######", CultureInfo.InvariantCulture); - return true; - } - - private static bool TryParseBits(string bits, out int code) - { - code = bits switch - { - "00" => 0, - "01" => 1, - "10" => 2, - "11" => 3, - _ => -1 - }; - return code >= 0; - } - - private static string AppendUnit(string value, string unit) - => string.IsNullOrWhiteSpace(unit) ? value : $"{value} {unit}"; -} -, '.') - .EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) && - TryNormalizeTwoBitWireStatus(wireBits, out var code)) - return $"Bits [{Convert.ToString(code, 2).PadLeft(2, '0')}]"; + // The protocol decoder owns raw MMS truth. Only a typed operator + // status projection may render a two-bit status; never mutate MMS + // measurements, quality evidence or an unknown semantic field. + if (normalizedValue is string renderedBits && + Iec61850TwoBitStatusPresentation.TryFormat( + renderedBits, dataType, category, reference, out var displayBits)) + return displayBits; return FormatNormalizedValue(normalizedValue, dataType, unit); } diff --git a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs index fbbce7bcb..83e7df424 100644 --- a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs +++ b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs @@ -109,6 +109,28 @@ public void TwoBitRawEvidence_OutsideExactStatusStVal_IsNotMutated( wire, "BitString", "", category, reference)); } + [Theory] + [InlineData("Float32")] + [InlineData("Quality")] + [InlineData("Boolean")] + [InlineData("Enum")] + [InlineData("INT32")] + public void TwoBitStatus_ContradictingDeclaredType_MustStayRaw(string declaredType) + { + const string raw = "bits(80,unused=6)"; + Assert.Equal(raw, Iec61850ValueFormatter.FormatReportProcessValue( + raw, declaredType, "", "Status", "IEDLD/GGIO1.Flag.stVal")); + } + + [Theory] + [InlineData("bits(00,unused=6)", "Bits [00]")] + [InlineData("bits(40,unused=6)", "Bits [01]")] + [InlineData("bits(80,unused=6)", "Bits [10]")] + [InlineData("bits(C0,unused=6)", "Bits [11]")] + public void TwoBitStatus_IncompleteModelRetainsTruthfulSignificantBits(string raw, string expected) + => Assert.Equal(expected, Iec61850ValueFormatter.FormatReportProcessValue( + raw, "Unknown", "", "Status", "IEDLD/GGIO1.Mode.stVal")); + [Fact] public void TwoBitStatus_IsOnlyDpcWhenExplicitPositionSemanticsArePresent() { From c7b7c68c6bf59a53082c09dd7584ea3d5312429e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 5 Nov 2026 11:39:01 +0700 Subject: [PATCH 19/19] refactor(connect): single association recovery executor for discovery and cached models Eliminate duplicate retry/teardown loops; preserve separate attempt budgets, enforce explicit cancellation, keep per-device scope, and classify only transient transport failures. Never replay discovery or MMS control writes. Add lifecycle guard. --- Services/Iec61850MonitorRuntime.cs | 133 +++++++----------- .../P62CSmartReconnectRegressionTests.cs | 45 +++--- 2 files changed, 75 insertions(+), 103 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index da3bb2b87..87f047b02 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -3037,131 +3037,96 @@ private void EmitStatusSnapshot( }); } - /// - /// Only initial MMS association is retried, never discovery, RCB writes, - /// or controls. Denied ACSE/MMS associations and unknown errors fail closed. - /// - private async Task ConnectInitialDiscoveryAssociationWithRetryAsync( - DeviceSession session, - CancellationToken cancellationToken) + // Both initial full discovery and cached reconnect use ONE association + // executor; only their bounded per-attempt timeout differs. Reporting, + // discovery and control operations stay strictly outside this lifecycle. + private Task ConnectInitialDiscoveryAssociationWithRetryAsync( + DeviceSession session, CancellationToken cancellationToken) + => ConnectAssociationWithRetryAsync(session, cachedModel: false, cancellationToken); + + private Task ConnectCachedAssociationWithRetryAsync( + DeviceSession session, CancellationToken cancellationToken) + => ConnectAssociationWithRetryAsync(session, cachedModel: true, cancellationToken); + + private async Task ConnectAssociationWithRetryAsync( + DeviceSession session, bool cachedModel, CancellationToken cancellationToken) { + var phase = cachedModel ? "Fast saved-model" : "Initial discovery"; for (var attempt = 1; attempt <= SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts; attempt++) { cancellationToken.ThrowIfCancellationRequested(); + + // Cached association already has a strict two-second attempt budget. + // Initial discovery retains the engine's own eight-second budget. + using var budget = cachedModel + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : null; + if (budget is not null) + budget.CancelAfter(SmartReconnectPolicy.ConnectBudget); + var attemptToken = budget?.Token ?? cancellationToken; + var started = Stopwatch.GetTimestamp(); - var nativeAttemptCancelled = false; + var nativeDeadlineCancelled = false; try { await session.Client.ConnectAsync( session.Device.IpAddress, session.Device.Port, - cancellationToken).ConfigureAwait(false); + attemptToken).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - // Only an internal/native deadline cancelled: operator shutdown or - // explicit external cancellation still propagates immediately. - nativeAttemptCancelled = true; + // Only the attempt-local budget or native internal timeout. + // Explicit user cancellation still propagates and is not retried. + nativeDeadlineCancelled = true; } var elapsed = Stopwatch.GetElapsedTime(started); - if (session.Client.IsConnected) { if (attempt > 1) Log("INFO", session.Device.Name, - $"Initial MMS connection recovered on attempt #{attempt} in {elapsed.TotalMilliseconds:0} ms; " + - "full discovery will run once on this association."); + $"{phase} MMS connection recovered on attempt #{attempt} " + + $"after {elapsed.TotalMilliseconds:0} ms; discovery and RCB setup remain single-flight."); return; } - var kind = nativeAttemptCancelled + var failureKind = nativeDeadlineCancelled ? "NATIVE_ASSOCIATION_CANCELLED" : session.Client.LastConnectionFailureKind; - var description = nativeAttemptCancelled - ? "Native association attempt was cancelled before completion while the application cancellation token remained active." + var failureDetail = nativeDeadlineCancelled + ? "Association attempt deadline expired before MMS was ready." : string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) - ? $"native state={session.Client.NativeState}" - : session.Client.LastErrorMessage; + ? $"native state={session.Client.NativeState}" + : session.Client.LastErrorMessage; session.Device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - $"Initial MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms"); + $"{phase} MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms"); - var willRetry = attempt < SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts && - SmartReconnectPolicy.CanRetryInitialDiscoveryAssociation(kind); + // Fail closed on unclassified errors and ACSE/MMS refusal. Do not + // repeat a confirmed protocol rejection just to satisfy a retry count. + var retry = attempt < SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts && + SmartReconnectPolicy.CanRetryInitialDiscoveryAssociation(failureKind); Log("WARN", session.Device.Name, - $"Initial MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms " + - $"(kind={kind}, retry={willRetry}). {description}"); + $"{phase} MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms " + + $"(kind={failureKind}, retry={retry}). {failureDetail}"); - if (!willRetry) + if (!retry) return; - // Never inherit failed ACSE/MMS state across attempt boundaries. - var failedClient = session.Client; + // A new client gives the next TCP/COTP/ACSE/MMS attempt a new + // ownership scope. Never recycle a failed protocol session. + var oldClient = session.Client; session.Client = new NativeIec61850Client(); await DisposeClientForReconnectAsync( - failedClient, - session.Device.Name, - SmartReconnectPolicy.ClientCleanupBudget, - cancellationToken).ConfigureAwait(false); + oldClient, session.Device.Name, + SmartReconnectPolicy.ClientCleanupBudget, cancellationToken).ConfigureAwait(false); await Task.Delay( SmartReconnectPolicy.InitialAssociationRetryDelay, cancellationToken).ConfigureAwait(false); } } - private async Task ConnectCachedAssociationWithRetryAsync( - DeviceSession session, - CancellationToken cancellationToken) - { - const int maxAttempts = 2; - for (var attempt = 1; attempt <= maxAttempts; attempt++) - { - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(SmartReconnectPolicy.ConnectBudget); - try - { - await session.Client.ConnectAsync( - session.Device.IpAddress, - session.Device.Port, - timeout.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - session.Device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - $"Fast saved-model connect attempt #{attempt} timed out"); - } - - if (session.Client.IsConnected) - { - if (attempt > 1) - { - Log("INFO", session.Device.Name, - $"Fast saved-model connection recovered automatically on attempt #{attempt}; no manual Play retry was required."); - } - return; - } - - if (attempt >= maxAttempts) - return; - - var failure = string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) - ? $"native state={session.Client.NativeState}" - : session.Client.LastErrorMessage; - Log("WARN", session.Device.Name, - $"Fast saved-model connection attempt #{attempt} did not establish MMS ({failure}). Retrying once after {SmartReconnectPolicy.InitialAssociationRetryDelay.TotalMilliseconds:0} ms."); - - var staleClient = session.Client; - session.Client = new NativeIec61850Client(); - await DisposeClientForReconnectAsync( - staleClient, - session.Device.Name, - SmartReconnectPolicy.ClientCleanupBudget, - cancellationToken).ConfigureAwait(false); - await Task.Delay(SmartReconnectPolicy.InitialAssociationRetryDelay, cancellationToken).ConfigureAwait(false); - } - } - private static async Task ResetAssociationControlAuthorityAsync(Iec61850MonitorDevice device) { var dispatcher = System.Windows.Application.Current?.Dispatcher; diff --git a/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs b/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs index f123d76fb..a4f5cfcbb 100644 --- a/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs +++ b/tests/ARSAS.Tests/P62CSmartReconnectRegressionTests.cs @@ -33,27 +33,34 @@ public void InitialDiscovery_RetriesOnlyKnownTransientTransportFailures( } [Fact] - public void InitialDiscoveryTransportRetry_CannotTriggerDuplicateDiscoveryOrRCBWrite() + public void AssociationRecovery_UsesOneExecutorAndNeverReplaysDiscoveryOrControl() { - // The helper belongs exclusively to the association stage before - // discovering directories or attempting any RCB activation. var runtime = ReadRepoFile("Services/Iec61850MonitorRuntime.cs"); - var connect = runtime.IndexOf("await ConnectInitialDiscoveryAssociationWithRetryAsync(", - StringComparison.Ordinal); - var discovery = runtime.IndexOf("await session.Client.DiscoverSignalsAsync(", - StringComparison.Ordinal); - var helper = runtime.IndexOf("private async Task ConnectInitialDiscoveryAssociationWithRetryAsync(", - StringComparison.Ordinal); - var nextHelper = runtime.IndexOf("private async Task ConnectCachedAssociationWithRetryAsync(", - StringComparison.Ordinal); - Assert.True(connect >= 0 && discovery > connect && helper > 0 && nextHelper > helper); - var body = runtime[helper..nextHelper]; - Assert.DoesNotContain("DiscoverSignalsAsync(", body, StringComparison.Ordinal); - Assert.DoesNotContain("StartPersistentReport", body, StringComparison.Ordinal); - Assert.DoesNotContain("WriteReport", body, StringComparison.Ordinal); - Assert.Contains("new NativeIec61850Client()", body, StringComparison.Ordinal); - Assert.Contains("InitialAssociationRetryDelay", body, StringComparison.Ordinal); - Assert.Contains("cancellationToken.ThrowIfCancellationRequested()", body, StringComparison.Ordinal); + var fullDiscoveryCall = runtime.IndexOf( + "await ConnectInitialDiscoveryAssociationWithRetryAsync(", StringComparison.Ordinal); + var discoveryCall = runtime.IndexOf( + "await session.Client.DiscoverSignalsAsync(", StringComparison.Ordinal); + var cachedCall = runtime.IndexOf( + "await ConnectCachedAssociationWithRetryAsync(", StringComparison.Ordinal); + var executor = runtime.IndexOf( + "private async Task ConnectAssociationWithRetryAsync(", StringComparison.Ordinal); + var next = runtime.IndexOf( + "private static async Task ResetAssociationControlAuthorityAsync(", StringComparison.Ordinal); + Assert.True(fullDiscoveryCall >= 0 && fullDiscoveryCall < discoveryCall); + Assert.True(cachedCall >= 0 && executor >= 0 && next > executor); + + var owner = runtime[executor..next]; + Assert.DoesNotContain("DiscoverSignalsAsync(", owner, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReport", owner, StringComparison.Ordinal); + Assert.DoesNotContain("WriteReport", owner, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteControlAsync(", owner, StringComparison.Ordinal); + Assert.Contains("new NativeIec61850Client()", owner, StringComparison.Ordinal); + Assert.Contains("InitialAssociationRetryDelay", owner, StringComparison.Ordinal); + Assert.Contains("ClientCleanupBudget", owner, StringComparison.Ordinal); + Assert.Contains("cancellationToken.ThrowIfCancellationRequested()", owner, StringComparison.Ordinal); + Assert.Contains("CanRetryInitialDiscoveryAssociation(failureKind)", owner, StringComparison.Ordinal); + Assert.Contains("cachedModel ? \"Fast saved-model\" : \"Initial discovery\"", owner, StringComparison.Ordinal); + Assert.Contains("budget.CancelAfter(SmartReconnectPolicy.ConnectBudget)", owner, StringComparison.Ordinal); } [Fact]