From 045f9ca2e848af38242d078e7805c729ff8deb2c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 06:07:32 -0400 Subject: [PATCH] feat(opcuaclient): implement IHistoryProvider.ReadEventsAsync passthrough --- .../OpcUaClientDriver.cs | 78 +++++++++++++++++-- .../OpcUaClientHistoryTests.cs | 13 ++-- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 4dec9564..73c3a551 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -1764,11 +1764,79 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return 0; } } - // 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. + /// + /// Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical + /// and maps the result onto + /// (the OtOpcUa server projects only those six BaseEventType fields, so a richer client + /// filter would be discarded server-side — the driver supplies the canonical set itself). + /// + /// + /// Upstream event-notifier NodeId to read from (mirrors how fullReference is the + /// upstream NodeId for raw reads). Null/empty → the upstream Server object (i=2253), + /// the standard server-wide event notifier. An unparseable id short-circuits to an empty + /// result, matching the raw path's malformed-NodeId behavior. + /// + /// Inclusive lower bound on event time (UTC). + /// Exclusive upper bound on event time (UTC). + /// Upper cap; <= 0 means "no cap" (NumValuesPerNode = 0). + /// Request cancellation. + /// The historical events plus the upstream continuation point (null when complete). + public async Task ReadEventsAsync( + string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, + CancellationToken cancellationToken) + { + var session = RequireSession(); + + NodeId notifierNodeId; + if (string.IsNullOrEmpty(sourceName)) + { + notifierNodeId = ObjectIds.Server; + } + else if (!TryParseNodeId(session, sourceName, out var parsed)) + { + return new Core.Abstractions.HistoricalEventsResult([], null); + } + else + { + notifierNodeId = parsed; + } + + var details = new ReadEventDetails + { + StartTime = startUtc, + EndTime = endUtc, + NumValuesPerNode = maxEvents <= 0 ? 0u : (uint)maxEvents, + Filter = BuildBaseEventFilter(), + }; + + var nodesToRead = new HistoryReadValueIdCollection + { + new HistoryReadValueId { NodeId = notifierNodeId }, + }; + + 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 Core.Abstractions.HistoricalEventsResult([], null); + var r = resp.Results[0]; + + var events = r.HistoryData?.Body is HistoryEvent he + ? MapHistoryEvents(he) + : []; + + var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null; + return new Core.Abstractions.HistoricalEventsResult(events, contPt); + } + finally { _gate.Release(); } + } // ---- IHostConnectivityProbe ---- diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs index 69eadae4..a515dac5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs @@ -80,16 +80,13 @@ public sealed class OpcUaClientHistoryTests TestContext.Current.CancellationToken)); } - /// Verifies ReadEventsAsync throws NotSupportedException as documented. + /// ReadEventsAsync requires an initialized session like the sibling history reads. [Fact] - public async Task ReadEventsAsync_throws_NotSupportedException_as_documented() + public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException() { - // The IHistoryProvider default implementation throws; the OPC UA Client driver - // deliberately inherits that default (see PR 76 commit body) because the OPC UA - // client call path needs an EventFilter SelectClauses spec the interface doesn't carry. - using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-default"); - await Should.ThrowAsync(async () => - await ((IHistoryProvider)drv).ReadEventsAsync( + using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit"); + await Should.ThrowAsync(async () => + await drv.ReadEventsAsync( sourceName: null, startUtc: DateTime.UtcNow.AddMinutes(-5), endUtc: DateTime.UtcNow,