feat(scripted-alarms): materialise real Part 9 AlarmConditionState nodes (T14)
This commit is contained in:
@@ -581,11 +581,20 @@ ServiceResult OnAck(ISystemContext ctx, ConditionState c, byte[] eventId, Locali
|
||||
confirm `Server.Telemetry` (`ITelemetryContext`) is non-null in our host
|
||||
before relying on the telemetry ctor — fall back to `new AlarmConditionState(parent)`
|
||||
if not. (Not load-bearing; pick whichever the host supports.)
|
||||
2. **Optional children before `Create`.** Whether `ShelvingState` /
|
||||
`ConfirmedState` are auto-created by `Create` or must be instantiated first
|
||||
(the sample instantiates them) — **[SAMPLE-ONLY]** behaviour; verify by
|
||||
inspecting the live node after `Create` (browse the children). If Confirm /
|
||||
Shelve children are missing, materialise them like the sample before `Create`.
|
||||
2. **Optional children before `Create`.** ~~Whether `ShelvingState` /
|
||||
`ConfirmedState` are auto-created by `Create` or must be instantiated first.~~
|
||||
**RESOLVED in T14 (real-server integration test, 1.5.378.106):** `Create`
|
||||
auto-builds the **full** optional Part 9 child set from the embedded type
|
||||
definition with **no** pre-setting — for `OffNormalAlarmState`, both
|
||||
`ConfirmedState` AND `ShelvingState` come back **non-null** after `Create`
|
||||
(richer than the `[SAMPLE-ONLY]` caveat predicted). So T15/T16 can call
|
||||
`SetConfirmedState` / `SetShelvingState` directly; no manual child
|
||||
materialisation is needed. **Gotcha also found:** `BranchId.Value` is left a
|
||||
**null reference** by `Create`, and the very first `Set*` call
|
||||
(`SetEnableState` → `UpdateRetainState` → `GetRetainState` → `IsBranch()`)
|
||||
**NREs** on it. Fix: set `alarm.BranchId.Value = NodeId.Null` (the main
|
||||
branch) **before** any `Set*` call. T14's `MaterialiseAlarmCondition` does
|
||||
this. (Covered by `SdkAddressSpaceSinkTests.MaterialiseAlarmCondition_*`.)
|
||||
3. **`InstanceStateSnapshot` vs reporting the node directly.** The sample uses
|
||||
an `InstanceStateSnapshot` as the `IFilterTarget`. Confirm whether reporting
|
||||
the alarm node itself (which is also an `IFilterTarget`) is acceptable — the
|
||||
|
||||
@@ -38,6 +38,15 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Materialises a real Part 9 alarm-condition node through the inner sink.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID the condition parents under.</param>
|
||||
/// <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);
|
||||
|
||||
/// <summary>Ensures a folder exists in the address space through the inner sink.</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>
|
||||
|
||||
@@ -15,13 +15,29 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
|
||||
/// <param name="alarmNodeId">The OPC UA node ID of the alarm.</param>
|
||||
/// <summary>Write an alarm-condition's active/acknowledged state. When a real Part 9 condition
|
||||
/// node has been materialised for <paramref name="alarmNodeId"/> via
|
||||
/// <see cref="MaterialiseAlarmCondition"/>, this projects onto its ActiveState/AckedState/Retain;
|
||||
/// otherwise it falls back to the legacy two-element placeholder variable.</summary>
|
||||
/// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
||||
/// can browse it as a proper condition (with basic Active/Ack state). The node id equals the
|
||||
/// alarm node id (the ScriptedAlarmId) so subsequent <see cref="WriteAlarmState"/> calls update
|
||||
/// it. Used by <c>Phase7Applier.MaterialiseScriptedAlarms</c>. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId); becomes the condition's NodeId.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID the condition parents under.</param>
|
||||
/// <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);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a folder node exists under the given parent. Used by <c>Phase7Applier</c> to
|
||||
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
|
||||
@@ -70,6 +86,9 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,36 @@ public sealed class Phase7Applier
|
||||
composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T14 — materialise real OPC UA Part 9 <c>AlarmConditionState</c> nodes from a composition
|
||||
/// snapshot. For each <b>enabled</b> <see cref="EquipmentScriptedAlarmPlan"/>, register a
|
||||
/// condition node (keyed by its <see cref="EquipmentScriptedAlarmPlan.ScriptedAlarmId"/>, which
|
||||
/// is the same id <c>OpcUaPublishActor.AlarmStateUpdate</c> targets) under its equipment folder.
|
||||
/// Disabled alarms are skipped — they expose no node. Must run AFTER
|
||||
/// <see cref="MaterialiseHierarchy"/> so the equipment folders exist. Idempotent (the sink's
|
||||
/// <c>MaterialiseAlarmCondition</c> re-creates cleanly on re-apply).
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition result containing the scripted alarms to materialise.</param>
|
||||
public void MaterialiseScriptedAlarms(Phase7CompositionResult composition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.EquipmentScriptedAlarms.Count == 0) return;
|
||||
|
||||
var materialised = 0;
|
||||
foreach (var alarm in composition.EquipmentScriptedAlarms)
|
||||
{
|
||||
if (!alarm.Enabled) continue;
|
||||
SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity);
|
||||
materialised++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Phase7Applier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment})",
|
||||
materialised,
|
||||
composition.EquipmentScriptedAlarms.Where(a => a.Enabled)
|
||||
.Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
/// <summary>Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
|
||||
/// folder so two equipments' identically-named sub-folders never collide.</summary>
|
||||
private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) =>
|
||||
@@ -307,6 +337,12 @@ public sealed class Phase7Applier
|
||||
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmState threw for {Node}", nodeId); }
|
||||
}
|
||||
|
||||
private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
{
|
||||
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.</summary>
|
||||
|
||||
@@ -36,6 +36,15 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Materialises a real Part 9 alarm-condition node in the address space.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node identifier the condition parents under.</param>
|
||||
/// <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);
|
||||
|
||||
/// <summary>Ensures a folder node exists in the address space.</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
/// <param name="parentNodeId">The parent folder node identifier.</param>
|
||||
|
||||
@@ -229,6 +229,11 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
|
||||
// skips folders that already exist with the same node id.
|
||||
_applier.MaterialiseHierarchy(composition);
|
||||
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
|
||||
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
|
||||
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
|
||||
// alarms are skipped.
|
||||
_applier.MaterialiseScriptedAlarms(composition);
|
||||
// Galaxy / SystemPlatform tags get their own pass: ensures their FolderPath folder
|
||||
// + Variable node exist so clients can browse them. The Galaxy driver fills values
|
||||
// on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData.
|
||||
|
||||
@@ -81,6 +81,9 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
=> CallQueue.Enqueue($"MA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -252,6 +252,13 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
||||
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <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) { }
|
||||
/// <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>
|
||||
|
||||
@@ -311,6 +311,38 @@ public sealed class Phase7ApplierTests
|
||||
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64"));
|
||||
}
|
||||
|
||||
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
|
||||
/// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
|
||||
/// disabled alarms.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var composition = new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentScriptedAlarms = new[]
|
||||
{
|
||||
new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm",
|
||||
Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;",
|
||||
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true),
|
||||
new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition",
|
||||
Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;",
|
||||
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: false),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseScriptedAlarms(composition);
|
||||
|
||||
// Only the enabled alarm is materialised; the disabled one is skipped entirely.
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
|
||||
/// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment
|
||||
/// tags, so a tags-only deploy is no longer a silent no-op).</summary>
|
||||
@@ -419,6 +451,8 @@ public sealed class Phase7ApplierTests
|
||||
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
|
||||
/// <summary>Gets the queue of variable creation calls.</summary>
|
||||
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
|
||||
/// <summary>Gets the queue of alarm-condition materialise calls.</summary>
|
||||
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
|
||||
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
|
||||
public int RebuildCalls;
|
||||
|
||||
@@ -428,6 +462,8 @@ public sealed class Phase7ApplierTests
|
||||
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
|
||||
/// <summary>Gets the list of recorded variable creation calls.</summary>
|
||||
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
|
||||
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
|
||||
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();
|
||||
|
||||
/// <summary>Records a value write (no-op in this recording sink).</summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
@@ -442,6 +478,14 @@ public sealed class Phase7ApplierTests
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
|
||||
/// <summary>Records an alarm-condition materialise call.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <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)
|
||||
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity));
|
||||
/// <summary>Records a folder creation call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
||||
@@ -482,6 +526,13 @@ public sealed class Phase7ApplierTests
|
||||
{
|
||||
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
||||
}
|
||||
/// <summary>No-op alarm-condition materialise call.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <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) { }
|
||||
/// <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,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
@@ -85,6 +86,94 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>T14 — materialises an equipment folder + a real Part 9 AlarmConditionState under it,
|
||||
/// then projects active state through WriteAlarmState. Asserts the node is a real
|
||||
/// <see cref="AlarmConditionState"/>, reachable under the equipment folder, and that
|
||||
/// ActiveState/Retain reflect the write. Also inspects which optional Part 9 children
|
||||
/// <c>Create</c> auto-builds (the T13 uncertainty) and records the finding inline.</summary>
|
||||
[Fact]
|
||||
public async Task MaterialiseAlarmCondition_creates_real_condition_node_and_WriteAlarmState_updates_it()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
// Equipment folder must exist first (MaterialiseHierarchy owns this in production).
|
||||
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
|
||||
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmState targets it.
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
|
||||
nm.AlarmConditionCount.ShouldBe(1);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-1");
|
||||
condition.ShouldNotBeNull();
|
||||
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
|
||||
condition.ShouldBeOfType<OffNormalAlarmState>();
|
||||
condition.NodeId.ShouldBe(new NodeId("alm-1", nm.NamespaceIndex));
|
||||
|
||||
// Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child).
|
||||
condition.Parent.ShouldNotBeNull();
|
||||
condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", nm.NamespaceIndex));
|
||||
|
||||
// Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false.
|
||||
condition.EnabledState.Id.Value.ShouldBeTrue();
|
||||
condition.ActiveState.Id.Value.ShouldBeFalse();
|
||||
condition.Retain.Value.ShouldBeFalse();
|
||||
|
||||
// --- T13 optional-children finding (RESOLVED by THIS real-server test) ---
|
||||
// AckedState is mandatory on AcknowledgeableConditionState so it is always present.
|
||||
condition.AckedState.ShouldNotBeNull();
|
||||
// FINDING (1.5.378.106): Create auto-builds the FULL optional Part 9 child set from the
|
||||
// embedded type definition WITHOUT us pre-setting any property — both ConfirmedState (Confirm
|
||||
// sub-state machine) AND ShelvingState (Shelve state machine) come back non-null. This is
|
||||
// RICHER than the SDK-notes' [SAMPLE-ONLY] caveat predicted (it suggested we'd have to
|
||||
// instantiate optional children ourselves). Net: T15/T16 can drive SetConfirmedState /
|
||||
// SetShelvingState directly — no manual child materialisation needed. Asserting both non-null
|
||||
// so a future SDK bump that changes auto-build behaviour fails loudly.
|
||||
condition.ConfirmedState.ShouldNotBeNull();
|
||||
condition.ShelvingState.ShouldNotBeNull();
|
||||
|
||||
// WriteAlarmState now targets the real condition (not the bool[2] placeholder): no extra
|
||||
// BaseDataVariable is minted for the alarm id.
|
||||
sink.WriteAlarmState("alm-1", active: true, acknowledged: false, DateTime.UtcNow);
|
||||
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
|
||||
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||
|
||||
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
nm.AlarmConditionCount.ShouldBe(1);
|
||||
|
||||
// RebuildAddressSpace clears the alarm dict too.
|
||||
sink.RebuildAddressSpace();
|
||||
nm.AlarmConditionCount.ShouldBe(0);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>An unknown / limit-style AlarmType (with no script-supplied OPC limits) falls back to
|
||||
/// the base <see cref="AlarmConditionState"/> per the T13 notes.</summary>
|
||||
[Fact]
|
||||
public async Task MaterialiseAlarmCondition_unknown_type_falls_back_to_base_condition()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9");
|
||||
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-x");
|
||||
condition.ShouldNotBeNull();
|
||||
// Base type exactly — NOT a LimitAlarmState (no limits to populate for a script alarm).
|
||||
condition.GetType().ShouldBe(typeof(AlarmConditionState));
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
|
||||
+7
@@ -201,6 +201,13 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
|
||||
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
|
||||
/// <summary>Materialises an alarm condition (stub implementation).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node identifier.</param>
|
||||
/// <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) { }
|
||||
/// <summary>Ensures folder exists (stub implementation).</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
/// <param name="parentNodeId">The parent folder node identifier.</param>
|
||||
|
||||
@@ -253,6 +253,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <param name="ts">The timestamp of the state change.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
|
||||
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <summary>Records a materialise-alarm-condition call.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <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)
|
||||
=> Calls.Enqueue($"MA:{alarmNodeId}");
|
||||
/// <summary>Records a folder ensure call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
/// <param name="parentNodeId">The parent node ID, or null if this is a root folder.</param>
|
||||
|
||||
@@ -176,6 +176,14 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
|
||||
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
|
||||
|
||||
/// <summary>Materialises an alarm condition (no-op in test).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <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) { }
|
||||
|
||||
/// <summary>Ensures a folder exists (no-op in test).</summary>
|
||||
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
|
||||
/// <param name="parentNodeId">The parent folder node identifier, or null for root.</param>
|
||||
|
||||
Reference in New Issue
Block a user