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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 130 additions & 165 deletions IoListTestingWindow.xaml

Large diffs are not rendered by default.

85 changes: 47 additions & 38 deletions IoListTestingWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ namespace ArIED61850Tester;
public partial class IoListTestingWindow : Window, INotifyPropertyChanged
{
private IoTestIedPlan? _selectedIed;
private bool _isPreparingIed;
private string _preparationStatusText = "Workbook-driven connection is ready.";
private IoTestIedPlan? _preparingIed;
private string _preparationStatusText = string.Empty;

public IoListTestingWindow()
: this(CreateEmptyProject(), CreateEmptyController(), null)
Expand Down Expand Up @@ -51,55 +51,49 @@ public IoTestIedPlan? SelectedIed
}
}

public bool IsPreparingIed
{
get => _isPreparingIed;
private set
{
if (_isPreparingIed == value)
return;
_isPreparingIed = value;
Raise();
Raise(nameof(CanStartWorkflow));
Raise(nameof(CanSelectIed));
Raise(nameof(CanEditPlan));
Raise(nameof(StartWorkflowText));
}
}
public bool IsPreparingIed => _preparingIed != null;

public string PreparationStatusText
{
get => _preparationStatusText;
private set
{
var normalized = string.IsNullOrWhiteSpace(value)
? "Workbook-driven connection is ready."
: value.Trim();
var normalized = value?.Trim() ?? string.Empty;
if (_preparationStatusText == normalized)
return;
_preparationStatusText = normalized;
Raise();
Raise(nameof(FooterStatusText));
}
}

public Visibility PreparationVisibility => IsPreparingIed ? Visibility.Visible : Visibility.Collapsed;
public string PreparationIedText => _preparingIed == null ? string.Empty : $"Preparing {_preparingIed.IedName}";

public bool CanStartWorkflow =>
SelectedIed != null && !IsPreparingIed && Session.CanStart;

public bool CanSelectIed =>
!IsPreparingIed && Session.CanSelectIed;
// Explorer navigation stays available while one IED is connecting or another FAT
// session is running. This is inspection-only; the active evidence scope remains
// pinned to Session.ActiveIed.
public bool CanSelectIed => true;

public bool CanEditPlan =>
!IsPreparingIed && Session.CanEditPlan;

public string StartWorkflowText =>
IsPreparingIed ? "Connecting IED…" : "Connect & Start IED";
IsPreparingIed ? $"Connecting {_preparingIed!.IedName}…" : "Connect & Start IED";

public string ProjectSummary =>
$"{Project.Ieds.Count} IED · {Project.SignalCount} SDI · {Project.ReadySignalCount} ready · {Project.LiveBoundSignalCount} live-bound";
$"{Project.Ieds.Count} IED · {Project.SignalCount} points · {Project.LiveBoundSignalCount} live";

public string SelectedIedSummary => SelectedIed == null
? "Select an imported IED"
: $"{SelectedIed.IpAddress} · {SelectedIed.BoundCount}/{SelectedIed.TestPoints.Count} bound · {SelectedIed.LiveStatusText}";
: $"{SelectedIed.IpAddress} · {SelectedIed.EnabledCount} test points · {SelectedIed.LiveStatusText}";

public string FooterStatusText => IsPreparingIed
? PreparationStatusText
: Session.StatusText;

public event PropertyChangedEventHandler? PropertyChanged;

Expand All @@ -116,40 +110,36 @@ private async void StartSession_Click(object sender, RoutedEventArgs e)
return;
}

IsPreparingIed = true;
PreparationStatusText = $"Preparing {selectedIed!.IedName} from workbook endpoint {selectedIed.IpAddress}:102…";
SetPreparingIed(selectedIed!, $"Connecting {selectedIed!.IedName} · {selectedIed.IpAddress}:102");
try
{
if (Owner is MainWindow engineeringWindow)
{
var progress = new Progress<string>(message =>
{
selectedIed.SetPreparationState(true, message);
PreparationStatusText = message;
Raise(nameof(ProjectSummary));
Raise(nameof(SelectedIedSummary));
RaiseStatusProperties();
});
var preparation = await engineeringWindow.PrepareIoTestIedForFatAsync(
Project,
selectedIed,
progress);
Raise(nameof(ProjectSummary));
Raise(nameof(SelectedIedSummary));
RaiseStatusProperties();
if (!preparation.Succeeded)
{
PreparationStatusText = preparation.Message;
ShowActionResult(preparation, "IED connection and monitoring could not start");
ShowActionResult(preparation, "IED acquisition could not start");
return;
}
}

var result = Session.Start(selectedIed);
ShowActionResult(result, "FAT evidence session could not start");
Raise(nameof(ProjectSummary));
Raise(nameof(SelectedIedSummary));
RaiseStatusProperties();
if (result.Succeeded)
{
PreparationStatusText =
$"{selectedIed.IedName} is connected and monitoring. Evidence capture is waiting for OFF → ON → OFF.";
PreparationStatusText = $"{selectedIed.IedName} live · waiting for OFF → ON → OFF";
Storage?.ScheduleSave();
}
else
Expand All @@ -169,7 +159,8 @@ private async void StartSession_Click(object sender, RoutedEventArgs e)
}
finally
{
IsPreparingIed = false;
selectedIed.SetPreparationState(false, selectedIed.LiveStatusText);
SetPreparingIed(null, string.Empty);
}
}

Expand Down Expand Up @@ -358,7 +349,7 @@ private void Window_Closing(object? sender, CancelEventArgs e)
{
MessageBox.Show(
this,
"ARSAS is still connecting, discovering, or preparing live monitoring for the selected IED. Wait for this operation to finish before returning to Engineering.",
$"ARSAS is still preparing {_preparingIed!.IedName}. You can inspect other IEDs while it runs, but wait for acquisition setup to finish before closing this workspace.",
"IED preparation in progress",
MessageBoxButton.OK,
MessageBoxImage.Information);
Expand Down Expand Up @@ -409,12 +400,30 @@ protected override void OnClosed(EventArgs e)
}

private void Session_PropertyChanged(object? sender, PropertyChangedEventArgs e)
=> RaiseStatusProperties();

private void SetPreparingIed(IoTestIedPlan? ied, string status)
{
_preparingIed = ied;
PreparationStatusText = status;
Raise(nameof(IsPreparingIed));
Raise(nameof(PreparationVisibility));
Raise(nameof(PreparationIedText));
Raise(nameof(CanStartWorkflow));
Raise(nameof(CanSelectIed));
Raise(nameof(CanEditPlan));
Raise(nameof(StartWorkflowText));
Raise(nameof(FooterStatusText));
}

private void RaiseStatusProperties()
{
Raise(nameof(CanStartWorkflow));
Raise(nameof(CanSelectIed));
Raise(nameof(CanEditPlan));
Raise(nameof(ProjectSummary));
Raise(nameof(SelectedIedSummary));
Raise(nameof(FooterStatusText));
}

private void ShowActionResult(IoTestSessionActionResult result, string title)
Expand Down
78 changes: 57 additions & 21 deletions MainWindow.IoTesting.AutoConnect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ public partial class MainWindow
private readonly IoTestSignalSelectionService _ioTestSignalSelectionService = new();

/// <summary>
/// Prepares one imported IO-list IED for a read-only FAT session. The workbook
/// Prepares one imported IO-list IED for a monitoring-only FAT session. The workbook
/// supplies the endpoint and exact signal scope, so the operator does not need to
/// duplicate the Add IED and signal-selection workflow in the engineering window.
/// duplicate Add IED and signal-selection work in the engineering window.
/// </summary>
internal async Task<IoTestSessionActionResult> PrepareIoTestIedForFatAsync(
IoTestProject project,
Expand All @@ -27,6 +27,13 @@ internal async Task<IoTestSessionActionResult> PrepareIoTestIedForFatAsync(
if (requestedPoints.Count == 0)
return IoTestSessionActionResult.Failure("No import-ready IO-list signal is enabled for this IED.");

void ReportProgress(string message)
{
ied.SetPreparationState(true, message);
progress?.Report(message);
}

ied.SetPreparationState(true, $"Connecting {ied.IedName} · {ied.IpAddress}:102");
var device = ResolveIoTestDevice(ied.LiveDeviceId)
?? ResolveIoTestDevice(ied.IpAddress)
?? ResolveIoTestDevice(ied.IedName);
Expand All @@ -40,21 +47,25 @@ internal async Task<IoTestSessionActionResult> PrepareIoTestIedForFatAsync(
IdentitySource = "IO List workbook",
IpAddress = ied.IpAddress,
Port = 102,
AllowDynamicDataSetWrites = false,
AllowDynamicDataSetWrites = true,
Status = "IO FAT ready to connect",
Detail = "Connect & Start will discover the live model and monitor only the imported FAT scope."
Detail = "Connect & Start will discover the live model and arm report-first acquisition for the imported FAT scope."
};
Devices.Add(device);
RaiseWorkspaceCounts();
}

if (device.IsBusy)
{
ied.SetPreparationState(false, "IED is busy in another connection workflow");
return IoTestSessionActionResult.Failure($"{ied.IedName} is already busy with another connection or discovery workflow.");
}

if (!device.IpAddress.Equals(ied.IpAddress, StringComparison.OrdinalIgnoreCase))
{
if (device.IsConnected || device.IsMonitoring)
{
ied.SetPreparationState(false, "Endpoint mismatch");
return IoTestSessionActionResult.Failure(
$"The loaded {ied.IedName} workspace is connected to {device.IpAddress}, but the IO list requires {ied.IpAddress}. Stop that engineering session or correct the workbook endpoint before FAT.");
}
Expand All @@ -68,14 +79,16 @@ internal async Task<IoTestSessionActionResult> PrepareIoTestIedForFatAsync(
if (string.IsNullOrWhiteSpace(device.SclIedName))
device.SclIedName = ied.IedName;

// Dedicated IO FAT remains read-only toward IED configuration. Existing
// configured reports are preferred; uncovered points use visible bounded
// polling instead of creating a dynamic DataSet or writing an RCB.
device.AllowDynamicDataSetWrites = false;
// Monitoring-only toward the process: no control commands are executed. Use
// configured RCB/DataSet coverage first, then an association-scoped temporary
// dynamic DataSet/URCB when exact coverage is missing, with bounded MMS
// verification/fallback last. Temporary report resources are released when the
// native monitoring session stops.
device.AllowDynamicDataSetWrites = true;

try
{
progress?.Report($"Connecting to {ied.IedName} at {ied.IpAddress}:102");
ReportProgress($"Connecting {ied.IedName} · {ied.IpAddress}:102");
var usedSavedModel = false;
if (!device.IsConnected)
{
Expand All @@ -88,12 +101,12 @@ internal async Task<IoTestSessionActionResult> PrepareIoTestIedForFatAsync(
{
_ioTestLiveBindingService.Bind(project, Devices);
return IoTestSessionActionResult.Failure(
$"ARSAS could not connect to {ied.IedName} at {ied.IpAddress}:102. Open Diagnostics for the exact MMS association or discovery error.");
$"ARSAS could not connect to {ied.IedName} at {ied.IpAddress}:102. Open Diagnostics for the MMS association or discovery error.");
}
}
else if (device.Signals.Count == 0)
{
progress?.Report($"{ied.IedName} is connected but has no discovered model. Running full live discovery…");
ReportProgress($"{ied.IedName} connected · discovering live model");
await StopDeviceConnectionAsync(device);
if (!await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false))
{
Expand All @@ -102,11 +115,11 @@ internal async Task<IoTestSessionActionResult> PrepareIoTestIedForFatAsync(
}
}

progress?.Report($"Matching {requestedPoints.Count} imported signal(s) to the discovered IED model…");
ReportProgress($"Matching {requestedPoints.Count} workbook signal(s)");
var selection = _ioTestSignalSelectionService.Resolve(ied, device);
if (!selection.Succeeded && selection.CanRetryWithFreshDiscovery)
{
progress?.Report("The saved/loaded model does not contain every imported signal. Refreshing the live model once…");
ReportProgress("Refreshing live model once · saved model missed workbook points");
if (device.IsMonitoring)
await StopDeviceMonitorAsync(device);
if (device.IsConnected)
Expand Down Expand Up @@ -147,22 +160,23 @@ internal async Task<IoTestSessionActionResult> PrepareIoTestIedForFatAsync(

if (device.IsMonitoring && (selectionChanged || !allRequestedPointsLive))
{
progress?.Report("Refreshing monitoring so every imported signal is included…");
ReportProgress("Refreshing acquisition for the workbook scope");
await StopDeviceMonitorAsync(device);
}

if (!device.IsMonitoring)
{
progress?.Report(
"Starting configured-report-first monitoring. Uncovered signals will use visible bounded polling fallback…");
ReportProgress("Arming configured RCB · dynamic URCB fallback if coverage is missing");
if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false))
{
_ioTestLiveBindingService.Bind(project, Devices);
return IoTestSessionActionResult.Failure(
$"{ied.IedName} connected, but ARSAS could not start live monitoring for the imported FAT scope.");
$"{ied.IedName} connected, but ARSAS could not start live acquisition for the imported FAT scope.");
}
}

await WaitForIoFatAcquisitionAsync(device, ReportProgress);

var binding = _ioTestLiveBindingService.Bind(project, Devices);
var liveCount = requestedPoints.Count(point =>
point.LiveBindingState == IoTestLiveBindingState.LivePointReady);
Expand All @@ -178,14 +192,13 @@ internal async Task<IoTestSessionActionResult> PrepareIoTestIedForFatAsync(

SaveSignalSelectionMemory(device);
var modelText = usedSavedModel ? "saved model" : "live model";
var message =
$"{ied.IedName} is connected from the {modelText}; {liveCount}/{requestedPoints.Count} imported signal(s) are live. {device.AcquisitionMode}";
var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {device.AcquisitionMode}";
SetStatus(message);
AddLog(
"INFO",
"IO Testing",
$"{message} Dynamic DataSet/RCB writes are disabled for the dedicated FAT workflow. Project live-bound={binding.LivePointCount}.");
progress?.Report(message);
$"{message}. Acquisition policy: configured RCB → temporary dynamic DataSet/URCB → bounded MMS verification/fallback. No process control commands are enabled. Project live-bound={binding.LivePointCount}; model={modelText}.");
ReportProgress(message);
return IoTestSessionActionResult.Success(message);
}
catch (OperationCanceledException)
Expand All @@ -203,8 +216,31 @@ internal async Task<IoTestSessionActionResult> PrepareIoTestIedForFatAsync(
}
finally
{
ied.SetPreparationState(false, ied.LiveStatusText);
if (createdFromWorkbook)
RaiseWorkspaceCounts();
}
}

private static async Task WaitForIoFatAcquisitionAsync(
Iec61850MonitorDevice device,
Action<string> reportProgress)
{
if (!device.IsMonitoring)
return;

for (var attempt = 0; attempt < 35; attempt++)
{
var mode = device.AcquisitionMode ?? string.Empty;
if (!mode.Contains("arming", StringComparison.OrdinalIgnoreCase) &&
!mode.Contains("live start", StringComparison.OrdinalIgnoreCase))
return;

if (attempt == 0)
reportProgress("Validating RCB/DataSet coverage · MMS remains verification only");
await Task.Delay(100);
}

reportProgress($"Acquisition still settling · {device.AcquisitionMode}");
}
}
Loading
Loading