From 914cc300d1307a2d4d3862f2948f4234902e8812 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 22:39:11 +0700 Subject: [PATCH 01/52] discovery: promote verified smart route into source --- Services/NativeIec61850Client.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 04a657e42..963ff9f12 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -62,6 +62,7 @@ public async Task ConnectAsync(string ipAddress, int port, CancellationToken can LastConnectionTechnicalSummary = string.Empty; _lastDiscovery = null; _liveModel = null; + ResetSmartDiscoveryAuthority(); // __P0_5C_CONNECT_RESET__ ClearCanonicalModel(); _reportMonitorSessions.Clear(); _reportMonitorCoverage.Clear(); @@ -127,6 +128,12 @@ public Iec61850DeviceDiagnosticSnapshot CaptureDiagnosticSnapshot( public async Task> DiscoverSignalsAsync(CancellationToken cancellationToken, IProgress? progress = null) { + // Production source now uses the same bounded single-flight discovery route that + // produced the field-verified golden runtime. The old supplemental/recursive path + // remains below only as dormant compatibility code while parity is validated. + if (SmartDiscoveryCaptureModeEnabled) + return await DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress).ConfigureAwait(false); + LastDiscoverySummary = string.Empty; cancellationToken.ThrowIfCancellationRequested(); progress?.Report(new IedDiscoveryProgress( @@ -1950,6 +1957,8 @@ private static Iec61850ControlCommandResult ControlWireUnknownFailure( public async ValueTask DisposeAsync() { + ResetSmartDiscoveryAuthority(); // __P0_5C_DISPOSE_RESET__ + await DisposeControlSessionsAsync().ConfigureAwait(false); await StopReportMonitorsAsync().ConfigureAwait(false); await _mmsIoGate.WaitAsync().ConfigureAwait(false); @@ -1979,7 +1988,9 @@ public async ValueTask DisposeAsync() var service = new ArControl.Iec61850ControlService(); var opened = await RunMmsOperationAsync( - () => service.OpenAsync(_session, signal.ObjectReference, cancellationToken), + () => _lastDiscovery != null + ? service.OpenAsync(_session, signal.ObjectReference, _lastDiscovery.Snapshot.DomainVariables, cancellationToken) + : service.OpenAsync(_session, signal.ObjectReference, cancellationToken), cancellationToken).ConfigureAwait(false); _controlSessions[key] = opened; return opened; From dfbabf0c41b1bb7ab8283dfdba0cfeffc01f5979 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 22:39:21 +0700 Subject: [PATCH 02/52] discovery: enter shared IED actions workflow after model build --- MainWindow.xaml.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 69eae95b6..a2324ea2b 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -766,9 +766,16 @@ private async Task ConnectAndConfigureDeviceAsync( if (openWizard && device.SignalCount > 0) { if ((selectDevice || ReferenceEquals(SelectedDevice, device)) && !_signalSelectionWizardOpen) - await OpenSignalSelectionWizardAsync(device, restoredCount); + { + // Discovery and Open SCL converge here. Once a canonical model exists, + // both sources present the same task-first IED Actions workflow so Static + // DataSet acquisition is source-neutral. + await OpenIedWorkspaceActionsAsync(device); + } else + { SetStatus($"{device.Name}: discovery complete. Use the edit icon on its IED card to review {restoredCount} restored selection(s)."); + } } return true; } From 7e5bb3a68d3bb0bbec2e76459f1e1bdc50a743b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 22:39:37 +0700 Subject: [PATCH 03/52] model: share canonical DataSet directory projection across sources --- Services/NativeIec61850Client.SclAssisted.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index 3c5c17940..b54a4d373 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -120,6 +120,7 @@ public async Task ConnectUsingSclAsync( LastDiscoverySummary = string.Empty; _lastDiscovery = null; _liveModel = null; + ResetSmartDiscoveryAuthority(); LastReportInventory = new NativeReportInventory(); _reportMonitorSessions.Clear(); _reportMonitorCoverage.Clear(); @@ -256,7 +257,7 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut _liveModel = preparation.InitialReadDesign.Model; var reportInventory = BuildTrustedSclReportInventory(_liveModel); - var dataSetDirectories = BuildTrustedSclDataSetDirectories(_liveModel); + var dataSetDirectories = BuildModelDataSetDirectories(_liveModel, "TrustedScl"); foreach (var directory in dataSetDirectories) { _trustedSclDataSetDirectories[ @@ -455,8 +456,9 @@ private static ArMms.MmsReportInventory BuildTrustedSclReportInventory( return inventory; } - private static IReadOnlyList BuildTrustedSclDataSetDirectories( - LiveIedModelDiscoveryDocument model) + private static IReadOnlyList BuildModelDataSetDirectories( + LiveIedModelDiscoveryDocument model, + string source) => model.DataSets .Select(dataSet => { @@ -467,7 +469,7 @@ private static ArMms.MmsReportInventory BuildTrustedSclReportInventory( dataSet.Name); var members = dataSet.Members .OrderBy(member => member.Index) - .Select(member => BuildTrustedSclDataSetMember(member, domain)) + .Select(member => BuildModelDataSetMember(member, domain, source)) .ToArray(); return new ArMms.MmsDataSetDirectoryResult { @@ -478,14 +480,15 @@ private static ArMms.MmsReportInventory BuildTrustedSclReportInventory( IsDeletable = dataSet.IsDeletable, Members = members, Message = - $"Trusted SCL DataSet authority: {dataSet.Reference} has {members.Length} ordered member(s); no network directory request was sent." + $"{source} DataSet authority: {dataSet.Reference} has {members.Length} ordered member(s); model order is preserved." }; }) .ToArray(); - private static ArMms.MmsDataSetDirectoryMember BuildTrustedSclDataSetMember( + private static ArMms.MmsDataSetDirectoryMember BuildModelDataSetMember( LiveIedDataSetMemberModel member, - string fallbackDomain) + string fallbackDomain, + string source) { var mmsReference = member.MmsReference?.Trim() ?? string.Empty; var slash = mmsReference.IndexOf('/'); @@ -507,7 +510,7 @@ private static ArMms.MmsDataSetDirectoryMember BuildTrustedSclDataSetMember( FunctionalConstraint = member.FunctionalConstraint, LogicalNode = logicalNode, DataObjectPath = dataObjectPath, - Source = "TrustedScl", + Source = source, Confidence = 100 }; } From 043a710e608231aa11e2cf3cab7dbd3ecc284f93 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 22:39:59 +0700 Subject: [PATCH 04/52] reporting: reuse canonical DataSet order after discovery --- ...veIec61850Client.StaticDataSetReporting.cs | 79 ++++++++++++++++--- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index fbc918f4c..95c052d10 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -60,6 +60,13 @@ public async Task BuildStaticDataSetReportPlan ? "opened SCL design model" : "live discovery model (online-only)"; + // The model that created the operator-visible DataSet inventory is also the + // positional authority for report projection. Open SCL and Smart Discovery now + // converge on this exact ordered representation. + var modelDataSetDirectories = BuildModelDataSetDirectories( + projectionModel, + device.SclWorkspace?.DesignModel is not null ? "SclDesignModel" : "LiveDiscoveryModel"); + // Fresh report discovery is verification, not permission policy. In particular we // deliberately do NOT require the adaptive Hybrid availability gate to classify a // configured BRCB as Available. Some perfectly usable servers omit enough reservation @@ -235,20 +242,31 @@ public async Task BuildStaticDataSetReportPlan if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference)) liveRcb.DataSetReference = dataSetReference; - var directories = await RunMmsOperationAsync( - () => _session.GetDataSetDirectoriesAsync( - new[] { dataSetReference }, - discovery.IedDirectory, - cancellationToken), - cancellationToken).ConfigureAwait(false); - var directory = directories.SingleOrDefault(result => + var modelDirectory = modelDataSetDirectories.SingleOrDefault(result => result.IsSuccess && SameStaticReference(result.DataSetReference, dataSetReference)); + if (modelDirectory is null || modelDirectory.Members.Count == 0) + { + warnings.Add( + $"{dataSetReference}: canonical model has no ordered DataSet member list. RCB was not armed because report-index mapping would be unsafe; MMS process polling remains disabled."); + continue; + } + + // Golden Smart Discovery already captured the live DataSet directory in the + // authoritative single-flight. Reuse that evidence instead of performing a + // second directory request after the model is built. + var liveDirectory = discovery.DataSetDirectories.SingleOrDefault(result => + result.IsSuccess && SameStaticReference(result.DataSetReference, dataSetReference)); + if (liveDirectory is null || liveDirectory.Members.Count == 0) + { + warnings.Add( + $"{dataSetReference}: discovery authority has no ordered live DataSet directory. RCB was not armed; re-scan is required and MMS process polling remains disabled."); + continue; + } - if (directory is null || directory.Members.Count == 0) + if (!TryVerifyStaticDataSetMemberOrder(modelDirectory, liveDirectory, out var directoryMismatch)) { - var detail = directories.FirstOrDefault()?.Message ?? "no directory response"; warnings.Add( - $"{dataSetReference}: live DataSet directory could not prove an ordered non-empty member list ({detail}). RCB was not armed because report-index mapping would be unsafe; MMS process polling remains disabled."); + $"{dataSetReference}: canonical/live DataSet member order mismatch ({directoryMismatch}). RCB was not armed; unsafe positional projection and MMS process polling were both refused."); continue; } @@ -307,12 +325,12 @@ public async Task BuildStaticDataSetReportPlan Status = ArMms.MmsReportSubscriptionPlanStatus.ReadyRequiresWrite, ReportControl = liveRcb, DataSetReference = dataSetReference, - Members = directory.Members, + Members = modelDirectory.Members, DynamicPoints = Array.Empty(), Steps = new[] { $"Verify authoritative configured RCB {configured.Reference} as live object {concreteReportReference}.", - $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).", + $"Use canonical ordered DataSet members {dataSetReference} ({modelDirectory.Members.Count} members), verified against the authoritative discovery evidence.", "Install InformationReport receiver before enabling the RCB.", "Use client-compatible BRCB reservation when ResvTms is exposed, enable RptEna, then request GI after receiver registration.", "Map report values by ordered DataSet member index; never substitute cyclic MMS process reads." @@ -508,6 +526,43 @@ private static NativeHybridReportPlanningResult StaticPlanningUnavailable( UncoveredSignalCount = points.Count }; + private static bool TryVerifyStaticDataSetMemberOrder( + ArMms.MmsDataSetDirectoryResult modelDirectory, + ArMms.MmsDataSetDirectoryResult liveDirectory, + out string mismatch) + { + mismatch = string.Empty; + if (modelDirectory.Members.Count != liveDirectory.Members.Count) + { + mismatch = $"count model={modelDirectory.Members.Count}, live={liveDirectory.Members.Count}"; + return false; + } + + for (var index = 0; index < modelDirectory.Members.Count; index++) + { + var modelMember = modelDirectory.Members[index]; + var liveMember = liveDirectory.Members[index]; + var mmsMatches = + !string.IsNullOrWhiteSpace(modelMember.MmsReference) && + !string.IsNullOrWhiteSpace(liveMember.MmsReference) && + SameStaticReference(modelMember.MmsReference, liveMember.MmsReference); + var userMatches = + !string.IsNullOrWhiteSpace(modelMember.UserReference) && + !string.IsNullOrWhiteSpace(liveMember.UserReference) && + SameStaticReference(modelMember.UserReference, liveMember.UserReference); + + if (mmsMatches || userMatches) + continue; + + mismatch = + $"member[{index}] model={modelMember.UserReference} ({modelMember.MmsReference}), " + + $"live={liveMember.UserReference} ({liveMember.MmsReference})"; + return false; + } + + return true; + } + private static int StaticRcbAvailabilityRank(ArMms.MmsRcbOperationalAvailability availability) => availability switch { From 03e28f5dbf17dade755c70ffa0e56ef4483d4e40 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 22:40:11 +0700 Subject: [PATCH 05/52] reporting: keep model authority consistent through projection --- Services/Iec61850MonitorRuntime.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index cb5a7d36b..1abc8af02 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1136,7 +1136,7 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT { var effectiveUpdates = session.StaticDataSetReportOnly ? session.StaticReportProjection.Project( - session.Device.LiveDiscoveryModel ?? session.Device.SclWorkspace?.DesignModel, + session.Device.SclWorkspace?.DesignModel ?? session.Device.LiveDiscoveryModel, session.Points.Values.ToArray(), sourceUpdate) : new[] { sourceUpdate }; From 444a484cdd096b7ecc813feb17518c393c71b15b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 22:40:29 +0700 Subject: [PATCH 06/52] ux: derive static DataSet action from canonical model capability --- SclSignalSelectionModeWindow.xaml.cs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/SclSignalSelectionModeWindow.xaml.cs b/SclSignalSelectionModeWindow.xaml.cs index 27533599f..f7a7fa089 100644 --- a/SclSignalSelectionModeWindow.xaml.cs +++ b/SclSignalSelectionModeWindow.xaml.cs @@ -1,5 +1,6 @@ using System.Windows; using ArIED61850Tester.Models; +using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -23,6 +24,30 @@ public SclSignalSelectionModeWindow(int iedCount, Iec61850MonitorDevice? targetD : iedCount == 1 ? "1 IED WORKSPACE" : $"{iedCount} IED WORKSPACES"; + + // Preserve the proven Open SCL prompt when it is opened before a concrete device + // card is selected. For a discovered/card-scoped IED, capability comes from the same + // canonical model used by Static DataSet selection. + if (targetDevice is null) + { + CanUseStaticDataSet = true; + StaticDataSetAvailabilityText = "Select + monitor"; + } + else + { + var model = targetDevice.SclWorkspace?.DesignModel ?? targetDevice.LiveDiscoveryModel; + var dataSetCount = model?.DataSets.Count ?? 0; + var reportBackedCount = + Iec61850StaticDataSetAuthoritySelection.BuildReportBackedDataSetReferences(targetDevice).Count; + + CanUseStaticDataSet = dataSetCount > 0; + StaticDataSetAvailabilityText = dataSetCount == 0 + ? "No static DataSet" + : reportBackedCount > 0 + ? $"{dataSetCount} DataSet(s) • report ready" + : $"{dataSetCount} DataSet(s) • no configured RCB"; + } + DataContext = this; } @@ -30,6 +55,8 @@ public SclSignalSelectionModeWindow(int iedCount, Iec61850MonitorDevice? targetD public string ContextHeading { get; } public string ContextSubtitle { get; } public string ImportScopeText { get; } + public bool CanUseStaticDataSet { get; } + public string StaticDataSetAvailabilityText { get; } public bool UseStaticDataSet => _useStaticDataSet; From 47c4988519409eb561bfe670e22f59b80bbe4a8f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 22:40:39 +0700 Subject: [PATCH 07/52] ux: enable static DataSet action only when model contains DataSets --- SclSignalSelectionModeWindow.xaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SclSignalSelectionModeWindow.xaml b/SclSignalSelectionModeWindow.xaml index f4668d997..1365a1f40 100644 --- a/SclSignalSelectionModeWindow.xaml +++ b/SclSignalSelectionModeWindow.xaml @@ -50,10 +50,11 @@