4f7999eac2
Subscribe the host to the cluster alarm-commands DPS topic in PreStart and drive the matching ScriptedAlarmEngine op per inbound AlarmCommand. An ownership filter (engine.LoadedAlarmIds) ignores commands for alarms this node does not own; TimedShelve without UnshelveAtUtc and unknown operations are logged + rejected (never thrown); op failures are caught + logged so a faulting op can't fault the actor. Re-projection is left to the engine's existing OnEvent -> OnEngineEmission path. Handler is a Task-returning ReceiveAsync (the project's AK2003 analyzer forbids an async-void Receive delegate), giving ordered awaited async on the actor thread. Adds 3 TestKit tests: ack drives the engine with mapped args, unowned command ignored, missing-UnshelveAtUtc TimedShelve rejected not thrown.
473 lines
27 KiB
C#
473 lines
27 KiB
C#
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;
|
||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||
|
||
/// <summary>
|
||
/// Akka host that owns one <see cref="ScriptedAlarmEngine"/> for an Equipment-namespace
|
||
/// driver node, feeds it live tag values, and bridges its emissions to OPC UA publish + the
|
||
/// cluster <c>alerts</c> DistributedPubSub topic.
|
||
///
|
||
/// <para>
|
||
/// The host is the engine's lifecycle owner: the caller (the driver-role startup) builds
|
||
/// the <see cref="DependencyMuxTagUpstreamSource"/>, constructs a <see cref="ScriptedAlarmEngine"/>
|
||
/// <b>around that same upstream</b>, and passes both here. The host disposes the engine in
|
||
/// <see cref="PostStop"/>.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Data flow.</b> <see cref="DependencyMuxActor"/> delivers a
|
||
/// <see cref="VirtualTagActor.DependencyValueChanged"/> for every tag the loaded alarms
|
||
/// depend on; the host pushes each into the upstream. The engine self-evaluates: its own
|
||
/// <see cref="ITagUpstreamSource.SubscribeTag"/> observer re-runs the predicates and raises
|
||
/// <see cref="ScriptedAlarmEngine.OnEvent"/>. The host does NOT call evaluate — it only
|
||
/// feeds values in and fans emissions out.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Thread-safety.</b> <see cref="ScriptedAlarmEngine.OnEvent"/> fires on the engine's
|
||
/// background worker thread (its fire-and-forget re-evaluation), NOT on the actor thread.
|
||
/// The ctor subscribes a handler that does nothing but <c>Self.Tell(new EngineEmission(e))</c>
|
||
/// — <see cref="ICanTell.Tell"/> is thread-safe. ALL sink work (touching <see cref="Context"/>,
|
||
/// the publish actor ref, the DPS mediator) then happens on the actor thread in
|
||
/// <see cref="OnEngineEmission"/>. No <see cref="Context"/> or actor state is ever read or
|
||
/// written from the OnEvent callback.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Historization.</b> The host publishes each transition as an
|
||
/// <see cref="AlarmTransitionEvent"/> to the <c>alerts</c> topic ONLY. That topic is the
|
||
/// historization path: <c>HistorianAdapterActor</c>'s upstream and the Admin UI Alerts page
|
||
/// both consume <c>alerts</c>. The host deliberately does NOT also <c>Tell</c> the historian
|
||
/// adapter directly — doing so would double-historize every transition. (T9 plan called for a
|
||
/// "direct historian tell"; that was dropped because the alerts topic already feeds the
|
||
/// historian path, so a direct tell would duplicate every row.)
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||
{
|
||
/// <summary>The cluster DistributedPubSub topic every alarm transition is published to. Matches
|
||
/// the constant the (retired) <c>ScriptedAlarmActor</c> used so subscribers stay wired.</summary>
|
||
public const string AlertsTopic = "alerts";
|
||
|
||
/// <summary>The cluster DistributedPubSub topic inbound OPC UA Part 9 alarm method calls
|
||
/// (Acknowledge / Confirm / Shelve / AddComment) are routed onto as <see cref="AlarmCommand"/>s.
|
||
/// The OPC UA node manager's condition handlers build the command (after the <c>AlarmAck</c> role
|
||
/// gate); the host's boot wiring publishes it here; T19's engine-side subscriber consumes it.</summary>
|
||
public const string AlarmCommandsTopic = "alarm-commands";
|
||
|
||
/// <summary>Reconcile the loaded alarm set to exactly the enabled subset of <paramref name="Plans"/>:
|
||
/// builds <see cref="ScriptedAlarmDefinition"/>s (skipping disabled plans), reloads the engine, and
|
||
/// re-registers mux interest for the union of dependency refs.</summary>
|
||
/// <param name="Plans">The desired Equipment-namespace scripted-alarm plans.</param>
|
||
public sealed record ApplyScriptedAlarms(IReadOnlyList<EquipmentScriptedAlarmPlan> Plans);
|
||
|
||
/// <summary>Marshals an engine emission off the engine's worker thread onto the actor thread.
|
||
/// Carries the <see cref="ScriptedAlarmEvent"/> the engine raised on <c>OnEvent</c>.</summary>
|
||
private sealed record EngineEmission(ScriptedAlarmEvent Event);
|
||
|
||
/// <summary>Pipe-back completion of an in-flight <see cref="ScriptedAlarmEngine.LoadAsync"/>:
|
||
/// carries the union of dependency refs to register mux interest for AFTER the load completed
|
||
/// (so the engine's upstream subscriptions exist before any value can arrive). <see cref="Generation"/>
|
||
/// is the load generation captured when <see cref="OnApply"/> kicked the load off; a stale
|
||
/// continuation (an earlier generation completing after a newer apply) is discarded in
|
||
/// <see cref="OnAlarmsLoaded"/> so out-of-order completions can't register stale dep refs.</summary>
|
||
private sealed record AlarmsLoaded(IReadOnlyList<string> DepRefs, int Generation);
|
||
|
||
/// <summary>Pipe-back marker for a <see cref="ScriptedAlarmEngine.LoadAsync"/> that was cancelled
|
||
/// because the actor is stopping (its <see cref="_cts"/> fired in <see cref="PostStop"/>). It is a
|
||
/// no-op — there is nothing to register and no fault to log — and exists only so a cancelled load
|
||
/// pipes back a quiet message instead of a <see cref="Status.Failure"/> that would log a spurious
|
||
/// Warning on every clean shutdown.</summary>
|
||
private sealed record AlarmsLoadCanceled;
|
||
|
||
private readonly IActorRef _publishActor;
|
||
private readonly IActorRef? _mux;
|
||
private readonly DependencyMuxTagUpstreamSource _upstream;
|
||
private readonly ScriptedAlarmEngine _engine;
|
||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||
private readonly CancellationTokenSource _cts = new();
|
||
private readonly EventHandler<ScriptedAlarmEvent> _onEngineEvent;
|
||
|
||
/// <summary>Monotonic load generation, bumped on every <see cref="OnApply"/>. The continuation that
|
||
/// pipes back an <see cref="AlarmsLoaded"/> captures the generation it was started under; a stale
|
||
/// completion (an earlier generation arriving after a newer apply) is discarded in
|
||
/// <see cref="OnAlarmsLoaded"/> so out-of-order loads never register superseded dep refs.</summary>
|
||
private int _loadGeneration;
|
||
|
||
/// <summary>Cached cluster DistributedPubSub mediator, resolved once in <see cref="PreStart"/> (on the
|
||
/// actor thread) and reused for every emission instead of re-resolving it per-publish.</summary>
|
||
private IActorRef _mediator = null!;
|
||
|
||
/// <summary>Factory method to create Props for a <see cref="ScriptedAlarmHostActor"/>.</summary>
|
||
/// <param name="publishActor">The OPC UA publish actor that consumes
|
||
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> bridged from engine emissions.</param>
|
||
/// <param name="mux">Optional dependency multiplexer the host registers interest with so it
|
||
/// receives a <see cref="VirtualTagActor.DependencyValueChanged"/> per dependency tag. Null on the
|
||
/// dev/Mac path (no live values).</param>
|
||
/// <param name="upstream">The mux-fed upstream the engine reads + subscribes from. MUST be the
|
||
/// same instance the <paramref name="engine"/> was constructed around.</param>
|
||
/// <param name="engine">The scripted-alarm engine this host owns + disposes.</param>
|
||
public static Props Props(
|
||
IActorRef publishActor,
|
||
IActorRef? mux,
|
||
DependencyMuxTagUpstreamSource upstream,
|
||
ScriptedAlarmEngine engine) =>
|
||
Akka.Actor.Props.Create(() => new ScriptedAlarmHostActor(publishActor, mux, upstream, engine));
|
||
|
||
/// <summary>Initializes a new instance of the <see cref="ScriptedAlarmHostActor"/> class.</summary>
|
||
/// <param name="publishActor">The OPC UA publish actor emissions are bridged to.</param>
|
||
/// <param name="mux">Optional dependency multiplexer the host registers dependency interest with.</param>
|
||
/// <param name="upstream">The mux-fed upstream the engine reads + subscribes from (same instance the engine wraps).</param>
|
||
/// <param name="engine">The scripted-alarm engine this host owns + disposes.</param>
|
||
public ScriptedAlarmHostActor(
|
||
IActorRef publishActor,
|
||
IActorRef? mux,
|
||
DependencyMuxTagUpstreamSource upstream,
|
||
ScriptedAlarmEngine engine)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(publishActor);
|
||
ArgumentNullException.ThrowIfNull(upstream);
|
||
ArgumentNullException.ThrowIfNull(engine);
|
||
_publishActor = publishActor;
|
||
_mux = mux;
|
||
_upstream = upstream;
|
||
_engine = engine;
|
||
|
||
// OnEvent fires on the engine's worker thread. NEVER touch Context / actor state here —
|
||
// marshal onto the actor thread via the thread-safe Self.Tell. Keep the handler in a field
|
||
// so PostStop can unsubscribe it. (Self is captured once; it is stable for the actor's life.)
|
||
var self = Self;
|
||
_onEngineEvent = (_, e) => self.Tell(new EngineEmission(e));
|
||
_engine.OnEvent += _onEngineEvent;
|
||
|
||
Receive<ApplyScriptedAlarms>(OnApply);
|
||
Receive<AlarmsLoaded>(OnAlarmsLoaded);
|
||
Receive<VirtualTagActor.DependencyValueChanged>(OnDependencyChanged);
|
||
Receive<EngineEmission>(OnEngineEmission);
|
||
// Inbound OPC UA Part 9 alarm method calls arrive as AlarmCommands on the cluster
|
||
// `alarm-commands` DPS topic (T18 publishes them after the AlarmAck role gate). The topic is a
|
||
// cluster-wide broadcast — every host node receives every command — so OnAlarmCommand filters to
|
||
// the alarms THIS host's engine owns before driving the matching engine op. The engine ops are
|
||
// async, and this project's Akka analyzer (AK2003) forbids an async-void Receive delegate, so
|
||
// the handler is a Task-returning ReceiveAsync: Akka suspends the mailbox until the op completes
|
||
// (ordered, awaited on the actor thread) and routes any escaped fault through supervision.
|
||
ReceiveAsync<AlarmCommand>(OnAlarmCommand);
|
||
// A faulted LoadAsync pipes back a Status.Failure (see OnApply) — log it and stay inert so the
|
||
// failure doesn't hit the dead-letter log.
|
||
Receive<Status.Failure>(OnLoadFailed);
|
||
// A LoadAsync cancelled by PostStop's _cts pipes back this marker. The actor is stopping, so
|
||
// there's nothing to do — swallow it quietly (no Warning, no dead letter).
|
||
Receive<AlarmsLoadCanceled>(_ => { });
|
||
// DPS Subscribe (PreStart) acks back here once the mediator has registered Self on the topic.
|
||
// No-op — the subscription is live the moment the ack arrives; we only need to keep it off the
|
||
// dead-letter log. Matches OpcUaPublishActor / DriverHostActor's SubscribeAck convention.
|
||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||
}
|
||
|
||
private void OnApply(ApplyScriptedAlarms msg)
|
||
{
|
||
// Bump the load generation and capture it into this apply's continuation. Rapid back-to-back
|
||
// applies kick off concurrent LoadAsyncs whose continuations may complete out of order; tagging
|
||
// each with its generation lets OnAlarmsLoaded discard any but the latest.
|
||
var gen = ++_loadGeneration;
|
||
|
||
// Skip disabled plans entirely — the engine has no Enabled flag, so a disabled alarm is simply
|
||
// not loaded (no predicate, no upstream subscription, no events).
|
||
var enabled = msg.Plans.Where(p => p.Enabled).ToList();
|
||
|
||
var defs = enabled.Select(ToDefinition).ToList();
|
||
|
||
// Union of dependency refs across the loaded (enabled) alarms — interest is registered with
|
||
// the mux only AFTER LoadAsync completes (see OnAlarmsLoaded), because the engine establishes
|
||
// its upstream SubscribeTag subscriptions inside LoadAsync; values must not arrive before then.
|
||
var depRefs = enabled
|
||
.SelectMany(p => p.DependencyRefs)
|
||
.Distinct(StringComparer.Ordinal)
|
||
.ToList();
|
||
|
||
// PipeTo marshals the completion back onto the actor thread. The continuation runs with
|
||
// CancellationToken.None so it ALWAYS executes (even after _cts fired on stop) and branches on
|
||
// the load's outcome: a clean cancel (actor stopping) pipes back a quiet AlarmsLoadCanceled; a
|
||
// genuine fault pipes back a Status.Failure we log as Warning (the prior generation's mux
|
||
// subscription stays — a failed reload should not silently drop live values); success pipes back
|
||
// AlarmsLoaded carrying the refs + the generation it was loaded under.
|
||
_engine.LoadAsync(defs, _cts.Token)
|
||
.ContinueWith(
|
||
t => t.IsCanceled ? (object)new AlarmsLoadCanceled()
|
||
: t.IsFaulted ? new Status.Failure(t.Exception!.GetBaseException())
|
||
: new AlarmsLoaded(depRefs, gen),
|
||
CancellationToken.None,
|
||
TaskContinuationOptions.ExecuteSynchronously,
|
||
TaskScheduler.Default)
|
||
.PipeTo(Self);
|
||
|
||
_log.Debug("ScriptedAlarmHost: applying (enabled={Enabled}/{Total}, depRefs={Refs})",
|
||
enabled.Count, msg.Plans.Count, depRefs.Count);
|
||
}
|
||
|
||
private void OnLoadFailed(Status.Failure msg)
|
||
=> _log.Warning(msg.Cause, "ScriptedAlarmHost: engine LoadAsync failed — alarms not (re)loaded");
|
||
|
||
private void OnAlarmsLoaded(AlarmsLoaded msg)
|
||
{
|
||
// Discard a stale completion: if a newer apply has since bumped the generation, this load's
|
||
// dep refs are superseded and registering them would re-introduce the old set. The latest
|
||
// generation's continuation will (or already did) register the current interest set.
|
||
if (msg.Generation != _loadGeneration)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Register mux interest only now that LoadAsync has returned — the engine's upstream
|
||
// subscriptions exist, so any DependencyValueChanged the mux delivers will be observed by the
|
||
// engine. RegisterInterest replaces a subscriber's prior interest set (see DependencyMuxActor),
|
||
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
|
||
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
|
||
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
|
||
}
|
||
|
||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||
{
|
||
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
|
||
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
|
||
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
|
||
}
|
||
|
||
private void OnEngineEmission(EngineEmission msg)
|
||
{
|
||
var e = msg.Event;
|
||
|
||
// None = no meaningful change; Suppressed = shelving ate the emission. Neither should reach a
|
||
// sink. (The engine already filters these out of BuildEmission, but guard defensively.)
|
||
if (e.Emission is EmissionKind.None or EmissionKind.Suppressed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 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,
|
||
State: ToSnapshot(e),
|
||
TimestampUtc: e.TimestampUtc));
|
||
|
||
// Publish the transition to the cluster `alerts` topic — the single historization + live
|
||
// fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here.
|
||
var evt = new AlarmTransitionEvent(
|
||
AlarmId: e.AlarmId,
|
||
EquipmentPath: e.EquipmentPath,
|
||
AlarmName: e.AlarmName,
|
||
TransitionKind: e.Emission.ToString(),
|
||
Severity: SeverityToInt(e.Severity),
|
||
Message: e.Message,
|
||
User: TransitionUser(e),
|
||
TimestampUtc: e.TimestampUtc);
|
||
|
||
_mediator.Tell(new Publish(AlertsTopic, evt));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drives an inbound OPC UA Part 9 alarm method call (delivered as an <see cref="AlarmCommand"/>
|
||
/// on the cluster <c>alarm-commands</c> topic) onto the matching <see cref="ScriptedAlarmEngine"/>
|
||
/// operation.
|
||
///
|
||
/// <para>
|
||
/// <b>Ownership filter.</b> The topic is a cluster-wide broadcast; every host node receives
|
||
/// every command, but each owns a disjoint subset of alarms (its engine's loaded set). A
|
||
/// command for an alarm this engine does NOT own is a no-op — the owning node will act on it.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>No re-projection.</b> The engine op raises <see cref="ScriptedAlarmEngine.OnEvent"/> on
|
||
/// success, which already marshals back to <see cref="OnEngineEmission"/> and re-projects the
|
||
/// condition to the OPC UA node + the alerts topic. So this handler just calls the op and
|
||
/// awaits; it never touches the publish actor directly.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Async on the actor thread.</b> The handler is a <c>Task</c>-returning
|
||
/// <c>ReceiveAsync</c> (this project's AK2003 analyzer forbids an async-void Receive
|
||
/// delegate). Akka suspends the actor's mailbox until the returned task completes, so the op
|
||
/// runs ordered + awaited on the actor thread — never overlapping the next message. The engine
|
||
/// also serialises every operation behind its own <c>_evalGate</c> and marshals every emission
|
||
/// back via <c>Self.Tell</c> (never touching <see cref="Context"/> off-thread). The whole body
|
||
/// is wrapped in a try/catch so a faulting op can never escape the handler and fault the actor
|
||
/// — failures are logged like <see cref="OnLoadFailed"/> and swallowed.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <param name="cmd">The inbound alarm command.</param>
|
||
private async Task OnAlarmCommand(AlarmCommand cmd)
|
||
{
|
||
// Ownership filter FIRST: ignore commands for alarms this engine doesn't own. The topic is a
|
||
// cluster-wide broadcast, so the same command lands on every host — only the owner acts.
|
||
if (!_engine.LoadedAlarmIds.Contains(cmd.AlarmId))
|
||
{
|
||
_log.Debug("ScriptedAlarmHost: ignoring AlarmCommand {Op} for unowned alarm {AlarmId}",
|
||
cmd.Operation, cmd.AlarmId);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
switch (cmd.Operation)
|
||
{
|
||
case "Acknowledge":
|
||
await _engine.AcknowledgeAsync(cmd.AlarmId, cmd.User, cmd.Comment, CancellationToken.None);
|
||
break;
|
||
case "Confirm":
|
||
await _engine.ConfirmAsync(cmd.AlarmId, cmd.User, cmd.Comment, CancellationToken.None);
|
||
break;
|
||
case "OneShotShelve":
|
||
await _engine.OneShotShelveAsync(cmd.AlarmId, cmd.User, CancellationToken.None);
|
||
break;
|
||
case "TimedShelve":
|
||
// A timed shelve needs the absolute unshelve instant. T18 derives it from the OPC UA
|
||
// Duration (UtcNow + shelvingTime); a command missing it is malformed — log + reject
|
||
// rather than throw (a throw out of this async void would crash the actor).
|
||
if (cmd.UnshelveAtUtc is not { } unshelveAt)
|
||
{
|
||
_log.Warning("ScriptedAlarmHost: rejecting TimedShelve for {AlarmId} — missing UnshelveAtUtc",
|
||
cmd.AlarmId);
|
||
return;
|
||
}
|
||
await _engine.TimedShelveAsync(cmd.AlarmId, cmd.User, unshelveAt, CancellationToken.None);
|
||
break;
|
||
case "Unshelve":
|
||
await _engine.UnshelveAsync(cmd.AlarmId, cmd.User, CancellationToken.None);
|
||
break;
|
||
case "Enable":
|
||
await _engine.EnableAsync(cmd.AlarmId, cmd.User, CancellationToken.None);
|
||
break;
|
||
case "Disable":
|
||
await _engine.DisableAsync(cmd.AlarmId, cmd.User, CancellationToken.None);
|
||
break;
|
||
case "AddComment":
|
||
// AddComment's text is required by the engine (ApplyAddComment takes a non-null text);
|
||
// coalesce a null comment to empty so a comment-less AddComment is still a valid no-op
|
||
// rather than an NRE.
|
||
await _engine.AddCommentAsync(cmd.AlarmId, cmd.User, cmd.Comment ?? string.Empty, CancellationToken.None);
|
||
break;
|
||
default:
|
||
_log.Warning("ScriptedAlarmHost: ignoring AlarmCommand with unknown operation {Op} for {AlarmId}",
|
||
cmd.Operation, cmd.AlarmId);
|
||
break;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// A failing engine op must not crash the actor — mirror OnLoadFailed's log-and-stay-inert style.
|
||
_log.Warning(ex, "ScriptedAlarmHost: engine op {Op} failed for alarm {AlarmId}",
|
||
cmd.Operation, cmd.AlarmId);
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void PreStart()
|
||
{
|
||
// Resolve the cluster DPS mediator once, on the actor thread, so emissions only Tell it.
|
||
_mediator = DistributedPubSub.Get(Context.System).Mediator;
|
||
// Subscribe to the `alarm-commands` topic so inbound OPC UA Part 9 method calls (published by
|
||
// the node manager's condition handlers, T18) land here as AlarmCommands. The Subscribe is sent
|
||
// from Self so the SubscribeAck returns to this actor (handled as a no-op in the ctor wiring).
|
||
_mediator.Tell(new Subscribe(AlarmCommandsTopic, Self));
|
||
base.PreStart();
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void PostStop()
|
||
{
|
||
// Unregister mux interest first so no further DependencyValueChanged arrives while we tear down.
|
||
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
||
// Cancel any in-flight LoadAsync, detach the OnEvent handler (so a late engine emission can't
|
||
// Tell a stopping actor), then dispose the engine (which drains its background work + clears).
|
||
_cts.Cancel();
|
||
_engine.OnEvent -= _onEngineEvent;
|
||
_engine.Dispose();
|
||
_cts.Dispose();
|
||
base.PostStop();
|
||
}
|
||
|
||
/// <summary>Maps an <see cref="EquipmentScriptedAlarmPlan"/> to the engine's
|
||
/// <see cref="ScriptedAlarmDefinition"/>. <see cref="EquipmentScriptedAlarmPlan.AlarmType"/> is a
|
||
/// string that must parse to a defined <see cref="AlarmKind"/> NAME; an unrecognised type — including
|
||
/// a numeric string like "5" or "-1", which <see cref="Enum.TryParse{TEnum}(string, out TEnum)"/>
|
||
/// would otherwise accept as an undefined value — falls back to <see cref="AlarmKind.AlarmCondition"/>
|
||
/// rather than dropping the alarm.</summary>
|
||
private static ScriptedAlarmDefinition ToDefinition(EquipmentScriptedAlarmPlan p) => new(
|
||
AlarmId: p.ScriptedAlarmId,
|
||
EquipmentPath: p.EquipmentId,
|
||
AlarmName: p.Name,
|
||
Kind: Enum.TryParse<AlarmKind>(p.AlarmType, out var k) && Enum.IsDefined(k) ? k : AlarmKind.AlarmCondition,
|
||
Severity: SeverityFromInt(p.Severity),
|
||
MessageTemplate: p.MessageTemplate,
|
||
PredicateScriptSource: p.PredicateSource,
|
||
HistorizeToAveva: p.HistorizeToAveva,
|
||
Retain: p.Retain);
|
||
|
||
/// <summary>Maps a <see cref="ScriptedAlarmEvent"/>'s Core <see cref="AlarmConditionState"/> +
|
||
/// severity/message down to the Commons <see cref="AlarmConditionSnapshot"/> the SDK sink projects.
|
||
/// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine
|
||
/// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind
|
||
/// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary>
|
||
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);
|
||
|
||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||
private static AlarmShelvingKind MapShelving(ShelvingKind kind) => kind switch
|
||
{
|
||
ShelvingKind.OneShot => AlarmShelvingKind.OneShot,
|
||
ShelvingKind.Timed => AlarmShelvingKind.Timed,
|
||
_ => AlarmShelvingKind.Unshelved,
|
||
};
|
||
|
||
/// <summary>The acting user for an <see cref="AlarmTransitionEvent"/>. Engine-driven
|
||
/// Activated / Cleared transitions are <c>"system"</c>; operator Acknowledged / Confirmed carry the
|
||
/// recorded user from the condition state, falling back to <c>"system"</c> when none was recorded.</summary>
|
||
private static string TransitionUser(ScriptedAlarmEvent e) => e.Emission switch
|
||
{
|
||
EmissionKind.Acknowledged => e.Condition.LastAckUser ?? "system",
|
||
EmissionKind.Confirmed => e.Condition.LastConfirmUser ?? "system",
|
||
_ => "system",
|
||
};
|
||
|
||
// Severity conversion convention: the engine + plan disagree on type (engine = AlarmSeverity enum,
|
||
// plan + AlarmTransitionEvent = OPC UA 1–1000 int). We bucket the int into quartiles on the way IN
|
||
// and emit the quartile ceiling on the way OUT, so a round-trip is stable within a bucket.
|
||
|
||
/// <summary>Buckets an OPC UA 1–1000 severity into the engine's coarse <see cref="AlarmSeverity"/>
|
||
/// enum: ≤250 Low, ≤500 Medium, ≤750 High, else Critical.</summary>
|
||
private static AlarmSeverity SeverityFromInt(int s) =>
|
||
s <= 250 ? AlarmSeverity.Low
|
||
: s <= 500 ? AlarmSeverity.Medium
|
||
: s <= 750 ? AlarmSeverity.High
|
||
: AlarmSeverity.Critical;
|
||
|
||
/// <summary>Maps the engine's coarse <see cref="AlarmSeverity"/> back to an OPC UA 1–1000 severity
|
||
/// at each bucket's ceiling: Low=250, Medium=500, High=750, Critical=1000.</summary>
|
||
private static int SeverityToInt(AlarmSeverity s) => s switch
|
||
{
|
||
AlarmSeverity.Low => 250,
|
||
AlarmSeverity.Medium => 500,
|
||
AlarmSeverity.High => 750,
|
||
AlarmSeverity.Critical => 1000,
|
||
_ => 500,
|
||
};
|
||
}
|