Merge hw4-alma (#592/#611)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -79,7 +79,7 @@ public sealed class AbCipDriverOptions
|
||||
/// <summary>
|
||||
/// When <c>true</c>, declared ALMD tags are surfaced as alarm conditions
|
||||
/// via <c>IAlarmSource</c>; the driver polls each subscribed
|
||||
/// alarm's <c>InFaulted</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
|
||||
/// alarm's <c>InAlarm</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
|
||||
/// state transitions. Default <c>false</c> — operators explicitly opt in because
|
||||
/// projection semantics don't exactly mirror Rockwell FT Alarm & Events; shops
|
||||
/// running FT Live should keep this off + take alarms through the native route.
|
||||
|
||||
@@ -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>InAlarm</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.
|
||||
@@ -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>
|
||||
@@ -61,6 +62,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 +77,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,15 +149,26 @@ 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
|
||||
// doesn't bubble out of the caller's batch expectation.
|
||||
#pragma warning disable OTOPCUA0001 // Driver-INTERNAL self-call: the projection is owned by AbCipDriver; CapabilityInvoker wraps the driver from the dispatch layer OUTWARD, so a driver's own internal ack path must NOT re-wrap. Intentional, not a dispatch gap.
|
||||
_ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
|
||||
var written = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
// An ALMA ref whose OperAckAll member is not declared (or an ALMA-looking user UDT) fails here as
|
||||
// BadNodeIdUnknown; surface it rather than letting the ack vanish.
|
||||
for (var i = 0; i < written.Count && i < requests.Length; i++)
|
||||
if (written[i].StatusCode != AbCipStatusMapper.Good)
|
||||
_logger.LogWarning("AbCip alarm acknowledge write to {Reference} failed with status 0x{Status:X8}",
|
||||
requests[i].FullReference, written[i].StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Releases all resources associated with this alarm projection.</summary>
|
||||
@@ -172,7 +186,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Poll-tick body — reads <c>InFaulted</c> + <c>Severity</c> for every source node id
|
||||
/// Poll-tick body — reads <c>InAlarm</c> + <c>Severity</c> for every source node id
|
||||
/// in the subscription, diffs each against last-seen state, fires raise/clear events.
|
||||
/// Extracted so tests can drive one tick without standing up the Task.Run loop.
|
||||
/// </summary>
|
||||
@@ -180,19 +194,28 @@ 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 = [InAlarm, 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];
|
||||
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
|
||||
var plan = sub.Plans[i];
|
||||
if (plan.Alma is not null)
|
||||
{
|
||||
TickAlma(sub, nodeId, plan.Alma, results, cursor);
|
||||
cursor += plan.Alma.RefCount;
|
||||
continue;
|
||||
}
|
||||
var inAlarmDv = results[cursor];
|
||||
var severityDv = results[cursor + 1];
|
||||
cursor += 2;
|
||||
if (inAlarmDv.StatusCode != AbCipStatusMapper.Good) continue;
|
||||
|
||||
var nowFaulted = ToBool(inFaultedDv.Value);
|
||||
var nowFaulted = ToBool(inAlarmDv.Value);
|
||||
// severityDv.StatusCode is not checked here. When the Severity read is Bad (value null),
|
||||
// ToInt(null) returns 0 and MapSeverity buckets it as Low. This is acceptable because
|
||||
// InFaulted and Severity are members of the same ALMD UDT read in one batch, so a Good
|
||||
// InFaulted almost always implies a Good Severity. The "unknown severity → Low" fallback
|
||||
// InAlarm and Severity are members of the same ALMD UDT read in one batch, so a Good
|
||||
// InAlarm almost always implies a Good Severity. The "unknown severity → Low" fallback
|
||||
// is intentional and matches the documented behaviour.
|
||||
var severity = ToInt(severityDv.Value);
|
||||
|
||||
@@ -228,9 +251,14 @@ 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++)
|
||||
{
|
||||
refs.Add($"{nodeId}.InFaulted");
|
||||
var nodeId = sub.SourceNodeIds[i];
|
||||
if (sub.Plans[i].Alma is { } alma) { refs.AddRange(alma.Refs(nodeId)); continue; }
|
||||
// InAlarm is ALMD's alarm-active output (Rockwell ALMD reference). InFaulted (Status.1) is the INPUT
|
||||
// health flag — it is not the alarm. Until hw4 this polled InFaulted, so a live ALMD would have raised on
|
||||
// input faults and never on the alarm condition.
|
||||
refs.Add($"{nodeId}.InAlarm");
|
||||
refs.Add($"{nodeId}.Severity");
|
||||
}
|
||||
|
||||
@@ -258,6 +286,135 @@ 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);
|
||||
foreach (var id in nodeIds)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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-). What is told to the host:
|
||||
/// <list type="bullet">
|
||||
/// <item>none->active: Raise, or the atomic ActiveAcknowledged when every declared limit is acked.</item>
|
||||
/// <item>active->none: Clear.</item>
|
||||
/// <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>
|
||||
/// </list>
|
||||
/// "Acked" is judged over EVERY declared limit's <c>*Acked</c> bit, active or not: in Logix an occurrence that
|
||||
/// went back to normal unacknowledged keeps its bit false, so it must keep the condition unacknowledged
|
||||
/// rather than being silently acked by a surviving, already-acked limit. A Bad ack/severity/limit read judges
|
||||
/// nothing it could corrupt (the tick is skipped, or the ack state carried forward).
|
||||
/// </summary>
|
||||
private void TickAlma(Subscription sub, string nodeId, AlmaPlan plan, IReadOnlyList<DataValueSnapshot> results, int at)
|
||||
{
|
||||
var mask = 0;
|
||||
AbCipAlmaLimit? top = null;
|
||||
var topSev = int.MinValue;
|
||||
for (var k = 0; k < plan.Limits.Count; k++)
|
||||
{
|
||||
var active = results[at + plan.InAlarmIndex(k)];
|
||||
// A Bad limit bit leaves the whole node unjudged this tick (matches ALMD skipping a Bad InAlarm).
|
||||
if (active.StatusCode != AbCipStatusMapper.Good) return;
|
||||
if (!ToBool(active.Value)) continue;
|
||||
var lim = plan.Limits[k];
|
||||
mask |= 1 << (int)lim;
|
||||
var sev = 1;
|
||||
if (plan.SeverityIndex(k) is { } si)
|
||||
{
|
||||
var sevDv = results[at + si];
|
||||
// A Bad severity must not change the told message/severity: skip the tick.
|
||||
if (sevDv.StatusCode != AbCipStatusMapper.Good) return;
|
||||
sev = Math.Clamp(ToInt(sevDv.Value), 1, 1000);
|
||||
}
|
||||
if (sev > topSev) { topSev = sev; top = lim; }
|
||||
}
|
||||
|
||||
// Ack state over every declared *Acked bit. Unknown (none declared, or any read Bad) ⇒ carry the told value.
|
||||
var ackKnown = plan.HasAcked;
|
||||
var allAcked = true;
|
||||
for (var k = 0; k < plan.Limits.Count && ackKnown; k++)
|
||||
{
|
||||
if (plan.AckedIndex(k) is not { } ai) continue;
|
||||
var dv = results[at + ai];
|
||||
if (dv.StatusCode != AbCipStatusMapper.Good) ackKnown = false;
|
||||
else if (!ToBool(dv.Value)) allAcked = false;
|
||||
}
|
||||
|
||||
var prior = _driver.AlarmAnalogRecord.Get(nodeId);
|
||||
var nowAcked = mask != 0 && (ackKnown ? allAcked : prior.Acked);
|
||||
var topId = top is { } t0 ? (int)t0 : -1;
|
||||
if (mask == 0 && prior.Mask == 0) { _driver.AlarmAnalogRecord.Set(nodeId, 0, false, -1, 0); return; }
|
||||
|
||||
AlarmEventArgs Build(AlarmTransitionKind kind, string message, int sev, bool restatement = false) => new(
|
||||
sub.Handle, nodeId, ConditionId: nodeId, AlarmType: "ALMA", Message: message,
|
||||
Severity: MapSeverity(sev), SourceTimestampUtc: DateTime.UtcNow, Kind: kind, OpcUaSeverity: sev,
|
||||
IsRestatement: restatement);
|
||||
|
||||
if (mask == 0)
|
||||
{
|
||||
_driver.AlarmAnalogRecord.Set(nodeId, 0, false, -1, 0);
|
||||
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Clear, $"ALMA {nodeId} cleared", 1));
|
||||
return;
|
||||
}
|
||||
|
||||
var message = $"ALMA {nodeId} {top!.Value.Label()} alarm";
|
||||
if (prior.Mask == 0)
|
||||
{
|
||||
_driver.AlarmAnalogRecord.Set(nodeId, mask, nowAcked, topId, topSev);
|
||||
_driver.InvokeAlarmEvent(Build(
|
||||
nowAcked ? AlarmTransitionKind.ActiveAcknowledged : AlarmTransitionKind.Raise, message, topSev));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((mask & ~prior.Mask) != 0)
|
||||
{
|
||||
_driver.AlarmAnalogRecord.Set(nodeId, mask, nowAcked, topId, topSev);
|
||||
_driver.InvokeAlarmEvent(Build(
|
||||
nowAcked ? AlarmTransitionKind.ActiveAcknowledged : AlarmTransitionKind.Retrigger, message, topSev));
|
||||
return;
|
||||
}
|
||||
|
||||
_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)
|
||||
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Unspecified, message, topSev, restatement: true));
|
||||
}
|
||||
|
||||
/// <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 +443,55 @@ 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; }
|
||||
|
||||
/// <summary>True when the tag declares at least one <c>*Acked</c> member.</summary>
|
||||
public bool HasAcked => _acked.Any(a => a is not null);
|
||||
|
||||
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,11 +514,14 @@ 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; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the last <c>InFaulted</c> the ALMD projection told the server, per alarm RawPath. Owned by
|
||||
/// Gitea #641 review — the last <c>InAlarm</c> the ALMD projection told the server, per alarm RawPath. Owned by
|
||||
/// the <see cref="AbCipDriver"/> rather than a subscription or a projection, so it survives a re-subscribe and a
|
||||
/// re-initialise (both of which happen on every reconnect): the server's condition nodes outlive them, and a
|
||||
/// re-raise there would un-acknowledge. Thread-safe — a retiring poll loop may overlap the new one for a tick.
|
||||
@@ -349,6 +558,66 @@ 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
|
||||
{
|
||||
internal readonly record struct State(int Mask, bool Acked, int Top, int TopSeverity);
|
||||
|
||||
private readonly Dictionary<string, State> _last = new(StringComparer.Ordinal);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
/// <summary>The state last told for <paramref name="nodeId"/> (all-zero when none).</summary>
|
||||
public State Get(string nodeId)
|
||||
{
|
||||
lock (_lock) return _last.GetValueOrDefault(nodeId, new State(0, false, -1, 0));
|
||||
}
|
||||
|
||||
/// <summary>Records the state just told.</summary>
|
||||
public void Set(string nodeId, int mask, bool acked, int top, int topSeverity)
|
||||
{
|
||||
lock (_lock) _last[nodeId] = new State(mask, acked, top, topSeverity);
|
||||
}
|
||||
|
||||
/// <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 +634,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>InAlarm</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>
|
||||
@@ -375,6 +643,24 @@ public static class AbCipAlarmDetector
|
||||
{
|
||||
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
|
||||
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return names.Contains("InFaulted") && names.Contains("Acked");
|
||||
return names.Contains("InAlarm") && 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>) and no plain <c>Acked</c> (an ALMD member). 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);
|
||||
// ALMA-specific: a per-limit *InAlarm bit, and NOT ALMD's plain Acked (ALMA has only per-limit *Acked). A user
|
||||
// UDT that is ALMD-shaped (plain InAlarm/Acked) stays ALMD even if it also names an "HInAlarm".
|
||||
return !names.Contains("Acked")
|
||||
&& Enum.GetValues<AbCipAlmaLimit>().Any(l => names.Contains(l.InAlarmMember()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +119,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly ILogger<AbCipDriver> _logger;
|
||||
private AbCipAlarmProjection _alarmProjection;
|
||||
|
||||
/// <summary>Gitea #641 review — the ALMD projection's last-told InFaulted per alarm ref; outlives every
|
||||
/// <summary>Gitea #641 review — the ALMD projection's last-told InAlarm 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>
|
||||
|
||||
Reference in New Issue
Block a user