diff --git a/ControlModelWriteWindow.xaml b/ControlModelWriteWindow.xaml
index 77171121e..6fc89b9f2 100644
--- a/ControlModelWriteWindow.xaml
+++ b/ControlModelWriteWindow.xaml
@@ -1,53 +1,109 @@
-
+
-
+
-
+
-
+ TextTrimming="CharacterEllipsis"
+ ToolTip="{Binding Text, RelativeSource={RelativeSource Self}}"
+ Margin="2,0,2,10"/>
+
+
+
-
+
-
+
-
-
+
+
+
+
+
+
+
+
+
-
+ SelectionChanged="ModelPicker_SelectionChanged"
+ ToolTip="Verified current ctlModel is preselected after live Read"/>
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ControlModelWriteWindow.xaml.cs b/ControlModelWriteWindow.xaml.cs
index bf11df221..2a64bb95d 100644
--- a/ControlModelWriteWindow.xaml.cs
+++ b/ControlModelWriteWindow.xaml.cs
@@ -22,6 +22,16 @@ public sealed record ModelChoice(Iec61850ControlModelKind Model, string Label);
private bool _busy;
private bool _liveModelReady;
private Iec61850ControlModelKind _liveModel = Iec61850ControlModelKind.Unknown;
+ // Read/GVA evidence belongs to one live MMS association and one immutable
+ // model instance. A reconnect cannot keep a previously enabled Write valid.
+ private long _inspectedAssociation;
+ private object? _inspectedLiveModel;
+
+ private bool InspectionIsCurrent =>
+ _inspectedLiveModel is not null &&
+ ReferenceEquals(_device.LiveDiscoveryModel, _inspectedLiveModel) &&
+ _device.LiveValues.Association == _inspectedAssociation &&
+ _device.IsConnected && !_cancellation.IsCancellationRequested;
public ObservableCollection Choices { get; } = new()
{
@@ -52,7 +62,7 @@ private void ModelPicker_SelectionChanged(object sender, SelectionChangedEventAr
private void UpdateWriteButton()
{
ReadButton.IsEnabled = !_busy;
- WriteButton.IsEnabled = !_busy && _device.IsConnected && _liveModelReady &&
+ WriteButton.IsEnabled = !_busy && _liveModelReady && InspectionIsCurrent &&
ModelPicker.SelectedItem is ModelChoice choice && choice.Model != _liveModel;
}
@@ -60,15 +70,30 @@ private async Task ReadModelAsync()
{
_busy = true;
_liveModelReady = false;
+ _inspectedLiveModel = null;
ModelPicker.IsEnabled = false;
UpdateWriteButton();
StatusLabel.Text = "Reading current ctlModel…";
+ var association = _device.LiveValues.Association;
+ var model = _device.LiveDiscoveryModel;
FailureDetails.Visibility = Visibility.Collapsed;
FailureDetails.Text = string.Empty;
try
{
+ if (!_device.IsConnected || model is null)
+ throw new InvalidOperationException("A connected IED with verified live model is required.");
var capabilities = await _runtime.InspectControlAsync(
_device.DeviceId, _signal, _cancellation.Token);
+ if (_cancellation.IsCancellationRequested || !IsLoaded)
+ return;
+ if (!_device.IsConnected || _device.LiveValues.Association != association ||
+ !ReferenceEquals(_device.LiveDiscoveryModel, model))
+ {
+ StatusLabel.Text = "IED connection or live model changed. Read again before Write.";
+ return;
+ }
+ _inspectedAssociation = association;
+ _inspectedLiveModel = model;
_liveModel = capabilities.ControlModel;
_liveModelReady = _liveModel != Iec61850ControlModelKind.Unknown &&
capabilities.EngineControlServiceAvailable;
@@ -105,12 +130,36 @@ private async void Read_Click(object sender, RoutedEventArgs e)
private async void Write_Click(object sender, RoutedEventArgs e)
{
- if (_busy || !_liveModelReady || !_device.IsConnected ||
+ if (_busy || !_liveModelReady || !InspectionIsCurrent ||
ModelPicker.SelectedItem is not ModelChoice selected || selected.Model == _liveModel)
return;
- // Exact expected value is confirmed again by ARIEC immediately before
- // the one permitted CF Write. No automatic mode trials.
+ // Explicit operator confirmation: changing ctlModel affects later
+ // command admission and must never be a single accidental click.
var expected = _liveModel;
+ var decision = MessageBox.Show(
+ this,
+ $"IED: {_device.Name}\n" +
+ $"DA: {_signal.ObjectReference}.ctlModel [CF]\n" +
+ $"Current: {(int)expected}\n" +
+ $"Requested: {selected.Label}\n\n" +
+ "Send one configuration Write and verify its exact readback?\n" +
+ "No Operate or automatic retry will be sent.",
+ "Confirm IEC 61850 ctlModel Write",
+ MessageBoxButton.OKCancel, MessageBoxImage.Warning, MessageBoxResult.Cancel);
+ if (decision != MessageBoxResult.OK)
+ return;
+ // An operator may spend minutes in the confirmation dialog. Recheck
+ // live identity AFTER confirmation and BEFORE entering the native call.
+ if (!InspectionIsCurrent || ModelPicker.SelectedItem is not ModelChoice current ||
+ current.Model != selected.Model || _liveModel != expected)
+ {
+ _liveModelReady = false;
+ UpdateWriteButton();
+ ShowWriteFailure("IED association or selected ctlModel changed. Read again; no Write was sent.");
+ return;
+ }
+ // Native ARIEC will additionally compare expected ctlModel, dispatch
+ // at most one CF Write, and independently verify MMS readback.
_busy = true;
_liveModelReady = false;
ModelPicker.IsEnabled = false;
diff --git a/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs
new file mode 100644
index 000000000..7cb8ddb5a
--- /dev/null
+++ b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs
@@ -0,0 +1,92 @@
+using System.Xml.Linq;
+
+namespace ARSAS.Tests;
+
+///
+/// Regression contracts for the dedicated CF ctlModel editor. The native
+/// semantic gate is tested separately by the control service suite; these
+/// tests defend operator affordances and reconnect safety at the WPF seam.
+///
+public sealed class ControlModelWriteAcceptanceTests
+{
+ private static readonly XNamespace P = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
+
+ private static string Source(string path)
+ {
+ for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
+ {
+ var file = Path.Combine(dir.FullName, path);
+ if (File.Exists(file)) return File.ReadAllText(file);
+ }
+ throw new FileNotFoundException(path);
+ }
+
+ [Fact]
+ public void SingleCfEditorHasOneInlineDropdownAndPersistentMaterialActionFooter()
+ {
+ var window = XDocument.Parse(Source("ControlModelWriteWindow.xaml"));
+ var root = Assert.IsType(window.Root);
+ Assert.Equal(P + "Window", root.Name);
+ Assert.Equal("Write Data to IED", (string?)root.Attribute("Title"));
+ Assert.Equal("CanResize", (string?)root.Attribute("ResizeMode"));
+ Assert.Equal("{StaticResource AppFontFamily}", (string?)root.Attribute("FontFamily"));
+ Assert.Equal("450", (string?)root.Attribute("MaxHeight"));
+
+ var picker = Assert.Single(window.Descendants(P + "ComboBox"));
+ Assert.Equal("ModelPicker", (string?)picker.Attribute(XName.Get("Name",
+ "http://schemas.microsoft.com/winfx/2006/xaml")));
+ Assert.Equal("{Binding Choices}", (string?)picker.Attribute("ItemsSource"));
+ Assert.Equal("False", (string?)picker.Attribute("IsEnabled"));
+ Assert.Contains(window.Descendants(P + "TextBlock"), x => (string?)x.Attribute("Text") == "Name");
+ Assert.Contains(window.Descendants(P + "TextBlock"), x => (string?)x.Attribute("Text") == "Value");
+ Assert.Contains(window.Descendants(P + "TextBlock"), x => (string?)x.Attribute("Text") == "ctlModel [CF]");
+ Assert.All(window.Descendants(P + "TextBox"), x =>
+ Assert.Equal("True", (string?)x.Attribute("IsReadOnly")));
+
+ var button = Assert.Single(window.Descendants(P + "Button").Where(x =>
+ (string?)x.Attribute("Click") == "Write_Click"));
+ Assert.Equal("94", (string?)button.Attribute("Width"));
+ Assert.Equal("3", (string?)button.Parent?.Attribute("Grid.Row"));
+ Assert.Contains(button.Descendants(P + "TextBlock"), x =>
+ (string?)x.Attribute("Text") == "Write");
+ Assert.Contains(button.Descendants(P + "Path"), x =>
+ (string?)x.Attribute("Data") == "{StaticResource MaterialEditIcon}");
+ Assert.Contains(window.Descendants(P + "Path"), x =>
+ (string?)x.Attribute("Data") == "{StaticResource MaterialRefreshIcon}");
+ Assert.Contains(window.Descendants(P + "Path"), x =>
+ (string?)x.Attribute("Data") == "{StaticResource MaterialCloseIcon}");
+
+ var scrolling = Assert.Single(window.Descendants(P + "ScrollViewer"));
+ Assert.Equal("2", (string?)scrolling.Attribute("Grid.Row"));
+ Assert.Equal("Auto", (string?)scrolling.Attribute("VerticalScrollBarVisibility"));
+ var resources = Source("App.xaml");
+ Assert.Contains("x:Key=\"MaterialEditIcon\"", resources);
+ Assert.Contains("x:Key=\"MaterialRefreshIcon\"", resources);
+ Assert.Contains("x:Key=\"MaterialCloseIcon\"", resources);
+ }
+
+ [Fact]
+ public void ControlModelWriteRequiresVerifiedLiveAssociationAndAnExplicitPostConfirmationCheck()
+ {
+ var source = Source("ControlModelWriteWindow.xaml.cs");
+ var requireRead = source.IndexOf("_inspectedAssociation = association", StringComparison.Ordinal);
+ var requireModel = source.IndexOf("_inspectedLiveModel = model", StringComparison.Ordinal);
+ var confirmation = source.IndexOf("MessageBox.Show(", StringComparison.Ordinal);
+ var afterConfirm = source.IndexOf("if (!InspectionIsCurrent", confirmation, StringComparison.Ordinal);
+ var dispatch = source.IndexOf("ConfigureExactModelAsync(expected, selected.Model", StringComparison.Ordinal);
+ Assert.True(requireRead >= 0 && requireModel >= 0);
+ Assert.True(confirmation > requireModel);
+ Assert.True(afterConfirm > confirmation);
+ Assert.True(dispatch > afterConfirm);
+ Assert.Contains("ReferenceEquals(_device.LiveDiscoveryModel, _inspectedLiveModel)", source);
+ Assert.Contains("_device.LiveValues.Association == _inspectedAssociation", source);
+ Assert.Contains("MessageBoxButton.OKCancel, MessageBoxImage.Warning, MessageBoxResult.Cancel", source);
+ Assert.Contains("no Write was sent.", source);
+ Assert.DoesNotContain("OperateAsync(", source);
+ Assert.DoesNotContain("WriteDataAttributeAsync(", source);
+
+ var bridge = Source("Services/Iec61850ControlModelWriteDispatcher.cs");
+ Assert.Contains("runtime.ConfigureControlModelAsync", bridge);
+ Assert.DoesNotContain("WriteDataAttributeAsync(", bridge);
+ }
+}
diff --git a/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs
index 679a87aab..b6fc8360a 100644
--- a/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs
+++ b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs
@@ -146,7 +146,8 @@ public void ModelButtonOpensSimplePickAndWriteDialog()
var ui = Read("ControlModelWriteWindow.xaml");
var handler = Read("ControlModelWriteWindow.xaml.cs");
Assert.Contains("new ControlModelWriteWindow(", code);
- Assert.Contains("Content=\"Write\"", ui);
+ Assert.Contains("", ui);
+ Assert.Contains("Data=\"{StaticResource MaterialEditIcon}\"", ui);
Assert.Contains("ItemsSource=\"{Binding Choices}\"", ui);
Assert.DoesNotContain("Type the exact object reference", ui);
Assert.DoesNotContain("I am authorized to change", ui);
@@ -163,7 +164,8 @@ public void ModelButtonOpensSimplePickAndWriteDialog()
Assert.Contains("public Task ConfigureControlModelAsync(", facade);
Assert.Contains("_inner.ConfigureControlModelAsync(deviceId, signal, expected, requested, token)", facade);
Assert.Contains("RunDeviceOperationAsync(", facade);
- Assert.Contains("Background=\"#F4F7FB\"", ui);
+ Assert.Contains("Background=\"#F5F7FB\"", ui);
+ Assert.Contains("FontFamily=\"{StaticResource AppFontFamily}\"", ui);
Assert.DoesNotContain("new ControlCommandWindow(", code);
}