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:
Joseph Doherty
2026-10-09 04:30:56 -04:00
parent 83d8de303c
commit 5d7c53bb19
8 changed files with 105 additions and 10 deletions
@@ -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)