diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml
index cf981206a..7ec7a715c 100644
--- a/.github/workflows/release-windows.yml
+++ b/.github/workflows/release-windows.yml
@@ -63,11 +63,11 @@ jobs:
python-version: '3.12'
- name: Run offline release-ready and installer-verifier fixtures
+ shell: bash
run: |
- python .\ArIED61850Tester\scripts\test-ci-release-ready.py
- if ($LASTEXITCODE -ne 0) { throw 'Release-ready metadata gate fixtures failed.' }
- python .\ArIED61850Tester\scripts\test-ci-installer-reuse.py
- if ($LASTEXITCODE -ne 0) { throw 'Validated-installer verifier fixtures failed.' }
+ set -euo pipefail
+ python ./ArIED61850Tester/scripts/test-ci-release-ready.py
+ python ./ArIED61850Tester/scripts/test-ci-installer-reuse.py
- name: Wait for exact immutable release dependencies
id: ready
@@ -76,9 +76,9 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
$proofPath = Join-Path $env:RUNNER_TEMP 'arsas-release-ready.json'
- python .\ArIED61850Tester\scripts\wait-ci-release-ready.py `
+ python ./ArIED61850Tester/scripts/wait-ci-release-ready.py `
--repository $env:GITHUB_REPOSITORY `
- --source-dir .\ArIED61850Tester `
+ --source-dir ./ArIED61850Tester `
--source-sha $env:GITHUB_SHA `
--ref-type $env:GITHUB_REF_TYPE `
--ref-name $env:GITHUB_REF_NAME `
diff --git a/.release/windows.json b/.release/windows.json
index 4bcd4d415..077417d4b 100644
--- a/.release/windows.json
+++ b/.release/windows.json
@@ -1,5 +1,5 @@
{
- "version": "1.6.40",
+ "version": "1.6.41",
"channel": "stable",
"platform": "windows-x64",
"primaryAsset": "ARSAS-Windows-x64-Setup.exe",
diff --git a/App.xaml b/App.xaml
index c5977b709..e528961f8 100644
--- a/App.xaml
+++ b/App.xaml
@@ -476,9 +476,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1010,7 +1047,7 @@
diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj
index 1af830598..97e09a24d 100644
--- a/ArIED61850Tester.csproj
+++ b/ArIED61850Tester.csproj
@@ -15,9 +15,9 @@
ARSAS
ARSAS - IEC 61850 Engineering Workstation
Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation.
- 1.6.40
- 1.6.40.0
- 1.6.40.0
+ 1.6.41
+ 1.6.41.0
+ 1.6.41.0
https://github.com/masarray/arsas
https://github.com/masarray/arsas
git
diff --git a/ControlModelWriteWindow.xaml b/ControlModelWriteWindow.xaml
new file mode 100644
index 000000000..77171121e
--- /dev/null
+++ b/ControlModelWriteWindow.xaml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ControlModelWriteWindow.xaml.cs b/ControlModelWriteWindow.xaml.cs
new file mode 100644
index 000000000..bf11df221
--- /dev/null
+++ b/ControlModelWriteWindow.xaml.cs
@@ -0,0 +1,176 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+using AR.Iec61850.Control;
+
+namespace ArIED61850Tester;
+
+///
+/// One CF value editor. Read → select → Write → exact readback.
+/// No typed phrases, extra checkboxes, command-service Operate, or retries.
+///
+public partial class ControlModelWriteWindow : Window
+{
+ public sealed record ModelChoice(Iec61850ControlModelKind Model, string Label);
+ private readonly Iec61850MonitorRuntime _runtime;
+ private readonly Iec61850MonitorDevice _device;
+ private readonly SignalDefinition _signal;
+ private readonly CancellationTokenSource _cancellation = new();
+ private bool _busy;
+ private bool _liveModelReady;
+ private Iec61850ControlModelKind _liveModel = Iec61850ControlModelKind.Unknown;
+
+ public ObservableCollection Choices { get; } = new()
+ {
+ new(Iec61850ControlModelKind.StatusOnly, "0 [status-only]"),
+ new(Iec61850ControlModelKind.DirectNormal, "1 [direct-with-normal-security]"),
+ new(Iec61850ControlModelKind.SboNormal, "2 [sbo-with-normal-security]"),
+ new(Iec61850ControlModelKind.DirectEnhanced, "3 [direct-with-enhanced-security]"),
+ new(Iec61850ControlModelKind.SboEnhanced, "4 [sbo-with-enhanced-security]")
+ };
+
+ public ControlModelWriteWindow(
+ Iec61850MonitorRuntime runtime, Iec61850MonitorDevice device, SignalDefinition signal)
+ {
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ _signal = signal ?? throw new ArgumentNullException(nameof(signal));
+ InitializeComponent();
+ DataContext = this;
+ ReferenceLabel.Text = _signal.ObjectReference + ".ctlModel";
+ }
+
+ private async void Window_Loaded(object sender, RoutedEventArgs e)
+ => await ReadModelAsync();
+
+ private void ModelPicker_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ => UpdateWriteButton();
+
+ private void UpdateWriteButton()
+ {
+ ReadButton.IsEnabled = !_busy;
+ WriteButton.IsEnabled = !_busy && _device.IsConnected && _liveModelReady &&
+ ModelPicker.SelectedItem is ModelChoice choice && choice.Model != _liveModel;
+ }
+
+ private async Task ReadModelAsync()
+ {
+ _busy = true;
+ _liveModelReady = false;
+ ModelPicker.IsEnabled = false;
+ UpdateWriteButton();
+ StatusLabel.Text = "Reading current ctlModel…";
+ FailureDetails.Visibility = Visibility.Collapsed;
+ FailureDetails.Text = string.Empty;
+ try
+ {
+ var capabilities = await _runtime.InspectControlAsync(
+ _device.DeviceId, _signal, _cancellation.Token);
+ _liveModel = capabilities.ControlModel;
+ _liveModelReady = _liveModel != Iec61850ControlModelKind.Unknown &&
+ capabilities.EngineControlServiceAvailable;
+ if (_liveModelReady)
+ {
+ ModelPicker.SelectedItem = Choices.FirstOrDefault(x => x.Model == _liveModel);
+ ModelPicker.IsEnabled = true;
+ StatusLabel.Text = $"Current: {(int)_liveModel} · {capabilities.ControlModelText}";
+ }
+ else
+ {
+ StatusLabel.Text = "ctlModel could not be read. Write disabled; check Diagnostics.";
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ StatusLabel.Text = "Read cancelled.";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Read failed: {ex.Message}";
+ }
+ finally
+ {
+ _busy = false;
+ UpdateWriteButton();
+ }
+ }
+
+ private async void Read_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_busy) await ReadModelAsync();
+ }
+
+ private async void Write_Click(object sender, RoutedEventArgs e)
+ {
+ if (_busy || !_liveModelReady || !_device.IsConnected ||
+ 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.
+ var expected = _liveModel;
+ _busy = true;
+ _liveModelReady = false;
+ ModelPicker.IsEnabled = false;
+ UpdateWriteButton();
+ StatusLabel.Text = "Writing ctlModel and checking readback…";
+ try
+ {
+ var result = await ConfigureExactModelAsync(expected, selected.Model, _cancellation.Token);
+ if (!result.WriteAccepted || !result.ReadbackVerified ||
+ (int)result.AfterModel != (int)selected.Model)
+ {
+ ShowWriteFailure(result.Detail, (int)selected.Model, (int)result.AfterModel, result.ReadbackVerified);
+ return;
+ }
+
+ // The engine has completed the exact CF write and authoritative MMS readback.
+ // Do not issue a second inspection or keep the commissioning dialog open.
+ if (!_cancellation.IsCancellationRequested && IsLoaded)
+ DialogResult = true;
+ }
+ catch (OperationCanceledException)
+ {
+ ShowWriteFailure("Connection interrupted or operation cancelled. Read actual ctlModel before retrying.");
+ }
+ catch (Exception ex)
+ {
+ ShowWriteFailure($"Write not verified: {ex.Message}. Read actual ctlModel.");
+ }
+ finally
+ {
+ _busy = false;
+ UpdateWriteButton();
+ }
+ }
+
+ private void ShowWriteFailure(string? detail,
+ int? expected = null,
+ int? actual = null, bool readbackVerified = false)
+ {
+ if (_cancellation.IsCancellationRequested || !IsLoaded)
+ return;
+
+ var reason = string.IsNullOrWhiteSpace(detail)
+ ? "IED did not provide a verified write result."
+ : detail.Trim();
+ if (readbackVerified && expected.HasValue && actual.HasValue && expected != actual)
+ reason = $"Readback mismatch • Expected {expected.Value}, actual {actual.Value}. {reason}";
+
+ FailureDetails.Text = reason;
+ FailureDetails.Visibility = Visibility.Visible;
+ StatusLabel.Text = "Write not verified • no further write will be sent automatically.";
+ }
+
+ // Compile-time transport bridge; no reflection or ad-hoc MMS Write.
+ private Task ConfigureExactModelAsync(
+ Iec61850ControlModelKind expected, Iec61850ControlModelKind selected,
+ CancellationToken cancellationToken)
+ => Iec61850ControlModelWriteDispatcher.ConfigureAsync(
+ _runtime, _device.DeviceId, _signal, expected, selected, cancellationToken);
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+ private void Window_Closing(object? sender, CancelEventArgs e) => _cancellation.Cancel();
+}
diff --git a/Directory.Build.props b/Directory.Build.props
index bdd4ff353..303b457a1 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,8 +1,8 @@
- 1.6.40
- 1.6.40.0
- 1.6.40.0
- 1.6.40
+ 1.6.41
+ 1.6.41.0
+ 1.6.41.0
+ 1.6.41
diff --git a/MainWindow.EventDetails.cs b/MainWindow.EventDetails.cs
new file mode 100644
index 000000000..9bc5658eb
--- /dev/null
+++ b/MainWindow.EventDetails.cs
@@ -0,0 +1,101 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Threading;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester;
+
+/// Presentation-only SOE inspector. Raw provenance stays in the event and CSV.
+public partial class MainWindow
+{
+ // Event detail projection is independent from deferred RCB inspector models.
+ private sealed record EventDetailProperty(string Property, string Value, string Source);
+
+ private DispatcherTimer? _eventDrawerAutoHide;
+ private bool _eventDrawerHovering;
+
+ private void EventLogGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (!ReferenceEquals(sender, EventLogGrid) || EventLogGrid.SelectedItem is not Iec61850EventEntry entry)
+ {
+ HideEventDetails();
+ return;
+ }
+
+ EventDetailsSubtitle.Text = $"{entry.DeviceName} · {entry.EventCause} · #{entry.Sequence}";
+ static string Show(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim();
+ EventDetailsGrid.ItemsSource = new[]
+ {
+ new EventDetailProperty("IED", Show(entry.DeviceName), "Event snapshot"),
+ new EventDetailProperty("Signal", Show(entry.IecReference), "Event snapshot"),
+ new EventDetailProperty("Category", entry.EventCause, "Presentation mapping"),
+ new EventDetailProperty("Before → after", $"{entry.OldValue} → {entry.NewValue}", "Event snapshot"),
+ new EventDetailProperty("Timestamp", Show(entry.DeviceTimestamp), "Event snapshot"),
+ new EventDetailProperty("Quality", Show(entry.Quality), "Event snapshot"),
+ new EventDetailProperty("Raw Reason", Show(entry.Reason), "Original event reason"),
+ new EventDetailProperty("ReasonForInclusion", Show(entry.Reason),
+ "Original event reason if provided; not separately decoded"),
+ new EventDetailProperty("Acquisition", Show(entry.SourceMode), "Runtime acquisition evidence"),
+ new EventDetailProperty("RCB Name(s)", Show(entry.AcquisitionShort), "Observed RCB routing"),
+ new EventDetailProperty("RCB type", Show(entry.ReportChannel), "Acquisition classification"),
+ new EventDetailProperty("Report ID / RptID", Show(entry.ReportId), "Received report metadata"),
+ new EventDetailProperty("RCB reference", Show(entry.ReportControlReference), "Received report metadata"),
+ new EventDetailProperty("EntryID", "— (not captured)", "Unavailable"),
+ new EventDetailProperty("Buffer overflow", "— (not captured)", "Unavailable"),
+ new EventDetailProperty("Buffered replay", entry.VerifiedBufferedReplay ? "Verified" : "Not proven", "Continuity proof")
+ };
+ EventDrawerColumn.Width = new GridLength(370);
+ EventDetailsDrawer.Visibility = Visibility.Visible;
+ RestartEventDetailsAutoHide();
+ }
+
+ private void RestartEventDetailsAutoHide()
+ {
+ _eventDrawerAutoHide ??= new DispatcherTimer(DispatcherPriority.Background)
+ {
+ Interval = TimeSpan.FromSeconds(18)
+ };
+ _eventDrawerAutoHide.Tick -= EventDrawerAutoHide_Tick;
+ _eventDrawerAutoHide.Tick += EventDrawerAutoHide_Tick;
+ _eventDrawerAutoHide.Stop();
+ if (!_eventDrawerHovering)
+ _eventDrawerAutoHide.Start();
+ }
+
+ private void EventDrawerAutoHide_Tick(object? sender, EventArgs e) => HideEventDetails();
+
+ private void EventDetailsDrawer_MouseEnter(object sender, MouseEventArgs e)
+ {
+ _eventDrawerHovering = true;
+ _eventDrawerAutoHide?.Stop();
+ }
+
+ private void EventDetailsDrawer_MouseLeave(object sender, MouseEventArgs e)
+ {
+ _eventDrawerHovering = false;
+ if (EventDetailsDrawer.Visibility == Visibility.Visible)
+ RestartEventDetailsAutoHide();
+ }
+
+ private void EventLogGrid_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Escape)
+ {
+ HideEventDetails();
+ e.Handled = true;
+ }
+ }
+
+ private void EventDetailsClose_Click(object sender, RoutedEventArgs e) => HideEventDetails();
+
+ private void HideEventDetails()
+ {
+ _eventDrawerAutoHide?.Stop();
+ _eventDrawerHovering = false;
+ if (EventDetailsDrawer == null) return;
+ EventDetailsDrawer.Visibility = Visibility.Collapsed;
+ EventDrawerColumn.Width = new GridLength(0);
+ EventDetailsGrid.ItemsSource = null;
+ }
+}
diff --git a/MainWindow.IoFatCommandBridge.cs b/MainWindow.IoFatCommandBridge.cs
index bb3683b12..6da25eec6 100644
--- a/MainWindow.IoFatCommandBridge.cs
+++ b/MainWindow.IoFatCommandBridge.cs
@@ -23,10 +23,9 @@ public partial class MainWindow
if (device is null)
return null;
- // CommandSignals contains only controls whose live ctlModel has proved that
- // operation is allowed and whose UI command semantics are supported. Keep an
- // explicit owner mapping so a FAT command cannot accidentally fall back to the
- // Engineering tab's currently selected IED in a multi-IED workspace.
+ // CommandSignals is the selected control-object catalog, including readonly
+ // objects. Only ControlSupportsOperate authorizes actions. Keep explicit IED
+ // owner mapping so FAT cannot fall back to another IED in the workspace.
device.RefreshCommandSignalProjection();
foreach (var signal in device.Signals.Where(signal => signal.IsControlSignal && signal.IsValidControlObject))
_signalOwners[signal] = device;
@@ -40,8 +39,8 @@ internal async Task RefreshIoFatCommandValuesAsync(Iec61850MonitorDevice device)
var stopwatch = Stopwatch.StartNew();
var fallbackRead = false;
- // Preload is the same serialized live ctlModel authority used by the Engineering
- // Command Panel. StatusOnly stays read-only and never enters CommandSignals.
+ // Preload uses the same live ctlModel authority as Engineering. StatusOnly
+ // remains visible in the selected catalog but stays action-disabled.
await PreloadControlModelsAsync();
device.RefreshCommandSignalProjection();
diff --git a/MainWindow.MultiRcbExport.cs b/MainWindow.MultiRcbExport.cs
index d44c5d470..fa7a69492 100644
--- a/MainWindow.MultiRcbExport.cs
+++ b/MainWindow.MultiRcbExport.cs
@@ -39,18 +39,9 @@ internal static void RegisterMultiRcbExportButtonClassHandler()
private static void MultiRcbExportButton_Loaded(object sender, RoutedEventArgs e)
{
- if (sender is not Button button || Window.GetWindow(button) is not MainWindow window)
- return;
-
- var toolTip = button.ToolTip?.ToString() ?? string.Empty;
- if (!toolTip.Contains("RCB Export Filter", StringComparison.OrdinalIgnoreCase) &&
- !toolTip.Contains("RCB Export", StringComparison.OrdinalIgnoreCase))
- return;
-
- button.Click -= window.IedEditRcb_Click;
- button.Click -= window.IedEditRcbMulti_Click;
- button.Click += window.IedEditRcbMulti_Click;
- button.ToolTip = "RCB Export — select any number of native RCBs and export generic interoperable IEC 61850 SCL";
+ // Release gate: keep the legacy XAML Click binding intact.
+ // The experimental multi-exporter is retained for a future feature branch,
+ // not installed on the production card's RCB button.
}
private void IedEditRcbMulti_Click(object sender, RoutedEventArgs e)
diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs
index bed12b760..508d0b2a3 100644
--- a/MainWindow.RcbExport.cs
+++ b/MainWindow.RcbExport.cs
@@ -499,8 +499,10 @@ private async Task ExportLegacySasRcbAsync(
}
var filteredModel = SclReportControlFilter.FilterLiveModel(exportModel, row.Reference);
+ // Retain verified IED identity authority added after v1.6.40.
+ // This preserves the public dialog/RCB workflow without security regression.
var verifiedIdentity = await TrustedSclIdentityAuthority.TryMatchAsync(
- device,filteredModel,cancellationToken);
+ device, filteredModel, cancellationToken);
var liveResult = await Task.Run(() => AuthoritativeLiveIedSclExporter.WriteFiles(
filteredModel,
outputPath,
diff --git a/MainWindow.RcbExportClickAuthority.cs b/MainWindow.RcbExportClickAuthority.cs
index 2a7e1c7ee..a4ba01c8d 100644
--- a/MainWindow.RcbExportClickAuthority.cs
+++ b/MainWindow.RcbExportClickAuthority.cs
@@ -36,8 +36,9 @@ private static void RcbExportClickAuthority_Click(object sender, RoutedEventArgs
// Class handlers execute before the XAML instance Click handler. Marking this routed
// event handled prevents IedEditRcb_Click from opening the legacy one-RCB dialog.
- e.Handled = true;
- window.IedEditRcbMulti_Click(button, e);
+ // Release authority: the existing XAML Click opens RcbExportFilterWindow.
+ // No routed-click interception and no second invocation.
+ return;
}
private static bool IsProductionRcbExportButton(Button button)
diff --git a/MainWindow.RcbMultiSelectPointerAuthority.cs b/MainWindow.RcbMultiSelectPointerAuthority.cs
index 3cefe736c..45b2cdd5f 100644
--- a/MainWindow.RcbMultiSelectPointerAuthority.cs
+++ b/MainWindow.RcbMultiSelectPointerAuthority.cs
@@ -52,8 +52,8 @@ private static void RcbExport_PreviewMouseLeftButtonDown(object sender, MouseBut
// Preview input happens before Button.Click, therefore the legacy singular XAML
// handler cannot open at all on this operator action.
- e.Handled = true;
- window.IedEditRcbMulti_Click(button, e);
+ // Release: mouse preview must fall through to the original button Click.
+ return;
}
private static void RcbExport_PreviewKeyDown(object sender, KeyEventArgs e)
@@ -64,8 +64,8 @@ private static void RcbExport_PreviewKeyDown(object sender, KeyEventArgs e)
return;
}
- e.Handled = true;
- window.IedEditRcbMulti_Click(button, e);
+ // Release: keyboard activation must fall through to the original button Click.
+ return;
}
private static bool IsProductionRcbButton(Button button)
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 8ee6d221f..654b31bb7 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -11,6 +11,12 @@
TextOptions.TextHintingMode="Fixed"
Icon="Assets/app-icon.ico" Closing="Window_Closing" PreviewKeyDown="MainWindow_PreviewKeyDown">
+
+
+
+
+
+
-
+
@@ -633,10 +641,10 @@
@@ -652,7 +660,14 @@
-
+
+
+
+
+
@@ -672,11 +687,15 @@
-
+
-
+
+
+
+
+
-
+
-
+
-
-
+
+
@@ -761,9 +779,9 @@
+ IsEnabled="{Binding ControlSupportsOperate}" Height="29" MinHeight="29" Padding="10,3"/>
+ IsEnabled="{Binding ControlSupportsOperate}" Height="29" MinHeight="29" Padding="10,3"/>
@@ -773,9 +791,27 @@
-
+
+
+
+
+
+
+
+
@@ -793,10 +829,41 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -897,7 +964,12 @@
-
+
+
+
+
+
+
@@ -917,7 +989,7 @@
-
+
@@ -939,21 +1011,58 @@
-
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1617,35 +1726,7 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 8b5553833..3e1c15984 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -102,9 +102,9 @@ public Iec61850MonitorDevice? SelectedDevice
public string HeaderStatusText => $"{Devices.Count} IED • {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring";
public string DeviceCountText => $"{Devices.Count} device(s)";
- public string RuntimeSummaryText => $"Connected {(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} • Monitoring {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} • Values {GlobalPoints.Count} • Events {Events.Count}";
+ public string RuntimeSummaryText => $"Connected {(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} • Monitoring {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} • Values {GlobalPoints.Count(point => !point.IsOffline)} • Events {Events.Count}";
public string ConnectionInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} connected / {Devices.Count} discovered";
- public string MonitoringInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring / {GlobalPoints.Count} values";
+ public string MonitoringInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring / {GlobalPoints.Count(point => !point.IsOffline)} live values";
public string EventInsightText => $"{Events.Count} event(s)";
public Visibility DiagnosticsAlertVisibility => _hasUnreadDiagnosticError ? Visibility.Visible : Visibility.Collapsed;
public Visibility EmptyExplorerVisibility => SelectedDevice == null ? Visibility.Visible : Visibility.Collapsed;
@@ -120,6 +120,12 @@ public Iec61850MonitorDevice? SelectedDevice
public MainWindow()
{
InitializeComponent();
+ // Use the actual compiled ARSAS assembly version, not a duplicated
+ // title-bar constant that can drift from the installed package.
+ var installedVersion = typeof(MainWindow).Assembly.GetName().Version;
+ Title = installedVersion is { Build: >= 0 }
+ ? $"ARSAS v{installedVersion.ToString(3)} — Smart IEC 61850 Communication Tester"
+ : "ARSAS — Smart IEC 61850 Communication Tester";
DataContext = this;
_controlShoutTimer.Tick += (_, _) =>
{
@@ -802,6 +808,8 @@ private void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs
if (MainTabs.SelectedIndex != GooseSubscriberTabIndex)
_pendingIedGooseAutoStart = null;
+ if (MainTabs.SelectedIndex != 2)
+ HideEventDetails();
if (MainTabs.SelectedIndex == 2)
{
foreach (var device in Devices)
@@ -1018,8 +1026,11 @@ private async Task ConnectUsingSavedModelAsync(
if (!device.HasDiscoveryCache || device.Signals.Count == 0)
return await ConnectAndConfigureDeviceAsync(device, openWizard: true, selectDevice: selectDevice);
- RemoveDevicePoints(device.DeviceId);
- device.Points.Clear();
+ ResetControlReadinessForNewAssociation(device);
+ // Keep last-seen rows muted during fast reconnect. Replace them only
+ // after a new monitor session has successfully built its point model.
+ MarkDevicePointsInactive(device, disconnected: true);
+ EnsureOfflinePointProjection(device);
device.RefreshComputed();
if (selectDevice)
SelectedDevice = device;
@@ -1280,7 +1291,8 @@ private async void IedPlayAction_Click(object sender, RoutedEventArgs e)
private async void IedStopAction_Click(object sender, RoutedEventArgs e)
{
- if (!TryGetDeviceFromButton(sender, out var device) || device.IsBusy) return;
+ // Stop is a preemptive safety lane even while Connect/Start is busy.
+ if (!TryGetDeviceFromButton(sender, out var device) || device.IsStopping) return;
SelectedDevice = device;
if (device.IsDemo)
{
@@ -1288,9 +1300,10 @@ private async void IedStopAction_Click(object sender, RoutedEventArgs e)
return;
}
- if (device.IsMonitoring)
- await StopDeviceMonitorAsync(device);
- else if (device.IsConnected)
+ // One Stop = one complete disconnect. The independent pause-monitor
+ // action remains available elsewhere; the IED card never requires
+ // a second Stop to release its MMS association.
+ if (device.IsBusy || device.IsConnected || device.IsMonitoring)
await StopDeviceConnectionAsync(device);
}
@@ -1368,11 +1381,13 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool
signal.ControlLastResult = "Ready • live feedback available";
return;
}
- if (!device.IsConnected || device.CommandSignals.Count == 0)
+ if (!device.IsConnected)
return;
- var candidates = device.CommandSignals
- .Where(signal => force || signal.ControlCurrentValue == "-" || signal.ControlModelText == "Auto-detect")
+ // Never gate inspection on an operable-only UI projection.
+ var candidates = Iec61850ControlIdentity.DistinctSelected(device.Signals)
+ .Where(signal => force || !signal.ControlModelResolved ||
+ signal.ControlCurrentValue == "-")
.ToArray();
if (candidates.Length == 0)
{
@@ -1394,9 +1409,14 @@ await Task.WhenAll(candidates.Select(async signal =>
if (!signal.ControlCommandBusy)
{
signal.ControlCurrentValue = capabilities.CurrentValue;
- signal.ControlLastResult = capabilities.SupportsOperate
- ? capabilities.ControlModelText
- : "Control unavailable";
+ signal.ControlInspectionEvidence =
+ $"{capabilities.ControlModelReference} | {capabilities.ControlModelText} | " +
+ $"{capabilities.EngineControlServiceStatus} | {capabilities.DiscoveryEvidence}";
+ signal.ControlLastResult = capabilities.EngineControlServiceStatus;
+ if (!capabilities.SupportsOperate)
+ AddLog("INFO", device.Name,
+ $"Read-only control inspection {signal.ObjectReference}: " +
+ signal.ControlInspectionEvidence);
}
}
catch (OperationCanceledException)
@@ -1406,7 +1426,11 @@ await Task.WhenAll(candidates.Select(async signal =>
catch (Exception ex)
{
if (!signal.ControlCommandBusy)
+ {
+ signal.ControlInspectionEvidence =
+ $"{signal.ObjectReference}: inspection failed; {ex.GetType().Name}: {ex.Message}";
signal.ControlLastResult = $"Status read failed: {ex.Message}";
+ }
}
finally
{
@@ -1421,12 +1445,56 @@ await Task.WhenAll(candidates.Select(async signal =>
SetStatus($"{device.Name}: refreshed {candidates.Length} control value(s).");
}
- private void ControlStageAction_Click(object sender, RoutedEventArgs e)
+ private async void ControlStageAction_Click(object sender, RoutedEventArgs e)
{
- if (sender is not Button button || button.Tag is not SignalDefinition signal || !signal.IsPositionControl)
+ if (sender is not Button button || button.Tag is not SignalDefinition signal ||
+ !(signal.IsPositionControl || signal.IsRaiseLowerControl ||
+ signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl))
return;
var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
+ if (device is null || !device.IsConnected || signal.ControlInspectionBusy || signal.ControlCommandBusy)
+ return;
+ DismissControlShout();
+
+ // A live Direct/SBO model permits a preflight inspection, not an
+ // unqualified Operate. Only a verified live descriptor may be staged.
+ if (signal.IsPositionControl && !signal.ControlSemanticVerified)
+ {
+ signal.ControlInspectionBusy = true;
+ try
+ {
+ var capabilities = await _runtime.InspectControlAsync(
+ device.DeviceId, signal, _applicationCancellation.Token);
+ signal.ControlCurrentValue = capabilities.CurrentValue;
+ signal.ControlLastResult = capabilities.EngineControlServiceStatus;
+ if (!capabilities.IsOperationallyReady || !signal.ControlSupportsOperate)
+ {
+ AddLog("WARN", device.Name,
+ $"Position preflight not ready: {signal.ObjectReference}; {signal.ControlInspectionEvidence}");
+ SetStatus($"{device.Name}: {signal.Name} cannot operate — check Diagnostics.");
+ ShowControlShout(Iec61850ControlShout.FromLocalRejection(
+ "live typed control preflight did not authorize operation"));
+ return;
+ }
+ }
+ catch (OperationCanceledException) { return; }
+ catch (Exception ex)
+ {
+ signal.ControlLastResult = $"Live control inspection failed: {ex.Message}";
+ AddLog("WARN", device.Name,
+ $"Position preflight failed: {signal.ObjectReference}; {ex.GetType().Name}: {ex.Message}");
+ SetStatus($"{device.Name}: {signal.Name} verification failed — check Diagnostics.");
+ ShowControlShout(Iec61850ControlShout.FromLocalRejection(
+ "live control inspection failed"));
+ return;
+ }
+ finally
+ {
+ signal.ControlInspectionBusy = false;
+ }
+ }
+
var requestedValue = button.CommandParameter?.ToString()?.Trim() ?? string.Empty;
var actionLabel = button.Content?.ToString()?.Trim() ?? string.Empty;
AddLog("INFO", device?.Name ?? "IED",
@@ -1438,6 +1506,7 @@ private void ControlStageAction_Click(object sender, RoutedEventArgs e)
AddLog("WARN", device?.Name ?? "IED",
$"Control confirmation stage rejected: {signal.ObjectReference}; reason={rejectionReason}.");
SetStatus($"{device?.Name ?? "IED"}: {signal.Name} command not staged — {rejectionReason}.");
+ ShowControlShout(Iec61850ControlShout.FromLocalRejection(rejectionReason));
return;
}
@@ -1461,6 +1530,7 @@ private async void ControlConfirmAction_Click(object sender, RoutedEventArgs e)
AddLog("WARN", device?.Name ?? "IED",
$"Confirm rejected: {signal.ObjectReference}; reason={rejectionReason}.");
SetStatus($"{device?.Name ?? "IED"}: {signal.Name} confirmation rejected — {rejectionReason}.");
+ ShowControlShout(Iec61850ControlShout.FromLocalRejection(rejectionReason));
return;
}
@@ -1493,6 +1563,7 @@ private async void ControlQuickAction_Click(object sender, RoutedEventArgs e)
if (string.IsNullOrWhiteSpace(requestedValue))
{
signal.ControlLastResult = "Enter a setpoint value first.";
+ ShowControlShout(Iec61850ControlShout.FromLocalRejection("enter a setpoint value"));
return;
}
@@ -1507,6 +1578,7 @@ private async void ControlQuickAction_Click(object sender, RoutedEventArgs e)
AddLog("WARN", device?.Name ?? "IED",
$"Direct control rejected: {signal.ObjectReference}; reason={rejectionReason}.");
SetStatus($"{device?.Name ?? "IED"}: {signal.Name} command rejected — {rejectionReason}.");
+ ShowControlShout(Iec61850ControlShout.FromLocalRejection(rejectionReason));
return;
}
@@ -1522,6 +1594,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
return;
}
+ DismissControlShout();
var clickStopwatch = Stopwatch.StartNew();
signal.ControlLastResult = $"Dispatching {claim.RequestedValue}…";
SetStatus($"{device.Name}: dispatching {signal.Name} = {claim.RequestedValue}…");
@@ -1534,7 +1607,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
if (device.IsDemo)
{
await ExecuteDemoControlAsync(device, signal, claim);
- return;
+ return; // Demo success is reflected in the values/status, not in a toast.
}
if (!device.IsConnected)
{
@@ -1543,7 +1616,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
? await ConnectUsingSavedModelAsync(device)
: await ConnectAndConfigureDeviceAsync(device, openWizard: false);
if (!connected)
+ {
+ ShowControlShout(Iec61850ControlShout.FromLocalRejection(
+ "MMS connection could not be established"));
return;
+ }
}
AddLog("INFO", device.Name,
@@ -1575,14 +1652,13 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
AddLog(result.IsSuccess ? "INFO" : "WARN", device.Name,
$"Control UI timing: {signal.ObjectReference}; sequence={claim.Sequence}; click-to-result={clickStopwatch.Elapsed.TotalMilliseconds:0.###} ms; engine-total={result.TotalElapsedText}; serviceAccepted={result.ServiceAccepted}; stage={result.Stage}.");
- // Failure must be visible without a modal dialog blocking unrelated
- // IED operations. Service/AddCause evidence stays in diagnostics and
- // the per-command result; the notice never invents Local/Remote.
+ // Only failures require immediate, local Command Dock feedback.
+ // Successful commands and already-at-state preflights remain
+ // visible in values/status/Diagnostics without a distracting toast.
if (!result.IsSuccess)
{
MarkDiagnosticAlert();
- var notice = Iec61850ControlShout.FromResult(result);
- ShowControlShout(notice.Title, notice.Detail);
+ ShowControlShout(Iec61850ControlShout.FromResult(result));
}
}
catch (OperationCanceledException)
@@ -1596,8 +1672,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}");
SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}");
MarkDiagnosticAlert();
- var notice = Iec61850ControlShout.FromUnexpectedFailure();
- ShowControlShout(notice.Title, notice.Detail);
+ ShowControlShout(Iec61850ControlShout.FromUnexpectedFailure());
}
finally
{
@@ -1641,19 +1716,42 @@ private static string BuildQuickControlResult(Iec61850ControlCommandResult resul
: $"{result.Stage}: {Iec61850ControlFailureReason.Explain(result).Summary} • {result.Message}{suffix}";
}
- private void ShowControlShout(string title, string detail)
+ private void DismissControlShout()
{
- // UI ownership is confined to WPF Dispatcher. The runtime's atomic
- // per-target command admission remains independent of display timing.
+ // Stale failures must not appear to belong to the next command.
+ _controlShoutTimer.Stop();
+ ControlShoutCard.BeginAnimation(OpacityProperty, null);
+ ControlShoutCard.Visibility = Visibility.Collapsed;
+ }
+
+ private void ShowControlShout(Iec61850ControlShout.Notice notice)
+ {
+ // Only warning/error notifications. The full failure evidence stays
+ // in Diagnostics; only one short summary is rendered in Command Dock.
if (!Dispatcher.CheckAccess())
{
- Dispatcher.BeginInvoke(() => ShowControlShout(title, detail));
+ Dispatcher.BeginInvoke(() => ShowControlShout(notice));
return;
}
+ if (notice.Tone is not (Iec61850ControlNoticeTone.Warning or Iec61850ControlNoticeTone.Error))
+ return;
+
+ var error = notice.Tone == Iec61850ControlNoticeTone.Error;
+ var accent = error ? Color.FromRgb(175, 57, 53) : Color.FromRgb(153, 98, 26);
_controlShoutTimer.Stop();
- ControlShoutTitle.Text = title;
- ControlShoutDetail.Text = detail;
+ _controlShoutTimer.Interval = TimeSpan.FromSeconds(error ? 6 : 4.5);
+ ControlShoutCard.BeginAnimation(OpacityProperty, null);
+ ControlShoutCard.BorderBrush = new SolidColorBrush(
+ error ? Color.FromRgb(238, 193, 189) : Color.FromRgb(232, 201, 154));
+ ControlShoutGlyphBackdrop.Background = new SolidColorBrush(
+ error ? Color.FromRgb(254, 236, 233) : Color.FromRgb(255, 245, 226));
+ ControlShoutGlyph.Foreground = new SolidColorBrush(accent);
+ ControlShoutTitle.Foreground = new SolidColorBrush(accent);
+ ControlShoutGlyph.Text = error ? "×" : "!";
+ ControlShoutTitle.Text = notice.Title;
ControlShoutCard.Visibility = Visibility.Visible;
+ ControlShoutCard.BeginAnimation(OpacityProperty,
+ new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(140)));
_controlShoutTimer.Start();
}
@@ -1662,25 +1760,25 @@ private async void ControlDetails_Click(object sender, RoutedEventArgs e)
if (sender is not Button button || button.Tag is not SignalDefinition signal || !signal.IsControlSignal)
return;
- var device = SelectedDevice;
+ var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
if (device == null)
return;
- if (!device.IsConnected)
+ // ctlModel writes require an intentionally opened, current association.
+ // Never create a hidden MMS connection from an offline command dock.
+ if (!device.IsConnected || device.IsStopping || device.IsBusy)
{
- SetStatus($"{device.Name}: connecting before opening technical control details…");
- var connected = device.HasDiscoveryCache && device.Signals.Count > 0
- ? await ConnectUsingSavedModelAsync(device)
- : await ConnectAndConfigureDeviceAsync(device, openWizard: false);
- if (!connected)
- return;
+ SetStatus($"{device.Name}: press Play to connect before editing the control model.");
+ return;
}
- var dialog = new ControlCommandWindow(_runtime, device, signal)
+ var dialog = new ControlModelWriteWindow(_runtime, device, signal)
{
Owner = this
};
dialog.ShowDialog();
+ if (device.IsConnected)
+ await RefreshControlValuesAsync(device, force: true);
}
private void RemoveControlFeedbackIndex(string deviceId)
@@ -2090,11 +2188,15 @@ private async Task StartDeviceMonitorAsync(Iec61850MonitorDevice device, b
}
catch (OperationCanceledException)
{
- SetStatus($"{device.Name}: monitor start cancelled.");
+ EnsureOfflinePointProjection(device);
+ MarkDevicePointsInactive(device, disconnected: !device.IsConnected);
+ SetStatus($"{device.Name}: monitor start cancelled; offline signal catalog retained.");
return false;
}
catch (Exception ex)
{
+ EnsureOfflinePointProjection(device);
+ MarkDevicePointsInactive(device, disconnected: !device.IsConnected);
AddLog("ERROR", device.Name, ex.Message);
SetStatus($"{device.Name}: monitor start failed. Diagnostics is marked with !.");
MarkDiagnosticAlert();
@@ -2109,31 +2211,127 @@ private async Task StopDeviceMonitorAsync(Iec61850MonitorDevice device)
device.ReportPulseActive = false;
_reportPulseUntil.Remove(device.DeviceId);
RemoveDeviceHighlights(device.DeviceId);
- RemoveDevicePoints(device.DeviceId);
- device.Points.Clear();
+ MarkDevicePointsInactive(device, disconnected: false);
+ EnsureOfflinePointProjection(device);
device.RefreshComputed();
RaiseWorkspaceCounts();
- SetStatus($"{device.Name}: monitoring stopped; MMS connection remains available.");
+ SetStatus($"{device.Name}: monitoring paused; last observed values are not live.");
+ }
+
+ private static void ResetControlReadinessForNewAssociation(Iec61850MonitorDevice device)
+ {
+ foreach (var signal in device.Signals.Where(s => s.IsValidControlObject))
+ {
+ signal.ClearControlConfirmation();
+ signal.ClearLiveControlAuthority();
+ signal.ControlInspectionBusy = false;
+ signal.ControlModelText = "Auto-detect";
+ signal.ControlInspectionEvidence = "Inspection pending for the current MMS association.";
+ signal.ControlLastResult = "Inspection pending for the current MMS association.";
+ }
+ device.RefreshCommandSignalProjection();
}
private async Task StopDeviceConnectionAsync(Iec61850MonitorDevice device)
{
- await _runtime.StopDeviceAsync(device.DeviceId);
- device.HasReportStream = false;
- device.ReportPulseActive = false;
- _reportPulseUntil.Remove(device.DeviceId);
- RemoveDeviceHighlights(device.DeviceId);
- RemoveDevicePoints(device.DeviceId);
- device.Points.Clear();
- if (device.HasDiscoveryCache)
+ // The button and every alternate Stop caller share this UI flight.
+ // A repeat click must not initiate another MMS teardown or consume
+ // a newly opened association while the first disconnect is in flight.
+ if (device.IsStopping) return;
+ device.IsStopping = true;
+ device.Status = "Disconnecting";
+ try
{
- device.Status = "Saved model ready";
- device.Detail = "Press Play for fast reconnect and live values. Use Re-scan only when the IED model changes.";
- device.AcquisitionMode = "Saved model • fast connect";
+ await _runtime.StopDeviceAsync(device.DeviceId);
}
+ catch (Exception ex)
+ {
+ // A failed native teardown must never prevent the operator from
+ // pressing Play again. The runtime evicts the association first.
+ AddLog("WARN", device.Name, $"Disconnect cleanup: {ex.GetType().Name}: {ex.Message}");
+ }
+ finally
+ {
+ ResetControlReadinessForNewAssociation(device);
+ device.IsMonitoring = false;
+ device.IsConnected = false;
+ device.HasReportStream = false;
+ device.ReportPulseActive = false;
+ _reportPulseUntil.Remove(device.DeviceId);
+ RemoveDeviceHighlights(device.DeviceId);
+ MarkDevicePointsInactive(device, disconnected: true);
+ EnsureOfflinePointProjection(device);
+ if (device.HasDiscoveryCache)
+ {
+ device.Status = "Offline • saved model";
+ device.Detail = "Last observed values are offline. Press Play on this IED to reconnect.";
+ device.AcquisitionMode = "Saved model • offline";
+ }
+ device.RefreshComputed();
+ device.IsStopping = false;
+ RaiseWorkspaceCounts();
+ SetStatus($"{device.Name}: disconnected; values retained as offline. Press Play to reconnect.");
+ }
+ }
+
+ // Offline is a display state, not an IEC 61850 quality claim or a fresh
+ // process measurement. Retain old values/timestamps, but never present
+ // them as live data or raise new SOE events during the transition.
+ private void MarkDevicePointsInactive(Iec61850MonitorDevice device, bool disconnected)
+ {
+ var label = disconnected ? "Offline" : "Paused";
+ foreach (var point in device.Points)
+ {
+ point.IsOffline = true;
+ point.IsRecentlyChanged = false;
+ point.SourceMode = label;
+ point.Quality = label;
+ point.Status = disconnected ? "Offline • last known value" : "Paused • last known value";
+ }
+ foreach (var key in _pendingPointSnapshots.Keys.Where(key =>
+ key.StartsWith(device.DeviceId + "|", StringComparison.OrdinalIgnoreCase)).ToArray())
+ _pendingPointSnapshots.TryRemove(key, out _);
+ }
+
+ // A saved project may be opened with no MMS session and no runtime points.
+ // Materialize *only* the user's already-selected live signals, never new
+ // selections or invented process values. Live Start replaces these rows.
+ private void EnsureOfflinePointProjection(Iec61850MonitorDevice device)
+ {
+ if (device.Points.Count > 0) return;
+ var offlinePoints = device.Signals
+ .Where(signal => signal.IsSelected && signal.CanPublishAsSignal)
+ .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .Select(signal => new Iec61850MonitorPoint
+ {
+ DeviceId = device.DeviceId,
+ DeviceName = device.Name,
+ IpAddress = device.IpAddress,
+ SignalName = string.IsNullOrWhiteSpace(signal.Name) ? signal.ObjectReference : signal.Name,
+ IecReference = signal.ObjectReference,
+ QualityReference = signal.QualityReference,
+ TimestampReference = signal.TimestampReference,
+ FunctionalConstraint = signal.FunctionalConstraint,
+ IecDataType = signal.DataType,
+ Category = signal.Category,
+ Unit = signal.Unit,
+ DataSetReference = signal.DataSetReference,
+ ReportControlReference = signal.ReportControlReference,
+ PollingIntervalMs = PollingIntervalMs,
+ Quality = "Offline",
+ SourceMode = "Offline",
+ Status = "Offline • waiting for Play",
+ IsOffline = true
+ })
+ .ToArray();
+ if (offlinePoints.Length == 0) return;
+ device.Points.AddRange(offlinePoints);
+ GlobalPoints.AddRange(offlinePoints);
+ foreach (var point in offlinePoints)
+ _pointIndex[point.PointKey] = point;
device.RefreshComputed();
RaiseWorkspaceCounts();
- SetStatus($"{device.Name}: connection closed; saved model retained for fast reconnect.");
}
private void Runtime_PointUpdated(Iec61850PointSnapshot snapshot)
@@ -2190,8 +2388,13 @@ private void UiFlushTimer_Tick(object? sender, EventArgs e)
if (pending.HasValueEdge || snapshot.IsValueEdge || uiDetectedEdge)
MarkPointRecentlyChanged(point);
point.Quality = snapshot.Quality;
+ point.IsOffline = false;
point.DeviceTimestamp = snapshot.DeviceTimestamp;
point.SourceMode = snapshot.SourceMode;
+ // The immutable runtime snapshot aggregates only RCBs that have
+ // actually delivered this signal on the current association.
+ // A non-value status snapshot cannot erase prior routed sources.
+ point.RoutedReportControlReferences = snapshot.RoutedReportControlReferences;
point.Reason = snapshot.Reason;
point.Status = snapshot.Status;
point.Sequence = snapshot.Sequence;
@@ -2471,6 +2674,7 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e)
}
device.Signals.AddRange(cachedSignals);
device.RecountSelectedSignals();
+ EnsureOfflinePointProjection(device);
}
else
{
@@ -2562,7 +2766,7 @@ private void ExportEvents_Click(object sender, RoutedEventArgs e)
try
{
var csv = new StringBuilder();
- csv.AppendLine("Sequence,DeviceTimestamp,IED,IP,Signal,Value,PreviousValue,Transition,Quality,Acquisition,IECTelegram");
+ csv.AppendLine("Sequence,DeviceTimestamp,IED,IP,Signal,Value,PreviousValue,Transition,Quality,Event,Acquisition,Stream,RawAcquisition,RawReason,ReportId,RCBReference,IECTelegram");
foreach (var entry in Events.OrderBy(item => item.Sequence))
{
csv.AppendLine(string.Join(',', new[]
@@ -2576,7 +2780,13 @@ private void ExportEvents_Click(object sender, RoutedEventArgs e)
Csv(entry.OldValue),
Csv(entry.EdgeType),
Csv(entry.Quality),
+ Csv(entry.EventCause),
+ Csv(entry.AcquisitionShort),
+ Csv(entry.ReportChannel),
Csv(entry.SourceMode),
+ Csv(entry.Reason),
+ Csv(entry.ReportId),
+ Csv(entry.ReportControlReference),
Csv(entry.IecTelegram)
}));
}
diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs
index eeb30e767..2d2c15f04 100644
--- a/Models/MonitorModels.cs
+++ b/Models/MonitorModels.cs
@@ -17,6 +17,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject
private string _acquisitionMode = "Not started";
private bool _isConnected;
private bool _isBusy;
+ private bool _isStopping;
private bool _isMonitoring;
private bool _isActive;
private bool _hasReportStream;
@@ -338,6 +339,17 @@ public bool IsBusy
}
}
+ // One disconnect flight at a time; prevents double-click and Play races.
+ public bool IsStopping
+ {
+ get => _isStopping;
+ set
+ {
+ if (Set(ref _isStopping, value))
+ RefreshComputed();
+ }
+ }
+
public bool IsMonitoring
{
get => _isMonitoring;
@@ -480,8 +492,10 @@ public void MarkDiscoveryFailed(string message)
public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring && !IsDemo;
public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy;
public bool CanStartOrStopMonitor => !IsBusy && (IsMonitoring || SelectedLiveSignalCount > 0);
- public bool CanPlayAction => !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0));
- public bool CanStopAction => !IsBusy && (IsConnected || IsMonitoring);
+ public bool CanPlayAction => !IsStopping && !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0));
+ // Stop is intentionally pre-emptive: a hung Connect/Start must not
+ // disable the only safe operator exit from an in-flight MMS operation.
+ public bool CanStopAction => !IsStopping && (IsBusy || IsConnected || IsMonitoring);
public bool CanRescan => !IsBusy && !IsMonitoring && !IsDemo;
public bool CanSaveScl => !IsBusy && !IsDemo && (SclWorkspace != null || LiveDiscoveryModel != null);
public string SaveSclToolTip => SclWorkspace != null
@@ -551,7 +565,9 @@ public void MarkDiscoveryFailed(string message)
: SelectedLiveSignalCount == 0
? $"Choose signals for {Name} first"
: $"Start monitoring {Name}";
- public string StopToolTip => IsMonitoring
+ public string StopToolTip => IsStopping
+ ? $"Disconnecting {Name}…"
+ : IsMonitoring
? $"Stop monitoring {Name}"
: IsConnected
? $"Disconnect {Name}"
@@ -595,10 +611,9 @@ public void RefreshCommandSignalProjection()
private void RefreshCommandSignals()
{
- // The Command Panel is an operating surface, not a second signal browser.
- // Keep unresolved, StatusOnly, unsupported, and feedback-only objects out so
- // the panel contains only actions the connected IED has proven executable.
- var selected = Iec61850ControlIdentity.DistinctOperable(Signals)
+ // Show every selected valid control DataObject. Operate authorization remains
+ // independently gated by ControlSupportsOperate on each command action.
+ var selected = Iec61850ControlIdentity.DistinctSelected(Signals)
.OrderBy(signal => signal.SortPriority)
.ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase)
.ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase)
@@ -653,10 +668,12 @@ public sealed class Iec61850MonitorPoint : ObservableObject
private string _quality = "Unknown";
private string _deviceTimestamp = "-";
private string _sourceMode = "Waiting";
+ private IReadOnlyList _routedReportControlReferences = Array.Empty();
private string _reason = "-";
private string _status = "Queued";
private long _sequence;
private bool _isRecentlyChanged;
+ private bool _isOffline;
private bool _isAnnunciatorSelected;
public string DeviceId { get; set; } = string.Empty;
@@ -740,12 +757,50 @@ public string Value
IecReference);
public string Quality { get => _quality; set => Set(ref _quality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); }
public string DeviceTimestamp { get => _deviceTimestamp; set => Set(ref _deviceTimestamp, string.IsNullOrWhiteSpace(value) ? "-" : value); }
- public string SourceMode { get => _sourceMode; set => Set(ref _sourceMode, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); }
+ public string SourceMode
+ {
+ get => _sourceMode;
+ set
+ {
+ if (Set(ref _sourceMode, string.IsNullOrWhiteSpace(value) ? "Unknown" : value))
+ Raise(nameof(AcquisitionShort));
+ }
+ }
+ // Proven by a live routed report frame, not a configured/cached RCB hint.
+ // Written on the WPF UI thread from a point snapshot only.
+ // Association-scoped actual report ingress; an IEC DataSet member may be
+ // published by multiple distinct enabled RCBs. This is NOT the set of all
+ // merely configured RCBs or the last wire RptID.
+ public IReadOnlyList RoutedReportControlReferences
+ {
+ get => _routedReportControlReferences;
+ set
+ {
+ var next = value?.ToArray() ?? Array.Empty();
+ if (_routedReportControlReferences.SequenceEqual(next, StringComparer.OrdinalIgnoreCase))
+ return;
+ if (Set(ref _routedReportControlReferences, next))
+ Raise(nameof(AcquisitionShort));
+ }
+ }
+ public string AcquisitionShort => Iec61850EventOriginPolicy.PointAcquisition(
+ RoutedReportControlReferences, SourceMode, IsOffline);
public string Reason { get => _reason; set => Set(ref _reason, string.IsNullOrWhiteSpace(value) ? "-" : value); }
public string Status { get => _status; set => Set(ref _status, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); }
// Runtime ordering metadata is not rendered. Avoid an unnecessary WPF property-change
// notification for every poll/report sample; the value still guards stale snapshots.
public long Sequence { get => _sequence; set => _sequence = value; }
+ // Presentation state only: cached/last-seen values cannot be mistaken
+ // for fresh IED data when the MMS association has been stopped.
+ public bool IsOffline
+ {
+ get => _isOffline;
+ set
+ {
+ if (Set(ref _isOffline, value))
+ Raise(nameof(AcquisitionShort));
+ }
+ }
public bool IsRecentlyChanged { get => _isRecentlyChanged; set => Set(ref _isRecentlyChanged, value); }
public bool IsAnnunciatorSelected { get => _isAnnunciatorSelected; set => Set(ref _isAnnunciatorSelected, value); }
public bool CanUseAsAnnunciator => FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase);
@@ -855,6 +910,9 @@ public sealed class Iec61850PointSnapshot
///
public bool IsValueEdge { get; init; }
public bool IsReportTraffic { get; init; }
+ // Association-local aggregate sourced only from real decoded report ingress.
+ // Immutable copy when published: safe across the runtime / WPF dispatcher.
+ public IReadOnlyList RoutedReportControlReferences { get; init; } = Array.Empty();
public long Sequence { get; init; }
}
@@ -874,6 +932,20 @@ public sealed class Iec61850EventEntry
public string Quality { get; init; } = "Unknown";
public string SourceMode { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
+ // Raw reporting identity, copied ONLY from a routed live report, never
+ // fabricated from static DataSet labels or a process-value edge.
+ public string ReportId { get; init; } = string.Empty;
+ public string ReportControlReference { get; init; } = string.Empty;
+ public IReadOnlyList RoutedReportControlReferences { get; init; } = Array.Empty();
+ // Assigned only from future independently proven buffered replay evidence.
+ // BRCB membership or receipt of a buffered report does not prove replay.
+ public bool VerifiedBufferedReplay { get; init; }
+ // Operator labels are deliberately short. Full source evidence remains
+ // available in the export and the native report diagnostics.
+ public string EventCause => Iec61850EventOriginPolicy.Cause(Reason, SourceMode, VerifiedBufferedReplay);
+ public string AcquisitionShort => Iec61850EventOriginPolicy.Acquisition(
+ ReportId, ReportControlReference, SourceMode, RoutedReportControlReferences);
+ public string ReportChannel => Iec61850EventOriginPolicy.Channel(SourceMode);
public string EdgeType
{
get
diff --git a/Models/SignalDefinition.cs b/Models/SignalDefinition.cs
index b6c1aa4c2..47d1eb5af 100644
--- a/Models/SignalDefinition.cs
+++ b/Models/SignalDefinition.cs
@@ -16,6 +16,8 @@ public class SignalDefinition : ObservableObject
private string _displayReference = string.Empty;
private DateTime _timestamp = DateTime.MinValue;
private string _controlCdc = string.Empty;
+ private string _verifiedLiveControlCdc = string.Empty;
+ private string _dataSetAdvisoryCdc = string.Empty;
private string _controlModelReference = string.Empty;
private string _controlStatusReference = string.Empty;
private string _controlModelText = "Auto-detect";
@@ -28,6 +30,7 @@ public class SignalDefinition : ObservableObject
private string? _deferredControlCurrentValue;
private string _controlSetPointText = string.Empty;
private string _controlLastResult = string.Empty;
+ private string _controlInspectionEvidence = "Live ctlModel inspection pending.";
private readonly object _controlCommandSync = new();
private bool _controlInspectionBusy;
private bool _controlCommandBusy;
@@ -91,15 +94,76 @@ public string DisplayReference
public string Source { get; set; } = "Online";
public bool IsControlSignal { get; set; }
public bool IsValidControlObject => IsControlSignal && IsControlObjectReference(ObjectReference);
+ /// Association-scoped CDC proved by a typed live control descriptor.
+ [System.Text.Json.Serialization.JsonIgnore]
+ public string VerifiedLiveControlCdc => _verifiedLiveControlCdc;
+
+ /// Last advisory CDC from SCL/DataSet projection; not command authority.
+ [System.Text.Json.Serialization.JsonIgnore]
+ public string DataSetAdvisoryCdc => _dataSetAdvisoryCdc;
+
public string ControlCdc
{
get => _controlCdc;
set
{
- if (!Set(ref _controlCdc, value?.Trim() ?? string.Empty)) return;
+ var candidate = value?.Trim() ?? string.Empty;
+ // Existing callers can no longer overwrite a live-verified CDC using
+ // stale SCL, DataSet, or speculative discovery metadata.
+ if (_verifiedLiveControlCdc.Length > 0 &&
+ !candidate.Equals(_verifiedLiveControlCdc, StringComparison.OrdinalIgnoreCase))
+ return;
+ if (!Set(ref _controlCdc, candidate)) return;
RaiseControlActionProperties();
}
}
+
+ public void AcceptVerifiedLiveControlCdc(string cdc)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(cdc);
+ if (!IsValidControlObject)
+ throw new InvalidOperationException("Only a valid control DataObject can receive live CDC evidence.");
+ var validated = cdc.Trim().ToUpperInvariant();
+ // This method must only be called after the engine's typed live semantic
+ // resolver succeeded; it is not an SCL authority override.
+ _verifiedLiveControlCdc = validated;
+ Raise(nameof(VerifiedLiveControlCdc));
+ ControlCdc = validated;
+ }
+
+ public bool ApplyDataSetAdvisoryControlMetadata(string cdc, string? statusReference)
+ {
+ _dataSetAdvisoryCdc = cdc?.Trim().ToUpperInvariant() ?? string.Empty;
+ Raise(nameof(DataSetAdvisoryCdc));
+ if (_verifiedLiveControlCdc.Length > 0)
+ {
+ // Reporting membership is independent of the control plane. Do not
+ // rewrite validated CDC or live feedback binding during monitoring.
+ return _dataSetAdvisoryCdc.Equals(_verifiedLiveControlCdc, StringComparison.OrdinalIgnoreCase);
+ }
+ if (_dataSetAdvisoryCdc.Length > 0)
+ ControlCdc = _dataSetAdvisoryCdc;
+ if (!string.IsNullOrWhiteSpace(statusReference))
+ ControlStatusReference = statusReference;
+ return true;
+ }
+
+ public void ClearLiveControlAuthority()
+ {
+ // Called only at MMS association teardown/new association boundary.
+ _verifiedLiveControlCdc = string.Empty;
+ _dataSetAdvisoryCdc = string.Empty;
+ Raise(nameof(VerifiedLiveControlCdc));
+ Raise(nameof(DataSetAdvisoryCdc));
+ ControlSemanticVerified = false;
+ }
+ // Preserve exact read-only MMS proof independently of ControlLastResult,
+ // whose operator-friendly StatusOnly normalization intentionally shortens text.
+ public string ControlInspectionEvidence
+ {
+ get => _controlInspectionEvidence;
+ set => Set(ref _controlInspectionEvidence, value?.Trim() ?? string.Empty);
+ }
public string ControlModelReference { get => _controlModelReference; set => Set(ref _controlModelReference, value?.Trim() ?? string.Empty); }
public string ControlStatusReference { get => _controlStatusReference; set => Set(ref _controlStatusReference, value?.Trim() ?? string.Empty); }
public string ControlModelText
@@ -138,6 +202,14 @@ Iec61850ControlModelKind.DirectNormal or
Iec61850ControlModelKind.SboNormal or
Iec61850ControlModelKind.DirectEnhanced or
Iec61850ControlModelKind.SboEnhanced;
+ // Direct/SBO makes an Open/Close preflight clickable; actual Select/Operate
+ // still requires live, typed CDC/status/ctlVal semantic verification.
+ public bool ControlCanInspectPosition => _controlModelResolved &&
+ _controlModelKind is (Iec61850ControlModelKind.DirectNormal or
+ Iec61850ControlModelKind.SboNormal or
+ Iec61850ControlModelKind.DirectEnhanced or
+ Iec61850ControlModelKind.SboEnhanced) &&
+ !_controlInspectionBusy && !_controlCommandBusy;
public bool IsReadOnlyControl => _controlModelResolved && !ControlSupportsOperate;
public string ControlValueType
{
@@ -234,6 +306,7 @@ public bool ControlInspectionBusy
if (!Set(ref _controlInspectionBusy, value))
return;
Raise(nameof(ControlIsBusy));
+ Raise(nameof(ControlCanInspectPosition));
}
}
@@ -403,6 +476,7 @@ public bool CompleteControlCommand(ControlCommandClaim claim)
Raise(nameof(ControlCommandBusy));
Raise(nameof(ControlIsBusy));
+ Raise(nameof(ControlCanInspectPosition));
Raise(nameof(ControlCanConfirm));
if (deferred != null)
ApplyControlCurrentValue(deferred);
@@ -434,10 +508,14 @@ private void PublishCommandClaimed()
Raise(nameof(ControlConfirmationPending));
Raise(nameof(ControlCommandBusy));
Raise(nameof(ControlIsBusy));
+ Raise(nameof(ControlCanInspectPosition));
Raise(nameof(ControlCanConfirm));
}
- private bool CanExposeControlActions => !_controlModelResolved || ControlSupportsOperate;
+ // Presentation classification is independent of live command authorization.
+ // A StatusOnly/unknown MMS ctlModel must NEVER hide a known CDC action family;
+ // ControlSupportsOperate still independently gates every staged/direct write.
+ private bool CanExposeControlActions => IsValidControlObject;
private bool IsPositionSemanticControl
{
@@ -547,6 +625,7 @@ private void ApplyControlModel(Iec61850ControlModelKind model, bool resolved, bo
Raise(nameof(ControlModelKind));
Raise(nameof(ControlModelResolved));
+ Raise(nameof(ControlCanInspectPosition));
Raise(nameof(ControlSupportsOperate));
Raise(nameof(ControlCanConfirm));
Raise(nameof(IsReadOnlyControl));
diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs
index 336332cdd..fd67a132c 100644
--- a/Services/DiagnosticReportBuilder.cs
+++ b/Services/DiagnosticReportBuilder.cs
@@ -176,6 +176,17 @@ public static async Task BuildAsync(
AppendStaticInitialImage(builder, device.Points);
builder.AppendLine($"Saved model : {device.HasDiscoveryCache} ({device.SignalCount:N0} signal(s))");
builder.AppendLine($"Selected : live={device.SelectedLiveSignalCount:N0}, control={device.SelectedControlSignalCount:N0}");
+ var commandCatalog = Iec61850ControlIdentity.DistinctSelected(device.Signals);
+ builder.AppendLine($"Control catalog : selected={commandCatalog.Length}, verified-operable={commandCatalog.Count(s => s.ControlSupportsOperate)}, read-only-or-unverified={commandCatalog.Count(s => !s.ControlSupportsOperate)}");
+ foreach (var command in commandCatalog.Take(40))
+ {
+ // A bounded single line gives next real-test report the live
+ // ctlModel reference/value and reason without losing raw evidence.
+ var evidence = (command.ControlInspectionEvidence ?? string.Empty)
+ .Replace('\r', ' ').Replace('\n', ' ');
+ if (evidence.Length > 480) evidence = evidence[..480] + "…";
+ builder.AppendLine($" {command.ObjectReference} | CDC={command.ControlCdc} | liveVerifiedCdc={command.VerifiedLiveControlCdc} | dataSetCdc={command.DataSetAdvisoryCdc} | model={command.ControlModelText} | canOperate={command.ControlSupportsOperate} | {evidence}");
+ }
builder.AppendLine($"Logical Devices : {EmptyAsUnavailable(device.LogicalDeviceSummary)}");
builder.AppendLine($"Identity source : {EmptyAsUnavailable(device.IdentitySource)}");
diff --git a/Services/Iec61850ControlFailureReason.cs b/Services/Iec61850ControlFailureReason.cs
index a4e77a9b9..331aabf7a 100644
--- a/Services/Iec61850ControlFailureReason.cs
+++ b/Services/Iec61850ControlFailureReason.cs
@@ -14,6 +14,16 @@ public static Explanation Explain(Iec61850ControlCommandResult result)
{
ArgumentNullException.ThrowIfNull(result);
if (result.IsSuccess) return new("Command accepted", "NotApplicable", "-", "-");
+ // Local typed semantic validation is not an IED-side rejection.
+ // An absent wire step alone cannot prove NOT SENT when the transport was
+ // interrupted; require the explicit Validation + NotSent state.
+ if (result.Stage.Equals("Validation", StringComparison.OrdinalIgnoreCase) &&
+ result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase) &&
+ result.WireSteps.Count == 0 && !result.ServiceAccepted &&
+ string.IsNullOrWhiteSpace(result.RequestHex))
+ return new("Local control validation failed", "LocalValidation",
+ string.IsNullOrWhiteSpace(result.Message) ? "Typed control model validation blocked dispatch" : result.Message,
+ "Review the declared/live CDC, ctlVal and status reference; no MMS control write was sent");
if (result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase))
return new("Command was not sent to the IED", "ConfirmedClient",
"Client rejected before MMS Operate", "Review local command validation");
diff --git a/Services/Iec61850ControlIdentity.cs b/Services/Iec61850ControlIdentity.cs
index ae60fc313..c95ad6aac 100644
--- a/Services/Iec61850ControlIdentity.cs
+++ b/Services/Iec61850ControlIdentity.cs
@@ -16,6 +16,16 @@ public static int CountSelected(IEnumerable signals)
.Select(s => Normalize(s.ObjectReference))
.Where(s => s.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).Count();
+ // Catalog membership and operational authorization are separate concerns.
+ public static SignalDefinition[] DistinctSelected(IEnumerable signals)
+ => signals.Where(s => s.IsSelected && s.IsValidControlObject)
+ .GroupBy(s => Normalize(s.ObjectReference), StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.OrderByDescending(s => s.ControlCommandBusy)
+ .ThenByDescending(s => s.ControlConfirmationPending)
+ .ThenByDescending(s => s.ControlSupportsOperate)
+ .ThenByDescending(s => s.ControlModelResolved).First())
+ .ToArray();
+
public static SignalDefinition[] DistinctOperable(IEnumerable signals)
=> signals.Where(s => s.IsSelected && s.IsValidControlObject &&
s.ControlModelResolved && s.ControlSupportsOperate && !s.IsGenericControl)
@@ -25,8 +35,5 @@ public static SignalDefinition[] DistinctOperable(IEnumerable
.ThenByDescending(s => s.ControlConfirmationPending).First()).ToArray();
public static SignalDefinition[] InspectionRepresentatives(IEnumerable signals)
- => signals.Where(s => s.IsSelected && s.IsValidControlObject)
- .GroupBy(s => Normalize(s.ObjectReference), StringComparer.OrdinalIgnoreCase)
- .Select(group => group.OrderByDescending(s => s.ControlModelResolved).First())
- .Where(s => !s.ControlModelResolved).ToArray();
+ => DistinctSelected(signals).Where(s => !s.ControlModelResolved).ToArray();
}
diff --git a/Services/Iec61850ControlModelWriteDispatcher.cs b/Services/Iec61850ControlModelWriteDispatcher.cs
new file mode 100644
index 000000000..20192c3f6
--- /dev/null
+++ b/Services/Iec61850ControlModelWriteDispatcher.cs
@@ -0,0 +1,18 @@
+using ArIED61850Tester.Models;
+using AR.Iec61850.Control;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Statically bound dispatch for the WPF ctlModel editor. The adapter is not
+/// XAML-generated, and its signature is verified during normal C# compilation.
+/// No reflection, generic MMS Write, or automatic configuration.
+///
+public static class Iec61850ControlModelWriteDispatcher
+{
+ public static Task ConfigureAsync(
+ global::ArIED61850Tester.Iec61850MonitorRuntime runtime, string deviceId, SignalDefinition signal,
+ Iec61850ControlModelKind expected, Iec61850ControlModelKind requested,
+ CancellationToken cancellationToken)
+ => runtime.ConfigureControlModelAsync(deviceId, signal, expected, requested, cancellationToken);
+}
diff --git a/Services/Iec61850ControlShout.cs b/Services/Iec61850ControlShout.cs
index 8bff8513e..4d770b426 100644
--- a/Services/Iec61850ControlShout.cs
+++ b/Services/Iec61850ControlShout.cs
@@ -3,46 +3,134 @@
namespace ArIED61850Tester.Services;
///
-/// Evidence-graded, bounded, human-readable operator notification.
-/// ARIEC61850 remains the sole authority for MMS/AddCause and for control
-/// acceptance. No control write or status inference is performed here.
+/// Presentation-only outcomes. No IED semantic or process value is inferred
+/// from UI text; the native command result remains authoritative.
+///
+internal enum Iec61850ControlNoticeTone { Info, Success, Warning, Error }
+
+///
+/// Compact, sanitized command feedback for the operator. A confirmed MMS
+/// service response is not proof that the requested primary state was reached.
///
internal static class Iec61850ControlShout
{
- internal sealed record Notice(string Title, string Detail);
+ internal sealed record Notice(string Title, string Detail, Iec61850ControlNoticeTone Tone);
internal static Notice FromResult(Iec61850ControlCommandResult result)
{
ArgumentNullException.ThrowIfNull(result);
- var explanation = Iec61850ControlFailureReason.Explain(result);
+ var stage = (result.Stage ?? string.Empty).Trim();
+ var feedback = Safe(result.FeedbackValue, 65);
+ var requested = Safe(result.RequestedValue, 55);
+
+ // The preflight optimization returns IsSuccess=true even though no
+ // Select/Operate was sent. Never claim command success in this case.
+ if (result.IsSuccess && stage.Equals("Already at requested state",
+ StringComparison.OrdinalIgnoreCase) &&
+ result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase))
+ return new("Already at requested state",
+ Safe($"Live position: {feedback}. No control command was sent.", 170),
+ Iec61850ControlNoticeTone.Info);
+
+ // In test mode process movement is deliberately not required.
+ if (result.IsSuccess && stage.StartsWith("Test command", StringComparison.OrdinalIgnoreCase))
+ return new("Test command accepted",
+ "IED test response received. Physical position change was not verified.",
+ Iec61850ControlNoticeTone.Info);
+
+ if (result.IsSuccess && result.ServiceAccepted && result.FeedbackConfirmed)
+ {
+ var isTap = requested.Equals("Raise", StringComparison.OrdinalIgnoreCase) ||
+ requested.Equals("Lower", StringComparison.OrdinalIgnoreCase);
+ var isPosition = requested.Contains("Open", StringComparison.OrdinalIgnoreCase) ||
+ requested.Contains("Clos", StringComparison.OrdinalIgnoreCase);
+ var title = isTap ? "Tap movement confirmed" :
+ isPosition ? "Position reached" : "Feedback confirmed";
+ return new(title, Safe($"IED feedback: {feedback} • Requested: {requested}", 170),
+ Iec61850ControlNoticeTone.Success);
+ }
+
+ // The control service may be accepted without verified process feedback.
+ // Neither positive termination nor Test=true proves a position reached.
if (result.IsSuccess)
- return new("Command succeeded", "The IED accepted the command.");
+ return new(result.ServiceAccepted ? "Command accepted" : "Command outcome unverified",
+ result.ServiceAccepted
+ ? "IED accepted the service; the requested process state was not confirmed."
+ : "No accepted control service or verified position change is recorded.",
+ Iec61850ControlNoticeTone.Warning);
- var title = explanation.Confidence switch
+ var explanation = Iec61850ControlFailureReason.Explain(result);
+ if (explanation.Confidence == "IEDExplicitAddCause")
+ {
+ var cause = (result.AddCause ?? string.Empty).Trim().ToLowerInvariant();
+ var title = cause switch
+ {
+ "blocked-by-interlocking" => "Blocked by interlock",
+ "blocked-by-synchrocheck" => "Blocked by synchrocheck",
+ "blocked-by-mode" => "Blocked by control mode",
+ "blocked-by-switching-hierarchy" => "Blocked by switching hierarchy",
+ "no-access-authority" => "Authorization denied by IED",
+ _ => explanation.Summary
+ };
+ // An authorization denial alone is NOT proof of local switch position.
+ return new(Safe(title, 70),
+ Safe($"IED AddCause: {cause}. {explanation.Checks}", 190),
+ Iec61850ControlNoticeTone.Error);
+ }
+
+ // A rejected or negative-terminated command must not be softened just
+ // because an earlier Select/Operate service received a positive response.
+ if (result.CommandTerminationReceived && !result.PositiveTermination)
+ return new("Command termination rejected",
+ Safe($"IED negative termination. {explanation.Evidence}", 190),
+ Iec61850ControlNoticeTone.Error);
+
+ if (stage.Equals("Feedback timeout", StringComparison.OrdinalIgnoreCase) &&
+ result.ServiceAccepted)
+ return new("Feedback not confirmed",
+ Safe($"Command service accepted, but expected process feedback did not arrive. Last: {feedback}", 190),
+ Iec61850ControlNoticeTone.Warning);
+
+ if (result.CompletionState.Equals("WireStateUnknown", StringComparison.OrdinalIgnoreCase))
+ return new("Command outcome unknown",
+ "Transport interrupted; verify the actual IED state before another command.",
+ Iec61850ControlNoticeTone.Warning);
+
+ var otherTitle = explanation.Confidence switch
{
"ConfirmedClient" => "Command not sent",
- "IEDExplicitAddCause" => explanation.Summary,
- "MmsServiceOnly" => "IED rejected command — reason unspecified",
- "TerminationOnly" => "IED reported negative command termination",
- _ => "Command failed — reason unavailable"
+ "LocalValidation" => "Command not sent",
+ "MmsServiceOnly" => "IED rejected command",
+ "TerminationOnly" => "Command termination failed",
+ _ => "Command failed"
};
-
- // The context is guidance, NOT a confirmed diagnosis for generic MMS
- // object-access-denied. A local BCU position must be substantiated by
- // distinct IED status/LastApplError evidence, never guessed here.
var detail = explanation.Confidence == "MmsServiceOnly"
? "MMS access denied; exact cause not supplied. Check BCU Local/Remote, authorization and interlocks."
: $"Evidence: {explanation.Evidence}. Check: {explanation.Checks}.";
- return new(Safe(title, 95), Safe(detail, 250));
+ return new(Safe(otherTitle, 95), Safe(detail, 190),
+ explanation.Confidence is "ConfirmedClient" or "LocalValidation"
+ ? Iec61850ControlNoticeTone.Warning
+ : Iec61850ControlNoticeTone.Error);
}
+ internal static Notice FromLocalRejection(string reason) => new(
+ "Command not sent",
+ Safe($"ARSAS blocked the request before dispatch: {reason}.", 175),
+ Iec61850ControlNoticeTone.Warning);
+
+ internal static Notice FromDemo(string feedback) => new(
+ "Simulation updated",
+ Safe($"Demo feedback: {feedback}. No command was sent to a physical IED.", 175),
+ Iec61850ControlNoticeTone.Info);
+
internal static Notice FromUnexpectedFailure() =>
new("Command could not complete",
- "A local or communication error occurred. The exact exception is recorded in Diagnostics.");
+ "A local or communication error occurred. Exact evidence is recorded in Diagnostics.",
+ Iec61850ControlNoticeTone.Error);
private static string Safe(string? value, int max)
{
- // No CR/LF or control-character injection into the command overlay.
+ // Avoid multi-line and unbounded input from an untrusted IED.
var flattened = new string((value ?? string.Empty).Select(ch =>
char.IsControl(ch) ? ' ' : ch).ToArray()).Trim();
return flattened.Length <= max ? flattened : flattened[..max] + "…";
diff --git a/Services/Iec61850ControlStatusEvidence.cs b/Services/Iec61850ControlStatusEvidence.cs
new file mode 100644
index 000000000..76e6aa3d1
--- /dev/null
+++ b/Services/Iec61850ControlStatusEvidence.cs
@@ -0,0 +1,42 @@
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Promote an uncertain discovery CDC to BSC only when the *same* DataObject
+/// has an exact ST valWTr.posVal signal. This affects the presentation/declared
+/// semantic candidate only; live ctlModel and typed CO verification must still
+/// succeed independently before any control write.
+///
+public static class Iec61850ControlStatusEvidence
+{
+ public static int ApplyExactStepStatus(ICollection signals)
+ {
+ ArgumentNullException.ThrowIfNull(signals);
+ var exactStepStatuses = signals
+ .Where(s => !s.IsControlSignal &&
+ s.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) &&
+ s.ObjectReference.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase))
+ .Select(s => Iec61850ControlIdentity.Normalize(s.ObjectReference))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var corrected = 0;
+ foreach (var control in signals.Where(s => s.IsValidControlObject))
+ {
+ if (control.ControlCdc.Equals("BSC", StringComparison.OrdinalIgnoreCase) ||
+ !(string.IsNullOrWhiteSpace(control.ControlCdc) ||
+ control.ControlCdc.Equals("SPC", StringComparison.OrdinalIgnoreCase)))
+ continue;
+
+ var exactStatus = $"{Iec61850ControlIdentity.Normalize(control.ObjectReference)}.valWTr.posVal";
+ if (!exactStepStatuses.Contains(exactStatus))
+ continue;
+
+ control.ControlCdc = "BSC";
+ control.ControlValueType = "ValWithTrans";
+ control.ControlStatusReference = exactStatus;
+ // Never change ControlModelText, ControlModelResolved or ControlSemanticVerified.
+ corrected++;
+ }
+ return corrected;
+ }
+}
diff --git a/Services/Iec61850EventOriginPolicy.cs b/Services/Iec61850EventOriginPolicy.cs
new file mode 100644
index 000000000..e05227b85
--- /dev/null
+++ b/Services/Iec61850EventOriginPolicy.cs
@@ -0,0 +1,113 @@
+namespace ArIED61850Tester.Services;
+
+///
+/// Small operator-facing labels backed by actual IEC reporting provenance.
+/// Original wire reason, ReportId and RCB reference remain in event records.
+/// A BRCB source does not by itself prove buffered replay.
+///
+public static class Iec61850EventOriginPolicy
+{
+ public static string Cause(string? reportReason, string? acquisition, bool verifiedBufferedReplay = false)
+ {
+ var tokens = (reportReason ?? string.Empty).Split(
+ new[] { ' ', ',', ';', '|', '/', '+', '(', ')', ':' },
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ bool Includes(string longName, string shortName) =>
+ tokens.Any(x => x.Equals(longName, StringComparison.OrdinalIgnoreCase) ||
+ x.Equals(shortName, StringComparison.OrdinalIgnoreCase));
+
+ // Buffered reports are not necessarily replayed historical events.
+ // Only a separate verified continuity/replay proof may request Buffer.
+ if (verifiedBufferedReplay) return "Buffer";
+ if (IsPolling(acquisition)) return "Poll";
+ // GI and integrity are snapshots, never spontaneous merely because
+ // another optional trigger accompanies them.
+ if (Includes("general-interrogation", "gi")) return "GI";
+ if (Includes("integrity", "intg")) return "Integrity";
+ if (Includes("data-change", "dchg")) return "Spontaneous";
+ if (Includes("quality-change", "qchg")) return "Quality";
+ if (Includes("data-update", "dupd")) return "Data update";
+
+ // SOE already requires an observed process edge. The actual IEC
+ // reason was not supplied, so do NOT label it spontaneous.
+ return "State change";
+ }
+
+ // UI Acquisition means the actual RCB that routed this report, NOT
+ // IEC ReportId (RptID). RptID can legitimately be "Rpt_0" while
+ // the configured BRCB object is named "brcb01".
+ public static string Acquisition(
+ string? reportId, string? reportControlReference, string? sourceMode,
+ IReadOnlyList? routedReportControlReferences = null)
+ {
+ if (IsPolling(sourceMode)) return "MMS Polling";
+ // An SOE row keeps the causative report reference separately for
+ // forensics; Acquisition is the operator's short list of *observed*
+ // RCB sources for that point up to this event.
+ var allNames = RcbNames(routedReportControlReferences);
+ if (allNames.Length > 0) return allNames;
+ var thisRcb = RcbName(reportControlReference);
+ if (thisRcb.Length > 0) return thisRcb;
+ // RptID is deliberately never a fallback for an RCB object name.
+ return Channel(sourceMode) switch
+ {
+ "BRCB" => "BRCB",
+ "URCB" => "URCB",
+ "MMS read" => "MMS Polling",
+ "Simulation" => "Demo",
+ _ => "Unknown"
+ };
+ }
+
+ public static string PointAcquisition(
+ IReadOnlyList? liveRcbReferences, string? sourceMode, bool offline)
+ {
+ if (offline) return "Offline";
+ if (IsPolling(sourceMode)) return "MMS Polling";
+ var names = RcbNames(liveRcbReferences);
+ if (names.Length > 0) return names;
+ if (sourceMode?.Contains("report", StringComparison.OrdinalIgnoreCase) == true ||
+ Channel(sourceMode) is "BRCB" or "URCB")
+ return "RCB pending";
+ return Channel(sourceMode) == "Simulation" ? "Demo" : "Unknown";
+ }
+
+ // Case-insensitive de-duplication with deterministic name order. Names
+ // come from actual observed RCB references, never from RptID or DataSet.
+ public static string RcbNames(IEnumerable? references)
+ {
+ if (references == null) return string.Empty;
+ return string.Join(", ", references
+ .Select(RcbName)
+ .Where(name => name.Length > 0)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(name => name, StringComparer.OrdinalIgnoreCase));
+ }
+
+ // Both MMS ('$') and normalized SCL ('.') references are accepted.
+ public static string RcbName(string? reference)
+ {
+ if (string.IsNullOrWhiteSpace(reference)) return string.Empty;
+ var parts = reference.Trim().Split(
+ new[] { '/', '$', '.' },
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ return parts.Length == 0 ? string.Empty : parts[^1];
+ }
+
+ public static string Channel(string? acquisition)
+ {
+ var text = acquisition ?? string.Empty;
+ if (IsPolling(text)) return "MMS read";
+ if (text.Contains("brcb", StringComparison.OrdinalIgnoreCase)) return "BRCB";
+ if (text.Contains("urcb", StringComparison.OrdinalIgnoreCase)) return "URCB";
+ if (text.Contains("demo", StringComparison.OrdinalIgnoreCase)) return "Simulation";
+ return "Unspecified";
+ }
+
+ private static bool IsPolling(string? sourceMode)
+ {
+ var text = sourceMode ?? string.Empty;
+ return text.Contains("poll", StringComparison.OrdinalIgnoreCase) ||
+ text.Contains("MMS fallback", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs
index 61fc6d56f..31cfebda0 100644
--- a/Services/Iec61850MonitorRuntime.cs
+++ b/Services/Iec61850MonitorRuntime.cs
@@ -24,6 +24,10 @@ private sealed class RuntimePointState
public long Sequence { get; set; }
public string SourceMode { get; set; } = "Waiting";
public string AcquisitionLabel { get; set; } = "MMS polling";
+ // Actual report ingress, scoped to the live MMS association. Multiple
+ // BRCBs/URCBs may publish the same selected DataSet member.
+ public HashSet RoutedReportControlReferences { get; } =
+ new(StringComparer.OrdinalIgnoreCase);
public string Reason { get; set; } = "-";
public string Status { get; set; } = "Queued";
public bool HasValue { get; set; }
@@ -659,6 +663,50 @@ public async Task> StartMonitoringAsync(
.ToList();
}
+ public async Task ConfigureControlModelAsync(
+ string deviceId, SignalDefinition signal,
+ Iec61850ControlModelKind expected, Iec61850ControlModelKind target,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
+ ArgumentNullException.ThrowIfNull(signal);
+ if (!_sessions.TryGetValue(deviceId, out var session) || !session.Client.IsConnected)
+ throw new InvalidOperationException("The IED is not associated. ctlModel Write is forbidden.");
+ if (expected == Iec61850ControlModelKind.Unknown ||
+ target is not (Iec61850ControlModelKind.StatusOnly or
+ Iec61850ControlModelKind.DirectNormal or
+ Iec61850ControlModelKind.DirectEnhanced or
+ Iec61850ControlModelKind.SboNormal or
+ Iec61850ControlModelKind.SboEnhanced))
+ throw new ArgumentException("A valid expected live ctlModel and target 1-4 are required.");
+
+ var targetKey = deviceId + "|" + Iec61850ControlIdentity.Normalize(signal.ObjectReference);
+ if (!_activeControlTargets.TryAdd(targetKey, 0))
+ throw new InvalidOperationException("A command is already running for this object.");
+
+ Interlocked.Increment(ref session.ControlCommandActive);
+ try
+ {
+ if (Volatile.Read(ref session.ControlCommandActive) != 1)
+ throw new InvalidOperationException("Another IED command is in progress. ctlModel configuration was not started.");
+ Log("WARN", session.Device.Name,
+ $"OPERATOR_CTL_MODEL_CONFIGURATION: {signal.ObjectReference}; expected={expected}; requested={target}; " +
+ "explicit commissioning confirmed; one CF Write maximum, never Operate.");
+ var result = await session.Client.ConfigureControlModelAsync(
+ signal, expected, target, cancellationToken).ConfigureAwait(false);
+ Log(result.ReadbackVerified ? "INFO" : "WARN", session.Device.Name,
+ $"CTL_MODEL_CONFIGURATION_RESULT: {signal.ObjectReference}; " +
+ $"writeAccepted={result.WriteAccepted}; readbackVerified={result.ReadbackVerified}; " +
+ $"before={result.BeforeModel}; after={result.AfterModel}; {result.Detail}");
+ return result;
+ }
+ finally
+ {
+ Interlocked.Decrement(ref session.ControlCommandActive);
+ _activeControlTargets.TryRemove(targetKey, out _);
+ }
+ }
+
public async Task InspectControlAsync(
string deviceId,
SignalDefinition signal,
@@ -843,16 +891,46 @@ public async Task StopDeviceAsync(string deviceId)
if (!_sessions.TryRemove(deviceId, out var session))
return;
- await StopMonitoringSessionAsync(session, preservePointDefinitions: false).ConfigureAwait(false);
- await session.Client.DisposeAsync().ConfigureAwait(false);
- session.MonitorCancellation.Dispose();
- session.Device.IsMonitoring = false;
- session.Device.IsConnected = false;
- session.Device.AcquisitionMode = "Not started";
- session.Device.Status = "Disconnected";
- session.Device.Detail = "IEC 61850 session closed.";
- session.Device.RefreshComputed();
- Log("INFO", session.Device.Name, "IEC 61850 session disconnected.");
+ // Stop MUST leave a reusable device after any teardown failure. The
+ // session is already removed from the live registry, so never leave a
+ // half-connected UI or a stale control authority behind on exception.
+ try
+ {
+ await StopMonitoringSessionAsync(session, preservePointDefinitions: false).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Log("WARN", session.Device.Name, $"Monitor stop cleanup: {ex.GetType().Name}: {ex.Message}");
+ }
+ try
+ {
+ await session.Client.DisposeAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Log("WARN", session.Device.Name, $"MMS disconnect cleanup: {ex.GetType().Name}: {ex.Message}");
+ }
+ finally
+ {
+ // WPF-owned CommandSignals may only be replaced on its Dispatcher.
+ // Await this work before allowing the facade's StopGate to release.
+ try
+ {
+ await ResetAssociationControlAuthorityAsync(session.Device).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Log("WARN", session.Device.Name, $"Control UI authority cleanup: {ex.GetType().Name}: {ex.Message}");
+ }
+ session.MonitorCancellation.Dispose();
+ session.Device.IsMonitoring = false;
+ session.Device.IsConnected = false;
+ session.Device.AcquisitionMode = "Not started";
+ session.Device.Status = "Disconnected";
+ session.Device.Detail = "IEC 61850 session closed. Saved model remains available for Play.";
+ session.Device.RefreshComputed();
+ Log("INFO", session.Device.Name, "IEC 61850 session disconnected.");
+ }
}
private async Task StartReportPlansAsync(
@@ -1364,7 +1442,10 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report verified" : $"Live / report verified ({update.ProjectionStatus})"
: string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report traffic + MMS verification" : $"Live / report traffic + MMS verification ({update.ProjectionStatus})",
trustReportEdge: true,
- hasProcessValue: update.HasValue);
+ hasProcessValue: update.HasValue,
+ reportId: update.ReportId,
+ reportControlReference: string.IsNullOrWhiteSpace(update.ReportControlReference)
+ ? plan.ReportControlReference : update.ReportControlReference);
// A routed, schema-safe process value is the data-plane proof. RptEna,
// GI, raw/unrouted frames, rejected values, or stale associations
@@ -1651,7 +1732,9 @@ private void ApplyValueUpdate(
DateTime timestampUtc,
string status,
bool trustReportEdge,
- bool hasProcessValue = true)
+ bool hasProcessValue = true,
+ string? reportId = null,
+ string? reportControlReference = null)
{
var state = session.States[point.PointKey];
var hadValue = state.HasValue;
@@ -1659,7 +1742,14 @@ private void ApplyValueUpdate(
var oldQuality = state.Quality;
var displayChanged = hasProcessValue && !string.Equals(oldValue, display, StringComparison.Ordinal);
var qualityChangedForUi = !string.Equals(oldQuality, quality, StringComparison.OrdinalIgnoreCase);
- var metadataChanged = !string.Equals(state.DeviceTimestamp, deviceTimestamp, StringComparison.Ordinal) ||
+ // Many enabled RCBs can produce one signal. Remember every RCB with
+ // *actual* routed ingress, not only the most recently received report.
+ // A new source is UI-relevant even if the process value has not changed.
+ var reportRcbChanged = trustReportEdge &&
+ !string.IsNullOrWhiteSpace(reportControlReference) &&
+ state.RoutedReportControlReferences.Add(reportControlReference.Trim());
+ var metadataChanged = reportRcbChanged ||
+ !string.Equals(state.DeviceTimestamp, deviceTimestamp, StringComparison.Ordinal) ||
!string.Equals(state.SourceMode, sourceMode, StringComparison.Ordinal) ||
!string.Equals(state.Reason, reason, StringComparison.Ordinal) ||
!string.Equals(state.Status, status, StringComparison.Ordinal);
@@ -1707,6 +1797,10 @@ private void ApplyValueUpdate(
Status = status,
IsValueEdge = changed,
IsReportTraffic = trustReportEdge,
+ // This is association-local evidence from an actual routed report.
+ // Preserve it through later metadata/status UI coalescing.
+ RoutedReportControlReferences = state.RoutedReportControlReferences
+ .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase).ToArray(),
Sequence = state.Sequence
});
}
@@ -1733,7 +1827,11 @@ private void ApplyValueUpdate(
NewValue = display,
Quality = quality,
SourceMode = sourceMode,
- Reason = reason
+ Reason = reason,
+ ReportId = trustReportEdge ? reportId ?? string.Empty : string.Empty,
+ ReportControlReference = trustReportEdge ? reportControlReference ?? string.Empty : string.Empty,
+ RoutedReportControlReferences = state.RoutedReportControlReferences
+ .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase).ToArray()
};
EventRaised?.Invoke(entry);
}
@@ -1756,6 +1854,8 @@ private void EmitStatusSnapshot(
Reason = state.Reason,
Status = status,
IsValueEdge = false,
+ RoutedReportControlReferences = state.RoutedReportControlReferences
+ .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase).ToArray(),
Sequence = state.Sequence
});
}
@@ -1812,6 +1912,35 @@ await DisposeClientForReconnectAsync(
}
}
+ private static async Task ResetAssociationControlAuthorityAsync(Iec61850MonitorDevice device)
+ {
+ var dispatcher = System.Windows.Application.Current?.Dispatcher;
+ if (dispatcher != null && !dispatcher.CheckAccess())
+ {
+ if (dispatcher.HasShutdownStarted || dispatcher.HasShutdownFinished)
+ return; // UI is gone; the native association is already revoked.
+ await dispatcher.InvokeAsync(
+ () => ResetAssociationControlAuthority(device),
+ System.Windows.Threading.DispatcherPriority.DataBind).Task.ConfigureAwait(false);
+ return;
+ }
+ ResetAssociationControlAuthority(device);
+ }
+
+ private static void ResetAssociationControlAuthority(Iec61850MonitorDevice device)
+ {
+ foreach (var signal in device.Signals.Where(s => s.IsValidControlObject))
+ {
+ signal.ClearControlConfirmation();
+ signal.ClearLiveControlAuthority();
+ signal.ControlInspectionBusy = false;
+ signal.ControlModelText = "Auto-detect";
+ signal.ControlInspectionEvidence =
+ "New MMS association: live CDC, ctlModel and typed ctlVal require reinspection.";
+ }
+ device.RefreshCommandSignalProjection();
+ }
+
private async Task TryReconnectAsync(DeviceSession session, CancellationToken cancellationToken)
{
var nowUtc = DateTime.UtcNow;
@@ -1827,6 +1956,10 @@ private async Task TryReconnectAsync(DeviceSession session, CancellationToken ca
Log("WARN", session.Device.Name,
$"Smart reconnect attempt #{attempt} started. Transport recovery is bounded independently from report re-arming.");
+ // Automatic reconnection must revoke all previously verified control
+ // semantics BEFORE the fresh association is opened, even if the UI
+ // never visits its normal manual Disconnect/Connect path.
+ await ResetAssociationControlAuthorityAsync(session.Device).ConfigureAwait(false);
var staleClient = session.Client;
await DisposeClientForReconnectAsync(
staleClient,
@@ -2065,6 +2198,13 @@ private void MarkSessionOffline(DeviceSession session, string detail)
// credit a future planning attempt after transport loss.
session.CurrentStaticParityAttemptId = Guid.Empty;
var wasConnected = session.Device.IsConnected;
+ // A new MMS association has new RCB ownership; report names observed
+ // on the *old* socket cannot be credited to the recovered session.
+ if (wasConnected)
+ {
+ foreach (var state in session.States.Values)
+ state.RoutedReportControlReferences.Clear();
+ }
session.Device.IsConnected = false;
session.Device.HasReportStream = false;
session.Device.ReportPulseActive = false;
diff --git a/Services/Iec61850StaticControlStatusProjectionService.cs b/Services/Iec61850StaticControlStatusProjectionService.cs
index f4ece1e33..a5cac9147 100644
--- a/Services/Iec61850StaticControlStatusProjectionService.cs
+++ b/Services/Iec61850StaticControlStatusProjectionService.cs
@@ -268,9 +268,9 @@ private static void LinkControlToMembership(
if (string.IsNullOrWhiteSpace(control.DataSetReference))
control.DataSetReference = membership.DataSetReference;
control.IsReportCapable = true;
- control.ControlCdc = cdc;
- if (!string.IsNullOrWhiteSpace(exactFeedbackReference))
- control.ControlStatusReference = exactFeedbackReference;
+ // Static DataSet metadata is read/report authority, never permission to
+ // overwrite a previously typed-verified live control identity.
+ control.ApplyDataSetAdvisoryControlMetadata(cdc, exactFeedbackReference);
var report = descriptor.ReportMemberships.FirstOrDefault();
if (report is not null && string.IsNullOrWhiteSpace(control.ReportControlReference))
diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs
index 8ab80c5c0..935032cd7 100644
--- a/Services/NativeIec61850Client.cs
+++ b/Services/NativeIec61850Client.cs
@@ -1086,9 +1086,89 @@ private static string BuildDynamicDataSetName(ReportControlPlan plan)
return null;
}
+ ///
+ /// Opt-in commissioning only. An exact ctlModel CF Write is allowed once
+ /// and only after engine preflight confirms the expected live model.
+ /// Serializes with normal control operations; never issues SBO/Operate.
+ ///
+ public async Task ConfigureControlModelAsync(
+ SignalDefinition signal,
+ Iec61850ControlModelKind expected, Iec61850ControlModelKind target,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(signal);
+ if (!signal.IsValidControlObject || !_session.IsMmsInitiated)
+ throw new InvalidOperationException("A valid connected control DataObject is required.");
+ await _controlCommandGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ try
+ {
+ var service = new ArControl.Iec61850ControlService();
+ return await RunMmsOperationAsync(
+ () => service.ConfigureControlModelAsync(_session, signal.ObjectReference,
+ (ArControl.Iec61850ControlModel)(int)expected,
+ (ArControl.Iec61850ControlModel)(int)target,
+ cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Even a partial/uncertain write invalidates the cached descriptor.
+ // A subsequent explicit read-only inspection must rebuild authority.
+ await InvalidateCachedControlSessionAsync(signal.ObjectReference)
+ .ConfigureAwait(false);
+ signal.ClearControlConfirmation();
+ signal.ClearLiveControlAuthority();
+ signal.ControlModelText = "Auto-detect";
+ signal.ControlInspectionEvidence =
+ "ctlModel configuration attempted: fresh typed live inspection required.";
+ }
+ }
+ finally
+ {
+ _controlCommandGate.Release();
+ }
+ }
+
+ private async Task InvalidateCachedControlSessionAsync(string objectReference)
+ {
+ ArControl.Iec61850ControlObjectSession? stale = null;
+ await _controlSessionGate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ var key = NormalizeControlObjectKey(objectReference);
+ if (_controlSessions.Remove(key, out var previous))
+ stale = previous;
+ }
+ finally
+ {
+ _controlSessionGate.Release();
+ }
+ if (stale != null)
+ await stale.DisposeAsync().ConfigureAwait(false);
+ }
+
public async Task InspectControlAsync(
SignalDefinition signal,
CancellationToken cancellationToken)
+ {
+ // Rebuilding an MMS control descriptor can dispose a session currently
+ // used by Operate. Serialize inspection, configuration and control.
+ await _controlCommandGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ return await InspectControlCoreAsync(signal, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _controlCommandGate.Release();
+ }
+ }
+
+ private async Task InspectControlCoreAsync(
+ SignalDefinition signal,
+ CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(signal);
if (!signal.IsControlSignal || !signal.IsValidControlObject)
@@ -1096,7 +1176,56 @@ public async Task InspectControlAsync(
if (!_session.IsMmsInitiated)
throw new InvalidOperationException("The IEC 61850 MMS association is not active.");
- var control = await GetOrOpenControlSessionAsync(signal, cancellationToken).ConfigureAwait(false);
+ ArControl.Iec61850ControlObjectSession control;
+ try
+ {
+ // Explicit inspection must use a fresh, association-scoped descriptor.
+ // Another engineering client may have changed CF ctlModel since the
+ // previous inspection; cached control sessions are not authoritative.
+ signal.ClearControlConfirmation();
+ signal.ClearLiveControlAuthority();
+ signal.ControlModelText = "Auto-detect";
+ await InvalidateCachedControlSessionAsync(signal.ObjectReference).ConfigureAwait(false);
+ control = await GetOrOpenControlSessionAsync(signal, cancellationToken).ConfigureAwait(false);
+ }
+ catch (InvalidOperationException discoveryError) when (_session.IsMmsInitiated)
+ {
+ // Distinguish an exact StatusOnly read from a failed/unsupported
+ // ctlModel read without a service write or full NameList discovery.
+ var probe = await RunMmsOperationAsync(
+ () => new ArControl.Iec61850ControlService().ProbeControlModelAsync(
+ _session, signal.ObjectReference, cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+ signal.ControlSemanticVerified = false;
+ signal.ControlModelText = probe.Decoded
+ ? FriendlyControlModel(probe.ControlModel)
+ : "Unknown";
+ signal.ControlModelReference = $"{signal.ObjectReference}.ctlModel";
+ var reason = $"{probe.Detail} Descriptor not ready: {discoveryError.Message}";
+ signal.ControlLastResult = reason;
+ signal.ControlInspectionEvidence =
+ $"{signal.ControlModelReference} | {probe.Detail} | " +
+ $"readSucceeded={probe.ReadSucceeded}, decoded={probe.Decoded}, " +
+ $"kind={probe.RawKind}, scalar={probe.RawScalar}, request={probe.RequestHexPreview}, " +
+ $"response={probe.ResponseHexPreview} | {discoveryError.Message}";
+ return new Iec61850ControlCapabilities
+ {
+ ObjectReference = signal.ObjectReference,
+ StatusReference = signal.ControlStatusReference,
+ ControlModelReference = signal.ControlModelReference,
+ ControlModel = probe.Decoded
+ ? MapControlModel(probe.ControlModel)
+ : Iec61850ControlModelKind.Unknown,
+ ControlModelText = signal.ControlModelText,
+ ControlCdc = signal.ControlCdc,
+ ControlValueType = signal.ControlValueType,
+ CurrentValue = signal.ControlCurrentValue,
+ EngineControlServiceAvailable = probe.ReadSucceeded,
+ IsOperationallyReady = false,
+ DiscoveryEvidence = $"Read-only {probe.MmsReference}; kind={probe.RawKind}; scalar={probe.RawScalar}; request={probe.RequestHexPreview}; response={probe.ResponseHexPreview}",
+ EngineControlServiceStatus = reason
+ };
+ }
var descriptor = control.Descriptor;
var semantic = ArControl.Iec61850ControlSemanticResolver.Resolve(
descriptor, signal.ControlCdc, signal.ControlStatusReference);
@@ -1139,13 +1268,21 @@ public async Task InspectControlAsync(
var modelText = FriendlyControlModel(descriptor.ControlModel);
var valueType = MapControlValueType(descriptor, effectiveCdc);
- signal.ControlCdc = effectiveCdc;
+ if (semantic.IsCommandSafe)
+ signal.AcceptVerifiedLiveControlCdc(effectiveCdc);
+ else
+ signal.ControlCdc = effectiveCdc;
signal.ControlSemanticVerified = semantic.IsCommandSafe;
signal.ControlValueType = valueType;
signal.ControlCurrentValue = currentValue;
signal.ControlStatusReference = statusReference;
signal.ControlModelReference = $"{descriptor.ObjectReference}.ctlModel";
signal.ControlModelText = modelText;
+ signal.ControlInspectionEvidence =
+ $"{descriptor.ObjectReference}.ctlModel | {modelText} | " +
+ $"typedCdc={descriptor.Cdc}, effectiveCdc={effectiveCdc}, " +
+ $"ctlVal={descriptor.CtlValSpecification.Signature}, status={statusReference}, " +
+ $"semanticSafe={semantic.IsCommandSafe} | {semantic.Reason} | {descriptor.DiscoveryEvidence}";
return new Iec61850ControlCapabilities
{
@@ -1927,7 +2064,7 @@ private static Iec61850ControlCommandResult ControlFailure(
IsSuccess = false,
ServiceAccepted = false,
FeedbackConfirmed = false,
- CompletionState = "Rejected",
+ CompletionState = "NotSent",
Stage = stage,
Message = message,
ControlModelText = capabilities.ControlModelText,
@@ -2850,6 +2987,9 @@ private static IReadOnlyList FinalizeDiscoveredSignals(IEnumer
{
var expanded = signals.ToList();
AddValidatedControlCandidatesFromStatus(expanded);
+ // Reconcile only exact typed ST evidence from this discovery model.
+ // Do not guess that a TapChg name alone proves BSC semantics.
+ Iec61850ControlStatusEvidence.ApplyExactStepStatus(expanded);
return expanded
.Where(s => s.DataType != "Directory")
diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs
index 27dedb04b..9dbc5d331 100644
--- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs
+++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs
@@ -33,6 +33,7 @@ private sealed class DeviceOperationSlot
public SemaphoreSlim StopGate { get; } = new(1, 1);
public CancellationTokenSource? ActiveOperationCancellation { get; set; }
public long Generation { get; set; }
+ public Task? ActiveStopTask { get; set; }
}
private sealed record CommandFeedbackFence(
@@ -127,6 +128,18 @@ public Task InspectControlAsync(
cancellationToken,
token => _inner.InspectControlAsync(deviceId, signal, token));
+ // The UI and the inner MMS runtime intentionally have different types.
+ // ctlModel editing must cross this facade, just like inspection/Operate;
+ // bypassing it loses the per-device lifecycle/serialization authority.
+ public Task ConfigureControlModelAsync(
+ string deviceId, SignalDefinition signal,
+ Iec61850ControlModelKind expected, Iec61850ControlModelKind requested,
+ CancellationToken cancellationToken)
+ => RunDeviceOperationAsync(
+ deviceId,
+ cancellationToken,
+ token => _inner.ConfigureControlModelAsync(deviceId, signal, expected, requested, token));
+
public Task ExecuteControlAsync(
string deviceId,
Iec61850ControlCommandRequest request,
@@ -568,41 +581,75 @@ private async Task RunDeviceOperationAsync(
}
}
- private async Task RunPreemptiveStopAsync(string? deviceId, Func stopOperation)
+ private Task RunPreemptiveStopAsync(string? deviceId, Func stopOperation)
{
ThrowIfDisposing();
ArgumentNullException.ThrowIfNull(stopOperation);
var slot = GetSlot(deviceId);
+ TaskCompletionSource completion;
CancellationTokenSource? activeCancellation;
lock (slot.SyncRoot)
{
+ // Repeated Stop calls share the same teardown and do not increment
+ // the generation or cancel a future association a second time.
+ if (slot.ActiveStopTask is { IsCompleted: false } inFlight)
+ return inFlight;
+
+ completion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ slot.ActiveStopTask = completion.Task;
activeCancellation = slot.ActiveOperationCancellation;
++slot.Generation;
}
- // Cancellation is intentionally issued before StopGate and, critically, without
- // waiting for OperationGate. This is the escape path when Connect/Start is hung.
+ // Preserve the preemptive lane: request cancellation immediately,
+ // before waiting for the StopGate (never wait for OperationGate).
try
{
activeCancellation?.Cancel();
}
catch (ObjectDisposedException)
{
- // The active operation completed between snapshot and cancellation.
+ // Finished between snapshot and cancellation.
}
- await slot.StopGate.WaitAsync().ConfigureAwait(false);
+ _ = ExecutePreemptiveStopAsync(slot, stopOperation, completion);
+ return completion.Task;
+ }
+
+ private static async Task ExecutePreemptiveStopAsync(
+ DeviceOperationSlot slot,
+ Func stopOperation,
+ TaskCompletionSource completion)
+ {
try
{
- await Task.Run(
- async () => await stopOperation().ConfigureAwait(false),
- CancellationToken.None)
- .ConfigureAwait(false);
+ await slot.StopGate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ await Task.Run(
+ async () => await stopOperation().ConfigureAwait(false),
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ slot.StopGate.Release();
+ }
+ completion.TrySetResult(true);
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetException(ex);
}
finally
{
- slot.StopGate.Release();
+ lock (slot.SyncRoot)
+ {
+ if (ReferenceEquals(slot.ActiveStopTask, completion.Task))
+ slot.ActiveStopTask = null;
+ }
}
}
diff --git a/VERSION b/VERSION
index 4a7899b3c..390836ffc 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.6.40
+1.6.41
diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json
index 449ad8e69..d7476cce4 100644
--- a/engines/ARIEC61850.lock.json
+++ b/engines/ARIEC61850.lock.json
@@ -2,9 +2,9 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
- "commit": "a43b9b4864a9565c02f544d4135fe157c2fac577",
- "sourcePullRequest": 168,
- "purpose": "P10.9K CodeVerified-only: vendor-neutral control CDC fail-closed semantics with live ctlVal/status evidence plus association-scoped NameList single-flight reuse; ARIEC PR #168 CI #38231327892. Retains P10.9J exact OID/BSC interoperability, P10.9I FIFO protection, physical R7/R10 baseline and no-automatic-dynamic reporting. Physical ZIV control still NOT accepted.",
+ "commit": "036e2c00d83a0815b6160147b14ea05975057965",
+ "sourcePullRequest": 169,
+ "purpose": "P10.9V: exact typed live BSC authority over stale SPC static projection; preserves P10.9U CSWI Pos recovery. Engine CI green; ZIV bench acceptance pending.",
"previousTrialPin": {
"commit": "4467124775d8d9d76f3db194f9fbfd97144767a8",
"sourcePullRequest": 134,
@@ -161,5 +161,98 @@
"mainlinePromotionForbidden": true,
"fullDiscoveryFallbackForbidden": true,
"cyclicMmsProcessPollingForbidden": true
+ },
+ "p109LControlRecoveryTrial": {
+ "status": "code-trial-not-physical",
+ "issue": 525,
+ "enginePullRequest": 169,
+ "baseEngineCommit": "a43b9b4864a9565c02f544d4135fe157c2fac577",
+ "exactCommit": "5e17da5f673eedce125b5c66e7dc2fa0abef2851",
+ "readOnlyCtlModelProbeRequired": true,
+ "noControlWriteOnProbe": true,
+ "physicalQualificationRequired": true,
+ "mainlinePromotionForbidden": true,
+ "fullDiscoveryFallbackForbidden": true,
+ "cyclicMmsProcessPollingForbidden": true,
+ "engineCiRun": 38239451501
+ },
+ "p109QControlModelCommissioningTrial": {
+ "status": "code-trial-not-physical",
+ "enginePullRequest": 169,
+ "engineCiRun": 38257606517,
+ "baseEngineCommit": "5e17da5f673eedce125b5c66e7dc2fa0abef2851",
+ "exactCommit": "f7a4e3ba2f9771e34e04073c0db0900112aa55fa",
+ "authorizedConfirmationRequired": true,
+ "exactLiveCompareRequired": true,
+ "singleWriteNoRetry": true,
+ "verifiedReadbackRequired": true,
+ "commandOperateForbidden": true,
+ "physicalQualificationRequired": true,
+ "mainlinePromotionForbidden": true,
+ "fullDiscoveryFallbackForbidden": true,
+ "cyclicMmsProcessPollingForbidden": true
+ },
+ "p109RSimpleCtlModelEditorTrial": {
+ "status": "code-trial-not-physical",
+ "enginePullRequest": 169,
+ "engineCiRun": 38311424952,
+ "baseEngineCommit": "f7a4e3ba2f9771e34e04073c0db0900112aa55fa",
+ "exactCommit": "d1dd8fc14e79c5de15fbc52d77aea48a14c7c02b",
+ "simpleSelectWriteUi": true,
+ "statusOnlyZeroSupported": true,
+ "exactLiveCompareRequired": true,
+ "singleWriteNoRetry": true,
+ "verifiedReadbackRequired": true,
+ "commandOperateForbidden": true,
+ "physicalQualificationRequired": true,
+ "mainlinePromotionForbidden": true,
+ "fullDiscoveryFallbackForbidden": true,
+ "cyclicMmsProcessPollingForbidden": true
+ },
+ "p109TPositionControlInteroperabilityTrial": {
+ "status": "code-trial-not-physical",
+ "enginePullRequest": 169,
+ "engineCiRun": 38320987660,
+ "baseEngineCommit": "d1dd8fc14e79c5de15fbc52d77aea48a14c7c02b",
+ "exactCommit": "9d82523978c29dcbf38f60cbfb384c3b050eae7c",
+ "structuredStatusRequiresTypedMmsRead": true,
+ "optionalCancelNotRequiredForNormalSbo": true,
+ "liveTypedControlVerificationRequired": true,
+ "commandOperateNeverAutoDispatched": true,
+ "physicalQualificationRequired": true,
+ "mainlinePromotionForbidden": true,
+ "fullDiscoveryFallbackForbidden": true,
+ "cyclicMmsProcessPollingForbidden": true
+ },
+ "p109UPositionStatusNameListRecoveryTrial": {
+ "status": "code-trial-not-physical",
+ "enginePullRequest": 169,
+ "engineCiRun": 38324547203,
+ "baseEngineCommit": "9d82523978c29dcbf38f60cbfb384c3b050eae7c",
+ "exactCommit": "ad43399ed411cc72e6eb68b67e198db5e7b95381",
+ "truncatedNameListSupported": true,
+ "standardPositionOnlyProbe": true,
+ "singleScalarReadOnly": true,
+ "bitStringWidthTwoRequired": true,
+ "typedControlSemanticVerificationRequired": true,
+ "operateRequiresExplicitConfirmation": true,
+ "physicalQualificationRequired": true,
+ "mainlinePromotionForbidden": true,
+ "fullDiscoveryFallbackForbidden": true,
+ "cyclicMmsProcessPollingForbidden": true
+ },
+ "p109VLiveBscAuthorityTrial": {
+ "status": "engine-ci-success-consumer-ci-pending-not-physical",
+ "enginePullRequest": 169,
+ "consumerPullRequest": 543,
+ "baseEngineCommit": "ad43399ed411cc72e6eb68b67e198db5e7b95381",
+ "exactCommit": "036e2c00d83a0815b6160147b14ea05975057965",
+ "engineCiRun": 38328335496,
+ "staticSpcNeverOverridesExactLiveBsc": true,
+ "typedTwoBitCtlValAndExactStepStatusRequired": true,
+ "wrongOrCrossObjectStatusFailsClosed": true,
+ "physicalQualificationRequired": true,
+ "mainlinePromotionForbidden": true,
+ "cyclicMmsProcessPollingForbidden": true
}
}
diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1
index 2019e2253..2b87b2ec6 100644
--- a/scripts/test-p10-9f-core-routing-trial.ps1
+++ b/scripts/test-p10-9f-core-routing-trial.ps1
@@ -91,7 +91,207 @@ function Test-P109FCoreRoutingTrial {
[bool]$generic.fullDiscoveryFallbackForbidden -and
[bool]$generic.cyclicMmsProcessPollingForbidden
)
- if (-not ($previousHead -or $subscriberHead -or $canonicalHead -or $zivHead -or $genericHead) -or
+ # Exact P10.9L read-only engine candidate, preserving every historical gate.
+ $probe = $Lock.p109LControlRecoveryTrial
+ $probeHead = (
+ [string]$Lock.commit -ceq '5e17da5f673eedce125b5c66e7dc2fa0abef2851' -and
+ [int]$Lock.sourcePullRequest -eq 169 -and
+ $zivIntegrityValid -and $null -ne $generic -and $null -ne $probe -and
+ [string]$generic.exactCommit -ceq 'a43b9b4864a9565c02f544d4135fe157c2fac577' -and
+ [string]$generic.baseEngineCommit -ceq [string]$ziv.exactCommit -and
+ [int]$generic.enginePullRequest -eq 168 -and
+ [long]$generic.engineCiRun -eq 38231327892 -and
+ [bool]$generic.typedSemanticGateRequired -and
+ [bool]$generic.associationScopedInventoryRequired -and
+ [bool]$generic.vendorDispatchForbidden -and
+ [bool]$generic.physicalQualificationRequired -and
+ [bool]$generic.mainlinePromotionForbidden -and
+ [bool]$generic.fullDiscoveryFallbackForbidden -and
+ [bool]$generic.cyclicMmsProcessPollingForbidden -and
+ [string]$probe.status -ceq 'code-trial-not-physical' -and
+ [int]$probe.issue -eq 525 -and
+ [int]$probe.enginePullRequest -eq 169 -and
+ [string]$probe.baseEngineCommit -ceq [string]$generic.exactCommit -and
+ [string]$probe.exactCommit -ceq '5e17da5f673eedce125b5c66e7dc2fa0abef2851' -and
+ [long]$probe.engineCiRun -eq 38239451501 -and
+ [bool]$probe.readOnlyCtlModelProbeRequired -and
+ [bool]$probe.noControlWriteOnProbe -and
+ [bool]$probe.physicalQualificationRequired -and
+ [bool]$probe.mainlinePromotionForbidden -and
+ [bool]$probe.fullDiscoveryFallbackForbidden -and
+ [bool]$probe.cyclicMmsProcessPollingForbidden
+ )
+ # P10.9Q preserves the read-only probe gate; explicit commissioning requires
+ # live compare, one CF write and readback, never an Operate service.
+ $commission = $Lock.p109QControlModelCommissioningTrial
+ $commissionHead = (
+ [string]$Lock.commit -ceq 'f7a4e3ba2f9771e34e04073c0db0900112aa55fa' -and
+ [int]$Lock.sourcePullRequest -eq 169 -and
+ $probeHead -eq $false -and $null -ne $probe -and $null -ne $commission -and
+ $zivIntegrityValid -and $null -ne $generic -and
+ [string]$generic.exactCommit -ceq 'a43b9b4864a9565c02f544d4135fe157c2fac577' -and
+ [string]$generic.baseEngineCommit -ceq [string]$ziv.exactCommit -and
+ [int]$generic.enginePullRequest -eq 168 -and
+ [long]$generic.engineCiRun -eq 38231327892 -and
+ [bool]$generic.typedSemanticGateRequired -and
+ [bool]$generic.associationScopedInventoryRequired -and
+ [bool]$generic.vendorDispatchForbidden -and
+ [bool]$generic.physicalQualificationRequired -and
+ [bool]$generic.mainlinePromotionForbidden -and
+ [bool]$generic.fullDiscoveryFallbackForbidden -and
+ [bool]$generic.cyclicMmsProcessPollingForbidden -and
+ [string]$probe.status -ceq 'code-trial-not-physical' -and
+ [int]$probe.issue -eq 525 -and
+ [int]$probe.enginePullRequest -eq 169 -and
+ [string]$probe.baseEngineCommit -ceq [string]$generic.exactCommit -and
+ [bool]$probe.readOnlyCtlModelProbeRequired -and
+ [string]$probe.exactCommit -ceq '5e17da5f673eedce125b5c66e7dc2fa0abef2851' -and
+ [long]$probe.engineCiRun -eq 38239451501 -and
+ [bool]$probe.noControlWriteOnProbe -and
+ [bool]$probe.physicalQualificationRequired -and
+ [bool]$probe.mainlinePromotionForbidden -and
+ [bool]$probe.fullDiscoveryFallbackForbidden -and
+ [bool]$probe.cyclicMmsProcessPollingForbidden -and
+ [string]$commission.status -ceq 'code-trial-not-physical' -and
+ [int]$commission.enginePullRequest -eq 169 -and
+ [long]$commission.engineCiRun -eq 38257606517 -and
+ [string]$commission.baseEngineCommit -ceq [string]$probe.exactCommit -and
+ [string]$commission.exactCommit -ceq 'f7a4e3ba2f9771e34e04073c0db0900112aa55fa' -and
+ [bool]$commission.authorizedConfirmationRequired -and
+ [bool]$commission.exactLiveCompareRequired -and
+ [bool]$commission.singleWriteNoRetry -and
+ [bool]$commission.verifiedReadbackRequired -and
+ [bool]$commission.commandOperateForbidden -and
+ [bool]$commission.physicalQualificationRequired -and
+ [bool]$commission.mainlinePromotionForbidden -and
+ [bool]$commission.fullDiscoveryFallbackForbidden -and
+ [bool]$commission.cyclicMmsProcessPollingForbidden
+ )
+ # P10.9R retains all Q lineage checks and adds explicit StatusOnly=0
+ # usability without promoting commissioning to physical qualification.
+ $position = $Lock.p109TPositionControlInteroperabilityTrial
+ $positionHead = (
+ $null -ne $position -and
+ [string]$position.status -ceq 'code-trial-not-physical' -and
+ [int]$position.enginePullRequest -eq 169 -and
+ [long]$position.engineCiRun -eq 38320987660 -and
+ [string]$position.baseEngineCommit -ceq 'd1dd8fc14e79c5de15fbc52d77aea48a14c7c02b' -and
+ [string]$position.exactCommit -ceq '9d82523978c29dcbf38f60cbfb384c3b050eae7c' -and
+ [bool]$position.structuredStatusRequiresTypedMmsRead -and
+ [bool]$position.optionalCancelNotRequiredForNormalSbo -and
+ [bool]$position.liveTypedControlVerificationRequired -and
+ [bool]$position.commandOperateNeverAutoDispatched -and
+ [bool]$position.physicalQualificationRequired -and
+ [bool]$position.mainlinePromotionForbidden -and
+ [bool]$position.fullDiscoveryFallbackForbidden -and
+ [bool]$position.cyclicMmsProcessPollingForbidden
+ )
+ $positionStatus = $Lock.p109UPositionStatusNameListRecoveryTrial
+ $positionStatusHead = (
+ $null -ne $positionStatus -and
+ [string]$positionStatus.status -ceq 'code-trial-not-physical' -and
+ [int]$positionStatus.enginePullRequest -eq 169 -and
+ [long]$positionStatus.engineCiRun -eq 38324547203 -and
+ [string]$positionStatus.baseEngineCommit -ceq '9d82523978c29dcbf38f60cbfb384c3b050eae7c' -and
+ [string]$positionStatus.exactCommit -ceq 'ad43399ed411cc72e6eb68b67e198db5e7b95381' -and
+ [bool]$positionStatus.truncatedNameListSupported -and
+ [bool]$positionStatus.standardPositionOnlyProbe -and
+ [bool]$positionStatus.singleScalarReadOnly -and
+ [bool]$positionStatus.bitStringWidthTwoRequired -and
+ [bool]$positionStatus.typedControlSemanticVerificationRequired -and
+ [bool]$positionStatus.operateRequiresExplicitConfirmation -and
+ [bool]$positionStatus.physicalQualificationRequired -and
+ [bool]$positionStatus.mainlinePromotionForbidden -and
+ [bool]$positionStatus.fullDiscoveryFallbackForbidden -and
+ [bool]$positionStatus.cyclicMmsProcessPollingForbidden
+ )
+ # P10.9V is the exact CI-qualified, still-unaccepted successor to U.
+ # Its semantic resolver changes no reporting/Operate workflow. Preserve
+ # every historic P10.9T/U, commissioning and physical promotion guard.
+ $typedBsc = $Lock.p109VLiveBscAuthorityTrial
+ $typedBscHead = (
+ $positionHead -and $positionStatusHead -and
+ $null -ne $typedBsc -and
+ [string]$Lock.commit -ceq '036e2c00d83a0815b6160147b14ea05975057965' -and
+ [int]$Lock.sourcePullRequest -eq 169 -and
+ [string]$typedBsc.status -ceq 'engine-ci-success-consumer-ci-pending-not-physical' -and
+ [int]$typedBsc.enginePullRequest -eq 169 -and
+ [int]$typedBsc.consumerPullRequest -eq 543 -and
+ [string]$typedBsc.baseEngineCommit -ceq [string]$positionStatus.exactCommit -and
+ [string]$typedBsc.exactCommit -ceq '036e2c00d83a0815b6160147b14ea05975057965' -and
+ [long]$typedBsc.engineCiRun -eq 38328335496 -and
+ [bool]$typedBsc.staticSpcNeverOverridesExactLiveBsc -and
+ [bool]$typedBsc.typedTwoBitCtlValAndExactStepStatusRequired -and
+ [bool]$typedBsc.wrongOrCrossObjectStatusFailsClosed -and
+ [bool]$typedBsc.physicalQualificationRequired -and
+ [bool]$typedBsc.mainlinePromotionForbidden -and
+ [bool]$typedBsc.cyclicMmsProcessPollingForbidden
+ )
+ $simple = $Lock.p109RSimpleCtlModelEditorTrial
+ $simpleHead = (
+ ([string]$Lock.commit -ceq 'd1dd8fc14e79c5de15fbc52d77aea48a14c7c02b' -or
+ ([string]$Lock.commit -ceq '9d82523978c29dcbf38f60cbfb384c3b050eae7c' -and $positionHead) -or
+ ([string]$Lock.commit -ceq 'ad43399ed411cc72e6eb68b67e198db5e7b95381' -and $positionHead -and $positionStatusHead) -or
+ $typedBscHead) -and
+ [int]$Lock.sourcePullRequest -eq 169 -and
+ $zivIntegrityValid -and $null -ne $generic -and
+ [string]$generic.exactCommit -ceq 'a43b9b4864a9565c02f544d4135fe157c2fac577' -and
+ [string]$generic.baseEngineCommit -ceq [string]$ziv.exactCommit -and
+ [int]$generic.enginePullRequest -eq 168 -and
+ [long]$generic.engineCiRun -eq 38231327892 -and
+ [bool]$generic.typedSemanticGateRequired -and
+ [bool]$generic.associationScopedInventoryRequired -and
+ [bool]$generic.vendorDispatchForbidden -and
+ [bool]$generic.physicalQualificationRequired -and
+ [bool]$generic.mainlinePromotionForbidden -and
+ [bool]$generic.fullDiscoveryFallbackForbidden -and
+ [bool]$generic.cyclicMmsProcessPollingForbidden -and
+ $null -ne $probe -and
+ [string]$probe.status -ceq 'code-trial-not-physical' -and
+ [int]$probe.issue -eq 525 -and
+ [int]$probe.enginePullRequest -eq 169 -and
+ [string]$probe.baseEngineCommit -ceq [string]$generic.exactCommit -and
+ [string]$probe.exactCommit -ceq '5e17da5f673eedce125b5c66e7dc2fa0abef2851' -and
+ [long]$probe.engineCiRun -eq 38239451501 -and
+ [bool]$probe.readOnlyCtlModelProbeRequired -and
+ [bool]$probe.noControlWriteOnProbe -and
+ [bool]$probe.physicalQualificationRequired -and
+ [bool]$probe.mainlinePromotionForbidden -and
+ [bool]$probe.fullDiscoveryFallbackForbidden -and
+ [bool]$probe.cyclicMmsProcessPollingForbidden -and
+ $null -ne $commission -and
+ [string]$commission.status -ceq 'code-trial-not-physical' -and
+ [int]$commission.enginePullRequest -eq 169 -and
+ [string]$commission.baseEngineCommit -ceq [string]$probe.exactCommit -and
+ [string]$commission.exactCommit -ceq 'f7a4e3ba2f9771e34e04073c0db0900112aa55fa' -and
+ [long]$commission.engineCiRun -eq 38257606517 -and
+ [bool]$commission.authorizedConfirmationRequired -and
+ [bool]$commission.exactLiveCompareRequired -and
+ [bool]$commission.singleWriteNoRetry -and
+ [bool]$commission.verifiedReadbackRequired -and
+ [bool]$commission.commandOperateForbidden -and
+ [bool]$commission.physicalQualificationRequired -and
+ [bool]$commission.mainlinePromotionForbidden -and
+ [bool]$commission.fullDiscoveryFallbackForbidden -and
+ [bool]$commission.cyclicMmsProcessPollingForbidden -and
+ $null -ne $simple -and
+ [string]$simple.status -ceq 'code-trial-not-physical' -and
+ [int]$simple.enginePullRequest -eq 169 -and
+ [long]$simple.engineCiRun -eq 38311424952 -and
+ [string]$simple.baseEngineCommit -ceq [string]$commission.exactCommit -and
+ [string]$simple.exactCommit -ceq 'd1dd8fc14e79c5de15fbc52d77aea48a14c7c02b' -and
+ [bool]$simple.simpleSelectWriteUi -and
+ [bool]$simple.statusOnlyZeroSupported -and
+ [bool]$simple.exactLiveCompareRequired -and
+ [bool]$simple.singleWriteNoRetry -and
+ [bool]$simple.verifiedReadbackRequired -and
+ [bool]$simple.commandOperateForbidden -and
+ [bool]$simple.physicalQualificationRequired -and
+ [bool]$simple.mainlinePromotionForbidden -and
+ [bool]$simple.fullDiscoveryFallbackForbidden -and
+ [bool]$simple.cyclicMmsProcessPollingForbidden
+ )
+ if (-not ($previousHead -or $subscriberHead -or $canonicalHead -or $zivHead -or $genericHead -or $probeHead -or $commissionHead -or $simpleHead) -or
[string]$Lock.repository -cne 'masarray/ARIEC61850' -or
[string]$Lock.ref -cne 'main' -or $null -eq $candidate) { return $false }
diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs
index f2e110668..714ce483b 100644
--- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs
+++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs
@@ -445,8 +445,49 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter
var genericTrial = root.GetProperty("genericControlInteroperabilityTrial");
Assert.Equal(zivTrial.GetProperty("exactCommit").GetString(),
genericTrial.GetProperty("baseEngineCommit").GetString());
+ var recovery = root.GetProperty("p109LControlRecoveryTrial");
Assert.Equal(genericTrial.GetProperty("exactCommit").GetString(),
+ recovery.GetProperty("baseEngineCommit").GetString());
+ var commissioning = root.GetProperty("p109QControlModelCommissioningTrial");
+ Assert.Equal(recovery.GetProperty("exactCommit").GetString(),
+ commissioning.GetProperty("baseEngineCommit").GetString());
+ var simpleEditor = root.GetProperty("p109RSimpleCtlModelEditorTrial");
+ Assert.Equal(commissioning.GetProperty("exactCommit").GetString(),
+ simpleEditor.GetProperty("baseEngineCommit").GetString());
+ var position = root.GetProperty("p109TPositionControlInteroperabilityTrial");
+ Assert.Equal(simpleEditor.GetProperty("exactCommit").GetString(),
+ position.GetProperty("baseEngineCommit").GetString());
+ var positionStatus = root.GetProperty("p109UPositionStatusNameListRecoveryTrial");
+ Assert.Equal(position.GetProperty("exactCommit").GetString(),
+ positionStatus.GetProperty("baseEngineCommit").GetString());
+ // P10.9U remains immutable historical evidence. P10.9V is the
+ // exact tested successor, not a rewrite of that accepted lineage.
+ Assert.Equal("ad43399ed411cc72e6eb68b67e198db5e7b95381",
+ positionStatus.GetProperty("exactCommit").GetString());
+ var typedBscTrial = root.GetProperty("p109VLiveBscAuthorityTrial");
+ Assert.Equal(positionStatus.GetProperty("exactCommit").GetString(),
+ typedBscTrial.GetProperty("baseEngineCommit").GetString());
+ Assert.Equal("036e2c00d83a0815b6160147b14ea05975057965",
+ typedBscTrial.GetProperty("exactCommit").GetString());
+ Assert.Equal(typedBscTrial.GetProperty("exactCommit").GetString(),
root.GetProperty("commit").GetString());
+ Assert.Equal("engine-ci-success-consumer-ci-pending-not-physical",
+ typedBscTrial.GetProperty("status").GetString());
+ Assert.Equal(38328335496L, typedBscTrial.GetProperty("engineCiRun").GetInt64());
+ Assert.True(typedBscTrial.GetProperty("staticSpcNeverOverridesExactLiveBsc").GetBoolean());
+ Assert.True(typedBscTrial.GetProperty("typedTwoBitCtlValAndExactStepStatusRequired").GetBoolean());
+ Assert.True(typedBscTrial.GetProperty("wrongOrCrossObjectStatusFailsClosed").GetBoolean());
+ Assert.True(typedBscTrial.GetProperty("physicalQualificationRequired").GetBoolean());
+ Assert.True(typedBscTrial.GetProperty("mainlinePromotionForbidden").GetBoolean());
+ Assert.True(typedBscTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean());
+ Assert.True(positionStatus.GetProperty("singleScalarReadOnly").GetBoolean());
+ Assert.True(positionStatus.GetProperty("bitStringWidthTwoRequired").GetBoolean());
+ Assert.True(position.GetProperty("structuredStatusRequiresTypedMmsRead").GetBoolean());
+ Assert.True(position.GetProperty("liveTypedControlVerificationRequired").GetBoolean());
+ Assert.True(simpleEditor.GetProperty("statusOnlyZeroSupported").GetBoolean());
+ Assert.True(commissioning.GetProperty("singleWriteNoRetry").GetBoolean());
+ Assert.True(commissioning.GetProperty("commandOperateForbidden").GetBoolean());
+ Assert.True(recovery.GetProperty("noControlWriteOnProbe").GetBoolean());
Assert.Equal("a43b9b4864a9565c02f544d4135fe157c2fac577",
genericTrial.GetProperty("exactCommit").GetString());
Assert.Equal("code-verified-not-physical", canonicalTrial.GetProperty("status").GetString());
@@ -467,7 +508,7 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter
Assert.True(zivTrial.GetProperty("mainlinePromotionForbidden").GetBoolean());
Assert.True(zivTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean());
Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean());
- Assert.Equal(168, root.GetProperty("sourcePullRequest").GetInt32());
+ Assert.Equal(169, root.GetProperty("sourcePullRequest").GetInt32());
Assert.True(genericTrial.GetProperty("typedSemanticGateRequired").GetBoolean());
Assert.True(genericTrial.GetProperty("associationScopedInventoryRequired").GetBoolean());
Assert.True(genericTrial.GetProperty("vendorDispatchForbidden").GetBoolean());
diff --git a/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs b/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs
index 8565339f5..8b8e1f9fd 100644
--- a/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs
+++ b/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs
@@ -82,14 +82,22 @@ public void LocalClientFailure_ReportsOperateNotSent()
}
[Fact]
- public void FailedCommand_HasVisibleOperatorWarning_OnlyOnFailure()
+ public void OnlyFailedCommandsRaiseDiagnosticsAndToast_NoSuccessNotice()
{
var main = Read("MainWindow.xaml.cs");
- Assert.Contains("if (!result.IsSuccess)", main, StringComparison.Ordinal);
- Assert.Contains("Iec61850ControlShout.FromResult(result)", main, StringComparison.Ordinal);
- Assert.Contains("ShowControlShout(notice.Title, notice.Detail)", main, StringComparison.Ordinal);
- Assert.Contains("MarkDiagnosticAlert()", main, StringComparison.Ordinal);
- Assert.DoesNotContain("IED Command Failed —", main, StringComparison.Ordinal);
+ var start = main.IndexOf("signal.ControlLastResult = BuildQuickControlResult(result);", StringComparison.Ordinal);
+ var end = main.IndexOf("catch (OperationCanceledException)", start, StringComparison.Ordinal);
+ Assert.True(start >= 0 && end > start);
+ var resultPresentation = main[start..end];
+ // Whitespace-neutral: Git on Windows may check out CRLF source.
+ // The toast must remain lexically nested inside failure handling.
+ Assert.Matches(
+ @"if\s*\(!result\.IsSuccess\)\s*\{\s*MarkDiagnosticAlert\(\);\s*ShowControlShout\(Iec61850ControlShout\.FromResult\(result\)\);\s*\}",
+ resultPresentation);
+ Assert.Equal(1, resultPresentation.Split(
+ "ShowControlShout(Iec61850ControlShout.FromResult(result));",
+ StringSplitOptions.None).Length - 1);
+ Assert.DoesNotContain("if (result.IsSuccess)", resultPresentation, StringComparison.Ordinal);
Assert.DoesNotContain("MessageBoxImage.Warning", main, StringComparison.Ordinal);
}
@@ -99,7 +107,8 @@ public void ProducersAndDispatcher_EnforceCanonicalIdentity()
var projection = Read("Services/Iec61850StaticControlStatusProjectionService.cs");
Assert.Contains("Full DataObject is command identity", projection, StringComparison.Ordinal);
var model = Read("Models/MonitorModels.cs");
- Assert.Contains("DistinctOperable(Signals)", model, StringComparison.Ordinal);
+ Assert.Contains("DistinctSelected(Signals)", model, StringComparison.Ordinal);
+ Assert.Contains("ControlSupportsOperate", model, StringComparison.Ordinal);
var runtime = Read("Services/Iec61850MonitorRuntime.cs");
Assert.Contains("_activeControlTargets.TryAdd(targetKey, 0)", runtime, StringComparison.Ordinal);
Assert.Contains("_activeControlTargets.TryRemove(targetKey, out _)", runtime, StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs b/tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs
index 4806e2a4a..de6f36d71 100644
--- a/tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs
+++ b/tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs
@@ -18,16 +18,16 @@ public void MmsDenied_StatesNoExactCause_AndSuggestsLocalRemoteCheck()
CompletionState = "Rejected"
};
var notice = Iec61850ControlShout.FromResult(result);
- Assert.Contains("reason unspecified", notice.Title, StringComparison.Ordinal);
+ Assert.Equal("IED rejected command", notice.Title);
Assert.Contains("Check BCU Local/Remote", notice.Detail, StringComparison.Ordinal);
Assert.DoesNotContain("IED confirmed Local", notice.Detail, StringComparison.OrdinalIgnoreCase);
}
[Theory]
- [InlineData("blocked-by-interlocking", "interlocking")]
- [InlineData("blocked-by-synchrocheck", "synchrocheck")]
- [InlineData("blocked-by-mode", "control mode")]
- [InlineData("no-access-authority", "access authority")]
+ [InlineData("blocked-by-interlocking", "Blocked by interlock")]
+ [InlineData("blocked-by-synchrocheck", "Blocked by synchrocheck")]
+ [InlineData("blocked-by-mode", "Blocked by control mode")]
+ [InlineData("no-access-authority", "Authorization denied by IED")]
public void ExplicitIedAddCause_IsShoutedAsEngineEvidence(string addCause, string message)
{
var result = new Iec61850ControlCommandResult
@@ -37,8 +37,9 @@ public void ExplicitIedAddCause_IsShoutedAsEngineEvidence(string addCause, strin
CompletionState = "Rejected"
};
var notice = Iec61850ControlShout.FromResult(result);
- Assert.Contains(message, notice.Title, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("LastApplError AddCause", notice.Detail, StringComparison.Ordinal);
+ Assert.Equal(message, notice.Title);
+ Assert.Contains("IED AddCause", notice.Detail, StringComparison.Ordinal);
+ Assert.Equal(Iec61850ControlNoticeTone.Error, notice.Tone);
Assert.DoesNotContain("exact cause not supplied", notice.Detail, StringComparison.OrdinalIgnoreCase);
}
@@ -51,12 +52,119 @@ public void NoWireTransmission_ReportsNotSentRatherThanIedRejected()
Assert.Contains("Client rejected before MMS Operate", notice.Detail, StringComparison.Ordinal);
}
+ [Theory]
+ [InlineData("Closed [10]", "Position reached")]
+ [InlineData("Open [01]", "Position reached")]
+ [InlineData("Raise", "Tap movement confirmed")]
+ [InlineData("Lower", "Tap movement confirmed")]
+ public void ConfirmedFeedback_ProducesTruthfulSuccessToast(string requested, string expected)
+ {
+ var result = new Iec61850ControlCommandResult
+ {
+ IsSuccess = true, ServiceAccepted = true, FeedbackConfirmed = true,
+ RequestedValue = requested, FeedbackValue = requested == "Raise" ? "12" : "Open [01]"
+ };
+ var notice = Iec61850ControlShout.FromResult(result);
+ Assert.Equal(expected, notice.Title);
+ Assert.Equal(Iec61850ControlNoticeTone.Success, notice.Tone);
+ Assert.Contains("IED feedback", notice.Detail);
+ }
+
[Fact]
- public void Shout_FromSuccess_DoesNotClaimControlRejection()
+ public void UnverifiedServiceSuccess_NeverClaimsPositionReached()
{
- var result = new Iec61850ControlCommandResult { IsSuccess = true };
+ var result = new Iec61850ControlCommandResult
+ {
+ IsSuccess = true, ServiceAccepted = true,
+ FeedbackConfirmed = false, RequestedValue = "Closed [10]",
+ Stage = "Command accepted"
+ };
var notice = Iec61850ControlShout.FromResult(result);
- Assert.Equal("Command succeeded", notice.Title);
+ Assert.Equal("Command accepted", notice.Title);
+ Assert.Equal(Iec61850ControlNoticeTone.Warning, notice.Tone);
+ Assert.Contains("not confirmed", notice.Detail);
+ }
+
+ [Fact]
+ public void BareSuccessFlag_DoesNotInventMmsCommand()
+ {
+ var notice = Iec61850ControlShout.FromResult(new() { IsSuccess = true });
+ Assert.Equal("Command outcome unverified", notice.Title);
+ Assert.NotEqual(Iec61850ControlNoticeTone.Success, notice.Tone);
+ }
+
+ [Fact]
+ public void AlreadyAtState_ReturnsNeutralNoticeAndNoSentClaim()
+ {
+ var notice = Iec61850ControlShout.FromResult(new()
+ {
+ IsSuccess = true, ServiceAccepted = false, FeedbackConfirmed = true,
+ CompletionState = "NotSent", Stage = "Already at requested state",
+ RequestedValue = "Open [01]", FeedbackValue = "Open [01]"
+ });
+ Assert.Equal("Already at requested state", notice.Title);
+ Assert.Equal(Iec61850ControlNoticeTone.Info, notice.Tone);
+ Assert.Contains("No control command was sent", notice.Detail);
+ }
+
+ [Fact]
+ public void FeedbackTimeout_DistinguishesServiceAcceptanceFromPositionReached()
+ {
+ var notice = Iec61850ControlShout.FromResult(new()
+ {
+ IsSuccess = false, ServiceAccepted = true,
+ FeedbackConfirmed = false, Stage = "Feedback timeout",
+ RequestedValue = "Raise", FeedbackValue = "11"
+ });
+ Assert.Equal("Feedback not confirmed", notice.Title);
+ Assert.Equal(Iec61850ControlNoticeTone.Warning, notice.Tone);
+ }
+
+ [Fact]
+ public void TestModeNeverClaimsPrimaryProcessMovement()
+ {
+ var notice = Iec61850ControlShout.FromResult(new()
+ {
+ IsSuccess = true, ServiceAccepted = true,
+ Stage = "Test command accepted", RequestedValue = "Closed [10]"
+ });
+ Assert.Equal("Test command accepted", notice.Title);
+ Assert.Equal(Iec61850ControlNoticeTone.Info, notice.Tone);
+ Assert.Contains("not verified", notice.Detail);
+ }
+
+ [Fact]
+ public void NegativeTerminationAlwaysRemainsError()
+ {
+ var notice = Iec61850ControlShout.FromResult(new()
+ {
+ ServiceAccepted = true, IsSuccess = false,
+ CommandTerminationReceived = true, PositiveTermination = false
+ });
+ Assert.Equal(Iec61850ControlNoticeTone.Error, notice.Tone);
+ Assert.DoesNotContain("reached", notice.Title, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void NoAccessAuthorityFromIedNeverInventsPhysicalLocalMode()
+ {
+ var notice = Iec61850ControlShout.FromResult(new()
+ {
+ IsSuccess = false, AddCause = "no-access-authority", CompletionState = "Rejected"
+ });
+ Assert.Equal("Authorization denied by IED", notice.Title);
+ Assert.Equal(Iec61850ControlNoticeTone.Error, notice.Tone);
+ Assert.Contains("AddCause", notice.Detail);
+ Assert.DoesNotContain("confirmed Local", notice.Detail, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LocalPrewireBlockIsNotRepresentedAsIedRejection()
+ {
+ var notice = Iec61850ControlShout.FromLocalRejection("live model is not ready");
+ Assert.Equal("Command not sent", notice.Title);
+ Assert.Equal(Iec61850ControlNoticeTone.Warning, notice.Tone);
+ Assert.Contains("before dispatch", notice.Detail);
}
[Fact]
@@ -89,6 +197,20 @@ public void UiOverlay_IsNonmodalAndReusesDispatcherTimer()
var code = Read("MainWindow.xaml.cs");
Assert.Contains("x:Name=\"ControlShoutCard\"", xaml, StringComparison.Ordinal);
Assert.Contains("Panel.ZIndex=\"90\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("IsHitTestVisible=\"False\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("HorizontalAlignment=\"Center\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("VerticalAlignment=\"Center\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("x:Name=\"ControlShoutGlyph\"", xaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("x:Name=\"ControlShoutDetail\"", xaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("x:Name=\"ControlShoutAccent\"", xaml, StringComparison.Ordinal);
+ var dockStart = xaml.IndexOf("x:Name=\"CommandPanelExpander\"", StringComparison.Ordinal);
+ var dockEnd = xaml.IndexOf("", dockStart, StringComparison.Ordinal);
+ var toast = xaml.IndexOf("x:Name=\"ControlShoutCard\"", StringComparison.Ordinal);
+ Assert.True(dockStart >= 0 && toast > dockStart && dockEnd > toast);
+ Assert.Contains("ShowControlShout(Iec61850ControlShout.FromResult(result));", code, StringComparison.Ordinal);
+ Assert.Contains("if (!result.IsSuccess)", code, StringComparison.Ordinal);
+ Assert.Contains("DismissControlShout();", code, StringComparison.Ordinal);
+ Assert.DoesNotContain("ShowControlShout(Iec61850ControlShout.FromDemo(", code, StringComparison.Ordinal);
Assert.Contains("Interval = TimeSpan.FromSeconds(8)", code, StringComparison.Ordinal);
Assert.Contains("ControlShoutCard.Visibility = Visibility.Collapsed", code, StringComparison.Ordinal);
Assert.Contains("Dispatcher.CheckAccess()", code, StringComparison.Ordinal);
@@ -100,7 +222,8 @@ public void IdentityProjection_AndSingleFlightProtection_StillActive()
{
var model = Read("Models/MonitorModels.cs");
var runtime = Read("Services/Iec61850MonitorRuntime.cs");
- Assert.Contains("Iec61850ControlIdentity.DistinctOperable(Signals)", model, StringComparison.Ordinal);
+ Assert.Contains("Iec61850ControlIdentity.DistinctSelected(Signals)", model, StringComparison.Ordinal);
+ Assert.Contains("ControlSupportsOperate", model, StringComparison.Ordinal);
Assert.Contains("_activeControlTargets.TryAdd(targetKey, 0)", runtime, StringComparison.Ordinal);
Assert.Contains("_activeControlTargets.TryRemove(targetKey, out _)", runtime, StringComparison.Ordinal);
}
diff --git a/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs b/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs
index be1c8b726..cdec37efc 100644
--- a/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs
@@ -79,11 +79,17 @@ public void FatCommandPanel_RemainsFailClosedForStatusOnlyOrUnsupportedControls(
var identity = File.ReadAllText(FindRepoFile("Services/Iec61850ControlIdentity.cs"));
Assert.Contains("live ctlModel", bridge, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("StatusOnly stays read-only", bridge, StringComparison.Ordinal);
+ // The selected catalog may contain StatusOnly rows; runtime authorization,
+ // not catalog membership, must remain the write-safety boundary.
+ Assert.Contains("ControlSupportsOperate", bridge, StringComparison.Ordinal);
+ Assert.Contains("StatusOnly", bridge, StringComparison.Ordinal);
Assert.Contains("device.CommandSignals", bridge, StringComparison.Ordinal);
- Assert.Contains("DistinctOperable(Signals)", monitorModels, StringComparison.Ordinal);
- // Fail-closed gate migrated into the shared canonical command projection;
- // all entry points still exclude unknown/read-only and generic controls.
+ // Selected control objects remain visible for diagnosis; operating safety
+ // is separately enforced by the per-object gate and FAT command dispatch.
+ Assert.Contains("DistinctSelected(Signals)", monitorModels, StringComparison.Ordinal);
+ Assert.Contains("ControlSupportsOperate", monitorModels, StringComparison.Ordinal);
+ // Legacy explicit-operability filter remains available for actual execution
+ // eligibility, never for removing the selected control row from the dock.
Assert.Contains("ControlModelResolved && s.ControlSupportsOperate", identity, StringComparison.Ordinal);
Assert.Contains("!s.IsGenericControl", identity, StringComparison.Ordinal);
Assert.Contains("Status-only controls remain read-only", panel, StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/P0FieldBenchRound2RegressionTests.cs b/tests/ARSAS.Tests/P0FieldBenchRound2RegressionTests.cs
index 13768aa38..15ba74445 100644
--- a/tests/ARSAS.Tests/P0FieldBenchRound2RegressionTests.cs
+++ b/tests/ARSAS.Tests/P0FieldBenchRound2RegressionTests.cs
@@ -15,13 +15,13 @@ public void FatSignalColumn_UsesDeterministicPhaseAwareTemplateAfterV2Rebuild()
}
[Fact]
- public void RcbProductionPointer_OpensMultiExporterAndUsesOneIndependentSelectionAuthority()
+ public void RcbProductionPointer_LeavesLegacyClickAuthorityAndPreservesMultiExportSelection()
{
var source = File.ReadAllText(FindRepoFile("MainWindow.RcbMultiSelectPointerAuthority.cs"));
Assert.Contains("PreviewMouseLeftButtonDownEvent", source, StringComparison.Ordinal);
Assert.Contains("PreviewKeyDownEvent", source, StringComparison.Ordinal);
- Assert.Contains("window.IedEditRcbMulti_Click(button, e)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("window.IedEditRcbMulti_Click(button, e)", source, StringComparison.Ordinal);
Assert.Contains("Window.GetWindow(grid) is not RcbMultiExportWindow", source, StringComparison.Ordinal);
Assert.Contains("ApplyRcbPointerSelection(", source, StringComparison.Ordinal);
Assert.Contains("ApplyRcbSelectionForTest(", source, StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs b/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs
index 5b15dc9bc..3195dbaa5 100644
--- a/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs
+++ b/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs
@@ -103,7 +103,7 @@ public void MultiRcbExport_ProductionClickUsesMultiSelectAndNativeDataSetsOnly()
Assert.Contains("_rows.Where(row => row.IsSelected).ToArray()", window, StringComparison.Ordinal);
Assert.Contains("select one or more native RCBs", window, StringComparison.OrdinalIgnoreCase);
Assert.Contains("[ModuleInitializer]", authority, StringComparison.Ordinal);
- Assert.Contains("window.IedEditRcbMulti_Click", authority, StringComparison.Ordinal);
+ Assert.DoesNotContain("window.IedEditRcbMulti_Click(button, e)", authority, StringComparison.Ordinal);
Assert.DoesNotContain("CreateDynamic", source, StringComparison.OrdinalIgnoreCase);
}
diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs
index a62e461ec..25b9b1bdc 100644
--- a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs
+++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs
@@ -17,7 +17,13 @@ public void MainWindow_RuntimeFacade_OffloadsLifecycle_AndStopCanPreemptHungOper
Assert.Contains("RunDeviceOperationAsync", facade, StringComparison.Ordinal);
Assert.Contains("RunPreemptiveStopAsync", facade, StringComparison.Ordinal);
Assert.Contains("activeCancellation?.Cancel()", facade, StringComparison.Ordinal);
- Assert.Contains("without\n // waiting for OperationGate", facade, StringComparison.Ordinal);
+ var preemptiveStart = facade.IndexOf("private Task RunPreemptiveStopAsync(", StringComparison.Ordinal);
+ var preemptiveEnd = facade.IndexOf("public async ValueTask DisposeAsync()", preemptiveStart, StringComparison.Ordinal);
+ Assert.True(preemptiveStart >= 0 && preemptiveEnd > preemptiveStart);
+ var preemptiveBody = facade[preemptiveStart..preemptiveEnd];
+ Assert.DoesNotContain("await slot.OperationGate.WaitAsync(", preemptiveBody, StringComparison.Ordinal);
+ Assert.Contains("slot.ActiveStopTask", preemptiveBody, StringComparison.Ordinal);
+ Assert.Contains("await slot.StopGate.WaitAsync()", preemptiveBody, StringComparison.Ordinal);
Assert.Contains("linkedCancellation.Token.ThrowIfCancellationRequested()", facade, StringComparison.Ordinal);
Assert.Contains("slot.Generation != generation", facade, StringComparison.Ordinal);
Assert.Contains("DisposeBudget = TimeSpan.FromSeconds(3)", facade, StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/P109LControlRecoveryTests.cs b/tests/ARSAS.Tests/P109LControlRecoveryTests.cs
new file mode 100644
index 000000000..78bf68e39
--- /dev/null
+++ b/tests/ARSAS.Tests/P109LControlRecoveryTests.cs
@@ -0,0 +1,81 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class P109LControlRecoveryTests
+{
+ private static SignalDefinition Control(string reference, string cdc, bool selected = true)
+ => new()
+ {
+ Name = reference[(reference.LastIndexOf('.') + 1)..],
+ ObjectReference = reference,
+ FunctionalConstraint = "CO",
+ IsControlSignal = true,
+ IsSelected = selected,
+ ControlCdc = cdc,
+ ControlModelText = "Auto-detect"
+ };
+
+ [Fact]
+ public void FiveZivControlsRemainVisibleBeforeInspection()
+ {
+ var device = new Iec61850MonitorDevice();
+ foreach (var (reference, cdc) in new[] {
+ ("ZIVAVRLD1/ATCC1.TapChg", "BSC"),
+ ("ZIVAVRLD1/CSWI1.Pos", "DPC"),
+ ("ZIVAVRLD1/CSWI2.Pos", "DPC"),
+ ("ZIVAVRLD1/CSWI3.Pos", "DPC"),
+ ("ZIVAVRLD1/CSWI4.Pos", "DPC")
+ })
+ device.Signals.Add(Control(reference, cdc));
+ device.RecountSelectedSignals();
+
+ Assert.Equal(5, device.SelectedControlSignalCount);
+ Assert.Equal(5, device.CommandSignals.Count);
+ Assert.All(device.CommandSignals, signal =>
+ {
+ Assert.False(signal.ControlSupportsOperate);
+ Assert.False(signal.ControlSemanticVerified);
+ });
+ Assert.True(device.CommandSignals.Single(s => s.ControlCdc == "BSC").IsRaiseLowerControl);
+ Assert.All(device.CommandSignals.Where(s => s.ControlCdc == "DPC"),
+ s => Assert.True(s.IsPositionControl));
+ }
+
+ [Fact]
+ public void UninspectedAndStatusOnlyRemainInCatalogWithAllWritesBlocked()
+ {
+ var device = new Iec61850MonitorDevice();
+ var pending = Control("LD0/CSWI1.Pos", "DPC");
+ var readOnly = Control("LD0/ATCC1.TapChg", "BSC");
+ readOnly.ControlModelText = "Status only";
+ device.Signals.Add(pending);
+ device.Signals.Add(readOnly);
+ device.RecountSelectedSignals();
+
+ Assert.Equal(2, device.CommandSignals.Count);
+ Assert.All(device.CommandSignals, s => Assert.False(s.ControlSupportsOperate));
+ Assert.Single(Iec61850ControlIdentity.InspectionRepresentatives(device.Signals));
+ }
+
+ [Fact]
+ public void SelectedCatalogUsesExactDataObjectAndIgnoresUnselectedObjects()
+ {
+ var a = Control("LD0/CSWI1.Pos", "DPC");
+ var duplicate = Control("ld0/cswi1.pos", "DPC");
+ var unselected = Control("LD0/CSWI2.Pos", "DPC", selected: false);
+ var catalog = Iec61850ControlIdentity.DistinctSelected(new[] { a, duplicate, unselected });
+ Assert.Single(catalog);
+ Assert.Same(a, catalog[0]);
+ }
+
+ [Fact]
+ public void EngineeringCtlModelIsNotAuthorizationWithoutLiveSemanticEvidence()
+ {
+ var tap = Control("ZIVAVRLD1/ATCC1.TapChg", "BSC");
+ tap.ControlModelText = "ctlModel=4";
+ Assert.True(tap.ControlModelResolved);
+ Assert.False(tap.ControlSupportsOperate);
+ }
+}
diff --git a/tests/ARSAS.Tests/P109MControlDockPresentationTests.cs b/tests/ARSAS.Tests/P109MControlDockPresentationTests.cs
new file mode 100644
index 000000000..366865fb6
--- /dev/null
+++ b/tests/ARSAS.Tests/P109MControlDockPresentationTests.cs
@@ -0,0 +1,110 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class P109MControlDockPresentationTests
+{
+ private static SignalDefinition Control(string obj, string cdc)
+ => new()
+ {
+ Name = obj[(obj.LastIndexOf('.') + 1)..],
+ ObjectReference = obj,
+ IsControlSignal = true,
+ IsSelected = true,
+ FunctionalConstraint = "CO",
+ ControlCdc = cdc,
+ ControlModelText = "Status only",
+ };
+
+ [Theory]
+ [InlineData("IED/CSWI1.Pos", "DPC", true, false)]
+ [InlineData("IED/CSWI2.Pos", "DPC", true, false)]
+ [InlineData("IED/CSWI3.Pos", "DPC", true, false)]
+ [InlineData("IED/CSWI4.Pos", "DPC", true, false)]
+ [InlineData("IED/ATCC1.TapChg", "BSC", false, true)]
+ public void StatusOnly_DoesNotEraseDeclaredButtons_AndCommandsRemainBlocked(
+ string reference, string cdc, bool expectedPosition, bool expectedTap)
+ {
+ var signal = Control(reference, cdc);
+ Assert.True(signal.ControlModelResolved);
+ Assert.Equal(expectedPosition, signal.IsPositionControl);
+ Assert.Equal(expectedTap, signal.IsRaiseLowerControl);
+ Assert.False(signal.IsGenericControl);
+ Assert.False(signal.ControlSupportsOperate);
+ Assert.False(signal.TryStageControlConfirmation("Closed [10]", "Close", out _));
+ Assert.False(signal.TryBeginDirectControlCommand("Raise", "Raise", out _, out _));
+ signal.ControlModelText = "Auto-detect";
+ Assert.Equal(expectedPosition, signal.IsPositionControl);
+ Assert.Equal(expectedTap, signal.IsRaiseLowerControl);
+ Assert.False(signal.ControlSupportsOperate);
+ }
+
+ [Fact]
+ public void MismatchedCdc_RequiresExactStepStatus_NotTapChgNameHeuristics()
+ {
+ var control = Control("IED/ATCC1.TapChg", "SPC");
+ var onlyName = new List { control };
+ Assert.Equal(0, Iec61850ControlStatusEvidence.ApplyExactStepStatus(onlyName));
+ Assert.True(control.IsBooleanControl);
+
+ var exact = new SignalDefinition
+ {
+ ObjectReference = "IED/ATCC1.TapChg.valWTr.posVal",
+ FunctionalConstraint = "ST"
+ };
+ onlyName.Add(exact);
+ Assert.Equal(1, Iec61850ControlStatusEvidence.ApplyExactStepStatus(onlyName));
+ Assert.Equal("BSC", control.ControlCdc);
+ Assert.Equal(exact.ObjectReference, control.ControlStatusReference);
+ Assert.True(control.IsRaiseLowerControl);
+ Assert.False(control.ControlSupportsOperate);
+ Assert.Equal(0, Iec61850ControlStatusEvidence.ApplyExactStepStatus(onlyName));
+ }
+
+ [Fact]
+ public void SimilarOrWrongFunctionalConstraintStatus_CannotReclassifyCdc()
+ {
+ var control = Control("IED/ATCC1.TapChg", "SPC");
+ var neighbors = new List {
+ control,
+ new() { ObjectReference = "IED/ATCC2.TapChg.valWTr.posVal", FunctionalConstraint = "ST" },
+ new() { ObjectReference = "IED/ATCC1.TapChg.valWTr.posVal", FunctionalConstraint = "MX" }
+ };
+ Assert.Equal(0, Iec61850ControlStatusEvidence.ApplyExactStepStatus(neighbors));
+ Assert.Equal("SPC", control.ControlCdc);
+ }
+
+ [Fact]
+ public void DiagnosticsAndXamlKeepDisabledControlsVisibleAndExplainWhy()
+ {
+ var root = FindRepo();
+ var xaml = File.ReadAllText(Path.Combine(root, "MainWindow.xaml"));
+ var diagnostics = File.ReadAllText(Path.Combine(root, "Services", "DiagnosticReportBuilder.cs"));
+ Assert.Contains("Binding IsPositionControl", xaml);
+ Assert.Contains("Binding IsRaiseLowerControl", xaml);
+ Assert.Contains("IsEnabled=\"{Binding ControlSupportsOperate}\"", xaml);
+ // Detailed diagnostics belong to the model/evidence column and
+ // Diagnostic Report, never to a large hover popover over buttons.
+ Assert.Contains("", xaml);
+ var controlStart = xaml.IndexOf("", controlStart, StringComparison.Ordinal);
+ Assert.True(controlStart >= 0 && controlEnd > controlStart);
+ Assert.DoesNotContain("ToolTip=\"{Binding ControlInspectionEvidence}\"",
+ xaml[controlStart..controlEnd]);
+ Assert.Contains("No typed action", xaml);
+ Assert.Contains("Control catalog : selected=", diagnostics);
+ Assert.Contains("ControlInspectionEvidence", diagnostics);
+ }
+
+ private static string FindRepo()
+ {
+ DirectoryInfo? dir = new(AppContext.BaseDirectory);
+ while (dir != null)
+ {
+ if (File.Exists(Path.Combine(dir.FullName, "MainWindow.xaml"))) return dir.FullName;
+ dir = dir.Parent;
+ }
+ throw new DirectoryNotFoundException("ARSAS source root not found");
+ }
+}
diff --git a/tests/ARSAS.Tests/P109NControlAuthorityTimelineTests.cs b/tests/ARSAS.Tests/P109NControlAuthorityTimelineTests.cs
new file mode 100644
index 000000000..56ff19511
--- /dev/null
+++ b/tests/ARSAS.Tests/P109NControlAuthorityTimelineTests.cs
@@ -0,0 +1,177 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+///
+/// Real ZIV sequence: live typed BSC verified first, then a stale static DataSet
+/// announces SPC, then operator requests directional tap change. No network IO.
+///
+public sealed class P109NControlAuthorityTimelineTests
+{
+ private static SignalDefinition Control(string name, string cdc) => new()
+ {
+ Name = name[(name.LastIndexOf('.') + 1)..],
+ ObjectReference = name,
+ FunctionalConstraint = "CO",
+ IsControlSignal = true,
+ IsSelected = true,
+ ControlCdc = cdc
+ };
+
+ [Fact]
+ public void ZivLiveBscSurvivesStaticProjection_AndRetainsRaiseLower()
+ {
+ var tap = Control("ZIVAVRLD1/ATCC1.TapChg", "SPC");
+ tap.ControlModelText = "Select Before Operate (SBO) • Normal security";
+ tap.AcceptVerifiedLiveControlCdc("BSC");
+ tap.ControlSemanticVerified = true;
+ Assert.True(tap.ControlSupportsOperate);
+ Assert.True(tap.IsRaiseLowerControl);
+ Assert.False(tap.IsBooleanControl);
+
+ // Exact regression: static DataSet arrives AFTER verified control inspection.
+ var accepted = tap.ApplyDataSetAdvisoryControlMetadata(
+ "SPC", "ZIVAVRLD1/ATCC1.TapChg.valWTr.posVal");
+ Assert.False(accepted);
+ Assert.Equal("SPC", tap.DataSetAdvisoryCdc);
+ Assert.Equal("BSC", tap.VerifiedLiveControlCdc);
+ Assert.Equal("BSC", tap.ControlCdc);
+ Assert.True(tap.IsRaiseLowerControl);
+ Assert.False(tap.IsBooleanControl);
+ Assert.True(tap.ControlSupportsOperate);
+
+ // Second pre-existing producer also cannot replace typed live authority.
+ tap.ControlCdc = "SPC";
+ Assert.Equal("BSC", tap.ControlCdc);
+ Assert.True(tap.IsRaiseLowerControl);
+ }
+
+ [Fact]
+ public void LiveReadOnlyControlNeverBecomesOperableDueToStaticMetadata()
+ {
+ var pos = Control("ZIVAVRLD1/CSWI1.Pos", "DPC");
+ pos.ControlModelText = "Status only";
+ pos.ApplyDataSetAdvisoryControlMetadata("DPC", "ZIVAVRLD1/CSWI1.Pos.stVal");
+ Assert.True(pos.IsPositionControl);
+ Assert.False(pos.ControlSupportsOperate);
+ Assert.False(pos.TryStageControlConfirmation("Closed [10]", "Close", out _));
+ Assert.False(pos.TryBeginDirectControlCommand("Closed [10]", "Close", out _, out _));
+ }
+
+ [Fact]
+ public void NewAssociationResetsLiveAuthorityAndRequiresReinspection()
+ {
+ var tap = Control("ZIVAVRLD1/ATCC1.TapChg", "BSC");
+ tap.ControlModelText = "Select Before Operate (SBO) • Normal security";
+ tap.AcceptVerifiedLiveControlCdc("BSC");
+ tap.ControlSemanticVerified = true;
+ tap.ClearLiveControlAuthority();
+ tap.ControlModelText = "Auto-detect";
+ Assert.Equal(string.Empty, tap.VerifiedLiveControlCdc);
+ Assert.False(tap.ControlSupportsOperate);
+ Assert.False(tap.ControlModelResolved);
+ Assert.True(tap.ApplyDataSetAdvisoryControlMetadata("SPC", null));
+ Assert.Equal("SPC", tap.ControlCdc);
+ Assert.False(tap.ControlSupportsOperate);
+ }
+
+ [Fact]
+ public void LocalValidationMismatchHasExactClientDiagnostic_NotIEDDenial()
+ {
+ var failure = new Iec61850ControlCommandResult
+ {
+ CompletionState = "NotSent",
+ Stage = "Validation",
+ Message = "Unsafe or ambiguous IEC 61850 control semantics: Declared CDC 'SPC' conflicts with live CDC evidence 'BSC'."
+ };
+ var explanation = Iec61850ControlFailureReason.Explain(failure);
+ Assert.Equal("LocalValidation", explanation.Confidence);
+ Assert.Contains("SPC", explanation.Evidence);
+ Assert.Contains("BSC", explanation.Evidence);
+ Assert.Contains("no MMS control write", explanation.Checks);
+ var unrelatedDenied = new Iec61850ControlCommandResult {
+ CompletionState = "Rejected", Stage = "Operate",
+ Message = "MMS object-access-denied"
+ };
+ Assert.Equal("MmsServiceOnly", Iec61850ControlFailureReason.Explain(unrelatedDenied).Confidence);
+ }
+
+ [Fact]
+ public void AutomaticReconnectAlsoRevokesAllAssociationScopedControlAuthority()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(
+ dir.FullName, "Services", "Iec61850MonitorRuntime.cs")))
+ dir = dir.Parent;
+ Assert.NotNull(dir);
+ var source = File.ReadAllText(Path.Combine(
+ dir!.FullName, "Services", "Iec61850MonitorRuntime.cs"));
+ Assert.Contains("await ResetAssociationControlAuthorityAsync(session.Device).ConfigureAwait(false);", source);
+ Assert.Contains("dispatcher.InvokeAsync(", source);
+ var start = source.IndexOf("private async Task TryReconnectAsync(", StringComparison.Ordinal);
+ var end = source.IndexOf("private void ScheduleReconnectRetry(", start, StringComparison.Ordinal);
+ Assert.True(start > 0 && end > start);
+ var body = source[start..end];
+ Assert.True(body.IndexOf("await ResetAssociationControlAuthorityAsync(session.Device).ConfigureAwait(false);", StringComparison.Ordinal) <
+ body.IndexOf("ConnectUsingSelectedFastPathAsync(", StringComparison.Ordinal));
+ Assert.Contains("signal.ClearLiveControlAuthority();", source);
+ Assert.Contains("signal.ControlModelText = \"Auto-detect\";", source);
+ }
+
+ [Fact]
+ public void CompactDockKeepsFullLiveAreaAndActionButtonsWithinRows()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "MainWindow.xaml")))
+ dir = dir.Parent;
+ Assert.NotNull(dir);
+ var xaml = File.ReadAllText(Path.Combine(dir!.FullName, "MainWindow.xaml"));
+ var code = File.ReadAllText(Path.Combine(dir.FullName, "MainWindow.xaml.cs"));
+ Assert.DoesNotContain("ExplorerLiveRow", xaml);
+ Assert.DoesNotContain("ExplorerCommandRow", xaml);
+ Assert.DoesNotContain("ExplorerLiveRow.Height", code);
+ Assert.Contains("RowHeight=\"43\" MinRowHeight=\"42\"", xaml);
+ Assert.Contains("Height=\"29\" MinHeight=\"29\" Padding=\"10,3\"", xaml);
+ Assert.Contains("Content=\"Model\"", xaml);
+ Assert.Contains("CommandParameter=\"Raise\" Click=\"ControlStageAction_Click\"", xaml);
+ Assert.Contains("CommandParameter=\"Lower\" Click=\"ControlStageAction_Click\"", xaml);
+ }
+
+ [Fact]
+ public void ZivRaiseLowerRequiresExplicitConfirmBeforeCommandClaim()
+ {
+ var tap = Control("ZIVAVRLD1/ATCC1.TapChg", "BSC");
+ tap.ControlModelText = "Select Before Operate (SBO) • Normal security";
+ tap.AcceptVerifiedLiveControlCdc("BSC");
+ tap.ControlSemanticVerified = true;
+ Assert.True(tap.TryStageControlConfirmation("Raise", "Raise", out _));
+ Assert.True(tap.ControlConfirmationPending);
+ Assert.Equal("Confirm Raise", tap.ControlPendingConfirmationLabel);
+ Assert.False(tap.ControlCommandBusy);
+ tap.ClearControlConfirmation();
+ Assert.False(tap.ControlConfirmationPending);
+ Assert.False(tap.TryClaimControlConfirmation(out _, out _));
+ Assert.True(tap.TryStageControlConfirmation("Lower", "Lower", out _));
+ Assert.True(tap.TryClaimControlConfirmation(out var claim, out _));
+ Assert.NotNull(claim);
+ Assert.True(tap.ControlCommandBusy);
+ Assert.True(tap.CompleteControlCommand(claim!));
+ }
+
+ [Fact]
+ public void ProjectionProducerUsesAdvisoryApi_NotDirectCdcAssignment()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(
+ dir.FullName, "Services", "Iec61850StaticControlStatusProjectionService.cs")))
+ dir = dir.Parent;
+ Assert.NotNull(dir);
+ var source = File.ReadAllText(Path.Combine(
+ dir!.FullName, "Services", "Iec61850StaticControlStatusProjectionService.cs"));
+ var method = source[source.IndexOf("private static void LinkControlToMembership(", StringComparison.Ordinal)..];
+ method = method[..method.IndexOf("private static SignalDefinition CreateRuntimeFeedback(", StringComparison.Ordinal)];
+ Assert.Contains("control.ApplyDataSetAdvisoryControlMetadata(cdc, exactFeedbackReference)", method);
+ Assert.DoesNotContain("control.ControlCdc = cdc;", method);
+ }
+}
diff --git a/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs
new file mode 100644
index 000000000..c190f31c2
--- /dev/null
+++ b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs
@@ -0,0 +1,178 @@
+using System.IO;
+using ArIED61850Tester.Models;
+
+namespace ARSAS.Tests;
+
+public sealed class P109QCommandDockCommissioningRegressionTests
+{
+ private static string Read(string relative)
+ {
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ while (current != null)
+ {
+ var path = Path.Combine(current.FullName, relative);
+ if (File.Exists(path)) return File.ReadAllText(path);
+ current = current.Parent;
+ }
+ throw new FileNotFoundException(relative);
+ }
+
+ [Theory]
+ [InlineData("Raise")]
+ [InlineData("Lower")]
+ public void DirectionalControlMustRequireTwoIndependentUserClicks(string action)
+ {
+ var xaml = Read("MainWindow.xaml");
+ Assert.Contains($"CommandParameter=\"{action}\" Click=\"ControlStageAction_Click\"", xaml);
+ Assert.DoesNotContain($"CommandParameter=\"{action}\" Click=\"ControlQuickAction_Click\"", xaml);
+ Assert.Contains("Click=\"ControlConfirmAction_Click\"", xaml);
+ Assert.Contains("Click=\"ControlCancelAction_Click\"", xaml);
+ }
+
+ [Fact]
+ public void RaiseAndLowerUseSharedSemanticPalette_AndConfirmTracksPendingAction()
+ {
+ var xaml = Read("MainWindow.xaml");
+ var app = Read("App.xaml");
+ var start = xaml.IndexOf("", start, StringComparison.Ordinal);
+ Assert.True(start >= 0 && end > start);
+ var cell = xaml[start..end];
+
+ // Both the combined BSC and separate TapOpR/TapOpL paths must
+ // have equally prominent, distinct colored command surfaces.
+ Assert.Equal(2, cell.Split("Style=\"{StaticResource CommandRaiseButton}\"",
+ StringSplitOptions.None).Length - 1);
+ Assert.Equal(2, cell.Split("Style=\"{StaticResource CommandLowerButton}\"",
+ StringSplitOptions.None).Length - 1);
+ Assert.Contains("Style=\"{StaticResource CommandConfirmButton}\"", cell);
+ Assert.Contains("Style=\"{StaticResource CommandDockSoftButton}\"", cell); // Cancel
+
+ foreach (var resource in new[]
+ {
+ "CommandRaiseFill", "CommandRaiseStroke",
+ "CommandLowerFill", "CommandLowerStroke", "CommandLowerInk"
+ })
+ Assert.Contains($"x:Key=\"{resource}\"", app);
+
+ var confirmStart = app.IndexOf("