Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
451ead1
staging: add validated SCL export source transformer
masarray Jul 15, 2026
132a03e
ci: apply validated per-card SCL export implementation
masarray Jul 15, 2026
d995c5d
ci: capture per-card SCL export transform evidence
masarray Jul 15, 2026
cb19d5f
ci: publish per-card SCL export through contents API
masarray Jul 15, 2026
dd8bc85
ci: capture SCL export publication failure
masarray Jul 15, 2026
f6398b7
ci: restore validated transform and capture publication
masarray Jul 15, 2026
b017535
feat: add SCL edition selector
github-actions[bot] Jul 15, 2026
157fbfe
feat: add SCL edition selector model
github-actions[bot] Jul 15, 2026
c36cde7
docs: document per-card SCL export
github-actions[bot] Jul 15, 2026
75dcf2d
ci: resolve branch ref for existing SCL export files
masarray Jul 15, 2026
a5f88c1
ci: atomically publish per-card SCL export source
masarray Jul 15, 2026
7a8d32d
ci: atomically publish SCL export through Git data API
masarray Jul 15, 2026
9a74e0b
ci: capture atomic SCL export transform failure
masarray Jul 15, 2026
c658060
ci: capture atomic SCL export publication log
masarray Jul 15, 2026
c766ce2
ci: make SCL export generation independent of staging history
masarray Jul 15, 2026
10d024c
ci: upload exact SCL export transform mismatch
masarray Jul 15, 2026
c5b6333
staging: restore verified SCL export generator payload
masarray Jul 15, 2026
d3093da
ci: stream large SCL export blobs into atomic commit
masarray Jul 15, 2026
18c0fd8
feat: add per-card interoperable SCL export
github-actions[bot] Jul 15, 2026
b0a564e
ci: validate Edition 2 and Edition 1 SCL export APIs
masarray Jul 15, 2026
8f95df8
feat: add per-card interoperable SCL export
github-actions[bot] Jul 15, 2026
30c8437
ci: remove temporary SCL export apply workflow
masarray Jul 15, 2026
e4dc24a
chore: remove temporary SCL export staging payload
masarray Jul 15, 2026
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
23 changes: 19 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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."
}
Expand All @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions ArIED61850Tester.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
<Copyright>Copyright (C) 2026 Ari Sulistiono</Copyright>
<Product>ArIED 61850</Product>
<AssemblyTitle>ArIED 61850 — IEC 61850 Engineering Workstation</AssemblyTitle>
<Description>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.</Description>
<Version>1.6.9</Version>
<AssemblyVersion>1.6.9.0</AssemblyVersion>
<FileVersion>1.6.9.0</FileVersion>
<Description>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.</Description>
<Version>1.6.10</Version>
<AssemblyVersion>1.6.10.0</AssemblyVersion>
<FileVersion>1.6.10.0</FileVersion>
<PackageTags>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</PackageTags>
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
<ArIec61850Project Condition="'$(ArIec61850Project)' == '' and '$(ARIEC61850_PROJECT)' != ''">$(ARIEC61850_PROJECT)</ArIec61850Project>
Expand Down
17 changes: 13 additions & 4 deletions MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@

<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Top">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource Ink}" TextTrimming="CharacterEllipsis"/>
Foreground="{StaticResource Ink}" TextTrimming="CharacterEllipsis" Margin="0,0,28,0"/>
<TextBlock Text="{Binding EndpointText}" FontSize="11.6"
Foreground="{StaticResource Muted}" Margin="0,2,0,0"
TextTrimming="CharacterEllipsis"/>
Expand All @@ -240,15 +240,24 @@
<Path Data="M16.5,3.5 A2.12,2.12 0 0 1 19.5,6.5 L7,19 L3,20 L4,16 Z" Stroke="#2563EB" StrokeThickness="2" StrokeLineJoin="Round" Fill="Transparent"/>
</Canvas></Viewbox>
</Button>
<Button Style="{StaticResource IedIconButton}" Tag="{Binding}" Click="IedRemove_Click"
IsEnabled="{Binding IsActionEnabled}" ToolTip="{Binding RemoveToolTip}">
<Button Style="{StaticResource IedIconButton}" Tag="{Binding}" Click="IedSaveScl_Click"
IsEnabled="{Binding CanSaveScl}" ToolTip="{Binding SaveSclToolTip}">
<Viewbox Width="16" Height="16"><Canvas Width="24" Height="24">
<Path Data="M18,6 L6,18 M6,6 L18,18" Stroke="#64748B" StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round"/>
<Path Data="M5,4 H17 L20,7 V20 H5 Z M8,4 V9 H16 V4 M9,20 V14 H16 V20" Stroke="#2563EB" StrokeThickness="1.8" StrokeLineJoin="Round" StrokeStartLineCap="Round" StrokeEndLineCap="Round" Fill="Transparent"/>
</Canvas></Viewbox>
</Button>
</StackPanel>
</Grid>

<Button Style="{StaticResource IedIconButton}" Tag="{Binding}" Click="IedRemove_Click"
IsEnabled="{Binding IsActionEnabled}" ToolTip="{Binding RemoveToolTip}"
Width="25" Height="25" Margin="0,-4,-4,0"
HorizontalAlignment="Right" VerticalAlignment="Top" Panel.ZIndex="12">
<Viewbox Width="14" Height="14"><Canvas Width="24" Height="24">
<Path Data="M18,6 L6,18 M6,6 L18,18" Stroke="#64748B" StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round"/>
</Canvas></Viewbox>
</Button>

<Border x:Name="BusyOverlay" Visibility="Collapsed" Background="#F2F7FDFA" CornerRadius="12"
BorderBrush="#B8CEF3" BorderThickness="1" Padding="12" Panel.ZIndex="20">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Width="190">
Expand Down
169 changes: 169 additions & 0 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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!;
Expand Down
23 changes: 23 additions & 0 deletions Models/MonitorModels.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Scl.Workspace;

namespace ArIED61850Tester.Models;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading