diff --git a/MainWindow.xaml b/MainWindow.xaml index a04a4dc77..2724e4014 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -30,7 +30,7 @@ @@ -131,13 +131,20 @@ Background="#F7FAFF" BorderBrush="#DCE6F5" BorderThickness="1" CornerRadius="15" Effect="{StaticResource SoftShadow}" Panel.ZIndex="30"> + + + + + - - + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index dc7c58ff7..86660102f 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -147,6 +147,137 @@ private async void AddRelay_Click(object sender, RoutedEventArgs e) } + private async void OpenScl_Click(object sender, RoutedEventArgs e) + { + var dialog = new OpenFileDialog + { + Title = "Open IEC 61850 SCL", + Filter = "IEC 61850 SCL (*.scd;*.cid;*.icd;*.iid;*.ssd)|*.scd;*.cid;*.icd;*.iid;*.ssd|XML SCL (*.xml)|*.xml|All files (*.*)|*.*", + CheckFileExists = true, + Multiselect = false + }; + if (dialog.ShowDialog(this) != true) + return; + + var sourceName = Path.GetFileName(dialog.FileName); + SetStatus($"Reading IED endpoints from {sourceName}…"); + 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."); + + if (result.Endpoints.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); + return; + } + + var added = 0; + var refreshed = 0; + var retained = 0; + Iec61850MonitorDevice? firstImported = null; + + foreach (var endpoint in result.Endpoints) + { + var device = Devices.FirstOrDefault(item => + item.IpAddress.Equals(endpoint.IpAddress, StringComparison.OrdinalIgnoreCase) && + item.Port == endpoint.Port); + + 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" + }; + 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++; + } + + firstImported ??= device; + } + + if (firstImported != null) + SelectedDevice = firstImported; + MainTabs.SelectedIndex = 0; + 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."; + SetStatus(status); + AddLog("INFO", "SCL", status); + } + catch (OperationCanceledException) + { + SetStatus($"{sourceName}: SCL import cancelled."); + } + catch (Exception ex) + { + AddLog("ERROR", "SCL", $"Could not open {sourceName}: {ex.Message}"); + SetStatus($"{sourceName}: SCL import failed. Diagnostics is marked with !."); + MarkDiagnosticAlert(); + MessageBox.Show( + this, + $"ArIED could not read this SCL file.\n\n{ex.Message}", + "Open SCL", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + + private static string BuildSclEndpointSummary(SclIedEndpoint endpoint) + { + 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); + } + + private async void ConnectAllIeds_Click(object sender, RoutedEventArgs e) { if (_connectAllInProgress) diff --git a/README.md b/README.md index 7e7585ff6..e7f2b20b0 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ ArIED 61850 is a Windows desktop engineering tool for IEC 61850 MMS discovery, m ## Core workflow ```text -Add or open IED -→ discover or restore cached model +Add IED by IP, open a project, or import SCL +→ connect and verify the live model, or restore a cached model → select live and control objects → start independent per-IED monitoring → inspect reports, SOE, diagnostics, and commands @@ -20,6 +20,7 @@ Each IED keeps its own connection, discovery progress, selected signals, report - Live MMS discovery of Logical Devices, Logical Nodes, data objects, values, quality, and IED timestamps. - Real IEDName and Logical Device boundary resolution. +- SCL endpoint import from SCD, CID, ICD, IID, SSD, or XML files, including multi-IED ConnectedAP/IP extraction and duplicate protection. - Multi-IED independent connections and monitoring. - Static RCB/DataSet first, dynamic reporting when needed, and bounded MMS fallback for uncovered or unverified points. - Saved project model cache for fast reconnect without repeating full discovery. diff --git a/Services/SclImportService.cs b/Services/SclImportService.cs new file mode 100644 index 000000000..77bcc13c4 --- /dev/null +++ b/Services/SclImportService.cs @@ -0,0 +1,208 @@ +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; +}