Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 90 additions & 34 deletions ControlModelWriteWindow.xaml
Original file line number Diff line number Diff line change
@@ -1,53 +1,109 @@
<Window x:Class="ArIED61850Tester.ControlModelWriteWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Write Data to IED" Width="510" Height="290" MinWidth="490"
ResizeMode="NoResize" WindowStartupLocation="CenterOwner"
Background="#F4F7FB" FontFamily="{StaticResource AppFontFamily}"
Title="Write Data to IED" Width="495" Height="284" MinWidth="455"
MinHeight="265" MaxHeight="450" ResizeMode="CanResize"
WindowStartupLocation="CenterOwner" Background="#F5F7FB"
FontFamily="{StaticResource AppFontFamily}" FontSize="11.5"
Icon="Assets/app-icon.ico" Loaded="Window_Loaded" Closing="Window_Closing">
<Grid Margin="16">
<Grid Margin="14,12,14,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*" MinHeight="0"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock x:Name="ReferenceLabel" Grid.Row="0" FontSize="11.5"

<!-- Exact IEC object identity; the only editable DA is ctlModel [CF]. -->
<TextBlock x:Name="ReferenceLabel" Grid.Row="0" FontSize="11"
Foreground="{StaticResource Ink}" FontFamily="{StaticResource AppCodeFontFamily}"
TextTrimming="CharacterEllipsis" Margin="0,0,0,12"/>
<Border Grid.Row="1" BorderThickness="1" BorderBrush="{StaticResource Line}"
CornerRadius="8" Background="#F8FAFC" Padding="10,12">
TextTrimming="CharacterEllipsis"
ToolTip="{Binding Text, RelativeSource={RelativeSource Self}}"
Margin="2,0,2,10"/>

<!-- One-row Name | Value editor, with verified enum choices only. -->
<Border Grid.Row="1" Background="White" BorderBrush="{StaticResource Line}"
BorderThickness="1" CornerRadius="7" Padding="10,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="105"/>
<ColumnDefinition Width="117"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="83"/>
</Grid.ColumnDefinitions>
<TextBlock Text="ctlModel [CF]" VerticalAlignment="Center"
Foreground="{StaticResource Ink}" FontSize="12"/>
<ComboBox x:Name="ModelPicker" Grid.Column="1" Height="31"
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="1"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Name" Grid.Row="0" FontSize="10" Foreground="{StaticResource Muted}"
Margin="2,0,0,6"/>
<TextBlock Text="Value" Grid.Row="0" Grid.Column="1" FontSize="10"
Foreground="{StaticResource Muted}" Margin="3,0,0,6"/>
<Border Grid.Row="1" Grid.ColumnSpan="3" Background="#E6ECF2"/>
<TextBlock Text="ctlModel [CF]" Grid.Row="2" Foreground="{StaticResource Ink}"
VerticalAlignment="Center" Margin="2,8,0,0"/>
<ComboBox x:Name="ModelPicker" Grid.Row="2" Grid.Column="1"
Height="31" Margin="2,8,7,0" IsEnabled="False"
ItemsSource="{Binding Choices}" DisplayMemberPath="Label"
SelectionChanged="ModelPicker_SelectionChanged" IsEnabled="False"/>
<Button x:Name="ReadButton" Grid.Column="2" Content="Read" Click="Read_Click"
Style="{StaticResource SoftButton}" Height="29" MinHeight="29"
Padding="10,3" Margin="8,0,0,0"/>
SelectionChanged="ModelPicker_SelectionChanged"
ToolTip="Verified current ctlModel is preselected after live Read"/>
<Button x:Name="ReadButton" Grid.Row="2" Grid.Column="2"
Style="{StaticResource SoftButton}" Click="Read_Click"
Height="30" MinHeight="30" Margin="0,8,0,0" Padding="6,3">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Viewbox Width="15" Height="15" Margin="0,0,5,0">
<Path Data="{StaticResource MaterialRefreshIcon}" Fill="#415771"/>
</Viewbox>
<TextBlock Text="Read" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
</Border>
<StackPanel Grid.Row="2" Margin="2,10,0,5">
<TextBlock x:Name="StatusLabel" FontSize="11" TextWrapping="Wrap"
Foreground="{StaticResource Muted}" Text="Reading ctlModel from IED…"/>
<TextBox x:Name="FailureDetails" Visibility="Collapsed" Margin="0,7,0,0"
IsReadOnly="True" IsTabStop="False" TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" MaxHeight="73" MinHeight="48"
FontSize="11" Padding="8,6" BorderThickness="1" BorderBrush="#E7C4C4"
Background="#FFF8F8" Foreground="#923C3C"/>
</StackPanel>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="WriteButton" Content="Write" Click="Write_Click"
Style="{StaticResource PrimaryButton}" Width="93" Height="31"
MinHeight="31" Padding="10,4" IsEnabled="False" Margin="0,0,8,0"/>
<Button Content="Cancel" Click="Close_Click" Style="{StaticResource SoftButton}"
Width="85" Height="31" MinHeight="31" Padding="10,4"/>
</StackPanel>

<!-- Error details may scroll; action footer never moves off screen. -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" MinHeight="0"
Margin="0,10,0,3">
<StackPanel Margin="2,0,6,0">
<TextBlock x:Name="StatusLabel" FontSize="11" TextWrapping="Wrap"
Foreground="{StaticResource Muted}"
Text="Reading current ctlModel from IED…"/>
<TextBlock Foreground="{StaticResource Muted}" FontSize="10"
Margin="0,6,0,0" TextWrapping="Wrap"
Text="Only a confirmed configuration Write is sent. No Operate or automatic retry."/>
<TextBox x:Name="FailureDetails" Visibility="Collapsed" Margin="0,8,0,0"
IsReadOnly="True" IsTabStop="False" TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" MaxHeight="96" MinHeight="43"
FontSize="11" Padding="8,6" BorderThickness="1" BorderBrush="#E7C4C4"
Background="#FFF8F8" Foreground="#923C3C"/>
</StackPanel>
</ScrollViewer>

<Grid Grid.Row="3" Margin="0,9,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button x:Name="WriteButton" Grid.Column="1" Click="Write_Click"
Style="{StaticResource PrimaryButton}" Width="94" Height="32"
MinHeight="32" Padding="9,4" IsEnabled="False" Margin="0,0,8,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Viewbox Width="16" Height="16" Margin="0,0,6,0">
<Path Data="{StaticResource MaterialEditIcon}" Fill="White"/>
</Viewbox>
<TextBlock Text="Write" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="2" Click="Close_Click" Style="{StaticResource SoftButton}"
Width="84" Height="32" MinHeight="32" Padding="8,4">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Viewbox Width="14" Height="14" Margin="0,0,5,0">
<Path Data="{StaticResource MaterialCloseIcon}" Fill="#415771"/>
</Viewbox>
<TextBlock Text="Cancel" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
</Grid>
</Window>
57 changes: 53 additions & 4 deletions ControlModelWriteWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelChoice> Choices { get; } = new()
{
Expand Down Expand Up @@ -52,23 +62,38 @@ 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;
}

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;
Expand Down Expand Up @@ -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;
Expand Down
92 changes: 92 additions & 0 deletions tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Xml.Linq;

namespace ARSAS.Tests;

/// <summary>
/// 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.
/// </summary>
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<XElement>(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);
}
}
Loading
Loading