feat(cli): historyread --events reads event history over the wire (lmxopcua#776)
v2-ci / unit-tests (push) Has been cancelled

Item 1 of #776 (the B2 over-the-wire ask): Client.CLI had no way to issue a
HistoryReadEvents. `historyread --events` now sends ReadEventDetails to an
event notifier and prints one line per event in select-clause order.
--fields picks the select clauses (default EventId, EventType, Time,
SourceName, Severity, Message, ConditionName (on ConditionType), SourceNode);
--of-type adds an OfType where-clause. --events is exclusive with --modified /
--aggregate, and --fields / --of-type require it.

Client.Shared gains IOpcUaClientService/ISessionAdapter.HistoryReadEventsAsync
(default-implemented as NotSupported for other implementations) backed by the
real session adapter through HistoryReadPaging, so continuation points are
followed up to --max and a Bad node status is surfaced, not printed as empty.

Tests: CLI tests over the fake service (default filter, --fields/--of-type
filter, option conflicts); an over-the-wire test drives the shared client's
event read against the in-process server — the default fields come back
non-null, OfType filters, an invalid where-clause is BadEventFilterInvalid.
The other #776 items are decisions and are left open.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 16:07:11 -05:00
parent e790938f94
commit 380ec541c2
11 changed files with 369 additions and 4 deletions
+17 -1
View File
@@ -196,7 +196,7 @@ The summary prints per-node counts across these buckets:
### historyread
Reads historical data from a node. Supports raw history reads and aggregate (processed) history reads.
Reads historical data from a node. Supports raw, aggregate (processed), modified-value and event history reads.
`--start` and `--end` are parsed with `CultureInfo.InvariantCulture` and treated as UTC; supply
them in ISO 8601 UTC form (`YYYY-MM-DDTHH:MM:SSZ`) for unambiguous behaviour across hosts.
@@ -221,6 +221,11 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa -U apikey -P otua_<keyId>_<secret> \
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
--start "2026-01-15T00:00:00Z" --end "2026-01-16T00:00:00Z" --modified
# Event (alarm) history from an event notifier, only LimitAlarmType events and their subtypes
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=3;s=Area1/Line1/Tank1" \
--start "2026-01-15T00:00:00Z" --end "2026-01-16T00:00:00Z" --events --of-type i=2955
```
`--modified` sends `ReadRawModifiedDetails` with `IsReadModified = true` (OPC UA Part 11, Gitea #668) and prints one line per
@@ -229,6 +234,14 @@ recorded modification: the source timestamp, the **prior** value (`(none)` for a
continuation points up to `--max`. A server on the `Gateway` backend (no modification trail) answers `BadHistoryOperationUnsupported`.
`--modified` cannot be combined with `--aggregate`. See [Historian.md §Modified-value history](Historian.md#modified-value-history-part-11).
`--events` sends `ReadEventDetails` (OPC UA Part 11 HistoryReadEvents, Gitea #776) to an **event notifier** — an equipment or device
folder — and prints one ` | `-separated line per event, in `--fields` order. `--fields` takes comma-separated event field browse names
(default `EventId,EventType,Time,SourceName,Severity,Message,ConditionName,SourceNode`; `ConditionName` is selected on `ConditionType`,
every other name on `BaseEventType`); a field the event cannot supply prints as `-`, a byte string (`EventId`) as hex. `--of-type <NodeId>`
adds an `OfType` where-clause (that event type and its subtypes, e.g. `i=2955` LimitAlarmType, `i=10637` OffNormalAlarmType). It follows
continuation points up to `--max`. `--events` cannot be combined with `--modified` or `--aggregate`, and `--fields` / `--of-type` need
`--events`. See [Historian.md §Read variants](Historian.md#read-variants).
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Node ID to read history for (required) |
@@ -238,6 +251,9 @@ continuation points up to `--max`. A server on the `Gateway` backend (no modific
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative |
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) |
| `--modified` | Read modified-value history (Part 11 `IsReadModified`) instead of raw samples |
| `--events` | Read event history (Part 11 HistoryReadEvents) from an event notifier instead of raw samples |
| `--fields` | With `--events`: comma-separated event fields to select (default `EventId,EventType,Time,SourceName,Severity,Message,ConditionName,SourceNode`) |
| `--of-type` | With `--events`: keep only events of this type or a subtype (an `OfType` where-clause), e.g. `i=2955` |
### historyupdate
+1 -1
View File
@@ -563,7 +563,7 @@ The server supports all four OPC UA HistoryRead variants:
| **Raw** | Historized variable | (omit `--aggregate`) |
| **Processed** | Historized variable | `Average`, `Minimum`, `Maximum`, `Count`, `Start`, `End`, `StandardDeviation` (the CLI's own list; `SqlServer` / `QuestDb` serve seventeen aggregates — see the [aggregate table](#aggregates-processed-reads)) |
| **AtTime** | Historized variable | n/a (client supplies exact timestamps) |
| **Events** | Equipment folder (event notifier) | n/a |
| **Events** | Equipment folder (event notifier) | `--events` (+ `--fields`, `--of-type`; #776) |
| **Modified** (Raw with `IsReadModified = true`, #668) | Historized variable | `--modified` (see [Modified-value history](#modified-value-history-part-11)) |
**Variable nodes** (historized tags) serve Raw, Processed, and AtTime history.
@@ -9,7 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
[Command("historyread", Description = "Read historical data from a node")]
[Command("historyread", Description = "Read historical data (raw, aggregate, modified or event history) from a node")]
public class HistoryReadCommand : CommandBase
{
/// <summary>
@@ -63,6 +63,29 @@ public class HistoryReadCommand : CommandBase
[CommandOption("modified", Description = "Read modified-value history (what HistoryUpdate changed: prior value, update type, user, time) instead of raw samples")]
public bool Modified { get; init; }
/// <summary>
/// Gets a value indicating whether to read event history (Part 11 HistoryReadEvents) from an event notifier instead of
/// raw samples.
/// </summary>
[CommandOption("events", Description = "Read event history (HistoryReadEvents) from an event notifier (an equipment/device folder) instead of raw samples")]
public bool Events { get; init; }
/// <summary>
/// Gets the comma-separated event fields to select (BaseEventType browse names, or ConditionType's ConditionName).
/// </summary>
[CommandOption("fields", Description = "Event fields to select with --events, comma-separated (default: EventId,EventType,Time,SourceName,Severity,Message,ConditionName,SourceNode)")]
public string? Fields { get; init; }
/// <summary>
/// Gets the optional event type NodeId for an OfType where-clause on an event read.
/// </summary>
[CommandOption("of-type", Description = "With --events: keep only events of this type or a subtype (an OfType where-clause), e.g. i=2955 (LimitAlarmType)")]
public string? OfType { get; init; }
/// <summary>The event fields an event read selects when <c>--fields</c> is omitted.</summary>
internal static readonly string[] DefaultEventFields =
["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"];
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -73,6 +96,10 @@ public class HistoryReadCommand : CommandBase
throw new CommandException($"--interval must be greater than 0 (was {IntervalMs}).");
if (Modified && !string.IsNullOrEmpty(Aggregate))
throw new CommandException("--modified and --aggregate cannot be combined.");
if (Events && (Modified || !string.IsNullOrEmpty(Aggregate)))
throw new CommandException("--events cannot be combined with --modified or --aggregate.");
if (!Events && (Fields is not null || OfType is not null))
throw new CommandException("--fields and --of-type apply only to --events.");
NodeId nodeId;
try
@@ -109,6 +136,30 @@ public class HistoryReadCommand : CommandBase
throw new CommandException($"Invalid --end value '{EndTime}': {ex.Message}. Expected ISO 8601 UTC format, e.g. 2026-01-15T08:00:00Z.");
}
EventFilter? eventFilter = null;
IReadOnlyList<string> eventFields = [];
if (Events)
{
eventFields = Fields is null
? DefaultEventFields
: Fields.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (eventFields.Count == 0)
throw new CommandException("--fields must name at least one event field.");
NodeId? ofType = null;
if (OfType is not null)
{
try
{
ofType = NodeIdParser.ParseRequired(OfType);
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
throw new CommandException($"Invalid --of-type value: {ex.Message}");
}
}
eventFilter = BuildEventFilter(eventFields, ofType);
}
AggregateType aggregateType = default;
if (!string.IsNullOrEmpty(Aggregate))
{
@@ -128,6 +179,22 @@ public class HistoryReadCommand : CommandBase
var ct = console.RegisterCancellationHandler();
(service, _) = await CreateServiceAndConnectAsync(ct);
if (eventFilter is not null)
{
await console.Output.WriteLineAsync(
$"Event history for {NodeId} ({start:yyyy-MM-dd HH:mm} -> {end:yyyy-MM-dd HH:mm})" +
(OfType is null ? "" : $", of type {OfType}"));
var events = await service.HistoryReadEventsAsync(nodeId, start, end, eventFilter, MaxValues, ct);
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(string.Join(" | ", eventFields));
foreach (var e in events)
await console.Output.WriteLineAsync(string.Join(" | ", e.Fields.Select(FormatEventField)));
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync($"{events.Count} events returned.");
return;
}
if (Modified)
{
await console.Output.WriteLineAsync(
@@ -199,6 +266,41 @@ public class HistoryReadCommand : CommandBase
}
}
/// <summary>
/// Builds the event filter for an event read: one select clause per field (<c>ConditionName</c> on ConditionType,
/// every other name on BaseEventType) and, when <paramref name="ofType"/> is given, an <c>OfType</c> where-clause.
/// </summary>
/// <param name="fields">The event field browse names, in output order.</param>
/// <param name="ofType">The event type to keep (with its subtypes), or null for every event.</param>
/// <returns>The event filter.</returns>
internal static EventFilter BuildEventFilter(IReadOnlyList<string> fields, NodeId? ofType)
{
var filter = new EventFilter();
foreach (var field in fields)
{
var type = string.Equals(field, "ConditionName", StringComparison.Ordinal)
? ObjectTypeIds.ConditionType
: ObjectTypeIds.BaseEventType;
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
}
if (ofType is not null)
filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType)));
return filter;
}
/// <summary>Formats one projected event field for the console: byte strings as hex, times as ISO 8601, null as <c>-</c>.</summary>
/// <param name="field">The field value.</param>
/// <returns>Its display text.</returns>
private static string FormatEventField(Variant field) => field.Value switch
{
null => "-",
byte[] bytes => Convert.ToHexString(bytes),
DateTime time => time.ToString("O", CultureInfo.InvariantCulture),
LocalizedText text => text.Text ?? "",
var other => Convert.ToString(other, CultureInfo.InvariantCulture) ?? ""
};
private static readonly string SupportedAggregates = string.Join(", ", Enum.GetNames<AggregateType>());
private static AggregateType ParseAggregateType(string name)
@@ -178,6 +178,27 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
ToModifiedEntries, maxValues, nodeId, ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Models.HistoryEventEntry>> HistoryReadEventsAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxEvents, EventFilter filter, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(filter);
var details = new ReadEventDetails
{
StartTime = startTime,
EndTime = endTime,
NumValuesPerNode = (uint)maxEvents,
Filter = filter
};
return await HistoryReadPaging.ReadAllAsync(
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
result => result.HistoryData is ExtensionObject { Body: HistoryEvent historyEvent }
? historyEvent.Events.Select(e => new Models.HistoryEventEntry(e.EventFields?.ToArray() ?? []))
: [],
maxEvents, nodeId, ct).ConfigureAwait(false);
}
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
{
if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break;
@@ -194,7 +215,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
/// <summary>Issues one single-node HistoryRead (or a release of its continuation point).</summary>
private async Task<HistoryReadResult?> ReadHistoryPageAsync(
ReadRawModifiedDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
HistoryReadDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
TimestampsToReturn timestamps, CancellationToken ct)
{
var nodesToRead = new HistoryReadValueIdCollection
@@ -116,6 +116,21 @@ internal interface ISessionAdapter : IDisposable
DateTime endTime, int maxValues, CancellationToken ct = default) =>
throw new NotSupportedException("This session adapter does not support modified-value history reads.");
/// <summary>
/// Reads event history (Part 11 HistoryReadEvents) from an event notifier with the supplied event filter, following
/// continuation points.
/// </summary>
/// <param name="nodeId">The event notifier node.</param>
/// <param name="startTime">Window start.</param>
/// <param name="endTime">Window end.</param>
/// <param name="maxEvents">The maximum number of events to return.</param>
/// <param name="filter">The event filter: the select clauses to project and an optional where-clause.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The events in server order.</returns>
Task<IReadOnlyList<Models.HistoryEventEntry>> HistoryReadEventsAsync(NodeId nodeId, DateTime startTime,
DateTime endTime, int maxEvents, EventFilter filter, CancellationToken ct = default) =>
throw new NotSupportedException("This session adapter does not support event history reads.");
/// <summary>
/// Sends a HistoryUpdate request for the supplied details.
/// </summary>
@@ -207,6 +207,21 @@ public interface IOpcUaClientService : IDisposable
DateTime endTime, int maxValues = 1000, CancellationToken ct = default) =>
throw new NotSupportedException("This client service does not support modified-value history reads.");
/// <summary>
/// Reads event history (Part 11 HistoryReadEvents) from an event notifier: the events in the window that pass the
/// filter's where-clause, each projected onto its select clauses.
/// </summary>
/// <param name="nodeId">The event notifier node (an equipment/device folder, or the Server object).</param>
/// <param name="startTime">Window start.</param>
/// <param name="endTime">Window end.</param>
/// <param name="filter">The event filter: the select clauses to project and an optional where-clause.</param>
/// <param name="maxEvents">The maximum number of events to return.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The events in server order.</returns>
Task<IReadOnlyList<HistoryEventEntry>> HistoryReadEventsAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
EventFilter filter, int maxEvents = 1000, CancellationToken ct = default) =>
throw new NotSupportedException("This client service does not support event history reads.");
/// <summary>
/// Sends a HistoryUpdate request (insert/replace/update or delete) for the supplied details.
/// </summary>
@@ -0,0 +1,10 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// <summary>
/// One event of an OPC UA Part 11 event-history read (HistoryReadEvents): the values the server projected for the
/// request's select clauses, in select-clause order (<see cref="Variant.Null"/> for a field the event cannot supply).
/// </summary>
/// <param name="Fields">The projected field values, one per select clause.</param>
public sealed record HistoryEventEntry(IReadOnlyList<Variant> Fields);
@@ -645,6 +645,16 @@ public sealed class OpcUaClientService : IOpcUaClientService
return await _session!.HistoryReadModifiedAsync(nodeId, startTime, endTime, maxValues, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<HistoryEventEntry>> HistoryReadEventsAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, EventFilter filter, int maxEvents = 1000,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
return await _session!.HistoryReadEventsAsync(nodeId, startTime, endTime, maxEvents, filter, ct);
}
/// <inheritdoc />
public async Task<HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
{
@@ -344,6 +344,21 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryReadModifiedResult);
}
/// <summary>Gets the (NodeId, start, end, filter, maxEvents) tuples from HistoryReadEventsAsync calls.</summary>
public List<(NodeId NodeId, DateTime Start, DateTime End, EventFilter Filter, int MaxEvents)> HistoryReadEventsCalls { get; } = [];
/// <summary>Gets or sets the events returned by HistoryReadEventsAsync.</summary>
public IReadOnlyList<HistoryEventEntry> HistoryReadEventsResult { get; set; } = [];
/// <inheritdoc />
public Task<IReadOnlyList<HistoryEventEntry>> HistoryReadEventsAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
EventFilter filter, int maxEvents = 1000, CancellationToken ct = default)
{
HistoryReadEventsCalls.Add((nodeId, startTime, endTime, filter, maxEvents));
if (HistoryReadException != null) throw HistoryReadException;
return Task.FromResult(HistoryReadEventsResult);
}
/// <summary>Gets the details passed to HistoryUpdateAsync.</summary>
public List<HistoryUpdateDetails> HistoryUpdateCalls { get; } = [];
@@ -182,6 +182,98 @@ public class HistoryReadCommandTests
output.ShouldContain("2 modifications returned.");
}
/// <summary>#776: --events reads event history with the default select clauses and prints one line per event.</summary>
[Fact]
public async Task Execute_EventRead_CallsHistoryReadEvents_with_the_default_fields_and_prints_each_event()
{
var t = new DateTime(2025, 6, 15, 10, 0, 0, DateTimeKind.Utc);
var fakeService = new FakeOpcUaClientService
{
HistoryReadEventsResult =
[
new HistoryEventEntry([
new Variant(new byte[] { 0xAB, 0x01 }), new Variant(ObjectTypeIds.LimitAlarmType), new Variant(t),
new Variant("Tank1"), new Variant((ushort)700), new Variant(new LocalizedText("high level")),
new Variant("temp_hi"), Variant.Null,
]),
],
};
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=Area/Line/Tank1",
Events = true,
MaxValues = 25,
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var call = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem();
call.MaxEvents.ShouldBe(25);
call.Filter.SelectClauses.Select(c => c.BrowsePath.ShouldHaveSingleItem().Name)
.ShouldBe(["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"]);
call.Filter.SelectClauses.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId
.ShouldBe(ObjectTypeIds.ConditionType);
call.Filter.WhereClause.Elements.ShouldBeEmpty("no --of-type, no where-clause");
fakeService.HistoryReadRawCalls.ShouldBeEmpty();
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Event history for ns=2;s=Area/Line/Tank1");
output.ShouldContain("EventId | EventType | Time | SourceName | Severity | Message | ConditionName | SourceNode");
output.ShouldContain($"AB01 | {ObjectTypeIds.LimitAlarmType} | {t:O} | Tank1 | 700 | high level | temp_hi | -");
output.ShouldContain("1 events returned.");
}
/// <summary>#776: --fields picks the select clauses and --of-type adds an OfType where-clause.</summary>
[Fact]
public async Task Execute_EventRead_with_fields_and_of_type_builds_that_filter()
{
var fakeService = new FakeOpcUaClientService();
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=Area/Line/Tank1",
Events = true,
Fields = "EventType, Severity",
OfType = "i=2955",
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var filter = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem().Filter;
filter.SelectClauses.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
var element = filter.WhereClause.Elements.ShouldHaveSingleItem();
element.FilterOperator.ShouldBe(FilterOperator.OfType);
((LiteralOperand)ExtensionObject.ToEncodeable(element.FilterOperands[0])).Value.Value
.ShouldBe(ObjectTypeIds.LimitAlarmType);
TestConsoleHelper.GetOutput(console).ShouldContain("0 events returned.");
}
/// <summary>#776: --events cannot be mixed with the other read modes, and --fields/--of-type need --events.</summary>
[Theory]
[InlineData(true, true, null, null)]
[InlineData(true, false, "Average", null)]
[InlineData(false, false, null, "EventType")]
public async Task Execute_EventRead_option_conflicts_throw(bool events, bool modified, string? aggregate, string? fields)
{
var fakeService = new FakeOpcUaClientService();
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
Events = events,
Modified = modified,
Aggregate = aggregate,
Fields = fields,
};
using var console = TestConsoleHelper.CreateConsole();
await Should.ThrowAsync<CliFx.Exceptions.CommandException>(async () => await command.ExecuteAsync(console));
fakeService.HistoryReadEventsCalls.ShouldBeEmpty();
}
/// <summary>--modified cannot be combined with --aggregate.</summary>
[Fact]
public async Task Execute_ModifiedWithAggregate_ThrowsCommandException()
@@ -2,6 +2,8 @@ using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
@@ -58,6 +60,73 @@ public sealed class EventHistoryProjectionOverTheWireTests
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// #776: the shared client's event-history read — the code behind Client.CLI's <c>historyread --events</c> — over
/// the wire with the real session adapter: the default CLI select clauses come back non-null, <c>--of-type</c>
/// filters, and an invalid where-clause is refused as <c>BadEventFilterInvalid</c> (#778), not printed as no events.
/// </summary>
[Fact]
public async Task The_shared_client_reads_event_history_with_projection_and_OfType()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("EvtHistoryClient", encrypted: false, ct);
try
{
rig.Nm.HistorianDataSource = new FixedSource(
[
new HistoricalEvent("e-limit", RawDeviceFolder, Epoch, Epoch, "high", 700,
EventTypeName: "LimitAlarm", ConditionName: "temp_hi", AlarmId: RawAlarmPath),
new HistoricalEvent("e-off", RawDeviceFolder, Epoch.AddSeconds(1), Epoch, "door", 500,
EventTypeName: "OffNormalAlarm", ConditionName: "door_open", AlarmId: RawAlarmPath),
]);
rig.MaterialiseNative();
rig.Enforce(false, [Key()]);
using var client = new OpcUaClientService();
await client.ConnectAsync(new ConnectionSettings
{
EndpointUrl = rig.EndpointUrl,
CertificateStorePath = Path.Combine(Path.GetTempPath(), $"otopcua-clientpki-{Guid.NewGuid():N}"),
}, ct);
ushort rawNs;
using (var session = await rig.ConnectAsync(anonymous: true, ct))
rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var node = new NodeId(RawDeviceFolder, rawNs);
// The CLI's default fields: EventId, EventType, Time, SourceName, Severity, Message, ConditionName, SourceNode.
var all = await client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1), Filter(ofType: null), 100, ct);
all.Count.ShouldBe(2);
foreach (var e in all)
e.Fields.ShouldAllBe(f => f.Value != null, "every default field is projected");
all[0].Fields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
all[0].Fields[4].Value.ShouldBe((ushort)700);
all[0].Fields[6].Value.ShouldBe("temp_hi");
var limits = await client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1),
Filter(ObjectTypeIds.LimitAlarmType), 100, ct);
limits.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.Fields[0].Value)).ShouldBe(["e-limit"]);
var invalid = Filter(ofType: null);
invalid.WhereClause.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1)));
var refused = await Should.ThrowAsync<ServiceResultException>(() =>
client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1), invalid, 100, ct));
refused.StatusCode.ShouldBe(StatusCodes.BadEventFilterInvalid);
}
finally { await rig.DisposeAsync(); }
static EventFilter Filter(NodeId? ofType)
{
var filter = new EventFilter();
foreach (var leaf in new[] { "EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode" })
{
var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType;
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
}
if (ofType is not null) filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType)));
return filter;
}
}
private static async Task<List<VariantCollection>> ReadAsync(ISession session, NodeId node, ContentFilter? where, CancellationToken ct)
{
var filter = new EventFilter { WhereClause = where ?? new ContentFilter() };