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:
@@ -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 ----
|
||||
|
||||
|
||||
Reference in New Issue
Block a user