diff --git a/CHANGELOG.md b/CHANGELOG.md index 16cb5c5e..61a1089e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable public changes to ARIEC61850 are recorded here. The project is still ### Added +- Added bounded SCL-assisted association resolution for incomplete ConnectedAP addressing: explicit valid selectors/application identity remain immutable constraints, genuinely unspecified fields may be resolved from engine-owned interoperability profiles, malformed/conflicting declarations fail closed, and accepted candidate provenance is retained without mutating the source SCL. - Added zero-traffic smart-discovery KPI evidence across structural `GetNameList`, DataSet directory, hierarchy GVA, and bounded FC-root initial Read phases, including per-phase latency, duplicate semantic-request detection, peak outstanding requests, canonical completeness counters, deterministic repeat-run signatures, and explicit partial wire-accounting status for unobserved enrichment paths. - Added live-authoritative SCL ReportControl family reconciliation for static reporting: indexed declarative controls are matched only to concrete live MMS RCB instances in the same domain/logical-node/report-FC with decimal instance suffixes, while non-indexed controls remain exact-name only and no runtime RCB name is synthesized. - Added an SCL-aware static report planner that scopes the existing safe RCB selector to the reconciled live family, allowing a busy first instance to fall through to another proven-free instance without escaping to an unrelated control block. @@ -38,6 +39,7 @@ All notable public changes to ARIEC61850 are recorded here. The project is still ### Fixed +- SCL-assisted connection no longer rejects a usable CID/SCL before TCP solely because AP-title or AE-qualifier is omitted; the runtime now performs serial fresh-transport negotiation over the bounded engine candidate set and still limits live validation to VMD Domain inventory rather than falling back to full discovery. - Closed the persistent-monitor GI startup race in the new bootstrap path: a fast IED can no longer deliver the initial GI InformationReport before the target persistent monitor has been registered for routing. - Hardened the SCL-assisted Step-3 runtime so the configured timeout bounds TCP/COTP/ACSE/MMS plus Domain/VMD inventory, MMS domain identifiers are reconciled case-sensitively, and malformed SCL returns typed validation evidence instead of leaking XML parser exceptions. - Live-model discovery no longer drops DataSet members when callers request DataSet-directory reads through `MmsClientSession.DiscoverAsync`. diff --git a/docs/SCL_ASSISTED_MMS_CONNECT_STEP2.md b/docs/SCL_ASSISTED_MMS_CONNECT_STEP2.md index 4698e36a..f6190424 100644 --- a/docs/SCL_ASSISTED_MMS_CONNECT_STEP2.md +++ b/docs/SCL_ASSISTED_MMS_CONNECT_STEP2.md @@ -10,7 +10,7 @@ The design keeps remote/called and local/calling identity separate: - remote/called IP, TSEL, SSEL, PSEL, AP-title and AE qualifier come from the selected SCL `ConnectedAP`; - local/calling selectors, AP-title, AE qualifier and MMS Initiate limits come from an explicit named client profile; -- missing or invalid remote SCL association values fail closed and remain visible as typed build errors; +- the **exact-plan builder** still fails closed when required remote values are missing or invalid; later runtime interoperability resolution is a separate typed layer and never rewrites the source SCL; - no remote value is silently replaced with a local value. ## Implemented @@ -21,7 +21,9 @@ The design keeps remote/called and local/calling identity separate: - exact SCL selector/AP-title parsing and validation; - `ExistingRuntimeDefault` as an explicit compatibility baseline, not an automatic fallback; - golden test proving the parameterized encoder reproduces the existing `BalancedApTitle` association payload exactly when supplied the same identities; -- negative tests proving incomplete SCL association addressing does not silently fall back. +- negative tests proving the exact builder does not silently invent incomplete SCL association addressing. + +The exact builder is intentionally preserved as the deterministic complete-SCL contract. Step 3 now layers a bounded candidate resolver above it: genuinely unspecified fields may be resolved from engine-owned association profiles, while malformed or conflicting explicit declarations still fail closed. ## Runtime boundary @@ -34,6 +36,6 @@ Step 2 does **not**: The existing runtime continues to use `CotpConnectRequest.BuildDefault()` and the existing association profiles. -## Next step +## Runtime handoff -Step 3 should add an opt-in SCL-assisted association entry point that consumes a validated plan, performs TCP/COTP/ACSE association, then validates only the live MMS domain inventory with `GetNameList(Domain, VMD)`. It must not repeat NamedVariable/GVAA/DataSet discovery when the trusted SCL model is being used. +Step 3 consumes either a complete exact plan or an engine-owned bounded association resolution, performs TCP/COTP/ACSE association, and then validates only the live MMS domain inventory with `GetNameList(Domain, VMD)`. It does not repeat NamedVariable/GVAA/DataSet discovery when the trusted SCL model is being used. diff --git a/docs/SCL_ASSISTED_MMS_CONNECT_STEP3.md b/docs/SCL_ASSISTED_MMS_CONNECT_STEP3.md index 90acdd2e..4d45675f 100644 --- a/docs/SCL_ASSISTED_MMS_CONNECT_STEP3.md +++ b/docs/SCL_ASSISTED_MMS_CONNECT_STEP3.md @@ -9,8 +9,13 @@ Step 3 is the first opt-in runtime slice for the SCL-assisted connect path. It d ```text trusted SCL -> Step 1: parse MMS endpoint + ISO association identity - -> Step 2: build exact COTP + Session/Presentation/ACSE/MMS plan - -> Step 3: TCP/COTP/ACSE/MMS associate with that exact plan + -> Step 2: retain exact-plan capability for complete addressing + -> Step 3: classify association fields + explicit valid -> immutable constraint + unspecified -> bounded engine-profile resolution + invalid/conflict -> fail closed + -> serial fresh-transport candidate negotiation + -> first accepted MMS association -> GetNameList(Domain, VMD) only -> compare online domains with SCL logical devices -> keep the MMS association alive for later work @@ -18,6 +23,19 @@ trusted SCL The ordinary `MmsClientSession.ConnectAsync()` and full `DiscoverAsync()` paths remain unchanged. Step 3 is reached only through `ConnectSclAssistedAsync(...)`. +## Incomplete association addressing + +A ConnectedAP that omits an association parameter is not equivalent to one that declares malformed or conflicting data. + +`SclAssistedMmsAssociationCandidateResolver` classifies each critical field as: + +- `PresentValid` — explicit SCL evidence; candidates must match it exactly; +- `Unspecified` — no source value; the engine may resolve it from one of its bounded interoperability profiles; +- `Invalid` — explicit malformed value; fail closed before network I/O; +- `Ambiguous` — conflicting duplicate critical declarations; fail closed before network I/O. + +Candidate generation is deterministic and bounded. It does not form a Cartesian product of guessed selector/AP-title/AE values. Complete SCL keeps the exact plan first. For incomplete SCL, engine-owned profiles may supply only the unspecified fields, and accepted resolution is returned as provenance rather than written back into the source SCL. + ## Domain design inventory `SclMmsDomainInventoryReader` scopes the design model to one exact `IED/AccessPoint/Server` and projects each direct `LDevice` into its expected MMS domain: @@ -50,11 +68,13 @@ all expected present and no extra -> exact match A mismatch does not rewrite the trusted SCL model and does not automatically launch full discovery. If the transport remains healthy, the association stays open so the application can show diagnostics and choose an explicit next action. -## Exact-plan guard +## Plan/candidate guards + +The legacy exact-plan overload remains byte-stable: before opening a socket it rebuilds the Step-2 COTP and ACSE/MMS bytes and rejects an internally inconsistent plan as `InvalidPlan`. -Before opening a socket, Step 3 rebuilds the Step-2 COTP and ACSE/MMS bytes from the typed plan and verifies that they still match the prebuilt bytes. A mutated or internally inconsistent plan is rejected as `InvalidPlan` without network side effects. +The smart overload accepts only a successful bounded resolution. Every candidate is validated as a legal COTP request plus client ACSE/MMS associate payload before network I/O. Candidates are attempted strictly serially, each from a fresh transport. Per-attempt timeout and caller cancellation are explicit, and attempt evidence is association-scoped. -The existing no-argument `CotpClient.ConnectAsync(...)` path still builds the historical default COTP CR bytes. The new overload accepts `CotpConnectParameters` only for explicitly parameterized callers. +The existing no-argument `CotpClient.ConnectAsync(...)` path still builds the historical default COTP CR bytes. Parameterized SCL candidates use the explicit `CotpConnectParameters` overload. ## Explicit non-goals @@ -79,7 +99,11 @@ Deterministic tests cover: 2. fail-closed missing Server handling; 3. missing expected domain => incompatible; 4. extra online domain => evidence-only and still compatible; -5. invalid design/plan input returns a typed `InvalidPlan` result without TCP side effects. +5. invalid design/plan input returns a typed `InvalidPlan` result without TCP side effects; +6. missing AP-title/AE qualifier produces bounded compatibility candidates instead of a pre-socket error; +7. explicit non-default selectors remain unchanged in typed candidates; +8. malformed and conflicting explicit association values fail closed; +9. complete-SCL exact candidate remains first and byte-stable. Repository validation remains: diff --git a/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs b/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs index 904cd251..4e92e272 100644 --- a/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs +++ b/src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs @@ -51,13 +51,13 @@ public static class Iec61850StandardModelRegistry [Key("MMXU", "PF")] = Def("MMXU", "PF", "WYE", 0.84, "power factor"), // Harmonic measurements use phase-group CDCs. Physical AA1E1F06R4 - // plus IEDScout SCL prove that MHAI ThdA / ThdPhV are WYE objects whose + // plus field-reference SCL evidence prove that MHAI ThdA / ThdPhV are WYE objects whose // phase children are CMV SDOs, not ACD indication structures. [Key("MHAI", "ThdA")] = Def("MHAI", "ThdA", "WYE", 0.98, "three-phase current THD"), [Key("MHAI", "ThdPhV")] = Def("MHAI", "ThdPhV", "WYE", 0.98, "three-phase voltage THD"), // Instrument-transformer semantics are standardized and were also observed in - // the AA1E1F06R4 IEDScout engineering model. Exact registry entries prevent + // the field-reference engineering model. Exact registry entries prevent // live discovery from dropping these configuration DOs when attribute-name // heuristics alone cannot infer their CDC safely. [Key("TCTR", "Mod")] = Def("TCTR", "Mod", "ENC", 0.98, "mode control"), diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclAssisted.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclAssisted.cs index e167f347..3b989091 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SclAssisted.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SclAssisted.cs @@ -43,8 +43,56 @@ public async Task ConnectSclAssistedAsync( validationError); } - _lastHost = plan.Host; - _lastPort = plan.Port; + return await ConnectSclAssistedAsync( + SclAssistedMmsAssociationCandidateResolver.FromExactPlan(plan), + designDomains, + timeout, + cancellationToken) + .ConfigureAwait(false); + } + + public Task ConnectSclAssistedAsync( + SclAssistedMmsAssociationResolution resolution, + SclMmsDomainInventory designDomains, + CancellationToken cancellationToken = default) + => ConnectSclAssistedAsync( + resolution, + designDomains, + TimeSpan.FromSeconds(5), + cancellationToken); + + /// + /// Bounded SCL-assisted association negotiation. Candidates are engine-owned and + /// already constrained by explicit SCL evidence. Attempts are strictly serial and + /// every candidate starts from a fresh TCP/COTP transport. After the first accepted + /// MMS association only VMD Domain GetNameList is used to reconcile the trusted SCL + /// model; no NamedVariable/GVAA/DataSet discovery is introduced here. + /// + public async Task ConnectSclAssistedAsync( + SclAssistedMmsAssociationResolution resolution, + SclMmsDomainInventory designDomains, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resolution); + ArgumentNullException.ThrowIfNull(designDomains); + + var validationError = ValidateSclAssistedInputs(resolution, designDomains); + if (!string.IsNullOrWhiteSpace(validationError)) + { + return BuildSclAssistedResult( + SclAssistedMmsOnlineStatus.InvalidPlan, + resolution, + associationSucceeded: false, + domainInventorySucceeded: false, + domains: null, + validationError, + selectedCandidate: null, + associationAttemptCount: 0); + } + + _lastHost = resolution.Host; + _lastPort = resolution.Port; _lastTimeout = timeout <= TimeSpan.Zero ? TimeSpan.FromSeconds(5) : timeout; _nextInvokeId = 0; @@ -52,74 +100,144 @@ public async Task ConnectSclAssistedAsync( State = MmsAssociationState.Disconnected; ResetSclAssistedDiagnostics(); - var associationSucceeded = false; - var profileName = $"SCL:{plan.IedName}/{plan.AccessPointName}"; - using var operationTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - operationTimeout.CancelAfter(_lastTimeout); - var operationToken = operationTimeout.Token; + var attempts = new List(); + SclAssistedMmsAssociationCandidate? selectedCandidate = null; + var sawTimedOutAttempt = false; + var sawNonTimeoutFailure = false; - try + foreach (var candidate in resolution.Candidates) { - await _tpkt.ConnectAsync(_lastHost, _lastPort, _lastTimeout, operationToken).ConfigureAwait(false); - State = MmsAssociationState.TcpConnected; - - await _cotp.ConnectAsync(plan.Cotp, operationToken).ConfigureAwait(false); - State = MmsAssociationState.CotpConnected; - LastHandshakeMessage = $"{profileName}: {_cotp.LastConnectionConfirm?.Message ?? "COTP connection confirmed."}"; - - var associationProfile = new AcseAssociationProfile( - profileName, - "Exact SCL-assisted MMS association plan.", - plan.SessionPresentationAcseMmsRequest.ToArray()); - - var association = await TryInitiateMmsAssociationAsync(associationProfile, operationToken).ConfigureAwait(false); - LastAssociationAttempts = - [ - new AcseAssociationAttempt + cancellationToken.ThrowIfCancellationRequested(); + await ResetTransportAsync().ConfigureAwait(false); + State = MmsAssociationState.Disconnected; + LastAssociationResponseHex = string.Empty; + + using var attemptTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + attemptTimeout.CancelAfter(_lastTimeout); + var attemptToken = attemptTimeout.Token; + + try + { + await _tpkt.ConnectAsync(_lastHost, _lastPort, _lastTimeout, attemptToken).ConfigureAwait(false); + State = MmsAssociationState.TcpConnected; + + await _cotp.ConnectAsync(candidate.Cotp, attemptToken).ConfigureAwait(false); + State = MmsAssociationState.CotpConnected; + LastHandshakeMessage = + $"{candidate.Name}: {_cotp.LastConnectionConfirm?.Message ?? "COTP connection confirmed."}"; + + var association = await TryInitiateMmsAssociationAsync( + candidate.AssociationProfile, + attemptToken) + .ConfigureAwait(false); + + attempts.Add(new AcseAssociationAttempt { - ProfileName = profileName, + ProfileName = candidate.Name, IsAccepted = association.IsAccepted, Message = association.Message, ResponseHexPreview = association.ResponseHexPreview + }); + LastAssociationAttempts = attempts.ToArray(); + + if (!association.IsAccepted) + { + sawNonTimeoutFailure = true; + State = MmsAssociationState.MmsInitiateFailed; + continue; } - ]; - if (!association.IsAccepted) + selectedCandidate = candidate; + State = MmsAssociationState.MmsInitiated; + LastHandshakeMessage = $"{candidate.Name}: {association.Message}"; + break; + } + catch (OperationCanceledException) when ( + !cancellationToken.IsCancellationRequested && + attemptTimeout.IsCancellationRequested) { + sawTimedOutAttempt = true; State = MmsAssociationState.MmsInitiateFailed; - LastHandshakeMessage = association.Message; + attempts.Add(new AcseAssociationAttempt + { + ProfileName = candidate.Name, + IsAccepted = false, + Message = $"{candidate.Name}: association attempt timed out after {_lastTimeout.TotalMilliseconds:0} ms.", + ResponseHexPreview = LastAssociationResponseHex + }); + LastAssociationAttempts = attempts.ToArray(); + } + catch (OperationCanceledException) + { await ResetTransportAsync().ConfigureAwait(false); + State = MmsAssociationState.Disconnected; + throw; + } + catch (Exception ex) when ( + ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + sawNonTimeoutFailure = true; State = MmsAssociationState.MmsInitiateFailed; - - return BuildSclAssistedResult( - SclAssistedMmsOnlineStatus.AssociationFailed, - plan, - associationSucceeded: false, - domainInventorySucceeded: false, - domains: null, - association.Message); + attempts.Add(new AcseAssociationAttempt + { + ProfileName = candidate.Name, + IsAccepted = false, + Message = $"{candidate.Name}: transport/association failure: {ex.GetType().Name}: {ex.Message}", + ResponseHexPreview = LastAssociationResponseHex + }); + LastAssociationAttempts = attempts.ToArray(); } + } + + if (selectedCandidate is null) + { + await ResetTransportAsync().ConfigureAwait(false); + State = MmsAssociationState.MmsInitiateFailed; + LastAssociationAttempts = attempts.ToArray(); + LastHandshakeMessage = LastAssociationAttemptSummary; + var status = sawTimedOutAttempt && !sawNonTimeoutFailure + ? SclAssistedMmsOnlineStatus.TimedOut + : SclAssistedMmsOnlineStatus.AssociationFailed; + var message = string.IsNullOrWhiteSpace(LastHandshakeMessage) + ? "All bounded SCL-assisted association candidates failed." + : LastHandshakeMessage; - associationSucceeded = true; - State = MmsAssociationState.MmsInitiated; - LastHandshakeMessage = association.Message; - _receivePump.Start(cancellationToken); + return BuildSclAssistedResult( + status, + resolution, + associationSucceeded: false, + domainInventorySucceeded: false, + domains: null, + message, + selectedCandidate: null, + associationAttemptCount: attempts.Count); + } + + _receivePump.Start(cancellationToken); + using var domainTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + domainTimeout.CancelAfter(_lastTimeout); + var domainToken = domainTimeout.Token; + + try + { var domainInventory = await GetNameListPagedAsync( MmsGetNameListObjectClass.Domain, domainId: null, - cancellationToken: operationToken) + cancellationToken: domainToken) .ConfigureAwait(false); if (!domainInventory.IsSuccess) { return BuildSclAssistedResult( SclAssistedMmsOnlineStatus.DomainInventoryFailed, - plan, + resolution, associationSucceeded: true, domainInventorySucceeded: false, domains: null, - domainInventory.Message); + domainInventory.Message, + selectedCandidate, + attempts.Count); } var reconciliation = SclMmsDomainReconciler.Reconcile( @@ -129,30 +247,37 @@ public async Task ConnectSclAssistedAsync( ? SclAssistedMmsOnlineStatus.Compatible : SclAssistedMmsOnlineStatus.DomainMismatch; - LastHandshakeMessage = $"{profileName}: association accepted. {reconciliation.Summary}"; + LastHandshakeMessage = + $"{selectedCandidate.Name}: association accepted. {reconciliation.Summary}"; return BuildSclAssistedResult( status, - plan, + resolution, associationSucceeded: true, domainInventorySucceeded: true, reconciliation, - LastHandshakeMessage); + LastHandshakeMessage, + selectedCandidate, + attempts.Count); } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && operationTimeout.IsCancellationRequested) + catch (OperationCanceledException) when ( + !cancellationToken.IsCancellationRequested && + domainTimeout.IsCancellationRequested) { - var phase = associationSucceeded ? "Domain/VMD inventory" : "TCP/COTP/ACSE/MMS association"; - var message = $"{profileName}: {phase} timed out after {_lastTimeout.TotalMilliseconds:0} ms."; + var message = + $"{selectedCandidate.Name}: Domain/VMD inventory timed out after {_lastTimeout.TotalMilliseconds:0} ms."; LastHandshakeMessage = message; await ResetTransportAsync().ConfigureAwait(false); State = MmsAssociationState.MmsInitiateFailed; return BuildSclAssistedResult( SclAssistedMmsOnlineStatus.TimedOut, - plan, - associationSucceeded, + resolution, + associationSucceeded: true, domainInventorySucceeded: false, domains: null, - message); + message, + selectedCandidate, + attempts.Count); } catch (OperationCanceledException) { @@ -160,37 +285,25 @@ public async Task ConnectSclAssistedAsync( State = MmsAssociationState.Disconnected; throw; } - catch (Exception ex) + catch (Exception ex) when ( + ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) { - var message = associationSucceeded - ? $"{profileName}: Domain/VMD inventory transport failure after association: {ex.GetType().Name}: {ex.Message}" - : $"{profileName}: SCL-assisted transport/association failure: {ex.GetType().Name}: {ex.Message}"; - - LastAssociationAttempts = associationSucceeded - ? LastAssociationAttempts - : - [ - new AcseAssociationAttempt - { - ProfileName = profileName, - IsAccepted = false, - Message = message, - ResponseHexPreview = LastAssociationResponseHex - } - ]; + var message = + $"{selectedCandidate.Name}: Domain/VMD inventory transport failure after association: " + + $"{ex.GetType().Name}: {ex.Message}"; LastHandshakeMessage = message; await ResetTransportAsync().ConfigureAwait(false); State = MmsAssociationState.MmsInitiateFailed; return BuildSclAssistedResult( - associationSucceeded - ? SclAssistedMmsOnlineStatus.DomainInventoryFailed - : SclAssistedMmsOnlineStatus.AssociationFailed, - plan, - associationSucceeded, + SclAssistedMmsOnlineStatus.DomainInventoryFailed, + resolution, + associationSucceeded: true, domainInventorySucceeded: false, domains: null, - message); + message, + selectedCandidate, + attempts.Count); } } @@ -243,6 +356,47 @@ private static string ValidateSclAssistedInputs( return string.Empty; } + private static string ValidateSclAssistedInputs( + SclAssistedMmsAssociationResolution resolution, + SclMmsDomainInventory designDomains) + { + if (!designDomains.IsSuccess) + return "SCL MMS design-domain inventory is not valid: " + string.Join(" | ", designDomains.Errors); + if (!resolution.IsSuccess) + return resolution.Errors.Count == 0 + ? "SCL-assisted association resolution contains no usable candidate." + : string.Join(" | ", resolution.Errors); + if (resolution.Candidates.Count > SclAssistedMmsAssociationCandidateResolver.MaximumCandidateCount) + return $"SCL-assisted association resolution exceeds the bounded candidate limit of {SclAssistedMmsAssociationCandidateResolver.MaximumCandidateCount}."; + if (!string.Equals(resolution.IedName, designDomains.IedName, StringComparison.Ordinal) || + !string.Equals(resolution.AccessPointName, designDomains.AccessPointName, StringComparison.Ordinal)) + { + return $"SCL association identity '{resolution.IedName}/{resolution.AccessPointName}' does not match design-domain identity '{designDomains.IedName}/{designDomains.AccessPointName}'."; + } + + foreach (var candidate in resolution.Candidates) + { + if (string.IsNullOrWhiteSpace(candidate.Name)) + return "SCL-assisted association candidate has no stable name."; + if (candidate.AssociationProfile.Payload.Length == 0) + return $"SCL-assisted association candidate '{candidate.Name}' has an empty ACSE/MMS payload."; + + try + { + _ = CotpConnectRequest.Build(candidate.Cotp); + var inspection = AcseAssociationPayloadInspector.Inspect(candidate.AssociationProfile.Payload); + if (!inspection.LooksLikeClientAssociateRequest) + return $"SCL-assisted association candidate '{candidate.Name}' is not a valid client associate request: {inspection.Message}"; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return $"SCL-assisted association candidate '{candidate.Name}' validation failed: {ex.GetType().Name}: {ex.Message}"; + } + } + + return string.Empty; + } + private SclAssistedMmsOnlineResult BuildSclAssistedResult( SclAssistedMmsOnlineStatus status, SclAssistedMmsAssociationPlan plan, @@ -263,4 +417,31 @@ private SclAssistedMmsOnlineResult BuildSclAssistedResult( Domains = domains, Message = message }; + + private SclAssistedMmsOnlineResult BuildSclAssistedResult( + SclAssistedMmsOnlineStatus status, + SclAssistedMmsAssociationResolution resolution, + bool associationSucceeded, + bool domainInventorySucceeded, + SclMmsDomainReconciliation? domains, + string message, + SclAssistedMmsAssociationCandidate? selectedCandidate, + int associationAttemptCount) + => new() + { + Status = status, + IedName = resolution.IedName, + AccessPointName = resolution.AccessPointName, + Host = resolution.Host, + Port = resolution.Port, + AssociationSucceeded = associationSucceeded, + DomainInventorySucceeded = domainInventorySucceeded, + SessionRemainsOpen = IsMmsInitiated && IsTransportConnected, + SelectedAssociationCandidateName = selectedCandidate?.Name ?? string.Empty, + SelectedAssociationCandidateSource = selectedCandidate?.Source, + AssociationAttemptCount = associationAttemptCount, + AssociationResolutionNotes = selectedCandidate?.ResolutionNotes ?? Array.Empty(), + Domains = domains, + Message = message + }; } diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs new file mode 100644 index 00000000..805061b0 --- /dev/null +++ b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs @@ -0,0 +1,684 @@ +using System.Globalization; +using AR.Iec61850.Acse; +using AR.Iec61850.Osi; + +namespace AR.Iec61850.Scl; + +public enum SclAssociationFieldState +{ + Unspecified, + PresentValid, + Invalid, + Ambiguous +} + +public enum SclAssociationCandidateSource +{ + ExactScl, + EngineProfileTemplate, + EngineRawCompatibility +} + +public sealed class SclAssociationFieldEvidence +{ + public string Name { get; init; } = string.Empty; + public SclAssociationFieldState State { get; init; } + public string DeclaredText { get; init; } = string.Empty; +} + +public sealed class SclAssistedMmsAssociationCandidate +{ + public string Name { get; init; } = string.Empty; + public SclAssociationCandidateSource Source { get; init; } + public CotpConnectParameters Cotp { get; init; } = new(); + public AcseAssociationProfile AssociationProfile { get; init; } = + new("invalid", "invalid", Array.Empty()); + public IReadOnlyList ResolutionNotes { get; init; } = Array.Empty(); +} + +public sealed class SclAssistedMmsAssociationResolution +{ + public string Host { get; init; } = string.Empty; + public int Port { get; init; } = 102; + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public IReadOnlyList Fields { get; init; } = + Array.Empty(); + public IReadOnlyList Candidates { get; init; } = + Array.Empty(); + public IReadOnlyList Errors { get; init; } = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public bool IsSuccess => + Errors.Count == 0 && + !string.IsNullOrWhiteSpace(Host) && + Port is >= 1 and <= 65535 && + Candidates.Count > 0; +} + +/// +/// Builds a small deterministic set of SCL-constrained association candidates. +/// Explicit valid SCL values are immutable constraints. Only values that are truly +/// unspecified may be supplied by engine-owned interoperability profiles. Malformed +/// or conflicting declarations fail closed before any socket is opened. +/// +public static class SclAssistedMmsAssociationCandidateResolver +{ + public const int MaximumCandidateCount = 8; + + public static SclAssistedMmsAssociationResolution Resolve( + SclMmsAccessPoint remote, + MmsLocalAssociationProfile exactLocalProfile, + int port = 102) + { + ArgumentNullException.ThrowIfNull(remote); + ArgumentNullException.ThrowIfNull(exactLocalProfile); + + var errors = new List(); + var warnings = new List(); + var host = (remote.Endpoint.IpAddress ?? string.Empty).Trim(); + var normalizedPort = port <= 0 ? 102 : port; + + if (string.IsNullOrWhiteSpace(host)) + errors.Add("SCL-assisted association requires a resolved TCP endpoint."); + if (normalizedPort is < 1 or > 65535) + errors.Add($"TCP port must be in 1..65535; received {normalizedPort}."); + + var tsel = ParseSelector(remote, "OSI-TSEL", remote.Association.TransportSelector, errors); + var ssel = ParseSelector(remote, "OSI-SSEL", remote.Association.SessionSelector, errors); + var psel = ParseSelector(remote, "OSI-PSEL", remote.Association.PresentationSelector, errors); + var apTitle = ParseApTitle(remote, remote.Association.ApTitle, errors); + var aeQualifier = ParseAeQualifier(remote, errors); + + var fields = new[] + { + tsel.Evidence, + ssel.Evidence, + psel.Evidence, + apTitle.Evidence, + aeQualifier.Evidence + }; + + if (errors.Count > 0) + return Build(remote, normalizedPort, fields, Array.Empty(), errors, warnings); + + var missing = fields + .Where(field => field.State == SclAssociationFieldState.Unspecified) + .Select(field => field.Name) + .ToArray(); + if (missing.Length > 0) + { + warnings.Add( + $"SCL ConnectedAP leaves {string.Join(", ", missing)} unspecified; " + + "only those fields may be resolved from the bounded engine interoperability profiles."); + } + + var candidates = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + // Complete SCL remains the highest-authority, byte-stable path. + var exact = SclAssistedMmsAssociationPlanBuilder.BuildExact(remote, exactLocalProfile); + if (exact.IsSuccess && exact.Plan is not null) + { + AddCandidate( + candidates, + seen, + FromPlan( + exact.Plan, + "SCL exact", + SclAssociationCandidateSource.ExactScl, + Array.Empty())); + } + + var defaultCotp = new CotpConnectParameters(); + var engineProfiles = AcseMmsInitiateRequest.BuildAssociationProfiles(); + var qualifiedTemplate = engineProfiles + .Select(profile => new + { + Profile = profile, + Evidence = AcseAssociationRequestIdentityReader.Read( + profile.Payload, + defaultCotp.DestinationTsap) + }) + .FirstOrDefault(item => + item.Evidence.HasQualifiedCalledApplicationIdentity && + item.Evidence.CalledPresentationSelector.Length > 0 && + item.Evidence.CalledSessionSelector.Length > 0); + + foreach (var profile in engineProfiles) + { + if (candidates.Count >= MaximumCandidateCount) + break; + + var evidence = AcseAssociationRequestIdentityReader.Read( + profile.Payload, + defaultCotp.DestinationTsap); + + // Raw profile attempts reproduce the native discovery association bytes. They + // are admitted only when every explicit SCL field embedded in that payload + // agrees exactly. TSEL is carried by COTP and can therefore remain SCL-exact. + if (RawProfileSatisfiesConstraints(ssel, psel, apTitle, aeQualifier, evidence)) + { + var destinationTsel = tsel.Value ?? defaultCotp.DestinationTsap; + var notes = BuildRawResolutionNotes( + profile.Name, + tsel, + ssel, + psel, + apTitle, + aeQualifier, + evidence); + + AddCandidate( + candidates, + seen, + new SclAssistedMmsAssociationCandidate + { + Name = $"Engine raw profile: {profile.Name}", + Source = SclAssociationCandidateSource.EngineRawCompatibility, + Cotp = new CotpConnectParameters + { + SourceTsap = defaultCotp.SourceTsap.ToArray(), + DestinationTsap = destinationTsel.ToArray(), + TpduSizeExponent = defaultCotp.TpduSizeExponent + }, + AssociationProfile = new AcseAssociationProfile( + profile.Name, + profile.Description, + profile.Payload.ToArray()), + ResolutionNotes = notes + }); + } + } + + // A qualified engine profile can also act as one coherent missing-field template. + // This is not a Cartesian product: all missing remote values come from the same + // engine profile while every explicit SCL value is overlaid unchanged. + if (qualifiedTemplate is not null && candidates.Count < MaximumCandidateCount) + { + var template = qualifiedTemplate.Evidence; + var effectiveRemote = BuildEffectiveRemote( + remote, + tsel, + ssel, + psel, + apTitle, + aeQualifier, + template, + defaultCotp); + + foreach (var local in DistinctLocalProfiles(exactLocalProfile)) + { + if (candidates.Count >= MaximumCandidateCount) + break; + + var planned = SclAssistedMmsAssociationPlanBuilder.BuildExact(effectiveRemote, local); + if (!planned.IsSuccess || planned.Plan is null) + continue; + + AddCandidate( + candidates, + seen, + FromPlan( + planned.Plan, + $"SCL constrained: {qualifiedTemplate.Profile.Name}/{local.Name}", + SclAssociationCandidateSource.EngineProfileTemplate, + BuildTypedResolutionNotes( + qualifiedTemplate.Profile.Name, + tsel, + ssel, + psel, + apTitle, + aeQualifier))); + } + } + + if (candidates.Count == 0) + { + errors.Add( + "No bounded engine association candidate satisfies the explicit SCL constraints. " + + "The SCL was not altered and no socket should be opened."); + } + + return Build(remote, normalizedPort, fields, candidates, errors, warnings); + } + + public static SclAssistedMmsAssociationResolution FromExactPlan( + SclAssistedMmsAssociationPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + + return new SclAssistedMmsAssociationResolution + { + Host = plan.Host, + Port = plan.Port, + IedName = plan.IedName, + AccessPointName = plan.AccessPointName, + Candidates = + [ + FromPlan( + plan, + "SCL exact", + SclAssociationCandidateSource.ExactScl, + Array.Empty()) + ] + }; + } + + private static IEnumerable DistinctLocalProfiles( + MmsLocalAssociationProfile primary) + { + yield return primary; + + var fallback = MmsLocalAssociationProfile.SclInteroperabilityDefault; + if (!SameLocalProfile(primary, fallback)) + yield return fallback; + } + + private static bool SameLocalProfile( + MmsLocalAssociationProfile left, + MmsLocalAssociationProfile right) + => left.TransportSelector.SequenceEqual(right.TransportSelector) && + left.SessionSelector.SequenceEqual(right.SessionSelector) && + left.PresentationSelector.SequenceEqual(right.PresentationSelector) && + left.ApTitle.SequenceEqual(right.ApTitle) && + left.AeQualifier == right.AeQualifier && + left.TpduSizeExponent == right.TpduSizeExponent && + left.Initiate.LocalDetailCalling == right.Initiate.LocalDetailCalling && + left.Initiate.MaxOutstandingCalling == right.Initiate.MaxOutstandingCalling && + left.Initiate.MaxOutstandingCalled == right.Initiate.MaxOutstandingCalled && + left.Initiate.NestingLevel == right.Initiate.NestingLevel; + + private static SclMmsAccessPoint BuildEffectiveRemote( + SclMmsAccessPoint source, + SelectorConstraint tsel, + SelectorConstraint ssel, + SelectorConstraint psel, + ApTitleConstraint apTitle, + AeQualifierConstraint aeQualifier, + AcseAssociationRequestIdentityEvidence template, + CotpConnectParameters defaultCotp) + => new() + { + IedName = source.IedName, + AccessPointName = source.AccessPointName, + SubNetworkName = source.SubNetworkName, + SubNetworkType = source.SubNetworkType, + Endpoint = source.Endpoint, + Parameters = source.Parameters, + AmbiguousParameters = source.AmbiguousParameters, + Association = new SclIsoAssociationAddress + { + TransportSelector = ToHex(tsel.Value ?? defaultCotp.DestinationTsap), + SessionSelector = ToHex(ssel.Value ?? template.CalledSessionSelector), + PresentationSelector = ToHex(psel.Value ?? template.CalledPresentationSelector), + ApTitle = apTitle.Value is not null + ? string.Join(",", apTitle.Value) + : template.CalledApTitleText, + AeQualifier = aeQualifier.Value ?? template.CalledAeQualifier, + AeQualifierText = (aeQualifier.Value ?? template.CalledAeQualifier)? + .ToString(CultureInfo.InvariantCulture) ?? string.Empty + } + }; + + private static bool RawProfileSatisfiesConstraints( + SelectorConstraint ssel, + SelectorConstraint psel, + ApTitleConstraint apTitle, + AeQualifierConstraint aeQualifier, + AcseAssociationRequestIdentityEvidence candidate) + => Matches(ssel, candidate.CalledSessionSelector) && + Matches(psel, candidate.CalledPresentationSelector) && + Matches(apTitle, candidate.CalledApTitle) && + Matches(aeQualifier, candidate.CalledAeQualifier); + + private static bool Matches(SelectorConstraint constraint, byte[] candidate) + => constraint.State == SclAssociationFieldState.Unspecified || + (constraint.State == SclAssociationFieldState.PresentValid && + constraint.Value is not null && + candidate.Length > 0 && + constraint.Value.SequenceEqual(candidate)); + + private static bool Matches(ApTitleConstraint constraint, uint[] candidate) + => constraint.State == SclAssociationFieldState.Unspecified || + (constraint.State == SclAssociationFieldState.PresentValid && + constraint.Value is not null && + candidate.Length >= 2 && + constraint.Value.SequenceEqual(candidate)); + + private static bool Matches(AeQualifierConstraint constraint, int? candidate) + => constraint.State == SclAssociationFieldState.Unspecified || + (constraint.State == SclAssociationFieldState.PresentValid && + constraint.Value.HasValue && + candidate == constraint.Value); + + private static IReadOnlyList BuildRawResolutionNotes( + string profileName, + SelectorConstraint tsel, + SelectorConstraint ssel, + SelectorConstraint psel, + ApTitleConstraint apTitle, + AeQualifierConstraint aeQualifier, + AcseAssociationRequestIdentityEvidence evidence) + { + var notes = new List(); + AddResolved(notes, "OSI-TSEL", tsel.State, ToHex(evidence.CalledTransportSelector), profileName); + AddResolved(notes, "OSI-SSEL", ssel.State, ToHex(evidence.CalledSessionSelector), profileName); + AddResolved(notes, "OSI-PSEL", psel.State, ToHex(evidence.CalledPresentationSelector), profileName); + AddResolved(notes, "OSI-AP-Title", apTitle.State, evidence.CalledApTitleText, profileName); + AddResolved( + notes, + "OSI-AE-Qualifier", + aeQualifier.State, + evidence.CalledAeQualifier?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + profileName); + return notes; + } + + private static IReadOnlyList BuildTypedResolutionNotes( + string profileName, + SelectorConstraint tsel, + SelectorConstraint ssel, + SelectorConstraint psel, + ApTitleConstraint apTitle, + AeQualifierConstraint aeQualifier) + { + var notes = new List(); + AddTemplateResolution(notes, "OSI-TSEL", tsel.State, profileName); + AddTemplateResolution(notes, "OSI-SSEL", ssel.State, profileName); + AddTemplateResolution(notes, "OSI-PSEL", psel.State, profileName); + AddTemplateResolution(notes, "OSI-AP-Title", apTitle.State, profileName); + AddTemplateResolution(notes, "OSI-AE-Qualifier", aeQualifier.State, profileName); + return notes; + } + + private static void AddResolved( + ICollection notes, + string field, + SclAssociationFieldState state, + string candidateValue, + string profileName) + { + if (state != SclAssociationFieldState.Unspecified) + return; + + notes.Add(string.IsNullOrWhiteSpace(candidateValue) + ? $"{field} remains omitted by engine profile {profileName}." + : $"{field} resolved from engine profile {profileName}; source SCL remains unchanged."); + } + + private static void AddTemplateResolution( + ICollection notes, + string field, + SclAssociationFieldState state, + string profileName) + { + if (state == SclAssociationFieldState.Unspecified) + notes.Add($"{field} resolved from the single engine profile template {profileName}; source SCL remains unchanged."); + } + + private static SclAssistedMmsAssociationCandidate FromPlan( + SclAssistedMmsAssociationPlan plan, + string name, + SclAssociationCandidateSource source, + IReadOnlyList notes) + => new() + { + Name = name, + Source = source, + Cotp = new CotpConnectParameters + { + SourceTsap = plan.Cotp.SourceTsap.ToArray(), + DestinationTsap = plan.Cotp.DestinationTsap.ToArray(), + TpduSizeExponent = plan.Cotp.TpduSizeExponent + }, + AssociationProfile = new AcseAssociationProfile( + name, + $"SCL-assisted candidate ({source}).", + plan.SessionPresentationAcseMmsRequest.ToArray()), + ResolutionNotes = notes + }; + + private static void AddCandidate( + ICollection candidates, + ISet seen, + SclAssistedMmsAssociationCandidate candidate) + { + var cotp = CotpConnectRequest.Build(candidate.Cotp); + var key = Convert.ToHexString(cotp) + ":" + + Convert.ToHexString(candidate.AssociationProfile.Payload); + if (!seen.Add(key)) + return; + + candidates.Add(candidate); + } + + private static SclAssistedMmsAssociationResolution Build( + SclMmsAccessPoint remote, + int port, + IReadOnlyList fields, + IReadOnlyList candidates, + IReadOnlyList errors, + IReadOnlyList warnings) + => new() + { + Host = remote.Endpoint.IpAddress?.Trim() ?? string.Empty, + Port = port, + IedName = remote.IedName, + AccessPointName = remote.AccessPointName, + Fields = fields, + Candidates = candidates, + Errors = errors.Distinct(StringComparer.Ordinal).ToArray(), + Warnings = warnings.Distinct(StringComparer.Ordinal).ToArray() + }; + + private static SelectorConstraint ParseSelector( + SclMmsAccessPoint remote, + string name, + string text, + ICollection errors) + { + if (remote.AmbiguousParameters.Contains(name)) + { + errors.Add($"SCL ConnectedAP declares conflicting duplicate {name} values."); + return new SelectorConstraint( + SclAssociationFieldState.Ambiguous, + null, + new SclAssociationFieldEvidence { Name = name, State = SclAssociationFieldState.Ambiguous }); + } + + if (string.IsNullOrWhiteSpace(text)) + { + return new SelectorConstraint( + SclAssociationFieldState.Unspecified, + null, + new SclAssociationFieldEvidence { Name = name, State = SclAssociationFieldState.Unspecified }); + } + + var compact = text.Trim(); + if (compact.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + compact = compact[2..]; + compact = compact + .Replace(" ", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .Replace(":", string.Empty, StringComparison.Ordinal); + + if (compact.Length == 0 || (compact.Length & 1) != 0 || compact.Length > 32) + { + errors.Add($"SCL {name} '{text}' is not a 1-to-16-byte hexadecimal selector."); + return InvalidSelector(name, text); + } + + var bytes = new byte[compact.Length / 2]; + for (var i = 0; i < bytes.Length; i++) + { + if (!byte.TryParse( + compact.AsSpan(i * 2, 2), + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out bytes[i])) + { + errors.Add($"SCL {name} '{text}' contains non-hexadecimal data."); + return InvalidSelector(name, text); + } + } + + return new SelectorConstraint( + SclAssociationFieldState.PresentValid, + bytes, + new SclAssociationFieldEvidence + { + Name = name, + State = SclAssociationFieldState.PresentValid, + DeclaredText = text + }); + } + + private static SelectorConstraint InvalidSelector(string name, string text) + => new( + SclAssociationFieldState.Invalid, + null, + new SclAssociationFieldEvidence + { + Name = name, + State = SclAssociationFieldState.Invalid, + DeclaredText = text + }); + + private static ApTitleConstraint ParseApTitle( + SclMmsAccessPoint remote, + string text, + ICollection errors) + { + const string name = "OSI-AP-Title"; + if (remote.AmbiguousParameters.Contains(name)) + { + errors.Add("SCL ConnectedAP declares conflicting duplicate OSI-AP-Title values."); + return new ApTitleConstraint( + SclAssociationFieldState.Ambiguous, + null, + new SclAssociationFieldEvidence { Name = name, State = SclAssociationFieldState.Ambiguous }); + } + + if (string.IsNullOrWhiteSpace(text)) + { + return new ApTitleConstraint( + SclAssociationFieldState.Unspecified, + null, + new SclAssociationFieldEvidence { Name = name, State = SclAssociationFieldState.Unspecified }); + } + + var tokens = text.Split( + [',', '.', ' ', ';'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length < 2) + { + errors.Add($"SCL OSI-AP-Title '{text}' does not contain a valid OID."); + return InvalidApTitle(text); + } + + var arcs = new uint[tokens.Length]; + for (var i = 0; i < tokens.Length; i++) + { + if (!uint.TryParse(tokens[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out arcs[i])) + { + errors.Add($"SCL OSI-AP-Title '{text}' contains an invalid OID arc '{tokens[i]}'."); + return InvalidApTitle(text); + } + } + + if (arcs[0] > 2 || (arcs[0] < 2 && arcs[1] > 39)) + { + errors.Add($"SCL OSI-AP-Title '{text}' violates ASN.1 object-identifier arc rules."); + return InvalidApTitle(text); + } + + return new ApTitleConstraint( + SclAssociationFieldState.PresentValid, + arcs, + new SclAssociationFieldEvidence + { + Name = name, + State = SclAssociationFieldState.PresentValid, + DeclaredText = text + }); + } + + private static ApTitleConstraint InvalidApTitle(string text) + => new( + SclAssociationFieldState.Invalid, + null, + new SclAssociationFieldEvidence + { + Name = "OSI-AP-Title", + State = SclAssociationFieldState.Invalid, + DeclaredText = text + }); + + private static AeQualifierConstraint ParseAeQualifier( + SclMmsAccessPoint remote, + ICollection errors) + { + const string name = "OSI-AE-Qualifier"; + if (remote.AmbiguousParameters.Contains(name)) + { + errors.Add("SCL ConnectedAP declares conflicting duplicate OSI-AE-Qualifier values."); + return new AeQualifierConstraint( + SclAssociationFieldState.Ambiguous, + null, + new SclAssociationFieldEvidence { Name = name, State = SclAssociationFieldState.Ambiguous }); + } + + var text = remote.Association.AeQualifierText?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + return new AeQualifierConstraint( + SclAssociationFieldState.Unspecified, + null, + new SclAssociationFieldEvidence { Name = name, State = SclAssociationFieldState.Unspecified }); + } + + if (!remote.Association.AeQualifier.HasValue || + remote.Association.AeQualifier.Value is < 0 or > ushort.MaxValue) + { + errors.Add($"SCL OSI-AE-Qualifier '{text}' is invalid; expected 0..65535."); + return new AeQualifierConstraint( + SclAssociationFieldState.Invalid, + null, + new SclAssociationFieldEvidence + { + Name = name, + State = SclAssociationFieldState.Invalid, + DeclaredText = text + }); + } + + return new AeQualifierConstraint( + SclAssociationFieldState.PresentValid, + remote.Association.AeQualifier.Value, + new SclAssociationFieldEvidence + { + Name = name, + State = SclAssociationFieldState.PresentValid, + DeclaredText = text + }); + } + + private static string ToHex(byte[] bytes) + => bytes.Length == 0 ? string.Empty : Convert.ToHexString(bytes); + + private sealed record SelectorConstraint( + SclAssociationFieldState State, + byte[]? Value, + SclAssociationFieldEvidence Evidence); + + private sealed record ApTitleConstraint( + SclAssociationFieldState State, + uint[]? Value, + SclAssociationFieldEvidence Evidence); + + private sealed record AeQualifierConstraint( + SclAssociationFieldState State, + int? Value, + SclAssociationFieldEvidence Evidence); +} diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs index 697ad1ee..0643b4b9 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs @@ -226,6 +226,10 @@ public sealed class SclAssistedMmsOnlineResult public bool AssociationSucceeded { get; init; } public bool DomainInventorySucceeded { get; init; } public bool SessionRemainsOpen { get; init; } + public string SelectedAssociationCandidateName { get; init; } = string.Empty; + public SclAssociationCandidateSource? SelectedAssociationCandidateSource { get; init; } + public int AssociationAttemptCount { get; init; } + public IReadOnlyList AssociationResolutionNotes { get; init; } = Array.Empty(); public SclMmsDomainReconciliation? Domains { get; init; } public string Message { get; init; } = string.Empty; public bool IsCompatible => Status == SclAssistedMmsOnlineStatus.Compatible; diff --git a/src/AR.Iec61850/Scl/SclMmsAssociationModels.cs b/src/AR.Iec61850/Scl/SclMmsAssociationModels.cs index cfca0517..2b6e12a9 100644 --- a/src/AR.Iec61850/Scl/SclMmsAssociationModels.cs +++ b/src/AR.Iec61850/Scl/SclMmsAssociationModels.cs @@ -22,6 +22,14 @@ public sealed class SclMmsAccessPoint public IReadOnlyDictionary Parameters { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// + /// Critical Address/P parameter names that were declared more than once with + /// conflicting values. Ambiguity is distinct from omission: interoperability + /// resolution may fill only omitted values and must fail closed on ambiguity. + /// + public IReadOnlySet AmbiguousParameters { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); + public bool HasNetworkEndpoint => !string.IsNullOrWhiteSpace(Endpoint.IpAddress); } diff --git a/src/AR.Iec61850/Scl/SclMmsAssociationProfileReader.cs b/src/AR.Iec61850/Scl/SclMmsAssociationProfileReader.cs index 0ff0d909..404856c7 100644 --- a/src/AR.Iec61850/Scl/SclMmsAssociationProfileReader.cs +++ b/src/AR.Iec61850/Scl/SclMmsAssociationProfileReader.cs @@ -48,7 +48,12 @@ public static SclMmsAssociationProfileSet Read(XDocument document) var iedName = Attr(connectedAp, "iedName"); var accessPointName = Attr(connectedAp, "apName"); var directAddress = connectedAp.Elements().FirstOrDefault(e => Is(e, "Address")); - var parameters = ReadDirectParameters(directAddress, iedName, accessPointName, warnings); + var parameters = ReadDirectParameters( + directAddress, + iedName, + accessPointName, + warnings, + out var ambiguousParameters); string Get(string type) => parameters.TryGetValue(type, out var value) ? value : string.Empty; @@ -94,7 +99,8 @@ string Get(string type) SessionSelector = Get("OSI-SSEL"), TransportSelector = Get("OSI-TSEL") }, - Parameters = parameters + Parameters = parameters, + AmbiguousParameters = ambiguousParameters }); } } @@ -110,11 +116,16 @@ private static IReadOnlyDictionary ReadDirectParameters( XElement? address, string iedName, string accessPointName, - ICollection warnings) + ICollection warnings, + out IReadOnlySet ambiguousParameters) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); if (address is null) + { + ambiguousParameters = ambiguous; return result; + } var groups = address.Elements() .Where(e => Is(e, "P")) @@ -135,6 +146,7 @@ private static IReadOnlyDictionary ReadDirectParameters( if (CriticalAssociationParameters.Contains(group.Key) && values.Length > 1) { result[group.Key] = string.Empty; + ambiguous.Add(group.Key); warnings.Add( $"ConnectedAP {Describe(iedName, accessPointName)} has conflicting duplicate {group.Key} values; the association parameter is ambiguous and was left unresolved."); continue; @@ -148,6 +160,7 @@ private static IReadOnlyDictionary ReadDirectParameters( } } + ambiguousParameters = ambiguous; return result; } diff --git a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs new file mode 100644 index 00000000..bdd61755 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs @@ -0,0 +1,181 @@ +using AR.Iec61850.Acse; +using AR.Iec61850.Osi; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class SclAssistedMmsAssociationResolutionTests +{ + [Fact] + public void Resolve_Missing_ApTitle_And_AeQualifier_Produces_Bounded_Compatibility_Candidates() + { + var remote = BuildRemote( + tsel: "0001", + ssel: "0001", + psel: "00000001", + apTitle: string.Empty, + aeQualifierText: string.Empty, + aeQualifier: null); + + var result = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, + MmsLocalAssociationProfile.ExistingRuntimeDefault); + + Assert.True(result.IsSuccess, string.Join(" | ", result.Errors)); + Assert.InRange(result.Candidates.Count, 1, SclAssistedMmsAssociationCandidateResolver.MaximumCandidateCount); + Assert.Contains(result.Fields, field => + field.Name == "OSI-AP-Title" && + field.State == SclAssociationFieldState.Unspecified); + Assert.Contains(result.Fields, field => + field.Name == "OSI-AE-Qualifier" && + field.State == SclAssociationFieldState.Unspecified); + Assert.Contains(result.Candidates, candidate => + candidate.AssociationProfile.Name == "BalancedApTitle" || + candidate.Name.Contains("BalancedApTitle", StringComparison.Ordinal)); + + foreach (var candidate in result.Candidates) + { + Assert.Equal(new byte[] { 0x00, 0x01 }, candidate.Cotp.DestinationTsap); + } + } + + [Fact] + public void Resolve_Complete_Scl_Keeps_Exact_Candidate_First_And_Golden_Bytes() + { + var remote = BuildRemote( + tsel: "0001", + ssel: "0001", + psel: "00000001", + apTitle: "1,1,1,999,1", + aeQualifierText: "12", + aeQualifier: 12); + + var exact = SclAssistedMmsAssociationPlanBuilder.BuildExact( + remote, + MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.True(exact.IsSuccess, string.Join(" | ", exact.Errors)); + + var result = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, + MmsLocalAssociationProfile.ExistingRuntimeDefault); + + Assert.True(result.IsSuccess, string.Join(" | ", result.Errors)); + var first = Assert.IsType(result.Candidates[0]); + Assert.Equal(SclAssociationCandidateSource.ExactScl, first.Source); + Assert.Equal(exact.Plan!.CotpConnectRequest, CotpConnectRequest.Build(first.Cotp)); + Assert.Equal(exact.Plan.SessionPresentationAcseMmsRequest, first.AssociationProfile.Payload); + } + + [Fact] + public void Resolve_Missing_Application_Identity_Preserves_Explicit_NonDefault_Selectors() + { + var remote = BuildRemote( + tsel: "0002", + ssel: "0003", + psel: "00000004", + apTitle: string.Empty, + aeQualifierText: string.Empty, + aeQualifier: null); + + var result = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, + MmsLocalAssociationProfile.ExistingRuntimeDefault); + + Assert.True(result.IsSuccess, string.Join(" | ", result.Errors)); + var typed = Assert.Single( + result.Candidates, + candidate => + candidate.Source == SclAssociationCandidateSource.EngineProfileTemplate && + candidate.Name.Contains("ExistingRuntimeDefault", StringComparison.Ordinal)); + + Assert.Equal(new byte[] { 0x00, 0x02 }, typed.Cotp.DestinationTsap); + var wire = AcseAssociationRequestIdentityReader.Read( + typed.AssociationProfile.Payload, + typed.Cotp.DestinationTsap); + Assert.Equal(new byte[] { 0x00, 0x03 }, wire.CalledSessionSelector); + Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x04 }, wire.CalledPresentationSelector); + Assert.True(wire.HasQualifiedCalledApplicationIdentity); + } + + [Fact] + public void Resolve_Invalid_Explicit_ApTitle_Fails_Closed() + { + var remote = BuildRemote( + tsel: "0001", + ssel: "0001", + psel: "00000001", + apTitle: "not-an-oid", + aeQualifierText: string.Empty, + aeQualifier: null); + + var result = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, + MmsLocalAssociationProfile.ExistingRuntimeDefault); + + Assert.False(result.IsSuccess); + Assert.Empty(result.Candidates); + Assert.Contains(result.Fields, field => + field.Name == "OSI-AP-Title" && + field.State == SclAssociationFieldState.Invalid); + } + + [Fact] + public void Resolve_Conflicting_Duplicate_Critical_Field_Fails_Closed() + { + const string xml = """ + + + + +
+

192.0.2.10

+

0001

+

0001

+

00000001

+

1,1,1,999,1

+

1,1,1,999,2

+
+
+
+
+
+ """; + + var parsed = SclMmsAssociationProfileReader.Read(xml); + var remote = Assert.Single(parsed.AccessPoints); + Assert.Contains("OSI-AP-Title", remote.AmbiguousParameters); + + var result = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, + MmsLocalAssociationProfile.ExistingRuntimeDefault); + + Assert.False(result.IsSuccess); + Assert.Empty(result.Candidates); + Assert.Contains(result.Fields, field => + field.Name == "OSI-AP-Title" && + field.State == SclAssociationFieldState.Ambiguous); + } + + private static SclMmsAccessPoint BuildRemote( + string tsel, + string ssel, + string psel, + string apTitle, + string aeQualifierText, + int? aeQualifier) + => new() + { + IedName = "IED01", + AccessPointName = "AP1", + Endpoint = new SclMmsEndpoint { IpAddress = "192.0.2.10" }, + Association = new SclIsoAssociationAddress + { + TransportSelector = tsel, + SessionSelector = ssel, + PresentationSelector = psel, + ApTitle = apTitle, + AeQualifierText = aeQualifierText, + AeQualifier = aeQualifier + } + }; +}