From b3838fcce95372d631aeaea6a025cc280f730f3c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 04:09:47 -0400 Subject: [PATCH] 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 --- docs/drivers/AbCip.md | 40 ++- .../AbCipAlarmProjection.cs | 247 ++++++++++++++++- .../AbCipDriver.cs | 8 + .../AbCipAlmaProjectionTests.cs | 257 ++++++++++++++++++ 4 files changed, 532 insertions(+), 20 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 276a1735..4838b4a9 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -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 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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs index 73f6261b..72d32800 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs @@ -5,18 +5,18 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// -/// Projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by -/// polling the ALMD UDT's InFaulted / Acked / Severity members at a +/// Projects AB Logix ALMD (digital) and ALMA (analog, ALARM_ANALOG) alarm instructions onto the OPC UA +/// alarm surface by polling the ALMD UDT's InFaulted / Acked / Severity members (or the +/// ALMA limit/ack/severity members) at a /// configurable interval + translating state transitions into OnAlarmEvent /// callbacks on the owning . Feature-flagged off by default via /// ; callers that leave the flag off /// get a no-op subscribe path so capability negotiation still works. /// /// -/// 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 -/// contract without concessions. +/// A subscribed ref is treated as ALMA when its declared tag definition carries any of the six per-limit +/// *InAlarm members (); every other ref is ALMD (the historic +/// default, including refs with no declared members). See for the ALMA mapping. /// /// Polling reuses , 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 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 /// The data values read from the subscription source nodes. internal void Tick(Subscription sub, IReadOnlyList 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(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 BuildPlans(IReadOnlyList nodeIds) + { + var plans = new List(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; + } + + /// + /// 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. + /// + private void TickAlma(Subscription sub, string nodeId, AlmaPlan plan, IReadOnlyList 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)); + } + /// Maps a raw severity value to an enum value. /// The raw severity value from the alarm data. /// The corresponding alarm severity level. @@ -286,6 +385,52 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable _ => 0, }; + internal sealed record NodePlan(AlmaPlan? Alma); + + /// The members one ALMA node polls, in the order they are read: all *InAlarm bits, then the + /// *Acked bits, then the *Severity values — only for the limits the tag declares. + internal sealed class AlmaPlan + { + private readonly List _inAlarm = []; + private readonly List _acked = []; + private readonly List _severity = []; + private readonly List _ackIdx = []; + private readonly List _sevIdx = []; + + public List 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()) + { + 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 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 { /// Initializes a new instance of the class. @@ -308,6 +453,9 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable /// Gets or sets the polling loop task. public Task Loop { get; set; } = Task.CompletedTask; + + /// Per-source-node read plan (ALMD or ALMA), index-aligned with . + public List Plans { get; set; } = []; } } @@ -349,6 +497,63 @@ internal sealed class AbCipAlarmFaultRecord } } +/// +/// The ALMA (ALARM_ANALOG) 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: +/// HHInAlarm/HInAlarm/LInAlarm/LLInAlarm/ROCPosInAlarm/ROCNegInAlarm, +/// HHAcked… ROCNegAcked, and HHSeverity… ROCNegSeverity (DINT, 1..1000). +/// +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"; + + /// Operator-facing name used in the alarm message. + public static string Label(this AbCipAlmaLimit l) => l switch + { + AbCipAlmaLimit.ROCPos => "ROC+", + AbCipAlmaLimit.ROCNeg => "ROC-", + _ => l.ToString(), + }; +} + +/// +/// 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 — it must +/// survive a re-subscribe so a still-active alarm is not re-raised (which would un-acknowledge it). +/// +internal sealed class AbCipAlarmAnalogRecord +{ + private readonly Dictionary _last = new(StringComparer.Ordinal); + private readonly Lock _lock = new(); + + /// Records the state just read, returning the prior one ((0, false) when none). + 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; + } + } + + /// Forgets every ref not in . + public void RetainOnly(IReadOnlyList nodeIds) + { + var keep = new HashSet(nodeIds, StringComparer.Ordinal); + lock (_lock) + { + foreach (var k in _last.Keys.Where(k => !keep.Contains(k)).ToList()) _last.Remove(k); + } + } +} + /// Handle returned by . public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle { @@ -365,9 +570,8 @@ public static class AbCipAlarmDetector { /// /// true when is a Structure whose declared members match - /// the ALMD signature (InFaulted + Acked present). ALMA detection - /// (analog alarms with HHLimit/HLimit/LLimit/LLLimit) - /// ships as a follow-up. + /// the ALMD signature (InFaulted + Acked present). ALMA is detected by + /// . /// /// The tag definition to check for ALMD signature. /// True if the tag has the ALMD alarm signature; false otherwise. @@ -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"); } + + /// + /// true when is a Structure whose declared members include at least one ALMA + /// per-limit status bit (HHInAlarm/HInAlarm/LInAlarm/LLInAlarm/ + /// ROCPosInAlarm/ROCNegInAlarm). Checked BEFORE by the projection — an + /// ALMA also exposes InFaulted, and the limit bits are what ALMD does not have. + /// + /// The tag definition to check. + /// True for an ALMA signature. + 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().Any(l => names.Contains(l.InAlarmMember())); + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 2fade80c..06de7a1a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -122,6 +122,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// Gitea #641 review — the ALMD projection's last-told InFaulted per alarm ref; outlives every /// projection and subscription (see ). internal AbCipAlarmFaultRecord AlarmFaultRecord { get; } = new(); + + /// The ALMA projection's last-told (active-limit mask, acked) per alarm ref; driver-owned for the + /// same reason as . + internal AbCipAlarmAnalogRecord AlarmAnalogRecord { get; } = new(); + + /// Resolves an authored tag definition by RawPath (the alarm projection's ALMA/ALMD discriminator). + internal bool TryGetTagDefinition(string rawPath, out AbCipTagDefinition definition) => + _tagsByRawPath.TryGetValue(rawPath, out definition!); private DriverHealth _health = new(DriverState.Unknown, null, null); /// Occurs when a subscribed tag's value changes. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs new file mode 100644 index 00000000..26d96c15 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs @@ -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; + +/// +/// Gitea #611 item 1 — ALMA (ALARM_ANALOG) 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. +/// +[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; + + /// Seeds a quiet, ack-required ALMA: severities set, nothing in alarm, nothing acked. + 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 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 StartAsync(Action? seed = null) + { + var factory = new FakeAbCipTagFactory(); + var drv = new AbCipDriver(Options(), "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + var events = new List(); + 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 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 Next(Rig r, Func 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); + } + + /// The detector flags the six-limit signature and not an ALMD or a plain UDT. + [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(); + } + + /// A limit entering alarm raises one ALMA condition keyed on the RawPath, naming the limit, with the limit's severity. + [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); + } + + /// With several limits active the most severe one names the condition and sets its severity. + [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); + } + + /// ROC limits are named with their sign. + [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"); + } + + /// All limits returning to normal clears the one condition. + [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); + } + + /// A newly entered limit while already active is a Retrigger (a new unacknowledged occurrence), not a second Raise. + [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); + } + + /// A limit dropping out while another stays active updates message/severity without a Raise/Retrigger/Clear. + [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); + } + } + + /// The PLC acknowledging every active limit surfaces as one Acknowledge. + [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); + } + + /// An alarm already active AND acked when first seen is told atomically, never Raise-then-Acknowledge. + [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); + } + + /// A re-subscribe must not re-raise a still-active ALMA (it would un-acknowledge it). + [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); + } + + /// A Bad limit read judges nothing that tick: no event, and no false Clear for an active alarm. + [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(); + } + + /// A client acknowledge writes the ALMA's OperAckAll input — never the ALMD-only Acked member. + [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"); + } +}