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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user