diff --git a/BrowserBatchWriteWindow.xaml b/BrowserBatchWriteWindow.xaml
new file mode 100644
index 000000000..3597b69cf
--- /dev/null
+++ b/BrowserBatchWriteWindow.xaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/BrowserBatchWriteWindow.xaml.cs b/BrowserBatchWriteWindow.xaml.cs
new file mode 100644
index 000000000..6043f5011
--- /dev/null
+++ b/BrowserBatchWriteWindow.xaml.cs
@@ -0,0 +1,362 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Windows;
+using System.Windows.Controls;
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Mms;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester;
+
+///
+/// Explicit, bounded same-DO multi-DA editor. Only user-checked and changed
+/// scalar leaves enter the immutable native transaction plan; no DO overwrite.
+/// All network access is through the existing association and runtime.
+///
+public partial class BrowserBatchWriteWindow : Window
+{
+ private readonly Iec61850MonitorRuntime _runtime;
+ private readonly Iec61850MonitorDevice _device;
+ private readonly LiveIedModelDiscoveryDocument _model;
+ private readonly long _association;
+ private readonly CancellationTokenSource _lifetime = new();
+ private bool _busy;
+ private bool _prepared;
+
+ public ObservableCollection Rows { get; } = new();
+
+ public BrowserBatchWriteWindow(
+ Iec61850MonitorRuntime runtime,
+ Iec61850MonitorDevice device,
+ LiveIedModelDiscoveryDocument model,
+ LiveIedDataObjectModel owner)
+ {
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ _model = model ?? throw new ArgumentNullException(nameof(model));
+ ArgumentNullException.ThrowIfNull(owner);
+ if (owner.Attributes.Count is 0 or > 256)
+ throw new InvalidOperationException("The focused DO needs 1–256 explicit attributes.");
+
+ _association = device.LiveValues.Association;
+ var duplicated = owner.Attributes
+ .GroupBy(a => a.FunctionalConstraint + "|" + a.MmsReference, StringComparer.Ordinal)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key)
+ .ToHashSet(StringComparer.Ordinal);
+
+ foreach (var attr in owner.Attributes)
+ {
+ MmsObjectReference? reference = null;
+ try { reference = MmsObjectReference.Parse(attr.MmsReference, attr.FunctionalConstraint); }
+ catch (Exception ex) when (ex is ArgumentException or FormatException) { }
+
+ var parts = reference?.Item.Split('$') ?? [];
+ var sameOwner = reference is not null && parts.Length >= 4 &&
+ string.Equals(parts[1], reference.Value.FunctionalConstraint, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(reference.Value.Domain + "/" + parts[0] + "." + parts[2],
+ owner.Reference, StringComparison.Ordinal);
+ var fcOk = reference is not null && reference.Value.FunctionalConstraint.ToUpperInvariant() is "CF" or "SP" or "DC";
+ var isControlMember = parts.Skip(2).Any(p => p is "Oper" or "SBO" or "SBOw" or "Cancel" or "ctlModel");
+ var isDuplicate = duplicated.Contains(attr.FunctionalConstraint + "|" + attr.MmsReference);
+ var eligible = sameOwner && fcOk && !isControlMember && !isDuplicate;
+ var row = new BrowserBatchWriteRow(
+ reference, attr.MmsReference, attr.FunctionalConstraint, eligible,
+ !sameOwner ? "Not an exact live DA of this DO" :
+ isDuplicate ? "Ambiguous duplicate in live model" :
+ isControlMember ? "Use IEC Control / dedicated ctlModel service" :
+ !fcOk ? "FC not approved for generic Write" : "Select, edit and inspect");
+ row.PropertyChanged += Row_PropertyChanged;
+ Rows.Add(row);
+ }
+
+ InitializeComponent();
+ DataContext = this;
+ IdentityLabel.Text = device.Name + " · " + owner.Reference +
+ " · association " + _association;
+ }
+
+ private bool CurrentAssociation =>
+ !_lifetime.IsCancellationRequested && _device.IsConnected &&
+ !_device.IsDemo && !_device.IsBusy && !_device.IsMonitoring &&
+ !_device.LiveValues.IsOffline &&
+ ReferenceEquals(_device.LiveDiscoveryModel, _model) &&
+ _device.LiveValues.Association == _association;
+
+ private void Row_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(BrowserBatchWriteRow.Selected) or
+ nameof(BrowserBatchWriteRow.NewValue))
+ {
+ _prepared = false;
+ if (WriteButton is not null) WriteButton.IsEnabled = false;
+ }
+ }
+
+ private void SetBusy(bool busy)
+ {
+ _busy = busy;
+ RowsGrid.IsEnabled = !busy;
+ InspectButton.IsEnabled = !busy;
+ WriteButton.IsEnabled = !busy && _prepared;
+ }
+
+ private void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ var work = SystemParameters.WorkArea;
+ Height = Math.Max(MinHeight, Math.Min(580, work.Height - 26));
+ Width = Math.Max(MinWidth, Math.Min(760, work.Width - 26));
+ }
+
+ private BrowserBatchWriteRow[] CheckedRows()
+ {
+ RowsGrid.CommitEdit(DataGridEditingUnit.Cell, true);
+ RowsGrid.CommitEdit(DataGridEditingUnit.Row, true);
+ return Rows.Where(r => r.Selected).ToArray();
+ }
+
+ private async void Inspect_Click(object sender, RoutedEventArgs e)
+ {
+ if (_busy) return;
+ _prepared = false;
+ WriteButton.IsEnabled = false;
+ var selected = CheckedRows();
+ if (selected.Length is 0 or > IedBrowserTypedWriteBatch.MaxSelectedAttributes ||
+ selected.Any(r => !r.Eligible || !r.HasEdited) || !CurrentAssociation)
+ {
+ StatusLabel.Text =
+ "Select 1–64 eligible DAs, explicitly edit each new value and keep the same online association. No Write sent.";
+ return;
+ }
+
+ SetBusy(true);
+ try
+ {
+ for (var i = 0; i < selected.Length; i++)
+ {
+ var row = selected[i];
+ _lifetime.Token.ThrowIfCancellationRequested();
+ if (!CurrentAssociation)
+ throw new InvalidOperationException("IED association changed during inspection.");
+ StatusLabel.Text = $"Read-only inspect {i + 1}/{selected.Length}: {row.ReferenceText}";
+ row.Preview = null;
+ var preview = await _runtime.PreviewBrowserDataAttributeWriteAsync(
+ _device, row.Reference!.Value, _lifetime.Token);
+ if (!CurrentAssociation)
+ throw new InvalidOperationException("IED association changed during inspection.");
+ row.Preview = preview.IsAvailable ? preview : null;
+ row.Type = preview.Type?.MmsType ?? "unverified";
+ row.Old = preview.CurrentText;
+ row.Status = preview.Message;
+ if (row.Preview is null)
+ {
+ StatusLabel.Text = "One DA has no valid live typed Read/GVA proof. No Write sent.";
+ return;
+ }
+ }
+
+ if (!TryFreezePlan(selected, out _, out var rejection))
+ {
+ StatusLabel.Text = rejection + " No Write sent.";
+ return;
+ }
+ _prepared = true;
+ WriteButton.IsEnabled = true;
+ StatusLabel.Text = $"{selected.Length} changed DA(s) inspected. Review all old/new values before confirming; zero Writes so far.";
+ }
+ catch (OperationCanceledException)
+ {
+ StatusLabel.Text = "Inspection cancelled; no Write requested.";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = "Inspection stopped (no Write sent): " + ex.Message;
+ }
+ finally { SetBusy(false); }
+ }
+
+ private static bool TryFreezePlan(
+ IReadOnlyList selected,
+ out IReadOnlyList? steps,
+ out string reason)
+ {
+ steps = null;
+ reason = "";
+ var pending = new List(selected.Count);
+ foreach (var row in selected)
+ {
+ var preview = row.Preview;
+ if (!row.Eligible || !row.HasEdited ||
+ preview is not { IsAvailable: true, Type: not null, OldValue: not null } ||
+ row.Reference is null ||
+ !IedBrowserTypedWriteInput.TryParse(preview.Type, row.NewValue,
+ out var proposed, out var error) || proposed is null)
+ {
+ reason = $"{row.ReferenceText}: typed value or inspection missing.";
+ return false;
+ }
+ var live = new MmsVariableAccessAttributesResult
+ {
+ IsSuccess = true, Reference = row.Reference.Value,
+ TypeSpecification = preview.Type
+ };
+ var reject = IedBrowserTypedWriteAdmission.RejectReason(
+ row.Reference.Value, live, preview.GvaSignature, proposed);
+ if (reject is not null)
+ {
+ reason = $"{row.ReferenceText}: {reject}";
+ return false;
+ }
+ if (MmsDataCodec.Encode(preview.OldValue).AsSpan().SequenceEqual(
+ MmsDataCodec.Encode(proposed)))
+ {
+ reason = $"{row.ReferenceText}: unchanged value is not staged.";
+ return false;
+ }
+ pending.Add(new(row.Reference.Value, preview.GvaSignature,
+ preview.OldValue, proposed));
+ }
+
+ try { steps = IedBrowserTypedWriteBatch.Freeze(pending); }
+ catch (ArgumentException ex) { reason = ex.Message; return false; }
+ return true;
+ }
+
+ private async void Write_Click(object sender, RoutedEventArgs e)
+ {
+ if (_busy || !_prepared || !CurrentAssociation) return;
+ var selected = CheckedRows();
+ if (!TryFreezePlan(selected, out var steps, out var reason) || steps is null)
+ {
+ StatusLabel.Text = "Write NOT SENT: " + reason;
+ _prepared = false;
+ WriteButton.IsEnabled = false;
+ return;
+ }
+
+ var diff = string.Join(Environment.NewLine, steps.Select(s =>
+ $"{s.Reference.Domain}/{s.Reference.Item} [{s.Reference.FunctionalConstraint}]" +
+ $"\n {MmsDataCodec.ToDisplayString(s.ExpectedOldValue)} → {MmsDataCodec.ToDisplayString(s.ProposedValue)}"));
+ var confirmed = MessageBox.Show(this,
+ $"IED: {_device.Name}\nDO: {IdentityLabel.Text}\n\n" + diff +
+ $"\n\n{steps.Count} Confirmed-Write request(s), sequential, NOT ATOMIC. " +
+ "A failure stops remaining DAs. No retry or rollback. " +
+ "This can modify protection settings/configuration. Are you authorized to write?",
+ "Confirm exact live MMS Writes", MessageBoxButton.YesNo,
+ MessageBoxImage.Warning, MessageBoxResult.No);
+ if (confirmed != MessageBoxResult.Yes)
+ {
+ StatusLabel.Text = "Operator declined; zero Writes sent.";
+ return;
+ }
+ if (!CurrentAssociation)
+ {
+ StatusLabel.Text = "Association changed after review; zero Writes sent.";
+ return;
+ }
+
+ SetBusy(true);
+ _prepared = false;
+ WriteButton.IsEnabled = false;
+ try
+ {
+ StatusLabel.Text = $"Sending up to {steps.Count} individually confirmed DA Writes…";
+ var result = await _runtime.WriteBrowserDataAttributesAsync(
+ _device, steps, true, _lifetime.Token);
+ for (var i = 0; i < result.Rows.Count; i++)
+ {
+ selected[i].Status = result.Rows[i].State?.ToString() ?? "Unattempted";
+ selected[i].Status += ": " + result.Rows[i].Message;
+ selected[i].Preview = null;
+ }
+ StatusLabel.Text =
+ $"Readback verified: {result.Verified}; rejected: {result.Rejected}; " +
+ $"unknown: {result.Unknown}; unverified: {result.Unverified}; " +
+ $"not attempted: {result.Unattempted}. " +
+ "Reinspect the IED before any new attempt; no physical operation is implied.";
+ }
+ catch (OperationCanceledException)
+ {
+ StatusLabel.Text =
+ "Interrupted write; wire outcome may be UNKNOWN. Read actual IED before manual retry.";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text =
+ "Write stopped/refused: " + ex.Message +
+ ". Some in-flight outcomes may be unknown; do not retry automatically.";
+ }
+ finally
+ {
+ _prepared = false;
+ SetBusy(false);
+ }
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+ private void Window_Closed(object? sender, EventArgs e) => _lifetime.Cancel();
+}
+
+public sealed class BrowserBatchWriteRow : INotifyPropertyChanged
+{
+ private bool _selected;
+ private string _newValue = "";
+ private string _status;
+ private string _type = "Inspect first";
+ private string _old = "—";
+
+ public MmsObjectReference? Reference { get; }
+ public string ReferenceText { get; }
+ public string Fc { get; }
+ public bool Eligible { get; }
+ public bool HasEdited { get; private set; }
+ public IedBrowserNativeWritePreview? Preview { get; set; }
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public BrowserBatchWriteRow(
+ MmsObjectReference? reference, string referenceText, string fc,
+ bool eligible, string reason)
+ {
+ Reference = reference;
+ ReferenceText = referenceText;
+ Fc = fc;
+ Eligible = eligible;
+ _status = reason;
+ }
+
+ public bool Selected
+ {
+ get => _selected;
+ set { if (_selected != value) { _selected = value; Changed(); } }
+ }
+ public string NewValue
+ {
+ get => _newValue;
+ set
+ {
+ if (_newValue == value) return;
+ _newValue = value;
+ HasEdited = true;
+ Changed();
+ }
+ }
+ public string Status
+ {
+ get => _status;
+ set { _status = value; Changed(); }
+ }
+ public string Type
+ {
+ get => _type;
+ set { _type = value; Changed(); }
+ }
+ public string Old
+ {
+ get => _old;
+ set { _old = value; Changed(); }
+ }
+ private void Changed([CallerMemberName] string? propertyName = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+}
diff --git a/BrowserDataWriteWindow.xaml b/BrowserDataWriteWindow.xaml
new file mode 100644
index 000000000..63664c816
--- /dev/null
+++ b/BrowserDataWriteWindow.xaml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BrowserDataWriteWindow.xaml.cs b/BrowserDataWriteWindow.xaml.cs
new file mode 100644
index 000000000..5a326d4ed
--- /dev/null
+++ b/BrowserDataWriteWindow.xaml.cs
@@ -0,0 +1,191 @@
+using System.Windows;
+using System.Windows.Controls;
+using AR.Iec61850.Mms;
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester;
+
+///
+/// Explicit, one-DA data Write workflow. This is not IEC Control and never
+/// calls Select/Operate or reconfigures a Report/GOOSE control block.
+///
+public partial class BrowserDataWriteWindow : Window
+{
+ private readonly Iec61850MonitorRuntime _runtime;
+ private readonly Iec61850MonitorDevice _device;
+ private readonly MmsObjectReference _reference;
+ private readonly CancellationTokenSource _lifetime = new();
+ private IedBrowserNativeWritePreview? _preview;
+ private bool _busy;
+
+ public BrowserDataWriteWindow(
+ Iec61850MonitorRuntime runtime,
+ Iec61850MonitorDevice device,
+ LiveIedDataAttributeModel attribute)
+ {
+ InitializeComponent();
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ ArgumentNullException.ThrowIfNull(attribute);
+ _reference = MmsObjectReference.Parse(
+ attribute.MmsReference, attribute.FunctionalConstraint);
+ ReferenceLabel.Text = $"{_device.Name} • {_reference.Domain}/{_reference.Item} [{_reference.FunctionalConstraint}]";
+ }
+
+ private async void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ _busy = true;
+ try
+ {
+ _preview = await _runtime.PreviewBrowserDataAttributeWriteAsync(
+ _device, _reference, _lifetime.Token);
+ if (_lifetime.IsCancellationRequested) return;
+ if (_preview is not { IsAvailable: true, Type: not null, OldValue: not null } p)
+ {
+ StatusLabel.Text = _preview?.Message ?? "Live GVA and value not available.";
+ DiffLabel.Text = "No Write is permitted. Select another live DA.";
+ return;
+ }
+
+ CurrentLabel.Text = p.CurrentText;
+ TypeLabel.Text = p.Type.SclBType.Length > 0
+ ? $"{p.Type.SclBType} ({p.Type.MmsType}, {p.Type.Size?.ToString() ?? "?"})"
+ : $"{p.Type.MmsType} ({p.Type.Size?.ToString() ?? "?"})";
+ var supported = p.Type.MmsType switch
+ {
+ "boolean" or "integer" or "unsigned" or "visible-string" or "mms-string" => true,
+ "floating-point" when p.Type.Size == 32 => true,
+ _ => false
+ };
+ NewValueBox.IsEnabled = supported;
+ StatusLabel.Text = supported
+ ? "Live GVA/read verified. Enter a new value and inspect the diff."
+ : "This MMS wire type has no qualified typed text editor.";
+ DiffLabel.Text = $"Current: {p.CurrentText}\nNew: —\nFC={_reference.FunctionalConstraint} · GVA locked to this association.";
+ if (supported) NewValueBox.Focus();
+ }
+ catch (OperationCanceledException)
+ {
+ if (!_lifetime.IsCancellationRequested)
+ StatusLabel.Text = "Preview cancelled; no Write sent.";
+ }
+ catch (Exception ex)
+ {
+ if (!_lifetime.IsCancellationRequested)
+ StatusLabel.Text = "Preview refused: " + ex.Message;
+ }
+ finally
+ {
+ _busy = false;
+ RefreshInput();
+ }
+ }
+
+ private void NewValueBox_TextChanged(object sender, TextChangedEventArgs e)
+ => RefreshInput();
+
+ private void RefreshInput()
+ {
+ if (WriteButton is null || DiffLabel is null || NewValueBox is null) return;
+ WriteButton.IsEnabled = false;
+ if (_busy || _preview is not { IsAvailable: true, Type: not null, OldValue: not null } p ||
+ !NewValueBox.IsEnabled)
+ return;
+ if (!IedBrowserTypedWriteInput.TryParse(p.Type, NewValueBox.Text,
+ out var value, out var error) || value is null)
+ {
+ DiffLabel.Text = error;
+ return;
+ }
+ var gva = new MmsVariableAccessAttributesResult
+ {
+ IsSuccess = true, Reference = _reference, TypeSpecification = p.Type
+ };
+ var rejection = IedBrowserTypedWriteAdmission.RejectReason(
+ _reference, gva, p.GvaSignature, value);
+ if (rejection is not null)
+ {
+ DiffLabel.Text = rejection;
+ return;
+ }
+ var oldBytes = MmsDataCodec.Encode(p.OldValue);
+ var newBytes = MmsDataCodec.Encode(value);
+ var changed = !oldBytes.AsSpan().SequenceEqual(newBytes);
+ DiffLabel.Text = $"Current: {p.CurrentText}\nNew: {MmsDataCodec.ToDisplayString(value)}\n" +
+ $"FC={_reference.FunctionalConstraint} · " +
+ (changed ? "One exact DA will be written." : "No change; Write disabled.");
+ WriteButton.IsEnabled = changed;
+ }
+
+ private async void Write_Click(object sender, RoutedEventArgs e)
+ {
+ if (_busy || _preview is not { IsAvailable: true, Type: not null, OldValue: not null } p ||
+ !WriteButton.IsEnabled ||
+ !IedBrowserTypedWriteInput.TryParse(p.Type, NewValueBox.Text,
+ out var proposed, out _) || proposed is null)
+ return;
+ var gva = new MmsVariableAccessAttributesResult
+ {
+ IsSuccess = true, Reference = _reference, TypeSpecification = p.Type
+ };
+ if (IedBrowserTypedWriteAdmission.RejectReason(
+ _reference, gva, p.GvaSignature, proposed) is not null)
+ {
+ RefreshInput();
+ return;
+ }
+
+ var confirm =
+ "Confirm ONE IEC 61850 MMS data Write on the connected IED?\n\n" +
+ $"IED: {_device.Name}\n" +
+ $"DA: {_reference.Domain}/{_reference.Item} [{_reference.FunctionalConstraint}]\n" +
+ $"Before: {p.CurrentText}\n" +
+ $"After: {MmsDataCodec.ToDisplayString(proposed)}\n\n" +
+ "No automatic retries, rollback, or control Operate. " +
+ "The server may reject Write; readback must independently match.";
+ if (MessageBox.Show(this, confirm, "Confirm live MMS Write",
+ MessageBoxButton.YesNo, MessageBoxImage.Warning,
+ MessageBoxResult.No) != MessageBoxResult.Yes)
+ return;
+
+ _busy = true;
+ WriteButton.IsEnabled = false;
+ NewValueBox.IsEnabled = false;
+ StatusLabel.Text = "One exact MMS Write requested…";
+ try
+ {
+ var step = new IedBrowserTypedWriteStep(
+ _reference, p.GvaSignature, p.OldValue, proposed);
+ var outcome = await _runtime.WriteBrowserDataAttributesAsync(
+ _device, new[] { step }, true, _lifetime.Token);
+ if (_lifetime.IsCancellationRequested) return;
+ var result = outcome.Rows.Single();
+ StatusLabel.Text = $"{result.State}: {result.Message}";
+ DiffLabel.Text =
+ "Write completed or resolved as uncertain. Close and inspect the actual IED value " +
+ "before another attempt; physical process change is NOT implied.";
+ _preview = null; // A new attempt must acquire a NEW live GVA/read + confirmation.
+ }
+ catch (OperationCanceledException)
+ {
+ if (!_lifetime.IsCancellationRequested)
+ StatusLabel.Text = "Write may have been dispatched: outcome UNKNOWN. Read IED manually.";
+ }
+ catch (Exception ex)
+ {
+ if (!_lifetime.IsCancellationRequested)
+ StatusLabel.Text = "Write refused or outcome unresolved: " + ex.Message;
+ }
+ finally
+ {
+ _busy = false;
+ WriteButton.IsEnabled = false;
+ }
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+
+ private void Window_Closed(object? sender, EventArgs e) => _lifetime.Cancel();
+}
diff --git a/ControlCommandWindow.xaml b/ControlCommandWindow.xaml
index a50655032..cd5905a3e 100644
--- a/ControlCommandWindow.xaml
+++ b/ControlCommandWindow.xaml
@@ -1,171 +1,204 @@
+ Title="Control · IEC 61850" Width="565" Height="500"
+ MinWidth="505" MinHeight="390" MaxHeight="670"
+ WindowStartupLocation="CenterOwner" ResizeMode="CanResize"
+ Background="{StaticResource Bg}" FontFamily="{StaticResource AppFontFamily}"
+ FontSize="11.5" Icon="Assets/app-icon.ico"
+ Loaded="Window_Loaded" Closing="Window_Closing">
-
+
-
-
+
-
+
+
-
+
+
-
-
-
-
-
-
-
+
+
+
+
-
-
+
+
-
+
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+
+
+
-
-
-
-
-
+
+
+
+
-
+
\ No newline at end of file
diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs
index b2074ba0c..5cbd05972 100644
--- a/ControlCommandWindow.xaml.cs
+++ b/ControlCommandWindow.xaml.cs
@@ -21,11 +21,21 @@ public partial class ControlCommandWindow : Window, INotifyPropertyChanged
private string _sequenceText = "Detecting the required IEC 61850 control sequence…";
private string _controlEvidenceText = string.Empty;
private string _selectedValue = string.Empty;
+ private string _modelShortText = "Inspecting…";
+ private string _sequenceHint = "Inspecting control…";
+ private string _originatorId = "ARSAS";
+ private string _selectedOriginCategory = "StationControl";
+ private string _controlNumberText = "Auto";
+ private string _terminationText = "—";
+ private string _wireTimelineText = string.Empty;
+ private Iec61850ControlModelKind _liveControlModel = Iec61850ControlModelKind.Unknown;
private bool _interlockCheck = true;
private bool _synchroCheck;
private bool _testMode;
private bool _confirmLiveCommand;
private bool _isBusy;
+ private bool _commandFlightActive;
+ private bool _commandAttempted;
private bool _isReady;
private string _commandStage = "Ready to inspect";
private string _commandStatus = "ArIED will read the live control model and current status before enabling Send Command.";
@@ -33,6 +43,11 @@ public partial class ControlCommandWindow : Window, INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
public ObservableCollection ValueOptions { get; } = new();
+ public IReadOnlyList OriginCategories { get; } = new[]
+ {
+ "StationControl", "BayControl", "RemoteControl", "AutomaticBay",
+ "AutomaticStation", "AutomaticRemote", "Maintenance", "Process"
+ };
public string DeviceSummary => $"{_device.Name} • {_device.EndpointText}";
public string SignalName => _signal.Name;
public string Telegram => Iec61850MonitorPoint.StripIedNamePrefix(_signal.ObjectReference, _device.Name);
@@ -43,6 +58,39 @@ public partial class ControlCommandWindow : Window, INotifyPropertyChanged
public string SequenceText { get => _sequenceText; private set => Set(ref _sequenceText, value); }
public string ControlEvidenceText { get => _controlEvidenceText; private set => Set(ref _controlEvidenceText, value); }
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); }
+ public string ControlNumberText { get => _controlNumberText; private set => Set(ref _controlNumberText, value); }
+ public string TerminationText { get => _terminationText; private set => Set(ref _terminationText, value); }
+ public string WireTimelineText { get => _wireTimelineText; private set => Set(ref _wireTimelineText, value); }
+ public string SelectedOriginCategory
+ {
+ get => _selectedOriginCategory;
+ set
+ {
+ if (Set(ref _selectedOriginCategory, value)) Raise(nameof(CanSend));
+ }
+ }
+ public string OriginatorId
+ {
+ get => _originatorId;
+ set
+ {
+ if (Set(ref _originatorId, value))
+ {
+ Raise(nameof(OriginValidationText));
+ Raise(nameof(CanSend));
+ }
+ }
+ }
+ public string OriginValidationText => IsValidOriginator(OriginatorId)
+ ? string.Empty : "Originator ID requires 1–64 printable ASCII characters.";
+ public static bool IsValidOriginator(string? id) =>
+ !string.IsNullOrWhiteSpace(id) && id.Length <= 64 &&
+ id.All(c => c is >= ' ' and <= '~');
+ public bool AllowCustomValue
+ => _controlCdcText is "APC" or "INC" or "ISC" or "INC/ISC";
+
public bool InterlockCheck { get => _interlockCheck; set => Set(ref _interlockCheck, value); }
public bool SynchroCheck { get => _synchroCheck; set => Set(ref _synchroCheck, value); }
public bool TestMode
@@ -53,6 +101,7 @@ public bool TestMode
if (!Set(ref _testMode, value)) return;
UpdateLiveWarning();
Raise(nameof(CanSend));
+ Raise(nameof(SendButtonText));
}
}
public bool ConfirmLiveCommand
@@ -71,9 +120,15 @@ private set
{
if (!Set(ref _isBusy, value)) return;
Raise(nameof(CanSend));
+ Raise(nameof(CanClose));
+ Raise(nameof(CanReinspect));
+ Raise(nameof(CanEdit));
Raise(nameof(SendButtonText));
}
}
+ public bool CanClose => !_commandFlightActive;
+ public bool CanReinspect => !_commandFlightActive && !IsBusy;
+ public bool CanEdit => IsReady && !IsBusy && !_commandAttempted;
public bool IsReady
{
get => _isReady;
@@ -82,12 +137,18 @@ private set
if (!Set(ref _isReady, value)) return;
UpdateLiveWarning();
Raise(nameof(CanSend));
+ Raise(nameof(CanEdit));
}
}
public string CommandStage { get => _commandStage; private set => Set(ref _commandStage, value); }
public string CommandStatus { get => _commandStatus; private set => Set(ref _commandStatus, value); }
- public string SendButtonText => IsBusy ? "Sending…" : TestMode ? "Send Test" : "Send Command";
- public bool CanSend => IsReady && !IsBusy && !string.IsNullOrWhiteSpace(SelectedValue) && (TestMode || ConfirmLiveCommand);
+ public string SendButtonText => IsBusy ? "Working…" : TestMode ? "Send test" :
+ _liveControlModel is Iec61850ControlModelKind.SboNormal or
+ Iec61850ControlModelKind.SboEnhanced ? "Select → Operate" : "Operate";
+ public bool CanSend => IsReady && !IsBusy && !_commandAttempted &&
+ !string.IsNullOrWhiteSpace(SelectedValue) &&
+ IsValidOriginator(OriginatorId) && OriginCategories.Contains(SelectedOriginCategory) &&
+ ConfirmLiveCommand;
public ControlCommandWindow(
Iec61850MonitorRuntime runtime,
@@ -104,11 +165,24 @@ public ControlCommandWindow(
private async void Window_Loaded(object sender, RoutedEventArgs e)
{
+ // Honor the actual DPI-aware working area. All details scroll while
+ // the command confirmation footer stays on-screen at 1366x768/150%.
+ var work = SystemParameters.WorkArea;
+ Height = Math.Max(MinHeight, Math.Min(500, work.Height - 24));
+ Width = Math.Max(MinWidth, Math.Min(565, work.Width - 24));
await InspectAsync();
}
private async Task InspectAsync()
{
+ if (_commandFlightActive || IsBusy) return;
+ _commandAttempted = false;
+ ConfirmLiveCommand = false;
+ IsReady = false;
+ SelectedValue = string.Empty;
+ ControlNumberText = "Auto";
+ TerminationText = "—";
+ WireTimelineText = string.Empty;
IsBusy = true;
CommandStage = "Inspecting control";
CommandStatus = "Reading ctlModel and current status from the IED…";
@@ -121,7 +195,22 @@ private async Task InspectAsync()
_cancellation.Token);
ControlModelText = capabilities.ControlModelText;
+ _liveControlModel = capabilities.ControlModel;
+ ModelShortText = capabilities.ControlModel switch
+ {
+ Iec61850ControlModelKind.DirectNormal => "Direct · normal",
+ Iec61850ControlModelKind.DirectEnhanced => "Direct · enhanced",
+ Iec61850ControlModelKind.SboNormal => "SBO · normal",
+ Iec61850ControlModelKind.SboEnhanced => "SBO · enhanced",
+ Iec61850ControlModelKind.StatusOnly => "Status only",
+ _ => "Unverified"
+ };
+ SequenceHint = !capabilities.SupportsOperate ? "Not operable" :
+ capabilities.UsesSelectBeforeOperate
+ ? "Native: Select → Operate" : "Native: Operate";
+ Raise(nameof(SendButtonText));
ControlCdcText = string.IsNullOrWhiteSpace(capabilities.ControlCdc) ? "CONTROL" : capabilities.ControlCdc;
+ Raise(nameof(AllowCustomValue));
CurrentValue = capabilities.CurrentValue;
EngineStatusText = capabilities.EngineControlServiceStatus;
SequenceText = capabilities.SequenceText;
@@ -138,7 +227,7 @@ private async Task InspectAsync()
{
CommandStage = capabilities.SupportsOperate ? "Ready" : "Control unavailable";
CommandStatus = capabilities.SupportsOperate
- ? $"{capabilities.ControlModelText}. {capabilities.SequenceText}. Select a command value and required check flags."
+ ? "Choose a value and verify the checks before sending."
: capabilities.EngineControlServiceStatus;
SetResultTone(capabilities.SupportsOperate ? "Ready" : "Error");
}
@@ -166,6 +255,24 @@ private async void SendCommand_Click(object sender, RoutedEventArgs e)
if (!CanSend)
return;
+ // Keep explicit staged safety confirmation without cluttering the main popup.
+ // SBO selection is part of the native one-shot sequence, NOT an already-held lease.
+ var confirm = MessageBox.Show(this,
+ $"IED: {_device.Name}\nObject: {Telegram}\nValue: {SelectedValue}\n" +
+ $"Sequence: {ModelShortText}\nOrigin: {SelectedOriginCategory}/{OriginatorId.Trim()}\n\n" +
+ (TestMode ? "Send an IEC 61850 command with Test=true?" :
+ "Send a LIVE IEC 61850 command to this IED?"),
+ "Confirm control", MessageBoxButton.YesNo,
+ MessageBoxImage.Warning, MessageBoxResult.No);
+ if (confirm != MessageBoxResult.Yes) return;
+
+ // One explicit wire attempt consumes the preview. Subsequent commands
+ // need a fresh typed ctlModel / state inspection on this association.
+ _commandAttempted = true;
+ Raise(nameof(CanSend));
+ _commandFlightActive = true;
+ Raise(nameof(CanClose));
+ Raise(nameof(CanReinspect));
IsBusy = true;
CommandStage = TestMode ? "Sending test command" : "Sending live command";
CommandStatus = "Executing the IEC 61850 control sequence selected from ctlModel…";
@@ -183,22 +290,27 @@ private async void SendCommand_Click(object sender, RoutedEventArgs e)
TestMode = TestMode,
FeedbackTimeoutMs = _signal.IsPositionControl ? 12000 : 8000,
CommandTerminationTimeoutMs = 10000,
- OriginCategory = "StationControl"
+ Originator = OriginatorId.Trim(),
+ OriginCategory = SelectedOriginCategory
},
_cancellation.Token);
- CommandStage = result.IsSuccess && !TestMode ? "Command accepted" : result.Stage;
- CommandStatus = BuildCommandResultText(result);
- if (result.IsSuccess && !TestMode)
- {
- CommandStatus += " Command accepted by the IEC 61850 control service. Waiting for independent IED process feedback; monitored stVal is not changed from the command path.";
- }
- SetResultTone(result.IsSuccess ? "Success" : "Error");
+ ControlNumberText = result.ControlNumber;
+ WireTimelineText = Iec61850ControlOutcomePresentation.WireTimeline(result);
+ TerminationText = result.CommandTerminationReceived
+ ? result.PositiveTermination ? "Positive" : "Negative" : "Not reported";
+ var outcome = Iec61850ControlOutcomePresentation.From(result, TestMode);
+ CommandStage = outcome.Stage;
+ CommandStatus = outcome.Summary;
+ // IEC 61850 service acceptance is not process-image authority.
+ // Wire steps, termination, LastApplError and timeout remain inspectable.
+ ControlEvidenceText += "\n\nRESULT\n" + BuildCommandResultText(result);
+ SetResultTone(outcome.Tone);
}
catch (OperationCanceledException)
{
- CommandStage = "Cancelled";
- CommandStatus = "The command operation was cancelled.";
+ CommandStage = "Outcome uncertain";
+ CommandStatus = "Request may have reached the IED. Verify before another attempt.";
SetResultTone("Neutral");
}
catch (InvalidOperationException ex) when (
@@ -212,16 +324,30 @@ private async void SendCommand_Click(object sender, RoutedEventArgs e)
}
catch (Exception ex)
{
- CommandStage = "Command failed";
- CommandStatus = $"{ex.GetType().Name}: {ex.Message}";
- SetResultTone("Error");
+ // An unexpected failure after dispatch cannot prove the command was NOT sent.
+ CommandStage = "Outcome unresolved";
+ CommandStatus = $"{ex.GetType().Name}: {ex.Message}. Verify IED state before retry.";
+ SetResultTone("Warning");
}
finally
{
+ _commandFlightActive = false;
+ Raise(nameof(CanClose));
+ Raise(nameof(CanReinspect));
IsBusy = false;
+ Raise(nameof(CanSend));
+ Raise(nameof(CanEdit));
}
}
+ private async void Reinspect_Click(object sender, RoutedEventArgs e)
+ {
+ // Only a read-only live reinspection can re-arm the command action.
+ // Never retry a timed out or ambiguous native command automatically.
+ if (!CanReinspect) return;
+ await InspectAsync();
+ }
+
private void PopulateValueOptions(string cdc, string currentValue)
{
ValueOptions.Clear();
@@ -271,7 +397,9 @@ private void PopulateValueOptions(string cdc, string currentValue)
break;
}
- SelectedValue = ValueOptions.FirstOrDefault() ?? string.Empty;
+ // Never pre-select Open, Close, Raise, or a numeric setpoint.
+ // The operator must deliberately choose an action after inspection.
+ SelectedValue = string.Empty;
}
@@ -344,7 +472,9 @@ private void UpdateLiveWarning()
{
if (LiveCommandWarning == null)
return;
- LiveCommandWarning.Visibility = IsReady && !TestMode ? Visibility.Visible : Visibility.Collapsed;
+ // Test=true is an MMS control flag, not an offline simulator.
+ // Some servers may still change equipment: authorization is mandatory.
+ LiveCommandWarning.Visibility = IsReady ? Visibility.Visible : Visibility.Collapsed;
if (TestMode)
ConfirmLiveCommand = false;
}
@@ -376,6 +506,14 @@ private void Close_Click(object sender, RoutedEventArgs e)
private void Window_Closing(object? sender, CancelEventArgs e)
{
+ // Never abandon a dispatched native control flight by simply closing
+ // its UI. Late responses need the owning session to resolve/quarantine.
+ if (_commandFlightActive)
+ {
+ e.Cancel = true;
+ CommandStatus = "IED operation still resolving. Close after the result is known.";
+ return;
+ }
_cancellation.Cancel();
_cancellation.Dispose();
}
diff --git a/MainWindow.IedBrowser.ContextControl.cs b/MainWindow.IedBrowser.ContextControl.cs
new file mode 100644
index 000000000..706d22064
--- /dev/null
+++ b/MainWindow.IedBrowser.ContextControl.cs
@@ -0,0 +1,109 @@
+using System.Windows;
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester;
+
+///
+/// Browser Control action is a consumer of existing Command Dock live
+/// authority, NEVER a fresh speculative IED-wide lookup. The existing native
+/// ControlCommandWindow remains responsible for inspecting the live command
+/// descriptor and obtaining an explicit operator confirmation before sending.
+///
+public partial class MainWindow
+{
+ private (SignalDefinition Signal, LiveIedDataObjectModel Object)?
+ BrowserSelectedLiveControl()
+ {
+ var device = SelectedDevice;
+ 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 })
+ 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;
+ }
+
+ 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();
+ }
+}
diff --git a/MainWindow.IedBrowser.TypedDataWrite.cs b/MainWindow.IedBrowser.TypedDataWrite.cs
new file mode 100644
index 000000000..7603ab978
--- /dev/null
+++ b/MainWindow.IedBrowser.TypedDataWrite.cs
@@ -0,0 +1,179 @@
+using System.Windows;
+using System.Windows.Controls;
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Mms;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester;
+
+public partial class MainWindow
+{
+ ///
+ /// Only the currently selected, exact model-declared DA may enter the
+ /// explicit staged write dialog. Selection does NOT contact MMS.
+ /// Bounded to the visible LN/DO; never enumerate all IED rows.
+ ///
+ private LiveIedDataAttributeModel? BrowserWritableSelectedAttribute()
+ {
+ if (SelectedDevice is not { IsConnected: true, IsDemo: false, IsBusy: false,
+ IsMonitoring: false, IsBrowserReading: false } device ||
+ device.LiveDiscoveryModel is null ||
+ _iedBrowserReadOperation is not null ||
+ IedBrowserDetailList?.SelectedItem is not IedBrowserDetailRow
+ {
+ Kind: "Data", IsDataObject: false
+ } row ||
+ string.IsNullOrWhiteSpace(row.ValueReference) ||
+ row.FunctionalConstraint is not ("CF" or "SP" or "DC"))
+ return null;
+
+ var page = _iedBrowserNavigation.Current;
+ LiveIedDataObjectModel? owner = page?.Payload switch
+ {
+ LiveIedDataObjectModel obj => obj,
+ LiveIedLogicalNodeModel ln when
+ !string.IsNullOrWhiteSpace(_iedBrowserSelectedDoReference) =>
+ ln.DataObjects.FirstOrDefault(obj =>
+ string.Equals(obj.Reference, _iedBrowserSelectedDoReference,
+ StringComparison.Ordinal)),
+ _ => null
+ };
+ if (owner is null) return null;
+
+ var model = device.LiveDiscoveryModel;
+ // A navigated page or reconnected model cannot lend authority to
+ // stale row objects. Resolve exact owner in CURRENT live LD/LN.
+ var slash = owner.Reference.IndexOf('/');
+ var dotted = slash < 0 ? "" : owner.Reference[(slash + 1)..];
+ var dot = dotted.IndexOf('.');
+ if (slash < 1 || dot < 1) return null;
+ var domain = owner.Reference[..slash];
+ var lnName = dotted[..dot];
+ var liveOwner = model.LogicalDevices
+ .FirstOrDefault(ld => string.Equals(ld.MmsDomain, domain, StringComparison.Ordinal))?
+ .LogicalNodes.FirstOrDefault(ln => string.Equals(ln.Name, lnName, StringComparison.Ordinal))?
+ .DataObjects.FirstOrDefault(obj => string.Equals(obj.Reference, owner.Reference, StringComparison.Ordinal));
+ if (liveOwner is null) return null;
+ // Never treat a synthetic parent display/reference as a selected
+ // leaf. The clicked row must expose the EXACT MMS DA identity from
+ // live discovery, not a canonicalised proxy/engineering projection.
+ var candidates = liveOwner.Attributes.Where(attr =>
+ string.Equals(attr.FunctionalConstraint, row.FunctionalConstraint,
+ StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(attr.MmsReference, row.ValueReference,
+ StringComparison.Ordinal))
+ .Take(2).ToArray();
+ if (candidates.Length != 1) return null; // duplicates are ambiguous.
+ var exact = candidates[0];
+ var reference = MmsObjectReference.Parse(
+ exact.MmsReference, exact.FunctionalConstraint);
+ var parts = reference.Item.Split('$');
+ if (parts.Length < 4 ||
+ !string.Equals(parts[1], reference.FunctionalConstraint,
+ StringComparison.OrdinalIgnoreCase) ||
+ parts.Skip(2).Any(x => x is "Oper" or "SBO" or "SBOw" or "Cancel" or "ctlModel"))
+ return null;
+ return exact;
+ }
+
+ // A DO focus has a bounded local list of live attributes, never an IED-wide scan.
+ // The dialog checks the exact same current live model and association again.
+ private LiveIedDataObjectModel? BrowserWritableSelectedDo()
+ {
+ if (SelectedDevice is not { IsConnected: true, IsDemo: false, IsBusy: false,
+ IsMonitoring: false, IsBrowserReading: false } device ||
+ device.LiveValues.IsOffline || device.LiveDiscoveryModel is null ||
+ _iedBrowserReadOperation is not null)
+ return null;
+ // A focused status-only DA cannot borrow its parent DO's writable siblings.
+ if (IedBrowserDetailList?.SelectedItem is IedBrowserDetailRow
+ { Kind: "Data", IsDataObject: false })
+ return null;
+
+ var page = _iedBrowserNavigation.Current;
+ var focused = page?.Payload switch
+ {
+ LiveIedDataObjectModel obj => obj.Reference,
+ LiveIedLogicalNodeModel when !string.IsNullOrWhiteSpace(_iedBrowserSelectedDoReference)
+ => _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 + 1) return null;
+
+ var owner = 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(obj => string.Equals(obj.Reference, focused, StringComparison.Ordinal));
+ if (owner is null || owner.Attributes.Count is 0 or > 256) return null;
+
+ return owner.Attributes.Any(a =>
+ a.FunctionalConstraint is "CF" or "SP" or "DC" &&
+ !string.IsNullOrWhiteSpace(a.MmsReference) &&
+ !a.MmsReference.Contains("$Oper", StringComparison.Ordinal) &&
+ !a.MmsReference.Contains("$SBO", StringComparison.Ordinal) &&
+ !a.MmsReference.EndsWith("$ctlModel", StringComparison.Ordinal))
+ ? owner : null;
+ }
+
+ 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.";
+ }
+
+ private void IedBrowserDetailList_SelectionChanged(
+ object sender, SelectionChangedEventArgs e)
+ {
+ if (ReferenceEquals(sender, IedBrowserDetailList))
+ {
+ RefreshIedBrowserWriteButton();
+ RefreshIedBrowserControlButton();
+ }
+ }
+
+ 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))
+ {
+ IedBrowserSubtitle.Text =
+ "Write requires a current online CF/SP/DC attribute or exact owning DO.";
+ RefreshIedBrowserWriteButton();
+ return;
+ }
+
+ if (attribute is not null)
+ {
+ var dialog = new BrowserDataWriteWindow(_runtime, device, attribute)
+ {
+ Owner = this
+ };
+ dialog.ShowDialog();
+ }
+ else if (owner is not null && device.LiveDiscoveryModel is { } model)
+ {
+ var dialog = new BrowserBatchWriteWindow(_runtime, device, model, owner)
+ {
+ Owner = this
+ };
+ dialog.ShowDialog();
+ }
+ RefreshIedBrowserWriteButton();
+ // A successful MMS Write is NOT process feedback. An explicit Read or
+ // subsequent verified report remains the only live value authority.
+ }
+}
diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs
index 7e48555e3..a9fc3cd60 100644
--- a/MainWindow.IedBrowser.cs
+++ b/MainWindow.IedBrowser.cs
@@ -916,6 +916,8 @@ private void RefreshIedBrowserReadButtons()
device?.LiveDiscoveryModel is not null &&
_iedBrowserNavigation.Current is not null;
IedBrowserCancelReadButton.IsEnabled = _iedBrowserReadOperation != null;
+ RefreshIedBrowserWriteButton();
+ RefreshIedBrowserControlButton();
}
private bool TryGetBrowserReadFailure(
diff --git a/MainWindow.xaml b/MainWindow.xaml
index adabc18a8..414a0c98e 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -2046,6 +2046,14 @@
+
+