diff --git a/docs/Historian.md b/docs/Historian.md
index 02d4f9d56..9f84c1ea6 100644
--- a/docs/Historian.md
+++ b/docs/Historian.md
@@ -585,7 +585,21 @@ owns at least one alarm condition is already an event notifier; the server regis
HistorianGateway using that source. (Alarm-history `ReadEvents` requires the gateway running
`RuntimeDb:EventReadsEnabled=true`.) Event-field projection supports the standard
`BaseEventType` select clauses — `EventId`, `SourceName`, `Time`, `ReceiveTime`, `Message`,
-and `Severity`; an unsupported select operand returns a null field (spec-conformant).
+`Severity` — plus (client-visible: these used to be null):
+
+- **`EventType`** — the stored alarm type name mapped to its Part 9 type (`LimitAlarm` ⇒ `LimitAlarmType`, `OffNormalAlarm` ⇒
+ `OffNormalAlarmType`, `DiscreteAlarm` ⇒ `DiscreteAlarmType`, `AlarmCondition` ⇒ `AlarmConditionType`); a row with no type
+ name, or one that is not an event type, is `AlarmConditionType` (every historical row is alarm history).
+- **`ConditionName`** — the stored alarm name.
+- **`SourceNode`** — the condition node the stored alarm id names (a native alarm's RawPath, a scripted alarm's id), the same
+ `SourceNode` live condition events carry; null when that condition is not materialised on this node now.
+
+Every backend stores the three (SqlServer `AlarmTypeName` / `AlarmName` / `AlarmId`, QuestDB `alarm_type_name` /
+`alarm_name` / `alarm_id`, Gateway the event `Type` and its `AlarmName` / `AlarmId` properties). **The where-clause is
+honoured:** each event is evaluated against it with the SDK's content filter — `OfType` through the server's type tree (a
+supertype keeps its subtypes), any other operator against the projected fields. Filtering happens after a page's
+continuation point is taken from the unfiltered page, so paging is unchanged: a filtered page may hold fewer events (even
+none) and still carry a continuation point. An unsupported select operand returns a null field (spec-conformant).
### No driver-side history (Gitea #655)
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs
index 2d524020b..047d5b83d 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs
@@ -118,13 +118,26 @@ public sealed class HistoryReadLimitExceededException : InvalidOperationExceptio
/// Historian-side timestamp — when the historian persisted the row; may lag by the historian's buffer flush cadence.
/// Human-readable message text.
/// OPC UA severity (1-1000). Backends map their native priority scale onto this range.
+///
+/// The stored alarm type name (AlarmTypeName — e.g. AlarmCondition, LimitAlarm,
+/// OffNormalAlarm, DiscreteAlarm), or null when the backend has none. The server maps it to the OPC UA
+/// event type NodeId it projects as EventType and filters an OfType where-clause by.
+///
+/// The condition's name (the stored AlarmName), projected as ConditionName.
+///
+/// The stored alarm id (a native alarm's RawPath / a scripted alarm's id) — what the server resolves the event's
+/// SourceNode (the condition node) from, when that condition is materialised.
+///
public sealed record HistoricalEvent(
string EventId,
string? SourceName,
DateTime EventTimeUtc,
DateTime ReceivedTimeUtc,
string? Message,
- ushort Severity);
+ ushort Severity,
+ string? EventTypeName = null,
+ string? ConditionName = null,
+ string? AlarmId = null);
/// Result of a call.
/// Events ordered by EventTimeUtc in the read's direction — ascending, or descending for a reverse read.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs
index 6aaadca8c..facbe5f8f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs
@@ -33,9 +33,16 @@ internal static class EventMapper
EventTimeUtc: historianEvent.EventTime?.ToDateTime() ?? default, // Utc kind
ReceivedTimeUtc: historianEvent.ReceivedTime?.ToDateTime() ?? default, // Utc kind
Message: message,
- Severity: ParseSeverity(historianEvent.Properties));
+ Severity: ParseSeverity(historianEvent.Properties),
+ // What GatewayAlarmHistorianWriter sent (AlarmEventMapper): Type = AlarmTypeName, AlarmName / AlarmId properties.
+ EventTypeName: string.IsNullOrEmpty(historianEvent.Type) ? null : historianEvent.Type,
+ ConditionName: Property(historianEvent.Properties, "AlarmName"),
+ AlarmId: Property(historianEvent.Properties, "AlarmId"));
}
+ private static string? Property(IDictionary properties, string name) =>
+ properties.TryGetValue(name, out var v) && !string.IsNullOrEmpty(v) ? v : null;
+
/// Maps a batch of gateway events to historical events, in order.
/// The gateway wire events.
/// The driver-agnostic historical events.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
index 12c98f992..cd21f6dd3 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
@@ -449,7 +449,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
var (lo, hi) = start <= end ? (start, end) : (end, start);
var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
var sql =
- $"SELECT event_id, source, ts, received_utc, message, severity FROM {_conn.EventTable} " +
+ $"SELECT event_id, source, ts, received_utc, message, severity, alarm_type_name, alarm_name, alarm_id FROM {_conn.EventTable} " +
(reverse ? "WHERE ts > $1 AND ts <= $2" : "WHERE ts >= $1 AND ts < $2") +
(sourceName is null ? "" : " AND source = $3") +
(reverse ? " ORDER BY ts DESC, event_id DESC" : " ORDER BY ts ASC, event_id ASC") +
@@ -474,7 +474,10 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
eventTime,
reader.IsDBNull(3) ? eventTime : QuestDbConnection.ReadUtc(reader, 3),
reader.IsDBNull(4) ? null : reader.GetString(4),
- HistoricalEventRows.ClampSeverity(reader.IsDBNull(5) ? 0 : reader.GetInt32(5)));
+ HistoricalEventRows.ClampSeverity(reader.IsDBNull(5) ? 0 : reader.GetInt32(5)),
+ EventTypeName: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(6) ? null : reader.GetString(6)),
+ ConditionName: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(7) ? null : reader.GetString(7)),
+ AlarmId: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(8) ? null : reader.GetString(8)));
if (bounded && events.Count == maxEvents)
{
continuation = Encoding.UTF8.GetBytes(EventContinuation.Encode(e.EventTimeUtc, e.EventId));
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoricalEventRows.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoricalEventRows.cs
index 70de0a4b1..19fc5f438 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoricalEventRows.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoricalEventRows.cs
@@ -7,4 +7,9 @@ public static class HistoricalEventRows
/// Stored severity.
/// The severity clamped to 0..65535.
public static ushort ClampSeverity(int severity) => (ushort)Math.Clamp(severity, 0, ushort.MaxValue);
+
+ /// A stored optional text column as the event carries it: empty (the writers' "none") is null.
+ /// The stored text.
+ /// The text, or null when empty or absent.
+ public static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs
index 6525a1733..9dd0d75c3 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs
@@ -396,7 +396,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
var (lo, hi) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc);
var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
var sql =
- $"SELECT {(bounded ? "TOP (@take) " : "")}EventId, SourceName, EventTimeUtc, ReceivedTimeUtc, Message, Severity " +
+ $"SELECT {(bounded ? "TOP (@take) " : "")}EventId, SourceName, EventTimeUtc, ReceivedTimeUtc, Message, Severity, AlarmTypeName, AlarmName, AlarmId " +
$"FROM {_conn.Q("Event")} WHERE " +
(reverse ? "EventTimeUtc > @lo AND EventTimeUtc <= @hi" : "EventTimeUtc >= @lo AND EventTimeUtc < @hi") +
(sourceName is null ? "" : " AND SourceName = @source") +
@@ -421,7 +421,10 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
SqlHistorianConnection.GetUtc(reader, 2),
SqlHistorianConnection.GetUtc(reader, 3),
reader.IsDBNull(4) ? null : reader.GetString(4),
- HistoricalEventRows.ClampSeverity(reader.GetInt32(5)));
+ HistoricalEventRows.ClampSeverity(reader.GetInt32(5)),
+ EventTypeName: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(6) ? null : reader.GetString(6)),
+ ConditionName: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(7) ? null : reader.GetString(7)),
+ AlarmId: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(8) ? null : reader.GetString(8)));
if (bounded && events.Count == maxEvents)
{
continuation = Encoding.UTF8.GetBytes(EventContinuation.Encode(e.EventTimeUtc, e.EventId));
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 471eef2fe..b15726a1c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -5714,8 +5714,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
List nodesToProcess,
IDictionary cache)
{
- // Snapshot the select clauses once — the same filter projects every node's events.
+ // Snapshot the select clauses once — the same filter projects every node's events — and the where-clause, which
+ // is evaluated per event against the same projection (an OfType through the server's type tree).
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
+ var where = details.Filter?.WhereClause is { Elements.Count: > 0 } w ? w : null;
+ var filterContext = where is null ? null : new FilterContext(Server.NamespaceUris, Server.TypeTree, context.PreferredLocales, Server.Telemetry);
var session = context.OperationContext?.Session;
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
@@ -5739,7 +5742,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
var sn = sourceName;
- work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, nodesToRead, session, results, errors, ct));
+ work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, where, filterContext, nodesToRead, session,
+ results, errors, ct));
}
RunBounded(work, HistoryReadBatchParallelism);
@@ -5757,6 +5761,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The registered historian event-source name for this notifier.
/// The event-read details (window + count cap).
/// The event-filter select clauses (the fields to emit, in order).
+ /// The event filter's where-clause, or null for none.
+ /// The context the where-clause is evaluated in (null when there is none).
/// The service-level results list to fill at handle.Index.
/// The service-level errors list to fill at handle.Index.
/// Per-request deadline token.
@@ -5765,6 +5771,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
string? sourceName,
ReadEventDetails details,
SimpleAttributeOperandCollection selectClauses,
+ ContentFilter? where,
+ FilterContext? filterContext,
IList nodesToRead,
ISession? session,
IList results,
@@ -5830,7 +5838,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = StatusCodes.GoodNoData,
- HistoryData = new ExtensionObject(ProjectEvents([], selectClauses)),
+ HistoryData = new ExtensionObject(ProjectEvents([], selectClauses, null, null)),
};
errors[handle.Index] = ServiceResult.Good;
return;
@@ -5961,12 +5969,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
EmptyToTicks: walkHint.EmptyToTicks);
}
- var historyEvent = ProjectEvents(events, selectClauses);
+ // The where-clause filters AFTER the page's cursor was taken from the unfiltered events, so paging (time,
+ // boundary skip) is exactly as without a filter: a page may come back with fewer events — even none — and a
+ // continuation point, never skipping or repeating one.
+ var historyEvent = ProjectEvents(events, selectClauses, where, filterContext);
var outboundCp = pendingCursor is null ? null : _historyContinuationStore.Save(session, pendingCursor);
results[handle.Index] = new SdkHistoryReadResult
{
- // No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
- StatusCode = events.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
+ // No events ⇒ GoodNoData (the notifier is historized, the window just held no events — or none matched).
+ StatusCode = historyEvent.Events.Count == 0 && outboundCp is null ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyEvent),
// Null when the page was complete, or when no session is available to hold the cursor.
ContinuationPoint = outboundCp,
@@ -6415,23 +6426,28 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
/// Project a sequence of s into an SDK —
- /// one per event, each carrying the requested
- /// ' fields in select-clause order (see
- /// ).
+ /// one per event that passes , each carrying the
+ /// requested ' fields in select-clause order (see ).
///
/// The historian's event rows.
/// The request's event-filter select clauses (the fields to emit, in order).
+ /// The request's where-clause, or null for none (every event passes).
+ /// The context is evaluated in.
/// The populated SDK .
- private static HistoryEvent ProjectEvents(
- IReadOnlyList events, SimpleAttributeOperandCollection selectClauses)
+ private HistoryEvent ProjectEvents(
+ IReadOnlyList events, SimpleAttributeOperandCollection selectClauses,
+ ContentFilter? where, FilterContext? filterContext)
{
var fieldLists = new HistoryEventFieldListCollection(events.Count);
foreach (var evt in events)
{
+ var target = new HistoricalEventFilterTarget(this, evt);
+ if (where is not null && filterContext is not null && !where.Evaluate(filterContext, target)) continue;
+
var fields = new VariantCollection(selectClauses.Count);
foreach (var operand in selectClauses)
{
- fields.Add(ProjectEventField(evt, operand));
+ fields.Add(target.Project(operand));
}
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
@@ -6440,32 +6456,98 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return new HistoryEvent { Events = fieldLists };
}
+ ///
+ /// A historical event as the SDK's content-filter evaluator sees it: its type (for OfType, through the
+ /// server's type tree) and its projected fields (for every other operator).
+ ///
+ private sealed class HistoricalEventFilterTarget(OtOpcUaNodeManager owner, HistoricalEvent evt) : IFilterTarget
+ {
+ private NodeId? _type;
+
+ /// The event's OPC UA event type (see ).
+ public NodeId EventType => _type ??= owner.EventTypeOf(evt.EventTypeName);
+
+ public Variant Project(SimpleAttributeOperand operand) => owner.ProjectEventField(evt, operand, EventType);
+
+ public bool IsTypeOf(IFilterContext context, NodeId typeDefinitionId) =>
+ EventType == typeDefinitionId || context.TypeTree.IsTypeOf(EventType, typeDefinitionId);
+
+ public object? GetAttributeValue(IFilterContext context, NodeId typeDefinitionId, IList relativePath,
+ uint attributeId, NumericRange indexRange)
+ {
+ if (attributeId != Attributes.Value || relativePath is null || relativePath.Count == 0) return null;
+ var operand = new SimpleAttributeOperand { TypeDefinitionId = typeDefinitionId, AttributeId = attributeId };
+ foreach (var name in relativePath) operand.BrowsePath.Add(name);
+ return Project(operand).Value;
+ }
+ }
+
+ ///
+ /// The OPC UA event type of a historical alarm event, from its stored alarm type name: the Part 9 type of that name
+ /// (LimitAlarm ⇒ LimitAlarmType, OffNormalAlarm ⇒ OffNormalAlarmType, …, with or without the
+ /// Type suffix) when it is an event type in the server's type tree; otherwise — no name, or a name that is not
+ /// an event type — AlarmConditionType: every historical row is alarm history, of a subtype not modelled.
+ ///
+ /// The stored alarm type name, or null.
+ /// The event type NodeId.
+ private NodeId EventTypeOf(string? typeName)
+ {
+ if (string.IsNullOrWhiteSpace(typeName)) return ObjectTypeIds.AlarmConditionType;
+ return s_eventTypesByName.GetOrAdd(typeName, name =>
+ {
+ var field = typeof(ObjectTypeIds).GetField(name.EndsWith("Type", StringComparison.Ordinal) ? name : name + "Type",
+ System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
+ return field?.GetValue(null) is NodeId type && Server.TypeTree.IsTypeOf(type, ObjectTypeIds.BaseEventType)
+ ? type
+ : ObjectTypeIds.AlarmConditionType;
+ });
+ }
+
+ private static readonly ConcurrentDictionary s_eventTypesByName = new(StringComparer.Ordinal);
+
+ ///
+ /// The SourceNode of a historical alarm event: the materialised condition its alarm id names (a native
+ /// condition's id is its RawPath, Raw realm; a scripted one its alarm id, UNS realm) — the SourceNode the live
+ /// condition events carry. Null when no such condition is materialised now (removed, or never on this node).
+ ///
+ private NodeId? SourceNodeOf(HistoricalEvent evt)
+ {
+ if (string.IsNullOrEmpty(evt.AlarmId)) return null;
+ foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns })
+ if (_alarmConditions.TryGetValue(MapKey(realm, evt.AlarmId), out var condition)) return condition.NodeId;
+ return null;
+ }
+
///
/// Project one field requested by a select
/// into a . Mapping is by the operand's BrowsePath LEAF QualifiedName name
- /// (case-sensitive, per the OPC UA BaseEventType field names) against the
+ /// (case-sensitive, per the OPC UA BaseEventType / ConditionType field names) against the
/// shape:
- /// EventId→ByteString, SourceName→String, Time→DateTime,
- /// ReceiveTime→DateTime, Message→LocalizedText, Severity→UInt16. Any other
- /// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
- /// — spec-conformant: a field the server can't supply is null.
+ /// EventId→ByteString, EventType→NodeId (), SourceName→String,
+ /// SourceNode→NodeId (the resolved condition, else null), Time/ReceiveTime→DateTime,
+ /// Message→LocalizedText, Severity→UInt16, ConditionName→String. Any other leaf and an empty
+ /// BrowsePath ⇒ — spec-conformant: a field the server can't supply is null.
///
/// The source event row.
/// The select-clause operand naming the field to project.
+ /// The event's OPC UA event type ().
/// The projected variant, or for an unsupported field.
- private static Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand)
+ private Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand, NodeId eventType)
{
var leaf = LeafFieldName(operand);
return leaf switch
{
// BaseEventType/EventId is a ByteString — encode the driver-specific string id as UTF-8 bytes.
"EventId" => new Variant(System.Text.Encoding.UTF8.GetBytes(evt.EventId ?? string.Empty)),
+ "EventType" => new Variant(eventType),
"SourceName" => evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName),
+ "SourceNode" => SourceNodeOf(evt) is { } source ? new Variant(source) : Variant.Null,
"Time" => new Variant(evt.EventTimeUtc),
"ReceiveTime" => new Variant(evt.ReceivedTimeUtc),
"Message" => new Variant(new LocalizedText(evt.Message ?? string.Empty)),
"Severity" => new Variant(evt.Severity), // UInt16
- // EventType / SourceNode / ConditionName / empty path / unrecognised leaf ⇒ null (spec-conformant).
+ "ConditionName" => evt.ConditionName is null ? Variant.Null : new Variant(evt.ConditionName),
+ // empty path / unrecognised leaf ⇒ null (spec-conformant).
_ => Variant.Null,
};
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs
index de0f2cdd3..b46e44b76 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs
@@ -7,6 +7,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
public sealed class EventMapperTests
{
+ [Fact]
+ public void Carries_the_alarm_type_condition_name_and_alarm_id_the_writer_sent()
+ {
+ // The round trip of AlarmEventMapper's shape: Type = AlarmTypeName, Properties AlarmName / AlarmId.
+ var sent = AlarmEventMapper.ToHistorianEvent(new ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.AlarmHistorianEvent(
+ "Plant/Modbus/dev1/temp_hi", "Area/Line/Eq", "HiTemp", "LimitAlarm", ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmSeverity.High,
+ "Activated", "high", "u", null, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
+ var h = EventMapper.ToHistoricalEvent(sent);
+ Assert.Equal("LimitAlarm", h.EventTypeName);
+ Assert.Equal("HiTemp", h.ConditionName);
+ Assert.Equal("Plant/Modbus/dev1/temp_hi", h.AlarmId);
+
+ // A gateway row without them (e.g. an event another writer produced) maps to nulls, never throws.
+ var bare = new HistorianEvent { Id = "E", EventTime = Ts(2026, 1, 1, 0, 0, 0), ReceivedTime = Ts(2026, 1, 1, 0, 0, 0) };
+ var b = EventMapper.ToHistoricalEvent(bare);
+ Assert.Null(b.EventTypeName);
+ Assert.Null(b.ConditionName);
+ Assert.Null(b.AlarmId);
+ }
+
[Fact]
public void Maps_core_fields_and_times()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/AlarmWriterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/AlarmWriterTests.cs
index 7299b2c87..506bac2d9 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/AlarmWriterTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/AlarmWriterTests.cs
@@ -32,6 +32,25 @@ public sealed class AlarmWriterTests(QuestDbFixture fx)
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
}
+ [Fact]
+ public async Task An_event_reads_back_with_its_type_name_condition_name_and_alarm_id()
+ {
+ fx.SkipIfUnavailable();
+ var o = fx.Options(fx.UniquePrefix());
+ var c = new QuestDbConnection(o);
+ var boot = new QuestDbSchemaBootstrapper(c, o);
+ var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout);
+ var w = new QuestDbAlarmHistorianWriter(c, boot, wal);
+ (await w.WriteBatchAsync([Ev()], CancellationToken.None)).ShouldBe([HistorianWriteOutcome.Ack]);
+
+ var src = new QuestDbHistorianDataSource(c, boot, new QuestDbTagCatalog(c, boot, wal));
+ var e = (await src.ReadEventsAsync("Plant/Line/Eq", T.AddMinutes(-1), T.AddMinutes(1), 0, CancellationToken.None))
+ .Events.ShouldHaveSingleItem();
+ e.EventTypeName.ShouldBe("LimitAlarm");
+ e.ConditionName.ShouldBe("HiTemp");
+ e.AlarmId.ShouldBe("alarm-1");
+ }
+
[Fact]
public async Task A_batch_is_one_insert_per_chunk_and_a_redelivery_converges()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AlarmWriterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AlarmWriterTests.cs
index 0ecf22281..f631be609 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AlarmWriterTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AlarmWriterTests.cs
@@ -63,6 +63,25 @@ public sealed class AlarmWriterTests(SqlHistorianFixture fx)
r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.PermanentFail, HistorianWriteOutcome.Ack]);
}
+ [Fact]
+ public async Task An_event_reads_back_with_its_type_name_condition_name_and_alarm_id()
+ {
+ fx.SkipIfUnavailable();
+ var s = SqlHistorianFixture.UniqueSchema();
+ var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = s };
+ var c = new SqlHistorianConnection(o);
+ var boot = new SqlServerSchemaBootstrapper(c, o);
+ var w = new SqlServerAlarmHistorianWriter(c, boot);
+ (await w.WriteBatchAsync([Ev()], CancellationToken.None)).ShouldBe([HistorianWriteOutcome.Ack]);
+
+ using var src = new SqlServerHistorianDataSource(c, boot, new SqlServerTagCatalog(c, boot));
+ var e = (await src.ReadEventsAsync("Plant/Line/Eq", T.AddMinutes(-1), T.AddMinutes(1), 0, CancellationToken.None))
+ .Events.ShouldHaveSingleItem();
+ e.EventTypeName.ShouldBe("LimitAlarm");
+ e.ConditionName.ShouldBe("HiTemp");
+ e.AlarmId.ShouldBe("alarm-1");
+ }
+
[Fact]
public async Task A_batch_is_one_statement_per_chunk_and_a_redelivery_converges()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryProjectionOverTheWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryProjectionOverTheWireTests.cs
new file mode 100644
index 000000000..0aebbcc3c
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryProjectionOverTheWireTests.cs
@@ -0,0 +1,107 @@
+using Opc.Ua;
+using Opc.Ua.Client;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
+using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
+
+///
+/// Over a real SDK client session: a HistoryReadEvents whose select clause includes EventType,
+/// ConditionName and SourceNode — what a generic alarm viewer asks for — gets non-null values (they all
+/// used to be null), and an OfType where-clause is honoured on the wire.
+///
+public sealed class EventHistoryProjectionOverTheWireTests
+{
+ private static readonly DateTime Epoch = new(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public async Task EventType_ConditionName_and_SourceNode_come_back_non_null_and_OfType_filters()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var rig = await StartAsync("EvtProjectionWire", 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 session = await rig.ConnectAsync(anonymous: true, ct);
+ var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
+
+ var all = await ReadAsync(session, node, where: null, ct);
+ all.Count.ShouldBe(2);
+ foreach (var fields in all)
+ {
+ fields[1].Value.ShouldNotBeNull("EventType");
+ fields[2].Value.ShouldNotBeNull("ConditionName");
+ fields[3].Value.ShouldNotBeNull("SourceNode");
+ }
+ all[0][1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
+ all[0][2].Value.ShouldBe("temp_hi");
+ all[0][3].Value.ShouldBe(ConditionNodeId(session, native: true), "the native condition the alarm id names");
+
+ var where = new ContentFilter();
+ where.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ObjectTypeIds.LimitAlarmType)));
+ var limits = await ReadAsync(session, node, where, ct);
+ limits.Select(f => System.Text.Encoding.UTF8.GetString((byte[])f[0].Value)).ShouldBe(["e-limit"]);
+ }
+ finally { await rig.DisposeAsync(); }
+ }
+
+ private static async Task> ReadAsync(ISession session, NodeId node, ContentFilter? where, CancellationToken ct)
+ {
+ var filter = new EventFilter { WhereClause = where ?? new ContentFilter() };
+ foreach (var leaf in new[] { "EventId", "EventType", "ConditionName", "SourceNode" })
+ {
+ var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType;
+ filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
+ }
+ var details = new ReadEventDetails
+ {
+ StartTime = Epoch.AddDays(-1),
+ EndTime = Epoch.AddDays(1),
+ NumValuesPerNode = 100,
+ Filter = filter,
+ };
+ var response = await session.HistoryReadAsync(
+ null, new ExtensionObject(details), TimestampsToReturn.Both, false,
+ new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
+ var result = response.Results.ShouldHaveSingleItem();
+ StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}");
+ var he = (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData);
+ return he.Events.Select(e => e.EventFields).ToList();
+ }
+
+ private sealed class FixedSource(IReadOnlyList events) : IHistorianDataSource
+ {
+ public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
+ CancellationToken ct) =>
+ Task.FromResult(new HistoricalEventsResult(events.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList(), null));
+
+ public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
+ CancellationToken cancellationToken) =>
+ NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
+
+ public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
+ HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
+ NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
+
+ public Task ReadAtTimeAsync(string fullReference, IReadOnlyList timestampsUtc,
+ CancellationToken cancellationToken) =>
+ NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
+
+ public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
+
+ public void Dispose() { }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsProjectionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsProjectionTests.cs
new file mode 100644
index 000000000..2ec7d39ca
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsProjectionTests.cs
@@ -0,0 +1,179 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
+using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// HistoryReadEvents projects every field a historical alarm event can supply — EventType (the stored alarm
+/// type name mapped to its Part 9 type, an unmodelled one to AlarmConditionType), ConditionName, and
+/// SourceNode when the event's alarm id resolves to a materialised condition — and honours the request's
+/// where-clause (an OfType by the type tree; any other operator against the projected fields). These used to
+/// project and the where-clause was ignored.
+///
+public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+ private static readonly DateTime T0 = new(2026, 10, 22, 8, 0, 0, DateTimeKind.Utc);
+ private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-evtproj-{Guid.NewGuid():N}");
+
+ private const string Equipment = "eq-proj";
+
+ private static readonly HistoricalEvent[] Events =
+ [
+ new("e-limit", Equipment, T0, T0, "hi", 700, EventTypeName: "LimitAlarm", ConditionName: "HighTemp", AlarmId: "alarm-1"),
+ new("e-offnormal", Equipment, T0.AddSeconds(1), T0, "off", 500, EventTypeName: "OffNormalAlarm", ConditionName: "Door", AlarmId: "alarm-gone"),
+ new("e-untyped", Equipment, T0.AddSeconds(2), T0, "x", 300),
+ new("e-bogus", Equipment, T0.AddSeconds(3), T0, "y", 900, EventTypeName: "Folder", ConditionName: "Odd"),
+ ];
+
+ [Fact]
+ public async Task EventType_ConditionName_and_a_resolvable_SourceNode_are_projected()
+ {
+ var (host, server, nm, notifier) = await BootAsync();
+ var condition = nm.TryGetAlarmCondition("alarm-1")!.NodeId;
+
+ var history = Read(server, nm, notifier, Select("EventId", "EventType", "ConditionName", "SourceNode"), where: null);
+
+ var rows = history.Events.ToDictionary(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value));
+ rows["e-limit"].EventFields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
+ rows["e-limit"].EventFields[2].Value.ShouldBe("HighTemp");
+ rows["e-limit"].EventFields[3].Value.ShouldBe(condition, "the alarm id resolves to the materialised condition — the live events' SourceNode");
+
+ rows["e-offnormal"].EventFields[1].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
+ rows["e-offnormal"].EventFields[3].Value.ShouldBeNull("no condition is materialised for that alarm id");
+
+ // No stored type: still an alarm condition (every historical row is alarm history); an unrecognised or non-event
+ // type name is not passed through as a non-event type.
+ rows["e-untyped"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
+ rows["e-untyped"].EventFields[2].Value.ShouldBeNull();
+ rows["e-bogus"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
+
+ await host.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task An_OfType_where_clause_keeps_only_events_of_that_type_or_a_subtype()
+ {
+ var (host, server, nm, notifier) = await BootAsync();
+
+ Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.LimitAlarmType))).ShouldBe(["e-limit"]);
+ Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.OffNormalAlarmType))).ShouldBe(["e-offnormal"]);
+ // A supertype keeps its subtypes: every row is an AlarmConditionType (positive control — the filter is not
+ // simply dropping everything typed).
+ Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.AlarmConditionType)))
+ .ShouldBe(["e-limit", "e-offnormal", "e-untyped", "e-bogus"]);
+ // An unrelated event type keeps nothing (GoodNoData).
+ Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.AuditEventType))).ShouldBeEmpty();
+ await host.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task A_field_where_clause_is_evaluated_against_the_projected_fields()
+ {
+ var (host, server, nm, notifier) = await BootAsync();
+ var where = new ContentFilter();
+ where.Push(FilterOperator.GreaterThan,
+ new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("Severity")) { AttributeId = Attributes.Value },
+ new LiteralOperand(new Variant((ushort)600)));
+
+ Ids(Read(server, nm, notifier, Select("EventId"), where)).ShouldBe(["e-limit", "e-bogus"]);
+ await host.DisposeAsync();
+ }
+
+ private static string[] Ids(HistoryEvent h) =>
+ h.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToArray();
+
+ private static ContentFilter OfType(NodeId type)
+ {
+ var f = new ContentFilter();
+ f.Push(FilterOperator.OfType, new LiteralOperand(new Variant(type)));
+ return f;
+ }
+
+ private static SimpleAttributeOperandCollection Select(params string[] leaves)
+ {
+ var c = new SimpleAttributeOperandCollection();
+ foreach (var leaf in leaves)
+ c.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
+ return c;
+ }
+
+ private static HistoryEvent Read(OtOpcUaSdkServer server, OtOpcUaNodeManager nm, NodeId notifier,
+ SimpleAttributeOperandCollection select, ContentFilter? where)
+ {
+ var details = new ReadEventDetails
+ {
+ StartTime = T0.AddHours(-1),
+ EndTime = T0.AddHours(1),
+ NumValuesPerNode = 100,
+ Filter = new EventFilter { SelectClauses = select, WhereClause = where ?? new ContentFilter() },
+ };
+ var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
+ var results = new List { null! };
+ var errors = new List { null! };
+ nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false,
+ new List { new() { NodeId = notifier } }, results, errors);
+ StatusCode.IsGood(errors[0].StatusCode).ShouldBeTrue($"HistoryRead failed: {errors[0].StatusCode}");
+ return (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
+ }
+
+ private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server, OtOpcUaNodeManager Nm, NodeId Notifier)> BootAsync()
+ {
+ var started = await OpcUaHostStart.StartAsync(
+ port => new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.HistoryReadEventsProjectionTest",
+ ApplicationUri = $"urn:OtOpcUa.HistoryReadEventsProjectionTest:{Guid.NewGuid():N}",
+ OpcUaPort = port,
+ PublicHostname = "localhost",
+ PkiStoreRoot = _pkiRoot,
+ },
+ NullLogger.Instance),
+ () => new OtOpcUaSdkServer(), Ct);
+ var nm = started.Server.NodeManager!;
+ nm.HistorianDataSource = new FixedEventsSource(Events);
+ nm.EnsureFolder(Equipment, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
+ nm.MaterialiseAlarmCondition("alarm-1", Equipment, "HighTemp", "LimitAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
+ return (started.Host, started.Server, nm, nm.TryGetFolder(Equipment)!.NodeId);
+ }
+
+ private sealed class FixedEventsSource(IReadOnlyList events) : IHistorianDataSource
+ {
+ public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
+ CancellationToken cancellationToken) =>
+ NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
+
+ public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
+ HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
+ NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
+
+ public Task ReadAtTimeAsync(string fullReference, IReadOnlyList timestampsUtc,
+ CancellationToken cancellationToken) =>
+ NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
+
+ public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(new HistoricalEventsResult(events.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList(), null));
+
+ public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
+
+ public void Dispose() { }
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_pkiRoot))
+ {
+ try { Directory.Delete(_pkiRoot, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs
index 3df484bc1..b2fa28c40 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs
@@ -133,8 +133,9 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
await host.DisposeAsync();
}
- /// An unsupported select operand (BrowsePath ["EventType"]) projects to Variant.Null — a field
- /// the server can't supply is null (spec-conformant) — while supported siblings still project.
+ /// An unsupported select operand (BrowsePath ["LocalTime"]) projects to Variant.Null — a field
+ /// the server can't supply is null (spec-conformant) — while supported siblings still project. (EventType used to
+ /// be such a field; it is projected now — see NodeManagerHistoryReadEventsProjectionTests.)
[Fact]
public async Task Events_unsupported_select_field_projects_null()
{
@@ -156,8 +157,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
- // EventType is a real BaseEventType field we cannot supply from a HistoricalEvent; EventId is.
- Filter = SelectFilter("EventType", "EventId"),
+ // LocalTime is a real BaseEventType field we cannot supply from a HistoricalEvent; EventId is.
+ Filter = SelectFilter("LocalTime", "EventId"),
};
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
@@ -166,7 +167,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
var fields = history.Events[0].EventFields;
fields.Count.ShouldBe(2);
- // EventType ⇒ Variant.Null.
+ // LocalTime ⇒ Variant.Null.
fields[0].Value.ShouldBeNull();
// EventId still projects to a ByteString.
fields[1].Value.ShouldBeOfType().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));