From 4eb1d65e2bc8704e1cbfb129f181c8a7d27b404c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 19:41:16 -0400 Subject: [PATCH] feat(scripted-alarms): richer AlarmConditionState bridge to the OPC UA node (T15) --- .../OpcUa/AlarmConditionSnapshot.cs | 45 +++++++ .../OpcUa/DeferredAddressSpaceSink.cs | 9 +- .../OpcUa/IOpcUaAddressSpaceSink.cs | 19 +-- .../OtOpcUaNodeManager.cs | 63 ++++++--- .../Phase7Applier.cs | 22 +++- .../SdkAddressSpaceSink.cs | 9 +- .../OpcUa/OpcUaPublishActor.cs | 14 +- .../ScriptedAlarms/ScriptedAlarmHostActor.cs | 33 ++++- .../DeferredAddressSpaceSinkTests.cs | 12 +- .../Phase7ApplierHierarchyTests.cs | 7 +- .../Phase7ApplierTests.cs | 27 ++-- .../SdkAddressSpaceSinkTests.cs | 124 ++++++++++++++++-- .../OtOpcUaTelemetryHookTests.cs | 7 +- .../OpcUa/OpcUaPublishActorRebuildTests.cs | 7 +- .../OpcUa/OpcUaPublishActorTests.cs | 43 +++--- .../ScriptedAlarmHostActorTests.cs | 16 ++- 16 files changed, 349 insertions(+), 108 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs 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);