From 431efde21c0c60c4ba3f5d171dda07b7229d5424 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:07:00 +0700 Subject: [PATCH 01/11] feat(control): discover verified CSWI Pos outside report DataSet without auto-selecting signals --- Services/Iec61850ControlIdentity.cs | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Services/Iec61850ControlIdentity.cs b/Services/Iec61850ControlIdentity.cs index 95bae27cb..d2d69d03d 100644 --- a/Services/Iec61850ControlIdentity.cs +++ b/Services/Iec61850ControlIdentity.cs @@ -41,6 +41,38 @@ public static bool NeedsInspection(SignalDefinition signal, bool force) (force || !signal.ControlModelResolved || (signal.ControlSupportsOperate && signal.ControlCurrentValue == "-")); + /// + /// A control is NOT a DataSet member merely because its feedback Pos.stVal + /// is reported. Yet a verified live CSWI.Pos may exist in another LD and + /// must remain discoverable without changing the operator's signal selection. + /// Candidate admission is structural only; no control is enabled until + /// the exact live ctlModel and Oper structure pass native inspection. + /// + public static bool IsIndependentCswiCandidate(SignalDefinition signal) + => signal.IsValidControlObject && !signal.IsGenericControl && + string.Equals(SignalDefinition.DetectLogicalNodeClass(signal.LogicalNode), + "CSWI", StringComparison.OrdinalIgnoreCase) && + Normalize(signal.ObjectReference).EndsWith(".Pos", + StringComparison.OrdinalIgnoreCase); + + public static SignalDefinition[] IndependentCswiInspectionCandidates( + IEnumerable signals, int budget = 24) + { + if (budget <= 0) return []; + return signals.Where(s => !s.IsSelected && IsIndependentCswiCandidate(s)) + .GroupBy(s => Normalize(s.ObjectReference), StringComparer.OrdinalIgnoreCase) + .Select(g => g.OrderByDescending(s => s.ControlModelResolved) + .ThenByDescending(s => s.ControlSupportsOperate).First()) + .OrderBy(s => Normalize(s.ObjectReference), StringComparer.OrdinalIgnoreCase) + .Take(budget).ToArray(); + } + + public static SignalDefinition[] VerifiedIndependentCswiControls( + IEnumerable signals) + => IndependentCswiInspectionCandidates(signals) + .Where(s => s.ControlModelResolved && s.ControlSupportsOperate) + .ToArray(); + public static SignalDefinition[] InspectionRepresentatives(IEnumerable signals) => DistinctSelected(signals).Where(s => !s.ControlModelResolved).ToArray(); } From 615adcde0c402c59867dbd6431fa33678fdd9de2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:12:25 +0700 Subject: [PATCH 02/11] feat(control): show live-verified cross-DataSet CSWI in Command Dock without changing DataSet selection --- Models/MonitorModels.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index b7be5cff0..8770ca1b4 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -742,7 +742,19 @@ private void RefreshCommandSignals() // CO DataObjects including ZIV ctlModel=0 so commissioning can explicitly // read/configure them; every physical action is still gated by typed // live ctlModel + CDC proof in SignalDefinition/runtime. + // An actual CSWI.Pos can be in another LD and absent from every + // reporting DataSet. Surface it only AFTER current live ctlModel + // inspection proves it operable; never mutate IsSelected or report + // coverage to make the command appear. This cannot grant authority + // to StatusOnly XCBR/XSWI feedback objects. var selected = Iec61850ControlIdentity.DistinctSelected(Signals) + .Concat(IsConnected && LiveDiscoveryModel is not null + ? Iec61850ControlIdentity.VerifiedIndependentCswiControls(Signals) + : []) + .GroupBy(signal => Iec61850ControlIdentity.Normalize(signal.ObjectReference), + StringComparer.OrdinalIgnoreCase) + .Select(group => group.OrderByDescending(signal => signal.IsSelected) + .ThenByDescending(signal => signal.ControlSupportsOperate).First()) .OrderBy(signal => signal.SortPriority) .ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase) .ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase) From ffb1ffc81c583bedae9abc8a6a5fd4dd1cb4e440 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:12:47 +0700 Subject: [PATCH 03/11] fix(control): inspect up to 24 live CSWI outside static DataSets before showing verified command controls --- MainWindow.CommandPanelUx.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/MainWindow.CommandPanelUx.cs b/MainWindow.CommandPanelUx.cs index ee907c8cd..71fd39aa1 100644 --- a/MainWindow.CommandPanelUx.cs +++ b/MainWindow.CommandPanelUx.cs @@ -306,8 +306,20 @@ private async Task PreloadControlModelsAsync() if (device.CommandSignals.Any(signal => signal.ControlCommandBusy)) continue; - // Cache aliases of the same DataObject share one ctlModel probe. - var candidates = Iec61850ControlIdentity.InspectionRepresentatives(device.Signals); + // DataSets report XCBR/XSWI status, not necessarily their + // command-owning CSWI in another LD. Probe a bounded set of + // exact live-discovered CSWI.Pos candidates read-only; never + // infer operability from their names or the DataSet feedback. + var selectedCandidates = Iec61850ControlIdentity + .InspectionRepresentatives(device.Signals); + var independentCswi = device.LiveDiscoveryModel is not null + ? Iec61850ControlIdentity.IndependentCswiInspectionCandidates(device.Signals) + : []; + var candidates = selectedCandidates.Concat(independentCswi) + .Where(signal => !signal.ControlModelResolved) + .DistinctBy(signal => Iec61850ControlIdentity.Normalize(signal.ObjectReference), + StringComparer.OrdinalIgnoreCase) + .ToArray(); if (candidates.Length == 0) continue; From 3334692185d7019b01ff0f70969c27f86cd5e64f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:16:04 +0700 Subject: [PATCH 04/11] test(control): GR_X cross-LD CSWI candidates, status-only safeguards, 24-probe cap, and no auto-select --- ...P0CswiCrossDataSetCommandDiscoveryTests.cs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs diff --git a/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs b/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs new file mode 100644 index 000000000..33b85b2ea --- /dev/null +++ b/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs @@ -0,0 +1,94 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// Structural candidates never grant operate permission. Only live +/// ctlModel + exact CDC/type/semantic proof makes a cross-DataSet CSWI visible. +public sealed class P0CswiCrossDataSetCommandDiscoveryTests +{ + private static SignalDefinition Control(string path, bool selected = false) + => new() + { + ObjectReference = path, + Name = path, + FunctionalConstraint = "CO", + ControlCdc = "DPC", + IsControlSignal = true, + IsSelected = selected, + ControlModelText = "Auto-detect" + }; + + [Fact] + public void ControlsInOtherLogicalDevicesAreFoundWithoutDataSetMembership() + { + var cbFeedback = Control("GR_X_7SX85CB1/XCBR1.Pos", selected: true); + cbFeedback.ControlModelText = "Status only"; + var disconnector = Control("GR_X_7SX85Ds1/XSWI1.Pos", selected: true); + disconnector.ControlModelText = "Status only"; + var cswi = Control("GR_X_7SX85CB1/CSWI1.Pos"); + var other = Control("GR_X_7SX85Ds1/CSWI2.Pos"); + var nameplate = Control("GR_X_7SX85CB1/CSWI1.Mod"); + var candidates = Iec61850ControlIdentity.IndependentCswiInspectionCandidates( + [cbFeedback, disconnector, cswi, other, nameplate]); + Assert.Equal(2, candidates.Length); + Assert.Contains(cswi, candidates); + Assert.Contains(other, candidates); + Assert.False(cswi.IsSelected); + Assert.Empty(Iec61850ControlIdentity.VerifiedIndependentCswiControls(candidates)); + + // StatusOnly or declaration-only never enables a command. + cswi.ControlModelText = "Status only"; + Assert.Empty(Iec61850ControlIdentity.VerifiedIndependentCswiControls(candidates)); + cswi.ControlModelText = "SBO • Enhanced security"; + Assert.Empty(Iec61850ControlIdentity.VerifiedIndependentCswiControls(candidates)); + cswi.AcceptVerifiedLiveControlCdc("DPC"); + cswi.ControlSemanticVerified = true; + Assert.True(cswi.ControlSupportsOperate); + Assert.Equal(cswi, + Assert.Single(Iec61850ControlIdentity.VerifiedIndependentCswiControls(candidates))); + Assert.False(cswi.IsSelected); + Assert.False(cbFeedback.ControlSupportsOperate); + Assert.False(disconnector.ControlSupportsOperate); + } + + [Fact] + public void CrossDataSetCswiProbeIsBoundedDeduplicatedAndDoesNotChangeUserSelection() + { + var signals = Enumerable.Range(1, 80) + .Select(i => Control($"GR_X_7SX85CB1/CSWI{i}.Pos")) + .ToArray(); + var duplicate = Control(signals[0].ObjectReference); + var candidates = Iec61850ControlIdentity.IndependentCswiInspectionCandidates( + signals.Append(duplicate), budget: 24); + Assert.Equal(24, candidates.Length); + Assert.Equal(24, candidates.Select(x => x.ObjectReference) + .Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.Empty(Iec61850ControlIdentity.IndependentCswiInspectionCandidates(signals, 0)); + Assert.All(signals, x => Assert.False(x.IsSelected)); + } + + [Fact] + public void CommandDockUsesVerifiedLiveCswiAndPreloadsWithoutMmsWrite() + { + string Source(string file) + { + for (DirectoryInfo? root = new(AppContext.BaseDirectory); + root is not null; root = root.Parent) + { + var candidate = Path.Combine(root.FullName, + file.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(candidate)) return File.ReadAllText(candidate); + } + throw new FileNotFoundException(file); + } + var monitor = Source("Models/MonitorModels.cs"); + var preload = Source("MainWindow.CommandPanelUx.cs"); + Assert.Contains("VerifiedIndependentCswiControls(Signals)", monitor); + Assert.Contains("IsConnected && LiveDiscoveryModel is not null", monitor); + Assert.Contains("IndependentCswiInspectionCandidates(device.Signals)", preload); + Assert.Contains("_runtime.InspectControlAsync(", preload); + Assert.DoesNotContain("ConfigureControlModelAsync(", preload); + Assert.DoesNotContain("ExecuteControlAsync(", preload); + } +} From 9155eeffe2ef9924cdb7a96bbf4c4d750046c6e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:18:37 +0700 Subject: [PATCH 05/11] feat(diag): disclose discovered cross-DataSet CSWI vs verified operability and bounded inspected targets --- Services/DiagnosticReportBuilder.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index a79fd0f55..e896e249a 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -199,6 +199,23 @@ public static async Task BuildAsync( if (evidence.Length > 480) evidence = evidence[..480] + "…"; builder.AppendLine($" {command.ObjectReference} | CDC={command.ControlCdc} | liveVerifiedCdc={command.VerifiedLiveControlCdc} | dataSetCdc={command.DataSetAdvisoryCdc} | model={command.ControlModelText} | canOperate={command.ControlSupportsOperate} | {evidence}"); } + // A static DataSet may report XCBR/XSWI Pos but omit the + // separate command-owning CSWI.Pos in another LD. Report the + // live discovery candidates independently of user selection; + // diagnostic visibility NEVER grants an MMS control command. + var independentCswi = device.LiveDiscoveryModel is not null + ? Iec61850ControlIdentity.IndependentCswiInspectionCandidates(device.Signals) + : []; + var cswiTotal = device.Signals + .Count(s => Iec61850ControlIdentity.IsIndependentCswiCandidate(s)); + builder.AppendLine($"CSWI outside DataSet: live candidates={cswiTotal}, bounded inspections={independentCswi.Length}, typed-operable={independentCswi.Count(s => s.ControlSupportsOperate)}, unresolved/status-only={independentCswi.Count(s => !s.ControlSupportsOperate)}; discovery does not imply command authority"); + foreach (var cswi in independentCswi.Take(12)) + { + var note = (cswi.ControlInspectionEvidence ?? string.Empty) + .Replace('\\r', ' ').Replace('\\n', ' '); + if (note.Length > 180) note = note[..180] + "…"; + builder.AppendLine($" CSWI {cswi.ObjectReference} | model={cswi.ControlModelText} | verifiedCDC={cswi.VerifiedLiveControlCdc} | operable={cswi.ControlSupportsOperate} | {note}"); + } builder.AppendLine($"Logical Devices : {EmptyAsUnavailable(device.LogicalDeviceSummary)}"); builder.AppendLine($"Identity source : {EmptyAsUnavailable(device.IdentitySource)}"); From 5aca4cd70825157536977792e0a31cd7346cf99b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:18:58 +0700 Subject: [PATCH 06/11] fix(control): preload independent verified CSWI even when no command is selected in static DataSet --- MainWindow.CommandPanelUx.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/MainWindow.CommandPanelUx.cs b/MainWindow.CommandPanelUx.cs index 71fd39aa1..41c5dc5c2 100644 --- a/MainWindow.CommandPanelUx.cs +++ b/MainWindow.CommandPanelUx.cs @@ -296,7 +296,10 @@ private async Task PreloadControlModelsAsync() do { _controlModelPreloadPending = false; - foreach (var device in Devices.Where(device => device.IsConnected && device.SelectedControlSignalCount > 0)) + foreach (var device in Devices.Where(device => device.IsConnected && + (device.SelectedControlSignalCount > 0 || + (device.LiveDiscoveryModel is not null && device.Signals.Any( + Iec61850ControlIdentity.IsIndependentCswiCandidate))))) { foreach (var signal in device.Signals.Where(signal => signal.IsSelected && signal.IsValidControlObject)) EnsureDefaultControlSafetyChecks(signal); From f0119a4b0708d8edb82fb27819007daa09888c43 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:19:13 +0700 Subject: [PATCH 07/11] test(control): require cross-DataSet CSWI preload even when no selected command and visible diagnostics --- .../ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs b/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs index 33b85b2ea..909abe882 100644 --- a/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs +++ b/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs @@ -87,7 +87,13 @@ string Source(string file) Assert.Contains("VerifiedIndependentCswiControls(Signals)", monitor); Assert.Contains("IsConnected && LiveDiscoveryModel is not null", monitor); Assert.Contains("IndependentCswiInspectionCandidates(device.Signals)", preload); + Assert.Contains("device.SelectedControlSignalCount > 0 ||", preload); + Assert.Contains("device.Signals.Any(", preload); Assert.Contains("_runtime.InspectControlAsync(", preload); + var diagnostic = Source("Services/DiagnosticReportBuilder.cs"); + Assert.Contains("CSWI outside DataSet: live candidates=", diagnostic); + Assert.Contains("typed-operable=", diagnostic); + Assert.Contains("operable={cswi.ControlSupportsOperate}", diagnostic); Assert.DoesNotContain("ConfigureControlModelAsync(", preload); Assert.DoesNotContain("ExecuteControlAsync(", preload); } From e152eac97913aee6ad0970c96973ced0dff9f471 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:22:33 +0700 Subject: [PATCH 08/11] fix(ci): repair C# CR/LF character literals in CSWI diagnostic line --- Services/DiagnosticReportBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index e896e249a..558ba735c 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -212,7 +212,7 @@ public static async Task BuildAsync( foreach (var cswi in independentCswi.Take(12)) { var note = (cswi.ControlInspectionEvidence ?? string.Empty) - .Replace('\\r', ' ').Replace('\\n', ' '); + .Replace('\r', ' ').Replace('\n', ' '); if (note.Length > 180) note = note[..180] + "…"; builder.AppendLine($" CSWI {cswi.ObjectReference} | model={cswi.ControlModelText} | verifiedCDC={cswi.VerifiedLiveControlCdc} | operable={cswi.ControlSupportsOperate} | {note}"); } From 8acca8a4a57994f97608f7824bcc6183a1f31b48 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:23:13 +0700 Subject: [PATCH 09/11] fix(control): keep unclassified CSWI candidates eligible for typed read-only inspection --- Services/Iec61850ControlIdentity.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Services/Iec61850ControlIdentity.cs b/Services/Iec61850ControlIdentity.cs index d2d69d03d..ec3ca30d3 100644 --- a/Services/Iec61850ControlIdentity.cs +++ b/Services/Iec61850ControlIdentity.cs @@ -45,11 +45,13 @@ public static bool NeedsInspection(SignalDefinition signal, bool force) /// A control is NOT a DataSet member merely because its feedback Pos.stVal /// is reported. Yet a verified live CSWI.Pos may exist in another LD and /// must remain discoverable without changing the operator's signal selection. - /// Candidate admission is structural only; no control is enabled until + /// Candidate admission deliberately does not require a pre-proved CDC: + /// the purpose of the bounded native inspection is to verify that CDC. + /// No control is enabled until /// the exact live ctlModel and Oper structure pass native inspection. /// public static bool IsIndependentCswiCandidate(SignalDefinition signal) - => signal.IsValidControlObject && !signal.IsGenericControl && + => signal.IsValidControlObject && string.Equals(SignalDefinition.DetectLogicalNodeClass(signal.LogicalNode), "CSWI", StringComparison.OrdinalIgnoreCase) && Normalize(signal.ObjectReference).EndsWith(".Pos", From fec8a4a2e5ae541c19c88549260793ae06231304 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:23:28 +0700 Subject: [PATCH 10/11] test(control): missing provisional CDC must not erase CSWI candidate before live inspection --- tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs b/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs index 909abe882..a5ae820ca 100644 --- a/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs +++ b/tests/ARSAS.Tests/P0CswiCrossDataSetCommandDiscoveryTests.cs @@ -29,6 +29,9 @@ public void ControlsInOtherLogicalDevicesAreFoundWithoutDataSetMembership() var cswi = Control("GR_X_7SX85CB1/CSWI1.Pos"); var other = Control("GR_X_7SX85Ds1/CSWI2.Pos"); var nameplate = Control("GR_X_7SX85CB1/CSWI1.Mod"); + // Genuinely discoverable CSWI may have no CDC yet; native inspection + // must establish it, not prefilter this exact DO away as generic. + other.ControlCdc = ""; var candidates = Iec61850ControlIdentity.IndependentCswiInspectionCandidates( [cbFeedback, disconnector, cswi, other, nameplate]); Assert.Equal(2, candidates.Length); From 1b9ce4f3193ebb6a43f31b00514d82cee31fa868 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 09:24:23 +0700 Subject: [PATCH 11/11] fix(diag): distinguish unselected cross-DataSet CSWI candidate budget from completed inspections --- Services/DiagnosticReportBuilder.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 558ba735c..df766a6d7 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -207,8 +207,9 @@ public static async Task BuildAsync( ? Iec61850ControlIdentity.IndependentCswiInspectionCandidates(device.Signals) : []; var cswiTotal = device.Signals - .Count(s => Iec61850ControlIdentity.IsIndependentCswiCandidate(s)); - builder.AppendLine($"CSWI outside DataSet: live candidates={cswiTotal}, bounded inspections={independentCswi.Length}, typed-operable={independentCswi.Count(s => s.ControlSupportsOperate)}, unresolved/status-only={independentCswi.Count(s => !s.ControlSupportsOperate)}; discovery does not imply command authority"); + .Count(s => !s.IsSelected && + Iec61850ControlIdentity.IsIndependentCswiCandidate(s)); + builder.AppendLine($"CSWI outside DataSet: live candidates={cswiTotal}, bounded candidates={independentCswi.Length}, typed-operable={independentCswi.Count(s => s.ControlSupportsOperate)}, unresolved/status-only={independentCswi.Count(s => !s.ControlSupportsOperate)}; discovery does not imply command authority"); foreach (var cswi in independentCswi.Take(12)) { var note = (cswi.ControlInspectionEvidence ?? string.Empty)