From e859963853e91e571a05855ad20548b354c47710 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 06:02:11 -0400 Subject: [PATCH] feat(opcuaclient): add BuildBaseEventFilter + MapHistoryEvents pure cores --- .../OpcUaClientDriver.cs | 85 +++++++++++++++++++ .../OpcUaClientHistoryTests.cs | 71 ++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index d81ed3b7..bf9cb5ab 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -1675,6 +1675,91 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null), }; + // Canonical BaseEventType select-clause order — MapHistoryEvents maps by these indices. + private static readonly string[] EventFieldBrowseNames = + [ + BrowseNames.EventId, // 0 + BrowseNames.SourceName, // 1 + BrowseNames.Time, // 2 + BrowseNames.ReceiveTime, // 3 + BrowseNames.Message, // 4 + BrowseNames.Severity, // 5 + ]; + + /// + /// Builds the fixed canonical EventFilter the driver sends upstream for HistoryReadEvents — + /// the six BaseEventType fields the OtOpcUa server projects (). + /// The clause order is load-bearing: reads results by index. + /// + /// An EventFilter with six SimpleAttributeOperand value clauses. + internal static EventFilter BuildBaseEventFilter() + { + var filter = new EventFilter(); + foreach (var browseName in EventFieldBrowseNames) + { + filter.SelectClauses.Add(new SimpleAttributeOperand + { + TypeDefinitionId = ObjectTypeIds.BaseEventType, + BrowsePath = new QualifiedNameCollection { new QualifiedName(browseName) }, + AttributeId = Attributes.Value, + }); + } + return filter; + } + + /// + /// Maps an upstream (field arrays ordered to match + /// ) onto records. Defensive: + /// short / null / wrong-typed fields degrade to null/default rather than throwing. + /// + /// The upstream history-event payload. + /// The mapped historical events in upstream order. + internal static IReadOnlyList MapHistoryEvents(HistoryEvent historyEvent) + { + if (historyEvent?.Events is not { Count: > 0 } rows) return []; + var result = new List(rows.Count); + foreach (var row in rows) + { + var fields = row?.EventFields; + result.Add(new Core.Abstractions.HistoricalEvent( + EventId: CoerceEventId(FieldAt(fields, 0)), + SourceName: CoerceString(FieldAt(fields, 1)), + EventTimeUtc: CoerceDateTime(FieldAt(fields, 2)), + ReceivedTimeUtc: CoerceDateTime(FieldAt(fields, 3)), + Message: CoerceString(FieldAt(fields, 4)), + Severity: CoerceSeverity(FieldAt(fields, 5)))); + } + return result; + } + + private static object? FieldAt(VariantCollection? fields, int index) + => fields is not null && index < fields.Count ? fields[index].Value : null; + + private static string CoerceEventId(object? value) => value switch + { + byte[] bytes => Convert.ToBase64String(bytes), + string s => s, + null => string.Empty, + _ => value.ToString() ?? string.Empty, + }; + + private static string? CoerceString(object? value) => value switch + { + LocalizedText lt => lt.Text, + string s => s, + null => null, + _ => value.ToString(), + }; + + private static DateTime CoerceDateTime(object? value) + => value is DateTime dt ? dt : DateTime.MinValue; + + private static ushort CoerceSeverity(object? value) + { + try { return value is null ? (ushort)0 : Convert.ToUInt16(value); } + catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return 0; } + } + // ReadEventsAsync stays at the interface default (throws NotSupportedException) per // IHistoryProvider contract -- the OPC UA Client driver CAN forward HistoryReadEvents, // but the call-site needs an EventFilter SelectClauses surface which the interface diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs index 381e99ce..4207fa74 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs @@ -96,4 +96,75 @@ public sealed class OpcUaClientHistoryTests maxEvents: 100, cancellationToken: TestContext.Current.CancellationToken)); } + + /// BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order. + [Fact] + public void BuildBaseEventFilter_has_six_canonical_BaseEventType_value_clauses() + { + var filter = OpcUaClientDriver.BuildBaseEventFilter(); + + filter.SelectClauses.Count.ShouldBe(6); + string[] expected = ["EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"]; + for (var i = 0; i < expected.Length; i++) + { + var clause = filter.SelectClauses[i]; + clause.TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType); + clause.AttributeId.ShouldBe(Attributes.Value); + clause.BrowsePath.Count.ShouldBe(1); + clause.BrowsePath[0].Name.ShouldBe(expected[i]); + } + } + + /// MapHistoryEvents maps every field by its canonical select-clause index. + [Fact] + public void MapHistoryEvents_maps_all_six_fields_by_canonical_index() + { + var eventTime = new DateTime(2026, 6, 18, 10, 0, 0, DateTimeKind.Utc); + var recvTime = eventTime.AddSeconds(1); + var he = new HistoryEvent(); + var fields = new HistoryEventFieldList(); + fields.EventFields.Add(new Variant(new byte[] { 1, 2, 3 })); // 0 EventId + fields.EventFields.Add(new Variant("Pump17")); // 1 SourceName + fields.EventFields.Add(new Variant(eventTime)); // 2 Time + fields.EventFields.Add(new Variant(recvTime)); // 3 ReceiveTime + fields.EventFields.Add(new Variant(new LocalizedText("High temp"))); // 4 Message + fields.EventFields.Add(new Variant((ushort)700)); // 5 Severity + he.Events.Add(fields); + + var mapped = OpcUaClientDriver.MapHistoryEvents(he); + + mapped.Count.ShouldBe(1); + var e = mapped[0]; + e.EventId.ShouldBe(Convert.ToBase64String(new byte[] { 1, 2, 3 })); + e.SourceName.ShouldBe("Pump17"); + e.EventTimeUtc.ShouldBe(eventTime); + e.ReceivedTimeUtc.ShouldBe(recvTime); + e.Message.ShouldBe("High temp"); + e.Severity.ShouldBe((ushort)700); + } + + /// MapHistoryEvents returns empty for a HistoryEvent with no rows. + [Fact] + public void MapHistoryEvents_with_no_events_returns_empty() + { + OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty(); + } + + /// MapHistoryEvents tolerates a field list shorter than six without throwing. + [Fact] + public void MapHistoryEvents_tolerates_short_field_list_without_throwing() + { + var he = new HistoryEvent(); + var fields = new HistoryEventFieldList(); + fields.EventFields.Add(new Variant(new byte[] { 9 })); // only EventId present + he.Events.Add(fields); + + var mapped = OpcUaClientDriver.MapHistoryEvents(he); + + mapped.Count.ShouldBe(1); + mapped[0].EventId.ShouldBe(Convert.ToBase64String(new byte[] { 9 })); + mapped[0].SourceName.ShouldBeNull(); + mapped[0].EventTimeUtc.ShouldBe(DateTime.MinValue); + mapped[0].Severity.ShouldBe((ushort)0); + } }