bug(alarms): native alarm conditions emit null SourceNode/SourceName/EventType #473

Closed
opened 2026-07-16 23:58:02 -04:00 by dohertj2 · 0 comments
Owner

Summary

MaterialiseAlarmCondition never assigns SourceNode, SourceName, or EventType on native alarm conditions, so all three arrive null over the wire on every condition event — native and scripted. These are mandatory BaseEventType fields that essentially every OPC UA client selects and keys on, so a conforming client cannot identify the source of an alarm.

Found while planning ScadaBridge's v3.0 cutover (ScadaBridge issue #14): ScadaBridge's Data Connection Layer selects exactly [EventType, SourceNode, SourceName, Time, Message, Severity] and derives its alarm identity from SourceName, falling back to SourceNode. Against v3 both are null, so its key degrades to ".HR200" and no alarm can be routed. ScadaBridge has its own fix to make (it should key on ConditionId), but the null fields are a server-side defect independent of any one client.

Detail

Construction site: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:781-809 (MaterialiseAlarmCondition), called for raw tags from AddressSpaceApplier.cs:702.

The full field initialisation is:

AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);   // :781
alarm.SymbolicName = displayName;                                            // :782
alarm.Create(SystemContext, new NodeId(alarmNodeId, ns),                     // :789-794
             new QualifiedName(displayName, ns), new LocalizedText(displayName), true);
alarm.SetEnableState(...true); alarm.SetActiveState(...false);               // :803-805
alarm.SetAcknowledgedState(...true);
alarm.SetSeverity(SystemContext, MapSeverity(severity));                     // :806
alarm.Retain.Value = false;                                                  // :807
alarm.Message.Value = new LocalizedText(displayName);                        // :808
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName; // :809

There is no alarm.SourceNode.Value = ..., no alarm.SourceName.Value = ..., no alarm.EventType.Value = ....

The SDK does not fill them either. NodeState.CreateInitialize(context) → the generated Initialize(context, InitializationString); the ConditionTypeInstance init string declares SourceNode/SourceName/EventType as mandatory children with no default value, so they stay null. The auto-filling overload BaseEventState.Initialize(context, source, severity, message) — which would set SourceNode = source.NodeId and SourceName = source.BrowseName.Name — is never called on this path (it is only used for transient events). NodeState.ReportEvent and InstanceStateSnapshot.CreateChildNode do no field synthesis; the snapshot copies PropertyState.Value verbatim.

The SDK's own AlarmConditionServer sample does alarm.EventType.Value = alarm.TypeDefinitionId; — this code does not.

Worked example

Raw tag pymodbus/plc/HR200, TagConfig {"alarm":{"alarmType":"OffNormalAlarm","severity":700}}, driver raises with AlarmSeverity.High, message "HR200 over limit". Client subscribes for events at the Server object (ns=0;i=2253) with a standard select clause:

Field Value on the wire
EventId fresh GUID
EventType null
SourceNode null
SourceName null
Time / ReceiveTime transition SourceTimestampUtc
Message LocalizedText("HR200 over limit")
Severity 700
ConditionName "HR200" (leaf name, not the RawPath)
ConditionId (NodeId attr) ns=2;s=pymodbus/plc/HR200 — correct

ConditionId is correct because the server resolves it from the condition node's own NodeId rather than from a populated field. TypeDefinitionId is likewise set, so a WhereClause OfType filter works — but a client reading the EventType field gets null.

Impact

  • Any client keying on SourceName/SourceNode (the common pattern) cannot attribute an alarm to its source.
  • Clients reading the EventType field for type resolution / client-side filtering get null; only OfType where-clauses or a HasTypeDefinition browse work.
  • ConditionName is the leaf tag name, so it is ambiguous across devices (HR200 on two PLCs collides) and is not a usable identity on its own.

Suggested fix

In MaterialiseAlarmCondition, right after alarm.Create(...), follow the standard SDK pattern:

alarm.EventType.Value  = alarm.TypeDefinitionId;
alarm.SourceNode.Value = new NodeId(alarmNodeId, ns);  // the raw tag / condition node
alarm.SourceName.Value = displayName;                  // or the RawPath — see question below

Open question worth deciding deliberately: should SourceName be the leaf Name ("HR200") or the full RawPath ("pymodbus/plc/HR200")? The leaf name matches the usual OPC UA convention and ConditionName, but is ambiguous across devices; the RawPath is unique and matches ConditionId. Clients should key on ConditionId regardless, so this is mainly a human-readability call — but it is worth being explicit since clients will read it.

Why this survived

No test asserts these field values. tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs is the only wire-level event test, and its filter (:173-179) selects only EventId and Message — it counts delivery copies, it does not validate fields. NodeManagerHistoryReadEventsTests.cs:159-169 asserts EventType => Variant.Null, but that is the HistoryRead projection (OtOpcUaNodeManager.cs:2658-2673), which nulls those fields by design — a separate path from live condition events.

A fix should come with a wire-level test asserting SourceNode/SourceName/EventType are populated on a live condition event.

References

  • Raised from the ScadaBridge v3.0 cutover analysis (ScadaBridge Gitea #14).
  • ScadaBridge consumer side: RealOpcUaClient.cs:464 (select clause), :749-751 (identity derivation).
  • docs/AlarmTracking.md:47 — ack/confirm/shelve already key on ConditionId = RawPath, consistent with the suggested client guidance.
## Summary `MaterialiseAlarmCondition` never assigns `SourceNode`, `SourceName`, or `EventType` on native alarm conditions, so all three arrive **null** over the wire on every condition event — native and scripted. These are mandatory `BaseEventType` fields that essentially every OPC UA client selects and keys on, so a conforming client cannot identify the source of an alarm. Found while planning ScadaBridge's v3.0 cutover (ScadaBridge issue #14): ScadaBridge's Data Connection Layer selects exactly `[EventType, SourceNode, SourceName, Time, Message, Severity]` and derives its alarm identity from `SourceName`, falling back to `SourceNode`. Against v3 both are null, so its key degrades to `".HR200"` and no alarm can be routed. ScadaBridge has its own fix to make (it should key on `ConditionId`), but the null fields are a server-side defect independent of any one client. ## Detail Construction site: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:781-809` (`MaterialiseAlarmCondition`), called for raw tags from `AddressSpaceApplier.cs:702`. The full field initialisation is: ```csharp AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent); // :781 alarm.SymbolicName = displayName; // :782 alarm.Create(SystemContext, new NodeId(alarmNodeId, ns), // :789-794 new QualifiedName(displayName, ns), new LocalizedText(displayName), true); alarm.SetEnableState(...true); alarm.SetActiveState(...false); // :803-805 alarm.SetAcknowledgedState(...true); alarm.SetSeverity(SystemContext, MapSeverity(severity)); // :806 alarm.Retain.Value = false; // :807 alarm.Message.Value = new LocalizedText(displayName); // :808 if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName; // :809 ``` There is no `alarm.SourceNode.Value = ...`, no `alarm.SourceName.Value = ...`, no `alarm.EventType.Value = ...`. The SDK does not fill them either. `NodeState.Create` → `Initialize(context)` → the generated `Initialize(context, InitializationString)`; the `ConditionTypeInstance` init string declares `SourceNode`/`SourceName`/`EventType` as mandatory children **with no default value**, so they stay `null`. The auto-filling overload `BaseEventState.Initialize(context, source, severity, message)` — which would set `SourceNode = source.NodeId` and `SourceName = source.BrowseName.Name` — is never called on this path (it is only used for transient events). `NodeState.ReportEvent` and `InstanceStateSnapshot.CreateChildNode` do no field synthesis; the snapshot copies `PropertyState.Value` verbatim. The SDK's own `AlarmConditionServer` sample does `alarm.EventType.Value = alarm.TypeDefinitionId;` — this code does not. ## Worked example Raw tag `pymodbus/plc/HR200`, `TagConfig {"alarm":{"alarmType":"OffNormalAlarm","severity":700}}`, driver raises with `AlarmSeverity.High`, message `"HR200 over limit"`. Client subscribes for events at the Server object (`ns=0;i=2253`) with a standard select clause: | Field | Value on the wire | |---|---| | EventId | fresh GUID | | **EventType** | **null** | | **SourceNode** | **null** | | **SourceName** | **null** | | Time / ReceiveTime | transition `SourceTimestampUtc` | | Message | `LocalizedText("HR200 over limit")` | | Severity | 700 | | ConditionName | `"HR200"` (leaf name, not the RawPath) | | ConditionId (NodeId attr) | `ns=2;s=pymodbus/plc/HR200` — correct | `ConditionId` is correct because the server resolves it from the condition node's own NodeId rather than from a populated field. `TypeDefinitionId` is likewise set, so a `WhereClause` `OfType` filter works — but a client reading the `EventType` **field** gets null. ## Impact - Any client keying on `SourceName`/`SourceNode` (the common pattern) cannot attribute an alarm to its source. - Clients reading the `EventType` field for type resolution / client-side filtering get null; only `OfType` where-clauses or a `HasTypeDefinition` browse work. - `ConditionName` is the leaf tag name, so it is ambiguous across devices (`HR200` on two PLCs collides) and is not a usable identity on its own. ## Suggested fix In `MaterialiseAlarmCondition`, right after `alarm.Create(...)`, follow the standard SDK pattern: ```csharp alarm.EventType.Value = alarm.TypeDefinitionId; alarm.SourceNode.Value = new NodeId(alarmNodeId, ns); // the raw tag / condition node alarm.SourceName.Value = displayName; // or the RawPath — see question below ``` **Open question worth deciding deliberately:** should `SourceName` be the leaf `Name` (`"HR200"`) or the full RawPath (`"pymodbus/plc/HR200"`)? The leaf name matches the usual OPC UA convention and `ConditionName`, but is ambiguous across devices; the RawPath is unique and matches `ConditionId`. Clients should key on `ConditionId` regardless, so this is mainly a human-readability call — but it is worth being explicit since clients will read it. ## Why this survived No test asserts these field values. `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs` is the only wire-level event test, and its filter (`:173-179`) selects only `EventId` and `Message` — it counts delivery copies, it does not validate fields. `NodeManagerHistoryReadEventsTests.cs:159-169` asserts `EventType => Variant.Null`, but that is the HistoryRead projection (`OtOpcUaNodeManager.cs:2658-2673`), which nulls those fields by design — a separate path from live condition events. A fix should come with a wire-level test asserting `SourceNode`/`SourceName`/`EventType` are populated on a live condition event. ## References - Raised from the ScadaBridge v3.0 cutover analysis (ScadaBridge Gitea #14). - ScadaBridge consumer side: `RealOpcUaClient.cs:464` (select clause), `:749-751` (identity derivation). - `docs/AlarmTracking.md:47` — ack/confirm/shelve already key on ConditionId = RawPath, consistent with the suggested client guidance.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#473