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
This commit is contained in:
Joseph Doherty
2026-10-22 17:45:38 -04:00
parent 096a3d5b3c
commit 29665c1872
13 changed files with 503 additions and 31 deletions
@@ -5714,8 +5714,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> 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
/// <param name="sourceName">The registered historian event-source name for this notifier.</param>
/// <param name="details">The event-read details (window + count cap).</param>
/// <param name="selectClauses">The event-filter select clauses (the fields to emit, in order).</param>
/// <param name="where">The event filter's where-clause, or null for none.</param>
/// <param name="filterContext">The context the where-clause is evaluated in (null when there is none).</param>
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
/// <param name="ct">Per-request deadline token.</param>
@@ -5765,6 +5771,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
string? sourceName,
ReadEventDetails details,
SimpleAttributeOperandCollection selectClauses,
ContentFilter? where,
FilterContext? filterContext,
IList<HistoryReadValueId> nodesToRead,
ISession? session,
IList<SdkHistoryReadResult> 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
/// <summary>
/// Project a sequence of <see cref="HistoricalEvent"/>s into an SDK <see cref="HistoryEvent"/> —
/// one <see cref="HistoryEventFieldList"/> per event, each carrying the requested
/// <paramref name="selectClauses"/>' fields in select-clause order (see
/// <see cref="ProjectEventField"/>).
/// one <see cref="HistoryEventFieldList"/> per event that passes <paramref name="where"/>, each carrying the
/// requested <paramref name="selectClauses"/>' fields in select-clause order (see <see cref="ProjectEventField"/>).
/// </summary>
/// <param name="events">The historian's event rows.</param>
/// <param name="selectClauses">The request's event-filter select clauses (the fields to emit, in order).</param>
/// <param name="where">The request's where-clause, or null for none (every event passes).</param>
/// <param name="filterContext">The context <paramref name="where"/> is evaluated in.</param>
/// <returns>The populated SDK <see cref="HistoryEvent"/>.</returns>
private static HistoryEvent ProjectEvents(
IReadOnlyList<HistoricalEvent> events, SimpleAttributeOperandCollection selectClauses)
private HistoryEvent ProjectEvents(
IReadOnlyList<HistoricalEvent> 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 };
}
/// <summary>
/// A historical event as the SDK's content-filter evaluator sees it: its type (for <c>OfType</c>, through the
/// server's type tree) and its projected fields (for every other operator).
/// </summary>
private sealed class HistoricalEventFilterTarget(OtOpcUaNodeManager owner, HistoricalEvent evt) : IFilterTarget
{
private NodeId? _type;
/// <summary>The event's OPC UA event type (see <see cref="OtOpcUaNodeManager.EventTypeOf"/>).</summary>
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<QualifiedName> 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;
}
}
/// <summary>
/// The OPC UA event type of a historical alarm event, from its stored alarm type name: the Part 9 type of that name
/// (<c>LimitAlarm</c> ⇒ <c>LimitAlarmType</c>, <c>OffNormalAlarm</c> ⇒ <c>OffNormalAlarmType</c>, …, with or without the
/// <c>Type</c> 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 — <c>AlarmConditionType</c>: every historical row is alarm history, of a subtype not modelled.
/// </summary>
/// <param name="typeName">The stored alarm type name, or null.</param>
/// <returns>The event type NodeId.</returns>
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<string, NodeId> s_eventTypesByName = new(StringComparer.Ordinal);
/// <summary>
/// The <c>SourceNode</c> 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).
/// </summary>
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;
}
/// <summary>
/// Project one <see cref="HistoricalEvent"/> field requested by a select <paramref name="operand"/>
/// into a <see cref="Variant"/>. 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
/// <see cref="HistoricalEvent"/> shape:
/// <c>EventId</c>→ByteString, <c>SourceName</c>→String, <c>Time</c>→DateTime,
/// <c>ReceiveTime</c>→DateTime, <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16. Any other
/// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
/// <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
/// <c>EventId</c>→ByteString, <c>EventType</c>→NodeId (<paramref name="eventType"/>), <c>SourceName</c>→String,
/// <c>SourceNode</c>→NodeId (the resolved condition, else null), <c>Time</c>/<c>ReceiveTime</c>→DateTime,
/// <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16, <c>ConditionName</c>→String. Any other leaf and an empty
/// BrowsePath ⇒ <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
/// </summary>
/// <param name="evt">The source event row.</param>
/// <param name="operand">The select-clause operand naming the field to project.</param>
/// <param name="eventType">The event's OPC UA event type (<see cref="EventTypeOf"/>).</param>
/// <returns>The projected variant, or <see cref="Variant.Null"/> for an unsupported field.</returns>
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,
};
}