diff --git a/IoListTestingWindow.ClockSyncUx.cs b/IoListTestingWindow.ClockSyncUx.cs
index 9a22b81fe..2ca060789 100644
--- a/IoListTestingWindow.ClockSyncUx.cs
+++ b/IoListTestingWindow.ClockSyncUx.cs
@@ -7,10 +7,9 @@ namespace ArIED61850Tester;
public partial class IoListTestingWindow
{
- private CheckBox? _clockSyncCheckBox;
+ private TextBlock? _clockSyncGlobalStatusText;
private TextBlock? _clockSyncEvidenceText;
private MainWindow? _clockSyncSnapshotOwner;
- private bool _clockSyncCheckBoxRefreshing;
protected override void OnInitialized(EventArgs e)
{
@@ -21,9 +20,8 @@ protected override void OnInitialized(EventArgs e)
private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e)
{
- if (_clockSyncCheckBox != null)
+ if (_clockSyncGlobalStatusText != null)
{
- RefreshClockSyncCheckBox();
AttachClockSyncSnapshotOwner();
return;
}
@@ -35,18 +33,17 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e)
if (previewIndex < 0)
return;
- var checkBox = new CheckBox
+ var globalStatus = new TextBlock
{
- Name = "ClockSyncEnabledCheckBox",
- Content = "Clock Sync",
+ Name = "GlobalSntpStatusTextBlock",
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
Padding = new Thickness(2, 0, 2, 0),
FontSize = 11.2,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("Ink") as Brush ?? Brushes.DimGray,
- Focusable = false,
- ToolTip = "SNTP laptop → IED. Checked: ARSAS serves laptop time using normal UDP/123 when available, with an Npcap RAW fallback when Windows already owns UDP/123. Unchecked: ARSAS stops only its Clock Sync service. IEC 61850 remains unaffected."
+ Text = "Global SNTP",
+ ToolTip = "Global SNTP is controlled from the ARSAS header. It is not owned by FAT and continues running when the FAT window closes while the global server toggle remains enabled."
};
var evidence = new TextBlock
@@ -57,15 +54,12 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e)
FontSize = 10.4,
FontWeight = FontWeights.Medium,
Foreground = TryFindResource("MutedInk") as Brush ?? Brushes.SlateGray,
- Text = "Clock: waiting"
+ Text = "SNTP: waiting"
};
- _clockSyncCheckBox = checkBox;
+ _clockSyncGlobalStatusText = globalStatus;
_clockSyncEvidenceText = evidence;
- RefreshClockSyncCheckBox();
- checkBox.Checked += ClockSyncCheckBox_Changed;
- checkBox.Unchecked += ClockSyncCheckBox_Changed;
- actionPanel.Children.Insert(previewIndex + 1, checkBox);
+ actionPanel.Children.Insert(previewIndex + 1, globalStatus);
actionPanel.Children.Insert(previewIndex + 2, evidence);
AttachClockSyncSnapshotOwner();
}
@@ -100,28 +94,40 @@ private void ClockSyncSnapshotChanged(SntpClockServiceSnapshot snapshot)
private void RefreshClockSyncEvidence(SntpClockServiceSnapshot snapshot)
{
- if (_clockSyncEvidenceText == null)
+ if (_clockSyncEvidenceText == null || _clockSyncGlobalStatusText == null)
return;
+ var enabled = _clockSyncSnapshotOwner?.IsClockSyncEnabled == true;
var transport = snapshot.TransportMode switch
{
SntpClockTransportMode.NpcapRaw => "RAW",
SntpClockTransportMode.UdpSocket => "UDP",
_ => "—"
};
-
- _clockSyncEvidenceText.Text = snapshot.State switch
- {
- SntpClockServiceState.Serving =>
- $"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Reply {snapshot.ReplyCount} · sync not proven",
- SntpClockServiceState.Starting => "Clock: starting…",
- SntpClockServiceState.Stopped => "Clock: off",
- SntpClockServiceState.PortUnavailable => "Clock: unavailable",
- SntpClockServiceState.Faulted => "Clock: fault",
- _ => $"Clock: {snapshot.State}"
- };
-
- _clockSyncEvidenceText.ToolTip = BuildClockSyncEvidenceToolTip(snapshot, transport);
+ var localAddress = snapshot.Binding?.LocalAddress.ToString();
+
+ _clockSyncGlobalStatusText.Text = !enabled
+ ? "Global SNTP · Off"
+ : snapshot.State == SntpClockServiceState.Serving
+ ? $"Global SNTP · {localAddress ?? "Active"}"
+ : "Global SNTP · Enabled";
+
+ _clockSyncEvidenceText.Text = !enabled
+ ? "SNTP: off"
+ : snapshot.State switch
+ {
+ SntpClockServiceState.Serving =>
+ $"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Reply {snapshot.ReplyCount} · sync not proven",
+ SntpClockServiceState.Starting => "SNTP: starting…",
+ SntpClockServiceState.Stopped => "SNTP: waiting for connected IED",
+ SntpClockServiceState.PortUnavailable => "SNTP: unavailable",
+ SntpClockServiceState.Faulted => "SNTP: fault",
+ _ => $"SNTP: {snapshot.State}"
+ };
+
+ var toolTip = BuildClockSyncEvidenceToolTip(snapshot, transport, enabled);
+ _clockSyncGlobalStatusText.ToolTip = toolTip;
+ _clockSyncEvidenceText.ToolTip = toolTip;
_clockSyncEvidenceText.Foreground = snapshot.State switch
{
SntpClockServiceState.PortUnavailable or SntpClockServiceState.Faulted => Brushes.DarkOrange,
@@ -130,60 +136,28 @@ private void RefreshClockSyncEvidence(SntpClockServiceSnapshot snapshot)
};
}
- private static string BuildClockSyncEvidenceToolTip(SntpClockServiceSnapshot snapshot, string transport)
+ private static string BuildClockSyncEvidenceToolTip(
+ SntpClockServiceSnapshot snapshot,
+ string transport,
+ bool enabled)
{
var binding = snapshot.Binding == null ? "—" : snapshot.Binding.Summary;
var lastBroadcast = snapshot.LastBroadcastUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—";
var lastRequest = snapshot.LastRequestUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—";
var lastReply = snapshot.LastReplyUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—";
- return $"Clock Sync evidence\n" +
+ return $"Global ARSAS SNTP Server\n" +
+ $"Enabled: {enabled}\n" +
$"State: {snapshot.State}\n" +
$"Transport: {transport}\n" +
$"Binding: {binding}\n" +
$"Broadcast sent: {snapshot.BroadcastCount} (last {lastBroadcast})\n" +
$"Client request seen: {snapshot.ClientRequestCount} (last {lastRequest})\n" +
$"Mode 4 reply sent: {snapshot.ReplyCount} (last {lastReply})\n\n" +
+ "The global server is controlled from the ARSAS header and continues outside FAT while enabled. " +
"These counters prove packet activity only. ARSAS does not claim that the relay clock is synchronized without device-side evidence.\n\n" +
snapshot.Detail;
}
- private void RefreshClockSyncCheckBox()
- {
- if (_clockSyncCheckBox == null || Owner is not MainWindow mainWindow)
- return;
-
- _clockSyncCheckBoxRefreshing = true;
- try
- {
- _clockSyncCheckBox.IsChecked = mainWindow.IsClockSyncEnabled;
- }
- finally
- {
- _clockSyncCheckBoxRefreshing = false;
- }
- }
-
- private async void ClockSyncCheckBox_Changed(object sender, RoutedEventArgs e)
- {
- if (_clockSyncCheckBoxRefreshing ||
- _clockSyncCheckBox == null ||
- Owner is not MainWindow mainWindow)
- return;
-
- var requested = _clockSyncCheckBox.IsChecked == true;
- _clockSyncCheckBox.IsEnabled = false;
- try
- {
- await mainWindow.SetClockSyncEnabledAsync(requested);
- RefreshClockSyncCheckBox();
- RefreshClockSyncEvidence(mainWindow.ClockSyncSnapshot);
- }
- finally
- {
- _clockSyncCheckBox.IsEnabled = true;
- }
- }
-
private void ClockSyncUx_Closed(object? sender, EventArgs e)
{
if (_clockSyncSnapshotOwner != null)
diff --git a/IoListTestingWindow.P0Lifecycle.cs b/IoListTestingWindow.P0Lifecycle.cs
new file mode 100644
index 000000000..6bb04a16a
--- /dev/null
+++ b/IoListTestingWindow.P0Lifecycle.cs
@@ -0,0 +1,148 @@
+using System.ComponentModel;
+using System.IO;
+using System.Windows;
+
+namespace ArIED61850Tester;
+
+///
+/// P0 responsiveness guard for the FAT workspace lifecycle.
+///
+/// The legacy Closing handler flushed workspace persistence synchronously on the WPF
+/// Dispatcher. Keep confirmation and evidence-session state transitions on the Dispatcher
+/// (the session controllers publish UI-bound PropertyChanged notifications there), then move
+/// only the durable workspace save to a worker thread. Native IEC 61850 teardown is handled
+/// independently by the responsive runtime facade.
+///
+public partial class IoListTestingWindow
+{
+ private static readonly bool P0LifecycleRegistered = RegisterP0Lifecycle();
+
+ private bool _p0LifecycleInstalled;
+ private bool _p0CloseInProgress;
+ private bool _p0AllowClose;
+
+ private static bool RegisterP0Lifecycle()
+ {
+ // Avoid another Window lifecycle override in this already heavily split partial class.
+ // Loaded is a routed event, so one class handler can install the P0 Closing handler on
+ // every FAT workspace instance after XAML has wired its legacy Window_Closing handler.
+ EventManager.RegisterClassHandler(
+ typeof(IoListTestingWindow),
+ FrameworkElement.LoadedEvent,
+ new RoutedEventHandler(P0Lifecycle_Loaded));
+ return true;
+ }
+
+ private static void P0Lifecycle_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is IoListTestingWindow window)
+ window.InstallP0Lifecycle();
+ }
+
+ private void InstallP0Lifecycle()
+ {
+ if (_p0LifecycleInstalled)
+ return;
+
+ _p0LifecycleInstalled = true;
+
+ // XAML wires Window_Closing during InitializeComponent. Replace only that lifecycle
+ // edge; all existing OnClosed cleanup remains intact.
+ Closing -= Window_Closing;
+ Closing += P0Window_Closing;
+ }
+
+ private async void P0Window_Closing(object? sender, CancelEventArgs e)
+ {
+ if (_p0AllowClose)
+ return;
+
+ e.Cancel = true;
+ if (_p0CloseInProgress)
+ return;
+
+ if (IsPreparingIed)
+ {
+ var activeNames = string.Join(", ", Project.Ieds
+ .Where(ied => ied.IsPreparing)
+ .Select(ied => ied.IedName));
+ MessageBox.Show(
+ this,
+ $"ARSAS is still preparing {activeNames}. You can inspect or connect other IEDs while these independent workflows run, but finish preparation before closing this workspace.",
+ "IED preparation in progress",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ return;
+ }
+
+ if (Session.HasActiveSessions)
+ {
+ var activeCount = Session.ActiveSessionCount;
+ var answer = MessageBox.Show(
+ this,
+ $"{activeCount} FAT evidence session(s) are active. Returning to Engineering will stop every active IED session, seal each independent evidence journal, and save the current project progress.\n\nStop all sessions and return?",
+ "Stop active FAT sessions",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning,
+ MessageBoxResult.No);
+ if (answer != MessageBoxResult.Yes)
+ return;
+ }
+
+ _p0CloseInProgress = true;
+ IsEnabled = false;
+
+ try
+ {
+ if (Session.HasActiveSessions)
+ {
+ // Session.StopAll mutates controller/project state and raises UI-bound
+ // PropertyChanged notifications. Keep that state transition on Dispatcher;
+ // offloading the whole coordinator would create a cross-thread WPF defect.
+ var stopAll = Session.StopAll(
+ "Workspace closed by operator; per-IED evidence journal sealed.");
+ if (!stopAll.Succeeded)
+ {
+ MessageBox.Show(
+ this,
+ stopAll.Message,
+ "Evidence journals could not be sealed",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ return;
+ }
+ }
+
+ // Persistence is pure durable I/O after the session state is sealed and is the
+ // portion that must not occupy the WPF Dispatcher.
+ if (Storage != null)
+ await Task.Run(Storage.SaveNow);
+
+ _p0AllowClose = true;
+ Close();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
+ {
+ var answer = MessageBox.Show(
+ this,
+ $"ARSAS could not save the latest IO FAT progress.\n\n{ex.Message}\n\nClose the workspace anyway?",
+ "Progress save failed",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Error,
+ MessageBoxResult.No);
+ if (answer == MessageBoxResult.Yes)
+ {
+ _p0AllowClose = true;
+ Close();
+ }
+ }
+ finally
+ {
+ if (!_p0AllowClose)
+ {
+ _p0CloseInProgress = false;
+ IsEnabled = true;
+ }
+ }
+ }
+}
diff --git a/IpConnectWizardWindow.xaml b/IpConnectWizardWindow.xaml
index 892f0445d..0fcc5a0f4 100644
--- a/IpConnectWizardWindow.xaml
+++ b/IpConnectWizardWindow.xaml
@@ -35,9 +35,9 @@
-
+ Text="{Binding RelayIpAddress, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=LostFocus}"/>
diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs
index aff2da34c..af2c2a58d 100644
--- a/MainWindow.ClockSync.cs
+++ b/MainWindow.ClockSync.cs
@@ -24,18 +24,27 @@ private void InitializeClockSyncLifecycle()
return;
_clockSyncLifecycleAttached = true;
- Devices.CollectionChanged += ClockSyncDevices_CollectionChanged;
- foreach (var device in Devices)
- AttachClockSyncDevice(device);
+ InstallGlobalSntpToggle();
+
+ if (_clockSyncEnabled)
+ {
+ Devices.CollectionChanged += ClockSyncDevices_CollectionChanged;
+ foreach (var device in Devices)
+ AttachClockSyncDevice(device);
+ }
_sntpClockService.StatusChanged += ClockSyncService_StatusChanged;
_sntpClockService.ClientRequestObserved += ClockSyncService_ClientRequestObserved;
_sntpClockService.ReplySent += ClockSyncService_ReplySent;
Closed += ClockSyncMainWindow_Closed;
+ PublishGlobalSntpUiState();
}
private void ClockSyncDevices_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
+ if (!_clockSyncEnabled)
+ return;
+
if (e.OldItems != null)
{
foreach (var item in e.OldItems.OfType())
@@ -51,6 +60,9 @@ private void ClockSyncDevices_CollectionChanged(object? sender, NotifyCollection
private void AttachClockSyncDevice(Iec61850MonitorDevice device)
{
+ if (!_clockSyncEnabled)
+ return;
+
device.PropertyChanged -= ClockSyncDevice_PropertyChanged;
device.PropertyChanged += ClockSyncDevice_PropertyChanged;
@@ -60,16 +72,21 @@ private void AttachClockSyncDevice(Iec61850MonitorDevice device)
private void ClockSyncDevice_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (e.PropertyName != nameof(Iec61850MonitorDevice.IsConnected) ||
- sender is not Iec61850MonitorDevice device ||
- !device.IsConnected)
+ if (!_clockSyncEnabled || sender is not Iec61850MonitorDevice device || !device.IsConnected)
return;
- ScheduleClockSyncReconcile(device);
+ if (e.PropertyName == nameof(Iec61850MonitorDevice.IsConnected) ||
+ e.PropertyName == nameof(Iec61850MonitorDevice.IpAddress))
+ {
+ ScheduleClockSyncReconcile(device);
+ }
}
private void ScheduleClockSyncReconcile(Iec61850MonitorDevice device)
{
+ if (!_clockSyncEnabled)
+ return;
+
if (!Dispatcher.CheckAccess())
{
Dispatcher.BeginInvoke(new Action(() => ScheduleClockSyncReconcile(device)));
@@ -81,13 +98,19 @@ private void ScheduleClockSyncReconcile(Iec61850MonitorDevice device)
private async Task EnsureClockSyncForDeviceAsync(Iec61850MonitorDevice device)
{
- if (!IPAddress.TryParse(device.IpAddress, out var iedAddress) ||
+ if (!_clockSyncEnabled || !device.IsConnected ||
+ !IPAddress.TryParse(device.IpAddress, out var iedAddress) ||
iedAddress.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
return;
await _clockSyncIntegrationGate.WaitAsync();
try
{
+ // Re-check after entering the integration gate so a queued connect event cannot
+ // restart SNTP after the operator has switched the global toggle off.
+ if (!_clockSyncEnabled || !device.IsConnected)
+ return;
+
await _sntpClockService.EnsureStartedAsync(iedAddress, _applicationCancellation.Token);
_sntpClockService.RequestImmediateBroadcast();
}
@@ -96,8 +119,8 @@ private async Task EnsureClockSyncForDeviceAsync(Iec61850MonitorDevice device)
}
catch (Exception ex)
{
- AddLog("WARN", "Clock Sync",
- $"{device.Name}: IEC 61850 remains connected, but ARSAS SNTP could not start: {ex.Message}");
+ AddLog("WARN", "SNTP Server",
+ $"{device.Name}: IEC 61850 remains connected, but the global ARSAS SNTP Server could not start: {ex.Message}");
}
finally
{
@@ -109,8 +132,9 @@ private void ClockSyncService_StatusChanged(SntpClockServiceSnapshot snapshot)
{
void Publish()
{
- // FAT telemetry must receive every evidence-counter change even when the textual
- // service detail did not change. The live log remains deduplicated separately.
+ // Global telemetry receives every evidence-counter change even when the textual
+ // service detail did not change. FAT is only one passive consumer of this state.
+ RefreshGlobalSntpToggle(snapshot);
ClockSyncSnapshotChanged?.Invoke(snapshot);
var status = $"{snapshot.State}|{snapshot.TransportMode}|{snapshot.Detail}";
@@ -125,7 +149,7 @@ void Publish()
SntpClockServiceState.Stopped => "INFO",
_ => "WARN"
};
- AddLog(level, "Clock Sync", snapshot.Detail);
+ AddLog(level, "SNTP Server", snapshot.Detail);
}
if (Dispatcher.CheckAccess())
@@ -146,7 +170,7 @@ void Publish()
var device = Devices.FirstOrDefault(item =>
item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase));
var name = device?.Name ?? key;
- AddLog("INFO", "Clock Sync",
+ AddLog("INFO", "SNTP Server",
$"{name} ({key}) sent an SNTPv{observation.Version} client request to ARSAS. Request observed; synchronization is not yet proven.");
}
@@ -169,7 +193,7 @@ void Publish()
item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase));
var name = device?.Name ?? key;
var transport = observation.TransportMode == SntpClockTransportMode.NpcapRaw ? "Npcap RAW" : "UDP";
- AddLog("INFO", "Clock Sync",
+ AddLog("INFO", "SNTP Server",
$"{name} ({key}) received an ARSAS SNTP Mode 4 reply via {transport}. Reply sent; relay clock synchronization remains unproven until device evidence confirms it.");
}
diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs
index d9ed36b99..39598e3b3 100644
--- a/MainWindow.ClockSyncToggle.cs
+++ b/MainWindow.ClockSyncToggle.cs
@@ -1,19 +1,115 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Media;
+using System.Windows.Shapes;
using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
namespace ArIED61850Tester;
public partial class MainWindow
{
+ // Keep the existing commissioning default for compatibility, but make the state explicit
+ // in the global ARSAS chrome. The service is application-scoped: FAT never owns it.
private bool _clockSyncEnabled = true;
+ private ToggleButton? _globalSntpToggle;
+ private Ellipse? _globalSntpStateDot;
+ private TextBlock? _globalSntpCaption;
+ private bool _globalSntpToggleRefreshing;
internal bool IsClockSyncEnabled => _clockSyncEnabled;
+ private void InstallGlobalSntpToggle()
+ {
+ if (_globalSntpToggle != null || WorkflowNavShell.Parent is not Grid headerGrid)
+ return;
+
+ var stateDot = new Ellipse
+ {
+ Width = 8,
+ Height = 8,
+ Margin = new Thickness(0, 0, 8, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ Fill = Brushes.SlateGray
+ };
+ var caption = new TextBlock
+ {
+ Text = "SNTP Server",
+ FontSize = 11.3,
+ FontWeight = FontWeights.SemiBold,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ };
+ var content = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+ content.Children.Add(stateDot);
+ content.Children.Add(caption);
+
+ var toggle = new ToggleButton
+ {
+ Name = "GlobalSntpServerToggle",
+ MinWidth = 282,
+ MaxWidth = 340,
+ Height = 38,
+ Margin = new Thickness(12, 0, 0, 0),
+ Padding = new Thickness(12, 0, 12, 0),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ BorderThickness = new Thickness(1),
+ Background = new SolidColorBrush(Color.FromRgb(244, 247, 251)),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 234)),
+ Foreground = new SolidColorBrush(Color.FromRgb(82, 103, 126)),
+ Focusable = false,
+ IsThreeState = false,
+ Content = content
+ };
+
+ toggle.Checked += GlobalSntpToggle_Changed;
+ toggle.Unchecked += GlobalSntpToggle_Changed;
+ Grid.SetColumn(toggle, 2);
+ headerGrid.Children.Add(toggle);
+
+ _globalSntpToggle = toggle;
+ _globalSntpStateDot = stateDot;
+ _globalSntpCaption = caption;
+ RefreshGlobalSntpToggle(_sntpClockService.Snapshot);
+ }
+
+ private async void GlobalSntpToggle_Changed(object sender, RoutedEventArgs e)
+ {
+ if (_globalSntpToggleRefreshing || _globalSntpToggle == null)
+ return;
+
+ _globalSntpToggle.IsEnabled = false;
+ try
+ {
+ await SetClockSyncEnabledAsync(_globalSntpToggle.IsChecked == true);
+ }
+ finally
+ {
+ _globalSntpToggle.IsEnabled = true;
+ }
+ }
+
internal async Task SetClockSyncEnabledAsync(bool enabled)
{
if (_clockSyncEnabled == enabled)
+ {
+ if (enabled)
+ _sntpClockService.RequestImmediateBroadcast();
+ PublishGlobalSntpUiState();
return;
+ }
_clockSyncEnabled = enabled;
+ PublishGlobalSntpUiState();
if (!enabled)
{
@@ -27,19 +123,20 @@ internal async Task SetClockSyncEnabledAsync(bool enabled)
await _sntpClockService.StopAsync();
_clockSyncObservedClients.Clear();
_clockSyncRepliedClients.Clear();
- AddLog("INFO", "Clock Sync",
- "Clock Sync disabled from the FAT workspace. IEC 61850 monitoring remains active.");
+ AddLog("INFO", "SNTP Server",
+ "Global SNTP Server disabled. IEC 61850 monitoring and FAT sessions remain active.");
}
catch (Exception ex)
{
- AddLog("WARN", "Clock Sync",
- $"Clock Sync disable requested, but the SNTP service reported: {ex.Message}");
+ AddLog("WARN", "SNTP Server",
+ $"Global SNTP Server disable requested, but the clock service reported: {ex.Message}");
}
finally
{
_clockSyncIntegrationGate.Release();
}
+ PublishGlobalSntpUiState();
return;
}
@@ -48,7 +145,118 @@ internal async Task SetClockSyncEnabledAsync(bool enabled)
foreach (var device in Devices)
AttachClockSyncDevice(device);
- AddLog("INFO", "Clock Sync",
- "Clock Sync enabled from the FAT workspace. ARSAS will serve connected IPv4 IEDs using normal UDP/123 when available and an Npcap RAW fallback when Windows already owns that port. Windows Time is never stopped or reconfigured.");
+ AddLog("INFO", "SNTP Server",
+ "Global SNTP Server enabled. It remains active independently of FAT while ARSAS is running. Connected IPv4 IEDs select the station-bus interface; ARSAS serves UDP/123 and broadcasts Mode 5 time, with Npcap RAW fallback when Windows already owns UDP/123. Windows Time is never stopped or reconfigured.");
+ PublishGlobalSntpUiState();
+ }
+
+ private void PublishGlobalSntpUiState()
+ {
+ var snapshot = _sntpClockService.Snapshot;
+
+ void Publish()
+ {
+ RefreshGlobalSntpToggle(snapshot);
+ ClockSyncSnapshotChanged?.Invoke(snapshot);
+ }
+
+ if (Dispatcher.CheckAccess())
+ Publish();
+ else
+ Dispatcher.BeginInvoke(new Action(Publish));
+ }
+
+ private void RefreshGlobalSntpToggle(SntpClockServiceSnapshot snapshot)
+ {
+ if (_globalSntpToggle == null || _globalSntpCaption == null || _globalSntpStateDot == null)
+ return;
+
+ _globalSntpToggleRefreshing = true;
+ try
+ {
+ _globalSntpToggle.IsChecked = _clockSyncEnabled;
+ }
+ finally
+ {
+ _globalSntpToggleRefreshing = false;
+ }
+
+ var localAddress = snapshot.Binding?.LocalAddress.ToString();
+ var isServing = _clockSyncEnabled && snapshot.State == SntpClockServiceState.Serving;
+ var isStarting = _clockSyncEnabled && snapshot.State == SntpClockServiceState.Starting;
+ var isFault = _clockSyncEnabled && snapshot.State is SntpClockServiceState.Faulted or SntpClockServiceState.PortUnavailable;
+
+ _globalSntpCaption.Text = !_clockSyncEnabled
+ ? "SNTP Server Off"
+ : isServing
+ ? $"SNTP Server Active: {localAddress ?? "station bus"}"
+ : isStarting
+ ? "SNTP Server Starting…"
+ : isFault
+ ? "SNTP Server Attention"
+ : "SNTP Server Enabled · waiting for IED";
+
+ if (isServing)
+ {
+ _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(236, 253, 245));
+ _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(167, 243, 208));
+ _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(4, 120, 87));
+ _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(16, 185, 129));
+ }
+ else if (isFault)
+ {
+ _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(255, 248, 230));
+ _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(242, 210, 138));
+ _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(148, 98, 0));
+ _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(245, 158, 11));
+ }
+ else if (_clockSyncEnabled)
+ {
+ _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(238, 244, 255));
+ _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(201, 217, 241));
+ _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(69, 100, 142));
+ _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(47, 128, 237));
+ }
+ else
+ {
+ _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(244, 247, 251));
+ _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 234));
+ _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(82, 103, 126));
+ _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(148, 163, 184));
+ }
+
+ _globalSntpToggle.ToolTip = BuildGlobalSntpToolTip(snapshot);
+ }
+
+ private string BuildGlobalSntpToolTip(SntpClockServiceSnapshot snapshot)
+ {
+ var binding = snapshot.Binding;
+ var local = binding?.LocalAddress.ToString() ?? "waiting for first connected IPv4 IED";
+ var broadcast = binding?.DirectedBroadcast?.ToString() ?? "—";
+ var adapter = binding?.InterfaceName ?? "—";
+ var transport = snapshot.TransportMode switch
+ {
+ SntpClockTransportMode.UdpSocket => "UDP/123",
+ SntpClockTransportMode.NpcapRaw => "Npcap RAW",
+ _ => "—"
+ };
+ var lastBroadcast = snapshot.LastBroadcastUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—";
+ var lastRequest = snapshot.LastRequestUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—";
+ var lastReply = snapshot.LastReplyUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—";
+
+ return $"Global ARSAS SNTP Server\n" +
+ $"Enabled: {_clockSyncEnabled}\n" +
+ $"State: {snapshot.State}\n" +
+ $"Local server IP: {local}\n" +
+ $"Station-bus adapter: {adapter}\n" +
+ $"Directed broadcast: {broadcast}\n" +
+ $"Transport: {transport}\n" +
+ $"Mode 5 broadcasts: {snapshot.BroadcastCount} (last {lastBroadcast})\n" +
+ $"Client requests observed: {snapshot.ClientRequestCount} (last {lastRequest})\n" +
+ $"Mode 4 replies sent: {snapshot.ReplyCount} (last {lastReply})\n\n" +
+ "When enabled, this service is global and continues outside FAT until disabled or ARSAS closes. " +
+ "A sent broadcast or reply proves SNTP packet activity, not that an IED accepted or locked its internal clock. " +
+ "Relay/event timestamps are trustworthy only after device-side synchronization evidence confirms the clock.\n\n" +
+ snapshot.Detail;
}
}
diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs
index 7ace02f5f..cc05bd11d 100644
--- a/MainWindow.IoTesting.AutoConnect.cs
+++ b/MainWindow.IoTesting.AutoConnect.cs
@@ -13,6 +13,11 @@ public partial class MainWindow
/// Prepares one imported IO-list IED for monitoring-only FAT acquisition. Calls for
/// different IEDs are independent and may overlap; the IED model itself owns the
/// preparation flag, while live binding is deliberately scoped to this IED only.
+ ///
+ /// For an SCL workspace already owned by Engineering, FAT is a consumer of the
+ /// existing per-IED acquisition session. It must never stop/restart that session merely
+ /// to change evidence scope or polling cadence. Legacy workbook-only FAT remains on the
+ /// compatibility acquisition path until it is migrated to the same shared-session model.
///
internal async Task PrepareIoTestIedForFatAsync(
IoTestProject project,
@@ -103,11 +108,21 @@ void ReportProgress(string message)
// connection path can then perform only TCP/ACSE/MMS association; Re-scan remains
// the explicit engineering action for full live discovery/comparison.
var hasSclRuntimeAuthority = AttachIoFatSclRuntimeAuthority(ied, device);
-
- // Monitoring-only toward the process: no control commands are executed. FAT uses
- // deterministic fast MMS acquisition for digital commissioning points while the
- // normal engineering workspace keeps report-first behavior for ordinary intervals.
- device.AllowDynamicDataSetWrites = true;
+ var sharedStaticDataSetAuthority =
+ hasSclRuntimeAuthority && IsSharedStaticDataSetAuthority(device);
+ var reuseSharedSclAcquisition =
+ hasSclRuntimeAuthority &&
+ (_sharedSclSelectionAuthorityDeviceIds.Contains(device.DeviceId) ||
+ sharedStaticDataSetAuthority);
+
+ // Static DataSet is an operator acquisition contract, not a FAT-local preference.
+ // Never turn dynamic DataSet writes back on while FAT attaches to that shared
+ // Engineering session. Legacy/non-shared FAT keeps its historical compatibility
+ // behavior for now.
+ if (sharedStaticDataSetAuthority)
+ Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device);
+ else if (!reuseSharedSclAcquisition)
+ device.AllowDynamicDataSetWrites = true;
try
{
@@ -186,7 +201,9 @@ void ReportProgress(string message)
// pass and used to hold this SIPROTEC FAT preparation for about one minute.
// Explicit Re-scan remains the design-versus-live comparison action.
IoTestReconciliationCache.Invalidate(device);
- ReportProgress("Using authoritative SCL workspace identity · starting live acquisition");
+ ReportProgress(reuseSharedSclAcquisition
+ ? "Using shared Engineering SCL identity · FAT will consume the existing acquisition session"
+ : "Using authoritative SCL workspace identity · starting live acquisition");
}
else
{
@@ -289,9 +306,9 @@ void ReportProgress(string message)
device.DeviceId);
}
- // P1 acquisition scope is the safe model match set, not the operator TEST
- // selection. Keep Engineering selection and FAT TEST untouched while arming
- // all proven shared-workspace-selected rows for this isolated IED runtime.
+ // FAT evidence scope is a consumer-side projection. It must not redefine the
+ // SCL acquisition session owned by Engineering. For legacy/non-shared FAT this
+ // list is still used by the compatibility monitor path below.
var acquisitionSignals = selection.Matches
.Select(match => match.Signal)
.Where(signal => signal.CanPublishToRuntime)
@@ -317,13 +334,15 @@ void ReportProgress(string message)
match.Signal.ObjectReference);
}
- // Time synchronization is device-level FAT evidence rather than an ON/OFF
- // test point. If the live model exposes an explicit status (for example
- // SIPROTEC TimeSynchrnz or MiCOM LLN0.SyncSt), arm that one extra read-only
- // signal so the FAT window can capture the real IED value automatically.
- IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device);
+ // Time-sync evidence must not contaminate a strict Static DataSet selection.
+ // If the status point is already part of the shared session we can consume it;
+ // otherwise SNTP/clock evidence remains device-level and FAT must not add a
+ // process point that would force an acquisition restart.
+ if (!reuseSharedSclAcquisition)
+ IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device);
var timeSyncSignal = IoFatSupplementalEvidenceService.FindTimeSyncSignal(device);
- if (timeSyncSignal?.CanPublishToRuntime == true &&
+ if (!reuseSharedSclAcquisition &&
+ timeSyncSignal?.CanPublishToRuntime == true &&
acquisitionSignals.All(signal => !ReferenceEquals(signal, timeSyncSignal)))
{
acquisitionSignals.Add(timeSyncSignal);
@@ -334,52 +353,88 @@ void ReportProgress(string message)
RaiseWorkspaceCounts();
_ioTestLiveBindingService.BindIed(ied, Devices);
- var fatPollingNotActive = device.IsMonitoring &&
- device.Points.Any(point => point.PollingIntervalMs > 500);
- var requestedAcquisitionReferences = acquisitionSignals
- .Select(signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference))
- .Where(reference => reference.Length > 0)
- .ToHashSet(StringComparer.OrdinalIgnoreCase);
- var activeAcquisitionReferences = device.Points
- .Select(point => IoTestLiveBindingService.NormalizeReference(point.IecReference))
- .Where(reference => reference.Length > 0)
- .ToHashSet(StringComparer.OrdinalIgnoreCase);
- var acquisitionScopeChanged =
- !requestedAcquisitionReferences.SetEquals(activeAcquisitionReferences);
-
- // Each IED monitor is isolated. Refresh only this device when its acquisition
- // scope or FAT polling cadence changed; another connected/monitoring IED is
- // never stopped or rebound by this preparation.
- if (device.IsMonitoring && (acquisitionScopeChanged || fatPollingNotActive))
+
+ if (reuseSharedSclAcquisition)
{
- ReportProgress("Refreshing this IED's FAT acquisition · other IED monitors remain active");
- await StopDeviceMonitorAsync(device);
- }
+ // P0 lifecycle rule: one IED owns one acquisition session. FAT is only a
+ // consumer. It must not call StopDeviceMonitorAsync, StartIoFatDeviceMonitorAsync,
+ // or alter polling cadence merely because its evidence scope differs.
+ if (sharedStaticDataSetAuthority)
+ Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device);
+
+ if (!device.IsMonitoring)
+ {
+ ReportProgress(sharedStaticDataSetAuthority
+ ? "Starting the shared Static DataSet acquisition session · FAT will subscribe to it"
+ : "Starting the shared Engineering acquisition session · FAT will subscribe to it");
+ if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false))
+ {
+ _ioTestLiveBindingService.BindIed(ied, Devices);
+ return IoTestSessionActionResult.Failure(
+ $"{ied.IedName} connected, but ARSAS could not start the shared Engineering acquisition session required by FAT.");
+ }
+ }
+ else
+ {
+ ReportProgress("FAT attached to the existing shared acquisition session · no monitor restart");
+ AddLog(
+ "INFO",
+ "IO Testing",
+ $"{ied.IedName}: FAT attached as a consumer of the existing Engineering acquisition session. Session ownership, RCB state and acquisition mode were not restarted or replaced.");
+ }
- if (!device.IsMonitoring)
+ if (sharedStaticDataSetAuthority)
+ {
+ AddLog(
+ "INFO",
+ device.Name,
+ "Static DataSet lifecycle evidence: configured RCB control-plane setup includes GI; waiting for actual InformationReport traffic. FAT will not use MMS process polling as a substitute.");
+ _ = ObserveSharedStaticReportEvidenceAsync(device, TimeSpan.FromSeconds(3));
+ }
+ }
+ else
{
- ReportProgress("Starting independent FAT live acquisition · fast MMS verification");
- // Compatibility note: the pre-P1 source contract called StartDeviceMonitorAsync
- // here. P1 routes through StartIoFatDeviceMonitorAsync so acquisition scope can
- // be armed without mutating/persisting the operator TEST selection.
- if (!await StartIoFatDeviceMonitorAsync(device, acquisitionSignals))
+ var fatPollingNotActive = device.IsMonitoring &&
+ device.Points.Any(point => point.PollingIntervalMs > 500);
+ var requestedAcquisitionReferences = acquisitionSignals
+ .Select(signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference))
+ .Where(reference => reference.Length > 0)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var activeAcquisitionReferences = device.Points
+ .Select(point => IoTestLiveBindingService.NormalizeReference(point.IecReference))
+ .Where(reference => reference.Length > 0)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var acquisitionScopeChanged =
+ !requestedAcquisitionReferences.SetEquals(activeAcquisitionReferences);
+
+ // Compatibility path for non-shared/legacy FAT only. Shared SCL workspaces
+ // never enter this stop/restart branch.
+ if (device.IsMonitoring && (acquisitionScopeChanged || fatPollingNotActive))
{
- _ioTestLiveBindingService.BindIed(ied, Devices);
- return IoTestSessionActionResult.Failure(
- $"{ied.IedName} connected, but ARSAS could not start live acquisition for the imported FAT scope.");
+ ReportProgress("Refreshing this legacy FAT IED acquisition · other IED monitors remain active");
+ await StopDeviceMonitorAsync(device);
+ }
+
+ if (!device.IsMonitoring)
+ {
+ ReportProgress("Starting legacy FAT live acquisition · fast MMS verification");
+ if (!await StartIoFatDeviceMonitorAsync(device, acquisitionSignals))
+ {
+ _ioTestLiveBindingService.BindIed(ied, Devices);
+ return IoTestSessionActionResult.Failure(
+ $"{ied.IedName} connected, but ARSAS could not start live acquisition for the imported FAT scope.");
+ }
}
}
- // The monitor points now exist and MMS polling has already been scheduled.
- // Return control to FAT immediately: waiting here for values/report setup made
- // a sub-second SCL association look like a 30-50 second connection whenever
- // the UI dispatcher was busy with the first report burst. Values and report
- // optimization continue through the shared Engineering runtime.
+ // Bind FAT rows to the already-owned per-IED monitor points. In a shared SCL
+ // workspace this is purely a consumer projection; it does not create another
+ // runtime or change the acquisition method.
var binding = _ioTestLiveBindingService.BindIed(ied, Devices);
var acquisition = ReadIoFatAcquisitionSummary(requestedPoints, device);
- var liveCount = requestedPoints.Count(point =>
+ var boundCount = requestedPoints.Count(point =>
point.LiveBindingState == IoTestLiveBindingState.LivePointReady);
- if (liveCount == 0)
+ if (boundCount == 0)
{
var unresolved = requestedPoints
.Take(4)
@@ -388,28 +443,31 @@ void ReportProgress(string message)
$"{ied.IedName} is connected and monitoring, but none of the {requestedPoints.Count} requested FAT signal(s) has a unique live monitor point. Unresolved: {string.Join(", ", unresolved)}.");
}
- // The temporary acquisition arm has already been restored by
- // StartIoFatDeviceMonitorAsync. Persist only the real operator selection.
SaveSignalSelectionMemory(device);
var modelText = hasSclRuntimeAuthority
? "imported SCL model"
: usedSavedModel ? "saved model" : "live model";
- var acquisitionText = acquisition.PollingCount == 0
- ? $"report-backed {acquisition.ReportCount}/{liveCount}"
- : $"fast MMS {acquisition.PollingCount} · report-backed {acquisition.ReportCount}";
+ var acquisitionText = sharedStaticDataSetAuthority
+ ? $"static report live {acquisition.ReportCount} · pending/unavailable {acquisition.UnknownCount} · MMS polling {acquisition.PollingCount}"
+ : acquisition.PollingCount == 0
+ ? $"report-backed {acquisition.ReportCount}/{boundCount}"
+ : $"fast MMS {acquisition.PollingCount} · report-backed {acquisition.ReportCount}";
var timeSyncText = IoFatSupplementalEvidenceService.FindTimeSyncSignal(device) == null
? " · time-sync fallback ready"
- : " · time-sync status armed";
- var unresolvedCount = requestedPoints.Count - liveCount;
+ : reuseSharedSclAcquisition
+ ? " · time-sync evidence observed from shared scope only"
+ : " · time-sync status armed";
+ var unresolvedCount = requestedPoints.Count - boundCount;
var partialText = unresolvedCount == 0
? string.Empty
: $" · {unresolvedCount} FAT row(s) waiting for safe live binding";
- var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {acquisitionText}{timeSyncText}{partialText}";
+ var sessionText = reuseSharedSclAcquisition ? " · shared acquisition session" : string.Empty;
+ var message = $"{ied.IedName} · {boundCount}/{requestedPoints.Count} bound · {acquisitionText}{timeSyncText}{partialText}{sessionText}";
SetStatus(message);
AddLog(
unresolvedCount == 0 ? "INFO" : "WARN",
"IO Testing",
- $"{message}. Live rows are usable immediately; unresolved rows stay visible but are excluded from the active evidence scope until a unique live point exists. No checkbox or FAT disposition is changed by the engine. IED live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}.");
+ $"{message}. FAT is an evidence consumer; shared SCL acquisition ownership is preserved. No checkbox or FAT disposition is changed by the engine. IED live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}.");
ReportProgress(message);
return IoTestSessionActionResult.Success(message);
}
@@ -434,6 +492,40 @@ void ReportProgress(string message)
}
}
+ private async Task ObserveSharedStaticReportEvidenceAsync(
+ Iec61850MonitorDevice device,
+ TimeSpan proofWindow)
+ {
+ try
+ {
+ await Task.Delay(proofWindow, _applicationCancellation.Token);
+ if (!device.IsMonitoring || !IsSharedStaticDataSetAuthority(device))
+ return;
+
+ if (device.HasReportStream)
+ {
+ var reportPoints = device.Points.Count(point =>
+ IsReportSource(point.SourceMode) &&
+ !point.SourceMode.Contains("pending", StringComparison.OrdinalIgnoreCase));
+ AddLog(
+ "INFO",
+ device.Name,
+ $"Static DataSet report evidence: actual InformationReport traffic observed; report-backed runtime point(s)={reportPoints}. RCB/GI path is alive.");
+ return;
+ }
+
+ AddLog(
+ "WARN",
+ device.Name,
+ $"Static DataSet report evidence: configured RCB setup/GI was requested, but no InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s. ARSAS will remain report-pending and will NOT switch process values to cyclic MMS polling. Check RptEna/ownership, GI support and whether the IED server actually emits reports for the configured DataSet.");
+ MarkDiagnosticAlert();
+ }
+ catch (OperationCanceledException)
+ {
+ // Application shutdown or explicit lifecycle cancellation needs no warning.
+ }
+ }
+
private bool AttachIoFatSclRuntimeAuthority(
IoTestIedPlan ied,
Iec61850MonitorDevice device)
@@ -604,9 +696,18 @@ private static IoFatAcquisitionSummary ReadIoFatAcquisitionSummary(
}
private static bool IsReportSource(string source)
- => source.Contains("BRCB", StringComparison.OrdinalIgnoreCase) ||
- source.Contains("URCB", StringComparison.OrdinalIgnoreCase) ||
- source.Contains("report", StringComparison.OrdinalIgnoreCase);
+ {
+ if (string.IsNullOrWhiteSpace(source) ||
+ source.Contains("pending", StringComparison.OrdinalIgnoreCase) ||
+ source.Contains("unavailable", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return source.Contains("BRCB", StringComparison.OrdinalIgnoreCase) ||
+ source.Contains("URCB", StringComparison.OrdinalIgnoreCase) ||
+ source.Contains("report", StringComparison.OrdinalIgnoreCase);
+ }
private sealed record IoFatAcquisitionSummary(
int LiveCount,
diff --git a/MainWindow.IoTesting.MultiIedMonitor.cs b/MainWindow.IoTesting.MultiIedMonitor.cs
index c6b405911..6353fdc5a 100644
--- a/MainWindow.IoTesting.MultiIedMonitor.cs
+++ b/MainWindow.IoTesting.MultiIedMonitor.cs
@@ -1,4 +1,5 @@
using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
using ArIED61850Tester.Services.IoTesting;
namespace ArIED61850Tester;
@@ -19,6 +20,11 @@ private async Task StartIoFatDeviceMonitorAsync(
ArgumentNullException.ThrowIfNull(device);
ArgumentNullException.ThrowIfNull(acquisitionSignals);
+ // Engineering and FAT are two views of the same SCL workspace. Remember the
+ // operator's explicit Static DataSet intent independently of any transient monitor
+ // stop/restart that FAT performs while preparing its evidence scope.
+ var preserveStaticDataSetAuthority = IsSharedStaticDataSetAuthority(device);
+
var acquisition = acquisitionSignals
.Where(signal => signal.CanPublishToRuntime)
.GroupBy(
@@ -52,7 +58,21 @@ private async Task StartIoFatDeviceMonitorAsync(
RemoveDevicePoints(device.DeviceId);
device.Points.Clear();
- SetStatus($"{device.Name}: starting independent FAT live acquisition…");
+ // P0 cross-mode authority guard: FAT must never silently turn an Engineering
+ // Static DataSet workspace into generic Hybrid/MMS acquisition. Reassert the
+ // protocol contract immediately before the new runtime snapshots its mode.
+ if (preserveStaticDataSetAuthority)
+ {
+ Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device);
+ AddLog(
+ "INFO",
+ device.Name,
+ "FAT reuses the shared Static DataSet report-only authority; cyclic MMS process polling remains disabled.");
+ }
+
+ SetStatus(preserveStaticDataSetAuthority
+ ? $"{device.Name}: starting shared Static DataSet FAT acquisition…"
+ : $"{device.Name}: starting independent FAT live acquisition…");
var points = await _runtime.StartMonitoringAsync(
device,
acquisition,
@@ -86,6 +106,13 @@ private async Task StartIoFatDeviceMonitorAsync(
foreach (var (signal, wasSelected) in originalSelection)
signal.IsSelected = wasSelected;
device.EndBulkSignalSelection();
+
+ // A temporary FAT acquisition selection must not alter the shared protocol
+ // authority either. This also restores AllowDynamicDataSetWrites=false if an
+ // older FAT preparation path set that compatibility flag while entering FAT.
+ if (preserveStaticDataSetAuthority)
+ Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device);
+
device.RefreshComputed();
RaiseWorkspaceCounts();
}
diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs
index 0b602b1b7..2531c48f5 100644
--- a/MainWindow.SharedSclWorkspace.cs
+++ b/MainWindow.SharedSclWorkspace.cs
@@ -21,6 +21,16 @@ private enum SclSignalSelectionMode
private readonly HashSet _sharedSclSelectionAuthorityDeviceIds =
new(StringComparer.OrdinalIgnoreCase);
+ // Keep the operator's acquisition intent independently of transient runtime teardown.
+ // FAT and Engineering share one device/workspace, so entering FAT must never demote an
+ // explicitly selected Static DataSet report-only workspace into generic Hybrid/MMS.
+ private readonly HashSet _sharedSclStaticDataSetAuthorityDeviceIds =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ private bool IsSharedStaticDataSetAuthority(Iec61850MonitorDevice device)
+ => _sharedSclStaticDataSetAuthorityDeviceIds.Contains(device.DeviceId) ||
+ Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device);
+
private SclSignalSelectionMode? PromptSclSignalSelectionMode(Window owner, int iedCount)
{
var dialog = new SclSignalSelectionModeWindow(iedCount)
@@ -37,19 +47,20 @@ private enum SclSignalSelectionMode
private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device)
{
- // Static DataSet is a protocol-authority mode, not a request to monitor the
- // whole IED and then opportunistically prefer reports. First make sure every
- // ARIEC-owned DataSet member is present in the workspace, then select only
- // runtime signals that carry an explicit static DataSet identity.
+ // Static DataSet remains the protocol authority established by the report-only
+ // baseline. Materialize every ARIEC-owned member first, then select exactly one
+ // presentation/runtime row per literal static membership. A browsed alias carrying
+ // DataSetReference is not enough authority and must not inflate the live plan.
var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device);
RegisterRecoveredDataSetSignals(device, merge);
+ var authoritativeSignals = Iec61850StaticDataSetAuthoritySelection.Build(device);
Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device);
device.BeginBulkSignalSelection();
try
{
foreach (var signal in device.Signals)
- signal.IsSelected = Iec61850StaticDataSetSelectionPolicy.IsEligible(signal);
+ signal.IsSelected = authoritativeSignals.Contains(signal);
}
finally
{
@@ -58,13 +69,20 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device)
SynchronizeAllEngineeringSelectionsToFat(device);
_sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId);
+ _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId);
SaveSignalSelectionMemory(device);
device.RefreshComputed();
AddLog(
"INFO",
device.Name,
- $"Static DataSet report-only authority selected: {device.SelectedLiveSignalCount} runtime DataSet signal(s); cyclic MMS process polling and dynamic DataSet writes are disabled for this monitoring mode.");
+ $"Static DataSet report-only authority selected: {device.SelectedLiveSignalCount} exact runtime member row(s) from {merge.MandatoryCatalogCount} ARIEC static membership descriptor(s); cyclic MMS process polling and dynamic DataSet writes remain disabled.");
+
+ // Make feasibility and first-report proof visible from the initial Engineering
+ // workflow rather than waiting until FAT is opened. The observer waits for the
+ // shared monitor to start and never changes acquisition method.
+ LogStaticDataSetReportFeasibility(device);
+ _ = ObserveInitialStaticReportEvidenceAsync(device);
}
private void ClearSharedSignalSelection(Iec61850MonitorDevice device)
@@ -84,6 +102,7 @@ private void ClearSharedSignalSelection(Iec61850MonitorDevice device)
private void MarkSharedSelectionAuthority(Iec61850MonitorDevice device)
{
// Manual selection restores the normal Smart/Hybrid acquisition contract.
+ _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId);
Iec61850MonitoringModeRegistry.UseHybrid(device);
_sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId);
SaveSignalSelectionMemory(device);
diff --git a/MainWindow.StaticReportEvidence.cs b/MainWindow.StaticReportEvidence.cs
new file mode 100644
index 000000000..07b618564
--- /dev/null
+++ b/MainWindow.StaticReportEvidence.cs
@@ -0,0 +1,132 @@
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester;
+
+public partial class MainWindow
+{
+ ///
+ /// Emits a protocol-feasibility inventory before monitoring starts. A configured
+ /// DataSet is not automatically reportable: at least one configured BRCB/URCB must
+ /// reference it. This diagnostic deliberately does not infer availability from
+ /// freeBRCB/freeURCB counts or from process-value polling.
+ ///
+ private void LogStaticDataSetReportFeasibility(Iec61850MonitorDevice device)
+ {
+ var model = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel;
+ if (model is null)
+ {
+ AddLog("WARN", device.Name,
+ "Static DataSet feasibility: no SCL/live design model is available; configured RCB → DataSet mapping cannot be audited.");
+ return;
+ }
+
+ var reports = model.ReportControls.ToArray();
+ foreach (var dataSet in model.DataSets)
+ {
+ var matchingReports = reports
+ .Where(report => SameSclReference(report.DataSetReference, dataSet.Reference))
+ .ToArray();
+
+ if (matchingReports.Length == 0)
+ {
+ AddLog(
+ "WARN",
+ device.Name,
+ $"Static DataSet feasibility: {dataSet.Reference} · members={dataSet.Members.Count} · NO CONFIGURED RCB. These members cannot become report-live until the IED/SCL provides a BRCB/URCB for this DataSet; ARSAS will not substitute cyclic MMS process polling.");
+ continue;
+ }
+
+ var reportSummary = string.Join(", ", matchingReports.Select(report =>
+ $"{report.Reference} ({(report.Buffered ? "BRCB" : "URCB")})"));
+ AddLog(
+ "INFO",
+ device.Name,
+ $"Static DataSet feasibility: {dataSet.Reference} · members={dataSet.Members.Count} · configured RCB={reportSummary}.");
+ }
+ }
+
+ ///
+ /// Starts a causal proof timer from the operator's initial Static DataSet selection.
+ /// It waits for the shared Engineering monitor to exist, labels points whose DataSet
+ /// has no configured RCB, then checks for actual InformationReport traffic. A successful
+ /// RptEna/GI write alone is intentionally not treated as report proof.
+ ///
+ private async Task ObserveInitialStaticReportEvidenceAsync(Iec61850MonitorDevice device)
+ {
+ try
+ {
+ var monitorDeadline = DateTime.UtcNow.AddSeconds(12);
+ while (!device.IsMonitoring && DateTime.UtcNow < monitorDeadline)
+ {
+ if (!IsSharedStaticDataSetAuthority(device))
+ return;
+ await Task.Delay(100, _applicationCancellation.Token);
+ }
+
+ if (!device.IsMonitoring || !IsSharedStaticDataSetAuthority(device))
+ return;
+
+ ApplyNoConfiguredRcbPointEvidence(device);
+ AddLog(
+ "INFO",
+ device.Name,
+ "Static DataSet causal gate: shared monitor started. RCB control-plane setup may use one-shot MMS; cyclic MMS process-value polling remains forbidden. Waiting for actual InformationReport traffic after GI.");
+ await ObserveSharedStaticReportEvidenceAsync(device, TimeSpan.FromSeconds(3));
+ }
+ catch (OperationCanceledException)
+ {
+ // Application shutdown or lifecycle cancellation needs no warning.
+ }
+ }
+
+ private void ApplyNoConfiguredRcbPointEvidence(Iec61850MonitorDevice device)
+ {
+ var model = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel;
+ if (model is null || device.Points.Count == 0)
+ return;
+
+ var reportableDataSets = model.ReportControls
+ .Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference))
+ .Select(report => NormalizeSclReference(report.DataSetReference))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var selectedSignals = device.Signals
+ .Where(signal => signal.IsSelected && !string.IsNullOrWhiteSpace(signal.DataSetReference))
+ .GroupBy(signal => NormalizeSclReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
+
+ var unavailable = 0;
+ foreach (var point in device.Points)
+ {
+ if (!selectedSignals.TryGetValue(NormalizeSclReference(point.IecReference), out var signal))
+ continue;
+
+ var dataSetReference = NormalizeSclReference(signal.DataSetReference);
+ if (dataSetReference.Length == 0 || reportableDataSets.Contains(dataSetReference))
+ continue;
+
+ point.SourceMode = "Static DataSet: no configured RCB";
+ point.Status = "Unavailable / no configured RCB";
+ unavailable++;
+ }
+
+ if (unavailable > 0)
+ {
+ AddLog(
+ "WARN",
+ device.Name,
+ $"Static DataSet feasibility projected to runtime: {unavailable} point(s) belong to DataSet(s) with no configured RCB and are explicitly marked unavailable instead of generic report-pending or MMS polling.");
+ }
+ }
+
+ private static bool SameSclReference(string? left, string? right)
+ => string.Equals(
+ NormalizeSclReference(left),
+ NormalizeSclReference(right),
+ StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeSclReference(string? value)
+ => string.IsNullOrWhiteSpace(value)
+ ? string.Empty
+ : value.Trim().Replace('$', '.');
+}
diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs
index 25d8bcfef..0b939ec05 100644
--- a/Services/Iec61850MonitorRuntime.cs
+++ b/Services/Iec61850MonitorRuntime.cs
@@ -83,6 +83,7 @@ private sealed class DeviceSession
public string HealthProbePointKey { get; set; } = string.Empty;
public int ControlCommandActive;
public HybridReportPhysicalValidationTracker HybridValidation { get; } = new();
+ public StaticDataSetReportProjectionAccumulator StaticReportProjection { get; } = new();
public bool StaticDataSetReportOnly { get; set; }
}
@@ -399,6 +400,7 @@ public async Task> StartMonitoringAsync(
session.ConsecutiveHealthProbeFailures = 0;
session.HealthProbePointKey = string.Empty;
session.HybridValidation.Reset(null);
+ session.StaticReportProjection.Reset();
session.StaticDataSetReportOnly = staticDataSetReportOnly;
var safePollMs = Math.Clamp(pollingIntervalMs <= 0 ? 1000 : pollingIntervalMs, 50, 600000);
@@ -743,22 +745,21 @@ private async Task StartReportPlansAsync(
session.ActiveReportPlanOrder.Add(plan);
var coveredPoints = ResolveCoveredPoints(plan, result.CoveredReferences);
- var acquisitionLabel = string.IsNullOrWhiteSpace(result.AcquisitionLabel)
- ? BuildPlanAcquisitionLabel(plan, result.UsedDynamicDataSet)
- : result.AcquisitionLabel;
+ var acquisitionLabel = session.StaticDataSetReportOnly
+ ? BuildStaticDataSetAcquisitionLabel(plan)
+ : string.IsNullOrWhiteSpace(result.AcquisitionLabel)
+ ? BuildPlanAcquisitionLabel(plan, result.UsedDynamicDataSet)
+ : result.AcquisitionLabel;
foreach (var point in coveredPoints)
{
- if (RequiresExactMmsValueAuthority(point.IecReference))
- {
- if (session.StaticDataSetReportOnly && session.States.TryGetValue(point.PointKey, out var unresolvedState))
- {
- unresolvedState.SourceMode = "Static DataSet: report leaf unresolved";
- unresolvedState.AcquisitionLabel = unresolvedState.SourceMode;
- unresolvedState.Reason = "structured report projection is not yet schema-proven; unsafe MMS fallback is disabled in Static DataSet mode";
- EmitStatusSnapshot(point, unresolvedState, "Static report leaf unresolved / no MMS fallback", "Pending");
- }
+ // Hybrid/manual mode retains its conservative exact-MMS authority rule.
+ // Static DataSet report-only mode is different: when ARIEC proves that
+ // the configured RCB covers the literal DataSet member, bind that point
+ // to the report plan and let schema-safe report projection decide whether
+ // an arriving value is publishable. Never convert this case into MMS.
+ if (!session.StaticDataSetReportOnly && RequiresExactMmsValueAuthority(point.IecReference))
continue;
- }
+
session.PointPlanIds[point.PointKey] = plan.PlanId;
if (!string.IsNullOrWhiteSpace(result.ReportControlReference))
point.ReportControlReference = result.ReportControlReference;
@@ -768,8 +769,12 @@ private async Task StartReportPlansAsync(
{
pointState.AcquisitionLabel = acquisitionLabel;
pointState.SourceMode = acquisitionLabel;
- pointState.Reason = "awaiting report / MMS initial read";
- pointState.Status = "Report armed / verification active";
+ pointState.Reason = session.StaticDataSetReportOnly
+ ? "awaiting configured static report / General Interrogation"
+ : "awaiting report / MMS initial read";
+ pointState.Status = session.StaticDataSetReportOnly
+ ? "Static report armed / awaiting value"
+ : "Report armed / verification active";
}
}
@@ -954,22 +959,37 @@ private async Task> BuildReportPlansForCurrentA
{
IsAuthoritative = true,
Authority = "ARIEC61850 hybrid acquisition",
- Status = "Planner failure / polling safe",
- Summary = $"ARIEC hybrid planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted; bounded MMS polling remains active.",
+ Status = session.StaticDataSetReportOnly
+ ? "Planner failure / static report unavailable"
+ : "Planner failure / polling safe",
+ Summary = session.StaticDataSetReportOnly
+ ? $"ARIEC report planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted and MMS process fallback remains disabled by Static DataSet mode."
+ : $"ARIEC hybrid planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted; bounded MMS polling remains active.",
RequestedPointCount = session.Points.Count,
- PollingPointKeys = session.Points.Keys.ToArray(),
- PollingFallbackSignalCount = session.Points.Count,
+ PollingPointKeys = session.StaticDataSetReportOnly ? Array.Empty() : session.Points.Keys.ToArray(),
+ PollingFallbackSignalCount = session.StaticDataSetReportOnly ? 0 : session.Points.Count,
+ UncoveredSignalCount = session.StaticDataSetReportOnly ? session.Points.Count : 0,
Warnings = [$"Hybrid planning exception: {ex.GetType().Name}: {ex.Message}"]
};
}
session.HybridValidation.Reset(hybrid);
- Log("INFO", session.Device.Name,
- $"Hybrid authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, dynamicBRCB={hybrid.DynamicBrcbSignalCount}, dynamicURCB={hybrid.DynamicUrcbSignalCount}, polling={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. {hybrid.Summary}");
+ if (session.StaticDataSetReportOnly)
+ {
+ Log("INFO", session.Device.Name,
+ $"Static DataSet ARIEC authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, engineFallbackCandidates={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. Engine fallback candidates are diagnostic only and are not scheduled as MMS process polling.");
+ }
+ else
+ {
+ Log("INFO", session.Device.Name,
+ $"Hybrid authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, dynamicBRCB={hybrid.DynamicBrcbSignalCount}, dynamicURCB={hybrid.DynamicUrcbSignalCount}, polling={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. {hybrid.Summary}");
+ }
foreach (var warning in hybrid.Warnings.Take(5))
Log("WARN", session.Device.Name, warning);
- return hybrid.ReportPlans;
+ return session.StaticDataSetReportOnly
+ ? hybrid.ReportPlans.Where(plan => !plan.AllowDynamicDataSetWrites).ToArray()
+ : hybrid.ReportPlans;
}
session.HybridValidation.Reset(null);
@@ -1051,22 +1071,37 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
if (slice.ReportFrames.Count > 0 || slice.Updates.Count > 0)
RecordSuccessfulIo(session);
- foreach (var update in slice.Updates)
+ foreach (var sourceUpdate in slice.Updates)
{
- var point = FindPointForReportReference(session, update.Reference);
- if (point == null)
- continue;
+ var effectiveUpdates = session.StaticDataSetReportOnly
+ ? session.StaticReportProjection.Project(
+ session.Device.LiveDiscoveryModel ?? session.Device.SclWorkspace?.DesignModel,
+ session.Points.Values.ToArray(),
+ sourceUpdate)
+ : new[] { sourceUpdate };
+
+ foreach (var update in effectiveUpdates)
+ {
+ var point = FindPointForReportReference(session, update.Reference);
+ if (point == null)
+ continue;
- // Siemens static DataSets report THD as a nested three-phase structure.
- // Until its scalar fan-out is schema-proven, exact MMS reads remain the
- // value authority so a report projection cannot overwrite correct values.
- if (RequiresExactMmsValueAuthority(point.IecReference))
- continue;
+ // The old rule rejected every THD/DmdWh report value merely because
+ // those families once required MMS reads. ARIEC now provides exact
+ // semantic leaf identities, and StaticDataSetReportProjectionAccumulator
+ // reconstructs structured parents from schema-named leaves. Keep the
+ // legacy veto only when the report update itself is not schema-proven.
+ if (RequiresExactMmsValueAuthority(point.IecReference) &&
+ !HasSchemaProvenReportValueAuthority(point, update))
+ {
+ continue;
+ }
- // A real report update proves reference coverage, but an initial GI or
- // integrity image does not yet prove that dchg/qchg is working. Keep MMS
- // verification active until a change report is actually observed.
- session.PointPlanIds[point.PointKey] = plan.PlanId;
+ // A real report update proves reference coverage. In Hybrid mode an
+ // initial GI/integrity image still keeps MMS verification active until
+ // dchg/qchg is proven; Static DataSet mode publishes the schema-safe GI
+ // image directly because cyclic MMS process verification is disabled.
+ session.PointPlanIds[point.PointKey] = plan.PlanId;
var state = session.States[point.PointKey];
var display = update.HasValue
@@ -1084,7 +1119,8 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
{
// A malformed/misaligned report is still useful as proof that the
// RCB is alive, but its process value is not authoritative. Do not
- // mutate state or SOE history; keep MMS verification/fallback active.
+ // mutate state or SOE history. Hybrid mode keeps MMS verification;
+ // Static DataSet mode stays report-pending without any MMS fallback.
state.ReportTrafficSeen = true;
state.LastReportUtc = DateTime.UtcNow;
state.ReportChangeVerified = false;
@@ -1096,7 +1132,9 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
if (rawSummary.Length > 120)
rawSummary = rawSummary[..120] + "…";
Log("WARN", session.Device.Name,
- $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative.");
+ session.StaticDataSetReportOnly
+ ? $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} The point remains report-pending; MMS process fallback is disabled."
+ : $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative.");
}
continue;
}
@@ -1168,11 +1206,16 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
reportSource,
string.IsNullOrWhiteSpace(update.Reason) ? "report / reason not supplied" : update.Reason,
receivedUtc,
- state.ReportChangeVerified
- ? 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})",
+ session.StaticDataSetReportOnly
+ ? state.ReportChangeVerified
+ ? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / static report verified" : $"Live / static report verified ({update.ProjectionStatus})"
+ : string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / static report" : $"Live / static report ({update.ProjectionStatus})"
+ : state.ReportChangeVerified
+ ? 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);
+ }
}
var verifiedReportPointKeys = slice.Updates
@@ -2032,6 +2075,7 @@ private async Task StopMonitoringSessionAsync(DeviceSession session, bool preser
session.ActiveReportPlans.Clear();
session.ActiveReportPlanOrder.Clear();
session.PointPlanIds.Clear();
+ session.StaticReportProjection.Reset();
session.PollQueue.Clear();
session.ReportStreams.Clear();
session.LastUnroutedReportCount = 0;
@@ -2111,6 +2155,33 @@ private static bool RequiresExactMmsValueAuthority(string reference)
normalized.Contains(".dmdwh", StringComparison.OrdinalIgnoreCase);
}
+ private static bool HasSchemaProvenReportValueAuthority(
+ Iec61850MonitorPoint point,
+ NativeReportValueUpdate update)
+ {
+ var projectionStatus = (update.ProjectionStatus ?? string.Empty).Trim();
+ if (projectionStatus.Equals("semantic-structured-leaf", StringComparison.OrdinalIgnoreCase) ||
+ projectionStatus.Equals("schema-safe-report-three-phase-aggregate", StringComparison.OrdinalIgnoreCase) ||
+ projectionStatus.Equals("schema-safe-report-demand-energy-aggregate", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ // Exact report identity is also authoritative for a scalar member. This admits
+ // direct and deterministic MX-pair projections only when ARIEC has named exactly
+ // the same IEC reference selected by the runtime; parent/child prefix coincidence
+ // alone is not sufficient.
+ if (!CanonicalDataReference(point.IecReference)
+ .Equals(CanonicalDataReference(update.Reference), StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return projectionStatus.Length == 0 ||
+ projectionStatus.Equals("direct", StringComparison.OrdinalIgnoreCase) ||
+ projectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase);
+ }
+
private static bool HasExactSemanticEdge(Iec61850MonitorPoint point, string oldValue, string newValue)
{
var oldText = (oldValue ?? string.Empty).Trim();
@@ -2257,6 +2328,13 @@ private static int GetVerificationPollIntervalMs(
state.ReportTrafficSeen,
state.ReportChangeVerified);
+ private static string BuildStaticDataSetAcquisitionLabel(ReportControlPlan plan)
+ {
+ if (!string.IsNullOrWhiteSpace(plan.EngineAcquisitionKind))
+ return $"Static DataSet: {plan.EngineAcquisitionKind}";
+ return plan.Buffered ? "Static DataSet: StaticBrcb" : "Static DataSet: StaticUrcb";
+ }
+
private static string BuildPlanAcquisitionLabel(ReportControlPlan plan, bool dynamicDataSet)
{
var reference = (plan.ReportControlReference ?? string.Empty).Trim().Replace('$', '.');
diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs
new file mode 100644
index 000000000..638440473
--- /dev/null
+++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs
@@ -0,0 +1,87 @@
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Builds the exact Static DataSet selection used by the report-only workflow.
+///
+/// A DataSetReference on a browsed/runtime alias is not sufficient authority: several
+/// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or
+/// structured measurement descendants). Static mode must select one presentation row
+/// for each engine-authoritative DataSet membership, preserving the literal member
+/// identity that appears in SCL/ARIEC.
+///
+public static class Iec61850StaticDataSetAuthoritySelection
+{
+ public static IReadOnlySet Build(Iec61850MonitorDevice device)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+
+ var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel;
+ if (model is null)
+ return new HashSet(ReferenceEqualityComparer.Instance);
+
+ var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model);
+ var selected = new HashSet(ReferenceEqualityComparer.Instance);
+ var signals = device.Signals.ToArray();
+
+ foreach (var descriptor in mandatory)
+ {
+ var membership = descriptor.DataSetMemberships
+ .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(item => item.MemberIndex)
+ .FirstOrDefault();
+ if (membership is null)
+ continue;
+
+ var memberReference = FirstNonEmpty(
+ membership.CanonicalMemberReference,
+ membership.OriginalMemberReference,
+ descriptor.DesignReference,
+ descriptor.ObservedReference,
+ descriptor.PrimaryValueReference);
+ if (string.IsNullOrWhiteSpace(memberReference) ||
+ string.IsNullOrWhiteSpace(membership.DataSetReference))
+ continue;
+
+ var candidates = signals
+ .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime)
+ .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference))
+ .Where(signal => LiteralEquals(signal.DisplayReference, memberReference))
+ .ToArray();
+ if (candidates.Length == 0)
+ continue;
+
+ // Prefer the runtime row already bound to ARIEC's resolved primary value.
+ // For an unresolved structured member, the inventory-created exact static row
+ // wins over a generic browsed alias. Never choose by fuzzy/prefix matching.
+ var chosen = candidates
+ .OrderByDescending(signal =>
+ !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) &&
+ LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference))
+ .ThenByDescending(signal =>
+ (signal.Source ?? string.Empty).Contains(
+ "mandatory static DataSet member",
+ StringComparison.OrdinalIgnoreCase))
+ .ThenByDescending(signal =>
+ string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase))
+ .ThenByDescending(signal =>
+ string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase))
+ .First();
+
+ selected.Add(chosen);
+ }
+
+ return selected;
+ }
+
+ private static bool LiteralEquals(string? left, string? right)
+ => string.Equals(NormalizeLiteral(left), NormalizeLiteral(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeLiteral(string? value)
+ => (value ?? string.Empty).Trim();
+
+ private static string FirstNonEmpty(params string?[] values)
+ => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
+}
diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs
index bcf9a5de5..5c9750701 100644
--- a/Services/NativeIec61850Client.HybridReporting.cs
+++ b/Services/NativeIec61850Client.HybridReporting.cs
@@ -45,6 +45,17 @@ public async Task BuildHybridReportPlansAsync(
ArgumentNullException.ThrowIfNull(points);
cancellationToken.ThrowIfCancellationRequested();
+ // Static DataSet is deliberately deterministic: exact configured SCL RCB +
+ // exact live RCB object + ordered live DataSet directory -> RptEna + GI. Do not
+ // send Static mode through adaptive Hybrid availability/capability policy.
+ if (Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device))
+ {
+ return await BuildStaticDataSetReportPlansAsync(
+ device,
+ points,
+ cancellationToken).ConfigureAwait(false);
+ }
+
_authoritativeHybridSubscriptions.Clear();
ResetSemanticReportProjectionContext();
@@ -356,6 +367,12 @@ public async Task StartHybridReportMonitorAsync(
ArgumentNullException.ThrowIfNull(plan);
cancellationToken.ThrowIfCancellationRequested();
+ // Deterministic Static plans are executed directly. Hybrid revalidation can
+ // otherwise discard a valid configured RCB merely because reservation metadata
+ // is incomplete, which is exactly the field regression this path removes.
+ if (_deterministicStaticSubscriptions.ContainsKey(plan.PlanId))
+ return await StartStaticDataSetReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);
+
if (!plan.IsEngineAuthoritative)
{
return new NativeReportMonitorStartResult
diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs
new file mode 100644
index 000000000..68cdcd9bb
--- /dev/null
+++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs
@@ -0,0 +1,398 @@
+using ArIED61850Tester.Models;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Deterministic acquisition path used only by Static DataSet report-only mode.
+///
+/// Static mode already has complete configuration authority in the opened/live SCL model:
+/// exact DataSet membership and configured RCB -> DataSet bindings. Do not run those facts
+/// through the adaptive Hybrid acquisition planner. The live association is used only to
+/// verify that the exact configured RCB and exact DataSet directory exist, then ARIEC's
+/// persistent monitor installs the InformationReport receiver, enables RptEna and requests
+/// GI. No dynamic DataSet write and no cyclic process-value MMS read is permitted here.
+///
+public sealed partial class NativeIec61850Client
+{
+ private readonly Dictionary _deterministicStaticSubscriptions =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public async Task BuildStaticDataSetReportPlansAsync(
+ Iec61850MonitorDevice device,
+ IReadOnlyCollection points,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(points);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ _deterministicStaticSubscriptions.Clear();
+ ResetSemanticReportProjectionContext();
+
+ var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel;
+ if (model is null)
+ {
+ return StaticPlanningUnavailable(
+ points,
+ "Static DataSet report-only requires an opened/live SCL model with exact DataSet and RCB configuration.");
+ }
+
+ if (!_session.IsMmsInitiated)
+ {
+ return StaticPlanningUnavailable(
+ points,
+ $"Static DataSet report-only requires an initiated MMS association. Current state: {_session.State}.");
+ }
+
+ SetSemanticReportProjectionAuthority(model);
+
+ // Fresh report discovery is verification, not permission policy. In particular we
+ // deliberately do NOT classify a configured BRCB through the Hybrid availability
+ // confidence gate. Some perfectly usable servers expose RptEna and DatSet but omit
+ // enough reservation metadata for that adaptive gate to call the RCB 'Available'.
+ var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false);
+ if (discovery is null)
+ {
+ return StaticPlanningUnavailable(
+ points,
+ string.IsNullOrWhiteSpace(LastErrorMessage)
+ ? "Fresh report discovery was unavailable."
+ : LastErrorMessage);
+ }
+
+ var reportPlans = new List();
+ var warnings = new List();
+ var coveredPointKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ var selectedByDataSet = points
+ .Where(point => !string.IsNullOrWhiteSpace(point.DataSetReference))
+ .GroupBy(point => NormalizeStaticReference(point.DataSetReference), StringComparer.OrdinalIgnoreCase)
+ .Where(group => !string.IsNullOrWhiteSpace(group.Key))
+ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ foreach (var dataSetGroup in selectedByDataSet)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var configuredReports = model.ReportControls
+ .Where(report => SameStaticReference(report.DataSetReference, dataSetGroup.Key))
+ .OrderByDescending(report => report.Buffered)
+ .ThenBy(report => report.Reference, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (configuredReports.Length == 0)
+ {
+ warnings.Add(
+ $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL model; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted.");
+ continue;
+ }
+
+ if (configuredReports.Length > 1)
+ {
+ warnings.Add(
+ $"{dataSetGroup.First().DataSetReference}: {configuredReports.Length} configured RCBs reference this DataSet; deterministic Static mode selected {configuredReports[0].Reference} (BRCB preferred, then literal reference order)." );
+ }
+
+ var configured = configuredReports[0];
+ var liveCandidates = discovery.ReportInventory.ReportControls
+ .Where(candidate => SameStaticReference(candidate.Reference, configured.Reference))
+ .ToArray();
+
+ if (liveCandidates.Length != 1)
+ {
+ warnings.Add(
+ $"{configured.Reference}: configured RCB was proven by SCL but exact live MMS discovery returned {liveCandidates.Length} matching RCB object(s). Static mode refused to guess and did not poll process values.");
+ continue;
+ }
+
+ var liveRcb = CloneReportControlForPlanning(liveCandidates[0]);
+ var dataSetReference = dataSetGroup.First().DataSetReference.Trim();
+
+ if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) &&
+ !SameStaticReference(liveRcb.DataSetReference, dataSetReference))
+ {
+ warnings.Add(
+ $"{configured.Reference}: SCL binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling.");
+ continue;
+ }
+
+ // Missing live DatSet text is not treated as a reason to discard correct SCL
+ // configuration. The exact DataSet directory below is still required and is the
+ // ordered mapping authority for InformationReport values.
+ if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference))
+ liveRcb.DataSetReference = dataSetReference;
+
+ var directories = await RunMmsOperationAsync(
+ () => _session.GetDataSetDirectoriesAsync(
+ new[] { dataSetReference },
+ discovery.IedDirectory,
+ cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+ var directory = directories.SingleOrDefault(result =>
+ result.IsSuccess && SameStaticReference(result.DataSetReference, dataSetReference));
+
+ if (directory is null || directory.Members.Count == 0)
+ {
+ var detail = directories.FirstOrDefault()?.Message ?? "no directory response";
+ warnings.Add(
+ $"{dataSetReference}: live DataSet directory could not prove an ordered non-empty member list ({detail}). RCB was not armed because report-index mapping would be unsafe; MMS process polling remains disabled.");
+ continue;
+ }
+
+ var bindings = dataSetGroup
+ .GroupBy(point => point.PointKey, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .OrderBy(point => point.IecReference, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ if (bindings.Count == 0)
+ continue;
+
+ var plan = new ReportControlPlan
+ {
+ RelayId = device.DeviceId,
+ RelayName = device.Name,
+ RelayIpAddress = device.IpAddress,
+ IedName = device.Name,
+ ReportControlReference = configured.Reference,
+ DataSetReference = dataSetReference,
+ Mode = "Static DataSet • deterministic configured RCB",
+ AllowDynamicDataSetWrites = false,
+ Buffered = configured.Buffered,
+ ReportId = liveRcb.ReportId,
+ IntegrityPeriodMs = ParseStaticInteger(liveRcb.IntegrityPeriodMs),
+ TriggerOptions = liveRcb.TriggerOptions,
+ OptionalFields = liveRcb.OptionalFields,
+ Status = "Deterministic static report planned",
+ IsEngineAuthoritative = true,
+ EngineAcquisitionKind = configured.Buffered ? "StaticBrcb" : "StaticUrcb",
+ Bindings = bindings
+ };
+
+ var subscriptionWarnings = new List();
+ if (string.IsNullOrWhiteSpace(liveCandidates[0].DataSetReference))
+ {
+ subscriptionWarnings.Add(
+ "Live RCB DatSet text was not returned; exact SCL RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority.");
+ }
+
+ var subscription = new ArMms.MmsReportSubscriptionPlan
+ {
+ Mode = ArMms.MmsReportSubscriptionPlanMode.StaticDataSet,
+ Status = ArMms.MmsReportSubscriptionPlanStatus.ReadyRequiresWrite,
+ ReportControl = liveRcb,
+ DataSetReference = dataSetReference,
+ Members = directory.Members,
+ DynamicPoints = Array.Empty(),
+ Steps = new[]
+ {
+ $"Verify exact configured RCB {configured.Reference} exists on the live association.",
+ $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).",
+ "Install InformationReport receiver before enabling the RCB.",
+ "Write RptEna=true, then request GI=true.",
+ "Map report values by ordered DataSet member index; never substitute cyclic MMS process reads."
+ },
+ Warnings = subscriptionWarnings
+ };
+
+ _deterministicStaticSubscriptions[plan.PlanId] = subscription;
+ reportPlans.Add(plan);
+ foreach (var binding in bindings)
+ coveredPointKeys.Add(binding.PointKey);
+ }
+
+ var uncovered = points
+ .Where(point => !coveredPointKeys.Contains(point.PointKey))
+ .Select(point => point.PointKey)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ var staticBrcb = reportPlans.Where(plan => plan.Buffered).Sum(plan => plan.BindingCount);
+ var staticUrcb = reportPlans.Where(plan => !plan.Buffered).Sum(plan => plan.BindingCount);
+
+ return new NativeHybridReportPlanningResult
+ {
+ IsAuthoritative = true,
+ Authority = "Deterministic Static DataSet configured-RCB path",
+ Status = reportPlans.Count > 0 ? "StaticReportReady" : "StaticReportingUnavailable",
+ Summary = reportPlans.Count > 0
+ ? $"Deterministic Static DataSet path prepared {reportPlans.Count} configured RCB plan(s), covering {coveredPointKeys.Count}/{points.Count} selected point(s). Hybrid planning, dynamic DataSet writes and cyclic MMS process polling were bypassed."
+ : $"No configured Static DataSet RCB could be armed safely for {points.Count} selected point(s). Hybrid planning and cyclic MMS process polling were not used.",
+ ReportPlans = reportPlans,
+ PollingPointKeys = Array.Empty(),
+ UncoveredPointKeys = uncovered,
+ UnmappedPointKeys = Array.Empty(),
+ PointAttemptEvidence = Array.Empty(),
+ Warnings = warnings,
+ RequestedPointCount = points.Count,
+ CatalogMappedPointCount = points.Count,
+ StaticBrcbSignalCount = staticBrcb,
+ StaticUrcbSignalCount = staticUrcb,
+ DynamicBrcbSignalCount = 0,
+ DynamicUrcbSignalCount = 0,
+ PollingFallbackSignalCount = 0,
+ UncoveredSignalCount = uncovered.Length
+ };
+ }
+
+ public async Task StartStaticDataSetReportMonitorAsync(
+ ReportControlPlan plan,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!_deterministicStaticSubscriptions.TryGetValue(plan.PlanId, out var subscription))
+ {
+ return new NativeReportMonitorStartResult
+ {
+ IsSuccess = false,
+ PlanId = plan.PlanId,
+ Message = "Deterministic Static DataSet subscription evidence is missing. RCB was not armed; MMS process polling remains disabled.",
+ FailureReason = "StaticSubscriptionEvidenceMissing"
+ };
+ }
+
+ if (_reportMonitorSessions.ContainsKey(plan.PlanId))
+ {
+ return new NativeReportMonitorStartResult
+ {
+ IsSuccess = true,
+ PlanId = plan.PlanId,
+ Message = $"Deterministic Static DataSet monitor already active for {plan.DisplayReference}.",
+ SubscriptionSummary = subscription.Summary,
+ MemberCount = subscription.Members.Count,
+ ReportControlReference = plan.ReportControlReference,
+ DataSetReference = plan.DataSetReference,
+ AcquisitionLabel = $"Static DataSet: {plan.EngineAcquisitionKind}",
+ CoveredReferences = _reportMonitorCoverage.TryGetValue(plan.PlanId, out var existingCoverage)
+ ? existingCoverage
+ : Array.Empty()
+ };
+ }
+
+ if (!_session.IsMmsInitiated)
+ {
+ return new NativeReportMonitorStartResult
+ {
+ IsSuccess = false,
+ PlanId = plan.PlanId,
+ Message = $"Deterministic Static DataSet monitor requires an initiated MMS association. Current state: {_session.State}.",
+ SubscriptionSummary = subscription.Summary,
+ MemberCount = subscription.Members.Count,
+ FailureReason = "TransportUnavailable"
+ };
+ }
+
+ var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false);
+ if (discovery is null)
+ {
+ return new NativeReportMonitorStartResult
+ {
+ IsSuccess = false,
+ PlanId = plan.PlanId,
+ Message = string.IsNullOrWhiteSpace(LastErrorMessage) ? "Fresh report discovery unavailable." : LastErrorMessage,
+ SubscriptionSummary = subscription.Summary,
+ MemberCount = subscription.Members.Count,
+ FailureReason = "FreshReportDiscoveryUnavailable"
+ };
+ }
+
+ var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members);
+ var attempt = await RunMmsOperationAsync(
+ () => _session.StartPersistentReportMonitorWithAttemptEvidenceAsync(
+ subscription,
+ triggerGeneralInterrogation: true,
+ deleteDynamicDataSetOnStop: false,
+ discovery.IedDirectory,
+ cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+ var start = attempt.StartResult;
+ var warnings = start.Warnings
+ .Concat(subscription.Warnings)
+ .Concat(attempt.CleanupWarnings)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (!attempt.IsSuccess || start.Session is null)
+ {
+ return new NativeReportMonitorStartResult
+ {
+ IsSuccess = false,
+ PlanId = plan.PlanId,
+ Message = $"Deterministic Static DataSet activation failed for {plan.DisplayReference}: {start.Message}",
+ SubscriptionSummary = subscription.Summary,
+ MemberCount = subscription.Members.Count,
+ WriteStepCount = start.WriteSteps.Count,
+ UsedDynamicDataSet = false,
+ DynamicAttempted = false,
+ DynamicAttemptState = "NotApplicable",
+ FailureReason = attempt.FailureReason.ToString(),
+ CleanupAttempted = attempt.CleanupAttempted,
+ CleanupSucceeded = attempt.CleanupSucceeded,
+ ReportControlReference = plan.ReportControlReference,
+ DataSetReference = plan.DataSetReference,
+ CoveredReferences = coveredReferences,
+ Warnings = warnings
+ };
+ }
+
+ if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference))
+ plan.ReportControlReference = start.Session.ReportControl.Reference;
+ if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference))
+ plan.DataSetReference = start.Session.Plan.DataSetReference;
+
+ _reportMonitorSessions[plan.PlanId] = start.Session;
+ _reportMonitorCoverage[plan.PlanId] = coveredReferences;
+
+ return new NativeReportMonitorStartResult
+ {
+ IsSuccess = true,
+ PlanId = plan.PlanId,
+ Message = $"Deterministic Static DataSet {plan.EngineAcquisitionKind} active. {start.Message}",
+ SubscriptionSummary = subscription.Summary,
+ MemberCount = subscription.Members.Count,
+ WriteStepCount = start.WriteSteps.Count,
+ UsedDynamicDataSet = false,
+ DynamicAttempted = false,
+ DynamicAttemptState = "NotApplicable",
+ ReportControlReference = plan.ReportControlReference,
+ DataSetReference = plan.DataSetReference,
+ AcquisitionLabel = $"Static DataSet: {plan.EngineAcquisitionKind}",
+ CoveredReferences = coveredReferences,
+ Warnings = warnings
+ };
+ }
+
+ private static NativeHybridReportPlanningResult StaticPlanningUnavailable(
+ IReadOnlyCollection points,
+ string reason)
+ => new()
+ {
+ IsAuthoritative = true,
+ Authority = "Deterministic Static DataSet configured-RCB path",
+ Status = "StaticReportingUnavailable",
+ Summary = reason + " Cyclic MMS process polling was not enabled.",
+ RequestedPointCount = points.Count,
+ CatalogMappedPointCount = points.Count,
+ PollingPointKeys = Array.Empty(),
+ UncoveredPointKeys = points.Select(point => point.PointKey).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
+ UnmappedPointKeys = Array.Empty(),
+ PointAttemptEvidence = Array.Empty(),
+ Warnings = new[] { reason },
+ PollingFallbackSignalCount = 0,
+ UncoveredSignalCount = points.Count
+ };
+
+ private static bool SameStaticReference(string? left, string? right)
+ => string.Equals(
+ NormalizeStaticReference(left),
+ NormalizeStaticReference(right),
+ StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeStaticReference(string? reference)
+ => (reference ?? string.Empty).Trim().Replace('$', '.');
+
+ private static int ParseStaticInteger(string? value)
+ => int.TryParse(value, out var parsed) && parsed > 0 ? parsed : 0;
+}
diff --git a/Services/StaticDataSetReportProjectionAccumulator.cs b/Services/StaticDataSetReportProjectionAccumulator.cs
new file mode 100644
index 000000000..ac4e82d50
--- /dev/null
+++ b/Services/StaticDataSetReportProjectionAccumulator.cs
@@ -0,0 +1,399 @@
+using System.Globalization;
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Reconstructs a single Static DataSet member value from schema-proven report leaves.
+///
+/// ARIEC deliberately expands structured report members such as MHAI.ThdA/ThdPPV into
+/// exact scalar descendants. Static DataSet mode, however, presents one row per literal
+/// FCDA/FCD membership (the same boundary an IED engineering browser shows). This
+/// accumulator joins only the exact leaves named by the authoritative SCL/live schema and
+/// publishes a synthetic parent update after every required leaf has been observed.
+/// No MMS read, positional child guessing, prefix-only phase choice, or fuzzy DataSet
+/// mapping is performed here.
+///
+public sealed class StaticDataSetReportProjectionAccumulator
+{
+ private sealed record LeafState(
+ string Reference,
+ string Label,
+ string Value,
+ string Quality,
+ string Timestamp,
+ string Reason,
+ DateTimeOffset UpdatedAt,
+ NativeReportValueUpdate Source);
+
+ private sealed record CompanionMetadata(
+ string Quality,
+ string Timestamp,
+ DateTimeOffset UpdatedAt);
+
+ private readonly Dictionary> _leavesByParent =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ // IEC 61850 report projection may publish q/t companion evidence before the scalar
+ // value leaf produced by semantic expansion. Keep that report evidence session-local
+ // and join it back to the later value update. This is deliberately not an MMS read or
+ // a synthetic "good" quality default: only q/t already supplied by the report can be
+ // carried forward.
+ private readonly Dictionary _metadataByReference =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public void Reset()
+ {
+ _leavesByParent.Clear();
+ _metadataByReference.Clear();
+ }
+
+ ///
+ /// Returns the updates that should be consumed by the Static DataSet runtime for one
+ /// engine report update. Ordinary scalar members pass through unchanged. A semantic
+ /// descendant belonging to a selected structured parent is withheld until the parent
+ /// can be reconstructed from all exact schema-named leaves. If the descendant itself
+ /// is also an independently selected DataSet member, its original update is preserved.
+ ///
+ public IReadOnlyList Project(
+ LiveIedModelDiscoveryDocument? authorityModel,
+ IReadOnlyCollection monitoredPoints,
+ NativeReportValueUpdate update)
+ {
+ ArgumentNullException.ThrowIfNull(monitoredPoints);
+ ArgumentNullException.ThrowIfNull(update);
+
+ if (string.IsNullOrWhiteSpace(update.Reference))
+ return new[] { update };
+
+ var updateReference = NormalizeReference(update.Reference);
+ RememberCompanionMetadata(updateReference, update);
+ var effectiveUpdate = EnrichFromCompanionMetadata(updateReference, update);
+
+ if (!effectiveUpdate.HasValue)
+ return new[] { effectiveUpdate };
+
+ var exactSelected = monitoredPoints.Any(point =>
+ ReferencesEqual(point.IecReference, effectiveUpdate.Reference));
+
+ var aggregateParents = monitoredPoints
+ .Where(point => IsAggregate(point.IecReference))
+ .Where(point => IsDescendant(updateReference, NormalizeReference(point.IecReference)))
+ .ToArray();
+
+ // A semantic child may not be assigned to an aggregate unless its parent is unique.
+ // In an ambiguous model, preserve only an independently selected exact child.
+ if (aggregateParents.Length != 1)
+ return exactSelected ? new[] { effectiveUpdate } : aggregateParents.Length == 0 ? new[] { effectiveUpdate } : Array.Empty();
+
+ var parent = aggregateParents[0];
+ if (!SchemaSafeAggregateProjectionService.TryBuildReadPlan(
+ authorityModel,
+ parent.IecReference,
+ out var plan,
+ out _))
+ {
+ return exactSelected ? new[] { effectiveUpdate } : Array.Empty();
+ }
+
+ var expectedLeaf = plan.Leaves.FirstOrDefault(leaf =>
+ ReferencesEqual(leaf.Reference, effectiveUpdate.Reference));
+ if (expectedLeaf is null)
+ {
+ // Ignore non-authoritative siblings such as instCVal when cVal is the schema-
+ // preferred engineering value. Keep it only when it is a separate selected row.
+ return exactSelected ? new[] { effectiveUpdate } : Array.Empty();
+ }
+
+ var parentKey = NormalizeReference(parent.IecReference);
+ if (!_leavesByParent.TryGetValue(parentKey, out var leaves))
+ {
+ leaves = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ _leavesByParent[parentKey] = leaves;
+ }
+
+ leaves[NormalizeReference(expectedLeaf.Reference)] = new LeafState(
+ expectedLeaf.Reference,
+ expectedLeaf.Label,
+ effectiveUpdate.Value,
+ effectiveUpdate.HasQuality ? effectiveUpdate.Quality : string.Empty,
+ effectiveUpdate.HasTimestamp ? effectiveUpdate.Timestamp : string.Empty,
+ effectiveUpdate.Reason,
+ effectiveUpdate.UpdatedAt,
+ effectiveUpdate);
+
+ var projected = BuildParentUpdate(parent, plan, leaves);
+ if (projected is null)
+ return exactSelected ? new[] { effectiveUpdate } : Array.Empty();
+
+ return exactSelected
+ ? new[] { effectiveUpdate, projected }
+ : new[] { projected };
+ }
+
+ private void RememberCompanionMetadata(string updateReference, NativeReportValueUpdate update)
+ {
+ if ((!update.HasQuality || !IsUsefulMetadata(update.Quality)) &&
+ (!update.HasTimestamp || !IsUsefulMetadata(update.Timestamp)))
+ {
+ return;
+ }
+
+ foreach (var key in MetadataKeys(updateReference))
+ {
+ _metadataByReference.TryGetValue(key, out var previous);
+ var quality = update.HasQuality && IsUsefulMetadata(update.Quality)
+ ? update.Quality.Trim()
+ : previous?.Quality ?? string.Empty;
+ var timestamp = update.HasTimestamp && IsUsefulMetadata(update.Timestamp)
+ ? update.Timestamp.Trim()
+ : previous?.Timestamp ?? string.Empty;
+ var updatedAt = update.UpdatedAt == default
+ ? previous?.UpdatedAt ?? default
+ : update.UpdatedAt;
+
+ _metadataByReference[key] = new CompanionMetadata(quality, timestamp, updatedAt);
+ }
+ }
+
+ private NativeReportValueUpdate EnrichFromCompanionMetadata(
+ string updateReference,
+ NativeReportValueUpdate update)
+ {
+ if (!update.HasValue || (update.HasQuality && update.HasTimestamp))
+ return update;
+
+ CompanionMetadata? metadata = null;
+ foreach (var key in MetadataKeys(updateReference))
+ {
+ if (_metadataByReference.TryGetValue(key, out metadata))
+ break;
+ }
+
+ if (metadata is null)
+ return update;
+
+ var quality = update.HasQuality && IsUsefulMetadata(update.Quality)
+ ? update.Quality
+ : metadata.Quality;
+ var timestamp = update.HasTimestamp && IsUsefulMetadata(update.Timestamp)
+ ? update.Timestamp
+ : metadata.Timestamp;
+ var hasQuality = update.HasQuality || IsUsefulMetadata(quality);
+ var hasTimestamp = update.HasTimestamp || IsUsefulMetadata(timestamp);
+
+ if (hasQuality == update.HasQuality &&
+ hasTimestamp == update.HasTimestamp &&
+ string.Equals(quality, update.Quality, StringComparison.Ordinal) &&
+ string.Equals(timestamp, update.Timestamp, StringComparison.Ordinal))
+ {
+ return update;
+ }
+
+ return new NativeReportValueUpdate
+ {
+ Reference = update.Reference,
+ FunctionalConstraint = update.FunctionalConstraint,
+ Value = update.Value,
+ Quality = quality,
+ Timestamp = timestamp,
+ Reason = update.Reason,
+ Source = update.Source,
+ ProjectionStatus = update.ProjectionStatus,
+ HasValue = update.HasValue,
+ HasQuality = hasQuality,
+ HasTimestamp = hasTimestamp,
+ ReportControlReference = update.ReportControlReference,
+ ReportId = update.ReportId,
+ DataSetReference = update.DataSetReference,
+ SequenceNumber = update.SequenceNumber,
+ ConfRev = update.ConfRev,
+ ReportTimestamp = update.ReportTimestamp,
+ UpdatedAt = update.UpdatedAt
+ };
+ }
+
+ private static IEnumerable MetadataKeys(string normalizedReference)
+ {
+ if (string.IsNullOrWhiteSpace(normalizedReference))
+ yield break;
+
+ yield return normalizedReference;
+
+ var scope = MetadataScope(normalizedReference);
+ if (!scope.Equals(normalizedReference, StringComparison.OrdinalIgnoreCase))
+ yield return scope;
+ }
+
+ private static string MetadataScope(string normalizedReference)
+ {
+ var suffixes = new[]
+ {
+ ".instcval.mag.f",
+ ".cval.mag.f",
+ ".instmag.f",
+ ".mag.f",
+ ".q",
+ ".t"
+ };
+
+ foreach (var suffix in suffixes)
+ {
+ if (normalizedReference.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
+ return normalizedReference[..^suffix.Length];
+ }
+
+ return normalizedReference;
+ }
+
+ private static NativeReportValueUpdate? BuildParentUpdate(
+ Iec61850MonitorPoint parent,
+ SchemaSafeAggregateProjectionService.ReadPlan plan,
+ IReadOnlyDictionary leaves)
+ {
+ if (plan.Kind.Equals("ThreePhaseThd", StringComparison.OrdinalIgnoreCase))
+ {
+ var ordered = new List(plan.Leaves.Count);
+ foreach (var expected in plan.Leaves)
+ {
+ if (!leaves.TryGetValue(NormalizeReference(expected.Reference), out var leaf) ||
+ !TryParseNumeric(leaf.Value, out _))
+ {
+ return null;
+ }
+ ordered.Add(leaf);
+ }
+
+ var display = string.Join(", ", ordered.Select(leaf =>
+ {
+ TryParseNumeric(leaf.Value, out var numeric);
+ return $"{leaf.Label}={numeric.ToString("0.######", CultureInfo.InvariantCulture)}";
+ }));
+
+ return Synthesize(
+ parent.IecReference,
+ display,
+ "schema-safe-report-three-phase-aggregate",
+ ordered);
+ }
+
+ if (plan.Kind.Equals("DemandEnergy", StringComparison.OrdinalIgnoreCase))
+ {
+ var expected = plan.Leaves.SingleOrDefault();
+ if (expected is null ||
+ !leaves.TryGetValue(NormalizeReference(expected.Reference), out var leaf) ||
+ !TryParseNumeric(leaf.Value, out var numeric))
+ {
+ return null;
+ }
+
+ var display = numeric.ToString("0.######", CultureInfo.InvariantCulture);
+ return Synthesize(
+ parent.IecReference,
+ display,
+ "schema-safe-report-demand-energy-aggregate",
+ new[] { leaf });
+ }
+
+ return null;
+ }
+
+ private static NativeReportValueUpdate Synthesize(
+ string parentReference,
+ string value,
+ string projectionStatus,
+ IReadOnlyList leaves)
+ {
+ var latest = leaves
+ .OrderByDescending(leaf => leaf.UpdatedAt)
+ .First();
+ var quality = SharedUseful(leaves.Select(leaf => leaf.Quality));
+ var timestamp = SharedUseful(leaves.Select(leaf => leaf.Timestamp));
+ var reasons = leaves
+ .Select(leaf => leaf.Reason)
+ .Where(IsUseful)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ return new NativeReportValueUpdate
+ {
+ Reference = parentReference,
+ FunctionalConstraint = latest.Source.FunctionalConstraint,
+ Value = value,
+ Quality = quality,
+ Timestamp = timestamp,
+ Reason = reasons.Length == 0
+ ? "schema-safe static DataSet report aggregate"
+ : string.Join(",", reasons),
+ Source = "report",
+ ProjectionStatus = projectionStatus,
+ HasValue = true,
+ HasQuality = IsUsefulMetadata(quality),
+ HasTimestamp = IsUsefulMetadata(timestamp),
+ ReportControlReference = latest.Source.ReportControlReference,
+ ReportId = latest.Source.ReportId,
+ DataSetReference = latest.Source.DataSetReference,
+ SequenceNumber = latest.Source.SequenceNumber,
+ ConfRev = latest.Source.ConfRev,
+ ReportTimestamp = latest.Source.ReportTimestamp,
+ UpdatedAt = leaves.Max(leaf => leaf.UpdatedAt)
+ };
+ }
+
+ private static string SharedUseful(IEnumerable values)
+ {
+ var useful = values
+ .Where(IsUsefulMetadata)
+ .Select(value => value.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ return useful.Length == 1 ? useful[0] : string.Empty;
+ }
+
+ private static bool IsAggregate(string? reference)
+ => SignalDefinition.IsThreePhaseMeasurementAggregate(reference) ||
+ SignalDefinition.IsDemandEnergyAggregate(reference);
+
+ private static bool IsDescendant(string child, string parent)
+ => !string.IsNullOrWhiteSpace(child) &&
+ !string.IsNullOrWhiteSpace(parent) &&
+ child.Length > parent.Length &&
+ child.StartsWith(parent + ".", StringComparison.OrdinalIgnoreCase);
+
+ private static bool ReferencesEqual(string? left, string? right)
+ => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeReference(string? reference)
+ => (reference ?? string.Empty)
+ .Trim()
+ .Replace('$', '.')
+ .Replace("..", ".")
+ .ToLowerInvariant();
+
+ private static bool TryParseNumeric(string? value, out double numeric)
+ {
+ var token = (value ?? string.Empty)
+ .Trim()
+ .Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault() ?? string.Empty;
+ return double.TryParse(
+ token,
+ NumberStyles.Float | NumberStyles.AllowThousands,
+ CultureInfo.InvariantCulture,
+ out numeric);
+ }
+
+ private static bool IsUseful(string? value)
+ => !string.IsNullOrWhiteSpace(value) && value != "-";
+
+ private static bool IsUsefulMetadata(string? value)
+ {
+ if (!IsUseful(value))
+ return false;
+
+ var normalized = value!.Trim();
+ return !normalized.Equals("Unknown", StringComparison.OrdinalIgnoreCase) &&
+ !normalized.StartsWith("Pending", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs
new file mode 100644
index 000000000..2f499c89f
--- /dev/null
+++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs
@@ -0,0 +1,338 @@
+using System.Collections.Concurrent;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester;
+
+///
+/// UI-facing facade for the IEC 61850 runtime.
+///
+/// Native/network calls must never execute their synchronous prefix on the WPF Dispatcher.
+/// A Task-returning API is not sufficient protection because socket/vendor code may block
+/// before the first asynchronous yield.
+///
+/// Per IED, normal lifecycle/control operations remain serialized. Stop/Disconnect is a
+/// deliberately pre-emptive lane: it cancels the active operation first and invokes the
+/// runtime stop without waiting behind a hung Connect/Start gate. A cancelled/stale operation
+/// is never allowed to report success to its caller afterwards. Different IEDs stay fully
+/// independent. This facade changes lifecycle scheduling only; it does not add MMS polling,
+/// dynamic DataSet writes, or any acquisition fallback.
+///
+public sealed class Iec61850MonitorRuntime : IAsyncDisposable
+{
+ private static readonly TimeSpan DisposeBudget = TimeSpan.FromSeconds(3);
+
+ private sealed class DeviceOperationSlot
+ {
+ public object SyncRoot { get; } = new();
+ public SemaphoreSlim OperationGate { get; } = new(1, 1);
+ public SemaphoreSlim StopGate { get; } = new(1, 1);
+ public CancellationTokenSource? ActiveOperationCancellation { get; set; }
+ public long Generation { get; set; }
+ }
+
+ private readonly Services.Iec61850MonitorRuntime _inner = new();
+ private readonly ConcurrentDictionary _deviceSlots =
+ new(StringComparer.OrdinalIgnoreCase);
+ private int _disposeStarted;
+
+ public Iec61850MonitorRuntime()
+ {
+ _inner.Diagnostic += entry => Diagnostic?.Invoke(entry);
+ _inner.PointUpdated += snapshot => PointUpdated?.Invoke(snapshot);
+ _inner.EventRaised += entry => EventRaised?.Invoke(entry);
+ }
+
+ public event Action? Diagnostic;
+ public event Action? PointUpdated;
+ public event Action? EventRaised;
+
+ public int ConnectedDeviceCount => _inner.ConnectedDeviceCount;
+ public int MonitoringDeviceCount => _inner.MonitoringDeviceCount;
+
+ public Task> ConnectAndDiscoverAsync(
+ Iec61850MonitorDevice device,
+ CancellationToken cancellationToken,
+ IProgress? progress = null)
+ => RunDeviceOperationAsync(
+ device?.DeviceId,
+ cancellationToken,
+ token => _inner.ConnectAndDiscoverAsync(device, token, progress));
+
+ public Task ConnectUsingCachedModelAsync(
+ Iec61850MonitorDevice device,
+ CancellationToken cancellationToken,
+ IProgress? progress = null)
+ => RunDeviceOperationAsync(
+ device?.DeviceId,
+ cancellationToken,
+ token => _inner.ConnectUsingCachedModelAsync(device, token, progress));
+
+ public Task> StartMonitoringAsync(
+ Iec61850MonitorDevice device,
+ IEnumerable selectedSignals,
+ int pollingIntervalMs,
+ CancellationToken cancellationToken)
+ => RunDeviceOperationAsync(
+ device?.DeviceId,
+ cancellationToken,
+ token => _inner.StartMonitoringAsync(device, selectedSignals, pollingIntervalMs, token));
+
+ public Task InspectControlAsync(
+ string deviceId,
+ SignalDefinition signal,
+ CancellationToken cancellationToken)
+ => RunDeviceOperationAsync(
+ deviceId,
+ cancellationToken,
+ token => _inner.InspectControlAsync(deviceId, signal, token));
+
+ public Task ExecuteControlAsync(
+ string deviceId,
+ Iec61850ControlCommandRequest request,
+ CancellationToken cancellationToken)
+ => RunDeviceOperationAsync(
+ deviceId,
+ cancellationToken,
+ token => _inner.ExecuteControlAsync(deviceId, request, token));
+
+ public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId)
+ => _inner.CaptureHybridReportPhysicalValidation(deviceId);
+
+ public Task StopMonitoringAsync(string deviceId)
+ => RunPreemptiveStopAsync(deviceId, () => _inner.StopMonitoringAsync(deviceId));
+
+ public Task StopDeviceAsync(string deviceId)
+ => RunPreemptiveStopAsync(deviceId, () => _inner.StopDeviceAsync(deviceId));
+
+ private async Task RunDeviceOperationAsync(
+ string? deviceId,
+ CancellationToken cancellationToken,
+ Func operation)
+ {
+ ThrowIfDisposing();
+ ArgumentNullException.ThrowIfNull(operation);
+
+ var slot = GetSlot(deviceId);
+ await slot.OperationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ CancellationTokenSource? linkedCancellation = null;
+ long generation = 0;
+ try
+ {
+ // A Stop that is already in progress owns StopGate. Waiting here prevents a new
+ // Connect/Start from racing the teardown. We hold StopGate only while publishing
+ // the new active-operation identity; never for the network operation itself.
+ await slot.StopGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ lock (slot.SyncRoot)
+ {
+ slot.ActiveOperationCancellation = linkedCancellation;
+ generation = ++slot.Generation;
+ }
+ }
+ finally
+ {
+ slot.StopGate.Release();
+ }
+
+ await Task.Run(
+ async () => await operation(linkedCancellation.Token).ConfigureAwait(false),
+ CancellationToken.None)
+ .ConfigureAwait(false);
+
+ // Stop/Disconnect can pre-empt this lane. Even if a native call ignored
+ // cancellation and eventually returned success, a pre-empted generation must
+ // not re-enter the UI as a successful stale Connect/Start operation.
+ linkedCancellation.Token.ThrowIfCancellationRequested();
+ lock (slot.SyncRoot)
+ {
+ if (slot.Generation != generation)
+ throw new OperationCanceledException("IEC 61850 lifecycle operation was superseded.");
+ }
+ }
+ finally
+ {
+ if (linkedCancellation != null)
+ {
+ lock (slot.SyncRoot)
+ {
+ if (ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation))
+ slot.ActiveOperationCancellation = null;
+ }
+ linkedCancellation.Dispose();
+ }
+ slot.OperationGate.Release();
+ }
+ }
+
+ private async Task RunDeviceOperationAsync(
+ string? deviceId,
+ CancellationToken cancellationToken,
+ Func> operation)
+ {
+ ThrowIfDisposing();
+ ArgumentNullException.ThrowIfNull(operation);
+
+ var slot = GetSlot(deviceId);
+ await slot.OperationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ CancellationTokenSource? linkedCancellation = null;
+ long generation = 0;
+ try
+ {
+ await slot.StopGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ lock (slot.SyncRoot)
+ {
+ slot.ActiveOperationCancellation = linkedCancellation;
+ generation = ++slot.Generation;
+ }
+ }
+ finally
+ {
+ slot.StopGate.Release();
+ }
+
+ var result = await Task.Run(
+ async () => await operation(linkedCancellation.Token).ConfigureAwait(false),
+ CancellationToken.None)
+ .ConfigureAwait(false);
+
+ linkedCancellation.Token.ThrowIfCancellationRequested();
+ lock (slot.SyncRoot)
+ {
+ if (slot.Generation != generation)
+ throw new OperationCanceledException("IEC 61850 lifecycle operation was superseded.");
+ }
+ return result;
+ }
+ finally
+ {
+ if (linkedCancellation != null)
+ {
+ lock (slot.SyncRoot)
+ {
+ if (ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation))
+ slot.ActiveOperationCancellation = null;
+ }
+ linkedCancellation.Dispose();
+ }
+ slot.OperationGate.Release();
+ }
+ }
+
+ private async Task RunPreemptiveStopAsync(string? deviceId, Func stopOperation)
+ {
+ ThrowIfDisposing();
+ ArgumentNullException.ThrowIfNull(stopOperation);
+
+ var slot = GetSlot(deviceId);
+ CancellationTokenSource? activeCancellation;
+ lock (slot.SyncRoot)
+ {
+ 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.
+ try
+ {
+ activeCancellation?.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ // The active operation completed between snapshot and cancellation.
+ }
+
+ await slot.StopGate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ await Task.Run(
+ async () => await stopOperation().ConfigureAwait(false),
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ slot.StopGate.Release();
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
+ return;
+
+ foreach (var slot in _deviceSlots.Values)
+ {
+ CancellationTokenSource? activeCancellation;
+ lock (slot.SyncRoot)
+ {
+ activeCancellation = slot.ActiveOperationCancellation;
+ ++slot.Generation;
+ }
+ try
+ {
+ activeCancellation?.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Benign completion race during process teardown.
+ }
+ }
+
+ Task disposeTask;
+ try
+ {
+ disposeTask = Task.Run(async () => await _inner.DisposeAsync().ConfigureAwait(false));
+ }
+ catch
+ {
+ return;
+ }
+
+ var completed = await Task.WhenAny(disposeTask, Task.Delay(DisposeBudget)).ConfigureAwait(false);
+ if (completed == disposeTask)
+ {
+ try
+ {
+ await disposeTask.ConfigureAwait(false);
+ }
+ catch
+ {
+ // Shutdown is best-effort. Native teardown failure must not freeze WPF.
+ }
+ }
+ else
+ {
+ _ = disposeTask.ContinueWith(
+ static task => _ = task.Exception,
+ CancellationToken.None,
+ TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+ }
+
+ // Do not Dispose the per-device semaphores here. An uncooperative native operation
+ // can still unwind after the bounded shutdown budget and its finally block must be
+ // able to Release() safely. They become process-lifetime garbage with this facade.
+ _deviceSlots.Clear();
+ }
+
+ private DeviceOperationSlot GetSlot(string? deviceId)
+ => _deviceSlots.GetOrAdd(NormalizeDeviceKey(deviceId), static _ => new DeviceOperationSlot());
+
+ private void ThrowIfDisposing()
+ {
+ if (Volatile.Read(ref _disposeStarted) != 0)
+ throw new ObjectDisposedException(nameof(Iec61850MonitorRuntime));
+ }
+
+ private static string NormalizeDeviceKey(string? deviceId)
+ => string.IsNullOrWhiteSpace(deviceId) ? "__unbound__" : deviceId.Trim();
+}
diff --git a/docs/static-dataset-report-only-contract.md b/docs/static-dataset-report-only-contract.md
new file mode 100644
index 000000000..b9df6ab8e
--- /dev/null
+++ b/docs/static-dataset-report-only-contract.md
@@ -0,0 +1,47 @@
+# Static DataSet report-only contract
+
+This document is the acceptance boundary for ARSAS Static DataSet monitoring.
+
+## Acquisition authority
+
+- The imported/live ARIEC Static DataSet membership is the signal-selection source of truth.
+- One user-visible runtime row is selected for each exact DataSet membership identity; browsed aliases do not become extra rows merely because they share a DataSetReference.
+- Configured static BRCB/URCB plus General Interrogation are the process-value acquisition path.
+- Cyclic MMS process polling is disabled in this mode.
+- Dynamic DataSet writes are disabled in this mode.
+- ARIEC planner fallback candidates are diagnostic evidence only and must never become silent MMS polling.
+
+Manual / Select Signals mode keeps the normal Hybrid acquisition behavior and is intentionally outside this contract.
+
+## Deterministic configured-RCB path
+
+Static DataSet mode does not pass configured SCL reporting through the adaptive Hybrid acquisition planner. The deterministic sequence is:
+
+1. Take the exact DataSet membership and RCB -> DataSet binding from the opened/live SCL model.
+2. Verify that the exact configured RCB object exists on the active MMS association.
+3. Read the exact live DataSet directory to obtain a non-empty ordered member list. This is report-index mapping evidence, not process-value polling.
+4. Reject an explicit SCL/live `DatSet` mismatch; never guess another RCB or DataSet.
+5. Install the InformationReport receiver before enabling reporting.
+6. Write `RptEna=true`, then request `GI=true` through the normal one-shot MMS control plane.
+7. Map report values by ordered DataSet member index and project them through the exact semantic report schema.
+
+Missing reservation metadata is not allowed to cancel an otherwise exact configured static RCB before an actual enable attempt. Conversely, a missing exact RCB, unreadable/empty DataSet directory, or explicit SCL/live binding mismatch fails closed. None of these conditions enables cyclic MMS process-value reads.
+
+## Structured report values
+
+ARIEC may decode one structured Static DataSet member into exact semantic scalar descendants. ARSAS may reconstruct the parent DataSet row only when the live/SCL schema proves every required descendant identity. This projection must fail closed: no positional phase guessing, prefix/fuzzy matching, sibling substitution, or MMS fallback is allowed.
+
+Examples covered by the schema-safe report projection are three-phase THD aggregates such as `ThdA` / `ThdPPV` and demand-energy aggregate values such as `DmdWhMV` when the authoritative model exposes the required scalar leaf.
+
+## Physical acceptance
+
+A Static DataSet physical test passes this contract only when:
+
+1. Live Signal Values contains the exact Static DataSet membership set rather than duplicate `cVal` / `instCVal` aliases.
+2. The Acquisition column contains only configured static report acquisition (`StaticBrcb` / `StaticUrcb`) for resolved values; it contains no `MMS polling` rows.
+3. General Interrogation can supply the initial report image for schema-safe reportable members.
+4. A DataSet with no configured RCB remains explicitly unavailable instead of being sent through Hybrid planning or MMS polling.
+5. Unsupported or ambiguous report projection remains explicitly Pending/unresolved instead of silently switching acquisition method.
+6. Stop/start/reconnect and FAT attach/detach preserve the same report-only authority without freezing the WPF UI.
+
+This keeps the Static DataSet workflow deterministic and prevents a readability or feature change from regressing the established report-only protocol contract.
diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json
index 2e101141b..5d8014d0b 100644
--- a/engines/ARIEC61850.lock.json
+++ b/engines/ARIEC61850.lock.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
- "commit": "b9ee5fc9650b72e69bc81287a1e1b047ad19b054",
- "sourcePullRequest": 106,
- "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam."
+ "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc",
+ "sourcePullRequest": 111,
+ "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling."
}
diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs
new file mode 100644
index 000000000..af8ab324a
--- /dev/null
+++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs
@@ -0,0 +1,78 @@
+namespace ARSAS.Tests;
+
+public sealed class DeterministicStaticReportPathRegressionTests
+{
+ [Fact]
+ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling()
+ {
+ var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs");
+
+ Assert.Contains("Deterministic Static DataSet configured-RCB path", source, StringComparison.Ordinal);
+ Assert.Contains("model.ReportControls", source, StringComparison.Ordinal);
+ Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal);
+ Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal);
+ Assert.Contains("MmsReportSubscriptionPlanStatus.ReadyRequiresWrite", source, StringComparison.Ordinal);
+ Assert.Contains("triggerGeneralInterrogation: true", source, StringComparison.Ordinal);
+ Assert.Contains("deleteDynamicDataSetOnStop: false", source, StringComparison.Ordinal);
+ Assert.Contains("PollingPointKeys = Array.Empty()", source, StringComparison.Ordinal);
+ Assert.Contains("PollingFallbackSignalCount = 0", source, StringComparison.Ordinal);
+
+ Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("BuildDynamicPlan", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ReadValueAsync", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void StaticPath_RequiresExactConfiguredRcbAndOrderedLiveDataSetDirectory()
+ {
+ var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs");
+
+ Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal);
+ Assert.Contains("SameStaticReference(candidate.Reference, configured.Reference)", source, StringComparison.Ordinal);
+ Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal);
+ Assert.Contains("directory.Members", source, StringComparison.Ordinal);
+ Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal);
+ Assert.Contains("SCL binds", source, StringComparison.Ordinal);
+ Assert.Contains("live DatSet reports", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void HybridEntryPoints_RouteStaticModeToDeterministicPath()
+ {
+ var hybrid = Read("Services/NativeIec61850Client.HybridReporting.cs");
+
+ Assert.Contains("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)", hybrid, StringComparison.Ordinal);
+ Assert.Contains("BuildStaticDataSetReportPlansAsync", hybrid, StringComparison.Ordinal);
+ Assert.Contains("_deterministicStaticSubscriptions.ContainsKey(plan.PlanId)", hybrid, StringComparison.Ordinal);
+ Assert.Contains("StartStaticDataSetReportMonitorAsync", hybrid, StringComparison.Ordinal);
+ Assert.DoesNotContain("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId)", hybrid, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ManualHybridPlanner_RemainsAvailableOutsideStaticRoute()
+ {
+ var hybrid = Read("Services/NativeIec61850Client.HybridReporting.cs");
+
+ Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", hybrid, StringComparison.Ordinal);
+ Assert.Contains("AllowDynamicBrcb", hybrid, StringComparison.Ordinal);
+ Assert.Contains("AllowPollingFallback = true", hybrid, StringComparison.Ordinal);
+ }
+
+ private static string Read(string relativePath)
+ => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal);
+
+ private static string FindRepoFile(string relativePath)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, relativePath);
+ if (File.Exists(candidate))
+ return candidate;
+ directory = directory.Parent;
+ }
+
+ throw new FileNotFoundException(relativePath);
+ }
+}
diff --git a/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs b/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs
new file mode 100644
index 000000000..eb7b3650e
--- /dev/null
+++ b/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs
@@ -0,0 +1,70 @@
+namespace ARSAS.Tests;
+
+public sealed class GlobalSntpLifecycleRegressionTests
+{
+ [Fact]
+ public void MainHeader_ExposesGlobalSntpToggleWithActualBoundServerIp()
+ {
+ var source = File.ReadAllText(FindRepoFile("MainWindow.ClockSyncToggle.cs"));
+
+ Assert.Contains("WorkflowNavShell.Parent is not Grid headerGrid", source, StringComparison.Ordinal);
+ Assert.Contains("Name = \"GlobalSntpServerToggle\"", source, StringComparison.Ordinal);
+ Assert.Contains("Grid.SetColumn(toggle, 2)", source, StringComparison.Ordinal);
+ Assert.Contains("SNTP Server Active:", source, StringComparison.Ordinal);
+ Assert.Contains("snapshot.Binding?.LocalAddress", source, StringComparison.Ordinal);
+ Assert.Contains("Directed broadcast", source, StringComparison.Ordinal);
+ Assert.Contains("continues outside FAT", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GlobalLifecycle_FollowsConnectedIedsAndCannotRestartAfterToggleOff()
+ {
+ var source = File.ReadAllText(FindRepoFile("MainWindow.ClockSync.cs"));
+
+ Assert.Contains("Devices.CollectionChanged += ClockSyncDevices_CollectionChanged", source, StringComparison.Ordinal);
+ Assert.Contains("device.PropertyChanged += ClockSyncDevice_PropertyChanged", source, StringComparison.Ordinal);
+ Assert.Contains("EnsureStartedAsync(iedAddress", source, StringComparison.Ordinal);
+ Assert.Contains("if (!_clockSyncEnabled || !device.IsConnected)", source, StringComparison.Ordinal);
+ Assert.Contains("Closed += ClockSyncMainWindow_Closed", source, StringComparison.Ordinal);
+ Assert.Contains("await _sntpClockService.DisposeAsync()", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void FatWorkspace_IsPassiveConsumerOfGlobalSntpState()
+ {
+ var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.ClockSyncUx.cs"));
+
+ Assert.Contains("Global SNTP is controlled from the ARSAS header", source, StringComparison.Ordinal);
+ Assert.Contains("continues running when the FAT window closes", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("new CheckBox", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("SetClockSyncEnabledAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ClockSyncCheckBox_Changed", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GlobalService_RemainsARealUdp123ServerAndMode5Broadcaster()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/SntpClockService.cs"));
+
+ Assert.Contains("new IPEndPoint(binding.LocalAddress, 123)", source, StringComparison.Ordinal);
+ Assert.Contains("SntpPacket.BuildServerReply", source, StringComparison.Ordinal);
+ Assert.Contains("SntpPacket.BuildBroadcast", source, StringComparison.Ordinal);
+ Assert.Contains("Mode 5 broadcast", source, StringComparison.Ordinal);
+ Assert.Contains("SendBroadcastPacketAsync", source, StringComparison.Ordinal);
+ Assert.Contains("TimeSpan.FromSeconds(64)", source, StringComparison.Ordinal);
+ }
+
+ private static string FindRepoFile(string relativePath)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, relativePath);
+ if (File.Exists(candidate))
+ return candidate;
+ directory = directory.Parent;
+ }
+
+ throw new FileNotFoundException($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'.");
+ }
+}
diff --git a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs
index 6399a9938..929cfe1b4 100644
--- a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs
@@ -5,14 +5,15 @@ namespace ARSAS.Tests;
public sealed class IoFatP53PartialLiveRegressionTests
{
[Fact]
- public void Preparation_AllowsUsablePartialSclLiveScopeWithoutMutatingOperatorSelection()
+ public void Preparation_AllowsUsablePartialSclBindingWithoutMutatingOperatorSelection()
{
var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs"));
Assert.Contains("mayProceedWithPartialSclSelection", source, StringComparison.Ordinal);
Assert.Contains("selection.Matches.Count > 0", source, StringComparison.Ordinal);
Assert.Contains("unresolvedSelectionPoints.All(IoTestSignalSelectionService.IsDirectSclAuthority)", source, StringComparison.Ordinal);
- Assert.Contains("if (liveCount == 0)", source, StringComparison.Ordinal);
+ Assert.Contains("if (boundCount == 0)", source, StringComparison.Ordinal);
+ Assert.Contains("FAT is an evidence consumer", source, StringComparison.Ordinal);
Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal);
Assert.DoesNotContain("RemoveFromFat()", source, StringComparison.Ordinal);
Assert.Contains("checkbox or FAT disposition is changed by the engine", source, StringComparison.OrdinalIgnoreCase);
diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs
index 047813963..74d798cdf 100644
--- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs
+++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs
@@ -160,13 +160,16 @@ public void PreviewKeepsProgressOnIedCardOnly()
}
[Fact]
- public void IoFatPreparation_UsesCachedReconnect_AndReturnsImmediatelyAfterMonitorStart()
+ public void IoFatPreparation_UsesCachedReconnect_AndAttachesToSharedMonitorWithoutRestart()
{
var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs"));
Assert.Contains("Fast reconnect", source, StringComparison.Ordinal);
Assert.Contains("ConnectUsingSavedModelAsync", source, StringComparison.Ordinal);
- Assert.Contains("Return control to FAT immediately", source, StringComparison.Ordinal);
+ Assert.Contains("reuseSharedSclAcquisition", source, StringComparison.Ordinal);
+ Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal);
+ Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal);
+ Assert.Contains("Bind FAT rows to the already-owned per-IED monitor points", source, StringComparison.Ordinal);
Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal);
Assert.Contains("IsReportSource", source, StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs b/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs
index 9048e1342..9f562587a 100644
--- a/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs
+++ b/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs
@@ -8,9 +8,10 @@ public void FatWorkspace_UsesFastVerificationCadenceWithoutBypassingReportFirstP
var main = Read("MainWindow.IoTesting.cs");
var planner = Read("Services/Iec61850ReportPlanner.cs");
- // FAT may temporarily tighten MMS verification cadence, but that cadence must not
- // classify fast status/protection points as polling-only. Report planning remains
- // static first, then dynamic, with MMS polling reserved for the residual fallback.
+ // Legacy workbook-only FAT may temporarily tighten MMS verification cadence, but
+ // that cadence must not classify fast status/protection points as polling-only.
+ // Shared SCL workspaces are guarded separately: FAT consumes the Engineering
+ // acquisition session and never owns a second polling runtime.
Assert.Contains("private const int IoFatPollingIntervalMs = 250;", main, StringComparison.Ordinal);
Assert.Contains("_pollingIntervalBeforeIoFat", main, StringComparison.Ordinal);
Assert.Contains("PollingIntervalMs = Math.Min(PollingIntervalMs, IoFatPollingIntervalMs);", main, StringComparison.Ordinal);
@@ -22,13 +23,15 @@ public void FatWorkspace_UsesFastVerificationCadenceWithoutBypassingReportFirstP
}
[Fact]
- public void FatConnect_ReadinessDoesNotBlockOnLegacyEightToEighteenSecondReportSettle()
+ public void SharedSclFat_ReadinessDoesNotRestartMonitorOrWaitForLegacyReportSettle()
{
var source = Read("MainWindow.IoTesting.AutoConnect.cs");
- Assert.Contains("Return control to FAT immediately", source, StringComparison.Ordinal);
+ Assert.Contains("reuseSharedSclAcquisition", source, StringComparison.Ordinal);
+ Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal);
+ Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal);
+ Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal);
Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal);
- Assert.Contains("fast MMS verification", source, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("TimeSpan.FromSeconds(8)", source, StringComparison.Ordinal);
Assert.DoesNotContain("TimeSpan.FromSeconds(10)", source, StringComparison.Ordinal);
Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.OrdinalIgnoreCase);
diff --git a/tests/ARSAS.Tests/IoTestingUiContractTests.cs b/tests/ARSAS.Tests/IoTestingUiContractTests.cs
index 1ad83cc40..1e430db40 100644
--- a/tests/ARSAS.Tests/IoTestingUiContractTests.cs
+++ b/tests/ARSAS.Tests/IoTestingUiContractTests.cs
@@ -162,15 +162,16 @@ public void IoTestingWindow_UsesSelectedIedContextAndWorkspacePreviewToggle()
}
[Fact]
- public void IoFatAutomaticPreparation_UsesFastCommissioningAcquisitionWithoutProcessControls()
+ public void IoFatAutomaticPreparation_ReusesSharedSclAcquisitionWithoutProcessControls()
{
var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs"));
- Assert.Contains("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal);
- Assert.Contains("ConnectAndConfigureDeviceAsync", source, StringComparison.Ordinal);
- Assert.Contains("StartDeviceMonitorAsync", source, StringComparison.Ordinal);
- Assert.Contains("deterministic fast MMS", source, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("Return control to FAT immediately", source, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("reuseSharedSclAcquisition", source, StringComparison.Ordinal);
+ Assert.Contains("sharedStaticDataSetAuthority", source, StringComparison.Ordinal);
+ Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal);
+ Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal);
+ Assert.Contains("FAT is an evidence consumer", source, StringComparison.Ordinal);
+ Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal);
Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal);
Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.OrdinalIgnoreCase);
diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs
new file mode 100644
index 000000000..3594d1ace
--- /dev/null
+++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs
@@ -0,0 +1,77 @@
+namespace ARSAS.Tests;
+
+public sealed class P0LifecycleAndTotPfRegressionTests
+{
+ [Fact]
+ public void MainWindow_RuntimeFacade_OffloadsLifecycle_AndStopCanPreemptHungOperation()
+ {
+ var facade = Read("Services/UiResponsiveIec61850MonitorRuntimeFacade.cs");
+ var main = Read("MainWindow.xaml.cs");
+
+ Assert.Contains("namespace ArIED61850Tester;", facade, StringComparison.Ordinal);
+ Assert.Contains("public sealed class Iec61850MonitorRuntime", facade, StringComparison.Ordinal);
+ Assert.Contains("Services.Iec61850MonitorRuntime _inner", facade, StringComparison.Ordinal);
+ Assert.Contains("ConcurrentDictionary _deviceSlots", facade, StringComparison.Ordinal);
+ Assert.Contains("SemaphoreSlim OperationGate", facade, StringComparison.Ordinal);
+ Assert.Contains("SemaphoreSlim StopGate", facade, StringComparison.Ordinal);
+ 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);
+ 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);
+ Assert.Contains("Do not Dispose the per-device semaphores here", facade, StringComparison.Ordinal);
+
+ // MainWindow intentionally uses the unqualified type from its own namespace, so the
+ // responsive facade is the UI boundary without altering protocol/runtime ownership.
+ Assert.Contains("private readonly Iec61850MonitorRuntime _runtime = new();", main, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void FatWindow_Close_KeepsUiBoundSessionMutationOnDispatcher_AndOffloadsPersistence()
+ {
+ var lifecycle = Read("IoListTestingWindow.P0Lifecycle.cs");
+
+ Assert.Contains("Closing -= Window_Closing", lifecycle, StringComparison.Ordinal);
+ Assert.Contains("Closing += P0Window_Closing", lifecycle, StringComparison.Ordinal);
+ Assert.Contains("var stopAll = Session.StopAll(", lifecycle, StringComparison.Ordinal);
+ Assert.DoesNotContain("Task.Run(() =>\n Session.StopAll", lifecycle, StringComparison.Ordinal);
+ Assert.Contains("await Task.Run(Storage.SaveNow)", lifecycle, StringComparison.Ordinal);
+ Assert.Contains("e.Cancel = true", lifecycle, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void StaticReport_TotPf_UsesExactSemanticEngineAuthorityWithoutMmsFallback()
+ {
+ var engineLock = Read("engines/ARIEC61850.lock.json");
+ var semanticBridge = Read("Services/NativeIec61850Client.SemanticReporting.cs");
+ var runtime = Read("Services/Iec61850MonitorRuntime.cs");
+ var projection = Read("Services/StaticDataSetReportProjectionAccumulator.cs");
+
+ Assert.Contains("\"sourcePullRequest\": 111", engineLock, StringComparison.Ordinal);
+ Assert.Contains("69bfe70e2c779c7e8268af087bd1a3a38986c0fc", engineLock, StringComparison.Ordinal);
+ Assert.Contains("MmsSemanticReportValueProjector.Project", semanticBridge, StringComparison.Ordinal);
+ Assert.Contains("session.StaticReportProjection.Project", runtime, StringComparison.Ordinal);
+ Assert.Contains("MMS process fallback is disabled", runtime, StringComparison.Ordinal);
+ Assert.Contains(".mag.f", projection, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains(".instmag.f", projection, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string Read(string relativePath)
+ => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal);
+
+ private static string FindRepoFile(string relativePath)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, relativePath);
+ if (File.Exists(candidate))
+ return candidate;
+ directory = directory.Parent;
+ }
+
+ throw new FileNotFoundException(relativePath);
+ }
+}
diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs
index 7c01ed719..50a41a62b 100644
--- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs
+++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs
@@ -18,7 +18,7 @@ public void StaticMode_DisablesDynamicWrites_AndManualModeRestoresPriorValue()
}
[Fact]
- public void StaticSelection_AllowsOnlyRuntimeSignalsWithExplicitDataSetAuthority()
+ public void StaticSelectionPolicy_StillRejectsBrowsedAndControlSignals()
{
var dataSetLeaf = Signal("IEDLD/MMXU1.TotW.mag.f", "IEDLD/LLN0.Analog");
var browsedLeaf = Signal("IEDLD/MMXU1.Hz.mag.f", string.Empty);
@@ -40,13 +40,105 @@ public void RuntimeContract_StaticDataSetMode_DoesNotScheduleCyclicMmsProcessPol
}
[Fact]
- public void SharedSclStaticSelection_UsesDatasetAuthorityPolicy_NotSelectEverything()
+ public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatasetTaggedAlias()
{
var source = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs"));
+ var authority = File.ReadAllText(FindRepoFile("Services/Iec61850StaticDataSetAuthoritySelection.cs"));
+
Assert.Contains("Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device)", source, StringComparison.Ordinal);
+ Assert.Contains("Iec61850StaticDataSetAuthoritySelection.Build(device)", source, StringComparison.Ordinal);
+ Assert.Contains("signal.IsSelected = authoritativeSignals.Contains(signal)", source, StringComparison.Ordinal);
Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device)", source, StringComparison.Ordinal);
- Assert.Contains("Iec61850StaticDataSetSelectionPolicy.IsEligible(signal)", source, StringComparison.Ordinal);
- Assert.DoesNotContain("signal.IsSelected = !string.IsNullOrWhiteSpace(signal.DataSetReference)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("Iec61850StaticDataSetSelectionPolicy.IsEligible(signal)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("fallback remains available", source, StringComparison.OrdinalIgnoreCase);
+
+ Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)", authority, StringComparison.Ordinal);
+ Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal);
+ Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal);
+ Assert.DoesNotContain("Contains(memberReference", authority, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void StaticDataSetRegressionGuard_PreservesFa16ReportOnlyDirection()
+ {
+ var shared = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs"));
+ var registry = File.ReadAllText(FindRepoFile("Services/Iec61850MonitoringModeRegistry.cs"));
+
+ Assert.Contains("UseStaticDataSetReportOnly(device)", shared, StringComparison.Ordinal);
+ Assert.Contains("Static DataSet report-only authority selected", shared, StringComparison.Ordinal);
+ Assert.Contains("cyclic MMS process polling and dynamic DataSet writes remain disabled", shared, StringComparison.Ordinal);
+ Assert.Contains("state.StaticDataSetReportOnly = true", registry, StringComparison.Ordinal);
+ Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", registry, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void FatMode_ReusesSharedStaticDataSetAuthority_InsteadOfDemotingToMms()
+ {
+ var shared = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs"));
+ var fatMonitor = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.MultiIedMonitor.cs"));
+
+ Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds", shared, StringComparison.Ordinal);
+ Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId)", shared, StringComparison.Ordinal);
+ Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId)", shared, StringComparison.Ordinal);
+ Assert.Contains("IsSharedStaticDataSetAuthority(device)", fatMonitor, StringComparison.Ordinal);
+ Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device)", fatMonitor, StringComparison.Ordinal);
+ Assert.Contains("FAT reuses the shared Static DataSet report-only authority", fatMonitor, StringComparison.Ordinal);
+ Assert.DoesNotContain("Iec61850MonitoringModeRegistry.UseHybrid(device)", fatMonitor, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void SharedSclFat_IsConsumerOfExistingAcquisitionSession_NotASecondMonitorOwner()
+ {
+ var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs"));
+
+ Assert.Contains("var reuseSharedSclAcquisition", source, StringComparison.Ordinal);
+ Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal);
+ Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal);
+ Assert.Contains("one IED owns one acquisition session", source, StringComparison.Ordinal);
+ Assert.Contains("FAT is only a", source, StringComparison.Ordinal);
+ Assert.Contains("consumer", source, StringComparison.Ordinal);
+ Assert.Contains("if (reuseSharedSclAcquisition)", source, StringComparison.Ordinal);
+ Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal);
+ Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal);
+
+ // The legacy helper may remain for workbook-only FAT, but the shared SCL branch
+ // must be structurally separate and must start through the normal Engineering owner.
+ var sharedBranchStart = source.IndexOf("if (reuseSharedSclAcquisition)", StringComparison.Ordinal);
+ var legacyBranchStart = source.IndexOf("Compatibility path for non-shared/legacy FAT only", StringComparison.Ordinal);
+ Assert.True(sharedBranchStart >= 0 && legacyBranchStart > sharedBranchStart);
+
+ var sharedBranch = source[sharedBranchStart..legacyBranchStart];
+ Assert.DoesNotContain("StopDeviceMonitorAsync(device)", sharedBranch, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartIoFatDeviceMonitorAsync(device", sharedBranch, StringComparison.Ordinal);
+ Assert.DoesNotContain("initial MMS", sharedBranch, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void SharedStaticFat_DoesNotAddSupplementalSignalOrMisreportPendingAsReportBacked()
+ {
+ var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs"));
+
+ Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal);
+ Assert.Contains("IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device)", source, StringComparison.Ordinal);
+ Assert.Contains("source.Contains(\"pending\"", source, StringComparison.Ordinal);
+ Assert.Contains("source.Contains(\"unavailable\"", source, StringComparison.Ordinal);
+ Assert.Contains("static report live", source, StringComparison.Ordinal);
+ Assert.Contains("pending/unavailable", source, StringComparison.Ordinal);
+ Assert.Contains("MMS polling", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void SharedStaticFat_EmitsCausalReportEvidenceWithoutMmsFallback()
+ {
+ var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs"));
+
+ Assert.Contains("ObserveSharedStaticReportEvidenceAsync", source, StringComparison.Ordinal);
+ Assert.Contains("waiting for actual InformationReport traffic", source, StringComparison.Ordinal);
+ Assert.Contains("actual InformationReport traffic observed", source, StringComparison.Ordinal);
+ Assert.Contains("no InformationReport traffic was observed", source, StringComparison.Ordinal);
+ Assert.Contains("will NOT switch process values to cyclic MMS polling", source, StringComparison.Ordinal);
}
private static SignalDefinition Signal(string reference, string dataSetReference)
diff --git a/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs b/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs
new file mode 100644
index 000000000..474b5c545
--- /dev/null
+++ b/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs
@@ -0,0 +1,303 @@
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class StaticDataSetReportProjectionAccumulatorTests
+{
+ [Fact]
+ public void ThdA_SemanticLeaves_ReconstructOneStaticParentValue()
+ {
+ const string parent = "IEDLD0/I_MHAI1.ThdA";
+ var model = Model(DataObject(
+ parent,
+ "ThdA",
+ Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"),
+ Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"),
+ Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f")));
+ var point = Point(parent);
+ var accumulator = new StaticDataSetReportProjectionAccumulator();
+
+ Assert.Empty(accumulator.Project(model, new[] { point }, Update(parent + ".phsA.cVal.mag.f", "1.25")));
+ Assert.Empty(accumulator.Project(model, new[] { point }, Update(parent + ".phsB.cVal.mag.f", "2.5")));
+
+ var projected = Assert.Single(accumulator.Project(
+ model,
+ new[] { point },
+ Update(parent + ".phsC.cVal.mag.f", "3.75")));
+
+ Assert.Equal(parent, projected.Reference, ignoreCase: true);
+ Assert.Equal("A=1.25, B=2.5, C=3.75", projected.Value);
+ Assert.Equal("good", projected.Quality, ignoreCase: true);
+ Assert.Equal("2026-09-04 16:00:00.000", projected.Timestamp);
+ Assert.Equal("schema-safe-report-three-phase-aggregate", projected.ProjectionStatus);
+ Assert.True(projected.HasValue);
+ Assert.True(projected.HasQuality);
+ Assert.True(projected.HasTimestamp);
+ }
+
+ [Fact]
+ public void ReportCompanionQualityAndTimestamp_ArrivingBeforeSemanticValue_ArePreserved()
+ {
+ const string parent = "IEDLD0/I_MHAI1.ThdA";
+ const string phaseA = parent + ".phsA.cVal.mag.f";
+ const string phaseB = parent + ".phsB.cVal.mag.f";
+ const string phaseC = parent + ".phsC.cVal.mag.f";
+ var model = Model(DataObject(
+ parent,
+ "ThdA",
+ Attribute(phaseA, "phsA.cVal.mag.f"),
+ Attribute(phaseB, "phsB.cVal.mag.f"),
+ Attribute(phaseC, "phsC.cVal.mag.f")));
+ var parentPoint = Point(parent);
+ var phaseAPoint = Point(phaseA);
+ var monitored = new[] { parentPoint, phaseAPoint };
+ var accumulator = new StaticDataSetReportProjectionAccumulator();
+
+ // ARIEC may expose q/t on the phase structure before its semantic cVal.mag.f leaf.
+ accumulator.Project(model, monitored, CompanionUpdate(parent + ".phsA"));
+ accumulator.Project(model, monitored, CompanionUpdate(parent + ".phsB"));
+ accumulator.Project(model, monitored, CompanionUpdate(parent + ".phsC"));
+
+ var phaseAUpdates = accumulator.Project(model, monitored, ValueOnlyUpdate(phaseA, "1"));
+ var exactPhaseA = Assert.Single(phaseAUpdates, value =>
+ value.Reference.Equals(phaseA, StringComparison.OrdinalIgnoreCase));
+ Assert.Equal("questionable", exactPhaseA.Quality, ignoreCase: true);
+ Assert.Equal("2026-09-04 16:20:51.480", exactPhaseA.Timestamp);
+ Assert.True(exactPhaseA.HasQuality);
+ Assert.True(exactPhaseA.HasTimestamp);
+
+ Assert.Empty(accumulator.Project(model, monitored, ValueOnlyUpdate(phaseB, "2")));
+ var finalUpdates = accumulator.Project(model, monitored, ValueOnlyUpdate(phaseC, "3"));
+ var aggregate = Assert.Single(finalUpdates, value =>
+ value.Reference.Equals(parent, StringComparison.OrdinalIgnoreCase));
+ Assert.Equal("A=1, B=2, C=3", aggregate.Value);
+ Assert.Equal("questionable", aggregate.Quality, ignoreCase: true);
+ Assert.Equal("2026-09-04 16:20:51.480", aggregate.Timestamp);
+ Assert.True(aggregate.HasQuality);
+ Assert.True(aggregate.HasTimestamp);
+ }
+
+ [Fact]
+ public void ThdA_InstantaneousSibling_DoesNotOverrideCanonicalCValPlan()
+ {
+ const string parent = "IEDLD0/I_MHAI1.ThdA";
+ var model = Model(DataObject(
+ parent,
+ "ThdA",
+ Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"),
+ Attribute(parent + ".phsA.instCVal.mag.f", "phsA.instCVal.mag.f"),
+ Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"),
+ Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f")));
+ var accumulator = new StaticDataSetReportProjectionAccumulator();
+
+ Assert.Empty(accumulator.Project(
+ model,
+ new[] { Point(parent) },
+ Update(parent + ".phsA.instCVal.mag.f", "99")));
+ }
+
+ [Fact]
+ public void DmdWhMV_ExactMagnitude_ReconstructsParentWithoutMmsRead()
+ {
+ const string parent = "IEDLD0/XPRE_MMTR1.DmdWhMV";
+ var model = Model(DataObject(
+ parent,
+ "DmdWhMV",
+ Attribute(parent + ".instMag.f", "instMag.f"),
+ Attribute(parent + ".mag.f", "mag.f")));
+ var accumulator = new StaticDataSetReportProjectionAccumulator();
+
+ Assert.Empty(accumulator.Project(
+ model,
+ new[] { Point(parent) },
+ Update(parent + ".instMag.f", "9.9")));
+
+ var projected = Assert.Single(accumulator.Project(
+ model,
+ new[] { Point(parent) },
+ Update(parent + ".mag.f", "1.055")));
+
+ Assert.Equal(parent, projected.Reference, ignoreCase: true);
+ Assert.Equal("1.055", projected.Value);
+ Assert.Equal("schema-safe-report-demand-energy-aggregate", projected.ProjectionStatus);
+ }
+
+ [Fact]
+ public void ExactSelectedChild_IsPreservedAlongsideCompletedParentAggregate()
+ {
+ const string parent = "IEDLD0/I_MHAI1.ThdA";
+ const string child = "IEDLD0/I_MHAI1.ThdA.phsC.cVal.mag.f";
+ var model = Model(DataObject(
+ parent,
+ "ThdA",
+ Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"),
+ Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"),
+ Attribute(child, "phsC.cVal.mag.f")));
+ var parentPoint = Point(parent);
+ var childPoint = Point(child);
+ var accumulator = new StaticDataSetReportProjectionAccumulator();
+
+ accumulator.Project(model, new[] { parentPoint, childPoint }, Update(parent + ".phsA.cVal.mag.f", "1"));
+ accumulator.Project(model, new[] { parentPoint, childPoint }, Update(parent + ".phsB.cVal.mag.f", "2"));
+ var updates = accumulator.Project(model, new[] { parentPoint, childPoint }, Update(child, "3"));
+
+ Assert.Equal(2, updates.Count);
+ Assert.Contains(updates, update => update.Reference.Equals(child, StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(updates, update => update.Reference.Equals(parent, StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void RuntimeSource_StaticMode_UsesSchemaSafeReportAuthority_AndNoMmsFallback()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs"));
+
+ Assert.Contains("StaticDataSetReportProjectionAccumulator StaticReportProjection", source, StringComparison.Ordinal);
+ Assert.Contains("!session.StaticDataSetReportOnly && RequiresExactMmsValueAuthority", source, StringComparison.Ordinal);
+ Assert.Contains("HasSchemaProvenReportValueAuthority(point, update)", source, StringComparison.Ordinal);
+ Assert.Contains("Engine fallback candidates are diagnostic only and are not scheduled as MMS process polling", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("structured report projection is not yet schema-proven; unsafe MMS fallback is disabled", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void IpWizard_DoesNotRunEditableAddressBindingOnEveryKeystroke()
+ {
+ var xaml = File.ReadAllText(FindRepoFile("IpConnectWizardWindow.xaml"));
+
+ Assert.Contains("IsTextSearchEnabled=\"False\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("RelayIpAddress, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=LostFocus", xaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("RelayIpAddress, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=PropertyChanged", xaml, StringComparison.Ordinal);
+ }
+
+ private static NativeReportValueUpdate Update(string reference, string value)
+ => new()
+ {
+ Reference = reference,
+ FunctionalConstraint = "MX",
+ Value = value,
+ Quality = "good",
+ Timestamp = "2026-09-04 16:00:00.000",
+ Reason = "integrity",
+ ProjectionStatus = "semantic-structured-leaf",
+ HasValue = true,
+ HasQuality = true,
+ HasTimestamp = true,
+ DataSetReference = "IEDLD0/LLN0.Analog",
+ ReportControlReference = "IEDLD0/LLN0.RP.Unbuffer02",
+ UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 0, 0, TimeSpan.FromHours(7))
+ };
+
+ private static NativeReportValueUpdate CompanionUpdate(string reference)
+ => new()
+ {
+ Reference = reference,
+ FunctionalConstraint = "MX",
+ Value = "-",
+ Quality = "questionable",
+ Timestamp = "2026-09-04 16:20:51.480",
+ Reason = "integrity",
+ ProjectionStatus = "measurement-companion",
+ HasValue = false,
+ HasQuality = true,
+ HasTimestamp = true,
+ DataSetReference = "IEDLD0/LLN0.Analog",
+ ReportControlReference = "IEDLD0/LLN0.RP.Unbuffer02",
+ UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 20, 51, 480, TimeSpan.FromHours(7))
+ };
+
+ private static NativeReportValueUpdate ValueOnlyUpdate(string reference, string value)
+ => new()
+ {
+ Reference = reference,
+ FunctionalConstraint = "MX",
+ Value = value,
+ Quality = string.Empty,
+ Timestamp = string.Empty,
+ Reason = "integrity",
+ ProjectionStatus = "semantic-structured-leaf",
+ HasValue = true,
+ HasQuality = false,
+ HasTimestamp = false,
+ DataSetReference = "IEDLD0/LLN0.Analog",
+ ReportControlReference = "IEDLD0/LLN0.RP.Unbuffer02",
+ UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 20, 51, 481, TimeSpan.FromHours(7))
+ };
+
+ private static Iec61850MonitorPoint Point(string reference)
+ => new()
+ {
+ DeviceId = "IED",
+ DeviceName = "IED",
+ SignalName = reference.Split('.').Last(),
+ IecReference = reference,
+ FunctionalConstraint = "MX",
+ IecDataType = "FLOAT32",
+ Category = "DataSet",
+ DataSetReference = "IEDLD0/LLN0.Analog"
+ };
+
+ private static LiveIedModelDiscoveryDocument Model(params LiveIedDataObjectModel[] objects)
+ => new()
+ {
+ Source = "Static report regression",
+ IedName = "IED",
+ LogicalDevices =
+ [
+ new LiveIedLogicalDeviceModel
+ {
+ MmsDomain = "IEDLD0",
+ Inst = "LD0",
+ LogicalNodes =
+ [
+ new LiveIedLogicalNodeModel
+ {
+ Name = "MHAI1",
+ LnClass = "MHAI",
+ LnInst = "1",
+ DataObjects = objects
+ }
+ ]
+ }
+ ]
+ };
+
+ private static LiveIedDataObjectModel DataObject(
+ string reference,
+ string name,
+ params LiveIedDataAttributeModel[] attributes)
+ => new()
+ {
+ Reference = reference,
+ Name = name,
+ InferredCdc = "MV",
+ Attributes = attributes
+ };
+
+ private static LiveIedDataAttributeModel Attribute(string reference, string path)
+ => new()
+ {
+ ObjectReference = reference,
+ AttributePath = path,
+ FunctionalConstraint = "MX",
+ MmsReference = reference.Replace('.', '$'),
+ SclBType = "FLOAT32",
+ MmsType = "floating-point",
+ Source = "SCL.DataTypeTemplates",
+ TypeSource = "SCL.DataTypeTemplates",
+ TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact
+ };
+
+ private static string FindRepoFile(string relativePath)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, relativePath);
+ if (File.Exists(candidate))
+ return candidate;
+ directory = directory.Parent;
+ }
+ throw new FileNotFoundException(relativePath);
+ }
+}