using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
public enum ScriptedAlarmActorState { Inactive, Active, Acknowledged }
///
/// One scripted alarm. Receives dependency value updates, runs the predicate via an
/// injected , and on transitions publishes both
/// an on the cluster alerts DPS topic and a
/// on script-logs. Manual
/// + still flow through the same state machine so the
/// legacy callers keep working.
///
public sealed class ScriptedAlarmActor : ReceiveActor
{
public const string AlertsTopic = "alerts";
public const string ScriptLogsTopic = "script-logs";
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
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);
public sealed record AlarmConfig(
string AlarmId,
string AlarmName,
string EquipmentPath,
int Severity,
string? Predicate);
private readonly AlarmConfig _config;
private readonly IScriptedAlarmEvaluator _evaluator;
private readonly IAlarmActorStateStore _stateStore;
private readonly Func? _publisherFactory;
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly Dictionary _dependencies = new(StringComparer.Ordinal);
private ScriptedAlarmActorState _state = ScriptedAlarmActorState.Inactive;
private string? _lastAckUser;
public sealed record StateRestored(ScriptedAlarmActorState State, string? LastAckUser);
public static Props Props(
AlarmConfig config,
IScriptedAlarmEvaluator? evaluator = null,
Func? publisherFactory = null,
IAlarmActorStateStore? stateStore = null) =>
Akka.Actor.Props.Create(() => new ScriptedAlarmActor(
config,
evaluator ?? NullScriptedAlarmEvaluator.Instance,
publisherFactory,
stateStore ?? NullAlarmActorStateStore.Instance));
/// Legacy single-arg ctor kept for callers that only care about the state machine
/// (no engine evaluation, no DPS fan-out, no persistence). Equivalent to Props(new AlarmConfig(...)).
public static Props Props(string alarmId) =>
Props(new AlarmConfig(alarmId, alarmId, EquipmentPath: "", Severity: 500, Predicate: null));
public ScriptedAlarmActor(
AlarmConfig config,
IScriptedAlarmEvaluator evaluator,
Func? publisherFactory,
IAlarmActorStateStore stateStore)
{
_config = config;
_evaluator = evaluator;
_publisherFactory = publisherFactory;
_stateStore = stateStore;
Receive(OnDependencyChanged);
Receive(_ => { if (_state == ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Active, user: "system"); });
Receive(msg => { if (_state == ScriptedAlarmActorState.Active) Transition(ScriptedAlarmActorState.Acknowledged, user: msg.Actor); });
Receive(_ => { if (_state != ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Inactive, user: "system"); });
Receive(OnStateRestored);
}
protected override void PreStart()
{
// Load persisted state — when the store has a row, restore in-memory state before the
// first dependency-change arrives. Async I/O is piped back as StateRestored so we don't
// block the message-loop thread; until it arrives the actor stays at the default Inactive.
var self = Self;
_ = Task.Run(async () =>
{
try
{
var snapshot = await _stateStore.LoadAsync(_config.AlarmId, CancellationToken.None)
.ConfigureAwait(false);
if (snapshot is null) return;
if (!Enum.TryParse(snapshot.State, ignoreCase: true, out var parsed))
return;
self.Tell(new StateRestored(parsed, snapshot.LastAckUser));
}
catch (Exception ex)
{
_log.Warning(ex, "ScriptedAlarm {Id}: state-store load failed; booting Inactive",
_config.AlarmId);
}
});
}
private void OnStateRestored(StateRestored msg)
{
// Active is re-derived from the evaluator at the next DependencyValueChanged — we still
// restore Active here so operators don't lose the in-flight transition if a restart races
// ahead of the next eval. The first evaluator tick will correct it if the condition cleared.
_state = msg.State;
_lastAckUser = msg.LastAckUser;
_log.Info("ScriptedAlarm {Id}: restored persisted state {State} (lastAck={User})",
_config.AlarmId, _state, _lastAckUser ?? "(none)");
}
private void OnDependencyChanged(DependencyValueChanged msg)
{
_dependencies[msg.TagId] = msg.Value;
if (string.IsNullOrEmpty(_config.Predicate)) return;
ScriptedAlarmEvalResult result;
try
{
result = _evaluator.Evaluate(_config.AlarmId, _config.Predicate, _dependencies);
}
catch (Exception ex)
{
_log.Warning(ex, "ScriptedAlarm {Id}: evaluator threw", _config.AlarmId);
PublishLog("Error", $"evaluator threw: {ex.Message}");
return;
}
if (!result.Success)
{
PublishLog("Warning", result.Reason ?? "evaluator failure");
return;
}
// Active condition wins regardless of ack state — re-firing is suppressed because
// _state already == Active. Cleared moves Active OR Acknowledged → Inactive.
if (result.Active && _state == ScriptedAlarmActorState.Inactive)
{
Transition(ScriptedAlarmActorState.Active, user: "system");
}
else if (!result.Active && _state != ScriptedAlarmActorState.Inactive)
{
Transition(ScriptedAlarmActorState.Inactive, user: "system");
}
}
private void Transition(ScriptedAlarmActorState next, string user)
{
var prev = _state;
_state = next;
if (next == ScriptedAlarmActorState.Acknowledged) _lastAckUser = user;
_log.Info("ScriptedAlarm {Id}: {From} → {To}", _config.AlarmId, prev, next);
var nowUtc = DateTime.UtcNow;
Context.Parent.Tell(new StateChanged(_config.AlarmId, next, nowUtc));
PersistStateAsync(nowUtc);
var kind = next switch
{
ScriptedAlarmActorState.Active => "Activated",
ScriptedAlarmActorState.Acknowledged => "Acknowledged",
ScriptedAlarmActorState.Inactive => "Cleared",
_ => next.ToString(),
};
OtOpcUaTelemetry.ScriptedAlarmTransition.Add(1,
new KeyValuePair("state", kind.ToLowerInvariant()));
var evt = new AlarmTransitionEvent(
AlarmId: _config.AlarmId,
EquipmentPath: _config.EquipmentPath,
AlarmName: _config.AlarmName,
TransitionKind: kind,
Severity: _config.Severity,
Message: $"{_config.AlarmName} {kind}",
User: user,
TimestampUtc: nowUtc);
PublishOrFallback(AlertsTopic, evt);
PublishLog("Information", $"{_config.AlarmName} {kind} (by {user})");
}
private void PublishLog(string level, string message)
{
var entry = new ScriptLogEntry(
ScriptId: _config.AlarmId,
Level: level,
Message: message,
TimestampUtc: DateTime.UtcNow,
VirtualTagId: null,
AlarmId: _config.AlarmId,
EquipmentId: null);
PublishOrFallback(ScriptLogsTopic, entry);
}
private void PublishOrFallback(string topic, object payload)
{
if (_publisherFactory is not null)
{
_publisherFactory().Publish(topic, payload);
return;
}
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(topic, payload));
}
private void PersistStateAsync(DateTime nowUtc)
{
var snapshot = new AlarmActorStateSnapshot(
AlarmId: _config.AlarmId,
State: _state.ToString(),
LastTransitionUtc: nowUtc,
LastAckUser: _lastAckUser);
// Fire-and-forget. Save failures get logged but don't block the message loop —
// the worst case is a restart loses one transition, which then re-derives from
// the evaluator's next tick anyway.
_ = Task.Run(async () =>
{
try
{
await _stateStore.SaveAsync(snapshot, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_log.Warning(ex, "ScriptedAlarm {Id}: state-store save failed", _config.AlarmId);
}
});
}
}