feat(opcuaclient): implement IHistoryProvider.ReadEventsAsync passthrough

This commit is contained in:
Joseph Doherty
2026-06-18 06:07:32 -04:00
parent d48674ba31
commit 045f9ca2e8
2 changed files with 78 additions and 13 deletions
@@ -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.
/// <summary>
/// Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical
/// <see cref="BuildBaseEventFilter"/> and maps the result onto <see cref="HistoricalEvent"/>
/// (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).
/// </summary>
/// <param name="sourceName">
/// Upstream event-notifier NodeId to read from (mirrors how <c>fullReference</c> is the
/// upstream NodeId for raw reads). Null/empty → the upstream Server object (<c>i=2253</c>),
/// the standard server-wide event notifier. An unparseable id short-circuits to an empty
/// result, matching the raw path's malformed-NodeId behavior.
/// </param>
/// <param name="startUtc">Inclusive lower bound on event time (UTC).</param>
/// <param name="endUtc">Exclusive upper bound on event time (UTC).</param>
/// <param name="maxEvents">Upper cap; <c>&lt;= 0</c> means "no cap" (NumValuesPerNode = 0).</param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <returns>The historical events plus the upstream continuation point (null when complete).</returns>
public async Task<Core.Abstractions.HistoricalEventsResult> 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 ----
@@ -80,16 +80,13 @@ public sealed class OpcUaClientHistoryTests
TestContext.Current.CancellationToken));
}
/// <summary>Verifies ReadEventsAsync throws NotSupportedException as documented.</summary>
/// <summary>ReadEventsAsync requires an initialized session like the sibling history reads.</summary>
[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<NotSupportedException>(async () =>
await ((IHistoryProvider)drv).ReadEventsAsync(
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadEventsAsync(
sourceName: null,
startUtc: DateTime.UtcNow.AddMinutes(-5),
endUtc: DateTime.UtcNow,