[opcuaclient] OpcUaClient — IHistoryProvider.ReadEventsAsync interface fix + impl #402

Merged
dohertj2 merged 1 commits from auto/opcuaclient/12 into auto/driver-gaps 2026-04-26 09:32:24 -04:00
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` |
| `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
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,
not a security primitive. Always pair with `Sign` or `SignAndEncrypt`
+ 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",
[int]$ChangeWaitSec = 10,
[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"
@@ -164,6 +173,33 @@ if ($triggerCmd) {
$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 "=== test-opcuaclient.ps1 results ==="
$results | Format-Table -AutoSize

View File

@@ -76,8 +76,107 @@ public interface IHistoryProvider
=> throw new NotSupportedException(
$"{GetType().Name} does not implement ReadEventsAsync. " +
"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>
/// <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>
@@ -96,9 +195,11 @@ public enum HistoryAggregateType
}
/// <summary>
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — 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.
/// One row returned by the fixed-field
/// <see cref="IHistoryProvider.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
/// 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>
/// <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>
@@ -114,9 +215,46 @@ public sealed record HistoricalEvent(
string? Message,
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="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events,
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),
};
// 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 ----

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();
}
}