feat(alarms): thread isNative through MaterialiseAlarmCondition; node manager tracks native conditions [H6a]

This commit is contained in:
Joseph Doherty
2026-06-15 14:13:30 -04:00
parent ed941c51da
commit 418663b359
12 changed files with 82 additions and 18 deletions
@@ -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>