diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
index a52f8fe0..7184bc58 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
@@ -43,8 +43,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
/// The human-readable condition name.
/// The domain alarm type.
/// The domain severity.
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
- => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity);
+ /// True for a driver-fed (native) equipment-tag alarm; false (default) for a scripted alarm.
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
+ => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
/// Ensures a folder exists in the address space through the inner sink.
/// The node ID of the folder.
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
index 2b45fd8a..62469be8 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
@@ -38,7 +38,10 @@ public interface IOpcUaAddressSpaceSink
/// Human-readable condition name (BrowseName / DisplayName / Message).
/// Domain alarm type — mapped to the SDK condition subtype by the sink.
/// Domain severity (OPC UA 1..1000 scale); mapped to the SDK severity buckets.
- void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity);
+ /// When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
+ /// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later
+ /// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.
+ void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false);
///
/// Ensure a folder node exists under the given parent. Used by Phase7Applier to
@@ -95,7 +98,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
///
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
///
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 457b2331..3327bf40 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -36,6 +36,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private readonly ConcurrentDictionary _variables = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _folders = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _alarmConditions = new(StringComparer.Ordinal);
+ /// H6a: the subset of node ids materialised as NATIVE
+ /// (driver-fed, e.g. Galaxy equipment-tag alarms) rather than scripted. A later task routes a native
+ /// condition's inbound Acknowledge to the driver instead of the scripted engine, so the node manager
+ /// must know which conditions are native. Maintained in lock-step with :
+ /// a native re-materialise adds, and clears it alongside
+ /// so a re-materialise as the other kind is correct.
+ private readonly HashSet _nativeAlarmNodeIds = new(StringComparer.Ordinal);
/// Phase C: NodeId → resolved historian tagname for every variable materialised
/// Historizing. Populated by when a historian tagname is supplied; the
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
@@ -535,7 +542,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// . LimitAlarm deliberately falls back to base per the T13
/// notes — a script alarm carries no High/Low limits to populate.
///
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -550,6 +557,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(existing.NodeId);
}
+ // H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
+ // kind, so always drop the stale native flag first and only re-add it below when isNative.
+ _nativeAlarmNodeIds.Remove(alarmNodeId);
+
var parent = ResolveParentFolder(equipmentNodeId);
AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
@@ -632,9 +643,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
AddPredefinedNode(SystemContext, alarm);
_alarmConditions[alarmNodeId] = alarm;
+ // H6a: record native (driver-fed) conditions so a later task can route their inbound
+ // Acknowledge to the driver rather than the scripted engine.
+ if (isNative) _nativeAlarmNodeIds.Add(alarmNodeId);
}
}
+ /// H6a — true if the condition materialised at is a NATIVE
+ /// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
+ /// inbound Acknowledge to the driver instead of the scripted engine.
+ /// The alarm condition node id.
+ internal bool IsNativeAlarmNode(string alarmNodeId)
+ {
+ // _nativeAlarmNodeIds is a plain HashSet mutated only under Lock (in MaterialiseAlarmCondition /
+ // RebuildAddressSpace), so guard the read with the same Lock rather than risk a torn concurrent read.
+ lock (Lock) return _nativeAlarmNodeIds.Contains(alarmNodeId);
+ }
+
///
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
/// principal off the SDK , applies the AlarmAck role gate
@@ -1289,6 +1314,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(alarm.NodeId);
}
_alarmConditions.Clear();
+ // H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
+ // re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
+ _nativeAlarmNodeIds.Clear();
foreach (var f in _folders.Values)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
index 72b9d1c4..8dba58f4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
@@ -201,7 +201,7 @@ public sealed class Phase7Applier
{
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
- SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity);
+ SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true);
}
else
{
@@ -292,7 +292,7 @@ public sealed class Phase7Applier
foreach (var alarm in composition.EquipmentScriptedAlarms)
{
if (!alarm.Enabled) continue;
- SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity);
+ SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false);
materialised++;
}
@@ -333,9 +333,9 @@ public sealed class Phase7Applier
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmCondition threw for {Node}", nodeId); }
}
- private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
+ private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative)
{
- try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity); }
+ try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); }
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
index 474155cf..6357f31c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
@@ -41,8 +41,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
/// The human-readable condition name.
/// The domain alarm type.
/// The domain severity.
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
- => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity);
+ /// True for a driver-fed (native) equipment-tag alarm; false (default) for a scripted alarm.
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
+ => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
/// Ensures a folder node exists in the address space.
/// The folder node identifier.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
index 1594c2c1..b629a79f 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
@@ -323,6 +323,37 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
+ /// H6a — a condition materialised with isNative:true is tracked so later inbound-ack
+ /// routing can dispatch its Acknowledge to the driver rather than the scripted engine.
+ [Fact]
+ public async Task Native_materialise_is_tracked_as_native()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
+ nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
+
+ nm.IsNativeAlarmNode("a1").ShouldBeTrue();
+
+ await host.DisposeAsync();
+ }
+
+ /// H6a — a scripted condition (the default, isNative:false) is NOT tracked as native.
+ [Fact]
+ public async Task Scripted_materialise_is_not_native()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
+ nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false);
+
+ nm.IsNativeAlarmNode("a2").ShouldBeFalse();
+
+ await host.DisposeAsync();
+ }
+
/// Builds a (an )
/// carrying a with the given name + roles — the exact seam the
/// gate reads via (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
index cd6793ab..644ac4c4 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
@@ -109,7 +109,7 @@ public sealed class DeferredAddressSpaceSinkTests
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
///
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
=> CallQueue.Enqueue($"MA:{alarmNodeId}");
///
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
index 15a12123..1d8affdb 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
@@ -252,7 +252,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
/// The condition display name.
/// The domain alarm type.
/// The domain severity.
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
/// Records a folder creation request.
/// The node ID of the folder.
/// The node ID of the parent folder, or null for root.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
index 6bdc71b3..074b99d3 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -750,7 +750,7 @@ public sealed class Phase7ApplierTests
/// The condition display name.
/// The domain alarm type.
/// The domain severity.
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity));
/// Records a folder creation call.
/// The folder node ID.
@@ -802,7 +802,7 @@ public sealed class Phase7ApplierTests
/// The condition display name.
/// The domain alarm type.
/// The domain severity.
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
/// No-op folder creation call.
/// The folder node ID.
/// The parent folder node ID, if any.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
index 74ca4a5a..44432435 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
@@ -206,7 +206,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// The condition display name.
/// The domain alarm type.
/// The domain severity.
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
/// Ensures folder exists (stub implementation).
/// The folder node identifier.
/// The parent folder node identifier.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
index 6329d183..eeb45342 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
@@ -274,7 +274,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// The condition display name.
/// The domain alarm type.
/// The domain severity.
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
=> Calls.Enqueue($"MA:{alarmNodeId}");
/// Records a folder ensure call.
/// The folder node ID.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
index 7d829ec3..e0368ba3 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
@@ -577,7 +577,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// The condition display name.
/// The domain alarm type.
/// The domain severity.
- public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
/// Ensures a folder exists (no-op in test).
/// The OPC UA folder node identifier.