feat(cli): historyread --events reads event history over the wire (lmxopcua#776)
v2-ci / unit-tests (push) Has been cancelled
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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user