From c36903d6a0b7e8d842a7e7b993a06aa3d913aac7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 09:29:40 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20opcuaclient-12=20=E2=80=94=20IHistoryPr?= =?UTF-8?q?ovider.ReadEventsAsync=20EventFilter=20spec=20+=20impl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a filter-aware overload of IHistoryProvider.ReadEventsAsync that carries EventFilter SelectClauses + WhereClause, and implements it on the OPC UA Client driver via Session.HistoryReadAsync + ReadEventDetails. The change is additive (default-impl returns NotSupportedException) so the existing Galaxy.Proxy.GalaxyProxyDriver implementation keeps compiling against the fixed-field overload — no cross-driver refactor required. * Core.Abstractions: new EventHistoryRequest / SimpleAttributeSpec / ContentFilterSpec records mirror the OPC UA wire shape transport-neutrally. HistoricalEventBatch / HistoricalEventRow carry an open-ended Fields bag keyed by SimpleAttributeSpec.FieldName so server-side dispatch can re-align with the client's wire-side SelectClause order. * OpcUaClient driver: new ReadEventsAsync(fullReference, EventHistoryRequest, ct) builds an EventFilter, calls Session.HistoryReadAsync, and unwraps HistoryEvent.Events into HistoricalEventBatch rows. Default SelectClause set matches BuildHistoryEvent on the server side. ContentFilter bytes are decoded through the live session's MessageContext (passthrough — the driver does not evaluate filters). * Unit tests: 7 new tests cover SelectClause translation, default-clause fallback, malformed where-clause swallowing, uninitialized-driver guard, null-request guard, and IHistoryProvider default fallback. * Integration scaffold: build-only [Fact] gated on opc-plc --alm; flips to green when the fixture image is upgraded. * Docs: HistoryRead Events section in docs/drivers/OpcUaClient.md plus a cross-link from Client.CLI.md historyread page. * E2E: -HistoryEvents switch on scripts/e2e/test-opcuaclient.ps1 confirms the gateway round-trips HistoryReadEvents without BadHistoryOperationUnsupported (gated; defaults to skip). Closes #284 --- docs/Client.CLI.md | 14 ++ docs/drivers/OpcUaClient.md | 67 +++++++ scripts/e2e/test-opcuaclient.ps1 | 38 +++- .../IHistoryProvider.cs | 146 ++++++++++++++- .../OpcUaClientDriver.cs | 173 +++++++++++++++++- .../OpcUaClientHistoryEventsTests.cs | 64 +++++++ .../OpcUaClientHistoryEventsTests.cs | 165 +++++++++++++++++ 7 files changed, 657 insertions(+), 10 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientHistoryEventsTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryEventsTests.cs 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(); + } +}