diff --git a/docs/v2/f14b-part9-sdk-notes.md b/docs/v2/f14b-part9-sdk-notes.md
index 1f843b35..6afd7872 100644
--- a/docs/v2/f14b-part9-sdk-notes.md
+++ b/docs/v2/f14b-part9-sdk-notes.md
@@ -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
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 3bb7e934..d652d407 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
@@ -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);
+ /// Materialises a real Part 9 alarm-condition node through the inner sink.
+ /// The alarm node ID (== ScriptedAlarmId).
+ /// The equipment folder node ID the condition parents under.
+ /// 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);
+
/// Ensures a folder exists in the address space through the inner sink.
/// The node ID of the folder.
/// The node ID of the parent folder, or null for root.
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 eb7b7c3a..7708b0be 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
@@ -15,13 +15,29 @@ public interface IOpcUaAddressSpaceSink
/// The source timestamp in UTC.
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
- /// Write an alarm-condition Variable's active/acknowledged state.
- /// The OPC UA node ID of the alarm.
+ /// Write an alarm-condition's active/acknowledged state. When a real Part 9 condition
+ /// node has been materialised for via
+ /// , this projects onto its ActiveState/AckedState/Retain;
+ /// otherwise it falls back to the legacy two-element placeholder variable.
+ /// The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).
/// Whether the alarm is active.
/// Whether the alarm has been acknowledged.
/// The source timestamp in UTC.
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
+ ///
+ /// 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 calls update
+ /// it. Used by Phase7Applier.MaterialiseScriptedAlarms. Idempotent.
+ ///
+ /// The alarm node ID (== ScriptedAlarmId); becomes the condition's NodeId.
+ /// The equipment folder node ID the condition parents under.
+ /// 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);
+
///
/// Ensure a folder node exists under the given parent. Used by Phase7Applier to
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
@@ -70,6 +86,9 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
///
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
+ ///
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
+
///
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 adbc7873..56a47fa2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -28,6 +28,10 @@ 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);
+ /// Folders we have already promoted to event-notifiers + registered as root notifiers,
+ /// so repeated calls don't double-add (idempotent guard).
+ private readonly HashSet _notifierFolders = new();
private FolderState? _root;
/// Initializes a new instance of the class with the OPC UA server and configuration.
@@ -43,6 +47,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public int VariableCount => _variables.Count;
/// Gets the count of folder nodes currently managed.
public int FolderCount => _folders.Count;
+ /// Gets the count of real Part 9 nodes currently managed.
+ public int AlarmConditionCount => _alarmConditions.Count;
+
+ /// 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.
+ /// The alarm node identifier (== ScriptedAlarmId).
+ /// The cached , or null when none is registered.
+ public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId) =>
+ _alarmConditions.TryGetValue(alarmNodeId, out var condition) ? condition : null;
///
/// Apply a value write from . Creates the
@@ -67,18 +80,44 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
- /// Apply an alarm-state write. Surfaced as a two-element Variable carrying
- /// [active, acknowledged] — proper AlarmConditionState + event firing
- /// comes when the F14b walker integration lands and registers real condition nodes.
- /// The node identifier of the alarm variable.
+ ///
+ /// Apply an alarm-state write. When a real Part 9 has been
+ /// materialised for (via ),
+ /// this projects / onto the live
+ /// condition node's ActiveState/AckedState/Retain (T14 — basic active/ack only; no event
+ /// firing yet, that lands in T16). Otherwise it falls back to the legacy two-element
+ /// [active, acknowledged] placeholder so callers
+ /// whose alarm node hasn't been materialised (and the existing unit tests) keep working.
+ ///
+ /// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).
/// Whether the alarm is currently active.
/// Whether the alarm has been acknowledged.
/// The timestamp of the alarm state change in UTC.
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
}
}
+ ///
+ /// Materialise a real OPC UA Part 9 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
+ /// calls — which target that same id — update this node.
+ ///
+ /// This is the T14 production replacement for the bool[2] placeholder: it creates
+ /// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
+ /// no events itself.
+ ///
+ /// Idempotent: a second call with the same tears down the prior
+ /// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
+ ///
+ /// The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.
+ /// The equipment folder node id the condition parents under (null/unknown ⇒ root).
+ /// Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).
+ /// Domain alarm type — maps to the SDK condition subtype (see remarks).
+ /// Domain severity (treated as an OPC UA 1..1000 severity); mapped to .
+ ///
+ /// AlarmType → SDK subtype mapping. Script-driven alarms have no OPC limit /
+ /// setpoint values, so any limit-style subtype would have unset limit children. We therefore
+ /// map: OffNormalAlarm → , DiscreteAlarm →
+ /// , and everything else (including AlarmCondition and
+ /// LimitAlarm, which has no script-supplied limits) → the base
+ /// . 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)
+ {
+ 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;
+ }
+ }
+
+ /// Map our domain AlarmType 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 remarks).
+ 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),
+ };
+
+ /// Promote to and
+ /// register it as a root notifier (idempotent — guarded by ) so the
+ /// alarm condition has a notifier path to the Server object for T16's event propagation.
+ private void EnsureFolderIsEventNotifier(FolderState folder)
+ {
+ if (!_notifierFolders.Add(folder.NodeId)) return;
+ folder.EventNotifier = EventNotifiers.SubscribeToEvents;
+ AddRootNotifier(folder);
+ folder.ClearChangeMasks(SystemContext, includeChildren: false);
+ }
+
+ /// Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the
+ /// enum buckets the SDK's SetSeverity expects.
+ 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,
+ };
+
///
/// Ensure a folder node exists at with the given display
/// name, parented under (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();
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
index fa36c740..c0f7d032 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
@@ -285,6 +285,36 @@ public sealed class Phase7Applier
composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
+ ///
+ /// T14 — materialise real OPC UA Part 9 AlarmConditionState nodes from a composition
+ /// snapshot. For each enabled , register a
+ /// condition node (keyed by its , which
+ /// is the same id OpcUaPublishActor.AlarmStateUpdate targets) under its equipment folder.
+ /// Disabled alarms are skipped — they expose no node. Must run AFTER
+ /// so the equipment folders exist. Idempotent (the sink's
+ /// MaterialiseAlarmCondition re-creates cleanly on re-apply).
+ ///
+ /// The composition result containing the scripted alarms to materialise.
+ 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());
+ }
+
/// Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
/// folder so two equipments' identically-named sub-folders never collide.
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 of one apply pass. Useful for tests + audit-log entries on the deploy path.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
index 69fb2135..31367660 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
@@ -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);
+ /// Materialises a real Part 9 alarm-condition node in the address space.
+ /// The alarm node identifier (== ScriptedAlarmId).
+ /// The equipment folder node identifier the condition parents under.
+ /// 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);
+
/// Ensures a folder node exists in the address space.
/// The folder node identifier.
/// The parent folder node identifier.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
index 81464a11..983f7977 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
@@ -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.
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 1961e3c8..2011d5c8 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
@@ -81,6 +81,9 @@ public sealed class DeferredAddressSpaceSinkTests
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
///
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
+ => CallQueue.Enqueue($"MA:{alarmNodeId}");
+ ///
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
///
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 ae8610fe..baa2717a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
@@ -252,6 +252,13 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
/// Whether the alarm has been acknowledged.
/// The source timestamp in UTC.
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
+ /// Materialises an alarm condition (stub implementation for testing).
+ /// The alarm node ID (== ScriptedAlarmId).
+ /// The equipment folder node ID.
+ /// The condition display name.
+ /// The domain alarm type.
+ /// The domain severity.
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
/// 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 7e09f541..8af8ad9c 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -311,6 +311,38 @@ public sealed class Phase7ApplierTests
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64"));
}
+ /// T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
+ /// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
+ /// disabled alarms.
+ [Fact]
+ public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var composition = new Phase7CompositionResult(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ 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(), 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(), 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));
+ }
+
/// 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).
@@ -419,6 +451,8 @@ public sealed class Phase7ApplierTests
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// Gets the queue of variable creation calls.
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
+ /// Gets the queue of alarm-condition materialise calls.
+ public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
/// Gets the number of rebuild calls made on this sink.
public int RebuildCalls;
@@ -428,6 +462,8 @@ public sealed class Phase7ApplierTests
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// Gets the list of recorded variable creation calls.
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
+ /// Gets the list of recorded alarm-condition materialise calls.
+ public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();
/// Records a value write (no-op in this recording sink).
/// The node ID.
@@ -442,6 +478,14 @@ public sealed class Phase7ApplierTests
/// The source timestamp in UTC.
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
+ /// Records an alarm-condition materialise call.
+ /// The alarm node ID (== ScriptedAlarmId).
+ /// The equipment folder node ID.
+ /// The condition display name.
+ /// The domain alarm type.
+ /// The domain severity.
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
+ => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity));
/// Records a folder creation call.
/// The folder node ID.
/// The parent folder node ID, if any.
@@ -482,6 +526,13 @@ public sealed class Phase7ApplierTests
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
+ /// No-op alarm-condition materialise call.
+ /// The alarm node ID.
+ /// The equipment folder node ID.
+ /// The condition display name.
+ /// The domain alarm type.
+ /// The domain severity.
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
/// 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.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
index 18c37d0b..1f45463d 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
@@ -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;
}
+ /// T14 — materialises an equipment folder + a real Part 9 AlarmConditionState under it,
+ /// then projects active state through WriteAlarmState. Asserts the node is a real
+ /// , reachable under the equipment folder, and that
+ /// ActiveState/Retain reflect the write. Also inspects which optional Part 9 children
+ /// Create auto-builds (the T13 uncertainty) and records the finding inline.
+ [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();
+ 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();
+ }
+
+ /// An unknown / limit-style AlarmType (with no script-supplied OPC limits) falls back to
+ /// the base per the T13 notes.
+ [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(
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 62e08ee6..3dee5c97 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
@@ -201,6 +201,13 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// Whether the alarm is acknowledged.
/// The time the alarm occurred in UTC.
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
+ /// Materialises an alarm condition (stub implementation).
+ /// The alarm node identifier.
+ /// The equipment folder node identifier.
+ /// The condition display name.
+ /// The domain alarm type.
+ /// The domain severity.
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
/// 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 1a9760d7..3de0a8b3 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
@@ -253,6 +253,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// The timestamp of the state change.
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
=> Calls.Enqueue($"WA:{alarmNodeId}");
+ /// Records a materialise-alarm-condition call.
+ /// The alarm node ID (== ScriptedAlarmId).
+ /// The equipment folder node ID.
+ /// The condition display name.
+ /// The domain alarm type.
+ /// The domain severity.
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
+ => Calls.Enqueue($"MA:{alarmNodeId}");
/// Records a folder ensure call.
/// The folder node ID.
/// The parent node ID, or null if this is a root folder.
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 f89033a2..a81387d8 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
@@ -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));
+ /// Materialises an alarm condition (no-op in test).
+ /// The alarm node ID.
+ /// The equipment folder node ID.
+ /// The condition display name.
+ /// The domain alarm type.
+ /// The domain severity.
+ public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
+
/// Ensures a folder exists (no-op in test).
/// The OPC UA folder node identifier.
/// The parent folder node identifier, or null for root.