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:
+17
-1
@@ -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
@@ -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()
|
||||
|
||||
+69
@@ -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() };
|
||||
|
||||
Reference in New Issue
Block a user