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:
@@ -51,6 +51,49 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
||||
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
||||
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||
|
||||
## Condition event identity fields (what a client reads on the wire)
|
||||
|
||||
Every condition event — native and scripted — carries the mandatory `BaseEventType` identity
|
||||
fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK
|
||||
does **not** synthesize them on this path (`Create` builds the children from the type definition
|
||||
but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they
|
||||
are set explicitly. Leaving them unset shipped them as **null** on every event — see issue #473.
|
||||
|
||||
| Field | Value | Notes |
|
||||
|---|---|---|
|
||||
| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause |
|
||||
| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
|
||||
| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
|
||||
| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
|
||||
|
||||
**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
|
||||
two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
|
||||
identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId`
|
||||
triple mutually consistent and unique. This diverges from the loose OPC UA convention that
|
||||
`SourceName` mirrors the source node's BrowseName; the divergence is intentional.
|
||||
|
||||
**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals
|
||||
`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for
|
||||
scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same
|
||||
identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with
|
||||
`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the
|
||||
condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`).
|
||||
|
||||
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three fields arrive
|
||||
populated on a real subscription using the standard `[EventType, SourceNode, SourceName, Time,
|
||||
Message, Severity]` select clause; `NodeManagerAlarmSourceFieldsTests` guards the node itself
|
||||
across both realms.
|
||||
|
||||
> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.**
|
||||
> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns
|
||||
> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's
|
||||
> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the
|
||||
> alarm-history writer stamps that field with the **EquipmentPath**
|
||||
> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries.
|
||||
> So `SourceName` is the one field populated on both paths **with different values**, and it is not
|
||||
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
|
||||
> live-path fix above does not change the history path.)
|
||||
|
||||
## Galaxy driver path (driver-native)
|
||||
|
||||
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
||||
|
||||
Reference in New Issue
Block a user