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:
+34
-6
@@ -19,7 +19,7 @@ coverage map, see [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md).
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD alarm projection. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD/ALMA alarm projection. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/` | Project-less shared source (`LibplctagOrphanRelease.cs`, #578/#580) compiled into both `Driver.AbCip` and `Driver.AbLegacy`. The `AbStatus` → OPC UA table is shared in `Driver.Ab.Transport` (`AbOpcUaStatusMap`). |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. |
|
||||
|
||||
@@ -44,7 +44,7 @@ AB Legacy surface.
|
||||
| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | CIP has no push model — subscriptions become polling groups. |
|
||||
| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | One probe loop per device reading `Probe.ProbeTagPath`; no path configured ⇒ a warning is logged and the device stays `Unknown`. |
|
||||
| `IPerCallHostResolver` | `ResolveHost` | Routes each call to the tag's `DeviceHostAddress`, the breaker key for the resilience pipeline so one dead PLC trips only its own breaker. |
|
||||
| `IAlarmSource` | `AbCipAlarmProjection` (ALMD) | Opt-in via `EnableAlarmProjection`; off by default the subscribe path is a no-op so capability negotiation still works. |
|
||||
| `IAlarmSource` | `AbCipAlarmProjection` (ALMD + ALMA) | Opt-in via `EnableAlarmProjection`; off by default the subscribe path is a no-op so capability negotiation still works. |
|
||||
|
||||
## Addressing Model
|
||||
|
||||
@@ -80,7 +80,7 @@ the driver's `DriverConfig` JSON. Key fields:
|
||||
- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). They also write durations as the integer-millisecond keys the factory binds (`timeoutMs`, `alarmPollIntervalMs`, `probe.intervalMs`, `probe.timeoutMs`); a legacy TimeSpan-string key (`timeout`: "00:00:05") is loaded and migrated on save.
|
||||
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
|
||||
- **Discovery** — `EnableControllerBrowse` (`@tags` walk), `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path) and `browsePageDelayMs` (native browse pacing, see [Native browse](#native-browse-paging-pacing-and-template-leases)).
|
||||
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
|
||||
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD/ALMA projection.
|
||||
|
||||
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
|
||||
reproduced in [docs/v2/driver-specs.md §3](../v2/driver-specs.md).
|
||||
@@ -258,9 +258,9 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
## Alarm Projection
|
||||
|
||||
`IAlarmSource` is served by `AbCipAlarmProjection`, which polls each subscribed
|
||||
ALMD UDT's `InFaulted` + `Severity` members at `AlarmPollInterval` and fires
|
||||
`OnAlarmEvent` on raise/clear transitions. It is **ALMD-only** in this pass (ALMA
|
||||
analog alarms are a follow-up) and **disabled by default** — shops running FT
|
||||
ALMD UDT's `InFaulted` + `Severity` members (or an ALMA's limit members, below) at
|
||||
`AlarmPollInterval` and fires `OnAlarmEvent` on raise/clear transitions. It handles
|
||||
ALMD (digital) and ALMA (analog, `ALARM_ANALOG`, Gitea #611) and is **disabled by default** — shops running FT
|
||||
Alarm & Events should keep it off and take alarms through the native route, since
|
||||
the projection semantics don't exactly mirror Rockwell FT A&E.
|
||||
|
||||
@@ -268,6 +268,34 @@ Each event carries `ConditionId` = the subscribed reference (the alarm tag's Raw
|
||||
routes native transitions on) and `Kind` = `Raise` / `Clear`. Before Gitea #641 the projection sent
|
||||
`"{ref}#active"` with no kind, so the host dropped every ALMD transition as unrouted.
|
||||
|
||||
### ALMA (analog alarms)
|
||||
|
||||
A subscribed ref is ALMA when its declared tag definition (`Structure` with `members`) carries any of
|
||||
`HHInAlarm` / `HInAlarm` / `LInAlarm` / `LLInAlarm` / `ROCPosInAlarm` / `ROCNegInAlarm`
|
||||
(`AbCipAlarmDetector.IsAlma`); anything else stays ALMD, so an ALMD with no declared members is unchanged.
|
||||
**Declare the members you want projected** — the projection reads only the limits declared (its
|
||||
`*InAlarm`, and where declared its `*Acked` and `*Severity`). Member names/types are from Rockwell's
|
||||
Studio 5000 "Analog Alarm (ALMA)" reference (BOOL status bits, DINT severities 1..1000).
|
||||
|
||||
- **One condition per ALMA tag**, `ConditionId` = the RawPath, `AlarmType` = `ALMA`. Active = any polled limit
|
||||
bit set. Severity (exact, `OpcUaSeverity`, plus the 4-bucket enum) and message (`ALMA <ref> HH alarm`,
|
||||
`H`, `L`, `LL`, `ROC+`, `ROC-`) come from the **most severe active limit** (ties: HH, LL, H, L, ROC+, ROC-).
|
||||
A missing/Bad severity member counts as 1.
|
||||
- **Transitions** (last-told state — the active-limit mask and all-acked — is driver-owned, so a re-subscribe
|
||||
never re-raises a still-active alarm): none→active `Raise` (or the atomic `ActiveAcknowledged` when every
|
||||
active limit's `*Acked` is already true — always so when the instruction's `AckRequired` is false);
|
||||
active→none `Clear`; a limit **newly** entering alarm while active `Retrigger` (new, unacknowledged
|
||||
occurrence); a limit merely dropping out `Unspecified` (message/severity update, state kept); the PLC
|
||||
acknowledging every active limit `Acknowledge`. A Bad read of any polled `*InAlarm` bit judges nothing that tick.
|
||||
- **Acknowledge** writes the ALMA's `OperAckAll` input (not `Acked`, which ALMA lacks); declare `OperAckAll`
|
||||
on the tag. The `*Acked` bits are instruction outputs and are never written.
|
||||
- **Not projected:** `Disabled`, `Suppressed` and `InFaulted` (input-health) — the event contract has no
|
||||
enable/suppress channel and ALMD does not use them either — and the aggregate `AnyInAlarmUnack` /
|
||||
per-limit `*InAlarmUnack` bits (derivable from the polled `*InAlarm` + `*Acked`).
|
||||
- **Evidence:** unit tests over fake runtimes only (`AbCipAlmaProjectionTests`). `ab_server` has no alarm
|
||||
instructions and the Z35162 press (CompactLogix 5069-L320ERS2) has no ALMA/ALMD, so there is **no wire-level
|
||||
or live gate** for ALMA (or ALMD); it needs a controller that actually runs the instruction (Logix Emulate tier).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` cover the driver, host-address parser, UDT planner, and alarm projection via fake tag runtimes.
|
||||
|
||||
@@ -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->active Raise (or
|
||||
/// ActiveAcknowledged when every active limit is already acked), active->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>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #611 item 1 — ALMA (<c>ALARM_ANALOG</c>) projection. Driven through fake tag runtimes only:
|
||||
/// ab_server has no alarm instructions, and Z35162 has no ALMA/ALMD, so there is no wire-level gate.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipAlmaProjectionTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
private const string Tag = "HighPress";
|
||||
|
||||
// Declaration order fixes the fake's byte offsets (DINT stride 4).
|
||||
private static readonly string[] Layout =
|
||||
[
|
||||
"HHInAlarm", "HInAlarm", "LInAlarm", "LLInAlarm", "ROCPosInAlarm",
|
||||
"HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked",
|
||||
"HHSeverity", "HSeverity", "LSeverity", "LLSeverity", "ROCPosSeverity",
|
||||
];
|
||||
|
||||
private static AbCipTagDefinition AlmaTag() => new(
|
||||
Tag, Device, Tag, AbCipDataType.Structure,
|
||||
Members: [.. Layout.Select(n => new AbCipStructureMember(n, AbCipDataType.DInt)),
|
||||
new AbCipStructureMember("OperAckAll", AbCipDataType.DInt)]);
|
||||
|
||||
private static AbCipDriverOptions Options() => new()
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(AlmaTag()),
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
EnableDeclarationOnlyUdtGrouping = true,
|
||||
};
|
||||
|
||||
private static void Set(FakeAbCipTag t, string member, int v) => t.ValuesByOffset[Array.IndexOf(Layout, member) * 4] = v;
|
||||
|
||||
/// <summary>Seeds a quiet, ack-required ALMA: severities set, nothing in alarm, nothing acked.</summary>
|
||||
private static void Quiet(FakeAbCipTag t)
|
||||
{
|
||||
foreach (var n in Layout) Set(t, n, 0);
|
||||
Set(t, "HHSeverity", 900); Set(t, "HSeverity", 600); Set(t, "LSeverity", 300);
|
||||
Set(t, "LLSeverity", 800); Set(t, "ROCPosSeverity", 400);
|
||||
}
|
||||
|
||||
private sealed record Rig(AbCipDriver Drv, FakeAbCipTagFactory Factory, List<AlarmEventArgs> Events, IAlarmSubscriptionHandle Handle)
|
||||
{
|
||||
public FakeAbCipTag T => Factory.Tags[Tag];
|
||||
public async Task StopAsync()
|
||||
{
|
||||
await Drv.UnsubscribeAlarmsAsync(Handle, CancellationToken.None);
|
||||
await Drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Rig> StartAsync(Action<FakeAbCipTag>? seed = null)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(Options(), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
var handle = await drv.SubscribeAlarmsAsync([Tag], CancellationToken.None);
|
||||
await WaitFor(() => factory.Tags.ContainsKey(Tag));
|
||||
var rig = new Rig(drv, factory, events, handle);
|
||||
Quiet(rig.T);
|
||||
seed?.Invoke(rig.T);
|
||||
return rig;
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> cond)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline) { if (cond()) return; await Task.Delay(5); }
|
||||
throw new TimeoutException("condition never met");
|
||||
}
|
||||
|
||||
private static Task<AlarmEventArgs> Next(Rig r, Func<AlarmEventArgs, bool> match) =>
|
||||
WaitFor(() => { lock (r.Events) return r.Events.Any(match); })
|
||||
.ContinueWith(_ => { lock (r.Events) return r.Events.First(match); });
|
||||
|
||||
private static async Task Settled(Rig r)
|
||||
{
|
||||
var target = r.T.ReadCount + 6;
|
||||
await WaitFor(() => r.T.ReadCount >= target);
|
||||
}
|
||||
|
||||
/// <summary>The detector flags the six-limit signature and not an ALMD or a plain UDT.</summary>
|
||||
[Fact]
|
||||
public void Detector_Flags_Alma_And_Not_Almd()
|
||||
{
|
||||
AbCipAlarmDetector.IsAlma(AlmaTag()).ShouldBeTrue();
|
||||
var almd = new AbCipTagDefinition("A", Device, "A", AbCipDataType.Structure, Members:
|
||||
[new AbCipStructureMember("InFaulted", AbCipDataType.DInt), new AbCipStructureMember("Acked", AbCipDataType.DInt)]);
|
||||
AbCipAlarmDetector.IsAlma(almd).ShouldBeFalse();
|
||||
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
|
||||
AbCipAlarmDetector.IsAlma(new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A limit entering alarm raises one ALMA condition keyed on the RawPath, naming the limit, with the limit's severity.</summary>
|
||||
[Fact]
|
||||
public async Task A_limit_in_alarm_raises_with_the_limits_name_and_severity()
|
||||
{
|
||||
var r = await StartAsync();
|
||||
await Settled(r);
|
||||
Set(r.T, "HInAlarm", 1);
|
||||
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
await r.StopAsync();
|
||||
|
||||
e.ConditionId.ShouldBe(Tag);
|
||||
e.SourceNodeId.ShouldBe(Tag);
|
||||
e.AlarmType.ShouldBe("ALMA");
|
||||
e.Message.ShouldBe($"ALMA {Tag} H alarm");
|
||||
e.OpcUaSeverity.ShouldBe(600);
|
||||
e.Severity.ShouldBe(AlarmSeverity.High);
|
||||
}
|
||||
|
||||
/// <summary>With several limits active the most severe one names the condition and sets its severity.</summary>
|
||||
[Fact]
|
||||
public async Task The_most_severe_active_limit_wins()
|
||||
{
|
||||
var r = await StartAsync(t => { Set(t, "LInAlarm", 1); Set(t, "HHInAlarm", 1); Set(t, "ROCPosInAlarm", 1); });
|
||||
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
await r.StopAsync();
|
||||
e.Message.ShouldBe($"ALMA {Tag} HH alarm");
|
||||
e.OpcUaSeverity.ShouldBe(900);
|
||||
}
|
||||
|
||||
/// <summary>ROC limits are named with their sign.</summary>
|
||||
[Fact]
|
||||
public async Task A_rate_of_change_limit_is_named_with_its_sign()
|
||||
{
|
||||
var r = await StartAsync(t => Set(t, "ROCPosInAlarm", 1));
|
||||
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
await r.StopAsync();
|
||||
e.Message.ShouldBe($"ALMA {Tag} ROC+ alarm");
|
||||
}
|
||||
|
||||
/// <summary>All limits returning to normal clears the one condition.</summary>
|
||||
[Fact]
|
||||
public async Task All_limits_clearing_fires_one_clear()
|
||||
{
|
||||
var r = await StartAsync(t => Set(t, "LLInAlarm", 1));
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
Set(r.T, "LLInAlarm", 0);
|
||||
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Clear);
|
||||
await Settled(r);
|
||||
await r.StopAsync();
|
||||
e.ConditionId.ShouldBe(Tag);
|
||||
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Clear).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A newly entered limit while already active is a Retrigger (a new unacknowledged occurrence), not a second Raise.</summary>
|
||||
[Fact]
|
||||
public async Task A_newly_entered_limit_while_active_retriggers()
|
||||
{
|
||||
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
Set(r.T, "HHInAlarm", 1);
|
||||
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger);
|
||||
await Settled(r);
|
||||
await r.StopAsync();
|
||||
e.Message.ShouldBe($"ALMA {Tag} HH alarm");
|
||||
e.OpcUaSeverity.ShouldBe(900);
|
||||
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A limit dropping out while another stays active updates message/severity without a Raise/Retrigger/Clear.</summary>
|
||||
[Fact]
|
||||
public async Task A_limit_dropping_out_updates_the_condition_without_re_raising()
|
||||
{
|
||||
var r = await StartAsync(t => { Set(t, "HInAlarm", 1); Set(t, "HHInAlarm", 1); });
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
Set(r.T, "HHInAlarm", 0);
|
||||
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Unspecified);
|
||||
await Settled(r);
|
||||
await r.StopAsync();
|
||||
e.Message.ShouldBe($"ALMA {Tag} H alarm");
|
||||
e.OpcUaSeverity.ShouldBe(600);
|
||||
lock (r.Events)
|
||||
{
|
||||
r.Events.Count(x => x.Kind is AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger or AlarmTransitionKind.Clear)
|
||||
.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The PLC acknowledging every active limit surfaces as one Acknowledge.</summary>
|
||||
[Fact]
|
||||
public async Task The_plc_acknowledging_the_active_limits_fires_acknowledge()
|
||||
{
|
||||
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
Set(r.T, "HAcked", 1);
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Acknowledge);
|
||||
await Settled(r);
|
||||
await r.StopAsync();
|
||||
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>An alarm already active AND acked when first seen is told atomically, never Raise-then-Acknowledge.</summary>
|
||||
[Fact]
|
||||
public async Task An_already_acked_active_alarm_is_active_acknowledged()
|
||||
{
|
||||
var r = await StartAsync(t => { Set(t, "LInAlarm", 1); Set(t, "LAcked", 1); });
|
||||
var e = await Next(r, x => x.Kind != AlarmTransitionKind.Unspecified);
|
||||
await Settled(r);
|
||||
await r.StopAsync();
|
||||
e.Kind.ShouldBe(AlarmTransitionKind.ActiveAcknowledged);
|
||||
lock (r.Events) r.Events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A re-subscribe must not re-raise a still-active ALMA (it would un-acknowledge it).</summary>
|
||||
[Fact]
|
||||
public async Task A_resubscribe_does_not_re_raise_a_still_active_alma()
|
||||
{
|
||||
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
var sub2 = await r.Drv.SubscribeAlarmsAsync([Tag], CancellationToken.None);
|
||||
await Settled(r);
|
||||
await r.Drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
||||
await r.StopAsync();
|
||||
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A Bad limit read judges nothing that tick: no event, and no false Clear for an active alarm.</summary>
|
||||
[Fact]
|
||||
public async Task A_bad_read_does_not_clear_an_active_alarm()
|
||||
{
|
||||
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
// A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so
|
||||
// every re-created runtime must fail too (a fresh fake would read all-zero = "all clear").
|
||||
var recreations = 0;
|
||||
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
|
||||
r.T.Status = (int)AbStatus.NotFound;
|
||||
// Processed-count wait: the poll loop re-creates the failing runtime on tick after tick — six Bad ticks.
|
||||
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
|
||||
await r.StopAsync();
|
||||
lock (r.Events) r.Events.Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A client acknowledge writes the ALMA's OperAckAll input — never the ALMD-only <c>Acked</c> member.</summary>
|
||||
[Fact]
|
||||
public async Task Acknowledge_writes_OperAckAll_not_Acked()
|
||||
{
|
||||
var r = await StartAsync();
|
||||
await r.Drv.AcknowledgeAsync([new AlarmAcknowledgeRequest(Tag, Tag, null)], CancellationToken.None);
|
||||
await r.StopAsync();
|
||||
r.Factory.Tags.ShouldContainKey($"{Tag}.OperAckAll");
|
||||
r.Factory.Tags[$"{Tag}.OperAckAll"].WriteCount.ShouldBe(1);
|
||||
r.Factory.Tags.ShouldNotContainKey($"{Tag}.Acked");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user