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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +