From 29665c1872f5e17f43b266c51252c78a1ea1c574 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 17:45:38 -0400 Subject: [PATCH] feat(opcua): HistoryReadEvents projects EventType, ConditionName, SourceNode; honours the where-clause (lmxopcua#746) HistoryReadEvents projected only EventId / SourceName / Time / ReceiveTime / Message / Severity: a select clause asking for EventType, ConditionName or SourceNode (what most generic alarm viewers ask for) got nulls, and the event filter's where-clause (an OfType) was ignored. - HistoricalEvent gains optional EventTypeName, ConditionName and AlarmId. SqlServer, QuestDB and Gateway event reads fill them from what their alarm writers already store (AlarmTypeName / AlarmName / AlarmId; the gateway event Type and its AlarmName / AlarmId properties). - The node manager maps the type name to its Part 9 event type (unknown or non-event => AlarmConditionType), projects ConditionName, and resolves SourceNode to the materialised condition the alarm id names (the live events' SourceNode), else null. - The where-clause is evaluated per event with the SDK content filter through an IFilterTarget (OfType via the type tree; other operators against the projected fields), after the page's cursor is taken, so paging is unchanged. Tested in the node manager, over a real client session (the Client.CLI has no `historyread --events` mode yet; filed as a follow-up), and by alarm writer -> event read round trips on SqlServer and QuestDB. Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22 in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- docs/Historian.md | 16 +- .../Historian/HistoryReadContracts.cs | 15 +- .../Mapping/EventMapper.cs | 9 +- .../QuestDbHistorianDataSource.cs | 7 +- .../HistoricalEventRows.cs | 5 + .../SqlServerHistorianDataSource.cs | 7 +- .../OtOpcUaNodeManager.cs | 120 ++++++++++-- .../Mapping/EventMapperTests.cs | 20 ++ .../AlarmWriterTests.cs | 19 ++ .../AlarmWriterTests.cs | 19 ++ .../EventHistoryProjectionOverTheWireTests.cs | 107 +++++++++++ ...ManagerHistoryReadEventsProjectionTests.cs | 179 ++++++++++++++++++ .../NodeManagerHistoryReadEventsTests.cs | 11 +- 13 files changed, 503 insertions(+), 31 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryProjectionOverTheWireTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsProjectionTests.cs 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"));