fix(abcip): ALMA review round 2 - IsRestatement contract docs, ALMD no-InAlarm warning, un-ack retrigger
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -107,8 +107,12 @@ public sealed record AlarmAcknowledgeRequest(
|
||||
/// watched happen: a reconnect snapshot entry (Galaxy) or the first event for a condition after a driver start or
|
||||
/// deploy respawn (OpcUaClient). It governs <b>history only, never node state</b>: the host still projects the
|
||||
/// state, but writes no <c>alerts</c> row when the re-statement changes nothing the condition shows (a Retrigger on
|
||||
/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition).
|
||||
/// <c>false</c> (the default, every source that does not opt in) keeps every transition's row.
|
||||
/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition) — or
|
||||
/// when a flagged <see cref="AlarmTransitionKind.Unspecified"/> update moves the message/severity of an
|
||||
/// already-active condition (node updated, one condition event, active/acked kept, no row). AbCip ALMA sets the
|
||||
/// flag for the last shape (the governing limit changed, no new occurrence); OpcUaClient's first sight of an
|
||||
/// inactive condition after a respawn can reach it too. <c>false</c> (the default, every source that does not
|
||||
/// opt in) keeps every transition's row.
|
||||
/// </param>
|
||||
public sealed record AlarmEventArgs(
|
||||
IAlarmSubscriptionHandle SubscriptionHandle,
|
||||
|
||||
@@ -38,6 +38,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
private readonly HashSet<string> _warnedNoInAlarm = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AbCipAlarmProjection"/> class.</summary>
|
||||
/// <param name="driver">The AB CIP driver instance.</param>
|
||||
@@ -285,6 +286,8 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private bool WarnOnce(string id) { lock (_warnedNoInAlarm) return _warnedNoInAlarm.Add(id); }
|
||||
|
||||
private List<NodePlan> BuildPlans(IReadOnlyList<string> nodeIds)
|
||||
{
|
||||
var plans = new List<NodePlan>(nodeIds.Count);
|
||||
@@ -293,6 +296,16 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
AlmaPlan? alma = null;
|
||||
if (_driver.TryGetTagDefinition(id, out var def) && AbCipAlarmDetector.IsAlma(def))
|
||||
alma = AlmaPlan.From(def);
|
||||
else if (def is { DataType: AbCipDataType.Structure, Members: { } members }
|
||||
&& !members.Any(m => string.Equals(m.Name, "InAlarm", StringComparison.OrdinalIgnoreCase))
|
||||
&& WarnOnce(id))
|
||||
{
|
||||
// ALMD's active bit is InAlarm; a tag that declared members without it (e.g. a pre-hw4 config that
|
||||
// declared InFaulted) reads BadNodeIdUnknown every tick and would stay silent forever.
|
||||
_logger.LogWarning(
|
||||
"AbCip alarm ref {Ref} declares members but no 'InAlarm'; the ALMD projection polls InAlarm + Severity, " +
|
||||
"so this alarm will never raise. Add InAlarm (InFaulted is the input-health flag, not the alarm).", id);
|
||||
}
|
||||
plans.Add(new NodePlan(alma));
|
||||
}
|
||||
return plans;
|
||||
@@ -307,6 +320,8 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
/// <item>a limit NEWLY entering: Retrigger (a new unacknowledged occurrence) — or ActiveAcknowledged when
|
||||
/// nothing is pending ack (AckRequired false), never Retrigger-then-Acknowledge (#656).</item>
|
||||
/// <item>no new limit, every declared limit now acked while the condition was unacked: Acknowledge.</item>
|
||||
/// <item>no new limit, the condition was fully acked and a declared <c>*Acked</c> read (Good) is now false:
|
||||
/// Retrigger — an occurrence the poll missed.</item>
|
||||
/// <item>no new limit, the governing limit or its severity changed (a drop-out): an
|
||||
/// <c>Unspecified</c> + <c>IsRestatement</c> state update — the projector keeps active/acked and writes
|
||||
/// NO alerts row; anything else here would render a false "Activated".</item>
|
||||
@@ -386,6 +401,14 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
}
|
||||
|
||||
_driver.AlarmAnalogRecord.Set(nodeId, mask, nowAcked, topId, topSev);
|
||||
if (ackKnown && prior.Acked && !allAcked)
|
||||
{
|
||||
// Every limit was acked and now one is not, with the same limits active: an occurrence the poll missed (a
|
||||
// limit left and re-entered inside one interval) or one a Bad *Acked read hid on the tick it entered.
|
||||
// Either way the PLC says unacknowledged, so tell it — a new unacknowledged occurrence.
|
||||
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Retrigger, message, topSev));
|
||||
return;
|
||||
}
|
||||
if (nowAcked && !prior.Acked)
|
||||
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Acknowledge, message, topSev));
|
||||
else if (topId != prior.Top || topSev != prior.TopSeverity)
|
||||
|
||||
@@ -52,8 +52,10 @@ public sealed class NativeAlarmProjector
|
||||
/// <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>
|
||||
/// a Clear on an already-inactive condition; and a flagged Unspecified update of an already-active
|
||||
/// condition moves its message/severity (node + one condition event) with active/acked kept and no row
|
||||
/// (AbCip ALMA's governing-limit change). Unflagged sources (FOCAS, MTConnect, 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>
|
||||
@@ -110,6 +112,9 @@ public sealed class NativeAlarmProjector
|
||||
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).
|
||||
// OpcUaClient can reach it too: first sight after a respawn of an upstream-inactive condition is told
|
||||
// Unspecified+flagged; with the host still Active the node stays Active exactly as before (a pre-existing
|
||||
// gap) and the false "Activated" row it used to write is now gone.
|
||||
// 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, []);
|
||||
|
||||
Reference in New Issue
Block a user