|
|
|
@@ -28,6 +28,10 @@ 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>Folders we have already promoted to event-notifiers + registered as root notifiers,
|
|
|
|
|
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).</summary>
|
|
|
|
|
private readonly HashSet<NodeId> _notifierFolders = new();
|
|
|
|
|
private FolderState? _root;
|
|
|
|
|
|
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
|
|
|
|
@@ -43,6 +47,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
public int VariableCount => _variables.Count;
|
|
|
|
|
/// <summary>Gets the count of folder nodes currently managed.</summary>
|
|
|
|
|
public int FolderCount => _folders.Count;
|
|
|
|
|
/// <summary>Gets the count of real Part 9 <see cref="AlarmConditionState"/> nodes currently managed.</summary>
|
|
|
|
|
public int AlarmConditionCount => _alarmConditions.Count;
|
|
|
|
|
|
|
|
|
|
/// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
|
|
|
|
|
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
|
|
|
|
|
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
|
|
|
|
|
/// <returns>The cached <see cref="AlarmConditionState"/>, or null when none is registered.</returns>
|
|
|
|
|
public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId) =>
|
|
|
|
|
_alarmConditions.TryGetValue(alarmNodeId, out var condition) ? condition : null;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
|
|
|
@@ -67,18 +80,44 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Apply an alarm-state write. Surfaced as a two-element Variable carrying
|
|
|
|
|
/// <c>[active, acknowledged]</c> — proper <c>AlarmConditionState</c> + event firing
|
|
|
|
|
/// comes when the F14b walker integration lands and registers real condition nodes.</summary>
|
|
|
|
|
/// <param name="alarmNodeId">The node identifier of the alarm variable.</param>
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Apply an alarm-state write. When a real Part 9 <see cref="AlarmConditionState"/> has been
|
|
|
|
|
/// materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
|
|
|
|
|
/// this projects <paramref name="active"/>/<paramref name="acknowledged"/> onto the live
|
|
|
|
|
/// condition node's ActiveState/AckedState/Retain (T14 — basic active/ack only; <b>no event
|
|
|
|
|
/// firing yet</b>, that lands in T16). Otherwise it falls back to the legacy two-element
|
|
|
|
|
/// <c>[active, acknowledged]</c> <see cref="BaseDataVariableState"/> placeholder so callers
|
|
|
|
|
/// whose alarm node hasn't been materialised (and the existing unit tests) keep working.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
|
|
|
|
/// <param name="active">Whether the alarm is currently active.</param>
|
|
|
|
|
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
|
|
|
|
|
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
|
|
|
|
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
|
|
|
|
{
|
|
|
|
|
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
|
|
|
|
var variable = _variables.GetOrAdd(alarmNodeId, CreateVariable);
|
|
|
|
|
|
|
|
|
|
if (_alarmConditions.TryGetValue(alarmNodeId, out var condition))
|
|
|
|
|
{
|
|
|
|
|
lock (Lock)
|
|
|
|
|
{
|
|
|
|
|
condition.SetActiveState(SystemContext, active);
|
|
|
|
|
condition.SetAcknowledgedState(SystemContext, acknowledged);
|
|
|
|
|
// Part 9: retain the condition while it is active OR unacknowledged so a client's
|
|
|
|
|
// ConditionRefresh replays it. T16's event firing will also drive Retain; here we keep
|
|
|
|
|
// it correct for the basic projection.
|
|
|
|
|
condition.Retain.Value = active || !acknowledged;
|
|
|
|
|
condition.Time.Value = sourceTimestampUtc;
|
|
|
|
|
condition.ReceiveTime.Value = sourceTimestampUtc;
|
|
|
|
|
// NO ReportEvent here — T16 owns event firing. ClearChangeMasks just notifies any
|
|
|
|
|
// attribute (not event) subscribers watching ActiveState/AckedState/Retain directly.
|
|
|
|
|
condition.ClearChangeMasks(SystemContext, includeChildren: true);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable.
|
|
|
|
|
var variable = _variables.GetOrAdd(alarmNodeId, CreateVariable);
|
|
|
|
|
lock (Lock)
|
|
|
|
|
{
|
|
|
|
|
variable.Value = new[] { active, acknowledged };
|
|
|
|
@@ -88,6 +127,127 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> node under its equipment
|
|
|
|
|
/// folder so clients can browse it as a proper condition (and, once T16 lands, subscribe to its
|
|
|
|
|
/// events). The node id is the alarm node id (the ScriptedAlarmId) so subsequent
|
|
|
|
|
/// <see cref="WriteAlarmState"/> calls — which target that same id — update this node.
|
|
|
|
|
/// <para>
|
|
|
|
|
/// This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
|
|
|
|
|
/// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
|
|
|
|
|
/// <b>no</b> events itself.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// Idempotent: a second call with the same <paramref name="alarmNodeId"/> tears down the prior
|
|
|
|
|
/// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.</param>
|
|
|
|
|
/// <param name="equipmentNodeId">The equipment folder node id the condition parents under (null/unknown ⇒ root).</param>
|
|
|
|
|
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
|
|
|
|
|
/// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
|
|
|
|
|
/// <param name="severity">Domain severity (treated as an OPC UA 1..1000 severity); mapped to <see cref="EventSeverity"/>.</param>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
|
|
|
|
|
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
|
|
|
|
|
/// map: <c>OffNormalAlarm</c> → <see cref="OffNormalAlarmState"/>, <c>DiscreteAlarm</c> →
|
|
|
|
|
/// <see cref="DiscreteAlarmState"/>, and everything else (including <c>AlarmCondition</c> and
|
|
|
|
|
/// <c>LimitAlarm</c>, which has no script-supplied limits) → the base
|
|
|
|
|
/// <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)
|
|
|
|
|
{
|
|
|
|
|
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
|
|
|
|
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
|
|
|
|
|
|
|
|
|
lock (Lock)
|
|
|
|
|
{
|
|
|
|
|
// Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
|
|
|
|
|
// type/severity on redeploy) reflects cleanly instead of leaking the old node.
|
|
|
|
|
if (_alarmConditions.TryRemove(alarmNodeId, out var existing))
|
|
|
|
|
{
|
|
|
|
|
existing.Parent?.RemoveChild(existing);
|
|
|
|
|
PredefinedNodes?.Remove(existing.NodeId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var parent = ResolveParentFolder(equipmentNodeId);
|
|
|
|
|
|
|
|
|
|
AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
|
|
|
|
|
alarm.SymbolicName = displayName;
|
|
|
|
|
// HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
|
|
|
|
|
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
|
|
|
|
|
|
|
|
|
|
// Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
|
|
|
|
|
// ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
|
|
|
|
|
// type's embedded definition; we do not hand-build them.
|
|
|
|
|
alarm.Create(
|
|
|
|
|
SystemContext,
|
|
|
|
|
new NodeId(alarmNodeId, NamespaceIndex),
|
|
|
|
|
new QualifiedName(displayName, NamespaceIndex),
|
|
|
|
|
new LocalizedText(displayName),
|
|
|
|
|
assignNodeIds: true);
|
|
|
|
|
|
|
|
|
|
// Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
|
|
|
|
|
// UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
|
|
|
|
|
// Create leaves as a null reference and would NRE. NodeId.Null marks "the main branch".
|
|
|
|
|
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
|
|
|
|
|
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
|
|
|
|
|
|
|
|
|
|
// Initial state via the SDK setters (T14: basic state only, NO event firing).
|
|
|
|
|
alarm.SetEnableState(SystemContext, true);
|
|
|
|
|
alarm.SetActiveState(SystemContext, false);
|
|
|
|
|
alarm.SetAcknowledgedState(SystemContext, true);
|
|
|
|
|
alarm.SetSeverity(SystemContext, MapSeverity(severity));
|
|
|
|
|
alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
|
|
|
|
|
alarm.Message.Value = new LocalizedText(displayName);
|
|
|
|
|
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
|
|
|
|
|
|
|
|
|
|
parent.AddChild(alarm);
|
|
|
|
|
|
|
|
|
|
// Promote the equipment folder to an event notifier + register it as a root notifier so
|
|
|
|
|
// T16's ReportEvent has a notifier path up to the Server object. Guard so repeated
|
|
|
|
|
// materialise under the same folder doesn't double-add the root notifier.
|
|
|
|
|
EnsureFolderIsEventNotifier(parent);
|
|
|
|
|
|
|
|
|
|
AddPredefinedNode(SystemContext, alarm);
|
|
|
|
|
_alarmConditions[alarmNodeId] = alarm;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
|
|
|
|
|
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
|
|
|
|
|
/// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
|
|
|
|
|
private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) => alarmType switch
|
|
|
|
|
{
|
|
|
|
|
"OffNormalAlarm" => new OffNormalAlarmState(parent),
|
|
|
|
|
"DiscreteAlarm" => new DiscreteAlarmState(parent),
|
|
|
|
|
// "LimitAlarm" / "AlarmCondition" / unknown ⇒ base: a script-driven alarm has no OPC limits
|
|
|
|
|
// to populate, so the limit subtypes would carry unset High/Low children.
|
|
|
|
|
_ => new AlarmConditionState(parent),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
|
|
|
|
|
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
|
|
|
|
|
/// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
|
|
|
|
|
private void EnsureFolderIsEventNotifier(FolderState folder)
|
|
|
|
|
{
|
|
|
|
|
if (!_notifierFolders.Add(folder.NodeId)) return;
|
|
|
|
|
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
|
|
|
|
AddRootNotifier(folder);
|
|
|
|
|
folder.ClearChangeMasks(SystemContext, includeChildren: false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the
|
|
|
|
|
/// <see cref="EventSeverity"/> enum buckets the SDK's <c>SetSeverity</c> expects.</summary>
|
|
|
|
|
private static EventSeverity MapSeverity(int severity) => severity switch
|
|
|
|
|
{
|
|
|
|
|
<= 0 => EventSeverity.Low,
|
|
|
|
|
< 200 => EventSeverity.Low,
|
|
|
|
|
< 400 => EventSeverity.MediumLow,
|
|
|
|
|
< 600 => EventSeverity.Medium,
|
|
|
|
|
< 800 => EventSeverity.MediumHigh,
|
|
|
|
|
_ => EventSeverity.High,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
|
|
|
|
|
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
|
|
|
|
@@ -206,12 +366,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
}
|
|
|
|
|
_variables.Clear();
|
|
|
|
|
|
|
|
|
|
foreach (var alarm in _alarmConditions.Values)
|
|
|
|
|
{
|
|
|
|
|
alarm.Parent?.RemoveChild(alarm);
|
|
|
|
|
PredefinedNodes?.Remove(alarm.NodeId);
|
|
|
|
|
}
|
|
|
|
|
_alarmConditions.Clear();
|
|
|
|
|
|
|
|
|
|
foreach (var f in _folders.Values)
|
|
|
|
|
{
|
|
|
|
|
f.Parent?.RemoveChild(f);
|
|
|
|
|
PredefinedNodes?.Remove(f.NodeId);
|
|
|
|
|
}
|
|
|
|
|
_folders.Clear();
|
|
|
|
|
|
|
|
|
|
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
|
|
|
|
|
// equipment folders to event notifiers.
|
|
|
|
|
_notifierFolders.Clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|