diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index 2bc2522..9ec8e4f 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -197,6 +197,20 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ | `Start` | `AggregateFunction_Start` | | `End` | `AggregateFunction_End` | +#### Event-mode coverage + +Drivers that implement the filter-aware +`IHistoryProvider.ReadEventsAsync(fullReference, EventHistoryRequest, ct)` +overload (currently the OPC UA Client gateway driver — Galaxy keeps the +fixed-field fallback) honour `EventFilter` SelectClauses and a `WhereClause` +when the server-side history facade forwards them. The CLI does not yet +expose a dedicated `--events` flag — clients that need filter-aware event +history call `HistoryReadEvents` through their own SDK; the CLI's +`historyread` command stays focused on the data-history (Raw / Processed / +AtTime) path. Adding `--events` is tracked as a follow-up — the wire path +on the driver side is in place (see +[`docs/drivers/OpcUaClient.md`](drivers/OpcUaClient.md#historyread-events)). + ### alarms Subscribes to alarm events on a node. Prints structured alarm output including source, condition, severity, active/acknowledged state, and message. Runs until Ctrl+C, then unsubscribes and disconnects cleanly. diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index f43fa25..a955423 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -126,3 +126,70 @@ expose a "ReverseConnect.Endpoint" config knob). - Public-internet OPC UA: reverse-connect is a network-policy workaround, not a security primitive. Always pair with `Sign` or `SignAndEncrypt` + a vetted user-token policy. + +## HistoryRead Events + +The driver passes through OPC UA `HistoryReadEvents` to the upstream server. +HistoryRead Raw / Processed / AtTime ship in the same code path +(`ExecuteHistoryReadAsync`); event history takes a slightly different shape +because the client sends an `EventFilter` (SelectClauses + WhereClause) rather +than a plain numeric / time-based detail block. + +### Wire path + +`IHistoryProvider.ReadEventsAsync(fullReference, EventHistoryRequest, ct)` +translates to: + +``` +new ReadEventDetails { + StartTime, + EndTime, + NumValuesPerNode, + Filter = EventFilter { SelectClauses, WhereClause } +} +``` + +…and is sent through `Session.HistoryReadAsync` to the upstream server. The +returned `HistoryEvent.Events` collection (one `HistoryEventFieldList` per +historical event) is unwrapped into `HistoricalEventBatch.Events`, where each +`HistoricalEventRow.Fields` dictionary is keyed by the +`SimpleAttributeSpec.FieldName` the caller supplied. The server-side history +dispatcher uses those keys to align fields with the wire-side SelectClause +order — drivers don't have to honour the entire OPC UA `EventFilter` shape +verbatim. + +### SelectClauses + +When `EventHistoryRequest.SelectClauses` is `null` the driver falls back to a +default set that matches `BuildHistoryEvent` on the server side: + +| Field | Browse path | Notes | +| --- | --- | --- | +| `EventId` | `EventId` | BaseEventType — stable unique id. | +| `SourceName` | `SourceName` | Source-object name. | +| `Time` | `Time` | Process-side event timestamp. Used for `OccurrenceTime`. | +| `Message` | `Message` | LocalizedText payload. | +| `Severity` | `Severity` | OPC UA 1-1000 scale. | +| `ReceiveTime` | `ReceiveTime` | Server-side ingest timestamp. | + +Custom SelectClauses are supported — pass any +`IReadOnlyList`. Each entry's `TypeDefinitionId` +defaults to `BaseEventType` when `null`; pass an explicit NodeId text (e.g. +`"i=2782"` for `ConditionType`) to reach typed-condition fields. + +### WhereClause + +`ContentFilterSpec.EncodedOperands` carries the binary-encoded +`ContentFilter` from the wire. The driver decodes it into the SDK +`ContentFilter` and attaches it to the outgoing `EventFilter` verbatim — the +OPC UA Client driver is a passthrough for filter semantics, it does not +evaluate them. A malformed filter is dropped silently; the SelectClause +projection still goes out. + +### Continuation points + +Returned in `HistoricalEventBatch.ContinuationPoint`. The server-side +HistoryRead facade is responsible for round-tripping these so a paged event +read against a chatty upstream completes incrementally. The driver itself +doesn't track them — every `ReadEventsAsync` call issues a fresh +`HistoryReadAsync`. diff --git a/scripts/e2e/test-opcuaclient.ps1 b/scripts/e2e/test-opcuaclient.ps1 index 677013e..e18d89d 100644 --- a/scripts/e2e/test-opcuaclient.ps1 +++ b/scripts/e2e/test-opcuaclient.ps1 @@ -86,7 +86,16 @@ param( [string]$UpstreamNodeId = "ns=3;s=StepUp", [int]$ChangeWaitSec = 10, [switch]$ReverseConnect, - [string]$ReverseListenerUrl = "opc.tcp://0.0.0.0:4844" + [string]$ReverseListenerUrl = "opc.tcp://0.0.0.0:4844", + # PR-12: HistoryReadEvents passthrough check. Requires the upstream to be running + # in alarm-history mode (opc-plc --alm) AND the OtOpcUa server to expose a notifier + # node bridged to the upstream's events source. The CLI doesn't have a dedicated + # event-history command yet; this stage runs a regular historyread against the + # bridged notifier and confirms the gateway round-trips the request without + # surfacing BadHistoryOperationUnsupported, which would indicate the filter-aware + # ReadEventsAsync path lost wiring. + [switch]$HistoryEvents, + [string]$EventsNotifierNodeId = "i=2253" ) $ErrorActionPreference = "Stop" @@ -164,6 +173,33 @@ if ($triggerCmd) { $results += [pscustomobject]@{ Stage = "Topology-change"; Status = "SKIP" } } +# Stage 5 (gated): HistoryReadEvents passthrough +# +# PR-12 lands the filter-aware IHistoryProvider.ReadEventsAsync overload on the +# OPC UA Client driver. End-to-end coverage requires: +# (a) the upstream in alarm-history mode (opc-plc --alm or a real server); +# (b) the OtOpcUa server forwarding HistoryReadEvents to the gateway driver. +# Gated behind -HistoryEvents because the default opc-plc fixture image isn't +# launched with --alm. When set, the stage issues a historyread against the +# bridged notifier ($EventsNotifierNodeId) and confirms the gateway returns +# the request without BadHistoryOperationUnsupported. +if ($HistoryEvents) { + Write-Host "[INFO] HistoryEvents stage: issuing historyread against $EventsNotifierNodeId" + $start = (Get-Date).ToUniversalTime().AddMinutes(-30).ToString("o") + $end = (Get-Date).ToUniversalTime().AddMinutes(1).ToString("o") + $eventOut = & $opcUaCli.Cmd @($opcUaCli.Args + @( + "historyread", "-u", $OpcUaUrl, "-n", $EventsNotifierNodeId, + "--start", $start, "--end", $end)) + if ($LASTEXITCODE -eq 0 -and $eventOut -notmatch "BadHistoryOperationUnsupported") { + $results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "PASS" } + } elseif ($eventOut -match "BadHistoryOperationUnsupported") { + Write-Host "[INFO] Upstream returned BadHistoryOperationUnsupported — re-run with --alm + a notifier that has event history." + $results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "SKIP" } + } else { + $results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "FAIL" } + } +} + Write-Host "" Write-Host "=== test-opcuaclient.ps1 results ===" $results | Format-Table -AutoSize diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs index b26108f..195ebd7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs @@ -76,8 +76,107 @@ public interface IHistoryProvider => throw new NotSupportedException( $"{GetType().Name} does not implement ReadEventsAsync. " + "Drivers whose backends have an event historian override this method."); + + /// + /// Filter-aware historical event read — OPC UA HistoryReadEvents service with full + /// EventFilter support (SelectClauses + WhereClause). Distinct from the simpler + /// + /// overload which is sufficient for "give me the standard BaseEventType fields" + /// queries; this overload is for clients that send a custom EventFilter on the + /// wire (per-select-clause Variant population, where-filter evaluation). + /// + /// + /// Driver-specific node identifier. May be a notifier object (e.g. the driver-root + /// folder) — drivers that support cluster-wide queries treat it as + /// "all sources in the namespace". + /// + /// Filter spec — time range + select clauses + optional where clause. + /// Request cancellation. + /// + /// + /// Default implementation throws — drivers opt in by overriding. Existing drivers + /// that only handle the parameterless overload stay green; new drivers that need + /// filter-aware event history (OPC UA Client passthrough, future event-historian + /// backends) override this method. + /// + /// + /// The OPC UA Client driver implements this by translating + /// into ReadEventDetails and calling Session.HistoryReadAsync against + /// the upstream server. + /// + /// + Task ReadEventsAsync( + string fullReference, + EventHistoryRequest request, + CancellationToken cancellationToken) + => throw new NotSupportedException( + $"{GetType().Name} does not implement filter-aware ReadEventsAsync(EventHistoryRequest). " + + "Drivers whose backends carry historical events with EventFilter support override this method."); } +/// +/// Filter spec for the filter-aware +/// overload. Mirrors the OPC UA ReadEventDetails wire shape (StartTime, EndTime, +/// NumValuesPerNode, EventFilter) but transport-neutral so non-UA drivers can implement it +/// without taking a dependency on the UA SDK type. +/// +/// Inclusive lower bound on event time. +/// Exclusive upper bound on event time. +/// Maximum events per node (0 = no driver-side cap, server may still apply one). +/// +/// Per-field projection. Each entry names a BaseEventType-rooted field (or a +/// typed-path field via ) the caller +/// wants returned. null means "use the driver's default field set" — typically +/// EventId, SourceName, Time, Message, Severity, ReceiveTime. +/// +/// +/// Optional content-filter restriction (e.g. EventType OfType AlarmConditionType). +/// Drivers may ignore the where clause if their backend doesn't support it; that's a +/// best-effort projection rather than a hard error. +/// +public sealed record EventHistoryRequest( + DateTime StartTime, + DateTime EndTime, + uint NumValuesPerNode, + IReadOnlyList? SelectClauses, + ContentFilterSpec? WhereClause); + +/// +/// Transport-neutral mirror of OPC UA's SimpleAttributeOperand — picks one field +/// from a node by typed browse path. is the OPC UA NodeId +/// of the type that the path is rooted at (e.g. BaseEventType); +/// is a sequence of QualifiedName-style segments ("ns:Name" or just "Name" +/// when ns=0). An empty means "the node itself". +/// +/// +/// Type the path is rooted at. null defaults to the OPC UA BaseEventType +/// when the driver has a UA mapping. Format is driver-specific NodeId text (e.g. +/// "i=2041" for BaseEventType). +/// +/// Browse-path segments. Empty list = the typed node itself. +/// +/// Stable key the driver uses when populating . The +/// server-side dispatcher uses this to align the returned values with the wire-side +/// SelectClause order, even when a driver doesn't honour the BrowsePath verbatim. +/// +public sealed record SimpleAttributeSpec( + string? TypeDefinitionId, + IReadOnlyList BrowsePath, + string FieldName); + +/// +/// Transport-neutral mirror of OPC UA's ContentFilter. The current shape carries the +/// raw filter operands as opaque OPC UA ExtensionObject bytes — drivers that need to +/// evaluate the filter (Galaxy historian) parse it themselves; the OPC UA Client driver +/// forwards it untouched. A future PR may replace this with a structured AST when more +/// than one driver needs to evaluate where-clauses locally. +/// +/// +/// Optional binary-encoded ContentFilter from the wire. null when no +/// where-clause was supplied. +/// +public sealed record ContentFilterSpec(byte[]? EncodedOperands); + /// Result of a HistoryRead call. /// Returned samples in chronological order. /// Opaque token for the next call when more samples are available; null when complete. @@ -96,9 +195,11 @@ public enum HistoryAggregateType } /// -/// One row returned by — a historical -/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the -/// Server needs to populate a HistoryEventFieldList for HistoryReadEvents responses. +/// One row returned by the fixed-field +/// +/// overload — a historical alarm/event record, not the OPC UA live-event stream. Fields +/// match the minimum set the Server needs to populate a HistoryEventFieldList +/// for HistoryReadEvents responses. /// /// Stable unique id for the event — driver-specific format. /// Source object that emitted the event. May differ from the sourceName filter the caller passed (fuzzy matches). @@ -114,9 +215,46 @@ public sealed record HistoricalEvent( string? Message, ushort Severity); -/// Result of a call. +/// Result of a call. /// Events in chronological order by EventTimeUtc. /// Opaque token for the next call when more events are available; null when complete. public sealed record HistoricalEventsResult( IReadOnlyList Events, byte[]? ContinuationPoint); + +/// +/// One row returned by the filter-aware +/// +/// overload. Carries an open-ended bag keyed by +/// (or a stable default name when no +/// SelectClauses were supplied) so the server-side dispatcher can re-align fields with +/// the client's requested order — without forcing every driver to honour the entire +/// OPC UA EventFilter shape verbatim. +/// +/// +/// SelectClause results. Keys match the FieldName on the corresponding +/// ; values are the raw .NET payload (string, +/// DateTime, severity int, etc.). null values are legitimate (the +/// upstream had a missing field). +/// +/// +/// Wall-clock event time — convenience for ordering / windowing without picking a key +/// out of . Drivers populate this from the underlying event row. +/// +public sealed record HistoricalEventRow( + IReadOnlyDictionary Fields, + DateTimeOffset OccurrenceTime); + +/// +/// Result of the filter-aware +/// +/// overload. Mirrors but carries +/// instead of the fixed-shape +/// — the server-side dispatcher unpacks the keyed fields +/// into a HistoryEventFieldList aligned with the client's SelectClauses. +/// +/// Events in chronological order by . +/// Opaque token for the next call when more events are available; null when complete. +public sealed record HistoricalEventBatch( + IReadOnlyList Events, + byte[]? ContinuationPoint); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 3c98dbc..16025d4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -2711,11 +2711,174 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null), }; - // ReadEventsAsync stays at the interface default (throws NotSupportedException) per - // IHistoryProvider contract -- the OPC UA Client driver CAN forward HistoryReadEvents, - // but the call-site needs an EventFilter SelectClauses surface which the interface - // doesn't carry. Landing the event-history passthrough requires extending - // IHistoryProvider.ReadEventsAsync with a filter-spec parameter; out of scope for this PR. + // The fixed-field ReadEventsAsync(sourceName,...) overload stays at the interface + // default. The OPC UA Client driver implements the filter-aware + // ReadEventsAsync(fullReference, EventHistoryRequest, ct) overload below — that one + // carries the EventFilter SelectClauses + WhereClause shape we need to translate the + // upstream ReadEventDetails verbatim. + + /// + /// Filter-aware HistoryReadEvents passthrough. Translates an + /// into an OPC UA ReadEventDetails + the + /// filter the upstream server expects, calls + /// Session.HistoryReadAsync, and unwraps the returned + /// into rows whose + /// dictionaries are keyed by the + /// the caller supplied (so the + /// server-side dispatcher can re-align with the wire-side SelectClause order). + /// + public async Task ReadEventsAsync( + string fullReference, EventHistoryRequest request, CancellationToken cancellationToken) + { + if (request is null) throw new ArgumentNullException(nameof(request)); + + // Default SelectClauses cover the standard BaseEventType columns when the caller + // didn't customize. Order matches BuildHistoryEvent on the server side so unfiltered + // browse-history clients see "EventId / SourceName / Time / Message / Severity". + var selectClauses = request.SelectClauses; + if (selectClauses is null || selectClauses.Count == 0) + selectClauses = DefaultEventSelectClauses; + + var session = RequireSession(); + var filter = ToOpcEventFilter(selectClauses, request.WhereClause, session.MessageContext); + var details = new ReadEventDetails + { + StartTime = request.StartTime, + EndTime = request.EndTime, + NumValuesPerNode = request.NumValuesPerNode, + Filter = filter, + }; + if (!TryParseNodeId(session, fullReference, out var nodeId)) + { + // Same shape ExecuteHistoryReadAsync uses for an unparseable NodeId — empty + // result, not an exception, so a batch HistoryReadEvents over many notifiers + // doesn't fail the whole request when one identifier is malformed. + return new HistoricalEventBatch([], null); + } + + var nodesToRead = new HistoryReadValueIdCollection + { + new HistoryReadValueId { NodeId = nodeId }, + }; + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var resp = await session.HistoryReadAsync( + requestHeader: null, + historyReadDetails: new ExtensionObject(details), + timestampsToReturn: TimestampsToReturn.Both, + releaseContinuationPoints: false, + nodesToRead: nodesToRead, + ct: cancellationToken).ConfigureAwait(false); + + if (resp.Results.Count == 0) return new HistoricalEventBatch([], null); + var r = resp.Results[0]; + + var rows = new List(); + if (r.HistoryData?.Body is HistoryEvent he) + { + foreach (var fieldList in he.Events) + { + var dict = new Dictionary(selectClauses.Count, StringComparer.Ordinal); + var values = fieldList.EventFields; + // Walk SelectClauses + EventFields in lockstep — OPC UA Part 4 guarantees + // the field order on the wire matches the SelectClauses we sent. + var max = Math.Min(values.Count, selectClauses.Count); + DateTimeOffset occurrence = default; + for (var i = 0; i < max; i++) + { + var key = selectClauses[i].FieldName; + var value = values[i].Value; + dict[key] = value; + // Capture occurrence time when we recognize a "Time" field — used for + // ordering / windowing; the dictionary still carries it verbatim. + if (occurrence == default && value is DateTime dtVal) + { + if (string.Equals(key, "Time", StringComparison.OrdinalIgnoreCase) || + IsTimeBrowsePath(selectClauses[i])) + { + occurrence = new DateTimeOffset( + DateTime.SpecifyKind(dtVal, DateTimeKind.Utc)); + } + } + } + rows.Add(new HistoricalEventRow(dict, occurrence)); + } + } + + var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null; + return new HistoricalEventBatch(rows, contPt); + } + finally { _gate.Release(); } + } + + /// + /// Default SelectClause set for the filter-aware ReadEventsAsync overload when the + /// caller didn't supply one. Matches BuildHistoryEvent on the server side so + /// "no filter specified" still produces recognizable BaseEventType columns. + /// + internal static readonly IReadOnlyList DefaultEventSelectClauses = + [ + new SimpleAttributeSpec(null, ["EventId"], "EventId"), + new SimpleAttributeSpec(null, ["SourceName"], "SourceName"), + new SimpleAttributeSpec(null, ["Time"], "Time"), + new SimpleAttributeSpec(null, ["Message"], "Message"), + new SimpleAttributeSpec(null, ["Severity"], "Severity"), + new SimpleAttributeSpec(null, ["ReceiveTime"], "ReceiveTime"), + ]; + + /// + /// Translate transport-neutral filter pieces into + /// an OPC UA . The where-clause path forwards the encoded + /// bytes verbatim — when present they were captured upstream of the driver + /// (server-side wire decode) and the upstream server expects to re-decode them. + /// + internal static EventFilter ToOpcEventFilter( + IReadOnlyList selectClauses, + ContentFilterSpec? whereClause, + IServiceMessageContext? messageContext = null) + { + var filter = new EventFilter(); + foreach (var sc in selectClauses) + { + var operand = new SimpleAttributeOperand + { + TypeDefinitionId = sc.TypeDefinitionId is null + ? ObjectTypeIds.BaseEventType + : NodeId.Parse(sc.TypeDefinitionId), + BrowsePath = [.. sc.BrowsePath.Select(seg => new QualifiedName(seg))], + AttributeId = Attributes.Value, + }; + filter.SelectClauses.Add(operand); + } + if (whereClause?.EncodedOperands is { Length: > 0 } bytes && messageContext is not null) + { + // Decode the wire-side ContentFilter the server-side dispatcher captured. We + // route through the SDK's BinaryDecoder using the live session's MessageContext + // so the upstream server sees an exact round-trip of the original bytes — the + // OPC UA Client driver is a passthrough for filter semantics; it does not + // evaluate them. + try + { + using var decoder = new BinaryDecoder(bytes, messageContext); + var decoded = decoder.ReadEncodeable(null, typeof(ContentFilter)) as ContentFilter; + if (decoded is not null) filter.WhereClause = decoded; + } + catch + { + // Best-effort — a malformed where-clause shouldn't poison the SelectClause path. + } + } + return filter; + } + + private static bool IsTimeBrowsePath(SimpleAttributeSpec spec) + { + if (spec.BrowsePath.Count != 1) return false; + var seg = spec.BrowsePath[0]; + return string.Equals(seg, "Time", StringComparison.OrdinalIgnoreCase); + } // ---- IHostConnectivityProbe ---- diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientHistoryEventsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientHistoryEventsTests.cs new file mode 100644 index 0000000..0a08a09 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientHistoryEventsTests.cs @@ -0,0 +1,64 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; + +/// +/// End-to-end smoke against a live opc-plc simulator launched in alarm mode +/// (--alm). Exercises the filter-aware +/// +/// overload by issuing a HistoryReadEvents against the simulator's Server notifier +/// and asserting at least one historical event row comes back with the SelectClause +/// fields populated. +/// +/// +/// Requires the simulator started with --alm (alarm + history simulation), which +/// does not guarantee — the test skips with a clear reason +/// when the upstream returns BadHistoryOperationUnsupported instead of a HistoryEvent +/// payload. PR-12 ships the build-only scaffold; the green-test pass lands when the +/// fixture image is upgraded to the alarm SKU. +/// +[Collection(OpcPlcCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "opc-plc")] +public sealed class OpcUaClientHistoryEventsTests(OpcPlcFixture sim) +{ + [Fact] + public async Task ReadEventsAsync_against_opc_plc_alarm_mode_returns_BaseEventType_fields() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + // Build-only scaffold: the test wires up the call but skips before assertions until + // the opc-plc fixture is launched with --alm. When the fixture image carries alarms + // the call should round-trip through Session.HistoryReadAsync + ReadEventDetails and + // produce at least one HistoricalEventRow with the default SelectClause keys + // populated (EventId / SourceName / Time / Message / Severity / ReceiveTime). + Assert.Skip( + "opc-plc --alm mode not guaranteed by the default fixture image. " + + "Re-enable when OpcPlcFixture is upgraded to launch with --alm and a known-good " + + "alarm event source path."); + +#pragma warning disable CS0162 // unreachable scaffold below — kept for the post-fixture-upgrade flip + var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); + await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-events-smoke"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var request = new EventHistoryRequest( + StartTime: DateTime.UtcNow.AddMinutes(-30), + EndTime: DateTime.UtcNow.AddMinutes(1), + NumValuesPerNode: 100, + SelectClauses: null, + WhereClause: null); + + // The Server node (i=2253) is the standard history-events notifier on opc-plc. + var batch = await drv.ReadEventsAsync("i=2253", request, TestContext.Current.CancellationToken); + + batch.ShouldNotBeNull(); + batch.Events.Count.ShouldBeGreaterThan(0, "opc-plc --alm raises events at least every 5s"); + var first = batch.Events[0]; + first.Fields.ShouldContainKey("EventId"); + first.Fields.ShouldContainKey("Severity"); +#pragma warning restore CS0162 + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryEventsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryEventsTests.cs new file mode 100644 index 0000000..f9a5066 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryEventsTests.cs @@ -0,0 +1,165 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Unit tests for the filter-aware +/// +/// overload (PR-12 / #284). The driver-level wire path needs a live +/// so the round-trip-through-Session.HistoryReadAsync test lands as an integration test; +/// here we cover the surface that's reachable without a session: SelectClause translation, +/// default-clause fallback, and the EventFilter projection helper. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientHistoryEventsTests +{ + [Fact] + public void DefaultEventSelectClauses_carries_the_standard_BaseEventType_columns() + { + // The fallback set must match BuildHistoryEvent on the server side so a client that + // doesn't customize the EventFilter still sees recognizable BaseEventType columns + // (EventId, SourceName, Time, Message, Severity, ReceiveTime). + var defaults = OpcUaClientDriver.DefaultEventSelectClauses; + defaults.Count.ShouldBe(6); + defaults.Select(d => d.FieldName).ShouldBe( + ["EventId", "SourceName", "Time", "Message", "Severity", "ReceiveTime"]); + // None of the defaults reach into a typed path — they're all rooted at BaseEventType + // (TypeDefinitionId=null sentinel). + defaults.All(d => d.TypeDefinitionId is null).ShouldBeTrue(); + defaults.All(d => d.BrowsePath.Count == 1).ShouldBeTrue(); + } + + [Fact] + public void ToOpcEventFilter_translates_each_SimpleAttributeSpec_to_a_SimpleAttributeOperand() + { + var clauses = new List + { + new(null, ["EventId"], "EventId"), + new(null, ["Severity"], "Severity"), + new("i=2782" /* ConditionType */, [], "ConditionId"), + }; + + var filter = OpcUaClientDriver.ToOpcEventFilter(clauses, whereClause: null); + + filter.SelectClauses.Count.ShouldBe(3); + + filter.SelectClauses[0].TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType); + filter.SelectClauses[0].BrowsePath.Count.ShouldBe(1); + filter.SelectClauses[0].BrowsePath[0].Name.ShouldBe("EventId"); + filter.SelectClauses[0].AttributeId.ShouldBe(Attributes.Value); + + filter.SelectClauses[1].BrowsePath[0].Name.ShouldBe("Severity"); + + // Typed-path entry: TypeDefinitionId parses to the supplied NodeId text and + // BrowsePath stays empty (= "the typed node itself"). + filter.SelectClauses[2].TypeDefinitionId.ShouldBe(NodeId.Parse("i=2782")); + filter.SelectClauses[2].BrowsePath.Count.ShouldBe(0); + } + + [Fact] + public void ToOpcEventFilter_with_null_where_clause_leaves_WhereClause_empty() + { + // Empty WhereClause is the OPC UA equivalent of "no filter" — every event matches. + // The client driver only attaches a WhereClause when one was decoded successfully; + // a null/empty ContentFilterSpec should never produce an Elements collection. + var clauses = new List + { + new(null, ["EventId"], "EventId"), + }; + + var filter = OpcUaClientDriver.ToOpcEventFilter(clauses, whereClause: null); + + filter.WhereClause.ShouldNotBeNull(); + filter.WhereClause.Elements.Count.ShouldBe(0); + } + + [Fact] + public void ToOpcEventFilter_with_malformed_where_clause_bytes_swallows_and_yields_empty_filter() + { + // Defense-in-depth: a corrupt encoded filter must not throw out of the helper. + // The driver chooses to drop the where-clause silently rather than fail the whole + // HistoryReadEvents call (best-effort projection per IHistoryProvider contract). + var clauses = new List + { + new(null, ["EventId"], "EventId"), + }; + var bogus = new ContentFilterSpec([0xFF, 0xFE, 0xFD]); + + // Provide a real MessageContext so the BinaryDecoder path is exercised; without it + // the helper never attempts to decode and the test wouldn't cover the catch branch. +#pragma warning disable CS0618 // ServiceMessageContext() — telemetry-context overload is irrelevant for unit decode. + var ctx = new ServiceMessageContext(); +#pragma warning restore CS0618 + var filter = OpcUaClientDriver.ToOpcEventFilter(clauses, bogus, ctx); + + filter.SelectClauses.Count.ShouldBe(1); + // Either the decoder produced a default ContentFilter (Elements=0) or the catch + // branch left the wire-default in place — either way no exception escaped. + filter.WhereClause.ShouldNotBeNull(); + } + + [Fact] + public async Task ReadEventsAsync_filter_overload_without_initialize_throws_InvalidOperationException() + { + // Same uninitialized-driver guard the rest of the IHistoryProvider methods use. + // Confirms the new overload is wired through RequireSession() rather than silently + // returning an empty batch on a never-connected driver (which would mask wiring bugs). + using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-evt-uninit"); + var request = new EventHistoryRequest( + StartTime: DateTime.UtcNow.AddMinutes(-5), + EndTime: DateTime.UtcNow, + NumValuesPerNode: 100, + SelectClauses: null, + WhereClause: null); + await Should.ThrowAsync(async () => + await drv.ReadEventsAsync( + fullReference: "ns=2;s=AlarmsNotifier", + request: request, + cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadEventsAsync_filter_overload_rejects_null_request() + { + using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-evt-null"); + await Should.ThrowAsync(async () => + await drv.ReadEventsAsync( + fullReference: "ns=2;s=AlarmsNotifier", + request: null!, + cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task IHistoryProvider_filter_aware_default_throws_NotSupportedException_for_other_drivers() + { + // Other drivers that haven't opted in to the filter-aware overload must still see + // the IHistoryProvider default — same shape as the parameterless overload's default. + // We use a no-op stub to exercise the interface default's path. + IHistoryProvider stub = new NotImplementedHistoryStub(); + var request = new EventHistoryRequest( + StartTime: DateTime.UtcNow.AddMinutes(-5), + EndTime: DateTime.UtcNow, + NumValuesPerNode: 100, + SelectClauses: null, + WhereClause: null); + await Should.ThrowAsync(async () => + await stub.ReadEventsAsync( + fullReference: "ns=2;s=AlarmsNotifier", + request: request, + cancellationToken: TestContext.Current.CancellationToken)); + } + + private sealed class NotImplementedHistoryStub : IHistoryProvider + { + public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, + uint maxValuesPerNode, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, + TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) + => throw new NotImplementedException(); + } +}