diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a1089e..2895c155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Control/report isolation trial — 2026-10-20 + +- Reject non-RCB InformationReport shapes before persistent single-monitor fallback; control termination and LastApplError remain available to independent control subscribers. +- Include monotonic service duration and post-response wait in each control wire-step detail. UTC marks local service start, not wire send. This separates SBOw/Operate service latency from asynchronous completion wait without changing timeout, checks, or command sequencing. +- Synthetic regression coverage added; physical latency improvement is not yet established. + All notable public changes to ARIEC61850 are recorded here. The project is still evolving toward stable semantic releases; entries are grouped by engineering milestone until a formal versioning policy is adopted. ## Unreleased — public wording, provenance and release assurance diff --git a/docs/P3_SAMPLED_VALUES_ANALYSIS.md b/docs/P3_SAMPLED_VALUES_ANALYSIS.md new file mode 100644 index 00000000..dfb5de2e --- /dev/null +++ b/docs/P3_SAMPLED_VALUES_ANALYSIS.md @@ -0,0 +1,139 @@ +# P3 — Sampled Values Analysis + +P3 begins the `ROADMAP.md` Sampled Values analysis priority without replacing the established IEC 61850-9-2 parser, payload decoder, SCL binding, or publisher code. + +## Engine boundary + +P3 now has a sustained, bounded stream-analysis layer plus a source-neutral observation session after successful SV decoding: + +```text +Live capture / PCAP replay + | + v +existing SV frame parser + | + v +SampledValuesFrame + | + v +SvSustainedObservationSession + | + +--> existing SvStreamObservationManager + | `--> profile / SCL comparison + | + +--> SvSustainedStreamAnalyzer + | +--> bounded stream registry + | +--> smpCnt continuity / missing-sample evidence + | +--> arrival-rate / jitter / dropout evidence + | +--> payload-length stability + | `--> smpSynch observations + | + `--> optional explicit SCL profile + `--> existing SampledValuesPayloadDecoder + `--> SvSclBoundMeasurementProjector + `--> SvEngineeringWindowAnalyzer + `--> SvSignalWindowAnalyzer +``` + +`SvSustainedObservationSession` is deliberately downstream of the existing frame parser. It accepts an already parsed `SampledValuesFrame`, sends the frame through `SvStreamObservationManager` first, and advances sustained state only when that canonical observation path accepts the frame. Live capture and PCAP replay therefore share the same parsed-frame admission and stream-identity path. The session does not add a raw capture parser, a second SV decoder, or a second channel model. + +`SvSustainedStreamAnalyzer` retains incremental counters and timing statistics rather than packet payload history. Stream cardinality is bounded, idle streams are evicted, and the oldest stream is evicted when the configured registry capacity is reached. The default registry limit is 256 streams. + +The stream identity reuses the existing `SvObservedStreamKey` (`APPID`, source/destination MAC, VLAN, `svID`, and DataSet reference), so this phase does not introduce a second stream-identity model. + +## Source provenance + +`SvObservationInputKind` remains the provenance marker for the established observation path (`LiveCapture` or `PcapReplay`). The new session preserves that marker while making the sustained metrics source-neutral: equivalent parsed frames with equivalent timestamps produce equivalent sustained evidence regardless of whether the caller obtained them from live capture or PCAP replay. + +This is an integration boundary, not a claim that the repository now contains every live-capture or PCAP-file reader required by a production application. Capture-specific byte acquisition remains outside this analyzer and must feed the established SV parser before entering the session. + +## Continuity evidence + +Sample-counter continuity reuses `SvSampleCounterTracker`. P3 records continuous transitions, normal wraps, forward gaps, missing-sample counts, duplicates, and out-of-order transitions. A caller may supply a trusted/configured counter modulus; otherwise the existing tracker remains profile-neutral. + +A sample-counter gap means samples were not observed by this analyzer. It is evidence of an observation gap, not by itself proof of physical-network packet loss. + +The engineering-window layer reuses the same tracker rather than implementing a second counter algorithm. An incomplete one-cycle engineering window is discarded on a gap, duplicate, out-of-order transition, restart, channel-metadata change, or timebase/evidence change. A gap/out-of-order/restart sample may begin a new clean window; a duplicate is not counted as a new time sample. + +## Timing evidence and claim boundary + +P3 records mean frame interval, observed frame rate, estimated sample rate, arrival-interval standard deviation, maximum observed arrival jitter, and large arrival intervals. + +Timestamp provenance is explicit: + +- `HostSoftwareTimestamp` is ordinary host/capture-arrival evidence only. +- `HardwareTimestamp` records that the caller supplied hardware timestamp evidence, but does **not** by itself prove clock accuracy, PTP synchronization quality, or IEC 61850 process-bus timing performance. +- `Unknown` makes no stronger timing claim. + +Applications must carry this claim boundary forward. A Windows or other ordinary host timestamp must never be relabeled as precision synchronization evidence. + +## Synchronization evidence + +The analyzer counts observed `smpSynch` values (0, 1, 2, and other values) exactly as decoded. This is wire evidence. P3 does not infer clock quality, grandmaster identity, or station synchronization health solely from these counts. + +## Payload-layout evidence + +For a stable stream identity, P3 records the first ASDU payload length and marks the stream when later ASDUs change length. This complements the existing SCL-aware payload decoder and configuration comparer; it does not guess signal semantics from raw word positions. + +When no explicit `SampledValuesPublisherProfile` is supplied, `SamplePayload` remains opaque to the observation session. When a profile is supplied, `SvSclBoundMeasurementProjector` reuses that profile's existing `SampledValuesPayloadLayout` and `SampledValuesPayloadDecoder`; it does not infer a new layout from packet shape. + +## Evidence-backed channel projection + +`SvSclBoundMeasurementProjector` provides the conservative bridge from an explicitly bound SCL publisher profile into numeric channel samples. Before decoding it requires exact APPID, destination MAC, VLAN, `svID`, DataSet reference, and `confRev` compatibility. Unsupported SCL layout elements or a payload-length mismatch fail closed rather than shifting offsets and continuing with guessed semantics. + +Numeric values always retain their decoded raw value. Engineering values are emitted only when the existing `SvEngineeringScaleResolver` has sufficient SCL-backed evidence. The currently supported installed-base scaling rule additionally requires the exact fixed eight-value/eight-quality, 64-byte layout, four SCL-derived current channels, four SCL-derived voltage channels, and protection-rate evidence. Other valid SCL layouts remain raw-only until a separate explicit scale rule exists. + +CT/VT conversion is also explicit. An optional `SvStreamMeasurementContext` is accepted only when its validated stream key and optional `svID` match the observed stream. Primary/secondary-equivalent values are then produced through `SvMeasurementDomainResolver`; a missing, invalid, or mismatched context never causes a ratio to be guessed. + +## Bounded engineering windows, RMS, and phasor + +`SvEngineeringWindowAnalyzer` is the bridge from successfully SCL-bound/scaled projected samples into the existing `SvSignalWindowAnalyzer`. It never decodes payload bytes and never manufactures engineering units. Samples whose projection remains raw-only are intentionally excluded from engineering RMS/phasor state. + +The caller must provide `SvEngineeringWindowEvidence` containing a finite positive sample rate and fundamental frequency plus their provenance. `Unknown` and `ProfileInferred` provenance is rejected for these values, so the analyzer never chooses 50 Hz versus 60 Hz itself. The ratio `sampleRateHz / fundamentalFrequencyHz` must resolve to an integral one-cycle sample count within the configured tolerance and bounded maximum before any state is created. + +State is bounded at three dimensions: maximum streams, maximum channels per stream, and maximum samples per cycle. The default configuration is 256 streams × 32 channels × 512 samples/cycle, with an additional validation guard that rejects option combinations whose theoretical retained `double` sample buffers could exceed 64 MiB. The analyzer stores only the current incomplete cycle for each active channel; completed cycle history is not retained internally. + +Each channel is keyed by the SCL projection element index and guarded by exact signal reference, CDC, engineering unit, scale source, and scale confidence. A metadata change resets the partial waveform and counter state before accepting the new identity. Timebase, nominal-frequency, or counter-wrap evidence changes likewise reset partial state across that stream. + +`SvSignalWindowAnalyzer` remains the deterministic one-cycle-window primitive. It receives exactly one complete, continuity-clean engineering window and returns: + +- true window RMS; +- fundamental RMS magnitude; +- fundamental phase angle; and +- sample count. + +No frequency, channel meaning, CT/VT ratio, engineering unit, or missing sample is guessed by either layer. + +## Deterministic regression coverage + +Synthetic tests cover: + +- sustained 4 kHz stream-rate estimation; +- continuous sample counters and synchronization observations; +- sample-counter gaps and missing-sample totals; +- arrival-time dropout diagnostics with explicit non-network-loss wording; +- bounded stream-registry eviction; +- payload-length changes inside one stream identity; +- host-vs-hardware timestamp claim boundaries; +- RMS/fundamental phasor accuracy for a deterministic sine wave; +- one-ingress coordination of observation and sustained snapshots; +- equivalent sustained metrics for equivalent live-capture and PCAP-replay parsed frames; +- fail-closed behavior when the canonical observation path rejects an empty-payload frame; +- a 10,000-frame synthetic ingestion regression that keeps the sustained registry at its configured stream bound; +- fixed SCL-bound current/voltage projection using the existing payload decoder; +- explicit primary-to-secondary CT/VT display conversion; +- raw-only preservation for SCL layouts without an approved engineering-scale rule; +- fail-closed APPID and `confRev` mismatch handling before payload projection; +- one-cycle engineering RMS/phasor from explicitly scaled samples; +- raw-only exclusion from engineering-window state; +- partial-window reset after `smpCnt` gap and duplicate evidence; +- normal counter-wrap continuity; +- channel-metadata change reset; +- fail-closed non-integral samples-per-cycle and inferred-frequency input; and +- bounded engineering stream/channel registries. + +These are unit-test and synthetic-fixture claims only. This phase does not claim production timing accuracy, formal conformance, universal device interoperability, or operational-substation validation. + +## Remaining P3 work + +The per-channel engineering-window slice is now implemented, but the roadmap priority is not complete. Follow-up work should connect application live-capture and PCAP replay byte sources to the established parser plus `SvSustainedObservationSession`, carry explicit timebase/frequency evidence into `SvEngineeringWindowAnalyzer`, add sanitized real/replay validation fixtures and longer bounded soak evidence, and expose evidence/export contracts without retaining customer or live-network identifiers. diff --git a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs index 87f4a9ea..5c70f4f0 100644 --- a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs +++ b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Threading.Channels; using AR.Iec61850.Asn1; using AR.Iec61850.Mms; @@ -192,6 +193,17 @@ public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDi private readonly Dictionary _integrityTimers = new(StringComparer.OrdinalIgnoreCase); private readonly object _gate = new(); private readonly CancellationTokenSource _cts = new(); + // One FIFO per MMS association. A distinct Task.Run per report previously + // allowed send delegates to acquire the socket lock in a different order + // from the SqNum assignment, manufacturing on-wire discontinuities. + private readonly Channel<(MmsRcbRuntimeState State, byte Reason)> _sendQueue = + Channel.CreateUnbounded<(MmsRcbRuntimeState, byte)>( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + }); private bool _disposed; public MmsAssociationReportingRuntime( @@ -226,6 +238,12 @@ public MmsAssociationReportingRuntime( IntgPd = (uint)Math.Max(0, rcb.IntegrityPeriodMs) }; } + + // One asynchronous worker is allocated per association, not per report. + // Capture the token here: a delayed worker must never access a CTS + // after Dispose() has disposed its source. + var senderToken = _cts.Token; + _ = Task.Run(() => DrainReportQueueAsync(senderToken)); } public IReadOnlyCollection States => _states.Values; @@ -414,6 +432,7 @@ public void Dispose() return; _disposed = true; + _sendQueue.Writer.TryComplete(); _cts.Cancel(); lock (_gate) { @@ -449,21 +468,39 @@ private void QueueReport(MmsRcbRuntimeState state, byte reason) if (_disposed) return; - _ = Task.Run(async () => + // Channel FIFO is the report ordering authority. No per-report Task.Run, + // no competing asynchronous socket writers and no silent report drop. + _sendQueue.Writer.TryWrite((state, reason)); + } + + private async Task DrainReportQueueAsync(CancellationToken cancellationToken) + { + try { - try - { - await SendReportAsync(state, reason, _cts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Association closing. - } - catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException) + await foreach (var request in _sendQueue.Reader.ReadAllAsync(cancellationToken) + .ConfigureAwait(false)) { - _activity?.Invoke(state.MmsReference, false, $"InformationReport send failed: {ex.Message}"); + try + { + await SendReportAsync(request.State, request.Reason, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or + System.Net.Sockets.SocketException or InvalidOperationException) + { + _activity?.Invoke(request.State.MmsReference, false, + $"InformationReport send failed: {ex.Message}"); + } } - }, CancellationToken.None); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Association closing: pending reports are not sent on a dead socket. + } } private async Task SendReportAsync(MmsRcbRuntimeState state, byte reason, CancellationToken cancellationToken) diff --git a/src/AR.Iec61850/Control/Iec61850ControlModels.cs b/src/AR.Iec61850/Control/Iec61850ControlModels.cs index 7396a139..4459ada6 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlModels.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlModels.cs @@ -162,6 +162,12 @@ public sealed class Iec61850ControlObjectDescriptor public MmsTypeSpecificationNode? CancelSpecification { get; init; } public string StatusReference { get; init; } = string.Empty; public string StatusFunctionalConstraint { get; init; } = string.Empty; + /// + /// Live MMS Read proved a two-bit stVal Dbpos on the SAME control DataObject. + /// This typed evidence, not an LN/DO name, can distinguish Boolean DPC + /// Oper.ctlVal from a Boolean SPC control. + /// + public bool LiveStatusIsDoublePoint { get; init; } public TimeSpan? SboTimeout { get; init; } public TimeSpan? OperTimeout { get; init; } public bool SupportsTimeActivatedOperate { get; init; } @@ -174,7 +180,9 @@ public sealed class Iec61850ControlObjectDescriptor ControlModel is not Iec61850ControlModel.StatusOnly and not Iec61850ControlModel.Unknown && !string.IsNullOrWhiteSpace(ObjectReference) && !string.IsNullOrWhiteSpace(CtlValSpecification.MmsType) && - (!RequiresSelect || CancelSpecification != null) && + // Normal SBO Select -> Operate does not depend on optional Cancel. + // Enhanced SBO still requires Cancel for sequence recovery. + (ControlModel != Iec61850ControlModel.SelectBeforeOperateEnhanced || CancelSpecification != null) && (ControlModel != Iec61850ControlModel.SelectBeforeOperateEnhanced || SelectWithValueSpecification != null) && (!IsEnhanced || SupportsCommandTermination); diff --git a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs index 9550388e..55f911f3 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs @@ -27,6 +27,11 @@ private sealed class AssociationControlState private CancellationTokenSource? _selectionLeaseCts; private bool _ownsLock; private bool _disposed; + private DateTimeOffset _serviceStartedUtc; + private TimeSpan _serviceElapsed; + private long _serviceCompletedTimestamp; + private MmsWriteResult? _lastMeasuredWrite; + internal Iec61850ControlObjectSession( IIec61850ControlTransport transport, @@ -115,7 +120,7 @@ private async Task SelectCoreAsync( try { var context = CreateContext(request); - var read = await _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken).ConfigureAwait(false); + var read = await MeasureServiceAsync(() => _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken)).ConfigureAwait(false); if (!read.IsSuccess || !IsPositiveSboSelection(read.Value, Descriptor.References)) { ReleaseObject(); @@ -172,7 +177,7 @@ private async Task SelectWithValueCoreAsync( context, Descriptor.SelectWithValueSpecification, _options.RequireExactNamedControlFields); - var write = await _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken).ConfigureAwait(false); + var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken)).ConfigureAwait(false); if (!write.IsSuccess) { var appError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false); @@ -289,7 +294,7 @@ private async Task OperateCoreAsync( context, Descriptor.OperSpecification, _options.RequireExactNamedControlFields); - var write = await _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token).ConfigureAwait(false); + var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token)).ConfigureAwait(false); if (!write.IsSuccess) { var applicationError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false); @@ -432,7 +437,7 @@ private async Task CancelCoreAsync(CancellationToke return Unsupported(Iec61850ControlAction.Cancel, "IED did not expose a decodable Cancel type specification."); var value = Iec61850ControlStructureBuilder.BuildCancel(context, Descriptor.CancelSpecification, _options.RequireExactNamedControlFields); - var write = await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false); + var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false); return write.IsSuccess ? Accepted(Iec61850ControlAction.Cancel, context, stopwatch.Elapsed, "Cancel accepted; local SBO ownership released.") : FromWriteFailure(Iec61850ControlAction.Cancel, write, null, context, stopwatch.Elapsed); @@ -691,7 +696,7 @@ private async Task BestEffortCancelAsync(CancellationToken cancellationToken) context, Descriptor.CancelSpecification, _options.RequireExactNamedControlFields); - await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false); + await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false); } } catch @@ -782,6 +787,29 @@ private Iec61850ControlActionResult FromApplicationRejection( BuildWireStep(action, requestAccepted: true, clientMessage)) }; + // Serialized by _operationGate. These are local service boundaries, not + // packet capture timestamps; elapsed measurement uses the monotonic clock. + private async Task MeasureServiceAsync(Func> service) + { + _serviceStartedUtc = DateTimeOffset.UtcNow; + var started = Stopwatch.GetTimestamp(); + _lastMeasuredWrite = null; + try + { + var result = await service().ConfigureAwait(false); + // Only the exact MMS write result which produced this step can + // supply transport timing. Never borrow another invoke ID's stats. + if (result is MmsWriteResult write) + _lastMeasuredWrite = write; + return result; + } + finally + { + _serviceElapsed = Stopwatch.GetElapsedTime(started); + _serviceCompletedTimestamp = Stopwatch.GetTimestamp(); + } + } + private Iec61850ControlWireStep BuildWireStep( Iec61850ControlAction action, bool requestAccepted, @@ -796,6 +824,25 @@ private Iec61850ControlWireStep BuildWireStep( _ => Descriptor.References.Oper }; + // Distinguish protocol service response time from the selection + // negative-indication observation gate. The latter dominates the + // field sample (~400 ms); it must not be misreported as slow MMS I/O. + // This is telemetry only: do not reduce the grace period or bypass + // LastApplError and enhanced-security CommandTermination. + var postResponseStage = action switch + { + Iec61850ControlAction.SelectWithValue => "sbo-application-error-observation", + Iec61850ControlAction.Operate => "operate-termination-observation", + _ => "post-service-completion" + }; + var graceBudget = action == Iec61850ControlAction.SelectWithValue + ? _options.ApplicationErrorGracePeriod.TotalMilliseconds : 0d; + var transportStages = _lastMeasuredWrite is + { TpktSendElapsed: { } send, ConfirmedResponseWaitElapsed: { } response, + ResponseDecodeElapsed: { } decode } + ? FormattableString.Invariant( + $"tpktSendMs={send.TotalMilliseconds:0.###}; confirmedResponseWaitMs={response.TotalMilliseconds:0.###}; mmsDecodeMs={decode.TotalMilliseconds:0.###}") + : "transportStages=unavailable"; return new Iec61850ControlWireStep { Action = action, @@ -803,7 +850,8 @@ private Iec61850ControlWireStep BuildWireStep( RequestAccepted = requestAccepted, RequestHex = _transport.LastRequestHex, ResponseHex = _transport.LastResponseHex, - Detail = detail + Detail = FormattableString.Invariant( + $"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; postResponseStage={postResponseStage}; configuredApplicationErrorGraceMs={graceBudget:0.###}; {transportStages}; timingScope=local-service-not-wire") }; } diff --git a/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs new file mode 100644 index 00000000..9f787ad6 --- /dev/null +++ b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs @@ -0,0 +1,128 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Control; + +/// +/// Resolves *command* semantics from a declared CDC and corroborating live MMS +/// evidence. A primitive MMS type is an encoding, not permission to Operate. +/// This is vendor-independent and never sends an MMS request. +/// +public static class Iec61850ControlSemanticResolver +{ + public static Iec61850ControlSemanticResolution Resolve( + Iec61850ControlObjectDescriptor descriptor, + string? declaredCdc, + string? declaredStatusReference = null) + { + ArgumentNullException.ThrowIfNull(descriptor); + var declared = (declaredCdc ?? string.Empty).Trim().ToUpperInvariant(); + var inferred = (descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant(); + var status = descriptor.StatusReference ?? string.Empty; + var mmsType = (descriptor.CtlValSpecification.MmsType ?? string.Empty).Trim().ToLowerInvariant(); + var size = descriptor.CtlValSpecification.Size; + // A saved SCL/DataSet SPC can describe a process projection, not the + // command payload. Only an EXACT same-object ST step status plus a + // typed two-bit MMS ctlVal establishes live directional BSC authority. + // A bit-string alone (or a similarly named object) proves nothing. + var exactLiveBsc = + inferred == "BSC" && + mmsType == "bit-string" && size == 2 && + status.Equals(descriptor.ObjectReference + ".valWTr.posVal", + StringComparison.OrdinalIgnoreCase); + var liveBooleanDpc = inferred == "DPC" && + mmsType == "boolean" && descriptor.LiveStatusIsDoublePoint && + status.Equals(descriptor.ObjectReference + ".stVal", StringComparison.OrdinalIgnoreCase); + var supersededAdvisorySpc = declared == "SPC" && exactLiveBsc; + var supersededAdvisoryDpcSpc = declared == "SPC" && liveBooleanDpc; + var cdc = supersededAdvisorySpc + ? "BSC" + : supersededAdvisoryDpcSpc ? "DPC" + : string.IsNullOrWhiteSpace(declared) ? inferred : declared; + + Iec61850ControlSemanticResolution Deny(string reason) + => new(false, cdc, reason); + + if (!descriptor.IsOperationallyReady) + return Deny("The live control descriptor or ctlModel is not operationally ready."); + if (string.IsNullOrWhiteSpace(status)) + return Deny("No live status reference corroborates the control semantics."); + + // A stale persisted feedback reference must not silently override a + // different live BSC status object when commands are involved. + if (cdc == "BSC" && !string.IsNullOrWhiteSpace(declaredStatusReference) && + !string.Equals(declaredStatusReference.Trim(), status, StringComparison.OrdinalIgnoreCase)) + return Deny("Declared BSC feedback reference disagrees with the live control descriptor."); + + if (cdc != inferred && + !(inferred == "INC/ISC" && cdc is "INC" or "ISC") && + !(cdc == "DPC" && inferred == "SPC" && + descriptor.LiveStatusIsDoublePoint && mmsType == "boolean")) + return Deny($"Declared CDC '{cdc}' conflicts with live CDC evidence '{inferred}'."); + + var statusIsStep = status.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase); + var statusIsState = status.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase); + var supported = cdc switch + { + "BSC" => statusIsStep && + (mmsType is "integer" or "unsigned" || + (mmsType == "bit-string" && size == 2)), + "DPC" => statusIsState && + ((mmsType == "bit-string" && size == 2) || + (mmsType == "boolean" && + descriptor.LiveStatusIsDoublePoint && + status.Equals(descriptor.ObjectReference + ".stVal", + StringComparison.OrdinalIgnoreCase))), + "SPC" => statusIsState && mmsType == "boolean", + "INC" or "ISC" => declared == cdc && statusIsState && + mmsType is "integer" or "unsigned" or "bcd", + "APC" => declared == "APC" && + (mmsType == "floating-point" || IsAnalogStructure(descriptor.CtlValSpecification)), + _ => false // Other CDCs require an independently audited typed codec. + }; + + if (!supported) + return Deny($"CDC '{cdc}' cannot be safely bound to live ctlVal '{descriptor.CtlValSpecification.Signature}' and status '{status}'."); + + var provenance = supersededAdvisorySpc + ? " Saved/DataSet CDC=SPC was advisory and superseded by exact live BSC control evidence." + : supersededAdvisoryDpcSpc + ? " Saved/DataSet CDC=SPC was advisory and superseded by live two-bit DPC status evidence." + : string.Empty; + return new(true, cdc, + $"Verified CDC={cdc}; live ctlModel={descriptor.ControlModel}; ctlVal={descriptor.CtlValSpecification.Signature}; status={status}.{provenance}"); + } + + internal static bool IsStandardPosition(string reference) + { + var suffix = reference[(reference.LastIndexOf('/') + 1)..]; + var dot = suffix.IndexOf('.'); + if (dot <= 0 || !suffix[(dot + 1)..].Equals("Pos", StringComparison.OrdinalIgnoreCase)) + return false; + var ln = suffix[..dot]; + // IEC 61850 LN identity is , not necessarily + // . Examples: Bkr0XCBR2, Disc0XSWI1, BkrCSWI4. + // Class is the final four letters before the REQUIRED instance digits, + // regardless of the vendor/operator-configured prefix. Never infer a + // position control from a substring or a DO name alone. + var instanceStart = ln.Length; + while (instanceStart > 0 && char.IsAsciiDigit(ln[instanceStart - 1])) + instanceStart--; + if (instanceStart == ln.Length || instanceStart < 4) + return false; + var classEnd = ln.AsSpan(0, instanceStart); + return classEnd.EndsWith("CSWI".AsSpan(), StringComparison.OrdinalIgnoreCase) || + classEnd.EndsWith("XCBR".AsSpan(), StringComparison.OrdinalIgnoreCase) || + classEnd.EndsWith("XSWI".AsSpan(), StringComparison.OrdinalIgnoreCase); + } + + private static bool IsAnalogStructure(MmsTypeSpecificationNode ctlVal) + { + if (!ctlVal.MmsType.Equals("structure", StringComparison.OrdinalIgnoreCase)) + return false; + var numeric = ctlVal.Children.Where(child => + child.MmsType is "integer" or "unsigned" or "floating-point").ToArray(); + return numeric.Length == 1 && ctlVal.Children.Count == 1; + } +} + +public sealed record Iec61850ControlSemanticResolution(bool IsCommandSafe, string Cdc, string Reason); diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index 30b4051f..03d96b92 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -41,6 +41,128 @@ public Task OpenAsync( cancellationToken); } + /// + /// Read-only exact CF ctlModel probe. An unsuccessful read, unknown encoding, + /// and an explicitly reported StatusOnly model are distinct outcomes. + /// Never discovers variable directories or writes any control service. + /// + public Task ProbeControlModelAsync( + MmsClientSession session, + string objectReference, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + return ProbeControlModelCoreAsync(new MmsClientControlTransport(session), objectReference, cancellationToken); + } + + internal async Task ProbeControlModelCoreAsync( + IIec61850ControlTransport transport, + string objectReference, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(transport); + if (!transport.IsAssociated) + throw new InvalidOperationException("IEC 61850 ctlModel probe requires an active MMS association."); + + var references = Iec61850ControlObjectReferences.Parse(objectReference); + var read = await transport.ReadAsync(references.CtlModel, cancellationToken).ConfigureAwait(false); + var kind = read.Value?.Kind.ToString() ?? "Unavailable"; + var numeric = read.Value?.Kind is MmsDataKind.Integer or MmsDataKind.Unsigned + ? Convert.ToString(read.Value.Value, System.Globalization.CultureInfo.InvariantCulture) ?? "" + : ""; + var model = read.IsSuccess && read.Value != null + ? DecodeControlModel(read.Value) + : Iec61850ControlModel.Unknown; + var decoded = read.IsSuccess && read.Value != null && model != Iec61850ControlModel.Unknown; + var detail = !read.IsSuccess || read.Value == null + ? $"MMS Read failed/unavailable: {read.Message}" + : !decoded + ? $"MMS Read returned unsupported ctlModel encoding/value ({kind}, {numeric})." + : $"Exact MMS Read returned ctlModel={numeric} ({model})."; + return new Iec61850ControlModelProbe( + references.ObjectReference, + references.CtlModel.ToString(), + read.IsSuccess && read.Value != null, + decoded, + model, + kind, + numeric, + detail, + BoundedHex(transport.LastRequestHex), + BoundedHex(transport.LastResponseHex)); + } + + /// + /// Explicit commissioning-only ctlModel update. The caller must confirm + /// device authorization separately. One exact CF write at most; no fallback + /// sequence or implicit command Operate is attempted. + /// + public Task ConfigureControlModelAsync( + MmsClientSession session, string objectReference, + Iec61850ControlModel expectedLiveModel, Iec61850ControlModel requestedModel, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + return ConfigureControlModelCoreAsync( + new MmsClientControlTransport(session), objectReference, + expectedLiveModel, requestedModel, cancellationToken); + } + + internal async Task ConfigureControlModelCoreAsync( + IIec61850ControlTransport transport, string objectReference, + Iec61850ControlModel expectedLiveModel, Iec61850ControlModel requestedModel, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(transport); + if (!transport.IsAssociated) + throw new InvalidOperationException("Control model configuration requires an active MMS association."); + if (expectedLiveModel == Iec61850ControlModel.Unknown || + requestedModel is not (Iec61850ControlModel.StatusOnly or Iec61850ControlModel.DirectNormal or + Iec61850ControlModel.SelectBeforeOperateNormal or + Iec61850ControlModel.DirectEnhanced or + Iec61850ControlModel.SelectBeforeOperateEnhanced)) + throw new ArgumentOutOfRangeException(nameof(requestedModel), + "Only exact, known control models 0-4 can be commissioned."); + + var reference = Iec61850ControlObjectReferences.Parse(objectReference).CtlModel; + var before = await ProbeControlModelCoreAsync( + transport, objectReference, cancellationToken).ConfigureAwait(false); + if (!before.Decoded || before.ControlModel != expectedLiveModel) + return new Iec61850ControlModelConfigurationResult( + false, false, before.ControlModel, Iec61850ControlModel.Unknown, + reference.ToString(), + $"No Write: live ctlModel changed or could not be verified. Expected={expectedLiveModel}; {before.Detail}"); + + if (expectedLiveModel == requestedModel) + return new Iec61850ControlModelConfigurationResult( + false, true, before.ControlModel, before.ControlModel, + reference.ToString(), "No Write: requested ctlModel is already active."); + + // Deliberate one-shot configuration: no retries, no auto-rollback. + var write = await transport.WriteControlAsync( + reference, MmsDataValue.Integer((long)requestedModel), cancellationToken) + .ConfigureAwait(false); + var writeAccepted = write.IsSuccess && write.AccessResults.All(x => x.IsSuccess); + if (!writeAccepted) + return new Iec61850ControlModelConfigurationResult( + false, false, before.ControlModel, Iec61850ControlModel.Unknown, + reference.ToString(), + $"MMS ctlModel Write rejected; readback not assumed. {write.Message}"); + + var after = await ProbeControlModelCoreAsync( + transport, objectReference, cancellationToken).ConfigureAwait(false); + var verified = after.Decoded && after.ControlModel == requestedModel; + return new Iec61850ControlModelConfigurationResult( + true, verified, before.ControlModel, after.ControlModel, + reference.ToString(), + verified + ? $"ctlModel Write accepted and exact MMS readback confirmed {requestedModel}." + : $"Write accepted but readback is not verified: {after.Detail}. Do not Operate until reinspection."); + } + + private static string BoundedHex(string? hex) + => string.IsNullOrWhiteSpace(hex) ? "" : hex.Length <= 512 ? hex : hex[..512] + "… (truncated)"; + internal Task OpenCoreAsync( IIec61850ControlTransport transport, string objectReference, @@ -121,12 +243,48 @@ internal async Task OpenCoreAsync( _options.DefaultOperateTimeout, cancellationToken).ConfigureAwait(false); - var status = FindStatusReference(references, domainNames); + var isBooleanControl = ctlValSpecification.MmsType.Equals("boolean", StringComparison.OrdinalIgnoreCase); + var isStepShapedControl = + (ctlValSpecification.MmsType.Equals("bit-string", StringComparison.OrdinalIgnoreCase) && + ctlValSpecification.Size == 2) || + ctlValSpecification.MmsType is "integer" or "unsigned"; + var status = await FindStatusReferenceAsync(transport, references, domainNames, + isBooleanControl, isStepShapedControl, cancellationToken).ConfigureAwait(false); + var liveDoublePoint = status.TypedDoublePoint; + if (!status.TypedStatusRead && !liveDoublePoint && + ctlValSpecification.MmsType.Equals("boolean", StringComparison.OrdinalIgnoreCase) && + status.Reference.Equals(references.ObjectReference + ".stVal", + StringComparison.OrdinalIgnoreCase)) + { + // A standard DPC can use a Boolean Oper.ctlVal while returning + // two-bit coded stVal. Verify the actual wire shape, not a prefix, + // a DO called Pos, or a remembered SCL CDC. + var statusRead = await transport.ReadAsync( + MmsObjectReference.Parse(status.Reference, "ST"), + cancellationToken).ConfigureAwait(false); + var actual = statusRead.Value; + liveDoublePoint = statusRead.IsSuccess && actual != null && + actual.Kind == MmsDataKind.BitString && + actual.RawValue.Count == 2 && actual.RawValue[0] == 6; + evidence.Add(liveDoublePoint + ? "statusShape=live-two-bit-double-point" + : "statusShape=not-proven-two-bit-double-point"); + } + evidence.Add(string.IsNullOrWhiteSpace(status.Reference) + ? "status=not-verified" + : "status=live-confirmed:" + status.Reference); var supportsTimeActivated = operSpecification.Children.Any(x => NormalizeName(x.Name) == "opertm"); var descriptor = new Iec61850ControlObjectDescriptor { ObjectReference = references.ObjectReference, - Cdc = InferCdc(ctlValSpecification), + // The exact live status shape distinguishes BSC TapChg's Tcmd enum + // from DPC even when both ctlVal types have a two-bit MMS encoding. + // Never classify a step-position value as an Open/Close switch. + Cdc = status.Reference.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase) + ? "BSC" + : liveDoublePoint && ctlValSpecification.MmsType.Equals("boolean", StringComparison.OrdinalIgnoreCase) + ? "DPC" + : InferCdc(ctlValSpecification), ControlModel = controlModel, CtlValSpecification = ctlValSpecification, OperSpecification = operSpecification, @@ -134,6 +292,7 @@ internal async Task OpenCoreAsync( CancelSpecification = cancelSpecification, StatusReference = status.Reference, StatusFunctionalConstraint = status.FunctionalConstraint, + LiveStatusIsDoublePoint = liveDoublePoint, SboTimeout = sboTimeout, OperTimeout = operTimeout, SupportsTimeActivatedOperate = supportsTimeActivated, @@ -220,6 +379,7 @@ private static (string Reference, string FunctionalConstraint) FindStatusReferen { var prefixes = new[] { + $"{references.LogicalNode}$ST${references.MmsDataObjectPath}$valWTr$posVal", $"{references.LogicalNode}$ST${references.MmsDataObjectPath}$stVal", $"{references.LogicalNode}$ST${references.MmsDataObjectPath}$posVal", $"{references.LogicalNode}$MX${references.MmsDataObjectPath}$mag$f", @@ -239,6 +399,62 @@ private static (string Reference, string FunctionalConstraint) FindStatusReferen return ($"{references.Domain}/{parts[0]}.{string.Join(".", parts.Skip(2))}", parts[1]); } + // Some MMS NameLists omit ST scalar children or are truncated. A positive + // typed ctlModel and Boolean Oper.ctlVal already prove an actual control + // DataObject exists, regardless of arbitrary LN prefix/class/name. Allow + // exactly one additional *read-only* ST.stVal probe of that same DO. + // Only live two-bit evidence can authorize DPC; no name-only promotion. + private static async Task<(string Reference, string FunctionalConstraint, bool TypedDoublePoint, bool TypedStatusRead)> FindStatusReferenceAsync( + IIec61850ControlTransport transport, Iec61850ControlObjectReferences references, + IReadOnlyList names, bool booleanControl, bool stepControl, + CancellationToken cancellationToken) + { + var exact = FindStatusReference(references, names); + if (!string.IsNullOrWhiteSpace(exact.Reference)) + return (exact.Reference, exact.FunctionalConstraint, false, false); + var root = references.LogicalNode + "$ST$" + references.MmsDataObjectPath; + var hasListedStatusRoot = names.Any(name => + name.Equals(root, StringComparison.OrdinalIgnoreCase) || + name.StartsWith(root + "$", StringComparison.OrdinalIgnoreCase)); + var standardPosition = Iec61850ControlSemanticResolver.IsStandardPosition(references.ObjectReference); + + // A sparse NameList cannot hide a real renamed control. Boolean + // control structures may be SPC or DPC: only a typed ST read + // distinguishes them. Other unknown objects stay fail-closed. + if (!hasListedStatusRoot && !standardPosition && !booleanControl && !stepControl) + return (exact.Reference, exact.FunctionalConstraint, false, false); + + // Boolean controls have one ST.stVal candidate. A typed two-bit + // step command may be BSC (valWTr.posVal) or DPC (stVal): try these + // two exact leaves at most, never broaden to wildcard NameList reads. + var candidates = booleanControl || standardPosition + ? new[] { (Suffix: "$stVal", Path: ".stVal", IsStep: false) } + : new[] + { + (Suffix: "$valWTr$posVal", Path: ".valWTr.posVal", IsStep: true), + (Suffix: "$stVal", Path: ".stVal", IsStep: false) + }; + foreach (var candidate in candidates) + { + var item = new MmsObjectReference(references.Domain, root + candidate.Suffix, "ST"); + var read = await transport.ReadAsync(item, cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value == null) + continue; + var value = read.Value; + var isExactTwoBitStatus = value.Kind == MmsDataKind.BitString && + value.RawValue.Count == 2 && value.RawValue[0] == 6; + var valid = candidate.IsStep + ? value.Kind is MmsDataKind.Integer or MmsDataKind.Unsigned + : standardPosition && !booleanControl ? isExactTwoBitStatus : + isExactTwoBitStatus || value.Kind == MmsDataKind.Boolean; + if (valid) + return (references.Domain + "/" + references.LogicalNode + "." + + references.MmsDataObjectPath.Replace('$', '.') + candidate.Path, + "ST", isExactTwoBitStatus, true); + } + return (exact.Reference, exact.FunctionalConstraint, false, false); + } + private static string InferCdc(MmsTypeSpecificationNode ctlVal) { var type = ctlVal.MmsType.Trim().ToLowerInvariant(); @@ -258,3 +474,27 @@ private static string InferCdc(MmsTypeSpecificationNode ctlVal) private static string NormalizeName(string value) => new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); } + + +/// Read-only ctlModel evidence; not permission to send an MMS control write. +public sealed record Iec61850ControlModelProbe( + string ObjectReference, + string MmsReference, + bool ReadSucceeded, + bool Decoded, + Iec61850ControlModel ControlModel, + string RawKind, + string RawScalar, + string Detail, + string RequestHexPreview, + string ResponseHexPreview); + + +/// Configuration result is not permission to operate primary equipment. +public sealed record Iec61850ControlModelConfigurationResult( + bool WriteAccepted, + bool ReadbackVerified, + Iec61850ControlModel BeforeModel, + Iec61850ControlModel AfterModel, + string MmsReference, + string Detail); diff --git a/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs b/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs index f580a835..f84e3b05 100644 --- a/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs +++ b/src/AR.Iec61850/Discovery/CdcInferenceEngine.cs @@ -25,6 +25,62 @@ public static class CdcInferenceEngine public static bool IsKnownCdc(string cdc) => !string.IsNullOrWhiteSpace(cdc) && KnownCdcValues.Contains(cdc.Trim()); + /// + /// Refines only low/medium-confidence generic status inference when the MMS + /// TypeSpecification provides an exact primitive stVal type. Exact wire type is + /// stronger evidence than the generic ST/stVal/q/t shape; object names are never + /// used as a substitute for missing type evidence. + /// + public static CdcInferenceResult RefineWithExactPrimaryType( + CdcInferenceResult inferred, + IReadOnlyCollection attributes) + { + ArgumentNullException.ThrowIfNull(inferred); + ArgumentNullException.ThrowIfNull(attributes); + + if (!string.Equals(inferred.Cdc, "SPS", StringComparison.OrdinalIgnoreCase) || + inferred.Confidence >= 0.85) + { + return inferred; + } + + var exactStatusValues = attributes + .Where(attribute => + attribute.TypeConfidence == LiveIedDiscoveryConfidenceLevel.Exact && + string.Equals( + (attribute.AttributePath ?? string.Empty).Trim().Split('.').LastOrDefault(), + "stVal", + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (exactStatusValues.Length != 1) + return inferred; + + var primary = exactStatusValues[0]; + var bType = (primary.SclBType ?? string.Empty).Trim().ToUpperInvariant(); + var mmsType = (primary.MmsType ?? string.Empty).Trim().ToUpperInvariant(); + + if (IsExactSignedIntegerType(bType, mmsType)) + { + return Result( + "INS", + 0.96, + inferred.Evidence.Append( + $"exact MMS TypeSpecification stVal={FirstNonEmpty(primary.SclBType, primary.MmsType)} overrides generic SPS shape")); + } + + if (IsExactBooleanType(bType, mmsType)) + { + return Result( + "SPS", + Math.Max(inferred.Confidence, 0.96), + inferred.Evidence.Append( + $"exact MMS TypeSpecification stVal={FirstNonEmpty(primary.SclBType, primary.MmsType)} confirms SPS")); + } + + return inferred; + } + public static CdcInferenceResult Infer( string logicalNodeClass, string dataObjectName, @@ -133,6 +189,18 @@ public static CdcInferenceResult Infer( return Result(string.Empty, 0.0, evidence.Append("CDC cannot be inferred from current online discovery only")); } + private static bool IsExactSignedIntegerType(string bType, string mmsType) + => bType is "INT8" or "INT16" or "INT24" or "INT32" or "INT64" or "INTEGER" || + mmsType.Contains("INTEGER", StringComparison.Ordinal) || + mmsType.Contains("SIGNED", StringComparison.Ordinal); + + private static bool IsExactBooleanType(string bType, string mmsType) + => bType == "BOOLEAN" || + mmsType.Contains("BOOLEAN", StringComparison.Ordinal); + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + private static bool IsProtectionOperation(string lnClass, string dataObjectName) => string.Equals(dataObjectName, "Op", StringComparison.OrdinalIgnoreCase) && ((lnClass.Length > 0 && lnClass[0] == 'P') || string.Equals(lnClass, "RREC", StringComparison.OrdinalIgnoreCase)); diff --git a/src/AR.Iec61850/Discovery/LiveIedDataSetTypeProbePlanner.cs b/src/AR.Iec61850/Discovery/LiveIedDataSetTypeProbePlanner.cs new file mode 100644 index 00000000..086ead0a --- /dev/null +++ b/src/AR.Iec61850/Discovery/LiveIedDataSetTypeProbePlanner.cs @@ -0,0 +1,90 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Discovery; + +/// +/// Builds bounded typed-LN hints from live MMS DataSet directories. A successful +/// DataSet directory is independent evidence when GetNameList stopped early. +/// No SCL, guessed LN names, values, or extra MMS name scans are used. +/// +public static class LiveIedDataSetTypeProbePlanner +{ + public static IReadOnlyList BuildVerifiedMemberHints( + MmsDiscoveryResult discovery, + int maxExtraLogicalNodes = 48, + int maxMemberHints = 2048) + { + ArgumentNullException.ThrowIfNull(discovery); + var extraLimit = Math.Clamp(maxExtraLogicalNodes, 0, 256); + var pointLimit = Math.Clamp(maxMemberHints, 0, 8192); + if (pointLimit == 0) + return Array.Empty(); + + // Only domains returned by this association can authorize probes. + var domains = discovery.Snapshot.DomainVariables.Keys + .Where(domain => !string.IsNullOrWhiteSpace(domain)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (domains.Count == 0) + return Array.Empty(); + + static string RootKey(string domain, string ln) => domain + "\u001f" + ln; + var knownRoots = discovery.IedDirectory.LogicalDevices.Values + .SelectMany(ld => ld.LogicalNodes.Keys.Select(ln => RootKey(ld.Name, ln))) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var newRoots = new HashSet(StringComparer.OrdinalIgnoreCase); + var addedReferences = new HashSet(StringComparer.Ordinal); + var results = new List(); + + // ST first (event / control feedback), then MX; stable DataSet and + // membership order within each priority tier. + var liveMembers = discovery.DataSetDirectories + .Where(directory => directory.IsSuccess) + .OrderBy(directory => directory.DataSetReference, StringComparer.OrdinalIgnoreCase) + .SelectMany(directory => directory.Members) + .OrderBy(member => string.Equals(member.FunctionalConstraint, "ST", + StringComparison.OrdinalIgnoreCase) ? 0 : 1); + + foreach (var member in liveMembers) + { + if (results.Count >= pointLimit) + break; + if (!domains.Contains(member.Domain) || + string.IsNullOrWhiteSpace(member.MmsItemName) || + !(string.Equals(member.FunctionalConstraint, "ST", StringComparison.OrdinalIgnoreCase) || + string.Equals(member.FunctionalConstraint, "MX", StringComparison.OrdinalIgnoreCase))) + continue; + + // Do not accept a fabricated LN, incorrect FC, or ambiguous DO. + if (!MmsIedModelDirectoryBuilder.TryParseLiveMmsVariable( + member.Domain, member.MmsItemName, out var point) || + !string.Equals(point.LogicalNode, member.LogicalNode, StringComparison.OrdinalIgnoreCase) || + !string.Equals(point.FunctionalConstraint, member.FunctionalConstraint, + StringComparison.OrdinalIgnoreCase) || + !string.Equals(point.DataObjectPath, member.DataObjectPath, + StringComparison.OrdinalIgnoreCase)) + continue; + + var root = RootKey(point.Domain, point.LogicalNode); + if (!knownRoots.Contains(root) && !newRoots.Contains(root)) + { + if (newRoots.Count >= extraLimit) + continue; + newRoots.Add(root); + } + + if (addedReferences.Add(point.MmsReference)) + results.Add(new MmsFcResolvedPoint + { + Domain = point.Domain, + LogicalNode = point.LogicalNode, + FunctionalConstraint = point.FunctionalConstraint, + DataObjectPath = point.DataObjectPath, + MmsItemName = point.MmsItemName, + Source = "LiveMmsDataSetDirectoryTypeHint", + Confidence = member.Confidence + }); + } + + return results; + } +} diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 8508a176..8ce7beb3 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -7,7 +7,7 @@ public sealed class LiveIedIdentity public LiveIedDiscoveryConfidenceLevel Confidence { get; init; } = LiveIedDiscoveryConfidenceLevel.Unknown; public bool IsAmbiguous { get; init; } public IReadOnlyList CandidateNames { get; init; } = Array.Empty(); - public IReadOnlyDictionary LogicalDeviceAliases { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public IReadOnlyDictionary LogicalDeviceAliases { get; init; } = new Dictionary(StringComparer.Ordinal); public IReadOnlyList Evidence { get; init; } = Array.Empty(); public string Summary => $"IED identity: name={IedName}, source={Source}, confidence={Confidence}, candidates={CandidateNames.Count}, ambiguous={IsAmbiguous.ToString().ToLowerInvariant()}"; @@ -31,15 +31,15 @@ public static LiveIedIdentity Resolve( var materialized = domains .Where(domain => !string.IsNullOrWhiteSpace(domain)) .Select(domain => domain.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(domain => domain, StringComparer.OrdinalIgnoreCase) + .Distinct(StringComparer.Ordinal) + .OrderBy(domain => domain, StringComparer.Ordinal) .ToArray(); if (!string.IsNullOrWhiteSpace(explicitIedName)) { var name = explicitIedName.Trim(); - return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Exact, false, [name], materialized, - [$"IED name was supplied explicitly as '{name}'."]); + return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Medium, false, [name], materialized, + [$"Operator-supplied IED display name '{name}' has not been independently verified against this MMS endpoint."]); } var suffixMatches = materialized @@ -56,7 +56,7 @@ public static LiveIedIdentity Resolve( .OrderBy(candidate => candidate, StringComparer.OrdinalIgnoreCase) .ToArray(); - // A known LD suffix is authoritative only when it explains the complete domain set. + // A known LD suffix is only a provisional naming hint even when it explains the complete domain set. // A single nested domain such as OCR7SJ8Mod2_MU1 must never rename the physical IED // to OCR7SJ8Mod2 while sibling domains prove the shared OCR7SJ8 prefix. if (materialized.Length > 0 && suffixMatches.Length == materialized.Length) @@ -64,9 +64,7 @@ public static LiveIedIdentity Resolve( if (distinctSuffixCandidates.Length == 1) { var name = distinctSuffixCandidates[0]; - var confidence = materialized.Length > 1 - ? LiveIedDiscoveryConfidenceLevel.High - : LiveIedDiscoveryConfidenceLevel.Medium; + var confidence = LiveIedDiscoveryConfidenceLevel.Medium; var evidence = suffixMatches .Select(match => $"MMS domain '{match.Domain}' matched the logical-device suffix pattern for IED '{name}'.") .ToArray(); @@ -89,9 +87,7 @@ public static LiveIedIdentity Resolve( var commonPrefix = InferCommonPrefix(materialized); if (!string.IsNullOrWhiteSpace(commonPrefix)) { - var confidence = materialized.Length >= 3 - ? LiveIedDiscoveryConfidenceLevel.High - : LiveIedDiscoveryConfidenceLevel.Medium; + var confidence = LiveIedDiscoveryConfidenceLevel.Medium; return Create( commonPrefix, "MmsDomainCommonPrefix", @@ -112,7 +108,7 @@ public static LiveIedIdentity Resolve( return Create( name, "MmsDomainKnownLogicalDeviceSuffix", - materialized.Length == 1 ? LiveIedDiscoveryConfidenceLevel.Medium : LiveIedDiscoveryConfidenceLevel.High, + LiveIedDiscoveryConfidenceLevel.Medium, false, distinctSuffixCandidates, materialized, @@ -172,7 +168,7 @@ private static LiveIedIdentity Create( IReadOnlyList domains, IReadOnlyList evidence) { - var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + var aliases = new Dictionary(StringComparer.Ordinal); foreach (var domain in domains) { var alias = domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) && domain.Length > name.Length @@ -235,18 +231,10 @@ private static string InferCommonPrefix(IReadOnlyList domains) return string.Empty; } + // The common prefix is evidence of the shared IED-name candidate, not a + // logical-device suffix. Removing a known LD stem here corrupts valid + // names such as SIEBCU, STATIONPROT and STATIONMU. prefix = TrimBoundary(prefix); - foreach (var stem in KnownLogicalDeviceStems) - { - if (prefix.EndsWith(stem, StringComparison.OrdinalIgnoreCase) && prefix.Length > stem.Length) - { - var withoutStem = TrimBoundary(prefix[..^stem.Length]); - if (IsViableName(withoutStem)) - prefix = withoutStem; - break; - } - } - return IsViableName(prefix) ? prefix : string.Empty; } diff --git a/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs b/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs index 21025d14..744cbe56 100644 --- a/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs +++ b/src/AR.Iec61850/Discovery/LiveIedModelDiscoveryBuilder.cs @@ -215,7 +215,8 @@ private static IEnumerable BuildDataObjects( .ToArray(); var attrPaths = attributes.Select(x => x.AttributePath).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); var fcs = group.Select(x => x.FunctionalConstraint).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); - var cdc = CdcInferenceEngine.Infer(parsedLn.SclLnClass, group.Key, attrPaths, fcs); + var inferredCdc = CdcInferenceEngine.Infer(parsedLn.SclLnClass, group.Key, attrPaths, fcs); + var cdc = CdcInferenceEngine.RefineWithExactPrimaryType(inferredCdc, attributes); var reference = $"{ln.Domain}/{ln.Name}.{group.Key}"; yield return new LiveIedDataObjectModel { diff --git a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs index a5932071..d5231eb5 100644 --- a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs +++ b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs @@ -31,6 +31,29 @@ public static IReadOnlyList BuildLogicalNodeRootCandidates(M /// public static class LiveIedVariableTypeProbeExecutor { + /// + /// Optional DataSet-first late-root recovery from successful live MMS directory + /// evidence. Retains current single-pass LN hierarchy probing and existing + /// negotiated-window policies; never changes the legacy directory overload. + /// + public static Task> ProbeSmartAsync( + MmsClientSession session, + MmsDiscoveryResult discovery, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(discovery); + options ??= new MmsSmartDiscoveryOptions(); + + var verified = LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints( + discovery, + options.MaxDataSetTypeExtraLogicalNodes, + options.MaxDataSetTypeMemberHints); + discovery.IedDirectory.AddSupplementalPoints(verified); + return ProbeSmartAsync(session, discovery.IedDirectory, options, cancellationToken); + } + public static Task> ProbeSmartAsync( MmsClientSession session, MmsIedModelDirectory directory, @@ -84,6 +107,9 @@ result.TypeSpecification is not null && { var domain = result.Reference.Domain.Trim(); var logicalNode = result.Reference.Item.Trim(); + // Fail closed: only known directory LN roots are materialized. + // DataSet-root closure first promotes confirmed MMS member references + // into this directory, after a successful exact LN type probe. if (!directory.LogicalDevices.TryGetValue(domain, out var logicalDevice) || !logicalDevice.LogicalNodes.ContainsKey(logicalNode)) { diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs new file mode 100644 index 00000000..b7fd9424 --- /dev/null +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Xml.Linq; + +namespace AR.Iec61850.Discovery; + +/// +/// Evidence-only IEC 61850 identity reconciliation. A server exposes MMS domains, +/// not a guaranteed physical IED name or LDevice.inst boundary. Only a unique, +/// fully matched engineering SCL topology can promote a provisional identity. +/// No vendor-name heuristic, network call, cached-IP assumption, or model mutation. +/// +public static class TrustedSclIedIdentityMatcher +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static LiveIedIdentity? TryMatch( + XDocument trustedScl, + IEnumerable observedMmsDomains, + string? connectedHost, + string? selectedIedName = null, + string? selectedAccessPointName = null) + { + ArgumentNullException.ThrowIfNull(trustedScl); + ArgumentNullException.ThrowIfNull(observedMmsDomains); + if (trustedScl.Root?.Name != Scl + "SCL") return null; + var observed = observedMmsDomains + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .Distinct(StringComparer.Ordinal) + .ToHashSet(StringComparer.Ordinal); + if (observed.Count == 0) return null; + + var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); + var ambiguousUnboundTopology = false; + foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) + { + var name = ((string?)ied.Attribute("name") ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name) || + (!string.IsNullOrWhiteSpace(selectedIedName) && + !name.Equals(selectedIedName.Trim(), StringComparison.Ordinal))) + continue; + + foreach (var ap in ied.Elements(Scl + "AccessPoint")) + { + var apName = ((string?)ap.Attribute("name") ?? "").Trim(); + if (string.IsNullOrWhiteSpace(apName)) continue; + var devices = ap.Element(Scl + "Server")?.Elements(Scl + "LDevice").ToArray() + ?? Array.Empty(); + if (devices.Length != observed.Count) continue; + + var aliases = new Dictionary(StringComparer.Ordinal); + var invalid = false; + foreach (var ld in devices) + { + var inst = ((string?)ld.Attribute("inst") ?? "").Trim(); + var ldName = ((string?)ld.Attribute("ldName") ?? "").Trim(); + if (string.IsNullOrWhiteSpace(inst) || !IsValidSclIdentifier(inst)) + { + invalid = true; + break; + } + var domain = ldName.Length > 0 ? ldName : name + inst; + if (!observed.Contains(domain) || !aliases.TryAdd(domain,inst)) + { + invalid = true; + break; + } + } + if (invalid || aliases.Count != observed.Count) continue; + + var connected = trustedScl.Root.Element(Scl + "Communication")? + .Descendants(Scl + "ConnectedAP") + .Where(x => string.Equals((string?)x.Attribute("iedName"),name,StringComparison.Ordinal) + && string.Equals((string?)x.Attribute("apName"),apName,StringComparison.Ordinal)) + .ToArray() ?? Array.Empty(); + // SCL topology alone is not endpoint evidence. Require exactly one + // declared ConnectedAP/IP and an exact observed association endpoint. + // Missing/ambiguous endpoints remain provisional, never verified. + if (connected.Length != 1 || string.IsNullOrWhiteSpace(connectedHost)) + { + ambiguousUnboundTopology = true; + continue; + } + var ipValues = connected[0].Element(Scl + "Address")?.Elements(Scl + "P") + .Where(x => string.Equals((string?)x.Attribute("type"),"IP",StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value.Trim()).Take(2).ToArray() ?? Array.Empty(); + if (ipValues.Length != 1 || + !IPAddress.TryParse(ipValues[0],out var designIp) || + !IPAddress.TryParse(connectedHost.Trim(),out var observedIp)) + { + ambiguousUnboundTopology = true; + continue; + } + if (!designIp.Equals(observedIp)) + continue; + matches.Add((name, apName, aliases, ipValues[0])); + } + } + // A single IED can have multiple APs and the same LD topology. This + // remains ambiguous until AP is independently selected/proven. + if (matches.Count != 1 || ambiguousUnboundTopology) return null; + var match=matches[0]; + if (!string.IsNullOrWhiteSpace(selectedAccessPointName) && + !string.Equals(match.Ap, selectedAccessPointName.Trim(), StringComparison.Ordinal)) + return null; + return new LiveIedIdentity + { + IedName=match.Ied, + Source="TrustedSclExactDomainMatch", + Confidence=LiveIedDiscoveryConfidenceLevel.High, + IsAmbiguous=false, + CandidateNames=[match.Ied], + LogicalDeviceAliases=match.Aliases, + Evidence=[ + $"Trusted SCL IED '{match.Ied}' / AccessPoint '{match.Ap}' matches all {observed.Count} observed MMS domains and unique LDevice instances.", + string.IsNullOrWhiteSpace(match.Ip) ? "No endpoint IP asserted by SCL." : $"SCL endpoint IP {match.Ip}." + ] + }; + } + + private static bool IsValidSclIdentifier(string s) + => s.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.'); +} diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs index bd60b925..8038c3f1 100644 --- a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs @@ -1,4 +1,5 @@ using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; namespace AR.Iec61850.Engineering.Canonical; @@ -34,6 +35,24 @@ public static CanonicalIedModel FromLiveDiscovery(LiveIedModelDiscoveryDocument }); } + /// + /// Public source-correct canonical ingress for an already opened SCL IED workspace. + /// Applications should use this instead of re-labeling the workspace DesignModel as + /// live discovery merely to reuse canonical planning. + /// + public static CanonicalIedModel FromSclWorkspace(SclIedWorkspace workspace) + { + ArgumentNullException.ThrowIfNull(workspace); + + return FromSclProjection( + workspace.DesignModel, + sourceName: "SCL workspace", + sourceEdition: string.Empty, + iedName: workspace.IedName, + accessPointName: workspace.AccessPointName, + domainAliases: EmptyAliases); + } + internal static CanonicalIedModel FromSclProjection( LiveIedModelDiscoveryDocument projection, string sourceName, @@ -150,7 +169,7 @@ private static CanonicalIedModel Build( dataSets[i] = new CanonicalDataSet { - Reference = RemapReference(dataSet.Reference, context.DomainAliases), + Reference = NormalizeConfiguredReference(dataSet.Reference, context.DomainAliases), MmsDomain = RemapDomain(dataSet.Domain, context.DomainAliases), LogicalNode = dataSet.LogicalNode, Name = dataSet.Name, @@ -167,12 +186,12 @@ private static CanonicalIedModel Build( var report = source.ReportControls[i]; reportControls[i] = new CanonicalReportControl { - Reference = RemapReference(report.Reference, context.DomainAliases), + Reference = NormalizeConfiguredReference(report.Reference, context.DomainAliases), MmsDomain = RemapDomain(report.Domain, context.DomainAliases), LogicalNode = report.LogicalNode, Name = report.Name, Buffered = report.Buffered, - DataSetReference = RemapReference(report.DataSetReference, context.DomainAliases), + DataSetReference = NormalizeConfiguredReference(report.DataSetReference, context.DomainAliases), ReportId = report.ReportId, ConfRev = report.ConfRev, TriggerOptions = report.TriggerOptions, @@ -232,6 +251,17 @@ private static CanonicalIedModel Build( }; } + /// + /// Canonical DataSet/ReportControl identity uses one source-neutral dotted form. + /// SCL commonly carries '$' MMS component separators while live discovery may expose + /// the same configured resource with dots. Source projections retain their original + /// spelling; the shared CanonicalIedModel does not. + /// + private static string NormalizeConfiguredReference( + string value, + IReadOnlyDictionary aliases) + => RemapReference(value, aliases).Replace('$', '.'); + private static string RemapDomain(string value, IReadOnlyDictionary aliases) { var normalized = value?.Trim() ?? string.Empty; diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs new file mode 100644 index 00000000..c1e4ecba --- /dev/null +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs @@ -0,0 +1,282 @@ +namespace AR.Iec61850.Engineering.Canonical; + +public enum CanonicalStaticReportSignalCoverageStatus +{ + Covered, + SignalNotFound, + AmbiguousSignalIdentity, + NoStaticDataSetMembership, + StaticDataSetWithoutReportControl +} + +public sealed class CanonicalStaticReportSelection +{ + public string Reference { get; init; } = string.Empty; + public string FunctionalConstraint { get; init; } = string.Empty; +} + +public sealed class CanonicalStaticReportDataSetCandidate +{ + public string DataSetReference { get; init; } = string.Empty; + public string SourceDataSetReference { get; init; } = string.Empty; + public int[] MemberIndexes { get; init; } = Array.Empty(); + public string[] ReportControlReferences { get; init; } = Array.Empty(); +} + +public sealed class CanonicalStaticReportSignalCoverage +{ + public string RequestedReference { get; init; } = string.Empty; + public string RequestedFunctionalConstraint { get; init; } = string.Empty; + public string CanonicalReference { get; init; } = string.Empty; + public string CanonicalFunctionalConstraint { get; init; } = string.Empty; + public CanonicalStaticReportSignalCoverageStatus Status { get; init; } + public CanonicalStaticReportDataSetCandidate[] DataSetCandidates { get; init; } = Array.Empty(); + + public bool IsCovered => Status == CanonicalStaticReportSignalCoverageStatus.Covered; +} + +public sealed class CanonicalStaticReportCoverageSegment +{ + public string DataSetReference { get; init; } = string.Empty; + public string SourceDataSetReference { get; init; } = string.Empty; + public CanonicalDataSetMember[] OrderedMembers { get; init; } = Array.Empty(); + public CanonicalReportControl[] ReportControls { get; init; } = Array.Empty(); + public string[] SelectedSignalReferences { get; init; } = Array.Empty(); +} + +public sealed class CanonicalStaticReportCoveragePlan +{ + public CanonicalIngressKind Ingress { get; init; } + public CanonicalStaticReportSignalCoverage[] Signals { get; init; } = Array.Empty(); + public CanonicalStaticReportCoverageSegment[] Segments { get; init; } = Array.Empty(); + + public int RequestedSignalCount => Signals.Length; + public int CoveredSignalCount => Signals.Count(signal => signal.IsCovered); + public int UncoveredSignalCount => RequestedSignalCount - CoveredSignalCount; +} + +/// +/// Source-neutral static-report coverage over the shared CanonicalIedModel. +/// +/// This resolver never chooses an operational RCB instance and performs no IED writes. +/// It answers one question only: which configured static DataSet/ReportControl resources +/// already cover each exact canonical signal? Discovery and Open-SCL therefore enter the +/// same coverage logic after canonicalization. +/// +/// Structured DataSet members are handled by exact reference-boundary containment. A +/// member such as A.phsA covers its typed descendants but never sibling phases. Full +/// ordered DataSet membership is retained in every segment because InformationReport +/// projection remains positional even when the application selected only a subset. +/// +public static class CanonicalStaticReportCoverageResolver +{ + public static CanonicalStaticReportCoveragePlan Resolve( + CanonicalIedModel model, + IEnumerable selections) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + + var requested = selections.ToArray(); + var signalIndex = BuildSignalIndex(model); + var reportByDataSet = model.ReportControls + .Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference)) + .GroupBy(report => NormalizeConfiguredReference(report.DataSetReference), StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group + .OrderBy(report => NormalizeConfiguredReference(report.Reference), StringComparer.Ordinal) + .ToArray(), + StringComparer.Ordinal); + + var results = new CanonicalStaticReportSignalCoverage[requested.Length]; + for (var index = 0; index < requested.Length; index++) + results[index] = ResolveSignal(model, requested[index], signalIndex, reportByDataSet); + + var segmentDataSets = results + .Where(result => result.IsCovered) + .SelectMany(result => result.DataSetCandidates) + .Select(candidate => candidate.DataSetReference) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + var segments = segmentDataSets + .Select(dataSetReference => BuildSegment( + model, + dataSetReference, + results, + reportByDataSet)) + .Where(segment => segment is not null) + .Cast() + .ToArray(); + + return new CanonicalStaticReportCoveragePlan + { + Ingress = model.Source.Ingress, + Signals = results, + Segments = segments + }; + } + + private static CanonicalStaticReportSignalCoverage ResolveSignal( + CanonicalIedModel model, + CanonicalStaticReportSelection selection, + IReadOnlyDictionary signalIndex, + IReadOnlyDictionary reportByDataSet) + { + var requestedReference = selection.Reference?.Trim() ?? string.Empty; + var requestedFc = NormalizeFc(selection.FunctionalConstraint); + + if (!signalIndex.TryGetValue(requestedReference, out var referenceMatches)) + { + return new CanonicalStaticReportSignalCoverage + { + RequestedReference = requestedReference, + RequestedFunctionalConstraint = requestedFc, + Status = CanonicalStaticReportSignalCoverageStatus.SignalNotFound + }; + } + + var matches = string.IsNullOrWhiteSpace(requestedFc) + ? referenceMatches + : referenceMatches + .Where(signal => string.Equals( + NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint)), + requestedFc, + StringComparison.Ordinal)) + .ToArray(); + + if (matches.Length != 1) + { + return new CanonicalStaticReportSignalCoverage + { + RequestedReference = requestedReference, + RequestedFunctionalConstraint = requestedFc, + Status = matches.Length == 0 + ? CanonicalStaticReportSignalCoverageStatus.SignalNotFound + : CanonicalStaticReportSignalCoverageStatus.AmbiguousSignalIdentity + }; + } + + var signal = matches[0]; + var canonicalReference = model.Strings.Resolve(signal.ObjectReference); + var canonicalFc = NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint)); + + var candidates = new List(); + var anyDataSet = false; + foreach (var dataSet in model.DataSets) + { + var memberIndexes = dataSet.Members + .Where(member => MemberCoversSignal(member, canonicalReference, canonicalFc)) + .Select(member => member.Index) + .OrderBy(memberIndex => memberIndex) + .ToArray(); + if (memberIndexes.Length == 0) + continue; + + anyDataSet = true; + var canonicalDataSetReference = NormalizeConfiguredReference(dataSet.Reference); + reportByDataSet.TryGetValue(canonicalDataSetReference, out var reports); + candidates.Add(new CanonicalStaticReportDataSetCandidate + { + DataSetReference = canonicalDataSetReference, + SourceDataSetReference = dataSet.Reference, + MemberIndexes = memberIndexes, + ReportControlReferences = reports? + .Select(report => NormalizeConfiguredReference(report.Reference)) + .ToArray() ?? Array.Empty() + }); + } + + var reportBacked = candidates + .Where(candidate => candidate.ReportControlReferences.Length > 0) + .OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal) + .ToArray(); + + return new CanonicalStaticReportSignalCoverage + { + RequestedReference = requestedReference, + RequestedFunctionalConstraint = requestedFc, + CanonicalReference = canonicalReference, + CanonicalFunctionalConstraint = canonicalFc, + Status = reportBacked.Length > 0 + ? CanonicalStaticReportSignalCoverageStatus.Covered + : anyDataSet + ? CanonicalStaticReportSignalCoverageStatus.StaticDataSetWithoutReportControl + : CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership, + DataSetCandidates = reportBacked.Length > 0 + ? reportBacked + : candidates.OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal).ToArray() + }; + } + + private static CanonicalStaticReportCoverageSegment? BuildSegment( + CanonicalIedModel model, + string dataSetReference, + IReadOnlyList results, + IReadOnlyDictionary reportByDataSet) + { + var dataSet = model.DataSets.SingleOrDefault(candidate => + string.Equals( + NormalizeConfiguredReference(candidate.Reference), + dataSetReference, + StringComparison.Ordinal)); + if (dataSet is null) + return null; + + reportByDataSet.TryGetValue(dataSetReference, out var reports); + reports ??= Array.Empty(); + + var selected = results + .Where(result => result.IsCovered && + result.DataSetCandidates.Any(candidate => + string.Equals(candidate.DataSetReference, dataSetReference, StringComparison.Ordinal))) + .Select(result => result.CanonicalReference) + .Distinct(StringComparer.Ordinal) + .OrderBy(reference => reference, StringComparer.Ordinal) + .ToArray(); + + return new CanonicalStaticReportCoverageSegment + { + DataSetReference = dataSetReference, + SourceDataSetReference = dataSet.Reference, + OrderedMembers = dataSet.Members.OrderBy(member => member.Index).ToArray(), + ReportControls = reports, + SelectedSignalReferences = selected + }; + } + + private static IReadOnlyDictionary BuildSignalIndex(CanonicalIedModel model) + => model.Signals + .GroupBy(signal => model.Strings.Resolve(signal.ObjectReference), StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + + private static bool MemberCoversSignal( + CanonicalDataSetMember member, + string signalReference, + string signalFc) + { + var memberReference = member.Reference?.Trim() ?? string.Empty; + if (memberReference.Length == 0 || signalReference.Length == 0) + return false; + + var memberFc = NormalizeFc(member.FunctionalConstraint); + if (memberFc.Length > 0 && + !string.Equals(memberFc, signalFc, StringComparison.Ordinal)) + { + return false; + } + + return string.Equals(signalReference, memberReference, StringComparison.Ordinal) || + signalReference.StartsWith(memberReference + ".", StringComparison.Ordinal); + } + + private static string NormalizeConfiguredReference(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? string.Empty : text.Replace('$', '.'); + } + + private static string NormalizeFc(string? value) + => (value ?? string.Empty).Trim().ToUpperInvariant(); +} diff --git a/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs index b38a88a6..d2e5c8ff 100644 --- a/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs +++ b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs @@ -12,7 +12,9 @@ public enum Iec61850FaultRecordFileKind Information, Combined, Archive, - VendorPackage + VendorPackage, + /// Optional relay-settings sidecar, never a standalone COMTRADE record. + RioSidecar } public sealed class Iec61850FaultRecordFile @@ -108,6 +110,7 @@ public static class Iec61850FaultRecordCatalogBuilder [".dat"] = Iec61850FaultRecordFileKind.Data, [".hdr"] = Iec61850FaultRecordFileKind.Header, [".inf"] = Iec61850FaultRecordFileKind.Information, + [".rio"] = Iec61850FaultRecordFileKind.RioSidecar, [".cff"] = Iec61850FaultRecordFileKind.Combined, [".zip"] = Iec61850FaultRecordFileKind.Archive }; @@ -140,6 +143,11 @@ public static Iec61850FaultRecordCatalog Build( .GroupBy( file => BuildRecordId(file.RemoteDirectory, file.BaseName), StringComparer.OrdinalIgnoreCase) + // A lone RIO is ordinary root inventory, not a downloadable + // COMTRADE record. Only exact directory + base-name matches join + // an already recognized primary CFG/DAT/package identity. + .Where(group => group.Any(file => + file.Kind != Iec61850FaultRecordFileKind.RioSidecar)) .Select(group => BuildRecord(group.Key, group)) .OrderByDescending(record => record.LastModifiedUtc ?? DateTimeOffset.MinValue) .ThenBy(record => record.RecordId, StringComparer.OrdinalIgnoreCase) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs b/src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs new file mode 100644 index 00000000..d3d31984 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs @@ -0,0 +1,89 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +/// +/// Projects immutable canonical engineering structure into the legacy report-inventory shape +/// without promoting it to live runtime evidence. +/// +/// This exists so applications do not need to duplicate IEC 61850 DataSet/RCB mapping logic. +/// It intentionally does not fabricate indexed RCB instances, writable attribute exposure, +/// reservation state, ownership, or live enable state. +/// +public static class MmsCanonicalReportInventoryProjection +{ + public static MmsReportInventory Build(CanonicalIedModel model) + { + ArgumentNullException.ThrowIfNull(model); + + var inventory = new MmsReportInventory + { + Authority = model.Source.Ingress == CanonicalIngressKind.SclFile + ? MmsReportInventoryAuthority.SclDesignProjection + : MmsReportInventoryAuthority.CanonicalModelProjection + }; + + foreach (var dataSet in model.DataSets) + { + var (domain, tail) = SplitReference(dataSet.Reference); + inventory.DataSets.Add(new MmsDataSetCandidate + { + Domain = FirstNonEmpty(dataSet.MmsDomain, domain), + LogicalNode = FirstNonEmpty(dataSet.LogicalNode, LogicalNodeFromTail(tail)), + Name = FirstNonEmpty(dataSet.Name, LeafFromTail(tail)), + Reference = dataSet.Reference, + RawMmsName = string.Empty + }); + } + + foreach (var report in model.ReportControls) + { + var (domain, tail) = SplitReference(report.Reference); + inventory.ReportControls.Add(new MmsReportControlCandidate + { + Domain = FirstNonEmpty(report.MmsDomain, domain), + LogicalNode = FirstNonEmpty(report.LogicalNode, LogicalNodeFromTail(tail)), + FunctionalConstraint = report.Buffered ? "BR" : "RP", + Name = FirstNonEmpty(report.Name, LeafFromTail(tail)), + Reference = report.Reference, + Buffered = report.Buffered, + DataSetReference = report.DataSetReference, + ReportId = report.ReportId, + ConfRev = report.ConfRev, + IntegrityPeriodMs = report.IntegrityPeriodMs, + BufferTimeMs = report.BufferTimeMs, + TriggerOptions = report.TriggerOptions, + OptionalFields = report.OptionalFields, + Status = "CanonicalModelProjection" + }); + } + + return inventory; + } + + private static (string Domain, string Tail) SplitReference(string? reference) + { + var normalized = (reference ?? string.Empty).Trim(); + var slash = normalized.IndexOf('/'); + return slash > 0 && slash + 1 < normalized.Length + ? (normalized[..slash], normalized[(slash + 1)..]) + : (string.Empty, normalized); + } + + private static string LogicalNodeFromTail(string tail) + { + var dot = tail.IndexOf('.'); + return dot > 0 ? tail[..dot] : string.Empty; + } + + private static string LeafFromTail(string tail) + { + var dot = tail.LastIndexOf('.'); + return dot >= 0 && dot + 1 < tail.Length ? tail[(dot + 1)..] : tail; + } + + private static string FirstNonEmpty(string? primary, string? fallback) + => !string.IsNullOrWhiteSpace(primary) + ? primary.Trim() + : (fallback ?? string.Empty).Trim(); +} diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs new file mode 100644 index 00000000..9070d02a --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs @@ -0,0 +1,357 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +public enum MmsCanonicalStaticAcquisitionProbeStatus +{ + NoSelection, + NoConfiguredStaticCoverage, + LiveInventoryRequired, + ExactTargetsUnresolved, + ExactTargetsReady, + PartialExactTargetsReady, + TargetBudgetExceeded, + AvailabilityChecked, + PartialAvailabilityChecked +} + +public sealed class MmsCanonicalStaticAcquisitionProbePlan +{ + public MmsReportInventoryAuthority InventoryAuthority { get; init; } + public CanonicalStaticReportCoveragePlan Coverage { get; init; } = new(); + public MmsCanonicalStaticRcbTargetResolution TargetResolution { get; init; } = new(); + public MmsCanonicalStaticAcquisitionProbeStatus Status { get; init; } + public IReadOnlySet ExactTargetReportControlReferences { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public int RequestedSignalCount => Coverage.RequestedSignalCount; + public int StaticCoveredSignalCount => Coverage.CoveredSignalCount; + public int CoveredSegmentCount => Coverage.Segments.Length; + public int ResolvedSegmentCount => TargetResolution.Segments.Count(segment => + segment.ExactLiveReportControlReferences.Count > 0); + public int UnresolvedSegmentCount => Math.Max(0, CoveredSegmentCount - ResolvedSegmentCount); + public bool HasExactOperationalTargets => + InventoryAuthority == MmsReportInventoryAuthority.LiveMmsObserved && + ExactTargetReportControlReferences.Count > 0; + + public string Summary => + $"canonical static preflight: status={Status}, ingress={Coverage.Ingress}, requested={RequestedSignalCount}, " + + $"staticCovered={StaticCoveredSignalCount}, segments={CoveredSegmentCount}, resolvedSegments={ResolvedSegmentCount}, " + + $"unresolvedSegments={UnresolvedSegmentCount}, exactTargets={ExactTargetReportControlReferences.Count}, inventory={InventoryAuthority}."; +} + +public sealed class MmsCanonicalStaticAcquisitionProbeOptions +{ + /// Maximum exact RCBs inspected per page, not a global catalog cap. + public int MaxExactTargets { get; init; } = 64; + public int MaxExactTargetPagesPerInvocation { get; init; } = 8; + public string? ExactTargetContinuationToken { get; init; } + public bool ReadDataSetDirectories { get; init; } = true; + public IReadOnlySet CallerOwnedRcbReferences { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); +} + +public sealed class MmsCanonicalStaticAcquisitionProbeResult +{ + public MmsCanonicalStaticAcquisitionProbePlan Plan { get; init; } = new(); + public MmsCanonicalStaticAcquisitionProbeStatus Status { get; init; } + public bool NetworkProbePerformed { get; init; } + public int ExactTargetPageCount { get; init; } + public int DeferredExactTargetCount { get; init; } + public string? ExactTargetContinuationToken { get; init; } + public MmsRcbAvailabilityResult? Availability { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public string Summary => + $"{Plan.Summary} networkProbe={NetworkProbePerformed.ToString().ToLowerInvariant()}, " + + $"availability={(Availability is null ? "not-run" : Availability.Summary)}"; +} + +/// +/// Pure source-aware coordinator between canonical static coverage and live RCB targeting. +/// +/// It deliberately refuses to turn an SCL-projected inventory into operational live evidence. +/// A caller must provide a live MMS-observed inventory before exact target resolution can +/// authorize network probing. Unresolved segments stay unresolved; this planner never broadens +/// them to siblings or starts a broad RCB scan. +/// +public static class MmsCanonicalStaticAcquisitionPreflightPlanner +{ + public static MmsCanonicalStaticAcquisitionProbePlan Build( + CanonicalIedModel model, + IEnumerable selections, + MmsReportInventory inventory) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + ArgumentNullException.ThrowIfNull(inventory); + + var requested = selections.ToArray(); + var coverage = CanonicalStaticReportCoverageResolver.Resolve(model, requested); + return Build(coverage, inventory); + } + + public static MmsCanonicalStaticAcquisitionProbePlan Build( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventory inventory) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(inventory); + + var warnings = new List(); + + if (coverage.RequestedSignalCount == 0) + { + return Result( + coverage, + inventory.Authority, + new MmsCanonicalStaticRcbTargetResolution(), + MmsCanonicalStaticAcquisitionProbeStatus.NoSelection, + warnings); + } + + if (coverage.CoveredSignalCount == 0 || coverage.Segments.Length == 0) + { + warnings.Add("No selected signal has configured static DataSet/RCB coverage; no operational RCB probe is required."); + return Result( + coverage, + inventory.Authority, + new MmsCanonicalStaticRcbTargetResolution(), + MmsCanonicalStaticAcquisitionProbeStatus.NoConfiguredStaticCoverage, + warnings); + } + + if (inventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved) + { + warnings.Add( + "Configured static coverage exists, but operational RCB targeting requires a live MMS-observed report inventory. " + + "SCL/design inventory is structural evidence only and was not promoted to live target evidence."); + return Result( + coverage, + inventory.Authority, + new MmsCanonicalStaticRcbTargetResolution(), + MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, + warnings); + } + + var targetResolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + warnings.AddRange(targetResolution.Warnings); + + if (!targetResolution.HasTargets) + { + warnings.Add( + "Configured static coverage exists but no exact live RCB target was proven. No broad diagnostic scan or sibling-name guess was started."); + return Result( + coverage, + inventory.Authority, + targetResolution, + MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsUnresolved, + warnings); + } + + var unresolved = coverage.Segments.Length - targetResolution.Segments.Count(segment => + segment.ExactLiveReportControlReferences.Count > 0); + var status = unresolved > 0 + ? MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady + : MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady; + + return Result(coverage, inventory.Authority, targetResolution, status, warnings); + } + + private static MmsCanonicalStaticAcquisitionProbePlan Result( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventoryAuthority inventoryAuthority, + MmsCanonicalStaticRcbTargetResolution targetResolution, + MmsCanonicalStaticAcquisitionProbeStatus status, + IReadOnlyList warnings) + => new() + { + InventoryAuthority = inventoryAuthority, + Coverage = coverage, + TargetResolution = targetResolution, + Status = status, + ExactTargetReportControlReferences = targetResolution.ExactLiveReportControlReferences, + Warnings = warnings + .Where(message => !string.IsNullOrWhiteSpace(message)) + .Distinct(StringComparer.Ordinal) + .ToArray() + }; +} + +public sealed partial class MmsClientSession +{ + /// + /// Preferred one-shot runtime entry point. Planning and execution consume the same + /// inventory instance so callers cannot accidentally plan from one evidence source + /// and execute against another. + /// + public Task PrepareCanonicalStaticAcquisitionAsync( + CanonicalIedModel model, + IEnumerable selections, + MmsReportInventory inventory, + MmsIedModelDirectory? directory = null, + MmsCanonicalStaticAcquisitionProbeOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + ArgumentNullException.ThrowIfNull(inventory); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + model, + selections, + inventory); + return ProbeCanonicalStaticAcquisitionAsync( + plan, + inventory, + directory, + options, + cancellationToken); + } + + /// + /// Executes only the exact-target availability work authorized by the pure preflight + /// planner. There is intentionally no implicit broad fallback. + /// + public async Task ProbeCanonicalStaticAcquisitionAsync( + MmsCanonicalStaticAcquisitionProbePlan plan, + MmsReportInventory liveInventory, + MmsIedModelDirectory? directory = null, + MmsCanonicalStaticAcquisitionProbeOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(liveInventory); + options ??= new MmsCanonicalStaticAcquisitionProbeOptions(); + + if (liveInventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved) + { + return new MmsCanonicalStaticAcquisitionProbeResult + { + Plan = plan, + Status = MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, + NetworkProbePerformed = false, + Warnings = plan.Warnings + .Concat(new[] + { + "The execution inventory is not marked LiveMmsObserved; the exact-target plan was not allowed to touch the network." + }) + .Distinct(StringComparer.Ordinal) + .ToArray() + }; + } + + if (!plan.HasExactOperationalTargets) + { + return new MmsCanonicalStaticAcquisitionProbeResult + { + Plan = plan, + Status = plan.Status, + NetworkProbePerformed = false, + Warnings = plan.Warnings + }; + } + + var maxPerPage = Math.Clamp(options.MaxExactTargets, 1, 512); + var maxPages = Math.Clamp(options.MaxExactTargetPagesPerInvocation, 1, 32); + EnsureMmsReady(); + + var snapshots = new List(); + var probeWarnings = new List(); + var continuation = options.ExactTargetContinuationToken; + var totalExact = 0; + var pages = 0; + var targetedProbes = 0; + var stateReads = 0; + var directoryReads = 0; + var directoryHits = 0; + + while (pages < maxPages && IsMmsInitiated) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = MmsCanonicalStaticExactTargetPager.Select( + plan, liveInventory, maxPerPage, continuation, + _canonicalStaticRcbAssociationKey); + totalExact = page.TotalExactTargets; + continuation = page.ContinuationToken; + if (page.References.Count == 0) + break; + + pages++; + targetedProbes += page.References.Count; + // Exact target list, never a broad RCB availability probe. + var batch = await CheckReportControlAvailabilityAsync( + liveInventory, + directory, + new MmsRcbAvailabilityOptions + { + MaxReportControls = maxPerPage, + ReadDataSetDirectories = options.ReadDataSetDirectories, + TargetReportControlReferences = page.References, + CallerOwnedRcbReferences = options.CallerOwnedRcbReferences + }, + cancellationToken).ConfigureAwait(false); + snapshots.AddRange(batch.ReportControls); + probeWarnings.AddRange(batch.Warnings); + stateReads += batch.TargetedRcbStateLogicalReadCount; + directoryReads += batch.DataSetDirectoryNetworkReadCount; + directoryHits += batch.DataSetDirectoryCacheHitCount; + + // Once each resolved DataSet segment has an independently eligible + // exact RCB, additional pages provide no benefit to this one-shot + // selected acquisition. Availability is READ-ONLY; the consumer + // still verifies ordered members and user authorization before writes. + var resolvedSegments = plan.TargetResolution.Segments + .Where(segment => segment.ExactLiveReportControlReferences.Count > 0) + .ToArray(); + var allHaveEligible = resolvedSegments.Length > 0 && + resolvedSegments.All(segment => + snapshots.Any(snapshot => + segment.ExactLiveReportControlReferences.Any(reference => + string.Equals( + MmsRcbAvailabilityEvaluator.NormalizeReference(reference), + MmsRcbAvailabilityEvaluator.NormalizeReference(snapshot.Reference), + StringComparison.OrdinalIgnoreCase)) && + MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, allowCallerOwned: true, + allowReducedMissingReservationEvidence: true).IsEligible)); + if (allHaveEligible || continuation is null) + break; + } + + var deferred = Math.Max(0, totalExact - targetedProbes); + if (continuation is not null) + probeWarnings.Add( + $"Exact target availability deferred {deferred} RCB(s) after {pages} bounded page(s); resume using ExactTargetContinuationToken."); + var availability = new MmsRcbAvailabilityResult + { + ReportControls = snapshots, + Warnings = probeWarnings.Distinct(StringComparer.Ordinal).ToArray(), + InventoryReportControlCount = liveInventory.ReportControls.Count, + TargetFilterApplied = true, + RequestedTargetReportControlCount = plan.ExactTargetReportControlReferences.Count, + MatchedTargetReportControlCount = snapshots.Count, + TargetedRcbStateLogicalReadCount = stateReads, + DataSetDirectoryNetworkReadCount = directoryReads, + DataSetDirectoryCacheHitCount = directoryHits + }; + + return new MmsCanonicalStaticAcquisitionProbeResult + { + Plan = plan, + Status = !IsMmsInitiated || pages == 0 || continuation is not null || plan.UnresolvedSegmentCount > 0 + ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked + : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked, + NetworkProbePerformed = pages > 0, + Availability = pages > 0 ? availability : null, + ExactTargetPageCount = pages, + DeferredExactTargetCount = deferred, + ExactTargetContinuationToken = continuation, + Warnings = plan.Warnings + .Concat(availability.Warnings) + .Distinct(StringComparer.Ordinal) + .ToArray() + }; + } +} diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs new file mode 100644 index 00000000..42c72f26 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AR.Iec61850.Mms; + +public sealed class MmsCanonicalStaticExactTargetPage +{ + public IReadOnlySet References { get; init; } = + new HashSet(StringComparer.Ordinal); + public int TotalExactTargets { get; init; } + public string? ContinuationToken { get; init; } + public bool HasMore => ContinuationToken is not null; +} + +/// +/// Resumable exact-target availability admission. Unlike a global 64/128 RCB +/// abort, this pure selector constrains work per page. It cannot add targets +/// missing from the verified live inventory or invent indexed RCB siblings. +/// +public static class MmsCanonicalStaticExactTargetPager +{ + public static MmsCanonicalStaticExactTargetPage Select( + MmsCanonicalStaticAcquisitionProbePlan plan, + MmsReportInventory inventory, + int maxPerPage, + string? continuationToken = null, + string? associationKey = null) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(inventory); + if (maxPerPage <= 0) + throw new ArgumentOutOfRangeException(nameof(maxPerPage)); + if (inventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved) + throw new ArgumentException("Exact target paging requires live MMS inventory.", nameof(inventory)); + + var liveReferences = inventory.ReportControls + .Select(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var targets = plan.ExactTargetReportControlReferences + .Select(MmsRcbAvailabilityEvaluator.NormalizeReference) + .Where(reference => reference.Length > 0 && liveReferences.Contains(reference)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(reference => reference, StringComparer.Ordinal) + .ToArray(); + var input = new StringBuilder().Append("association=") + .Append(associationKey ?? "detached").Append('\n'); + foreach (var reference in targets) + input.Append(reference).Append('\n'); + var fingerprint = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(input.ToString()))); + + var start = 0; + if (continuationToken is not null) + { + var parts = continuationToken.Split(':'); + if (parts.Length != 2 || + !string.Equals(parts[0], fingerprint, StringComparison.Ordinal) || + !int.TryParse(parts[1], out start) || start < 0 || start >= targets.Length) + throw new ArgumentException( + "Exact RCB target continuation does not match current association/catalog.", + nameof(continuationToken)); + } + var next = Math.Min(targets.Length, start + maxPerPage); + return new MmsCanonicalStaticExactTargetPage + { + TotalExactTargets = targets.Length, + References = targets.Skip(start).Take(next - start) + .ToHashSet(StringComparer.OrdinalIgnoreCase), + ContinuationToken = next < targets.Length ? $"{fingerprint}:{next}" : null + }; + } +} diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs new file mode 100644 index 00000000..414c24a7 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -0,0 +1,463 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +public enum MmsCanonicalStaticLiveReconciliationStatus +{ + NoStaticCoverage, + Ready, + DomainBudgetExceeded, + CandidateBudgetExceeded, + Partial, + AssociationLost +} + +public sealed class MmsCanonicalStaticLiveDomainTarget +{ + public string Domain { get; init; } = string.Empty; + public bool NeedsBuffered { get; init; } + public bool NeedsUnbuffered { get; init; } +} + +public sealed class MmsCanonicalStaticLiveReconciliationPlan +{ + public MmsCanonicalStaticLiveReconciliationStatus Status { get; init; } + public IReadOnlyList Domains { get; init; } + = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public bool RequiresNetwork => Status == MmsCanonicalStaticLiveReconciliationStatus.Ready && + Domains.Count > 0; +} + +public sealed class MmsCanonicalStaticLiveReconciliationOptions +{ + /// + /// Hard guard. Targeted reconciliation never silently truncates the configured domain set. + /// + public int MaxDomains { get; init; } = 16; + + /// + /// Per-domain GetNameList bound. This is a safety envelope, not a target count. + /// + public int MaxVariableNamesPerDomain { get; init; } = 20000; + + public int MaxNameListPages { get; init; } = 64; + + /// + /// Bounded independent domain chains, additionally capped by negotiated MMS outstandingCalling. + /// + public int MaxConcurrentDomains { get; init; } = 4; + public int UnknownPeerMaxConcurrentDomains { get; init; } = 2; + + /// + /// Maximum RCB Read candidates PER PAGE. A partial catalog is resumable; + /// the total number of unrelated RCBs must never abort selected discovery. + /// + public int MaxReportControlCandidates { get; init; } = 64; + + /// Bounded batches per invocation; never a global catalog cap. + public int MaxCandidatePagesPerInvocation { get; init; } = 8; + + /// Checked catalog continuation from a previously partial reconciliation. + public string? CandidateContinuationToken { get; init; } +} + +public sealed class MmsCanonicalStaticLiveReconciliationResult +{ + public MmsCanonicalStaticLiveReconciliationPlan Plan { get; init; } = new(); + public MmsCanonicalStaticLiveReconciliationStatus Status { get; init; } + public MmsReportInventory Inventory { get; init; } = new(); + public int EnumeratedVariableCount { get; init; } + public int CandidateReportControlCount { get; init; } + public int DataSetBoundReportControlCount { get; init; } + public int ExaminedReportControlCount { get; init; } + public int DeferredReportControlCount { get; init; } + public string? CandidateContinuationToken { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public bool HasLiveInventory => + Inventory.Authority == MmsReportInventoryAuthority.LiveMmsObserved; + + public string Summary => + $"targeted live RCB reconciliation: status={Status}, domains={Plan.Domains.Count}, " + + $"rawVariables={EnumeratedVariableCount}, candidates={CandidateReportControlCount}, " + + $"datasetBound={DataSetBoundReportControlCount}, examined={ExaminedReportControlCount}, " + + $"deferred={DeferredReportControlCount}, authority={Inventory.Authority}."; +} + +public sealed class MmsCanonicalStaticSmartPreparationResult +{ + public CanonicalStaticReportCoveragePlan Coverage { get; init; } = new(); + public MmsCanonicalStaticLiveReconciliationResult Reconciliation { get; init; } = new(); + public MmsCanonicalStaticAcquisitionProbeResult Acquisition { get; init; } = new(); + + public string Summary => + $"{Reconciliation.Summary} {Acquisition.Summary}"; +} + +/// +/// Pure planner for the minimum online structure needed to reconcile configured static +/// reporting. It extracts only MMS domains and BRCB/URCB families actually referenced by +/// selected static coverage. No runtime RCB instance is guessed here. +/// +public static class MmsCanonicalStaticLiveReconciliationPlanner +{ + public static MmsCanonicalStaticLiveReconciliationPlan Build( + CanonicalStaticReportCoveragePlan coverage, + MmsCanonicalStaticLiveReconciliationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(coverage); + options ??= new MmsCanonicalStaticLiveReconciliationOptions(); + + if (coverage.Segments.Length == 0 || coverage.CoveredSignalCount == 0) + { + return new MmsCanonicalStaticLiveReconciliationPlan + { + Status = MmsCanonicalStaticLiveReconciliationStatus.NoStaticCoverage + }; + } + + var map = new Dictionary(StringComparer.Ordinal); + var warnings = new List(); + + foreach (var report in coverage.Segments.SelectMany(segment => segment.ReportControls)) + { + var domain = ResolveDomain(report.MmsDomain, report.Reference, report.DataSetReference); + if (domain.Length == 0) + { + warnings.Add($"Configured RCB '{report.Reference}' has no resolvable MMS domain and cannot be reconciled online."); + continue; + } + + map.TryGetValue(domain, out var current); + map[domain] = report.Buffered + ? (true, current.Unbuffered) + : (current.Buffered, true); + } + + var domains = map + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => new MmsCanonicalStaticLiveDomainTarget + { + Domain = pair.Key, + NeedsBuffered = pair.Value.Buffered, + NeedsUnbuffered = pair.Value.Unbuffered + }) + .ToArray(); + + var maxDomains = Math.Clamp(options.MaxDomains, 1, 256); + if (domains.Length > maxDomains) + { + warnings.Add( + $"Configured static coverage requires {domains.Length} MMS domains, exceeding targeted reconciliation budget {maxDomains}; no partial domain subset is authorized."); + return new MmsCanonicalStaticLiveReconciliationPlan + { + Status = MmsCanonicalStaticLiveReconciliationStatus.DomainBudgetExceeded, + Domains = domains, + Warnings = warnings + }; + } + + return new MmsCanonicalStaticLiveReconciliationPlan + { + Status = domains.Length == 0 + ? MmsCanonicalStaticLiveReconciliationStatus.Partial + : MmsCanonicalStaticLiveReconciliationStatus.Ready, + Domains = domains, + Warnings = warnings + }; + } + + private static string ResolveDomain( + string mmsDomain, + string reportReference, + string dataSetReference) + { + if (!string.IsNullOrWhiteSpace(mmsDomain)) + return mmsDomain.Trim(); + + foreach (var reference in new[] { reportReference, dataSetReference }) + { + var normalized = reference?.Trim() ?? string.Empty; + var slash = normalized.IndexOf('/'); + if (slash > 0) + return normalized[..slash]; + } + + return string.Empty; + } +} + +public sealed partial class MmsClientSession +{ + private string _canonicalStaticRcbAssociationKey = Guid.NewGuid().ToString("N"); + + /// + /// Performs bounded online reconciliation for configured static reporting without + /// running full IED discovery. + /// + /// Work is proportional to the exact MMS domains involved in selected static coverage: + /// one NamedVariable GetNameList chain per domain, followed by whole-RCB/DatSet reads + /// only for live RCB candidates of the required BRCB/URCB class. DataSet directories, + /// FC points, unrelated domains and unrelated RCB classes are not discovered here. + /// + public async Task ReconcileCanonicalStaticReportInventoryAsync( + CanonicalStaticReportCoveragePlan coverage, + MmsCanonicalStaticLiveReconciliationOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(coverage); + options ??= new MmsCanonicalStaticLiveReconciliationOptions(); + + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(coverage, options); + var emptyInventory = new MmsReportInventory + { + Authority = MmsReportInventoryAuthority.LiveMmsObserved + }; + + if (!plan.RequiresNetwork) + { + return new MmsCanonicalStaticLiveReconciliationResult + { + Plan = plan, + Status = plan.Status, + Inventory = emptyInventory, + Warnings = plan.Warnings + }; + } + + EnsureMmsReady(); + + var maxNames = Math.Clamp(options.MaxVariableNamesPerDomain, 1, 100000); + var maxPages = Math.Clamp(options.MaxNameListPages, 1, 256); + var smartOptions = new MmsSmartDiscoveryOptions + { + MaxConcurrentChains = Math.Clamp(options.MaxConcurrentDomains, 1, 64), + UnknownPeerMaxConcurrentChains = Math.Clamp(options.UnknownPeerMaxConcurrentDomains, 1, 32) + }; + var window = ResolveSmartDiscoveryWindow(smartOptions); + + var work = plan.Domains + .Select(target => new SmartNameChainWorkItem( + target.Domain, + MmsGetNameListObjectClass.NamedVariable, + maxNames)) + .ToArray(); + + var chains = await RunSmartNameChainsAsync( + work, + window, + maxPages, + cancellationToken) + .ConfigureAwait(false); + + if (!IsMmsInitiated) + { + return new MmsCanonicalStaticLiveReconciliationResult + { + Plan = plan, + Status = MmsCanonicalStaticLiveReconciliationStatus.AssociationLost, + Inventory = emptyInventory, + EnumeratedVariableCount = chains.Sum(chain => chain.Names.Count), + Warnings = plan.Warnings + .Concat(new[] { "MMS association became unavailable during targeted RCB reconciliation." }) + .ToArray() + }; + } + + var variables = chains.ToDictionary( + chain => chain.Domain, + chain => (IReadOnlyList)chain.Names, + StringComparer.Ordinal); + + var observed = MmsReportDiscoveryMapper.BuildInventory(new MmsDiscoverySnapshot + { + DomainVariables = variables, + DomainVariableLists = new Dictionary>(StringComparer.Ordinal) + }); + + // The raw RCB directory has no authoritative DatSet association yet. + // Stage A: index by the exact selected DataSet and configured references. + // Stage B: read only bounded pages of still-unknown compatible candidates. + // Never reject the complete operation because *global* RCB count is 66/300. + // Directory/catalog objects remain immutable while pages are calculated; + // successful MMS reads enrich separate candidates, not continuation state. + var maxReadsPerPage = Math.Clamp(options.MaxReportControlCandidates, 1, 512); + var maxReadPages = Math.Clamp(options.MaxCandidatePagesPerInvocation, 1, 32); + var expectedDataSets = coverage.Segments + .Where(segment => segment.SelectedSignalReferences.Length > 0) + .Select(segment => MmsRcbAvailabilityEvaluator.NormalizeReference(segment.DataSetReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.Ordinal); + + var inventory = new MmsReportInventory + { + Authority = MmsReportInventoryAuthority.LiveMmsObserved + }; + var examined = 0; + var rejected = 0; + var catalogCount = 0; + var eligibleProbeCount = 0; + var pages = 0; + var continuation = options.CandidateContinuationToken; + + do + { + cancellationToken.ThrowIfCancellationRequested(); + // The pager binds its cursor to exact catalog content and coverage. + // A changed/ambiguous cursor must fail closed, not silently skip RCBs. + var page = MmsCanonicalStaticRcbCatalogPager.Select( + coverage, observed, maxReadsPerPage, continuation, + _canonicalStaticRcbAssociationKey); + catalogCount = page.CatalogCount; + eligibleProbeCount = page.EligibleProbeCount; + continuation = page.ContinuationToken; + pages++; + + foreach (var raw in page.Candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated) + break; + + // Do not mutate the directory source used for stable paging. + var candidate = new MmsReportControlCandidate + { + Domain = raw.Domain, + LogicalNode = raw.LogicalNode, + FunctionalConstraint = raw.FunctionalConstraint, + Name = raw.Name, + Reference = raw.Reference, + Buffered = raw.Buffered, + DataSetReference = raw.DataSetReference, + Attributes = raw.Attributes.ToList() + }; + examined++; + if (string.IsNullOrWhiteSpace(candidate.DataSetReference)) + { + await TryReadReportControlStructureSmartAsync( + candidate, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(candidate.DataSetReference)) + await TryReadReportAttributeSmartAsync( + candidate, "DatSet", cancellationToken).ConfigureAwait(false); + } + + // Exact live DatSet equality is mandatory. A matching SCL RCB + // name alone cannot become an operational report target. + if (expectedDataSets.Contains( + MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference))) + inventory.ReportControls.Add(candidate); + else + rejected++; + } + + // Stop additional catalog pages once every selected DataSet has + // one exact observed binding with explicit read-only "appears free" + // evidence. This is NOT activation authorization: the subsequent + // exact-target availability/owner/directory checks still decide. + // A busy or reservation-unknown RCB never prematurely ends search. + var allTargetsHaveFreeEvidence = expectedDataSets.Count > 0 && + expectedDataSets.All(dataSet => inventory.ReportControls.Any(candidate => + string.Equals( + MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference), + dataSet, StringComparison.Ordinal) && + MmsRcbAvailabilityEvaluator.ParseBool(candidate.EnabledState) == false && + !MmsRcbAvailabilityEvaluator.HasOwner(candidate.Owner) && + (candidate.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(candidate.ReservationTimeSeconds) == 0 + : MmsRcbAvailabilityEvaluator.ParseBool(candidate.ReservationState) == false))); + if (allTargetsHaveFreeEvidence) + break; + } while (continuation is not null && + pages < maxReadPages && + IsMmsInitiated); + + var complete = IsMmsInitiated && + chains.All(chain => chain.IsComplete) && + continuation is null; + var coveredDataSets = inventory.ReportControls + .Select(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference)) + .ToHashSet(StringComparer.Ordinal); + var unresolved = expectedDataSets.Where(ds => !coveredDataSets.Contains(ds)).ToArray(); + var warnings = plan.Warnings + .Concat(chains + .Where(chain => !chain.IsComplete) + .Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}")) + .Concat(rejected > 0 + ? new[] { $"{rejected} probed RCB(s) had no exact selected live DatSet binding; not admitted for activation." } + : Array.Empty()) + .Concat(continuation is not null + ? new[] { $"Target-scoped RCB discovery deferred {Math.Max(0, eligibleProbeCount - examined)} candidate(s) after {pages} bounded page(s); resume using CandidateContinuationToken." } + : Array.Empty()) + .Concat(unresolved.Select(ds => + $"Selected DataSet '{ds}' has no verified live RCB binding in this acquisition window.")) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return new MmsCanonicalStaticLiveReconciliationResult + { + Plan = plan, + Status = !IsMmsInitiated + ? MmsCanonicalStaticLiveReconciliationStatus.AssociationLost + : complete && unresolved.Length == 0 + ? MmsCanonicalStaticLiveReconciliationStatus.Ready + : MmsCanonicalStaticLiveReconciliationStatus.Partial, + Inventory = inventory, + EnumeratedVariableCount = variables.Values.Sum(list => list.Count), + CandidateReportControlCount = catalogCount, + DataSetBoundReportControlCount = inventory.ReportControls.Count, + ExaminedReportControlCount = examined, + DeferredReportControlCount = Math.Max(0, eligibleProbeCount - examined), + CandidateContinuationToken = continuation, + Warnings = warnings + }; + } + + /// + /// Preferred Open-SCL/Discovery-neutral static acquisition entry point. + /// Canonical coverage is resolved locally first. Only when configured static coverage + /// exists does the engine perform targeted live RCB reconciliation, then exact-target + /// availability. Full discovery is never implied by this method. + /// + public async Task PrepareCanonicalStaticAcquisitionSmartAsync( + CanonicalIedModel model, + IEnumerable selections, + MmsIedModelDirectory? directory = null, + MmsCanonicalStaticLiveReconciliationOptions? reconciliationOptions = null, + MmsCanonicalStaticAcquisitionProbeOptions? acquisitionOptions = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + + var requested = selections.ToArray(); + var coverage = CanonicalStaticReportCoverageResolver.Resolve(model, requested); + + var reconciliation = await ReconcileCanonicalStaticReportInventoryAsync( + coverage, + reconciliationOptions, + cancellationToken) + .ConfigureAwait(false); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + reconciliation.Inventory); + + var acquisition = await ProbeCanonicalStaticAcquisitionAsync( + plan, + reconciliation.Inventory, + directory, + acquisitionOptions, + cancellationToken) + .ConfigureAwait(false); + + return new MmsCanonicalStaticSmartPreparationResult + { + Coverage = coverage, + Reconciliation = reconciliation, + Acquisition = acquisition + }; + } +} diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs new file mode 100644 index 00000000..f436ba52 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs @@ -0,0 +1,164 @@ +using System.Security.Cryptography; +using System.Text; +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +public sealed class MmsCanonicalStaticRcbCatalogPage +{ + public IReadOnlyList Candidates { get; init; } + = Array.Empty(); + public int CatalogCount { get; init; } + public int TargetBoundCount { get; init; } + public int EligibleProbeCount { get; init; } + public string? ContinuationToken { get; init; } + public bool HasMore => ContinuationToken is not null; +} + +/// +/// Read-only candidate admission for one selected canonical DataSet scope. +/// An observed DatSet binding is authority; an exact configured RCB reference +/// is only a priority for a Read, never proof of its current DatSet. +/// No sibling name guess can authorize report activation. +/// Catalog size is never a reason to abandon discovery: a page limits only +/// the number of actual read candidates and carries a checked continuation. +/// +public static class MmsCanonicalStaticRcbCatalogPager +{ + public static MmsCanonicalStaticRcbCatalogPage Select( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventory inventory, + int maxReads, + string? continuationToken = null, + string? associationKey = null) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(inventory); + if (maxReads <= 0) + throw new ArgumentOutOfRangeException(nameof(maxReads)); + + // Exact scoped keys are derived from selected SCL coverage, not a + // vendor-specific RCB-name pattern or an arbitrary inventory cap. + var requested = coverage.Segments + .Where(s => s.SelectedSignalReferences.Length > 0) + .SelectMany(segment => segment.ReportControls.Select(report => new + { + Domain = DomainOf(report.MmsDomain, report.Reference), + report.Buffered, + Configured = Normalize(report.Reference), + TargetDataSet = Normalize(segment.DataSetReference) + })) + .Where(target => target.Domain.Length > 0 && target.TargetDataSet.Length > 0) + .ToArray(); + + var selectedDomains = requested.Select(x => (x.Domain, x.Buffered)) + .ToHashSet(); + var targetDataSets = requested.Select(x => x.TargetDataSet) + .ToHashSet(StringComparer.Ordinal); + var configured = requested.Select(x => x.Configured) + .Where(x => x.Length > 0).ToHashSet(StringComparer.Ordinal); + + var catalog = inventory.ReportControls + .Where(c => selectedDomains.Contains((c.Domain, c.Buffered))) + .Where(c => + { + var bound = Normalize(c.DataSetReference); + return bound.Length == 0 || targetDataSets.Contains(bound); + }) + .DistinctBy(c => Normalize(c.Reference), StringComparer.Ordinal) + .OrderBy(c => c.Reference, StringComparer.Ordinal) + .ToArray(); + + var bound = catalog + .Where(c => targetDataSets.Contains(Normalize(c.DataSetReference))) + .ToArray(); + var exactUnbound = catalog + .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && + configured.Contains(Normalize(c.Reference))) + .ToArray(); + + // A proven DataSet scope does not trigger a blind read of other, + // still-unbound RCBs. Without proof, an unknown candidate requires + // a real read of DatSet; exact configured references are probed first. + // A concrete indexed RCB instance (e.g. Events01 for declared + // Events) is ONLY a prioritization hint for a read-only DatSet probe. + // This never constitutes exact DataSet identity or activation proof. + var indexedUnbound = catalog + .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && + !configured.Contains(Normalize(c.Reference)) && + configured.Any(reference => + IsIndexedInstance(Normalize(c.Reference), reference))) + .ToArray(); + var indexedSet = indexedUnbound.ToHashSet(); + + var probes = bound.Length > 0 + ? bound.Concat(exactUnbound).ToArray() + : exactUnbound.Concat(indexedUnbound).Concat(catalog + .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && + !configured.Contains(Normalize(c.Reference)) && + !indexedSet.Contains(c))).ToArray(); + + // The token is bound to the exact catalog/selection. On a changed + // inventory it fails closed instead of skipping an arbitrary RCB. + var input = new StringBuilder(); + input.Append("association=").Append(associationKey ?? "detached").Append('\n'); + foreach (var target in requested + .OrderBy(t => t.Domain, StringComparer.Ordinal) + .ThenBy(t => t.Configured, StringComparer.Ordinal) + .ThenBy(t => t.TargetDataSet, StringComparer.Ordinal)) + input.Append(target.Domain).Append('|').Append(target.Buffered) + .Append('|').Append(target.Configured).Append('|') + .Append(target.TargetDataSet).Append('\n'); + foreach (var item in catalog) + input.Append(item.Reference).Append('|').Append(item.DataSetReference).Append('\n'); + var fingerprint = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(input.ToString()))); + + var start = 0; + if (continuationToken is not null) + { + var parts = continuationToken.Split(':'); + if (parts.Length != 2 || + !string.Equals(parts[0], fingerprint, StringComparison.Ordinal) || + !int.TryParse(parts[1], out start) || start < 0 || start >= probes.Length) + throw new ArgumentException( + "RCB continuation does not belong to the current target catalog.", + nameof(continuationToken)); + } + + var size = Math.Min(maxReads, probes.Length - start); + var next = start + size; + return new MmsCanonicalStaticRcbCatalogPage + { + CatalogCount = catalog.Length, + TargetBoundCount = bound.Length, + EligibleProbeCount = probes.Length, + Candidates = probes.Skip(start).Take(size).ToArray(), + ContinuationToken = next < probes.Length ? $"{fingerprint}:{next}" : null + }; + } + + private static bool IsIndexedInstance(string candidate, string configured) + { + if (configured.Length == 0 || candidate.Length <= configured.Length || + !candidate.StartsWith(configured, StringComparison.Ordinal)) + return false; + // Exact numeric instance suffix only: no arbitrary text similarity, + // and still no usable target until the live DatSet read succeeds. + for (var i = configured.Length; i < candidate.Length; i++) + if (candidate[i] is < '0' or > '9') + return false; + return true; + } + + private static string Normalize(string? reference) + => MmsRcbAvailabilityEvaluator.NormalizeReference(reference); + + private static string DomainOf(string? domain, string? reference) + { + if (!string.IsNullOrWhiteSpace(domain)) + return domain.Trim(); + var slash = reference?.IndexOf('/') ?? -1; + return slash > 0 ? reference![..slash] : string.Empty; + } +} diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs new file mode 100644 index 00000000..8e12ae40 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs @@ -0,0 +1,138 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +public sealed class MmsCanonicalStaticRcbTargetSegment +{ + public string DataSetReference { get; init; } = string.Empty; + public IReadOnlyList ConfiguredReportControlReferences { get; init; } = Array.Empty(); + public IReadOnlyList ExactLiveReportControlReferences { get; init; } = Array.Empty(); + public string Resolution { get; init; } = string.Empty; +} + +public sealed class MmsCanonicalStaticRcbTargetResolution +{ + public IReadOnlySet ExactLiveReportControlReferences { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); + public IReadOnlyList Segments { get; init; } + = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public bool HasTargets => ExactLiveReportControlReferences.Count > 0; + public int CoveredDataSetCount => Segments.Count; +} + +/// +/// Resolves canonical configured-static coverage to concrete live RCB references without +/// inventing indexed names. +/// +/// Exact live identity is accepted from either: +/// 1) an exact configured RCB reference match, or +/// 2) a live RCB whose live DatSet binding exactly matches the canonical covered DataSet +/// and whose BRCB/URCB family is declared for that segment. +/// +/// If neither evidence path is available the segment remains unresolved. The caller may +/// explicitly choose a broader diagnostic probe, but this resolver never guesses a sibling +/// RCB merely because its name looks similar. +/// +public static class MmsCanonicalStaticRcbTargetResolver +{ + public static MmsCanonicalStaticRcbTargetResolution Resolve( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventory liveInventory) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(liveInventory); + + var allTargets = new HashSet(StringComparer.OrdinalIgnoreCase); + var segments = new List(); + var warnings = new List(); + + foreach (var segment in coverage.Segments) + { + if (segment.SelectedSignalReferences.Length == 0) + continue; + + var canonicalDataSet = Normalize(segment.DataSetReference); + var configuredReferences = segment.ReportControls + .Select(report => Normalize(report.Reference)) + .Where(reference => reference.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var configuredBufferedFamilies = segment.ReportControls + .Select(report => report.Buffered) + .Distinct() + .ToHashSet(); + + var exactReferenceMatches = liveInventory.ReportControls + .Where(candidate => configuredReferences.Contains(Normalize(candidate.Reference))) + .Where(candidate => + configuredBufferedFamilies.Count == 0 || + configuredBufferedFamilies.Contains(candidate.Buffered)) + .Where(candidate => + canonicalDataSet.Length == 0 || + string.IsNullOrWhiteSpace(candidate.DataSetReference) || + string.Equals( + Normalize(candidate.DataSetReference), + canonicalDataSet, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + var exactDataSetMatches = canonicalDataSet.Length == 0 + ? Array.Empty() + : liveInventory.ReportControls + .Where(candidate => + string.Equals( + Normalize(candidate.DataSetReference), + canonicalDataSet, + StringComparison.OrdinalIgnoreCase)) + .Where(candidate => + configuredBufferedFamilies.Count == 0 || + configuredBufferedFamilies.Contains(candidate.Buffered)) + .ToArray(); + + var matched = exactReferenceMatches + .Concat(exactDataSetMatches) + .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Reference)) + .DistinctBy(candidate => Normalize(candidate.Reference), StringComparer.OrdinalIgnoreCase) + .OrderBy(candidate => candidate.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + foreach (var candidate in matched) + allTargets.Add(candidate.Reference); + + var resolution = matched.Length > 0 + ? exactDataSetMatches.Length > 0 + ? "exact-live-dataset-binding" + : "exact-configured-rcb-reference" + : "unresolved"; + + if (matched.Length == 0) + { + warnings.Add( + $"Canonical static DataSet {segment.DataSetReference} covers selected signal(s), but no concrete live RCB could be proven by exact reference or exact live DatSet binding. No sibling/name-family guess was made."); + } + + segments.Add(new MmsCanonicalStaticRcbTargetSegment + { + DataSetReference = segment.DataSetReference, + ConfiguredReportControlReferences = configuredReferences + .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase) + .ToArray(), + ExactLiveReportControlReferences = matched + .Select(candidate => candidate.Reference) + .ToArray(), + Resolution = resolution + }); + } + + return new MmsCanonicalStaticRcbTargetResolution + { + ExactLiveReportControlReferences = allTargets, + Segments = segments, + Warnings = warnings + }; + } + + private static string Normalize(string? value) + => MmsRcbAvailabilityEvaluator.NormalizeReference(value); +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs new file mode 100644 index 00000000..76729fcc --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs @@ -0,0 +1,46 @@ +using System.Collections.Concurrent; + +namespace AR.Iec61850.Mms; + +/// +/// Association-scoped cache for immutable configured DataSet directories. +/// +/// Only successfully read non-deletable DataSets are cached. Dynamic/deletable or +/// unknown-mutability DataSets are never cached because their membership can change. +/// The cache is cleared on every successful MMS association. +/// +public sealed partial class MmsClientSession +{ + private readonly ConcurrentDictionary _configuredStaticDataSetDirectoryEvidence = + new(StringComparer.OrdinalIgnoreCase); + + private void ResetConfiguredStaticDataSetDirectoryEvidence() + => _configuredStaticDataSetDirectoryEvidence.Clear(); + + private async Task<(MmsDataSetDirectoryResult Result, bool CacheHit)> GetAvailabilityDataSetDirectoryAsync( + string dataSetReference, + MmsIedModelDirectory? directory, + CancellationToken cancellationToken) + { + var key = MmsRcbAvailabilityEvaluator.NormalizeReference(dataSetReference); + if (key.Length > 0 && + _configuredStaticDataSetDirectoryEvidence.TryGetValue(key, out var cached)) + { + return (cached, true); + } + + var result = await GetDataSetDirectoryAsync( + dataSetReference, + directory, + cancellationToken).ConfigureAwait(false); + + if (key.Length > 0 && + result.IsSuccess && + result.IsDeletable == false) + { + _configuredStaticDataSetDirectoryEvidence[key] = result; + } + + return (result, false); + } +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs new file mode 100644 index 00000000..ef0b1768 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs @@ -0,0 +1,175 @@ +namespace AR.Iec61850.Mms; + +/// +/// Hot-path RCB state capture for configured-static activation. +/// +/// Normal discovery/diagnostic probing intentionally reads many attributes. Activation must +/// not repeat that broad scan. This path attempts one complete RCB structure read first and +/// falls back only to the exact leaf evidence still required for the imminent mutation. +/// There are no sleeps and no unrelated RCB/DataSet scans. +/// +public sealed partial class MmsClientSession +{ + private async Task CaptureConfiguredStaticActivationSnapshotAsync( + MmsReportControlCandidate source, + string stage, + bool includeReservationEvidence, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(source); + + var clone = CloneReportControlCandidate(source); + clone.EnabledState = string.Empty; + clone.ReservationState = string.Empty; + clone.ReservationTimeSeconds = string.Empty; + clone.Owner = string.Empty; + clone.DataSetReference = string.Empty; + clone.DataSetProbeState = MmsRcbDataSetProbeState.NotAttempted; + clone.DataSetProbeMessage = string.Empty; + clone.ProbeDiagnostics.Clear(); + + var logicalReads = 0; + var structureRead = false; + var leafFallbacks = 0; + + try + { + var baseReference = MmsObjectReference.Parse( + source.Reference, + source.FunctionalConstraint); + var baseRead = await ReadSingleVariableAsync( + baseReference, + cancellationToken).ConfigureAwait(false); + logicalReads++; + + clone.ProbeDiagnostics.Add( + $"Activation RCB base {baseReference.Item}: {(baseRead.IsSuccess ? "OK" : baseRead.Message)}"); + + if (baseRead.IsSuccess && + baseRead.Value is { Kind: MmsDataKind.Structure } structure && + structure.Children.Count > 0) + { + ApplyReportControlStructure(clone, structure); + structureRead = true; + + // DatSet is element 2 for BRCB and element 3 for URCB in the standard RCB + // structure layouts used by ApplyReportControlStructure. + var dataSetIndex = source.Buffered ? 2 : 3; + if (structure.Children.Count > dataSetIndex) + { + clone.DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded; + clone.DataSetProbeMessage = "DatSet observed in complete RCB structure read."; + } + } + + if (MmsRcbAvailabilityEvaluator.ParseBool(clone.EnabledState) is null) + { + leafFallbacks++; + logicalReads++; + await ProbeReportControlAttributeAsync( + clone, + "RptEna", + value => clone.EnabledState = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + } + + // A positive live DatSet contradiction is safety-relevant. If the complete + // structure did not provide the field, read only this one leaf. + if (clone.DataSetProbeState != MmsRcbDataSetProbeState.ReadSucceeded) + { + leafFallbacks++; + logicalReads++; + var beforeDiagnostics = clone.ProbeDiagnostics.Count; + await ProbeReportControlAttributeAsync( + clone, + "DatSet", + value => + { + var text = NormalizeReportAttributeText(value); + clone.DataSetReference = string.IsNullOrWhiteSpace(text) + ? string.Empty + : NormalizeReportedDataSetReference(clone.Domain, text); + }, + cancellationToken).ConfigureAwait(false); + + var datSetDiagnostic = clone.ProbeDiagnostics + .Skip(beforeDiagnostics) + .LastOrDefault(line => line.StartsWith("DatSet", StringComparison.OrdinalIgnoreCase)); + if (datSetDiagnostic?.Contains(": OK ", StringComparison.OrdinalIgnoreCase) == true) + { + clone.DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded; + clone.DataSetProbeMessage = datSetDiagnostic; + } + else + { + clone.DataSetProbeState = MmsRcbDataSetProbeState.ReadFailed; + clone.DataSetProbeMessage = datSetDiagnostic ?? "DatSet activation leaf read did not produce exact evidence."; + } + } + + if (includeReservationEvidence) + { + if (source.Buffered) + { + if (MmsRcbAvailabilityEvaluator.ParseUnsigned(clone.ReservationTimeSeconds) is null && + source.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase)) + { + leafFallbacks++; + logicalReads++; + await ProbeReportControlAttributeAsync( + clone, + "ResvTms", + value => clone.ReservationTimeSeconds = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + } + } + else if (MmsRcbAvailabilityEvaluator.ParseBool(clone.ReservationState) is null && + source.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) + { + leafFallbacks++; + logicalReads++; + await ProbeReportControlAttributeAsync( + clone, + "Resv", + value => clone.ReservationState = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + } + + // Owner is a separate optional leaf on many IEDs. Avoid paying this RTT + // when explicit reservation evidence is already free. Probe it only when + // reservation evidence remains ambiguous or earlier evidence was positive. + var explicitFree = source.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(clone.ReservationTimeSeconds) == 0 + : MmsRcbAvailabilityEvaluator.ParseBool(clone.ReservationState) == false; + var earlierPositiveOwner = MmsRcbAvailabilityEvaluator.HasOwner(source.Owner); + if (source.Attributes.Contains("Owner", StringComparer.OrdinalIgnoreCase) && + (!explicitFree || earlierPositiveOwner)) + { + leafFallbacks++; + logicalReads++; + await ProbeOwnerReadOnlyAsync(clone, cancellationToken).ConfigureAwait(false); + } + } + + var hasExactEnable = MmsRcbAvailabilityEvaluator.ParseBool(clone.EnabledState).HasValue; + var message = + $"Configured-static JIT snapshot: exactRptEna={hasExactEnable.ToString().ToLowerInvariant()}, " + + $"structureRead={structureRead.ToString().ToLowerInvariant()}, logicalReads={logicalReads}, " + + $"leafFallbacks={leafFallbacks}, dataSetProbe={clone.DataSetProbeState}."; + + return MmsReportRcbSnapshot.FromCandidate( + stage, + clone, + success: hasExactEnable, + message); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return MmsReportRcbSnapshot.FromCandidate( + stage, + clone, + success: false, + $"Configured-static JIT snapshot failed after logicalReads={logicalReads}: {ex.GetType().Name}: {ex.Message}"); + } + } +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs index 22c40a57..5a859e87 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs @@ -16,39 +16,49 @@ public async Task CheckReportControlAvailabilityAsync( var warnings = new List(); var snapshots = new List(); var dataSetDirectories = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rcbStateLogicalReads = 0; + var dataSetDirectoryNetworkReads = 0; + var dataSetDirectoryCacheHits = 0; + var callerOwned = options.CallerOwnedRcbReferences .Select(MmsRcbAvailabilityEvaluator.NormalizeReference) .Where(reference => reference.Length > 0) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var max = Math.Clamp(options.MaxReportControls, 1, 4096); - var candidates = inventory.ReportControls - .OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference)) - .ThenByDescending(candidate => candidate.Buffered) - .ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase) - .ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase) - .Take(max) - .ToArray(); - - if (inventory.ReportControls.Count > candidates.Length) - warnings.Add($"Availability check was bounded to {candidates.Length} of {inventory.ReportControls.Count} discovered RCBs."); - - foreach (var source in candidates) + + var selection = MmsRcbAvailabilityTargetSelector.Select(inventory, options); + warnings.AddRange(selection.Warnings); + + foreach (var source in selection.Candidates) { cancellationToken.ThrowIfCancellationRequested(); var candidate = CloneReportControl(source); - // Preserve discovery/SCL-derived binding only as fallback evidence. Clear the - // candidate before the forced live probe so a successful empty DatSet read can - // be distinguished from a failed read that merely left an old value in memory. + // Preserve discovery/SCL-derived binding only as fallback evidence. Volatile + // runtime state is cleared before every live check so stale discovery values + // can never masquerade as just-in-time availability evidence. var previouslyKnownDataSetReference = candidate.DataSetReference; candidate.DataSetReference = string.Empty; candidate.DataSetProbeState = MmsRcbDataSetProbeState.NotAttempted; candidate.DataSetProbeMessage = string.Empty; + candidate.EnabledState = string.Empty; + candidate.ReservationState = string.Empty; + candidate.ReservationTimeSeconds = string.Empty; + candidate.Owner = string.Empty; + + if (selection.TargetFilterApplied) + { + rcbStateLogicalReads += await ProbeTargetedReportControlAvailabilityAsync( + candidate, + cancellationToken).ConfigureAwait(false); + } + else + { + await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false); + // Broad diagnostic mode intentionally retains full attribute/Owner evidence. + await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false); + } - await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false); CaptureDataSetProbeEvidence(candidate, previouslyKnownDataSetReference); - await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false); MmsDataSetDirectoryResult? dataSetDirectory = null; var dataSetReference = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference); @@ -56,7 +66,15 @@ public async Task CheckReportControlAvailabilityAsync( { if (!dataSetDirectories.TryGetValue(dataSetReference, out dataSetDirectory)) { - dataSetDirectory = await GetDataSetDirectoryAsync(candidate.DataSetReference, directory, cancellationToken).ConfigureAwait(false); + var directoryEvidence = await GetAvailabilityDataSetDirectoryAsync( + candidate.DataSetReference, + directory, + cancellationToken).ConfigureAwait(false); + dataSetDirectory = directoryEvidence.Result; + if (directoryEvidence.CacheHit) + dataSetDirectoryCacheHits++; + else + dataSetDirectoryNetworkReads++; dataSetDirectories[dataSetReference] = dataSetDirectory; } } @@ -71,6 +89,13 @@ public async Task CheckReportControlAvailabilityAsync( return new MmsRcbAvailabilityResult { CheckedAtUtc = checkedAt, + InventoryReportControlCount = inventory.ReportControls.Count, + TargetFilterApplied = selection.TargetFilterApplied, + RequestedTargetReportControlCount = selection.RequestedTargetCount, + MatchedTargetReportControlCount = selection.MatchedTargetCount, + TargetedRcbStateLogicalReadCount = rcbStateLogicalReads, + DataSetDirectoryNetworkReadCount = dataSetDirectoryNetworkReads, + DataSetDirectoryCacheHitCount = dataSetDirectoryCacheHits, ReportControls = snapshots .OrderByDescending(item => item.DataSetMemberCount > 0) .ThenBy(item => AvailabilityRank(item.Availability)) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs index 5fb5da12..af26a9cb 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs @@ -45,15 +45,27 @@ public static IReadOnlyList BuildBrcbEnableFa public sealed partial class MmsClientSession { /// - /// Starts a persistent monitor for a static DataSet already trusted from SCL. + /// Backward-compatible SCL entry point. Once a caller supplies an authoritative + /// ordered DataSet and configured RCB, activation is source-neutral: Discovery and + /// Open-SCL must execute the same wire lifecycle rather than maintain parallel + /// reporting implementations. + /// + public Task StartStaticSclReportMonitorAsync( + MmsReportSubscriptionPlan plan, + bool triggerGeneralInterrogation = false, + CancellationToken cancellationToken = default) + => StartConfiguredStaticReportMonitorAsync(plan, triggerGeneralInterrogation, cancellationToken); + + /// + /// Starts a persistent monitor for an authoritative configured static DataSet. /// The primary wire sequence is deliberately minimal: whole-RCB Read, optional /// URCB Resv=true, RptEna=true, then two whole-RCB verification Reads. BRCB /// ResvTms is a retry-only compatibility fallback when direct RptEna is rejected. - /// No DataSet browsing/creation and no GI are performed unless explicitly requested. - /// When explicit GI is requested, GI acceptance is part of startup success so a - /// caller cannot report an active initial-image monitor when the GI write was rejected. + /// No DataSet browsing/creation is performed here. When explicit GI is requested, + /// GI acceptance is part of startup success so a caller cannot report an active + /// initial-image monitor when the GI write was rejected. /// - public async Task StartStaticSclReportMonitorAsync( + public async Task StartConfiguredStaticReportMonitorAsync( MmsReportSubscriptionPlan plan, bool triggerGeneralInterrogation = false, CancellationToken cancellationToken = default) @@ -66,7 +78,7 @@ public async Task StartStaticSclReportMon return new MmsPersistentReportMonitorStartResult { IsSuccess = false, - Message = "Trusted-SCL static report activation requires a ready plan with selected RCB." + Message = "Configured static report activation requires a ready plan with selected RCB." }; } @@ -75,7 +87,7 @@ public async Task StartStaticSclReportMon return new MmsPersistentReportMonitorStartResult { IsSuccess = false, - Message = "Trusted-SCL static report activation never creates or mutates a dynamic DataSet." + Message = "Configured static report activation never creates or mutates a dynamic DataSet." }; } @@ -89,10 +101,44 @@ public async Task StartStaticSclReportMon try { - snapshots.Add(await CaptureReportControlSnapshotAsync( + var beforeSnapshot = await CaptureConfiguredStaticActivationSnapshotAsync( rcb, - "scl-static-before-enable", - cancellationToken).ConfigureAwait(false)); + "configured-static-before-enable", + includeReservationEvidence: true, + cancellationToken).ConfigureAwait(false); + snapshots.Add(beforeSnapshot); + + var beforeProof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite( + beforeSnapshot, + plan.DataSetReference); + if (!beforeProof.IsProven) + { + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + WriteSteps = writes, + Warnings = warnings, + RcbSnapshots = snapshots, + Message = $"Configured static activation failed JIT pre-write proof: {beforeProof.Kind}: {beforeProof.Message}" + }; + } + + var normalPathWrites = new List + { + MmsReportControlSemanticWrite.ReportEnable(true) + }; + if (!rcb.Buffered && + rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) + { + normalPathWrites.Add(MmsReportControlSemanticWrite.Reservation(true)); + } + if (triggerGeneralInterrogation) + normalPathWrites.Add(MmsReportControlSemanticWrite.GeneralInterrogation(true)); + + await PrimeReportSemanticTypeEvidenceFromStructureAsync( + rcb, + normalPathWrites, + cancellationToken).ConfigureAwait(false); monitor = new MmsPersistentReportMonitorSession( plan, @@ -107,10 +153,8 @@ public async Task StartStaticSclReportMon if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync( - rcb, - "Resv", - MmsDataValue.Boolean(true), + var reserve = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = reserve.IsSuccess; @@ -119,10 +163,8 @@ public async Task StartStaticSclReportMon warnings.Add("URCB Resv=true was not accepted; direct RptEna will still be attempted once."); } - var enable = await WriteReportAttributeAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(true), + var enable = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabled = enable.IsSuccess; @@ -131,10 +173,8 @@ public async Task StartStaticSclReportMon rcb.Buffered && rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase)) { - var reserveBrcb = await WriteReportAttributeAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(60), + var reserveBrcb = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(60), cancellationToken).ConfigureAwait(false); writes.Add(reserveBrcb); reservationTouched = reserveBrcb.IsSuccess; @@ -142,10 +182,8 @@ public async Task StartStaticSclReportMon if (reserveBrcb.IsSuccess) { - var retryEnable = await WriteReportAttributeAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(true), + var retryEnable = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(retryEnable); enabled = retryEnable.IsSuccess; @@ -160,15 +198,11 @@ public async Task StartStaticSclReportMon if (reservationTouched) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync( - rcb, - "Resv", - MmsDataValue.Boolean(false), + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); } @@ -179,27 +213,62 @@ public async Task StartStaticSclReportMon WriteSteps = writes, Warnings = warnings, RcbSnapshots = snapshots, - Message = "RptEna=true was not accepted; trusted-SCL static report monitor was not started." + Message = "RptEna=true was not accepted; configured static report monitor was not started." }; } monitor.EnabledByThisClient = true; - snapshots.Add(await CaptureReportControlSnapshotAsync( + var afterEnable = await CaptureConfiguredStaticActivationSnapshotAsync( rcb, - "scl-static-after-enable-1", - cancellationToken).ConfigureAwait(false)); - snapshots.Add(await CaptureReportControlSnapshotAsync( - rcb, - "scl-static-after-enable-2", - cancellationToken).ConfigureAwait(false)); + "configured-static-after-enable", + includeReservationEvidence: false, + cancellationToken).ConfigureAwait(false); + snapshots.Add(afterEnable); - if (triggerGeneralInterrogation) + var enableProof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable( + afterEnable, + plan.DataSetReference); + if (!enableProof.IsProven) { - var gi = await WriteReportAttributeAsync( + UnregisterPersistentReportMonitor(monitor); + + writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync( rcb, - "GI", - MmsDataValue.Boolean(true), + MmsReportControlSemanticWrite.ReportEnable(false), + CancellationToken.None).ConfigureAwait(false)); + enabled = false; + monitor.EnabledByThisClient = false; + + if (reservationTouched) + { + writes.Add(rcb.Buffered + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, + MmsReportControlSemanticWrite.ReservationTime(0), + CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, + MmsReportControlSemanticWrite.Reservation(false), + CancellationToken.None).ConfigureAwait(false)); + reservationTouched = false; + monitor.ReservationTouched = false; + } + + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + WriteSteps = writes, + Warnings = warnings, + RcbSnapshots = snapshots, + Message = $"Configured static activation failed post-enable proof: {enableProof.Kind}: {enableProof.Message}" + }; + } + + if (triggerGeneralInterrogation) + { + var gi = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); @@ -208,10 +277,8 @@ public async Task StartStaticSclReportMon warnings.Add("Explicit GI=true request was rejected; initial-image startup failed closed."); UnregisterPersistentReportMonitor(monitor); - writes.Add(await TryWriteReportAttributeForCleanupAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(false), + writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false)); enabled = false; monitor.EnabledByThisClient = false; @@ -219,15 +286,11 @@ public async Task StartStaticSclReportMon if (reservationTouched) { writes.Add(rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync( - rcb, - "Resv", - MmsDataValue.Boolean(false), + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false)); reservationTouched = false; monitor.ReservationTouched = false; @@ -252,8 +315,8 @@ public async Task StartStaticSclReportMon Warnings = warnings, RcbSnapshots = snapshots, Message = triggerGeneralInterrogation - ? $"Trusted-SCL static report monitor armed for {rcb.Reference}; explicit one-shot GI=true was accepted after activation." - : $"Trusted-SCL static report monitor armed for {rcb.Reference}; no DataSet discovery/mutation or GI was performed." + ? $"Configured static report monitor armed for {rcb.Reference}; explicit one-shot GI=true was accepted after activation." + : $"Configured static report monitor armed for {rcb.Reference}; no DataSet discovery/mutation or GI was performed." }; } catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) @@ -263,25 +326,19 @@ public async Task StartStaticSclReportMon if (enabled) { - writes.Add(await TryWriteReportAttributeForCleanupAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(false), + writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false)); } if (reservationTouched) { writes.Add(rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync( - rcb, - "Resv", - MmsDataValue.Boolean(false), + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false)); } @@ -291,7 +348,7 @@ public async Task StartStaticSclReportMon WriteSteps = writes, Warnings = warnings, RcbSnapshots = snapshots, - Message = $"Trusted-SCL static report activation failed: {ex.GetType().Name}: {ex.Message}" + Message = $"Configured static report activation failed: {ex.GetType().Name}: {ex.Message}" }; } } diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs index c6fbfb14..67aa1722 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -47,6 +47,15 @@ public sealed class MmsSmartDiscoveryOptions public bool ReadDataSetDirectories { get; init; } public int MaxDataSetDirectoryReads { get; init; } = 64; + + /// + /// Limit on additional live DataSet-verified logical-node roots in the opt-in + /// type-probe overload (no additional GetNameList pages or cyclic polls). + /// + public int MaxDataSetTypeExtraLogicalNodes { get; init; } = 48; + + /// Hard cap on admitted positional DataSet member type hints. + public int MaxDataSetTypeMemberHints { get; init; } = 2048; } public sealed partial class MmsClientSession @@ -147,6 +156,7 @@ public async Task DiscoverSmartAsync( await EnrichReportInventorySmartAsync( inventory, Math.Max(0, options.MaxReportAttributeProbes), + effectiveWindow, cancellationToken) .ConfigureAwait(false); } @@ -241,65 +251,103 @@ await EnrichReportInventorySmartAsync( }; } + /// + /// Independent RCBs may be read concurrently up to the peer-negotiated + /// outstanding-calling window. A single RCB remains strictly sequential + /// (typed base structure then exact missing-field fallbacks). This is + /// finite, read-only and retains every selected candidate and request. + /// A global 64-RCB SERIAL loop previously multiplied WAN/IED latency. + /// private async Task EnrichReportInventorySmartAsync( MmsReportInventory inventory, int maxReportAttributeProbes, + int effectiveWindow, CancellationToken cancellationToken) { if (maxReportAttributeProbes <= 0 || inventory.ReportControls.Count == 0 || !IsMmsInitiated) - { return; - } - foreach (var reportControl in inventory.ReportControls - .OrderByDescending(control => control.Buffered) - .ThenByDescending(control => - control.LogicalNode.Equals("LLN0", StringComparison.OrdinalIgnoreCase)) - .Take(maxReportAttributeProbes)) + var candidates = inventory.ReportControls + .OrderByDescending(control => control.Buffered) + .ThenByDescending(control => + control.LogicalNode.Equals("LLN0", StringComparison.OrdinalIgnoreCase)) + .Take(maxReportAttributeProbes) + .ToArray(); + + // The association's negotiatedCalling is authoritative. Never create + // speculative associations or exceed the transport's existing window. + var workers = MmsSmartDiscoveryPolicy.ResolveReportEnrichmentWorkers( + effectiveWindow, candidates.Length); + var next = -1; + async Task WorkerAsync() { - cancellationToken.ThrowIfCancellationRequested(); - - // A complete RCB value is normally one MMS structure. Reading that structure - // first avoids 8-9 individual confirmed Reads per runtime instance while - // preserving the same static configuration evidence. - await TryReadReportControlStructureSmartAsync( - reportControl, - cancellationToken) - .ConfigureAwait(false); - - // Fall back only for static fields that the structured value did not expose. - // Every fallback is individually observed by the smart KPI recorder. - if (string.IsNullOrWhiteSpace(reportControl.DataSetReference)) - await TryReadReportAttributeSmartAsync(reportControl, "DatSet", cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(reportControl.ReportId)) - await TryReadReportAttributeSmartAsync(reportControl, "RptID", cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(reportControl.ConfRev)) - await TryReadReportAttributeSmartAsync(reportControl, "ConfRev", cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(reportControl.IntegrityPeriodMs)) - await TryReadReportAttributeSmartAsync(reportControl, "IntgPd", cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(reportControl.BufferTimeMs)) - await TryReadReportAttributeSmartAsync(reportControl, "BufTm", cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(reportControl.TriggerOptions)) - await TryReadReportAttributeSmartAsync(reportControl, "TrgOps", cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(reportControl.OptionalFields)) - await TryReadReportAttributeSmartAsync(reportControl, "OptFlds", cancellationToken).ConfigureAwait(false); - - if (reportControl.Buffered) - { - if (string.IsNullOrWhiteSpace(reportControl.ReservationTimeSeconds)) - await TryReadReportAttributeSmartAsync(reportControl, "ResvTms", cancellationToken).ConfigureAwait(false); - } - else if (string.IsNullOrWhiteSpace(reportControl.ReservationState)) + while (true) { - await TryReadReportAttributeSmartAsync(reportControl, "Resv", cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var index = Interlocked.Increment(ref next); + if (index >= candidates.Length) + return; + // An association loss must not silently present a partial RCB + // enrichment as completed. The outer smart-discovery handler + // will mark an interrupted/partial evidence window instead. + if (!IsMmsInitiated) + throw new OperationCanceledException( + "MMS association stopped during bounded RCB enrichment."); + await EnrichSingleReportControlSmartAsync(candidates[index], cancellationToken) + .ConfigureAwait(false); } + } + + await Task.WhenAll(Enumerable.Range(0, workers).Select(_ => WorkerAsync())) + .ConfigureAwait(false); + } + + private async Task EnrichSingleReportControlSmartAsync( + MmsReportControlCandidate reportControl, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + // A complete RCB value is normally one MMS structure. Reading that structure + // first avoids 8-9 individual confirmed Reads per runtime instance while + // preserving the same static configuration evidence. + await TryReadReportControlStructureSmartAsync( + reportControl, + cancellationToken) + .ConfigureAwait(false); - reportControl.Status = HasUsefulReportProbeData(reportControl) - ? "Attribute-probed" - : reportControl.Status; + // Fall back only for static fields that the structured value did not expose. + // Every fallback is individually observed by the smart KPI recorder. + if (string.IsNullOrWhiteSpace(reportControl.DataSetReference)) + await TryReadReportAttributeSmartAsync(reportControl, "DatSet", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.ReportId)) + await TryReadReportAttributeSmartAsync(reportControl, "RptID", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.ConfRev)) + await TryReadReportAttributeSmartAsync(reportControl, "ConfRev", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.IntegrityPeriodMs)) + await TryReadReportAttributeSmartAsync(reportControl, "IntgPd", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.BufferTimeMs)) + await TryReadReportAttributeSmartAsync(reportControl, "BufTm", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.TriggerOptions)) + await TryReadReportAttributeSmartAsync(reportControl, "TrgOps", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.OptionalFields)) + await TryReadReportAttributeSmartAsync(reportControl, "OptFlds", cancellationToken).ConfigureAwait(false); + + if (reportControl.Buffered) + { + if (string.IsNullOrWhiteSpace(reportControl.ReservationTimeSeconds)) + await TryReadReportAttributeSmartAsync(reportControl, "ResvTms", cancellationToken).ConfigureAwait(false); } + else if (string.IsNullOrWhiteSpace(reportControl.ReservationState)) + { + await TryReadReportAttributeSmartAsync(reportControl, "Resv", cancellationToken).ConfigureAwait(false); + } + + reportControl.Status = HasUsefulReportProbeData(reportControl) + ? "Attribute-probed" + : reportControl.Status; + } private async Task TryReadReportControlStructureSmartAsync( diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs index 59e36114..ee0d325d 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs @@ -8,6 +8,14 @@ namespace AR.Iec61850.Mms; public sealed partial class MmsClientSession { private readonly object _smartDiscoverySingleFlightSync = new(); + // Reuse a complete legacy control directory once per association when the + // smart-discovery inventory is not present (notably trusted Open SCL). + // This is structural metadata only; ctlModel and Oper/SBOw types stay live. + private Task>>? _controlNameInventoryTask; + private object? _controlNameInventoryAssociationMarker; + private string _controlNameInventoryHost = string.Empty; + private int _controlNameInventoryPort; + private object? _smartDiscoverySingleFlightAssociationMarker; private string _smartDiscoverySingleFlightHost = string.Empty; private int _smartDiscoverySingleFlightPort; @@ -102,7 +110,52 @@ internal async Task>> GetAutho } } - return await DiscoverDomainVariableNamesAsync(cancellationToken).ConfigureAwait(false); + return await GetLegacyControlInventoryOnceAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task>> GetLegacyControlInventoryOnceAsync( + CancellationToken cancellationToken) + { + var marker = LastAssociationAttempts; + var host = _lastHost; + var port = _lastPort; + Task>> pending; + lock (_smartDiscoverySingleFlightSync) + { + if (!ReferenceEquals(_controlNameInventoryAssociationMarker, marker) || + !string.Equals(_controlNameInventoryHost, host, StringComparison.OrdinalIgnoreCase) || + _controlNameInventoryPort != port) + { + _controlNameInventoryTask = null; + _controlNameInventoryAssociationMarker = marker; + _controlNameInventoryHost = host; + _controlNameInventoryPort = port; + } + pending = _controlNameInventoryTask ??= DiscoverDomainVariableNamesAsync(CancellationToken.None); + } + try + { + var result = await pending.WaitAsync(cancellationToken).ConfigureAwait(false); + // Never preserve empty/partial inventory as evidence after a failed + // enumeration; the next inspection can retry on the live association. + if (result.Count == 0 || result.Values.Any(names => names.Count == 0)) + ClearFailedControlInventory(pending); + return result; + } + catch + { + ClearFailedControlInventory(pending); + throw; + } + } + + private void ClearFailedControlInventory(Task>> task) + { + lock (_smartDiscoverySingleFlightSync) + { + if (ReferenceEquals(_controlNameInventoryTask, task)) + _controlNameInventoryTask = null; + } } private void ObserveSmartDiscoverySingleFlightCompletion(Task task) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs index 316e872a..3d189259 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -263,6 +263,59 @@ public async Task> GetVariableA return results; } + + /// + /// Exact static DataSet-member LN type closure. Shares the negotiated invoke + /// window and KPI accounting with smart hierarchy probes. This never reads + /// process values, mutates DataSets, or opens another MMS association. + /// + public async Task> ProbeDataSetMemberTypesSmartAsync( + MmsDiscoveryResult discovery, + IReadOnlyList previousTypes, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + ArgumentNullException.ThrowIfNull(discovery); + ArgumentNullException.ThrowIfNull(previousTypes); + var roots = MmsSmartTypeProbePolicy.SelectUnprobedDataSetLogicalNodeRoots( + discovery.IedDirectory, discovery.DataSetDirectories, previousTypes); + if (roots.Length == 0) + return Array.Empty(); + var results = await RunVariableAttributeBatchAsync( + roots, ResolveSmartDiscoveryWindow(options ?? new MmsSmartDiscoveryOptions()), + cancellationToken).ConfigureAwait(false); + + // Authorize previously unseen LN roots only when two independent live MMS + // observations agree: an exact configured DataSet FCDA/FCD member and a + // successful typed GetVariableAccessAttributes response for that LN. + // Never materialize a foreign LN from an unsolicited GVA result. + var successfulRoots = results + .Where(result => result.IsSuccess && result.TypeSpecification is not null) + .Select(result => result.Reference.Domain + "/" + result.Reference.Item) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var membershipSeeds = discovery.DataSetDirectories + .Where(dataSet => dataSet.IsSuccess) + .SelectMany(dataSet => dataSet.Members) + .Where(member => successfulRoots.Contains(member.Domain + "/" + member.LogicalNode) && + !string.IsNullOrWhiteSpace(member.DataObjectPath) && + (member.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + member.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase))) + .Select(member => new MmsFcResolvedPoint + { + Domain = member.Domain, + LogicalNode = member.LogicalNode, + FunctionalConstraint = member.FunctionalConstraint, + DataObjectPath = member.DataObjectPath, + MmsItemName = member.MmsItemName, + Source = "GetNamedVariableListAttributes+GetVariableAccessAttributes", + Confidence = 100 + }) + .ToArray(); + discovery.IedDirectory.AddSupplementalPoints(membershipSeeds); + return results; + } + private void PublishSmartTypeProbeBudget(MmsSmartTypeProbeBudgetSnapshot snapshot) => Volatile.Write(ref _lastSmartTypeProbeBudget, snapshot); diff --git a/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs new file mode 100644 index 00000000..940a75bc --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs @@ -0,0 +1,86 @@ +namespace AR.Iec61850.Mms; + +/// +/// Minimal live-state probe used only after an exact RCB target has already been chosen. +/// +/// The broad diagnostic probe reads many presentation/configuration attributes. The runtime +/// hot path instead tries one complete RCB structure read, then fills only missing DatSet, +/// RptEna, reservation and Owner evidence required for safe availability decisions. +/// +public sealed partial class MmsClientSession +{ + private async Task ProbeTargetedReportControlAvailabilityAsync( + MmsReportControlCandidate reportControl, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reportControl); + + var logicalReads = 0; + + // Prefer one complete RCB structure read. On interoperable peers this provides + // DatSet + RptEna + reservation state in one confirmed service round trip. + await TryReadReportControlStructureAsync(reportControl, cancellationToken).ConfigureAwait(false); + logicalReads++; + + if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.EnabledState) is null) + { + await ProbeReportControlAttributeAsync( + reportControl, + "RptEna", + value => reportControl.EnabledState = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + logicalReads++; + } + + if (string.IsNullOrWhiteSpace(reportControl.DataSetReference)) + { + await ProbeReportControlAttributeAsync( + reportControl, + "DatSet", + value => + { + var text = NormalizeReportAttributeText(value); + reportControl.DataSetReference = string.IsNullOrWhiteSpace(text) + ? string.Empty + : NormalizeReportedDataSetReference(reportControl.Domain, text); + }, + cancellationToken).ConfigureAwait(false); + logicalReads++; + } + + if (reportControl.Buffered) + { + if (MmsRcbAvailabilityEvaluator.ParseUnsigned(reportControl.ReservationTimeSeconds) is null && + reportControl.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase)) + { + await ProbeReportControlAttributeAsync( + reportControl, + "ResvTms", + value => reportControl.ReservationTimeSeconds = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + logicalReads++; + } + } + else if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.ReservationState) is null && + reportControl.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) + { + await ProbeReportControlAttributeAsync( + reportControl, + "Resv", + value => reportControl.ReservationState = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + logicalReads++; + } + + // Owner is outside the base structure on many devices. If exposed, one exact + // read is cheaper and safer than inferring ownership from missing metadata. + if (reportControl.Attributes.Contains("Owner", StringComparer.OrdinalIgnoreCase)) + { + await ProbeOwnerReadOnlyAsync(reportControl, cancellationToken).ConfigureAwait(false); + logicalReads++; + } + + reportControl.Status = "Targeted-availability-probed"; + return logicalReads; + } +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs index 237333a7..d783b5e7 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.cs @@ -1,6 +1,7 @@ using AR.Iec61850.Acse; using AR.Iec61850.Diagnostics; using AR.Iec61850.Osi; +using System.Diagnostics; namespace AR.Iec61850.Mms; @@ -561,8 +562,17 @@ private async Task WriteSingleVariableCoreAsync( try { - var response = await SendConfirmedPresentationPayloadAsync(request, invokeId, cancellationToken).ConfigureAwait(false); + TimeSpan? sendDuration = null; + TimeSpan? responseDuration = null; + var response = await SendConfirmedPresentationPayloadAsync( + request, invokeId, cancellationToken, + (send, wait) => { sendDuration = send; responseDuration = wait; }) + .ConfigureAwait(false); + var decodeStart = Stopwatch.GetTimestamp(); var result = MmsWriteResponseDecoder.Decode(response, invokeId); + result.TpktSendElapsed = sendDuration; + result.ConfirmedResponseWaitElapsed = responseDuration; + result.ResponseDecodeElapsed = Stopwatch.GetElapsedTime(decodeStart); LastReadResponseHex = result.ResponseHexPreview; return result; } @@ -659,21 +669,43 @@ public async Task SendPresentationPayloadAsync(ReadOnlyMemory payl private async Task SendConfirmedPresentationPayloadAsync( ReadOnlyMemory payload, int expectedInvokeId, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Action? onCompletedTiming = null) { if (!IsTransportConnected) throw new InvalidOperationException("Native IEC 61850 transport is not connected."); + // No global mutable timestamps: the closure and invoke ID belong to this + // one request. Concurrent confirmed reads cannot overwrite its evidence. + // The send measurement includes the local TPKT send gate and stream write. + // Response-wait begins AFTER the completed local send; it is NOT pure + // wire RTT, nor does it isolate remote IED decision time. + var sendStarted = onCompletedTiming is null ? 0L : Stopwatch.GetTimestamp(); + long sentAt = 0L; + void CaptureCompleteTiming() + { + if (onCompletedTiming is null || sentAt == 0) + return; + onCompletedTiming( + Stopwatch.GetElapsedTime(sendStarted, sentAt), + Stopwatch.GetElapsedTime(sentAt)); + } + if (_receivePump.IsRunning) { using var pending = _receivePump.RegisterConfirmedOperation(expectedInvokeId); await _cotp.SendDataAsync(payload, cancellationToken).ConfigureAwait(false); + if (onCompletedTiming is not null) + sentAt = Stopwatch.GetTimestamp(); var envelope = await pending.WaitAsync(cancellationToken).ConfigureAwait(false); + CaptureCompleteTiming(); LastReceiveRoutingSummary = $"Receive pump completed {envelope.Kind} for invokeID={expectedInvokeId}. queuedReports={_receivePump.QueuedInformationReportCount}."; return envelope.PresentationPayload; } await _cotp.SendDataAsync(payload, cancellationToken).ConfigureAwait(false); + if (onCompletedTiming is not null) + sentAt = Stopwatch.GetTimestamp(); while (true) { @@ -681,6 +713,7 @@ private async Task SendConfirmedPresentationPayloadAsync( if (_receiveRouter.TryDequeueConfirmedResult(expectedInvokeId, out var queued)) { + CaptureCompleteTiming(); LastReceiveRoutingSummary = $"Dequeued queued {queued.Kind} for invokeID={expectedInvokeId}."; return queued.PresentationPayload; } @@ -696,11 +729,14 @@ private async Task SendConfirmedPresentationPayloadAsync( if (route.Action == MmsReceiveRouteAction.QueuedConfirmedResult) { if (_receiveRouter.TryDequeueConfirmedResult(expectedInvokeId, out var matched)) + { + CaptureCompleteTiming(); return matched.PresentationPayload; - + } continue; } + CaptureCompleteTiming(); return route.Envelope.PresentationPayload; } } @@ -764,7 +800,11 @@ private async Task AssociateAsync(bool resetAssociationDiagnostics, Cancellation if (result.IsAccepted) { State = MmsAssociationState.MmsInitiated; + // Bind paged RCB discovery to this accepted association. + _canonicalStaticRcbAssociationKey = Guid.NewGuid().ToString("N"); LastHandshakeMessage = result.Message; + ResetReportSemanticTypeEvidence(); + ResetConfiguredStaticDataSetDirectoryEvidence(); _receivePump.Start(cancellationToken); return; } diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs new file mode 100644 index 00000000..232a4f56 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs @@ -0,0 +1,172 @@ +namespace AR.Iec61850.Mms; + +public enum MmsConfiguredStaticActivationProofKind +{ + Proven, + SnapshotReadFailed, + ReportEnableStateMismatch, + DataSetBindingMismatch, + BusyRuntimeEvidence, + InsufficientReadbackEvidence +} + +public sealed class MmsConfiguredStaticActivationProof +{ + public MmsConfiguredStaticActivationProofKind Kind { get; init; } + public string Stage { get; init; } = string.Empty; + public string ExpectedDataSetReference { get; init; } = string.Empty; + public string ObservedDataSetReference { get; init; } = string.Empty; + public string ObservedEnabledState { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + + public bool IsProven => Kind == MmsConfiguredStaticActivationProofKind.Proven; +} + +/// +/// Pure proof policy for configured-static RCB activation. +/// +/// Planning evidence can go stale between plan creation and mutation. This verifier treats +/// a minimal just-in-time RCB snapshot as the runtime authority. One exact post-write +/// readback is sufficient when it proves RptEna=true and no positive DataSet contradiction; +/// a second unconditional read would add latency without adding semantic evidence. +/// +/// Missing reservation/Owner metadata is not converted into a blocker, but positive busy +/// evidence always blocks. Missing DataSet text is not a mismatch unless the live DatSet +/// read positively succeeded and proved an empty binding. +/// +public static class MmsConfiguredStaticActivationVerifier +{ + public static MmsConfiguredStaticActivationProof VerifyBeforeWrite( + MmsReportRcbSnapshot snapshot, + string expectedDataSetReference) + { + ArgumentNullException.ThrowIfNull(snapshot); + + if (!snapshot.IsSuccess) + return Fail( + MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, + snapshot, + expectedDataSetReference, + "Pre-activation JIT RCB read failed; no activation write is permitted."); + + if (HasPositiveDataSetMismatch(snapshot, expectedDataSetReference)) + return Fail( + MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, + snapshot, + expectedDataSetReference, + "Live RCB DatSet contradicts the configured static plan; activation is blocked rather than rewriting DatSet."); + + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != false) + return Fail( + MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, + snapshot, + expectedDataSetReference, + "Configured static activation requires exact RptEna=false immediately before mutation."); + + if (HasPositiveBusyEvidence(snapshot)) + return Fail( + MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence, + snapshot, + expectedDataSetReference, + "Fresh reservation/Owner evidence indicates the RCB is occupied by another runtime context."); + + return Pass( + snapshot, + expectedDataSetReference, + "Pre-activation JIT evidence proves RptEna=false, no conflicting DataSet binding, and no positive busy evidence."); + } + + public static MmsConfiguredStaticActivationProof VerifyAfterEnable( + MmsReportRcbSnapshot snapshot, + string expectedDataSetReference) + { + ArgumentNullException.ThrowIfNull(snapshot); + + if (!snapshot.IsSuccess) + return Fail( + MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, + snapshot, + expectedDataSetReference, + "Post-enable JIT readback failed; write acceptance alone is not activation proof."); + + if (HasPositiveDataSetMismatch(snapshot, expectedDataSetReference)) + return Fail( + MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, + snapshot, + expectedDataSetReference, + "Post-enable RCB DatSet contradicts the configured static plan."); + + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != true) + return Fail( + MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, + snapshot, + expectedDataSetReference, + "RptEna=true was not proven by post-enable JIT readback."); + + return Pass( + snapshot, + expectedDataSetReference, + "Post-enable JIT readback proves RptEna=true with no conflicting live DataSet binding."); + } + + private static bool HasPositiveBusyEvidence(MmsReportRcbSnapshot snapshot) + { + if (MmsRcbAvailabilityEvaluator.HasOwner(snapshot.Owner)) + return true; + + return snapshot.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0 + : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == true; + } + + private static bool HasPositiveDataSetMismatch( + MmsReportRcbSnapshot snapshot, + string expected) + { + var observedNormalized = Normalize(snapshot.DataSetReference); + var expectedNormalized = Normalize(expected); + + if (snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded && + observedNormalized.Length == 0 && + expectedNormalized.Length > 0) + { + return true; + } + + return observedNormalized.Length > 0 && + expectedNormalized.Length > 0 && + !string.Equals(observedNormalized, expectedNormalized, StringComparison.OrdinalIgnoreCase); + } + + private static string Normalize(string? value) + => MmsRcbAvailabilityEvaluator.NormalizeReference(value); + + private static MmsConfiguredStaticActivationProof Pass( + MmsReportRcbSnapshot snapshot, + string expectedDataSetReference, + string message) + => new() + { + Kind = MmsConfiguredStaticActivationProofKind.Proven, + Stage = snapshot.Stage, + ExpectedDataSetReference = Normalize(expectedDataSetReference), + ObservedDataSetReference = Normalize(snapshot.DataSetReference), + ObservedEnabledState = snapshot.EnabledState, + Message = message + }; + + private static MmsConfiguredStaticActivationProof Fail( + MmsConfiguredStaticActivationProofKind kind, + MmsReportRcbSnapshot snapshot, + string expectedDataSetReference, + string message) + => new() + { + Kind = kind, + Stage = snapshot.Stage, + ExpectedDataSetReference = Normalize(expectedDataSetReference), + ObservedDataSetReference = Normalize(snapshot.DataSetReference), + ObservedEnabledState = snapshot.EnabledState, + Message = $"{message} {snapshot.Message}".Trim() + }; +} diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs new file mode 100644 index 00000000..d83140ba --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs @@ -0,0 +1,131 @@ +namespace AR.Iec61850.Mms; + +public enum MmsConfiguredStaticRcbEligibilityKind +{ + Blocked, + CallerOwned, + ExplicitFree, + ReducedMissingReservationEvidence +} + +public sealed class MmsConfiguredStaticRcbEligibility +{ + public MmsConfiguredStaticRcbEligibilityKind Kind { get; init; } + public string Reason { get; init; } = string.Empty; + public bool IsEligible => Kind != MmsConfiguredStaticRcbEligibilityKind.Blocked; + public bool RequiresWrite => Kind is + MmsConfiguredStaticRcbEligibilityKind.ExplicitFree or + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence; + public bool UsesReducedEvidence => + Kind == MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence; +} + +/// +/// Evaluates only configured static RCB activation eligibility. +/// +/// Exact-free evidence remains preferred. A separately gated reduced state may be emitted +/// only when the configured DataSet binding is freshly read and populated, RptEna is +/// explicitly false, no positive foreign ownership/reservation evidence exists, and the +/// reservation field is genuinely absent/unexposed. Malformed or contradictory values are +/// blockers, never "missing evidence". +/// +/// This policy never relaxes dynamic DataSet mutation eligibility. +/// +public static class MmsConfiguredStaticRcbEligibilityPolicy +{ + public static MmsConfiguredStaticRcbEligibility Evaluate( + MmsRcbAvailabilitySnapshot snapshot, + bool allowCallerOwned, + bool allowReducedMissingReservationEvidence) + { + ArgumentNullException.ThrowIfNull(snapshot); + + if (snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller) + { + var callerReady = allowCallerOwned && + HasVerifiedStaticDataSet(snapshot); + return callerReady + ? Eligible( + MmsConfiguredStaticRcbEligibilityKind.CallerOwned, + "The configured static RCB is already active in this caller association and its populated DataSet is verified.") + : Blocked("Caller-owned RCB reuse is disabled or its configured DataSet is not fully verified."); + } + + if (!HasVerifiedStaticDataSet(snapshot)) + return Blocked("Configured static RCB requires a successful live DatSet read and a populated DataSet directory."); + + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != false) + return Blocked("Configured static RCB requires explicit RptEna=false before a new activation write."); + + if (HasPositiveBusyEvidence(snapshot)) + return Blocked("Positive RCB ownership/reservation evidence indicates the configured static RCB is not free."); + + if (snapshot.Availability == MmsRcbOperationalAvailability.Available && + HasExplicitFreeReservation(snapshot)) + { + return Eligible( + MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, + $"Fresh evidence confirms a populated configured DataSet, RptEna=false, and explicit free reservation state (confidence={snapshot.Confidence})."); + } + + if (!allowReducedMissingReservationEvidence) + return Blocked("Exact free reservation evidence is unavailable and reduced configured-static activation is not enabled."); + + if (snapshot.Availability != MmsRcbOperationalAvailability.Unknown) + return Blocked($"Reduced configured-static activation accepts only missing reservation evidence; availability={snapshot.Availability} is not an unknown-reservation state."); + + if (!ReservationEvidenceIsGenuinelyMissing(snapshot)) + return Blocked("Reservation evidence is present but is neither explicitly free nor safely classifiable as missing."); + + return Eligible( + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence, + "Configured static DataSet and RptEna=false are verified and no positive busy evidence exists, but the IED does not expose usable reservation metadata. Activation may proceed only under the explicit reduced-evidence policy and must still prove success by readback/report traffic."); + } + + private static bool HasVerifiedStaticDataSet(MmsRcbAvailabilitySnapshot snapshot) + => snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded && + snapshot.DataSetDirectorySuccess && + snapshot.DataSetMembers.Count > 0 && + !string.IsNullOrWhiteSpace(snapshot.DataSetReference); + + private static bool HasPositiveBusyEvidence(MmsRcbAvailabilitySnapshot snapshot) + { + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) == true) + return true; + if (MmsRcbAvailabilityEvaluator.HasOwner(snapshot.Owner)) + return true; + + return snapshot.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0 + : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == true; + } + + private static bool HasExplicitFreeReservation(MmsRcbAvailabilitySnapshot snapshot) + => snapshot.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) == 0 + : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == false; + + private static bool ReservationEvidenceIsGenuinelyMissing(MmsRcbAvailabilitySnapshot snapshot) + => snapshot.Buffered + ? IsMissingToken(snapshot.ReservationTimeSeconds) + : IsMissingToken(snapshot.ReservationState); + + private static bool IsMissingToken(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 || + text == "-" || + text == "[]" || + text.Equals("null", StringComparison.OrdinalIgnoreCase) || + text.Equals("unsupported", StringComparison.OrdinalIgnoreCase) || + text.Equals("not-exposed", StringComparison.OrdinalIgnoreCase); + } + + private static MmsConfiguredStaticRcbEligibility Eligible( + MmsConfiguredStaticRcbEligibilityKind kind, + string reason) + => new() { Kind = kind, Reason = reason }; + + private static MmsConfiguredStaticRcbEligibility Blocked(string reason) + => new() { Kind = MmsConfiguredStaticRcbEligibilityKind.Blocked, Reason = reason }; +} diff --git a/src/AR.Iec61850/Mms/MmsDataCodec.cs b/src/AR.Iec61850/Mms/MmsDataCodec.cs index 086ed318..3150b312 100644 --- a/src/AR.Iec61850/Mms/MmsDataCodec.cs +++ b/src/AR.Iec61850/Mms/MmsDataCodec.cs @@ -39,11 +39,16 @@ public static MmsDataValue Decode(BerTlv tlv) { 1 => MmsDataValue.Array(DecodeAllData(tlv.Value)), 2 => MmsDataValue.Structure(DecodeAllData(tlv.Value)), - 3 => MmsDataValue.Boolean(BerReader.ReadBoolean(tlv) ?? false), + 3 => tlv.Value.Length == 1 && BerReader.ReadBoolean(tlv) is { } booleanValue + ? MmsDataValue.Boolean(booleanValue) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), 4 => DecodeBitString(tlv.Value.Span), - 5 => MmsDataValue.Integer(BerReader.ReadSignedInteger(tlv) ?? 0), - 6 => MmsDataValue.Unsigned(BerReader.ReadUnsignedInteger(tlv) ?? 0), - 7 => MmsDataValue.FloatingPoint(DecodeFloatingPoint(tlv.Value.Span)), + 5 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadSignedInteger(tlv) is { } signed + ? MmsDataValue.Integer(signed) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), + 6 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadUnsignedInteger(tlv) is { } unsigned + ? MmsDataValue.Unsigned(unsigned) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), + 7 => tlv.Value.Length is 4 or 5 + ? MmsDataValue.FloatingPoint(DecodeFloatingPoint(tlv.Value.Span)) + : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), 9 => MmsDataValue.OctetString(tlv.Value.Span), 10 => MmsDataValue.VisibleString(BerReader.ReadAsciiString(tlv)), 12 => MmsDataValue.BinaryTime(tlv.Value.Span), @@ -126,8 +131,8 @@ private static byte TagFor(MmsDataKind kind, int? unknownTagNumber) private static MmsDataValue DecodeBitString(ReadOnlySpan bytes) { - if (bytes.IsEmpty) - return MmsDataValue.BitString(0, ReadOnlySpan.Empty); + if (bytes.IsEmpty || bytes[0] > 7 || (bytes.Length == 1 && bytes[0] != 0)) + return MmsDataValue.Unknown(4, bytes); return MmsDataValue.BitString(bytes[0], bytes[1..]); } diff --git a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs index 12bb99f8..a39baf6c 100644 --- a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs +++ b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs @@ -47,6 +47,13 @@ public sealed class MmsHybridReportAcquisitionOptions public bool AllowCallerOwnedReports { get; init; } = true; public bool AllowStaticBrcb { get; init; } = true; public bool AllowStaticUrcb { get; init; } = true; + /// + /// Explicit interoperability opt-in for configured static RCBs whose live DataSet + /// and RptEna=false are verified but whose reservation attribute is genuinely + /// unexposed. Default false preserves the exact-evidence production contract. + /// This option never applies to dynamic DataSet mutation. + /// + public bool AllowConfiguredStaticWithMissingReservationEvidence { get; init; } public bool AllowDynamicBrcb { get; init; } = true; public bool AllowDynamicUrcb { get; init; } = true; public bool AllowPollingFallback { get; init; } = true; @@ -347,11 +354,12 @@ private static void PlanStaticCoverage( .Select(snapshot => new { Snapshot = snapshot, + Eligibility = EvaluateStaticEligibility(snapshot, options), Covered = remaining.Where(signal => StaticDataSetCovers(snapshot, signal)).ToArray() }) .Where(item => item.Covered.Length > 0) .OrderByDescending(item => item.Covered.Length) - .ThenBy(item => item.Snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller ? 0 : 1) + .ThenBy(item => StaticEligibilityRank(item.Eligibility.Kind)) .ThenBy(item => item.Snapshot.Buffered ? 0 : 1) .ThenBy(item => item.Snapshot.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -361,7 +369,8 @@ private static void PlanStaticCoverage( var selected = scored[0]; var snapshot = selected.Snapshot; - var plan = snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller + var eligibility = selected.Eligibility; + var plan = eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned ? BuildCallerOwnedStaticPlan(snapshot, inventory) : BuildFreshStaticPlan(snapshot); @@ -373,7 +382,14 @@ private static void PlanStaticCoverage( } var kind = snapshot.Buffered ? MmsHybridAcquisitionKind.StaticBrcb : MmsHybridAcquisitionKind.StaticUrcb; - var callerOwned = snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller; + var callerOwned = eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned; + var reducedEvidence = eligibility.UsesReducedEvidence; + if (reducedEvidence) + { + warnings.Add( + $"Configured static candidate {snapshot.Reference} is using the explicit reduced reservation-evidence policy: {eligibility.Reason}"); + } + var segment = new MmsHybridAcquisitionSegment { Kind = kind, @@ -387,7 +403,9 @@ private static void PlanStaticCoverage( IsAlreadyActiveByCaller = callerOwned, Reason = callerOwned ? "Fresh availability evidence identifies this report as already active in the caller's current session; reuse it without reconfiguration." - : "Fresh exact availability evidence confirms a populated static DataSet and a free RCB; enable the existing report without changing its DataSet membership." + : reducedEvidence + ? eligibility.Reason + : "Fresh availability evidence confirms a populated static DataSet, RptEna=false, and explicit free reservation state; enable the existing report without changing its DataSet membership." }; segments.Add(segment); @@ -681,23 +699,38 @@ private static bool IsStaticUsable(MmsRcbAvailabilitySnapshot snapshot, MmsHybri return false; if (!snapshot.Buffered && !options.AllowStaticUrcb) return false; - if (snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller) - return options.AllowCallerOwnedReports && - snapshot.DataSetDirectorySuccess && - snapshot.DataSetMembers.Count > 0 && - !string.IsNullOrWhiteSpace(snapshot.DataSetReference); - if (snapshot.Availability != MmsRcbOperationalAvailability.Available) - return false; - if (options.RequireExactAvailabilityEvidence && snapshot.Confidence != MmsRcbAvailabilityConfidence.Exact) + + var eligibility = EvaluateStaticEligibility(snapshot, options); + if (!eligibility.IsEligible) return false; - return snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded && - snapshot.DataSetDirectorySuccess && - snapshot.DataSetMembers.Count > 0 && - !string.IsNullOrWhiteSpace(snapshot.DataSetReference) && - ParseBool(snapshot.EnabledState) == false && - HasExplicitFreeReservation(snapshot); + + if (eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned) + return true; + + if (eligibility.UsesReducedEvidence) + return options.AllowConfiguredStaticWithMissingReservationEvidence; + + return !options.RequireExactAvailabilityEvidence || + snapshot.Confidence == MmsRcbAvailabilityConfidence.Exact; } + private static MmsConfiguredStaticRcbEligibility EvaluateStaticEligibility( + MmsRcbAvailabilitySnapshot snapshot, + MmsHybridReportAcquisitionOptions options) + => MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + options.AllowCallerOwnedReports, + options.AllowConfiguredStaticWithMissingReservationEvidence); + + private static int StaticEligibilityRank(MmsConfiguredStaticRcbEligibilityKind kind) + => kind switch + { + MmsConfiguredStaticRcbEligibilityKind.CallerOwned => 0, + MmsConfiguredStaticRcbEligibilityKind.ExplicitFree => 1, + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence => 2, + _ => 9 + }; + private static bool IsDynamicUsable(MmsRcbAvailabilitySnapshot snapshot, MmsHybridReportAcquisitionOptions options) { if (snapshot.Buffered && !options.AllowDynamicBrcb) diff --git a/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs b/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs new file mode 100644 index 00000000..df6b08ef --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs @@ -0,0 +1,71 @@ +namespace AR.Iec61850.Mms; + +/// +/// Protocol-level failure categories used by planning/execution code. These are derived +/// from structured MMS DataAccessError codes, never from human-readable message text. +/// +public enum MmsInteropFailureKind +{ + None, + ObjectInvalidated, + HardwareFault, + TemporarilyUnavailable, + AccessDenied, + ObjectUndefined, + InvalidAddress, + TypeUnsupported, + TypeMismatch, + AttributeInconsistent, + AccessUnsupported, + ObjectNotFound, + InvalidValue, + Unknown +} + +/// +/// Evidence-oriented recovery hint. This does not authorize an automatic retry; it tells +/// higher layers what must be revalidated before any bounded follow-up operation. +/// +public enum MmsInteropRecoveryHint +{ + None, + RevalidateType, + RevalidateRuntimeState, + RevalidateObjectIdentity, + RetryLaterAfterStateRefresh, + FailClosed +} + +public readonly record struct MmsInteropFailureClassification( + MmsInteropFailureKind Kind, + MmsInteropRecoveryHint RecoveryHint) +{ + public static MmsInteropFailureClassification Success => + new(MmsInteropFailureKind.None, MmsInteropRecoveryHint.None); +} + +public static class MmsInteropFailureClassifier +{ + public static MmsInteropFailureClassification Classify(MmsWriteAccessResult? access) + { + if (access is null || access.IsSuccess) + return MmsInteropFailureClassification.Success; + + return access.FailureCode switch + { + 0 => new(MmsInteropFailureKind.ObjectInvalidated, MmsInteropRecoveryHint.RevalidateRuntimeState), + 1 => new(MmsInteropFailureKind.HardwareFault, MmsInteropRecoveryHint.FailClosed), + 2 => new(MmsInteropFailureKind.TemporarilyUnavailable, MmsInteropRecoveryHint.RetryLaterAfterStateRefresh), + 3 => new(MmsInteropFailureKind.AccessDenied, MmsInteropRecoveryHint.FailClosed), + 4 => new(MmsInteropFailureKind.ObjectUndefined, MmsInteropRecoveryHint.RevalidateObjectIdentity), + 5 => new(MmsInteropFailureKind.InvalidAddress, MmsInteropRecoveryHint.RevalidateObjectIdentity), + 6 => new(MmsInteropFailureKind.TypeUnsupported, MmsInteropRecoveryHint.RevalidateType), + 7 => new(MmsInteropFailureKind.TypeMismatch, MmsInteropRecoveryHint.RevalidateType), + 8 => new(MmsInteropFailureKind.AttributeInconsistent, MmsInteropRecoveryHint.RevalidateRuntimeState), + 9 => new(MmsInteropFailureKind.AccessUnsupported, MmsInteropRecoveryHint.FailClosed), + 10 => new(MmsInteropFailureKind.ObjectNotFound, MmsInteropRecoveryHint.RevalidateObjectIdentity), + 11 => new(MmsInteropFailureKind.InvalidValue, MmsInteropRecoveryHint.FailClosed), + _ => new(MmsInteropFailureKind.Unknown, MmsInteropRecoveryHint.FailClosed) + }; + } +} diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index b09f7b45..ef5be0a7 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -2,8 +2,19 @@ namespace AR.Iec61850.Mms; +public enum MmsPersistentReportTrafficState +{ + ArmedAwaitingTraffic, + TrafficProven, + Stopped +} + public sealed class MmsPersistentReportMonitorSession { + private long _firstRoutedReportUtcTicks; + private long _lastRoutedReportUtcTicks; + private int _routedReportCount; + internal MmsPersistentReportMonitorSession( MmsReportSubscriptionPlan plan, MmsReportControlCandidate reportControl, @@ -38,10 +49,35 @@ internal MmsPersistentReportMonitorSession( public int ReportCount { get; internal set; } public int PollReadCount { get; internal set; } public bool IsStopped { get; internal set; } + public int RoutedReportCount => Volatile.Read(ref _routedReportCount); + public DateTimeOffset? FirstRoutedReportAt => ToUtcTimestamp(Volatile.Read(ref _firstRoutedReportUtcTicks)); + public DateTimeOffset? LastRoutedReportAt => ToUtcTimestamp(Volatile.Read(ref _lastRoutedReportUtcTicks)); + public MmsPersistentReportTrafficState TrafficState => IsStopped + ? MmsPersistentReportTrafficState.Stopped + : RoutedReportCount > 0 + ? MmsPersistentReportTrafficState.TrafficProven + : MmsPersistentReportTrafficState.ArmedAwaitingTraffic; + public TimeSpan? FirstReportLatency => FirstRoutedReportAt.HasValue + ? FirstRoutedReportAt.Value - StartedAt + : null; + internal ConcurrentQueue PendingReports { get; } = new(); + internal void ObserveRoutedReport(MmsReportFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + var ticks = frame.ReceivedAt.UtcDateTime.Ticks; + Interlocked.CompareExchange(ref _firstRoutedReportUtcTicks, ticks, 0); + Interlocked.Exchange(ref _lastRoutedReportUtcTicks, ticks); + Interlocked.Increment(ref _routedReportCount); + } + public string Summary => - $"persistent report monitor: rcb={ReportControl.Reference}, dataset={Plan.DataSetReference}, mode={Plan.Mode}, reports={ReportCount}, stopped={IsStopped}"; + $"persistent report monitor: rcb={ReportControl.Reference}, dataset={Plan.DataSetReference}, mode={Plan.Mode}, " + + $"traffic={TrafficState}, routedReports={RoutedReportCount}, drainedReports={ReportCount}, stopped={IsStopped}"; + + private static DateTimeOffset? ToUtcTimestamp(long ticks) + => ticks <= 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero); } public sealed class MmsPersistentReportMonitorStartResult @@ -233,14 +269,14 @@ public async Task StartPersistentReportMo } else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = reserve.IsSuccess; if (!reserve.IsSuccess) warnings.Add("URCB Resv write failed. Continuing only if RptEna=true is accepted by the IED."); } - var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabledByThisClient = enable.IsSuccess; if (!enable.IsSuccess) @@ -261,7 +297,7 @@ public async Task StartPersistentReportMo if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); if (!gi.IsSuccess) warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only."); @@ -363,7 +399,7 @@ public async Task StopPersistentReportMoni if (session.EnabledByThisClient) { - var disable = await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + var disable = await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); writes.Add(disable); success &= disable.IsSuccess; } @@ -410,8 +446,8 @@ public async Task StopPersistentReportMoni if (session.ReservationTouched) { var release = session.ReportControl.Buffered - ? await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); success &= release.IsSuccess; } @@ -509,10 +545,8 @@ private async Task ReceiveAndDispatchPersistentReportsAsync( if (giPending) { - writes.Add(await WriteReportAttributeAsync( - requestedSession.ReportControl, - "GI", - MmsDataValue.Boolean(true), + writes.Add(await WriteReportSemanticAttributeAsync( + requestedSession.ReportControl, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false)); giPending = false; continue; @@ -541,9 +575,26 @@ private async Task ReceiveAndDispatchPersistentReportsAsync( if (IsReceivePumpRunning) { - var delay = remaining < TimeSpan.FromMilliseconds(25) ? remaining : TimeSpan.FromMilliseconds(25); - if (delay > TimeSpan.Zero) - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + // The association-owned receive pump already blocks on the transport. + // Do not add a 25 ms application polling loop on top of it. Wait for the + // router's binary InformationReport signal, bounded by the next legitimate + // scheduler deadline (residual polling) or this receive slice deadline. + var wakeAt = deadline; + if (pollDirectory != null && + references.Length > 0 && + nextPollAt < wakeAt) + { + wakeAt = nextPollAt; + } + + var waitFor = wakeAt - DateTimeOffset.UtcNow; + if (waitFor > TimeSpan.Zero) + { + await _receiveRouter.WaitForInformationReportAsync( + waitFor, + cancellationToken).ConfigureAwait(false); + } + continue; } @@ -586,6 +637,12 @@ private void RoutePersistentInformationReport(byte[] payload) return; } + // Control termination / LastApplError use the same MMS service but are + // not RCB reports. Never feed them to the single-monitor fallback. + // Their independent control subscribers already received the envelope. + if (!HasRcbReportHeader(decoded)) + return; + var header = MmsReportFrameMapper.DecodeHeader(decoded); var active = SnapshotPersistentReportMonitors(); var target = SelectPersistentReportMonitor(active, header, out var evidence); @@ -598,11 +655,17 @@ private void RoutePersistentInformationReport(byte[] payload) } var frame = MmsReportFrameMapper.Map(decoded, target.Plan.Members, DateTimeOffset.UtcNow); + target.ObserveRoutedReport(frame); target.PendingReports.Enqueue(frame); LastReceiveRoutingSummary = $"Routed InformationReport to {target.ReportControl.Reference} by {evidence}. RptID={RoutingTextOrDash(header.ReportId)}, DatSet={RoutingTextOrDash(header.DataSetReference)}."; } + internal static bool HasRcbReportHeader(MmsInformationReport report) + => report.IsSuccess && report.Items.Count >= 2 && + report.Items[0].Value?.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString && + report.Items[1].Value?.Kind == MmsDataKind.BitString; + internal static MmsPersistentReportMonitorSession? SelectPersistentReportMonitor( IReadOnlyList active, MmsReportHeader header, @@ -617,6 +680,42 @@ private void RoutePersistentInformationReport(byte[] payload) return null; } + var dataSet = NormalizeRoutingReference(header.DataSetReference); + + // A recognized RptID cannot override a contradictory advertised DatSet. + // Some peers shorten the DatSet to LLN0$Name (or just Name), while + // others supply an exact LD/LN$Name. Match an abbreviated reference + // only at an explicit component boundary, never by a shared tail alone. + // An omitted DatSet preserves the legacy exact-RptID route. + bool ConflictsWithAdvertisedDataSet(MmsPersistentReportMonitorSession candidate) + { + if (string.IsNullOrWhiteSpace(dataSet)) + return false; + + var expected = NormalizeRoutingReference(candidate.Plan.DataSetReference); + if (string.IsNullOrWhiteSpace(expected)) + return false; // No authoritative plan identity to contradict. + + if (dataSet.Contains('/')) + return !expected.Equals(dataSet, StringComparison.OrdinalIgnoreCase); + + if (dataSet.Contains('.')) + { + // Domain omitted: require the entire LN.DataSet suffix. A + // matching bare DataSet name under another LN is not enough. + if (expected.Equals(dataSet, StringComparison.OrdinalIgnoreCase)) + return false; + var offset = expected.Length - dataSet.Length; + return offset <= 0 || + !expected.EndsWith(dataSet, StringComparison.OrdinalIgnoreCase) || + expected[offset - 1] != '/'; + } + + // Some peers omit both domain and LN. Retain explicit tail-only + // compatibility, but do not route a *different* advertised tail. + return !RoutingTail(expected).Equals(dataSet, StringComparison.OrdinalIgnoreCase); + } + var reportId = NormalizeRoutingReference(header.ReportId); if (!string.IsNullOrWhiteSpace(reportId)) { @@ -625,6 +724,11 @@ private void RoutePersistentInformationReport(byte[] payload) .ToArray(); if (exactRptId.Length == 1) { + if (ConflictsWithAdvertisedDataSet(exactRptId[0])) + { + evidence = "RptID/DatSet conflict: report was quarantined before process-value projection"; + return null; + } evidence = "exact RptID"; return exactRptId[0]; } @@ -632,12 +736,16 @@ private void RoutePersistentInformationReport(byte[] payload) var rcbAffinity = candidates.Where(x => HasRcbNameAffinity(reportId, x.ReportControl)).ToArray(); if (rcbAffinity.Length == 1) { + if (ConflictsWithAdvertisedDataSet(rcbAffinity[0])) + { + evidence = "RptID/DatSet conflict: report was quarantined before process-value projection"; + return null; + } evidence = "RptID-to-RCB name affinity"; return rcbAffinity[0]; } } - var dataSet = NormalizeRoutingReference(header.DataSetReference); if (!string.IsNullOrWhiteSpace(dataSet)) { var exactDataSet = candidates @@ -662,6 +770,11 @@ private void RoutePersistentInformationReport(byte[] payload) if (candidates.Length == 1) { + if (ConflictsWithAdvertisedDataSet(candidates[0])) + { + evidence = "single-monitor DatSet conflict: report was quarantined before process-value projection"; + return null; + } evidence = "single active monitor fallback"; return candidates[0]; } diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs index af6e046c..a2942421 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs @@ -19,7 +19,10 @@ public enum MmsReportActivationFailureReason DynamicDataSetBindFailed, TriggerOptionsUnavailable, TriggerOptionsWriteFailed, + RcbFieldTypeConflict, + ReservationWriteFailed, ReportEnableFailed, + GeneralInterrogationFailed, ActivationException, OtherActivationFailure } @@ -98,12 +101,22 @@ public async Task StartPersistentReport } } - var start = await StartPersistentReportMonitorAsync( - plan, - triggerGeneralInterrogation, - deleteDynamicDataSetOnStop, - directory, - cancellationToken).ConfigureAwait(false); + // A fresh configured static plan must use the same source-neutral activation + // lifecycle regardless of whether its model originated from live Discovery or SCL: + // receiver registration -> direct RptEna -> bounded BRCB ResvTms fallback -> + // readback -> optional GI. Dynamic plans retain the dynamic transaction path below. + var start = !isDynamic && + plan.Status == MmsReportSubscriptionPlanStatus.ReadyRequiresWrite + ? await StartConfiguredStaticReportMonitorAsync( + plan, + triggerGeneralInterrogation, + cancellationToken).ConfigureAwait(false) + : await StartPersistentReportMonitorAsync( + plan, + triggerGeneralInterrogation, + deleteDynamicDataSetOnStop, + directory, + cancellationToken).ConfigureAwait(false); if (probe is not null) start = MergeProbeEvidence(start, probe); @@ -162,10 +175,8 @@ public async Task StartPersistentReport if (enabled) { - var disable = await TryWriteReportAttributeForCleanupAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(false), + var disable = await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); cleanupSteps.Add(disable); cleanupSucceeded &= disable.IsSuccess; @@ -218,8 +229,8 @@ public async Task StartPersistentReport if (reserved) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); cleanupSteps.Add(release); cleanupSucceeded &= release.IsSuccess; } @@ -266,9 +277,15 @@ private static bool SuccessfulStep(IEnumerable step private static MmsReportActivationFailureReason ClassifyFailure(MmsPersistentReportMonitorStartResult result) { - var failed = result.WriteSteps.LastOrDefault(step => step.Attempted && !step.IsSuccess); + var failed = result.WriteSteps.LastOrDefault(step => !step.IsSuccess); if (failed is not null) { + if (failed.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.ExactMismatch || + failed.FailureKind is MmsInteropFailureKind.TypeMismatch or MmsInteropFailureKind.TypeUnsupported) + { + return MmsReportActivationFailureReason.RcbFieldTypeConflict; + } + if (failed.Attribute.Equals("Probe.DefineNamedVariableList", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.DynamicDataSetProbeDefineFailed; if (failed.Attribute.Equals("Probe.GetNamedVariableListAttributes", StringComparison.OrdinalIgnoreCase)) @@ -281,8 +298,13 @@ private static MmsReportActivationFailureReason ClassifyFailure(MmsPersistentRep return MmsReportActivationFailureReason.DynamicDataSetBindFailed; if (failed.Attribute.Equals("TrgOps", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.TriggerOptionsWriteFailed; + if (failed.Attribute.Equals("Resv", StringComparison.OrdinalIgnoreCase) || + failed.Attribute.Equals("ResvTms", StringComparison.OrdinalIgnoreCase)) + return MmsReportActivationFailureReason.ReservationWriteFailed; if (failed.Attribute.Equals("RptEna", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.ReportEnableFailed; + if (failed.Attribute.Equals("GI", StringComparison.OrdinalIgnoreCase)) + return MmsReportActivationFailureReason.GeneralInterrogationFailed; } if (result.Message.Contains("requires a writable TrgOps", StringComparison.OrdinalIgnoreCase)) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs index b0fb63d2..2a4d50ce 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs @@ -26,10 +26,8 @@ public async Task StartPersistentReport !MmsReportSubscriptionPlanner.IsExplicitlyEnabled(rcb) && !MmsReportSubscriptionPlanner.IsReservedByOtherClient(rcb)) { - reservationStep = await WriteReportAttributeAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(60), + reservationStep = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(60), cancellationToken).ConfigureAwait(false); if (!reservationStep.IsSuccess) @@ -66,10 +64,8 @@ public async Task StartPersistentReport if (reservationStep?.IsSuccess == true && rcb is not null) { - var release = await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + var release = await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false); cleanupSteps.Add(release); cleanupAttempted = true; @@ -95,10 +91,8 @@ public async Task StartPersistentReport if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync( - start.Session.ReportControl, - "GI", - MmsDataValue.Boolean(true), + var gi = await WriteReportSemanticAttributeAsync( + start.Session.ReportControl, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); if (!gi.IsSuccess) diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs index d916a64f..aed0a3a3 100644 --- a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs +++ b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs @@ -22,6 +22,15 @@ public sealed class MmsRcbAvailabilityOptions { public int MaxReportControls { get; init; } = 512; public bool ReadDataSetDirectories { get; init; } = true; + + /// + /// Optional exact live RCB references to probe. Empty means broad diagnostic mode. + /// Runtime acquisition hot paths should supply only the concrete RCB instances that + /// canonical coverage/family resolution has already selected. + /// + public IReadOnlySet TargetReportControlReferences { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); + public IReadOnlySet CallerOwnedRcbReferences { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); } @@ -70,10 +79,22 @@ public sealed class MmsRcbAvailabilityResult public IReadOnlyList ReportControls { get; init; } = Array.Empty(); public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public int InventoryReportControlCount { get; init; } + public bool TargetFilterApplied { get; init; } + public int RequestedTargetReportControlCount { get; init; } + public int MatchedTargetReportControlCount { get; init; } + public int TargetedRcbStateLogicalReadCount { get; init; } + public int DataSetDirectoryNetworkReadCount { get; init; } + public int DataSetDirectoryCacheHitCount { get; init; } + public int AvailableCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Available); public int InUseCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.InUse); public int UnknownCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Unknown); - public string Summary => $"RCB availability checked: total={ReportControls.Count}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}."; + public string Summary => + $"RCB availability checked: inventory={InventoryReportControlCount}, probed={ReportControls.Count}, " + + $"targeted={TargetFilterApplied.ToString().ToLowerInvariant()}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}, " + + $"targetedRcbStateReads={(TargetFilterApplied ? TargetedRcbStateLogicalReadCount.ToString() : "n/a")}, " + + $"datasetReads={DataSetDirectoryNetworkReadCount}, datasetCacheHits={DataSetDirectoryCacheHitCount}."; } public static class MmsRcbAvailabilityEvaluator diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs b/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs new file mode 100644 index 00000000..4963505c --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs @@ -0,0 +1,75 @@ +namespace AR.Iec61850.Mms; + +public sealed class MmsRcbAvailabilitySelection +{ + public IReadOnlyList Candidates { get; init; } = Array.Empty(); + public bool TargetFilterApplied { get; init; } + public int RequestedTargetCount { get; init; } + public int MatchedTargetCount { get; init; } + public int EligibleCountBeforeLimit { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); +} + +/// +/// Pure candidate selector for availability probing. +/// +/// Exact target references are never broadened to family/neighbor RCBs. Family resolution +/// belongs to the canonical/report-family planner before this stage. This keeps the network +/// hot path proportional to the RCBs that can actually serve the selected signals. +/// +public static class MmsRcbAvailabilityTargetSelector +{ + public static MmsRcbAvailabilitySelection Select( + MmsReportInventory inventory, + MmsRcbAvailabilityOptions options) + { + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(options); + + var warnings = new List(); + var targets = options.TargetReportControlReferences + .Select(MmsRcbAvailabilityEvaluator.NormalizeReference) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var targeted = targets.Count > 0; + + IEnumerable eligible = inventory.ReportControls; + if (targeted) + { + eligible = eligible.Where(candidate => + targets.Contains(MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference))); + } + + var eligibleArray = eligible.ToArray(); + var matched = targeted ? eligibleArray.Length : 0; + if (targeted && matched < targets.Count) + { + warnings.Add( + $"Targeted RCB availability matched {matched} of {targets.Count} requested exact live reference(s); " + + "unmatched targets were not broadened to unrelated RCBs."); + } + + var max = Math.Clamp(options.MaxReportControls, 1, 4096); + var candidates = eligibleArray + .OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference)) + .ThenByDescending(candidate => candidate.Buffered) + .ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase) + .Take(max) + .ToArray(); + + if (eligibleArray.Length > candidates.Length) + warnings.Add($"Availability check was bounded to {candidates.Length} of {eligibleArray.Length} eligible RCBs."); + + return new MmsRcbAvailabilitySelection + { + Candidates = candidates, + TargetFilterApplied = targeted, + RequestedTargetCount = targets.Count, + MatchedTargetCount = matched, + EligibleCountBeforeLimit = eligibleArray.Length, + Warnings = warnings + }; + } +} diff --git a/src/AR.Iec61850/Mms/MmsRead.cs b/src/AR.Iec61850/Mms/MmsRead.cs index 554f5179..fbf1cf4a 100644 --- a/src/AR.Iec61850/Mms/MmsRead.cs +++ b/src/AR.Iec61850/Mms/MmsRead.cs @@ -16,6 +16,7 @@ public sealed class MmsReadResult public bool IsSuccess { get; init; } public MmsDataValue? Value { get; init; } public int? FailureCode { get; init; } + public MmsReadEvidenceFailureKind FailureKind { get; init; } public string Message { get; init; } = string.Empty; public string ResponseHexPreview { get; init; } = string.Empty; } @@ -93,10 +94,10 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory presentati return Fail("Empty MMS response payload.", hex); if (mms[0] == 0xA2) - return Fail($"MMS Confirmed-Error PDU received: {HexDump.ToCompactString(mms)}", hex); + return Fail($"MMS Confirmed-Error PDU received: {HexDump.ToCompactString(mms)}", hex, kind: MmsReadEvidenceFailureKind.ProtocolRejected); if (mms[0] == 0xA3 || mms[0] == 0xA4) - return Fail($"MMS Reject/Abort PDU received: {HexDump.ToCompactString(mms)}", hex); + return Fail($"MMS Reject/Abort PDU received: {HexDump.ToCompactString(mms)}", hex, kind: MmsReadEvidenceFailureKind.ProtocolRejected); if (!TryValidateConfirmedResponse(mms, expectedInvokeId, out var message)) return Fail(message, hex); @@ -113,7 +114,9 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory presentati } if (!string.IsNullOrWhiteSpace(accessMessage)) - return Fail(accessMessage, hex, failureCode); + return Fail(accessMessage, hex, failureCode, failureCode.HasValue + ? MmsReadEvidenceClassifier.FromAccessCode(failureCode) + : MmsReadEvidenceFailureKind.InvalidResponse); // Legacy fallback for tolerant decoding of non-standard/short responses. var values = new List(); @@ -123,7 +126,8 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory presentati if (failure.EncodedTag == 0x80) { var code = BerReader.ReadUnsignedInteger(failure); - return Fail(FormatDataAccessFailure(code), hex, code.HasValue ? (int)code.Value : null); + return Fail(FormatDataAccessFailure(code), hex, code.HasValue ? (int)code.Value : null, + MmsReadEvidenceClassifier.FromAccessCode(code.HasValue ? (int)code.Value : null)); } foreach (var tlv in values.AsEnumerable().Reverse()) @@ -365,12 +369,14 @@ private static string DataAccessErrorName(ulong code) _ => "unknown" }; - private static MmsReadResult Fail(string message, string hex, int? failureCode = null) + private static MmsReadResult Fail(string message, string hex, int? failureCode = null, + MmsReadEvidenceFailureKind kind = MmsReadEvidenceFailureKind.InvalidResponse) { return new MmsReadResult { IsSuccess = false, FailureCode = failureCode, + FailureKind = kind, Message = message, ResponseHexPreview = hex }; diff --git a/src/AR.Iec61850/Mms/MmsReadBatch.cs b/src/AR.Iec61850/Mms/MmsReadBatch.cs index ccfbc03e..08862542 100644 --- a/src/AR.Iec61850/Mms/MmsReadBatch.cs +++ b/src/AR.Iec61850/Mms/MmsReadBatch.cs @@ -9,6 +9,7 @@ public sealed class MmsReadAccessResult public bool IsSuccess { get; init; } public MmsDataValue? Value { get; init; } public int? FailureCode { get; init; } + public MmsReadEvidenceFailureKind FailureKind { get; init; } public string Message { get; init; } = string.Empty; } @@ -124,6 +125,14 @@ public static MmsReadBatchResult DecodeResponse( return FailAll(references, "MMS Read response has no listOfAccessResult.", hex); var accessResults = BerReader.ReadChildren(accessList.Value); + // A Read response with missing/extra access results cannot be + // safely joined to requested FC tuples by ordinal. Retain the + // mismatch as failure evidence instead of accepting a prefix. + if (accessResults.Count != references.Count) + return FailAll(references, + $"MMS Read AccessResult count mismatch: requested={references.Count}, received={accessResults.Count}.", + hex, Math.Max(0, accessResults.Count - references.Count)); + var results = new List(references.Count); for (var index = 0; index < references.Count; index++) { @@ -133,6 +142,7 @@ public static MmsReadBatchResult DecodeResponse( { Reference = references[index], IsSuccess = false, + FailureKind = MmsReadEvidenceFailureKind.InvalidResponse, Message = $"MMS Read response ended before access result {index}." }); continue; @@ -166,6 +176,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen Reference = reference, IsSuccess = false, FailureCode = code.HasValue ? (int)code.Value : null, + FailureKind = MmsReadEvidenceClassifier.FromAccessCode(code.HasValue ? (int)code.Value : null), Message = code.HasValue ? $"MMS Read AccessResult.failure code {code.Value}." : "MMS Read AccessResult.failure code is undecodable." @@ -180,6 +191,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen Reference = reference, IsSuccess = value.Kind != MmsDataKind.Unknown, Value = value.Kind == MmsDataKind.Unknown ? null : value, + FailureKind = value.Kind == MmsDataKind.Unknown ? MmsReadEvidenceFailureKind.InvalidResponse : MmsReadEvidenceFailureKind.None, Message = value.Kind == MmsDataKind.Unknown ? "MMS Data value is unknown." : "MMS Data value decoded." }; } @@ -195,6 +207,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen Reference = reference, IsSuccess = value.Kind != MmsDataKind.Unknown, Value = value.Kind == MmsDataKind.Unknown ? null : value, + FailureKind = value.Kind == MmsDataKind.Unknown ? MmsReadEvidenceFailureKind.InvalidResponse : MmsReadEvidenceFailureKind.None, Message = value.Kind == MmsDataKind.Unknown ? "Wrapped MMS Data value is unknown." : "Wrapped MMS Data value decoded." }; } @@ -204,6 +217,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen { Reference = reference, IsSuccess = false, + FailureKind = MmsReadEvidenceFailureKind.InvalidResponse, Message = $"Unsupported MMS AccessResult tag 0x{accessResult.EncodedTag:X2}." }; } @@ -224,13 +238,16 @@ private static bool IsMmsData(BerTlv node) private static MmsReadBatchResult FailAll( IReadOnlyList references, string message, - string hex) + string hex, + int extraAccessResultCount = 0) => new() { + ExtraAccessResultCount = extraAccessResultCount, Results = references.Select(reference => new MmsReadAccessResult { Reference = reference, IsSuccess = false, + FailureKind = MmsReadEvidenceFailureKind.InvalidResponse, Message = message }).ToArray(), Message = message, diff --git a/src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs b/src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs new file mode 100644 index 00000000..05a53662 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs @@ -0,0 +1,32 @@ +namespace AR.Iec61850.Mms; + +/// +/// Evidence classification is orthogonal to MMS Data type: a failed access +/// never becomes a scalar, including when an expected SCL type exists. +/// +public enum MmsReadEvidenceFailureKind +{ + None, + AccessDenied, + NotSupported, + TypeMismatch, + ObjectMissing, + TemporarilyUnavailable, + RemoteAccessFailure, + InvalidResponse, + ProtocolRejected +} + +public static class MmsReadEvidenceClassifier +{ + public static MmsReadEvidenceFailureKind FromAccessCode(int? code) + => code switch + { + 2 => MmsReadEvidenceFailureKind.TemporarilyUnavailable, + 3 => MmsReadEvidenceFailureKind.AccessDenied, + 4 or 5 or 10 => MmsReadEvidenceFailureKind.ObjectMissing, + 6 or 9 => MmsReadEvidenceFailureKind.NotSupported, + 7 or 8 => MmsReadEvidenceFailureKind.TypeMismatch, + _ => MmsReadEvidenceFailureKind.RemoteAccessFailure + }; +} diff --git a/src/AR.Iec61850/Mms/MmsReceivePump.cs b/src/AR.Iec61850/Mms/MmsReceivePump.cs index d6f68854..1937f127 100644 --- a/src/AR.Iec61850/Mms/MmsReceivePump.cs +++ b/src/AR.Iec61850/Mms/MmsReceivePump.cs @@ -183,6 +183,7 @@ private async Task RunAsync(CancellationToken cancellationToken) { LastFaultMessage = $"MMS receive pump fault: {ex.GetType().Name}: {ex.Message}"; FaultPendingOperations(ex); + _router.FaultCanonicalInformationReports(ex); _router.FaultInformationReportSubscriptions(ex); } } diff --git a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs index 052909bc..523c85cc 100644 --- a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs +++ b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs @@ -21,10 +21,29 @@ public sealed class MmsReceiveRouter { private readonly object _sync = new(); private readonly Dictionary> _confirmedByInvoke = new(); + // The canonical FIFO is the static report monitor authority. Bound both frame count + // and retained PDU bytes; never silently discard a buffered event on overload. + private const int DefaultCanonicalCapacity = 2048; + private const long DefaultCanonicalByteCapacity = 32L * 1024 * 1024; + private readonly int _canonicalCapacity; + private readonly long _canonicalByteCapacity; + private long _queuedCanonicalBytes; + private Exception? _canonicalReportFault; private readonly Queue _informationReports = new(); private readonly Queue _unconfirmed = new(); private readonly Queue _unmatched = new(); private readonly Dictionary> _informationReportSubscriptions = new(); + private readonly SemaphoreSlim _informationReportAvailable = new(0, 1); + + public MmsReceiveRouter( + int canonicalReportCapacity = DefaultCanonicalCapacity, + long canonicalReportByteCapacity = DefaultCanonicalByteCapacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(canonicalReportCapacity); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(canonicalReportByteCapacity); + _canonicalCapacity = canonicalReportCapacity; + _canonicalByteCapacity = canonicalReportByteCapacity; + } public int QueuedConfirmedResultCount { @@ -68,6 +87,7 @@ public MmsReceiveRouteResult Route(ReadOnlyMemory presentationPayload) lock (_sync) { + ThrowIfCanonicalReportFaultLocked(); if (envelope.IsConfirmedServiceResult && envelope.InvokeId.HasValue) { if (!_confirmedByInvoke.TryGetValue(envelope.InvokeId.Value, out var queue)) @@ -87,9 +107,39 @@ public MmsReceiveRouteResult Route(ReadOnlyMemory presentationPayload) if (envelope.IsInformationReport) { + var retainedBytes = envelope.PresentationPayload.Length; + if (_informationReports.Count >= _canonicalCapacity || + retainedBytes > _canonicalByteCapacity - _queuedCanonicalBytes) + { + _canonicalReportFault = new IOException( + "Canonical MMS InformationReport FIFO exceeded its bounded frame/byte capacity; " + + "SOE continuity is unverified. Re-establish the MMS association after addressing consumer lag."); + SignalInformationReportAvailable(); + throw (IOException)_canonicalReportFault; + } + _informationReports.Enqueue(envelope); - foreach (var subscription in _informationReportSubscriptions.Values) - subscription.Writer.TryWrite(envelope); + _queuedCanonicalBytes += retainedBytes; + SignalInformationReportAvailable(); + // A subscriber cannot silently discard SOE on burst pressure. + // Preserve the canonical FIFO regardless of subscriber capacity; + // isolate and fault only consumers which cannot keep up. + List? overflowed = null; + foreach (var (id, subscription) in _informationReportSubscriptions) + { + if (!subscription.Writer.TryWrite(envelope)) + { + (overflowed ??= new List()).Add(id); + subscription.Writer.TryComplete(new IOException( + "MMS InformationReport subscriber exceeded its bounded capacity; " + + "continuity is unverified. Resubscribe/re-establish the report stream.")); + } + } + if (overflowed is not null) + { + foreach (var id in overflowed) + _informationReportSubscriptions.Remove(id); + } return new MmsReceiveRouteResult { @@ -144,20 +194,63 @@ public bool TryDequeueInformationReport(out MmsPduEnvelope envelope) if (_informationReports.Count > 0) { envelope = _informationReports.Dequeue(); + _queuedCanonicalBytes -= envelope.PresentationPayload.Length; return true; } } + lock (_sync) + ThrowIfCanonicalReportFaultLocked(); + envelope = new MmsPduEnvelope(); return false; } + /// + /// Waits until the canonical InformationReport queue has work without polling. + /// The signal is binary: multiple reports coalesce into one wake because callers + /// drain the queue after waking. Queue contents remain the single routing authority. + /// + internal async Task WaitForInformationReportAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + lock (_sync) + { + if (_informationReports.Count > 0) + { + _informationReportAvailable.Wait(0); + return true; + } + + ThrowIfCanonicalReportFaultLocked(); + } + + if (timeout <= TimeSpan.Zero) + return false; + + var signalled = await _informationReportAvailable + .WaitAsync(timeout, cancellationToken) + .ConfigureAwait(false); + lock (_sync) + { + if (_informationReports.Count == 0) + ThrowIfCanonicalReportFaultLocked(); + } + + return signalled; + } + internal MmsInformationReportSubscription SubscribeInformationReports(int capacity = 32) { var boundedCapacity = Math.Clamp(capacity, 1, 1024); var channel = Channel.CreateBounded(new BoundedChannelOptions(boundedCapacity) { - FullMode = BoundedChannelFullMode.DropOldest, + // Never silently throw away the oldest event (SOE data loss). + // TryWrite=false is handled by explicitly faulting this subscriber. + FullMode = BoundedChannelFullMode.Wait, SingleReader = true, SingleWriter = false, AllowSynchronousContinuations = false @@ -197,6 +290,39 @@ internal void FaultInformationReportSubscriptions(Exception exception) subscription.Writer.TryComplete(exception); } + // Called by the single receive pump on a terminal transport/decoder error. + // Existing canonical entries remain drainable in FIFO order; after the final + // entry the consumer observes the terminal error rather than a false clean slice. + internal void FaultCanonicalInformationReports(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + lock (_sync) + { + _canonicalReportFault ??= exception; + SignalInformationReportAvailable(); + } + } + + private void ThrowIfCanonicalReportFaultLocked() + { + if (_canonicalReportFault is not null) + throw new IOException( + "Canonical MMS InformationReport continuity is unverified; association receive routing has faulted.", + _canonicalReportFault); + } + + private void SignalInformationReportAvailable() + { + try + { + _informationReportAvailable.Release(); + } + catch (SemaphoreFullException) + { + // A wake is already pending. The queue remains authoritative and will be drained. + } + } + public void Clear() { Channel[] subscriptions; @@ -204,12 +330,18 @@ public void Clear() { _confirmedByInvoke.Clear(); _informationReports.Clear(); + _queuedCanonicalBytes = 0; + _canonicalReportFault = null; _unconfirmed.Clear(); _unmatched.Clear(); subscriptions = _informationReportSubscriptions.Values.ToArray(); _informationReportSubscriptions.Clear(); } + while (_informationReportAvailable.Wait(0)) + { + } + foreach (var subscription in subscriptions) subscription.Writer.TryComplete(new IOException("MMS association receive routing was reset.")); } diff --git a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs new file mode 100644 index 00000000..653559b3 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs @@ -0,0 +1,365 @@ +using System.Collections.Concurrent; + +namespace AR.Iec61850.Mms; + +/// +/// Binds a well-known IEC 61850 Report Control Block scalar field to the exact MMS +/// value kind used on the wire. This is the semantic boundary between reporting +/// policy and MMS encoding: callers request an IEC field/value, not a raw MMS scalar. +/// +/// The contract intentionally covers only scalar mutation fields whose semantics are +/// stable across BRCB/URCB implementations. Bit-string and object-reference fields keep +/// their dedicated codecs. +/// +public readonly record struct MmsReportControlSemanticWrite +{ + private MmsReportControlSemanticWrite( + string attribute, + MmsDataValue value, + MmsDataKind expectedKind, + string expectedMmsType) + { + Attribute = attribute; + Value = value; + ExpectedKind = expectedKind; + ExpectedMmsType = expectedMmsType; + } + + public string Attribute { get; } + public MmsDataValue Value { get; } + public MmsDataKind ExpectedKind { get; } + public string ExpectedMmsType { get; } + + public static MmsReportControlSemanticWrite ReportEnable(bool enabled) + => Boolean("RptEna", enabled); + + public static MmsReportControlSemanticWrite GeneralInterrogation(bool requested) + => Boolean("GI", requested); + + public static MmsReportControlSemanticWrite Reservation(bool reserved) + => Boolean("Resv", reserved); + + /// + /// IEC 61850 BRCB ResvTms is mapped to MMS INTEGER with signed INT16 wire semantics. + /// Policy about particular reservation-time values belongs above this codec; this + /// boundary validates only the standardized scalar representation and width. + /// + public static MmsReportControlSemanticWrite ReservationTime(int seconds) + { + if (seconds is < short.MinValue or > short.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(seconds), + seconds, + $"BRCB ResvTms must fit the signed IEC INT16 range {short.MinValue}..{short.MaxValue}."); + } + + return new MmsReportControlSemanticWrite( + "ResvTms", + MmsDataValue.Integer(seconds), + MmsDataKind.Integer, + "integer"); + } + + public bool IsCompatibleWith(MmsTypeSpecificationNode? liveType) + => liveType is not null && + Value.Kind == ExpectedKind && + string.Equals(liveType.MmsType, ExpectedMmsType, StringComparison.OrdinalIgnoreCase); + + private static MmsReportControlSemanticWrite Boolean(string attribute, bool value) + => new( + attribute, + MmsDataValue.Boolean(value), + MmsDataKind.Boolean, + "boolean"); +} + +public enum MmsReportSemanticTypeEvidenceStatus +{ + NotChecked, + ExactMatch, + ExactMismatch, + Unavailable +} + +/// +/// Evidence for one concrete RCB scalar field on the current MMS association. +/// A successful live GetVariableAccessAttributes result is exact protocol evidence; +/// a failed/unsupported probe remains missing evidence and is not converted into a +/// false blocker. An exact contradiction is a positive blocker and prevents mutation. +/// +public sealed class MmsReportSemanticTypeEvidence +{ + public MmsReportSemanticTypeEvidenceStatus Status { get; init; } + public string Reference { get; init; } = string.Empty; + public string Attribute { get; init; } = string.Empty; + public string ExpectedMmsType { get; init; } = string.Empty; + public string LiveMmsType { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + + public bool IsExact => Status is + MmsReportSemanticTypeEvidenceStatus.ExactMatch or + MmsReportSemanticTypeEvidenceStatus.ExactMismatch; + + public bool AllowsMutation => Status != MmsReportSemanticTypeEvidenceStatus.ExactMismatch; +} + +public static class MmsReportSemanticTypePolicy +{ + public static MmsReportSemanticTypeEvidence Evaluate( + MmsReportControlSemanticWrite write, + string reference, + MmsVariableAccessAttributesResult? live) + { + if (live?.IsSuccess != true || live.TypeSpecification is null) + { + return new MmsReportSemanticTypeEvidence + { + Status = MmsReportSemanticTypeEvidenceStatus.Unavailable, + Reference = reference, + Attribute = write.Attribute, + ExpectedMmsType = write.ExpectedMmsType, + LiveMmsType = live?.MmsType ?? string.Empty, + Source = live?.Source ?? "IEC semantic contract", + Message = live is null + ? "No live TypeSpecification probe was available; the IEC semantic contract remains the write authority." + : $"Live TypeSpecification was unavailable ({live.Message}); the IEC semantic contract remains the write authority." + }; + } + + var exactMatch = write.IsCompatibleWith(live.TypeSpecification); + return new MmsReportSemanticTypeEvidence + { + Status = exactMatch + ? MmsReportSemanticTypeEvidenceStatus.ExactMatch + : MmsReportSemanticTypeEvidenceStatus.ExactMismatch, + Reference = reference, + Attribute = write.Attribute, + ExpectedMmsType = write.ExpectedMmsType, + LiveMmsType = live.MmsType, + Source = live.Source, + Message = exactMatch + ? $"Live MMS TypeSpecification confirms {write.Attribute} as {live.MmsType}." + : $"Live MMS TypeSpecification reports {write.Attribute} as {live.MmsType}, conflicting with IEC semantic type {write.ExpectedMmsType}." + }; + } +} + +public sealed partial class MmsClientSession +{ + private readonly ConcurrentDictionary _reportSemanticTypeEvidence = + new(StringComparer.Ordinal); + + /// + /// Association-stable type evidence must never cross an MMS re-association. + /// Volatile RCB state is intentionally not cached here. + /// + private void ResetReportSemanticTypeEvidence() + => _reportSemanticTypeEvidence.Clear(); + + /// + /// Primes multiple scalar-field type contracts from one complete RCB + /// GetVariableAccessAttributes response when the server exposes named structure + /// components. This is an optimization only: missing/unsupported structure evidence + /// leaves the normal exact-leaf preflight unchanged. + /// + private async Task PrimeReportSemanticTypeEvidenceFromStructureAsync( + MmsReportControlCandidate rcb, + IReadOnlyCollection writes, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(rcb); + ArgumentNullException.ThrowIfNull(writes); + + var pending = writes + .DistinctBy(write => write.Attribute, StringComparer.OrdinalIgnoreCase) + .Where(write => + { + var reference = BuildReportAttributeReference(rcb, write.Attribute); + return !_reportSemanticTypeEvidence.ContainsKey(ReportSemanticTypeCacheKey(reference)); + }) + .ToArray(); + + // A single field gains nothing from a structure probe because its normal leaf + // preflight costs the same one confirmed service request. + if (pending.Length < 2) + return; + + MmsVariableAccessAttributesResult structure; + try + { + var baseReference = MmsObjectReference.Parse(rcb.Reference, rcb.FunctionalConstraint); + structure = await GetVariableAccessAttributesAsync( + baseReference, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return; + } + + if (!structure.IsSuccess || + structure.TypeSpecification is not { MmsType: "structure" } type || + type.Children.Count == 0) + { + return; + } + + foreach (var write in pending) + { + var child = type.Children.FirstOrDefault(component => + string.Equals(component.Name, write.Attribute, StringComparison.OrdinalIgnoreCase)); + if (child is null) + continue; + + var reference = BuildReportAttributeReference(rcb, write.Attribute); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = reference, + TypeSpecification = child, + Source = "GetVariableAccessAttributes(RCB structure)", + Message = $"RCB structure TypeSpecification supplied exact component {write.Attribute}:{child.MmsType}." + }; + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + reference.ToString(), + live); + + if (evidence.IsExact) + _reportSemanticTypeEvidence[ReportSemanticTypeCacheKey(reference)] = evidence; + } + } + + /// + /// Normal RCB scalar mutation path. The first mutation of each exact field on an + /// association performs a bounded exact GetVariableAccessAttributes check. Exact + /// matching evidence is cached for that association; missing evidence does not become + /// a false blocker, while an exact type contradiction fails closed before mutation. + /// + private async Task WriteReportSemanticAttributeAsync( + MmsReportControlCandidate rcb, + MmsReportControlSemanticWrite write, + CancellationToken cancellationToken) + { + var reference = BuildReportAttributeReference(rcb, write.Attribute); + var typeEvidence = await GetReportSemanticTypeEvidenceAsync( + reference, + write, + forceRefresh: false, + cancellationToken).ConfigureAwait(false); + + if (!typeEvidence.AllowsMutation) + { + return new MmsReportAttributeWriteStep + { + Attribute = write.Attribute, + Reference = reference.ToString(), + Attempted = false, + IsSuccess = false, + TypeEvidence = typeEvidence, + Message = $"RCB write blocked before mutation: {typeEvidence.Message}" + }; + } + + var step = await WriteReportAttributeAsync( + rcb, + write.Attribute, + write.Value, + cancellationToken).ConfigureAwait(false); + + if (!step.IsSuccess && + step.FailureKind == MmsInteropFailureKind.TypeMismatch && + IsMmsInitiated) + { + typeEvidence = await GetReportSemanticTypeEvidenceAsync( + reference, + write, + forceRefresh: true, + cancellationToken).ConfigureAwait(false); + } + + return WithSemanticTypeEvidence(step, typeEvidence); + } + + /// + /// Cleanup never adds a new network preflight. It reuses any exact type evidence + /// already learned on this association and otherwise uses the standardized semantic + /// encoder so cleanup is not delayed by optional diagnostics. + /// + private async Task TryWriteReportSemanticAttributeForCleanupAsync( + MmsReportControlCandidate rcb, + MmsReportControlSemanticWrite write, + CancellationToken cancellationToken) + { + var reference = BuildReportAttributeReference(rcb, write.Attribute); + _reportSemanticTypeEvidence.TryGetValue(ReportSemanticTypeCacheKey(reference), out var typeEvidence); + + var step = await TryWriteReportAttributeForCleanupAsync( + rcb, + write.Attribute, + write.Value, + cancellationToken).ConfigureAwait(false); + + return typeEvidence is null ? step : WithSemanticTypeEvidence(step, typeEvidence); + } + + private async Task GetReportSemanticTypeEvidenceAsync( + MmsObjectReference reference, + MmsReportControlSemanticWrite write, + bool forceRefresh, + CancellationToken cancellationToken) + { + var key = ReportSemanticTypeCacheKey(reference); + if (!forceRefresh && _reportSemanticTypeEvidence.TryGetValue(key, out var cached)) + return cached; + + MmsVariableAccessAttributesResult? live = null; + try + { + live = await GetVariableAccessAttributesAsync(reference, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + live = new MmsVariableAccessAttributesResult + { + IsSuccess = false, + Reference = reference, + Source = "GetVariableAccessAttributes", + Message = $"Type preflight failed safely: {ex.GetType().Name}: {ex.Message}" + }; + } + + var evidence = MmsReportSemanticTypePolicy.Evaluate(write, reference.ToString(), live); + if (evidence.IsExact) + _reportSemanticTypeEvidence[key] = evidence; + + return evidence; + } + + private static MmsObjectReference BuildReportAttributeReference( + MmsReportControlCandidate rcb, + string attribute) + => MmsObjectReference.Parse($"{rcb.Reference}.{attribute}", rcb.FunctionalConstraint); + + private static string ReportSemanticTypeCacheKey(MmsObjectReference reference) + => $"{reference.Domain}/{reference.Item}"; + + private static MmsReportAttributeWriteStep WithSemanticTypeEvidence( + MmsReportAttributeWriteStep step, + MmsReportSemanticTypeEvidence typeEvidence) + => new() + { + Attribute = step.Attribute, + Reference = step.Reference, + Attempted = step.Attempted, + IsSuccess = step.IsSuccess, + FailureCode = step.FailureCode, + FailureName = step.FailureName, + FailureKind = step.FailureKind, + RecoveryHint = step.RecoveryHint, + TypeEvidence = typeEvidence, + Message = $"{step.Message} Type evidence: {typeEvidence.Message}" + }; +} diff --git a/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs b/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs index 6c8a9b5b..8f4d9733 100644 --- a/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs +++ b/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs @@ -12,7 +12,10 @@ public static MmsReportInventory BuildInventory(MmsDiscoverySnapshot snapshot) { ArgumentNullException.ThrowIfNull(snapshot); - var inventory = new MmsReportInventory(); + var inventory = new MmsReportInventory + { + Authority = MmsReportInventoryAuthority.LiveMmsObserved + }; inventory.DataSets.AddRange(BuildDataSets(snapshot.DomainVariableLists)); inventory.ReportControls.AddRange(BuildReportControls(snapshot.DomainVariables)); return inventory; diff --git a/src/AR.Iec61850/Mms/MmsReportInventory.cs b/src/AR.Iec61850/Mms/MmsReportInventory.cs index 9bc43697..4bab6819 100644 --- a/src/AR.Iec61850/Mms/MmsReportInventory.cs +++ b/src/AR.Iec61850/Mms/MmsReportInventory.cs @@ -1,5 +1,13 @@ namespace AR.Iec61850.Mms; +public enum MmsReportInventoryAuthority +{ + Unknown, + LiveMmsObserved, + SclDesignProjection, + CanonicalModelProjection +} + public enum MmsRcbDataSetProbeState { NotAttempted, @@ -47,6 +55,11 @@ public sealed class MmsReportControlCandidate public sealed class MmsReportInventory { + /// + /// Provenance of the inventory itself. Operational planners must not infer live + /// authority merely from the presence of plausible RCB names or DataSet bindings. + /// + public MmsReportInventoryAuthority Authority { get; set; } = MmsReportInventoryAuthority.Unknown; public List DataSets { get; } = new(); public List ReportControls { get; } = new(); diff --git a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs index 64dbbc2b..3239a7c6 100644 --- a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs +++ b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs @@ -6,6 +6,11 @@ public sealed class MmsReportAttributeWriteStep public string Reference { get; init; } = string.Empty; public bool Attempted { get; init; } public bool IsSuccess { get; init; } + public int? FailureCode { get; init; } + public string FailureName { get; init; } = string.Empty; + public MmsInteropFailureKind FailureKind { get; init; } = MmsInteropFailureKind.None; + public MmsInteropRecoveryHint RecoveryHint { get; init; } = MmsInteropRecoveryHint.None; + public MmsReportSemanticTypeEvidence? TypeEvidence { get; init; } public string Message { get; init; } = string.Empty; } @@ -226,12 +231,16 @@ public sealed class MmsReportRcbSnapshot public string Message { get; init; } = string.Empty; public string Reference { get; init; } = string.Empty; public string Mode { get; init; } = string.Empty; + public bool Buffered { get; init; } public string DataSetReference { get; init; } = string.Empty; + public MmsRcbDataSetProbeState DataSetProbeState { get; init; } = MmsRcbDataSetProbeState.NotAttempted; + public string DataSetProbeMessage { get; init; } = string.Empty; public string ReportId { get; init; } = string.Empty; public string ConfRev { get; init; } = string.Empty; public string EnabledState { get; init; } = string.Empty; public string ReservationState { get; init; } = string.Empty; public string ReservationTimeSeconds { get; init; } = string.Empty; + public string Owner { get; init; } = string.Empty; public string BufferTimeMs { get; init; } = string.Empty; public string IntegrityPeriodMs { get; init; } = string.Empty; public string TriggerOptions { get; init; } = string.Empty; @@ -251,12 +260,16 @@ internal static MmsReportRcbSnapshot FromCandidate(string stage, MmsReportContro Message = message, Reference = candidate.Reference, Mode = candidate.Mode, + Buffered = candidate.Buffered, DataSetReference = candidate.DataSetReference, + DataSetProbeState = candidate.DataSetProbeState, + DataSetProbeMessage = candidate.DataSetProbeMessage, ReportId = candidate.ReportId, ConfRev = candidate.ConfRev, EnabledState = candidate.EnabledState, ReservationState = candidate.ReservationState, ReservationTimeSeconds = candidate.ReservationTimeSeconds, + Owner = candidate.Owner, BufferTimeMs = candidate.BufferTimeMs, IntegrityPeriodMs = candidate.IntegrityPeriodMs, TriggerOptions = candidate.TriggerOptions, @@ -1084,14 +1097,14 @@ public async Task RunGuardedStaticReportSessionAsy } else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = true; if (!reserve.IsSuccess) warnings.Add("URCB Resv write failed. Proceeding guarded only if RptEna is accepted by the IED."); } - var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabledByThisClient = enable.IsSuccess; if (!enable.IsSuccess) @@ -1113,7 +1126,7 @@ public async Task RunGuardedStaticReportSessionAsy if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); if (!gi.IsSuccess) warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only."); @@ -1126,7 +1139,7 @@ public async Task RunGuardedStaticReportSessionAsy { periodicGiWriter = async token => { - var step = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), token).ConfigureAwait(false); + var step = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), token).ConfigureAwait(false); return new MmsReportAttributeWriteStep { Attribute = "GI(periodic)", @@ -1158,7 +1171,7 @@ public async Task RunGuardedStaticReportSessionAsy { if (enabledByThisClient) { - var disable = await TryWriteReportAttributeForCleanupAsync(rcb, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + var disable = await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); writes.Add(disable); if (!disable.IsSuccess) verificationChecks.Add(FailCheck("after-cleanup", $"{rcb.Reference}.RptEna", "write false accepted", disable.Message, "RptEna=false cleanup write failed.")); @@ -1171,8 +1184,8 @@ public async Task RunGuardedStaticReportSessionAsy if (reservationTouched) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); } } @@ -1273,7 +1286,7 @@ public async Task RunGuardedDynamicReportSessionAs } else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = true; if (!reserve.IsSuccess) @@ -1300,7 +1313,7 @@ public async Task RunGuardedDynamicReportSessionAs rcbSnapshots.Add(afterBindSnapshot); AddRcbStateChecks(verificationChecks, afterBindSnapshot, expectedRptEna: false, expectedDataSet: plan.DataSetReference, stage: "after-bind"); - var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabledByThisClient = enable.IsSuccess; if (!enable.IsSuccess) @@ -1322,7 +1335,7 @@ public async Task RunGuardedDynamicReportSessionAs if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); if (!gi.IsSuccess) warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only."); @@ -1336,7 +1349,7 @@ public async Task RunGuardedDynamicReportSessionAs { if (enabledByThisClient) { - var disable = await TryWriteReportAttributeForCleanupAsync(rcb, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + var disable = await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); writes.Add(disable); if (!disable.IsSuccess) verificationChecks.Add(FailCheck("after-cleanup", $"{rcb.Reference}.RptEna", "write false accepted", disable.Message, "RptEna=false cleanup write failed.")); @@ -1360,8 +1373,8 @@ public async Task RunGuardedDynamicReportSessionAs if (reservationTouched) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); } @@ -1917,14 +1930,20 @@ private async Task WriteReportAttributeAsync( MmsDataValue value, CancellationToken cancellationToken) { - var reference = MmsObjectReference.Parse($"{rcb.Reference}.{attribute}", rcb.FunctionalConstraint); + var reference = BuildReportAttributeReference(rcb, attribute); var result = await WriteSingleVariableAsync(reference, value, cancellationToken).ConfigureAwait(false); + var failure = result.AccessResults.FirstOrDefault(access => !access.IsSuccess); + var classification = MmsInteropFailureClassifier.Classify(failure); return new MmsReportAttributeWriteStep { Attribute = attribute, Reference = reference.ToString(), Attempted = true, IsSuccess = result.IsSuccess, + FailureCode = failure?.FailureCode, + FailureName = failure?.FailureName ?? string.Empty, + FailureKind = classification.Kind, + RecoveryHint = classification.RecoveryHint, Message = result.Message }; } @@ -1950,6 +1969,11 @@ private async Task TryWriteReportAttributeForCleanu Reference = first.Reference, Attempted = true, IsSuccess = false, + FailureCode = first.FailureCode, + FailureName = first.FailureName, + FailureKind = first.FailureKind, + RecoveryHint = first.RecoveryHint, + TypeEvidence = first.TypeEvidence, Message = $"cleanup reconnect failed. First attempt: {first.Message}" }; } @@ -1961,6 +1985,11 @@ private async Task TryWriteReportAttributeForCleanu Reference = retry.Reference, Attempted = true, IsSuccess = retry.IsSuccess, + FailureCode = retry.FailureCode, + FailureName = retry.FailureName, + FailureKind = retry.FailureKind, + RecoveryHint = retry.RecoveryHint, + TypeEvidence = retry.TypeEvidence, Message = retry.IsSuccess ? $"cleanup retry after reconnect succeeded. First attempt: {first.Message}" : $"cleanup retry after reconnect failed: {retry.Message}. First attempt: {first.Message}" @@ -1979,6 +2008,11 @@ private async Task TryWriteReportAttributeForCleanu Reference = retry.Reference, Attempted = true, IsSuccess = retry.IsSuccess, + FailureCode = retry.FailureCode, + FailureName = retry.FailureName, + FailureKind = retry.FailureKind, + RecoveryHint = retry.RecoveryHint, + TypeEvidence = retry.TypeEvidence, Message = retry.IsSuccess ? $"cleanup retry after reconnect succeeded. First exception: {ex.GetType().Name}: {ex.Message}" : $"cleanup retry after reconnect failed: {retry.Message}. First exception: {ex.GetType().Name}: {ex.Message}" diff --git a/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs b/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs index 8ad5286d..c100a44b 100644 --- a/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs +++ b/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs @@ -11,6 +11,11 @@ public sealed class MmsReportSessionDiagnostics public int ValueCount { get; init; } public int WriteStepCount { get; init; } public int WriteFailureCount { get; init; } + public int WritePreflightBlockedCount { get; init; } + public int WriteTypeMismatchCount { get; init; } + public int WriteAccessDeniedCount { get; init; } + public int WriteTypeEvidenceConflictCount { get; init; } + public int WriteTypeEvidenceUnavailableCount { get; init; } public int PollReadCount { get; init; } public int PollReadSuccessCount { get; init; } public int PollReadFailureCount { get; init; } @@ -67,7 +72,8 @@ public IReadOnlyList WarningMessages public string Summary => $"diagnostics={OverallStatus}, reports={ReportCount}, values={ValueCount}, mappedFailures={MappingFailureCount}, partialMappings={PartialMappingFailureCount}, " + - $"pollReads={PollReadSuccessCount}/{PollReadCount}, writeFailures={WriteFailureCount}, " + + $"pollReads={PollReadSuccessCount}/{PollReadCount}, writeFailures={WriteFailureCount}, preflightBlocked={WritePreflightBlockedCount}, " + + $"typeMismatch={WriteTypeMismatchCount}, typeEvidenceConflict={WriteTypeEvidenceConflictCount}, " + $"seqGaps={SequenceGapCount}, seqResets={SequenceResetCount}, seqRegressions={SequenceRegressionCount}, " + $"entryIdGaps={EntryIdGapCount}, entryIdRegressions={EntryIdRegressionCount}, " + $"duplicates={DuplicateReportKeyCount}, bufOvfl={BufferOverflowObserved.ToString().ToLowerInvariant()}"; @@ -177,6 +183,13 @@ public static MmsReportSessionDiagnostics Analyze( ValueCount = reports.Sum(x => x.Values.Count), WriteStepCount = writeSteps.Count, WriteFailureCount = writeSteps.Count(x => !x.IsSuccess), + WritePreflightBlockedCount = writeSteps.Count(x => !x.Attempted && !x.IsSuccess), + WriteTypeMismatchCount = writeSteps.Count(x => x.FailureKind == MmsInteropFailureKind.TypeMismatch), + WriteAccessDeniedCount = writeSteps.Count(x => x.FailureKind == MmsInteropFailureKind.AccessDenied), + WriteTypeEvidenceConflictCount = writeSteps.Count(x => + x.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.ExactMismatch), + WriteTypeEvidenceUnavailableCount = writeSteps.Count(x => + x.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.Unavailable), PollReadCount = pollReads.Count, PollReadSuccessCount = pollReads.Count(x => x.IsSuccess), PollReadFailureCount = pollReads.Count(x => !x.IsSuccess), diff --git a/src/AR.Iec61850/Mms/MmsReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsReportValueProjector.cs index ead27b50..3f501a87 100644 --- a/src/AR.Iec61850/Mms/MmsReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsReportValueProjector.cs @@ -310,6 +310,14 @@ private static bool TryProjectKnownDataObjectStruct( } } + // C264/IEC status FCD members such as Mod and PhyHealth are whole + // structured ST DataObjects, not the selected scalar stVal leaf. + // Trust only a *typed* MMS tuple (integer enumeration, decoded q, + // decoded UTC time, optional Boolean stSeld). Never peel the first + // element from an arbitrary human-readable Structure(...) summary. + if (TryProjectEnumeratedStatusStruct(reference, fc, value, reason, receivedAt, out projected)) + return true; + if (TryProjectBooleanStatusStruct(reference, fc, value, reason, receivedAt, out projected)) return true; @@ -334,6 +342,51 @@ private static bool TryProjectKnownDataObjectStruct( return false; } + private static bool TryProjectEnumeratedStatusStruct( + string reference, + string fc, + MmsDataValue value, + string reason, + DateTimeOffset receivedAt, + out IReadOnlyList projected) + { + projected = Array.Empty(); + // Constrain the standard status DO names and exact ST FC. A valid q/t + // tuple is not enough to authorize a guessed semantic leaf elsewhere. + if (!string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase) || + LastSegment(reference).ToUpperInvariant() is not ("MOD" or "BEH" or "HEALTH" or "PHYHEALTH") || + value.Kind != MmsDataKind.Structure || + value.Children.Count is not (3 or 4) || + value.Children[0].Kind is not (MmsDataKind.Integer or MmsDataKind.Unsigned) || + (value.Children.Count == 4 && value.Children[3].Kind != MmsDataKind.Boolean)) + return false; + + var q = Iec61850QualityDecoder.Decode(value.Children[1]); + var t = Iec61850TimestampDecoder.Decode(value.Children[2]); + if (!q.IsDecoded || !t.IsDecoded) + return false; + + // Extra field (if any) was type-checked but is not given an invented + // path. The exact IED schema can project stSeld independently. + var stValReference = Combine(reference, "stVal"); + projected = + [ + new MmsReportProjectedSignalCandidate + { + Reference = stValReference, + FunctionalConstraint = "ST", + Value = DisplayScalar(stValReference, value.Children[0]), + Quality = q.Validity, + Timestamp = t.DisplayTime, + Reason = reason, + UpdatedAt = receivedAt, + IsProjectedChild = true, + ProjectionStatus = "projected-typed-enumerated-status" + } + ]; + return true; + } + private static bool TryProjectBooleanStatusStruct( string reference, string fc, diff --git a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs index b81cea8e..bbbc24d9 100644 --- a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs @@ -63,6 +63,9 @@ public static MmsReportSemanticProjectionContext Create(LiveIedModelDiscoveryDoc accumulated = $"{accumulated}.{part}"; current = current.GetOrAdd(part, accumulated); } + // Only a type backed by exact model evidence can veto a + // wire leaf. Partial discovery must remain compatible. + current.ObserveExactType(ExactMmsKind(attribute)); } if (root.Children.Count == 0) @@ -194,6 +197,16 @@ private static bool TryFlatten( return false; } + if (schema.HasTypeConflict) + { + reason = $"typed schema conflict for {schema.Reference}: exact model sources disagree"; + return false; + } + if (schema.ExactKind is MmsDataKind expected && expected != value.Kind) + { + reason = $"typed MMS leaf mismatch at {schema.Reference}: expected {expected}, received {value.Kind}"; + return false; + } leaves.Add(new ExpandedLeaf(schema.Reference, value)); reason = string.Empty; return true; @@ -253,6 +266,30 @@ private static string NormalizeDataSetReference(string value) return normalized[..(slash + 1)] + normalized[(slash + 1)..].Replace('$', '.'); } + // Strictly known, model-qualified MMS kinds only. SCL bType alone is not + // live MMS type authority; unknown/partial types retain existing fallback. + private static MmsDataKind? ExactMmsKind(LiveIedDataAttributeModel attribute) + { + if (attribute.TypeConfidence != LiveIedDiscoveryConfidenceLevel.Exact || + string.IsNullOrWhiteSpace(attribute.MmsType)) + return null; + + return attribute.MmsType.Trim().ToLowerInvariant().Replace('_', '-') switch + { + "floating-point" or "floatingpoint" => MmsDataKind.FloatingPoint, + "bit-string" or "bitstring" => MmsDataKind.BitString, + "utc-time" or "utctime" => MmsDataKind.UtcTime, + "boolean" => MmsDataKind.Boolean, + "integer" or "signed-integer" => MmsDataKind.Integer, + "unsigned" or "unsigned-integer" => MmsDataKind.Unsigned, + "octet-string" or "octetstring" => MmsDataKind.OctetString, + "visible-string" or "visiblestring" => MmsDataKind.VisibleString, + "mms-string" or "mmsstring" => MmsDataKind.MmsString, + "binary-time" or "binarytime" => MmsDataKind.BinaryTime, + _ => null + }; + } + private sealed class SchemaNode { private readonly Dictionary _byName = new(StringComparer.OrdinalIgnoreCase); @@ -263,6 +300,18 @@ public SchemaNode(string reference) public string Reference { get; } public IReadOnlyList Children => _children; + public MmsDataKind? ExactKind { get; private set; } + public bool HasTypeConflict { get; private set; } + + public void ObserveExactType(MmsDataKind? kind) + { + if (!kind.HasValue) + return; + if (ExactKind.HasValue && ExactKind != kind) + HasTypeConflict = true; + else + ExactKind = kind; + } public SchemaNode GetOrAdd(string name, string reference) { @@ -302,6 +351,7 @@ public static MmsReportValueProjection Project( var baseline = MmsReportValueProjector.Project(frame); var semanticReplacementPositions = new HashSet(); + var rejectedTypedPositions = new HashSet(); var semanticUpdates = new List(); var semanticWarnings = new List(); @@ -323,7 +373,17 @@ public static MmsReportValueProjection Project( out var resolvedMemberReference, out var expansionReason)) { - if (baselineWasRaw) + // A positively contradictory exact MMS type is not permission + // for the generic positional fallback to invent process values. + // Preserve the original frame and explicit rejection evidence, + // but exclude this member's candidate value/quality/timestamp. + if (expansionReason.StartsWith("typed MMS leaf mismatch", StringComparison.Ordinal) || + expansionReason.StartsWith("typed schema conflict", StringComparison.Ordinal)) + { + rejectedTypedPositions.Add(valuePosition); + semanticWarnings.Add($"REPORT_SEMANTIC_REJECTED: {reportedMemberReference}; {expansionReason}; no scalar projection from this member."); + } + else if (baselineWasRaw) { semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); } @@ -369,7 +429,7 @@ public static MmsReportValueProjection Project( semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {resolvedMemberReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics."); } - if (semanticReplacementPositions.Count == 0) + if (semanticReplacementPositions.Count == 0 && rejectedTypedPositions.Count == 0) { return new MmsReportValueProjection { @@ -383,7 +443,8 @@ public static MmsReportValueProjection Project( ReceivedAt = frame.ReceivedAt, Header = frame.Header, Values = frame.Values - .Where((_, index) => !semanticReplacementPositions.Contains(index)) + .Where((_, index) => !semanticReplacementPositions.Contains(index) && + !rejectedTypedPositions.Contains(index)) .ToArray(), DecoderMode = frame.DecoderMode, Message = frame.Message diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs index 0e6ddaae..c643f8ab 100644 --- a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs @@ -18,6 +18,16 @@ public static int ResolveWindow(int requested, int unknownPeerCap, int? negotiat return Math.Min(requestedBound, unknownBound); } + /// + /// Independent RCB read-only enrichment may fill the negotiated window, + /// but cannot exceed the peer's confirmed outstanding-calling capacity, + /// the finite report candidate count, or the existing hard worker cap. + /// Per-RCB fallback reads themselves remain ordered and serial. + /// + public static int ResolveReportEnrichmentWorkers(int effectiveWindow, int candidateCount) + => candidateCount <= 0 ? 0 : + Math.Min(Math.Clamp(effectiveWindow, 1, 8), candidateCount); + /// /// Selects the live domain set independently from any SCL hint. This is important: /// SCL may prioritize online work, but it must never manufacture or suppress live @@ -122,6 +132,55 @@ public static MmsObjectReference[] SelectLiveLogicalNodeRoots( .ToArray(); } + + /// + /// Wire-authoritative ST/MX LN candidates from successful DataSet directories. + /// No SCL guesses, no duplicate GVA requests, no extra name-list crawl. + /// + public static MmsObjectReference[] SelectUnprobedDataSetLogicalNodeRoots( + MmsIedModelDirectory directory, + IReadOnlyList directories, + IReadOnlyList attemptedTypes) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(directories); + ArgumentNullException.ThrowIfNull(attemptedTypes); + + var domains = directory.LogicalDevices.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var attempted = attemptedTypes + .Where(result => !result.Reference.Item.Contains('$')) + .Select(result => BuildReferenceKey(result.Reference.Domain, result.Reference.Item)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var targets = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var dataSet in directories.Where(dataSet => dataSet.IsSuccess)) + foreach (var member in dataSet.Members) + { + if (!domains.Contains(member.Domain) || + !(member.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + member.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase))) + continue; + + var parts = member.MmsItemName.Split('$'); + if (parts.Length < 3 || + !parts[1].Equals(member.FunctionalConstraint, StringComparison.OrdinalIgnoreCase)) + continue; + + var ln = parts[0].Trim(); + if (ln.Length == 0 || ln.Length > 64 || + !ln.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_')) + continue; + + var key = BuildReferenceKey(member.Domain, ln); + if (!attempted.Contains(key)) + targets.TryAdd(key, new MmsObjectReference(member.Domain, ln, string.Empty)); + } + + return targets.Values + .OrderBy(root => root.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(root => root.Item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + public static MmsObjectReference BuildDataObjectRoot(MmsFcResolvedPoint point) { ArgumentNullException.ThrowIfNull(point); diff --git a/src/AR.Iec61850/Mms/MmsWrite.cs b/src/AR.Iec61850/Mms/MmsWrite.cs index 4b23ba63..878c313f 100644 --- a/src/AR.Iec61850/Mms/MmsWrite.cs +++ b/src/AR.Iec61850/Mms/MmsWrite.cs @@ -17,6 +17,12 @@ public sealed class MmsWriteResult public IReadOnlyList AccessResults { get; init; } = Array.Empty(); public string Message { get; init; } = string.Empty; public string ResponseHexPreview { get; init; } = string.Empty; + + // Time inside the client process, not on-the-wire latency. Populated + // only when this write has a confirmed MMS response. + public TimeSpan? TpktSendElapsed { get; internal set; } + public TimeSpan? ConfirmedResponseWaitElapsed { get; internal set; } + public TimeSpan? ResponseDecodeElapsed { get; internal set; } } public static class MmsWriteRequest @@ -102,6 +108,11 @@ public static MmsWriteResult Decode(ReadOnlyMemory presentationPayload, in return Fail("MMS response has no Write service response node [5].", hex); var accessResults = DecodeAccessResults(service).ToArray(); + // This decoder is used by the single-variable Write primitive. An empty or + // multi-result response is not proof that the requested single DA was written. + if (accessResults.Length != 1) + return Fail($"MMS single-variable Write expected exactly one access result, received {accessResults.Length}.", hex); + var success = accessResults.Length > 0 && accessResults.All(x => x.IsSuccess); var failures = accessResults .Select((result, index) => (result, index)) diff --git a/src/AR.Iec61850/SampledValues/Analysis/SvEngineeringWindowAnalyzer.cs b/src/AR.Iec61850/SampledValues/Analysis/SvEngineeringWindowAnalyzer.cs new file mode 100644 index 00000000..a317895a --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Analysis/SvEngineeringWindowAnalyzer.cs @@ -0,0 +1,417 @@ +using AR.Iec61850.SampledValues.Measurements; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.SampledValues.Analysis; + +public sealed record SvEngineeringWindowAnalysisOptions +{ + private const long MaximumBufferedDoubles = 8L * 1024L * 1024L; + + public int MaximumStreams { get; init; } = 256; + public int MaximumChannelsPerStream { get; init; } = 32; + public int MaximumSamplesPerCycle { get; init; } = 512; + public double SamplesPerCycleTolerance { get; init; } = 0.01; + + public void Validate() + { + if (MaximumStreams < 1 || MaximumStreams > 4096) + throw new ArgumentOutOfRangeException(nameof(MaximumStreams)); + if (MaximumChannelsPerStream < 1 || MaximumChannelsPerStream > 1024) + throw new ArgumentOutOfRangeException(nameof(MaximumChannelsPerStream)); + if (MaximumSamplesPerCycle < 4 || MaximumSamplesPerCycle > 8192) + throw new ArgumentOutOfRangeException(nameof(MaximumSamplesPerCycle)); + if (!double.IsFinite(SamplesPerCycleTolerance) || SamplesPerCycleTolerance < 0 || SamplesPerCycleTolerance > 0.5) + throw new ArgumentOutOfRangeException(nameof(SamplesPerCycleTolerance)); + + var worstCaseBufferedDoubles = + (long)MaximumStreams * MaximumChannelsPerStream * MaximumSamplesPerCycle; + if (worstCaseBufferedDoubles > MaximumBufferedDoubles) + { + throw new ArgumentOutOfRangeException( + nameof(MaximumSamplesPerCycle), + "Configured engineering-window bounds could retain more than 64 MiB of sample buffers."); + } + } +} + +/// +/// Explicit evidence required before an engineering waveform may be windowed for RMS/phasor analysis. +/// Unknown or profile-inferred rate/frequency values are deliberately rejected so 50/60 Hz and the +/// samples-per-cycle count are never guessed from payload shape or amplitude. +/// +public sealed record SvEngineeringWindowEvidence +{ + public double SampleRateHz { get; init; } + public SvFactSource SampleRateSource { get; init; } = SvFactSource.Unknown; + public double FundamentalFrequencyHz { get; init; } + public SvFactSource FundamentalFrequencySource { get; init; } = SvFactSource.Unknown; + public ushort? SampleCounterWrap { get; init; } + public SvFactSource SampleCounterWrapSource { get; init; } = SvFactSource.Unknown; + + public bool TryResolveSamplesPerCycle( + SvEngineeringWindowAnalysisOptions options, + out int samplesPerCycle, + out string reason) + { + ArgumentNullException.ThrowIfNull(options); + samplesPerCycle = 0; + + if (!double.IsFinite(SampleRateHz) || SampleRateHz <= 0) + { + reason = "Engineering-window analysis requires a finite positive sample rate."; + return false; + } + if (!IsExplicitEvidence(SampleRateSource)) + { + reason = "Engineering-window analysis requires an explicit sample-rate evidence source; unknown/profile-inferred values are rejected."; + return false; + } + if (!double.IsFinite(FundamentalFrequencyHz) || FundamentalFrequencyHz <= 0) + { + reason = "Engineering-window analysis requires a finite positive fundamental frequency."; + return false; + } + if (!IsExplicitEvidence(FundamentalFrequencySource)) + { + reason = "Engineering-window analysis requires an explicit fundamental-frequency evidence source; 50/60 Hz is never guessed."; + return false; + } + if (SampleCounterWrap is <= 1) + { + reason = "Configured smpCnt wrap must be greater than one."; + return false; + } + if (SampleCounterWrap.HasValue && !IsExplicitEvidence(SampleCounterWrapSource)) + { + reason = "A configured smpCnt wrap requires an explicit evidence source."; + return false; + } + + var exact = SampleRateHz / FundamentalFrequencyHz; + var rounded = Math.Round(exact, MidpointRounding.AwayFromZero); + if (!double.IsFinite(exact) || rounded < 4 || rounded > options.MaximumSamplesPerCycle) + { + reason = $"Resolved samples-per-cycle {exact:G17} is outside the supported bounded window range 4..{options.MaximumSamplesPerCycle}."; + return false; + } + if (Math.Abs(exact - rounded) > options.SamplesPerCycleTolerance) + { + reason = $"Sample rate {SampleRateHz:G17} Hz and fundamental {FundamentalFrequencyHz:G17} Hz do not resolve to an integral one-cycle window within tolerance."; + return false; + } + + samplesPerCycle = checked((int)rounded); + reason = string.Empty; + return true; + } + + private static bool IsExplicitEvidence(SvFactSource source) + => source is not SvFactSource.Unknown and not SvFactSource.ProfileInferred; +} + +public sealed record SvEngineeringWindowResult +{ + public SvObservedStreamKey StreamKey { get; init; } = new(); + public int ElementIndex { get; init; } + public string SignalReference { get; init; } = string.Empty; + public string Cdc { get; init; } = string.Empty; + public string EngineeringUnit { get; init; } = string.Empty; + public SvEngineeringScaleSource ScaleSource { get; init; } + public SvEngineeringScaleConfidence ScaleConfidence { get; init; } + public ushort FirstSampleCount { get; init; } + public ushort LastSampleCount { get; init; } + public int SamplesPerCycle { get; init; } + public double SampleRateHz { get; init; } + public SvFactSource SampleRateSource { get; init; } + public double FundamentalFrequencyHz { get; init; } + public SvFactSource FundamentalFrequencySource { get; init; } + public SvPhasorEstimate Estimate { get; init; } = new(); +} + +/// +/// Bounded per-stream/per-channel engineering waveform windowing. Input must already come from the +/// fail-closed SCL-bound projector. Raw-only values are ignored. A partial cycle is discarded whenever +/// smpCnt continuity or channel metadata is broken, so RMS/phasor results never bridge an observed gap, +/// duplicate, out-of-order transition, restart, mapping change, or evidence/timebase change. +/// +public sealed class SvEngineeringWindowAnalyzer +{ + private sealed record WindowConfiguration( + int SamplesPerCycle, + double SampleRateHz, + SvFactSource SampleRateSource, + double FundamentalFrequencyHz, + SvFactSource FundamentalFrequencySource, + ushort? SampleCounterWrap, + SvFactSource SampleCounterWrapSource); + + private sealed class ChannelState + { + private readonly SvSampleCounterTracker _counter = new(); + private double[] _samples; + private int _count; + private ushort _firstSampleCount; + private ushort _lastSampleCount; + + public ChannelState(SvProjectedMeasurementSample sample, int samplesPerCycle) + { + _samples = new double[samplesPerCycle]; + SetMetadata(sample); + } + + public string SignalReference { get; private set; } = string.Empty; + public string Cdc { get; private set; } = string.Empty; + public string EngineeringUnit { get; private set; } = string.Empty; + public SvEngineeringScaleSource ScaleSource { get; private set; } + public SvEngineeringScaleConfidence ScaleConfidence { get; private set; } + + public bool MetadataMatches(SvProjectedMeasurementSample sample) + => string.Equals(SignalReference, sample.SignalReference, StringComparison.Ordinal) && + string.Equals(Cdc, sample.Cdc, StringComparison.Ordinal) && + string.Equals(EngineeringUnit, sample.Scale.Unit, StringComparison.Ordinal) && + ScaleSource == sample.Scale.Source && + ScaleConfidence == sample.Scale.Confidence; + + public void ResetForMetadata(SvProjectedMeasurementSample sample, int samplesPerCycle) + { + if (_samples.Length != samplesPerCycle) + _samples = new double[samplesPerCycle]; + _count = 0; + _counter.Reset(); + SetMetadata(sample); + } + + public void ResetForConfiguration(int samplesPerCycle) + { + if (_samples.Length != samplesPerCycle) + _samples = new double[samplesPerCycle]; + _count = 0; + _counter.Reset(); + } + + public SvEngineeringWindowResult? Observe( + SvObservedStreamKey streamKey, + SvProjectedMeasurementSample sample, + WindowConfiguration configuration) + { + var transition = _counter.Observe(sample.SampleCount, configuration.SampleCounterWrap); + switch (transition.Kind) + { + case SvSampleCounterTransitionKind.Duplicate: + ResetPartialWindow(); + return null; + case SvSampleCounterTransitionKind.Gap: + case SvSampleCounterTransitionKind.OutOfOrder: + case SvSampleCounterTransitionKind.Restart: + ResetPartialWindow(); + break; + } + + var value = sample.EngineeringValue!.Value; + if (_count == 0) + _firstSampleCount = sample.SampleCount; + _lastSampleCount = sample.SampleCount; + _samples[_count++] = value; + + if (_count < configuration.SamplesPerCycle) + return null; + + var estimate = SvSignalWindowAnalyzer.AnalyzeFundamental(_samples); + var result = new SvEngineeringWindowResult + { + StreamKey = streamKey, + ElementIndex = sample.ElementIndex, + SignalReference = SignalReference, + Cdc = Cdc, + EngineeringUnit = EngineeringUnit, + ScaleSource = ScaleSource, + ScaleConfidence = ScaleConfidence, + FirstSampleCount = _firstSampleCount, + LastSampleCount = _lastSampleCount, + SamplesPerCycle = configuration.SamplesPerCycle, + SampleRateHz = configuration.SampleRateHz, + SampleRateSource = configuration.SampleRateSource, + FundamentalFrequencyHz = configuration.FundamentalFrequencyHz, + FundamentalFrequencySource = configuration.FundamentalFrequencySource, + Estimate = estimate + }; + ResetPartialWindow(); + return result; + } + + private void SetMetadata(SvProjectedMeasurementSample sample) + { + SignalReference = sample.SignalReference; + Cdc = sample.Cdc; + EngineeringUnit = sample.Scale.Unit; + ScaleSource = sample.Scale.Source; + ScaleConfidence = sample.Scale.Confidence; + } + + private void ResetPartialWindow() => _count = 0; + } + + private sealed class StreamState + { + public Dictionary Channels { get; } = new(); + public WindowConfiguration? Configuration { get; set; } + public long LastUseSequence { get; set; } + + public void ResetForConfiguration(WindowConfiguration configuration) + { + Configuration = configuration; + foreach (var channel in Channels.Values) + channel.ResetForConfiguration(configuration.SamplesPerCycle); + } + } + + private readonly object _gate = new(); + private readonly Dictionary _streams = new(); + private readonly SvEngineeringWindowAnalysisOptions _options; + private long _useSequence; + + public SvEngineeringWindowAnalyzer(SvEngineeringWindowAnalysisOptions? options = null) + { + _options = options ?? new SvEngineeringWindowAnalysisOptions(); + _options.Validate(); + } + + public int ActiveStreamCount + { + get { lock (_gate) return _streams.Count; } + } + + public int ActiveChannelCount + { + get { lock (_gate) return _streams.Values.Sum(stream => stream.Channels.Count); } + } + + public bool TryObserve( + SvSclBoundMeasurementProjection projection, + SvEngineeringWindowEvidence evidence, + out IReadOnlyList completed, + out string reason) + { + ArgumentNullException.ThrowIfNull(projection); + ArgumentNullException.ThrowIfNull(evidence); + completed = Array.Empty(); + + if (!projection.IsBoundToScl) + { + reason = "Engineering-window analysis requires a projection that is explicitly bound to SCL."; + return false; + } + if (!evidence.TryResolveSamplesPerCycle(_options, out var samplesPerCycle, out reason)) + return false; + + var invalidEngineeringSample = projection.Samples.FirstOrDefault(sample => + sample.EngineeringValue.HasValue && + (!double.IsFinite(sample.EngineeringValue.Value) || !sample.Scale.HasEngineeringUnit)); + if (invalidEngineeringSample is not null) + { + reason = "Projection contains an invalid engineering sample or a value without evidence-backed engineering units."; + return false; + } + + var engineeringSamples = projection.Samples + .Where(sample => sample.EngineeringValue.HasValue && + sample.Scale.HasEngineeringUnit && + !string.IsNullOrWhiteSpace(sample.SignalReference) && + !string.IsNullOrWhiteSpace(sample.Scale.Unit)) + .ToArray(); + if (engineeringSamples.Length == 0) + { + reason = "Projection contains no evidence-backed engineering samples; raw-only values were intentionally ignored."; + return true; + } + + foreach (var group in engineeringSamples.GroupBy(sample => sample.ElementIndex)) + { + var first = group.First(); + if (group.Any(sample => + !string.Equals(sample.SignalReference, first.SignalReference, StringComparison.Ordinal) || + !string.Equals(sample.Cdc, first.Cdc, StringComparison.Ordinal) || + !string.Equals(sample.Scale.Unit, first.Scale.Unit, StringComparison.Ordinal) || + sample.Scale.Source != first.Scale.Source || + sample.Scale.Confidence != first.Scale.Confidence)) + { + reason = $"Element {group.Key} changed engineering identity inside one projection; analysis failed closed."; + return false; + } + } + + var configuration = new WindowConfiguration( + samplesPerCycle, + evidence.SampleRateHz, + evidence.SampleRateSource, + evidence.FundamentalFrequencyHz, + evidence.FundamentalFrequencySource, + evidence.SampleCounterWrap, + evidence.SampleCounterWrapSource); + + lock (_gate) + { + if (!_streams.TryGetValue(projection.StreamKey, out var stream)) + { + EnsureStreamCapacity(); + stream = new StreamState(); + _streams.Add(projection.StreamKey, stream); + } + + var incomingElements = engineeringSamples.Select(sample => sample.ElementIndex).Distinct().ToArray(); + var newChannelCount = incomingElements.Count(element => !stream.Channels.ContainsKey(element)); + if (stream.Channels.Count + newChannelCount > _options.MaximumChannelsPerStream) + { + reason = $"Engineering channel bound {_options.MaximumChannelsPerStream} would be exceeded for stream {projection.StreamKey.Id}."; + return false; + } + + stream.LastUseSequence = ++_useSequence; + if (stream.Configuration != configuration) + stream.ResetForConfiguration(configuration); + + var results = new List(); + foreach (var sample in engineeringSamples) + { + if (!stream.Channels.TryGetValue(sample.ElementIndex, out var channel)) + { + channel = new ChannelState(sample, samplesPerCycle); + stream.Channels.Add(sample.ElementIndex, channel); + } + else if (!channel.MetadataMatches(sample)) + { + channel.ResetForMetadata(sample, samplesPerCycle); + } + + var result = channel.Observe(projection.StreamKey, sample, configuration); + if (result is not null) + results.Add(result); + } + + completed = results; + reason = string.Empty; + return true; + } + } + + public void Clear() + { + lock (_gate) + { + _streams.Clear(); + _useSequence = 0; + } + } + + private void EnsureStreamCapacity() + { + while (_streams.Count >= _options.MaximumStreams) + { + var oldest = _streams + .OrderBy(pair => pair.Value.LastUseSequence) + .ThenBy(pair => pair.Key.Id, StringComparer.Ordinal) + .First(); + _streams.Remove(oldest.Key); + } + } +} diff --git a/src/AR.Iec61850/SampledValues/Analysis/SvSustainedCaptureProcessor.cs b/src/AR.Iec61850/SampledValues/Analysis/SvSustainedCaptureProcessor.cs new file mode 100644 index 00000000..d8369c9e --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Analysis/SvSustainedCaptureProcessor.cs @@ -0,0 +1,167 @@ +using System.Runtime.CompilerServices; +using AR.Iec61850.SampledValues.Measurements; +using AR.Iec61850.SampledValues.Profiles; +using AR.Iec61850.Transports; + +namespace AR.Iec61850.SampledValues.Analysis; + +public enum SvCapturedFrameProcessingStatus +{ + Accepted = 0, + ParserRejected = 1, + ObservationRejected = 2 +} + +/// +/// Explicit per-stream context resolved by the application after a captured Ethernet frame has +/// been parsed as Sampled Values. The stack does not guess an SCL publisher, CT/VT context, +/// nominal frequency, or engineering timebase from capture bytes. +/// +public sealed record SvSustainedFrameContext +{ + public SampledValuesPublisherProfile? Profile { get; init; } + public double? NominalFrequencyHz { get; init; } + public SvComparisonMode ComparisonMode { get; init; } = SvComparisonMode.Compatible; + public SvStreamMeasurementContext? MeasurementContext { get; init; } + public SvEngineeringWindowEvidence? EngineeringWindowEvidence { get; init; } +} + +/// +/// Compact result for one captured Ethernet frame. Raw frame bytes are deliberately not retained. +/// ParserRejected means only that the project-owned SV Ethernet parser did not accept the bytes; +/// it does not by itself classify the packet as malformed traffic. +/// +public sealed record SvCapturedFrameProcessingResult +{ + public DateTimeOffset Timestamp { get; init; } + public int CapturedLength { get; init; } + public SvCapturedFrameProcessingStatus Status { get; init; } + public SvSustainedObservationResult? Observation { get; init; } +} + +/// +/// Source-neutral bridge from raw captured Ethernet frames into the established Sampled Values +/// parser and . The processor is sequential and owns no +/// queue; buffering and overload policy remain the responsibility of the selected +/// implementation. +/// +public sealed class SvSustainedCaptureProcessor +{ + private readonly SvSustainedObservationSession _session; + + public SvSustainedCaptureProcessor(SvSustainedObservationSession? session = null) + { + _session = session ?? new SvSustainedObservationSession(); + } + + public SvSustainedObservationSession Session => _session; + + public SvCapturedFrameProcessingResult ProcessCapturedFrame( + ProcessBusCapturedFrame captured, + SvObservationInputKind inputKind, + SvSustainedFrameContext? context = null) + { + ArgumentNullException.ThrowIfNull(captured); + + if (!SampledValuesFrameParser.TryParseEthernetFrame(captured.Frame, out var frame)) + { + return new SvCapturedFrameProcessingResult + { + Timestamp = captured.Timestamp, + CapturedLength = captured.Frame.Length, + Status = SvCapturedFrameProcessingStatus.ParserRejected + }; + } + + context ??= new SvSustainedFrameContext(); + if (!_session.TryObserve( + captured.Timestamp, + frame, + inputKind, + out var observation, + profile: context.Profile, + nominalFrequencyHz: context.NominalFrequencyHz, + comparisonMode: context.ComparisonMode, + measurementContext: context.MeasurementContext, + engineeringWindowEvidence: context.EngineeringWindowEvidence)) + { + return new SvCapturedFrameProcessingResult + { + Timestamp = captured.Timestamp, + CapturedLength = captured.Frame.Length, + Status = SvCapturedFrameProcessingStatus.ObservationRejected + }; + } + + return new SvCapturedFrameProcessingResult + { + Timestamp = captured.Timestamp, + CapturedLength = captured.Frame.Length, + Status = SvCapturedFrameProcessingStatus.Accepted, + Observation = observation + }; + } + + public async IAsyncEnumerable ProcessAsync( + IProcessBusFrameSource source, + ProcessBusCaptureOptions captureOptions, + SvObservationInputKind inputKind, + Func? contextResolver = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(captureOptions); + + await foreach (var captured in source.CaptureAsync(captureOptions, cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (contextResolver is null) + { + yield return ProcessCapturedFrame(captured, inputKind); + continue; + } + + if (!SampledValuesFrameParser.TryParseEthernetFrame(captured.Frame, out var parsed)) + { + yield return new SvCapturedFrameProcessingResult + { + Timestamp = captured.Timestamp, + CapturedLength = captured.Frame.Length, + Status = SvCapturedFrameProcessingStatus.ParserRejected + }; + continue; + } + + var context = contextResolver(parsed); + context ??= new SvSustainedFrameContext(); + if (!_session.TryObserve( + captured.Timestamp, + parsed, + inputKind, + out var observation, + profile: context.Profile, + nominalFrequencyHz: context.NominalFrequencyHz, + comparisonMode: context.ComparisonMode, + measurementContext: context.MeasurementContext, + engineeringWindowEvidence: context.EngineeringWindowEvidence)) + { + yield return new SvCapturedFrameProcessingResult + { + Timestamp = captured.Timestamp, + CapturedLength = captured.Frame.Length, + Status = SvCapturedFrameProcessingStatus.ObservationRejected + }; + continue; + } + + yield return new SvCapturedFrameProcessingResult + { + Timestamp = captured.Timestamp, + CapturedLength = captured.Frame.Length, + Status = SvCapturedFrameProcessingStatus.Accepted, + Observation = observation + }; + } + } +} diff --git a/src/AR.Iec61850/SampledValues/Analysis/SvSustainedObservationSession.cs b/src/AR.Iec61850/SampledValues/Analysis/SvSustainedObservationSession.cs new file mode 100644 index 00000000..95d4685d --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Analysis/SvSustainedObservationSession.cs @@ -0,0 +1,136 @@ +using AR.Iec61850.SampledValues.Measurements; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.SampledValues.Analysis; + +/// +/// Source-neutral ingress for sustained Sampled Values observation. The session accepts an +/// already-parsed , first routes it through the established +/// observation/SCL-comparison path, and only then advances sustained analysis. +/// +public sealed record SvSustainedObservationResult +{ + public SvStreamObservationSnapshot Observation { get; init; } = new(); + public SvSustainedStreamSnapshot Sustained { get; init; } = new(); + public SvSclBoundMeasurementProjection? MeasurementProjection { get; init; } + public IReadOnlyList EngineeringWindows { get; init; } = Array.Empty(); + public string? EngineeringWindowDiagnostic { get; init; } +} + +/// +/// Keeps the short-window/profile observation path and sustained analysis on one parsed-frame +/// intake. It never parses capture bytes or invents payload semantics. When an explicit SCL +/// publisher profile is supplied, the established SCL payload decoder may project numeric +/// channels through the evidence-backed measurement path; otherwise payload bytes remain opaque. +/// Live capture and PCAP replay callers therefore share the same admission and stream-identity +/// path after successful IEC 61850-9-2 frame parsing. +/// +public sealed class SvSustainedObservationSession +{ + private readonly SvStreamObservationManager _observations; + private readonly SvSustainedStreamAnalyzer _sustained; + private readonly SvEngineeringWindowAnalyzer _engineeringWindows; + + public SvSustainedObservationSession( + SvStreamObservationManager? observations = null, + SvSustainedStreamAnalyzer? sustained = null, + SvEngineeringWindowAnalyzer? engineeringWindows = null) + { + _observations = observations ?? new SvStreamObservationManager(); + _sustained = sustained ?? new SvSustainedStreamAnalyzer(); + _engineeringWindows = engineeringWindows ?? new SvEngineeringWindowAnalyzer(); + } + + public int ObservationStreamCount => _observations.Count; + public int SustainedStreamCount => _sustained.Count; + public int EngineeringWindowStreamCount => _engineeringWindows.ActiveStreamCount; + public int EngineeringWindowChannelCount => _engineeringWindows.ActiveChannelCount; + + public bool TryObserve( + DateTimeOffset timestamp, + SampledValuesFrame frame, + SvObservationInputKind inputKind, + out SvSustainedObservationResult result, + SampledValuesPublisherProfile? profile = null, + double? nominalFrequencyHz = null, + SvComparisonMode comparisonMode = SvComparisonMode.Compatible, + SvStreamMeasurementContext? measurementContext = null, + SvEngineeringWindowEvidence? engineeringWindowEvidence = null) + { + ArgumentNullException.ThrowIfNull(frame); + result = new(); + + // Keep the established observation/profile path authoritative for admission. + // If it rejects the parsed frame, sustained state must not advance. + if (!_observations.TryObserve( + timestamp, + frame, + inputKind, + profile, + out var observation, + nominalFrequencyHz, + comparisonMode)) + { + return false; + } + + // The observation manager only accepts a frame with at least one ASDU carrying a + // non-empty sample payload, so a failure here would indicate an internal contract drift. + if (!_sustained.TryObserve(timestamp, frame, out var sustained)) + throw new InvalidOperationException("Sustained SV analysis rejected a frame already accepted by the canonical observation path."); + + var measurementProjection = profile is null + ? null + : SvSclBoundMeasurementProjector.Project( + frame, + profile, + measurementContext, + sustained.Timing.EstimatedSampleRateHz); + + IReadOnlyList engineeringWindows = Array.Empty(); + string? engineeringWindowDiagnostic = null; + if (engineeringWindowEvidence is not null) + { + if (measurementProjection is null) + { + engineeringWindowDiagnostic = "Engineering-window evidence was supplied without an explicit SCL publisher profile; no channel waveform analysis was attempted."; + } + else if (!_engineeringWindows.TryObserve( + measurementProjection, + engineeringWindowEvidence, + out engineeringWindows, + out var reason)) + { + engineeringWindows = Array.Empty(); + engineeringWindowDiagnostic = reason; + } + else if (!string.IsNullOrWhiteSpace(reason)) + { + engineeringWindowDiagnostic = reason; + } + } + + result = new SvSustainedObservationResult + { + Observation = observation, + Sustained = sustained, + MeasurementProjection = measurementProjection, + EngineeringWindows = engineeringWindows, + EngineeringWindowDiagnostic = engineeringWindowDiagnostic + }; + return true; + } + + public IReadOnlyList SnapshotObservations() + => _observations.SnapshotAll(); + + public IReadOnlyList SnapshotSustained() + => _sustained.SnapshotAll(); + + public void Clear() + { + _observations.Clear(); + _sustained.Clear(); + _engineeringWindows.Clear(); + } +} diff --git a/src/AR.Iec61850/SampledValues/Analysis/SvSustainedStreamAnalyzer.cs b/src/AR.Iec61850/SampledValues/Analysis/SvSustainedStreamAnalyzer.cs new file mode 100644 index 00000000..515c0846 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Analysis/SvSustainedStreamAnalyzer.cs @@ -0,0 +1,367 @@ +using System.Numerics; +using AR.Iec61850.SampledValues.Measurements; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.SampledValues.Analysis; + +public enum SvCaptureTimestampEvidence +{ + Unknown, + HostSoftwareTimestamp, + HardwareTimestamp +} + +public sealed record SvSustainedAnalysisOptions +{ + public int MaximumStreams { get; init; } = 256; + public TimeSpan StreamIdleTimeout { get; init; } = TimeSpan.FromSeconds(30); + public ushort? SampleCounterWrap { get; init; } + public SvCaptureTimestampEvidence TimestampEvidence { get; init; } = SvCaptureTimestampEvidence.HostSoftwareTimestamp; + public double DropoutIntervalMultiplier { get; init; } = 2.5; + + public void Validate() + { + if (MaximumStreams < 1 || MaximumStreams > 4096) + throw new ArgumentOutOfRangeException(nameof(MaximumStreams)); + if (StreamIdleTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(StreamIdleTimeout)); + if (DropoutIntervalMultiplier <= 1.0 || !double.IsFinite(DropoutIntervalMultiplier)) + throw new ArgumentOutOfRangeException(nameof(DropoutIntervalMultiplier)); + } +} + +public sealed record SvContinuityEvidence +{ + public long ContinuousTransitions { get; init; } + public long NormalWraps { get; init; } + public long GapTransitions { get; init; } + public long MissingSamples { get; init; } + public long DuplicateTransitions { get; init; } + public long OutOfOrderTransitions { get; init; } +} + +public sealed record SvTimingEvidence +{ + public SvCaptureTimestampEvidence TimestampEvidence { get; init; } + public long IntervalCount { get; init; } + public double? MeanFrameIntervalMicroseconds { get; init; } + public double? FrameRateHz { get; init; } + public double? EstimatedSampleRateHz { get; init; } + public double? IntervalJitterStdDevMicroseconds { get; init; } + public double? MaximumAbsoluteJitterMicroseconds { get; init; } + public long DropoutIntervals { get; init; } + public bool IsStrongTimingEvidence => TimestampEvidence == SvCaptureTimestampEvidence.HardwareTimestamp; + public string ClaimBoundary => IsStrongTimingEvidence + ? "Timing statistics use caller-declared hardware timestamp evidence; clock accuracy and synchronization still require separate validation." + : "Timing statistics are ordinary capture-arrival evidence only and must not be presented as process-bus time-accuracy or synchronization proof."; +} + +public sealed record SvSynchronizationEvidence +{ + public long SampleSynchronization0 { get; init; } + public long SampleSynchronization1 { get; init; } + public long SampleSynchronization2 { get; init; } + public long OtherSampleSynchronization { get; init; } +} + +public sealed record SvSustainedStreamSnapshot +{ + public SvObservedStreamKey Key { get; init; } = new(); + public long FrameCount { get; init; } + public long AsduCount { get; init; } + public DateTimeOffset FirstSeen { get; init; } + public DateTimeOffset LastSeen { get; init; } + public int StablePayloadBytesPerAsdu { get; init; } + public bool PayloadLayoutChanged { get; init; } + public SvContinuityEvidence Continuity { get; init; } = new(); + public SvTimingEvidence Timing { get; init; } = new(); + public SvSynchronizationEvidence Synchronization { get; init; } = new(); + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +/// +/// Bounded, sustained Sampled Values stream registry. It consumes already-decoded SV frames, +/// keeps incremental evidence only, and never retains packet payload history. This class is +/// deliberately separate from the wire parser and from application presentation state. +/// +public sealed class SvSustainedStreamAnalyzer +{ + private sealed class StreamState + { + private readonly SvSampleCounterTracker _counter = new(); + private long _intervalCount; + private double _intervalMeanTicks; + private double _intervalM2Ticks; + private double _maxAbsoluteJitterTicks; + private TimeSpan? _lastInterval; + private int _firstPayloadLength; + private readonly Queue _diagnostics = new(); + + public StreamState(SvObservedStreamKey key, DateTimeOffset timestamp) + { + Key = key; + FirstSeen = timestamp; + LastSeen = timestamp; + } + + public SvObservedStreamKey Key { get; } + public long FrameCount { get; private set; } + public long AsduCount { get; private set; } + public DateTimeOffset FirstSeen { get; } + public DateTimeOffset LastSeen { get; private set; } + public bool PayloadLayoutChanged { get; private set; } + public long Continuous { get; private set; } + public long Wraps { get; private set; } + public long Gaps { get; private set; } + public long Missing { get; private set; } + public long Duplicates { get; private set; } + public long OutOfOrder { get; private set; } + public long Dropouts { get; private set; } + public long Sync0 { get; private set; } + public long Sync1 { get; private set; } + public long Sync2 { get; private set; } + public long SyncOther { get; private set; } + public double AsduPerFrameMean { get; private set; } + + public void Observe(DateTimeOffset timestamp, SampledValuesFrame frame, SvSustainedAnalysisOptions options) + { + if (FrameCount > 0) + { + var interval = timestamp - LastSeen; + if (interval > TimeSpan.Zero) + ObserveInterval(interval, options.DropoutIntervalMultiplier); + else + AddDiagnostic("A non-increasing capture timestamp was observed; that interval was excluded from rate/jitter statistics."); + } + + LastSeen = timestamp > LastSeen ? timestamp : LastSeen; + FrameCount++; + var asdus = frame.Pdu.Asdus; + AsduCount += asdus.Count; + AsduPerFrameMean += (asdus.Count - AsduPerFrameMean) / FrameCount; + + foreach (var asdu in asdus) + { + if (_firstPayloadLength == 0) + _firstPayloadLength = asdu.SamplePayload.Length; + else if (asdu.SamplePayload.Length != _firstPayloadLength) + PayloadLayoutChanged = true; + + switch (asdu.SampleSynchronization) + { + case 0: Sync0++; break; + case 1: Sync1++; break; + case 2: Sync2++; break; + default: SyncOther++; break; + } + + var transition = _counter.Observe(asdu.SampleCount, options.SampleCounterWrap); + switch (transition.Kind) + { + case SvSampleCounterTransitionKind.Continuous: Continuous++; break; + case SvSampleCounterTransitionKind.NormalWrap: Wraps++; break; + case SvSampleCounterTransitionKind.Gap: + Gaps++; + Missing += transition.MissingSamples; + break; + case SvSampleCounterTransitionKind.Duplicate: Duplicates++; break; + case SvSampleCounterTransitionKind.OutOfOrder: OutOfOrder++; break; + } + } + } + + private void ObserveInterval(TimeSpan interval, double dropoutMultiplier) + { + var ticks = (double)interval.Ticks; + _intervalCount++; + var delta = ticks - _intervalMeanTicks; + _intervalMeanTicks += delta / _intervalCount; + _intervalM2Ticks += delta * (ticks - _intervalMeanTicks); + + if (_intervalCount > 1) + { + var jitter = Math.Abs(ticks - _intervalMeanTicks); + _maxAbsoluteJitterTicks = Math.Max(_maxAbsoluteJitterTicks, jitter); + } + + if (_lastInterval.HasValue && ticks > _intervalMeanTicks * dropoutMultiplier) + Dropouts++; + _lastInterval = interval; + } + + private void AddDiagnostic(string diagnostic) + { + if (_diagnostics.Contains(diagnostic, StringComparer.Ordinal)) + return; + _diagnostics.Enqueue(diagnostic); + while (_diagnostics.Count > 16) + _diagnostics.Dequeue(); + } + + public SvSustainedStreamSnapshot Snapshot(SvSustainedAnalysisOptions options) + { + double? meanUs = _intervalCount > 0 ? TimeSpan.FromTicks((long)_intervalMeanTicks).TotalMicroseconds : null; + double? frameRate = _intervalMeanTicks > 0 ? TimeSpan.TicksPerSecond / _intervalMeanTicks : null; + double? sampleRate = frameRate.HasValue ? frameRate.Value * AsduPerFrameMean : null; + double? stdUs = _intervalCount > 1 + ? Math.Sqrt(_intervalM2Ticks / (_intervalCount - 1)) / TimeSpan.TicksPerMicrosecond + : null; + + var diagnostics = _diagnostics.ToList(); + if (PayloadLayoutChanged) + diagnostics.Add("SV sample payload length changed inside one stream identity."); + if (Gaps > 0) + diagnostics.Add($"Observed {Gaps} smpCnt gap transition(s) representing {Missing} missing sample(s) under the configured counter modulus."); + if (Dropouts > 0) + diagnostics.Add($"Observed {Dropouts} capture interval(s) above the sustained dropout threshold; this is arrival-time evidence, not proof of network loss."); + + return new SvSustainedStreamSnapshot + { + Key = Key, + FrameCount = FrameCount, + AsduCount = AsduCount, + FirstSeen = FirstSeen, + LastSeen = LastSeen, + StablePayloadBytesPerAsdu = PayloadLayoutChanged ? 0 : _firstPayloadLength, + PayloadLayoutChanged = PayloadLayoutChanged, + Continuity = new SvContinuityEvidence + { + ContinuousTransitions = Continuous, + NormalWraps = Wraps, + GapTransitions = Gaps, + MissingSamples = Missing, + DuplicateTransitions = Duplicates, + OutOfOrderTransitions = OutOfOrder + }, + Timing = new SvTimingEvidence + { + TimestampEvidence = options.TimestampEvidence, + IntervalCount = _intervalCount, + MeanFrameIntervalMicroseconds = meanUs, + FrameRateHz = frameRate, + EstimatedSampleRateHz = sampleRate, + IntervalJitterStdDevMicroseconds = stdUs, + MaximumAbsoluteJitterMicroseconds = _maxAbsoluteJitterTicks / TimeSpan.TicksPerMicrosecond, + DropoutIntervals = Dropouts + }, + Synchronization = new SvSynchronizationEvidence + { + SampleSynchronization0 = Sync0, + SampleSynchronization1 = Sync1, + SampleSynchronization2 = Sync2, + OtherSampleSynchronization = SyncOther + }, + Diagnostics = diagnostics + }; + } + } + + private readonly object _gate = new(); + private readonly Dictionary _streams = new(); + private readonly SvSustainedAnalysisOptions _options; + + public SvSustainedStreamAnalyzer(SvSustainedAnalysisOptions? options = null) + { + _options = options ?? new SvSustainedAnalysisOptions(); + _options.Validate(); + } + + public int Count { get { lock (_gate) return _streams.Count; } } + + public bool TryObserve(DateTimeOffset timestamp, SampledValuesFrame frame, out SvSustainedStreamSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(frame); + snapshot = new(); + if (frame.Pdu.Asdus.Count == 0) + return false; + + var key = SvObservedStreamKey.FromFrame(frame); + lock (_gate) + { + EvictIdle(timestamp); + if (!_streams.TryGetValue(key, out var state)) + { + EnsureCapacity(); + state = new StreamState(key, timestamp); + _streams.Add(key, state); + } + state.Observe(timestamp, frame, _options); + snapshot = state.Snapshot(_options); + return true; + } + } + + public IReadOnlyList SnapshotAll() + { + lock (_gate) + return _streams.Values.Select(state => state.Snapshot(_options)) + .OrderBy(snapshot => snapshot.Key.AppId) + .ThenBy(snapshot => snapshot.Key.SvId, StringComparer.Ordinal) + .ToArray(); + } + + public void Clear() + { + lock (_gate) + _streams.Clear(); + } + + private void EvictIdle(DateTimeOffset now) + { + foreach (var key in _streams.Where(pair => now - pair.Value.LastSeen > _options.StreamIdleTimeout).Select(pair => pair.Key).ToArray()) + _streams.Remove(key); + } + + private void EnsureCapacity() + { + while (_streams.Count >= _options.MaximumStreams) + { + var oldest = _streams.OrderBy(pair => pair.Value.LastSeen).ThenBy(pair => pair.Key.Id, StringComparer.Ordinal).First(); + _streams.Remove(oldest.Key); + } + } +} + +public sealed record SvPhasorEstimate +{ + public double Rms { get; init; } + public double MagnitudeRms { get; init; } + public double AngleDegrees { get; init; } + public int SampleCount { get; init; } +} + +/// +/// Deterministic bounded-window measurement primitive. The caller must supply samples that +/// already have an explicit channel mapping/scaling context; this class never guesses payload semantics. +/// +public static class SvSignalWindowAnalyzer +{ + public static SvPhasorEstimate AnalyzeFundamental(IReadOnlyList samples) + { + ArgumentNullException.ThrowIfNull(samples); + if (samples.Count < 4) + throw new ArgumentException("At least four samples are required for RMS/phasor analysis.", nameof(samples)); + if (samples.Any(value => !double.IsFinite(value))) + throw new ArgumentException("Samples must be finite.", nameof(samples)); + + var sumSquares = 0.0; + var fundamental = Complex.Zero; + for (var index = 0; index < samples.Count; index++) + { + var value = samples[index]; + sumSquares += value * value; + var angle = -2.0 * Math.PI * index / samples.Count; + fundamental += value * Complex.FromPolarCoordinates(1.0, angle); + } + + var rms = Math.Sqrt(sumSquares / samples.Count); + var rmsPhasor = fundamental * (Math.Sqrt(2.0) / samples.Count); + return new SvPhasorEstimate + { + Rms = rms, + MagnitudeRms = rmsPhasor.Magnitude, + AngleDegrees = Math.Atan2(rmsPhasor.Imaginary, rmsPhasor.Real) * 180.0 / Math.PI, + SampleCount = samples.Count + }; + } +} diff --git a/src/AR.Iec61850/SampledValues/Measurements/SvSclBoundMeasurementProjector.cs b/src/AR.Iec61850/SampledValues/Measurements/SvSclBoundMeasurementProjector.cs new file mode 100644 index 00000000..31664ff3 --- /dev/null +++ b/src/AR.Iec61850/SampledValues/Measurements/SvSclBoundMeasurementProjector.cs @@ -0,0 +1,319 @@ +using AR.Iec61850.Mms; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.SampledValues.Measurements; + +/// +/// One numeric channel sample projected from an SV ASDU through an explicitly bound SCL payload layout. +/// RawValue is always the decoded numeric wire value. EngineeringValue is populated only when an +/// existing evidence-backed engineering-scale rule resolves to a real engineering unit. +/// +public sealed record SvProjectedMeasurementSample +{ + public int AsduIndex { get; init; } + public ushort SampleCount { get; init; } + public int ElementIndex { get; init; } + public string SignalReference { get; init; } = string.Empty; + public string Cdc { get; init; } = string.Empty; + public double RawValue { get; init; } + public SvEngineeringScale Scale { get; init; } = SvEngineeringScale.RawOnly("No engineering scale was resolved."); + public double? EngineeringValue { get; init; } + public SvMeasurementDomainValue? DomainValue { get; init; } + public double? DisplayValue { get; init; } +} + +public sealed record SvSclBoundMeasurementProjection +{ + public SvObservedStreamKey StreamKey { get; init; } = new(); + public string ControlBlockReference { get; init; } = string.Empty; + public bool IsBoundToScl { get; init; } + public bool HasEngineeringValues { get; init; } + public IReadOnlyList Samples { get; init; } = Array.Empty(); + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +/// +/// Conservative bridge from the existing SCL-backed SV payload decoder to numeric channel samples. +/// The projector never infers dataset order, channel identity, scale, CT/VT ratio, or engineering unit +/// from raw payload position. Any identity/configuration mismatch fails closed before payload decoding. +/// +public static class SvSclBoundMeasurementProjector +{ + public static SvSclBoundMeasurementProjection Project( + SampledValuesFrame frame, + SampledValuesPublisherProfile profile, + SvStreamMeasurementContext? measurementContext = null, + double? observedSamplesPerSecond = null) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(profile); + + var diagnostics = new List(); + var streamKey = frame.Pdu.Asdus.Count > 0 + ? SvObservedStreamKey.FromFrame(frame) + : new SvObservedStreamKey(); + + if (!TryValidateBinding(frame, profile, diagnostics)) + { + return new SvSclBoundMeasurementProjection + { + StreamKey = streamKey, + ControlBlockReference = profile.Stream.ControlBlockReference, + Diagnostics = diagnostics + }; + } + + if (!profile.PayloadLayout.IsFullySupported) + { + diagnostics.Add( + "The bound SCL payload layout contains unsupported elements. Engineering projection was not attempted because later byte offsets cannot be proven safely."); + diagnostics.AddRange(profile.PayloadLayout.UnsupportedElements.Select(item => item.Diagnostic)); + return BoundEmpty(streamKey, profile, diagnostics); + } + + if (frame.Pdu.Asdus.Any(asdu => asdu.SamplePayload.Length != profile.PayloadLayout.PayloadByteLength)) + { + diagnostics.Add( + $"The observed SV payload length does not exactly match the bound SCL layout ({profile.PayloadLayout.PayloadByteLength} byte(s)); projection failed closed."); + return BoundEmpty(streamKey, profile, diagnostics); + } + + var context = ValidateMeasurementContext(streamKey, frame, measurementContext, diagnostics); + var fixedLegacyLayout = IsFixedFourCurrentFourVoltageValueQualityLayout(profile.PayloadLayout); + var analogChannelCount = profile.PayloadLayout.Elements.Count(IsNumericAnalogElement); + var declaredSampleRate = StableNullable(frame.Pdu.Asdus.Select(item => item.SampleRate)); + var declaredSampleMode = StableNullable(frame.Pdu.Asdus.Select(item => item.SampleMode)); + var samples = new List(); + + for (var asduIndex = 0; asduIndex < frame.Pdu.Asdus.Count; asduIndex++) + { + var asdu = frame.Pdu.Asdus[asduIndex]; + var decoded = SampledValuesPayloadDecoder.Decode(profile.PayloadLayout, asdu.SamplePayload); + if (!decoded.IsComplete) + { + diagnostics.AddRange(decoded.Diagnostics.Select(item => $"ASDU {asduIndex}: {item}")); + continue; + } + + foreach (var decodedValue in decoded.Values) + { + if (!IsNumericAnalogElement(decodedValue.Element) || + !TryGetFiniteNumber(decodedValue.Value, out var rawValue)) + { + continue; + } + + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = decodedValue.Element.SignalReference, + Kind = decodedValue.Element.Cdc, + IsSclBound = true, + IsFixedFourCurrentFourVoltageLayout = fixedLegacyLayout, + AnalogChannelCount = analogChannelCount, + PayloadBytesPerAsdu = profile.PayloadLayout.PayloadByteLength, + DeclaredSampleRate = declaredSampleRate, + DeclaredSampleMode = declaredSampleMode, + ObservedSamplesPerSecond = observedSamplesPerSecond + }); + + double? engineeringValue = null; + SvMeasurementDomainValue? domainValue = null; + double? displayValue = null; + + if (scale.HasEngineeringUnit) + { + engineeringValue = scale.Apply(rawValue); + if (context is not null) + { + var ratio = context.ResolveRatio(decodedValue.Element.SignalReference); + domainValue = SvMeasurementDomainResolver.Resolve( + engineeringValue.Value, + scale.Unit, + context.WireDomain, + ratio); + displayValue = context.DisplayDomain switch + { + SvMeasurementValueDomain.PrimaryEngineering => domainValue.PrimaryValue, + SvMeasurementValueDomain.SecondaryEquivalent => domainValue.SecondaryEquivalentValue, + _ => null + }; + } + } + + samples.Add(new SvProjectedMeasurementSample + { + AsduIndex = asduIndex, + SampleCount = asdu.SampleCount, + ElementIndex = decodedValue.Element.Index, + SignalReference = decodedValue.Element.SignalReference, + Cdc = decodedValue.Element.Cdc, + RawValue = rawValue, + Scale = scale, + EngineeringValue = engineeringValue, + DomainValue = domainValue, + DisplayValue = displayValue + }); + } + } + + if (samples.Count == 0) + diagnostics.Add("No numeric SCL-mapped measurement elements were projected from the accepted SV frame."); + if (samples.Count > 0 && samples.All(item => !item.EngineeringValue.HasValue)) + diagnostics.Add("Numeric samples were decoded, but no evidence-backed engineering scale was established; values remain raw only."); + + return new SvSclBoundMeasurementProjection + { + StreamKey = streamKey, + ControlBlockReference = profile.Stream.ControlBlockReference, + IsBoundToScl = true, + HasEngineeringValues = samples.Any(item => item.EngineeringValue.HasValue), + Samples = samples, + Diagnostics = diagnostics.Distinct(StringComparer.Ordinal).ToArray() + }; + } + + private static bool TryValidateBinding( + SampledValuesFrame frame, + SampledValuesPublisherProfile profile, + ICollection diagnostics) + { + if (frame.Pdu.Asdus.Count == 0) + { + diagnostics.Add("The observed SV frame contains no ASDUs."); + return false; + } + + if (frame.AppId != profile.AppId || + !string.Equals(frame.Destination.ToString(), profile.Destination.ToString(), StringComparison.OrdinalIgnoreCase) || + frame.Vlan?.VlanId != profile.Vlan?.VlanId) + { + diagnostics.Add( + "The SCL profile does not bind this Ethernet SV identity: APPID, destination MAC, and VLAN must match exactly before payload projection."); + return false; + } + + if (frame.Pdu.Asdus.Any(asdu => + !string.Equals(asdu.SvId, profile.Stream.SvId, StringComparison.Ordinal) || + !string.Equals(asdu.DataSetReference, profile.Stream.DataSetReference, StringComparison.Ordinal))) + { + diagnostics.Add( + "The observed svID/DataSet reference does not match the bound SCL SampledValueControl stream; payload projection failed closed."); + return false; + } + + if (frame.Pdu.Asdus.Any(asdu => asdu.ConfigurationRevision != profile.Stream.ConfigurationRevision)) + { + diagnostics.Add( + "The observed confRev does not match the bound SCL SampledValueControl configuration; dataset layout cannot be assumed current."); + return false; + } + + return true; + } + + private static SvStreamMeasurementContext? ValidateMeasurementContext( + SvObservedStreamKey streamKey, + SampledValuesFrame frame, + SvStreamMeasurementContext? context, + ICollection diagnostics) + { + if (context is null) + return null; + + var errors = context.Validate(); + if (errors.Count > 0) + { + foreach (var error in errors) + diagnostics.Add($"Measurement context rejected: {error}"); + return null; + } + + if (!string.Equals(context.StreamKey, streamKey.Id, StringComparison.Ordinal)) + { + diagnostics.Add("Measurement context stream key does not match the observed stream identity; CT/VT ratio and display-domain conversion were not applied."); + return null; + } + + var observedSvId = frame.Pdu.Asdus[0].SvId; + if (!string.IsNullOrWhiteSpace(context.SvId) && + !string.Equals(context.SvId, observedSvId, StringComparison.Ordinal)) + { + diagnostics.Add("Measurement context svID does not match the observed stream; CT/VT ratio and display-domain conversion were not applied."); + return null; + } + + return context; + } + + private static bool IsFixedFourCurrentFourVoltageValueQualityLayout(SampledValuesPayloadLayout layout) + { + if (layout.PayloadByteLength != 64 || layout.Elements.Count != 16) + return false; + + var current = 0; + var voltage = 0; + for (var pair = 0; pair < 8; pair++) + { + var value = layout.Elements[pair * 2]; + var quality = layout.Elements[(pair * 2) + 1]; + if (value.Kind != SampledValuePayloadElementKind.Int32 || + quality.Kind != SampledValuePayloadElementKind.Quality) + { + return false; + } + + switch (SvEngineeringScaleResolver.ResolveDomain(value.SignalReference, value.Cdc)) + { + case SvMeasurementDomain.Current: current++; break; + case SvMeasurementDomain.Voltage: voltage++; break; + default: return false; + } + } + + return current == 4 && voltage == 4; + } + + private static bool IsNumericAnalogElement(SampledValuePayloadElement element) + => element.Kind is SampledValuePayloadElementKind.Int8 + or SampledValuePayloadElementKind.Int16 + or SampledValuePayloadElementKind.Int32 + or SampledValuePayloadElementKind.Int64 + or SampledValuePayloadElementKind.UInt8 + or SampledValuePayloadElementKind.UInt16 + or SampledValuePayloadElementKind.UInt24 + or SampledValuePayloadElementKind.UInt32 + or SampledValuePayloadElementKind.UInt64 + or SampledValuePayloadElementKind.Float32 + or SampledValuePayloadElementKind.Float64; + + private static bool TryGetFiniteNumber(MmsDataValue value, out double number) + { + number = value.Kind switch + { + MmsDataKind.Integer when value.Value is long signed => signed, + MmsDataKind.Unsigned when value.Value is ulong unsigned => unsigned, + MmsDataKind.FloatingPoint when value.Value is float single => single, + MmsDataKind.FloatingPoint when value.Value is double floating => floating, + _ => double.NaN + }; + return double.IsFinite(number); + } + + private static ushort? StableNullable(IEnumerable values) + { + var distinct = values.Distinct().ToArray(); + return distinct.Length == 1 ? distinct[0] : null; + } + + private static SvSclBoundMeasurementProjection BoundEmpty( + SvObservedStreamKey streamKey, + SampledValuesPublisherProfile profile, + IEnumerable diagnostics) + => new() + { + StreamKey = streamKey, + ControlBlockReference = profile.Stream.ControlBlockReference, + IsBoundToScl = true, + Diagnostics = diagnostics.Distinct(StringComparer.Ordinal).ToArray() + }; +} diff --git a/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs b/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs index 1798bfe9..30fe9d6e 100644 --- a/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs +++ b/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs @@ -47,7 +47,7 @@ public SclEngineeringProfile Build(XDocument xdoc, string sourceName = "") LogicalDeviceCount = logicalDevices.Count(ld => Same(ld.IedName, i.Name)) }).ToList(); - AddStaticFindings(scl, accessPoints, logicalDevices, logicalNodes, externalReferences, findings); + AddStaticFindings(root, scl, accessPoints, logicalDevices, logicalNodes, externalReferences, findings); var capabilities = new SclEngineeringCapabilityMatrix { @@ -104,13 +104,18 @@ private static IEnumerable ParseAccessPoints(XElement foreach (var ap in ied.Elements().Where(e => Is(e, "AccessPoint"))) { var server = ap.Elements().FirstOrDefault(e => Is(e, "Server")); + var owner = server is null + ? SclServerAtModelResolver.ResolveOwner(ied, ap) + : ap; + var effectiveServer = owner?.Elements().FirstOrDefault(e => Is(e, "Server")); yield return new SclEngineeringAccessPoint { IedName = iedName, Name = Attr(ap, "name"), + // HasServer describes a physically declared Server, not ServerAt. HasServer = server is not null, Router = Attr(ap, "router"), - LogicalDeviceCount = server?.Elements().Count(e => Is(e, "LDevice")) ?? 0 + LogicalDeviceCount = effectiveServer?.Elements().Count(e => Is(e, "LDevice")) ?? 0 }; } } @@ -230,6 +235,7 @@ private static HashSet ParseServiceDeclarations(XElement root) } private static void AddStaticFindings( + XElement root, SclDocument document, IReadOnlyList accessPoints, IReadOnlyList logicalDevices, @@ -244,7 +250,24 @@ private static void AddStaticFindings( findings.Add(Finding("High", "SCL_NO_SERVER_MODEL", "No Server/LDevice model was found. The file cannot seed MMS model discovery or simulator model generation.")); foreach (var ap in accessPoints.Where(a => !a.HasServer)) - findings.Add(Finding("Warning", "SCL_ACCESS_POINT_WITHOUT_SERVER", $"AccessPoint {ap.IedName}/{ap.Name} has no Server element.", $"{ap.IedName}/{ap.Name}")); + { + var iedElement = root.Elements().FirstOrDefault(e => + Is(e, "IED") && Same(Attr(e, "name"), ap.IedName)); + var apElement = iedElement?.Elements().FirstOrDefault(e => + Is(e, "AccessPoint") && Same(Attr(e, "name"), ap.Name)); + var hasServerAt = apElement?.Elements().Any(e => Is(e, "ServerAt")) == true; + if (hasServerAt && iedElement is not null && apElement is not null && + SclServerAtModelResolver.ResolveOwner(iedElement, apElement) is not null) + continue; // Valid ServerAt is not a broken/missing Server. + + findings.Add(hasServerAt + ? Finding("High", "SCL_SERVER_AT_UNRESOLVED", + $"AccessPoint {ap.IedName}/{ap.Name} has a dangling, ambiguous or cyclic ServerAt reference.", + $"{ap.IedName}/{ap.Name}") + : Finding("Warning", "SCL_ACCESS_POINT_WITHOUT_SERVER", + $"AccessPoint {ap.IedName}/{ap.Name} has no Server or ServerAt element.", + $"{ap.IedName}/{ap.Name}")); + } foreach (var ln in logicalNodes.Where(l => string.IsNullOrWhiteSpace(l.LnType))) findings.Add(Finding("Warning", "SCL_LN_TYPE_MISSING", $"Logical node {ln.Reference} has no lnType binding.", ln.Reference)); diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index ccd70779..b92de8a7 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -26,7 +26,14 @@ public static LiveIedSclExportResult WriteFiles( try { var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); - if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) + if (options.VerifiedIdentity is { } verified) + { + if (!string.IsNullOrWhiteSpace(options.IedNameOverride) && + !verified.IedName.Equals(options.IedNameOverride.Trim(), StringComparison.Ordinal)) + throw new InvalidDataException("Caller IED override conflicts with trusted SCL evidence."); + document = ApplyVerifiedIdentity(document, model, verified); + } + else if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) document = ApplyIdentity(document, model, options.IedNameOverride); document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile); @@ -99,6 +106,80 @@ public static XDocument ApplyIdentity( return document; } + + /// + /// Normalizes both IED identity and LD instance mappings from verified SCL + /// topology. Physical MMS domain / FCDA evidence remains unchanged. + /// + public static XDocument ApplyVerifiedIdentity( + XDocument generated, + LiveIedModelDiscoveryDocument model, + LiveIedIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + var observed = model.LogicalDevices + .Select(LogicalDeviceDomain) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!identity.Source.Equals("TrustedSclExactDomainMatch", StringComparison.Ordinal) || + identity.Confidence != LiveIedDiscoveryConfidenceLevel.High || + identity.IsAmbiguous || + observed.Count == 0 || + identity.LogicalDeviceAliases.Count != observed.Count || + identity.LogicalDeviceAliases.Keys.Any(x => !observed.Contains(x)) || + identity.LogicalDeviceAliases.Values.Any(x => string.IsNullOrWhiteSpace(x)) || + identity.LogicalDeviceAliases.Values.Distinct(StringComparer.OrdinalIgnoreCase).Count() != observed.Count) + throw new InvalidDataException("SCL identity evidence is not a unique complete MMS-domain mapping."); + + // Existing authoritative normalization proves identity and preserves the + // MMS wire-domain graph. Transform LDevice.inst and FCDA LD references + // together so that a single-domain BCUGEF650 becomes BCUGE/F650. + var document = ApplyIdentity(generated, model, identity.IedName); + var rewrites = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var ied = document.Root?.Element(Scl + "IED") + ?? throw new InvalidDataException("SCL has no unique IED."); + var nodes = ied.Descendants(Scl + "LDevice").ToArray(); + if (nodes.Length != observed.Count) + throw new InvalidDataException("Generated LD set and observed MMS domain set differ."); + + foreach (var ld in nodes) + { + var oldInst = ((string?)ld.Attribute("inst") ?? "").Trim(); + var explicitDomain = ((string?)ld.Attribute("ldName") ?? "").Trim(); + var domain = explicitDomain.Length != 0 ? explicitDomain : identity.IedName + oldInst; + if (!identity.LogicalDeviceAliases.TryGetValue(domain, out var newInst) || + !seen.Add(domain)) + throw new InvalidDataException($"Generated SCL domain '{domain}' is not uniquely matched."); + rewrites.Add(oldInst, newInst); + ld.SetAttributeValue("inst", newInst); + ld.SetAttributeValue("ldName", + domain.Equals(identity.IedName + newInst, StringComparison.OrdinalIgnoreCase) + ? null : domain); + } + if (seen.Count != observed.Count) + throw new InvalidDataException("Generated SCL did not map every observed MMS domain."); + + // FCDA ldInst references refer to LDevice.inst, not the MMS domain. + // Never rewrite references belonging to an explicitly different IED. + foreach (var fcda in ied.Descendants(Scl + "FCDA")) + { + var fcdaIed = ((string?)fcda.Attribute("iedName") ?? "").Trim(); + if (fcdaIed.Length > 0 && + !fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase) && + !fcdaIed.Equals(identity.IedName, StringComparison.OrdinalIgnoreCase)) + continue; + var oldRef = ((string?)fcda.Attribute("ldInst") ?? "").Trim(); + if (rewrites.TryGetValue(oldRef, out var mapped)) + fcda.SetAttributeValue("ldInst", mapped); + if (fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase)) + fcda.SetAttributeValue("iedName", identity.IedName); + } + ValidateIdentity(document, identity.IedName, model); + return document; + } + public static XDocument ApplyReportControlConfiguration( XDocument source, LiveIedModelDiscoveryDocument model, diff --git a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs index 925cbe11..ae31f67b 100644 --- a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs @@ -19,7 +19,8 @@ public static LiveIedSclExportResult WriteFiles( LiveIedCanonicalModel canonical, string sclPath, SclSchemaProfile schemaProfile = SclSchemaProfile.Edition2V31, - string profile = "safe-connection") + string profile = "safe-connection", + LiveIedIdentity? verifiedIdentity = null) { ArgumentNullException.ThrowIfNull(canonical); ValidateCanonicalCommunication(canonical); @@ -41,7 +42,8 @@ public static LiveIedSclExportResult WriteFiles( ? "StationBus" : communication.SubNetworkName.Trim(), IpAddress = communication.Host.Trim(), - IedNameOverride = canonical.IedName, + IedNameOverride = verifiedIdentity?.IedName ?? canonical.IedName, + VerifiedIdentity = verifiedIdentity, IpSubnet = communication.IpSubnet?.Trim() ?? string.Empty, IpGateway = communication.IpGateway?.Trim() ?? string.Empty, OsiApTitle = association.ApTitle.Trim(), @@ -53,10 +55,10 @@ public static LiveIedSclExportResult WriteFiles( }); var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); - ApplyCanonicalCommunication(document, canonical); + ApplyCanonicalCommunication(document, canonical, verifiedIdentity?.IedName); PreserveRuntimeServiceCapacity(document, canonical.Discovery); - ApplyCanonicalInstanceValues(document, canonical); - ValidateRoundTripAssociation(document, canonical); + ApplyCanonicalInstanceValues(document, canonical, verifiedIdentity); + ValidateRoundTripAssociation(document, canonical, verifiedIdentity?.IedName); document.Save(result.SclPath); return result; } @@ -106,11 +108,12 @@ public static void ValidateCanonicalCommunication(LiveIedCanonicalModel canonica throw new InvalidDataException(string.Join(" | ", errors)); } - private static void ApplyCanonicalCommunication(XDocument document, LiveIedCanonicalModel canonical) + private static void ApplyCanonicalCommunication( + XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName) { var connectedAp = document.Descendants(Scl + "ConnectedAP").SingleOrDefault() ?? throw new InvalidDataException("Generated SCL must contain exactly one ConnectedAP."); - connectedAp.SetAttributeValue("iedName", canonical.IedName); + connectedAp.SetAttributeValue("iedName", trustedIedName ?? canonical.IedName); connectedAp.SetAttributeValue("apName", canonical.AccessPointName); var subNetwork = connectedAp.Parent; @@ -163,7 +166,8 @@ public static void PreserveRuntimeServiceCapacity( public static void ApplyCanonicalInstanceValues( XDocument document, - LiveIedCanonicalModel canonical) + LiveIedCanonicalModel canonical, + LiveIedIdentity? verifiedIdentity = null) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(canonical); @@ -212,7 +216,7 @@ public static void ApplyCanonicalInstanceValues( if (logicalNodeModel is null) continue; - var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain); + var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain, verifiedIdentity); if (lDevice is null) continue; @@ -272,9 +276,20 @@ public static void ApplyCanonicalInstanceValues( private static XElement? FindExportedLogicalDevice( XDocument document, LiveIedCanonicalModel canonical, - string domain) + string domain, + LiveIedIdentity? verifiedIdentity) { var normalizedDomain = domain.Trim(); + // A verified engineering source provides the exact LD instance. + // Avoid recomputing it from the canonical *provisional* IED name. + if (verifiedIdentity is not null && + verifiedIdentity.LogicalDeviceAliases.TryGetValue(normalizedDomain,out var trustedInst)) + { + var trusted = document.Descendants(Scl + "LDevice") + .Where(ld => string.Equals((string?)ld.Attribute("inst"),trustedInst,StringComparison.OrdinalIgnoreCase)) + .ToArray(); + return trusted.Length == 1 ? trusted[0] : null; + } var iedName = canonical.IedName.Trim(); var stripped = normalizedDomain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) && normalizedDomain.Length > iedName.Length @@ -462,12 +477,13 @@ private static bool TryFormatScalarValue( } } - private static void ValidateRoundTripAssociation(XDocument document, LiveIedCanonicalModel canonical) + private static void ValidateRoundTripAssociation( + XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName) { var profiles = SclMmsAssociationProfileReader.Read(document); - var remote = profiles.Find(canonical.IedName, canonical.AccessPointName) + var remote = profiles.Find(trustedIedName ?? canonical.IedName, canonical.AccessPointName) ?? throw new InvalidDataException( - $"Generated SCL cannot round-trip its own ConnectedAP '{canonical.IedName}/{canonical.AccessPointName}'."); + $"Generated SCL cannot round-trip its own ConnectedAP '{trustedIedName ?? canonical.IedName}/{canonical.AccessPointName}'."); var canonicalAssociation = canonical.Communication.Association; var roundTripErrors = new List(); diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs index c6b6ac3f..ad2c7838 100644 --- a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs +++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs @@ -15,6 +15,13 @@ public sealed class LiveIedSclExportOptions /// when an implementation uses explicit communication-level LD names. /// public string IedNameOverride { get; init; } = string.Empty; + + /// + /// Optional evidence proved against the exact observed MMS domain set by + /// TrustedSclIedIdentityMatcher. Not a display alias or an IP-based guess. + /// Export fails closed on incomplete/colliding aliases. + /// + public AR.Iec61850.Discovery.LiveIedIdentity? VerifiedIdentity { get; init; } public string IpSubnet { get; init; } = "255.255.255.0"; public string IpGateway { get; init; } = "0.0.0.0"; public string OsiApTitle { get; init; } = string.Empty; diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs index ae583318..0be1e0b3 100644 --- a/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs @@ -877,7 +877,7 @@ private static XElement BuildDaElement(TypeTreeNode node, string ownerTypeId, Li new XAttribute("type", enumTypeId)); } - var bType = NormalizeBType(node.BType, node.Name, node.Path, cdc); + var bType = NormalizeBType(node.BType, node.Name, node.Path, cdc, node.TypeConfidence); return new XElement( Scl + (isRootDa ? "DA" : "BDA"), new XAttribute("name", SafeXmlName(node.Name)), @@ -1198,7 +1198,12 @@ private static bool IsControlBlockDataObject(LiveIedDataObjectModel dataObject) string.Equals(attribute.FunctionalConstraint, "US", StringComparison.OrdinalIgnoreCase) || string.Equals(attribute.FunctionalConstraint, "LG", StringComparison.OrdinalIgnoreCase)); - private static string NormalizeBType(string bType, string name, string path, string cdc) + private static string NormalizeBType( + string bType, + string name, + string path, + string cdc, + LiveIedDiscoveryConfidenceLevel typeConfidence) { if (string.Equals(name, "q", StringComparison.OrdinalIgnoreCase)) return "Quality"; @@ -1207,15 +1212,32 @@ private static string NormalizeBType(string bType, string name, string path, str if (string.Equals(name, "T", StringComparison.Ordinal)) return "Timestamp"; + var normalizedBType = (bType ?? string.Empty).Trim(); + var exactPrimitive = + typeConfidence == LiveIedDiscoveryConfidenceLevel.Exact && + !string.IsNullOrWhiteSpace(normalizedBType) && + !normalizedBType.Equals("Unknown", StringComparison.OrdinalIgnoreCase) && + !normalizedBType.Equals("Struct", StringComparison.OrdinalIgnoreCase); + + // Exact MMS TypeSpecification is protocol evidence. Never overwrite it merely + // because a generic CDC shape inferred a different primitive type. + if (exactPrimitive) + return normalizedBType; + var normalizedCdc = cdc.Trim().ToUpperInvariant(); var normalizedPath = path.Trim(); var inferred = InferBTypeFromCdc(normalizedCdc, name, normalizedPath); if (!string.IsNullOrWhiteSpace(inferred)) return inferred; - if (string.IsNullOrWhiteSpace(bType) || string.Equals(bType, "Unknown", StringComparison.OrdinalIgnoreCase) || string.Equals(bType, "Struct", StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrWhiteSpace(normalizedBType) || + normalizedBType.Equals("Unknown", StringComparison.OrdinalIgnoreCase) || + normalizedBType.Equals("Struct", StringComparison.OrdinalIgnoreCase)) + { return "VisString255"; - return bType.Trim(); + } + + return normalizedBType; } private static string InferBTypeFromCdc(string cdc, string name, string path) @@ -1428,6 +1450,8 @@ private TypeTreeNode(string name, string path) public string Path { get; } public string Fc { get; private set; } = string.Empty; public string BType { get; private set; } = string.Empty; + public LiveIedDiscoveryConfidenceLevel TypeConfidence { get; private set; } = + LiveIedDiscoveryConfidenceLevel.Unknown; public IReadOnlyList Children => _orderedChildren; public string EffectiveFunctionalConstraint @@ -1491,6 +1515,7 @@ private void Add(LiveIedDataAttributeModel attribute) current.Fc = attribute.FunctionalConstraint; current.BType = attribute.SclBType; + current.TypeConfidence = attribute.TypeConfidence; } } } diff --git a/src/AR.Iec61850/Scl/SclApTitleOidNotation.cs b/src/AR.Iec61850/Scl/SclApTitleOidNotation.cs new file mode 100644 index 00000000..d22206e9 --- /dev/null +++ b/src/AR.Iec61850/Scl/SclApTitleOidNotation.cs @@ -0,0 +1,68 @@ +using System.Globalization; + +namespace AR.Iec61850.Scl; + +/// +/// SCL AP-Title is normally an ASN.1 OID arc list. Some legacy engineering +/// exports spell the first *BER subidentifier* as one decimal component +/// (40*firstArc + secondArc). Only the unambiguous single-octet 40..79 +/// form is recognized here; arbitrary malformed OIDs remain invalid. +/// No source XML or other SCL association field is rewritten. +/// +internal static class SclApTitleOidNotation +{ + internal static bool TryParse( + string text, + out uint[] arcs, + out bool wasPackedFirstArc, + out string error) + { + arcs = Array.Empty(); + wasPackedFirstArc = false; + error = string.Empty; + var tokens = text.Split( + [',', '.', ' ', ';'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length < 2) + { + error = $"SCL OSI-AP-Title '{text}' does not contain a valid OID."; + return false; + } + + var declared = new uint[tokens.Length]; + for (var i = 0; i < tokens.Length; i++) + { + if (!uint.TryParse(tokens[i], NumberStyles.None, CultureInfo.InvariantCulture, out declared[i])) + { + error = $"SCL OSI-AP-Title '{text}' contains an invalid OID arc '{tokens[i]}'."; + return false; + } + } + + if (declared[0] <= 2 && (declared[0] == 2 || declared[1] <= 39)) + { + arcs = declared; + return true; + } + + // Legacy OID subidentifier: decimal 41 means ASN.1 arcs 1.1. + // At least one remaining arc is required to distinguish this + // representation from an incomplete engineering declaration. + if (declared.Length >= 3 && declared[0] is >= 40 and <= 79) + { + arcs = new uint[declared.Length + 1]; + arcs[0] = 1; + arcs[1] = declared[0] - 40; + Array.Copy(declared, 1, arcs, 2, declared.Length - 1); + wasPackedFirstArc = true; + return true; + } + + error = $"SCL OSI-AP-Title '{text}' violates ASN.1 object-identifier arc rules."; + return false; + } + + internal static string CompatibilityWarning(string declared, IReadOnlyList decoded) + => $"SCL OSI-AP-Title '{declared}' uses legacy BER-packed first subidentifier; " + + $"association encodes standard OID '{string.Join(",", decoded)}'. Original SCL declaration remains unchanged."; +} diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs index 265e436d..a0d97d2e 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs @@ -102,7 +102,7 @@ public static SclAssistedMmsAssociationPlanResult BuildExact( var remoteTsel = ParseSelector(remote.Association.TransportSelector, "OSI-TSEL", errors); var remoteSsel = ParseSelector(remote.Association.SessionSelector, "OSI-SSEL", errors); var remotePsel = ParseSelector(remote.Association.PresentationSelector, "OSI-PSEL", errors); - var remoteApTitle = ParseApTitle(remote.Association.ApTitle, errors); + var remoteApTitle = ParseApTitle(remote.Association.ApTitle, errors, warnings); if (!remote.Association.AeQualifier.HasValue || remote.Association.AeQualifier.Value is < 0 or > 65535) @@ -223,7 +223,10 @@ private static void ValidateLocal(MmsLocalAssociationProfile local, ICollection< return bytes; } - private static uint[]? ParseApTitle(string text, ICollection errors) + private static uint[]? ParseApTitle( + string text, + ICollection errors, + ICollection warnings) { if (string.IsNullOrWhiteSpace(text)) { @@ -231,28 +234,14 @@ private static void ValidateLocal(MmsLocalAssociationProfile local, ICollection< return null; } - var tokens = text.Split([',', '.', ' ', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (tokens.Length < 2) + if (!SclApTitleOidNotation.TryParse(text, out var arcs, out var packed, out var error)) { - errors.Add($"SCL OSI-AP-Title '{text}' does not contain a valid OID."); + errors.Add(error); return null; } - 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 null; - } - } - - 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 null; - } + if (packed) + warnings.Add(SclApTitleOidNotation.CompatibilityWarning(text, arcs)); return arcs; } diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs index 805061b0..432230e7 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs @@ -87,7 +87,7 @@ public static SclAssistedMmsAssociationResolution Resolve( 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 apTitle = ParseApTitle(remote, remote.Association.ApTitle, errors, warnings); var aeQualifier = ParseAeQualifier(remote, errors); var fields = new[] @@ -548,7 +548,8 @@ private static SelectorConstraint InvalidSelector(string name, string text) private static ApTitleConstraint ParseApTitle( SclMmsAccessPoint remote, string text, - ICollection errors) + ICollection errors, + ICollection warnings) { const string name = "OSI-AP-Title"; if (remote.AmbiguousParameters.Contains(name)) @@ -568,30 +569,14 @@ private static ApTitleConstraint ParseApTitle( new SclAssociationFieldEvidence { Name = name, State = SclAssociationFieldState.Unspecified }); } - var tokens = text.Split( - [',', '.', ' ', ';'], - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (tokens.Length < 2) + if (!SclApTitleOidNotation.TryParse(text, out var arcs, out var packed, out var error)) { - errors.Add($"SCL OSI-AP-Title '{text}' does not contain a valid OID."); + errors.Add(error); 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); - } + if (packed) + warnings.Add(SclApTitleOidNotation.CompatibilityWarning(text, arcs)); return new ApTitleConstraint( SclAssociationFieldState.PresentValid, diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs index 0643b4b9..e1d35132 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs @@ -91,10 +91,11 @@ public static SclMmsDomainInventory Read(XDocument document, string iedName, str return Build(canonicalIedName, accessPointName, domains, errors, warnings); } - var server = accessPoint.Elements().FirstOrDefault(element => Is(element, "Server")); + var serverOwner = SclServerAtModelResolver.ResolveOwner(ied, accessPoint); + var server = serverOwner?.Elements().FirstOrDefault(element => Is(element, "Server")); if (server is null) { - errors.Add($"SCL IED '{canonicalIedName}' AccessPoint '{accessPointName}' has no direct Server model."); + errors.Add($"SCL IED '{canonicalIedName}' AccessPoint '{accessPointName}' has no resolvable Server/ServerAt model."); return Build(canonicalIedName, accessPointName, domains, errors, warnings); } diff --git a/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs b/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs index ed5a8f7a..b78c7db4 100644 --- a/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs +++ b/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs @@ -63,7 +63,11 @@ public static SclInitialFcReadDesign Read(XDocument document, string iedName, st var selectedAp = selectedIed.Elements() .Where(element => Is(element, "AccessPoint")) .First(element => string.Equals(Attr(element, "name"), accessPointName, StringComparison.Ordinal)); - var server = selectedAp.Elements().First(element => Is(element, "Server")); + // Initial FC-read must use the same canonical ServerAt model authority as + // offline browsing. Keep the selected AP identity for its own MMS address. + var serverOwner = SclServerAtModelResolver.ResolveOwner(selectedIed, selectedAp); + var server = serverOwner?.Elements().FirstOrDefault(element => Is(element, "Server")) + ?? throw new InvalidDataException("Selected AccessPoint has no resolvable Server/ServerAt model."); var domainByInst = new Dictionary(StringComparer.Ordinal); foreach (var lDevice in server.Elements().Where(element => Is(element, "LDevice"))) @@ -107,6 +111,17 @@ public static SclInitialFcReadDesign Read(XDocument document, string iedName, st continue; } + var scopedSelectedAp = otherIed.Elements().First(element => + Is(element, "AccessPoint") && + string.Equals(Attr(element, "name"), accessPointName, StringComparison.Ordinal)); + if (!scopedSelectedAp.Elements().Any(element => Is(element, "Server"))) + { + // Only the disposable projection receives a cloned server. No source + // SCD change, no second server address, no cross-IED inheritance. + scopedSelectedAp.Elements().Where(element => Is(element, "ServerAt")).Remove(); + scopedSelectedAp.AddFirst(new XElement(server)); + } + foreach (var otherAp in otherIed.Elements().Where(element => Is(element, "AccessPoint")).ToArray()) { if (!string.Equals(Attr(otherAp, "name"), accessPointName, StringComparison.Ordinal)) diff --git a/src/AR.Iec61850/Scl/SclModels.cs b/src/AR.Iec61850/Scl/SclModels.cs index 227570a8..569c8626 100644 --- a/src/AR.Iec61850/Scl/SclModels.cs +++ b/src/AR.Iec61850/Scl/SclModels.cs @@ -24,7 +24,10 @@ public enum SclEdition Unknown, Edition1, Edition2, - Edition21 + Edition21, + // IEC 61850-6 Edition 2.2 (SCL namespace identification 2007/C/5). + // Append to preserve the existing public enum numeric identities. + Edition22 } public sealed class SclIed diff --git a/src/AR.Iec61850/Scl/SclParser.cs b/src/AR.Iec61850/Scl/SclParser.cs index 9ef0e92e..b876ee4f 100644 --- a/src/AR.Iec61850/Scl/SclParser.cs +++ b/src/AR.Iec61850/Scl/SclParser.cs @@ -435,39 +435,60 @@ private static SclEdition DetectEdition(XElement root) var release = Attr(root, "release"); var ns = root.Name.NamespaceName.ToLowerInvariant(); - // IEC 61850 SCL keeps the historical namespace URI http://www.iec.ch/61850/2003/SCL - // even for Edition 2 family files. Do not classify by namespace alone. - if (string.Equals(version, "2007", StringComparison.OrdinalIgnoreCase) && - string.Equals(revision, "B", StringComparison.OrdinalIgnoreCase) && - (string.Equals(release, "4", StringComparison.OrdinalIgnoreCase) || string.Equals(release, "4A", StringComparison.OrdinalIgnoreCase))) - return SclEdition.Edition21; + // IEC 61850-6 uses the SAME historical XML namespace URI for + // Editions 1/2/2.1/2.2. Root version/revision/release, not an invented + // namespace URI or vendor signature, identifies the SCL edition. + if (string.Equals(version, "2007", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(revision, "C", StringComparison.OrdinalIgnoreCase)) + return string.Equals(release, "5", StringComparison.OrdinalIgnoreCase) + ? SclEdition.Edition22 : SclEdition.Unknown; - if (string.Equals(version, "2007", StringComparison.OrdinalIgnoreCase) || - string.Equals(revision, "B", StringComparison.OrdinalIgnoreCase)) - return SclEdition.Edition2; + if (string.Equals(revision, "B", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(release, "4", StringComparison.OrdinalIgnoreCase) || + string.Equals(release, "4A", StringComparison.OrdinalIgnoreCase)) + return SclEdition.Edition21; + // Edition 2 legacy files may omit release or use an earlier + // integer release. Future/contradictory releases stay Unknown. + if (string.IsNullOrWhiteSpace(release) || + int.TryParse(release, NumberStyles.None, CultureInfo.InvariantCulture, + out var olderRelease) && olderRelease <= 3) + return SclEdition.Edition2; + return SclEdition.Unknown; + } - if (string.Equals(version, "2003", StringComparison.OrdinalIgnoreCase) || - string.Equals(revision, "A", StringComparison.OrdinalIgnoreCase)) - return SclEdition.Edition1; + return string.IsNullOrWhiteSpace(revision) && + string.IsNullOrWhiteSpace(release) + ? SclEdition.Edition2 : SclEdition.Unknown; + } - // Edition 1 IED capability files commonly use the historical SCL namespace - // without root version/revision attributes. Edition 2 identifies itself with - // version="2007" and revision="B" while retaining the same namespace URI. - if (string.IsNullOrWhiteSpace(version) && - string.IsNullOrWhiteSpace(revision) && - string.IsNullOrWhiteSpace(release) && - ns.Contains("/2003/scl", StringComparison.Ordinal)) - return SclEdition.Edition1; + if (string.Equals(version, "2003", StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrWhiteSpace(revision) || + string.Equals(revision, "A", StringComparison.OrdinalIgnoreCase) + ? SclEdition.Edition1 : SclEdition.Unknown; + // An explicit unsupported release/version is not safe to downgrade to + // Edition 2 using the shared 2003 namespace. + if (!string.IsNullOrWhiteSpace(version) || !string.IsNullOrWhiteSpace(release)) + return SclEdition.Unknown; + + // Retain the limited historical no-version classification for older + // IED capability files; never infer Edition 2.1/2.2 without root proof. + if (string.Equals(revision, "B", StringComparison.OrdinalIgnoreCase)) + return SclEdition.Edition2; + if (string.Equals(revision, "A", StringComparison.OrdinalIgnoreCase)) + return SclEdition.Edition1; + if (!string.IsNullOrWhiteSpace(revision)) + return SclEdition.Unknown; + if (ns.Contains("/2003/scl", StringComparison.Ordinal)) + return SclEdition.Edition1; if (ns.Contains("2007b4", StringComparison.Ordinal) || ns.Contains("ed2.1", StringComparison.Ordinal) || ns.Contains("edition2.1", StringComparison.Ordinal)) return SclEdition.Edition21; - - if (ns.Contains("scl", StringComparison.Ordinal)) - return SclEdition.Edition2; - - return SclEdition.Unknown; + return ns.Contains("scl", StringComparison.Ordinal) + ? SclEdition.Edition2 : SclEdition.Unknown; } private static string DataSetKey(string iedName, string ldInst, string lnPath, string dataSetName) diff --git a/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs b/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs new file mode 100644 index 00000000..cd1d963c --- /dev/null +++ b/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs @@ -0,0 +1,52 @@ +using System.Xml.Linq; + +namespace AR.Iec61850.Scl; + +/// +/// Resolves an IEC 61850 ServerAt reference within the same IED. This is a +/// model reference, not an alternate MMS address. Fail closed for dangling, +/// ambiguous, or circular references; never bind another IED's server. +/// +internal static class SclServerAtModelResolver +{ + public static XElement? ResolveOwner(XElement ied, XElement selectedAccessPoint) + { + var accessPoints = ied.Elements() + .Where(element => Is(element, "AccessPoint")) + .ToArray(); + var visited = new HashSet(); + + var current = selectedAccessPoint; + while (visited.Add(current)) + { + if (current.Elements().Any(element => Is(element, "Server"))) + return current; + + var references = current.Elements() + .Where(element => Is(element, "ServerAt")) + .ToArray(); + if (references.Length != 1) + return null; + + var referencedName = ((string?)references[0].Attribute("apName"))?.Trim(); + if (string.IsNullOrWhiteSpace(referencedName)) + return null; + + var matches = accessPoints + .Where(element => string.Equals( + ((string?)element.Attribute("name"))?.Trim(), + referencedName, + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (matches.Length != 1) + return null; + + current = matches[0]; + } + + return null; // ServerAt cycle + } + + private static bool Is(XElement element, string localName) + => element.Name.LocalName.Equals(localName, StringComparison.Ordinal); +} diff --git a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs index 1992f3b6..1f602e5a 100644 --- a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs +++ b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs @@ -208,6 +208,25 @@ private static LiveIedModelDiscoveryDocument BuildDesignModel( if (!string.IsNullOrWhiteSpace(accessPointName)) { + var selectedAccessPoint = selectedIed.Elements() + .FirstOrDefault(x => Is(x, "AccessPoint") && Same(Attr(x, "name"), accessPointName)); + if (selectedAccessPoint is not null && + !selectedAccessPoint.Elements().Any(x => Is(x, "Server"))) + { + // ServerAt points at another AP's server model, while this AP keeps + // its own Communication/ConnectedAP address. Materialize that model + // ONLY in the isolated projection (never modify the source SCD). + // This allows an AP like Siemens 7SX85/F -> J to retain LD/LN, + // DataSets and RCBs without misrepresenting its MMS endpoint. + var owner = SclServerAtModelResolver.ResolveOwner(selectedIed, selectedAccessPoint); + var server = owner?.Elements().FirstOrDefault(x => Is(x, "Server")); + if (server is not null) + { + selectedAccessPoint.Elements().Where(x => Is(x, "ServerAt")).Remove(); + selectedAccessPoint.AddFirst(new XElement(server)); + } + } + foreach (var otherAccessPoint in selectedIed.Elements() .Where(x => Is(x, "AccessPoint") && !Same(Attr(x, "name"), accessPointName)) .ToArray()) diff --git a/tests/AR.Iec61850.Tests/AR.Iec61850.Tests.csproj b/tests/AR.Iec61850.Tests/AR.Iec61850.Tests.csproj index 27387246..b1b28463 100644 --- a/tests/AR.Iec61850.Tests/AR.Iec61850.Tests.csproj +++ b/tests/AR.Iec61850.Tests/AR.Iec61850.Tests.csproj @@ -12,10 +12,10 @@ - - + + - + diff --git a/tests/AR.Iec61850.Tests/ComtradeRioSidecarCatalogTests.cs b/tests/AR.Iec61850.Tests/ComtradeRioSidecarCatalogTests.cs new file mode 100644 index 00000000..98772c65 --- /dev/null +++ b/tests/AR.Iec61850.Tests/ComtradeRioSidecarCatalogTests.cs @@ -0,0 +1,66 @@ +using AR.Iec61850.FaultRecords; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests; + +public sealed class ComtradeRioSidecarCatalogTests +{ + private static MmsFileDirectoryEntry File(string path, uint size = 10) => + new() { Path = path, Name = Path.GetFileName(path), SizeBytes = size }; + + [Fact] + public void RioSidecar_JoinsOnlySameDirectoryAndBasename_WithCaseInsensitiveExtension() + { + var catalog = Iec61850FaultRecordCatalogBuilder.Build( + [ + File("dir/line_1.cfg", 100), + File("dir/line_1.dat", 200), + File("dir/line_1.RIO", 30), + File("dir/line_2.RIO", 40), // no matching primary + File("other/line_1.RIO", 50) // wrong folder + ]); + var record = Assert.Single(catalog.Records); + Assert.Equal("line_1", record.BaseName); + Assert.True(record.IsComplete); + Assert.Equal(3, record.Files.Count); + Assert.Contains(record.Files, f => + f.Name == "line_1.RIO" && + f.Kind == Iec61850FaultRecordFileKind.RioSidecar && + f.RemotePath == "dir/line_1.RIO"); + Assert.DoesNotContain(record.Files, f => + f.RemotePath == "other/line_1.RIO"); + Assert.Equal(330L, record.KnownSizeBytes); + } + + [Fact] + public void OrphanRio_IsNotAuthorizedAsFaultRecord() + { + var catalog = Iec61850FaultRecordCatalogBuilder.Build([File("line_1.RIO")]); + Assert.Empty(catalog.Records); + Assert.True(Iec61850FaultRecordCatalogBuilder.IsSupportedFile("line_1.rio")); + } + + [Fact] + public void RioDoesNotMakeIncompleteCfgOrDatComplete() + { + var catalog = Iec61850FaultRecordCatalogBuilder.Build( + [File("line_1.cfg"), File("line_1.rio")]); + var record = Assert.Single(catalog.Records); + Assert.False(record.IsComplete); + Assert.Equal(2, record.Files.Count); + Assert.False(record.CanDownload && record.IsComplete); + } + + [Fact] + public void DuplicateOrDifferentlyCasedRemoteDirectoryListing_DoesNotDuplicateRio() + { + var catalog = Iec61850FaultRecordCatalogBuilder.Build( + [ + File("dir/line_1.cfg"), + File("dir/line_1.dat"), + File("dir/line_1.RIO"), + File("dir/line_1.RIO") + ]); + Assert.Equal(3, Assert.Single(catalog.Records).Files.Count); + } +} diff --git a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs index 856f2397..4071d179 100644 --- a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs +++ b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs @@ -69,11 +69,94 @@ public async Task SboEnhanced_PositiveTermination_PreservesSboWThenOperateWireSt Assert.True(result.WireSteps[1].RequestAccepted); Assert.Contains("positive CommandTermination", result.WireSteps[1].Detail, StringComparison.OrdinalIgnoreCase); + foreach (var step in result.WireSteps) + { + Assert.Contains("serviceStartedUtc=", step.Detail); + Assert.Contains("serviceMs=", step.Detail); + Assert.Contains("postResponseWaitMs=", step.Detail); + Assert.Contains("postResponseStage=", step.Detail); + Assert.Contains("configuredApplicationErrorGraceMs=", step.Detail); + Assert.Contains("timingScope=local-service-not-wire", step.Detail); + } + Assert.Equal(2, transport.Writes.Count); Assert.EndsWith("$SBOw", transport.Writes[0].Reference.Item, StringComparison.Ordinal); Assert.EndsWith("$Oper", transport.Writes[1].Reference.Item, StringComparison.Ordinal); } + [Theory] + [InlineData(0)] + [InlineData(35)] + public async Task SboEnhanced_AsyncNegativeApplicationError_QuarantinesOperate( + int rejectionDelayMs) + { + // Synthetic independently constructed MMS indication; deliberately + // arrives AFTER successful SBOw service acceptance. A faster UI must + // not bypass this asynchronous rejection or emit the Operate write. + var transport = new WireEvidenceTransport(); + Task? rejection = null; + transport.OnWrite = (reference, _) => + { + if (!reference.Item.EndsWith("$SBOw", StringComparison.Ordinal)) + return; + rejection = Task.Run(async () => + { + await Task.Delay(rejectionDelayMs); + transport.EmitReport(reference, MmsDataValue.Structure( + [ + MmsDataValue.VisibleString("LD0/CSWI1.Pos"), + MmsDataValue.Integer(1), // control-error + MmsDataValue.Integer(10) // blocked-by-interlocking + ])); + }); + }; + + var references = Iec61850ControlObjectReferences.Parse("LD0/CSWI1.Pos"); + await using var session = new Iec61850ControlObjectSession( + transport, + new Iec61850ControlObjectDescriptor + { + ObjectReference = references.ObjectReference, + Cdc = "DPC", + ControlModel = Iec61850ControlModel.SelectBeforeOperateEnhanced, + CtlValSpecification = Type("ctlVal", "bit-string", 2), + OperSpecification = CommandSpecification(), + SelectWithValueSpecification = CommandSpecification(), + CancelSpecification = CommandSpecification(), + StatusReference = "LD0/CSWI1.Pos.stVal", + StatusFunctionalConstraint = "ST", + SboTimeout = TimeSpan.FromSeconds(2), + OperTimeout = TimeSpan.FromMilliseconds(250), + SupportsCommandTermination = true, + References = references + }, + new Iec61850ControlServiceOptions + { + DefaultOperateTimeout = TimeSpan.FromMilliseconds(250), + ApplicationErrorGracePeriod = TimeSpan.FromMilliseconds(120) + }); + var result = await session.OperateAsync(new Iec61850ControlRequest + { + ControlValue = Iec61850ControlValue.Close(), + Origin = Iec61850Origin.FromText("ARG1"), + InterlockCheck = true, + AutoSelect = true, + CommandTerminationTimeout = TimeSpan.FromMilliseconds(250) + }); + if (rejection is not null) + await rejection; + + Assert.Equal(Iec61850ControlCompletionState.Rejected, result.CompletionState); + Assert.True(result.RequestAccepted); // SBOw service did respond positively + Assert.False(result.CommandTerminationReceived); + Assert.Equal("blocked-by-interlocking", result.AddCause); + Assert.Single(transport.Writes); // no Operate, even with a delayed rejection + Assert.EndsWith("$SBOw", transport.Writes[0].Reference.Item, StringComparison.Ordinal); + var wire = Assert.Single(result.WireSteps); + Assert.Contains("postResponseStage=sbo-application-error-observation", wire.Detail); + Assert.Contains("configuredApplicationErrorGraceMs=120", wire.Detail); + } + private static MmsTypeSpecificationNode CommandSpecification() => Structure(string.Empty, Type("ctlVal", "bit-string", 2), diff --git a/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs b/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs new file mode 100644 index 00000000..1b07570b --- /dev/null +++ b/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs @@ -0,0 +1,241 @@ +using AR.Iec61850.Control; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Control; + +public sealed class MultiVendorControlSemanticResolverTests +{ + [Theory] + [InlineData("BSC", "BSC", "bit-string", 2, "LD/ATCC1.TapChg.valWTr.posVal", true)] + [InlineData("DPC", "DPC", "bit-string", 2, "LD/CSWI1.Pos.stVal", true)] + [InlineData("SPC", "SPC", "boolean", 0, "LD/GGIO1.Enable.stVal", true)] + [InlineData("BSC", "DPC", "bit-string", 2, "LD/ATCC1.TapChg.stVal", false)] + [InlineData("DPC", "BSC", "bit-string", 2, "LD/CSWI1.Pos.valWTr.posVal", false)] + [InlineData("INC", "INC/ISC", "integer", 0, "LD/GGIO1.Set.stVal", true)] + [InlineData("", "INC/ISC", "integer", 0, "LD/GGIO1.Set.stVal", false)] + [InlineData("ISC", "INC/ISC", "bit-string", 2, "LD/ATCC1.Step.stVal", false)] + [InlineData("", "DPC", "bit-string", 2, "LD/CSWI1.Pos.stVal", true)] + public void ResolvesOnlyCorroboratedCommandSemantics( + string declared, string observed, string mmsType, int size, string status, bool safe) + { + var reference = status[..status.LastIndexOf('.')]; + if (status.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase)) + reference = reference[..reference.LastIndexOf('.')]; + var descriptor = NewDescriptor(reference, observed, mmsType, size, status); + var result = Iec61850ControlSemanticResolver.Resolve(descriptor, declared); + Assert.Equal(safe, result.IsCommandSafe); + Assert.False(string.IsNullOrWhiteSpace(result.Reason)); + } + + [Fact] + public void VerifiedLiveTapBscSupersedesStaleProjectedSpc_WithoutBooleanFallback() + { + var reference = "LD/ATCC1.TapChg"; + var feedback = reference + ".valWTr.posVal"; + var descriptor = NewDescriptor(reference, "BSC", "bit-string", 2, feedback); + + // Reinspect/refresh after a static DataSet projected SPC. The + // descriptor is generated from the live MMS ctlVal + step status. + var first = Iec61850ControlSemanticResolver.Resolve(descriptor, "SPC", feedback); + Assert.True(first.IsCommandSafe); + Assert.Equal("BSC", first.Cdc); + Assert.Contains("advisory", first.Reason, StringComparison.OrdinalIgnoreCase); + + // Subsequent Operate validation uses the verified BSC authority. + var repeated = Iec61850ControlSemanticResolver.Resolve(descriptor, first.Cdc, feedback); + Assert.True(repeated.IsCommandSafe); + Assert.Equal("BSC", repeated.Cdc); + } + + [Theory] + [InlineData("bit-string", 1)] + [InlineData("bit-string", 3)] + [InlineData("boolean", 0)] + public void ProjectedSpcConflictCannotAuthorizeWrongLiveCtlVal(string wireType, int width) + { + var reference = "LD/ATCC1.TapChg"; + var descriptor = NewDescriptor(reference, "BSC", wireType, width, + reference + ".valWTr.posVal"); + var resolution = Iec61850ControlSemanticResolver.Resolve(descriptor, "SPC"); + Assert.False(resolution.IsCommandSafe); + Assert.NotEqual("BSC", resolution.Cdc); + } + + [Fact] + public void ProjectedSpcConflictCannotOverrideDifferentObjectOrPersistedFeedback() + { + var reference = "LD/ATCC1.TapChg"; + var exact = reference + ".valWTr.posVal"; + var differentObject = NewDescriptor(reference, "BSC", "bit-string", 2, + "LD/ATCC2.TapChg.valWTr.posVal"); + Assert.False(Iec61850ControlSemanticResolver.Resolve(differentObject, "SPC").IsCommandSafe); + + var live = NewDescriptor(reference, "BSC", "bit-string", 2, exact); + var conflict = Iec61850ControlSemanticResolver.Resolve( + live, "SPC", "LD/ATCC2.TapChg.valWTr.posVal"); + Assert.False(conflict.IsCommandSafe); + Assert.Contains("disagrees", conflict.Reason); + } + + [Fact] + public void UnrelatedDeclaredCdcDoesNotGainBscAuthority() + { + var descriptor = NewDescriptor("LD/ATCC1.TapChg", "BSC", "bit-string", 2, + "LD/ATCC1.TapChg.valWTr.posVal"); + var result = Iec61850ControlSemanticResolver.Resolve(descriptor, "DPC"); + Assert.False(result.IsCommandSafe); + } + + [Fact] + public void DisagreedPersistedBscFeedbackIsNotCommandAuthority() + { + var descriptor = NewDescriptor("LD/ATCC1.TapChg", "BSC", "bit-string", 2, + "LD/ATCC1.TapChg.valWTr.posVal"); + var result = Iec61850ControlSemanticResolver.Resolve( + descriptor, "BSC", "LD/ATCC2.TapChg.valWTr.posVal"); + Assert.False(result.IsCommandSafe); + Assert.Contains("disagrees", result.Reason); + } + + [Fact] + public void RawTwoBitEncodingAloneCannotEstablishBscDirection() + { + var descriptor = NewDescriptor("LD/ATCC1.TapChg", "DPC", "bit-string", 2, + "LD/ATCC1.TapChg.stVal"); + Assert.False(Iec61850ControlSemanticResolver.Resolve(descriptor, "BSC").IsCommandSafe); + } + + + [Theory] + [InlineData("SG7_BCUSystem/Bkr0XCBR2.Pos")] + [InlineData("SG7_BCUSystem/Disc0XSWI1.Pos")] + [InlineData("SG7_BCUSystem/BkrCSWI4.Pos")] + [InlineData("LD/CSWI1.Pos")] + [InlineData("LD/XCBR1.Pos")] + [InlineData("LD/XSWI1.Pos")] + public void Edition2PrefixedLogicalNode_BooleanOperAndDbposFeedback_ResolvesDpc( + string reference) + { + // The DPC control value may be MMS Boolean although Pos.stVal is + // double-point. Class identity comes from . + var descriptor = NewDescriptor(reference, "SPC", "boolean", 0, + reference + ".stVal"); + descriptor = new Iec61850ControlObjectDescriptor + { + ObjectReference = descriptor.ObjectReference, + Cdc = descriptor.Cdc, + ControlModel = Iec61850ControlModel.SelectBeforeOperateEnhanced, + CtlValSpecification = descriptor.CtlValSpecification, + OperSpecification = descriptor.OperSpecification, + StatusReference = descriptor.StatusReference, + LiveStatusIsDoublePoint = true, + SelectWithValueSpecification = new MmsTypeSpecificationNode { MmsType = "structure" }, + CancelSpecification = new MmsTypeSpecificationNode { MmsType = "structure" }, + SupportsCommandTermination = true + }; + var result = Iec61850ControlSemanticResolver.Resolve(descriptor, "DPC"); + Assert.True(result.IsCommandSafe, result.Reason); + Assert.Equal("DPC", result.Cdc); + // StatusOnly and missing enhanced SBO components must still fail. + var blocked = new Iec61850ControlObjectDescriptor + { + ObjectReference = reference, Cdc = "SPC", + ControlModel = Iec61850ControlModel.SelectBeforeOperateEnhanced, + CtlValSpecification = new MmsTypeSpecificationNode { MmsType = "boolean" }, + StatusReference = reference + ".stVal", + SupportsCommandTermination = true + }; + Assert.False(Iec61850ControlSemanticResolver.Resolve(blocked, "DPC").IsCommandSafe); + } + + [Theory] + [InlineData("LD/Bkr0XCBR.Pos")] + [InlineData("LD/Bkr0XCBR2.Mode")] + [InlineData("LD/Bkr0XCBR2.PosExtra")] + [InlineData("LD/Bkr0GGIO2.Pos")] + [InlineData("LD/XCBR1Extra.Pos")] + [InlineData("LD/NotASwitch1.Pos")] + public void PositionSemantics_NotInferredWithoutExactPrefixedLnClassInstance( + string reference) + { + var descriptor = NewDescriptor(reference, "SPC", "boolean", 0, + reference + ".stVal"); + Assert.False(Iec61850ControlSemanticResolver.Resolve( + descriptor, "DPC").IsCommandSafe); + } + + [Fact] + public void DpcBooleanRejectsOtherObjectsStatus_AndWrongWireType() + { + var reference = "LD/Bkr0XCBR2.Pos"; + var otherStatus = NewDescriptor(reference, "SPC", "boolean", 0, + "LD/Bkr0XCBR3.Pos.stVal"); + Assert.False(Iec61850ControlSemanticResolver.Resolve( + otherStatus, "DPC").IsCommandSafe); + var invalidShape = NewDescriptor(reference, "SPC", "integer", 0, + reference + ".stVal"); + Assert.False(Iec61850ControlSemanticResolver.Resolve( + invalidShape, "DPC").IsCommandSafe); + } + + [Fact] + public void PrefixedSwitch_SpcDeclarationNeverGrantsUnverifiedDpc() + { + const string reference = "LD/Disc0XSWI9.Pos"; + var descriptor = NewDescriptor(reference, "SPC", "boolean", 0, + reference + ".stVal"); + var result = Iec61850ControlSemanticResolver.Resolve(descriptor, "SPC"); + Assert.True(result.IsCommandSafe); // A genuinely SPC declaration stays SPC. + Assert.Equal("SPC", result.Cdc); + Assert.False(Iec61850ControlSemanticResolver.Resolve( + descriptor, "APC").IsCommandSafe); + } + + + [Fact] + public void RenamedLogicalNode_UsesTypedStatusNotLnNameForDpc() + { + const string obj = "VendorLD/ArbitraryNode42.SwitchPoint"; + var typed = new Iec61850ControlObjectDescriptor + { + ObjectReference = obj, Cdc = "DPC", + ControlModel = Iec61850ControlModel.DirectNormal, + CtlValSpecification = new MmsTypeSpecificationNode + { Name = "ctlVal", MmsType = "boolean" }, + StatusReference = obj + ".stVal", + LiveStatusIsDoublePoint = true + }; + var correct = Iec61850ControlSemanticResolver.Resolve(typed, ""); + Assert.True(correct.IsCommandSafe, correct.Reason); + Assert.Equal("DPC", correct.Cdc); + Assert.True(Iec61850ControlSemanticResolver.Resolve(typed, "SPC").IsCommandSafe); + + var unsupported = new Iec61850ControlObjectDescriptor + { + ObjectReference = obj, Cdc = "DPC", + ControlModel = Iec61850ControlModel.DirectNormal, + CtlValSpecification = typed.CtlValSpecification, + StatusReference = obj + ".stVal", + LiveStatusIsDoublePoint = false + }; + Assert.False(Iec61850ControlSemanticResolver.Resolve(unsupported, "DPC").IsCommandSafe); + } + + private static Iec61850ControlObjectDescriptor NewDescriptor( + string reference, string cdc, string type, int size, string status) => new() + { + ObjectReference = reference, + Cdc = cdc, + ControlModel = Iec61850ControlModel.DirectNormal, + CtlValSpecification = new MmsTypeSpecificationNode + { + Name = "ctlVal", MmsType = type, Size = size <= 0 ? null : size + }, + StatusReference = status, + OperSpecification = new MmsTypeSpecificationNode + { + MmsType = "structure", + Children = new[] { new MmsTypeSpecificationNode { Name = "ctlVal", MmsType = type } } + } + }; +} diff --git a/tests/AR.Iec61850.Tests/Control/P0SboServiceWireStagesTests.cs b/tests/AR.Iec61850.Tests/Control/P0SboServiceWireStagesTests.cs new file mode 100644 index 00000000..faad0d88 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Control/P0SboServiceWireStagesTests.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Control; + +/// +/// Mechanical and data-contract guards for the opt-in client-side phase timer. +/// These tests do NOT simulate breaker operation or claim measured wire RTT. +/// +public sealed class P0SboServiceWireStagesTests +{ + [Fact] + public void MmsWriteResult_ExposesOptionalPerRequestTimingsWithoutFabricatedZero() + { + var failed = new MmsWriteResult + { + IsSuccess = false, + Message = "simulated timeout before response" + }; + Assert.Null(failed.TpktSendElapsed); + Assert.Null(failed.ConfirmedResponseWaitElapsed); + Assert.Null(failed.ResponseDecodeElapsed); + } + + [Fact] + public void ConfirmedMmsTimer_IsPerInvokeOptInNotGlobalSessionStopwatch() + { + var method = typeof(MmsClientSession).GetMethod( + "SendConfirmedPresentationPayloadAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + var parameters = method.GetParameters(); + Assert.Equal(4, parameters.Length); + Assert.Equal(typeof(int), parameters[1].ParameterType); + Assert.Equal(typeof(CancellationToken), parameters[2].ParameterType); + Assert.Equal(typeof(Action), parameters[3].ParameterType); + Assert.True(parameters[3].IsOptional); + } + + [Fact] + public void TelemetryCannotTurnOffLastApplErrorOrPositiveTermination() + { + var session = ReadRepoFile("src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs"); + var transport = ReadRepoFile("src/AR.Iec61850/Mms/MmsClientSession.cs"); + var tcp = ReadRepoFile("src/AR.Iec61850/Osi/TpktClient.cs"); + + Assert.Contains("WaitForApplicationErrorAsync(", session, StringComparison.Ordinal); + Assert.Contains("_options.ApplicationErrorGracePeriod", session, StringComparison.Ordinal); + Assert.Contains("WaitForTerminationAsync(reports, remaining, cancellationToken)", session, + StringComparison.Ordinal); + Assert.Contains("tpktSendMs=", session, StringComparison.Ordinal); + Assert.Contains("confirmedResponseWaitMs=", session, StringComparison.Ordinal); + Assert.Contains("mmsDecodeMs=", session, StringComparison.Ordinal); + Assert.Contains("RegisterConfirmedOperation(expectedInvokeId)", transport, + StringComparison.Ordinal); + Assert.Contains("new TcpClient { NoDelay = true }", tcp, StringComparison.Ordinal); + } + + private static string ReadRepoFile(string path) + { + var folder = new DirectoryInfo(AppContext.BaseDirectory); + while (folder is not null) + { + var file = Path.Combine(folder.FullName, path.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(file)) + return File.ReadAllText(file); + folder = folder.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index ead5a83d..23fd6484 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -579,6 +579,106 @@ public async Task GenericMmsWrite_BlocksControlServiceMembers() Assert.Contains("IIec61850ControlService", result.Message, StringComparison.Ordinal); } + [Fact] + public async Task CtlModelCommissioning_WritesExactCfOnce_AndVerifiesReadback() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(0) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); + var result = await new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "ZIVAVRLD1/CSWI1.Pos", + Iec61850ControlModel.StatusOnly, Iec61850ControlModel.SelectBeforeOperateNormal); + Assert.True(result.WriteAccepted); + Assert.True(result.ReadbackVerified); + var write = Assert.Single(transport.Writes); + Assert.Equal("CSWI1$CF$Pos$ctlModel", write.Reference.Item); + Assert.Equal(MmsDataKind.Integer, write.Value.Kind); + Assert.Equal(2L, write.Value.Value); + Assert.Equal(2, transport.Reads.Count); + Assert.DoesNotContain(transport.Writes, x => x.Reference.Item.Contains("$CO$", StringComparison.Ordinal)); + } + + [Fact] + public async Task CtlModelCommissioning_StaleExpectedValueNeverWrites() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(3) }); + var result = await new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "ZIVAVRLD1/CSWI2.Pos", + Iec61850ControlModel.StatusOnly, Iec61850ControlModel.SelectBeforeOperateEnhanced); + Assert.False(result.WriteAccepted); + Assert.False(result.ReadbackVerified); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task CtlModelCommissioning_AllowExplicitStatusOnlyZero_WithReadback() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(0) }); + var result = await new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "ZIVAVRLD1/CSWI1.Pos", + Iec61850ControlModel.SelectBeforeOperateNormal, + Iec61850ControlModel.StatusOnly); + Assert.True(result.WriteAccepted); + Assert.True(result.ReadbackVerified); + var write = Assert.Single(transport.Writes); + Assert.Equal("CSWI1$CF$Pos$ctlModel", write.Reference.Item); + Assert.Equal(0L, write.Value.Value); + } + + [Fact] + public async Task CtlModelCommissioning_DoesNotWriteUnknownOrNoChange() + { + var transport = new FakeControlTransport(); + await Assert.ThrowsAsync(() => + new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "LD0/CSWI1.Pos", + Iec61850ControlModel.StatusOnly, Iec61850ControlModel.Unknown)); + Assert.Empty(transport.Reads); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); + var result = await new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "LD0/CSWI1.Pos", + Iec61850ControlModel.SelectBeforeOperateNormal, + Iec61850ControlModel.SelectBeforeOperateNormal); + Assert.False(result.WriteAccepted); + Assert.True(result.ReadbackVerified); + Assert.Empty(transport.Writes); + } + + [Theory] + [InlineData(0L, Iec61850ControlModel.StatusOnly)] + [InlineData(3L, Iec61850ControlModel.DirectEnhanced)] + [InlineData(4L, Iec61850ControlModel.SelectBeforeOperateEnhanced)] + public async Task ReadOnlyCtlModelProbe_DecodesExactReference_NoCommandWrites( + long raw, Iec61850ControlModel expected) + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(raw) }); + var probe = await new Iec61850ControlService().ProbeControlModelCoreAsync( + transport, "ZIVAVRLD1/ATCC1.TapChg"); + Assert.True(probe.ReadSucceeded); + Assert.True(probe.Decoded); + Assert.Equal(expected, probe.ControlModel); + Assert.Equal("ATCC1$CF$TapChg$ctlModel", Assert.Single(transport.Reads).Item); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task ReadOnlyCtlModelProbe_FailedReadCannotMasqueradeAsStatusOnly() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = false, Message = "object-non-existent" }); + var probe = await new Iec61850ControlService().ProbeControlModelCoreAsync( + transport, "ZIVAVRLD1/CSWI1.Pos"); + Assert.False(probe.ReadSucceeded); + Assert.False(probe.Decoded); + Assert.Equal(Iec61850ControlModel.Unknown, probe.ControlModel); + Assert.Contains("object-non-existent", probe.Detail); + Assert.Empty(transport.Writes); + } + [Fact] public async Task DescriptorDiscovery_UsesLiveCtlModelAndExactCtlValType() { @@ -600,7 +700,223 @@ public async Task DescriptorDiscovery_UsesLiveCtlModelAndExactCtlValType() } [Fact] - public async Task DescriptorDiscovery_RejectsSboObjectWithoutCancelService() + public async Task DescriptorDiscovery_RecognizesBscStepControl_FromExactLiveValWTrPosition() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(4) }); + transport.Specifications["ATCC1$CO$TapChg$Oper"] = CommandSpecification(); + transport.Specifications["ATCC1$CO$TapChg$SBOw"] = CommandSpecification(); + transport.Specifications["ATCC1$CO$TapChg$Cancel"] = CommandSpecification(); + transport.DomainVariables["LD0"] = new[] + { + "ATCC1$ST$TapChg$valWTr$posVal", + "ATCC1$ST$TapChg$q" + }; + var service = new Iec61850ControlService(); + + await using var session = await service.OpenCoreAsync(transport, "LD0/ATCC1.TapChg"); + + Assert.Equal("BSC", session.Descriptor.Cdc); + Assert.Equal(Iec61850ControlModel.SelectBeforeOperateEnhanced, session.Descriptor.ControlModel); + Assert.Equal("LD0/ATCC1.TapChg.valWTr.posVal", session.Descriptor.StatusReference); + Assert.Equal("ST", session.Descriptor.StatusFunctionalConstraint); + Assert.Equal("bit-string", session.Descriptor.CtlValSpecification.MmsType); + } + + [Fact] + public async Task TruncatedDomainNameList_StandardPosVerifiedByTargetedTypedRead() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.BitString(6, new byte[] { 0x80 }) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecificationWithBooleanCtlVal(); + transport.Specifications["CSWI1$CO$Pos$Cancel"] = CommandSpecificationWithBooleanCtlVal(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; // 6400-item NameList cap omitted CSWI + await using var control = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/CSWI1.Pos"); + Assert.Equal("LD0/CSWI1.Pos.stVal", control.Descriptor.StatusReference); + Assert.Equal("ST", control.Descriptor.StatusFunctionalConstraint); + Assert.True(control.Descriptor.IsOperationallyReady); + Assert.Equal("DPC", control.Descriptor.Cdc); // typed two-bit stVal proves DPC despite Boolean Oper + Assert.True(control.Descriptor.LiveStatusIsDoublePoint); + Assert.True(Iec61850ControlSemanticResolver.Resolve(control.Descriptor, "DPC").IsCommandSafe); + Assert.Contains(transport.Reads, x => x.Item == "CSWI1$ST$Pos$stVal"); + Assert.Single(transport.Reads, x => x.Item.Contains("$ST$Pos$", StringComparison.Ordinal)); + Assert.Empty(transport.Writes); + } + + [Theory] + [InlineData(7, 0x80)] + [InlineData(5, 0x80)] + public async Task TruncatedDomainNameList_InvalidPosBitWidthMustNotAuthorizeCommand(byte unusedBits, byte payload) + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.BitString(unusedBits, new byte[] { payload }) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecificationWithBooleanCtlVal(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + await using var control = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/CSWI1.Pos"); + Assert.Equal("", control.Descriptor.StatusReference); + Assert.False(Iec61850ControlSemanticResolver.Resolve(control.Descriptor, "DPC").IsCommandSafe); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task TruncatedDomainNameList_UnreadablePosMustNotAuthorizeCommand() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecificationWithBooleanCtlVal(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + await using var control = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/CSWI1.Pos"); + Assert.Equal("", control.Descriptor.StatusReference); + Assert.False(Iec61850ControlSemanticResolver.Resolve(control.Descriptor, "DPC").IsCommandSafe); + Assert.Empty(transport.Writes); + } + + [Theory] + [InlineData("Bkr0XCBR2", "Pos")] + [InlineData("AnyVendorLN1", "SomeCtrl")] + [InlineData("CustomGGIO2", "RemoteSwitch")] + public async Task TruncatedDomainNameList_BooleanOperHasOneExactReadOnlyStValProbe( + string logicalNode, string dataObject) + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.Integer(1) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.BitString(6, new byte[] { 0x40 }) }); + transport.Specifications[$"{logicalNode}$CO${dataObject}$Oper"] = CommandSpecificationWithBooleanCtlVal(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + var reference = $"LD0/{logicalNode}.{dataObject}"; + + await using var session = await new Iec61850ControlService().OpenCoreAsync( + transport, reference); + + Assert.Equal(reference + ".stVal", session.Descriptor.StatusReference); + Assert.True(session.Descriptor.LiveStatusIsDoublePoint); + Assert.Equal("DPC", session.Descriptor.Cdc); + Assert.True(Iec61850ControlSemanticResolver.Resolve( + session.Descriptor, "DPC").IsCommandSafe); + Assert.Single(transport.Reads, x => x.FunctionalConstraint == "ST"); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task TruncatedDomainNameList_UnknownBooleanStatusCannotMasqueradeAsDpc() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.Integer(1) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.Boolean(true) }); + transport.Specifications["VendorLN1$CO$RemoteSwitch$Oper"] = CommandSpecificationWithBooleanCtlVal(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + await using var session = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/VendorLN1.RemoteSwitch"); + Assert.False(session.Descriptor.LiveStatusIsDoublePoint); + Assert.Equal("SPC", session.Descriptor.Cdc); + Assert.False(Iec61850ControlSemanticResolver.Resolve(session.Descriptor, "DPC").IsCommandSafe); + Assert.Single(transport.Reads, x => x.FunctionalConstraint == "ST"); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task TruncatedDomainNameList_UnlistedTapChgBscHasExactTypedStepProof() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.Integer(4) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.Integer(12) }); + transport.Specifications["UnusualLN2$CO$TapChg$Oper"] = CommandSpecification(); + transport.Specifications["UnusualLN2$CO$TapChg$SBOw"] = CommandSpecification(); + transport.Specifications["UnusualLN2$CO$TapChg$Cancel"] = CommandSpecification(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + + await using var session = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/UnusualLN2.TapChg"); + Assert.Equal("LD0/UnusualLN2.TapChg.valWTr.posVal", session.Descriptor.StatusReference); + Assert.Equal("BSC", session.Descriptor.Cdc); + Assert.True(Iec61850ControlSemanticResolver.Resolve( + session.Descriptor, "BSC").IsCommandSafe); + Assert.Single(transport.Reads, r => + r.Item == "UnusualLN2$ST$TapChg$valWTr$posVal"); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task TruncatedDomainNameList_UnlistedTapChgBadStatusNeverGrantsBsc() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.Integer(1) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.Boolean(true) }); + transport.Specifications["ArbitraryLN3$CO$TapChg$Oper"] = CommandSpecification(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + + await using var session = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/ArbitraryLN3.TapChg"); + Assert.False(Iec61850ControlSemanticResolver.Resolve( + session.Descriptor, "BSC").IsCommandSafe); + Assert.Empty(transport.Writes); + Assert.InRange(transport.Reads.Count(r => r.FunctionalConstraint == "ST"), 1, 2); + } + + [Fact] + public async Task TruncatedDomainNameList_NonPositionNonBooleanNoBlindScalarProbe() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + transport.Specifications["GGIO1$CO$SomeCtrl$Oper"] = + Structure(string.Empty, Type("ctlVal", "octet-string", size: 64)); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + await using var control = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/GGIO1.SomeCtrl"); + Assert.DoesNotContain(transport.Reads, x => x.FunctionalConstraint == "ST"); + Assert.Equal("", control.Descriptor.StatusReference); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task StructuredStatusInventory_VerifiesScalarViaLiveRead() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + // CSWI.Pos requires exactly one typed ST.stVal read, not a + // speculative valWTr probe first. + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.BitString(6, new byte[] { 0x40 }) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecification(); + transport.DomainVariables["LD0"] = new[] { "CSWI1$ST$Pos" }; + await using var session = await new Iec61850ControlService().OpenCoreAsync(transport, "LD0/CSWI1.Pos"); + Assert.Equal("LD0/CSWI1.Pos.stVal", session.Descriptor.StatusReference); + Assert.Equal("ST", session.Descriptor.StatusFunctionalConstraint); + Assert.True(session.Descriptor.IsOperationallyReady); + Assert.Contains(transport.Reads, x => x.Item == "CSWI1$ST$Pos$stVal"); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task StructuredStatusInventory_UnreadableStatusNeverAuthorizesCommand() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecification(); + transport.DomainVariables["LD0"] = new[] { "CSWI1$ST$Pos" }; + await using var session = await new Iec61850ControlService().OpenCoreAsync(transport, "LD0/CSWI1.Pos"); + Assert.Equal("", session.Descriptor.StatusReference); + Assert.False(Iec61850ControlSemanticResolver.Resolve(session.Descriptor, "DPC").IsCommandSafe); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task DescriptorDiscovery_AcceptsSboNormalWithoutCancelService() { var transport = new FakeControlTransport(); transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); @@ -608,10 +924,10 @@ public async Task DescriptorDiscovery_RejectsSboObjectWithoutCancelService() transport.DomainVariables["LD0"] = new[] { "CSWI1$ST$Pos$stVal" }; var service = new Iec61850ControlService(); - var error = await Assert.ThrowsAsync(() => - service.OpenCoreAsync(transport, "LD0/CSWI1.Pos")); - - Assert.Contains("incomplete", error.Message, StringComparison.OrdinalIgnoreCase); + await using var session = await service.OpenCoreAsync(transport, "LD0/CSWI1.Pos"); + Assert.Equal(Iec61850ControlModel.SelectBeforeOperateNormal, session.Descriptor.ControlModel); + Assert.True(session.Descriptor.IsOperationallyReady); + Assert.Null(session.Descriptor.CancelSpecification); } private static Iec61850ControlObjectSession NewSession( @@ -657,6 +973,15 @@ private static Iec61850ControlRequest Request(Iec61850ControlValue value, byte? InterlockCheck = true }; + private static MmsTypeSpecificationNode CommandSpecificationWithBooleanCtlVal() + => Structure(string.Empty, + Type("ctlVal", "boolean"), + Structure("origin", Type("orCat", "integer"), Type("orIdent", "octet-string", size: 64)), + Type("ctlNum", "unsigned"), + Type("T", "utc-time"), + Type("Test", "boolean"), + Type("Check", "bit-string", size: 2)); + private static MmsTypeSpecificationNode CommandSpecification() => Structure(string.Empty, Type("ctlVal", "bit-string", size: 2), diff --git a/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs b/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs new file mode 100644 index 00000000..139190fc --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs @@ -0,0 +1,109 @@ +using AR.Iec61850.Discovery; +using System.Xml.Linq; + +namespace AR.Iec61850.Tests.Discovery; + +/// +/// Independently constructed IEC 61850 domain/SCL topology; no device capture or external-client fixture. +/// +public sealed class GlobalIdentityEvidenceRegressionTests +{ + private static readonly string[] Domains = + [ + "SIEBCUCTRL", "SIEBCUMEAS", "SIEBCUPROT", "SIEBCUUD1", "SIEBCUUD2" + ]; + + [Fact] + public void MultipleDomains_NeverStripAnUnprovenSuffixFromCommonIdentity() + { + var inferred = LiveIedIdentityResolver.Resolve(Domains, "192.0.2.20"); + Assert.Equal("SIEBCU", inferred.IedName); + Assert.Equal("MmsDomainCommonPrefix", inferred.Source); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, inferred.Confidence); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.Exact, inferred.Confidence); + Assert.Equal("CTRL", inferred.LogicalDeviceAliases["SIEBCUCTRL"]); + Assert.Equal("UD2", inferred.LogicalDeviceAliases["SIEBCUUD2"]); + Assert.Equal(Domains.Length, inferred.LogicalDeviceAliases.Count); + } + + [Fact] + public void DomainSuffixHeuristic_MustNotClaimVerifiedWireIdentity() + { + var inferred = LiveIedIdentityResolver.Resolve( + ["LABCTRLLD0", "LABCTRLPROT", "LABCTRLMEAS"], "192.0.2.20"); + Assert.Equal("LABCTRL", inferred.IedName); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, inferred.Confidence); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.Exact, inferred.Confidence); + Assert.Equal("MmsDomainKnownLogicalDeviceSuffix", inferred.Source); + } + + [Fact] + public void OperatorDisplayAlias_IsNotWireVerified() + { + var inferred = LiveIedIdentityResolver.Resolve(Domains, "192.0.2.20", + explicitIedName: "OPERATOR_LABEL"); + Assert.Equal("OPERATOR_LABEL", inferred.IedName); + Assert.Equal("ExplicitOverride", inferred.Source); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, inferred.Confidence); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.Exact, inferred.Confidence); + } + + [Fact] + public void UniqueSclTopology_PromotesFullName_AndWrongEndpointOrApFailsClosed() + { + var verified = TrustedSclIedIdentityMatcher.TryMatch( + SyntheticScl(), Domains, "192.0.2.20", "SIEBCU"); + Assert.NotNull(verified); + Assert.Equal("SIEBCU", verified.IedName); + Assert.Equal("TrustedSclExactDomainMatch", verified.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, verified.Confidence); + Assert.Equal("UD1", verified.LogicalDeviceAliases["SIEBCUUD1"]); + + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + SyntheticScl(), Domains, "192.0.2.21", "SIEBCU")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + SyntheticScl(), Domains[..4], "192.0.2.20", "SIEBCU")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + SyntheticScl(), Domains, "192.0.2.20", "ANOTHER")); + + var ambiguous = SyntheticScl(); + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + var ied = Assert.Single(ambiguous.Descendants(ns + "IED")); + var duplicate = new XElement(Assert.Single(ied.Elements(ns + "AccessPoint"))); + duplicate.SetAttributeValue("name", "P2"); + ied.Add(duplicate); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + ambiguous, Domains, "192.0.2.20", "SIEBCU")); + } + + [Fact] + public void SclWithoutEndpointEvidence_CannotPromoteToVerifiedIdentity() + { + var scl = SyntheticScl(); + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + scl.Root!.Element(ns + "Communication")!.Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl, Domains, "192.0.2.20", "SIEBCU")); + } + + private static XDocument SyntheticScl() + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + string[] instances = ["CTRL", "MEAS", "PROT", "UD1", "UD2"]; + return new XDocument(new XElement(ns + "SCL", + new XElement(ns + "Communication", + new XElement(ns + "SubNetwork", + new XElement(ns + "ConnectedAP", + new XAttribute("iedName", "SIEBCU"), + new XAttribute("apName", "P1"), + new XElement(ns + "Address", + new XElement(ns + "P", + new XAttribute("type", "IP"), "192.0.2.20"))))), + new XElement(ns + "IED", new XAttribute("name", "SIEBCU"), + new XElement(ns + "AccessPoint", + new XAttribute("name", "P1"), + new XElement(ns + "Server", + instances.Select(inst => new XElement(ns + "LDevice", + new XAttribute("inst", inst)))))))); + } +} diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedDataSetTypeProbePlannerTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedDataSetTypeProbePlannerTests.cs new file mode 100644 index 00000000..00be4666 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedDataSetTypeProbePlannerTests.cs @@ -0,0 +1,91 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Discovery; + +public sealed class LiveIedDataSetTypeProbePlannerTests +{ + [Fact] + public void TruncatedNameList_StillAdmitsVerifiedStaticFeedbackRoots() + { + var discovery = Sample(); + Assert.Equal(1, discovery.IedDirectory.LogicalNodeCount); + var hints = LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints(discovery); + + Assert.Contains(hints, x => x.LogicalNode == "eveGGIO1" && x.MmsItemName == "eveGGIO1$ST$Ind11"); + Assert.Contains(hints, x => x.LogicalNode == "CSWI16" && x.MmsItemName == "CSWI16$ST$Pos"); + Assert.Contains(hints, x => x.LogicalNode == "MMXU1" && x.MmsItemName == "MMXU1$MX$A"); + Assert.DoesNotContain(hints, x => x.LogicalNode is "UNTRUSTED" or "BADFC" or "BADLN"); + + // The MMXU membership was already enumerated and must not be duplicated. + Assert.Equal(2, discovery.IedDirectory.AddSupplementalPoints(hints)); + Assert.Equal(0, discovery.IedDirectory.AddSupplementalPoints(hints)); + var roots = LiveIedVariableTypeProbePlanner.BuildLogicalNodeRootCandidates(discovery.IedDirectory); + Assert.Equal(3, roots.Count); + Assert.Contains(roots, root => root.Item == "CSWI16"); + Assert.Contains(roots, root => root.Item == "eveGGIO1"); + } + + [Fact] + public void Budget_BoundsMissingRoots_AndDeduplicatesExactMemberships() + { + var discovery = Sample(); + var hints = LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints( + discovery, maxExtraLogicalNodes: 1, maxMemberHints: 16); + + Assert.Contains(hints, x => x.LogicalNode == "MMXU1"); + Assert.Contains(hints, x => x.LogicalNode == "CSWI16"); + Assert.DoesNotContain(hints, x => x.LogicalNode == "eveGGIO1"); + Assert.Equal(hints.Count, hints.Select(x => x.MmsReference) + .Distinct(StringComparer.Ordinal).Count()); + Assert.Empty(LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints( + discovery, maxExtraLogicalNodes: 0, maxMemberHints: 0)); + } + + private static MmsDiscoveryResult Sample() + { + var snapshot = new MmsDiscoverySnapshot + { + DomainVariables = new Dictionary> + { + ["IEDLD0"] = ["MMXU1$MX$A"] + } + }; + return new MmsDiscoveryResult + { + Snapshot = snapshot, + IedDirectory = MmsIedModelDirectoryBuilder.Build(snapshot), + DataSetDirectories = + [ + new MmsDataSetDirectoryResult + { + IsSuccess = true, + DataSetReference = "IEDLD0/LLN0.Analog", + Members = + [ + Member("MMXU1$MX$A", "MMXU1", "A", "MX"), + Member("CSWI16$ST$Pos", "CSWI16", "Pos", "ST"), + Member("CSWI16$ST$Pos", "CSWI16", "Pos", "ST"), + Member("eveGGIO1$ST$Ind11", "eveGGIO1", "Ind11", "ST"), + Member("UNTRUSTED$ST$Pos", "UNTRUSTED", "Pos", "ST", "OTHER"), + Member("BADFC$MX$A", "BADFC", "A", "ST") + ] + }, + new MmsDataSetDirectoryResult + { + IsSuccess = false, + DataSetReference = "IEDLD0/LLN0.Bad", + Members = [Member("BADLN$ST$Pos", "BADLN", "Pos", "ST")] + } + ] + }; + } + + private static MmsDataSetDirectoryMember Member(string name, string ln, string path, + string fc, string domain = "IEDLD0") + => new() + { + Domain = domain, MmsItemName = name, LogicalNode = ln, + DataObjectPath = path, FunctionalConstraint = fc, Confidence = 100 + }; +} diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 308799b6..849c6049 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -6,6 +6,49 @@ namespace AR.Iec61850.Tests.Discovery; public sealed class LiveIedIdentityResolverTests { + + [Fact] + public void Resolve_Five_LogicalDevices_Preserves_Full_Common_Ied_Name_Without_Scl() + { + var domains = new[] { "SIEBCUCTRL", "SIEBCUMEAS", "SIEBCUPROT", "SIEBCUUD1", "SIEBCUUD2" }; + var identity = LiveIedIdentityResolver.Resolve(domains, "192.0.2.20"); + + Assert.Equal("SIEBCU", identity.IedName); + Assert.Equal("MmsDomainCommonPrefix", identity.Source); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.Exact, identity.Confidence); + Assert.Equal("CTRL", identity.LogicalDeviceAliases["SIEBCUCTRL"]); + Assert.Equal("UD2", identity.LogicalDeviceAliases["SIEBCUUD2"]); + } + + [Theory] + [InlineData("BCU")] + [InlineData("PROT")] + [InlineData("CTRL")] + [InlineData("MEAS")] + [InlineData("MU")] + public void Resolve_Common_Prefix_Does_Not_Remove_A_Valid_Ied_Name_Ending(string ending) + { + var fullName = "STATION" + ending; + var identity = LiveIedIdentityResolver.Resolve( + [fullName + "Application", fullName + "Reports", fullName + "Custom1"], + "192.0.2.21"); + + Assert.Equal(fullName, identity.IedName); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + } + + + [Fact] + public void Mms_Domain_Names_Are_Case_Sensitive_And_Never_Coalesced() + { + var identity = LiveIedIdentityResolver.Resolve( + ["SIEBCUCTRL", "siebcuctrl", "SIEBCUMEAS"], "192.0.2.61"); + Assert.Equal(3, identity.LogicalDeviceAliases.Count); + Assert.Contains("SIEBCUCTRL", identity.LogicalDeviceAliases.Keys); + Assert.Contains("siebcuctrl", identity.LogicalDeviceAliases.Keys); + } + [Fact] public void Resolve_Preserves_Digits_In_A_Single_Ld0_Domain() { @@ -25,7 +68,7 @@ public void Resolve_Uses_All_Domains_To_Confirm_The_Ied_Name() "192.0.2.10"); Assert.Equal("OLSF501", identity.IedName); - Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); Assert.False(identity.IsAmbiguous); Assert.Equal("PROT", identity.LogicalDeviceAliases["OLSF501PROT"]); } @@ -45,7 +88,7 @@ public void Resolve_Does_Not_Promote_A_Nested_Mu_Domain_To_The_Physical_Ied_Name Assert.Equal("OCR7SJ8", identity.IedName); Assert.Equal("MmsDomainCommonPrefix", identity.Source); - Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); Assert.Equal("Mod2_MU1", identity.LogicalDeviceAliases["OCR7SJ8Mod2_MU1"]); Assert.DoesNotContain("OCR7SJ8Mod2", identity.CandidateNames); } @@ -62,6 +105,40 @@ public void Resolve_Rejects_Conflicting_Domain_Candidates() Assert.Equal(["ALPHA", "BETA"], identity.CandidateNames); } + + + [Fact] + public void Resolve_Preserves_Case_Distinct_Mms_Domains_And_Alias_Keys() + { + var domains = new[] { "STATIONBCUCTRL", "stationbcuctrl" }; + var identity = LiveIedIdentityResolver.Resolve(domains, "192.0.2.42"); + Assert.Equal(2, identity.LogicalDeviceAliases.Count); + Assert.Equal("CTRL", identity.LogicalDeviceAliases["STATIONBCUCTRL"]); + Assert.Equal("ctrl", identity.LogicalDeviceAliases["stationbcuctrl"]); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + } + + [Fact] + public void Resolve_Fully_Known_Ld_Suffixes_Is_Still_Provisional_Without_Scl() + { + var identity = LiveIedIdentityResolver.Resolve( + ["STATIONBCUCTRL", "STATIONBCUPROT", "STATIONBCUMEAS"], + "192.0.2.20"); + Assert.Equal("STATIONBCU", identity.IedName); + Assert.Equal("MmsDomainKnownLogicalDeviceSuffix", identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + } + + [Fact] + public void Resolve_Explicit_Alias_Is_Operator_Supplied_Not_Wire_Verified() + { + var identity = LiveIedIdentityResolver.Resolve( + ["WIRELD0"], "192.0.2.20", explicitIedName: "OPERATOR_ALIAS"); + Assert.Equal("OPERATOR_ALIAS", identity.IedName); + Assert.Equal("ExplicitOverride", identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + } + [Fact] public void Resolve_Uses_Explicit_Name_Over_Live_Heuristic() { @@ -69,7 +146,18 @@ public void Resolve_Uses_Explicit_Name_Over_Live_Heuristic() Assert.Equal("COMMISSIONED_IED", identity.IedName); Assert.Equal("ExplicitOverride", identity.Source); - Assert.Equal(LiveIedDiscoveryConfidenceLevel.Exact, identity.Confidence); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + Assert.Equal("ExplicitOverride", identity.Source); + } + + [Fact] + public void Heuristic_Suffix_Is_Never_A_Verified_Identity_Even_With_Five_Lds() + { + var identity = LiveIedIdentityResolver.Resolve( + ["PLANTCTRL", "PLANTMEAS", "PLANTPROT", "PLANTLD0", "PLANTMU1"], "192.0.2.90"); + Assert.Equal("PLANT", identity.IedName); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + Assert.DoesNotContain(identity.CandidateNames, name => name.Equals("PLANTCTRL", StringComparison.Ordinal)); } [Fact] @@ -81,7 +169,7 @@ public void Binding_Identity_Resolver_Uses_The_Live_Domain_Resolver() Assert.Equal("OLSF501", identity.DisplayName); Assert.Equal("MmsDomainKnownLogicalDeviceSuffix", identity.Source); - Assert.Equal("High", identity.Confidence); + Assert.Equal("Medium", identity.Confidence); Assert.Equal("LD1", identity.LogicalDeviceAliases["OLSF501LD1"]); } diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs new file mode 100644 index 00000000..6de86a25 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -0,0 +1,264 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; +using System.Xml.Linq; + +namespace AR.Iec61850.Tests.Discovery; + +public sealed class TrustedSclIedIdentityMatcherTests +{ + private const string SclNs = "http://www.iec.ch/61850/2003/SCL"; + + + [Fact] + public void Five_Ld_Scl_Uniquely_Verifies_Full_Ied_Name_Not_A_Stripped_Prefix() + { + XNamespace ns = SclNs; + var scl = Design("SIEBCU", "CTRL", "192.0.2.55"); + var server = Assert.Single(scl.Descendants(ns + "Server")); + foreach (var inst in new[] { "MEAS", "PROT", "UD1", "UD2" }) + server.Add(new XElement(ns + "LDevice", new XAttribute("inst", inst))); + + var domains = new[] { "SIEBCUCTRL", "SIEBCUMEAS", "SIEBCUPROT", "SIEBCUUD1", "SIEBCUUD2" }; + var identity = TrustedSclIedIdentityMatcher.TryMatch(scl, domains, "192.0.2.55", "SIEBCU"); + + Assert.NotNull(identity); + Assert.Equal("SIEBCU", identity.IedName); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal("UD2", identity.LogicalDeviceAliases["SIEBCUUD2"]); + Assert.Equal(5, identity.LogicalDeviceAliases.Count); + } + + [Fact] + public void Engineering_Topology_Without_Unique_Confirmed_Endpoint_Cannot_Promote_Identity() + { + XNamespace ns = SclNs; + var scl = Design("SIEBCU", "CTRL", "192.0.2.55"); + scl.Root!.Element(ns + "Communication")!.Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl, ["SIEBCUCTRL"], "192.0.2.55")); + + var validScl = Design("SIEBCU", "CTRL", "192.0.2.55"); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(validScl, ["SIEBCUCTRL"], null)); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(validScl, ["SIEBCUCTRL"], "not-an-ip")); + } + + [Fact] + public void GE_F650_Single_Domain_Uses_SCL_Evidence_Not_Vendor_Suffix_Heuristic() + { + var inferred = LiveIedIdentityResolver.Resolve(["BCUGEF650"], "192.16.1.33"); + Assert.Equal("HostFallback", inferred.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Low,inferred.Confidence); + + var identity = TrustedSclIedIdentityMatcher.TryMatch( + Design("BCUGE", "F650", "192.16.1.33"), ["BCUGEF650"], "192.16.1.33"); + Assert.NotNull(identity); + Assert.Equal("BCUGE",identity.IedName); + Assert.Equal("F650",identity.LogicalDeviceAliases["BCUGEF650"]); + Assert.Equal("TrustedSclExactDomainMatch",identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High,identity.Confidence); + } + + [Fact] + public void Verified_F650_Export_RoundTrips_Mms_Domain_And_Fcda_LdInst() + { + var model = Model("IED_192_16_1_33","BCUGEF650"); + var identity = TrustedSclIedIdentityMatcher.TryMatch( + Design("BCUGE","F650","192.16.1.33"),["BCUGEF650"],"192.16.1.33"); + Assert.NotNull(identity); + + var generated = LiveIedSclExporter.BuildDocument(model, + new LiveIedSclExportOptions { SchemaProfile = SclSchemaProfile.Edition1V16 }); + var normalized = AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity(generated,model,identity); + XNamespace ns=SclNs; + var ied=Assert.Single(normalized.Root!.Elements(ns+"IED")); + Assert.Equal("BCUGE",(string?)ied.Attribute("name")); + var device=Assert.Single(ied.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)device.Attribute("inst")); + Assert.Null(device.Attribute("ldName")); // implicit MMS domain BCUGEF650 + Assert.Equal("BCUGE",(string?)Assert.Single(normalized.Descendants(ns+"ConnectedAP")).Attribute("iedName")); + var fcda=Assert.Single(normalized.Descendants(ns+"FCDA")); + Assert.Equal("F650",(string?)fcda.Attribute("ldInst")); + } + + [Fact] + public void Explicit_LdName_Remains_Exact_When_Different_From_Implicit_Mms_Domain() + { + var scl=Design("BCUGE","F650","192.16.1.33","CUSTOM_WIRE_DOMAIN"); + var identity=TrustedSclIedIdentityMatcher.TryMatch(scl, + ["CUSTOM_WIRE_DOMAIN"],"192.16.1.33"); + Assert.NotNull(identity); + var model=Model("IED_192_16_1_33","CUSTOM_WIRE_DOMAIN"); + var doc=AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity( + LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()),model,identity); + XNamespace ns=SclNs; + var ld=Assert.Single(doc.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)ld.Attribute("inst")); + Assert.Equal("CUSTOM_WIRE_DOMAIN",(string?)ld.Attribute("ldName")); + Assert.Equal("F650",(string?)Assert.Single(doc.Descendants(ns+"FCDA")).Attribute("ldInst")); + } + + [Fact] + public void Different_Host_Or_Missing_Domain_Or_Wrong_Selected_IED_Fails_Closed() + { + var scl=Design("BCUGE","F650","192.16.1.33"); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.34")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650","BCUGECTRL"],"192.16.1.33")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33","OTHER_IED")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["DIFFERENT"],"192.16.1.33")); + } + + [Fact] + public void Ambiguous_Duplicate_AccessPoints_And_Conflicting_Sources_Are_Not_Promoted() + { + XNamespace ns=SclNs; + var scl=Design("BCUGE","F650","192.16.1.33"); + var ied=Assert.Single(scl.Descendants(ns+"IED")); + var ap=Assert.Single(ied.Elements(ns+"AccessPoint")); + var duplicate=new XElement(ap); + duplicate.SetAttributeValue("name","S2"); + ied.Add(duplicate); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33")); + + var model=Model("IED_192_16_1_33","BCUGEF650"); + var unverified=LiveIedIdentityResolver.Resolve(["BCUGEF650"],"192.16.1.33"); + Assert.Throws(() => + AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity( + LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()), + model,unverified)); + } + + + [Fact] + public void Five_Domain_Bcu_Scl_Promotes_Exact_Identity_With_Logical_Device_Aliases() + { + var scl=Design("SIEBCU","CTRL","192.0.2.20"); + XNamespace ns=SclNs; + var server=Assert.Single(scl.Descendants(ns+"Server")); + foreach (var suffix in new[] {"MEAS","PROT","UD1","UD2"}) + server.Add(new XElement(ns+"LDevice",new XAttribute("inst",suffix), + new XElement(ns+"LN0",new XAttribute("lnType","LLN0_type")))); + var domains=new[] {"SIEBCUCTRL","SIEBCUMEAS","SIEBCUPROT","SIEBCUUD1","SIEBCUUD2"}; + var verified=TrustedSclIedIdentityMatcher.TryMatch( + scl,domains,"192.0.2.20",selectedIedName:"SIEBCU",selectedAccessPointName:"S1"); + Assert.NotNull(verified); + Assert.Equal("SIEBCU",verified.IedName); + Assert.Equal("TrustedSclExactDomainMatch",verified.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High,verified.Confidence); + Assert.Equal("UD2",verified.LogicalDeviceAliases["SIEBCUUD2"]); + Assert.Equal(5,verified.LogicalDeviceAliases.Count); + } + + [Fact] + public void Scl_Endpoint_Address_And_AccessPoint_Are_Mandatory_For_Verified_Identity() + { + XNamespace ns=SclNs; + var scl=Design("STATIONBCU","CTRL","192.0.2.30"); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,["STATIONBCUCTRL"],"192.0.2.30",selectedAccessPointName:"S2")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,["STATIONBCUCTRL"],null)); + var noIp=new XDocument(scl); + Assert.Single(noIp.Descendants(ns+"P")).Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + noIp,["STATIONBCUCTRL"],"192.0.2.30")); + var noConnectedAp=new XDocument(scl); + Assert.Single(noConnectedAp.Descendants(ns+"ConnectedAP")).Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + noConnectedAp,["STATIONBCUCTRL"],"192.0.2.30")); + var wrongDomainCase=new[] {"stationBCUCTRL"}; + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,wrongDomainCase,"192.0.2.30")); + } + + + [Fact] + public void Selected_Ap_Name_Alone_Does_Not_Disambiguate_Identical_Unbound_Topology() + { + XNamespace ns=SclNs; + var scl=Design("STATIONBCU","CTRL","192.0.2.30"); + var ied=Assert.Single(scl.Descendants(ns+"IED")); + var ap=Assert.Single(ied.Elements(ns+"AccessPoint")); + var copy=new XElement(ap); + copy.SetAttributeValue("name","S2"); + ied.Add(copy); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,["STATIONBCUCTRL"],"192.0.2.30", + selectedIedName:"STATIONBCU",selectedAccessPointName:"S1")); + } + + [Fact] + public void Other_Devices_And_Already_Recognized_Multiple_Domains_Are_Unchanged() + { + var old=LiveIedIdentityResolver.Resolve( + ["OLSF501LD0","OLSF501CTRL"],"192.0.2.10"); + Assert.Equal("OLSF501",old.IedName); + var other=LiveIedIdentityResolver.Resolve( + ["OCR7SJ8Application","OCR7SJ8CB1","OCR7SJ8Mod2_MU1"], + "192.0.2.12"); + Assert.Equal("OCR7SJ8",other.IedName); + } + + private static XDocument Design(string ied,string inst,string ip,string? ldName=null) + { + XNamespace ns=SclNs; + return new XDocument(new XElement(ns+"SCL", + new XElement(ns+"Communication", + new XElement(ns+"SubNetwork", + new XElement(ns+"ConnectedAP", + new XAttribute("iedName",ied),new XAttribute("apName","S1"), + new XElement(ns+"Address", + new XElement(ns+"P",new XAttribute("type","IP"),ip))))), + new XElement(ns+"IED",new XAttribute("name",ied), + new XElement(ns+"AccessPoint",new XAttribute("name","S1"), + new XElement(ns+"Server", + new XElement(ns+"LDevice", + new XAttribute("inst",inst), + ldName is null ? null : new XAttribute("ldName",ldName), + new XElement(ns+"LN0", + new XAttribute("lnType","LLN0_type")))))))); + } + + private static LiveIedModelDiscoveryDocument Model(string ied,string domain) + => new() + { + Host="192.16.1.33", + IedName=ied, + AccessPointName="S1", + LogicalDevices=[ + new LiveIedLogicalDeviceModel + { + MmsDomain=domain, + Inst=domain, + LogicalNodes=[ + new LiveIedLogicalNodeModel + { + Name="LLN0", + LnClass="LLN0", + ProposedLnTypeId="LN_LLN0_type" + }, + new LiveIedLogicalNodeModel + { + Name="CSWI6", + LnClass="CSWI", + LnInst="6", + ProposedLnTypeId="LN_CSWI6_type" + } + ] + } + ], + DataSets=[ + new LiveIedDataSetModel + { + Reference=$"{domain}/LLN0.Digital", + Domain=domain,LogicalNode="LLN0",Name="Digital", + MemberCount=1, + Members=[ + new LiveIedDataSetMemberModel + { + Index=0,Reference=$"{domain}/CSWI6.Pos.stVal", + FunctionalConstraint="ST" + } + ] + } + ] + }; +} diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs index c6d7ed27..1010e7d7 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -10,6 +10,69 @@ public sealed class CanonicalLiveIedSclExporterTests { private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + [Fact] + public void VerifiedSclIdentity_KeepsAcceptedMmsAssociationWhileMappingLogicalDevice() + { + var accepted = CreateCanonical(); + const string observedDomain = "IEDLD0"; + var canonical = new LiveIedCanonicalModel + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = "10.20.30.40", IedName = "IED", AccessPointName = "AP1", + LogicalDevices = [ + new LiveIedLogicalDeviceModel + { + MmsDomain = observedDomain, Inst = observedDomain, + LogicalNodes = [ + new LiveIedLogicalNodeModel + { + Name = "LLN0", LnClass = "LLN0", + ProposedLnTypeId = "LN_LLN0" + } + ] + } + ] + }, + Communication = accepted.Communication + }; + XNamespace ns = Scl; + var trusted = new XDocument(new XElement(ns+"SCL", + new XElement(ns+"Communication",new XElement(ns+"SubNetwork", + new XElement(ns+"ConnectedAP", + new XAttribute("iedName","BCUGE"),new XAttribute("apName","AP1"), + new XElement(ns+"Address", + new XElement(ns+"P",new XAttribute("type","IP"),"10.20.30.40"))))), + new XElement(ns+"IED",new XAttribute("name","BCUGE"), + new XElement(ns+"AccessPoint",new XAttribute("name","AP1"), + new XElement(ns+"Server",new XElement(ns+"LDevice", + new XAttribute("inst","F650"), + new XAttribute("ldName",observedDomain))))))); + var proof = TrustedSclIedIdentityMatcher.TryMatch( + trusted,[observedDomain],"10.20.30.40","BCUGE"); + Assert.NotNull(proof); + + var dir=Path.Combine(Path.GetTempPath(),"ariec-p10-canonical-"+Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var result=CanonicalLiveIedSclExporter.WriteFiles(canonical, + Path.Combine(dir,"BCUGE.iid"),SclSchemaProfile.Edition1V16, + verifiedIdentity:proof); + var doc=XDocument.Load(result.SclPath); + Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"IED")).Attribute("name")); + var ld=Assert.Single(doc.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)ld.Attribute("inst")); + Assert.Equal(observedDomain,(string?)ld.Attribute("ldName")); + Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"ConnectedAP")).Attribute("iedName")); + Assert.NotNull(SclMmsAssociationProfileReader.Read(doc).Find("BCUGE",canonical.AccessPointName)); + } + finally + { + Directory.Delete(dir,true); + } + } + [Fact] public void CanonicalBuilder_Preserves_CaseDistinct_InitialFcLeafEvidence() { diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs index 34364f1e..15d3b73d 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs @@ -1,5 +1,6 @@ using AR.Iec61850.Discovery; using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Scl.Workspace; namespace AR.Iec61850.Tests.Engineering; @@ -71,6 +72,39 @@ public void FromLiveDiscovery_Preserves_Identity_Provenance_And_Compacts_Repeate Assert.Equal("192.0.2.10", model.Communication.Host.Value); } + [Fact] + public void FromSclWorkspace_Preserves_Scl_Ingress_Provenance() + { + var workspace = new SclIedWorkspace + { + IedName = "IED_SCL", + AccessPointName = "P1", + DesignModel = new LiveIedModelDiscoveryDocument + { + Source = "SclDesign", + IedName = "IED_SCL", + AccessPointName = "P1", + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "IED_SCLLD0/LLN0$Events", + Domain = "IED_SCLLD0", + LogicalNode = "LLN0", + Name = "Events" + } + ] + } + }; + + var model = CanonicalLiveModelAdapter.FromSclWorkspace(workspace); + + Assert.Equal(CanonicalIngressKind.SclFile, model.Source.Ingress); + Assert.Equal(CanonicalEvidenceSource.SclDeclared, model.Identity.Provenance.Source); + Assert.Equal("IED_SCL", model.Identity.Name); + Assert.Equal("IED_SCLLD0/LLN0.Events", model.DataSets.Single().Reference); + } + private static LiveIedDataAttributeModel Attribute(string reference, string path) => new() { diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs new file mode 100644 index 00000000..6d3a7170 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs @@ -0,0 +1,475 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Scl.Engineering; + +namespace AR.Iec61850.Tests.Engineering; + +public sealed class CanonicalStaticReportCoverageResolverTests +{ + [Fact] + public void Discovery_And_Scl_Ingress_Use_The_Same_Static_Coverage_Contract() + { + var discovery = BuildCanonical(); + var scl = WithIngress(discovery, CanonicalIngressKind.SclFile); + + var selection = new[] + { + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + }; + + var fromDiscovery = CanonicalStaticReportCoverageResolver.Resolve(discovery, selection); + var fromScl = CanonicalStaticReportCoverageResolver.Resolve(scl, selection); + + Assert.Equal(CanonicalIngressKind.LiveMmsDiscovery, fromDiscovery.Ingress); + Assert.Equal(CanonicalIngressKind.SclFile, fromScl.Ingress); + Assert.Equal(fromDiscovery.CoveredSignalCount, fromScl.CoveredSignalCount); + Assert.Equal( + fromDiscovery.Signals[0].DataSetCandidates.Select(candidate => candidate.DataSetReference), + fromScl.Signals[0].DataSetCandidates.Select(candidate => candidate.DataSetReference)); + Assert.Equal( + fromDiscovery.Signals[0].DataSetCandidates[0].ReportControlReferences, + fromScl.Signals[0].DataSetCandidates[0].ReportControlReferences); + } + + [Fact] + public void Real_Discovery_And_OpenScl_Builders_Converge_On_Static_Coverage() + { + var discoveryModel = BuildStatusDiscoveryCanonical(); + var imported = SclCanonicalImporter.Import( + XDocument.Parse(BuildEquivalentStatusScl()), + new SclCanonicalImportOptions + { + IedName = "IEDA", + AccessPointName = "P1", + SourceName = "equivalent.cid" + }); + + Assert.True(imported.IsSuccess, string.Join(" | ", imported.Errors)); + var sclModel = Assert.IsType(imported.Model); + + Assert.Equal( + discoveryModel.DataSets.Single().Reference, + sclModel.DataSets.Single().Reference); + Assert.Equal( + "IEDALD0/LLN0.Indications", + sclModel.DataSets.Single().Reference); + Assert.Equal( + discoveryModel.ReportControls.Single().Reference, + sclModel.ReportControls.Single().Reference); + Assert.Equal( + "IEDALD0/LLN0.BR.Rpt01", + sclModel.ReportControls.Single().Reference); + Assert.Equal( + discoveryModel.ReportControls.Single().DataSetReference, + sclModel.ReportControls.Single().DataSetReference); + + var selection = new[] + { + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/GGIO1.Alm.stVal", + FunctionalConstraint = "ST" + } + }; + + var discoveryCoverage = CanonicalStaticReportCoverageResolver.Resolve(discoveryModel, selection); + var sclCoverage = CanonicalStaticReportCoverageResolver.Resolve(sclModel, selection); + + Assert.Equal(1, discoveryCoverage.CoveredSignalCount); + Assert.Equal(1, sclCoverage.CoveredSignalCount); + Assert.Equal( + discoveryCoverage.Signals[0].DataSetCandidates[0].DataSetReference, + sclCoverage.Signals[0].DataSetCandidates[0].DataSetReference); + Assert.Equal( + "IEDALD0/LLN0.Indications", + sclCoverage.Signals[0].DataSetCandidates[0].DataSetReference); + Assert.Equal( + discoveryCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences, + sclCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences); + Assert.Equal( + ["IEDALD0/LLN0.BR.Rpt01"], + sclCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences); + } + + [Fact] + public void Structured_Member_Covers_Only_Exact_Boundary_Descendants() + { + var model = BuildCanonical(); + var plan = CanonicalStaticReportCoverageResolver.Resolve( + model, + [ + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + }, + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsB.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + Assert.Equal(CanonicalStaticReportSignalCoverageStatus.Covered, plan.Signals[0].Status); + Assert.Equal([0], plan.Signals[0].DataSetCandidates[0].MemberIndexes); + Assert.Equal( + CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership, + plan.Signals[1].Status); + } + + [Fact] + public void Segment_Preserves_Full_Ordered_DataSet_And_All_Configured_Rcbs() + { + var model = BuildCanonical(); + var plan = CanonicalStaticReportCoverageResolver.Resolve( + model, + [ + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + var segment = Assert.Single(plan.Segments); + Assert.Equal("IEDALD0/LLN0.Analog", segment.DataSetReference); + Assert.Equal(2, segment.OrderedMembers.Length); + Assert.Equal(0, segment.OrderedMembers[0].Index); + Assert.Equal(1, segment.OrderedMembers[1].Index); + Assert.Equal( + new[] + { + "IEDALD0/LLN0.BR.Buffer01", + "IEDALD0/LLN0.RP.Unbuffer01" + }, + segment.ReportControls.Select(report => report.Reference)); + Assert.Equal( + ["IEDALD0/MMXU1.A.phsA.cVal.mag.f"], + segment.SelectedSignalReferences); + } + + [Fact] + public void Canonical_Identity_Remains_Exact_Case() + { + var model = BuildCanonical(); + var plan = CanonicalStaticReportCoverageResolver.Resolve( + model, + [ + new CanonicalStaticReportSelection + { + Reference = "iedald0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + Assert.Equal(CanonicalStaticReportSignalCoverageStatus.SignalNotFound, plan.Signals[0].Status); + Assert.Equal(0, plan.CoveredSignalCount); + } + + [Fact] + public void DataSet_Without_Rcb_Is_Not_Promoted_To_Report_Coverage() + { + var model = BuildCanonical(); + var withoutReports = new CanonicalIedModel + { + SchemaVersion = model.SchemaVersion, + GeneratedAtUtc = model.GeneratedAtUtc, + Identity = model.Identity, + Communication = model.Communication, + Source = model.Source, + Strings = model.Strings, + AccessPoints = model.AccessPoints, + LogicalDevices = model.LogicalDevices, + LogicalNodes = model.LogicalNodes, + DataObjects = model.DataObjects, + Signals = model.Signals, + DataSets = model.DataSets, + ReportControls = Array.Empty(), + Diagnostics = model.Diagnostics + }; + + var plan = CanonicalStaticReportCoverageResolver.Resolve( + withoutReports, + [ + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + Assert.Equal( + CanonicalStaticReportSignalCoverageStatus.StaticDataSetWithoutReportControl, + plan.Signals[0].Status); + Assert.Empty(plan.Segments); + } + + private static CanonicalIedModel BuildStatusDiscoveryCanonical() + { + var live = new LiveIedModelDiscoveryDocument + { + Source = "LiveMmsDiscovery", + IedName = "IEDA", + AccessPointName = "P1", + IedIdentity = new LiveIedIdentity + { + IedName = "IEDA", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }, + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDALD0", + Inst = "LD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDALD0/GGIO1.Alm", + Name = "Alm", + InferredCdc = "SPS", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = "IEDALD0/GGIO1.Alm.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + SclBType = "BOOLEAN", + MmsType = "boolean", + MmsTypeSignature = "boolean", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "IEDALD0/LLN0.Indications", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Indications", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 1, + Reference = "IEDALD0/GGIO1.Alm", + FunctionalConstraint = "ST" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "IEDALD0/LLN0.BR.Rpt01", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Rpt01", + Buffered = true, + DataSetReference = "IEDALD0/LLN0.Indications" + } + ] + }; + + return CanonicalLiveModelAdapter.FromLiveDiscovery(live); + } + + private static string BuildEquivalentStatusScl() + => """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + private static CanonicalIedModel BuildCanonical() + { + var live = new LiveIedModelDiscoveryDocument + { + Source = "LiveMmsDiscovery", + IedName = "IEDA", + IedIdentity = new LiveIedIdentity + { + IedName = "IEDA", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }, + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDALD0", + Inst = "LD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MMXU1", + LnClass = "MMXU", + LnInst = "1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDALD0/MMXU1.A", + Name = "A", + InferredCdc = "WYE", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = + [ + Attribute("IEDALD0/MMXU1.A.phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute("IEDALD0/MMXU1.A.phsB.cVal.mag.f", "phsB.cVal.mag.f") + ] + }, + new LiveIedDataObjectModel + { + Reference = "IEDALD0/MMXU1.TotW", + Name = "TotW", + InferredCdc = "MV", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = + [ + Attribute("IEDALD0/MMXU1.TotW.mag.f", "mag.f") + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "IEDALD0/LLN0.Analog", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Analog", + MemberCount = 2, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = "IEDALD0/MMXU1.A.phsA", + FunctionalConstraint = "MX" + }, + new LiveIedDataSetMemberModel + { + Index = 1, + Reference = "IEDALD0/MMXU1.TotW", + FunctionalConstraint = "MX" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "IEDALD0/LLN0.BR.Buffer01", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Buffer01", + Buffered = true, + DataSetReference = "IEDALD0/LLN0.Analog" + }, + new LiveIedReportControlModel + { + Reference = "IEDALD0/LLN0.RP.Unbuffer01", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Unbuffer01", + Buffered = false, + DataSetReference = "IEDALD0/LLN0.Analog" + } + ] + }; + + return CanonicalLiveModelAdapter.FromLiveDiscovery(live); + } + + private static LiveIedDataAttributeModel Attribute(string reference, string path) + => new() + { + ObjectReference = reference, + AttributePath = path, + FunctionalConstraint = "MX", + SclBType = "FLOAT32", + MmsType = "floating-point", + MmsTypeSignature = "floating-point", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + private static CanonicalIedModel WithIngress( + CanonicalIedModel model, + CanonicalIngressKind ingress) + => new() + { + SchemaVersion = model.SchemaVersion, + GeneratedAtUtc = model.GeneratedAtUtc, + Identity = model.Identity, + Communication = model.Communication, + Source = new CanonicalSourceEnvelope + { + Ingress = ingress, + SourceName = ingress == CanonicalIngressKind.SclFile ? "sample.scd" : "LiveMmsDiscovery" + }, + Strings = model.Strings, + AccessPoints = model.AccessPoints, + LogicalDevices = model.LogicalDevices, + LogicalNodes = model.LogicalNodes, + DataObjects = model.DataObjects, + Signals = model.Signals, + DataSets = model.DataSets, + ReportControls = model.ReportControls, + Diagnostics = model.Diagnostics + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs b/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs index 7e42dc89..e4c3e154 100644 --- a/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs @@ -47,4 +47,40 @@ public void StandardMmsDataAccessErrors_HaveExplicitNames(int code, string expec [Fact] public void VendorSpecificUnknownAccessError_RemainsNumericallyVisible() => Assert.Equal("data-access-error-99", MmsWriteResponseDecoder.NameDataAccessError(99)); + [Theory] + [InlineData(2, MmsInteropFailureKind.TemporarilyUnavailable, MmsInteropRecoveryHint.RetryLaterAfterStateRefresh)] + [InlineData(3, MmsInteropFailureKind.AccessDenied, MmsInteropRecoveryHint.FailClosed)] + [InlineData(6, MmsInteropFailureKind.TypeUnsupported, MmsInteropRecoveryHint.RevalidateType)] + [InlineData(7, MmsInteropFailureKind.TypeMismatch, MmsInteropRecoveryHint.RevalidateType)] + [InlineData(8, MmsInteropFailureKind.AttributeInconsistent, MmsInteropRecoveryHint.RevalidateRuntimeState)] + [InlineData(10, MmsInteropFailureKind.ObjectNotFound, MmsInteropRecoveryHint.RevalidateObjectIdentity)] + [InlineData(11, MmsInteropFailureKind.InvalidValue, MmsInteropRecoveryHint.FailClosed)] + public void DataAccessErrors_Map_To_Structured_Interop_Recovery( + int code, + MmsInteropFailureKind expectedKind, + MmsInteropRecoveryHint expectedRecovery) + { + var access = new MmsWriteAccessResult + { + IsSuccess = false, + FailureCode = code, + FailureName = MmsWriteResponseDecoder.NameDataAccessError(code) + }; + + var classified = MmsInteropFailureClassifier.Classify(access); + + Assert.Equal(expectedKind, classified.Kind); + Assert.Equal(expectedRecovery, classified.RecoveryHint); + } + + [Fact] + public void Successful_Access_Has_No_Recovery_Action() + { + var classified = MmsInteropFailureClassifier.Classify( + new MmsWriteAccessResult { IsSuccess = true }); + + Assert.Equal(MmsInteropFailureKind.None, classified.Kind); + Assert.Equal(MmsInteropRecoveryHint.None, classified.RecoveryHint); + } + } diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs new file mode 100644 index 00000000..d8863e5e --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs @@ -0,0 +1,146 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalReportInventoryProjectionTests +{ + [Fact] + public void SclCanonicalProjection_PreservesExactConfiguredIdentity_WithoutInventingLiveEvidence() + { + var model = Model( + CanonicalIngressKind.SclFile, + reportReference: "LD0/LLN0.BR.Rpt", + dataSetReference: "LD0/LLN0.Events"); + + var inventory = MmsCanonicalReportInventoryProjection.Build(model); + + Assert.Equal(MmsReportInventoryAuthority.SclDesignProjection, inventory.Authority); + var report = Assert.Single(inventory.ReportControls); + Assert.Equal("LD0/LLN0.BR.Rpt", report.Reference); + Assert.DoesNotContain("01", report.Reference, StringComparison.Ordinal); + Assert.Equal("LD0/LLN0.Events", report.DataSetReference); + Assert.True(report.Buffered); + Assert.Equal("BR", report.FunctionalConstraint); + Assert.Empty(report.Attributes); + Assert.Equal(string.Empty, report.EnabledState); + Assert.Equal(string.Empty, report.ReservationTimeSeconds); + Assert.Equal(string.Empty, report.Owner); + } + + [Fact] + public void DiscoveryDerivedCanonicalProjection_IsStillStructural_NotLiveRuntimeAuthority() + { + var model = Model( + CanonicalIngressKind.LiveMmsDiscovery, + reportReference: "LD0/LLN0.RP.Rpt01", + dataSetReference: "LD0/LLN0.Analog"); + + var inventory = MmsCanonicalReportInventoryProjection.Build(model); + + Assert.Equal(MmsReportInventoryAuthority.CanonicalModelProjection, inventory.Authority); + var report = Assert.Single(inventory.ReportControls); + Assert.Equal("LD0/LLN0.RP.Rpt01", report.Reference); + Assert.False(report.Buffered); + Assert.Equal("RP", report.FunctionalConstraint); + Assert.Empty(report.Attributes); + } + + [Fact] + public void CanonicalProjection_CannotAuthorizeOperationalStaticTargeting() + { + var model = Model( + CanonicalIngressKind.SclFile, + reportReference: "LD0/LLN0.BR.Rpt01", + dataSetReference: "LD0/LLN0.Events"); + var inventory = MmsCanonicalReportInventoryProjection.Build(model); + + var coverage = new CanonicalStaticReportCoveragePlan + { + Ingress = CanonicalIngressKind.SclFile, + Signals = + [ + new CanonicalStaticReportSignalCoverage + { + RequestedReference = "LD0/XCBR1.Pos.stVal", + CanonicalReference = "LD0/XCBR1.Pos.stVal", + Status = CanonicalStaticReportSignalCoverageStatus.Covered + } + ], + Segments = + [ + new CanonicalStaticReportCoverageSegment + { + DataSetReference = "LD0/LLN0.Events", + SourceDataSetReference = "LD0/LLN0.Events", + SelectedSignalReferences = ["LD0/XCBR1.Pos.stVal"], + ReportControls = model.ReportControls, + OrderedMembers = model.DataSets[0].Members + } + ] + }; + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(coverage, inventory); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, plan.Status); + Assert.False(plan.HasExactOperationalTargets); + } + + private static CanonicalIedModel Model( + CanonicalIngressKind ingress, + string reportReference, + string dataSetReference) + => new() + { + Source = new CanonicalSourceEnvelope + { + Ingress = ingress, + SourceName = ingress == CanonicalIngressKind.SclFile ? "relay.cid" : "LiveMmsDiscovery" + }, + DataSets = + [ + new CanonicalDataSet + { + Reference = dataSetReference, + MmsDomain = "LD0", + LogicalNode = "LLN0", + Name = dataSetReference.Split('.').Last(), + Members = + [ + new CanonicalDataSetMember( + 0, + "LD0/XCBR1.Pos", + "ST", + new CanonicalProvenance( + ingress == CanonicalIngressKind.SclFile + ? CanonicalEvidenceSource.SclDeclared + : CanonicalEvidenceSource.LiveMms, + CanonicalConfidence.Exact)) + ] + } + ], + ReportControls = + [ + new CanonicalReportControl + { + Reference = reportReference, + MmsDomain = "LD0", + LogicalNode = "LLN0", + Name = reportReference.Split('.').Last(), + Buffered = reportReference.Contains(".BR.", StringComparison.Ordinal), + DataSetReference = dataSetReference, + ReportId = "RID", + ConfRev = "1", + TriggerOptions = "dchg,qchg,gi", + OptionalFields = "seqNum,timeStamp,dataSet,reasonCode", + BufferTimeMs = "100", + IntegrityPeriodMs = "0", + Provenance = new CanonicalProvenance( + ingress == CanonicalIngressKind.SclFile + ? CanonicalEvidenceSource.SclDeclared + : CanonicalEvidenceSource.LiveMms, + CanonicalConfidence.Exact) + } + ] + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs new file mode 100644 index 00000000..d3a64b96 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs @@ -0,0 +1,284 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticAcquisitionPreflightTests +{ + [Fact] + public void SclProjectedInventory_IsNeverPromotedToLiveOperationalTargetEvidence() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal")); + + var inventory = Inventory( + MmsReportInventoryAuthority.SclDesignProjection, + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, plan.Status); + Assert.False(plan.HasExactOperationalTargets); + Assert.Empty(plan.ExactTargetReportControlReferences); + Assert.Contains(plan.Warnings, warning => + warning.Contains("structural evidence only", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void LiveInventory_ProducesOnlyExactStaticTargets() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal")); + + var inventory = Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true), + Candidate("LD0/LLN0.BR.Other01", "LD0/LLN0.Other", buffered: true)); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady, plan.Status); + Assert.True(plan.HasExactOperationalTargets); + Assert.Equal( + new[] { "LD0/LLN0.BR.Rpt01" }, + plan.ExactTargetReportControlReferences.OrderBy(x => x).ToArray()); + Assert.Equal(1, plan.ResolvedSegmentCount); + Assert.Equal(0, plan.UnresolvedSegmentCount); + } + + [Fact] + public void PartialResolution_RemainsExplicit_AndNeverBroadensUnresolvedSegment() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal"), + Segment( + "LD0/LLN0.Analog", + "LD0/LLN0.RP.Meas", + buffered: false, + "LD0/MMXU1.A.phsA.cVal.mag.f")); + + var inventory = Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true), + Candidate("LD0/LLN0.RP.Meas01", string.Empty, buffered: false), + Candidate("LD0/LLN0.RP.Meas02", string.Empty, buffered: false)); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady, plan.Status); + Assert.Equal(1, plan.ResolvedSegmentCount); + Assert.Equal(1, plan.UnresolvedSegmentCount); + Assert.Equal(new[] { "LD0/LLN0.BR.Rpt01" }, plan.ExactTargetReportControlReferences); + Assert.DoesNotContain( + plan.ExactTargetReportControlReferences, + reference => reference.Contains("Meas", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task NonOperationalPlan_DoesNotRequireConnectedSession() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal")); + var inventory = Inventory( + MmsReportInventoryAuthority.SclDesignProjection, + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory); + + await using var session = new MmsClientSession(); + var result = await session.ProbeCanonicalStaticAcquisitionAsync(plan, inventory); + + Assert.False(result.NetworkProbePerformed); + Assert.Null(result.Availability); + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, result.Status); + } + + [Fact] + public async Task ExecutorRejectsInventoryAuthoritySwapBeforeNetworkWork() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal")); + var liveInventory = Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + liveInventory); + + var structuralOnlyInventory = Inventory( + MmsReportInventoryAuthority.SclDesignProjection, + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + + await using var session = new MmsClientSession(); + var result = await session.ProbeCanonicalStaticAcquisitionAsync( + plan, + structuralOnlyInventory); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, result.Status); + Assert.False(result.NetworkProbePerformed); + Assert.Null(result.Availability); + } + + [Theory] + [InlineData(66, 2)] + [InlineData(300, 5)] + public void Exact_Targets_Are_Paged_Without_The_Previous_Global_Budget_Abort( + int count, int expectedPages) + { + var targetSet = Enumerable.Range(1, count) + .Select(index => $"LD0/LLN0.BR.Rpt{index:000}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var targetResolution = new MmsCanonicalStaticRcbTargetResolution + { + ExactLiveReportControlReferences = targetSet, + Segments = + [ + new MmsCanonicalStaticRcbTargetSegment + { + DataSetReference = "LD0/LLN0.Events", + ExactLiveReportControlReferences = targetSet.ToArray(), + Resolution = "exact-live-dataset-binding" + } + ] + }; + var plan = new MmsCanonicalStaticAcquisitionProbePlan + { + InventoryAuthority = MmsReportInventoryAuthority.LiveMmsObserved, + Coverage = Coverage(Segment( + "LD0/LLN0.Events", "LD0/LLN0.BR.Rpt", + buffered: true, "LD0/XCBR1.Pos.stVal")), + TargetResolution = targetResolution, + Status = MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady, + ExactTargetReportControlReferences = targetSet + }; + var inventory = Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, + targetSet.Select(r => Candidate(r, "LD0/LLN0.Events", true)).ToArray()); + + var examined = new HashSet(StringComparer.OrdinalIgnoreCase); + string? cursor = null; + var pages = 0; + do + { + var page = MmsCanonicalStaticExactTargetPager.Select( + plan, inventory, 64, cursor, "active-association"); + Assert.InRange(page.References.Count, 1, 64); + foreach (var reference in page.References) + Assert.True(examined.Add(reference), "Duplicate exact target across pages."); + cursor = page.ContinuationToken; + pages++; + } while (cursor is not null && pages < 10); + + Assert.Null(cursor); + Assert.Equal(count, examined.Count); + Assert.Equal(expectedPages, pages); + + var first = MmsCanonicalStaticExactTargetPager.Select( + plan, inventory, 64, associationKey: "active-association"); + Assert.NotNull(first.ContinuationToken); + Assert.Throws(() => + MmsCanonicalStaticExactTargetPager.Select( + plan, inventory, 64, first.ContinuationToken, "reconnected-association")); + } + + private static CanonicalStaticReportCoveragePlan Coverage( + params CanonicalStaticReportCoverageSegment[] segments) + { + var signals = segments + .SelectMany(segment => segment.SelectedSignalReferences) + .Distinct(StringComparer.Ordinal) + .Select(reference => new CanonicalStaticReportSignalCoverage + { + RequestedReference = reference, + CanonicalReference = reference, + Status = CanonicalStaticReportSignalCoverageStatus.Covered + }) + .ToArray(); + + return new CanonicalStaticReportCoveragePlan + { + Ingress = CanonicalIngressKind.SclFile, + Signals = signals, + Segments = segments + }; + } + + private static CanonicalStaticReportCoverageSegment Segment( + string dataSet, + string rcbReference, + bool buffered, + string selectedSignal) + => new() + { + DataSetReference = dataSet, + SourceDataSetReference = dataSet, + SelectedSignalReferences = [selectedSignal], + OrderedMembers = + [ + new CanonicalDataSetMember( + 0, + selectedSignal[..selectedSignal.LastIndexOf('.')], + selectedSignal.Contains("MMXU", StringComparison.Ordinal) ? "MX" : "ST", + new CanonicalProvenance(CanonicalEvidenceSource.SclDeclared, CanonicalConfidence.Exact)) + ], + ReportControls = + [ + new CanonicalReportControl + { + Reference = rcbReference, + DataSetReference = dataSet, + Buffered = buffered + } + ] + }; + + private static MmsReportInventory Inventory( + MmsReportInventoryAuthority authority, + params MmsReportControlCandidate[] controls) + { + var inventory = new MmsReportInventory + { + Authority = authority + }; + inventory.ReportControls.AddRange(controls); + return inventory; + } + + private static MmsReportControlCandidate Candidate( + string reference, + string dataSet, + bool buffered) + => new() + { + Reference = reference, + DataSetReference = dataSet, + Buffered = buffered + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs new file mode 100644 index 00000000..51a4ae5c --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs @@ -0,0 +1,179 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticLiveReconciliationPlannerTests +{ + [Fact] + public void Planner_Collapses_Selected_Static_Coverage_To_Minimum_Domain_Set() + { + var coverage = Coverage( + Segment( + "IEDLD0/LLN0.Events", + Report("IEDLD0/LLN0.BR.Rpt", "IEDLD0", buffered: true), + "IEDLD0/XCBR1.Pos.stVal"), + Segment( + "IEDLD0/LLN0.Analog", + Report("IEDLD0/LLN0.RP.Meas", "IEDLD0", buffered: false), + "IEDLD0/MMXU1.A.phsA.cVal.mag.f"), + Segment( + "IEDLD1/LLN0.Events", + Report("IEDLD1/LLN0.BR.Rpt", "IEDLD1", buffered: true), + "IEDLD1/GGIO1.Alm.stVal")); + + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(coverage); + + Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.Ready, plan.Status); + Assert.True(plan.RequiresNetwork); + Assert.Equal(2, plan.Domains.Count); + + var ld0 = plan.Domains.Single(target => target.Domain == "IEDLD0"); + Assert.True(ld0.NeedsBuffered); + Assert.True(ld0.NeedsUnbuffered); + + var ld1 = plan.Domains.Single(target => target.Domain == "IEDLD1"); + Assert.True(ld1.NeedsBuffered); + Assert.False(ld1.NeedsUnbuffered); + } + + [Fact] + public void Planner_Uses_Exact_Reference_Domain_When_MmsDomain_Is_Not_Populated() + { + var coverage = Coverage( + Segment( + "IEDLD0/LLN0.Events", + Report("IEDLD0/LLN0.BR.Rpt", mmsDomain: string.Empty, buffered: true), + "IEDLD0/XCBR1.Pos.stVal")); + + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(coverage); + + var target = Assert.Single(plan.Domains); + Assert.Equal("IEDLD0", target.Domain); + Assert.True(target.NeedsBuffered); + } + + [Fact] + public void Planner_FailsClosed_When_Domain_Budget_Would_Require_Truncation() + { + var coverage = Coverage( + Segment( + "IEDLD0/LLN0.Events", + Report("IEDLD0/LLN0.BR.Rpt", "IEDLD0", true), + "IEDLD0/XCBR1.Pos.stVal"), + Segment( + "IEDLD1/LLN0.Events", + Report("IEDLD1/LLN0.BR.Rpt", "IEDLD1", true), + "IEDLD1/XCBR1.Pos.stVal")); + + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build( + coverage, + new MmsCanonicalStaticLiveReconciliationOptions + { + MaxDomains = 1 + }); + + Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.DomainBudgetExceeded, plan.Status); + Assert.False(plan.RequiresNetwork); + Assert.Equal(2, plan.Domains.Count); + Assert.Contains(plan.Warnings, warning => + warning.Contains("no partial domain subset", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Planner_DoesNot_Request_Network_When_No_Configured_Static_Coverage_Exists() + { + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build( + new CanonicalStaticReportCoveragePlan + { + Signals = + [ + new CanonicalStaticReportSignalCoverage + { + RequestedReference = "IEDLD0/MMXU1.A.phsA.cVal.mag.f", + CanonicalReference = "IEDLD0/MMXU1.A.phsA.cVal.mag.f", + Status = CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership + } + ] + }); + + Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.NoStaticCoverage, plan.Status); + Assert.False(plan.RequiresNetwork); + Assert.Empty(plan.Domains); + } + + [Fact] + public async Task Reconcile_NoStaticCoverage_Performs_Zero_Network_Work_On_Disconnected_Session() + { + await using var session = new MmsClientSession(); + + var result = await session.ReconcileCanonicalStaticReportInventoryAsync( + new CanonicalStaticReportCoveragePlan()); + + Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.NoStaticCoverage, result.Status); + Assert.Equal(MmsReportInventoryAuthority.LiveMmsObserved, result.Inventory.Authority); + Assert.Equal(0, result.EnumeratedVariableCount); + Assert.Equal(0, result.CandidateReportControlCount); + } + + private static CanonicalStaticReportCoveragePlan Coverage( + params CanonicalStaticReportCoverageSegment[] segments) + { + var signals = segments + .SelectMany(segment => segment.SelectedSignalReferences) + .Distinct(StringComparer.Ordinal) + .Select(reference => new CanonicalStaticReportSignalCoverage + { + RequestedReference = reference, + CanonicalReference = reference, + Status = CanonicalStaticReportSignalCoverageStatus.Covered + }) + .ToArray(); + + return new CanonicalStaticReportCoveragePlan + { + Ingress = CanonicalIngressKind.SclFile, + Signals = signals, + Segments = segments + }; + } + + private static CanonicalStaticReportCoverageSegment Segment( + string dataSetReference, + CanonicalReportControl report, + string selectedSignal) + => new() + { + DataSetReference = dataSetReference, + SourceDataSetReference = dataSetReference, + SelectedSignalReferences = [selectedSignal], + ReportControls = [report], + OrderedMembers = + [ + new CanonicalDataSetMember( + 0, + selectedSignal[..selectedSignal.LastIndexOf('.')], + selectedSignal.Contains("MMXU", StringComparison.Ordinal) ? "MX" : "ST", + new CanonicalProvenance(CanonicalEvidenceSource.SclDeclared, CanonicalConfidence.Exact)) + ] + }; + + private static CanonicalReportControl Report( + string reference, + string mmsDomain, + bool buffered) + => new() + { + Reference = reference, + MmsDomain = mmsDomain, + LogicalNode = "LLN0", + Name = reference.Split('.').Last(), + Buffered = buffered, + DataSetReference = reference.Contains("Meas", StringComparison.Ordinal) + ? $"{mmsDomain}/LLN0.Analog" + : $"{mmsDomain}/LLN0.Events", + Provenance = new CanonicalProvenance( + CanonicalEvidenceSource.SclDeclared, + CanonicalConfidence.Exact) + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs new file mode 100644 index 00000000..76beb371 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs @@ -0,0 +1,211 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticRcbCatalogPagerTests +{ + [Theory] + [InlineData(66)] + [InlineData(300)] + public void Proven_Target_DataSet_Does_Not_Probe_Unrelated_Rcb_Inventory(int total) + { + var catalog = Enumerable.Range(0, total - 2) + .Select(i => Candidate($"LD0/LLN0.BR.Noise{i:000}", "", enabled: false)) + .Append(Candidate("LD0/LLN0.BR.Events01", "LD0/LLN0.Events", enabled: true)) + .Append(Candidate("LD0/LLN0.BR.Events02", "LD0/LLN0.Events", enabled: false)) + .ToArray(); + var first = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 4); + var reversed = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog.Reverse().ToArray()), 4); + + Assert.Equal(2, first.Candidates.Count); + Assert.Equal(first.Candidates.Select(x => x.Reference), reversed.Candidates.Select(x => x.Reference)); + Assert.False(first.HasMore); + Assert.Equal(total, first.CatalogCount); + Assert.Equal(2, first.TargetBoundCount); + Assert.DoesNotContain(first.Candidates, x => x.Name.StartsWith("Noise", StringComparison.Ordinal)); + Assert.All(first.Candidates, x => Assert.Equal("LD0/LLN0.Events", x.DataSetReference)); + + // Read-only discovery sees both matching RCBs; activation policy must + // reject the occupied one and choose the independently proven free one. + var occupied = first.Candidates.Single(x => x.EnabledState == "true"); + var free = first.Candidates.Single(x => x.EnabledState == "false"); + Assert.False(MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + VerifiedAvailability(occupied, MmsRcbOperationalAvailability.InUse, "60"), + allowCallerOwned: false, + allowReducedMissingReservationEvidence: false).IsEligible); + var eligibility = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + VerifiedAvailability(free, MmsRcbOperationalAvailability.Available, "0"), + allowCallerOwned: false, + allowReducedMissingReservationEvidence: false); + Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, eligibility.Kind); + } + + private static MmsRcbAvailabilitySnapshot VerifiedAvailability( + MmsReportControlCandidate candidate, + MmsRcbOperationalAvailability state, + string reservation) + => new() + { + Reference = candidate.Reference, + Domain = candidate.Domain, + LogicalNode = candidate.LogicalNode, + Buffered = candidate.Buffered, + DataSetReference = candidate.DataSetReference, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + EnabledState = candidate.EnabledState, + ReservationTimeSeconds = reservation, + DataSetDirectorySuccess = true, + DataSetMembers = [new MmsDataSetDirectoryMember + { + Domain = "LD0", + MmsItemName = "XCBR1$ST$Pos", + UserReference = "LD0/XCBR1.Pos", + FunctionalConstraint = "ST" + }], + Availability = state, + Confidence = MmsRcbAvailabilityConfidence.Exact + }; + + [Fact] + public void Unresolved_300_Rcbs_Continues_In_Bounded_Deterministic_Pages_Without_Duplicates() + { + var catalog = Enumerable.Range(0, 300) + .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", "")) + .ToArray(); + var inventory = Inventory(catalog); + string? continuation = null; + var visited = new List(); + var pages = 0; + do + { + var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), inventory, 64, continuation); + Assert.InRange(page.Candidates.Count, 1, 64); + visited.AddRange(page.Candidates.Select(x => x.Reference)); + continuation = page.ContinuationToken; + pages++; + } while (continuation is not null && pages < 10); + + Assert.Null(continuation); + Assert.Equal(5, pages); + Assert.Equal(300, visited.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(300, visited.Count); + } + + [Fact] + public void Indexed_Configured_Family_Is_Read_Priority_Not_Trusted_DataSet_Proof() + { + var catalog = Enumerable.Range(0, 300) + .Select(i => Candidate($"LD0/LLN0.BR.Unrelated{i:000}", "")) + .Append(Candidate("LD0/LLN0.BR.Events01", "")) + .Append(Candidate("LD0/LLN0.BR.Events02", "")) + .ToArray(); + var first = MmsCanonicalStaticRcbCatalogPager.Select( + Coverage(), Inventory(catalog), 2); + Assert.Equal(302, first.CatalogCount); + Assert.Equal(0, first.TargetBoundCount); + Assert.Equal(2, first.Candidates.Count); + Assert.Equal( + new[] { "LD0/LLN0.BR.Events01", "LD0/LLN0.BR.Events02" }, + first.Candidates.Select(c => c.Reference).ToArray()); + Assert.True(first.HasMore); + Assert.All(first.Candidates, c => Assert.Equal("", c.DataSetReference)); + } + + [Fact] + public void Catalog_Or_Target_Mutation_Invalidates_Continuation() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", "")) + .ToArray(); + var initial = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 16); + Assert.NotNull(initial.ContinuationToken); + var changed = catalog[..^1]; + Assert.Throws(() => + MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(changed), 16, initial.ContinuationToken)); + } + + [Fact] + public void Reconnect_Must_Reject_Prior_Association_Cursor() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", "")) + .ToArray(); + var inventory = Inventory(catalog); + var old = MmsCanonicalStaticRcbCatalogPager.Select( + Coverage(), inventory, 16, associationKey: "epoch-a"); + Assert.NotNull(old.ContinuationToken); + Assert.Throws(() => + MmsCanonicalStaticRcbCatalogPager.Select( + Coverage(), inventory, 16, old.ContinuationToken, "epoch-b")); + } + + [Fact] + public void Wrong_Dataset_And_Different_Functional_Constraint_Are_Not_Admitted() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Events", "LD0/LLN0.Wrong"), + Candidate("LD0/LLN0.RP.Events", "LD0/LLN0.Events", buffered: false), + Candidate("LD0/LLN0.BR.Events02", "LD0/LLN0.Events"), + Candidate("LD1/LLN0.BR.Events03", "LD0/LLN0.Events")); + var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), inventory, 32); + Assert.Equal("LD0/LLN0.BR.Events02", Assert.Single(page.Candidates).Reference); + } + + [Fact] + public void Exact_Configured_Reference_Is_Probe_Priority_But_Not_Proof_Of_Dataset() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"LD0/LLN0.BR.Noise{i:000}", "")) + .Append(Candidate("LD0/LLN0.BR.Events", "")) + .ToArray(); + var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 1); + Assert.Equal("LD0/LLN0.BR.Events", Assert.Single(page.Candidates).Reference); + Assert.Equal(0, page.TargetBoundCount); + Assert.True(page.HasMore); + } + + private static CanonicalStaticReportCoveragePlan Coverage() => new() + { + Segments = + [ + new CanonicalStaticReportCoverageSegment + { + DataSetReference = "LD0/LLN0.Events", + SourceDataSetReference = "LD0/LLN0.Events", + SelectedSignalReferences = ["LD0/XCBR1.Pos.stVal"], + ReportControls = + [ + new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Events", + MmsDomain = "LD0", + Buffered = true, + DataSetReference = "LD0/LLN0.Events" + } + ] + } + ] + }; + + private static MmsReportInventory Inventory(params MmsReportControlCandidate[] candidates) + { + var inventory = new MmsReportInventory { Authority = MmsReportInventoryAuthority.LiveMmsObserved }; + inventory.ReportControls.AddRange(candidates); + return inventory; + } + + private static MmsReportControlCandidate Candidate( + string reference, string dataSet, bool enabled = false, bool buffered = true) + => new() + { + Domain = reference[..reference.IndexOf('/')], + LogicalNode = "LLN0", + FunctionalConstraint = buffered ? "BR" : "RP", + Name = reference[(reference.LastIndexOf('.') + 1)..], + Reference = reference, + Buffered = buffered, + DataSetReference = dataSet, + EnabledState = enabled ? "true" : "false" + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs new file mode 100644 index 00000000..5293bf0c --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs @@ -0,0 +1,144 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticRcbTargetResolverTests +{ + [Fact] + public void Exact_Live_Dataset_Binding_Targets_Only_Covered_Rcb_Family() + { + var coverage = Coverage( + dataSet: "LD0/LLN0.Events", + configured: new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Rpt", + DataSetReference = "LD0/LLN0.Events", + Buffered = true + }); + + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true), + Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", buffered: true), + Candidate("LD0/LLN0.RP.Rpt01", "LD0/LLN0.Events", buffered: false), + Candidate("LD0/LLN0.BR.Other01", "LD0/LLN0.Other", buffered: true)); + + var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + + Assert.True(resolution.HasTargets); + Assert.Equal( + new[] { "LD0/LLN0.BR.Rpt01", "LD0/LLN0.BR.Rpt02" }, + resolution.ExactLiveReportControlReferences.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray()); + Assert.Equal("exact-live-dataset-binding", resolution.Segments.Single().Resolution); + } + + [Fact] + public void Exact_Configured_Reference_Can_Target_When_Live_Dataset_Binding_Is_Not_Yet_Populated() + { + var coverage = Coverage( + dataSet: "LD0/LLN0.Events", + configured: new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Rpt01", + DataSetReference = "LD0/LLN0.Events", + Buffered = true + }); + + var inventory = Inventory( + Candidate("LD0/LLN0$BR$Rpt01", string.Empty, buffered: true), + Candidate("LD0/LLN0.BR.Rpt02", string.Empty, buffered: true)); + + var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + + Assert.Equal(new[] { "LD0/LLN0$BR$Rpt01" }, resolution.ExactLiveReportControlReferences); + Assert.Equal("exact-configured-rcb-reference", resolution.Segments.Single().Resolution); + } + + [Fact] + public void Exact_Configured_Reference_With_Proven_Wrong_DataSet_Cannot_Be_Target() + { + var coverage = Coverage( + dataSet: "LD0/LLN0.Events", + configured: new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Events", + DataSetReference = "LD0/LLN0.Events", + Buffered = true + }); + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Events", "LD0/LLN0.Other", buffered: true)); + + var result = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + Assert.False(result.HasTargets); + Assert.Equal("unresolved", Assert.Single(result.Segments).Resolution); + } + + [Fact] + public void Unresolved_Segment_Does_Not_Guess_Name_Family() + { + var coverage = Coverage( + dataSet: "LD0/LLN0.Events", + configured: new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Rpt", + DataSetReference = "LD0/LLN0.Events", + Buffered = true + }); + + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", string.Empty, buffered: true), + Candidate("LD0/LLN0.BR.Rpt02", string.Empty, buffered: true)); + + var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + + Assert.False(resolution.HasTargets); + Assert.Empty(resolution.ExactLiveReportControlReferences); + Assert.Equal("unresolved", resolution.Segments.Single().Resolution); + Assert.Contains( + resolution.Warnings, + warning => warning.Contains("No sibling/name-family guess", StringComparison.OrdinalIgnoreCase)); + } + + private static CanonicalStaticReportCoveragePlan Coverage( + string dataSet, + CanonicalReportControl configured) + => new() + { + Segments = new[] + { + new CanonicalStaticReportCoverageSegment + { + DataSetReference = dataSet, + SourceDataSetReference = dataSet, + SelectedSignalReferences = new[] { "LD0/XCBR1.Pos.stVal" }, + OrderedMembers = new[] + { + new CanonicalDataSetMember( + 0, + "LD0/XCBR1.Pos", + "ST", + new CanonicalProvenance(CanonicalEvidenceSource.LiveMms, CanonicalConfidence.Exact)) + }, + ReportControls = new[] { configured } + } + } + }; + + private static MmsReportInventory Inventory(params MmsReportControlCandidate[] controls) + { + var inventory = new MmsReportInventory(); + inventory.ReportControls.AddRange(controls); + return inventory; + } + + private static MmsReportControlCandidate Candidate( + string reference, + string dataSet, + bool buffered) + => new() + { + Reference = reference, + DataSetReference = dataSet, + Buffered = buffered + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs new file mode 100644 index 00000000..e69606b5 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs @@ -0,0 +1,154 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsConfiguredStaticActivationVerifierTests +{ + [Fact] + public void BeforeWrite_Requires_Exact_Disabled_Readback() + { + var unknown = Snapshot(success: true, enabled: "-", dataSet: "LD0/LLN0.DS01", stage: "before"); + var enabled = Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "before"); + var disabled = Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before"); + + Assert.Equal( + MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(unknown, "LD0/LLN0.DS01").Kind); + Assert.Equal( + MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(enabled, "LD0/LLN0.DS01").Kind); + Assert.True( + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(disabled, "LD0/LLN0.DS01").IsProven); + } + + [Fact] + public void BeforeWrite_Blocks_Positive_DataSet_Contradiction_But_Not_Unread_DataSet() + { + var mismatch = Snapshot( + success: true, + enabled: "false", + dataSet: "LD0/LLN0.Other", + stage: "before", + dataSetProbeState: MmsRcbDataSetProbeState.ReadSucceeded); + var unread = Snapshot( + success: true, + enabled: "false", + dataSet: string.Empty, + stage: "before", + dataSetProbeState: MmsRcbDataSetProbeState.ReadFailed); + + Assert.Equal( + MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(mismatch, "LD0/LLN0.DS01").Kind); + Assert.True( + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(unread, "LD0/LLN0.DS01").IsProven); + } + + [Fact] + public void BeforeWrite_Blocks_Positive_Empty_Live_DataSet() + { + var empty = Snapshot( + success: true, + enabled: "false", + dataSet: string.Empty, + stage: "before", + dataSetProbeState: MmsRcbDataSetProbeState.ReadSucceeded); + + var proof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(empty, "LD0/LLN0.DS01"); + + Assert.Equal(MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, proof.Kind); + } + + [Fact] + public void BeforeWrite_Blocks_Positive_Busy_Evidence() + { + var brcb = Snapshot( + success: true, + enabled: "false", + dataSet: "LD0/LLN0.DS01", + stage: "before", + buffered: true, + reservationTimeSeconds: "30"); + + var urcb = Snapshot( + success: true, + enabled: "false", + dataSet: "LD0/LLN0.DS01", + stage: "before", + buffered: false, + reservationState: "true"); + + Assert.Equal( + MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(brcb, "LD0/LLN0.DS01").Kind); + Assert.Equal( + MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(urcb, "LD0/LLN0.DS01").Kind); + } + + [Fact] + public void BeforeWrite_Fails_Closed_When_Jit_Read_Fails() + { + var snapshot = Snapshot(success: false, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before"); + + var proof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(snapshot, "LD0/LLN0.DS01"); + + Assert.Equal(MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, proof.Kind); + Assert.False(proof.IsProven); + } + + [Fact] + public void AfterEnable_One_Exact_Readback_Is_Sufficient() + { + var snapshot = Snapshot( + success: true, + enabled: "true", + dataSet: "LD0/LLN0.DS01", + stage: "after-enable"); + + var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshot, "LD0/LLN0.DS01"); + + Assert.True(proof.IsProven); + Assert.Equal(MmsConfiguredStaticActivationProofKind.Proven, proof.Kind); + } + + [Fact] + public void AfterEnable_Rejects_Write_Accepted_But_Readback_False() + { + var snapshot = Snapshot( + success: true, + enabled: "false", + dataSet: "LD0/LLN0.DS01", + stage: "after-enable"); + + var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshot, "LD0/LLN0.DS01"); + + Assert.Equal(MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, proof.Kind); + Assert.False(proof.IsProven); + } + + private static MmsReportRcbSnapshot Snapshot( + bool success, + string enabled, + string dataSet, + string stage, + bool buffered = true, + MmsRcbDataSetProbeState dataSetProbeState = MmsRcbDataSetProbeState.NotAttempted, + string reservationState = "", + string reservationTimeSeconds = "", + string owner = "") + => new() + { + Stage = stage, + IsSuccess = success, + Reference = buffered ? "LD0/LLN0.BR.B01" : "LD0/LLN0.RP.U01", + Buffered = buffered, + EnabledState = enabled, + DataSetReference = dataSet, + DataSetProbeState = dataSetProbeState, + ReservationState = reservationState, + ReservationTimeSeconds = reservationTimeSeconds, + Owner = owner, + Message = success ? "snapshot ok" : "snapshot failed" + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs new file mode 100644 index 00000000..5031f57d --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs @@ -0,0 +1,185 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsConfiguredStaticRcbEligibilityPolicyTests +{ + [Fact] + public void Exact_Explicit_Free_Brcb_Remains_Preferred() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Available, + confidence: MmsRcbAvailabilityConfidence.Exact, + reservationTime: "0"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: false); + + Assert.True(result.IsEligible); + Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, result.Kind); + Assert.False(result.UsesReducedEvidence); + } + + [Fact] + public void Missing_Brcb_Reservation_Evidence_Is_Blocked_By_Default() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: string.Empty); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: false); + + Assert.False(result.IsEligible); + Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.Blocked, result.Kind); + } + + [Fact] + public void Explicit_OptIn_Allows_Only_Genuinely_Missing_Brcb_Reservation_Evidence() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: "-"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.True(result.IsEligible); + Assert.True(result.RequiresWrite); + Assert.True(result.UsesReducedEvidence); + Assert.Equal( + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence, + result.Kind); + } + + [Fact] + public void Malformed_Reservation_Evidence_Is_Not_Downgraded_To_Missing() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: "vendor-weird"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.False(result.IsEligible); + Assert.Contains("neither explicitly free", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Positive_Owner_Evidence_Always_Blocks_Reduced_Path() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: string.Empty, + owner: "01020304"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.False(result.IsEligible); + Assert.Contains("ownership/reservation", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Missing_Urcb_Resv_Can_Use_The_Same_Explicit_Reduced_Policy() + { + var snapshot = StaticSnapshot( + buffered: false, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationState: string.Empty); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.True(result.IsEligible); + Assert.Equal( + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence, + result.Kind); + } + + [Fact] + public void Dynamic_Empty_Rcb_Is_Never_Promoted_By_Configured_Static_Policy() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.NoDataSet, + confidence: MmsRcbAvailabilityConfidence.Exact, + reservationTime: string.Empty, + dataSetReference: string.Empty, + members: Array.Empty()); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.False(result.IsEligible); + Assert.Contains("configured static", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + private static MmsRcbAvailabilitySnapshot StaticSnapshot( + bool buffered, + MmsRcbOperationalAvailability availability, + MmsRcbAvailabilityConfidence confidence, + string reservationTime = "", + string reservationState = "", + string owner = "", + string dataSetReference = "LD0/LLN0.dsA", + IReadOnlyList? members = null) + { + members ??= + [ + new MmsDataSetDirectoryMember + { + Domain = "LD0", + MmsItemName = "GGIO1$ST$Ind1", + UserReference = "LD0/GGIO1.Ind1", + FunctionalConstraint = "ST" + } + ]; + + return new MmsRcbAvailabilitySnapshot + { + Reference = buffered ? "LD0/LLN0.BR.B01" : "LD0/LLN0.RP.U01", + Domain = "LD0", + LogicalNode = "LLN0", + Name = buffered ? "B01" : "U01", + Buffered = buffered, + DataSetReference = dataSetReference, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + EnabledState = "false", + ReservationTimeSeconds = reservationTime, + ReservationState = reservationState, + Owner = owner, + DataSetDirectoryRead = true, + DataSetDirectorySuccess = members.Count > 0, + DataSetMemberCount = members.Count, + DataSetMembers = members, + Availability = availability, + Confidence = confidence + }; + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs index 1d112a7c..b709fbd8 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs @@ -17,6 +17,7 @@ public void DefaultOptions_AreBoundedAndFailClosed() Assert.True(options.AllowCallerOwnedReports); Assert.True(options.AllowStaticBrcb); Assert.True(options.AllowStaticUrcb); + Assert.False(options.AllowConfiguredStaticWithMissingReservationEvidence); Assert.True(options.AllowDynamicBrcb); Assert.True(options.AllowDynamicUrcb); Assert.True(options.AllowPollingFallback); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs index a8c9ee3a..0634f2f4 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs @@ -213,6 +213,76 @@ public void PollingDisabled_LeavesResidualUncovered_AndNeverCallsItMissing() Assert.All(plan.Assignments, x => Assert.Contains("No absence conclusion", x.Reason, StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void ConfiguredStatic_WithMissingReservationEvidence_RemainsPollingByDefault() + { + var signal = Signal( + "LD0/GGIO1.Ind1.stVal", + "LD0/GGIO1$ST$Ind1$stVal", + "ST", + "LD0/LLN0.dsA", + "LD0/GGIO1.Ind1"); + var inventory = Inventory(Rcb("LD0/LLN0.BR.B01", true, "LD0/LLN0.dsA")); + var unknown = Copy( + StaticAvailable( + "LD0/LLN0.BR.B01", + true, + "LD0/LLN0.dsA", + Member("LD0", "GGIO1$ST$Ind1", "LD0/GGIO1.Ind1", "ST")), + availability: MmsRcbOperationalAvailability.Unknown, + reservationTimeSeconds: string.Empty, + confidence: MmsRcbAvailabilityConfidence.Reduced); + + var plan = Build([signal], inventory, Availability(unknown), EmptyDirectory()); + + Assert.Equal(MmsHybridAcquisitionPlanStatus.PollingOnly, plan.Status); + Assert.Equal(0, plan.StaticBrcbSignalCount); + Assert.Equal(1, plan.PollingFallbackSignalCount); + Assert.Equal(0, plan.Capability.StaticUsableCount); + } + + [Fact] + public void Explicit_Reduced_Static_OptIn_Uses_ConfiguredBrcb_BeforePolling() + { + var signal = Signal( + "LD0/GGIO1.Ind1.stVal", + "LD0/GGIO1$ST$Ind1$stVal", + "ST", + "LD0/LLN0.dsA", + "LD0/GGIO1.Ind1"); + var inventory = Inventory(Rcb("LD0/LLN0.BR.B01", true, "LD0/LLN0.dsA")); + var unknown = Copy( + StaticAvailable( + "LD0/LLN0.BR.B01", + true, + "LD0/LLN0.dsA", + Member("LD0", "GGIO1$ST$Ind1", "LD0/GGIO1.Ind1", "ST")), + availability: MmsRcbOperationalAvailability.Unknown, + reservationTimeSeconds: string.Empty, + confidence: MmsRcbAvailabilityConfidence.Reduced); + + var plan = Build( + [signal], + inventory, + Availability(unknown), + EmptyDirectory(), + new MmsHybridReportAcquisitionOptions + { + AllowConfiguredStaticWithMissingReservationEvidence = true + }); + + Assert.Equal(MmsHybridAcquisitionPlanStatus.FullReportCoverage, plan.Status); + Assert.Equal(1, plan.StaticBrcbSignalCount); + Assert.Equal(0, plan.PollingFallbackSignalCount); + Assert.Equal(1, plan.Capability.StaticUsableCount); + var segment = Assert.Single(plan.Segments); + Assert.Equal(MmsHybridAcquisitionKind.StaticBrcb, segment.Kind); + Assert.True(segment.RequiresWrite); + Assert.Contains("reduced", segment.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains(plan.Warnings, warning => + warning.Contains("reduced reservation-evidence", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Capability_DistinguishesDiscoveredCheckedUsableBusyAndUnknownRcbs() { @@ -389,7 +459,10 @@ private static MmsRcbAvailabilitySnapshot Copy( MmsRcbAvailabilitySnapshot source, MmsRcbOperationalAvailability? availability = null, string? enabledState = null, - string? reservationState = null) + string? reservationState = null, + string? reservationTimeSeconds = null, + string? owner = null, + MmsRcbAvailabilityConfidence? confidence = null) => new() { CheckedAtUtc = source.CheckedAtUtc, @@ -410,15 +483,15 @@ private static MmsRcbAvailabilitySnapshot Copy( OptionalFields = source.OptionalFields, EnabledState = enabledState ?? source.EnabledState, ReservationState = reservationState ?? source.ReservationState, - ReservationTimeSeconds = source.ReservationTimeSeconds, - Owner = source.Owner, + ReservationTimeSeconds = reservationTimeSeconds ?? source.ReservationTimeSeconds, + Owner = owner ?? source.Owner, DataSetDirectoryRead = source.DataSetDirectoryRead, DataSetDirectorySuccess = source.DataSetDirectorySuccess, DataSetIsDeletable = source.DataSetIsDeletable, DataSetMemberCount = source.DataSetMemberCount, DataSetMembers = source.DataSetMembers, Availability = availability ?? source.Availability, - Confidence = source.Confidence, + Confidence = confidence ?? source.Confidence, Reason = source.Reason, ProbeDiagnostics = source.ProbeDiagnostics }; diff --git a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs index 14ec1f56..3fd4cb58 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs @@ -4,6 +4,53 @@ namespace AR.Iec61850.Tests.Mms; public sealed class MmsPersistentReportRoutingTests { + // Synthetic typed MMS evidence: no external packet capture or station data. + [Fact] + public void ControlInformationReportsCannotEnterSingleRcbFallback() + { + foreach (var value in new[] + { + MmsDataValue.Boolean(true), + MmsDataValue.Structure(new[] { MmsDataValue.VisibleString("LD0/CSWI1$CO$Pos$Oper"), MmsDataValue.Integer(0) }) + }) + { + var report = new MmsInformationReport + { + IsSuccess = true, + VariableReferences = new[] { "LD0/CSWI1$CO$Pos$Oper" }, + Items = new[] { new MmsInformationReportItem { Value = value } } + }; + Assert.False(MmsClientSession.HasRcbReportHeader(report)); + } + } + + [Fact] + public void RcbHeaderDoesNotRequireOptionalSequenceOrDatasetFields() + { + var report = new MmsInformationReport + { + IsSuccess = true, + Items = new[] + { + new MmsInformationReportItem { Value = MmsDataValue.VisibleString("RPT_A") }, + new MmsInformationReportItem { Value = MmsDataValue.BitString(6, new byte[] { 0, 0 }) }, + new MmsInformationReportItem { Value = MmsDataValue.BitString(7, new byte[] { 0x80 }) }, + new MmsInformationReportItem { Value = MmsDataValue.Boolean(true) } + } + }; + Assert.True(MmsClientSession.HasRcbReportHeader(report)); + Assert.False(MmsClientSession.HasRcbReportHeader(new MmsInformationReport())); + Assert.False(MmsClientSession.HasRcbReportHeader(new MmsInformationReport + { + IsSuccess = true, + Items = new[] + { + new MmsInformationReportItem { Value = MmsDataValue.Boolean(true) }, + new MmsInformationReportItem { Value = MmsDataValue.BitString(6, new byte[] { 0, 0 }) } + } + })); + } + [Fact] public void Selector_RoutesByExactReportIdBeforeDataSetProjection() { @@ -19,6 +66,128 @@ public void Selector_RoutesByExactReportIdBeforeDataSetProjection() Assert.Equal("exact RptID", evidence); } + [Fact] + public void Selector_RejectsContradictoryDomainQualifiedDataSetWithExactReportId() + { + var first = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + var second = CreateSession("URCBA02", "RPT_B", "IEDLD0/LLN0.DS_B"); + + var selected = MmsClientSession.SelectPersistentReportMonitor( + [first, second], + new MmsReportHeader { ReportId = "RPT_A", DataSetReference = "IEDLD0/LLN0$DS_B" }, + out var evidence); + + Assert.Null(selected); + Assert.Contains("RptID/DatSet conflict", evidence); + } + + [Fact] + public void Selector_RejectsUnknownFullDataSetDespiteMatchingReportId() + { + var first = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + var second = CreateSession("URCBA02", "RPT_B", "IEDLD0/LLN0.DS_B"); + + var selected = MmsClientSession.SelectPersistentReportMonitor( + [first, second], + new MmsReportHeader { ReportId = "RPT_A", DataSetReference = "OTHERLD/LLN0$DS_X" }, + out var evidence); + + Assert.Null(selected); + Assert.Contains("quarantined", evidence); + } + + [Fact] + public void Selector_SingleActiveMonitor_DoesNotBypassDataSetConsistency() + { + var session = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + + var selected = MmsClientSession.SelectPersistentReportMonitor( + [session], + new MmsReportHeader { ReportId = "UNRECOGNIZED", DataSetReference = "OTHERLD/LLN0.DS_X" }, + out var evidence); + + Assert.Null(selected); + Assert.Contains("single-monitor DatSet conflict", evidence); + } + + [Fact] + public void Selector_PreservesValidNoDataSetAndVendorShortDataSetCompatibility() + { + var session = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + + var missing = MmsClientSession.SelectPersistentReportMonitor( + [session], new MmsReportHeader { ReportId = "RPT_A" }, out var missingEvidence); + var shortName = MmsClientSession.SelectPersistentReportMonitor( + [session], + new MmsReportHeader { ReportId = "RPT_A", DataSetReference = "LLN0$DS_A" }, + out var shortEvidence); + + Assert.Same(session, missing); + Assert.Same(session, shortName); + Assert.Equal("exact RptID", missingEvidence); + Assert.Equal("exact RptID", shortEvidence); + } + + [Theory] + [InlineData("LLN0$DS_B")] + [InlineData("OTHERLN$DS_A")] + [InlineData("DS_B")] + public void Selector_QuarantinesContradictoryAbbreviatedDataSetEvenWithExactRptId(string advertised) + { + var a = CreateSession("BrcbA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + var b = CreateSession("BrcbB01", "RPT_B", "IEDLD0/LLN0.DS_B"); + var result = MmsClientSession.SelectPersistentReportMonitor( + [a, b], new MmsReportHeader { ReportId = "RPT_A", DataSetReference = advertised }, + out var evidence); + Assert.Null(result); + Assert.Contains("RptID/DatSet conflict", evidence); + } + + [Theory] + [InlineData("LLN0$DS_A")] + [InlineData("DS_A")] + [InlineData("IEDLD0/LLN0$DS_A")] + public void Selector_StillAcceptsConsistentShortAndFullDataSetNames(string advertised) + { + var a = CreateSession("BrcbA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + var b = CreateSession("BrcbB01", "RPT_B", "IEDLD0/LLN0.DS_B"); + var result = MmsClientSession.SelectPersistentReportMonitor( + [b, a], new MmsReportHeader { ReportId = "RPT_A", DataSetReference = advertised }, + out var evidence); + Assert.Same(a, result); + Assert.Equal("exact RptID", evidence); + } + + [Fact] + public void Selector_FourIndependentSg7ConfiguredRcbsRemainDeterministicAcrossInputOrder() + { + var sessions = new[] + { + CreateSession("BrcbD01", "RPT_D", "SG7_BCUMaster/LLN0.TT3DataSet01"), + CreateSession("BrcbI01", "RPT_I", "SG7_BCUMaster/LLN0.TT6DataSet4"), + CreateSession("BrcbB01", "RPT_B", "SG7_BCUMaster/LLN0.TT3DataSet02"), + CreateSession("BrcbA01", "RPT_A", "SG7_BCUMaster/LLN0.TT6DataSet3") + }; + var reversed = sessions.Reverse().ToArray(); + foreach (var expected in sessions) + { + var header = new MmsReportHeader + { + ReportId = expected.ReportControl.ReportId, + DataSetReference = expected.Plan.DataSetReference.Replace('.', '$') + }; + Assert.Same(expected, MmsClientSession.SelectPersistentReportMonitor( + sessions, header, out _)); + Assert.Same(expected, MmsClientSession.SelectPersistentReportMonitor( + reversed, header, out _)); + } + Assert.Null(MmsClientSession.SelectPersistentReportMonitor( + sessions, + new MmsReportHeader { ReportId = "RPT_I", DataSetReference = "LLN0$TT3DataSet02" }, + out var mismatch)); + Assert.Contains("quarantined", mismatch); + } + [Fact] public void Selector_RoutesByExactDataSetWhenReportIdIsUnavailable() { diff --git a/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs new file mode 100644 index 00000000..cd5ed253 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs @@ -0,0 +1,123 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsRcbAvailabilityTargetSelectorTests +{ + [Fact] + public void Empty_Target_Set_Preserves_Broad_Diagnostic_Mode() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.B01", buffered: true), + Candidate("LD0/LLN0.RP.U01", buffered: false)); + + var selection = MmsRcbAvailabilityTargetSelector.Select( + inventory, + new MmsRcbAvailabilityOptions()); + + Assert.False(selection.TargetFilterApplied); + Assert.Equal(2, selection.Candidates.Count); + Assert.Empty(selection.Warnings); + } + + [Fact] + public void Exact_Targets_Probe_Only_Selected_Live_Rcbs() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.B01", buffered: true), + Candidate("LD0/LLN0.BR.B02", buffered: true), + Candidate("LD0/LLN0.RP.U01", buffered: false)); + + var selection = MmsRcbAvailabilityTargetSelector.Select( + inventory, + new MmsRcbAvailabilityOptions + { + TargetReportControlReferences = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "LD0/LLN0$BR$B02", + "LD0/LLN0.RP.U01" + } + }); + + Assert.True(selection.TargetFilterApplied); + Assert.Equal(2, selection.RequestedTargetCount); + Assert.Equal(2, selection.MatchedTargetCount); + Assert.Equal( + new[] { "LD0/LLN0.BR.B02", "LD0/LLN0.RP.U01" }, + selection.Candidates.Select(candidate => candidate.Reference).OrderBy(x => x, StringComparer.Ordinal).ToArray()); + } + + [Fact] + public void Missing_Target_Is_Not_Broadened_To_Sibling_Rcb() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", buffered: true), + Candidate("LD0/LLN0.BR.Rpt02", buffered: true)); + + var selection = MmsRcbAvailabilityTargetSelector.Select( + inventory, + new MmsRcbAvailabilityOptions + { + TargetReportControlReferences = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "LD0/LLN0.BR.Rpt03" + } + }); + + Assert.Empty(selection.Candidates); + Assert.Equal(0, selection.MatchedTargetCount); + Assert.Contains( + selection.Warnings, + warning => warning.Contains("not broadened", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Targeted_Mode_Still_Respects_Hard_Maximum() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.B01", buffered: true), + Candidate("LD0/LLN0.BR.B02", buffered: true), + Candidate("LD0/LLN0.BR.B03", buffered: true)); + + var targets = inventory.ReportControls + .Select(candidate => candidate.Reference) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var selection = MmsRcbAvailabilityTargetSelector.Select( + inventory, + new MmsRcbAvailabilityOptions + { + MaxReportControls = 2, + TargetReportControlReferences = targets + }); + + Assert.Equal(3, selection.EligibleCountBeforeLimit); + Assert.Equal(2, selection.Candidates.Count); + Assert.Contains( + selection.Warnings, + warning => warning.Contains("bounded to 2 of 3", StringComparison.OrdinalIgnoreCase)); + } + + private static MmsReportInventory Inventory(params MmsReportControlCandidate[] candidates) + { + var inventory = new MmsReportInventory(); + inventory.ReportControls.AddRange(candidates); + return inventory; + } + + private static MmsReportControlCandidate Candidate(string reference, bool buffered) + { + var slash = reference.IndexOf('/'); + var tail = reference[(slash + 1)..].Split('.'); + return new MmsReportControlCandidate + { + Domain = reference[..slash], + LogicalNode = tail[0], + FunctionalConstraint = tail[1], + Name = tail[2], + Reference = reference, + Buffered = buffered, + DataSetReference = "LD0/LLN0.Events" + }; + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs index e00df8d8..41d121ca 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs @@ -84,6 +84,30 @@ public async Task Pump_FaultsPendingInvokeWhenReaderFails() Assert.Equal(0, pump.PendingOperationCount); } + [Fact] + public async Task Pump_CanonicalOverflow_FaultsPendingConfirmedWorkAndRetainsQueuedReports() + { + var channel = Channel.CreateUnbounded(); + for (var i = 0; i < 4; i++) + await channel.Writer.WriteAsync(BuildInformationReport()); + + var router = new MmsReceiveRouter(canonicalReportCapacity: 2); + var pump = new MmsReceivePump(router, token => channel.Reader.ReadAsync(token).AsTask()); + using var pending = pump.RegisterConfirmedOperation(9); + pump.Start(); + + var failure = await Assert.ThrowsAsync( + () => pending.WaitAsync(new CancellationTokenSource(TimeSpan.FromSeconds(2)).Token)); + await pump.StopAsync(); + + Assert.Contains("bounded frame/byte capacity", failure.Message); + Assert.Equal(2, router.QueuedInformationReportCount); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.Throws(() => router.TryDequeueInformationReport(out _)); + Assert.Contains("IOException", pump.LastFaultMessage); + } + private static byte[] BuildConfirmedReadResponse(int invokeId) { var data = MmsDataCodec.Encode(MmsDataValue.Boolean(true)); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs index 31451bd1..80f53bc8 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs @@ -99,6 +99,160 @@ public async Task Route_FansOutInformationReportWithoutStealingLegacyQueue() Assert.True(queued.IsInformationReport); } + [Fact] + public async Task SubscriberBurstOverCapacity_FaultsWithoutSilentDrop_WhileCanonicalFifoRetainsEveryFrame() + { + var router = new MmsReceiveRouter(); + await using var slow = router.SubscribeInformationReports(capacity: 8); + var sent = Enumerable.Range(1, 80).Select(BuildInformationReport).ToArray(); + + foreach (var bytes in sent) + Assert.Equal(MmsReceiveRouteAction.QueuedInformationReport, router.Route(bytes).Action); + + // The bounded observer has *all eight original* frames, not the eight + // newest; its terminal error explicitly declares the missing 72. + for (var i = 0; i < 8; i++) + { + Assert.True(slow.Reader.TryRead(out var envelope)); + Assert.Equal(sent[i], envelope.PresentationPayload); + } + + Assert.False(slow.Reader.TryRead(out _)); + var overflow = await Assert.ThrowsAsync(async () => + await slow.Reader.Completion.WaitAsync(TimeSpan.FromSeconds(2))); + Assert.Contains("exceeded its bounded capacity", overflow.Message); + + // The actual static-monitor source of truth MUST NEVER lose those + // frames when an optional observer has fallen behind. + Assert.Equal(80, router.QueuedInformationReportCount); + for (var i = 0; i < sent.Length; i++) + { + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.Equal(sent[i], queued.PresentationPayload); + } + Assert.False(router.TryDequeueInformationReport(out _)); + } + + [Fact] + public async Task SubscriberOverflow_IsolatedFromFastSubscriberAndConfirmedMmsResponses() + { + var router = new MmsReceiveRouter(); + await using var slow = router.SubscribeInformationReports(capacity: 1); + await using var fast = router.SubscribeInformationReports(capacity: 1); + + for (var i = 1; i <= 16; i++) + { + var frame = BuildInformationReport(i); + router.Route(frame); + Assert.True(fast.Reader.TryRead(out var received)); + Assert.Equal(frame, received.PresentationPayload); + } + + router.Route(BuildConfirmedReadResponse(invokeId: 42)); + Assert.True(router.TryDequeueConfirmedResult(42, out var confirmation)); + Assert.Equal(42, confirmation.InvokeId); + Assert.False(fast.Reader.Completion.IsCompleted); + + Assert.True(slow.Reader.TryRead(out var original)); + Assert.Equal(BuildInformationReport(1), original.PresentationPayload); + await Assert.ThrowsAsync(async () => + await slow.Reader.Completion.WaitAsync(TimeSpan.FromSeconds(2))); + + // Faulting one slow observer cannot corrupt or drop canonical report + // order, even while confirmed-service responses arrive. + for (var i = 1; i <= 16; i++) + { + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.Equal(BuildInformationReport(i), queued.PresentationPayload); + } + } + + [Fact] + public async Task SubscriberAtCapacity_DoesNotFalseFail_AndCanReceiveAfterDrain() + { + var router = new MmsReceiveRouter(); + await using var sub = router.SubscribeInformationReports(capacity: 2); + var a = BuildInformationReport(1); + var b = BuildInformationReport(2); + var c = BuildInformationReport(3); + router.Route(a); + router.Route(b); + + Assert.False(sub.Reader.Completion.IsCompleted); + Assert.True(sub.Reader.TryRead(out var first)); + Assert.Equal(a, first.PresentationPayload); + router.Route(c); + + Assert.True(sub.Reader.TryRead(out var second)); + Assert.True(sub.Reader.TryRead(out var third)); + Assert.Equal(b, second.PresentationPayload); + Assert.Equal(c, third.PresentationPayload); + Assert.False(sub.Reader.Completion.IsCompleted); + } + + [Fact] + public async Task WaitForInformationReportAsync_ReturnsImmediately_WhenQueueAlreadyHasReport() + { + var router = new MmsReceiveRouter(); + router.Route(BuildInformationReport()); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var available = await router.WaitForInformationReportAsync( + TimeSpan.FromSeconds(1), + timeout.Token); + + Assert.True(available); + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.True(queued.IsInformationReport); + } + + [Fact] + public async Task WaitForInformationReportAsync_WakesFromFutureReport_WithoutPolling() + { + var router = new MmsReceiveRouter(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var wait = router.WaitForInformationReportAsync( + TimeSpan.FromSeconds(1), + timeout.Token); + + await Task.Yield(); + Assert.False(wait.IsCompleted); + + router.Route(BuildInformationReport()); + + Assert.True(await wait); + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.True(queued.IsInformationReport); + } + + [Fact] + public async Task WaitForInformationReportAsync_CoalescesBurstIntoSingleWake() + { + var router = new MmsReceiveRouter(); + router.Route(BuildInformationReport()); + router.Route(BuildInformationReport()); + + Assert.True(await router.WaitForInformationReportAsync(TimeSpan.FromSeconds(1))); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.True(router.TryDequeueInformationReport(out _)); + + var staleWake = await router.WaitForInformationReportAsync(TimeSpan.FromMilliseconds(20)); + Assert.False(staleWake); + } + + [Fact] + public async Task WaitForInformationReportAsync_TimesOut_WhenNoReportArrives() + { + var router = new MmsReceiveRouter(); + + var available = await router.WaitForInformationReportAsync( + TimeSpan.FromMilliseconds(20)); + + Assert.False(available); + Assert.Equal(0, router.QueuedInformationReportCount); + } + [Fact] public async Task Clear_FaultsActiveInformationReportSubscribers() { @@ -112,6 +266,63 @@ await Assert.ThrowsAsync(async () => await subscription.Reader.Completion.WaitAsync(timeout.Token)); } + [Fact] + public void CanonicalCapacityExceeded_PreservesOriginalFifoAndFailsClosedUntilClear() + { + var router = new MmsReceiveRouter(canonicalReportCapacity: 3); + var original = Enumerable.Range(1, 3).Select(BuildInformationReport).ToArray(); + foreach (var report in original) + router.Route(report); + + var failure = Assert.Throws(() => router.Route(BuildInformationReport(4))); + Assert.Contains("bounded frame/byte capacity", failure.Message); + Assert.Equal(3, router.QueuedInformationReportCount); + + for (var i = 0; i < original.Length; i++) + { + Assert.True(router.TryDequeueInformationReport(out var report)); + Assert.Equal(original[i], report.PresentationPayload); + } + + Assert.Throws(() => router.TryDequeueInformationReport(out _)); + Assert.Throws(() => router.Route(BuildConfirmedReadResponse(42))); + router.Clear(); + + Assert.Equal(0, router.QueuedInformationReportCount); + router.Route(BuildInformationReport(5)); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.False(router.TryDequeueInformationReport(out _)); + } + + [Fact] + public void CanonicalByteCapacity_RejectsThirdReportWithoutUnboundedRetention() + { + var report = BuildInformationReport(1); + var router = new MmsReceiveRouter( + canonicalReportCapacity: 10, + canonicalReportByteCapacity: 2L * report.Length); + router.Route(report); + router.Route(report); + + Assert.Throws(() => router.Route(report)); + Assert.Equal(2, router.QueuedInformationReportCount); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.Throws(() => router.TryDequeueInformationReport(out _)); + } + + [Fact] + public async Task CanonicalTerminalFault_WakesWaitingMonitorWithoutFalseTimeout() + { + var router = new MmsReceiveRouter(); + var waiting = router.WaitForInformationReportAsync(TimeSpan.FromSeconds(2)); + router.FaultCanonicalInformationReports(new IOException("scripted association failure")); + + var failure = await Assert.ThrowsAsync(() => waiting); + Assert.Contains("continuity is unverified", failure.Message); + Assert.Contains("scripted association failure", failure.InnerException?.Message); + } + private static byte[] BuildConfirmedReadResponse(int invokeId) { var data = MmsDataCodec.Encode(MmsDataValue.Boolean(true)); @@ -126,12 +337,12 @@ private static byte[] BuildConfirmedReadResponse(int invokeId) return MmsPresentation.WrapIsoPresentationPData(mms); } - private static byte[] BuildInformationReport() + private static byte[] BuildInformationReport(int sequence = 0) { var variableAccessSpecification = BerWriter.EncodeTlv( 0xA1, BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii("LD0")) - .Concat(BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii("LLN0$Events"))) + .Concat(BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii(sequence == 0 ? "LLN0$Events" : $"LLN0$Events{sequence:D3}"))) .ToArray()); var listOfAccessResult = BerWriter.EncodeTlv(0xA0, MmsDataCodec.Encode(MmsDataValue.Boolean(true))); var informationReport = BerWriter.EncodeTlv( diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs new file mode 100644 index 00000000..0c16a681 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs @@ -0,0 +1,158 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsReportControlSemanticWriteTests +{ + [Fact] + public void ResvTms_Uses_Signed_Mms_Integer_On_Wire() + { + var write = MmsReportControlSemanticWrite.ReservationTime(60); + + Assert.Equal("ResvTms", write.Attribute); + Assert.Equal(MmsDataKind.Integer, write.ExpectedKind); + Assert.Equal(MmsDataKind.Integer, write.Value.Kind); + Assert.Equal(60L, write.Value.Value); + + var encoded = MmsDataCodec.Encode(write.Value); + + Assert.NotEmpty(encoded); + Assert.Equal(0x85, encoded[0]); // MMS Data integer [5], never unsigned [6] (0x86). + } + + [Theory] + [InlineData(-32768)] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + [InlineData(60)] + [InlineData(32767)] + public void ResvTms_Accepts_Signed_Int16_Range(int seconds) + { + var write = MmsReportControlSemanticWrite.ReservationTime(seconds); + + Assert.Equal(MmsDataKind.Integer, write.Value.Kind); + Assert.Equal((long)seconds, write.Value.Value); + } + + [Theory] + [InlineData(-32769)] + [InlineData(32768)] + public void ResvTms_Rejects_Values_Outside_Signed_Int16_Range(int seconds) + { + Assert.Throws( + () => MmsReportControlSemanticWrite.ReservationTime(seconds)); + } + + [Fact] + public void Boolean_Rcb_Fields_Are_Bound_To_Mms_Boolean() + { + var values = new[] + { + MmsReportControlSemanticWrite.ReportEnable(true), + MmsReportControlSemanticWrite.Reservation(true), + MmsReportControlSemanticWrite.GeneralInterrogation(true) + }; + + Assert.Equal(new[] { "RptEna", "Resv", "GI" }, values.Select(x => x.Attribute)); + Assert.All(values, write => + { + Assert.Equal(MmsDataKind.Boolean, write.ExpectedKind); + Assert.Equal(MmsDataKind.Boolean, write.Value.Kind); + Assert.Equal(true, write.Value.Value); + Assert.Equal(0x83, MmsDataCodec.Encode(write.Value)[0]); // MMS Data boolean [3]. + }); + } + + [Fact] + public void Live_Type_Compatibility_Is_Exact_And_Does_Not_Guess() + { + var integer = new MmsTypeSpecificationNode { MmsType = "integer" }; + var unsigned = new MmsTypeSpecificationNode { MmsType = "unsigned" }; + var boolean = new MmsTypeSpecificationNode { MmsType = "boolean" }; + + var resvTms = MmsReportControlSemanticWrite.ReservationTime(60); + Assert.True(resvTms.IsCompatibleWith(integer)); + Assert.False(resvTms.IsCompatibleWith(unsigned)); + Assert.False(resvTms.IsCompatibleWith(boolean)); + + var rptEna = MmsReportControlSemanticWrite.ReportEnable(true); + Assert.True(rptEna.IsCompatibleWith(boolean)); + Assert.False(rptEna.IsCompatibleWith(integer)); + Assert.False(rptEna.IsCompatibleWith(null)); + } + [Fact] + public void Exact_Live_Type_Mismatch_Is_A_Positive_Blocker() + { + var write = MmsReportControlSemanticWrite.ReservationTime(60); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$ResvTms", "BR"), + TypeSpecification = new MmsTypeSpecificationNode + { + MmsType = "unsigned", + SclBType = "INT32U" + } + }; + + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + "LD0/LLN0.BR.Brcb01.ResvTms [BR]", + live); + + Assert.Equal(MmsReportSemanticTypeEvidenceStatus.ExactMismatch, evidence.Status); + Assert.True(evidence.IsExact); + Assert.False(evidence.AllowsMutation); + Assert.Equal("integer", evidence.ExpectedMmsType); + Assert.Equal("unsigned", evidence.LiveMmsType); + } + + [Fact] + public void Missing_Live_Type_Evidence_Does_Not_Become_A_False_Blocker() + { + var write = MmsReportControlSemanticWrite.ReportEnable(true); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = false, + Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$RptEna", "BR"), + Message = "GetVariableAccessAttributes unsupported by this server." + }; + + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + "LD0/LLN0.BR.Brcb01.RptEna [BR]", + live); + + Assert.Equal(MmsReportSemanticTypeEvidenceStatus.Unavailable, evidence.Status); + Assert.False(evidence.IsExact); + Assert.True(evidence.AllowsMutation); + Assert.Contains("IEC semantic contract", evidence.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Exact_Live_Type_Match_Confirms_Semantic_Mutation() + { + var write = MmsReportControlSemanticWrite.ReportEnable(true); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$RptEna", "BR"), + TypeSpecification = new MmsTypeSpecificationNode + { + MmsType = "boolean", + SclBType = "BOOLEAN" + } + }; + + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + "LD0/LLN0.BR.Brcb01.RptEna [BR]", + live); + + Assert.Equal(MmsReportSemanticTypeEvidenceStatus.ExactMatch, evidence.Status); + Assert.True(evidence.IsExact); + Assert.True(evidence.AllowsMutation); + } + +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs index 1844f73e..d4ac0166 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs @@ -27,6 +27,7 @@ public void BuildInventoryFindsDataSetsAndReportControls() var inventory = MmsReportDiscoveryMapper.BuildInventory(snapshot); + Assert.Equal(MmsReportInventoryAuthority.LiveMmsObserved, inventory.Authority); Assert.Single(inventory.DataSets); Assert.Equal("IED1LD0/LLN0.DataSet", inventory.DataSets[0].Reference); Assert.Equal(2, inventory.ReportControls.Count); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs index e1e84ef1..eabc10d6 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs @@ -108,4 +108,70 @@ public void Diagnostics_treats_sequence_zero_after_nonzero_as_reset_warning_not_ Assert.Contains(diagnostics.WarningMessages, x => x.Contains("reset-to-zero", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void Diagnostics_surfaces_structured_RCB_type_and_preflight_failures() + { + var writes = new[] + { + new MmsReportAttributeWriteStep + { + Attribute = "ResvTms", + Reference = "LD0/LLN0.BR.B01.ResvTms", + Attempted = true, + IsSuccess = false, + FailureCode = 7, + FailureName = "type-inconsistent", + FailureKind = MmsInteropFailureKind.TypeMismatch, + RecoveryHint = MmsInteropRecoveryHint.RevalidateType, + TypeEvidence = new MmsReportSemanticTypeEvidence + { + Status = MmsReportSemanticTypeEvidenceStatus.Unavailable, + Attribute = "ResvTms", + ExpectedMmsType = "integer", + Message = "Live TypeSpecification unavailable." + } + }, + new MmsReportAttributeWriteStep + { + Attribute = "RptEna", + Reference = "LD0/LLN0.BR.B01.RptEna", + Attempted = false, + IsSuccess = false, + TypeEvidence = new MmsReportSemanticTypeEvidence + { + Status = MmsReportSemanticTypeEvidenceStatus.ExactMismatch, + Attribute = "RptEna", + ExpectedMmsType = "boolean", + LiveMmsType = "integer" + } + }, + new MmsReportAttributeWriteStep + { + Attribute = "GI", + Reference = "LD0/LLN0.BR.B01.GI", + Attempted = true, + IsSuccess = false, + FailureCode = 3, + FailureName = "object-access-denied", + FailureKind = MmsInteropFailureKind.AccessDenied, + RecoveryHint = MmsInteropRecoveryHint.FailClosed + } + }; + + var diagnostics = MmsReportSessionDiagnostics.Analyze( + Array.Empty(), + writeSteps: writes); + + Assert.Equal("FAIL", diagnostics.OverallStatus); + Assert.Equal(3, diagnostics.WriteFailureCount); + Assert.Equal(1, diagnostics.WritePreflightBlockedCount); + Assert.Equal(1, diagnostics.WriteTypeMismatchCount); + Assert.Equal(1, diagnostics.WriteAccessDeniedCount); + Assert.Equal(1, diagnostics.WriteTypeEvidenceConflictCount); + Assert.Equal(1, diagnostics.WriteTypeEvidenceUnavailableCount); + Assert.Contains("typeMismatch=1", diagnostics.Summary, StringComparison.Ordinal); + Assert.Contains("preflightBlocked=1", diagnostics.Summary, StringComparison.Ordinal); + } + + } diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs index 3a109e77..0dff372b 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs @@ -193,6 +193,94 @@ public void Project_Does_Not_Misclassify_NonBoolean_Three_Field_Struct_As_Status Assert.Equal("direct", update.ProjectionStatus); } + [Theory] + [InlineData("C264CONTROL/CILO1.Mod", true)] + [InlineData("C264CONTROL/CSWI1.Mod", true)] + [InlineData("C264SYSTEM/LPHD1.PhyHealth", false)] + [InlineData("C264CONTROL/CILO1.Beh", false)] + public void C264_Typed_ST_Status_FCD_Projects_StVal_Quality_Time( + string memberReference, bool hasStSeld) + { + // Exact IEC 61850 ST declaration order in the supplied C264 IID: + // enumerated stVal, quality q, UTC t, optionally Boolean stSeld. + // The old generic projector left Mod and PhyHealth as Structure(...). + var at = new DateTimeOffset(2026, 10, 17, 2, 24, 5, TimeSpan.Zero); + var children = new List + { + MmsDataValue.Integer(1), + MmsDataValue.BitString(3, [0x00, 0x00]), + MmsDataValue.UtcTime(new Iec61850UtcTime(at, 0)) + }; + if (hasStSeld) + children.Add(MmsDataValue.Boolean(false)); + var frame = new MmsReportFrame + { + ReceivedAt = at, + Values = + [ + new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = memberReference, + FunctionalConstraint = "ST" + }, + Value = MmsDataValue.Structure(children), + ReasonForInclusion = ["general-interrogation"] + } + ] + }; + + var projected = MmsReportValueProjector.Project(frame); + var update = Assert.Single(projected.Updates); + Assert.Equal(memberReference + ".stVal", update.Reference); + Assert.Equal("1", update.Value); // no invented on/off enum mapping + Assert.Equal("good", update.Quality); + Assert.Contains("2026-10-17", update.Timestamp, StringComparison.Ordinal); + Assert.True(update.HasValue); + Assert.True(update.HasQuality); + Assert.True(update.HasTimestamp); + Assert.Equal("projected-typed-enumerated-status", update.ProjectionStatus); + Assert.DoesNotContain(projected.Warnings, x => x.StartsWith("REPORT_RAW_STRUCT", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("MX", false, false, false)] + [InlineData("ST", true, false, false)] + [InlineData("ST", false, true, false)] + [InlineData("ST", false, false, true)] + public void Typed_Enum_Status_Rejects_Unverified_Tuple_Shape( + string fc, bool wrongQuality, bool wrongTimestamp, bool wrongFourth) + { + var at = new DateTimeOffset(2026, 10, 17, 2, 24, 5, TimeSpan.Zero); + var payload = MmsDataValue.Structure([ + MmsDataValue.Integer(1), + wrongQuality ? MmsDataValue.Boolean(false) : MmsDataValue.BitString(3, [0x00, 0x00]), + wrongTimestamp ? MmsDataValue.Boolean(false) : MmsDataValue.UtcTime(new Iec61850UtcTime(at, 0)), + wrongFourth ? MmsDataValue.Integer(99) : MmsDataValue.Boolean(false) + ]); + var frame = new MmsReportFrame + { + ReceivedAt = at, + Values = [new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = "C264CONTROL/CILO1.Mod", + FunctionalConstraint = fc + }, + Value = payload + }] + }; + var projected = MmsReportValueProjector.Project(frame); + Assert.DoesNotContain(projected.Updates, + x => x.Reference.Equals("C264CONTROL/CILO1.Mod.stVal", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projected.Warnings, + x => x.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase)); + } + [Theory] [InlineData(0x40, "off")] [InlineData(0x80, "on")] diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 8fb40dc2..4b0b9d0c 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -191,6 +191,183 @@ public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection() update.ProjectionStatus.Equals("semantic-structured-leaf", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void C264_Incomplete_Live_Schema_Still_Projects_Strictly_Typed_Mod_FCD() + { + // C264 smart discovery can expose stVal without type-probing every + // sibling. That partial model must not suppress a fully typed ST + // Report containing stVal, q, t and stSeld. + const string root = "C264CONTROL/CILO1.Mod"; + const string dataSet = "C264CONTROL/LLN0.LLN0BRptStDs"; + var schema = new LiveIedModelDiscoveryDocument + { + IedName = "C264", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "C264CONTROL", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "CILO1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = root, + Name = "Mod", + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = root + ".stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST" + } + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = dataSet, + Domain = "C264CONTROL", + LogicalNode = "LLN0", + Name = "LLN0BRptStDs", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = root, + FunctionalConstraint = "ST", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + }; + var at = new DateTimeOffset(2026, 10, 17, 2, 24, 5, TimeSpan.Zero); + var frame = new MmsReportFrame + { + ReceivedAt = at, + Header = new MmsReportHeader { DataSetReference = dataSet }, + Values = + [ + new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = root, + FunctionalConstraint = "ST" + }, + Value = MmsDataValue.Structure([ + MmsDataValue.Integer(1), + MmsDataValue.BitString(3, [0x00, 0x00]), + MmsDataValue.UtcTime(new Iec61850UtcTime(at, 0)), + MmsDataValue.Boolean(false) + ]) + } + ] + }; + + var projected = MmsSemanticReportValueProjector.Project( + frame, MmsReportSemanticProjectionContext.Create(schema)); + var scalar = Assert.Single(projected.Updates); + Assert.Equal(root + ".stVal", scalar.Reference); + Assert.Equal("good", scalar.Quality); + Assert.True(scalar.HasTimestamp); + Assert.DoesNotContain(projected.Warnings, + x => x.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projected.Updates, + x => x.Value.StartsWith("Structure(", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ExactTypedFcd_SwappedQualityAndTimeIsRejectedWithoutFalseValuePublication() + { + const string root = "C264MEAS/MMXU1.A"; + const string dataSet = "C264MASTER/LLN0.Analog"; + var model = BuildMeasurementPairModel(root, dataSet); + var when = new DateTimeOffset(2026, 10, 20, 10, 0, 0, TimeSpan.Zero); + var invalid = BuildFrame(root, dataSet, + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(12f) }), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(13f) }), + MmsDataValue.UtcTime(new Iec61850UtcTime(when, 0)), + MmsDataValue.BitString(3, new byte[] { 0 })); + var result = MmsSemanticReportValueProjector.Project( + invalid, MmsReportSemanticProjectionContext.Create(model)); + + Assert.Empty(result.Updates); + Assert.Contains(result.Warnings, + warning => warning.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal) && + warning.Contains("expected BitString, received UtcTime", StringComparison.Ordinal)); + // The original received frame remains available to diagnosis. + Assert.Equal(4, invalid.Values[0].Value!.Children.Count); + } + + [Fact] + public void ExactTypedFcd_ValidOrderedQualityAndTimestampRetainProcessProjection() + { + const string root = "C264MEAS/MMXU1.A"; + const string dataSet = "C264MASTER/LLN0.Analog"; + var when = new DateTimeOffset(2026, 10, 20, 10, 0, 0, TimeSpan.Zero); + var valid = BuildFrame(root, dataSet, + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(12f) }), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(13f) }), + MmsDataValue.BitString(3, new byte[] { 0 }), + MmsDataValue.UtcTime(new Iec61850UtcTime(when, 0))); + var result = MmsSemanticReportValueProjector.Project( + valid, MmsReportSemanticProjectionContext.Create(BuildMeasurementPairModel(root, dataSet))); + + Assert.Contains(result.Updates, x => x.Reference == root + ".instMag.f" && x.HasValue); + Assert.Contains(result.Updates, x => x.Reference == root + ".mag.f" && x.HasValue); + Assert.DoesNotContain(result.Warnings, x => x.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal)); + } + + [Fact] + public void ConflictingExactSchemaAndPartialUnknownTypesStayDeterministic() + { + const string root = "C264MEAS/MMXU1.A"; + const string dataSet = "C264MASTER/LLN0.Analog"; + var contradiction = BuildModel(root, dataSet, "MV", new[] + { + Attribute(root + ".instMag.f", "instMag.f", "FLOAT32", "floating-point"), + Attribute(root + ".instMag.f", "instMag.f", "BOOLEAN", "boolean") + }); + var frame = BuildFrame(root, dataSet, + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(12f) })); + var rejected = MmsSemanticReportValueProjector.Project( + frame, MmsReportSemanticProjectionContext.Create(contradiction)); + Assert.Empty(rejected.Updates); + Assert.Contains(rejected.Warnings, x => x.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal)); + + var unknown = BuildModel(root, dataSet, "MV", new[] + { + new LiveIedDataAttributeModel + { + ObjectReference = root + ".instMag.f", + AttributePath = "instMag.f", + FunctionalConstraint = "MX", + MmsType = "", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Unknown + } + }); + var fallback = MmsSemanticReportValueProjector.Project( + frame, MmsReportSemanticProjectionContext.Create(unknown)); + Assert.Contains(fallback.Updates, x => x.Reference == root + ".instMag.f"); + Assert.DoesNotContain(fallback.Warnings, x => x.StartsWith("REPORT_SEMANTIC_REJECTED:", StringComparison.Ordinal)); + } + private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( string objectReference, string dataSetReference) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs index f69ed5f8..71e49705 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs @@ -26,6 +26,49 @@ public void ResolveWindow_RespectsNegotiatedLimitAndConservativeUnknownFallback( Assert.Equal(1, MmsSmartDiscoveryPolicy.ResolveWindow(0, 0, null)); } + [Theory] + [InlineData(5, 64, 5)] + [InlineData(8, 64, 8)] + [InlineData(16, 64, 8)] + [InlineData(1, 64, 1)] + [InlineData(5, 2, 2)] + [InlineData(5, 0, 0)] + [InlineData(0, 64, 1)] + public void ReportEnrichment_UsesOnlyNegotiatedWindowAndFiniteCandidateCount( + int effectiveWindow, int candidates, int expected) + { + Assert.Equal(expected, + MmsSmartDiscoveryPolicy.ResolveReportEnrichmentWorkers( + effectiveWindow, candidates)); + } + + [Fact] + public void ReportEnrichment_PreservesExactSequentialPerRcbFieldsAndKpiCapture() + { + // Contract locks the high-throughput change to read-only RCBs. + // Any change to required attributes, single-RCB order, or the + // existing observable MMS request accounting must be reviewed. + var path = new DirectoryInfo(AppContext.BaseDirectory); + string? source = null; + while (path is not null) + { + var file = Path.Combine(path.FullName, + "src", "AR.Iec61850", "Mms", "MmsClientSession.SmartDiscovery.cs"); + if (File.Exists(file)) { source = File.ReadAllText(file); break; } + path = path.Parent; + } + Assert.NotNull(source); + Assert.Contains("EnrichSingleReportControlSmartAsync", source); + Assert.Contains("ResolveReportEnrichmentWorkers(", source); + Assert.Contains("await Task.WhenAll(", source); + foreach (var field in new[] { + "DatSet", "RptID", "ConfRev", "IntgPd", + "BufTm", "TrgOps", "OptFlds", "ResvTms", "Resv" }) + Assert.Contains($"TryReadReportAttributeSmartAsync(reportControl, \"{field}\"", source); + Assert.Contains("ObserveSmartDiscoveryRequest(", source); + Assert.DoesNotContain("WriteSingleVariableAsync(", source); + } + [Fact] public void DomainPriority_ReordersOnlyAlreadyObservedDomains() { @@ -192,6 +235,54 @@ public void DataObjectFallbackRoot_StopsAtLnFcDo() Assert.Equal("MX", root.FunctionalConstraint); } + + [Fact] + public void DataSetClosure_SelectsOnlyObservedMissingStMxRoots_NoDuplicateOrForeignLn() + { + var directory = BuildDirectory(); + var dataset = new MmsDataSetDirectoryResult + { + IsSuccess = true, + Members = + [ + Member("LD0", "eveGGIO1", "ST", "Ind11"), + Member("LD0", "eveGGIO1", "ST", "Ind13"), + Member("LD0", "CSWI1", "ST", "Pos"), + Member("LD0", "CSWI16", "ST", "Pos"), + Member("LD0", "MMXU1", "MX", "A"), + Member("LD0", "FOREIGN1", "CO", "Oper"), + Member("FAKE", "eveGGIO2", "ST", "Ind1") + ] + }; + var already = new[] + { + new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "MMXU1", "") + }, + new MmsVariableAccessAttributesResult + { + IsSuccess = false, + Reference = new MmsObjectReference("LD0", "CSWI1", "") + } + }; + var selected = MmsSmartTypeProbePolicy.SelectUnprobedDataSetLogicalNodeRoots( + directory, [dataset], already); + Assert.Equal(2, selected.Length); + Assert.Equal(["CSWI16", "eveGGIO1"], selected.Select(x => x.Item).ToArray()); + } + + private static MmsDataSetDirectoryMember Member( + string domain, string ln, string fc, string dataObject) => new() + { + Domain = domain, + LogicalNode = ln, + FunctionalConstraint = fc, + DataObjectPath = dataObject, + MmsItemName = ln + "$" + fc + "$" + dataObject, + UserReference = domain + "/" + ln + "." + dataObject + }; private static MmsIedModelDirectory BuildDirectory() { var snapshot = new MmsDiscoverySnapshot diff --git a/tests/AR.Iec61850.Tests/Mms/MmsWriteAndDynamicDataSetTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsWriteAndDynamicDataSetTests.cs index 7cf9c12c..18ddc423 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsWriteAndDynamicDataSetTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsWriteAndDynamicDataSetTests.cs @@ -52,6 +52,36 @@ public void DecodeWriteResponse_AcceptsSuccessResult() Assert.Single(result.AccessResults); } + [Fact] + public void DecodeWriteResponse_RejectsTwoResultsForSingleVariableRequest() + { + var mms = Convert.FromHexString("A109020109A50481008100"); + var result = MmsWriteResponseDecoder.Decode(MmsPresentation.WrapIsoPresentationPData(mms), 9); + + Assert.False(result.IsSuccess); + Assert.Contains("exactly one access result", result.Message); + } + + [Fact] + public void DecodeWriteResponse_RejectsEmptyResultList() + { + var mms = Convert.FromHexString("A105020109A500"); + var result = MmsWriteResponseDecoder.Decode(MmsPresentation.WrapIsoPresentationPData(mms), 9); + + Assert.False(result.IsSuccess); + Assert.Contains("exactly one access result", result.Message); + } + + [Fact] + public void DecodeWriteResponse_RejectsWrongInvokeId() + { + var mms = Convert.FromHexString("A10702010A A5028100".Replace(" ", "")); + var result = MmsWriteResponseDecoder.Decode(MmsPresentation.WrapIsoPresentationPData(mms), 9); + + Assert.False(result.IsSuccess); + Assert.Contains("invokeID mismatch", result.Message); + } + [Fact] public void DecodeDefineNamedVariableListResponse_AcceptsNullResponse() { diff --git a/tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs b/tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs new file mode 100644 index 00000000..81892a86 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs @@ -0,0 +1,51 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +/// Independent synthetic BER fixtures; no device-specific captured bytes. +public sealed class P0CTypedMmsPrimitiveEvidenceTests +{ + [Theory] + [InlineData("8300", 3)] + [InlineData("83020001", 3)] + [InlineData("8500", 5)] + [InlineData("8600", 6)] + [InlineData("8509000000000000000000", 5)] + [InlineData("8609000000000000000000", 6)] + [InlineData("8703000000", 7)] + [InlineData("8400", 4)] + [InlineData("840108", 4)] + public void Malformed_Primitive_Is_Unknown_Not_False_Or_Zero(string berHex, int tag) + { + var values = MmsDataCodec.DecodeAllData(Convert.FromHexString(berHex)); + var decoded = Assert.Single(values); + Assert.Equal(MmsDataKind.Unknown, decoded.Kind); + Assert.Equal(tag, decoded.UnknownTagNumber); + } + + [Fact] + public void Valid_Zero_And_False_Are_Real_Observed_Typed_Values() + { + var encoded = MmsDataCodec.EncodeAllData([ + MmsDataValue.Boolean(false), MmsDataValue.Integer(0), + MmsDataValue.Unsigned(0), MmsDataValue.Integer(-3), + MmsDataValue.Unsigned(4294967295)]); + var decoded = MmsDataCodec.DecodeAllData(encoded); + Assert.Equal(false, decoded[0].Value); + Assert.Equal(0L, decoded[1].Value); + Assert.Equal(0UL, decoded[2].Value); + Assert.Equal(-3L, decoded[3].Value); + Assert.Equal(4294967295UL, decoded[4].Value); + Assert.All(decoded, x => Assert.NotEqual(MmsDataKind.Unknown, x.Kind)); + } + + [Fact] + public void Raw_Unknown_Wire_Kind_Never_Produces_A_Projected_Value() + { + var unknown = Assert.Single(MmsDataCodec.DecodeAllData( + Convert.FromHexString("8500"))); + Assert.Equal(MmsDataKind.Unknown, unknown.Kind); + Assert.Equal("85", MmsDataCodec.Encode(unknown).Length > 0 ? + Convert.ToHexString(MmsDataCodec.Encode(unknown))[..2] : ""); + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs b/tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs new file mode 100644 index 00000000..026be7b4 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs @@ -0,0 +1,86 @@ +using AR.Iec61850.Asn1; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class P0CTypedReadAccessResultTests +{ + [Fact] + public void Ordered_Confirmed_Read_Maintains_Int32_Zero_And_Individual_AccessFailures() + { + var references = Enumerable.Range(0, 4) + .Select(i => new MmsObjectReference("LD0", $"LLN0$SG$Value{i}$setVal", "SG")) + .ToArray(); + var response = Response(42, + MmsDataCodec.Encode(MmsDataValue.Integer(0)), + BerWriter.EncodeTlv(0x80, new byte[] { 3 }), + BerWriter.EncodeTlv(0x80, new byte[] { 6 }), + BerWriter.EncodeTlv(0x80, new byte[] { 7 })); + var batch = MmsReadBatchCodec.DecodeResponse(response, references, expectedInvokeId: 42); + Assert.Equal(4, batch.Results.Count); + Assert.Equal(0L, batch.Results[0].Value!.Value); + Assert.True(batch.Results[0].IsSuccess); + Assert.Equal(MmsReadEvidenceFailureKind.None, batch.Results[0].FailureKind); + Assert.Equal(MmsReadEvidenceFailureKind.AccessDenied, batch.Results[1].FailureKind); + Assert.Equal(3, batch.Results[1].FailureCode); + Assert.Equal(MmsReadEvidenceFailureKind.NotSupported, batch.Results[2].FailureKind); + Assert.Equal(MmsReadEvidenceFailureKind.TypeMismatch, batch.Results[3].FailureKind); + Assert.All(batch.Results.Skip(1), x => Assert.Null(x.Value)); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + public void Missing_Or_Extra_AccessResults_Fail_All_Without_Ordinal_Ambiguity(int resultCount) + { + var references = new[] + { + new MmsObjectReference("LD0", "LLN0$SG$PTOCEna$setVal", "SG"), + new MmsObjectReference("LD0", "LLN0$SG$InMagTyp$setVal", "SG") + }; + var response = Response(5, Enumerable.Range(0, resultCount) + .Select(_ => MmsDataCodec.Encode(MmsDataValue.Boolean(false))).ToArray()); + var result = MmsReadBatchCodec.DecodeResponse(response, references, expectedInvokeId: 5); + Assert.False(result.HasAnySuccess); + Assert.All(result.Results, x => + { + Assert.False(x.IsSuccess); + Assert.Null(x.Value); + Assert.Equal(MmsReadEvidenceFailureKind.InvalidResponse, x.FailureKind); + }); + Assert.Contains("count mismatch", result.Message); + } + + [Fact] + public void Stale_Invoke_Cannot_Publish_Even_When_Data_Is_Decodable() + { + var refs = new[] {new MmsObjectReference("LD0", "LLN0$SG$PTOCEna$setVal", "SG")}; + var result = MmsReadBatchCodec.DecodeResponse( + Response(21, MmsDataCodec.Encode(MmsDataValue.Boolean(false))), refs, + expectedInvokeId: 22); + Assert.False(result.HasAnySuccess); + Assert.Equal(MmsReadEvidenceFailureKind.InvalidResponse, + Assert.Single(result.Results).FailureKind); + } + + [Fact] + public void Invalid_Ber_Integer_Leaves_Unknown_Not_Observed_Zero() + { + var refs = new[] {new MmsObjectReference("LD0", "LLN0$SG$InMagTyp$setVal", "SG")}; + var result = MmsReadBatchCodec.DecodeResponse( + Response(42, Convert.FromHexString("8500")), refs, expectedInvokeId: 42); + var item = Assert.Single(result.Results); + Assert.False(item.IsSuccess); + Assert.Null(item.Value); + Assert.Equal(MmsReadEvidenceFailureKind.InvalidResponse, item.FailureKind); + } + + private static byte[] Response(int invokeId, params byte[][] accessResults) + { + var results = accessResults.SelectMany(item => item).ToArray(); + var service = BerWriter.EncodeTlv(0xA4, + BerWriter.EncodeTlv(0xA1, results)); + var invoke = BerWriter.EncodeTlv(0x02, BerWriter.EncodeUnsignedInteger((ulong)invokeId)); + return BerWriter.EncodeTlv(0xA1, invoke.Concat(service).ToArray()); + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs index d3235ef4..e048ebc5 100644 --- a/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs @@ -57,6 +57,17 @@ public void Brcb_Explicit_Reservation_Is_Retry_Only() fallback); } + [Fact] + public void ConfiguredStaticActivation_Is_SourceNeutral_PublicEntryPoint() + { + var configured = typeof(MmsClientSession).GetMethod(nameof(MmsClientSession.StartConfiguredStaticReportMonitorAsync)); + var scl = typeof(MmsClientSession).GetMethod(nameof(MmsClientSession.StartStaticSclReportMonitorAsync)); + + Assert.NotNull(configured); + Assert.NotNull(scl); + Assert.Equal(configured!.ReturnType, scl!.ReturnType); + } + [Fact] public void Urcb_Has_No_Brcb_Reservation_Fallback() { diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvEngineeringWindowAnalyzerTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvEngineeringWindowAnalyzerTests.cs new file mode 100644 index 00000000..a4ccaa71 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvEngineeringWindowAnalyzerTests.cs @@ -0,0 +1,318 @@ +using AR.Iec61850.SampledValues.Analysis; +using AR.Iec61850.SampledValues.Measurements; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvEngineeringWindowAnalyzerTests +{ + [Fact] + public void CompletesOneCycleRmsAndPhasorFromEvidenceBackedEngineeringSamples() + { + const int samplesPerCycle = 80; + const double peak = 100.0; + const double phaseDegrees = 30.0; + var phaseRadians = phaseDegrees * Math.PI / 180.0; + var analyzer = new SvEngineeringWindowAnalyzer(); + var key = CreateKey("MU01", 0x4000); + var samples = Enumerable.Range(0, samplesPerCycle) + .Select(index => EngineeringSample( + (ushort)index, + peak * Math.Cos((2.0 * Math.PI * index / samplesPerCycle) + phaseRadians))) + .ToArray(); + + var accepted = analyzer.TryObserve( + Projection(key, samples), + Evidence(4_000, 50), + out var completed, + out var reason); + + Assert.True(accepted, reason); + var result = Assert.Single(completed); + var expectedRms = peak / Math.Sqrt(2.0); + Assert.Equal(expectedRms, result.Estimate.Rms, 9); + Assert.Equal(expectedRms, result.Estimate.MagnitudeRms, 9); + Assert.Equal(phaseDegrees, result.Estimate.AngleDegrees, 9); + Assert.Equal(samplesPerCycle, result.SamplesPerCycle); + Assert.Equal((ushort)0, result.FirstSampleCount); + Assert.Equal((ushort)79, result.LastSampleCount); + Assert.Equal("A", result.EngineeringUnit); + Assert.Equal(SvEngineeringScaleSource.ManualOverride, result.ScaleSource); + Assert.Equal(SvFactSource.TrustedContext, result.FundamentalFrequencySource); + } + + [Fact] + public void RawOnlyProjectionNeverCreatesEngineeringWindowState() + { + var analyzer = new SvEngineeringWindowAnalyzer(); + var raw = new SvProjectedMeasurementSample + { + SampleCount = 0, + ElementIndex = 0, + SignalReference = "MU01LD0/TCTR1.AmpSv.instMag.i", + Cdc = "SAV", + RawValue = 1234, + Scale = SvEngineeringScale.RawOnly("test raw-only sample") + }; + + var accepted = analyzer.TryObserve( + Projection(CreateKey(), raw), + Evidence(4_000, 50), + out var completed, + out var reason); + + Assert.True(accepted, reason); + Assert.Empty(completed); + Assert.Equal(0, analyzer.ActiveStreamCount); + Assert.Equal(0, analyzer.ActiveChannelCount); + Assert.Contains("raw-only", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GapDiscardsPartialCycleAndRestartsAtCurrentSample() + { + var analyzer = new SvEngineeringWindowAnalyzer(); + var key = CreateKey(); + var evidence = Evidence(200, 50); + + Assert.True(analyzer.TryObserve( + Projection(key, EngineeringSample(0, 1), EngineeringSample(1, 0)), + evidence, + out var first, + out var firstReason), firstReason); + Assert.Empty(first); + + Assert.True(analyzer.TryObserve( + Projection(key, EngineeringSample(3, 0), EngineeringSample(4, 1)), + evidence, + out var second, + out var secondReason), secondReason); + Assert.Empty(second); + + Assert.True(analyzer.TryObserve( + Projection(key, EngineeringSample(5, 0), EngineeringSample(6, -1)), + evidence, + out var completed, + out var finalReason), finalReason); + + var result = Assert.Single(completed); + Assert.Equal((ushort)3, result.FirstSampleCount); + Assert.Equal((ushort)6, result.LastSampleCount); + Assert.Equal(4, result.Estimate.SampleCount); + } + + [Fact] + public void DuplicateDiscardsPartialCycleAndDoesNotTreatDuplicateAsNewSample() + { + var analyzer = new SvEngineeringWindowAnalyzer(); + var key = CreateKey(); + var evidence = Evidence(200, 50); + + Assert.True(analyzer.TryObserve( + Projection(key, + EngineeringSample(0, 1), + EngineeringSample(1, 0), + EngineeringSample(1, 999), + EngineeringSample(2, -1), + EngineeringSample(3, 0), + EngineeringSample(4, 1)), + evidence, + out var completed, + out var reason), reason); + + Assert.Empty(completed); + + Assert.True(analyzer.TryObserve( + Projection(key, EngineeringSample(5, 0)), + evidence, + out completed, + out reason), reason); + + var result = Assert.Single(completed); + Assert.Equal((ushort)2, result.FirstSampleCount); + Assert.Equal((ushort)5, result.LastSampleCount); + } + + [Fact] + public void ExplicitCounterWrapMayCompleteAcrossNormalWrap() + { + var analyzer = new SvEngineeringWindowAnalyzer(); + var evidence = Evidence(200, 50) with + { + SampleCounterWrap = 4, + SampleCounterWrapSource = SvFactSource.TrustedContext + }; + + var accepted = analyzer.TryObserve( + Projection(CreateKey(), + EngineeringSample(2, -1), + EngineeringSample(3, 0), + EngineeringSample(0, 1), + EngineeringSample(1, 0)), + evidence, + out var completed, + out var reason); + + Assert.True(accepted, reason); + var result = Assert.Single(completed); + Assert.Equal((ushort)2, result.FirstSampleCount); + Assert.Equal((ushort)1, result.LastSampleCount); + } + + [Fact] + public void MetadataChangeResetsPartialCycleBeforeNewSignalIdentity() + { + var analyzer = new SvEngineeringWindowAnalyzer(); + var evidence = Evidence(200, 50) with + { + SampleCounterWrap = 4, + SampleCounterWrapSource = SvFactSource.TrustedContext + }; + var key = CreateKey(); + + Assert.True(analyzer.TryObserve( + Projection(key, + EngineeringSample(0, 1, signalReference: "IA"), + EngineeringSample(1, 0, signalReference: "IA")), + evidence, + out var beforeChange, + out var beforeReason), beforeReason); + Assert.Empty(beforeChange); + + Assert.True(analyzer.TryObserve( + Projection(key, + EngineeringSample(2, -1, signalReference: "IB"), + EngineeringSample(3, 0, signalReference: "IB"), + EngineeringSample(0, 1, signalReference: "IB"), + EngineeringSample(1, 0, signalReference: "IB")), + evidence, + out var completed, + out var reason), reason); + + var result = Assert.Single(completed); + Assert.Equal("IB", result.SignalReference); + Assert.Equal((ushort)2, result.FirstSampleCount); + Assert.Equal((ushort)1, result.LastSampleCount); + } + + [Fact] + public void NonIntegralCycleOrInferredFrequencyFailsClosed() + { + var analyzer = new SvEngineeringWindowAnalyzer(); + var projection = Projection(CreateKey(), EngineeringSample(0, 1)); + + Assert.False(analyzer.TryObserve( + projection, + Evidence(4_000, 60), + out var nonIntegral, + out var nonIntegralReason)); + Assert.Empty(nonIntegral); + Assert.Contains("integral", nonIntegralReason, StringComparison.OrdinalIgnoreCase); + + Assert.False(analyzer.TryObserve( + projection, + Evidence(4_000, 50) with { FundamentalFrequencySource = SvFactSource.ProfileInferred }, + out var inferred, + out var inferredReason)); + Assert.Empty(inferred); + Assert.Contains("never guessed", inferredReason, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, analyzer.ActiveStreamCount); + } + + [Fact] + public void StreamAndChannelRegistriesStayBounded() + { + var streamBounded = new SvEngineeringWindowAnalyzer(new SvEngineeringWindowAnalysisOptions + { + MaximumStreams = 2, + MaximumChannelsPerStream = 2, + MaximumSamplesPerCycle = 80 + }); + var evidence = Evidence(4_000, 50); + + foreach (var pair in new[] { ("A", (ushort)0x4001), ("B", (ushort)0x4002), ("C", (ushort)0x4003) }) + { + Assert.True(streamBounded.TryObserve( + Projection(CreateKey(pair.Item1, pair.Item2), EngineeringSample(0, 1)), + evidence, + out _, + out var reason), reason); + } + Assert.Equal(2, streamBounded.ActiveStreamCount); + Assert.Equal(2, streamBounded.ActiveChannelCount); + + var channelBounded = new SvEngineeringWindowAnalyzer(new SvEngineeringWindowAnalysisOptions + { + MaximumStreams = 1, + MaximumChannelsPerStream = 1, + MaximumSamplesPerCycle = 80 + }); + var accepted = channelBounded.TryObserve( + Projection(CreateKey(), + EngineeringSample(0, 1, elementIndex: 0, signalReference: "IA"), + EngineeringSample(0, 2, elementIndex: 2, signalReference: "IB")), + evidence, + out var completed, + out var boundedReason); + + Assert.False(accepted); + Assert.Empty(completed); + Assert.Contains("bound", boundedReason, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, channelBounded.ActiveChannelCount); + } + + private static SvEngineeringWindowEvidence Evidence(double sampleRateHz, double fundamentalFrequencyHz) + => new() + { + SampleRateHz = sampleRateHz, + SampleRateSource = SvFactSource.CaptureCalculated, + FundamentalFrequencyHz = fundamentalFrequencyHz, + FundamentalFrequencySource = SvFactSource.TrustedContext + }; + + private static SvObservedStreamKey CreateKey(string svId = "MU01", ushort appId = 0x4000) + => new() + { + SourceMac = "020000000001", + DestinationMac = "010CCD040001", + AppId = appId, + VlanId = 10, + SvId = svId, + DataSetReference = "MU01LD0/LLN0$dsSV" + }; + + private static SvSclBoundMeasurementProjection Projection( + SvObservedStreamKey key, + params SvProjectedMeasurementSample[] samples) + => new() + { + StreamKey = key, + ControlBlockReference = "MU01LD0/LLN0.MSVCB01", + IsBoundToScl = true, + HasEngineeringValues = samples.Any(sample => sample.EngineeringValue.HasValue), + Samples = samples + }; + + private static SvProjectedMeasurementSample EngineeringSample( + ushort sampleCount, + double value, + int elementIndex = 0, + string signalReference = "MU01LD0/TCTR1.AmpSv.instMag.i") + => new() + { + SampleCount = sampleCount, + ElementIndex = elementIndex, + SignalReference = signalReference, + Cdc = "SAV-current", + RawValue = value, + EngineeringValue = value, + Scale = new SvEngineeringScale + { + Multiplier = 1.0, + Unit = "A", + Source = SvEngineeringScaleSource.ManualOverride, + Confidence = SvEngineeringScaleConfidence.DeviceValidated, + Reason = "Explicit test engineering scale." + } + }; +} diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvSclBoundMeasurementProjectorTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvSclBoundMeasurementProjectorTests.cs new file mode 100644 index 00000000..66b9fb0a --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvSclBoundMeasurementProjectorTests.cs @@ -0,0 +1,291 @@ +using System.Buffers.Binary; +using AR.Iec61850.Ethernet; +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Measurements; +using AR.Iec61850.SampledValues.Profiles; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvSclBoundMeasurementProjectorTests +{ + [Fact] + public void FixedSclBoundValueQualityLayoutProjectsEngineeringValuesWithoutGuessingDatasetOrder() + { + var profile = BuildFixedProtectionProfile(); + var payload = BuildFixedProtectionPayload(1_000); + var frame = profile.CreateFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: 7, + payload); + + var projection = SvSclBoundMeasurementProjector.Project(frame, profile); + + Assert.True(projection.IsBoundToScl); + Assert.True(projection.HasEngineeringValues); + Assert.Equal(8, projection.Samples.Count); + Assert.All(projection.Samples, sample => Assert.Equal(7, sample.SampleCount)); + + var current = projection.Samples.First(sample => sample.SignalReference.Contains("TCTR", StringComparison.Ordinal)); + Assert.Equal(1_000.0, current.RawValue, 9); + Assert.Equal(1.0, Assert.IsType(current.EngineeringValue), 9); + Assert.Equal("A", current.Scale.Unit); + Assert.Equal(SvEngineeringScaleSource.SclBackedLegacy92LeStyle, current.Scale.Source); + Assert.Null(current.DomainValue); + Assert.Null(current.DisplayValue); + + var voltage = projection.Samples.First(sample => sample.SignalReference.Contains("TVTR", StringComparison.Ordinal)); + Assert.Equal(10.0, Assert.IsType(voltage.EngineeringValue), 9); + Assert.Equal("V", voltage.Scale.Unit); + } + + [Fact] + public void ExplicitMeasurementContextEnablesPrimaryToSecondaryDisplayProjection() + { + var profile = BuildFixedProtectionProfile(); + var frame = profile.CreateFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: 8, + BuildFixedProtectionPayload(1_000)); + var key = SvObservedStreamKey.FromFrame(frame); + var context = new SvStreamMeasurementContext + { + StreamKey = key.Id, + SvId = profile.Stream.SvId, + WireDomain = SvMeasurementValueDomain.PrimaryEngineering, + DisplayDomain = SvMeasurementValueDomain.SecondaryEquivalent, + CurrentRatio = new SvMeasurementRatio + { + PrimaryNominal = 1_000, + SecondaryNominal = 1, + Unit = "A", + Source = SvRatioSource.Manual, + Reference = "test-current-ratio" + }, + VoltageRatio = new SvMeasurementRatio + { + PrimaryNominal = 100_000, + SecondaryNominal = 100, + Unit = "V", + Source = SvRatioSource.Manual, + Reference = "test-voltage-ratio" + } + }; + + var projection = SvSclBoundMeasurementProjector.Project(frame, profile, context); + + var current = projection.Samples.First(sample => sample.SignalReference.Contains("TCTR", StringComparison.Ordinal)); + Assert.NotNull(current.DomainValue); + Assert.Equal(1.0, Assert.IsType(current.DomainValue!.PrimaryValue), 9); + Assert.Equal(0.001, Assert.IsType(current.DomainValue.SecondaryEquivalentValue), 9); + Assert.Equal(0.001, Assert.IsType(current.DisplayValue), 9); + Assert.Equal(SvRatioSource.Manual, current.DomainValue.RatioSource); + } + + [Fact] + public void NonFixedSclLayoutKeepsNumericValuesRawOnly() + { + var profile = BuildSingleChannelProfile(); + var payload = new byte[8]; + BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(0, 4), 12_345); + var frame = profile.CreateFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: 0, + payload); + + var projection = SvSclBoundMeasurementProjector.Project(frame, profile); + + Assert.True(projection.IsBoundToScl); + Assert.False(projection.HasEngineeringValues); + var sample = Assert.Single(projection.Samples); + Assert.Equal(12_345.0, sample.RawValue, 9); + Assert.Null(sample.EngineeringValue); + Assert.Equal(SvEngineeringScaleSource.RawOnly, sample.Scale.Source); + Assert.Contains(projection.Diagnostics, item => item.Contains("raw only", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void EthernetIdentityMismatchFailsClosedBeforePayloadProjection() + { + var profile = BuildFixedProtectionProfile(); + var valid = profile.CreateFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: 0, + BuildFixedProtectionPayload(1_000)); + var mismatch = new SampledValuesFrame + { + Destination = valid.Destination, + Source = valid.Source, + Vlan = valid.Vlan, + AppId = 0x4999, + Pdu = valid.Pdu + }; + + var projection = SvSclBoundMeasurementProjector.Project(mismatch, profile); + + Assert.False(projection.IsBoundToScl); + Assert.Empty(projection.Samples); + Assert.Contains(projection.Diagnostics, item => item.Contains("APPID", StringComparison.Ordinal)); + } + + [Fact] + public void ConfigurationRevisionMismatchFailsClosedBeforePayloadProjection() + { + var profile = BuildFixedProtectionProfile(); + var valid = profile.CreateFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: 0, + BuildFixedProtectionPayload(1_000)); + var original = valid.Pdu.Asdus[0]; + var mismatch = new SampledValuesFrame + { + Destination = valid.Destination, + Source = valid.Source, + Vlan = valid.Vlan, + AppId = valid.AppId, + Pdu = new SampledValuesPdu + { + Asdus = + [ + new SampledValueAsdu + { + SvId = original.SvId, + DataSetReference = original.DataSetReference, + SampleCount = original.SampleCount, + ConfigurationRevision = original.ConfigurationRevision + 1, + SampleSynchronization = original.SampleSynchronization, + SampleRate = original.SampleRate, + SampleMode = original.SampleMode, + SamplePayload = original.SamplePayload + } + ] + } + }; + + var projection = SvSclBoundMeasurementProjector.Project(mismatch, profile); + + Assert.False(projection.IsBoundToScl); + Assert.Empty(projection.Samples); + Assert.Contains(projection.Diagnostics, item => item.Contains("confRev", StringComparison.Ordinal)); + } + + [Fact] + public void MismatchedMeasurementContextDoesNotApplyCtVtRatio() + { + var profile = BuildFixedProtectionProfile(); + var frame = profile.CreateFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: 0, + BuildFixedProtectionPayload(1_000)); + var context = new SvStreamMeasurementContext + { + StreamKey = "SV|different-stream", + SvId = profile.Stream.SvId, + WireDomain = SvMeasurementValueDomain.PrimaryEngineering, + DisplayDomain = SvMeasurementValueDomain.SecondaryEquivalent, + CurrentRatio = new SvMeasurementRatio + { + PrimaryNominal = 1_000, + SecondaryNominal = 1, + Unit = "A", + Source = SvRatioSource.Manual + } + }; + + var projection = SvSclBoundMeasurementProjector.Project(frame, profile, context); + + Assert.True(projection.HasEngineeringValues); + Assert.All(projection.Samples, sample => Assert.Null(sample.DomainValue)); + Assert.Contains(projection.Diagnostics, item => item.Contains("stream key", StringComparison.OrdinalIgnoreCase)); + } + + private static SampledValuesPublisherProfile BuildFixedProtectionProfile() + { + var entries = new List(); + var index = 0; + for (var channel = 1; channel <= 4; channel++) + { + entries.Add(ValueEntry(index++, $"MU01/TCTR{channel}.AmpSv.instMag.i")); + entries.Add(QualityEntry(index++, $"MU01/TCTR{channel}.AmpSv.q")); + } + for (var channel = 1; channel <= 4; channel++) + { + entries.Add(ValueEntry(index++, $"MU01/TVTR{channel}.VolSv.instMag.i")); + entries.Add(QualityEntry(index++, $"MU01/TVTR{channel}.VolSv.q")); + } + + return SampledValuesPublisherProfile.Create(new SclSampledValuesStream + { + IedName = "MU01", + LdInst = "MU", + ControlName = "MSVCB01", + ControlBlockReference = "MU01/LLN0$MSVCB01", + DataSetName = "PhsMeas1", + DataSetReference = "MU01/LLN0$PhsMeas1", + ConfigurationRevision = 1, + SvId = "MU01_SV01", + SampleRate = 4_000, + SampleMode = "SmpPerSec", + NoAsdu = 1, + Address = new SclStreamAddress + { + AppId = 0x4000, + DestinationMac = MacAddress.Parse("01:0C:CD:04:00:01") + }, + Entries = entries + }); + } + + private static SampledValuesPublisherProfile BuildSingleChannelProfile() + => SampledValuesPublisherProfile.Create(new SclSampledValuesStream + { + IedName = "MU01", + LdInst = "MU", + ControlName = "MSVCB01", + ControlBlockReference = "MU01/LLN0$MSVCB01", + DataSetName = "OneCurrent", + DataSetReference = "MU01/LLN0$OneCurrent", + ConfigurationRevision = 1, + SvId = "MU01_SV01", + SampleRate = 4_000, + SampleMode = "SmpPerSec", + NoAsdu = 1, + Address = new SclStreamAddress + { + AppId = 0x4000, + DestinationMac = MacAddress.Parse("01:0C:CD:04:00:01") + }, + Entries = + [ + ValueEntry(0, "MU01/TCTR1.AmpSv.instMag.i"), + QualityEntry(1, "MU01/TCTR1.AmpSv.q") + ] + }); + + private static SclDataSetEntry ValueEntry(int index, string reference) + => new() + { + Index = index, + SignalReference = reference, + Cdc = "SAV", + BType = "INT32" + }; + + private static SclDataSetEntry QualityEntry(int index, string reference) + => new() + { + Index = index, + SignalReference = reference, + Cdc = "SAV", + BType = "Quality", + IsQuality = true + }; + + private static byte[] BuildFixedProtectionPayload(int rawValue) + { + var payload = new byte[64]; + for (var channel = 0; channel < 8; channel++) + BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(channel * 8, 4), rawValue); + return payload; + } +} diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvSustainedCaptureProcessorTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvSustainedCaptureProcessorTests.cs new file mode 100644 index 00000000..fae02776 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvSustainedCaptureProcessorTests.cs @@ -0,0 +1,195 @@ +using System.Buffers.Binary; +using AR.Iec61850.Ethernet; +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; +using AR.Iec61850.SampledValues.Profiles; +using AR.Iec61850.Scl; +using AR.Iec61850.Transports; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvSustainedCaptureProcessorTests +{ + [Fact] + public async Task RawEthernetSourceUsesCanonicalParserAndDoesNotAdvanceOnParserReject() + { + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var firstBytes = SampledValuesFrameBuilder.BuildEthernetFrame(BuildOpaqueFrame(0)); + var secondBytes = SampledValuesFrameBuilder.BuildEthernetFrame(BuildOpaqueFrame(1)); + var source = new InMemoryProcessBusFrameSource( + [ + new ProcessBusCapturedFrame { Timestamp = start, Frame = firstBytes }, + new ProcessBusCapturedFrame { Timestamp = start.AddTicks(1), Frame = new byte[] { 1, 2, 3, 4 } }, + new ProcessBusCapturedFrame { Timestamp = start.AddTicks(2), Frame = secondBytes } + ]); + var processor = new SvSustainedCaptureProcessor(); + var results = new List(); + + await foreach (var result in processor.ProcessAsync( + source, + new ProcessBusCaptureOptions(), + SvObservationInputKind.PcapReplay)) + { + results.Add(result); + } + + Assert.Equal(3, results.Count); + Assert.Equal(SvCapturedFrameProcessingStatus.Accepted, results[0].Status); + Assert.Equal(SvCapturedFrameProcessingStatus.ParserRejected, results[1].Status); + Assert.Equal(SvCapturedFrameProcessingStatus.Accepted, results[2].Status); + Assert.Null(results[1].Observation); + Assert.Equal(4, results[1].CapturedLength); + Assert.Equal(1, processor.Session.ObservationStreamCount); + Assert.Equal(1, processor.Session.SustainedStreamCount); + Assert.Equal(2, results[2].Observation!.Sustained.FrameCount); + Assert.Contains(SvObservationInputKind.PcapReplay, results[2].Observation!.Observation.InputKinds); + } + + [Fact] + public async Task RawEthernetFramesCanCompleteEvidenceBackedEngineeringCycle() + { + const int samplesPerCycle = 80; + const double rawPeak = 100_000.0; + var profile = BuildFixedProtectionProfile(); + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var frames = new List(samplesPerCycle); + for (var index = 0; index < samplesPerCycle; index++) + { + var raw = checked((int)Math.Round( + rawPeak * Math.Cos(2.0 * Math.PI * index / samplesPerCycle), + MidpointRounding.AwayFromZero)); + var frame = profile.CreateFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: (ushort)index, + BuildFixedProtectionPayload(raw)); + frames.Add(new ProcessBusCapturedFrame + { + Timestamp = start.AddTicks(index * 2_500L), + Frame = SampledValuesFrameBuilder.BuildEthernetFrame(frame) + }); + } + + var source = new InMemoryProcessBusFrameSource(frames); + var processor = new SvSustainedCaptureProcessor(); + SvCapturedFrameProcessingResult? final = null; + await foreach (var result in processor.ProcessAsync( + source, + new ProcessBusCaptureOptions(), + SvObservationInputKind.PcapReplay, + _ => new SvSustainedFrameContext + { + Profile = profile, + NominalFrequencyHz = 50, + EngineeringWindowEvidence = new SvEngineeringWindowEvidence + { + SampleRateHz = 4_000, + SampleRateSource = SvFactSource.SclDerived, + FundamentalFrequencyHz = 50, + FundamentalFrequencySource = SvFactSource.TrustedContext + } + })) + { + final = result; + } + + Assert.NotNull(final); + Assert.Equal(SvCapturedFrameProcessingStatus.Accepted, final!.Status); + Assert.NotNull(final.Observation); + Assert.Null(final.Observation!.EngineeringWindowDiagnostic); + Assert.Equal(8, final.Observation.EngineeringWindows.Count); + var current = final.Observation.EngineeringWindows.Single(window => + window.SignalReference.Contains("TCTR1", StringComparison.Ordinal)); + Assert.InRange(current.Estimate.Rms, 70.70, 70.72); + Assert.InRange(Math.Abs(current.Estimate.AngleDegrees), 0, 0.01); + Assert.Equal(1, processor.Session.EngineeringWindowStreamCount); + Assert.Equal(8, processor.Session.EngineeringWindowChannelCount); + } + + private static SampledValuesFrame BuildOpaqueFrame(ushort sampleCount) + => new() + { + Destination = MacAddress.Parse("01:0C:CD:04:00:01"), + Source = MacAddress.Parse("02:00:00:00:00:01"), + AppId = 0x4000, + Pdu = new SampledValuesPdu + { + Asdus = + [ + new SampledValueAsdu + { + SvId = "MU01_SV01", + DataSetReference = "MU01/LLN0$Dataset1", + SampleCount = sampleCount, + SampleSynchronization = 2, + SampleRate = 4_000, + SampleMode = 1, + SamplePayload = new byte[8] + } + ] + } + }; + + private static SampledValuesPublisherProfile BuildFixedProtectionProfile() + { + var entries = new List(); + var index = 0; + for (var channel = 1; channel <= 4; channel++) + { + entries.Add(ValueEntry(index++, $"MU01/TCTR{channel}.AmpSv.instMag.i")); + entries.Add(QualityEntry(index++, $"MU01/TCTR{channel}.AmpSv.q")); + } + for (var channel = 1; channel <= 4; channel++) + { + entries.Add(ValueEntry(index++, $"MU01/TVTR{channel}.VolSv.instMag.i")); + entries.Add(QualityEntry(index++, $"MU01/TVTR{channel}.VolSv.q")); + } + + return SampledValuesPublisherProfile.Create(new SclSampledValuesStream + { + IedName = "MU01", + LdInst = "MU", + ControlName = "MSVCB01", + ControlBlockReference = "MU01/LLN0$MSVCB01", + DataSetName = "PhsMeas1", + DataSetReference = "MU01/LLN0$PhsMeas1", + ConfigurationRevision = 1, + SvId = "MU01_SV01", + SampleRate = 4_000, + SampleMode = "SmpPerSec", + NoAsdu = 1, + Address = new SclStreamAddress + { + AppId = 0x4000, + DestinationMac = MacAddress.Parse("01:0C:CD:04:00:01") + }, + Entries = entries + }); + } + + private static SclDataSetEntry ValueEntry(int index, string reference) + => new() + { + Index = index, + SignalReference = reference, + Cdc = "SAV", + BType = "INT32" + }; + + private static SclDataSetEntry QualityEntry(int index, string reference) + => new() + { + Index = index, + SignalReference = reference, + Cdc = "SAV", + BType = "Quality", + IsQuality = true + }; + + private static byte[] BuildFixedProtectionPayload(int rawValue) + { + var payload = new byte[64]; + for (var channel = 0; channel < 8; channel++) + BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(channel * 8, 4), rawValue); + return payload; + } +} diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvSustainedObservationSessionEngineeringTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvSustainedObservationSessionEngineeringTests.cs new file mode 100644 index 00000000..be310629 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvSustainedObservationSessionEngineeringTests.cs @@ -0,0 +1,215 @@ +using System.Buffers.Binary; +using AR.Iec61850.Ethernet; +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; +using AR.Iec61850.SampledValues.Profiles; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvSustainedObservationSessionEngineeringTests +{ + [Fact] + public void ExplicitSclAndTimebaseEvidenceProducesOneCycleEngineeringResultsThroughSession() + { + const int samplesPerCycle = 80; + const double rawPeak = 100_000.0; + const double phaseDegrees = 30.0; + var phaseRadians = phaseDegrees * Math.PI / 180.0; + var profile = BuildFixedProtectionProfile(); + var session = new SvSustainedObservationSession(); + var evidence = Evidence(4_000, 50); + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + SvSustainedObservationResult result = new(); + + for (var index = 0; index < samplesPerCycle; index++) + { + var raw = checked((int)Math.Round( + rawPeak * Math.Cos((2.0 * Math.PI * index / samplesPerCycle) + phaseRadians), + MidpointRounding.AwayFromZero)); + var frame = profile.CreateFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: (ushort)index, + BuildFixedProtectionPayload(raw)); + + Assert.True(session.TryObserve( + start.AddTicks(index * 2_500L), + frame, + SvObservationInputKind.PcapReplay, + out result, + profile: profile, + nominalFrequencyHz: 50, + engineeringWindowEvidence: evidence)); + } + + Assert.NotNull(result.MeasurementProjection); + Assert.True(result.MeasurementProjection!.IsBoundToScl); + Assert.True(result.MeasurementProjection.HasEngineeringValues); + Assert.Null(result.EngineeringWindowDiagnostic); + Assert.Equal(8, result.EngineeringWindows.Count); + Assert.Equal(1, session.EngineeringWindowStreamCount); + Assert.Equal(8, session.EngineeringWindowChannelCount); + + var current = result.EngineeringWindows.Single(window => + window.SignalReference.Contains("TCTR1", StringComparison.Ordinal)); + Assert.Equal("A", current.EngineeringUnit); + Assert.Equal(samplesPerCycle, current.SamplesPerCycle); + Assert.Equal((ushort)0, current.FirstSampleCount); + Assert.Equal((ushort)79, current.LastSampleCount); + Assert.InRange(current.Estimate.Rms, 70.70, 70.72); + Assert.InRange(current.Estimate.MagnitudeRms, 70.70, 70.72); + Assert.InRange(current.Estimate.AngleDegrees, 29.99, 30.01); + + session.Clear(); + Assert.Equal(0, session.EngineeringWindowStreamCount); + Assert.Equal(0, session.EngineeringWindowChannelCount); + } + + [Fact] + public void EvidenceWithoutSclProfileDoesNotCreateEngineeringState() + { + var session = new SvSustainedObservationSession(); + var frame = BuildOpaqueFrame(); + + Assert.True(session.TryObserve( + DateTimeOffset.UnixEpoch, + frame, + SvObservationInputKind.LiveCapture, + out var result, + engineeringWindowEvidence: Evidence(4_000, 50))); + + Assert.Null(result.MeasurementProjection); + Assert.Empty(result.EngineeringWindows); + Assert.Contains("without an explicit SCL publisher profile", result.EngineeringWindowDiagnostic, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, session.EngineeringWindowStreamCount); + Assert.Equal(0, session.EngineeringWindowChannelCount); + } + + [Fact] + public void InferredFrequencyEvidenceDoesNotRejectCanonicalObservationButFailsClosedForEngineeringWindow() + { + var profile = BuildFixedProtectionProfile(); + var frame = profile.CreateFrame( + MacAddress.Parse("02:00:00:00:00:01"), + sampleCount: 0, + BuildFixedProtectionPayload(1_000)); + var inferred = Evidence(4_000, 50) with + { + FundamentalFrequencySource = SvFactSource.ProfileInferred + }; + var session = new SvSustainedObservationSession(); + + Assert.True(session.TryObserve( + DateTimeOffset.UnixEpoch, + frame, + SvObservationInputKind.LiveCapture, + out var result, + profile: profile, + nominalFrequencyHz: 50, + engineeringWindowEvidence: inferred)); + + Assert.Equal(1, session.ObservationStreamCount); + Assert.Equal(1, session.SustainedStreamCount); + Assert.NotNull(result.MeasurementProjection); + Assert.Empty(result.EngineeringWindows); + Assert.Contains("never guessed", result.EngineeringWindowDiagnostic, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, session.EngineeringWindowStreamCount); + } + + private static SvEngineeringWindowEvidence Evidence(double sampleRateHz, double fundamentalFrequencyHz) + => new() + { + SampleRateHz = sampleRateHz, + SampleRateSource = SvFactSource.SclDerived, + FundamentalFrequencyHz = fundamentalFrequencyHz, + FundamentalFrequencySource = SvFactSource.TrustedContext + }; + + private static SampledValuesPublisherProfile BuildFixedProtectionProfile() + { + var entries = new List(); + var index = 0; + for (var channel = 1; channel <= 4; channel++) + { + entries.Add(ValueEntry(index++, $"MU01/TCTR{channel}.AmpSv.instMag.i")); + entries.Add(QualityEntry(index++, $"MU01/TCTR{channel}.AmpSv.q")); + } + for (var channel = 1; channel <= 4; channel++) + { + entries.Add(ValueEntry(index++, $"MU01/TVTR{channel}.VolSv.instMag.i")); + entries.Add(QualityEntry(index++, $"MU01/TVTR{channel}.VolSv.q")); + } + + return SampledValuesPublisherProfile.Create(new SclSampledValuesStream + { + IedName = "MU01", + LdInst = "MU", + ControlName = "MSVCB01", + ControlBlockReference = "MU01/LLN0$MSVCB01", + DataSetName = "PhsMeas1", + DataSetReference = "MU01/LLN0$PhsMeas1", + ConfigurationRevision = 1, + SvId = "MU01_SV01", + SampleRate = 4_000, + SampleMode = "SmpPerSec", + NoAsdu = 1, + Address = new SclStreamAddress + { + AppId = 0x4000, + DestinationMac = MacAddress.Parse("01:0C:CD:04:00:01") + }, + Entries = entries + }); + } + + private static SampledValuesFrame BuildOpaqueFrame() + => new() + { + Destination = MacAddress.Parse("01:0C:CD:04:00:01"), + Source = MacAddress.Parse("02:00:00:00:00:01"), + AppId = 0x4000, + Pdu = new SampledValuesPdu + { + Asdus = + [ + new SampledValueAsdu + { + SvId = "MU01_SV01", + DataSetReference = "MU01/LLN0$PhsMeas1", + SampleCount = 0, + SampleSynchronization = 2, + SampleRate = 4_000, + SampleMode = 1, + SamplePayload = new byte[8] + } + ] + } + }; + + private static SclDataSetEntry ValueEntry(int index, string reference) + => new() + { + Index = index, + SignalReference = reference, + Cdc = "SAV", + BType = "INT32" + }; + + private static SclDataSetEntry QualityEntry(int index, string reference) + => new() + { + Index = index, + SignalReference = reference, + Cdc = "SAV", + BType = "Quality", + IsQuality = true + }; + + private static byte[] BuildFixedProtectionPayload(int rawValue) + { + var payload = new byte[64]; + for (var channel = 0; channel < 8; channel++) + BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(channel * 8, 4), rawValue); + return payload; + } +} diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvSustainedObservationSessionTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvSustainedObservationSessionTests.cs new file mode 100644 index 00000000..ac33da21 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvSustainedObservationSessionTests.cs @@ -0,0 +1,152 @@ +using AR.Iec61850.Ethernet; +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; +using AR.Iec61850.SampledValues.Profiles; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvSustainedObservationSessionTests +{ + [Fact] + public void SessionFeedsOneParsedFrameIntoObservationAndSustainedEvidence() + { + var session = new SvSustainedObservationSession(); + var frame = BuildFrame(17); + + Assert.True(session.TryObserve( + DateTimeOffset.UnixEpoch, + frame, + SvObservationInputKind.LiveCapture, + out var result)); + + Assert.Equal(result.Observation.Key, result.Sustained.Key); + Assert.Equal(SvObservedStreamKey.FromFrame(frame), result.Sustained.Key); + Assert.Equal(1, result.Sustained.FrameCount); + Assert.Equal(1, result.Sustained.AsduCount); + Assert.Equal(1, session.ObservationStreamCount); + Assert.Equal(1, session.SustainedStreamCount); + Assert.Contains(SvObservationInputKind.LiveCapture, result.Observation.InputKinds); + } + + [Fact] + public void LiveCaptureAndPcapReplayProduceEquivalentSustainedMetrics() + { + var live = new SvSustainedObservationSession(); + var replay = new SvSustainedObservationSession(); + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + SvSustainedObservationResult liveResult = new(); + SvSustainedObservationResult replayResult = new(); + for (var index = 0; index < 64; index++) + { + var frame = BuildFrame((ushort)index); + var timestamp = start.AddMilliseconds(index * 0.25); + Assert.True(live.TryObserve(timestamp, frame, SvObservationInputKind.LiveCapture, out liveResult)); + Assert.True(replay.TryObserve(timestamp, frame, SvObservationInputKind.PcapReplay, out replayResult)); + } + + Assert.Equal(liveResult.Sustained.Key, replayResult.Sustained.Key); + Assert.Equal(liveResult.Sustained.FrameCount, replayResult.Sustained.FrameCount); + Assert.Equal(liveResult.Sustained.AsduCount, replayResult.Sustained.AsduCount); + Assert.Equal(liveResult.Sustained.Continuity, replayResult.Sustained.Continuity); + Assert.Equal(liveResult.Sustained.Synchronization, replayResult.Sustained.Synchronization); + Assert.Equal(liveResult.Sustained.Timing.FrameRateHz, replayResult.Sustained.Timing.FrameRateHz); + Assert.Equal(liveResult.Sustained.Timing.EstimatedSampleRateHz, replayResult.Sustained.Timing.EstimatedSampleRateHz); + Assert.Contains(SvObservationInputKind.LiveCapture, liveResult.Observation.InputKinds); + Assert.Contains(SvObservationInputKind.PcapReplay, replayResult.Observation.InputKinds); + } + + [Fact] + public void FrameRejectedByCanonicalObservationPathDoesNotAdvanceSustainedState() + { + var session = new SvSustainedObservationSession(); + var rejected = BuildFrame(0, payloadBytes: 0); + + Assert.False(session.TryObserve( + DateTimeOffset.UnixEpoch, + rejected, + SvObservationInputKind.PcapReplay, + out _)); + + Assert.Equal(0, session.ObservationStreamCount); + Assert.Equal(0, session.SustainedStreamCount); + Assert.Empty(session.SnapshotObservations()); + Assert.Empty(session.SnapshotSustained()); + } + + [Fact] + public void SessionPreservesBoundedSustainedRegistryDuringLongSyntheticIngestion() + { + var sustained = new SvSustainedStreamAnalyzer(new SvSustainedAnalysisOptions + { + MaximumStreams = 4, + StreamIdleTimeout = TimeSpan.FromHours(1) + }); + var session = new SvSustainedObservationSession(sustained: sustained); + var start = DateTimeOffset.UnixEpoch; + + for (var index = 0; index < 10_000; index++) + { + var stream = index % 8; + var frame = BuildFrame( + (ushort)(index % 4000), + svId: $"MU{stream:D2}", + appId: (ushort)(0x4000 + stream)); + Assert.True(session.TryObserve( + start.AddTicks(index * 2_500L), + frame, + SvObservationInputKind.PcapReplay, + out _)); + } + + Assert.Equal(4, session.SustainedStreamCount); + Assert.Equal(4, session.SnapshotSustained().Count); + Assert.Equal(8, session.ObservationStreamCount); + } + + [Fact] + public void ClearResetsBothObservationLayersTogether() + { + var session = new SvSustainedObservationSession(); + Assert.True(session.TryObserve( + DateTimeOffset.UnixEpoch, + BuildFrame(0), + SvObservationInputKind.LiveCapture, + out _)); + + session.Clear(); + + Assert.Equal(0, session.ObservationStreamCount); + Assert.Equal(0, session.SustainedStreamCount); + Assert.Empty(session.SnapshotObservations()); + Assert.Empty(session.SnapshotSustained()); + } + + private static SampledValuesFrame BuildFrame( + ushort sampleCount, + string svId = "MU01", + ushort appId = 0x4000, + int payloadBytes = 8) + => new() + { + Destination = MacAddress.Parse("01:0C:CD:04:00:01"), + Source = MacAddress.Parse("02:00:00:00:00:01"), + AppId = appId, + Pdu = new SampledValuesPdu + { + Asdus = + [ + new SampledValueAsdu + { + SvId = svId, + DataSetReference = "MU01/LLN0$Dataset1", + SampleCount = sampleCount, + SampleSynchronization = 2, + SampleRate = 4000, + SampleMode = 1, + SamplePayload = new byte[payloadBytes] + } + ] + } + }; +} diff --git a/tests/AR.Iec61850.Tests/SampledValues/SvSustainedStreamAnalyzerTests.cs b/tests/AR.Iec61850.Tests/SampledValues/SvSustainedStreamAnalyzerTests.cs new file mode 100644 index 00000000..df3c1cf2 --- /dev/null +++ b/tests/AR.Iec61850.Tests/SampledValues/SvSustainedStreamAnalyzerTests.cs @@ -0,0 +1,160 @@ +using AR.Iec61850.Ethernet; +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; + +namespace AR.Iec61850.Tests.SampledValues; + +public sealed class SvSustainedStreamAnalyzerTests +{ + [Fact] + public void SustainedAnalyzerTracksRateContinuityAndSynchronizationWithoutRetainingPayloadHistory() + { + var analyzer = new SvSustainedStreamAnalyzer(new SvSustainedAnalysisOptions + { + SampleCounterWrap = 4000, + TimestampEvidence = SvCaptureTimestampEvidence.HostSoftwareTimestamp, + StreamIdleTimeout = TimeSpan.FromMinutes(1) + }); + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + SvSustainedStreamSnapshot snapshot = new(); + for (var index = 0; index < 20; index++) + { + Assert.True(analyzer.TryObserve( + start.AddMilliseconds(index * 0.25), + BuildFrame((ushort)index, sampleSynchronization: 2), + out snapshot)); + } + + Assert.Equal(20, snapshot.FrameCount); + Assert.Equal(20, snapshot.AsduCount); + Assert.Equal(19, snapshot.Continuity.ContinuousTransitions); + Assert.Equal(0, snapshot.Continuity.GapTransitions); + Assert.Equal(20, snapshot.Synchronization.SampleSynchronization2); + Assert.Equal(4000.0, Assert.IsType(snapshot.Timing.FrameRateHz), 3); + Assert.Equal(4000.0, Assert.IsType(snapshot.Timing.EstimatedSampleRateHz), 3); + Assert.False(snapshot.Timing.IsStrongTimingEvidence); + Assert.Contains("ordinary capture-arrival evidence", snapshot.Timing.ClaimBoundary, StringComparison.Ordinal); + Assert.Equal(8, snapshot.StablePayloadBytesPerAsdu); + } + + [Fact] + public void SustainedAnalyzerCountsMissingSamplesAndDoesNotCallArrivalGapNetworkLoss() + { + var analyzer = new SvSustainedStreamAnalyzer(new SvSustainedAnalysisOptions + { + SampleCounterWrap = 4000, + DropoutIntervalMultiplier = 2.0 + }); + var start = DateTimeOffset.UnixEpoch; + + analyzer.TryObserve(start, BuildFrame(10), out _); + analyzer.TryObserve(start.AddMilliseconds(1), BuildFrame(11), out _); + analyzer.TryObserve(start.AddMilliseconds(2), BuildFrame(14), out _); + analyzer.TryObserve(start.AddMilliseconds(8), BuildFrame(15), out var snapshot); + + Assert.Equal(1, snapshot.Continuity.GapTransitions); + Assert.Equal(2, snapshot.Continuity.MissingSamples); + Assert.True(snapshot.Timing.DropoutIntervals >= 1); + Assert.Contains(snapshot.Diagnostics, item => item.Contains("not proof of network loss", StringComparison.Ordinal)); + } + + [Fact] + public void SustainedAnalyzerBoundsStreamRegistryAndEvictsOldestStream() + { + var analyzer = new SvSustainedStreamAnalyzer(new SvSustainedAnalysisOptions + { + MaximumStreams = 2, + StreamIdleTimeout = TimeSpan.FromHours(1) + }); + var start = DateTimeOffset.UnixEpoch; + + analyzer.TryObserve(start, BuildFrame(0, svId: "A", appId: 0x4001), out _); + analyzer.TryObserve(start.AddMilliseconds(1), BuildFrame(0, svId: "B", appId: 0x4002), out _); + analyzer.TryObserve(start.AddMilliseconds(2), BuildFrame(0, svId: "C", appId: 0x4003), out _); + + var snapshots = analyzer.SnapshotAll(); + Assert.Equal(2, snapshots.Count); + Assert.DoesNotContain(snapshots, item => item.Key.SvId == "A"); + Assert.Contains(snapshots, item => item.Key.SvId == "B"); + Assert.Contains(snapshots, item => item.Key.SvId == "C"); + } + + [Fact] + public void SustainedAnalyzerFlagsPayloadLayoutChangeInsideSameStreamIdentity() + { + var analyzer = new SvSustainedStreamAnalyzer(); + var start = DateTimeOffset.UnixEpoch; + + analyzer.TryObserve(start, BuildFrame(0, payloadBytes: 8), out _); + analyzer.TryObserve(start.AddMilliseconds(1), BuildFrame(1, payloadBytes: 16), out var snapshot); + + Assert.True(snapshot.PayloadLayoutChanged); + Assert.Equal(0, snapshot.StablePayloadBytesPerAsdu); + Assert.Contains(snapshot.Diagnostics, item => item.Contains("payload length changed", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void HardwareTimestampLabelDoesNotOverclaimClockAccuracy() + { + var analyzer = new SvSustainedStreamAnalyzer(new SvSustainedAnalysisOptions + { + TimestampEvidence = SvCaptureTimestampEvidence.HardwareTimestamp + }); + + analyzer.TryObserve(DateTimeOffset.UnixEpoch, BuildFrame(0), out _); + analyzer.TryObserve(DateTimeOffset.UnixEpoch.AddMilliseconds(1), BuildFrame(1), out var snapshot); + + Assert.True(snapshot.Timing.IsStrongTimingEvidence); + Assert.Contains("require separate validation", snapshot.Timing.ClaimBoundary, StringComparison.Ordinal); + } + + [Fact] + public void SignalWindowAnalyzerReturnsRmsAndFundamentalPhasorForDeterministicSine() + { + const int samplesPerCycle = 80; + const double peak = 100.0; + const double phaseDegrees = 30.0; + var phaseRadians = phaseDegrees * Math.PI / 180.0; + var samples = Enumerable.Range(0, samplesPerCycle) + .Select(index => peak * Math.Cos((2.0 * Math.PI * index / samplesPerCycle) + phaseRadians)) + .ToArray(); + + var result = SvSignalWindowAnalyzer.AnalyzeFundamental(samples); + + var expectedRms = peak / Math.Sqrt(2.0); + Assert.Equal(expectedRms, result.Rms, 9); + Assert.Equal(expectedRms, result.MagnitudeRms, 9); + Assert.Equal(phaseDegrees, result.AngleDegrees, 9); + Assert.Equal(samplesPerCycle, result.SampleCount); + } + + private static SampledValuesFrame BuildFrame( + ushort sampleCount, + byte sampleSynchronization = 2, + string svId = "MU01", + ushort appId = 0x4000, + int payloadBytes = 8) + => new() + { + Destination = MacAddress.Parse("01:0C:CD:04:00:01"), + Source = MacAddress.Parse("02:00:00:00:00:01"), + AppId = appId, + Pdu = new SampledValuesPdu + { + Asdus = + [ + new SampledValueAsdu + { + SvId = svId, + DataSetReference = "MU01/LLN0$Dataset1", + SampleCount = sampleCount, + SampleSynchronization = sampleSynchronization, + SampleRate = 4000, + SampleMode = 1, + SamplePayload = new byte[payloadBytes] + } + ] + } + }; +} diff --git a/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs index 2cb0de25..25bdf4b8 100644 --- a/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/LiveIedSclExporterTests.cs @@ -102,6 +102,109 @@ public void Exporter_Preserves_Mms_TypeSpecification_Declaration_Order_For_Statu .ToArray()); } + [Fact] + public void Exact_Integer_StVal_Refines_Generic_Status_To_Ins_And_Preserves_Int32() + { + var discovery = new MmsDiscoveryResult + { + IedDirectory = new MmsIedModelDirectory( + [ + new MmsFcResolvedPoint + { + Domain = "IEDADD", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = "UserStatus.stVal", + MmsItemName = "GGIO1$ST$UserStatus$stVal" + }, + new MmsFcResolvedPoint + { + Domain = "IEDADD", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = "UserStatus.q", + MmsItemName = "GGIO1$ST$UserStatus$q" + }, + new MmsFcResolvedPoint + { + Domain = "IEDADD", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = "UserStatus.t", + MmsItemName = "GGIO1$ST$UserStatus$t" + } + ]) + }; + var typeResult = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("IEDADD", "GGIO1", string.Empty), + TypeSpecification = new MmsTypeSpecificationNode + { + MmsType = "structure", + SclBType = "Struct", + Children = + [ + new MmsTypeSpecificationNode + { + Name = "ST", + MmsType = "structure", + SclBType = "Struct", + Children = + [ + new MmsTypeSpecificationNode + { + Name = "UserStatus", + MmsType = "structure", + SclBType = "Struct", + Children = + [ + new MmsTypeSpecificationNode { Name = "stVal", MmsType = "integer", SclBType = "INT32" }, + new MmsTypeSpecificationNode { Name = "q", MmsType = "bit-string", SclBType = "Quality" }, + new MmsTypeSpecificationNode { Name = "t", MmsType = "utc-time", SclBType = "Timestamp" } + ] + } + ] + } + ] + } + }; + + var model = LiveIedModelDiscoveryBuilder.Build( + discovery, + new LiveIedModelDiscoveryBuildOptions { IedName = "IED" }, + variableTypeAttributes: [typeResult]); + + var dataObject = Assert.Single( + model.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects), + item => item.Name == "UserStatus"); + Assert.Equal("INS", dataObject.InferredCdc); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Exact, + Assert.Single(dataObject.Attributes, attribute => attribute.AttributePath == "stVal").TypeConfidence); + + var document = LiveIedSclExporter.BuildDocument( + model, + new LiveIedSclExportOptions { Profile = "full-model" }); + var ns = document.Root!.Name.Namespace; + var lNodeType = Assert.Single( + document.Descendants(ns + "LNodeType"), + element => (string?)element.Attribute("lnClass") == "GGIO"); + var doRef = Assert.Single( + lNodeType.Elements(ns + "DO"), + element => (string?)element.Attribute("name") == "UserStatus"); + var doType = Assert.Single( + document.Descendants(ns + "DOType"), + element => (string?)element.Attribute("id") == (string?)doRef.Attribute("type")); + + Assert.Equal("INS", (string?)doType.Attribute("cdc")); + var stVal = Assert.Single( + doType.Elements(ns + "DA"), + element => (string?)element.Attribute("name") == "stVal"); + Assert.Equal("INT32", (string?)stVal.Attribute("bType")); + } + [Fact] public void Exporter_Preserves_CaseDistinct_Ltrk_Cts_Members_From_Root_TypeSpecification() { diff --git a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs index bdd61755..b4266050 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs @@ -156,6 +156,54 @@ public void Resolve_Conflicting_Duplicate_Critical_Field_Fails_Closed() field.State == SclAssociationFieldState.Ambiguous); } + [Fact] + public void Resolve_ZivStylePackedFirstSubidentifier_ProducesExactWireOid_WithoutDiscovery() + { + var remote = BuildRemote( + tsel: "0001", + ssel: "0001", + psel: "00000001", + apTitle: "41,1,999,1", + aeQualifierText: "12", + aeQualifier: 12); + + var exact = SclAssistedMmsAssociationPlanBuilder.BuildExact( + remote, MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.True(exact.IsSuccess, string.Join(" | ", exact.Errors)); + Assert.Equal(new uint[] { 1, 1, 1, 999, 1 }, exact.Plan!.Association.Called.ApTitle); + Assert.Contains(exact.Warnings, message => message.Contains("BER-packed", StringComparison.Ordinal)); + + var result = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.True(result.IsSuccess, string.Join(" | ", result.Errors)); + Assert.Equal(SclAssociationCandidateSource.ExactScl, result.Candidates[0].Source); + Assert.Contains(result.Warnings, message => + message.Contains("41,1,999,1", StringComparison.Ordinal) && + message.Contains("1,1,1,999,1", StringComparison.Ordinal)); + + var wire = AcseAssociationRequestIdentityReader.Read( + result.Candidates[0].AssociationProfile.Payload, + result.Candidates[0].Cotp.DestinationTsap); + Assert.Equal(new uint[] { 1, 1, 1, 999, 1 }, wire.CalledApTitle); + Assert.Equal(12, wire.CalledAeQualifier); + } + + [Theory] + [InlineData("80,1,999,1")] + [InlineData("41")] + [InlineData("41,invalid,999,1")] + [InlineData("3,1,999,1")] + public void Resolve_StillRejectsUnprovenMalformedApTitles(string title) + { + var remote = BuildRemote("0001", "0001", "00000001", title, "12", 12); + var resolution = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.False(resolution.IsSuccess); + Assert.Empty(resolution.Candidates); + Assert.Contains(resolution.Fields, f => f.Name == "OSI-AP-Title" && + f.State == SclAssociationFieldState.Invalid); + } + private static SclMmsAccessPoint BuildRemote( string tsel, string ssel, diff --git a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs index 6110c990..5ec16bd8 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs @@ -57,7 +57,7 @@ public void DomainInventoryReader_Fails_Closed_When_Selected_AccessPoint_Has_No_ Assert.False(inventory.IsSuccess); Assert.Empty(inventory.ExpectedDomains); - Assert.Contains(inventory.Errors, error => error.Contains("no direct Server", StringComparison.Ordinal)); + Assert.Contains(inventory.Errors, error => error.Contains("no resolvable Server/ServerAt model", StringComparison.Ordinal)); } [Fact] diff --git a/tests/AR.Iec61850.Tests/Scl/SclParserTests.cs b/tests/AR.Iec61850.Tests/Scl/SclParserTests.cs index 4d9cffb8..4447b0be 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclParserTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclParserTests.cs @@ -95,6 +95,45 @@ public void Parser_Detects_Edition1_When_Historical_Namespace_Has_No_Root_Versio Assert.Equal(SclEdition.Edition1, document.Edition); } + [Theory] + [InlineData("2003", null, null, SclEdition.Edition1)] + [InlineData("2007", "B", null, SclEdition.Edition2)] + [InlineData("2007", "B", "3", SclEdition.Edition2)] + [InlineData("2007", "B", "4", SclEdition.Edition21)] + [InlineData("2007", "B", "4A", SclEdition.Edition21)] + [InlineData("2007", "C", "5", SclEdition.Edition22)] + [InlineData("2007", "C", "4", SclEdition.Unknown)] + [InlineData("2007", "C", null, SclEdition.Unknown)] + [InlineData("2007", "B", "5", SclEdition.Unknown)] + [InlineData("2007", "Z", "5", SclEdition.Unknown)] + [InlineData("2003", "C", "5", SclEdition.Unknown)] + [InlineData("2099", "C", "5", SclEdition.Unknown)] + public void Parser_DetectsExactSclEditionWithoutNamespaceGuessOrDowngrade( + string? version, string? revision, string? release, SclEdition expected) + { + // Source-independent minimal synthetic SCL, not a vendor/customer IID. + static string Attr(string name, string? value) + => value is null ? string.Empty : " " + name + "=\"" + value + "\""; + var xml = "
"; + var parsed = new SclParser().Parse(xml, "edition-matrix.iid"); + + Assert.Equal(expected, parsed.Edition); + Assert.Equal("http://www.iec.ch/61850/2003/SCL", parsed.NamespaceUri); + } + + [Fact] + public void AddingEdition22_DoesNotRenumberPreviouslyPublishedEditionEnumValues() + { + Assert.Equal(0, (int)SclEdition.Unknown); + Assert.Equal(1, (int)SclEdition.Edition1); + Assert.Equal(2, (int)SclEdition.Edition2); + Assert.Equal(3, (int)SclEdition.Edition21); + Assert.Equal(4, (int)SclEdition.Edition22); + } + internal static SclDocument LoadMinimalStation() => new SclParser().Load(MinimalStationPath()); diff --git a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs new file mode 100644 index 00000000..cecab309 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs @@ -0,0 +1,146 @@ +using AR.Iec61850.Scl; +using AR.Iec61850.Scl.Engineering; +using AR.Iec61850.Scl.Workspace; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class StationServerAtRegressionTests +{ + [Fact] + public void Parse_SiemensStyle_7Sx85_ServerAt_Preserves_Model_On_Both_Mms_Endpoints() + { + // Sanitized topology derived from a real Siemens station SCD. + // Do not commit the customer's exported SCD or network configuration. + var document = new SclWorkspaceService().Parse(StationFixture(), "station.scd"); + + Assert.Equal(2, document.MmsEndpoints.Count); + Assert.Equal(2, document.Ieds.Count); + + var j = document.Ieds.Single(ied => ied.AccessPointName == "J"); + var f = document.Ieds.Single(ied => ied.AccessPointName == "F"); + + Assert.Equal("192.0.2.11", j.PreferredEndpoint!.IpAddress); + Assert.Equal("198.51.100.12", f.PreferredEndpoint!.IpAddress); + Assert.Equal(102, j.PreferredEndpoint.Port); + Assert.Equal(102, f.PreferredEndpoint.Port); + Assert.False(j.RequiresEndpointBinding); + Assert.False(f.RequiresEndpointBinding); + Assert.True(j.CanBrowseOffline); + Assert.True(f.CanBrowseOffline); + Assert.Single(j.DesignModel.LogicalDevices); + Assert.Single(f.DesignModel.LogicalDevices); + Assert.Equal(j.DesignModel.LogicalDevices[0].MmsDomain, f.DesignModel.LogicalDevices[0].MmsDomain); + Assert.Equal("GR_X_7SX85Application", f.DesignModel.LogicalDevices[0].MmsDomain); + Assert.Equal("F", f.DesignModel.AccessPointName); + Assert.Equal("198.51.100.12", f.DesignModel.Host); + Assert.Single(f.DataSets); + Assert.Single(f.ReportControls); + Assert.DoesNotContain(f.Findings, finding => finding.Code == "SCL_ACCESS_POINT_WITHOUT_SERVER"); + Assert.DoesNotContain(f.Findings, finding => finding.Code == "SCL_SERVER_AT_UNRESOLVED"); + } + + [Fact] + public void Engineering_Profile_Valid_ServerAt_Is_Not_Reported_As_Missing_Server() + { + var profile = new SclEngineeringProfileBuilder().Parse(StationFixture(), "station.scd"); + + Assert.Equal(2, profile.AccessPoints.Count); + var f = profile.AccessPoints.Single(ap => ap.Name == "F"); + Assert.False(f.HasServer); // Not a physical Server: references J via ServerAt. + Assert.Equal(1, f.LogicalDeviceCount); + Assert.DoesNotContain(profile.Findings, item => + item.Code == "SCL_ACCESS_POINT_WITHOUT_SERVER" || + item.Code == "SCL_SERVER_AT_UNRESOLVED"); + } + + [Fact] + public void SclAssisted_Preparation_Resolves_F_ServerAt_To_J_Domains_And_Fc_Roots() + { + var xml = StationFixture(); + var domainsJ = SclMmsDomainInventoryReader.Read(xml, "GR_X_7SX85", "J"); + var domainsF = SclMmsDomainInventoryReader.Read(xml, "GR_X_7SX85", "F"); + + Assert.True(domainsJ.IsSuccess); + Assert.True(domainsF.IsSuccess); + Assert.Equal(domainsJ.ExpectedDomains, domainsF.ExpectedDomains); + Assert.Equal("F", domainsF.AccessPointName); + Assert.Single(domainsF.ExpectedDomains); + + var initialJ = SclInitialFcReadDesignBuilder.Read(xml, "GR_X_7SX85", "J"); + var initialF = SclInitialFcReadDesignBuilder.Read(xml, "GR_X_7SX85", "F"); + Assert.True(initialJ.IsSuccess, string.Join(" | ", initialJ.Errors)); + Assert.True(initialF.IsSuccess, string.Join(" | ", initialF.Errors)); + Assert.Equal("F", initialF.Model.AccessPointName); + Assert.Equal(initialJ.Model.LogicalDevices.Count, initialF.Model.LogicalDevices.Count); + Assert.Equal(initialJ.Model.DataSets.Count, initialF.Model.DataSets.Count); + Assert.Equal(initialJ.Model.ReportControls.Count, initialF.Model.ReportControls.Count); + Assert.Single(initialF.Model.LogicalDevices); + } + + [Fact] + public void Dangling_ServerAt_Fails_Closed_Without_Fabricating_Mms_Model_Or_Losing_Ip() + { + var broken = StationFixture().Replace( + "", + "", + StringComparison.Ordinal); + var document = new SclWorkspaceService().Parse(broken, "dangling.scd"); + + var j = document.Ieds.Single(ied => ied.AccessPointName == "J"); + var f = document.Ieds.Single(ied => ied.AccessPointName == "F"); + Assert.True(j.CanBrowseOffline); + Assert.False(f.CanBrowseOffline); + Assert.Equal("198.51.100.12", f.PreferredEndpoint!.IpAddress); + Assert.Contains(document.Findings, finding => + finding.Code == "SCL_SERVER_AT_UNRESOLVED" && finding.Severity == "High"); + var invalidDomains = SclMmsDomainInventoryReader.Read(broken, "GR_X_7SX85", "F"); + var invalidInitial = SclInitialFcReadDesignBuilder.Read(broken, "GR_X_7SX85", "F"); + Assert.False(invalidDomains.IsSuccess); + Assert.False(invalidInitial.IsSuccess); + Assert.Empty(invalidDomains.ExpectedDomains); + } + + private static string StationFixture() => """ + +
+ + + +

192.0.2.11

+
+
+ + +

198.51.100.12

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + """; +} diff --git a/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs b/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs index 059f3fe3..f7d86174 100644 --- a/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs +++ b/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs @@ -128,6 +128,71 @@ public async Task ReportingRuntime_RequiresExplicitGiAndEmitsStandardReasonBits( Assert.Equal((byte)0x08, reason.RawValue[1]); } + [Fact] + public async Task ReportingRuntime_ConcurrentGiBurst_IsSerializedInWireOrder() + { + var enteredFirstSend = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirstSend = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var twoReportsSent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sent = new List(); + var activeSends = 0; + var maxConcurrentSends = 0; + + using var runtime = new MmsAssociationReportingRuntime( + () => CreateSession(), + async (payload, ct) => + { + var active = Interlocked.Increment(ref activeSends); + maxConcurrentSends = Math.Max(maxConcurrentSends, active); + try + { + if (active == 1 && sent.Count == 0) + { + enteredFirstSend.TrySetResult(); + await releaseFirstSend.Task.WaitAsync(ct); + } + + lock (sent) + { + sent.Add(payload); + if (sent.Count == 2) + twoReportsSent.TrySetResult(); + } + } + finally + { + Interlocked.Decrement(ref activeSends); + } + }); + var rcb = Assert.Single(runtime.States, state => !state.Definition.Buffered); + Assert.True(runtime.TryWriteRcbAttribute( + $"{rcb.MmsReference}$RptEna", MmsDataValue.Boolean(true), out var enabled)); + Assert.Equal(0, enabled); + Assert.True(runtime.TryWriteRcbAttribute( + $"{rcb.MmsReference}$GI", MmsDataValue.Boolean(true), out var first)); + Assert.Equal(0, first); + await enteredFirstSend.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True(runtime.TryWriteRcbAttribute( + $"{rcb.MmsReference}$GI", MmsDataValue.Boolean(true), out var second)); + Assert.Equal(0, second); + + // If a per-report fire-and-forget Task can overtake the blocked first + // send, the observed active sender count or frame order would fail. + await Task.Delay(100); + Assert.Equal(1, Volatile.Read(ref activeSends)); + Assert.Equal(1, maxConcurrentSends); + releaseFirstSend.TrySetResult(); + await twoReportsSent.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var decoded = sent.Select(payload => + MmsReportFrameMapper.DecodeHeader(MmsInformationReportDecoder.Decode(payload))) + .ToArray(); + Assert.Equal(new ulong?[] { 1, 2 }, decoded.Select(frame => frame.SequenceNumber)); + Assert.Equal(1, maxConcurrentSends); + Assert.All(decoded, frame => Assert.Equal(rcb.RptId, frame.ReportId)); + } + [Fact] public void ReportControlBlockLayout_UsesExpectedPackedListWidths() {