Auto: opcuaclient-12 — IHistoryProvider.ReadEventsAsync EventFilter spec + impl

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
This commit is contained in:
Joseph Doherty
2026-04-26 09:29:40 -04:00
parent 2ee61c0999
commit c36903d6a0
7 changed files with 657 additions and 10 deletions

View File

@@ -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.
/// <summary>
/// Filter-aware HistoryReadEvents passthrough. Translates an
/// <see cref="EventHistoryRequest"/> into an OPC UA <c>ReadEventDetails</c> + the
/// filter the upstream server expects, calls
/// <c>Session.HistoryReadAsync</c>, and unwraps the returned
/// <see cref="HistoryEvent"/> into <see cref="HistoricalEventBatch"/> rows whose
/// <see cref="HistoricalEventRow.Fields"/> dictionaries are keyed by the
/// <see cref="SimpleAttributeSpec.FieldName"/> the caller supplied (so the
/// server-side dispatcher can re-align with the wire-side SelectClause order).
/// </summary>
public async Task<HistoricalEventBatch> 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<HistoricalEventRow>();
if (r.HistoryData?.Body is HistoryEvent he)
{
foreach (var fieldList in he.Events)
{
var dict = new Dictionary<string, object?>(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(); }
}
/// <summary>
/// Default SelectClause set for the filter-aware ReadEventsAsync overload when the
/// caller didn't supply one. Matches <c>BuildHistoryEvent</c> on the server side so
/// "no filter specified" still produces recognizable BaseEventType columns.
/// </summary>
internal static readonly IReadOnlyList<SimpleAttributeSpec> 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"),
];
/// <summary>
/// Translate transport-neutral <see cref="EventHistoryRequest"/> filter pieces into
/// an OPC UA <see cref="EventFilter"/>. 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.
/// </summary>
internal static EventFilter ToOpcEventFilter(
IReadOnlyList<SimpleAttributeSpec> 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 ----