diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs new file mode 100644 index 0000000..da1a849 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs @@ -0,0 +1,60 @@ +using Akka.Actor; +using Akka.Event; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; + +public enum ScriptedAlarmActorState { Inactive, Active, Acknowledged } + +/// +/// State machine wrapping a single scripted alarm. Transitions: +/// Inactive → Active → Acknowledged → Inactive. +/// +/// Engine wiring (compile alarm expression via AlarmConditionService, persist state to +/// ScriptedAlarmState ConfigDb table on PreRestart, emit history rows to +/// HistorianAdapter) is staged for follow-up F9. This skeleton owns the state machine +/// so DriverHostActor can spawn it as a child. +/// +public sealed class ScriptedAlarmActor : ReceiveActor +{ + public sealed record ConditionMet(string Reason); + public sealed record AcknowledgeAlarm(string Actor); + public sealed record ConditionCleared; + public sealed record StateChanged(string AlarmId, ScriptedAlarmActorState State, DateTime AtUtc); + + private readonly string _alarmId; + private readonly ILoggingAdapter _log = Context.GetLogger(); + private ScriptedAlarmActorState _state = ScriptedAlarmActorState.Inactive; + + public static Props Props(string alarmId) => + Akka.Actor.Props.Create(() => new ScriptedAlarmActor(alarmId)); + + public ScriptedAlarmActor(string alarmId) + { + _alarmId = alarmId; + + Receive(msg => + { + if (_state != ScriptedAlarmActorState.Inactive) return; + Transition(ScriptedAlarmActorState.Active); + }); + Receive(msg => + { + if (_state != ScriptedAlarmActorState.Active) return; + Transition(ScriptedAlarmActorState.Acknowledged); + }); + Receive(_ => + { + if (_state == ScriptedAlarmActorState.Inactive) return; + Transition(ScriptedAlarmActorState.Inactive); + }); + } + + private void Transition(ScriptedAlarmActorState next) + { + var prev = _state; + _state = next; + _log.Info("ScriptedAlarm {Id}: {From} → {To}", _alarmId, prev, next); + Context.Parent.Tell(new StateChanged(_alarmId, next, DateTime.UtcNow)); + // F9: emit history row via HistorianAdapter; persist state to ScriptedAlarmState DB. + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs new file mode 100644 index 0000000..a6721c9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs @@ -0,0 +1,44 @@ +using Akka.Actor; +using Akka.TestKit; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; + +public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase +{ + [Fact] + public void Full_state_cycle_publishes_StateChanged_to_parent_at_each_transition() + { + var parent = CreateTestProbe(); + // Wrap the alarm actor under our probe as parent so StateChanged lands on the probe. + var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1")); + + actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold")); + var t1 = parent.ExpectMsg(); + t1.State.ShouldBe(ScriptedAlarmActorState.Active); + + actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("joe")); + var t2 = parent.ExpectMsg(); + t2.State.ShouldBe(ScriptedAlarmActorState.Acknowledged); + + actor.Tell(new ScriptedAlarmActor.ConditionCleared()); + var t3 = parent.ExpectMsg(); + t3.State.ShouldBe(ScriptedAlarmActorState.Inactive); + } + + [Fact] + public void Duplicate_ConditionMet_in_Active_is_ignored() + { + var parent = CreateTestProbe(); + var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1")); + + actor.Tell(new ScriptedAlarmActor.ConditionMet("first")); + parent.ExpectMsg(); + + actor.Tell(new ScriptedAlarmActor.ConditionMet("second")); + parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + } +}