diff --git a/docs/SCL_WORKSPACE_API.md b/docs/SCL_WORKSPACE_API.md new file mode 100644 index 0000000..52913b5 --- /dev/null +++ b/docs/SCL_WORKSPACE_API.md @@ -0,0 +1,66 @@ +# SCL Workspace API + +`AR.Iec61850.Scl.Workspace.SclWorkspaceService` is the engine-owned entry point for applications that open ICD, CID, IID, SCD, SSD, or XML SCL documents. + +The API keeps protocol semantics in ARIEC61850 and returns typed application-neutral results: + +- secure offline XML loading with DTD and external entity processing prohibited; +- IED and AccessPoint inventory; +- direct `ConnectedAP/Address` MMS endpoint resolution; +- preservation of missing or invalid endpoint evidence instead of discarding the IED model; +- one offline `LiveIedModelDiscoveryDocument` per IED/AccessPoint; +- LD/LN/DO/DA projection from `DataTypeTemplates`; +- DataSet, ReportControl, GOOSE, and Sampled Values inventory; +- SHA-256 source identity and typed engineering findings; +- expected SCL model versus observed live MMS model comparison. + +## Open an SCL workspace + +```csharp +using AR.Iec61850.Scl.Workspace; + +var service = new SclWorkspaceService(); +var workspace = await service.OpenAsync("station.scd", cancellationToken: cancellationToken); + +foreach (var ied in workspace.Ieds) +{ + Console.WriteLine($"{ied.WorkspaceKey}: {ied.DesignModel.Summary}"); + Console.WriteLine(ied.PreferredEndpoint?.EndpointText ?? "endpoint binding required"); +} +``` + +Opening an SCL workspace is offline and does not create an MMS association. An ICD without a `Communication` section still produces a browseable design model and reports `RequiresEndpointBinding=true`. + +## Select one IED or AccessPoint + +```csharp +var workspace = await service.OpenAsync( + "station.scd", + new SclWorkspaceOpenOptions + { + IedName = "IED_A", + AccessPointName = "P1" + }, + cancellationToken); +``` + +## Compare with a live model + +Applications can obtain a live `LiveIedModelDiscoveryDocument` through the existing MMS discovery engine and compare it with the offline SCL projection: + +```csharp +var ied = workspace.Ieds.Single(); +var comparison = service.CompareLive(ied, liveModel); + +if (comparison.RequiresFullDiscovery) +{ + foreach (var finding in comparison.Findings) + Console.WriteLine($"{finding.Severity}: {finding.Message}"); +} +``` + +The comparison checks IED identity, expected data attributes, functional constraints, basic types, DataSet presence/member count, and ReportControl mode/DataSet/confRev. Unexpected live objects are retained as informational evidence; missing or incompatible expected objects are blocking findings. + +## Application boundary + +Applications such as ArIED should present and orchestrate this API. They should not implement independent SCL XML parsing, endpoint interpretation, DataTypeTemplates traversal, or expected-versus-live comparison logic. diff --git a/src/AR.Iec61850/Scl/SclXmlDocumentLoader.cs b/src/AR.Iec61850/Scl/SclXmlDocumentLoader.cs new file mode 100644 index 0000000..43cb071 --- /dev/null +++ b/src/AR.Iec61850/Scl/SclXmlDocumentLoader.cs @@ -0,0 +1,40 @@ +using System.Xml; +using System.Xml.Linq; + +namespace AR.Iec61850.Scl; + +internal static class SclXmlDocumentLoader +{ + public static XDocument Load(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("SCL file path is empty.", nameof(filePath)); + if (!File.Exists(filePath)) + throw new FileNotFoundException("The selected SCL file does not exist.", filePath); + + using var stream = File.OpenRead(filePath); + using var reader = XmlReader.Create(stream, CreateSettings()); + return XDocument.Load(reader, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); + } + + public static XDocument Parse(string xml) + { + if (string.IsNullOrWhiteSpace(xml)) + throw new ArgumentException("SCL XML is empty.", nameof(xml)); + + using var textReader = new StringReader(xml); + using var reader = XmlReader.Create(textReader, CreateSettings()); + return XDocument.Load(reader, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); + } + + private static XmlReaderSettings CreateSettings() + => new() + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + IgnoreComments = false, + IgnoreProcessingInstructions = false, + IgnoreWhitespace = false, + CloseInput = true + }; +} diff --git a/src/AR.Iec61850/Scl/Workspace/SclLiveModelComparer.cs b/src/AR.Iec61850/Scl/Workspace/SclLiveModelComparer.cs new file mode 100644 index 0000000..890aaea --- /dev/null +++ b/src/AR.Iec61850/Scl/Workspace/SclLiveModelComparer.cs @@ -0,0 +1,422 @@ +using System.Globalization; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Scl.Workspace; + +public enum SclLiveModelFindingKind +{ + IdentityMismatch, + MissingLiveAttribute, + UnexpectedLiveAttribute, + FunctionalConstraintMismatch, + TypeMismatch, + MissingLiveDataSet, + UnexpectedLiveDataSet, + DataSetMemberCountMismatch, + MissingLiveReportControl, + UnexpectedLiveReportControl, + ReportControlModeMismatch, + ReportDataSetMismatch, + ReportConfigurationRevisionMismatch +} + +public sealed class SclLiveModelComparisonFinding +{ + public string Severity { get; init; } = "Info"; + public SclLiveModelFindingKind Kind { get; init; } + public string Reference { get; init; } = string.Empty; + public string Expected { get; init; } = string.Empty; + public string Observed { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; +} + +public sealed class SclLiveModelComparisonResult +{ + public string ExpectedIedName { get; init; } = string.Empty; + public string ObservedIedName { get; init; } = string.Empty; + public int ExpectedAttributeCount { get; init; } + public int ObservedAttributeCount { get; init; } + public int MatchedAttributeCount { get; init; } + public IReadOnlyList Findings { get; init; } + = Array.Empty(); + + public int BlockingFindingCount + => Findings.Count(x => string.Equals(x.Severity, "Error", StringComparison.OrdinalIgnoreCase)); + + public bool IsCompatible => BlockingFindingCount == 0; + public bool CanUseDesignModel => IsCompatible; + public bool RequiresFullDiscovery => !IsCompatible; +} + +public static class SclLiveModelComparer +{ + public static SclLiveModelComparisonResult Compare( + LiveIedModelDiscoveryDocument expected, + LiveIedModelDiscoveryDocument observed) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(observed); + + var findings = new List(); + CompareIdentity(expected, observed, findings); + + var expectedAttributes = FlattenAttributes(expected); + var observedAttributes = FlattenAttributes(observed); + var matchedAttributes = 0; + + foreach (var expectedPair in expectedAttributes.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)) + { + if (!observedAttributes.TryGetValue(expectedPair.Key, out var observedAttribute)) + { + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.MissingLiveAttribute, + expectedPair.Value.Reference, + expectedPair.Value.DisplayType, + string.Empty, + $"SCL attribute '{expectedPair.Value.Reference}' is missing from the observed live model.")); + continue; + } + + matchedAttributes++; + CompareAttribute(expectedPair.Value, observedAttribute, findings); + } + + foreach (var observedPair in observedAttributes.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)) + { + if (expectedAttributes.ContainsKey(observedPair.Key)) + continue; + + findings.Add(Finding( + "Info", + SclLiveModelFindingKind.UnexpectedLiveAttribute, + observedPair.Value.Reference, + string.Empty, + observedPair.Value.DisplayType, + $"Live attribute '{observedPair.Value.Reference}' is not present in the SCL design model.")); + } + + CompareDataSets(expected, observed, findings); + CompareReportControls(expected, observed, findings); + + return new SclLiveModelComparisonResult + { + ExpectedIedName = expected.IedName, + ObservedIedName = observed.IedName, + ExpectedAttributeCount = expectedAttributes.Count, + ObservedAttributeCount = observedAttributes.Count, + MatchedAttributeCount = matchedAttributes, + Findings = findings + .OrderByDescending(x => SeverityRank(x.Severity)) + .ThenBy(x => x.Kind) + .ThenBy(x => x.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + } + + private static void CompareIdentity( + LiveIedModelDiscoveryDocument expected, + LiveIedModelDiscoveryDocument observed, + ICollection findings) + { + if (string.IsNullOrWhiteSpace(expected.IedName) || + string.IsNullOrWhiteSpace(observed.IedName) || + Same(expected.IedName, observed.IedName) || + Same(expected.IedName, "TEMPLATE")) + { + return; + } + + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.IdentityMismatch, + expected.AccessPointName, + expected.IedName, + observed.IedName, + $"The connected live IED identity '{observed.IedName}' does not match SCL IED '{expected.IedName}'.")); + } + + private static void CompareAttribute( + AttributeDescriptor expected, + AttributeDescriptor observed, + ICollection findings) + { + if (!string.IsNullOrWhiteSpace(expected.FunctionalConstraint) && + !string.IsNullOrWhiteSpace(observed.FunctionalConstraint) && + !Same(expected.FunctionalConstraint, observed.FunctionalConstraint)) + { + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.FunctionalConstraintMismatch, + expected.Reference, + expected.FunctionalConstraint, + observed.FunctionalConstraint, + $"Functional constraint mismatch for '{expected.Reference}': SCL={expected.FunctionalConstraint}, live={observed.FunctionalConstraint}.")); + } + + if (!string.IsNullOrWhiteSpace(expected.ComparableType) && + !string.IsNullOrWhiteSpace(observed.ComparableType) && + !Same(expected.ComparableType, observed.ComparableType)) + { + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.TypeMismatch, + expected.Reference, + expected.DisplayType, + observed.DisplayType, + $"Type mismatch for '{expected.Reference}': SCL={expected.DisplayType}, live={observed.DisplayType}.")); + } + } + + private static void CompareDataSets( + LiveIedModelDiscoveryDocument expected, + LiveIedModelDiscoveryDocument observed, + ICollection findings) + { + var expectedIndex = expected.DataSets + .GroupBy(x => DataSetKey(x, expected.IedName), StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); + var observedIndex = observed.DataSets + .GroupBy(x => DataSetKey(x, observed.IedName), StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var pair in expectedIndex) + { + if (!observedIndex.TryGetValue(pair.Key, out var observedDataSet)) + { + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.MissingLiveDataSet, + pair.Value.Reference, + pair.Value.MemberCount.ToString(CultureInfo.InvariantCulture), + string.Empty, + $"SCL DataSet '{pair.Value.Reference}' is missing from the observed live model.")); + continue; + } + + if (pair.Value.MemberCount != observedDataSet.MemberCount) + { + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.DataSetMemberCountMismatch, + pair.Value.Reference, + pair.Value.MemberCount.ToString(CultureInfo.InvariantCulture), + observedDataSet.MemberCount.ToString(CultureInfo.InvariantCulture), + $"DataSet member-count mismatch for '{pair.Value.Reference}': SCL={pair.Value.MemberCount}, live={observedDataSet.MemberCount}.")); + } + } + + foreach (var pair in observedIndex) + { + if (expectedIndex.ContainsKey(pair.Key)) + continue; + + findings.Add(Finding( + "Info", + SclLiveModelFindingKind.UnexpectedLiveDataSet, + pair.Value.Reference, + string.Empty, + pair.Value.MemberCount.ToString(CultureInfo.InvariantCulture), + $"Live DataSet '{pair.Value.Reference}' is not present in the SCL design model.")); + } + } + + private static void CompareReportControls( + LiveIedModelDiscoveryDocument expected, + LiveIedModelDiscoveryDocument observed, + ICollection findings) + { + var expectedIndex = expected.ReportControls + .GroupBy(x => ReportKey(x, expected.IedName), StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); + var observedIndex = observed.ReportControls + .GroupBy(x => ReportKey(x, observed.IedName), StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var pair in expectedIndex) + { + if (!observedIndex.TryGetValue(pair.Key, out var observedReport)) + { + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.MissingLiveReportControl, + pair.Value.Reference, + pair.Value.Buffered ? "BRCB" : "URCB", + string.Empty, + $"SCL report control '{pair.Value.Reference}' is missing from the observed live model.")); + continue; + } + + if (pair.Value.Buffered != observedReport.Buffered) + { + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.ReportControlModeMismatch, + pair.Value.Reference, + pair.Value.Buffered ? "BRCB" : "URCB", + observedReport.Buffered ? "BRCB" : "URCB", + $"Report-control mode mismatch for '{pair.Value.Reference}'.")); + } + + if (!string.IsNullOrWhiteSpace(pair.Value.DataSetReference) && + !string.IsNullOrWhiteSpace(observedReport.DataSetReference) && + !Same(ReferenceTail(pair.Value.DataSetReference), ReferenceTail(observedReport.DataSetReference))) + { + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.ReportDataSetMismatch, + pair.Value.Reference, + pair.Value.DataSetReference, + observedReport.DataSetReference, + $"Report DataSet mismatch for '{pair.Value.Reference}'.")); + } + + if (TryParsePositiveUInt(pair.Value.ConfRev, out var expectedConfRev) && + TryParsePositiveUInt(observedReport.ConfRev, out var observedConfRev) && + expectedConfRev != observedConfRev) + { + findings.Add(Finding( + "Error", + SclLiveModelFindingKind.ReportConfigurationRevisionMismatch, + pair.Value.Reference, + expectedConfRev.ToString(CultureInfo.InvariantCulture), + observedConfRev.ToString(CultureInfo.InvariantCulture), + $"Report confRev mismatch for '{pair.Value.Reference}': SCL={expectedConfRev}, live={observedConfRev}.")); + } + } + + foreach (var pair in observedIndex) + { + if (expectedIndex.ContainsKey(pair.Key)) + continue; + + findings.Add(Finding( + "Info", + SclLiveModelFindingKind.UnexpectedLiveReportControl, + pair.Value.Reference, + string.Empty, + pair.Value.Buffered ? "BRCB" : "URCB", + $"Live report control '{pair.Value.Reference}' is not present in the SCL design model.")); + } + } + + private static Dictionary FlattenAttributes( + LiveIedModelDiscoveryDocument document) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var logicalDevice in document.LogicalDevices) + { + var ldKey = LogicalDeviceKey(logicalDevice, document.IedName); + foreach (var logicalNode in logicalDevice.LogicalNodes) + { + foreach (var dataObject in logicalNode.DataObjects) + { + foreach (var attribute in dataObject.Attributes) + { + var key = $"{ldKey}|{logicalNode.Name}|{dataObject.Name}|{attribute.AttributePath}"; + result.TryAdd(key, new AttributeDescriptor( + key, + $"{logicalDevice.MmsDomain}/{logicalNode.Name}.{dataObject.Name}.{attribute.AttributePath}", + attribute.FunctionalConstraint, + ComparableType(attribute), + DisplayType(attribute))); + } + } + } + } + return result; + } + + private static string DataSetKey(LiveIedDataSetModel dataSet, string iedName) + => $"{NormalizeDomain(dataSet.Domain, iedName)}|{dataSet.LogicalNode}|{dataSet.Name}"; + + private static string ReportKey(LiveIedReportControlModel report, string iedName) + => $"{NormalizeDomain(report.Domain, iedName)}|{report.LogicalNode}|{report.Name}"; + + private static string LogicalDeviceKey(LiveIedLogicalDeviceModel logicalDevice, string iedName) + { + if (!string.IsNullOrWhiteSpace(logicalDevice.Inst) && + !Same(logicalDevice.Inst, logicalDevice.MmsDomain)) + { + return logicalDevice.Inst.Trim(); + } + return NormalizeDomain(logicalDevice.MmsDomain, iedName); + } + + private static string NormalizeDomain(string domain, string iedName) + { + var trimmed = domain.Trim(); + if (!string.IsNullOrWhiteSpace(iedName) && + trimmed.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) && + trimmed.Length > iedName.Length) + { + return trimmed[iedName.Length..]; + } + return trimmed; + } + + private static string ComparableType(LiveIedDataAttributeModel attribute) + { + var type = FirstNonEmpty(attribute.SclBType, attribute.MmsType); + if (string.IsNullOrWhiteSpace(type)) + type = attribute.MmsTypeSignature; + if (type.Contains(':')) + type = type[..type.IndexOf(':')]; + + return type.Trim().ToUpperInvariant() switch + { + "BOOL" => "BOOLEAN", + "INTEGER" or "INT" => "INT32", + "UNSIGNED" => "INT32U", + "FLOATINGPOINT" or "FLOAT" => "FLOAT32", + "BITSTRING" => "BIT-STRING", + _ => type.Trim().ToUpperInvariant() + }; + } + + private static string DisplayType(LiveIedDataAttributeModel attribute) + => FirstNonEmpty(attribute.MmsTypeSignature, FirstNonEmpty(attribute.SclBType, attribute.MmsType)); + + private static string ReferenceTail(string reference) + { + var value = reference.Trim(); + var slash = value.IndexOf('/'); + return slash >= 0 && slash + 1 < value.Length ? value[(slash + 1)..] : value; + } + + private static bool TryParsePositiveUInt(string text, out uint value) + => uint.TryParse(text, out value) && value > 0; + + private static string FirstNonEmpty(string first, string second) + => string.IsNullOrWhiteSpace(first) ? second : first; + + private static SclLiveModelComparisonFinding Finding( + string severity, + SclLiveModelFindingKind kind, + string reference, + string expected, + string observed, + string message) + => new() + { + Severity = severity, + Kind = kind, + Reference = reference, + Expected = expected, + Observed = observed, + Message = message + }; + + private static int SeverityRank(string severity) + => string.Equals(severity, "Error", StringComparison.OrdinalIgnoreCase) ? 2 : 1; + + private static bool Same(string? left, string? right) + => string.Equals(left?.Trim(), right?.Trim(), StringComparison.OrdinalIgnoreCase); + + private sealed record AttributeDescriptor( + string Key, + string Reference, + string FunctionalConstraint, + string ComparableType, + string DisplayType); +} diff --git a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceModels.cs b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceModels.cs new file mode 100644 index 0000000..e6d56a0 --- /dev/null +++ b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceModels.cs @@ -0,0 +1,76 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Engineering; + +namespace AR.Iec61850.Scl.Workspace; + +public sealed class SclWorkspaceOpenOptions +{ + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public bool IncludeAccessPointsWithoutServer { get; init; } = true; +} + +public sealed class SclMmsEndpoint +{ + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public string SubNetworkName { get; init; } = string.Empty; + public string SubNetworkType { get; init; } = string.Empty; + public string IpAddress { get; init; } = string.Empty; + public int Port { get; init; } = 102; + public bool IsValidIpAddress { get; init; } + public IReadOnlyDictionary AddressParameters { get; init; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public bool HasUsableAddress => IsValidIpAddress && Port is > 0 and <= 65535; + public string IdentityKey => $"{IedName}/{AccessPointName}"; + public string EndpointText => HasUsableAddress ? $"{IpAddress}:{Port}" : "unassigned"; +} + +public sealed class SclWorkspaceFinding +{ + public string Severity { get; init; } = "Info"; + public string Code { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + public string ObjectReference { get; init; } = string.Empty; +} + +public sealed class SclWorkspaceDocument +{ + public string SourceName { get; init; } = string.Empty; + public string SourcePath { get; init; } = string.Empty; + public string SourceSha256 { get; init; } = string.Empty; + public SclDocument Document { get; init; } = new(); + public SclEngineeringProfile EngineeringProfile { get; init; } = new(); + public IReadOnlyList MmsEndpoints { get; init; } = Array.Empty(); + public IReadOnlyList Ieds { get; init; } = Array.Empty(); + public IReadOnlyList Findings { get; init; } = Array.Empty(); + + public bool HasBlockingFindings + => Findings.Any(x => string.Equals(x.Severity, "High", StringComparison.OrdinalIgnoreCase) || + string.Equals(x.Severity, "Error", StringComparison.OrdinalIgnoreCase)); +} + +public sealed class SclIedWorkspace +{ + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public string Manufacturer { get; init; } = string.Empty; + public string IedType { get; init; } = string.Empty; + public string ConfigVersion { get; init; } = string.Empty; + public IReadOnlyList Endpoints { get; init; } = Array.Empty(); + public SclMmsEndpoint? PreferredEndpoint { get; init; } + public LiveIedModelDiscoveryDocument DesignModel { get; init; } = new(); + public IReadOnlyList DataSets { get; init; } = Array.Empty(); + public IReadOnlyList ReportControls { get; init; } = Array.Empty(); + public IReadOnlyList GooseStreams { get; init; } = Array.Empty(); + public IReadOnlyList SampledValuesStreams { get; init; } = Array.Empty(); + public IReadOnlyList Findings { get; init; } = Array.Empty(); + + public string WorkspaceKey => string.IsNullOrWhiteSpace(AccessPointName) + ? IedName + : $"{IedName}/{AccessPointName}"; + + public bool CanBrowseOffline => DesignModel.Coverage.LogicalDeviceCount > 0; + public bool RequiresEndpointBinding => PreferredEndpoint?.HasUsableAddress != true; +} diff --git a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs new file mode 100644 index 0000000..1992f3b --- /dev/null +++ b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs @@ -0,0 +1,478 @@ +using System.Globalization; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Engineering; + +namespace AR.Iec61850.Scl.Workspace; + +public sealed class SclWorkspaceService +{ + 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 Task OpenAsync( + string filePath, + SclWorkspaceOpenOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + return Task.Run(() => Open(filePath, options, cancellationToken), cancellationToken); + } + + public SclWorkspaceDocument Open( + string filePath, + SclWorkspaceOpenOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + cancellationToken.ThrowIfCancellationRequested(); + + var fullPath = Path.GetFullPath(filePath); + var bytes = File.ReadAllBytes(fullPath); + cancellationToken.ThrowIfCancellationRequested(); + var document = SclXmlDocumentLoader.Load(fullPath); + return Build( + document, + Path.GetFileName(fullPath), + fullPath, + ComputeSha256(bytes), + options, + cancellationToken); + } + + public SclWorkspaceDocument Parse( + string xml, + string sourceName = "", + SclWorkspaceOpenOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(xml); + cancellationToken.ThrowIfCancellationRequested(); + + var document = SclXmlDocumentLoader.Parse(xml); + return Build( + document, + sourceName, + sourcePath: string.Empty, + sourceSha256: ComputeSha256(Encoding.UTF8.GetBytes(xml)), + options, + cancellationToken); + } + + public SclWorkspaceDocument Build( + XDocument document, + string sourceName = "", + string sourcePath = "", + string sourceSha256 = "", + SclWorkspaceOpenOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(document); + cancellationToken.ThrowIfCancellationRequested(); + options ??= new SclWorkspaceOpenOptions(); + + var parsed = new SclParser().Parse(document, sourceName); + var engineeringProfile = new SclEngineeringProfileBuilder().Build(document, sourceName); + var endpointResolution = ResolveMmsEndpoints(document); + var findings = new List(); + findings.AddRange(engineeringProfile.Findings.Select(ToWorkspaceFinding)); + findings.AddRange(endpointResolution.Findings); + + var descriptors = BuildWorkspaceDescriptors(parsed, engineeringProfile, options).ToArray(); + var workspaces = new List(descriptors.Length); + + foreach (var descriptor in descriptors) + { + cancellationToken.ThrowIfCancellationRequested(); + var matchingEndpoints = endpointResolution.Endpoints + .Where(x => Same(x.IedName, descriptor.IedName) && + (string.IsNullOrWhiteSpace(descriptor.AccessPointName) || + Same(x.AccessPointName, descriptor.AccessPointName))) + .OrderByDescending(x => x.HasUsableAddress) + .ThenBy(x => x.SubNetworkName, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.IpAddress, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var preferredEndpoint = matchingEndpoints.FirstOrDefault(x => x.HasUsableAddress); + + var designModel = BuildDesignModel( + document, + sourceName, + descriptor.IedName, + descriptor.AccessPointName, + preferredEndpoint); + + var workspaceFindings = findings + .Where(x => AppliesToWorkspace(x, descriptor.IedName, descriptor.AccessPointName)) + .ToList(); + + if (preferredEndpoint is null) + { + workspaceFindings.Add(new SclWorkspaceFinding + { + Severity = "Warning", + Code = "SCL_MMS_ENDPOINT_UNASSIGNED", + ObjectReference = BuildWorkspaceKey(descriptor.IedName, descriptor.AccessPointName), + Message = string.IsNullOrWhiteSpace(descriptor.AccessPointName) + ? $"IED '{descriptor.IedName}' has no usable MMS IP endpoint. The offline model remains available and an endpoint can be bound later." + : $"IED '{descriptor.IedName}' access point '{descriptor.AccessPointName}' has no usable MMS IP endpoint. The offline model remains available and an endpoint can be bound later." + }); + } + + workspaceFindings.AddRange(designModel.Warnings.Select(x => new SclWorkspaceFinding + { + Severity = "Warning", + Code = x.Code, + ObjectReference = x.Reference, + Message = x.Message + })); + + var ied = parsed.Ieds.FirstOrDefault(x => Same(x.Name, descriptor.IedName)) ?? new SclIed { Name = descriptor.IedName }; + workspaces.Add(new SclIedWorkspace + { + IedName = descriptor.IedName, + AccessPointName = descriptor.AccessPointName, + Manufacturer = ied.Manufacturer, + IedType = ied.Type, + ConfigVersion = ied.ConfigVersion, + Endpoints = matchingEndpoints, + PreferredEndpoint = preferredEndpoint, + DesignModel = designModel, + DataSets = parsed.DataSets.Where(x => Same(x.IedName, descriptor.IedName)).ToArray(), + ReportControls = parsed.ReportControls.Where(x => Same(x.IedName, descriptor.IedName)).ToArray(), + GooseStreams = parsed.GooseStreams.Where(x => Same(x.IedName, descriptor.IedName)).ToArray(), + SampledValuesStreams = parsed.SampledValuesStreams.Where(x => Same(x.IedName, descriptor.IedName)).ToArray(), + Findings = workspaceFindings + .GroupBy(FindingKey, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .OrderByDescending(x => SeverityRank(x.Severity)) + .ThenBy(x => x.Code, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.ObjectReference, StringComparer.OrdinalIgnoreCase) + .ToArray() + }); + } + + return new SclWorkspaceDocument + { + SourceName = sourceName, + SourcePath = sourcePath, + SourceSha256 = sourceSha256, + Document = parsed, + EngineeringProfile = engineeringProfile, + MmsEndpoints = endpointResolution.Endpoints, + Ieds = workspaces, + Findings = findings + .GroupBy(FindingKey, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .OrderByDescending(x => SeverityRank(x.Severity)) + .ThenBy(x => x.Code, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.ObjectReference, StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + } + + public SclLiveModelComparisonResult CompareLive( + SclIedWorkspace workspace, + LiveIedModelDiscoveryDocument liveModel) + { + ArgumentNullException.ThrowIfNull(workspace); + ArgumentNullException.ThrowIfNull(liveModel); + return SclLiveModelComparer.Compare(workspace.DesignModel, liveModel); + } + + private static LiveIedModelDiscoveryDocument BuildDesignModel( + XDocument document, + string sourceName, + string iedName, + string accessPointName, + SclMmsEndpoint? endpoint) + { + var isolated = new XDocument(document); + var root = isolated.Root ?? throw new InvalidDataException("SCL document has no root element."); + + foreach (var otherIed in root.Elements().Where(x => Is(x, "IED") && !Same(Attr(x, "name"), iedName)).ToArray()) + otherIed.Remove(); + + var selectedIed = root.Elements().FirstOrDefault(x => Is(x, "IED") && Same(Attr(x, "name"), iedName)); + if (selectedIed is null) + throw new InvalidDataException($"IED '{iedName}' was not found in the SCL document."); + + if (!string.IsNullOrWhiteSpace(accessPointName)) + { + foreach (var otherAccessPoint in selectedIed.Elements() + .Where(x => Is(x, "AccessPoint") && !Same(Attr(x, "name"), accessPointName)) + .ToArray()) + { + otherAccessPoint.Remove(); + } + } + + var projected = SclLiveModelProjectionBuilder.Build(isolated, sourceName); + return new LiveIedModelDiscoveryDocument + { + SchemaVersion = projected.SchemaVersion, + GeneratedAtUtc = projected.GeneratedAtUtc, + Source = projected.Source, + Host = endpoint?.IpAddress ?? string.Empty, + Port = endpoint?.Port ?? 102, + IedName = iedName, + IedIdentity = projected.IedIdentity, + AccessPointName = accessPointName, + Summary = projected.Summary, + Coverage = projected.Coverage, + LogicalDevices = projected.LogicalDevices, + FileDirectory = projected.FileDirectory, + DataSets = projected.DataSets, + ReportControls = projected.ReportControls, + GooseControlBlocks = projected.GooseControlBlocks, + SampledValueControlBlocks = projected.SampledValueControlBlocks, + SettingGroupControls = projected.SettingGroupControls, + LogControls = projected.LogControls, + TypeTemplates = projected.TypeTemplates, + VariableTypeDiscoveries = projected.VariableTypeDiscoveries, + Warnings = projected.Warnings + }; + } + + private static IEnumerable BuildWorkspaceDescriptors( + SclDocument parsed, + SclEngineeringProfile engineeringProfile, + SclWorkspaceOpenOptions options) + { + var requestedIedName = options.IedName.Trim(); + var requestedAccessPointName = options.AccessPointName.Trim(); + + foreach (var ied in parsed.Ieds.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)) + { + if (!string.IsNullOrWhiteSpace(requestedIedName) && !Same(ied.Name, requestedIedName)) + continue; + + var accessPoints = engineeringProfile.AccessPoints + .Where(x => Same(x.IedName, ied.Name)) + .Where(x => options.IncludeAccessPointsWithoutServer || x.HasServer) + .Where(x => string.IsNullOrWhiteSpace(requestedAccessPointName) || Same(x.Name, requestedAccessPointName)) + .OrderByDescending(x => x.HasServer) + .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (accessPoints.Length == 0) + { + if (string.IsNullOrWhiteSpace(requestedAccessPointName)) + yield return new WorkspaceDescriptor(ied.Name, string.Empty); + continue; + } + + foreach (var accessPoint in accessPoints) + yield return new WorkspaceDescriptor(ied.Name, accessPoint.Name); + } + } + + private static EndpointResolution ResolveMmsEndpoints(XDocument document) + { + var root = document.Root ?? throw new InvalidDataException("SCL document has no root element."); + var endpoints = new List(); + var findings = new List(); + var iedAccessPoints = root.Elements() + .Where(x => Is(x, "IED")) + .SelectMany(ied => ied.Elements() + .Where(x => Is(x, "AccessPoint")) + .Select(ap => BuildWorkspaceKey(Attr(ied, "name"), Attr(ap, "name")))) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var communication = root.Elements().FirstOrDefault(x => Is(x, "Communication")); + if (communication is null) + return new EndpointResolution(endpoints, findings); + + foreach (var subNetwork in communication.Elements().Where(x => Is(x, "SubNetwork"))) + { + var subNetworkName = Attr(subNetwork, "name"); + var subNetworkType = Attr(subNetwork, "type"); + foreach (var connectedAp in subNetwork.Elements().Where(x => Is(x, "ConnectedAP"))) + { + var iedName = Attr(connectedAp, "iedName"); + var accessPointName = Attr(connectedAp, "apName"); + var identity = BuildWorkspaceKey(iedName, accessPointName); + if (!iedAccessPoints.Contains(identity)) + { + findings.Add(new SclWorkspaceFinding + { + Severity = "Warning", + Code = "SCL_CONNECTED_AP_UNRESOLVED", + ObjectReference = identity, + Message = $"ConnectedAP '{identity}' does not match an IED AccessPoint definition." + }); + } + + var address = connectedAp.Elements().FirstOrDefault(x => Is(x, "Address")); + var parameters = ReadAddressParameters(address); + var ipText = FindParameter(parameters, IpParameterNames); + var port = ResolvePort(parameters, identity, findings); + var isValidIp = IPAddress.TryParse(ipText, out var parsedIp); + var canonicalIp = isValidIp ? parsedIp!.ToString() : ipText.Trim(); + + if (string.IsNullOrWhiteSpace(ipText)) + { + findings.Add(new SclWorkspaceFinding + { + Severity = "Warning", + Code = "SCL_MMS_IP_MISSING", + ObjectReference = identity, + Message = $"ConnectedAP '{identity}' has no direct MMS IP address. Nested GSE/SMV addresses are intentionally not used as MMS endpoints." + }); + } + else if (!isValidIp) + { + findings.Add(new SclWorkspaceFinding + { + Severity = "High", + Code = "SCL_MMS_IP_INVALID", + ObjectReference = identity, + Message = $"ConnectedAP '{identity}' has invalid MMS IP address '{ipText}'." + }); + } + + endpoints.Add(new SclMmsEndpoint + { + IedName = iedName, + AccessPointName = accessPointName, + SubNetworkName = subNetworkName, + SubNetworkType = subNetworkType, + IpAddress = canonicalIp, + Port = port, + IsValidIpAddress = isValidIp, + AddressParameters = parameters + }); + } + } + + foreach (var group in endpoints + .Where(x => x.HasUsableAddress) + .GroupBy(x => $"{x.IpAddress}|{x.Port}", StringComparer.OrdinalIgnoreCase) + .Where(x => x.Select(endpoint => endpoint.IdentityKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)) + { + var identities = group.Select(x => x.IdentityKey).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + findings.Add(new SclWorkspaceFinding + { + Severity = "High", + Code = "SCL_MMS_ENDPOINT_CONFLICT", + ObjectReference = group.Key.Replace('|', ':'), + Message = $"MMS endpoint {group.First().IpAddress}:{group.First().Port} is assigned to multiple IED access points: {string.Join(", ", identities)}." + }); + } + + return new EndpointResolution(endpoints, findings); + } + + private static IReadOnlyDictionary ReadAddressParameters(XElement? address) + { + if (address is null) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + return address.Elements() + .Where(x => Is(x, "P")) + .Select(x => new KeyValuePair(Attr(x, "type"), x.Value.Trim())) + .Where(x => !string.IsNullOrWhiteSpace(x.Key) && !string.IsNullOrWhiteSpace(x.Value)) + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.Last().Value, StringComparer.OrdinalIgnoreCase); + } + + private static int ResolvePort( + IReadOnlyDictionary parameters, + string identity, + ICollection findings) + { + var portText = FindParameter(parameters, PortParameterNames); + if (string.IsNullOrWhiteSpace(portText)) + return 102; + + if (int.TryParse(portText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var port) && + port is > 0 and <= 65535) + { + return port; + } + + findings.Add(new SclWorkspaceFinding + { + Severity = "Warning", + Code = "SCL_MMS_PORT_INVALID", + ObjectReference = identity, + Message = $"ConnectedAP '{identity}' has invalid MMS port '{portText}'. TCP port 102 was selected as the safe default." + }); + return 102; + } + + private static string FindParameter( + IReadOnlyDictionary parameters, + IEnumerable candidateNames) + { + foreach (var candidate in candidateNames) + { + if (parameters.TryGetValue(candidate, out var value) && !string.IsNullOrWhiteSpace(value)) + return value.Trim(); + } + return string.Empty; + } + + private static bool AppliesToWorkspace( + SclWorkspaceFinding finding, + string iedName, + string accessPointName) + { + if (string.IsNullOrWhiteSpace(finding.ObjectReference)) + return true; + + var workspaceKey = BuildWorkspaceKey(iedName, accessPointName); + return finding.ObjectReference.StartsWith(workspaceKey, StringComparison.OrdinalIgnoreCase) || + finding.ObjectReference.StartsWith(iedName, StringComparison.OrdinalIgnoreCase); + } + + private static SclWorkspaceFinding ToWorkspaceFinding(SclEngineeringFinding finding) + => new() + { + Severity = finding.Severity, + Code = finding.Code, + Message = finding.Message, + ObjectReference = finding.ObjectReference + }; + + private static string FindingKey(SclWorkspaceFinding finding) + => $"{finding.Severity}|{finding.Code}|{finding.ObjectReference}|{finding.Message}"; + + private static int SeverityRank(string severity) + => severity.ToUpperInvariant() switch + { + "ERROR" or "HIGH" => 3, + "WARNING" or "WARN" => 2, + _ => 1 + }; + + private static string ComputeSha256(byte[] bytes) + => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private static string BuildWorkspaceKey(string iedName, string accessPointName) + => string.IsNullOrWhiteSpace(accessPointName) ? iedName : $"{iedName}/{accessPointName}"; + + private static bool Same(string? left, string? right) + => string.Equals(left?.Trim(), right?.Trim(), StringComparison.OrdinalIgnoreCase); + + private static bool Is(XElement element, string localName) + => string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal); + + private static string Attr(XElement? element, string localName) + => element?.Attributes().FirstOrDefault(x => string.Equals(x.Name.LocalName, localName, StringComparison.Ordinal))?.Value?.Trim() ?? string.Empty; + + private sealed record WorkspaceDescriptor(string IedName, string AccessPointName); + + private sealed record EndpointResolution( + IReadOnlyList Endpoints, + IReadOnlyList Findings); +} diff --git a/tests/AR.Iec61850.Tests/Scl/SclWorkspaceServiceTests.cs b/tests/AR.Iec61850.Tests/Scl/SclWorkspaceServiceTests.cs new file mode 100644 index 0000000..4c733f2 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/SclWorkspaceServiceTests.cs @@ -0,0 +1,201 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class SclWorkspaceServiceTests +{ + [Fact] + public void Parse_Builds_Per_Ied_AccessPoint_Workspaces_With_Mms_Endpoints() + { + var workspace = new SclWorkspaceService().Parse(MultiIedScl(), "multi.scd"); + + Assert.Equal(2, workspace.Ieds.Count); + Assert.Equal(2, workspace.MmsEndpoints.Count); + Assert.Equal(64, workspace.SourceSha256.Length); + + var iedA = workspace.Ieds.Single(x => x.IedName == "IED_A"); + Assert.Equal("P1", iedA.AccessPointName); + Assert.NotNull(iedA.PreferredEndpoint); + Assert.Equal("192.0.2.10", iedA.PreferredEndpoint!.IpAddress); + Assert.Equal(102, iedA.PreferredEndpoint.Port); + Assert.True(iedA.CanBrowseOffline); + Assert.False(iedA.RequiresEndpointBinding); + Assert.Single(iedA.DesignModel.LogicalDevices); + Assert.Equal("IED_ALD0", iedA.DesignModel.LogicalDevices[0].MmsDomain); + Assert.DoesNotContain(iedA.DesignModel.LogicalDevices, x => x.MmsDomain.StartsWith("IED_B", StringComparison.Ordinal)); + + var iedB = workspace.Ieds.Single(x => x.IedName == "IED_B"); + Assert.Equal("P2", iedB.AccessPointName); + Assert.Equal("192.0.2.11", iedB.PreferredEndpoint!.IpAddress); + Assert.Equal(8102, iedB.PreferredEndpoint.Port); + Assert.Single(iedB.DesignModel.LogicalDevices); + Assert.Equal("IED_BLD1", iedB.DesignModel.LogicalDevices[0].MmsDomain); + } + + [Fact] + public void Parse_Keeps_Offline_Model_When_Communication_Is_Missing() + { + var workspace = new SclWorkspaceService().Parse(IcdWithoutCommunication(), "template.icd"); + + var ied = Assert.Single(workspace.Ieds); + Assert.Equal("TEMPLATE", ied.IedName); + Assert.True(ied.CanBrowseOffline); + Assert.True(ied.RequiresEndpointBinding); + Assert.Null(ied.PreferredEndpoint); + Assert.Contains(ied.Findings, x => x.Code == "SCL_MMS_ENDPOINT_UNASSIGNED"); + } + + [Fact] + public void Parse_Retains_Duplicate_Endpoint_Assignments_And_Reports_Conflict() + { + var xml = MultiIedScl().Replace("192.0.2.11", "192.0.2.10", StringComparison.Ordinal) + .Replace("

8102

", string.Empty, StringComparison.Ordinal); + + var workspace = new SclWorkspaceService().Parse(xml, "duplicate.scd"); + + Assert.Equal(2, workspace.MmsEndpoints.Count); + Assert.Contains(workspace.Findings, x => x.Code == "SCL_MMS_ENDPOINT_CONFLICT" && x.Severity == "High"); + } + + [Fact] + public void CompareLive_Accepts_Matching_Model_And_Blocks_Missing_Attribute() + { + var sclWorkspace = new SclWorkspaceService().Parse(MultiIedScl(), "multi.scd"); + var expected = sclWorkspace.Ieds.Single(x => x.IedName == "IED_A"); + + var matching = new SclWorkspaceService().CompareLive(expected, expected.DesignModel); + Assert.True(matching.IsCompatible); + Assert.False(matching.RequiresFullDiscovery); + Assert.Equal(matching.ExpectedAttributeCount, matching.MatchedAttributeCount); + + var expectedLd = expected.DesignModel.LogicalDevices.Single(); + var expectedLn = expectedLd.LogicalNodes.Single(x => x.Name == "XCBR1"); + var expectedDo = expectedLn.DataObjects.Single(x => x.Name == "Pos"); + var observed = new LiveIedModelDiscoveryDocument + { + IedName = "IED_A", + AccessPointName = "P1", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = expectedLd.MmsDomain, + Inst = expectedLd.Inst, + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = expectedLn.Name, + LnClass = expectedLn.LnClass, + LnInst = expectedLn.LnInst, + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = expectedDo.Reference, + Name = expectedDo.Name, + InferredCdc = expectedDo.InferredCdc, + Attributes = Array.Empty() + } + ] + } + ] + } + ] + }; + + var mismatch = new SclWorkspaceService().CompareLive(expected, observed); + Assert.False(mismatch.IsCompatible); + Assert.True(mismatch.RequiresFullDiscovery); + Assert.Contains(mismatch.Findings, x => x.Kind == SclLiveModelFindingKind.MissingLiveAttribute); + } + + private static string MultiIedScl() + => """ + + +
+ + + +
+

192.0.2.10

+
+
+ +
+

192.0.2.11

+

8102

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + private static string IcdWithoutCommunication() + => """ + +
+ + + + + + + + + + + + + + + + + + + + + """; +}