diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index a61094f4..58c1a971 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -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__ \ -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 ` +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 diff --git a/docs/Historian.md b/docs/Historian.md index ed286b6f..4f04d5c6 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -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. diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs index 64d5d665..7dd0cc56 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs @@ -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 { /// @@ -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; } + /// + /// Gets a value indicating whether to read event history (Part 11 HistoryReadEvents) from an event notifier instead of + /// raw samples. + /// + [CommandOption("events", Description = "Read event history (HistoryReadEvents) from an event notifier (an equipment/device folder) instead of raw samples")] + public bool Events { get; init; } + + /// + /// Gets the comma-separated event fields to select (BaseEventType browse names, or ConditionType's ConditionName). + /// + [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; } + + /// + /// Gets the optional event type NodeId for an OfType where-clause on an event read. + /// + [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; } + + /// The event fields an event read selects when --fields is omitted. + internal static readonly string[] DefaultEventFields = + ["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"]; + /// 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 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 } } + /// + /// Builds the event filter for an event read: one select clause per field (ConditionName on ConditionType, + /// every other name on BaseEventType) and, when is given, an OfType where-clause. + /// + /// The event field browse names, in output order. + /// The event type to keep (with its subtypes), or null for every event. + /// The event filter. + internal static EventFilter BuildEventFilter(IReadOnlyList 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; + } + + /// Formats one projected event field for the console: byte strings as hex, times as ISO 8601, null as -. + /// The field value. + /// Its display text. + 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()); private static AggregateType ParseAggregateType(string name) diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs index b07efa97..ddb5ed0b 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs @@ -178,6 +178,27 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter ToModifiedEntries, maxValues, nodeId, ct).ConfigureAwait(false); } + /// + public async Task> 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 ToModifiedEntries(HistoryReadResult result) { if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break; @@ -194,7 +215,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter /// Issues one single-node HistoryRead (or a release of its continuation point). private async Task 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 diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs index 8734c50b..1662a946 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs @@ -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."); + /// + /// Reads event history (Part 11 HistoryReadEvents) from an event notifier with the supplied event filter, following + /// continuation points. + /// + /// The event notifier node. + /// Window start. + /// Window end. + /// The maximum number of events to return. + /// The event filter: the select clauses to project and an optional where-clause. + /// The cancellation token. + /// The events in server order. + Task> 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."); + /// /// Sends a HistoryUpdate request for the supplied details. /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs index 207197ea..54012c43 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs @@ -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."); + /// + /// 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. + /// + /// The event notifier node (an equipment/device folder, or the Server object). + /// Window start. + /// Window end. + /// The event filter: the select clauses to project and an optional where-clause. + /// The maximum number of events to return. + /// The cancellation token. + /// The events in server order. + Task> 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."); + /// /// Sends a HistoryUpdate request (insert/replace/update or delete) for the supplied details. /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/HistoryEventEntry.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/HistoryEventEntry.cs new file mode 100644 index 00000000..d44f4a3b --- /dev/null +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/HistoryEventEntry.cs @@ -0,0 +1,10 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models; + +/// +/// 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 ( for a field the event cannot supply). +/// +/// The projected field values, one per select clause. +public sealed record HistoryEventEntry(IReadOnlyList Fields); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 272e3695..e0681337 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -645,6 +645,16 @@ public sealed class OpcUaClientService : IOpcUaClientService return await _session!.HistoryReadModifiedAsync(nodeId, startTime, endTime, maxValues, ct); } + /// + public async Task> 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); + } + /// public async Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default) { diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs index 4fb22ff6..cd36a88e 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs @@ -344,6 +344,21 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService return Task.FromResult(HistoryReadModifiedResult); } + /// Gets the (NodeId, start, end, filter, maxEvents) tuples from HistoryReadEventsAsync calls. + public List<(NodeId NodeId, DateTime Start, DateTime End, EventFilter Filter, int MaxEvents)> HistoryReadEventsCalls { get; } = []; + + /// Gets or sets the events returned by HistoryReadEventsAsync. + public IReadOnlyList HistoryReadEventsResult { get; set; } = []; + + /// + public Task> 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); + } + /// Gets the details passed to HistoryUpdateAsync. public List HistoryUpdateCalls { get; } = []; diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs index e0d77307..62c8e139 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs @@ -182,6 +182,98 @@ public class HistoryReadCommandTests output.ShouldContain("2 modifications returned."); } + /// #776: --events reads event history with the default select clauses and prints one line per event. + [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."); + } + + /// #776: --fields picks the select clauses and --of-type adds an OfType where-clause. + [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."); + } + + /// #776: --events cannot be mixed with the other read modes, and --fields/--of-type need --events. + [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(async () => await command.ExecuteAsync(console)); + fakeService.HistoryReadEventsCalls.ShouldBeEmpty(); + } + /// --modified cannot be combined with --aggregate. [Fact] public async Task Execute_ModifiedWithAggregate_ThrowsCommandException() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryProjectionOverTheWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryProjectionOverTheWireTests.cs index 0aebbcc3..73fcebab 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryProjectionOverTheWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryProjectionOverTheWireTests.cs @@ -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(); } } + /// + /// #776: the shared client's event-history read — the code behind Client.CLI's historyread --events — over + /// the wire with the real session adapter: the default CLI select clauses come back non-null, --of-type + /// filters, and an invalid where-clause is refused as BadEventFilterInvalid (#778), not printed as no events. + /// + [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(() => + 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> ReadAsync(ISession session, NodeId node, ContentFilter? where, CancellationToken ct) { var filter = new EventFilter { WhereClause = where ?? new ContentFilter() };