fix(alarms): populate EventType/SourceNode/SourceName on native + scripted conditions (#473) #474

Merged
dohertj2 merged 1 commits from fix/alarm-condition-source-fields into master 2026-07-17 00:45:53 -04:00
Owner

Fixes #473.

What

MaterialiseAlarmCondition never assigned the three mandatory BaseEventType identity fields, so all three arrived null on every condition event — native and scripted. Three lines of production code:

alarm.EventType.Value  = alarm.TypeDefinitionId;
alarm.SourceNode.Value = alarm.NodeId;   // Create() assigned this; don't rebuild it
alarm.SourceName.Value = alarmNodeId;

I confirmed the issue's root-cause analysis end to end: neither MaterialiseAlarmCondition nor ReportConditionEvent sets these, and the snapshot copies node children verbatim, so nothing downstream fills them in. Notably the same file already sets SourceNode/SourceName carefully on model-change and audit events — consistent with this being an oversight on the alarm path only.

The open question, decided

The issue flagged SourceName (leaf vs RawPath) as needing a deliberate call. Decision: the identifying id — RawPath for native, ScriptedAlarmId for scripted. Uniform rule, and it matches ConditionId/SourceNode.

Rationale: the leaf collides across devices (HR200 on two PLCs) and is already carried by ConditionName, so the leaf option adds no identity while costing uniqueness. The full spread a client now gets:

Field Value
EventType OffNormalAlarmType (concrete type; base fallback for unknown types)
SourceNode ns=2;s=pymodbus/plc/HR200 (== ConditionId)
SourceName pymodbus/plc/HR200 (unique)
ConditionName HR200 (leaf still available)

SourceNode self-references the condition because an alarm-bearing raw tag materialises only the condition — there is no sibling value variable to point at.

Two things reviewers should know

1. ScadaBridge will show a stuttered key until it fixes its own keying. The issue says ScadaBridge "derives its alarm identity from SourceName" — it actually composes $"{sourceName}.{conditionName}" (RealOpcUaClient.cs:755), which is why its broken key is ".HR200" with a leading dot. So its key becomes pymodbus/plc/HR200.HR200 — stuttered, but unique and correct, versus today's collision. ScadaBridge should key on ConditionId as the issue itself notes. Documented explicitly: clients must not compose SourceName with ConditionName.

2. SourceName is NOT a live↔history join key. HistoryRead nulls EventType/SourceNode by design, but it does project SourceName — stamped by the alarm-history writer as the EquipmentPath (AlarmEventMapper), not the RawPath. So it's the one field both paths populate with different values. Pre-existing and unchanged by this PR, but the docs now warn against it rather than papering over it.

Tests

  • NativeAlarmEventIdentityFieldDeliveryTests (wire-level, the guard the issue asked for) — real server + real client subscription using ScadaBridge's exact [EventType, SourceNode, SourceName, Time, Message, Severity] select clause. Verified to fail against the pre-fix server (EventType arrived empty), not just to pass after.
  • NodeManagerAlarmSourceFieldsTests — node-level across both realms, the base-type fallback, and the kind-swap re-materialise.

All 4 node tests were watched failing first. Full suites green: OpcUaServer.Tests 379, IntegrationTests 11, Runtime.Tests 378, Commons.Tests 306. NodeManagerHistoryReadEventsTests' deliberate EventType => Variant.Null assertions still hold (separate path).

Follow-up (not in this PR)

A high-effort review found the same bug class in the same function: ConditionClassId / ConditionClassName are assigned nowhere in the tree, so they're also null on every event. Out of scope for #473 (its own wire-contract decision + tests) — filed separately.

Verification note

Proven by the wire-level integration test (real OtOpcUaSdkServer + real OPC UA client over TCP, driven through the production SdkAddressSpaceSink), not by a docker-dev deploy. The fix sits in the single terminal node-manager method every materialise path funnels through, and adds no new interface surface — so the DeferredAddressSpaceSink prod-inertness trap doesn't apply here.

Fixes #473. ## What `MaterialiseAlarmCondition` never assigned the three mandatory `BaseEventType` identity fields, so all three arrived **null** on every condition event — native and scripted. Three lines of production code: ```csharp alarm.EventType.Value = alarm.TypeDefinitionId; alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this; don't rebuild it alarm.SourceName.Value = alarmNodeId; ``` I confirmed the issue's root-cause analysis end to end: neither `MaterialiseAlarmCondition` nor `ReportConditionEvent` sets these, and the snapshot copies node children verbatim, so nothing downstream fills them in. Notably the same file already sets `SourceNode`/`SourceName` carefully on model-change and audit events — consistent with this being an oversight on the alarm path only. ## The open question, decided The issue flagged `SourceName` (leaf vs RawPath) as needing a deliberate call. **Decision: the identifying id** — RawPath for native, ScriptedAlarmId for scripted. Uniform rule, and it matches `ConditionId`/`SourceNode`. Rationale: the leaf collides across devices (`HR200` on two PLCs) and is *already* carried by `ConditionName`, so the leaf option adds no identity while costing uniqueness. The full spread a client now gets: | Field | Value | |---|---| | `EventType` | `OffNormalAlarmType` (concrete type; base fallback for unknown types) | | `SourceNode` | `ns=2;s=pymodbus/plc/HR200` (== `ConditionId`) | | `SourceName` | `pymodbus/plc/HR200` (unique) | | `ConditionName` | `HR200` (leaf still available) | `SourceNode` self-references the condition because an alarm-bearing raw tag materialises **only** the condition — there is no sibling value variable to point at. ## Two things reviewers should know **1. ScadaBridge will show a stuttered key until it fixes its own keying.** The issue says ScadaBridge "derives its alarm identity from SourceName" — it actually composes `$"{sourceName}.{conditionName}"` (`RealOpcUaClient.cs:755`), which is why its broken key is `".HR200"` with a leading dot. So its key becomes `pymodbus/plc/HR200.HR200` — stuttered, but **unique and correct**, versus today's collision. ScadaBridge should key on `ConditionId` as the issue itself notes. Documented explicitly: clients must not compose `SourceName` with `ConditionName`. **2. `SourceName` is NOT a live↔history join key.** HistoryRead nulls `EventType`/`SourceNode` by design, but it *does* project `SourceName` — stamped by the alarm-history writer as the **EquipmentPath** (`AlarmEventMapper`), not the RawPath. So it's the one field both paths populate **with different values**. Pre-existing and unchanged by this PR, but the docs now warn against it rather than papering over it. ## Tests - **`NativeAlarmEventIdentityFieldDeliveryTests`** (wire-level, the guard the issue asked for) — real server + real client subscription using ScadaBridge's exact `[EventType, SourceNode, SourceName, Time, Message, Severity]` select clause. **Verified to fail against the pre-fix server** (`EventType` arrived empty), not just to pass after. - **`NodeManagerAlarmSourceFieldsTests`** — node-level across both realms, the base-type fallback, and the kind-swap re-materialise. All 4 node tests were watched failing first. Full suites green: OpcUaServer.Tests 379, IntegrationTests 11, Runtime.Tests 378, Commons.Tests 306. `NodeManagerHistoryReadEventsTests`' deliberate `EventType => Variant.Null` assertions still hold (separate path). ## Follow-up (not in this PR) A high-effort review found the **same bug class in the same function**: `ConditionClassId` / `ConditionClassName` are assigned nowhere in the tree, so they're also null on every event. Out of scope for #473 (its own wire-contract decision + tests) — filed separately. ## Verification note Proven by the wire-level integration test (real `OtOpcUaSdkServer` + real OPC UA client over TCP, driven through the production `SdkAddressSpaceSink`), not by a docker-dev deploy. The fix sits in the single terminal node-manager method every materialise path funnels through, and adds no new interface surface — so the `DeferredAddressSpaceSink` prod-inertness trap doesn't apply here.
dohertj2 added 1 commit 2026-07-17 00:37:18 -04:00
fix(alarms): populate EventType/SourceNode/SourceName on conditions (#473)
v2-ci / build (pull_request) Successful in 3m38s
v2-ci / unit-tests (pull_request) Failing after 9m38s
7339a4af07
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.
dohertj2 merged commit 50426d4790 into master 2026-07-17 00:45:53 -04:00
Sign in to join this conversation.