83d8de303c
M1 drop-out is a no-row Unspecified+IsRestatement update (projector case); M2 ack judged over every declared limit; L1 ActiveAcknowledged for an already-acked new limit; L2 Bad Acked/severity carried/skipped; L3 host end-to-end test; L4 BOOL member-by-member tests + mixed ALMD/ALMA; L5 detection requires no plain Acked. ALMD active bit is InAlarm per Rockwell's ALMD reference; InFaulted is input health. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
173 lines
11 KiB
C#
173 lines
11 KiB
C#
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
/// <summary>
|
|
/// Derives a full Part 9 <see cref="AlarmConditionSnapshot"/> from each native
|
|
/// <see cref="AlarmEventArgs"/> delta, tracking per-condition-NodeId prior state. Owned by the
|
|
/// single-threaded <c>DriverHostActor</c> (no locking). Native alarms carry only a transition
|
|
/// <see cref="AlarmTransitionKind"/>, not a full state machine, so this is the translation the
|
|
/// scripted-alarm engine does internally.
|
|
/// </summary>
|
|
public sealed class NativeAlarmProjector
|
|
{
|
|
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity)> _prior =
|
|
new(StringComparer.Ordinal);
|
|
|
|
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
|
|
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
|
/// <param name="e">The native alarm transition.</param>
|
|
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
|
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out IReadOnlyList<AlarmTransitionKind> _);
|
|
|
|
/// <summary>
|
|
/// Project an alarm transition, reporting whether it was a <b>re-statement</b> — a transition that writes no
|
|
/// <c>alerts</c> row (see <see cref="Project(string, AlarmEventArgs, out IReadOnlyList{AlarmTransitionKind})"/>).
|
|
/// </summary>
|
|
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
|
/// <param name="e">The native alarm transition.</param>
|
|
/// <param name="restatement">True when <paramref name="e"/> produced no history row.</param>
|
|
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
|
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out bool restatement)
|
|
{
|
|
var snapshot = Project(nodeId, e, out IReadOnlyList<AlarmTransitionKind> historyKinds);
|
|
restatement = historyKinds.Count == 0;
|
|
return snapshot;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Project an alarm transition and report the <c>alerts</c> history rows it warrants, in order. Node state
|
|
/// and history are decided separately: a transition always projects a snapshot, but only a transition that
|
|
/// actually CHANGED what the condition shows (or that a source reports as genuinely new) writes a row.
|
|
/// <list type="bullet">
|
|
/// <item>Gitea #647 review — a <see cref="AlarmTransitionKind.Raise"/> on a condition already projected
|
|
/// Active WITH THE SAME MESSAGE (re-review R1 — a different message is a different alarm, and un-acks) is a
|
|
/// re-statement: what a driver whose told-record died with its instance sends after a deploy respawns
|
|
/// it. The acknowledgement is KEPT and no row is written. Only <see cref="AlarmTransitionKind.Retrigger"/>
|
|
/// (or inactive → active) un-acknowledges.</item>
|
|
/// <item>Gitea #656 — <see cref="AlarmTransitionKind.ActiveAcknowledged"/> projects Active+acked in ONE
|
|
/// update (never an Active+unacked intermediate a client could see) and writes "Activated" only when the
|
|
/// condition was not already showing that alarm active, then "Acknowledged" only when it was not acked.</item>
|
|
/// <item>Gitea #656 — a <see cref="AlarmEventArgs.IsRestatement"/> event (a source re-stating upstream's
|
|
/// current state: a reconnect snapshot, first sight after a respawn) writes no row when it changes nothing
|
|
/// the condition shows: a Retrigger on an Active+unacked condition with the same message AND severity, or
|
|
/// a Clear on an already-inactive condition. Unflagged sources (FOCAS, MTConnect, AbCip, live Galaxy
|
|
/// transitions) are unaffected — a live Retrigger is a genuine re-occurrence and keeps its row.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
|
/// <param name="e">The native alarm transition.</param>
|
|
/// <param name="historyKinds">
|
|
/// The <c>alerts</c> rows to write, in order — each a <see cref="AlarmTransitionKind.Raise"/>/<see cref="AlarmTransitionKind.Retrigger"/>
|
|
/// ("Activated"), <see cref="AlarmTransitionKind.Acknowledge"/>, <see cref="AlarmTransitionKind.Clear"/> or
|
|
/// <see cref="AlarmTransitionKind.Unspecified"/>. Empty when the transition is a re-statement.
|
|
/// </param>
|
|
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
|
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out IReadOnlyList<AlarmTransitionKind> historyKinds)
|
|
{
|
|
var prev = _prior.TryGetValue(nodeId, out var p)
|
|
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0);
|
|
// #626: a source with a finer scale than the four buckets supplies the exact Part 9 severity.
|
|
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
|
|
// Gitea #647 re-review R1: only a statement saying the SAME thing the condition already shows is a re-statement.
|
|
// A respawned driver cannot tell "the alarm I was showing" from "a different one that appeared while I was
|
|
// down", so a different message is treated as a new statement — un-acked, with its alerts row. Comparing the
|
|
// message is conservative: any difference (a sources' messages are deterministic for an unchanged statement)
|
|
// errs toward showing the operator an unacknowledged alarm, never toward hiding one behind a stale ack.
|
|
var sameMessage = prev.Active
|
|
&& string.Equals(prev.Message?.Trim(), e.Message.Trim(), StringComparison.Ordinal);
|
|
bool active, acked;
|
|
switch (e.Kind)
|
|
{
|
|
case AlarmTransitionKind.Raise when sameMessage:
|
|
(active, acked, historyKinds) = (true, prev.Acked, []);
|
|
break;
|
|
// #656: a source re-stating an Active+unacked condition that already shows exactly this (message AND
|
|
// severity — an MTConnect Warning→Fault escalation can keep the text) changes nothing the node shows.
|
|
case AlarmTransitionKind.Retrigger when e.IsRestatement && sameMessage && !prev.Acked && prev.Severity == sev:
|
|
(active, acked, historyKinds) = (true, false, []);
|
|
break;
|
|
case AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger:
|
|
(active, acked, historyKinds) = (true, false, [e.Kind]);
|
|
break;
|
|
case AlarmTransitionKind.ActiveAcknowledged:
|
|
(active, acked) = (true, true);
|
|
historyKinds = !sameMessage
|
|
? [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]
|
|
: !prev.Acked ? [AlarmTransitionKind.Acknowledge] : [];
|
|
break;
|
|
case AlarmTransitionKind.Acknowledge:
|
|
(active, acked, historyKinds) = (prev.Active, true, [AlarmTransitionKind.Acknowledge]);
|
|
break;
|
|
// #656: a re-stated Clear of a condition that is already inactive changes nothing (Galaxy replays every
|
|
// Inactive snapshot entry on each reconnect).
|
|
case AlarmTransitionKind.Clear when e.IsRestatement && !prev.Active:
|
|
(active, acked, historyKinds) = (false, prev.Acked, []);
|
|
break;
|
|
case AlarmTransitionKind.Clear:
|
|
(active, acked, historyKinds) = (false, prev.Acked, [AlarmTransitionKind.Clear]);
|
|
break;
|
|
// A message/severity-only update of an ALREADY-ACTIVE condition that the source flags as a re-statement
|
|
// (AbCip ALMA: the governing limit changed but no new occurrence started, e.g. HH dropped back to H).
|
|
// The node's text/severity move; active/acked are kept; NO row (an "Activated" row would be false).
|
|
case AlarmTransitionKind.Unspecified when e.IsRestatement && prev.Active:
|
|
(active, acked, historyKinds) = (true, prev.Acked, []);
|
|
break;
|
|
default:
|
|
// Unspecified (a message/severity-only update) or an unknown future kind: keep the state. The row is
|
|
// written as before (Unspecified renders "Activated") — unchanged behaviour.
|
|
(active, acked, historyKinds) = (prev.Active, prev.Acked, [e.Kind]);
|
|
break;
|
|
}
|
|
|
|
_prior[nodeId] = (active, acked, e.Message, sev);
|
|
return new AlarmConditionSnapshot(
|
|
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
|
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
|
|
// #626: a per-condition source quality (MTConnect Unavailable) when the driver reports one. Absent
|
|
// (every pre-#626 source) ⇒ Good, as before — driver-wide comms loss stays the host's
|
|
// out-of-band WriteAlarmQuality annotation (#477), not something the projector infers.
|
|
StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #641 review — record a client acknowledgement the node manager applied to <paramref name="nodeId"/>
|
|
/// and routed toward the driver. The projector otherwise never sees it, and a later Clear would project the
|
|
/// stale unacknowledged prior. An untracked condition already defaults to acknowledged.
|
|
/// </summary>
|
|
/// <param name="nodeId">The acknowledged condition node id.</param>
|
|
public void MarkAcknowledged(string nodeId)
|
|
{
|
|
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = p with { Acked = true };
|
|
}
|
|
|
|
/// <summary>Clears all tracked per-node state.</summary>
|
|
public void Clear() => _prior.Clear();
|
|
|
|
/// <summary>
|
|
/// Gitea #641 — drop the tracked state of every condition NOT in <paramref name="deployedNodeIds"/>, keeping
|
|
/// the rest. Called on every apply instead of <see cref="Clear"/>: a condition that is still deployed keeps
|
|
/// its state across the deploy (the node manager carries the node's state across a rebuild too), so the
|
|
/// next Acknowledge does not project <c>Active = false</c> onto an active alarm and the next Clear does not
|
|
/// project <c>Acked = true</c> onto an unacknowledged one. A removed condition is forgotten, so a later
|
|
/// re-add starts from the default.
|
|
/// </summary>
|
|
/// <param name="deployedNodeIds">The condition node ids the new composition still materialises.</param>
|
|
public void RetainOnly(IReadOnlySet<string> deployedNodeIds)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(deployedNodeIds);
|
|
foreach (var nodeId in _prior.Keys.Where(k => !deployedNodeIds.Contains(k)).ToList())
|
|
_prior.Remove(nodeId);
|
|
}
|
|
|
|
private static ushort MapSeverity(AlarmSeverity s) => s switch
|
|
{
|
|
AlarmSeverity.Low => 200,
|
|
AlarmSeverity.Medium => 500,
|
|
AlarmSeverity.High => 700,
|
|
AlarmSeverity.Critical => 900,
|
|
_ => 500,
|
|
};
|
|
}
|