feat(alarms): thread isNative through MaterialiseAlarmCondition; node manager tracks native conditions [H6a]
This commit is contained in:
@@ -43,8 +43,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
/// <param name="displayName">The human-readable condition name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity);
|
||||
/// <param name="isNative">True for a driver-fed (native) equipment-tag alarm; false (default) for a scripted alarm.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
||||
|
||||
/// <summary>Ensures a folder exists in the address space through the inner sink.</summary>
|
||||
/// <param name="folderNodeId">The node ID of the folder.</param>
|
||||
|
||||
@@ -38,7 +38,10 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message).</param>
|
||||
/// <param name="alarmType">Domain alarm type — mapped to the SDK condition subtype by the sink.</param>
|
||||
/// <param name="severity">Domain severity (OPC UA 1..1000 scale); mapped to the SDK severity buckets.</param>
|
||||
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity);
|
||||
/// <param name="isNative">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.</param>
|
||||
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a folder node exists under the given parent. Used by <c>Phase7Applier</c> to
|
||||
@@ -95,7 +98,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
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) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
|
||||
@@ -36,6 +36,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, AlarmConditionState> _alarmConditions = new(StringComparer.Ordinal);
|
||||
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> 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 <see cref="_alarmConditions"/>:
|
||||
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
|
||||
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
|
||||
private readonly HashSet<string> _nativeAlarmNodeIds = new(StringComparer.Ordinal);
|
||||
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
|
||||
/// Historizing. Populated by <see cref="EnsureVariable"/> 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
|
||||
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
|
||||
/// notes — a script alarm carries no High/Low limits to populate.</para>
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> 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.</summary>
|
||||
/// <param name="alarmNodeId">The alarm condition node id.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
|
||||
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> 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)
|
||||
{
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
/// <param name="displayName">The human-readable condition name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity);
|
||||
/// <param name="isNative">True for a driver-fed (native) equipment-tag alarm; false (default) for a scripted alarm.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
||||
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
||||
|
||||
/// <summary>Ensures a folder node exists in the address space.</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
|
||||
@@ -323,6 +323,37 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>H6a — a condition materialised with <c>isNative:true</c> is tracked so later inbound-ack
|
||||
/// routing can dispatch its Acknowledge to the driver rather than the scripted engine.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>H6a — a scripted condition (the default, <c>isNative:false</c>) is NOT tracked as native.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Builds a <see cref="ServerSystemContext"/> (an <see cref="ISessionOperationContext"/>)
|
||||
/// carrying a <see cref="RoleCarryingUserIdentity"/> with the given name + roles — the exact seam the
|
||||
/// gate reads via <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c>.</summary>
|
||||
|
||||
@@ -109,7 +109,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
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}");
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
|
||||
@@ -252,7 +252,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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) { }
|
||||
/// <summary>Records a folder creation request.</summary>
|
||||
/// <param name="folderNodeId">The node ID of the folder.</param>
|
||||
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
|
||||
|
||||
@@ -750,7 +750,7 @@ public sealed class Phase7ApplierTests
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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));
|
||||
/// <summary>Records a folder creation call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
@@ -802,7 +802,7 @@ public sealed class Phase7ApplierTests
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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) { }
|
||||
/// <summary>No-op folder creation call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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) { }
|
||||
/// <summary>Ensures folder exists (stub implementation).</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
/// <param name="parentNodeId">The parent folder node identifier.</param>
|
||||
|
||||
@@ -274,7 +274,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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}");
|
||||
/// <summary>Records a folder ensure call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
|
||||
@@ -577,7 +577,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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) { }
|
||||
|
||||
/// <summary>Ensures a folder exists (no-op in test).</summary>
|
||||
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
|
||||
|
||||
Reference in New Issue
Block a user