diff --git a/App.xaml.cs b/App.xaml.cs index 7a0d16197..d375701dd 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -6,6 +6,7 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Threading; +using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -22,6 +23,20 @@ protected override void OnStartup(StartupEventArgs e) WindowsApplicationIdentity.Apply(); base.OnStartup(e); + if (SclSafeTrialCommand.IsRequested(e.Args)) + { + var trial = SclSafeTrialRunner.RunAsync(e.Args, CancellationToken.None) + .GetAwaiter() + .GetResult(); + MessageBox.Show( + $"{trial.Message}\n\nEvidence: {trial.EvidencePath}", + trial.IsSuccess ? "SCL Safe Trial — PASS" : "SCL Safe Trial — NOT PROVEN", + MessageBoxButton.OK, + trial.IsSuccess ? MessageBoxImage.Information : MessageBoxImage.Warning); + Shutdown(trial.ExitCode); + return; + } + // P2 installs one calm industrial visual system before StartupUri materializes. // Existing XAML keeps its semantic resource keys while the overlay replaces // glare-heavy white/blue surfaces with Blue Steel + Light Greige equivalents. @@ -64,6 +79,12 @@ protected override void OnActivated(EventArgs e) // tiny P2 adapter when windows activate so newly opened FAT workspaces also // inherit the selected industrial theme without touching engine workflows. P2BlueSteelGreigeUx.ApplyToOpenWindows(this); + + // Keep IED onboarding task-first without replacing the existing protocol owners: + // Add IED offers SCL-first or live-IP discovery, and bulk connect is surfaced only + // when more than one loaded IED makes the action useful. + if (Current?.MainWindow is MainWindow mainWindow) + SmartIedOnboardingBehavior.Install(mainWindow); } private void InstallP2BlueSteelGreigeTheme() diff --git a/Services/Iec61850ConnectionPathPolicy.cs b/Services/Iec61850ConnectionPathPolicy.cs new file mode 100644 index 000000000..dc3f9281f --- /dev/null +++ b/Services/Iec61850ConnectionPathPolicy.cs @@ -0,0 +1,29 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +public enum Iec61850ConnectionPath +{ + FullDiscovery, + CachedLiveModel, + SclAssisted +} + +/// +/// Pure routing policy. SCL design authority is distinct from a saved live-discovery +/// cache: Play/Connect uses the SCL-assisted path, while Re-scan remains an explicit +/// caller of full discovery. +/// +public static class Iec61850ConnectionPathPolicy +{ + public static Iec61850ConnectionPath SelectForFastConnect(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + if (device.HasSclDesignModel) + return Iec61850ConnectionPath.SclAssisted; + if (device.HasDiscoveryCache && device.Signals.Count > 0) + return Iec61850ConnectionPath.CachedLiveModel; + return Iec61850ConnectionPath.FullDiscovery; + } +} diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs new file mode 100644 index 000000000..963eecbaa --- /dev/null +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -0,0 +1,469 @@ +using System.Diagnostics; +using AR.Iec61850.Discovery; +using ArMms = AR.Iec61850.Mms; +using ArScl = AR.Iec61850.Scl; + +namespace ArIED61850Tester.Services; + +public sealed class SclAssistedClientConnectResult +{ + public bool IsSuccess { get; init; } + public SclAssistedConnectionPreparation Preparation { get; init; } = new(); + public ArScl.SclAssistedMmsOnlineResult? Online { get; init; } + public ArMms.InitialFcReadExecutionResult? InitialRead { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public TimeSpan AssociationValidationDuration { get; init; } + public TimeSpan InitialReadDuration { get; init; } + public TimeSpan TotalDuration { get; init; } + public string Message { get; init; } = string.Empty; +} + +public sealed partial class NativeIec61850Client +{ + private readonly Dictionary _trustedSclDataSetDirectories = + new(StringComparer.OrdinalIgnoreCase); + private bool _trustedSclOnlineAuthorityActive; + + internal bool HasTrustedSclOnlineAuthority => _trustedSclOnlineAuthorityActive; + + internal IReadOnlyList TrustedSclReportControls + => _trustedSclOnlineAuthorityActive && _lastDiscovery is not null + ? _lastDiscovery.ReportInventory.ReportControls + : Array.Empty(); + + internal bool TryGetTrustedSclDataSetDirectory( + string dataSetReference, + out ArMms.MmsDataSetDirectoryResult directory) + => _trustedSclOnlineAuthorityActive && + _trustedSclDataSetDirectories.TryGetValue( + NormalizeTrustedSclReference(dataSetReference), + out directory!); + + private void ResetTrustedSclOnlineAuthority() + { + _trustedSclOnlineAuthorityActive = false; + _trustedSclDataSetDirectories.Clear(); + } + + public Task ConnectUsingSclAsync( + string sclXml, + string iedName, + string accessPointName, + string host, + int port, + CancellationToken cancellationToken) + => ConnectUsingSclAsync( + sclXml, + iedName, + accessPointName, + host, + port, + ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead, + cancellationToken); + + /// + /// Opens the trusted-SCL online path: exact SCL association identity, one Domain/VMD + /// reconciliation, then bounded sequential FC-root initial Reads. Static DataSet and + /// ReportControl authority is retained from the SCL model in memory; no live directory + /// discovery is introduced behind this path. + /// + public async Task ConnectUsingSclAsync( + string sclXml, + string iedName, + string accessPointName, + string host, + int port, + int maximumVariableReferencesPerRead, + CancellationToken cancellationToken) + { + var totalWatch = Stopwatch.StartNew(); + var associationDuration = TimeSpan.Zero; + var initialReadDuration = TimeSpan.Zero; + + ResetTrustedSclOnlineAuthority(); + await DisposeControlSessionsAsync().ConfigureAwait(false); + LastErrorMessage = string.Empty; + LastConnectionFailureKind = string.Empty; + LastConnectionTechnicalSummary = string.Empty; + LastDiscoverySummary = string.Empty; + _lastDiscovery = null; + _liveModel = null; + LastReportInventory = new NativeReportInventory(); + _reportMonitorSessions.Clear(); + _reportMonitorCoverage.Clear(); + ResetSemanticReportProjectionContext(); + Interlocked.Exchange(ref _engineCompatibilityWarningIssued, 0); + DetectedIdentity = new Iec61850DeviceIdentity(); + _host = host?.Trim() ?? string.Empty; + _port = port <= 0 ? 102 : port; + + var preparation = SclAssistedConnectionPreparationBuilder.Build( + sclXml, + iedName, + accessPointName, + _host, + _port, + maximumVariableReferencesPerRead); + if (!preparation.IsSuccess || + preparation.AssociationPlan is null || + preparation.InitialReadDesign is null || + preparation.InitialReadPlan is null) + { + LastConnectionFailureKind = "SCL_PLAN_INVALID"; + LastErrorMessage = preparation.Errors.Count == 0 + ? "SCL-assisted connection preparation failed." + : string.Join(" | ", preparation.Errors); + LastConnectionTechnicalSummary = LastErrorMessage; + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Warnings = preparation.Warnings, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + + try + { + var associationWatch = Stopwatch.StartNew(); + var online = await _session.ConnectSclAssistedAsync( + preparation.AssociationPlan, + preparation.DomainInventory, + TimeSpan.FromSeconds(8), + cancellationToken).ConfigureAwait(false); + associationWatch.Stop(); + associationDuration = associationWatch.Elapsed; + + if (!online.IsCompatible) + { + LastConnectionFailureKind = online.Status.ToString(); + LastErrorMessage = online.Message; + LastConnectionTechnicalSummary = online.Domains?.Summary ?? online.Message; + await _session.DisposeAsync().ConfigureAwait(false); + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Online = online, + Warnings = preparation.Warnings, + AssociationValidationDuration = associationDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + + var readWatch = Stopwatch.StartNew(); + var initialRead = await _session.ExecuteInitialFcReadPlanAsync( + preparation.InitialReadPlan, + TimeSpan.FromSeconds(5), + cancellationToken).ConfigureAwait(false); + readWatch.Stop(); + initialReadDuration = readWatch.Elapsed; + + if (initialRead.Status is ArMms.InitialFcReadExecutionStatus.InvalidPlan + or ArMms.InitialFcReadExecutionStatus.SessionNotReady + or ArMms.InitialFcReadExecutionStatus.TimedOut + or ArMms.InitialFcReadExecutionStatus.TransportFailure) + { + LastConnectionFailureKind = $"INITIAL_FC_READ_{initialRead.Status}"; + LastErrorMessage = initialRead.Message; + LastConnectionTechnicalSummary = initialRead.Message; + await _session.DisposeAsync().ConfigureAwait(false); + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Online = online, + InitialRead = initialRead, + Warnings = preparation.Warnings, + AssociationValidationDuration = associationDuration, + InitialReadDuration = initialReadDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + + var reconciledDomains = online.Domains?.MatchedDomains + ?? preparation.DomainInventory.ExpectedDomains; + var domainVariables = reconciledDomains + .Distinct(StringComparer.Ordinal) + .ToDictionary( + domain => domain, + _ => (IReadOnlyList)Array.Empty(), + StringComparer.Ordinal); + + _liveModel = preparation.InitialReadDesign.Model; + var reportInventory = BuildTrustedSclReportInventory(_liveModel); + var dataSetDirectories = BuildTrustedSclDataSetDirectories(_liveModel); + foreach (var directory in dataSetDirectories) + { + _trustedSclDataSetDirectories[ + NormalizeTrustedSclReference(directory.DataSetReference)] = directory; + } + + _lastDiscovery = new ArMms.MmsDiscoveryResult + { + Snapshot = new ArMms.MmsDiscoverySnapshot + { + DomainVariables = domainVariables, + DomainVariableLists = new Dictionary>(StringComparer.Ordinal) + }, + ReportInventory = reportInventory, + IedDirectory = new ArMms.MmsIedModelDirectory(Array.Empty()), + DataSetDirectories = dataSetDirectories, + Summary = + "Trusted SCL authority: Domain/VMD validation and bounded FC-root snapshot completed; " + + "static DataSet/RCB authority retained locally; full live discovery intentionally skipped." + }; + LastReportInventory = ToNativeInventory(reportInventory); + _trustedSclOnlineAuthorityActive = true; + + var projectionErrors = initialRead.Batches + .Sum(batch => batch.Projections.Sum(projection => projection.Errors.Count)); + var extraDomains = online.Domains?.ExtraObservedDomains.Count ?? 0; + var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial; + LastDiscoverySummary = + $"SCL-assisted MMS: domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " + + $"FC-roots={initialRead.Plan.Targets.Count}, successfulReads={initialRead.SuccessfulTargetCount}, " + + $"failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " + + $"projectionErrors={projectionErrors}, maxVariablesPerRead={initialRead.Plan.MaximumVariableReferencesPerRead}, " + + $"staticDataSets={dataSetDirectories.Count}, staticRCB={reportInventory.ReportControls.Count}, fullDiscovery=skipped."; + LastConnectionFailureKind = string.Empty; + LastConnectionTechnicalSummary = online.Domains?.Summary ?? online.Message; + LastErrorMessage = partial + ? "SCL-assisted association is healthy, but one or more initial FC-root values could not be read or projected. The trusted SCL model was preserved." + : string.Empty; + + var warnings = preparation.Warnings + .Concat(extraDomains > 0 + ? new[] { $"IED exposes {extraDomains} extra online MMS domain(s); they remain evidence only and do not mutate the SCL model." } + : Array.Empty()) + .Concat(partial ? new[] { LastErrorMessage } : Array.Empty()) + .Where(message => !string.IsNullOrWhiteSpace(message)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + IsSuccess = true, + Preparation = preparation, + Online = online, + InitialRead = initialRead, + Warnings = warnings, + AssociationValidationDuration = associationDuration, + InitialReadDuration = initialReadDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastDiscoverySummary + }; + } + catch (OperationCanceledException) + { + ResetTrustedSclOnlineAuthority(); + totalWatch.Stop(); + await _session.DisposeAsync().ConfigureAwait(false); + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException) + { + ResetTrustedSclOnlineAuthority(); + totalWatch.Stop(); + LastConnectionFailureKind = "SCL_ASSISTED_RUNTIME_FAILURE"; + LastErrorMessage = $"SCL-assisted MMS connection failed: {ex.GetType().Name}: {ex.Message}"; + LastConnectionTechnicalSummary = LastErrorMessage; + await _session.DisposeAsync().ConfigureAwait(false); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Warnings = preparation.Warnings, + AssociationValidationDuration = associationDuration, + InitialReadDuration = initialReadDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + } + + private static ArMms.MmsReportInventory BuildTrustedSclReportInventory( + LiveIedModelDiscoveryDocument model) + { + var inventory = new ArMms.MmsReportInventory(); + foreach (var dataSet in model.DataSets) + { + var (domain, itemName) = ParseTrustedSclDataSetReference( + dataSet.Reference, + dataSet.Domain, + dataSet.LogicalNode, + dataSet.Name); + inventory.DataSets.Add(new ArMms.MmsDataSetCandidate + { + Domain = domain, + LogicalNode = dataSet.LogicalNode, + Name = dataSet.Name, + Reference = dataSet.Reference, + RawMmsName = itemName + }); + } + + foreach (var report in model.ReportControls) + { + var reference = ConcreteFirstStaticRcbReference(report.Reference); + var candidate = new ArMms.MmsReportControlCandidate + { + Domain = report.Domain, + LogicalNode = report.LogicalNode, + FunctionalConstraint = report.Buffered ? "BR" : "RP", + Name = StaticRcbLeaf(reference), + Reference = reference, + Buffered = report.Buffered, + DataSetReference = report.DataSetReference, + DataSetProbeState = ArMms.MmsRcbDataSetProbeState.NotAttempted, + DataSetProbeMessage = "Trusted SCL authority; no live DataSet probe performed.", + ReportId = report.ReportId, + ConfRev = report.ConfRev, + IntegrityPeriodMs = report.IntegrityPeriodMs, + EnabledState = report.EnabledState, + ReservationState = report.ReservationState, + ReservationTimeSeconds = report.ReservationTimeSeconds, + BufferTimeMs = report.BufferTimeMs, + TriggerOptions = report.TriggerOptions, + OptionalFields = report.OptionalFields, + Status = "TrustedSclAuthority" + }; + + candidate.Attributes.AddRange(report.Buffered + ? new[] + { + "RptID", "RptEna", "DatSet", "ConfRev", "OptFlds", "BufTm", "SqNum", + "TrgOps", "IntgPd", "GI", "PurgeBuf", "EntryID", "TimeOfEntry", "ResvTms", "Owner" + } + : new[] + { + "RptID", "RptEna", "Resv", "DatSet", "ConfRev", "OptFlds", "BufTm", + "SqNum", "TrgOps", "IntgPd", "GI", "Owner" + }); + inventory.ReportControls.Add(candidate); + } + + return inventory; + } + + private static IReadOnlyList BuildTrustedSclDataSetDirectories( + LiveIedModelDiscoveryDocument model) + => model.DataSets + .Select(dataSet => + { + var (domain, itemName) = ParseTrustedSclDataSetReference( + dataSet.Reference, + dataSet.Domain, + dataSet.LogicalNode, + dataSet.Name); + var members = dataSet.Members + .OrderBy(member => member.Index) + .Select(member => BuildTrustedSclDataSetMember(member, domain)) + .ToArray(); + return new ArMms.MmsDataSetDirectoryResult + { + IsSuccess = members.Length > 0, + DataSetReference = dataSet.Reference, + Domain = domain, + DataSetMmsName = itemName, + IsDeletable = dataSet.IsDeletable, + Members = members, + Message = + $"Trusted SCL DataSet authority: {dataSet.Reference} has {members.Length} ordered member(s); no network directory request was sent." + }; + }) + .ToArray(); + + private static ArMms.MmsDataSetDirectoryMember BuildTrustedSclDataSetMember( + LiveIedDataSetMemberModel member, + string fallbackDomain) + { + var mmsReference = member.MmsReference?.Trim() ?? string.Empty; + var slash = mmsReference.IndexOf('/'); + var domain = slash > 0 ? mmsReference[..slash] : fallbackDomain; + var itemName = slash > 0 && slash + 1 < mmsReference.Length + ? mmsReference[(slash + 1)..] + : BuildMmsItemNameFromUserReference(member.Reference, member.FunctionalConstraint); + var tokens = itemName.Split('$', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var logicalNode = tokens.FirstOrDefault() ?? string.Empty; + var dataObjectPath = tokens.Length > 2 + ? string.Join('.', tokens.Skip(2)) + : string.Empty; + + return new ArMms.MmsDataSetDirectoryMember + { + Domain = domain, + MmsItemName = itemName, + UserReference = member.Reference, + FunctionalConstraint = member.FunctionalConstraint, + LogicalNode = logicalNode, + DataObjectPath = dataObjectPath, + Source = "TrustedScl", + Confidence = 100 + }; + } + + private static (string Domain, string ItemName) ParseTrustedSclDataSetReference( + string reference, + string fallbackDomain, + string logicalNode, + string name) + { + var normalized = reference?.Trim() ?? string.Empty; + var slash = normalized.IndexOf('/'); + var domain = slash > 0 ? normalized[..slash] : fallbackDomain; + var tail = slash > 0 && slash + 1 < normalized.Length + ? normalized[(slash + 1)..] + : string.Empty; + var itemName = string.IsNullOrWhiteSpace(tail) + ? $"{(string.IsNullOrWhiteSpace(logicalNode) ? "LLN0" : logicalNode)}${name}" + : tail.Replace('.', '$'); + if (!itemName.Contains('$', StringComparison.Ordinal)) + itemName = $"LLN0${itemName}"; + return (domain, itemName); + } + + private static string BuildMmsItemNameFromUserReference(string reference, string functionalConstraint) + { + var normalized = reference?.Trim() ?? string.Empty; + var slash = normalized.IndexOf('/'); + var tail = slash >= 0 && slash + 1 < normalized.Length + ? normalized[(slash + 1)..] + : normalized; + var parts = tail.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 0) + return string.Empty; + var logicalNode = parts[0]; + var dataPath = parts.Length > 1 ? string.Join('$', parts.Skip(1)) : string.Empty; + return string.IsNullOrWhiteSpace(dataPath) + ? $"{logicalNode}${functionalConstraint}" + : $"{logicalNode}${functionalConstraint}${dataPath}"; + } + + private static string ConcreteFirstStaticRcbReference(string reference) + { + var normalized = reference?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalized)) + return string.Empty; + + var separator = Math.Max(normalized.LastIndexOf('$'), normalized.LastIndexOf('.')); + var leaf = separator >= 0 ? normalized[(separator + 1)..] : normalized; + return leaf.Length > 0 && !char.IsDigit(leaf[^1]) + ? normalized + "01" + : normalized; + } + + private static string StaticRcbLeaf(string reference) + { + var separator = Math.Max(reference.LastIndexOf('$'), reference.LastIndexOf('.')); + return separator >= 0 && separator + 1 < reference.Length + ? reference[(separator + 1)..] + : reference; + } + + private static string NormalizeTrustedSclReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); +} diff --git a/Services/SclAssistedConnectionPreparation.cs b/Services/SclAssistedConnectionPreparation.cs new file mode 100644 index 000000000..e37b849e9 --- /dev/null +++ b/Services/SclAssistedConnectionPreparation.cs @@ -0,0 +1,189 @@ +using System.Xml; +using ArMms = AR.Iec61850.Mms; +using ArScl = AR.Iec61850.Scl; + +namespace ArIED61850Tester.Services; + +public sealed class SclAssistedConnectionPreparation +{ + public ArScl.SclAssistedMmsAssociationPlan? AssociationPlan { get; init; } + public ArScl.SclMmsDomainInventory DomainInventory { get; init; } = new(); + public ArScl.SclInitialFcReadDesign? InitialReadDesign { get; init; } + public ArMms.InitialFcReadPlan? InitialReadPlan { get; init; } + public IReadOnlyList Errors { get; init; } = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public bool IsSuccess => + Errors.Count == 0 && + AssociationPlan is not null && + DomainInventory.IsSuccess && + InitialReadDesign?.IsSuccess == true && + InitialReadPlan?.IsValid == true; +} + +/// +/// Pure Step-5 orchestration preparation. It converts one trusted SCL IED/AccessPoint +/// plus the operator-bound TCP endpoint into the exact ARIEC61850 association/domain/ +/// initial-read contracts. It performs no socket I/O and never falls back to discovery. +/// +public static class SclAssistedConnectionPreparationBuilder +{ + public static SclAssistedConnectionPreparation Build( + string sclXml, + string iedName, + string accessPointName, + string host, + int port, + int maximumVariableReferencesPerRead = ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead) + { + var errors = new List(); + var warnings = new List(); + var normalizedHost = (host ?? string.Empty).Trim(); + var normalizedIed = (iedName ?? string.Empty).Trim(); + var normalizedAccessPoint = (accessPointName ?? string.Empty).Trim(); + var normalizedPort = port <= 0 ? 102 : port; + + if (string.IsNullOrWhiteSpace(sclXml)) + errors.Add("SCL XML is empty."); + if (string.IsNullOrWhiteSpace(normalizedIed)) + errors.Add("An exact SCL IED name is required."); + if (string.IsNullOrWhiteSpace(normalizedAccessPoint)) + errors.Add("An exact SCL AccessPoint name is required."); + if (string.IsNullOrWhiteSpace(normalizedHost)) + errors.Add("A TCP endpoint is required before SCL-assisted connect."); + if (normalizedPort is < 1 or > 65535) + errors.Add($"TCP port must be in 1..65535; received {normalizedPort}."); + if (maximumVariableReferencesPerRead is < 1 or > ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead) + { + errors.Add( + $"Initial Read batch size must be in 1..{ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead}; received {maximumVariableReferencesPerRead}."); + } + + if (errors.Count > 0) + return Fail(errors, warnings); + + ArScl.SclMmsAssociationProfileSet profileSet; + try + { + profileSet = ArScl.SclMmsAssociationProfileReader.Read(sclXml); + } + catch (XmlException ex) + { + errors.Add($"SCL XML is malformed: {ex.Message}"); + return Fail(errors, warnings); + } + catch (Exception ex) when (ex is InvalidDataException or ArgumentException or InvalidOperationException) + { + errors.Add($"SCL MMS communication profile could not be read: {ex.GetType().Name}: {ex.Message}"); + return Fail(errors, warnings); + } + + warnings.AddRange(profileSet.Warnings); + var matches = profileSet.AccessPoints + .Where(profile => + string.Equals(profile.IedName, normalizedIed, StringComparison.Ordinal) && + string.Equals(profile.AccessPointName, normalizedAccessPoint, StringComparison.Ordinal)) + .ToArray(); + + if (matches.Length != 1) + { + errors.Add(matches.Length == 0 + ? $"SCL Communication has no exact ConnectedAP for IED '{normalizedIed}' / AccessPoint '{normalizedAccessPoint}'." + : $"SCL Communication has {matches.Length} exact ConnectedAP entries for IED '{normalizedIed}' / AccessPoint '{normalizedAccessPoint}'; association identity is ambiguous."); + return Fail(errors, warnings); + } + + var sclRemote = matches[0]; + var sclHost = (sclRemote.Endpoint.IpAddress ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(sclHost)) + { + warnings.Add($"SCL ConnectedAP has no IP address; using the explicit endpoint binding '{normalizedHost}'."); + } + else if (!string.Equals(sclHost, normalizedHost, StringComparison.OrdinalIgnoreCase)) + { + warnings.Add($"SCL IP '{sclHost}' differs from the explicit endpoint binding '{normalizedHost}'; TCP uses the explicit binding while OSI association identity remains SCL-derived."); + } + + // TCP endpoint binding is an application-level choice. Preserve every called-side + // OSI identity value from SCL and change only the network endpoint presented to + // the pure ARIEC association planner. + var effectiveRemote = new ArScl.SclMmsAccessPoint + { + IedName = sclRemote.IedName, + AccessPointName = sclRemote.AccessPointName, + SubNetworkName = sclRemote.SubNetworkName, + SubNetworkType = sclRemote.SubNetworkType, + Endpoint = new ArScl.SclMmsEndpoint + { + IpAddress = normalizedHost, + IpSubnet = sclRemote.Endpoint.IpSubnet, + IpGateway = sclRemote.Endpoint.IpGateway + }, + Association = sclRemote.Association, + Parameters = sclRemote.Parameters + }; + + var association = ArScl.SclAssistedMmsAssociationPlanBuilder.BuildExact( + effectiveRemote, + ArScl.MmsLocalAssociationProfile.SclInteroperabilityDefault); + warnings.AddRange(association.Warnings); + if (!association.IsSuccess || association.Plan is null) + { + errors.AddRange(association.Errors); + return Fail(errors, warnings); + } + + var runtimePlan = new ArScl.SclAssistedMmsAssociationPlan + { + Host = normalizedHost, + Port = normalizedPort, + IedName = association.Plan.IedName, + AccessPointName = association.Plan.AccessPointName, + LocalProfileName = association.Plan.LocalProfileName, + Cotp = association.Plan.Cotp, + Association = association.Plan.Association, + CotpConnectRequest = association.Plan.CotpConnectRequest, + SessionPresentationAcseMmsRequest = association.Plan.SessionPresentationAcseMmsRequest + }; + + var domains = ArScl.SclMmsDomainInventoryReader.Read(sclXml, normalizedIed, normalizedAccessPoint); + warnings.AddRange(domains.Warnings); + if (!domains.IsSuccess) + errors.AddRange(domains.Errors); + + var design = ArScl.SclInitialFcReadDesignBuilder.Read(sclXml, normalizedIed, normalizedAccessPoint); + warnings.AddRange(design.Warnings); + if (!design.IsSuccess) + errors.AddRange(design.Errors); + + ArMms.InitialFcReadPlan? initialReadPlan = null; + if (design.IsSuccess && domains.IsSuccess) + { + initialReadPlan = ArMms.InitialFcReadPlanner.FromSclModel( + design.Model, + domains.ExpectedDomains, + maximumVariableReferencesPerRead); + warnings.AddRange(initialReadPlan.Warnings); + if (!initialReadPlan.IsValid) + errors.AddRange(initialReadPlan.Errors); + } + + return new SclAssistedConnectionPreparation + { + AssociationPlan = runtimePlan, + DomainInventory = domains, + InitialReadDesign = design, + InitialReadPlan = initialReadPlan, + Errors = errors.Distinct(StringComparer.Ordinal).ToArray(), + Warnings = warnings.Distinct(StringComparer.Ordinal).ToArray() + }; + } + + private static SclAssistedConnectionPreparation Fail( + IReadOnlyCollection errors, + IReadOnlyCollection warnings) + => new() + { + Errors = errors.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray(), + Warnings = warnings.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray() + }; +} diff --git a/Services/SclSafeTrialGlobalUsings.cs b/Services/SclSafeTrialGlobalUsings.cs new file mode 100644 index 000000000..e662eefc9 --- /dev/null +++ b/Services/SclSafeTrialGlobalUsings.cs @@ -0,0 +1 @@ +global using ArIED61850Tester.Models; diff --git a/Services/SclSafeTrialRunner.cs b/Services/SclSafeTrialRunner.cs new file mode 100644 index 000000000..4b8490600 --- /dev/null +++ b/Services/SclSafeTrialRunner.cs @@ -0,0 +1,288 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.Json; +using System.Xml.Linq; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed record SclSafeTrialCommand( + string SclPath, + string IedName, + string AccessPointName, + string Host, + int Port, + int MaximumVariableReferencesPerRead, + string EvidencePath) +{ + public const string Switch = "--scl-safe-trial"; + public const string SingleReferenceSwitch = "--scl-safe-trial-single"; + + public static bool IsRequested(IReadOnlyList args) + => args.Any(argument => + string.Equals(argument, Switch, StringComparison.OrdinalIgnoreCase) || + string.Equals(argument, SingleReferenceSwitch, StringComparison.OrdinalIgnoreCase)); + + public static bool TryParse(IReadOnlyList args, out SclSafeTrialCommand? command, out string error) + { + command = null; + error = string.Empty; + if (args.Count == 0 || + (!string.Equals(args[0], Switch, StringComparison.OrdinalIgnoreCase) && + !string.Equals(args[0], SingleReferenceSwitch, StringComparison.OrdinalIgnoreCase))) + { + error = $"Expected {Switch} or {SingleReferenceSwitch} as the first argument."; + return false; + } + + if (args.Count < 5) + { + error = + $"Usage: ARSAS.exe {Switch} [port] [evidence JSON path]. " + + $"Use {SingleReferenceSwitch} with the same arguments for a controlled one-variable-per-Read interoperability trial."; + return false; + } + + var sclPath = Path.GetFullPath(args[1]); + var iedName = (args[2] ?? string.Empty).Trim(); + var accessPointName = (args[3] ?? string.Empty).Trim(); + var host = (args[4] ?? string.Empty).Trim(); + var port = 102; + if (args.Count >= 6 && (!int.TryParse(args[5], out port) || port is < 1 or > 65535)) + { + error = $"Invalid MMS TCP port '{args[5]}'; expected 1..65535."; + return false; + } + + if (string.IsNullOrWhiteSpace(iedName) || string.IsNullOrWhiteSpace(accessPointName) || string.IsNullOrWhiteSpace(host)) + { + error = "IED name, AccessPoint name and host/IP are required."; + return false; + } + + var maximumVariableReferencesPerRead = string.Equals( + args[0], + SingleReferenceSwitch, + StringComparison.OrdinalIgnoreCase) + ? 1 + : ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead; + + var evidencePath = args.Count >= 7 && !string.IsNullOrWhiteSpace(args[6]) + ? Path.GetFullPath(args[6]) + : Path.Combine( + Path.GetTempPath(), + $"ARSAS-SCL-Trial-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{maximumVariableReferencesPerRead}ref-{Guid.NewGuid():N}.json"); + + command = new SclSafeTrialCommand( + sclPath, + iedName, + accessPointName, + host, + port, + maximumVariableReferencesPerRead, + evidencePath); + return true; + } +} + +public sealed record SclSafeTrialRunResult( + int ExitCode, + bool IsSuccess, + string Message, + string EvidencePath); + +/// +/// Read-only laboratory entry point for physically validating the SCL-assisted MMS path. +/// It performs association, Domain/VMD reconciliation and bounded initial FC-root Reads only. +/// The process exits immediately afterwards, so reporting, control, writes and hidden full +/// discovery cannot be entered by this trial path. +/// +public static class SclSafeTrialRunner +{ + public static async Task RunAsync( + IReadOnlyList args, + CancellationToken cancellationToken = default) + { + if (!SclSafeTrialCommand.TryParse(args, out var command, out var parseError) || command is null) + return await WriteInputFailureAsync(args, parseError, cancellationToken).ConfigureAwait(false); + + var stopwatch = Stopwatch.StartNew(); + string sourceSha256 = string.Empty; + SclAssistedClientConnectResult? result = null; + Iec61850DeviceDiagnosticSnapshot? diagnostic = null; + string message; + var exitCode = 0; + + try + { + if (!File.Exists(command.SclPath)) + throw new FileNotFoundException("SCL/CID source file was not found.", command.SclPath); + + var sourceBytes = await File.ReadAllBytesAsync(command.SclPath, cancellationToken).ConfigureAwait(false); + sourceSha256 = Convert.ToHexString(SHA256.HashData(sourceBytes)).ToLowerInvariant(); + + // Let an XML parser honor the document encoding and normalize only the in-memory + // representation passed to the pure SCL planners. The evidence hash remains over + // the exact source bytes selected by the operator. + var document = XDocument.Load(command.SclPath, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); + var sclXml = document.ToString(SaveOptions.DisableFormatting); + + var client = new NativeIec61850Client(); + result = await client.ConnectUsingSclAsync( + sclXml, + command.IedName, + command.AccessPointName, + command.Host, + command.Port, + command.MaximumVariableReferencesPerRead, + cancellationToken).ConfigureAwait(false); + + diagnostic = client.CaptureDiagnosticSnapshot("SCL safe trial"); + message = result.Message; + exitCode = result.IsSuccess ? 0 : 31; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + message = "SCL safe trial was cancelled by the operator."; + exitCode = 32; + } + catch (Exception ex) + { + message = $"SCL safe trial failed before completion: {ex.GetType().Name}: {ex.Message}"; + exitCode = 33; + } + finally + { + stopwatch.Stop(); + } + + var evidence = new + { + schema = "arsas-scl-safe-trial-v2", + capturedAtUtc = DateTimeOffset.UtcNow, + safety = new + { + readOnly = true, + fullDiscoveryAllowed = false, + writesAllowed = false, + controlAllowed = false, + reportEnableAllowed = false, + dynamicDataSetAllowed = false, + automaticReadFallbackAllowed = false + }, + source = new + { + path = command.SclPath, + sha256 = sourceSha256, + iedName = command.IedName, + accessPointName = command.AccessPointName, + host = command.Host, + port = command.Port + }, + trialMode = new + { + maximumVariableReferencesPerRead = command.MaximumVariableReferencesPerRead, + label = command.MaximumVariableReferencesPerRead == 1 + ? "single-reference-control" + : "bounded-multi-reference" + }, + timing = new + { + processTotalMilliseconds = stopwatch.Elapsed.TotalMilliseconds, + connectionTotalMilliseconds = result?.TotalDuration.TotalMilliseconds ?? 0d, + associationAndDomainValidationMilliseconds = result?.AssociationValidationDuration.TotalMilliseconds ?? 0d, + initialReadMilliseconds = result?.InitialReadDuration.TotalMilliseconds ?? 0d + }, + result = new + { + success = result?.IsSuccess == true, + exitCode, + message, + warnings = result?.Warnings ?? Array.Empty(), + preparationErrors = result?.Preparation.Errors ?? Array.Empty(), + preparationWarnings = result?.Preparation.Warnings ?? Array.Empty(), + association = result?.Online is null ? null : new + { + status = result.Online.Status.ToString(), + result.Online.AssociationSucceeded, + result.Online.DomainInventorySucceeded, + result.Online.SessionRemainsOpen, + result.Online.Message + }, + domains = result?.Online?.Domains is null ? null : new + { + expected = result.Online.Domains.ExpectedDomains, + observed = result.Online.Domains.ObservedDomains, + matched = result.Online.Domains.MatchedDomains, + missing = result.Online.Domains.MissingExpectedDomains, + extra = result.Online.Domains.ExtraObservedDomains, + result.Online.Domains.IsCompatible, + result.Online.Domains.IsExactMatch, + result.Online.Domains.Summary + }, + initialRead = result?.InitialRead is null ? null : new + { + status = result.InitialRead.Status.ToString(), + result.InitialRead.Message, + result.InitialRead.SuccessfulTargetCount, + result.InitialRead.FailedTargetCount, + result.InitialRead.ProjectedLeafCount, + maximumVariableReferencesPerRead = result.InitialRead.Plan.MaximumVariableReferencesPerRead, + maximumOutstandingReads = result.InitialRead.Plan.MaximumOutstandingReads, + targetCount = result.InitialRead.Plan.Targets.Count, + batchCount = result.InitialRead.Plan.Batches.Count, + batches = result.InitialRead.Batches.Select(batch => new + { + batch.BatchIndex, + targetCount = batch.Targets.Count, + references = batch.Targets.Select(target => target.MmsReference).ToArray(), + successCount = batch.Read.Results.Count(item => item.IsSuccess), + failureCount = batch.Read.Results.Count(item => !item.IsSuccess), + projectionErrorCount = batch.Projections.Sum(item => item.Errors.Count) + }).ToArray() + }, + diagnostic + } + }; + + await WriteEvidenceAsync(command.EvidencePath, evidence, cancellationToken).ConfigureAwait(false); + return new SclSafeTrialRunResult(exitCode, result?.IsSuccess == true, message, command.EvidencePath); + } + + private static async Task WriteInputFailureAsync( + IReadOnlyList args, + string message, + CancellationToken cancellationToken) + { + var path = Path.Combine( + Path.GetTempPath(), + $"ARSAS-SCL-Trial-Invalid-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.json"); + var evidence = new + { + schema = "arsas-scl-safe-trial-v2", + capturedAtUtc = DateTimeOffset.UtcNow, + success = false, + exitCode = 30, + message, + arguments = args.ToArray() + }; + await WriteEvidenceAsync(path, evidence, cancellationToken).ConfigureAwait(false); + return new SclSafeTrialRunResult(30, false, message, path); + } + + private static async Task WriteEvidenceAsync( + string path, + object evidence, + CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var json = JsonSerializer.Serialize(evidence, new JsonSerializerOptions + { + WriteIndented = true + }); + await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false); + } +} diff --git a/Services/SmartIedOnboardingBehavior.cs b/Services/SmartIedOnboardingBehavior.cs new file mode 100644 index 000000000..039c7bdc1 --- /dev/null +++ b/Services/SmartIedOnboardingBehavior.cs @@ -0,0 +1,326 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +public sealed record SmartIedBulkActionState( + bool ShowConnectAll, + int TotalDevices, + int CandidateCount, + string Label, + string ToolTip); + +public static class SmartIedBulkActionPolicy +{ + public static SmartIedBulkActionState Evaluate(IEnumerable devices) + { + var snapshot = devices?.ToArray() ?? Array.Empty(); + var candidateCount = snapshot.Count(IsActionableConnectCandidate); + var show = snapshot.Length > 1 && candidateCount > 0; + var label = candidateCount switch + { + <= 0 => "Connect All", + 1 => "Connect 1 IED", + _ => $"Connect {candidateCount} IEDs" + }; + var toolTip = show + ? $"Connect/start the {candidateCount} IED(s) that are ready for a bulk connection. Already monitoring, busy, or endpoint-unbound IEDs are skipped." + : "Bulk connect appears only when multiple IEDs are loaded and at least one has a usable endpoint that still needs connection/monitoring."; + + return new SmartIedBulkActionState(show, snapshot.Length, candidateCount, label, toolTip); + } + + private static bool IsActionableConnectCandidate(Iec61850MonitorDevice device) + => device is not null + && !device.IsBusy + && !device.IsMonitoring + && !string.IsNullOrWhiteSpace(device.IpAddress) + && device.Port is >= 1 and <= 65535; +} + +/// +/// Presentation-only onboarding refinement for the existing Engineering IED Explorer. +/// It does not own IEC 61850 sessions. Existing Open SCL, IP discovery, and Connect All +/// handlers remain the execution authorities; this behavior only exposes them when useful. +/// +public static class SmartIedOnboardingBehavior +{ + private static readonly ConditionalWeakTable States = new(); + + public static void Install(MainWindow window) + { + ArgumentNullException.ThrowIfNull(window); + var state = States.GetValue(window, static owner => new SmartIedOnboardingState(owner)); + state.InstallOrRefresh(); + } + + private sealed class SmartIedOnboardingState + { + private readonly MainWindow _window; + private readonly HashSet