diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 62f9f191..3ea37a8b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -7,8 +7,7 @@ on:
workflow_dispatch:
env:
- # Temporary integration pin. Switch to main after ARIEC61850 PR #27 is merged.
- ARIEC61850_REF: fix/scl-dataset-binding-resolution
+ ARIEC61850_REF: main
jobs:
build-windows:
@@ -35,6 +34,8 @@ jobs:
ArIED61850Tester/MainWindow.xaml.cs
ArIED61850Tester/MainWindow.CommandPanelUx.cs
ArIED61850Tester/MainWindow.ControlDiagnostics.cs
+ ArIED61850Tester/SaveSclWindow.xaml
+ ArIED61850Tester/SaveSclWindow.xaml.cs
ArIED61850Tester/GridUxBehavior.cs
ArIED61850Tester/Models
ArIED61850Tester/Services
@@ -53,6 +54,9 @@ jobs:
$workspace = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceService.cs"
$workspaceModels = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceModels.cs"
$sclModels = ".\ARIEC61850\src\AR.Iec61850\Scl\SclModels.cs"
+ $sclExporter = ".\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveIedSclExporter.cs"
+ $schemaProfiles = ".\ARIEC61850\src\AR.Iec61850\Scl\Export\SclSchemaProfiles.cs"
+ $interoperableConverter = ".\ARIEC61850\src\AR.Iec61850\Scl\Export\InteroperableSclConverter.cs"
if (!(Test-Path $control) -or !(Test-Path $models)) {
throw "ARIEC61850 Smart Control source was not found. ArIED requires the native Control namespace."
}
@@ -69,7 +73,18 @@ jobs:
throw "ARIEC61850 SCL Workspace API does not expose design-versus-live comparison."
}
if (!(Test-Path $sclModels) -or !(Select-String -Path $sclModels -Pattern "SclDataSetBindingStatus" -Quiet)) {
- throw "ARIEC61850 P1 typed SCL DataSet binding contract was not found."
+ throw "ARIEC61850 typed SCL DataSet binding contract was not found."
+ }
+ if (!(Test-Path $sclExporter) -or !(Select-String -Path $sclExporter -Pattern "WriteFiles" -Quiet)) {
+ throw "ARIEC61850 schema-aware LiveIedSclExporter was not found."
+ }
+ if (!(Test-Path $schemaProfiles) -or
+ !(Select-String -Path $schemaProfiles -Pattern "Edition2V31" -Quiet) -or
+ !(Select-String -Path $schemaProfiles -Pattern "Edition1V16" -Quiet)) {
+ throw "ARIEC61850 Edition 2 / Edition 1 SCL schema profiles were not found."
+ }
+ if (!(Test-Path $interoperableConverter) -or !(Select-String -Path $interoperableConverter -Pattern "WriteFiles" -Quiet)) {
+ throw "ARIEC61850 generic interoperable SCL converter was not found."
}
- name: Setup .NET 8
@@ -85,7 +100,7 @@ jobs:
- name: Publish portable x64
shell: powershell
- run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.9 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj"
+ run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.10 -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 901e0ebd..61bdd39f 100644
--- a/ArIED61850Tester.csproj
+++ b/ArIED61850Tester.csproj
@@ -13,10 +13,10 @@
Copyright (C) 2026 Ari Sulistiono
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.9
- 1.6.9.0
- 1.6.9.0
+ Windows IEC 61850 engineering workstation for SCL-assisted project setup, interoperable Edition 1/2 SCL export, live MMS model discovery, independent multi-IED monitoring, reporting diagnostics, sequence of events, and guarded Smart Control.
+ 1.6.10
+ 1.6.10.0
+ 1.6.10.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 9ba3ceb1..a1e9a31d 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -216,7 +216,7 @@
+ Foreground="{StaticResource Ink}" TextTrimming="CharacterEllipsis" Margin="0,0,28,0"/>
@@ -240,15 +240,24 @@
-
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index a0cee355..4b73384c 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -11,6 +11,7 @@
using System.Windows.Threading;
using System.Windows.Media;
using System.Windows.Media.Animation;
+using AR.Iec61850.Scl.Export;
using AR.Iec61850.Scl.Workspace;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;
@@ -1350,6 +1351,162 @@ private async void IedRescan_Click(object sender, RoutedEventArgs e)
await ConnectAndConfigureDeviceAsync(device, openWizard: false);
}
+ private void IedSaveScl_Click(object sender, RoutedEventArgs e)
+ {
+ if (!TryGetDeviceFromButton(sender, out var device) || device.IsBusy)
+ return;
+
+ var model = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel;
+ if (model == null)
+ {
+ SetStatus(device.HasDiscoveryCache
+ ? $"{device.Name}: run Re-scan to capture a complete live model before saving SCL."
+ : $"{device.Name}: open SCL or complete live discovery before saving SCL.");
+ return;
+ }
+
+ SelectedDevice = device;
+ var sourceDescription = device.SclWorkspace != null
+ ? "Opened SCL design model"
+ : "Last successful live MMS discovery";
+ var schemaDialog = new SaveSclWindow(device.Name, sourceDescription)
+ {
+ Owner = this
+ };
+ if (schemaDialog.ShowDialog() != true)
+ return;
+
+ var schema = schemaDialog.ViewModel.SelectedSchemaProfile;
+ var sourceSuffix = device.SclWorkspace != null ? "interoperable" : "discovered";
+ var editionSuffix = schema.IsEdition2 ? "ed2" : "ed1";
+ var dialog = new SaveFileDialog
+ {
+ Title = $"Save {device.Name} — {schema.DisplayName}",
+ Filter = schema.IsEdition2
+ ? "Edition 2 IID file (*.iid)|*.iid|All files (*.*)|*.*"
+ : "Edition 1 ICD file (*.icd)|*.icd|All files (*.*)|*.*",
+ DefaultExt = schema.DefaultExtension,
+ AddExtension = true,
+ FileName = $"{SafeSclFileStem(device.Name)}-{sourceSuffix}-{editionSuffix}{schema.DefaultExtension}"
+ };
+ if (dialog.ShowDialog(this) != true)
+ return;
+
+ try
+ {
+ if (device.SclWorkspace != null &&
+ schema.IsEdition2 &&
+ !string.IsNullOrWhiteSpace(device.SclSourcePath) &&
+ File.Exists(device.SclSourcePath))
+ {
+ SaveOpenedSclAsGenericEdition2(device, dialog.FileName);
+ return;
+ }
+
+ SaveTypedModelAsScl(device, model, sourceDescription, schema, dialog.FileName);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidOperationException or ArgumentException)
+ {
+ AddLog("ERROR", "SCL Export", $"{device.Name}: {ex.Message}");
+ MarkDiagnosticAlert();
+ SetStatus($"{device.Name}: SCL export failed. Diagnostics is marked with !.");
+ MessageBox.Show(this, ex.Message, "Save SCL", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ catch (Exception ex)
+ {
+ AddLog("ERROR", "SCL Export", $"{device.Name}: unexpected export failure: {ex}");
+ MarkDiagnosticAlert();
+ SetStatus($"{device.Name}: SCL export failed. Diagnostics is marked with !.");
+ MessageBox.Show(this, ex.Message, "Save SCL", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private void SaveOpenedSclAsGenericEdition2(Iec61850MonitorDevice device, string outputPath)
+ {
+ var result = InteroperableSclConverter.WriteFiles(
+ device.SclSourcePath,
+ outputPath,
+ new InteroperableSclConversionOptions
+ {
+ IedName = string.IsNullOrWhiteSpace(device.SclIedName) ? device.Name : device.SclIedName,
+ PreserveAllIeds = false,
+ ToolId = "ARIEC61850"
+ });
+
+ AddLog(
+ "INFO",
+ "SCL Export",
+ $"{device.Name}: saved generic interoperable Edition 2 IID from opened SCL. LD={result.LogicalDeviceCount}, LN={result.LogicalNodeCount}, DataSet={result.DataSetCount}, RCB={result.ReportControlCount}, findings={result.Findings.Count}. SCL={result.OutputPath}");
+
+ foreach (var finding in result.Findings.Take(12))
+ {
+ var level = finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase)
+ ? "ERROR"
+ : finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase) ? "WARN" : "INFO";
+ var reference = string.IsNullOrWhiteSpace(finding.Reference)
+ ? string.Empty
+ : $" [{finding.Reference}]";
+ AddLog(level, "SCL Export", $"{device.Name} • {finding.Code}{reference}: {finding.Message}");
+ }
+ if (result.Findings.Count > 12)
+ AddLog("WARN", "SCL Export", $"{device.Name}: {result.Findings.Count - 12} additional interoperability finding(s) are available in {result.ReportPath}.");
+ if (result.Findings.Any(finding => finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase)))
+ MarkDiagnosticAlert();
+
+ SetStatus($"{device.Name}: generic interoperable Edition 2 IID saved to {result.OutputPath}");
+ ShowSclSaveSuccess(
+ "Edition 2 (generic interoperable IID)",
+ result.OutputPath,
+ result.ReportPath,
+ result.SummaryPath);
+ }
+
+ private void SaveTypedModelAsScl(
+ Iec61850MonitorDevice device,
+ AR.Iec61850.Discovery.LiveIedModelDiscoveryDocument model,
+ string sourceDescription,
+ SclSchemaProfileDescriptor schema,
+ string outputPath)
+ {
+ var result = LiveIedSclExporter.WriteFiles(
+ model,
+ outputPath,
+ new LiveIedSclExportOptions
+ {
+ Profile = "safe-connection",
+ SchemaProfile = schema.Profile,
+ IpAddress = device.IpAddress
+ });
+
+ AddLog(
+ "INFO",
+ "SCL Export",
+ $"{device.Name}: saved {result.SclSchema} from {sourceDescription.ToLowerInvariant()}. LD={result.LogicalDeviceCount}, LN={result.LogicalNodeCount}, DataSet={result.DataSetCount}, RCB={result.ReportControlCount}, warnings={result.Warnings.Count}. SCL={result.SclPath}");
+
+ foreach (var warning in result.Warnings.Take(12))
+ {
+ var reference = string.IsNullOrWhiteSpace(warning.Reference)
+ ? string.Empty
+ : $" [{warning.Reference}]";
+ AddLog("WARN", "SCL Export", $"{device.Name} • {warning.Code}{reference}: {warning.Message}");
+ }
+ if (result.Warnings.Count > 12)
+ AddLog("WARN", "SCL Export", $"{device.Name}: {result.Warnings.Count - 12} additional export warning(s) are available in {result.ReportPath}.");
+
+ SetStatus($"{device.Name}: {result.SclSchema} saved to {result.SclPath}");
+ ShowSclSaveSuccess(result.SclSchema, result.SclPath, result.ReportPath, result.SummaryPath);
+ }
+
+ private void ShowSclSaveSuccess(string schema, string sclPath, string reportPath, string summaryPath)
+ {
+ MessageBox.Show(
+ this,
+ $"SCL saved successfully.\n\nSchema: {schema}\nSCL: {sclPath}\nEvidence: {reportPath}\nSummary: {summaryPath}",
+ "Save SCL",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ }
+
private async void IedRemove_Click(object sender, RoutedEventArgs e)
{
if (!TryGetDeviceFromButton(sender, out var device) || device.IsBusy) return;
@@ -2006,6 +2163,18 @@ private bool TryReadEndpoint(out string ip, out int port)
return true;
}
+ private static string SafeSclFileStem(string? value)
+ {
+ var source = string.IsNullOrWhiteSpace(value) ? "IED" : value.Trim();
+ var invalid = Path.GetInvalidFileNameChars().ToHashSet();
+ var builder = new StringBuilder(source.Length);
+ foreach (var character in source)
+ builder.Append(invalid.Contains(character) ? '_' : character);
+
+ var result = builder.ToString().Trim().Trim('.');
+ return string.IsNullOrWhiteSpace(result) ? "IED" : result;
+ }
+
private static bool TryGetDeviceFromButton(object sender, out Iec61850MonitorDevice device)
{
device = null!;
diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs
index 6dc27ab8..31e7749f 100644
--- a/Models/MonitorModels.cs
+++ b/Models/MonitorModels.cs
@@ -1,3 +1,4 @@
+using AR.Iec61850.Discovery;
using AR.Iec61850.Scl.Workspace;
namespace ArIED61850Tester.Models;
@@ -31,6 +32,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject
private string _busyTitle = "Discovering IEC 61850 IED";
private int _unreadEventCount;
private SclIedWorkspace? _sclWorkspace;
+ private LiveIedModelDiscoveryDocument? _liveDiscoveryModel;
private SclLiveModelComparisonResult? _sclComparison;
private string _sclSourcePath = string.Empty;
private string _sclSourceSha256 = string.Empty;
@@ -54,6 +56,17 @@ public SclIedWorkspace? SclWorkspace
}
}
+ public LiveIedModelDiscoveryDocument? LiveDiscoveryModel
+ {
+ get => _liveDiscoveryModel;
+ set
+ {
+ if (ReferenceEquals(_liveDiscoveryModel, value)) return;
+ _liveDiscoveryModel = value;
+ RefreshComputed();
+ }
+ }
+
public SclLiveModelComparisonResult? SclComparison
{
get => _sclComparison;
@@ -385,6 +398,14 @@ public void MarkDiscoveryFailed(string message)
public bool CanPlayAction => !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0));
public bool CanStopAction => !IsBusy && (IsConnected || IsMonitoring);
public bool CanRescan => !IsBusy && !IsMonitoring;
+ public bool CanSaveScl => !IsBusy && (SclWorkspace != null || LiveDiscoveryModel != null);
+ public string SaveSclToolTip => SclWorkspace != null
+ ? $"Save {Name} from the opened SCL design model"
+ : LiveDiscoveryModel != null
+ ? $"Save {Name} from the last successful live MMS discovery"
+ : HasDiscoveryCache
+ ? $"Run Re-scan for {Name} to capture a complete live model before saving SCL"
+ : $"Open SCL or complete live discovery for {Name} before saving";
public string CacheStateText => HasSclDesignModel
? $"SCL design model • {SignalCount:N0} signals"
: HasDiscoveryCache
@@ -518,6 +539,8 @@ public void RefreshComputed()
Raise(nameof(CanPlayAction));
Raise(nameof(CanStopAction));
Raise(nameof(CanRescan));
+ Raise(nameof(CanSaveScl));
+ Raise(nameof(SaveSclToolTip));
Raise(nameof(CacheStateText));
Raise(nameof(HasSclDesignModel));
Raise(nameof(RequiresEndpointBinding));
diff --git a/SaveSclWindow.xaml b/SaveSclWindow.xaml
new file mode 100644
index 00000000..1c4141d8
--- /dev/null
+++ b/SaveSclWindow.xaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SaveSclWindow.xaml.cs b/SaveSclWindow.xaml.cs
new file mode 100644
index 00000000..2dd67e56
--- /dev/null
+++ b/SaveSclWindow.xaml.cs
@@ -0,0 +1,46 @@
+using System.Windows;
+using AR.Iec61850.Scl.Export;
+
+namespace ArIED61850Tester;
+
+public sealed class SaveSclDialogViewModel
+{
+ public SaveSclDialogViewModel(
+ string iedName,
+ string sourceDescription,
+ SclSchemaProfile selectedProfile = SclSchemaProfile.Edition2V31)
+ {
+ IedName = string.IsNullOrWhiteSpace(iedName) ? "IED" : iedName.Trim();
+ SourceDescription = string.IsNullOrWhiteSpace(sourceDescription)
+ ? "IEC 61850 model"
+ : sourceDescription.Trim();
+ SchemaProfiles =
+ [
+ SclSchemaProfiles.Get(SclSchemaProfile.Edition2V31),
+ SclSchemaProfiles.Get(SclSchemaProfile.Edition1V16)
+ ];
+ SelectedSchemaProfile = SclSchemaProfiles.Get(selectedProfile);
+ }
+
+ public string IedName { get; }
+ public string SourceDescription { get; }
+ public IReadOnlyList SchemaProfiles { get; }
+ public SclSchemaProfileDescriptor SelectedSchemaProfile { get; set; }
+}
+
+public partial class SaveSclWindow : Window
+{
+ public SaveSclWindow(
+ string iedName,
+ string sourceDescription,
+ SclSchemaProfile selectedProfile = SclSchemaProfile.Edition2V31)
+ {
+ InitializeComponent();
+ DataContext = new SaveSclDialogViewModel(iedName, sourceDescription, selectedProfile);
+ }
+
+ public SaveSclDialogViewModel ViewModel => (SaveSclDialogViewModel)DataContext;
+
+ private void Save_Click(object sender, RoutedEventArgs e)
+ => DialogResult = true;
+}
diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs
index f554cf3e..952d5a02 100644
--- a/Services/Iec61850MonitorRuntime.cs
+++ b/Services/Iec61850MonitorRuntime.cs
@@ -135,6 +135,9 @@ public async Task> ConnectAndDiscoverAsync(
progress?.Report(new IedDiscoveryProgress(IedDiscoveryStage.AssociatingMms, "ACSE/MMS associated. Starting online discovery…", 18d, 3, 15));
var discovered = await session.Client.DiscoverSignalsAsync(cancellationToken, progress).ConfigureAwait(false);
+ if (session.Client.LastLiveModel != null)
+ device.LiveDiscoveryModel = session.Client.LastLiveModel;
+
var signals = discovered
.Where(signal => signal.CanPublishAsSignal || signal.IsControlSignal)
.GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase)
diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs
index 088e0f26..946f4f7a 100644
--- a/Services/NativeIec61850Client.cs
+++ b/Services/NativeIec61850Client.cs
@@ -52,6 +52,7 @@ public sealed class NativeIec61850Client : IIec61850Client, IIec61850ControlClie
public NativeReportInventory LastReportInventory { get; private set; } = new();
public Iec61850DeviceIdentity DetectedIdentity { get; private set; } = new();
public string DetectedIedName => DetectedIdentity.IedName;
+ public LiveIedModelDiscoveryDocument? LastLiveModel => _liveModel;
public async Task ConnectAsync(string ipAddress, int port, CancellationToken cancellationToken)
{
diff --git a/docs/SCL_EXPORT.md b/docs/SCL_EXPORT.md
new file mode 100644
index 00000000..5e34091e
--- /dev/null
+++ b/docs/SCL_EXPORT.md
@@ -0,0 +1,23 @@
+# Per-card interoperable SCL export
+
+Each IED card exposes a **Save SCL** action after ArIED has a complete typed model.
+
+## Supported sources
+
+- **Opened SCL design model** — Edition 2 uses the engine-owned generic interoperability converter against the original source file and the IED represented by that card. Edition 1 is rebuilt from the typed `SclWorkspace.DesignModel` using the schema-aware exporter.
+- **Live MMS discovery** — Edition 2 and Edition 1 are generated from the last successful full IP discovery model. A saved signal cache alone is not treated as complete engineering evidence; use **Re-scan** to capture a full model first.
+
+## Edition choices
+
+- **Edition 2 (Schema V3.1)** → `.iid`
+- **Edition 1 (Schema V1.6)** → `.icd`
+
+The chosen profile controls root schema metadata, supported ReportControl fields, Services declarations, and default file extension.
+
+## Companion evidence
+
+The ARIEC61850 export services write the SCL file together with a JSON report and a Markdown summary. The schema-aware discovery exporter also records excluded-attribute evidence where applicable. Conversion findings and export warnings remain visible in ArIED Diagnostics and in the companion report.
+
+## Scope
+
+The output reflects the typed model available from the opened file or the latest successful MMS discovery. Engineering information that cannot be obtained through the source model is not invented. Export success is interoperability evidence, not a claim of formal IEC 61850 conformance or operational approval.
diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1
index b5ec22ff..cf27f3bc 100644
--- a/scripts/publish-windows-portable.ps1
+++ b/scripts/publish-windows-portable.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.6.9",
+ [string]$Version = "1.6.10",
[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.9 or v1.6.9."
+ throw "Invalid version '$Version'. Use a value such as 1.6.10 or v1.6.10."
}
$outputRoot = Join-Path $root "dist"