From 418663b359e29267a39570c55ba4f14bcea50936 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 14:13:30 -0400 Subject: [PATCH] feat(alarms): thread isNative through MaterialiseAlarmCondition; node manager tracks native conditions [H6a] --- .../OpcUa/DeferredAddressSpaceSink.cs | 5 +-- .../OpcUa/IOpcUaAddressSpaceSink.cs | 7 +++-- .../OtOpcUaNodeManager.cs | 30 +++++++++++++++++- .../Phase7Applier.cs | 8 ++--- .../SdkAddressSpaceSink.cs | 5 +-- .../AlarmCommandRouterTests.cs | 31 +++++++++++++++++++ .../DeferredAddressSpaceSinkTests.cs | 2 +- .../Phase7ApplierHierarchyTests.cs | 2 +- .../Phase7ApplierTests.cs | 4 +-- .../OtOpcUaTelemetryHookTests.cs | 2 +- .../OpcUa/OpcUaPublishActorRebuildTests.cs | 2 +- .../OpcUa/OpcUaPublishActorTests.cs | 2 +- 12 files changed, 82 insertions(+), 18 deletions(-) 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.