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);
+ }
}