diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 63dd24d4..a57edd07 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -6,6 +6,10 @@ on:
pull_request:
workflow_dispatch:
+env:
+ # Temporary integration pin. Switch this to main after ARIEC61850 PR #25 is merged.
+ ARIEC61850_REF: agent/scl-workspace-api
+
jobs:
build-windows:
runs-on: windows-latest
@@ -36,21 +40,29 @@ jobs:
- name: Checkout ARIEC61850 engine
shell: powershell
- run: git clone --quiet --depth 1 https://github.com/masarray/ARIEC61850.git ARIEC61850
+ run: git clone --quiet --depth 1 --branch $env:ARIEC61850_REF https://github.com/masarray/ARIEC61850.git ARIEC61850
- - name: Verify ARIEC61850 Smart Control API
+ - name: Verify required ARIEC61850 APIs
shell: powershell
run: |
$control = ".\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlService.cs"
$models = ".\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlModels.cs"
+ $workspace = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceService.cs"
+ $workspaceModels = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceModels.cs"
if (!(Test-Path $control) -or !(Test-Path $models)) {
- throw "ARIEC61850 Smart Control source was not found. ArIED 1.6.5 requires the native Control namespace."
+ throw "ARIEC61850 Smart Control source was not found. ArIED requires the native Control namespace."
}
if (!(Select-String -Path $models -Pattern "interface IIec61850ControlService" -Quiet)) {
throw "ARIEC61850 does not expose IIec61850ControlService."
}
if (!(Select-String -Path $models -Pattern "CommandTerminationReceived" -Quiet)) {
- throw "ARIEC61850 Smart Control result contract is too old for ArIED 1.6.5."
+ throw "ARIEC61850 Smart Control result contract is too old for ArIED."
+ }
+ if (!(Test-Path $workspace) -or !(Test-Path $workspaceModels)) {
+ throw "ARIEC61850 SCL Workspace API was not found. ArIED Open SCL must use the engine-owned workspace service."
+ }
+ if (!(Select-String -Path $workspace -Pattern "CompareLive" -Quiet)) {
+ throw "ARIEC61850 SCL Workspace API does not expose design-versus-live comparison."
}
- name: Setup .NET 8
@@ -66,7 +78,7 @@ jobs:
- name: Publish portable x64
shell: powershell
- run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.5 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj"
+ run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.6 -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 e13c87c9..7b3b99e8 100644
--- a/ArIED61850Tester.csproj
+++ b/ArIED61850Tester.csproj
@@ -13,11 +13,11 @@
Copyright (C) 2026 Mas Ari / masarray
ArIED 61850
ArIED 61850
- Smart IED Explorer & Monitor for live IEC 61850 discovery, multi-IED monitoring, reporting, control-object inspection, quality, timestamps, and event logging.
- 1.6.5
- 1.6.5.0
- 1.6.5.0
- iec61850;iec-61850;mms;ied;relay-testing;substation-automation;digital-substation;report-control-block;wpf;dotnet
+ Smart IED Explorer & Monitor for SCL-based offline engineering, live IEC 61850 discovery, multi-IED monitoring, reporting, control-object inspection, quality, timestamps, and event logging.
+ 1.6.6
+ 1.6.6.0
+ 1.6.6.0
+ iec61850;iec-61850;mms;scl;ied;relay-testing;substation-automation;digital-substation;report-control-block;wpf;dotnet
GPL-3.0-or-later
$(ARIEC61850_PROJECT)
..\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 86660102..b1e2d95f 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.Workspace;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;
using Microsoft.Win32;
@@ -20,6 +21,7 @@ namespace ArIED61850Tester;
public partial class MainWindow : Window, INotifyPropertyChanged
{
private readonly Iec61850MonitorRuntime _runtime = new();
+ private readonly SclWorkspaceService _sclWorkspaceService = new();
private readonly CancellationTokenSource _applicationCancellation = new();
private readonly Dictionary> _pendingProjectSelections = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _signalOwners = new();
@@ -160,28 +162,18 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e)
return;
var sourceName = Path.GetFileName(dialog.FileName);
- SetStatus($"Reading IED endpoints from {sourceName}…");
+ SetStatus($"Opening {sourceName} as an offline IEC 61850 design model…");
try
{
- var result = await SclImportService.LoadAsync(dialog.FileName, _applicationCancellation.Token);
- foreach (var warning in result.Warnings.Take(25))
- AddLog("WARN", "SCL", warning);
- if (result.Warnings.Count > 25)
- AddLog("WARN", "SCL", $"{result.Warnings.Count - 25} additional SCL warning(s) were omitted from the live log.");
+ var document = await _sclWorkspaceService.OpenAsync(
+ dialog.FileName,
+ cancellationToken: _applicationCancellation.Token);
+ LogSclFindings(sourceName, document.Findings);
- if (result.Endpoints.Count == 0)
+ if (document.Ieds.Count == 0)
{
- var reason = result.ConnectedAccessPointCount == 0
- ? "No ConnectedAP communication entries were found."
- : "ConnectedAP entries were found, but none contained a valid IP address.";
- SetStatus($"{sourceName}: no usable IEC 61850 MMS endpoints. {reason}");
- AddLog("WARN", "SCL", $"{sourceName}: {reason}");
- MessageBox.Show(
- this,
- $"No usable IEC 61850 MMS endpoint was found in {sourceName}.\n\n{reason}\n\nThe file may contain only an IED template without a Communication section.",
- "Open SCL",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
+ SetStatus($"{sourceName}: no IED model was found.");
+ AddLog("WARN", "SCL", $"{sourceName}: the engine returned no IED workspace.");
return;
}
@@ -190,51 +182,33 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e)
var retained = 0;
Iec61850MonitorDevice? firstImported = null;
- foreach (var endpoint in result.Endpoints)
+ foreach (var workspace in document.Ieds)
{
var device = Devices.FirstOrDefault(item =>
- item.IpAddress.Equals(endpoint.IpAddress, StringComparison.OrdinalIgnoreCase) &&
- item.Port == endpoint.Port);
+ item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) &&
+ item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) &&
+ item.SclAccessPointName.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase));
+ if (device != null && (device.IsConnected || device.IsBusy || device.IsMonitoring))
+ {
+ retained++;
+ firstImported ??= device;
+ continue;
+ }
+
+ var signals = SclWorkspaceSignalMapper.BuildSignals(workspace);
if (device == null)
{
- device = new Iec61850MonitorDevice
- {
- Name = endpoint.IedName,
- IdentitySource = $"SCL • {sourceName}",
- LogicalDeviceSummary = BuildSclEndpointSummary(endpoint),
- IpAddress = endpoint.IpAddress,
- Port = endpoint.Port,
- AllowDynamicDataSetWrites = true,
- Status = "SCL endpoint ready",
- Detail = $"Imported from {sourceName}. Press Play to connect and verify the live IEC 61850 model.",
- AcquisitionMode = "SCL • live discovery pending"
- };
+ device = new Iec61850MonitorDevice();
Devices.Add(device);
added++;
}
- else if (!device.IsConnected && !device.IsBusy && !device.HasDiscoveryCache)
- {
- if (string.IsNullOrWhiteSpace(device.Name) ||
- device.Name.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase))
- {
- device.Name = endpoint.IedName;
- }
- device.IdentitySource = $"SCL • {sourceName}";
- device.LogicalDeviceSummary = BuildSclEndpointSummary(endpoint);
- device.Status = "SCL endpoint ready";
- device.Detail = $"Endpoint refreshed from {sourceName}. Press Play to connect and verify the live IEC 61850 model.";
- device.AcquisitionMode = "SCL • live discovery pending";
- device.RefreshComputed();
- refreshed++;
- }
else
{
- // Preserve active sessions and successful discovery caches. SCL is an
- // endpoint-import path, never authority over a verified live model.
- retained++;
+ refreshed++;
}
+ ApplySclWorkspaceToDevice(device, document, workspace, signals);
firstImported ??= device;
}
@@ -244,39 +218,130 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e)
UpdateNavigationVisuals(0, animate: true);
RaiseWorkspaceCounts();
- var warningText = result.Warnings.Count == 0 ? string.Empty : $", {result.Warnings.Count} warning(s)";
- var status = $"{sourceName}: {result.Endpoints.Count} SCL endpoint(s) read — {added} added, {refreshed} refreshed, {retained} existing retained{warningText}. Use Play or Connect All for live verification.";
+ var offlineCount = document.Ieds.Count(item => item.CanBrowseOffline);
+ var endpointCount = document.Ieds.Count(item => !item.RequiresEndpointBinding);
+ var status = $"{sourceName}: {document.Ieds.Count} IED/AP workspace(s), {offlineCount} offline model(s), {endpointCount} MMS endpoint(s) — {added} added, {refreshed} refreshed, {retained} active retained.";
SetStatus(status);
AddLog("INFO", "SCL", status);
}
catch (OperationCanceledException)
{
- SetStatus($"{sourceName}: SCL import cancelled.");
+ SetStatus($"{sourceName}: SCL open cancelled.");
}
catch (Exception ex)
{
AddLog("ERROR", "SCL", $"Could not open {sourceName}: {ex.Message}");
- SetStatus($"{sourceName}: SCL import failed. Diagnostics is marked with !.");
+ SetStatus($"{sourceName}: SCL open failed. Diagnostics is marked with !.");
MarkDiagnosticAlert();
MessageBox.Show(
this,
- $"ArIED could not read this SCL file.\n\n{ex.Message}",
+ $"ArIED could not open this SCL file through the ARIEC61850 engine.\n\n{ex.Message}",
"Open SCL",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
- private static string BuildSclEndpointSummary(SclIedEndpoint endpoint)
+ private void ApplySclWorkspaceToDevice(
+ Iec61850MonitorDevice device,
+ SclWorkspaceDocument document,
+ SclIedWorkspace workspace,
+ IReadOnlyList signals)
{
- var parts = new List();
- if (!string.IsNullOrWhiteSpace(endpoint.AccessPointName))
- parts.Add($"AP {endpoint.AccessPointName}");
- if (!string.IsNullOrWhiteSpace(endpoint.SubNetworkName))
- parts.Add(endpoint.SubNetworkName);
- return parts.Count == 0 ? "SCL endpoint" : string.Join(" • ", parts);
+ var previousSelection = device.Signals
+ .Where(signal => signal.IsSelected)
+ .Select(signal => NormalizeReference(signal.ObjectReference))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ DetachSignalHandlers(device.Signals);
+ device.Signals.Clear();
+ device.RecountSelectedSignals();
+
+ var endpoint = workspace.PreferredEndpoint;
+ device.Name = workspace.IedName;
+ device.IdentitySource = $"SCL design • {document.SourceName}";
+ device.LogicalDeviceSummary = BuildSclWorkspaceSummary(workspace);
+ if (endpoint?.HasUsableAddress == true)
+ {
+ device.IpAddress = endpoint.IpAddress;
+ device.Port = endpoint.Port;
+ }
+ else if (string.IsNullOrWhiteSpace(device.IpAddress) || device.IpAddress == "192.168.1.10")
+ {
+ device.IpAddress = string.Empty;
+ device.Port = 102;
+ }
+
+ device.AllowDynamicDataSetWrites = false;
+ device.SclWorkspace = workspace;
+ device.SclComparison = null;
+ device.SclSourcePath = document.SourcePath;
+ device.SclSourceSha256 = document.SourceSha256;
+ device.SclIedName = workspace.IedName;
+ device.SclAccessPointName = workspace.AccessPointName;
+ device.HasDiscoveryCache = signals.Count > 0;
+ device.Status = workspace.RequiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready";
+ device.Detail = 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 = "SCL offline design model";
+
+ foreach (var signal in signals)
+ {
+ signal.IsSelected = previousSelection.Contains(NormalizeReference(signal.ObjectReference));
+ signal.PropertyChanged += Signal_PropertyChanged;
+ _signalOwners[signal] = device;
+ }
+ device.Signals.AddRange(signals);
+ device.RecountSelectedSignals();
+ device.RefreshComputed();
+ }
+
+ private static string BuildSclWorkspaceSummary(SclIedWorkspace workspace)
+ {
+ var coverage = workspace.DesignModel.Coverage;
+ var ap = string.IsNullOrWhiteSpace(workspace.AccessPointName) ? "AP unassigned" : $"AP {workspace.AccessPointName}";
+ return $"{ap} • {coverage.LogicalDeviceCount} LD • {coverage.LogicalNodeCount} LN • {coverage.DataObjectCount} DO • {coverage.DataAttributeCount} DA";
}
+ private void LogSclFindings(string sourceName, IReadOnlyList findings)
+ {
+ foreach (var finding in findings.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}");
+ }
+ if (findings.Count > 40)
+ AddLog("WARN", "SCL", $"{findings.Count - 40} additional finding(s) were omitted from the live log.");
+ if (findings.Any(finding => finding.Severity is "High" or "Error"))
+ MarkDiagnosticAlert();
+ }
+
+ private bool EnsureSclEndpointBinding(Iec61850MonitorDevice device)
+ {
+ if (!device.RequiresEndpointBinding)
+ return true;
+
+ var initialIp = string.IsNullOrWhiteSpace(NewDeviceIp) ? "192.168.1.10" : NewDeviceIp;
+ var wizard = new IpConnectWizardWindow(initialIp, device.Port <= 0 ? 102 : device.Port) { Owner = this };
+ if (wizard.ShowDialog() != true)
+ {
+ SetStatus($"{device.Name}: endpoint binding cancelled; the SCL model remains available offline.");
+ return false;
+ }
+
+ device.IpAddress = wizard.RelayIpAddress;
+ device.Port = wizard.MmsPort;
+ device.Status = "SCL model ready";
+ device.Detail = "Endpoint bound locally. Play will fast-connect from the SCL design model; Re-scan performs full comparison.";
+ device.RefreshComputed();
+ NewDeviceIp = device.IpAddress;
+ NewDevicePort = device.Port.ToString(CultureInfo.InvariantCulture);
+ return true;
+ }
private async void ConnectAllIeds_Click(object sender, RoutedEventArgs e)
{
@@ -326,6 +391,14 @@ private async Task ConnectAndStartWorkspaceDeviceAsync(Iec61850MonitorDevi
{
if (device.IsMonitoring)
return true;
+ if (device.RequiresEndpointBinding)
+ {
+ device.Status = "SCL model ready — endpoint required";
+ device.Detail = "Connect All skipped this offline SCL workspace because no MMS endpoint is bound.";
+ device.RefreshComputed();
+ AddLog("WARN", device.Name, "Connect All skipped the SCL workspace because its MMS endpoint is unassigned.");
+ return false;
+ }
var connected = device.IsConnected;
if (!connected)
@@ -448,6 +521,7 @@ private async Task ConnectAndConfigureDeviceAsync(
bool selectDevice = true)
{
if (device.IsBusy) return false;
+ if (!EnsureSclEndpointBinding(device)) return false;
RememberCurrentSelectionForReconnect(device);
RemoveDevicePoints(device.DeviceId);
@@ -482,6 +556,7 @@ private async Task ConnectAndConfigureDeviceAsync(
}
device.Signals.AddRange(signals);
device.HasDiscoveryCache = signals.Count > 0;
+ ApplySclLiveComparison(device, signals);
try
{
@@ -576,6 +651,7 @@ private async Task ConnectUsingSavedModelAsync(
bool selectDevice = true)
{
if (device.IsBusy) return false;
+ if (!EnsureSclEndpointBinding(device)) return false;
if (!device.HasDiscoveryCache || device.Signals.Count == 0)
return await ConnectAndConfigureDeviceAsync(device, openWizard: true, selectDevice: selectDevice);
@@ -601,7 +677,17 @@ await _runtime.ConnectUsingCachedModelAsync(
device.RecountSelectedSignals();
await WaitForDiscoveryProgressAnimationAsync(device, TimeSpan.FromMilliseconds(900));
RaiseWorkspaceCounts();
- SetStatus($"{device.Name}: fast connected from saved project model; full discovery skipped.");
+ if (device.HasSclDesignModel)
+ {
+ device.Status = "Connected — SCL design model";
+ device.Detail = "MMS association is live and the SCL workspace remains the active model. Re-scan performs a complete design-versus-live comparison.";
+ device.AcquisitionMode = "SCL design model • live association";
+ SetStatus($"{device.Name}: fast connected from the SCL design model; full discovery skipped. Use Re-scan to compare the complete live model.");
+ }
+ else
+ {
+ SetStatus($"{device.Name}: fast connected from saved project model; full discovery skipped.");
+ }
return true;
}
catch (OperationCanceledException)
@@ -644,6 +730,47 @@ await _runtime.ConnectUsingCachedModelAsync(
}
}
+ private void ApplySclLiveComparison(Iec61850MonitorDevice device, IReadOnlyList liveSignals)
+ {
+ if (device.SclWorkspace == null)
+ return;
+
+ var expectedModel = SclLiveSignalModelProjection.Build(
+ device.SclWorkspace.IedName,
+ device.SclWorkspace.AccessPointName,
+ SclWorkspaceSignalMapper.BuildSignals(device.SclWorkspace));
+ var observedModel = SclLiveSignalModelProjection.Build(
+ device.Name,
+ device.SclWorkspace.AccessPointName,
+ liveSignals);
+ var comparison = SclLiveModelComparer.Compare(expectedModel, observedModel);
+ device.SclComparison = comparison;
+ foreach (var finding in comparison.Findings.Take(30))
+ {
+ var level = finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase) ? "ERROR" : "INFO";
+ AddLog(level, "SCL Compare", $"{finding.Kind} • {finding.Message}");
+ }
+ if (comparison.Findings.Count > 30)
+ AddLog("WARN", "SCL Compare", $"{comparison.Findings.Count - 30} additional comparison finding(s) were omitted from the live log.");
+
+ if (comparison.IsCompatible)
+ {
+ device.IdentitySource = $"SCL + live verified • {Path.GetFileName(device.SclSourcePath)}";
+ device.AcquisitionMode = "SCL design • live model verified";
+ device.Detail = $"SCL and live MMS structures are compatible: {comparison.MatchedAttributeCount}/{comparison.ExpectedAttributeCount} expected attributes matched.";
+ AddLog("INFO", device.Name, device.Detail);
+ }
+ else
+ {
+ device.IdentitySource = $"SCL drift detected • {Path.GetFileName(device.SclSourcePath)}";
+ device.AcquisitionMode = "Live discovery • SCL configuration drift";
+ device.Detail = $"Live discovery found {comparison.BlockingFindingCount} blocking SCL mismatch(es). Live data is shown; review Diagnostics before testing control or reporting.";
+ MarkDiagnosticAlert();
+ AddLog("ERROR", device.Name, device.Detail);
+ }
+ device.RefreshComputed();
+ }
+
private async Task OpenSignalSelectionWizardAsync(
Iec61850MonitorDevice device,
int restoredSelectionCount = -1,
@@ -1102,11 +1229,15 @@ private async void IedRescan_Click(object sender, RoutedEventArgs e)
return;
SelectedDevice = device;
+ if (!EnsureSclEndpointBinding(device))
+ return;
RememberCurrentSelectionForReconnect(device);
if (device.IsConnected)
await StopDeviceConnectionAsync(device);
- SetStatus($"{device.Name}: running a forced full live-model discovery. The saved cache will be replaced only after success.");
+ SetStatus(device.HasSclDesignModel
+ ? $"{device.Name}: discovering the complete live model and comparing it with the SCL design model."
+ : $"{device.Name}: running a forced full live-model discovery. The saved cache will be replaced only after success.");
await ConnectAndConfigureDeviceAsync(device, openWizard: false);
}
@@ -1395,6 +1526,10 @@ private async void SaveProject_Click(object sender, RoutedEventArgs e)
Port = device.Port,
AllowDynamicDataSetWrites = device.AllowDynamicDataSetWrites,
DiscoverySucceeded = device.HasDiscoveryCache && device.Signals.Count > 0,
+ SclSourcePath = device.SclSourcePath,
+ SclSourceSha256 = device.SclSourceSha256,
+ SclIedName = device.SclIedName,
+ SclAccessPointName = device.SclAccessPointName,
SelectedReferences = device.Signals
.Where(signal => signal.IsSelected)
.Select(signal => NormalizeReference(signal.ObjectReference))
@@ -1459,6 +1594,7 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e)
foreach (var profile in project.Devices ?? new List())
{
+ var restoredSclWorkspace = await TryRestoreSclWorkspaceAsync(profile);
var cachedSignals = (profile.CachedSignals ?? new List())
.Where(item => !string.IsNullOrWhiteSpace(item.ObjectReference))
.Select(item => item.ToSignal())
@@ -1466,11 +1602,14 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e)
.GroupBy(item => NormalizeReference(item.ObjectReference), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToList();
+ if (restoredSclWorkspace != null)
+ cachedSignals = SclWorkspaceSignalMapper.BuildSignals(restoredSclWorkspace).ToList();
var selectedReferences = (profile.SelectedReferences ?? new List())
.Select(NormalizeReference)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
- var hasSavedModel = profile.DiscoverySucceeded && cachedSignals.Count > 0;
+ var hasSclProvenance = restoredSclWorkspace != null || !string.IsNullOrWhiteSpace(profile.SclSourceSha256);
+ var hasSavedModel = (profile.DiscoverySucceeded || hasSclProvenance) && cachedSignals.Count > 0;
var device = new Iec61850MonitorDevice
{
DeviceId = string.IsNullOrWhiteSpace(profile.DeviceId) ? Guid.NewGuid().ToString("N") : profile.DeviceId,
@@ -1479,13 +1618,24 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e)
LogicalDeviceSummary = profile.LogicalDeviceSummary,
IpAddress = profile.IpAddress,
Port = profile.Port <= 0 ? 102 : profile.Port,
- AllowDynamicDataSetWrites = profile.AllowDynamicDataSetWrites,
+ AllowDynamicDataSetWrites = hasSclProvenance ? false : profile.AllowDynamicDataSetWrites,
+ SclWorkspace = restoredSclWorkspace,
+ SclSourcePath = profile.SclSourcePath,
+ SclSourceSha256 = profile.SclSourceSha256,
+ SclIedName = profile.SclIedName,
+ SclAccessPointName = profile.SclAccessPointName,
HasDiscoveryCache = hasSavedModel,
- Status = hasSavedModel ? "Saved model ready" : "Discovery required",
- Detail = hasSavedModel
- ? "Press Play for fast connect and live values. Full signal discovery is skipped unless Re-scan is selected."
- : "This IED has no successful saved discovery. Press Play to scan the live model.",
- AcquisitionMode = hasSavedModel ? "Saved model • fast connect" : "Not connected • scan required"
+ Status = hasSclProvenance
+ ? string.IsNullOrWhiteSpace(profile.IpAddress) ? "SCL model ready — bind endpoint" : "SCL model ready"
+ : hasSavedModel ? "Saved model ready" : "Discovery required",
+ Detail = hasSclProvenance
+ ? "SCL design model restored from project provenance. Play fast-connects; Re-scan compares the full live model."
+ : hasSavedModel
+ ? "Press Play for fast connect and live values. Full signal discovery is skipped unless Re-scan is selected."
+ : "This IED has no successful saved discovery. Press Play to scan the live model.",
+ AcquisitionMode = hasSclProvenance
+ ? "SCL project model • offline"
+ : hasSavedModel ? "Saved model • fast connect" : "Not connected • scan required"
};
Devices.Add(device);
@@ -1509,7 +1659,8 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e)
SelectedDevice = Devices.FirstOrDefault();
RaiseWorkspaceCounts();
var cachedCount = Devices.Count(device => device.HasDiscoveryCache);
- SetStatus($"Project loaded: {Devices.Count} IED profile(s), {cachedCount} saved discovery model(s) ready for fast Play connect without a full scan.");
+ var sclCount = Devices.Count(device => device.HasSclDesignModel);
+ SetStatus($"Project loaded: {Devices.Count} IED profile(s), {cachedCount} cached model(s), {sclCount} SCL design model(s) ready for offline browsing and fast Play connect.");
}
catch (Exception ex)
{
@@ -1518,6 +1669,44 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e)
}
}
+ private async Task TryRestoreSclWorkspaceAsync(Iec61850TesterDeviceProfile profile)
+ {
+ if (string.IsNullOrWhiteSpace(profile.SclSourcePath) || string.IsNullOrWhiteSpace(profile.SclSourceSha256))
+ return null;
+ if (!File.Exists(profile.SclSourcePath))
+ {
+ AddLog("WARN", "SCL", $"Saved SCL source is unavailable: {profile.SclSourcePath}. The cached signal model remains usable.");
+ return null;
+ }
+
+ try
+ {
+ var document = await _sclWorkspaceService.OpenAsync(
+ profile.SclSourcePath,
+ new SclWorkspaceOpenOptions
+ {
+ IedName = profile.SclIedName,
+ AccessPointName = profile.SclAccessPointName
+ },
+ _applicationCancellation.Token);
+ if (!document.SourceSha256.Equals(profile.SclSourceSha256, StringComparison.OrdinalIgnoreCase))
+ {
+ AddLog("ERROR", "SCL", $"Saved SCL source changed on disk: {profile.SclSourcePath}. Cached signals were retained and the changed file was not trusted automatically.");
+ MarkDiagnosticAlert();
+ return null;
+ }
+
+ return document.Ieds.FirstOrDefault(item =>
+ (string.IsNullOrWhiteSpace(profile.SclIedName) || item.IedName.Equals(profile.SclIedName, StringComparison.OrdinalIgnoreCase)) &&
+ (string.IsNullOrWhiteSpace(profile.SclAccessPointName) || item.AccessPointName.Equals(profile.SclAccessPointName, StringComparison.OrdinalIgnoreCase)));
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ AddLog("WARN", "SCL", $"Could not restore {profile.SclSourcePath}: {ex.Message}. Cached signals were retained.");
+ return null;
+ }
+ }
+
private void ExportEvents_Click(object sender, RoutedEventArgs e)
{
if (Events.Count == 0)
diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs
index 959b66cc..6dc27ab8 100644
--- a/Models/MonitorModels.cs
+++ b/Models/MonitorModels.cs
@@ -1,3 +1,5 @@
+using AR.Iec61850.Scl.Workspace;
+
namespace ArIED61850Tester.Models;
public sealed class Iec61850MonitorDevice : ObservableObject
@@ -28,6 +30,12 @@ public sealed class Iec61850MonitorDevice : ObservableObject
private bool _hasDiscoveryCache;
private string _busyTitle = "Discovering IEC 61850 IED";
private int _unreadEventCount;
+ private SclIedWorkspace? _sclWorkspace;
+ private SclLiveModelComparisonResult? _sclComparison;
+ private string _sclSourcePath = string.Empty;
+ private string _sclSourceSha256 = string.Empty;
+ private string _sclIedName = string.Empty;
+ private string _sclAccessPointName = string.Empty;
public string DeviceId { get; set; } = Guid.NewGuid().ToString("N");
public BulkObservableCollection Signals { get; } = new();
@@ -35,6 +43,63 @@ public sealed class Iec61850MonitorDevice : ObservableObject
public BulkObservableCollection CommandSignals { get; } = new();
public Iec61850DeviceDiagnosticSnapshot LastDiagnosticSnapshot { get; set; } = new();
+ public SclIedWorkspace? SclWorkspace
+ {
+ get => _sclWorkspace;
+ set
+ {
+ if (ReferenceEquals(_sclWorkspace, value)) return;
+ _sclWorkspace = value;
+ RefreshComputed();
+ }
+ }
+
+ public SclLiveModelComparisonResult? SclComparison
+ {
+ get => _sclComparison;
+ set
+ {
+ if (ReferenceEquals(_sclComparison, value)) return;
+ _sclComparison = value;
+ RefreshComputed();
+ }
+ }
+
+ public string SclSourcePath
+ {
+ get => _sclSourcePath;
+ set => Set(ref _sclSourcePath, value?.Trim() ?? string.Empty);
+ }
+
+ public string SclSourceSha256
+ {
+ get => _sclSourceSha256;
+ set => Set(ref _sclSourceSha256, value?.Trim() ?? string.Empty);
+ }
+
+ public string SclIedName
+ {
+ get => _sclIedName;
+ set => Set(ref _sclIedName, value?.Trim() ?? string.Empty);
+ }
+
+ public string SclAccessPointName
+ {
+ get => _sclAccessPointName;
+ set => Set(ref _sclAccessPointName, value?.Trim() ?? string.Empty);
+ }
+
+ public bool HasSclDesignModel => SclWorkspace != null || !string.IsNullOrWhiteSpace(SclSourceSha256);
+ public bool RequiresEndpointBinding => HasSclDesignModel && string.IsNullOrWhiteSpace(IpAddress);
+ public bool HasSclConfigurationDrift => SclComparison?.RequiresFullDiscovery == true;
+ public string SclVerificationText => !HasSclDesignModel
+ ? string.Empty
+ : SclComparison == null
+ ? IsConnected ? "SCL associated • full model unverified" : "SCL offline model"
+ : SclComparison.IsCompatible
+ ? "SCL verified against live model"
+ : $"SCL drift • {SclComparison.BlockingFindingCount} blocking finding(s)";
+
// Smart Auto reporting: existing static RCB/DataSet first, temporary association-
// scoped dynamic DataSet/URCB second, MMS polling only when reporting cannot be armed.
public bool AllowDynamicDataSetWrites { get; set; } = true;
@@ -320,28 +385,42 @@ 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 string CacheStateText => HasDiscoveryCache
- ? $"Saved model ready • {SignalCount:N0} signals"
- : "No saved model • discovery required";
- public string ReadyWorkspaceTitle => HasDiscoveryCache ? "Saved IED model is ready" : "IED is ready";
- public string ReadyWorkspaceMessage => HasDiscoveryCache && SelectedLiveSignalCount > 0
- ? $"{SelectedLiveSignalCount:N0} saved live signal(s) are selected. Use Play or Connect All for immediate live values without a full discovery scan."
+ public string CacheStateText => HasSclDesignModel
+ ? $"SCL design model • {SignalCount:N0} signals"
: HasDiscoveryCache
- ? "The complete signal model is restored. Choose signals offline; Apply & Start Live connects and starts monitoring automatically."
- : "Choose signals in the wizard; Apply & Start Live begins monitoring automatically.";
+ ? $"Saved live model • {SignalCount:N0} signals"
+ : "No cached model • discovery required";
+ public string ReadyWorkspaceTitle => HasSclDesignModel
+ ? RequiresEndpointBinding ? "SCL model ready — endpoint required" : "SCL design model is ready"
+ : HasDiscoveryCache ? "Saved IED model is ready" : "IED is ready";
+ public string ReadyWorkspaceMessage => HasSclDesignModel
+ ? RequiresEndpointBinding
+ ? "The LD/LN/DO/DA model is available offline. Press Play to bind an MMS endpoint before connecting."
+ : SelectedLiveSignalCount > 0
+ ? $"{SelectedLiveSignalCount:N0} SCL signal(s) are selected. Play performs a fast MMS association; Re-scan compares the complete live model."
+ : "Browse and choose signals offline. Play associates without repeating full discovery; Re-scan performs design-versus-live verification."
+ : HasDiscoveryCache && SelectedLiveSignalCount > 0
+ ? $"{SelectedLiveSignalCount:N0} saved live signal(s) are selected. Use Play or Connect All for immediate live values without a full discovery scan."
+ : HasDiscoveryCache
+ ? "The complete signal model is restored. Choose signals offline; Apply & Start Live connects and starts monitoring automatically."
+ : "Choose signals in the wizard; Apply & Start Live begins monitoring automatically.";
public string ConnectionActionLabel => IsBusy ? "Working…" : IsConnected ? "Disconnect" : "Connect";
public string MonitorActionLabel => IsBusy ? "Working…" : IsMonitoring ? "Stop Monitor" : "Start Monitor";
public string ActivityText => IsBusy ? "Working…" : IsMonitoring ? "Monitoring" : Status;
public string SummaryText => $"{SignalCount} scanned • {SelectedSignalCount} selected • {PointCount} live";
public string IdentityText => string.IsNullOrWhiteSpace(LogicalDeviceSummary)
? EndpointText
- : $"{EndpointText} • LD {LogicalDeviceSummary}";
+ : HasSclDesignModel
+ ? $"{EndpointText} • {LogicalDeviceSummary}"
+ : $"{EndpointText} • LD {LogicalDeviceSummary}";
public string ConnectionGlyph => IsBusy ? "…" : IsConnected ? "⏻" : "↗";
public string ConnectionToolTip => IsBusy
? "IED connection operation is running"
: IsConnected
? $"Disconnect {Name}"
- : $"Connect and discover {EndpointText}";
+ : HasSclDesignModel
+ ? $"Connect {Name} using the SCL design model"
+ : $"Connect and discover {EndpointText}";
public string MonitorGlyph => IsBusy ? "…" : IsMonitoring ? "■" : "▶";
public string MonitorToolTip => IsBusy
? "IED session operation is running"
@@ -354,9 +433,13 @@ public void MarkDiscoveryFailed(string message)
? $"Stop monitoring {Name} before changing its signal selection"
: $"Open signal selection wizard for {Name}";
public string PlayToolTip => !IsConnected
- ? HasDiscoveryCache
- ? $"Fast-connect {Name} from the saved model and start its selected live values"
- : $"Connect and discover {EndpointText}"
+ ? RequiresEndpointBinding
+ ? $"Bind an MMS endpoint for {Name}, then fast-connect from the SCL design model"
+ : HasSclDesignModel
+ ? $"Fast-connect {Name} from the SCL design model; use Re-scan for full live comparison"
+ : HasDiscoveryCache
+ ? $"Fast-connect {Name} from the saved model and start its selected live values"
+ : $"Connect and discover {EndpointText}"
: IsMonitoring
? $"{Name} is already monitoring"
: SelectedLiveSignalCount == 0
@@ -436,6 +519,10 @@ public void RefreshComputed()
Raise(nameof(CanStopAction));
Raise(nameof(CanRescan));
Raise(nameof(CacheStateText));
+ Raise(nameof(HasSclDesignModel));
+ Raise(nameof(RequiresEndpointBinding));
+ Raise(nameof(HasSclConfigurationDrift));
+ Raise(nameof(SclVerificationText));
Raise(nameof(ReadyWorkspaceTitle));
Raise(nameof(ReadyWorkspaceMessage));
Raise(nameof(ConnectionActionLabel));
@@ -681,7 +768,7 @@ private static bool TryParseBinaryState(string? value, out bool state)
public sealed class Iec61850TesterProject
{
- public int SchemaVersion { get; set; } = 2;
+ public int SchemaVersion { get; set; } = 3;
public string ProjectName { get; set; } = "ArIED 61850 Session";
public int DefaultPollingIntervalMs { get; set; } = 1000;
public List Devices { get; set; } = new();
@@ -697,6 +784,10 @@ public sealed class Iec61850TesterDeviceProfile
public int Port { get; set; } = 102;
public bool AllowDynamicDataSetWrites { get; set; } = true;
public bool DiscoverySucceeded { get; set; }
+ public string SclSourcePath { get; set; } = string.Empty;
+ public string SclSourceSha256 { get; set; } = string.Empty;
+ public string SclIedName { get; set; } = string.Empty;
+ public string SclAccessPointName { get; set; } = string.Empty;
public List SelectedReferences { get; set; } = new();
public List CachedSignals { get; set; } = new();
}
diff --git a/Services/SclImportService.cs b/Services/SclImportService.cs
deleted file mode 100644
index 77bcc13c..00000000
--- a/Services/SclImportService.cs
+++ /dev/null
@@ -1,208 +0,0 @@
-using System.Globalization;
-using System.IO;
-using System.Net;
-using System.Xml;
-using System.Xml.Linq;
-
-namespace ArIED61850Tester.Services;
-
-public sealed record SclIedEndpoint(
- string IedName,
- string AccessPointName,
- string IpAddress,
- int Port,
- string SubNetworkName)
-{
- public string EndpointText => $"{IpAddress}:{Port}";
-}
-
-public sealed class SclImportResult
-{
- public string SourceFilePath { get; init; } = string.Empty;
- public int IedDefinitionCount { get; init; }
- public int ConnectedAccessPointCount { get; init; }
- public IReadOnlyList Endpoints { get; init; } = Array.Empty();
- public IReadOnlyList Warnings { get; init; } = Array.Empty();
-}
-
-///
-/// Reads IEC 61850 SCL communication data without trusting external entities.
-/// The importer is namespace-version agnostic and extracts IED/AP/IP endpoint
-/// identity only; the live IED model is still verified through MMS discovery.
-///
-public static class SclImportService
-{
- private static readonly string[] IpParameterNames =
- {
- "IP", "IPv4", "IPv6", "IP-Address", "IPAddress"
- };
-
- private static readonly string[] PortParameterNames =
- {
- "MMS-Port", "MMS_PORT", "IP-Port", "TCP-Port", "Port"
- };
-
- public static Task LoadAsync(string filePath, CancellationToken cancellationToken = default)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
- return Task.Run(() => Load(filePath, cancellationToken), cancellationToken);
- }
-
- private static SclImportResult Load(string filePath, CancellationToken cancellationToken)
- {
- cancellationToken.ThrowIfCancellationRequested();
- if (!File.Exists(filePath))
- throw new FileNotFoundException("The selected SCL file no longer exists.", filePath);
-
- var settings = new XmlReaderSettings
- {
- DtdProcessing = DtdProcessing.Prohibit,
- XmlResolver = null,
- IgnoreComments = true,
- IgnoreProcessingInstructions = true,
- IgnoreWhitespace = true,
- CloseInput = true
- };
-
- XDocument document;
- using (var stream = File.OpenRead(filePath))
- using (var reader = XmlReader.Create(stream, settings))
- document = XDocument.Load(reader, LoadOptions.None);
-
- cancellationToken.ThrowIfCancellationRequested();
- var root = document.Root ?? throw new InvalidDataException("The selected XML document is empty.");
- if (!NameIs(root, "SCL"))
- throw new InvalidDataException("The selected XML document is not an IEC 61850 SCL file (missing SCL root element).");
-
- var warnings = new List();
- var iedNames = root
- .Descendants()
- .Where(element => NameIs(element, "IED"))
- .Select(element => AttributeValue(element, "name"))
- .Where(name => !string.IsNullOrWhiteSpace(name))
- .ToHashSet(StringComparer.OrdinalIgnoreCase);
-
- var connectedAccessPoints = root
- .Descendants()
- .Where(element => NameIs(element, "ConnectedAP"))
- .ToArray();
-
- var endpoints = new List();
- var endpointKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- foreach (var connectedAp in connectedAccessPoints)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var iedName = AttributeValue(connectedAp, "iedName");
- var accessPointName = AttributeValue(connectedAp, "apName");
- var subNetworkName = connectedAp
- .Ancestors()
- .FirstOrDefault(element => NameIs(element, "SubNetwork")) is { } subNetwork
- ? AttributeValue(subNetwork, "name")
- : string.Empty;
-
- if (string.IsNullOrWhiteSpace(iedName))
- {
- warnings.Add("A ConnectedAP entry was ignored because its iedName attribute is empty.");
- continue;
- }
-
- if (iedNames.Count > 0 && !iedNames.Contains(iedName))
- warnings.Add($"ConnectedAP '{iedName}/{accessPointName}' does not match an IED definition in the file; its endpoint was still imported.");
-
- var parameters = ReadAddressParameters(connectedAp).ToArray();
- var ipText = FindParameter(parameters, IpParameterNames);
- if (string.IsNullOrWhiteSpace(ipText))
- {
- warnings.Add($"{iedName}/{DisplayAccessPoint(accessPointName)} has no IP address in its ConnectedAP Address block.");
- continue;
- }
-
- if (!IPAddress.TryParse(ipText, out var parsedIp))
- {
- warnings.Add($"{iedName}/{DisplayAccessPoint(accessPointName)} has an invalid IP address '{ipText}'.");
- continue;
- }
-
- var port = 102;
- var portText = FindParameter(parameters, PortParameterNames);
- if (!string.IsNullOrWhiteSpace(portText) &&
- (!int.TryParse(portText, NumberStyles.Integer, CultureInfo.InvariantCulture, out port) || port is <= 0 or > 65535))
- {
- warnings.Add($"{iedName}/{DisplayAccessPoint(accessPointName)} has invalid MMS port '{portText}'; TCP 102 was used.");
- port = 102;
- }
-
- var canonicalIp = parsedIp.ToString();
- var key = $"{canonicalIp}|{port}";
- if (!endpointKeys.Add(key))
- {
- warnings.Add($"Duplicate endpoint {canonicalIp}:{port} for {iedName}/{DisplayAccessPoint(accessPointName)} was ignored.");
- continue;
- }
-
- endpoints.Add(new SclIedEndpoint(
- iedName,
- accessPointName,
- canonicalIp,
- port,
- subNetworkName));
- }
-
- return new SclImportResult
- {
- SourceFilePath = Path.GetFullPath(filePath),
- IedDefinitionCount = iedNames.Count,
- ConnectedAccessPointCount = connectedAccessPoints.Length,
- Endpoints = endpoints
- .OrderBy(endpoint => endpoint.IedName, StringComparer.OrdinalIgnoreCase)
- .ThenBy(endpoint => endpoint.AccessPointName, StringComparer.OrdinalIgnoreCase)
- .ThenBy(endpoint => endpoint.IpAddress, StringComparer.OrdinalIgnoreCase)
- .ToArray(),
- Warnings = warnings.ToArray()
- };
- }
-
- private static IEnumerable> ReadAddressParameters(XElement connectedAp)
- {
- var address = connectedAp.Elements().FirstOrDefault(element => NameIs(element, "Address")) ??
- connectedAp.Descendants().FirstOrDefault(element => NameIs(element, "Address"));
- if (address == null)
- yield break;
-
- foreach (var parameter in address.Elements().Where(element => NameIs(element, "P")))
- {
- var type = AttributeValue(parameter, "type");
- var value = parameter.Value.Trim();
- if (!string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(value))
- yield return new KeyValuePair(type, value);
- }
- }
-
- private static string FindParameter(
- IReadOnlyCollection> parameters,
- IEnumerable candidateNames)
- {
- foreach (var candidate in candidateNames)
- {
- var value = parameters.FirstOrDefault(parameter =>
- parameter.Key.Equals(candidate, StringComparison.OrdinalIgnoreCase)).Value;
- if (!string.IsNullOrWhiteSpace(value))
- return value.Trim();
- }
-
- return string.Empty;
- }
-
- private static bool NameIs(XElement element, string localName)
- => element.Name.LocalName.Equals(localName, StringComparison.OrdinalIgnoreCase);
-
- private static string AttributeValue(XElement element, string localName)
- => element.Attributes()
- .FirstOrDefault(attribute => attribute.Name.LocalName.Equals(localName, StringComparison.OrdinalIgnoreCase))
- ?.Value.Trim() ?? string.Empty;
-
- private static string DisplayAccessPoint(string accessPointName)
- => string.IsNullOrWhiteSpace(accessPointName) ? "unnamed AP" : accessPointName;
-}
diff --git a/Services/SclLiveSignalModelProjection.cs b/Services/SclLiveSignalModelProjection.cs
new file mode 100644
index 00000000..a55622bc
--- /dev/null
+++ b/Services/SclLiveSignalModelProjection.cs
@@ -0,0 +1,281 @@
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Converts the application-neutral signal rows produced by ARIEC61850 discovery into
+/// a bounded live-model projection that can be evaluated by the engine SCL comparer.
+/// It does not parse SCL or infer protocol services from XML.
+///
+public static class SclLiveSignalModelProjection
+{
+ public static LiveIedModelDiscoveryDocument Build(
+ string iedName,
+ string accessPointName,
+ IReadOnlyList signals)
+ {
+ ArgumentNullException.ThrowIfNull(signals);
+
+ var attributes = signals
+ .SelectMany(ToAttributeRows)
+ .GroupBy(row => row.Reference, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .ToArray();
+
+ var logicalDevices = attributes
+ .GroupBy(row => row.Domain, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
+ .Select(domainGroup => new LiveIedLogicalDeviceModel
+ {
+ MmsDomain = domainGroup.Key,
+ Inst = ResolveLdInst(domainGroup.Key, iedName),
+ LogicalNodes = domainGroup
+ .GroupBy(row => row.LogicalNode, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
+ .Select(logicalNodeGroup => BuildLogicalNode(logicalNodeGroup.Key, logicalNodeGroup))
+ .ToArray()
+ })
+ .ToArray();
+
+ var dataSets = BuildDataSets(signals);
+ var reports = BuildReportControls(signals);
+ var logicalNodes = logicalDevices.SelectMany(device => device.LogicalNodes).ToArray();
+ var dataObjects = logicalNodes.SelectMany(node => node.DataObjects).ToArray();
+ var dataAttributes = dataObjects.SelectMany(dataObject => dataObject.Attributes).ToArray();
+
+ return new LiveIedModelDiscoveryDocument
+ {
+ Source = "ArIEDSignalProjection",
+ IedName = iedName ?? string.Empty,
+ AccessPointName = accessPointName ?? string.Empty,
+ LogicalDevices = logicalDevices,
+ DataSets = dataSets,
+ ReportControls = reports,
+ Coverage = new LiveIedModelDiscoveryCoverage
+ {
+ LogicalDeviceCount = logicalDevices.Length,
+ LogicalNodeCount = logicalNodes.Length,
+ DataObjectCount = dataObjects.Length,
+ DataAttributeCount = dataAttributes.Length,
+ ExactFunctionalConstraintCount = dataAttributes.Count(attribute =>
+ attribute.FunctionalConstraintConfidence == LiveIedDiscoveryConfidenceLevel.Exact),
+ ExactMmsTypeCount = dataAttributes.Count(attribute =>
+ attribute.TypeConfidence is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High),
+ DataSetCount = dataSets.Length,
+ ReportControlCount = reports.Length,
+ BufferedReportControlCount = reports.Count(report => report.Buffered),
+ UnbufferedReportControlCount = reports.Count(report => !report.Buffered)
+ },
+ Summary = $"ArIED signal projection: LD={logicalDevices.Length}, LN={logicalNodes.Length}, DO={dataObjects.Length}, DA={dataAttributes.Length}, DataSet={dataSets.Length}, RCB={reports.Length}."
+ };
+ }
+
+ private static LiveIedLogicalNodeModel BuildLogicalNode(
+ string logicalNodeName,
+ IEnumerable rows)
+ {
+ var materialized = rows.ToArray();
+ var lnClass = SignalDefinition.DetectLogicalNodeClass(logicalNodeName);
+ var dataObjects = materialized
+ .GroupBy(row => row.DataObject, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
+ .Select(group => new LiveIedDataObjectModel
+ {
+ Reference = $"{group.First().Domain}/{logicalNodeName}.{group.Key}",
+ Name = group.Key,
+ InferredCdc = group.Select(row => row.Cdc).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty,
+ CdcConfidence = 0.90,
+ ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.High,
+ Evidence = new[] { "Projected from ARIEC61850 signal discovery output." },
+ Attributes = group
+ .OrderBy(row => row.AttributePath, StringComparer.OrdinalIgnoreCase)
+ .Select(row => new LiveIedDataAttributeModel
+ {
+ ObjectReference = row.Reference,
+ AttributePath = row.AttributePath,
+ FunctionalConstraint = row.FunctionalConstraint,
+ MmsReference = row.Reference,
+ MmsItemName = row.Reference.Contains('/')
+ ? row.Reference[(row.Reference.IndexOf('/') + 1)..]
+ : row.Reference,
+ Source = "ArIED.SignalDefinition",
+ SclBType = row.DataType,
+ MmsType = row.DataType,
+ MmsTypeSignature = row.DataType,
+ TypeDiscoveryStatus = "Projected",
+ TypeDiscoveryMessage = "Projected from ARIEC61850 live signal discovery.",
+ TypeSource = "ARIEC61850 live discovery",
+ TypeConfidence = LiveIedDiscoveryConfidenceLevel.High,
+ FunctionalConstraintConfidence = string.IsNullOrWhiteSpace(row.FunctionalConstraint)
+ ? LiveIedDiscoveryConfidenceLevel.Unknown
+ : LiveIedDiscoveryConfidenceLevel.Exact
+ })
+ .ToArray()
+ })
+ .ToArray();
+
+ return new LiveIedLogicalNodeModel
+ {
+ Name = logicalNodeName,
+ LnClass = lnClass,
+ ProposedLnTypeId = $"LN_{lnClass}_{logicalNodeName}",
+ FunctionalConstraintCounts = dataObjects
+ .SelectMany(dataObject => dataObject.Attributes)
+ .Where(attribute => !string.IsNullOrWhiteSpace(attribute.FunctionalConstraint))
+ .GroupBy(attribute => attribute.FunctionalConstraint, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase),
+ DataObjects = dataObjects
+ };
+ }
+
+ private static IEnumerable ToAttributeRows(SignalDefinition signal)
+ {
+ if (!TryParseReference(signal.ObjectReference, out var parsed))
+ yield break;
+
+ if (!signal.IsControlSignal)
+ {
+ yield return new AttributeRow(
+ parsed.Domain,
+ parsed.LogicalNode,
+ parsed.DataObject,
+ parsed.AttributePath,
+ signal.ObjectReference,
+ signal.FunctionalConstraint,
+ NormalizeDataType(signal.DataType),
+ string.Empty);
+ yield break;
+ }
+
+ if (TryParseReference(signal.ControlModelReference, out var ctlModel))
+ {
+ yield return new AttributeRow(
+ ctlModel.Domain,
+ ctlModel.LogicalNode,
+ ctlModel.DataObject,
+ ctlModel.AttributePath,
+ signal.ControlModelReference,
+ "CF",
+ "Enum",
+ signal.ControlCdc);
+ }
+ }
+
+ private static LiveIedDataSetModel[] BuildDataSets(IReadOnlyList signals)
+ => signals
+ .Where(signal => !string.IsNullOrWhiteSpace(signal.DataSetReference))
+ .GroupBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase)
+ .Select(group =>
+ {
+ ParseContainerReference(group.Key, out var domain, out var logicalNode, out var name);
+ var members = group
+ .Where(signal => !signal.IsControlSignal)
+ .GroupBy(signal => signal.ObjectReference, StringComparer.OrdinalIgnoreCase)
+ .Select((member, index) => new LiveIedDataSetMemberModel
+ {
+ Index = index + 1,
+ Reference = member.First().ObjectReference,
+ FunctionalConstraint = member.First().FunctionalConstraint,
+ MmsReference = member.First().ObjectReference,
+ Confidence = LiveIedDiscoveryConfidenceLevel.High
+ })
+ .ToArray();
+ return new LiveIedDataSetModel
+ {
+ Reference = group.Key,
+ Domain = domain,
+ LogicalNode = logicalNode,
+ Name = name,
+ MemberCount = members.Length,
+ Members = members
+ };
+ })
+ .ToArray();
+
+ private static LiveIedReportControlModel[] BuildReportControls(IReadOnlyList signals)
+ => signals
+ .Where(signal => !string.IsNullOrWhiteSpace(signal.ReportControlReference))
+ .GroupBy(signal => signal.ReportControlReference, StringComparer.OrdinalIgnoreCase)
+ .Select(group =>
+ {
+ ParseContainerReference(group.Key, out var domain, out var logicalNode, out var name);
+ var reference = group.Key.Replace('$', '.');
+ return new LiveIedReportControlModel
+ {
+ Reference = group.Key,
+ Domain = domain,
+ LogicalNode = logicalNode,
+ Name = name,
+ Buffered = reference.Contains(".BR.", StringComparison.OrdinalIgnoreCase),
+ DataSetReference = group.Select(signal => signal.DataSetReference)
+ .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty,
+ Status = "Projected from ARIEC61850 live signal bindings"
+ };
+ })
+ .ToArray();
+
+ private static bool TryParseReference(string? reference, out ParsedReference parsed)
+ {
+ parsed = default;
+ var text = (reference ?? string.Empty).Trim().Replace('$', '.');
+ var slash = text.IndexOf('/');
+ if (slash <= 0 || slash >= text.Length - 1)
+ return false;
+
+ var domain = text[..slash];
+ var parts = text[(slash + 1)..].Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length < 2)
+ return false;
+
+ parsed = new ParsedReference(
+ domain,
+ parts[0],
+ parts[1],
+ parts.Length > 2 ? string.Join(".", parts.Skip(2)) : "ctlModel");
+ return true;
+ }
+
+ private static void ParseContainerReference(
+ string reference,
+ out string domain,
+ out string logicalNode,
+ out string name)
+ {
+ var text = (reference ?? string.Empty).Trim().Replace('$', '.');
+ var slash = text.IndexOf('/');
+ domain = slash > 0 ? text[..slash] : string.Empty;
+ var remainder = slash >= 0 && slash < text.Length - 1 ? text[(slash + 1)..] : text;
+ var parts = remainder.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ logicalNode = parts.Length > 0 ? parts[0] : string.Empty;
+ name = parts.Length > 0 ? parts[^1] : string.Empty;
+ }
+
+ private static string ResolveLdInst(string domain, string iedName)
+ => !string.IsNullOrWhiteSpace(iedName) && domain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase)
+ ? domain[iedName.Length..]
+ : domain;
+
+ private static string NormalizeDataType(string? dataType)
+ {
+ var value = (dataType ?? string.Empty).Trim();
+ var separator = value.IndexOf(' ');
+ return separator > 0 ? value[..separator] : value;
+ }
+
+ private readonly record struct ParsedReference(
+ string Domain,
+ string LogicalNode,
+ string DataObject,
+ string AttributePath);
+
+ private readonly record struct AttributeRow(
+ string Domain,
+ string LogicalNode,
+ string DataObject,
+ string AttributePath,
+ string Reference,
+ string FunctionalConstraint,
+ string DataType,
+ string Cdc);
+}
diff --git a/Services/SclWorkspaceSignalMapper.cs b/Services/SclWorkspaceSignalMapper.cs
new file mode 100644
index 00000000..3f27b095
--- /dev/null
+++ b/Services/SclWorkspaceSignalMapper.cs
@@ -0,0 +1,261 @@
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Scl.Workspace;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Presentation adapter from the engine-owned SCL workspace model to ArIED signal rows.
+/// This class deliberately contains no XML parsing or IEC 61850 type-template traversal.
+///
+public static class SclWorkspaceSignalMapper
+{
+ private static readonly HashSet RuntimeLeafNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "stVal", "general", "posVal", "actVal", "setVal", "f", "i"
+ };
+
+ public static IReadOnlyList BuildSignals(SclIedWorkspace workspace)
+ {
+ ArgumentNullException.ThrowIfNull(workspace);
+
+ var dataSetBindings = BuildDataSetBindings(workspace.DesignModel);
+ var reportBindings = BuildReportBindings(workspace.DesignModel);
+ var signals = new List();
+
+ foreach (var logicalDevice in workspace.DesignModel.LogicalDevices)
+ {
+ foreach (var logicalNode in logicalDevice.LogicalNodes)
+ {
+ foreach (var dataObject in logicalNode.DataObjects)
+ {
+ AddRuntimeSignals(signals, logicalNode, dataObject, dataSetBindings, reportBindings);
+ AddControlSignal(signals, logicalNode, dataObject, dataSetBindings, reportBindings);
+ }
+ }
+ }
+
+ return signals
+ .Where(signal => signal.CanPublishAsSignal || signal.IsControlSignal)
+ .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .OrderBy(signal => signal.SortPriority)
+ .ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ private static void AddRuntimeSignals(
+ ICollection signals,
+ LiveIedLogicalNodeModel logicalNode,
+ LiveIedDataObjectModel dataObject,
+ IReadOnlyDictionary dataSetBindings,
+ IReadOnlyDictionary reportBindings)
+ {
+ var quality = FindCompanion(dataObject, "q");
+ var timestamp = FindCompanion(dataObject, "t");
+
+ foreach (var attribute in dataObject.Attributes)
+ {
+ var fc = (attribute.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant();
+ if (fc is not ("ST" or "MX") || IsCompanion(attribute.AttributePath))
+ continue;
+ if (!IsRuntimeLeaf(attribute.AttributePath))
+ continue;
+
+ var reference = attribute.ObjectReference;
+ var normalized = NormalizeReference(reference);
+ dataSetBindings.TryGetValue(normalized, out var dataSetReference);
+ reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference);
+ var category = ResolveCategory(logicalNode.LnClass, dataObject.Name, dataObject.InferredCdc, fc);
+
+ signals.Add(new SignalDefinition
+ {
+ Name = BuildDisplayName(logicalNode.Name, dataObject.Name, attribute.AttributePath),
+ ObjectReference = reference,
+ DisplayReference = reference,
+ FunctionalConstraint = fc,
+ DataType = ResolveDataType(attribute),
+ Category = category,
+ Confidence = attribute.TypeConfidence is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High
+ ? "High"
+ : "Medium",
+ DataSetReference = dataSetReference ?? string.Empty,
+ ReportControlReference = reportReference ?? string.Empty,
+ ReportCoverageReason = string.IsNullOrWhiteSpace(reportReference)
+ ? "SCL design model contains no static ReportControl coverage; polling remains the safe fallback."
+ : $"Static SCL report candidate {reportReference}; live RCB attributes are verified before enable.",
+ QualityReference = quality?.ObjectReference ?? string.Empty,
+ TimestampReference = timestamp?.ObjectReference ?? string.Empty,
+ Source = "SCL design model",
+ IsReportCapable = !string.IsNullOrWhiteSpace(reportReference),
+ ReportCoverage = string.IsNullOrWhiteSpace(reportReference)
+ ? "MMS polling fallback"
+ : "Static SCL report candidate",
+ IsSelected = false,
+ Value = "-",
+ Quality = "Unknown",
+ DeviceTimestamp = "-",
+ ProbeStatus = "Projected from SCL; live read verification pending"
+ });
+ }
+ }
+
+ private static void AddControlSignal(
+ ICollection signals,
+ LiveIedLogicalNodeModel logicalNode,
+ LiveIedDataObjectModel dataObject,
+ IReadOnlyDictionary dataSetBindings,
+ IReadOnlyDictionary reportBindings)
+ {
+ var controlAttributes = dataObject.Attributes
+ .Where(attribute => string.Equals(attribute.FunctionalConstraint, "CO", StringComparison.OrdinalIgnoreCase) ||
+ Leaf(attribute.AttributePath).Equals("ctlModel", StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ if (controlAttributes.Length == 0)
+ return;
+
+ var controlReference = dataObject.Reference;
+ var ctlModel = dataObject.Attributes.FirstOrDefault(attribute =>
+ Leaf(attribute.AttributePath).Equals("ctlModel", StringComparison.OrdinalIgnoreCase));
+ var status = dataObject.Attributes.FirstOrDefault(attribute =>
+ Leaf(attribute.AttributePath).Equals("stVal", StringComparison.OrdinalIgnoreCase));
+ var normalizedStatus = NormalizeReference(status?.ObjectReference);
+ dataSetBindings.TryGetValue(normalizedStatus, out var dataSetReference);
+ reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference);
+
+ signals.Add(new SignalDefinition
+ {
+ Name = $"{logicalNode.Name} {dataObject.Name}",
+ ObjectReference = controlReference,
+ DisplayReference = controlReference,
+ FunctionalConstraint = "CO",
+ DataType = string.IsNullOrWhiteSpace(dataObject.InferredCdc)
+ ? "IEC 61850 control"
+ : $"{dataObject.InferredCdc} control",
+ Category = "Control",
+ Confidence = "High",
+ DataSetReference = dataSetReference ?? string.Empty,
+ ReportControlReference = reportReference ?? string.Empty,
+ ReportCoverageReason = "Control execution is disabled until the live ctlModel and exact MMS Oper/SBOw/Cancel structures are inspected.",
+ Source = "SCL design model",
+ IsControlSignal = true,
+ ControlCdc = dataObject.InferredCdc,
+ ControlModelReference = ctlModel?.ObjectReference ?? $"{controlReference}.ctlModel",
+ ControlStatusReference = status?.ObjectReference ?? string.Empty,
+ ControlModelText = "SCL design • live verification required",
+ ControlValueType = ResolveControlValueType(dataObject.InferredCdc),
+ IsSelected = false,
+ Value = "-",
+ Quality = "Unknown",
+ DeviceTimestamp = "-",
+ ProbeStatus = "SCL control candidate; live verification required"
+ });
+ }
+
+ private static Dictionary BuildDataSetBindings(LiveIedModelDiscoveryDocument model)
+ {
+ var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var dataSet in model.DataSets)
+ {
+ foreach (var member in dataSet.Members)
+ {
+ var key = NormalizeReference(member.Reference);
+ if (!string.IsNullOrWhiteSpace(key))
+ bindings.TryAdd(key, dataSet.Reference);
+ }
+ }
+ return bindings;
+ }
+
+ private static Dictionary BuildReportBindings(LiveIedModelDiscoveryDocument model)
+ {
+ var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var report in model.ReportControls)
+ {
+ var key = NormalizeReference(report.DataSetReference);
+ if (!string.IsNullOrWhiteSpace(key))
+ bindings.TryAdd(key, report.Reference);
+ }
+ return bindings;
+ }
+
+ private static LiveIedDataAttributeModel? FindCompanion(LiveIedDataObjectModel dataObject, string leaf)
+ => dataObject.Attributes.FirstOrDefault(attribute => Leaf(attribute.AttributePath).Equals(leaf, StringComparison.OrdinalIgnoreCase));
+
+ private static bool IsCompanion(string path)
+ {
+ var leaf = Leaf(path);
+ return leaf.Equals("q", StringComparison.OrdinalIgnoreCase) ||
+ leaf.Equals("t", StringComparison.OrdinalIgnoreCase) ||
+ leaf.Equals("ctlModel", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsRuntimeLeaf(string path)
+ {
+ var leaf = Leaf(path);
+ if (RuntimeLeafNames.Contains(leaf))
+ return true;
+
+ var normalized = path.Replace('$', '.');
+ return normalized.EndsWith("mag.f", StringComparison.OrdinalIgnoreCase) ||
+ normalized.EndsWith("ang.f", StringComparison.OrdinalIgnoreCase) ||
+ normalized.EndsWith("instMag.i", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string ResolveDataType(LiveIedDataAttributeModel attribute)
+ {
+ if (!string.IsNullOrWhiteSpace(attribute.SclBType))
+ return attribute.SclBType;
+ if (!string.IsNullOrWhiteSpace(attribute.MmsType))
+ return attribute.MmsType;
+ return "IEC 61850 value";
+ }
+
+ private static string ResolveCategory(string lnClass, string doName, string cdc, string fc)
+ {
+ if (fc.Equals("MX", StringComparison.OrdinalIgnoreCase))
+ return "Measurement";
+ if (doName.Equals("Pos", StringComparison.OrdinalIgnoreCase) || cdc.Equals("DPC", StringComparison.OrdinalIgnoreCase))
+ return "Position";
+ if (lnClass.StartsWith('P') || doName.Equals("Op", StringComparison.OrdinalIgnoreCase) || doName.Equals("Str", StringComparison.OrdinalIgnoreCase))
+ return "Protection";
+ return "Status";
+ }
+
+ private static string ResolveControlValueType(string cdc)
+ => (cdc ?? string.Empty).Trim().ToUpperInvariant() switch
+ {
+ "DPC" => "Dbpos",
+ "SPC" => "Boolean",
+ "INC" or "ISC" or "BSC" => "Int32",
+ "APC" or "BAC" => "Float32",
+ _ => string.Empty
+ };
+
+ private static string BuildDisplayName(string logicalNode, string dataObject, string attributePath)
+ => $"{logicalNode} {dataObject} {attributePath}";
+
+ private static string Leaf(string? path)
+ {
+ var text = (path ?? string.Empty).Replace('$', '.').Trim('.');
+ var index = text.LastIndexOf('.');
+ return index >= 0 ? text[(index + 1)..] : text;
+ }
+
+ private static string NormalizeReference(string? reference)
+ {
+ var text = (reference ?? string.Empty).Trim();
+ var fcMarker = text.LastIndexOf(" [", StringComparison.Ordinal);
+ if (fcMarker >= 0)
+ text = text[..fcMarker];
+ text = text.Replace('$', '.').Replace("//", "/", StringComparison.Ordinal).Trim();
+
+ // SCL FCDA display references commonly use IED/LD/LN while MMS domains use IEDLD/LN.
+ var parts = text.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length >= 3)
+ text = string.Concat(parts[0], parts[1], "/", string.Join("/", parts.Skip(2)));
+
+ return text.ToUpperInvariant();
+ }
+}
diff --git a/docs/SCL_WORKSPACE_INTEGRATION.md b/docs/SCL_WORKSPACE_INTEGRATION.md
new file mode 100644
index 00000000..68030262
--- /dev/null
+++ b/docs/SCL_WORKSPACE_INTEGRATION.md
@@ -0,0 +1,52 @@
+# ArIED SCL Workspace Integration
+
+ArIED delegates SCL parsing, endpoint resolution, type-template projection, and expected-versus-live comparison to the reusable ARIEC61850 engine.
+
+## Open SCL
+
+`MainWindow.OpenScl_Click` calls `SclWorkspaceService.OpenAsync`. Opening an ICD, CID, IID, SCD, SSD, or XML SCL file is offline and sends no MMS traffic.
+
+For each IED and AccessPoint returned by the engine, ArIED creates an independent workspace containing:
+
+- IED and AccessPoint identity;
+- direct MMS endpoint when present;
+- LD/LN/DO/DA model projected from `DataTypeTemplates`;
+- static DataSet and ReportControl bindings;
+- GOOSE and Sampled Values engineering metadata;
+- source file path and SHA-256 provenance;
+- typed SCL findings.
+
+An ICD without `Communication` remains browseable. Pressing Play opens the existing endpoint wizard so an MMS address can be bound locally.
+
+## Connection behavior
+
+Play uses the existing cached-model connection path when an SCL design model is available:
+
+```text
+SCL workspace
+→ offline signal projection
+→ TCP/ACSE/MMS association
+→ selected-point verification and acquisition
+```
+
+It does not repeat full GetNameList discovery. Dynamic DataSet writes default to disabled for SCL workspaces.
+
+Re-scan intentionally performs full live discovery. ArIED converts the resulting runtime signal inventory into a bounded `LiveIedModelDiscoveryDocument` and sends both expected and observed projections to the engine-owned `SclLiveModelComparer`. Compatible models are marked verified. Missing visible attributes, FC/type differences, DataSet/RCB differences, or identity mismatch are surfaced as configuration drift in Diagnostics.
+
+Control objects remain unavailable for operation until ArIED performs the existing live `ctlModel`, Oper/SBOw/Cancel, and exact MMS type inspection.
+
+## Project persistence
+
+Project schema version 3 stores:
+
+- SCL source path;
+- SHA-256 source hash;
+- IED name;
+- AccessPoint name;
+- cached signal projection and user selections.
+
+When reopening a project, ArIED reloads the engine workspace only when the source hash still matches. A missing or changed source file never silently replaces the cached model.
+
+## Dependency gate
+
+Until ARIEC61850 PR #25 is merged, the ArIED CI workflow pins `ARIEC61850_REF` to `agent/scl-workspace-api`. After merge, change the value to `main` in `.github/workflows/build.yml`.
diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1
index e19dde57..6b8bc160 100644
--- a/scripts/publish-windows-portable.ps1
+++ b/scripts/publish-windows-portable.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.6.5",
+ [string]$Version = "1.6.6",
[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.5 or v1.6.5."
+ throw "Invalid version '$Version'. Use a value such as 1.6.6 or v1.6.6."
}
$outputRoot = Join-Path $root "dist"