From 967be84bb8b168d024b73b893bf165c1990d9528 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:42:18 +0700 Subject: [PATCH 01/18] feat(control-fat): create copy-only structured IED control evidence snapshot --- Models/Iec61850ControlFatEvidence.cs | 57 ++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 Models/Iec61850ControlFatEvidence.cs diff --git a/Models/Iec61850ControlFatEvidence.cs b/Models/Iec61850ControlFatEvidence.cs new file mode 100644 index 000000000..1e39b4b17 --- /dev/null +++ b/Models/Iec61850ControlFatEvidence.cs @@ -0,0 +1,57 @@ +namespace ArIED61850Tester.Models; + +/// +/// Copy-only field FAT snapshot. Never polls the relay, sends MMS requests, +/// changes a control lease, or claims physical feedback from a service reply. +/// +public sealed record Iec61850ControlFatEvidence( + DateTimeOffset CapturedAtUtc, + string Ied, + string ControlObject, + string Cdc, + string ControlModel, + string CurrentValueAtInspection, + string RequestedValue, + string OriginCategory, + string OriginatorId, + bool InterlockCheck, + bool SynchroCheck, + bool Test, + string ResultStage, + string ResultSummary, + string ControlNumber, + string CommandTermination, + string WireTimeline, + string DiagnosticEvidence) +{ + public string ToClipboardText() + { + static string Line(string? value) => + (value ?? "").Replace("\r", " ").Replace("\n", " ").Trim(); + + return string.Join(Environment.NewLine, new[] + { + "ARSAS IEC 61850 CONTROL FAT EVIDENCE", + "Captured UTC: " + CapturedAtUtc.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture), + "IED: " + Line(Ied), + "Object: " + Line(ControlObject), + "CDC / ctlModel: " + Line(Cdc) + " / " + Line(ControlModel), + "IED value at last inspection: " + Line(CurrentValueAtInspection), + "Requested value: " + Line(RequestedValue), + "Origin: " + Line(OriginCategory) + " / " + Line(OriginatorId), + "InterlockCheck: " + InterlockCheck.ToString(System.Globalization.CultureInfo.InvariantCulture), + "SynchroCheck: " + SynchroCheck.ToString(System.Globalization.CultureInfo.InvariantCulture), + "Test: " + Test.ToString(System.Globalization.CultureInfo.InvariantCulture), + "Result: " + Line(ResultStage), + "Summary: " + Line(ResultSummary), + "ctlNum: " + Line(ControlNumber), + "CommandTermination: " + Line(CommandTermination), + "MMS steps (evidence only): " + Line(WireTimeline), + "", + "IEC 61850 DIAGNOSTICS", + DiagnosticEvidence ?? "", + "", + "NOTICE: MMS service acceptance does not prove primary equipment movement. Verify independent process feedback and Wireshark capture." + }); + } +} From 65ca39e26b72dacac596bdb721f31513e01f3c6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:42:38 +0700 Subject: [PATCH 02/18] feat(control-fat): add safe one-click copy of full control evidence without MMS traffic --- ControlCommandWindow.xaml.cs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 5cbd05972..8bfe262bc 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -56,7 +56,14 @@ public partial class ControlCommandWindow : Window, INotifyPropertyChanged public string CurrentValue { get => _currentValue; private set => Set(ref _currentValue, value); } public string EngineStatusText { get => _engineStatusText; private set => Set(ref _engineStatusText, value); } public string SequenceText { get => _sequenceText; private set => Set(ref _sequenceText, value); } - public string ControlEvidenceText { get => _controlEvidenceText; private set => Set(ref _controlEvidenceText, value); } + public string ControlEvidenceText + { + get => _controlEvidenceText; + private set + { + if (Set(ref _controlEvidenceText, value)) Raise(nameof(CanCopyEvidence)); + } + } public string SelectedValue { get => _selectedValue; set { if (Set(ref _selectedValue, value)) Raise(nameof(CanSend)); } } public string ModelShortText { get => _modelShortText; private set => Set(ref _modelShortText, value); } public string SequenceHint { get => _sequenceHint; private set => Set(ref _sequenceHint, value); } @@ -123,10 +130,13 @@ private set Raise(nameof(CanClose)); Raise(nameof(CanReinspect)); Raise(nameof(CanEdit)); + Raise(nameof(CanCopyEvidence)); Raise(nameof(SendButtonText)); } } public bool CanClose => !_commandFlightActive; + public bool CanCopyEvidence => !IsBusy && !_commandFlightActive && + !string.IsNullOrWhiteSpace(ControlEvidenceText); public bool CanReinspect => !_commandFlightActive && !IsBusy; public bool CanEdit => IsReady && !IsBusy && !_commandAttempted; public bool IsReady @@ -335,6 +345,7 @@ private async void SendCommand_Click(object sender, RoutedEventArgs e) Raise(nameof(CanClose)); Raise(nameof(CanReinspect)); IsBusy = false; + Raise(nameof(CanCopyEvidence)); Raise(nameof(CanSend)); Raise(nameof(CanEdit)); } @@ -501,6 +512,28 @@ private static SolidColorBrush BrushFrom(string hex) return new SolidColorBrush(color); } + private void CopyEvidence_Click(object sender, RoutedEventArgs e) + { + if (!CanCopyEvidence) return; + // Pure local clipboard export: NO read, Select, Operate, Cancel, Write, + // reconnect or other MMS/network action is permitted in this handler. + var snapshot = new Iec61850ControlFatEvidence( + DateTimeOffset.UtcNow, + DeviceSummary, Telegram, ControlCdcText, ControlModelText, + CurrentValue, SelectedValue, SelectedOriginCategory, OriginatorId, + InterlockCheck, SynchroCheck, TestMode, CommandStage, CommandStatus, + ControlNumberText, TerminationText, WireTimelineText, ControlEvidenceText); + try + { + Clipboard.SetText(snapshot.ToClipboardText()); + CopyEvidenceButton.Content = "Copied"; + } + catch (System.Runtime.InteropServices.ExternalException) + { + CopyEvidenceButton.Content = "Copy failed"; + } + } + private void Close_Click(object sender, RoutedEventArgs e) => Close(); From d85b2f056535bc8a28c877a2d25eaae20c3d2f95 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:42:58 +0700 Subject: [PATCH 03/18] feat(control-fat): place compact Copy evidence action inside pinned result panel --- ControlCommandWindow.xaml | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/ControlCommandWindow.xaml b/ControlCommandWindow.xaml index cd5905a3e..dad5a4ec1 100644 --- a/ControlCommandWindow.xaml +++ b/ControlCommandWindow.xaml @@ -165,17 +165,32 @@ - - + + + + + + + + + + - - + + - + From 6e5d48e092e14249e1740200ca6b5a523a4b0c1f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:43:20 +0700 Subject: [PATCH 04/18] fix(control-fat): clear old evidence and copy indicator on reinspection --- ControlCommandWindow.xaml.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 8bfe262bc..ae8377550 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -193,6 +193,8 @@ private async Task InspectAsync() ControlNumberText = "Auto"; TerminationText = "—"; WireTimelineText = string.Empty; + ControlEvidenceText = string.Empty; // Do not export stale association evidence. + if (CopyEvidenceButton is not null) CopyEvidenceButton.Content = "Copy evidence"; IsBusy = true; CommandStage = "Inspecting control"; CommandStatus = "Reading ctlModel and current status from the IED…"; From d98bc6c84e8bcf3bb8741a16ce2332df46ec6180 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:43:50 +0700 Subject: [PATCH 05/18] test(control-fat): validate copied exact origin/termination, no stale data or MMS side effects --- .../ControlFatEvidenceExportTests.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/ARSAS.Tests/ControlFatEvidenceExportTests.cs diff --git a/tests/ARSAS.Tests/ControlFatEvidenceExportTests.cs b/tests/ARSAS.Tests/ControlFatEvidenceExportTests.cs new file mode 100644 index 000000000..17d5a9235 --- /dev/null +++ b/tests/ARSAS.Tests/ControlFatEvidenceExportTests.cs @@ -0,0 +1,77 @@ +using System.Xml.Linq; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class ControlFatEvidenceExportTests +{ + [Fact] + public void EvidenceSnapshot_IsDeterministic_AndLabelsWireVsIndependentFeedback() + { + var e = new Iec61850ControlFatEvidence( + new DateTimeOffset(2026, 10, 23, 8, 0, 0, TimeSpan.Zero), + "IED-A • 192.0.2.10:102", "LD0/CSWI1.Pos", "DPC", "SBO enhanced", + "Open", "Closed [10]", "StationControl", "ARSAS", + true, false, false, "Feedback not verified", + "IED accepted; independent process feedback not verified.", + "23", "Positive", "SBOw ✓ → Operate ✓ → Termination ✓", + "LastApplError: none\nMMS request: A1"); + var text = e.ToClipboardText(); + Assert.Contains("2026-10-23T08:00:00.0000000+00:00", text); + Assert.Contains("LD0/CSWI1.Pos", text); + Assert.Contains("Requested value: Closed [10]", text); + Assert.Contains("Origin: StationControl / ARSAS", text); + Assert.Contains("ctlNum: 23", text); + Assert.Contains("CommandTermination: Positive", text); + Assert.Contains("MMS steps (evidence only)", text); + Assert.Contains("LastApplError: none", text); + Assert.Contains("MMS service acceptance does not prove primary equipment movement", text); + } + + [Fact] + public void SingleLineIdentifiers_CannotImpersonateOtherEvidenceFields() + { + var e = new Iec61850ControlFatEvidence( + DateTimeOffset.UnixEpoch, "IED-1\nResult: confirmed", + "LD0/CSWI1.Pos", "DPC", "SBO", "", "", + "StationControl", "ARSAS", true, false, false, + "Not sent", "No command", "Auto", "—", "", ""); + var output = e.ToClipboardText(); + Assert.Contains("IED: IED-1 Result: confirmed", output); + Assert.DoesNotContain("IED-1\nResult: confirmed", output); + } + + [Fact] + public void PopupCopyEvidence_IsDisabledDuringFlightAndPerformsNoMmsIo() + { + var markup = ReadSource("ControlCommandWindow.xaml"); + XDocument.Parse(markup); + Assert.Contains("Content=\"Copy evidence\"", markup); + Assert.Contains("Click=\"CopyEvidence_Click\"", markup); + Assert.Contains("IsEnabled=\"{Binding CanCopyEvidence}\"", markup); + var source = ReadSource("ControlCommandWindow.xaml.cs"); + Assert.Contains("Clipboard.SetText(snapshot.ToClipboardText())", source); + Assert.Contains("!_commandFlightActive", source); + Assert.Contains("ControlEvidenceText = string.Empty; // Do not export stale association evidence.", source); + var handler = source.Split("private void CopyEvidence_Click(")[1] + .Split("private void Close_Click(", StringSplitOptions.None)[0]; + Assert.DoesNotContain("ExecuteControlAsync(", handler); + Assert.DoesNotContain("InspectControlAsync(", handler); + Assert.DoesNotContain("OperateAsync(", handler); + Assert.DoesNotContain("WriteBrowser", handler); + Assert.DoesNotContain("CancelAsync(", handler); + Assert.DoesNotContain("SelectAsync(", handler); + } + + private static string ReadSource(string file) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var path = Path.Combine(directory.FullName, file); + if (File.Exists(path)) return File.ReadAllText(path); + directory = directory.Parent; + } + throw new FileNotFoundException(file); + } +} From 8dfb43b0b722c0be176a3e2152483b2958ad254d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:19:49 +0700 Subject: [PATCH 06/18] fix(wpf): use OneWay for ctlNum and CommandTermination display bindings --- ControlCommandWindow.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ControlCommandWindow.xaml b/ControlCommandWindow.xaml index dad5a4ec1..6d89da6b9 100644 --- a/ControlCommandWindow.xaml +++ b/ControlCommandWindow.xaml @@ -138,13 +138,13 @@ - - From d41892529188a3095e5e3e018758be1740e502bd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:20:25 +0700 Subject: [PATCH 07/18] fix(browser): route only live CO Oper/SBOw ctlVal leaf to verified native Control dialog --- MainWindow.IedBrowser.ContextControl.cs | 103 +++++++++++++++++++++++- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/MainWindow.IedBrowser.ContextControl.cs b/MainWindow.IedBrowser.ContextControl.cs index 706d22064..a6b686888 100644 --- a/MainWindow.IedBrowser.ContextControl.cs +++ b/MainWindow.IedBrowser.ContextControl.cs @@ -19,10 +19,105 @@ public partial class MainWindow if (device is not { IsConnected: true, IsDemo: false, IsBusy: false } || device.LiveDiscoveryModel is null || device.LiveValues.IsOffline) return null; - // A selected ST/MX/status DA is not the controlling DO. Never infer - // Control authority from a cached parent DO after focus moves to a leaf. - if (IedBrowserDetailList?.SelectedItem is IedBrowserDetailRow - { Kind: "Data", IsDataObject: false }) + // Only a live CO control-value member may open Control from a DA row. + // ST/MX, generic CO parameters, and CF ctlModel are not Operate targets. + var leaf = IedBrowserDetailList?.SelectedItem as IedBrowserDetailRow; + var controlValueLeaf = leaf is { Kind: "Data", IsDataObject: false, + FunctionalConstraint: "CO" } && + leaf.ValueReference.Split(' + var page = _iedBrowserNavigation.Current; + var focused = page?.Payload switch + { + LiveIedDataObjectModel obj => obj.Reference, + LiveIedLogicalNodeModel => _iedBrowserSelectedDoReference, + _ => null + }; + if (string.IsNullOrWhiteSpace(focused)) return null; + + var slash = focused.IndexOf('/'); + var name = slash > 0 ? focused[(slash + 1)..] : ""; + var dot = name.IndexOf('.'); + if (slash < 1 || dot < 1) return null; + + // Exact owner lookup within one LD/LN, not a visible-grid scan. + var source = device.LiveDiscoveryModel.LogicalDevices + .FirstOrDefault(ld => + string.Equals(ld.MmsDomain, focused[..slash], StringComparison.Ordinal))? + .LogicalNodes.FirstOrDefault(ln => + string.Equals(ln.Name, name[..dot], StringComparison.Ordinal))? + .DataObjects.FirstOrDefault(obj => + string.Equals(obj.Reference, focused, StringComparison.Ordinal)); + if (source is null) return null; + if (controlValueLeaf && !source.Attributes.Any(a => + a.FunctionalConstraint == "CO" && + string.Equals(a.MmsReference, leaf!.ValueReference, StringComparison.Ordinal))) + return null; // CO target must come from the exact current live DO. + + // This collection is already filtered by the Command Dock live + // operational verification. All StatusOnly/unknown stay outside. + var candidates = device.OperableCommandSignals.Where(signal => + signal.IsValidControlObject && + signal.HasControlServiceEvidence && + signal.ControlSemanticVerified && + signal.ControlModelResolved && + string.Equals(signal.ObjectReference, focused, StringComparison.Ordinal)) + .Take(2).ToArray(); + if (candidates.Length != 1) return null; + var command = candidates[0]; + var inspected = new Iec61850ControlCapabilities + { + ObjectReference = command.ObjectReference, + ControlModelReference = command.ControlModelReference, + StatusReference = command.ControlStatusReference, + ControlModel = command.ControlModelKind, + ControlCdc = command.VerifiedLiveControlCdc, + CtlValSignature = command.ControlValueType, + EngineControlServiceAvailable = command.HasControlServiceEvidence, + IsOperationallyReady = command.ControlSupportsOperate + }; + var allow = IedBrowserControlEligibility.Check( + focusIsExactDo: true, focusedReference: focused, + onlineDo: source, liveControl: inspected, + currentAssociationVerified: !device.LiveValues.IsOffline, + operatorAuthorized: true); + return allow.CanControl ? (command, source) : null; + } + + private void RefreshIedBrowserControlButton() + { + if (IedBrowserControlButton is null) return; + var current = BrowserSelectedLiveControl(); + IedBrowserControlButton.IsEnabled = current is not null; + IedBrowserControlButton.ToolTip = current is null + ? "Control disabled: focus a DO with existing live-proven ctlModel/CDC/CO. StatusOnly is never operable." + : "Inspect exact native IEC Control model and confirm the command explicitly; no automatic Operate."; + } + + private void IedBrowserControl_Click(object sender, RoutedEventArgs e) + { + var control = BrowserSelectedLiveControl(); + if (control is null || SelectedDevice is not { } device) + { + RefreshIedBrowserControlButton(); + return; + } + // Reuse native Direct/SBO normal/enhanced safety pipeline. Its + // Window_Loaded explicitly re-inspects current wire authority before + // enabling Send Command; selection alone never issues any command. + var dialog = new ControlCommandWindow(_runtime, device, control.Value.Signal) + { + Owner = this + }; + dialog.ShowDialog(); + RefreshIedBrowserControlButton(); + } +} +, '.').LastOrDefault() == "ctlVal" && + (leaf.ValueReference.Contains("$Oper$", StringComparison.Ordinal) || + leaf.ValueReference.Contains("$SBOw$", StringComparison.Ordinal) || + leaf.ValueReference.Contains(".Oper.", StringComparison.Ordinal) || + leaf.ValueReference.Contains(".SBOw.", StringComparison.Ordinal)); + if (leaf is { Kind: "Data", IsDataObject: false } && !controlValueLeaf) return null; var page = _iedBrowserNavigation.Current; var focused = page?.Payload switch From 658d66e6196a8999a3f89eae5c63373c5cd85c78 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:20:49 +0700 Subject: [PATCH 08/18] fix(ui): lock status footer at 34px single-line regardless of diagnostic length --- MainWindow.xaml | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index 414a0c98e..a71295a42 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -2474,14 +2474,32 @@ - - - - - - - - + + + + + + + + + + + + + + + + From 91908a3e5b909d4bfd01263e450d3612b55fe14f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:21:24 +0700 Subject: [PATCH 09/18] feat(browser): resolve exact live ctlModel CF leaf to existing guarded control model editor --- MainWindow.IedBrowser.ContextControl.cs | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/MainWindow.IedBrowser.ContextControl.cs b/MainWindow.IedBrowser.ContextControl.cs index a6b686888..734f11f98 100644 --- a/MainWindow.IedBrowser.ContextControl.cs +++ b/MainWindow.IedBrowser.ContextControl.cs @@ -83,6 +83,53 @@ public partial class MainWindow return allow.CanControl ? (command, source) : null; } + // ctlModel is FC=CF CONFIGURATION, never a Control/Operate service. + // Reuse the proven dedicated read → exact expected model → Write → readback + // dialog already used by Command Dock. This selection is read-only until + // the operator explicitly opens the guarded configuration dialog. + private SignalDefinition? BrowserSelectedControlModelSignal() + { + var device = SelectedDevice; + if (device is not { IsConnected: true, IsDemo: false, IsBusy: false } || + device.LiveDiscoveryModel is null || device.LiveValues.IsOffline || + _iedBrowserReadOperation is not null || + IedBrowserDetailList?.SelectedItem is not IedBrowserDetailRow + { Kind: "Data", IsDataObject: false, + FunctionalConstraint: "CF" } row || + !row.ValueReference.EndsWith("$ctlModel", StringComparison.Ordinal) && + !row.ValueReference.EndsWith(".ctlModel", StringComparison.Ordinal)) + return null; + + var page = _iedBrowserNavigation.Current; + var focused = page?.Payload switch + { + LiveIedDataObjectModel o => o.Reference, + LiveIedLogicalNodeModel => _iedBrowserSelectedDoReference, + _ => null + }; + if (string.IsNullOrWhiteSpace(focused)) return null; + var slash = focused.IndexOf('/'); + var dot = slash < 0 ? -1 : focused.IndexOf('.', slash + 1); + if (slash < 1 || dot < slash + 2) return null; + + var source = device.LiveDiscoveryModel.LogicalDevices + .FirstOrDefault(ld => string.Equals(ld.MmsDomain, focused[..slash], + StringComparison.Ordinal))? + .LogicalNodes.FirstOrDefault(ln => string.Equals(ln.Name, + focused[(slash + 1)..dot], StringComparison.Ordinal))? + .DataObjects.FirstOrDefault(o => + string.Equals(o.Reference, focused, StringComparison.Ordinal)); + if (source is null || !source.Attributes.Any(a => + a.FunctionalConstraint == "CF" && + string.Equals(a.MmsReference, row.ValueReference, StringComparison.Ordinal))) + return null; + + return device.OperableCommandSignals.Concat(device.CommandSignals) + .Where(signal => signal.IsValidControlObject && + string.Equals(signal.ObjectReference, focused, StringComparison.Ordinal)) + .Distinct().Take(2).FirstOrDefault(); + } + private void RefreshIedBrowserControlButton() { if (IedBrowserControlButton is null) return; From b03994764a5cd9425ddbf75553d4856f16c2e5e5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:21:47 +0700 Subject: [PATCH 10/18] feat(browser): enable ctlModel leaf Write using existing validated CF configuration/readback dialog --- MainWindow.IedBrowser.TypedDataWrite.cs | 44 +++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/MainWindow.IedBrowser.TypedDataWrite.cs b/MainWindow.IedBrowser.TypedDataWrite.cs index 7603ab978..6779d0c62 100644 --- a/MainWindow.IedBrowser.TypedDataWrite.cs +++ b/MainWindow.IedBrowser.TypedDataWrite.cs @@ -122,15 +122,20 @@ a.FunctionalConstraint is "CF" or "SP" or "DC" && private void RefreshIedBrowserWriteButton() { if (IedBrowserWriteButton is null) return; - var selected = BrowserWritableSelectedAttribute(); - var selectedDo = selected is null ? BrowserWritableSelectedDo() : null; - IedBrowserWriteButton.IsEnabled = selected is not null || selectedDo is not null; - IedBrowserWriteButton.Content = selectedDo is not null ? "Write DAs…" : "Write DA…"; - IedBrowserWriteButton.ToolTip = selected is not null - ? "Exact DA: inspect live GVA, review old/new and confirm one MMS Write." - : selectedDo is not null - ? "Exact DO: select changed attributes, inspect and confirm sequential non-atomic Writes." - : "Focus an online CF/SP/DC Data Attribute or its exact DO. Status, control and ctlModel use separate services."; + var ctlModel = BrowserSelectedControlModelSignal(); + var selected = ctlModel is null ? BrowserWritableSelectedAttribute() : null; + var selectedDo = ctlModel is null && selected is null ? BrowserWritableSelectedDo() : null; + IedBrowserWriteButton.IsEnabled = + ctlModel is not null || selected is not null || selectedDo is not null; + IedBrowserWriteButton.Content = ctlModel is not null ? "Write ctlModel…" : + selectedDo is not null ? "Write DAs…" : "Write DA…"; + IedBrowserWriteButton.ToolTip = ctlModel is not null + ? "Read live ctlModel, choose IEC enum 0–4, confirm a guarded CF Write and verify readback." + : selected is not null + ? "Exact DA: inspect live GVA, review old/new and confirm one MMS Write." + : selectedDo is not null + ? "Exact DO: select changed attributes, inspect and confirm sequential non-atomic Writes." + : "Select an exact CF/SP/DC DA, ctlModel CF leaf or owning DO. CO members use Control."; } private void IedBrowserDetailList_SelectionChanged( @@ -146,9 +151,10 @@ private void IedBrowserDetailList_SelectionChanged( private void IedBrowserWrite_Click(object sender, RoutedEventArgs e) { var device = SelectedDevice; - var attribute = BrowserWritableSelectedAttribute(); - var owner = attribute is null ? BrowserWritableSelectedDo() : null; - if (device is null || (attribute is null && owner is null)) + var modelSignal = BrowserSelectedControlModelSignal(); + var attribute = modelSignal is null ? BrowserWritableSelectedAttribute() : null; + var owner = modelSignal is null && attribute is null ? BrowserWritableSelectedDo() : null; + if (device is null || (modelSignal is null && attribute is null && owner is null)) { IedBrowserSubtitle.Text = "Write requires a current online CF/SP/DC attribute or exact owning DO."; @@ -156,7 +162,19 @@ private void IedBrowserWrite_Click(object sender, RoutedEventArgs e) return; } - if (attribute is not null) + if (modelSignal is not null) + { + // Route ctlModel through the existing dedicated engine service, + // not generic CF Write and NEVER through CO Operate. + var dialog = new ControlModelWriteWindow(_runtime, device, modelSignal) + { + Owner = this + }; + var changed = dialog.ShowDialog() == true; + if (changed && device.IsConnected) + _ = RefreshControlValuesAsync(device, force: true); + } + else if (attribute is not null) { var dialog = new BrowserDataWriteWindow(_runtime, device, attribute) { From 400ca92946423600c0d0d25db36008a1bcb5935e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:22:01 +0700 Subject: [PATCH 11/18] fix(browser): permit explicit typed DA write during report monitoring through serialized MMS gate --- MainWindow.IedBrowser.TypedDataWrite.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MainWindow.IedBrowser.TypedDataWrite.cs b/MainWindow.IedBrowser.TypedDataWrite.cs index 6779d0c62..b132804fe 100644 --- a/MainWindow.IedBrowser.TypedDataWrite.cs +++ b/MainWindow.IedBrowser.TypedDataWrite.cs @@ -16,7 +16,7 @@ public partial class MainWindow private LiveIedDataAttributeModel? BrowserWritableSelectedAttribute() { if (SelectedDevice is not { IsConnected: true, IsDemo: false, IsBusy: false, - IsMonitoring: false, IsBrowserReading: false } device || + IsBrowserReading: false } device || device.LiveDiscoveryModel is null || _iedBrowserReadOperation is not null || IedBrowserDetailList?.SelectedItem is not IedBrowserDetailRow @@ -81,7 +81,7 @@ LiveIedLogicalNodeModel ln when private LiveIedDataObjectModel? BrowserWritableSelectedDo() { if (SelectedDevice is not { IsConnected: true, IsDemo: false, IsBusy: false, - IsMonitoring: false, IsBrowserReading: false } device || + IsBrowserReading: false } device || device.LiveValues.IsOffline || device.LiveDiscoveryModel is null || _iedBrowserReadOperation is not null) return null; From e0d1a3637d0dcdba8973e1a66368f87e35c4b8e8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:22:47 +0700 Subject: [PATCH 12/18] fix(mms-write): allow explicit confirmed DA writes while passive RCB monitoring continues on serialized association --- Services/Iec61850MonitorRuntime.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 1d91cabda..b5e7e765a 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -136,7 +136,7 @@ public async Task PreviewBrowserDataAttributeWrite { ArgumentNullException.ThrowIfNull(device); var model = device.LiveDiscoveryModel; - if (device.IsDemo || device.IsMonitoring || device.IsBusy || + if (device.IsDemo || device.IsBusy || model is null || !device.IsConnected || !_sessions.TryGetValue(device.DeviceId, out var session) || !ReferenceEquals(session.Device, device) || !session.Client.IsConnected || @@ -166,7 +166,7 @@ bool Current() => ReferenceEquals(actual, session) && session.Client.IsConnected && ReferenceEquals(device.LiveDiscoveryModel, model) && device.LiveValues.Association == epoch && !device.LiveValues.IsOffline && - device.IsConnected && !device.IsMonitoring && !device.IsBusy && + device.IsConnected && !device.IsBusy && !session.ControlLifetimeCancellation.IsCancellationRequested; return await session.Client.PreviewDataAttributeWriteAsync( reference, Current, cancellationToken).ConfigureAwait(false); @@ -176,7 +176,7 @@ bool Current() => /// Operator-confirmed, same-DO generic MMS data Write using the CURRENT IED /// association. The Browser passes an immutable per-DA plan frozen after /// preview; this runtime rejects SCL-only or stale attribute ownership. - /// No monitoring/reporting or control-flight may be in progress. + /// Uses the same existing serialized MMS association while RCB monitoring can continue;\n /// never changes report ownership or triggers cyclic polling. No control flight may be active. /// public async Task WriteBrowserDataAttributesAsync( Iec61850MonitorDevice device, @@ -188,7 +188,7 @@ public async Task WriteBrowserDataAttributesAsy var plan = IedBrowserTypedWriteBatch.Freeze(confirmedPlan); var model = device.LiveDiscoveryModel; if (!explicitOperatorConfirmation || device.IsDemo || device.IsBusy || - device.IsMonitoring || model is null || !device.IsConnected || + model is null || !device.IsConnected || !_sessions.TryGetValue(device.DeviceId, out var session) || !ReferenceEquals(session.Device, device) || !session.Client.IsConnected || session.ControlLifetimeCancellation.IsCancellationRequested || @@ -230,7 +230,7 @@ bool Current() => device.LiveValues.Association == epoch && !device.LiveValues.IsOffline && device.IsConnected && active.Client.IsConnected && - !device.IsMonitoring && !device.IsBusy && + !device.IsBusy && !active.ControlLifetimeCancellation.IsCancellationRequested && Volatile.Read(ref active.ControlCommandActive) == 0 && Volatile.Read(ref active.ControlFlightQuarantined) == 0; From bc34a10f6325673b3ec061c3575b8d55da8cb5ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:22:58 +0700 Subject: [PATCH 13/18] fix(mms-write): preserve same association approval while RCB monitor remains active --- BrowserBatchWriteWindow.xaml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BrowserBatchWriteWindow.xaml.cs b/BrowserBatchWriteWindow.xaml.cs index 6043f5011..da9bd4413 100644 --- a/BrowserBatchWriteWindow.xaml.cs +++ b/BrowserBatchWriteWindow.xaml.cs @@ -80,7 +80,7 @@ public BrowserBatchWriteWindow( private bool CurrentAssociation => !_lifetime.IsCancellationRequested && _device.IsConnected && - !_device.IsDemo && !_device.IsBusy && !_device.IsMonitoring && + !_device.IsDemo && !_device.IsBusy && !_device.LiveValues.IsOffline && ReferenceEquals(_device.LiveDiscoveryModel, _model) && _device.LiveValues.Association == _association; From 31cc734b5dea1f35bfe5bbe14142f08cd26a3f46 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:23:28 +0700 Subject: [PATCH 14/18] fix(ui): always condense status text to one bounded line; preserve full errors in Diagnostics --- MainWindow.xaml.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index ed7113b7c..ea0ccb186 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -69,7 +69,21 @@ public partial class MainWindow : Window, INotifyPropertyChanged public string NewDeviceIp { get => _newDeviceIp; set => Set(ref _newDeviceIp, value); } public string NewDevicePort { get => _newDevicePort; set => Set(ref _newDevicePort, value); } public int PollingIntervalMs { get => _pollingIntervalMs; set => Set(ref _pollingIntervalMs, Math.Clamp(value, 50, 600000)); } - public string LastStatusText { get => _lastStatusText; set => Set(ref _lastStatusText, value); } + public string LastStatusText + { + get => _lastStatusText; + set => Set(ref _lastStatusText, FormatSingleLineStatus(value)); + } + + // Diagnostics owns full exceptions, including stack traces. The bottom + // strip is one short summary, never a resizable log or multi-line surface. + public static string FormatSingleLineStatus(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return "Ready"; + var first = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault()?.Trim() ?? "Ready"; + return first.Length <= 180 ? first : first[..177] + "…"; + } public Iec61850MonitorDevice? SelectedDevice { From 69e732a11c0c9f0d4f1604f922e12ad29f5ee9b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:24:44 +0700 Subject: [PATCH 15/18] test(fat): reproduce read-only ctlNum WPF crash, CO ctlVal and CF ctlModel selection, single-row footer and monitoring Write --- ...owserLiveControlWriteFatRegressionTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs diff --git a/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs b/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs new file mode 100644 index 000000000..c8a18f5c9 --- /dev/null +++ b/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs @@ -0,0 +1,97 @@ +using System.Xml.Linq; +using ArIED61850Tester; + +namespace ARSAS.Tests; + +public sealed class IedBrowserLiveControlWriteFatRegressionTests +{ + [Fact] + public void ControlPopup_ReadOnlyDisplayBindings_AreOneWayNotWpfTwoWay() + { + var root = XDocument.Parse(Source("ControlCommandWindow.xaml")); + XNamespace p = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + foreach (var binding in new[] { "ControlNumberText", "TerminationText" }) + { + var textBox = root.Descendants(p + "TextBox").Single(x => + ((string?)x.Attribute("Text"))?.Contains(binding, StringComparison.Ordinal) == true); + Assert.Contains("Mode=OneWay", textBox.Attribute("Text")!.Value); + Assert.Equal("True", (string?)textBox.Attribute("IsReadOnly")); + } + } + + [Fact] + public void ControlLeaf_OnlyExactLiveCoCtlValMayBorrowOwningDoControl() + { + var src = Source("MainWindow.IedBrowser.ContextControl.cs"); + Assert.Contains("FunctionalConstraint: \"CO\"", src); + Assert.Contains("LastOrDefault() == \"ctlVal\"", src); + Assert.Contains("source.Attributes.Any", src); + Assert.Contains("BrowserSelectedLiveControl()", src); + Assert.Contains("new ControlCommandWindow(_runtime, device, control.Value.Signal)", src); + Assert.Contains("signal.ControlSemanticVerified", src); + Assert.Contains("IedBrowserControlEligibility.Check", src); + } + + [Fact] + public void CtlModelCfLeaf_UsesExistingGuardedWriteEditor_NotGenericMmsOrOperate() + { + var ctx = Source("MainWindow.IedBrowser.ContextControl.cs"); + var write = Source("MainWindow.IedBrowser.TypedDataWrite.cs"); + var safety = Source("Models/IedBrowserTypedWriteAdmission.cs"); + Assert.Contains("BrowserSelectedControlModelSignal()", ctx); + Assert.Contains("FunctionalConstraint: \"CF\"", ctx); + Assert.Contains("source.Attributes.Any", ctx); + Assert.Contains("new ControlModelWriteWindow(_runtime, device, modelSignal)", write); + Assert.Contains("ctlModel requires the existing guarded ConfigureControlModel workflow", safety); + Assert.Contains("selectedDo is not null ? \"Write DAs…\" : \"Write DA…\"", write); + } + + [Fact] + public void Monitoring_StillAllowsExplicitTypedWriteOnSerializedLiveAssociation() + { + var ui = Source("MainWindow.IedBrowser.TypedDataWrite.cs"); + var runtime = Source("Services/Iec61850MonitorRuntime.cs"); + var batch = Source("BrowserBatchWriteWindow.xaml.cs"); + Assert.DoesNotContain("IsMonitoring: false, IsBrowserReading: false", ui); + Assert.DoesNotContain("device.IsDemo || device.IsMonitoring || device.IsBusy", runtime); + Assert.DoesNotContain("device.IsMonitoring || model is null", runtime); + Assert.Contains("ReferenceEquals(session.Device, device)", runtime); + Assert.Contains("ControlFlightQuarantined", runtime); + Assert.Contains("IedBrowserTypedWriteBatch.Freeze(confirmedPlan)", runtime); + Assert.DoesNotContain("!_device.IsMonitoring", batch); + } + + [Fact] + public void StatusStrip_FixedHeight_NoWrapAndNoHistoryExpansion() + { + var root = XDocument.Parse(Source("MainWindow.xaml")); + XNamespace p = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + var status = root.Descendants(p + "Border").Single(x => + ((string?)x.Attribute("Grid.Row")) == "2" && + x.Descendants(p + "TextBlock").Any(t => + ((string?)t.Attribute("Text")) == "{Binding LastStatusText}")); + Assert.Equal("34", (string?)status.Attribute("Height")); + Assert.Equal("34", (string?)status.Attribute("MaxHeight")); + var text = status.Descendants(p + "TextBlock").Single(x => + ((string?)x.Attribute("Text")) == "{Binding LastStatusText}"); + Assert.Equal("NoWrap", (string?)text.Attribute("TextWrapping")); + Assert.Equal("CharacterEllipsis", (string?)text.Attribute("TextTrimming")); + Assert.Equal("19", (string?)text.Attribute("MaxHeight")); + Assert.Equal("ERROR • UI • System.InvalidOperationException: read-only", + MainWindow.FormatSingleLineStatus( + "ERROR • UI • System.InvalidOperationException: read-only\n at WPF stack")); + Assert.True(MainWindow.FormatSingleLineStatus(new string('x', 400)).Length <= 180); + } + + private static string Source(string relative) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + var path = Path.Combine(dir.FullName, relative); + if (File.Exists(path)) return File.ReadAllText(path); + dir = dir.Parent; + } + throw new FileNotFoundException(relative); + } +} From 2e9369a30bdd532fbb53de1f61d66adfc5bcfbf5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:27:42 +0700 Subject: [PATCH 16/18] fix(build): rebuild exact Browser Control/ctlModel selector from healthy FAT baseline --- MainWindow.IedBrowser.ContextControl.cs | 142 +++++------------------- 1 file changed, 29 insertions(+), 113 deletions(-) diff --git a/MainWindow.IedBrowser.ContextControl.cs b/MainWindow.IedBrowser.ContextControl.cs index 734f11f98..534f6de5f 100644 --- a/MainWindow.IedBrowser.ContextControl.cs +++ b/MainWindow.IedBrowser.ContextControl.cs @@ -19,12 +19,19 @@ public partial class MainWindow if (device is not { IsConnected: true, IsDemo: false, IsBusy: false } || device.LiveDiscoveryModel is null || device.LiveValues.IsOffline) return null; - // Only a live CO control-value member may open Control from a DA row. - // ST/MX, generic CO parameters, and CF ctlModel are not Operate targets. + // A CO ctlVal leaf is a control action on its exact DO; all status + // leaves remain read-only. This does not send any request on selection. var leaf = IedBrowserDetailList?.SelectedItem as IedBrowserDetailRow; - var controlValueLeaf = leaf is { Kind: "Data", IsDataObject: false, - FunctionalConstraint: "CO" } && - leaf.ValueReference.Split(' + var coValue = leaf is { Kind: "Data", IsDataObject: false, + FunctionalConstraint: "CO" } && + (leaf.ValueReference.EndsWith("$ctlVal", StringComparison.Ordinal) || + leaf.ValueReference.EndsWith(".ctlVal", StringComparison.Ordinal)) && + (leaf.ValueReference.Contains("$Oper$", StringComparison.Ordinal) || + leaf.ValueReference.Contains("$SBOw$", StringComparison.Ordinal) || + leaf.ValueReference.Contains(".Oper.", StringComparison.Ordinal) || + leaf.ValueReference.Contains(".SBOw.", StringComparison.Ordinal)); + if (leaf is { Kind: "Data", IsDataObject: false } && !coValue) + return null; var page = _iedBrowserNavigation.Current; var focused = page?.Payload switch { @@ -48,10 +55,10 @@ public partial class MainWindow .DataObjects.FirstOrDefault(obj => string.Equals(obj.Reference, focused, StringComparison.Ordinal)); if (source is null) return null; - if (controlValueLeaf && !source.Attributes.Any(a => + if (coValue && !source.Attributes.Any(a => a.FunctionalConstraint == "CO" && string.Equals(a.MmsReference, leaf!.ValueReference, StringComparison.Ordinal))) - return null; // CO target must come from the exact current live DO. + return null; // This collection is already filtered by the Command Dock live // operational verification. All StatusOnly/unknown stay outside. @@ -83,141 +90,50 @@ public partial class MainWindow return allow.CanControl ? (command, source) : null; } - // ctlModel is FC=CF CONFIGURATION, never a Control/Operate service. - // Reuse the proven dedicated read → exact expected model → Write → readback - // dialog already used by Command Dock. This selection is read-only until - // the operator explicitly opens the guarded configuration dialog. private SignalDefinition? BrowserSelectedControlModelSignal() { var device = SelectedDevice; if (device is not { IsConnected: true, IsDemo: false, IsBusy: false } || device.LiveDiscoveryModel is null || device.LiveValues.IsOffline || - _iedBrowserReadOperation is not null || - IedBrowserDetailList?.SelectedItem is not IedBrowserDetailRow + _iedBrowserReadOperation is not null) + return null; + + if (IedBrowserDetailList?.SelectedItem is not IedBrowserDetailRow { Kind: "Data", IsDataObject: false, - FunctionalConstraint: "CF" } row || - !row.ValueReference.EndsWith("$ctlModel", StringComparison.Ordinal) && + FunctionalConstraint: "CF" } row) + return null; + if (!row.ValueReference.EndsWith("$ctlModel", StringComparison.Ordinal) && !row.ValueReference.EndsWith(".ctlModel", StringComparison.Ordinal)) return null; var page = _iedBrowserNavigation.Current; var focused = page?.Payload switch { - LiveIedDataObjectModel o => o.Reference, + LiveIedDataObjectModel obj => obj.Reference, LiveIedLogicalNodeModel => _iedBrowserSelectedDoReference, _ => null }; if (string.IsNullOrWhiteSpace(focused)) return null; var slash = focused.IndexOf('/'); var dot = slash < 0 ? -1 : focused.IndexOf('.', slash + 1); - if (slash < 1 || dot < slash + 2) return null; + if (slash < 1 || dot <= slash + 1) return null; var source = device.LiveDiscoveryModel.LogicalDevices .FirstOrDefault(ld => string.Equals(ld.MmsDomain, focused[..slash], StringComparison.Ordinal))? .LogicalNodes.FirstOrDefault(ln => string.Equals(ln.Name, focused[(slash + 1)..dot], StringComparison.Ordinal))? - .DataObjects.FirstOrDefault(o => - string.Equals(o.Reference, focused, StringComparison.Ordinal)); - if (source is null || !source.Attributes.Any(a => - a.FunctionalConstraint == "CF" && - string.Equals(a.MmsReference, row.ValueReference, StringComparison.Ordinal))) + .DataObjects.FirstOrDefault(obj => + string.Equals(obj.Reference, focused, StringComparison.Ordinal)); + if (source is null || !source.Attributes.Any(attr => + attr.FunctionalConstraint == "CF" && + string.Equals(attr.MmsReference, row.ValueReference, StringComparison.Ordinal))) return null; return device.OperableCommandSignals.Concat(device.CommandSignals) .Where(signal => signal.IsValidControlObject && string.Equals(signal.ObjectReference, focused, StringComparison.Ordinal)) - .Distinct().Take(2).FirstOrDefault(); - } - - private void RefreshIedBrowserControlButton() - { - if (IedBrowserControlButton is null) return; - var current = BrowserSelectedLiveControl(); - IedBrowserControlButton.IsEnabled = current is not null; - IedBrowserControlButton.ToolTip = current is null - ? "Control disabled: focus a DO with existing live-proven ctlModel/CDC/CO. StatusOnly is never operable." - : "Inspect exact native IEC Control model and confirm the command explicitly; no automatic Operate."; - } - - private void IedBrowserControl_Click(object sender, RoutedEventArgs e) - { - var control = BrowserSelectedLiveControl(); - if (control is null || SelectedDevice is not { } device) - { - RefreshIedBrowserControlButton(); - return; - } - // Reuse native Direct/SBO normal/enhanced safety pipeline. Its - // Window_Loaded explicitly re-inspects current wire authority before - // enabling Send Command; selection alone never issues any command. - var dialog = new ControlCommandWindow(_runtime, device, control.Value.Signal) - { - Owner = this - }; - dialog.ShowDialog(); - RefreshIedBrowserControlButton(); - } -} -, '.').LastOrDefault() == "ctlVal" && - (leaf.ValueReference.Contains("$Oper$", StringComparison.Ordinal) || - leaf.ValueReference.Contains("$SBOw$", StringComparison.Ordinal) || - leaf.ValueReference.Contains(".Oper.", StringComparison.Ordinal) || - leaf.ValueReference.Contains(".SBOw.", StringComparison.Ordinal)); - if (leaf is { Kind: "Data", IsDataObject: false } && !controlValueLeaf) - return null; - var page = _iedBrowserNavigation.Current; - var focused = page?.Payload switch - { - LiveIedDataObjectModel obj => obj.Reference, - LiveIedLogicalNodeModel => _iedBrowserSelectedDoReference, - _ => null - }; - if (string.IsNullOrWhiteSpace(focused)) return null; - - var slash = focused.IndexOf('/'); - var name = slash > 0 ? focused[(slash + 1)..] : ""; - var dot = name.IndexOf('.'); - if (slash < 1 || dot < 1) return null; - - // Exact owner lookup within one LD/LN, not a visible-grid scan. - var source = device.LiveDiscoveryModel.LogicalDevices - .FirstOrDefault(ld => - string.Equals(ld.MmsDomain, focused[..slash], StringComparison.Ordinal))? - .LogicalNodes.FirstOrDefault(ln => - string.Equals(ln.Name, name[..dot], StringComparison.Ordinal))? - .DataObjects.FirstOrDefault(obj => - string.Equals(obj.Reference, focused, StringComparison.Ordinal)); - if (source is null) return null; - - // This collection is already filtered by the Command Dock live - // operational verification. All StatusOnly/unknown stay outside. - var candidates = device.OperableCommandSignals.Where(signal => - signal.IsValidControlObject && - signal.HasControlServiceEvidence && - signal.ControlSemanticVerified && - signal.ControlModelResolved && - string.Equals(signal.ObjectReference, focused, StringComparison.Ordinal)) - .Take(2).ToArray(); - if (candidates.Length != 1) return null; - var command = candidates[0]; - var inspected = new Iec61850ControlCapabilities - { - ObjectReference = command.ObjectReference, - ControlModelReference = command.ControlModelReference, - StatusReference = command.ControlStatusReference, - ControlModel = command.ControlModelKind, - ControlCdc = command.VerifiedLiveControlCdc, - CtlValSignature = command.ControlValueType, - EngineControlServiceAvailable = command.HasControlServiceEvidence, - IsOperationallyReady = command.ControlSupportsOperate - }; - var allow = IedBrowserControlEligibility.Check( - focusIsExactDo: true, focusedReference: focused, - onlineDo: source, liveControl: inspected, - currentAssociationVerified: !device.LiveValues.IsOffline, - operatorAuthorized: true); - return allow.CanControl ? (command, source) : null; + .Distinct().FirstOrDefault(); } private void RefreshIedBrowserControlButton() From 895bdaacb627ad124b9526c864d31e6bba9ecaff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:33:37 +0700 Subject: [PATCH 17/18] test(browser): verify exact ctlVal suffix admission rather than obsolete Split expression --- .../ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs b/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs index c8a18f5c9..944d58b4e 100644 --- a/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs +++ b/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs @@ -24,7 +24,7 @@ public void ControlLeaf_OnlyExactLiveCoCtlValMayBorrowOwningDoControl() { var src = Source("MainWindow.IedBrowser.ContextControl.cs"); Assert.Contains("FunctionalConstraint: \"CO\"", src); - Assert.Contains("LastOrDefault() == \"ctlVal\"", src); + Assert.Contains("EndsWith(\"$ctlVal\"", src); Assert.Contains("source.Attributes.Any", src); Assert.Contains("BrowserSelectedLiveControl()", src); Assert.Contains("new ControlCommandWindow(_runtime, device, control.Value.Signal)", src); From 3a84d0fcee06df16952c2f78f7575586bf973626 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:33:50 +0700 Subject: [PATCH 18/18] test(browser): validate priority Write ctlModel label and preserved multi DA label --- tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs b/tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs index 368b01f6d..659379a26 100644 --- a/tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs +++ b/tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs @@ -84,7 +84,8 @@ public void StatusDaSelectionRevokesParentWriteAndControlAuthority() Assert.Contains(dataLeaf, write); Assert.Contains(dataLeaf, control); Assert.Contains("RefreshIedBrowserControlButton();", write); - Assert.Contains("IedBrowserWriteButton.Content = selectedDo is not null", write); + Assert.Contains("IedBrowserWriteButton.Content = ctlModel is not null", write); + Assert.Contains("selectedDo is not null ? \"Write DAs…\" : \"Write DA…\"", write); } private static string Find(string relative)