fix(alarms): route native alarms by ConditionId (dotted FullName), not bare SourceNodeId (integration review)
v2-ci / build (push) Failing after 46s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

This commit is contained in:
Joseph Doherty
2026-06-14 04:09:01 -04:00
parent 7e86fa7099
commit f9be38430c
2 changed files with 27 additions and 21 deletions
@@ -111,7 +111,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByNodeId = private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByNodeId =
new(StringComparer.Ordinal); new(StringComparer.Ordinal);
/// <summary>(DriverInstanceId, FullName = alarm SourceNodeId) → folder-scoped condition NodeId(s). /// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s).
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native /// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native
/// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not /// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not
/// value variables, so they are kept OUT of the value maps + value-subscription set.</summary> /// value variables, so they are kept OUT of the value maps + value-subscription set.</summary>
@@ -497,10 +497,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <summary> /// <summary>
/// Routes a native alarm transition (published by a driver child as /// Routes a native alarm transition (published by a driver child as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition /// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition
/// node(s). The alarm path analogue of <see cref="ForwardToMux"/>: the driver fires keyed by the /// node(s). The alarm path analogue of <see cref="ForwardToMux"/>: the transition's <c>ConditionId</c>
/// alarm source's <c>SourceNodeId</c> (the equipment tag's wire-ref FullName), which the /// (the dotted alarm full-reference, which equals the authored equipment-tag FullName — NOT the bare
/// <see cref="_alarmNodeIdByDriverRef"/> map — built each apply from the alarm-bearing EquipmentTags /// <c>SourceNodeId</c> owning object) is resolved by the <see cref="_alarmNodeIdByDriverRef"/> map —
/// resolves to the folder-scoped condition NodeId(s) the materialiser placed the condition(s) at. /// built each apply from the alarm-bearing EquipmentTags — to the folder-scoped condition NodeId(s)
/// the materialiser placed the condition(s) at.
/// For each node the <see cref="_nativeAlarmProjector"/> projects the transition delta into a full /// For each node the <see cref="_nativeAlarmProjector"/> projects the transition delta into a full
/// <c>AlarmConditionSnapshot</c>, then this Tells <see cref="ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate"/> /// <c>AlarmConditionSnapshot</c>, then this Tells <see cref="ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate"/>
/// — the SAME message scripted alarms use, so it routes through <c>WriteAlarmCondition</c>. An /// — the SAME message scripted alarms use, so it routes through <c>WriteAlarmCondition</c>. An
@@ -518,10 +519,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg) private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
{ {
if (_opcUaPublishActor is null) return; if (_opcUaPublishActor is null) return;
if (!_alarmNodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.Args.SourceNodeId), out var nodeIds)) // Resolve on ConditionId, NOT SourceNodeId: for Galaxy the dotted alarm full-reference — which
// equals the authored equipment-tag FullName the map is keyed by — is carried in ConditionId
// (AlarmFullReference), while SourceNodeId is the bare owning object (SourceObjectReference).
if (!_alarmNodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.Args.ConditionId), out var nodeIds))
{ {
_log.Debug("DriverHost {Node}: no alarm condition for ({Driver},{Ref}) — transition dropped", _log.Debug("DriverHost {Node}: no alarm condition for ({Driver},{Ref}) — transition dropped",
_localNode, msg.DriverInstanceId, msg.Args.SourceNodeId); _localNode, msg.DriverInstanceId, msg.Args.ConditionId);
return; return;
} }
foreach (var nodeId in nodeIds) foreach (var nodeId in nodeIds)
@@ -862,7 +866,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// reflected. Each NodeId maps to exactly one driver ref (a variable is backed by a single driver // reflected. Each NodeId maps to exactly one driver ref (a variable is backed by a single driver
// attribute), so last-writer-wins on the rare duplicate is harmless. // attribute), so last-writer-wins on the rare duplicate is harmless.
_driverRefByNodeId.Clear(); _driverRefByNodeId.Clear();
// Alarm condition routing map: (DriverInstanceId, FullName = alarm SourceNodeId) → folder-scoped // Alarm condition routing map: (DriverInstanceId, FullName = alarm ConditionId/AlarmFullReference) → folder-scoped
// condition NodeId(s). Built from the SAME EquipmentTags pass (alarm-bearing tags only) so // condition NodeId(s). Built from the SAME EquipmentTags pass (alarm-bearing tags only) so
// ForwardNativeAlarm can land a native transition on the right condition node. Clear-and-rebuild // ForwardNativeAlarm can land a native transition on the right condition node. Clear-and-rebuild
// every apply; the projector is Clear()'d too so stale per-condition state never leaks across // every apply; the projector is Clear()'d too so stale per-condition state never leaks across
@@ -49,9 +49,11 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc); private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
/// <summary>A native alarm RAISE published by SourceNodeId (the alarm tag's FullName) lands on the /// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's <c>FullName</c> lands on
/// condition's folder-scoped NodeId (here <c>eq-1/temp_hi</c>) as an /// the condition's folder-scoped NodeId (here <c>eq-1/temp_hi</c>) as an
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>.</summary> /// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
/// production-shaped <c>SourceNodeId</c> (the bare owning object, distinct from <c>ConditionId</c>) so the
/// lookup is proven to key on <c>ConditionId</c>, not <c>SourceNodeId</c>.</summary>
[Fact] [Fact]
public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active() public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active()
{ {
@@ -64,8 +66,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), new StubAlarmHandle(),
SourceNodeId: "Temp.HiHi", SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
ConditionId: "cond-1", ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
AlarmType: "OffNormalAlarm", AlarmType: "OffNormalAlarm",
Message: "temperature high", Message: "temperature high",
Severity: AlarmSeverity.High, Severity: AlarmSeverity.High,
@@ -79,8 +81,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
update.TimestampUtc.ShouldBe(Ts); update.TimestampUtc.ShouldBe(Ts);
} }
/// <summary>An <see cref="DriverInstanceActor.AttributeAlarmPublished"/> for a SourceNodeId not in the /// <summary>An <see cref="DriverInstanceActor.AttributeAlarmPublished"/> whose <c>ConditionId</c> is not in
/// alarm map produces NO <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (unknown-ref drop).</summary> /// the alarm map produces NO <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (unknown-ref drop).</summary>
[Fact] [Fact]
public void Unknown_alarm_ref_produces_no_AlarmStateUpdate() public void Unknown_alarm_ref_produces_no_AlarmStateUpdate()
{ {
@@ -92,8 +94,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), new StubAlarmHandle(),
SourceNodeId: "NoSuch.Alarm", SourceNodeId: "Temp", // owning object exists, but the condition ref below is unmapped
ConditionId: "cond-x", ConditionId: "NoSuch.HiHi", // dotted ref not in the alarm map ⇒ drop
AlarmType: "OffNormalAlarm", AlarmType: "OffNormalAlarm",
Message: "nope", Message: "nope",
Severity: AlarmSeverity.Low, Severity: AlarmSeverity.Low,
@@ -122,8 +124,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), new StubAlarmHandle(),
SourceNodeId: "Temp.HiHi", SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
ConditionId: "cond-1", ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
AlarmType: "OffNormalAlarm", AlarmType: "OffNormalAlarm",
Message: "temperature high", Message: "temperature high",
Severity: AlarmSeverity.High, Severity: AlarmSeverity.High,
@@ -169,8 +171,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), new StubAlarmHandle(),
SourceNodeId: "Temp.HiHi", SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
ConditionId: "cond-1", ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
AlarmType: "OffNormalAlarm", AlarmType: "OffNormalAlarm",
Message: "temperature high", Message: "temperature high",
Severity: AlarmSeverity.High, Severity: AlarmSeverity.High,