[opcuaclient] OpcUaClient — IHistoryProvider.ReadEventsAsync interface fix + impl #402
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user