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
@@ -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)
{