fix(alarms): populate EventType/SourceNode/SourceName on conditions (#473)
MaterialiseAlarmCondition never assigned the three mandatory BaseEventType
identity fields, so all three arrived null on every condition event — native
and scripted. The SDK does not synthesise them on this path: Create() builds
the children from the type definition but leaves them unset, the auto-filling
BaseEventState.Initialize overload is only used for transient events, and
ReportEvent / InstanceStateSnapshot copy children verbatim. A conforming
client could not attribute an alarm to its source.
EventType = the concrete materialised type (TypeDefinitionId)
SourceNode = the condition's own NodeId (== ConditionId) — the condition IS
the source; an alarm-bearing raw tag materialises only the
condition, with no sibling value variable
SourceName = the same identifying id string: RawPath (native) /
ScriptedAlarmId (scripted)
SourceName carries the unique id rather than the leaf name: the leaf collides
across devices (HR200 on two PLCs) and is already carried by ConditionName, so
nothing is lost. Documented in docs/AlarmTracking.md, including that clients
must key on ConditionId and must not compose SourceName with ConditionName,
and that SourceName is NOT a live<->history join key (the alarm-history writer
stamps it with the EquipmentPath — a pre-existing divergence, now called out).
Tests: NativeAlarmEventIdentityFieldDeliveryTests is the wire-level guard —
a real client subscription using the standard [EventType, SourceNode,
SourceName, Time, Message, Severity] select clause, verified to fail against
the pre-fix server. NodeManagerAlarmSourceFieldsTests guards the node across
both realms, the base-type fallback, and the kind-swap re-materialise.
The HistoryRead events projection is a separate path (it projects historian
rows, not node fields) and is unaffected — its EventType => Variant.Null
assertions still hold.
This commit is contained in:
@@ -799,6 +799,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
|
||||
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
|
||||
|
||||
// #473 — the mandatory BaseEventType identity fields. Create() builds these three children from
|
||||
// the type's embedded definition but leaves them UNSET (the init string declares them mandatory
|
||||
// with NO default), and nothing downstream fills them in: the auto-filling
|
||||
// BaseEventState.Initialize(context, source, severity, message) overload is only used for
|
||||
// transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset
|
||||
// here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm.
|
||||
// SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An
|
||||
// alarm-bearing raw tag materialises ONLY this condition, with no sibling value
|
||||
// variable (see AddressSpaceApplier's alarm branch), so there is no other node.
|
||||
// SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
|
||||
// Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
|
||||
// is already carried by ConditionName below. See docs/AlarmTracking.md.
|
||||
alarm.EventType.Value = alarm.TypeDefinitionId;
|
||||
alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
|
||||
alarm.SourceName.Value = alarmNodeId;
|
||||
|
||||
// Initial state via the SDK setters (T14: basic state only, NO event firing).
|
||||
alarm.SetEnableState(SystemContext, true);
|
||||
alarm.SetActiveState(SystemContext, false);
|
||||
|
||||
Reference in New Issue
Block a user