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

@@ -197,6 +197,20 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
| `Start` | `AggregateFunction_Start` | | `Start` | `AggregateFunction_Start` |
| `End` | `AggregateFunction_End` | | `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 ### 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. 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.

View File

@@ -126,3 +126,70 @@ expose a "ReverseConnect.Endpoint" config knob).
- Public-internet OPC UA: reverse-connect is a network-policy workaround, - Public-internet OPC UA: reverse-connect is a network-policy workaround,
not a security primitive. Always pair with `Sign` or `SignAndEncrypt` not a security primitive. Always pair with `Sign` or `SignAndEncrypt`
+ a vetted user-token policy. + 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<SimpleAttributeSpec>`. 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`.

View File

@@ -86,7 +86,16 @@ param(
[string]$UpstreamNodeId = "ns=3;s=StepUp", [string]$UpstreamNodeId = "ns=3;s=StepUp",
[int]$ChangeWaitSec = 10, [int]$ChangeWaitSec = 10,
[switch]$ReverseConnect, [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" $ErrorActionPreference = "Stop"
@@ -164,6 +173,33 @@ if ($triggerCmd) {
$results += [pscustomobject]@{ Stage = "Topology-change"; Status = "SKIP" } $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 ""
Write-Host "=== test-opcuaclient.ps1 results ===" Write-Host "=== test-opcuaclient.ps1 results ==="
$results | Format-Table -AutoSize $results | Format-Table -AutoSize

View File

@@ -76,8 +76,107 @@ public interface IHistoryProvider
=> throw new NotSupportedException( => throw new NotSupportedException(
$"{GetType().Name} does not implement ReadEventsAsync. " + $"{GetType().Name} does not implement ReadEventsAsync. " +
"Drivers whose backends have an event historian override this method."); "Drivers whose backends have an event historian override this method.");
/// <summary>
/// Filter-aware historical event read — OPC UA HistoryReadEvents service with full
/// <c>EventFilter</c> support (SelectClauses + WhereClause). Distinct from the simpler
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
/// overload which is sufficient for "give me the standard BaseEventType fields"
/// queries; this overload is for clients that send a custom <c>EventFilter</c> on the
/// wire (per-select-clause Variant population, where-filter evaluation).
/// </summary>
/// <param name="fullReference">
/// 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".
/// </param>
/// <param name="request">Filter spec — time range + select clauses + optional where clause.</param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The OPC UA Client driver implements this by translating <see cref="EventHistoryRequest"/>
/// into <c>ReadEventDetails</c> and calling <c>Session.HistoryReadAsync</c> against
/// the upstream server.
/// </para>
/// </remarks>
Task<HistoricalEventBatch> 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.");
} }
/// <summary>
/// Filter spec for the filter-aware <see cref="IHistoryProvider.ReadEventsAsync(string, EventHistoryRequest, CancellationToken)"/>
/// overload. Mirrors the OPC UA <c>ReadEventDetails</c> 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.
/// </summary>
/// <param name="StartTime">Inclusive lower bound on event time.</param>
/// <param name="EndTime">Exclusive upper bound on event time.</param>
/// <param name="NumValuesPerNode">Maximum events per node (0 = no driver-side cap, server may still apply one).</param>
/// <param name="SelectClauses">
/// Per-field projection. Each entry names a BaseEventType-rooted field (or a
/// typed-path field via <see cref="SimpleAttributeSpec.TypeDefinitionId"/>) the caller
/// wants returned. <c>null</c> means "use the driver's default field set" — typically
/// EventId, SourceName, Time, Message, Severity, ReceiveTime.
/// </param>
/// <param name="WhereClause">
/// Optional content-filter restriction (e.g. <c>EventType OfType AlarmConditionType</c>).
/// Drivers may ignore the where clause if their backend doesn't support it; that's a
/// best-effort projection rather than a hard error.
/// </param>
public sealed record EventHistoryRequest(
DateTime StartTime,
DateTime EndTime,
uint NumValuesPerNode,
IReadOnlyList<SimpleAttributeSpec>? SelectClauses,
ContentFilterSpec? WhereClause);
/// <summary>
/// Transport-neutral mirror of OPC UA's <c>SimpleAttributeOperand</c> — picks one field
/// from a node by typed browse path. <see cref="TypeDefinitionId"/> is the OPC UA NodeId
/// of the type that the path is rooted at (e.g. <c>BaseEventType</c>); <see cref="BrowsePath"/>
/// is a sequence of QualifiedName-style segments (<c>"ns:Name"</c> or just <c>"Name"</c>
/// when ns=0). An empty <see cref="BrowsePath"/> means "the node itself".
/// </summary>
/// <param name="TypeDefinitionId">
/// Type the path is rooted at. <c>null</c> defaults to the OPC UA <c>BaseEventType</c>
/// when the driver has a UA mapping. Format is driver-specific NodeId text (e.g.
/// <c>"i=2041"</c> for BaseEventType).
/// </param>
/// <param name="BrowsePath">Browse-path segments. Empty list = the typed node itself.</param>
/// <param name="FieldName">
/// Stable key the driver uses when populating <see cref="HistoricalEventRow.Fields"/>. 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.
/// </param>
public sealed record SimpleAttributeSpec(
string? TypeDefinitionId,
IReadOnlyList<string> BrowsePath,
string FieldName);
/// <summary>
/// Transport-neutral mirror of OPC UA's <c>ContentFilter</c>. The current shape carries the
/// raw filter operands as opaque OPC UA <c>ExtensionObject</c> 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.
/// </summary>
/// <param name="EncodedOperands">
/// Optional binary-encoded <c>ContentFilter</c> from the wire. <c>null</c> when no
/// where-clause was supplied.
/// </param>
public sealed record ContentFilterSpec(byte[]? EncodedOperands);
/// <summary>Result of a HistoryRead call.</summary> /// <summary>Result of a HistoryRead call.</summary>
/// <param name="Samples">Returned samples in chronological order.</param> /// <param name="Samples">Returned samples in chronological order.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param> /// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
@@ -96,9 +195,11 @@ public enum HistoryAggregateType
} }
/// <summary> /// <summary>
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical /// One row returned by the fixed-field
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the /// <see cref="IHistoryProvider.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses. /// overload — a historical alarm/event record, not the OPC UA live-event stream. Fields
/// match the minimum set the Server needs to populate a <c>HistoryEventFieldList</c>
/// for HistoryReadEvents responses.
/// </summary> /// </summary>
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param> /// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param> /// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
@@ -114,9 +215,46 @@ public sealed record HistoricalEvent(
string? Message, string? Message,
ushort Severity); ushort Severity);
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary> /// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param> /// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param> /// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult( public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events, IReadOnlyList<HistoricalEvent> Events,
byte[]? ContinuationPoint); byte[]? ContinuationPoint);
/// <summary>
/// One row returned by the filter-aware
/// <see cref="IHistoryProvider.ReadEventsAsync(string, EventHistoryRequest, CancellationToken)"/>
/// overload. Carries an open-ended <see cref="Fields"/> bag keyed by
/// <see cref="SimpleAttributeSpec.FieldName"/> (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.
/// </summary>
/// <param name="Fields">
/// SelectClause results. Keys match the <c>FieldName</c> on the corresponding
/// <see cref="SimpleAttributeSpec"/>; values are the raw .NET payload (string,
/// <c>DateTime</c>, severity int, etc.). <c>null</c> values are legitimate (the
/// upstream had a missing field).
/// </param>
/// <param name="OccurrenceTime">
/// Wall-clock event time — convenience for ordering / windowing without picking a key
/// out of <see cref="Fields"/>. Drivers populate this from the underlying event row.
/// </param>
public sealed record HistoricalEventRow(
IReadOnlyDictionary<string, object?> Fields,
DateTimeOffset OccurrenceTime);
/// <summary>
/// Result of the filter-aware
/// <see cref="IHistoryProvider.ReadEventsAsync(string, EventHistoryRequest, CancellationToken)"/>
/// overload. Mirrors <see cref="HistoricalEventsResult"/> but carries
/// <see cref="HistoricalEventRow"/> instead of the fixed-shape
/// <see cref="HistoricalEvent"/> — the server-side dispatcher unpacks the keyed fields
/// into a <c>HistoryEventFieldList</c> aligned with the client's SelectClauses.
/// </summary>
/// <param name="Events">Events in chronological order by <see cref="HistoricalEventRow.OccurrenceTime"/>.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventBatch(
IReadOnlyList<HistoricalEventRow> Events,
byte[]? ContinuationPoint);

View File

@@ -2711,11 +2711,174 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null), _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null),
}; };
// ReadEventsAsync stays at the interface default (throws NotSupportedException) per // The fixed-field ReadEventsAsync(sourceName,...) overload stays at the interface
// IHistoryProvider contract -- the OPC UA Client driver CAN forward HistoryReadEvents, // default. The OPC UA Client driver implements the filter-aware
// but the call-site needs an EventFilter SelectClauses surface which the interface // ReadEventsAsync(fullReference, EventHistoryRequest, ct) overload below — that one
// doesn't carry. Landing the event-history passthrough requires extending // carries the EventFilter SelectClauses + WhereClause shape we need to translate the
// IHistoryProvider.ReadEventsAsync with a filter-spec parameter; out of scope for this PR. // 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 ---- // ---- IHostConnectivityProbe ----

View File

@@ -0,0 +1,64 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// End-to-end smoke against a live <c>opc-plc</c> simulator launched in alarm mode
/// (<c>--alm</c>). Exercises the filter-aware
/// <see cref="OpcUaClientDriver.ReadEventsAsync(string, EventHistoryRequest, System.Threading.CancellationToken)"/>
/// overload by issuing a HistoryReadEvents against the simulator's <c>Server</c> notifier
/// and asserting at least one historical event row comes back with the SelectClause
/// fields populated.
/// </summary>
/// <remarks>
/// Requires the simulator started with <c>--alm</c> (alarm + history simulation), which
/// <see cref="OpcPlcFixture"/> does not guarantee — the test skips with a clear reason
/// when the upstream returns <c>BadHistoryOperationUnsupported</c> 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.
/// </remarks>
[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
}
}

View File

@@ -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;
/// <summary>
/// Unit tests for the filter-aware
/// <see cref="OpcUaClientDriver.ReadEventsAsync(string, EventHistoryRequest, System.Threading.CancellationToken)"/>
/// overload (PR-12 / #284). The driver-level wire path needs a live <see cref="ISession"/>
/// 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.
/// </summary>
[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<SimpleAttributeSpec>
{
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<SimpleAttributeSpec>
{
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<SimpleAttributeSpec>
{
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<InvalidOperationException>(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<ArgumentNullException>(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<NotSupportedException>(async () =>
await stub.ReadEventsAsync(
fullReference: "ns=2;s=AlarmsNotifier",
request: request,
cancellationToken: TestContext.Current.CancellationToken));
}
private sealed class NotImplementedHistoryStub : IHistoryProvider
{
public Task<Core.Abstractions.HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc,
uint maxValuesPerNode, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task<Core.Abstractions.HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc,
TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
=> throw new NotImplementedException();
}
}