feat(abcip): ALMA (ALARM_ANALOG) alarm projection (#611 item 1)

Detect ALMA by declared *InAlarm members; one condition per tag keyed on RawPath; severity/message from
the most severe active limit; Raise/ActiveAcknowledged/Retrigger/Clear/Acknowledge; ack writes OperAckAll.
Unit-tested over fake runtimes only (no ab_server ALMA support, Z35162 has none).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 04:09:47 -04:00
parent 82750cccb9
commit b3838fcce9
4 changed files with 532 additions and 20 deletions
@@ -5,18 +5,18 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
/// polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members at a
/// Projects AB Logix ALMD (digital) and ALMA (analog, <c>ALARM_ANALOG</c>) alarm instructions onto the OPC UA
/// alarm surface by polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members (or the
/// ALMA limit/ack/severity members) at a
/// configurable interval + translating state transitions into <c>OnAlarmEvent</c>
/// callbacks on the owning <see cref="AbCipDriver"/>. Feature-flagged off by default via
/// <see cref="AbCipDriverOptions.EnableAlarmProjection"/>; callers that leave the flag off
/// get a no-op subscribe path so capability negotiation still works.
/// </summary>
/// <remarks>
/// <para>ALMD-only in this pass. ALMA (analog alarm) projection is a follow-up because
/// its threshold + limit semantics need more design — ALMD's "is the alarm active + has
/// the operator acked" shape maps cleanly onto the driver-agnostic
/// <see cref="IAlarmSource"/> contract without concessions.</para>
/// <para>A subscribed ref is treated as ALMA when its declared tag definition carries any of the six per-limit
/// <c>*InAlarm</c> members (<see cref="AbCipAlarmDetector.IsAlma"/>); every other ref is ALMD (the historic
/// default, including refs with no declared members). See <see cref="AbCipAlmaLimit"/> for the ALMA mapping.</para>
///
/// <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the
/// whole-UDT optimization for free when the ALMD is declared with its standard members.
@@ -61,6 +61,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
var handle = new AbCipAlarmSubscriptionHandle(id);
var cts = new CancellationTokenSource();
var sub = new Subscription(handle, [..sourceNodeIds], cts);
sub.Plans = BuildPlans(sub.SourceNodeIds);
// Collapse to a SINGLE active poll loop. The owning DriverInstanceActor re-subscribes
// alarms on every Connected entry (its DetachAlarmSource nulls the cached handle on
@@ -75,6 +76,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
// server carries the condition across a rebuild, and a Raise would un-acknowledge it. A ref that left and
// comes back was materialised fresh, so it starts from "not faulted" and is raised again.
_driver.AlarmFaultRecord.RetainOnly(sub.SourceNodeIds);
_driver.AlarmAnalogRecord.RetainOnly(sub.SourceNodeIds);
List<Subscription> stale;
lock (_subsLock)
@@ -146,8 +148,13 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
// Write Acked=1 per request. IWritable isn't on AbCipAlarmProjection so route through
// the driver's public interface — delegating instead of re-implementing the write path
// keeps the bit-in-DINT + idempotency + per-call-host-resolve knobs intact.
// ALMA has no top-level Acked status bit (the per-limit *Acked bits are instruction OUTPUTS); the designed
// operator-ack input is OperAckAll, so an ALMA ref gets that instead.
var requests = acknowledgements
.Select(a => new WriteRequest($"{a.SourceNodeId}.Acked", true))
.Select(a => new WriteRequest(
_driver.TryGetTagDefinition(a.SourceNodeId, out var def) && AbCipAlarmDetector.IsAlma(def)
? $"{a.SourceNodeId}.OperAckAll"
: $"{a.SourceNodeId}.Acked", true))
.ToArray();
// Best-effort — the driver's WriteAsync returns per-item status; individual ack
// failures don't poison the batch. Swallow the return so a single faulted ack
@@ -180,12 +187,21 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
/// <param name="results">The data values read from the subscription source nodes.</param>
internal void Tick(Subscription sub, IReadOnlyList<DataValueSnapshot> results)
{
// results index layout: for each sourceNode, [InFaulted, Severity] in order.
// results index layout: per sourceNode, ALMD = [InFaulted, Severity]; ALMA = the plan's member list.
var cursor = 0;
for (var i = 0; i < sub.SourceNodeIds.Count; i++)
{
var nodeId = sub.SourceNodeIds[i];
var inFaultedDv = results[i * 2];
var severityDv = results[i * 2 + 1];
var plan = sub.Plans[i];
if (plan.Alma is not null)
{
TickAlma(sub, nodeId, plan.Alma, results, cursor);
cursor += plan.Alma.RefCount;
continue;
}
var inFaultedDv = results[cursor];
var severityDv = results[cursor + 1];
cursor += 2;
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
var nowFaulted = ToBool(inFaultedDv.Value);
@@ -228,8 +244,10 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
{
var refs = new List<string>(sub.SourceNodeIds.Count * 2);
foreach (var nodeId in sub.SourceNodeIds)
for (var i = 0; i < sub.SourceNodeIds.Count; i++)
{
var nodeId = sub.SourceNodeIds[i];
if (sub.Plans[i].Alma is { } alma) { refs.AddRange(alma.Refs(nodeId)); continue; }
refs.Add($"{nodeId}.InFaulted");
refs.Add($"{nodeId}.Severity");
}
@@ -258,6 +276,87 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
}
}
private List<NodePlan> BuildPlans(IReadOnlyList<string> nodeIds)
{
var plans = new List<NodePlan>(nodeIds.Count);
foreach (var id in nodeIds)
{
AlmaPlan? alma = null;
if (_driver.TryGetTagDefinition(id, out var def) && AbCipAlarmDetector.IsAlma(def))
alma = AlmaPlan.From(def);
plans.Add(new NodePlan(alma));
}
return plans;
}
/// <summary>
/// ALMA tick for one node. Active = any polled limit bit set; the condition's severity/message come from the
/// MOST SEVERE active limit (ties: HH, LL, H, L, ROC+, ROC-). Transitions: none-&gt;active Raise (or
/// ActiveAcknowledged when every active limit is already acked), active-&gt;none Clear, a NEWLY entered limit
/// while active Retrigger (a new, unacknowledged occurrence), a pure shrink/severity change an
/// Unspecified-kind update (state kept), and an all-acked flip while active an Acknowledge.
/// </summary>
private void TickAlma(Subscription sub, string nodeId, AlmaPlan plan, IReadOnlyList<DataValueSnapshot> results, int at)
{
var mask = 0;
var ackedAll = true;
var ackKnown = true;
AbCipAlmaLimit? top = null;
var topSev = int.MinValue;
for (var k = 0; k < plan.Limits.Count; k++)
{
var lim = plan.Limits[k];
var active = results[at + plan.InAlarmIndex(k)];
// A Bad limit bit leaves the whole node unjudged this tick (matches ALMD skipping a Bad InFaulted).
if (active.StatusCode != AbCipStatusMapper.Good) return;
if (!ToBool(active.Value)) continue;
mask |= 1 << (int)lim;
if (plan.AckedIndex(k) is { } ai)
{
var acked = results[at + ai];
if (acked.StatusCode != AbCipStatusMapper.Good) ackKnown = false;
else if (!ToBool(acked.Value)) ackedAll = false;
}
else ackKnown = false;
var sev = plan.SeverityIndex(k) is { } si ? Math.Clamp(ToInt(results[at + si].Value), 1, 1000) : 1;
if (sev > topSev) { topSev = sev; top = lim; }
}
var nowAcked = mask != 0 && ackKnown && ackedAll;
var (wasMask, wasAcked) = _driver.AlarmAnalogRecord.Exchange(nodeId, mask, nowAcked);
if (mask == 0 && wasMask == 0) return;
AlarmEventArgs Build(AlarmTransitionKind kind, string message, int sev) => new(
sub.Handle, nodeId, ConditionId: nodeId, AlarmType: "ALMA", Message: message,
Severity: MapSeverity(sev), SourceTimestampUtc: DateTime.UtcNow, Kind: kind, OpcUaSeverity: sev);
if (mask == 0)
{
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Clear, $"ALMA {nodeId} cleared", 1));
return;
}
var message = $"ALMA {nodeId} {top!.Value.Label()} alarm";
if (wasMask == 0)
{
_driver.InvokeAlarmEvent(Build(
nowAcked ? AlarmTransitionKind.ActiveAcknowledged : AlarmTransitionKind.Raise, message, topSev));
return;
}
if ((mask & ~wasMask) != 0)
{
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Retrigger, message, topSev));
if (nowAcked) _driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Acknowledge, message, topSev));
return;
}
if (mask != wasMask)
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Unspecified, message, topSev));
if (nowAcked && !wasAcked)
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Acknowledge, message, topSev));
}
/// <summary>Maps a raw severity value to an <see cref="AlarmSeverity"/> enum value.</summary>
/// <param name="raw">The raw severity value from the alarm data.</param>
/// <returns>The corresponding alarm severity level.</returns>
@@ -286,6 +385,52 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
_ => 0,
};
internal sealed record NodePlan(AlmaPlan? Alma);
/// <summary>The members one ALMA node polls, in the order they are read: all <c>*InAlarm</c> bits, then the
/// <c>*Acked</c> bits, then the <c>*Severity</c> values — only for the limits the tag declares.</summary>
internal sealed class AlmaPlan
{
private readonly List<string> _inAlarm = [];
private readonly List<string?> _acked = [];
private readonly List<string?> _severity = [];
private readonly List<int?> _ackIdx = [];
private readonly List<int?> _sevIdx = [];
public List<AbCipAlmaLimit> Limits { get; } = [];
public int RefCount { get; private set; }
public static AlmaPlan From(AbCipTagDefinition def)
{
var names = def.Members!.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
var plan = new AlmaPlan();
foreach (var lim in Enum.GetValues<AbCipAlmaLimit>())
{
if (!names.Contains(lim.InAlarmMember())) continue;
plan.Limits.Add(lim);
plan._inAlarm.Add(lim.InAlarmMember());
plan._acked.Add(names.Contains(lim.AckedMember()) ? lim.AckedMember() : null);
plan._severity.Add(names.Contains(lim.SeverityMember()) ? lim.SeverityMember() : null);
}
var n = plan._inAlarm.Count;
var next = n;
foreach (var a in plan._acked) plan._ackIdx.Add(a is null ? null : next++);
foreach (var v in plan._severity) plan._sevIdx.Add(v is null ? null : next++);
plan.RefCount = next;
return plan;
}
public IEnumerable<string> Refs(string nodeId) =>
_inAlarm.Concat(_acked.Where(a => a is not null).Select(a => a!))
.Concat(_severity.Where(v => v is not null).Select(v => v!))
.Select(m => $"{nodeId}.{m}");
public int InAlarmIndex(int k) => k;
public int? AckedIndex(int k) => _ackIdx[k];
public int? SeverityIndex(int k) => _sevIdx[k];
}
internal sealed class Subscription
{
/// <summary>Initializes a new instance of the <see cref="Subscription"/> class.</summary>
@@ -308,6 +453,9 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
/// <summary>Gets or sets the polling loop task.</summary>
public Task Loop { get; set; } = Task.CompletedTask;
/// <summary>Per-source-node read plan (ALMD or ALMA), index-aligned with <see cref="SourceNodeIds"/>.</summary>
public List<NodePlan> Plans { get; set; } = [];
}
}
@@ -349,6 +497,63 @@ internal sealed class AbCipAlarmFaultRecord
}
}
/// <summary>
/// The ALMA (<c>ALARM_ANALOG</c>) alarm conditions. Declaration order is the severity tie-break order (the first
/// limit wins an equal-severity tie). Member names per Rockwell's Studio 5000 "Analog Alarm (ALMA)" reference:
/// <c>HHInAlarm</c>/<c>HInAlarm</c>/<c>LInAlarm</c>/<c>LLInAlarm</c>/<c>ROCPosInAlarm</c>/<c>ROCNegInAlarm</c>,
/// <c>HHAcked</c>… <c>ROCNegAcked</c>, and <c>HHSeverity</c>… <c>ROCNegSeverity</c> (DINT, 1..1000).
/// </summary>
internal enum AbCipAlmaLimit { HH, LL, H, L, ROCPos, ROCNeg }
internal static class AbCipAlmaLimitExtensions
{
private static string Prefix(AbCipAlmaLimit l) => l.ToString();
public static string InAlarmMember(this AbCipAlmaLimit l) => Prefix(l) + "InAlarm";
public static string AckedMember(this AbCipAlmaLimit l) => Prefix(l) + "Acked";
public static string SeverityMember(this AbCipAlmaLimit l) => Prefix(l) + "Severity";
/// <summary>Operator-facing name used in the alarm message.</summary>
public static string Label(this AbCipAlmaLimit l) => l switch
{
AbCipAlmaLimit.ROCPos => "ROC+",
AbCipAlmaLimit.ROCNeg => "ROC-",
_ => l.ToString(),
};
}
/// <summary>
/// The ALMA projection's last-told state per alarm RawPath: the bitmask of active limits and whether every
/// active limit was acked. Driver-owned for the same reason as <see cref="AbCipAlarmFaultRecord"/> — it must
/// survive a re-subscribe so a still-active alarm is not re-raised (which would un-acknowledge it).
/// </summary>
internal sealed class AbCipAlarmAnalogRecord
{
private readonly Dictionary<string, (int Mask, bool Acked)> _last = new(StringComparer.Ordinal);
private readonly Lock _lock = new();
/// <summary>Records the state just read, returning the prior one (<c>(0, false)</c> when none).</summary>
public (int Mask, bool Acked) Exchange(string nodeId, int mask, bool acked)
{
lock (_lock)
{
var was = _last.GetValueOrDefault(nodeId, (0, false));
_last[nodeId] = (mask, acked);
return was;
}
}
/// <summary>Forgets every ref not in <paramref name="nodeIds"/>.</summary>
public void RetainOnly(IReadOnlyList<string> nodeIds)
{
var keep = new HashSet<string>(nodeIds, StringComparer.Ordinal);
lock (_lock)
{
foreach (var k in _last.Keys.Where(k => !keep.Contains(k)).ToList()) _last.Remove(k);
}
}
}
/// <summary>Handle returned by <see cref="AbCipAlarmProjection.SubscribeAsync"/>.</summary>
public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
@@ -365,9 +570,8 @@ public static class AbCipAlarmDetector
{
/// <summary>
/// <c>true</c> when <paramref name="tag"/> is a Structure whose declared members match
/// the ALMD signature (<c>InFaulted</c> + <c>Acked</c> present). ALMA detection
/// (analog alarms with <c>HHLimit</c>/<c>HLimit</c>/<c>LLimit</c>/<c>LLLimit</c>)
/// ships as a follow-up.
/// the ALMD signature (<c>InFaulted</c> + <c>Acked</c> present). ALMA is detected by
/// <see cref="IsAlma"/>.
/// </summary>
/// <param name="tag">The tag definition to check for ALMD signature.</param>
/// <returns>True if the tag has the ALMD alarm signature; false otherwise.</returns>
@@ -377,4 +581,19 @@ public static class AbCipAlarmDetector
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
return names.Contains("InFaulted") && names.Contains("Acked");
}
/// <summary>
/// <c>true</c> when <paramref name="tag"/> is a Structure whose declared members include at least one ALMA
/// per-limit status bit (<c>HHInAlarm</c>/<c>HInAlarm</c>/<c>LInAlarm</c>/<c>LLInAlarm</c>/
/// <c>ROCPosInAlarm</c>/<c>ROCNegInAlarm</c>). Checked BEFORE <see cref="IsAlmd"/> by the projection — an
/// ALMA also exposes <c>InFaulted</c>, and the limit bits are what ALMD does not have.
/// </summary>
/// <param name="tag">The tag definition to check.</param>
/// <returns>True for an ALMA signature.</returns>
public static bool IsAlma(AbCipTagDefinition tag)
{
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
return Enum.GetValues<AbCipAlmaLimit>().Any(l => names.Contains(l.InAlarmMember()));
}
}
@@ -122,6 +122,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>Gitea #641 review — the ALMD projection's last-told InFaulted per alarm ref; outlives every
/// projection and subscription (see <see cref="AbCipAlarmFaultRecord"/>).</summary>
internal AbCipAlarmFaultRecord AlarmFaultRecord { get; } = new();
/// <summary>The ALMA projection's last-told (active-limit mask, acked) per alarm ref; driver-owned for the
/// same reason as <see cref="AlarmFaultRecord"/>.</summary>
internal AbCipAlarmAnalogRecord AlarmAnalogRecord { get; } = new();
/// <summary>Resolves an authored tag definition by RawPath (the alarm projection's ALMA/ALMD discriminator).</summary>
internal bool TryGetTagDefinition(string rawPath, out AbCipTagDefinition definition) =>
_tagsByRawPath.TryGetValue(rawPath, out definition!);
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Occurs when a subscribed tag's value changes.</summary>