diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 53948745..62f9f191 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -85,7 +85,7 @@ jobs:
- name: Publish portable x64
shell: powershell
- run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.8 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj"
+ run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.9 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj"
- name: Upload portable package
uses: actions/upload-artifact@v4
diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj
index c23d31f4..901e0ebd 100644
--- a/ArIED61850Tester.csproj
+++ b/ArIED61850Tester.csproj
@@ -14,9 +14,9 @@
ArIED 61850
ArIED 61850 — IEC 61850 Engineering Workstation
Windows IEC 61850 engineering workstation for SCL-assisted project setup, live MMS model discovery, independent multi-IED monitoring, reporting diagnostics, sequence of events, and guarded Smart Control.
- 1.6.8
- 1.6.8.0
- 1.6.8.0
+ 1.6.9
+ 1.6.9.0
+ 1.6.9.0
iec61850;iec-61850;mms;scl;ied-explorer;multi-ied-monitoring;relay-testing;substation-automation;digital-substation;report-control-block;sequence-of-events;smart-control;wpf;dotnet
GPL-3.0-or-later
$(ARIEC61850_PROJECT)
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 7fdee79d..9ba3ceb1 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -13,7 +13,7 @@
-
+
@@ -98,12 +98,12 @@
@@ -528,32 +528,9 @@
-
-
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index b8aeaebe..a0cee355 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -228,8 +228,8 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e)
if (firstImported != null && firstImported.Signals.Count > 0)
{
- AddLog("INFO", "SCL", $"{firstImported.Name}: choose the signals you want first; connect after saving the selection.");
- await OpenSignalSelectionWizardAsync(firstImported, autoStartAfterSave: false);
+ AddLog("INFO", "SCL", $"{firstImported.Name}: SCL model ready. Choose signals; saving the selection will continue to endpoint binding, connection, and monitoring.");
+ await OpenSignalSelectionWizardAsync(firstImported);
}
}
catch (OperationCanceledException)
@@ -292,13 +292,13 @@ private void ApplySclWorkspaceToDevice(
device.Status = workspace.RequiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready";
device.Detail = allowDynamicReporting
? (workspace.RequiresEndpointBinding
- ? "LD/LN/DO/DA are available offline. No static SCL DataSet coverage was found, so ArIED will prepare dynamic reporting from the user-selected signals after endpoint binding."
- : "LD/LN/DO/DA were loaded offline. No static SCL DataSet coverage was found, so Play will try dynamic reporting from the user-selected signals before falling back to polling.")
+ ? "LD/LN/DO/DA are available offline. Static report coverage is incomplete; after signal selection and endpoint binding, ArIED will create an association-scoped dynamic DataSet and use a safe free RCB before polling fallback."
+ : "LD/LN/DO/DA were loaded offline. Static report coverage is incomplete; ArIED will use static coverage where available and create an association-scoped dynamic DataSet for uncovered selected signals before polling fallback.")
: (workspace.RequiresEndpointBinding
? "LD/LN/DO/DA are available offline. Press Play to bind an MMS endpoint; no discovery traffic was sent while opening the file."
: "LD/LN/DO/DA were loaded offline. Play performs a fast MMS association; Re-scan performs full design-versus-live verification.");
device.AcquisitionMode = allowDynamicReporting
- ? "SCL offline design model • dynamic reporting prepared"
+ ? "SCL design • Smart Dynamic reporting prepared"
: "SCL offline design model";
foreach (var signal in signals)
@@ -321,61 +321,62 @@ private static string BuildSclWorkspaceSummary(SclIedWorkspace workspace)
private void LogSclFindings(string sourceName, IReadOnlyList findings)
{
- var suppressedDynamicHints = findings
- .Where(IsDynamicReportingHint)
- .ToList();
var actionableFindings = findings
- .Where(finding => !IsDynamicReportingHint(finding))
- .ToList();
+ .Where(finding => !IsSmartDynamicCapabilityHint(finding))
+ .ToArray();
+ if (actionableFindings.Length == 0)
+ return;
- if (suppressedDynamicHints.Count > 0)
+ var groups = SclFindingAggregator.Group(actionableFindings);
+ if (groups.Count != actionableFindings.Length)
{
AddLog(
"INFO",
"SCL",
- $"{sourceName}: {suppressedDynamicHints.Count} static report/DataSet hint(s) were treated as informational only. ArIED will prepare dynamic reporting when the SCL file does not carry usable DataSet bindings.");
+ $"{sourceName} • grouped {actionableFindings.Length} actionable finding(s) into {groups.Count} diagnostic group(s). Full typed evidence remains attached to the SCL workspace.");
}
- foreach (var finding in actionableFindings.Take(40))
+ foreach (var group in groups.Take(40))
{
- var level = finding.Severity.Equals("High", StringComparison.OrdinalIgnoreCase) ||
- finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase)
- ? "ERROR"
- : finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase) ? "WARN" : "INFO";
- AddLog(level, "SCL", $"{sourceName} • {finding.Code}: {finding.Message}");
+ AddLog(
+ SclFindingAggregator.ToLogLevel(group.Severity),
+ "SCL",
+ $"{sourceName} • {group.Code} [{group.Scope}]: {group.ToDiagnosticMessage()}");
+ }
+
+ if (groups.Count > 40)
+ {
+ var omittedRawCount = groups.Skip(40).Sum(group => group.Count);
+ AddLog(
+ "WARN",
+ "SCL",
+ $"{groups.Count - 40} additional diagnostic group(s), representing {omittedRawCount} actionable finding(s), were omitted from the live log.");
}
- if (actionableFindings.Count > 40)
- AddLog("WARN", "SCL", $"{actionableFindings.Count - 40} additional actionable finding(s) were omitted from the live log.");
- if (actionableFindings.Any(finding => finding.Severity is "High" or "Error"))
+
+ if (groups.Any(group => SclFindingAggregator.IsBlockingSeverity(group.Severity)))
MarkDiagnosticAlert();
}
- private static bool IsDynamicReportingHint(SclWorkspaceFinding finding)
+ private static bool IsSmartDynamicCapabilityHint(SclWorkspaceFinding finding)
{
- var code = finding.Code?.Trim() ?? string.Empty;
- var severity = finding.Severity?.Trim() ?? string.Empty;
- if (!severity.Equals("Warning", StringComparison.OrdinalIgnoreCase))
+ if (!finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase))
return false;
- return code.Equals("SCL_REPORT_DATASET_UNASSIGNED", StringComparison.OrdinalIgnoreCase) ||
- code.Equals("SCL_REPORT_DATASET_UNRESOLVED", StringComparison.OrdinalIgnoreCase) ||
- code.Equals("SCL_REPORT_DATASET_MISSING", StringComparison.OrdinalIgnoreCase);
+ return finding.Code.Equals("SCL_REPORT_DATASET_UNASSIGNED", StringComparison.OrdinalIgnoreCase) ||
+ finding.Code.Equals("SCL_REPORT_DATASET_UNRESOLVED", StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldAllowDynamicReportingForScl(IReadOnlyCollection signals)
{
- var hasStaticCoverage = signals.Any(signal =>
- !string.IsNullOrWhiteSpace(signal.ReportControlReference) ||
- !string.IsNullOrWhiteSpace(signal.DataSetReference));
-
- return !hasStaticCoverage;
+ return signals.Any(signal =>
+ signal.CanPublishAsSignal &&
+ (string.IsNullOrWhiteSpace(signal.DataSetReference) ||
+ string.IsNullOrWhiteSpace(signal.ReportControlReference)));
}
private void TryAutoExpandCommandPanelOnce(Iec61850MonitorDevice? device)
{
- if (device == null || CommandPanelExpander == null)
- return;
- if (device.CommandSignals.Count == 0)
+ if (device == null || CommandPanelExpander == null || device.CommandSignals.Count == 0)
return;
if (!_autoExpandedCommandDevices.Add(device.DeviceId))
return;
@@ -404,7 +405,7 @@ private bool EnsureSclEndpointBinding(Iec61850MonitorDevice device)
device.Port = wizard.MmsPort;
device.Status = "SCL model ready";
device.Detail = device.AllowDynamicDataSetWrites
- ? "Endpoint bound locally. ArIED will try dynamic reporting from the user-selected signals because the SCL file did not provide usable static DataSet coverage. Re-scan still performs full comparison."
+ ? "Endpoint bound locally. Saving the selected signals will connect and arm Smart Dynamic reporting with a safe free RCB before polling fallback."
: "Endpoint bound locally. Play will fast-connect from the SCL design model; Re-scan performs full comparison.";
device.RefreshComputed();
NewDeviceIp = device.IpAddress;
@@ -1039,17 +1040,20 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool
await Task.WhenAll(candidates.Select(async signal =>
{
await throttle.WaitAsync(_applicationCancellation.Token);
- signal.ControlIsBusy = true;
+ signal.ControlInspectionBusy = true;
try
{
var capabilities = await _runtime.InspectControlAsync(
device.DeviceId,
signal,
_applicationCancellation.Token);
- signal.ControlCurrentValue = capabilities.CurrentValue;
- signal.ControlLastResult = capabilities.SupportsOperate
- ? capabilities.ControlModelText
- : "Control unavailable";
+ if (!signal.ControlCommandBusy)
+ {
+ signal.ControlCurrentValue = capabilities.CurrentValue;
+ signal.ControlLastResult = capabilities.SupportsOperate
+ ? capabilities.ControlModelText
+ : "Control unavailable";
+ }
}
catch (OperationCanceledException)
{
@@ -1057,11 +1061,12 @@ await Task.WhenAll(candidates.Select(async signal =>
}
catch (Exception ex)
{
- signal.ControlLastResult = $"Status read failed: {ex.Message}";
+ if (!signal.ControlCommandBusy)
+ signal.ControlLastResult = $"Status read failed: {ex.Message}";
}
finally
{
- signal.ControlIsBusy = false;
+ signal.ControlInspectionBusy = false;
throttle.Release();
}
}));
@@ -1076,16 +1081,24 @@ private void ControlStageAction_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is not SignalDefinition signal || !signal.IsPositionControl)
return;
- if (signal.ControlIsBusy || !signal.ControlSupportsOperate)
- return;
+ var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
var requestedValue = button.CommandParameter?.ToString()?.Trim() ?? string.Empty;
var actionLabel = button.Content?.ToString()?.Trim() ?? string.Empty;
- if (string.IsNullOrWhiteSpace(requestedValue) || string.IsNullOrWhiteSpace(actionLabel))
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Control confirmation stage click received: {signal.ObjectReference}; action={actionLabel}; value={requestedValue}; commandBusy={signal.ControlCommandBusy}; inspectionBusy={signal.ControlInspectionBusy}.");
+
+ if (!signal.TryStageControlConfirmation(requestedValue, actionLabel, out var rejectionReason))
+ {
+ signal.ControlLastResult = $"Command not staged: {rejectionReason}.";
+ AddLog("WARN", device?.Name ?? "IED",
+ $"Control confirmation stage rejected: {signal.ObjectReference}; reason={rejectionReason}.");
+ SetStatus($"{device?.Name ?? "IED"}: {signal.Name} command not staged — {rejectionReason}.");
return;
+ }
- signal.StageControlConfirmation(requestedValue, actionLabel);
- var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Control confirmation staged: {signal.ObjectReference}; action={actionLabel}; value={requestedValue}.");
SetStatus($"{device?.Name ?? "IED"}: review {signal.Name} — {signal.ControlPendingConfirmationLabel}, or Cancel.");
}
@@ -1093,12 +1106,23 @@ private async void ControlConfirmAction_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is not SignalDefinition signal)
return;
- if (!signal.ControlCanConfirm || string.IsNullOrWhiteSpace(signal.ControlPendingValue))
+
+ var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Confirm click received: {signal.ObjectReference}; pending={signal.ControlConfirmationPending}; action={signal.ControlPendingAction}; value={signal.ControlPendingValue}; commandBusy={signal.ControlCommandBusy}; inspectionBusy={signal.ControlInspectionBusy}.");
+
+ if (!signal.TryClaimControlConfirmation(out var claim, out var rejectionReason) || claim == null)
+ {
+ signal.ControlLastResult = $"Confirmation rejected: {rejectionReason}.";
+ AddLog("WARN", device?.Name ?? "IED",
+ $"Confirm rejected: {signal.ObjectReference}; reason={rejectionReason}.");
+ SetStatus($"{device?.Name ?? "IED"}: {signal.Name} confirmation rejected — {rejectionReason}.");
return;
+ }
- var requestedValue = signal.ControlPendingValue;
- signal.ClearControlConfirmation();
- await ExecuteQuickControlAsync(signal, requestedValue);
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Confirm accepted: {signal.ObjectReference}; sequence={claim.Sequence}; action={claim.ActionLabel}; value={claim.RequestedValue}.");
+ await ExecuteClaimedControlAsync(signal, claim);
}
private void ControlCancelAction_Click(object sender, RoutedEventArgs e)
@@ -1109,6 +1133,8 @@ private void ControlCancelAction_Click(object sender, RoutedEventArgs e)
var action = signal.ControlPendingAction;
signal.ClearControlConfirmation();
var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Control confirmation cancelled: {signal.ObjectReference}; action={action}.");
SetStatus($"{device?.Name ?? "IED"}: {signal.Name} {action} cancelled before dispatch.");
}
@@ -1126,27 +1152,37 @@ private async void ControlQuickAction_Click(object sender, RoutedEventArgs e)
return;
}
- await ExecuteQuickControlAsync(signal, requestedValue);
+ var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
+ if (!signal.TryBeginDirectControlCommand(
+ requestedValue,
+ button.Content?.ToString()?.Trim() ?? requestedValue,
+ out var claim,
+ out var rejectionReason) || claim == null)
+ {
+ signal.ControlLastResult = $"Command rejected: {rejectionReason}.";
+ AddLog("WARN", device?.Name ?? "IED",
+ $"Direct control rejected: {signal.ObjectReference}; reason={rejectionReason}.");
+ SetStatus($"{device?.Name ?? "IED"}: {signal.Name} command rejected — {rejectionReason}.");
+ return;
+ }
+
+ await ExecuteClaimedControlAsync(signal, claim);
}
- private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requestedValue)
+ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCommandClaim claim)
{
var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
if (device == null)
- return;
-
- if (signal.ControlIsBusy)
{
- SetStatus($"{device.Name}: {signal.Name} command is already in progress.");
+ signal.CompleteControlCommand(claim);
return;
}
var clickStopwatch = Stopwatch.StartNew();
- signal.ControlIsBusy = true;
- signal.ControlLastResult = $"Dispatching {requestedValue}…";
- SetStatus($"{device.Name}: dispatching {signal.Name} = {requestedValue}…");
+ signal.ControlLastResult = $"Dispatching {claim.RequestedValue}…";
+ SetStatus($"{device.Name}: dispatching {signal.Name} = {claim.RequestedValue}…");
AddLog("INFO", device.Name,
- $"Control click accepted: {signal.ObjectReference} value={requestedValue}; test={signal.ControlTestMode}; interlock={signal.ControlInterlockCheck}; synchro={signal.ControlSynchroCheck}.");
+ $"Dispatch ownership acquired: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}; test={signal.ControlTestMode}; interlock={signal.ControlInterlockCheck}; synchro={signal.ControlSynchroCheck}.");
await Dispatcher.Yield(DispatcherPriority.Render);
try
@@ -1161,15 +1197,14 @@ private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requ
return;
}
- // ExecuteControlAsync owns one live status preflight and the complete control
- // sequence. The old UI path performed a second status read before this call,
- // which added queue latency and created a stale-value race.
+ AddLog("INFO", device.Name,
+ $"MMS command submitted: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}.");
var result = await _runtime.ExecuteControlAsync(
device.DeviceId,
new Iec61850ControlCommandRequest
{
Signal = signal,
- ValueText = requestedValue,
+ ValueText = claim.RequestedValue,
InterlockCheck = signal.ControlInterlockCheck,
SynchroCheck = signal.ControlSynchroCheck,
TestMode = signal.ControlTestMode,
@@ -1189,7 +1224,7 @@ private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requ
SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}");
clickStopwatch.Stop();
AddLog(result.IsSuccess ? "INFO" : "WARN", device.Name,
- $"Control UI timing: {signal.ObjectReference}; click-to-result={clickStopwatch.Elapsed.TotalMilliseconds:0.###} ms; engine-total={result.TotalElapsedText}; serviceAccepted={result.ServiceAccepted}; stage={result.Stage}.");
+ $"Control UI timing: {signal.ObjectReference}; sequence={claim.Sequence}; click-to-result={clickStopwatch.Elapsed.TotalMilliseconds:0.###} ms; engine-total={result.TotalElapsedText}; serviceAccepted={result.ServiceAccepted}; stage={result.Stage}.");
}
catch (OperationCanceledException)
{
@@ -1205,8 +1240,12 @@ private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requ
}
finally
{
- signal.ControlIsBusy = false;
- signal.ClearControlConfirmation();
+ if (!signal.CompleteControlCommand(claim))
+ {
+ AddLog("ERROR", device.Name,
+ $"Control ownership release mismatch: {signal.ObjectReference}; sequence={claim.Sequence}.");
+ MarkDiagnosticAlert();
+ }
}
}
diff --git a/docs/SCL_SMART_DYNAMIC_REPORTING.md b/docs/SCL_SMART_DYNAMIC_REPORTING.md
new file mode 100644
index 00000000..96e8ddc9
--- /dev/null
+++ b/docs/SCL_SMART_DYNAMIC_REPORTING.md
@@ -0,0 +1,9 @@
+# SCL-assisted Smart Dynamic reporting
+
+Opening an SCL file remains offline. After the model is loaded, ArIED opens signal selection immediately. Saving a valid live selection continues to endpoint binding, MMS association, and monitoring.
+
+Static DataSet and ReportControl coverage is preferred when complete. Missing or partial coverage enables an association-scoped dynamic DataSet for uncovered selected signals and a safe free RCB before polling fallback.
+
+Unassigned or unresolved report DataSet warnings remain in the typed SCL workspace but are not promoted into noisy live Diagnostics. Positively resolved empty DataSets and other blocking findings remain actionable.
+
+The command panel expands once per IED only after at least one executable command row is ready.
diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1
index 3e24c65d..b5ec22ff 100644
--- a/scripts/publish-windows-portable.ps1
+++ b/scripts/publish-windows-portable.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.6.8",
+ [string]$Version = "1.6.9",
[string]$Runtime = "win-x64",
[bool]$SingleFile = $true,
[bool]$SelfContained = $true,
@@ -23,7 +23,7 @@ if ($normalizedVersion.StartsWith("v", [System.StringComparison]::OrdinalIgnoreC
$normalizedVersion = $normalizedVersion.Substring(1)
}
if ($normalizedVersion -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$') {
- throw "Invalid version '$Version'. Use a value such as 1.6.8 or v1.6.8."
+ throw "Invalid version '$Version'. Use a value such as 1.6.9 or v1.6.9."
}
$outputRoot = Join-Path $root "dist"