diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs new file mode 100644 index 00000000..9cea1f3c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs @@ -0,0 +1,52 @@ +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// Derives a full Part 9 from each native +/// delta, tracking per-condition-NodeId prior state. Owned by the +/// single-threaded DriverHostActor (no locking). Native alarms carry only a transition +/// , not a full state machine, so this is the translation the +/// scripted-alarm engine does internally. +/// +public sealed class NativeAlarmProjector +{ + private readonly Dictionary _prior = + new(StringComparer.Ordinal); + + /// Project an alarm transition onto the full condition snapshot for . + /// The materialised condition node's id (the projection's state key). + /// The native alarm transition. + /// The full Part 9 condition snapshot to write to the node. + public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) + { + var prev = _prior.TryGetValue(nodeId, out var p) + ? p + : (Active: false, Acked: true, Severity: (ushort)0, Message: string.Empty); + var sev = MapSeverity(e.Severity); + var (active, acked) = e.Kind switch + { + AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false), + AlarmTransitionKind.Acknowledge => (prev.Active, true), + AlarmTransitionKind.Clear => (false, prev.Acked), + _ => (prev.Active, prev.Acked), + }; + _prior[nodeId] = (active, acked, sev, e.Message); + return new AlarmConditionSnapshot( + Active: active, Acknowledged: acked, Confirmed: true, Enabled: true, + Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message); + } + + /// Clears all tracked per-node state (call on address-space rebuild). + public void Clear() => _prior.Clear(); + + private static ushort MapSeverity(AlarmSeverity s) => s switch + { + AlarmSeverity.Low => 200, + AlarmSeverity.Medium => 500, + AlarmSeverity.High => 700, + AlarmSeverity.Critical => 900, + _ => 500, + }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs new file mode 100644 index 00000000..5492a3f6 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs @@ -0,0 +1,166 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +public class NativeAlarmProjectorTests +{ + [Fact] + public void Raise_projects_active_unacked_with_constant_fields_and_mapped_severity() + { + var sut = new NativeAlarmProjector(); + + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "boom")); + + snap.Active.ShouldBeTrue(); + snap.Acknowledged.ShouldBeFalse(); + snap.Confirmed.ShouldBeTrue(); + snap.Enabled.ShouldBeTrue(); + snap.Shelving.ShouldBe(AlarmShelvingKind.Unshelved); + snap.Severity.ShouldBe((ushort)700); + snap.Message.ShouldBe("boom"); + } + + [Fact] + public void Retrigger_projects_active_unacked() + { + var sut = new NativeAlarmProjector(); + + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger)); + + snap.Active.ShouldBeTrue(); + snap.Acknowledged.ShouldBeFalse(); + } + + [Fact] + public void Acknowledge_after_raise_acks_and_keeps_active() + { + var sut = new NativeAlarmProjector(); + sut.Project("n1", Evt(AlarmTransitionKind.Raise)); + + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge)); + + snap.Active.ShouldBeTrue(); + snap.Acknowledged.ShouldBeTrue(); + } + + [Fact] + public void Clear_after_raise_deactivates_and_keeps_prior_ack() + { + var sut = new NativeAlarmProjector(); + sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked + + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear)); + + snap.Active.ShouldBeFalse(); + snap.Acknowledged.ShouldBeFalse(); // preserved prior (unacked) ack state + } + + [Fact] + public void Clear_after_acknowledge_deactivates_and_keeps_acked() + { + var sut = new NativeAlarmProjector(); + sut.Project("n1", Evt(AlarmTransitionKind.Raise)); + sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge)); // active, acked + + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear)); + + snap.Active.ShouldBeFalse(); + snap.Acknowledged.ShouldBeTrue(); // preserved prior (acked) ack state + } + + [Fact] + public void Unspecified_carries_prior_active_and_ack_state() + { + var sut = new NativeAlarmProjector(); + sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked + + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified)); + + snap.Active.ShouldBeTrue(); + snap.Acknowledged.ShouldBeFalse(); + } + + [Fact] + public void Cold_state_defaults_to_inactive_acked() + { + var sut = new NativeAlarmProjector(); + + // Acknowledge on a never-seen node: prior Active defaults false, ack set true. + var snap = sut.Project("nNew", Evt(AlarmTransitionKind.Acknowledge)); + + snap.Active.ShouldBeFalse(); + snap.Acknowledged.ShouldBeTrue(); + } + + [Fact] + public void Per_node_state_is_isolated() + { + var sut = new NativeAlarmProjector(); + sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active + + // n2 is cold; a Clear on it must stay inactive and not borrow n1's active state. + var n2 = sut.Project("n2", Evt(AlarmTransitionKind.Clear)); + + n2.Active.ShouldBeFalse(); + + // n1 is unaffected by the n2 transition. + var n1 = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge)); + n1.Active.ShouldBeTrue(); + } + + [Theory] + [InlineData(AlarmSeverity.Low, (ushort)200)] + [InlineData(AlarmSeverity.Medium, (ushort)500)] + [InlineData(AlarmSeverity.High, (ushort)700)] + [InlineData(AlarmSeverity.Critical, (ushort)900)] + public void Severity_buckets_map_to_opcua_scale(AlarmSeverity sev, ushort expected) + { + var sut = new NativeAlarmProjector(); + + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, sev)); + + snap.Severity.ShouldBe(expected); + } + + [Fact] + public void Severity_and_message_always_come_from_the_event() + { + var sut = new NativeAlarmProjector(); + sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "first")); + + // A later Clear carries its own severity + message; the snapshot reflects the event, not prior. + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear, AlarmSeverity.Low, "second")); + + snap.Severity.ShouldBe((ushort)200); + snap.Message.ShouldBe("second"); + } + + [Fact] + public void Clear_resets_tracked_state() + { + var sut = new NativeAlarmProjector(); + sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active, unacked + + sut.Clear(); + + // After Clear() the node is cold again: an Acknowledge sees prior Active=false. + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge)); + snap.Active.ShouldBeFalse(); + snap.Acknowledged.ShouldBeTrue(); + } + + private static AlarmEventArgs Evt( + AlarmTransitionKind kind, + AlarmSeverity sev = AlarmSeverity.High, + string msg = "m") + => new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind); + + private sealed class H : IAlarmSubscriptionHandle + { + public string DiagnosticId => "t"; + } +}