diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs
new file mode 100644
index 00000000..8eddf929
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs
@@ -0,0 +1,45 @@
+namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+///
+/// Commons-level projection of an alarm's full OPC UA Part 9 condition state, carried from the
+/// Runtime engine to the SDK sink. Commons cannot reference Core.ScriptedAlarms (its
+/// domain enums live there), so this DTO is deliberately primitive: every field is a
+/// , the Commons-local enum, a
+/// severity, or a . The Runtime host maps its Core
+/// AlarmConditionState + AlarmSeverity down to this shape; the SDK
+/// OtOpcUaNodeManager projects it back up onto a real AlarmConditionState node.
+///
+/// Whether the alarm condition is currently active (ActiveState).
+/// Whether the active transition has been acknowledged (AckedState).
+/// Whether the clear transition has been confirmed (ConfirmedState).
+/// Whether the alarm is enabled (EnabledState); a disabled alarm reports no events.
+/// The shelving mode (ShelvingState): unshelved, one-shot, or timed.
+/// OPC UA severity on the 1..1000 scale (the SDK SetSeverity input).
+/// The human-readable condition message (LocalizedText payload).
+public sealed record AlarmConditionSnapshot(
+ bool Active,
+ bool Acknowledged,
+ bool Confirmed,
+ bool Enabled,
+ AlarmShelvingKind Shelving,
+ ushort Severity,
+ string Message);
+
+///
+/// Commons-local mirror of the Core ShelvingKind enum so this assembly carries no
+/// dependency on Core.ScriptedAlarms. = no suppression,
+/// = suppress the next active transition, = suppress
+/// until a configured expiry. The Runtime host maps the Core enum onto these members; the SDK
+/// sink maps them onto the Part 9 SetShelvingState(shelved, oneShot, shelvingTime) flags.
+///
+public enum AlarmShelvingKind
+{
+ /// No shelving — the alarm behaves normally.
+ Unshelved,
+
+ /// One-shot shelve — suppresses the next active transition, then expires.
+ OneShot,
+
+ /// Timed shelve — suppresses until a configured expiry timestamp passes.
+ Timed,
+}
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 d652d407..14ecb5c5 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
@@ -30,13 +30,12 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
- /// Writes an alarm state through the inner sink.
+ /// Writes a full alarm-condition state through the inner sink.
/// The node ID of the alarm condition.
- /// Whether the alarm is active.
- /// Whether the alarm has been acknowledged.
+ /// The full condition state to project onto the node.
/// The source timestamp in UTC.
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
- => _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
+ => _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
/// Materialises a real Part 9 alarm-condition node through the inner sink.
/// The alarm node ID (== ScriptedAlarmId).
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 7708b0be..8ab43bdf 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
@@ -15,20 +15,21 @@ public interface IOpcUaAddressSpaceSink
/// The source timestamp in UTC.
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
- /// 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.
+ /// Write an alarm-condition's full Part 9 state. When a real condition node has been
+ /// materialised for via ,
+ /// this projects the whole
+ /// (Enabled/Active/Acked/Confirmed/Shelving/Severity/Message) onto it and recomputes Retain;
+ /// otherwise it falls back to the legacy two-element [Active, Acknowledged] placeholder
+ /// variable. No OPC UA event is fired — that is T16's responsibility.
/// The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).
- /// Whether the alarm is active.
- /// Whether the alarm has been acknowledged.
+ /// The full condition state to project onto the node.
/// The source timestamp in UTC.
- void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
+ void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, 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
+ /// 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.
@@ -84,7 +85,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
///
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
///
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 3d29a556..2d0a6573 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -90,42 +90,69 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
///
- /// 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.
+ /// Apply a full Part 9 alarm-condition write. When a real has
+ /// been materialised for (via ),
+ /// this projects the whole snapshot
+ /// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
+ /// node and recomputes Retain (T15 — richer state; still no event firing, 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 full condition state to project onto the node.
/// The timestamp of the alarm state change in UTC.
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
+ ArgumentNullException.ThrowIfNull(state);
if (_alarmConditions.TryGetValue(alarmNodeId, out var condition))
{
lock (Lock)
{
- condition.SetActiveState(SystemContext, active);
- condition.SetAcknowledgedState(SystemContext, acknowledged);
+ // EnabledState / AckedState / ActiveState are mandatory children — always present after
+ // Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is
+ // that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a
+ // future SDK that builds a leaner child set) may leave them null. Null-guard each optional
+ // child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a
+ // no-op rather than an NRE.
+ condition.SetEnableState(SystemContext, state.Enabled);
+ condition.SetActiveState(SystemContext, state.Active);
+ condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
+ if (condition.ConfirmedState is not null)
+ {
+ condition.SetConfirmedState(SystemContext, state.Confirmed);
+ }
+ if (condition.ShelvingState is not null)
+ {
+ // SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
+ // (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the
+ // SDK timer, so we pass shelvingTime=0 (no SDK-managed auto-unshelve).
+ condition.SetShelvingState(
+ SystemContext,
+ shelved: state.Shelving != AlarmShelvingKind.Unshelved,
+ oneShot: state.Shelving == AlarmShelvingKind.OneShot,
+ shelvingTime: 0);
+ }
+ condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
+ condition.Message.Value = new LocalizedText(state.Message);
+
// 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;
+ // it correct for the projection.
+ condition.Retain.Value = state.Active || !state.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.
+ // attribute (not event) subscribers watching the condition's children directly.
condition.ClearChangeMasks(SystemContext, includeChildren: true);
}
return;
}
- // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable.
+ // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so
+ // un-materialised callers (and the existing unit tests) keep working.
lock (Lock)
{
// CreateVariable mutates the SDK address space, so it MUST run under Lock (see WriteValue).
@@ -135,7 +162,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_variables[alarmNodeId] = variable;
}
- variable.Value = new[] { active, acknowledged };
+ variable.Value = new[] { state.Active, state.Acknowledged };
variable.StatusCode = StatusCodes.Good;
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
@@ -146,7 +173,7 @@ 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.
+ /// 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
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
index c0f7d032..738f66c1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
@@ -59,12 +59,12 @@ public sealed class Phase7Applier
var removedCount = 0;
foreach (var eq in plan.RemovedEquipment)
{
- SafeWriteAlarmState(eq.EquipmentId, active: false, acknowledged: false, ts);
+ SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts);
removedCount++;
}
foreach (var alarm in plan.RemovedAlarms)
{
- SafeWriteAlarmState(alarm.ScriptedAlarmId, active: false, acknowledged: false, ts);
+ SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts);
removedCount++;
}
@@ -332,10 +332,22 @@ public sealed class Phase7Applier
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); }
}
- private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
+ /// The "no-event" condition state written to a removed equipment / alarm node before the
+ /// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message.
+ /// Drives Retain to false so a removed condition stops replaying on ConditionRefresh.
+ private static readonly AlarmConditionSnapshot RemovedConditionState = new(
+ Active: false,
+ Acknowledged: true,
+ Confirmed: true,
+ Enabled: true,
+ Shelving: AlarmShelvingKind.Unshelved,
+ Severity: 0,
+ Message: string.Empty);
+
+ private void SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts)
{
- try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
- catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmState threw for {Node}", nodeId); }
+ try { _sink.WriteAlarmCondition(nodeId, state, ts); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmCondition threw for {Node}", nodeId); }
}
private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
index 31367660..8dbf47ca 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
@@ -28,13 +28,12 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
- /// Writes alarm state to the OPC UA address space.
+ /// Writes the full Part 9 alarm-condition state to the OPC UA address space.
/// The alarm node identifier.
- /// Whether the alarm is active.
- /// Whether the alarm is acknowledged.
+ /// The full condition state to project onto the node.
/// The source timestamp in UTC.
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
- => _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
+ => _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
/// Materialises a real Part 9 alarm-condition node in the address space.
/// The alarm node identifier (== ScriptedAlarmId).
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 983f7977..a6deca75 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
@@ -30,7 +30,15 @@ public sealed class OpcUaPublishActor : ReceiveActor
public const string RedundancyStateTopic = "redundancy-state";
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
- public sealed record AlarmStateUpdate(string AlarmNodeId, bool Active, bool Acknowledged, DateTime TimestampUtc);
+
+ /// Carries the full Part 9 condition state for a scripted alarm to the sink. The
+ /// snapshot is the Commons projection the Runtime host maps from the engine's
+ /// Core AlarmConditionState + severity/message — the actor stays decoupled from
+ /// Core.ScriptedAlarms.
+ /// The alarm node id (== ScriptedAlarmId for materialised conditions).
+ /// The full condition state to project onto the node.
+ /// The source timestamp of the transition in UTC.
+ public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc);
///
/// Triggers an address-space rebuild. is the deployment
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
@@ -167,13 +175,13 @@ public sealed class OpcUaPublishActor : ReceiveActor
{
try
{
- _sink.WriteAlarmState(msg.AlarmNodeId, msg.Active, msg.Acknowledged, msg.TimestampUtc);
+ _sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "alarm"));
}
catch (Exception ex)
{
- _log.Warning(ex, "OpcUaPublish: sink.WriteAlarmState threw for {Node}", msg.AlarmNodeId);
+ _log.Warning(ex, "OpcUaPublish: sink.WriteAlarmCondition threw for {Node}", msg.AlarmNodeId);
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
index cb72b329..6ff420dc 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -234,13 +235,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
return;
}
- // Bridge to OPC UA: drive the alarm node's Active / Acknowledged sub-vars. We use e.AlarmId as
- // the node id for now — T14 will materialise the real condition node at an aligned NodeId and
- // this id will line up with it.
+ // Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
+ // shelving/severity/message) onto the materialised condition node via the Commons snapshot.
+ // e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
AlarmNodeId: e.AlarmId,
- Active: e.Condition.Active == AlarmActiveState.Active,
- Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
+ State: ToSnapshot(e),
TimestampUtc: e.TimestampUtc));
// Publish the transition to the cluster `alerts` topic — the single historization + live
@@ -297,6 +297,29 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
HistorizeToAveva: p.HistorizeToAveva,
Retain: p.Retain);
+ /// Maps a 's Core +
+ /// severity/message down to the Commons the SDK sink projects.
+ /// Severity is the OPC UA 1..1000 value derives from the coarse engine
+ /// bucket, cast to the ushort the SDK SetSeverity expects. Shelving's 3-way Core kind
+ /// maps 1:1 onto the Commons .
+ private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e) => new(
+ Active: e.Condition.Active == AlarmActiveState.Active,
+ Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
+ Confirmed: e.Condition.Confirmed == AlarmConfirmedState.Confirmed,
+ Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
+ Shelving: MapShelving(e.Condition.Shelving.Kind),
+ Severity: (ushort)SeverityToInt(e.Severity),
+ Message: e.Message);
+
+ /// Maps the Core onto the Commons
+ /// mirror (the Commons assembly can't see the Core enum).
+ private static AlarmShelvingKind MapShelving(ShelvingKind kind) => kind switch
+ {
+ ShelvingKind.OneShot => AlarmShelvingKind.OneShot,
+ ShelvingKind.Timed => AlarmShelvingKind.Timed,
+ _ => AlarmShelvingKind.Unshelved,
+ };
+
/// The acting user for an . Engine-driven
/// Activated / Cleared transitions are "system"; operator Acknowledged / Confirmed carry the
/// recorded user from the condition state, falling back to "system" when none was recorded.
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 2011d5c8..9600d9a3 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
@@ -15,7 +15,7 @@ public sealed class DeferredAddressSpaceSinkTests
// No throw, no observable side effect.
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
- deferred.WriteAlarmState("a", true, false, DateTime.UtcNow);
+ deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
deferred.RebuildAddressSpace();
}
@@ -28,7 +28,7 @@ public sealed class DeferredAddressSpaceSinkTests
deferred.SetSink(inner);
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow);
- deferred.WriteAlarmState("a-1", true, false, DateTime.UtcNow);
+ deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow);
deferred.RebuildAddressSpace();
inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB" });
@@ -67,6 +67,12 @@ public sealed class DeferredAddressSpaceSinkTests
second.Calls.Single().ShouldBe("WV:b");
}
+ /// Builds a minimal for the forwarding tests (the
+ /// inner sink only records the node id, so the exact state values don't matter here).
+ private static AlarmConditionSnapshot Snapshot(bool active = false) =>
+ new(active, Acknowledged: true, Confirmed: true, Enabled: true,
+ Shelving: AlarmShelvingKind.Unshelved, Severity: 500, Message: "test");
+
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
/// Gets the queue of recorded calls.
@@ -78,7 +84,7 @@ public sealed class DeferredAddressSpaceSinkTests
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WV:{nodeId}");
///
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
///
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
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 baa2717a..5d7b23ef 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
@@ -246,12 +246,11 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
/// The OPC UA quality value.
/// The source timestamp in UTC.
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
- /// Records an alarm state write (stub implementation for testing).
+ /// Records an alarm condition write (stub implementation for testing).
/// The node ID of the alarm condition.
- /// Whether the alarm is active.
- /// Whether the alarm has been acknowledged.
+ /// The full condition state snapshot.
/// The source timestamp in UTC.
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
/// Materialises an alarm condition (stub implementation for testing).
/// The alarm node ID (== ScriptedAlarmId).
/// The equipment folder node ID.
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 8af8ad9c..387ba14e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -38,7 +38,8 @@ public sealed class Phase7ApplierTests
outcome.RemovedNodes.ShouldBe(2);
outcome.RebuildCalled.ShouldBeTrue();
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
- sink.AlarmWrites.All(a => a.Active == false && a.Acknowledged == false).ShouldBeTrue();
+ // Removed nodes are reset to the "no-event" state: inactive + acked + confirmed + enabled.
+ sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
@@ -103,9 +104,9 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(0);
}
- /// Verifies that sink exceptions in WriteAlarmState do not propagate and rebuild still fires.
+ /// Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires.
[Fact]
- public void Sink_exception_in_WriteAlarmState_does_not_propagate_and_rebuild_still_fires()
+ public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires()
{
var sink = new ThrowingSink(throwOnAlarmWrite: true);
var applier = new Phase7Applier(sink, NullLogger.Instance);
@@ -445,8 +446,8 @@ public sealed class Phase7ApplierTests
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
- /// Gets the queue of alarm state write calls.
- public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
+ /// Gets the queue of alarm condition write calls.
+ public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new();
/// Gets the queue of folder creation calls.
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// Gets the queue of variable creation calls.
@@ -457,7 +458,7 @@ public sealed class Phase7ApplierTests
public int RebuildCalls;
/// Gets the list of recorded alarm writes.
- public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
+ public List<(string NodeId, AlarmConditionSnapshot State)> AlarmWrites => AlarmQueue.ToList();
/// Gets the list of recorded folder creation calls.
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// Gets the list of recorded variable creation calls.
@@ -471,13 +472,12 @@ public sealed class Phase7ApplierTests
/// The OPC UA quality.
/// The source timestamp in UTC.
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
- /// Records an alarm state write call.
+ /// Records an alarm condition write call.
/// The alarm node ID.
- /// Whether the alarm is active.
- /// Whether the alarm is acknowledged.
+ /// The full condition state snapshot.
/// The source timestamp in UTC.
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
- => AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
+ => AlarmQueue.Enqueue((alarmNodeId, state));
/// Records an alarm-condition materialise call.
/// The alarm node ID (== ScriptedAlarmId).
/// The equipment folder node ID.
@@ -518,11 +518,10 @@ public sealed class Phase7ApplierTests
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// Throws an exception if configured to do so.
/// The alarm node ID.
- /// Whether the alarm is active.
- /// Whether the alarm is acknowledged.
+ /// The full condition state snapshot.
/// The source timestamp in UTC.
/// Thrown when configured to throw on alarm write.
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
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 1f45463d..e02e5460 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
///
/// Integration tests for the F10b production binding: boot a real
/// through , attach a ,
-/// drive WriteValue/WriteAlarmState/RebuildAddressSpace, and verify the
+/// drive WriteValue/WriteAlarmCondition/RebuildAddressSpace, and verify the
/// reflects the writes.
///
public sealed class SdkAddressSpaceSinkTests : IDisposable
@@ -36,14 +36,15 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
await host.DisposeAsync();
}
- /// Verifies that WriteAlarmState creates a dedicated node distinct from value writes.
+ /// Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
+ /// distinct from value writes.
[Fact]
- public async Task WriteAlarmState_creates_dedicated_node_distinct_from_value_writes()
+ public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
- sink.WriteAlarmState("alarm-7", active: true, acknowledged: false, DateTime.UtcNow);
+ sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
server.NodeManager!.VariableCount.ShouldBe(2);
@@ -60,7 +61,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
- sink.WriteAlarmState("alarm-c", true, false, DateTime.UtcNow);
+ sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow);
server.NodeManager!.VariableCount.ShouldBe(3);
sink.RebuildAddressSpace();
@@ -81,18 +82,18 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
var sink = NullOpcUaAddressSpaceSink.Instance;
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
- sink.WriteAlarmState("a", true, false, DateTime.UtcNow);
+ sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
sink.RebuildAddressSpace();
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
+ /// then projects active state through WriteAlarmCondition. 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()
+ public async Task MaterialiseAlarmCondition_creates_real_condition_node_and_WriteAlarmCondition_updates_it()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
@@ -101,7 +102,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// 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.
+ // Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
nm.AlarmConditionCount.ShouldBe(1);
@@ -134,9 +135,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
condition.ConfirmedState.ShouldNotBeNull();
condition.ShelvingState.ShouldNotBeNull();
- // WriteAlarmState now targets the real condition (not the bool[2] placeholder): no extra
+ // WriteAlarmCondition 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);
+ sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
condition.ActiveState.Id.Value.ShouldBeTrue();
@@ -174,6 +175,107 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
await host.DisposeAsync();
}
+ /// T15 — the full condition state bridges through WriteAlarmCondition. Materialise a
+ /// condition, then push a rich snapshot (active + unacked + disabled + timed-shelved + high severity
+ /// + message) and assert every projected child reflects it:
+ /// ActiveState/AckedState/EnabledState/ConfirmedState/ShelvingState/Severity/Message/Retain.
+ [Fact]
+ public async Task WriteAlarmCondition_projects_full_state_onto_materialised_condition()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var sink = new SdkAddressSpaceSink(nm);
+
+ sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
+ sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300);
+
+ var condition = nm.TryGetAlarmCondition("alm-rich");
+ condition.ShouldNotBeNull();
+
+ // Rich snapshot: active, unacknowledged, unconfirmed, DISABLED, TIMED-shelved, severity 850, message.
+ sink.WriteAlarmCondition(
+ "alm-rich",
+ new AlarmConditionSnapshot(
+ Active: true,
+ Acknowledged: false,
+ Confirmed: false,
+ Enabled: false,
+ Shelving: AlarmShelvingKind.Timed,
+ Severity: 850,
+ Message: "Pressure above limit"),
+ DateTime.UtcNow);
+
+ condition.ActiveState.Id.Value.ShouldBeTrue();
+ condition.AckedState.Id.Value.ShouldBeFalse();
+ condition.EnabledState.Id.Value.ShouldBeFalse(); // disabled
+ condition.ConfirmedState!.Id.Value.ShouldBeFalse(); // unconfirmed
+ // SetShelvingState(shelved:true, oneShot:false) drives the shelving sub-state machine into a
+ // shelved state; CurrentState is populated (TimedShelved).
+ condition.ShelvingState!.CurrentState.Id.Value.ShouldNotBeNull();
+ // Severity 850 → High bucket (>= 800) → EventSeverity.High (the SDK stamps the ushort value).
+ condition.Severity.Value.ShouldBe((ushort)EventSeverity.High);
+ condition.Message.Value.Text.ShouldBe("Pressure above limit");
+ condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
+
+ await host.DisposeAsync();
+ }
+
+ /// T15 null-guard — a base (AlarmType "AlarmCondition")
+ /// whose optional ConfirmedState child the SDK might not materialise must not throw when a snapshot
+ /// sets Confirmed/Shelving. We force the ConfirmedState child to null after materialise to simulate a
+ /// leaner child set, then write a snapshot that sets Confirmed=true — the write must be a safe no-op
+ /// on that child rather than an NRE, while still projecting the mandatory state.
+ [Fact]
+ public async Task WriteAlarmCondition_null_optional_child_does_not_throw()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var sink = new SdkAddressSpaceSink(nm);
+
+ sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
+ sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200);
+
+ var condition = nm.TryGetAlarmCondition("alm-base");
+ condition.ShouldNotBeNull();
+ condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base type
+
+ // Simulate a build where the optional Confirm sub-state machine was NOT created.
+ condition.ConfirmedState = null;
+
+ // Setting Confirmed on a condition with no ConfirmedState child must not throw.
+ Should.NotThrow(() =>
+ sink.WriteAlarmCondition(
+ "alm-base",
+ new AlarmConditionSnapshot(
+ Active: true,
+ Acknowledged: false,
+ Confirmed: true, // targets the (now-null) optional child
+ Enabled: true,
+ Shelving: AlarmShelvingKind.OneShot,
+ Severity: 500,
+ Message: "still works"),
+ DateTime.UtcNow));
+
+ // Mandatory state still projected despite the missing optional child.
+ condition.ActiveState.Id.Value.ShouldBeTrue();
+ condition.AckedState.Id.Value.ShouldBeFalse();
+ condition.Message.Value.Text.ShouldBe("still works");
+
+ await host.DisposeAsync();
+ }
+
+ /// Builds a test with sensible defaults so each call
+ /// site only specifies the fields it cares about.
+ private static AlarmConditionSnapshot Snapshot(
+ bool active = false,
+ bool acknowledged = true,
+ bool confirmed = true,
+ bool enabled = true,
+ AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved,
+ ushort severity = 500,
+ string message = "test") =>
+ new(active, acknowledged, confirmed, enabled, shelving, severity, message);
+
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 3dee5c97..f46666ca 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
@@ -195,12 +195,11 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// The OPC UA quality status.
/// The source timestamp in UTC.
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
- /// Records an alarm state write.
+ /// Records an alarm condition write.
/// The alarm node identifier.
- /// Whether the alarm is active.
- /// Whether the alarm is acknowledged.
+ /// The full condition state snapshot.
/// The time the alarm occurred in UTC.
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) => Writes++;
/// Materialises an alarm condition (stub implementation).
/// The alarm node identifier.
/// The equipment 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 3de0a8b3..d524f602 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
@@ -246,12 +246,11 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// The timestamp of the write.
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
=> Calls.Enqueue($"WV:{nodeId}");
- /// Records an alarm state write call.
+ /// Records an alarm condition write call.
/// The alarm node ID.
- /// Whether the alarm is active.
- /// Whether the alarm is acknowledged.
+ /// The full condition state snapshot.
/// The timestamp of the state change.
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts)
=> Calls.Enqueue($"WA:{alarmNodeId}");
/// Records a materialise-alarm-condition call.
/// The alarm node ID (== ScriptedAlarmId).
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 a81387d8..c7c2a2a4 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
@@ -18,7 +18,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
{
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow));
- actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", true, false, DateTime.UtcNow));
+ actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
@@ -53,24 +53,38 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
}, duration: TimeSpan.FromMilliseconds(500));
}
- /// Verifies that AlarmStateUpdate routes to sink WriteAlarmState.
+ /// Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot.
[Fact]
- public void AlarmStateUpdate_routes_to_sink_WriteAlarmState()
+ public void AlarmStateUpdate_routes_to_sink_WriteAlarmCondition()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
- actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=A1", Active: true, Acknowledged: false, DateTime.UtcNow));
+ actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
+ "ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow));
AwaitAssert(() =>
{
sink.Alarms.Count.ShouldBe(1);
sink.Alarms[0].AlarmNodeId.ShouldBe("ns=2;s=A1");
- sink.Alarms[0].Active.ShouldBeTrue();
- sink.Alarms[0].Acknowledged.ShouldBeFalse();
+ sink.Alarms[0].State.Active.ShouldBeTrue();
+ sink.Alarms[0].State.Acknowledged.ShouldBeFalse();
+ sink.Alarms[0].State.Severity.ShouldBe((ushort)700);
}, duration: TimeSpan.FromMilliseconds(500));
}
+ /// Builds a test with sensible defaults so each test
+ /// only specifies the fields it cares about.
+ private static AlarmConditionSnapshot Snapshot(
+ bool active = false,
+ bool acknowledged = true,
+ bool confirmed = true,
+ bool enabled = true,
+ AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved,
+ ushort severity = 500,
+ string message = "test") =>
+ new(active, acknowledged, confirmed, enabled, shelving, severity, message);
+
/// Verifies that RebuildAddressSpace calls sink Rebuild.
[Fact]
public void RebuildAddressSpace_calls_sink_Rebuild()
@@ -148,16 +162,16 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
{
/// Gets the queue of recorded value updates.
public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
- /// Gets the queue of recorded alarm state updates.
- public ConcurrentQueue<(string AlarmNodeId, bool Active, bool Acknowledged, DateTime Ts)> AlarmQueue { get; } = new();
+ /// Gets the queue of recorded alarm condition updates.
+ public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new();
/// Count of rebuild calls.
public int RebuildCalls;
/// Gets the list of recorded value updates.
public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values =>
ValueQueue.ToList();
- /// Gets the list of recorded alarm state updates.
- public List<(string AlarmNodeId, bool Active, bool Acknowledged, DateTime Ts)> Alarms =>
+ /// Gets the list of recorded alarm condition updates.
+ public List<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> Alarms =>
AlarmQueue.ToList();
/// Records a value update.
@@ -168,13 +182,12 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) =>
ValueQueue.Enqueue((nodeId, value, quality, ts));
- /// Records an alarm state update.
+ /// Records an alarm condition update.
/// The OPC UA alarm node identifier.
- /// Whether the alarm is active.
- /// Whether the alarm is acknowledged.
+ /// The full condition state snapshot.
/// The timestamp of the update.
- public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
- AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
+ public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) =>
+ AlarmQueue.Enqueue((alarmNodeId, state, ts));
/// Materialises an alarm condition (no-op in test).
/// The alarm node ID.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs
index d23fd9f3..e8c0c7c1 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs
@@ -5,6 +5,7 @@ using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -131,7 +132,16 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
var state = publish.ExpectMsg(Timeout);
state.AlarmNodeId.ShouldBe("alm-1");
- state.Active.ShouldBeTrue();
+ // The full Part 9 snapshot bridges through (T15) — every Core condition field maps:
+ // on activation the engine sets Active, clears Ack AND Confirm (a new active occurrence needs a
+ // fresh ack→clear→confirm cycle), keeps Enabled, and leaves Shelving unshelved.
+ state.State.Active.ShouldBeTrue(); // Condition.Active == Active
+ state.State.Acknowledged.ShouldBeFalse(); // Condition.Acked == Unacknowledged on activation
+ state.State.Confirmed.ShouldBeFalse(); // Condition.Confirmed == Unconfirmed on activation
+ state.State.Enabled.ShouldBeTrue(); // Condition.Enabled == Enabled
+ state.State.Shelving.ShouldBe(AlarmShelvingKind.Unshelved); // Condition.Shelving.Kind == Unshelved
+ state.State.Severity.ShouldBe((ushort)1000); // 800 → Critical bucket → 1000
+ state.State.Message.ShouldBe("condition"); // e.Message
var evt = alerts.ExpectMsg(Timeout);
evt.AlarmId.ShouldBe("alm-1");
@@ -156,13 +166,13 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
// Activate first.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
- publish.FishForMessage(m => m.Active, Timeout);
+ publish.FishForMessage(m => m.State.Active, Timeout);
alerts.FishForMessage(e => e.TransitionKind == "Activated", Timeout);
// Now clear.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow));
- var cleared = publish.FishForMessage(m => !m.Active, Timeout);
+ var cleared = publish.FishForMessage(m => !m.State.Active, Timeout);
cleared.AlarmNodeId.ShouldBe("alm-1");
var evt = alerts.FishForMessage(e => e.TransitionKind == "Cleared", Timeout);