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"