From b3838fcce95372d631aeaea6a025cc280f730f3c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 04:09:47 -0400 Subject: [PATCH 1/3] 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"); + } +} From 83d8de303c9e75bb01e5607aec0def249c2e80f1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 04:22:45 -0400 Subject: [PATCH 2/3] fix(abcip): ALMA review round 1 + ALMD polls InAlarm (not InFaulted) M1 drop-out is a no-row Unspecified+IsRestatement update (projector case); M2 ack judged over every declared limit; L1 ActiveAcknowledged for an already-acked new limit; L2 Bad Acked/severity carried/skipped; L3 host end-to-end test; L4 BOOL member-by-member tests + mixed ALMD/ALMA; L5 detection requires no plain Acked. ALMD active bit is InAlarm per Rockwell's ALMD reference; InFaulted is input health. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/AlarmTracking.md | 2 +- docs/drivers/AbCip.md | 61 ++-- docs/drivers/AbServer-Test-Fixture.md | 2 +- .../AbCipDriverOptions.cs | 2 +- .../AbCipAlarmProjection.cs | 144 +++++---- .../AbCipDriver.cs | 2 +- .../Drivers/NativeAlarmProjector.cs | 6 + .../Emulate/AbCipEmulateAlmdTests.cs | 10 +- .../AbCipAlarmProjectionTests.cs | 44 ++- .../AbCipAlmaProjectionTests.cs | 278 +++++++++++++----- .../AbCipAlmaNativeAlarmEndToEndTests.cs | 228 ++++++++++++++ .../Drivers/NativeAlarmProjectorTests.cs | 22 ++ .../ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj | 1 + 13 files changed, 641 insertions(+), 161 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/AbCipAlmaNativeAlarmEndToEndTests.cs diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index bc46e7c3..4f45d865 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -125,7 +125,7 @@ Two companions keep the host and driver in step with the carried node: deploy, rebuild or not. - MTConnect's "last told" record is pruned to the conditions that **left** the subscribed set rather than cleared on any set change; re-raising a surviving (carried) condition would un-acknowledge it. AbCip's ALMD - last-seen `InFaulted` record is likewise driver-owned and pruned to the set, so a re-subscribe (every + last-seen `InAlarm` record is likewise driver-owned and pruned to the set, so a re-subscribe (every reconnect / set change) does not re-raise a still-faulted alarm. - A routed **client** acknowledgement marks the projector's prior state acknowledged (`NativeAlarmProjector.MarkAcknowledged`), so a later driver Clear does not project it unacknowledged. diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 4838b4a9..8a7852cc 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -258,7 +258,7 @@ 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 (or an ALMA's limit members, below) at +ALMD UDT's `InAlarm` + `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 @@ -268,33 +268,52 @@ 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. +**ALMD's active bit is `InAlarm`** (Rockwell's ALMD reference). Until hw4 the projection polled `InFaulted`, which +is the *input-health* flag (Status.1, set when the program raises `InFault`), so a live ALMD would have raised on +input faults and never on the alarm itself. Declare `InAlarm` (and `Severity`) on an ALMD tag. + ### 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). +A subscribed ref is ALMA when its declared tag definition (`Structure` with `members`) carries a per-limit +status bit — any of `HHInAlarm` / `HInAlarm` / `LInAlarm` / `LLInAlarm` / `ROCPosInAlarm` / `ROCNegInAlarm` — **and +no plain `Acked`** (an ALMD member; `AbCipAlarmDetector.IsAlma`). Anything else stays ALMD, so an ALMD-shaped user +UDT is not misclassified. ⚠️ **An ALMA authored without `members` cannot be told apart from an ALMD and is +projected as an ALMD** (it would poll `InAlarm`/`Severity`, which an ALMA lacks, and never raise) — declare the members. +**Declare the members you want projected**: the projection reads only the limits declared (their `*InAlarm`, and +where declared their `*Acked` and `*Severity`). Names/types are from Rockwell's Studio 5000 "Analog Alarm (ALMA)" +reference (BOOL status bits, DINT severities 1..1000). ⚠️ With `EnableDeclarationOnlyUdtGrouping` the whole-UDT +decode computes offsets from the DECLARATION order, which for a partially declared ALMA is not the controller's +layout — leave grouping off for ALMA tags (BOOL members are read member by member anyway). - **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. +- **What the host is told** (last-told state — active-limit mask, acked, governing limit + severity — is + driver-owned, so a re-subscribe never re-raises a still-active alarm): + none→active `Raise`, or the atomic `ActiveAcknowledged` when every declared `*Acked` is true (always so when the + instruction's `AckRequired` is false); active→none `Clear`; a limit **newly** entering while active `Retrigger` + (a new unacknowledged occurrence) — or `ActiveAcknowledged` if nothing is pending ack, never + Retrigger-then-Acknowledge; every declared `*Acked` bit going true while the condition was unacknowledged + `Acknowledge`; a limit dropping out (the governing limit or its severity changes, no new occurrence) an + `Unspecified` event flagged `IsRestatement`, which `NativeAlarmProjector` applies as a text/severity move with + **no `/alerts` row**. A non-governing limit flickering emits nothing. +- **"Acked" is judged over every declared limit, active or not.** In Logix an occurrence that returned to normal + unacknowledged keeps its `*Acked` bit false, so it keeps the condition unacknowledged — a surviving, already-acked + limit never turns it into a device "Acknowledged" for an excursion nobody acknowledged. +- **Bad reads:** a Bad `*InAlarm` or severity read skips that tick (no false Clear, no text change); a Bad `*Acked` + read carries the last-told ack state forward instead of recording "unacknowledged". +- **Acknowledge** writes the ALMA's `OperAckAll` input (not `Acked`, which ALMA lacks); declare `OperAckAll` as a + writable member. A failed acknowledge write is now logged at Warning (it used to vanish silently). 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). + enable/suppress channel — and the aggregate `AnyInAlarmUnack` / per-limit `*InAlarmUnack` bits (derivable from the + polled `*InAlarm` + `*Acked`). +- **Evidence:** unit tests over fake runtimes (`AbCipAlmaProjectionTests`, BOOL members, member-by-member reads, plus a + mixed ALMD+ALMA subscription) and a host-level test, `Runtime.Tests/AbCipAlmaNativeAlarmEndToEndTests`, that walks + raise → escalate → drop-out → ack → unacked excursion → clear through `DriverHostActor` and asserts the Part 9 + updates and `/alerts` rows. `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 diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md index 7c22622e..3df84b8c 100644 --- a/docs/drivers/AbServer-Test-Fixture.md +++ b/docs/drivers/AbServer-Test-Fixture.md @@ -92,7 +92,7 @@ Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The `OnAlarmEvent` raise/clear path + ack-write semantics are not exercised end-to-end. -Unit coverage: `AbCipAlarmProjectionTests` — fakes feed `InFaulted` / +Unit coverage: `AbCipAlarmProjectionTests` — fakes feed `InAlarm` / `Severity` via `ValuesByOffset` + assert the emitted `AlarmEventArgs`. ### 3. Micro800 unconnected-only path diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs index 8257b6ea..7a57955f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs @@ -79,7 +79,7 @@ public sealed class AbCipDriverOptions /// /// When true, declared ALMD tags are surfaced as alarm conditions /// via IAlarmSource; the driver polls each subscribed - /// alarm's InFaulted + Severity members + fires OnAlarmEvent on + /// alarm's InAlarm + Severity members + fires OnAlarmEvent on /// state transitions. Default false — 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. 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 72d32800..bff47d58 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs @@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// /// 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 +/// alarm surface by polling the ALMD UDT's InAlarm / 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 @@ -160,8 +160,14 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable // 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); } /// Releases all resources associated with this alarm projection. @@ -179,7 +185,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable } /// - /// Poll-tick body — reads InFaulted + Severity for every source node id + /// Poll-tick body — reads InAlarm + Severity 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. /// @@ -187,7 +193,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable /// The data values read from the subscription source nodes. internal void Tick(Subscription sub, IReadOnlyList results) { - // results index layout: per sourceNode, ALMD = [InFaulted, Severity]; ALMA = the plan's member list. + // 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++) { @@ -199,16 +205,16 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable cursor += plan.Alma.RefCount; continue; } - var inFaultedDv = results[cursor]; + var inAlarmDv = results[cursor]; var severityDv = results[cursor + 1]; cursor += 2; - if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue; + 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); @@ -248,7 +254,10 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable { var nodeId = sub.SourceNodeIds[i]; if (sub.Plans[i].Alma is { } alma) { refs.AddRange(alma.Refs(nodeId)); continue; } - refs.Add($"{nodeId}.InFaulted"); + // 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"); } @@ -291,70 +300,96 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable /// /// 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. + /// MOST SEVERE active limit (ties: HH, LL, H, L, ROC+, ROC-). What is told to the host: + /// + /// none->active: Raise, or the atomic ActiveAcknowledged when every declared limit is acked. + /// active->none: Clear. + /// a limit NEWLY entering: Retrigger (a new unacknowledged occurrence) — or ActiveAcknowledged when + /// nothing is pending ack (AckRequired false), never Retrigger-then-Acknowledge (#656). + /// no new limit, every declared limit now acked while the condition was unacked: Acknowledge. + /// no new limit, the governing limit or its severity changed (a drop-out): an + /// Unspecified + IsRestatement state update — the projector keeps active/acked and writes + /// NO alerts row; anything else here would render a false "Activated". + /// + /// "Acked" is judged over EVERY declared limit's *Acked 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). /// 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). + // 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; - if (plan.AckedIndex(k) is { } ai) + var sev = 1; + if (plan.SeverityIndex(k) is { } si) { - var acked = results[at + ai]; - if (acked.StatusCode != AbCipStatusMapper.Good) ackKnown = false; - else if (!ToBool(acked.Value)) ackedAll = false; + 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); } - 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; + // 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; + } - AlarmEventArgs Build(AlarmTransitionKind kind, string message, int sev) => new( + 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); + 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 (wasMask == 0) + 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 & ~wasMask) != 0) + if ((mask & ~prior.Mask) != 0) { - _driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Retrigger, message, topSev)); - if (nowAcked) _driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Acknowledge, message, topSev)); + _driver.AlarmAnalogRecord.Set(nodeId, mask, nowAcked, topId, topSev); + _driver.InvokeAlarmEvent(Build( + nowAcked ? AlarmTransitionKind.ActiveAcknowledged : AlarmTransitionKind.Retrigger, message, topSev)); return; } - if (mask != wasMask) - _driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Unspecified, message, topSev)); - if (nowAcked && !wasAcked) + _driver.AlarmAnalogRecord.Set(nodeId, mask, nowAcked, topId, topSev); + 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)); } /// Maps a raw severity value to an enum value. @@ -401,6 +436,9 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable public int RefCount { get; private set; } + /// True when the tag declares at least one *Acked member. + 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); @@ -460,7 +498,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable } /// -/// Gitea #641 review — the last InFaulted the ALMD projection told the server, per alarm RawPath. Owned by +/// Gitea #641 review — the last InAlarm the ALMD projection told the server, per alarm RawPath. Owned by /// the 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. @@ -529,18 +567,21 @@ internal static class AbCipAlmaLimitExtensions /// internal sealed class AbCipAlarmAnalogRecord { - private readonly Dictionary _last = new(StringComparer.Ordinal); + internal readonly record struct State(int Mask, bool Acked, int Top, int TopSeverity); + + 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) + /// The state last told for (all-zero when none). + public State Get(string nodeId) { - lock (_lock) - { - var was = _last.GetValueOrDefault(nodeId, (0, false)); - _last[nodeId] = (mask, acked); - return was; - } + lock (_lock) return _last.GetValueOrDefault(nodeId, new State(0, false, -1, 0)); + } + + /// Records the state just told. + public void Set(string nodeId, int mask, bool acked, int top, int topSeverity) + { + lock (_lock) _last[nodeId] = new State(mask, acked, top, topSeverity); } /// Forgets every ref not in . @@ -570,7 +611,7 @@ public static class AbCipAlarmDetector { /// /// true when is a Structure whose declared members match - /// the ALMD signature (InFaulted + Acked present). ALMA is detected by + /// the ALMD signature (InAlarm + Acked present). ALMA is detected by /// . /// /// The tag definition to check for ALMD signature. @@ -579,13 +620,13 @@ 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"); } /// /// 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 + /// ROCPosInAlarm/ROCNegInAlarm) and no plain Acked (an ALMD member). 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. @@ -594,6 +635,9 @@ 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 Enum.GetValues().Any(l => names.Contains(l.InAlarmMember())); + // 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().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 06de7a1a..d72c0188 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -119,7 +119,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly ILogger _logger; private AbCipAlarmProjection _alarmProjection; - /// Gitea #641 review — the ALMD projection's last-told InFaulted per alarm ref; outlives every + /// Gitea #641 review — the ALMD projection's last-told InAlarm per alarm ref; outlives every /// projection and subscription (see ). internal AbCipAlarmFaultRecord AlarmFaultRecord { get; } = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs index 1fba649f..f710ce84 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs @@ -108,6 +108,12 @@ public sealed class NativeAlarmProjector case AlarmTransitionKind.Clear: (active, acked, historyKinds) = (false, prev.Acked, [AlarmTransitionKind.Clear]); break; + // A message/severity-only update of an ALREADY-ACTIVE condition that the source flags as a re-statement + // (AbCip ALMA: the governing limit changed but no new occurrence started, e.g. HH dropped back to H). + // The node's text/severity move; active/acked are kept; NO row (an "Activated" row would be false). + case AlarmTransitionKind.Unspecified when e.IsRestatement && prev.Active: + (active, acked, historyKinds) = (true, prev.Acked, []); + break; default: // Unspecified (a message/severity-only update) or an unknown future kind: keep the state. The row is // written as before (Unspecified renders "Activated") — unchanged behaviour. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs index 0be30426..11b3378e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs @@ -8,16 +8,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate; /// /// Golden-box-tier ALMD alarm projection tests against Logix Emulate. /// Promotes the feature-flagged ALMD projection (task #177) from unit-only coverage -/// (AbCipAlarmProjectionTests with faked InFaulted sequences) to end-to-end +/// (AbCipAlarmProjectionTests with faked InAlarm sequences) to end-to-end /// wire-level coverage — Emulate runs the real ALMD instruction, with real -/// rising-edge semantics on InFaulted + Ack, so the driver's poll-based +/// rising-edge semantics on InAlarm + Ack, so the driver's poll-based /// projection gets validated against the actual behaviour shops running FT View see. /// /// /// Required Emulate project state (see LogixProject/README.md): /// /// Controller-scope ALMD tag HighTempAlarm — a standard ALMD instruction -/// with default member set (In, InFaulted, Acked, +/// with default member set (In, InAlarm, Acked, /// Severity, Cfg_ProgTime, …). /// A periodic task that drives HighTempAlarm.In false→true→false at a /// cadence the operator can script via a one-shot routine (e.g. a @@ -64,7 +64,7 @@ public sealed class AbCipEmulateAlmdTests TagPath: "HighTempAlarm", DataType: AbCipDataType.Structure, Members: [ - new AbCipStructureMember("InFaulted", AbCipDataType.DInt), + new AbCipStructureMember("InAlarm", AbCipDataType.DInt), new AbCipStructureMember("Acked", AbCipDataType.DInt), new AbCipStructureMember("Severity", AbCipDataType.DInt), new AbCipStructureMember("In", AbCipDataType.DInt), @@ -92,7 +92,7 @@ public sealed class AbCipEmulateAlmdTests ["HighTempAlarm"], TestContext.Current.CancellationToken); // Pulse the input bit the ladder watches, then wait for the driver's poll loop - // to see InFaulted rise + fire the raise event. + // to see InAlarm rise + fire the raise event. _ = await drv.WriteAsync( [new WriteRequest("SimulateAlarm", true)], TestContext.Current.CancellationToken); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs index ba312794..1b2f94fc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs @@ -16,10 +16,11 @@ public sealed class AbCipAlarmProjectionTests private static AbCipTagDefinition AlmdTag(string name) => new( name, Device, name, AbCipDataType.Structure, Members: [ - new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT + new AbCipStructureMember("InAlarm", AbCipDataType.DInt), // Logix stores ALMD bools as DINT new AbCipStructureMember("Acked", AbCipDataType.DInt), new AbCipStructureMember("Severity", AbCipDataType.DInt), new AbCipStructureMember("In", AbCipDataType.DInt), + new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // input-health flag — NOT the alarm ]); /// Verifies that ALMD structure signature is correctly detected as an alarm. @@ -75,7 +76,7 @@ public sealed class AbCipAlarmProjectionTests // subscription had started would have read (and so created) the ALMD runtime by now. await PolledThreeTimesWithTheFlagOnAsync(); factory.Tags.ShouldNotContainKey("HighTemp"); - factory.Tags.ShouldNotContainKey("HighTemp.InFaulted"); + factory.Tags.ShouldNotContainKey("HighTemp.InAlarm"); factory.Tags.ShouldNotContainKey("HighTemp.Severity"); await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); @@ -96,11 +97,11 @@ public sealed class AbCipAlarmProjectionTests var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); // The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime - // gets created + polled. Set InFaulted offset-value to 0 first (clear), let polls seed the + // gets created + polled. Set InAlarm offset-value to 0 first (clear), let polls seed the // "last-seen false" state, then flip to 1 (fault) + wait for the raise event. await WaitForTagCreation(factory, "HighTemp"); - factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0 - factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked) + factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InAlarm=false at offset 0 + factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InAlarm+Acked) await WaitForMoreReadsAsync(factory, "HighTemp", 2); factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted @@ -136,11 +137,11 @@ public sealed class AbCipAlarmProjectionTests if (e.Message.Contains("raised")) lock (raises) raises.Add(e); }; - // First subscribe creates + starts polling the HighTemp runtime. Seed InFaulted=false + + // First subscribe creates + starts polling the HighTemp runtime. Seed InAlarm=false + // a severity so the loop's baseline settles on "not faulted". var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForTagCreation(factory, "HighTemp"); - factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0 + factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InAlarm=false at offset 0 factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 await WaitForMoreReadsAsync(factory, "HighTemp", 2); // sub1's loop has seeded its baseline @@ -166,6 +167,35 @@ public sealed class AbCipAlarmProjectionTests } } + /// + /// hw4 — ALMD's alarm-active output is InAlarm (Rockwell ALMD reference); InFaulted (Status.1) is + /// the INPUT-health flag. The projection used to poll InFaulted, so a live ALMD would raise on an input fault + /// and never on the alarm itself. + /// + [Fact] + public async Task An_input_fault_is_not_an_alarm_and_InAlarm_is() + { + var factory = new FakeAbCipTagFactory(); + var drv = new AbCipDriver(Options(enabled: true), "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(["HighTemp"], CancellationToken.None); + await WaitForTagCreation(factory, "HighTemp"); + factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InAlarm clear + factory.Tags["HighTemp"].ValuesByOffset[8] = 500; + factory.Tags["HighTemp"].ValuesByOffset[16] = 1; // InFaulted set: input health bad, not an alarm + await WaitForMoreReadsAsync(factory, "HighTemp", 6); + lock (events) events.ShouldBeEmpty("an input fault (InFaulted) is not an alarm"); + + factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // the alarm condition + await WaitForEventAsync(events, e => e.Kind == AlarmTransitionKind.Raise); + + await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + } + /// Verifies that alarm clear event fires on 1-to-0 transition. [Fact] public async Task Clear_Event_Fires_On_1_to_0_Transition() 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 index 26d96c15..59814aa7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs @@ -8,6 +8,8 @@ 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. +/// Status members are declared BOOL, as on a real controller, so every member is read through its own runtime +/// (the production read path) rather than the whole-UDT offset fast path. /// [Trait("Category", "Unit")] public sealed class AbCipAlmaProjectionTests @@ -15,41 +17,35 @@ 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 = + private static readonly string[] Bits = [ "HHInAlarm", "HInAlarm", "LInAlarm", "LLInAlarm", "ROCPosInAlarm", - "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked", - "HHSeverity", "HSeverity", "LSeverity", "LLSeverity", "ROCPosSeverity", + "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked", "OperAckAll", ]; + private static readonly string[] Sevs = ["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 AbCipTagDefinition AlmaTag(string name = Tag) => new( + name, Device, name, AbCipDataType.Structure, + Members: [.. Bits.Select(n => new AbCipStructureMember(n, AbCipDataType.Bool)), + .. Sevs.Select(n => new AbCipStructureMember(n, AbCipDataType.DInt))]); - private static AbCipDriverOptions Options() => new() + private static AbCipTagDefinition AlmdTag(string name) => new( + name, Device, name, AbCipDataType.Structure, + Members: [new AbCipStructureMember("InAlarm", AbCipDataType.Bool), new AbCipStructureMember("Acked", AbCipDataType.Bool), + new AbCipStructureMember("Severity", AbCipDataType.DInt)]); + + private static AbCipDriverOptions Options(params AbCipTagDefinition[] tags) => new() { Devices = [new AbCipDeviceOptions(Device)], - RawTags = AbCipRawTags.From(AlmaTag()), + RawTags = AbCipRawTags.From(tags), 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 void Set(string member, object v, string tag = Tag) => Factory.Tags[$"{tag}.{member}"].Value = v; + public FakeAbCipTag Probe => Factory.Tags[$"{Tag}.HHInAlarm"]; public async Task StopAsync() { await Drv.UnsubscribeAlarmsAsync(Handle, CancellationToken.None); @@ -57,18 +53,21 @@ public sealed class AbCipAlmaProjectionTests } } - private static async Task StartAsync(Action? seed = null) + /// Starts a quiet ALMA (severities set, nothing in alarm, nothing acked) and applies . + private static async Task StartAsync(Action? seed = null) { var factory = new FakeAbCipTagFactory(); - var drv = new AbCipDriver(Options(), "drv-1", factory); + var drv = new AbCipDriver(Options(AlmaTag()), "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); + await WaitFor(() => Bits.Take(10).Concat(Sevs).All(m => factory.Tags.ContainsKey($"{Tag}.{m}"))); + foreach (var b in Bits.Take(10)) rig.Set(b, false); + rig.Set("HHSeverity", 900); rig.Set("HSeverity", 600); rig.Set("LSeverity", 300); + rig.Set("LLSeverity", 800); rig.Set("ROCPosSeverity", 400); + seed?.Invoke(rig); return rig; } @@ -79,26 +78,36 @@ public sealed class AbCipAlmaProjectionTests 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) + private static async Task Next(Rig r, Func match) { - var target = r.T.ReadCount + 6; - await WaitFor(() => r.T.ReadCount >= target); + await WaitFor(() => { lock (r.Events) return r.Events.Any(match); }); + lock (r.Events) return r.Events.First(match); } - /// The detector flags the six-limit signature and not an ALMD or a plain UDT. + /// Processed-count wait: six more polls of the shared member runtime. + private static async Task Settled(Rig r) + { + var target = r.Probe.ReadCount + 6; + await WaitFor(() => r.Probe.ReadCount >= target); + } + + private static List Snapshot(Rig r) { lock (r.Events) return [.. r.Events]; } + + /// Detector: ALMA by per-limit bits and no plain Acked; ALMD-shaped / plain UDTs are not ALMA. [Fact] - public void Detector_Flags_Alma_And_Not_Almd() + public void Detector_Flags_Alma_And_Not_Almd_Or_User_Udts() { 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(AlmdTag("A")).ShouldBeFalse(); + AbCipAlarmDetector.IsAlmd(AlmdTag("A")).ShouldBeTrue(); + // A user UDT that is ALMD-shaped but happens to name an "HInAlarm" stays ALMD. + var hybrid = new AbCipTagDefinition("U", Device, "U", AbCipDataType.Structure, Members: + [new AbCipStructureMember("InAlarm", AbCipDataType.Bool), new AbCipStructureMember("Acked", AbCipDataType.Bool), + new AbCipStructureMember("HInAlarm", AbCipDataType.Bool)]); + AbCipAlarmDetector.IsAlma(hybrid).ShouldBeFalse(); AbCipAlarmDetector.IsAlma(new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt)).ShouldBeFalse(); + // An ALMA authored WITHOUT members cannot be told apart from an ALMD: it falls to the ALMD path. + AbCipAlarmDetector.IsAlma(new AbCipTagDefinition("N", Device, "N", AbCipDataType.Structure)).ShouldBeFalse(); } /// A limit entering alarm raises one ALMA condition keyed on the RawPath, naming the limit, with the limit's severity. @@ -107,10 +116,9 @@ public sealed class AbCipAlmaProjectionTests { var r = await StartAsync(); await Settled(r); - Set(r.T, "HInAlarm", 1); + r.Set("HInAlarm", true); 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"); @@ -123,7 +131,7 @@ public sealed class AbCipAlmaProjectionTests [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 r = await StartAsync(x => { x.Set("LInAlarm", true); x.Set("HHInAlarm", true); x.Set("ROCPosInAlarm", true); }); var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise); await r.StopAsync(); e.Message.ShouldBe($"ALMA {Tag} HH alarm"); @@ -134,7 +142,7 @@ public sealed class AbCipAlmaProjectionTests [Fact] public async Task A_rate_of_change_limit_is_named_with_its_sign() { - var r = await StartAsync(t => Set(t, "ROCPosInAlarm", 1)); + var r = await StartAsync(x => x.Set("ROCPosInAlarm", true)); var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise); await r.StopAsync(); e.Message.ShouldBe($"ALMA {Tag} ROC+ alarm"); @@ -144,103 +152,195 @@ public sealed class AbCipAlmaProjectionTests [Fact] public async Task All_limits_clearing_fires_one_clear() { - var r = await StartAsync(t => Set(t, "LLInAlarm", 1)); + var r = await StartAsync(x => x.Set("LLInAlarm", true)); await Next(r, x => x.Kind == AlarmTransitionKind.Raise); - Set(r.T, "LLInAlarm", 0); + r.Set("LLInAlarm", false); 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); + Snapshot(r).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. + /// A newly entered (unacked) limit while active is a Retrigger, not a second Raise. [Fact] public async Task A_newly_entered_limit_while_active_retriggers() { - var r = await StartAsync(t => Set(t, "HInAlarm", 1)); + var r = await StartAsync(x => x.Set("HInAlarm", true)); await Next(r, x => x.Kind == AlarmTransitionKind.Raise); - Set(r.T, "HHInAlarm", 1); + r.Set("HHInAlarm", true); 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); + Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1); } - /// A limit dropping out while another stays active updates message/severity without a Raise/Retrigger/Clear. + /// L1 — a limit entering ALREADY acked (AckRequired false) is atomic ActiveAcknowledged, never Retrigger+Acknowledge. [Fact] - public async Task A_limit_dropping_out_updates_the_condition_without_re_raising() + public async Task A_new_limit_entering_already_acked_is_active_acknowledged_not_retrigger_then_ack() { - var r = await StartAsync(t => { Set(t, "HInAlarm", 1); Set(t, "HHInAlarm", 1); }); + var r = await StartAsync(x => { x.Set("HInAlarm", true); + foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true); }); + await Next(r, x => x.Kind == AlarmTransitionKind.ActiveAcknowledged); + r.Set("HHInAlarm", true); // AckRequired=false: HHAcked is already true + await WaitFor(() => Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.ActiveAcknowledged) >= 2); + await Settled(r); + await r.StopAsync(); + var evs = Snapshot(r); + evs.Any(x => x.Kind is AlarmTransitionKind.Retrigger or AlarmTransitionKind.Acknowledge).ShouldBeFalse(); + } + + /// M1 — a limit dropping out while another stays active moves the text/severity as a no-row state update. + [Fact] + public async Task A_limit_dropping_out_is_a_restatement_state_update() + { + var r = await StartAsync(x => { x.Set("HInAlarm", true); x.Set("HHInAlarm", true); }); await Next(r, x => x.Kind == AlarmTransitionKind.Raise); - Set(r.T, "HHInAlarm", 0); + r.Set("HHInAlarm", false); var e = await Next(r, x => x.Kind == AlarmTransitionKind.Unspecified); await Settled(r); await r.StopAsync(); + e.IsRestatement.ShouldBeTrue("the host must write no alerts row for it"); 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); - } + Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Unspecified).ShouldBe(1); } - /// The PLC acknowledging every active limit surfaces as one Acknowledge. + /// M1 — a non-governing limit flickering (ROC under HH) changes neither text nor severity: no event at all. [Fact] - public async Task The_plc_acknowledging_the_active_limits_fires_acknowledge() + public async Task A_non_governing_limit_flicker_emits_nothing() { - var r = await StartAsync(t => Set(t, "HInAlarm", 1)); + var r = await StartAsync(x => { x.Set("HHInAlarm", true); x.Set("ROCPosInAlarm", true); }); await Next(r, x => x.Kind == AlarmTransitionKind.Raise); - Set(r.T, "HAcked", 1); + r.Set("ROCPosInAlarm", false); + await Settled(r); + await r.StopAsync(); + Snapshot(r).Count.ShouldBe(1); + } + + /// The PLC acknowledging every declared limit surfaces as one Acknowledge. + [Fact] + public async Task The_plc_acknowledging_the_limits_fires_acknowledge() + { + var r = await StartAsync(x => x.Set("HInAlarm", true)); + await Next(r, x => x.Kind == AlarmTransitionKind.Raise); + r.Set("HAcked", true); r.Set("HHAcked", true); r.Set("LAcked", true); r.Set("LLAcked", true); r.Set("ROCPosAcked", true); 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); + Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1); + } + + /// + /// M2 — H acked; HH enters unacked then returns to normal still unacked (HHAcked stays false). The surviving, + /// acked H must NOT turn into a device "Acknowledged" for an HH excursion nobody acknowledged. + /// + [Fact] + public async Task An_unacked_excursion_that_returns_to_normal_is_not_reported_as_acknowledged() + { + var r = await StartAsync(x => { x.Set("HInAlarm", true); x.Set("HAcked", true); }); + await Next(r, x => x.Kind == AlarmTransitionKind.Raise); // other limits' Acked bits are false ⇒ still unacked + r.Set("LAcked", true); r.Set("LLAcked", true); r.Set("ROCPosAcked", true); r.Set("HHAcked", true); + await Next(r, x => x.Kind == AlarmTransitionKind.Acknowledge); + r.Set("HHAcked", false); // HH occurrence (unacked)… + r.Set("HHInAlarm", true); + await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger); + r.Set("HHInAlarm", false); // …returns to normal, still unacked + await Settled(r); + await r.StopAsync(); + Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1, + "only the genuine PLC ack — the unacked HH excursion must keep the condition unacknowledged"); } /// 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 r = await StartAsync(x => + { + x.Set("LInAlarm", true); + foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true); + }); 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); + Snapshot(r).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)); + var r = await StartAsync(x => x.Set("HInAlarm", true)); 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); + Snapshot(r).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)); + var r = await StartAsync(x => x.Set("HInAlarm", true)); 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"). + // every re-created runtime must fail too (a fresh fake would read as "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. + foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound; await WaitFor(() => Volatile.Read(ref recreations) >= 6); await r.StopAsync(); - lock (r.Events) r.Events.Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse(); + Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse(); + } + + /// L2 — a Bad *Acked read is "unknown", not "un-acked": it must neither un-ack nor re-ack. + [Fact] + public async Task A_bad_acked_read_is_carried_not_stored_as_unacked() + { + var r = await StartAsync(x => + { + x.Set("HInAlarm", true); + foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true); + }); + await Next(r, x => x.Kind == AlarmTransitionKind.ActiveAcknowledged); + // Transient Bad *Acked reads: the evicted runtime is re-created failing, tick after tick. + var bad = 0; + r.Factory.Customise = p => + { + if (!p.TagName.EndsWith("HAcked", StringComparison.Ordinal)) return null!; + Interlocked.Increment(ref bad); + return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; + }; + r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.NotFound; + await WaitFor(() => Volatile.Read(ref bad) >= 4); + // The link recovers and HAcked reads true again. + r.Factory.Customise = p => new FakeAbCipTag(p) { Value = true }; + await WaitFor(() => r.Factory.Tags[$"{Tag}.HAcked"].Status == 0 && r.Factory.Tags[$"{Tag}.HAcked"].Value is true); + await Settled(r); + await r.StopAsync(); + var evs = Snapshot(r); + evs.Any(x => x.Kind is AlarmTransitionKind.Acknowledge or AlarmTransitionKind.Retrigger or AlarmTransitionKind.Raise).ShouldBeFalse( + "a transient Bad *Acked read must not produce a second ack/raise"); + } + + /// L2 — a Bad severity read must not change the told message/severity. + [Fact] + public async Task A_bad_severity_read_changes_nothing() + { + var r = await StartAsync(x => { x.Set("HInAlarm", true); x.Set("HHInAlarm", true); }); + await Next(r, x => x.Kind == AlarmTransitionKind.Raise); + r.Factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; + r.Factory.Tags[$"{Tag}.HHSeverity"].Status = (int)AbStatus.NotFound; + await Task.Delay(150); + await Settled(r); + await r.StopAsync(); + Snapshot(r).Count.ShouldBe(1, "no Retrigger/update from a Bad severity read"); } /// A client acknowledge writes the ALMA's OperAckAll input — never the ALMD-only Acked member. @@ -254,4 +354,34 @@ public sealed class AbCipAlmaProjectionTests r.Factory.Tags[$"{Tag}.OperAckAll"].WriteCount.ShouldBe(1); r.Factory.Tags.ShouldNotContainKey($"{Tag}.Acked"); } + + /// L4 — one subscription mixing an ALMD and an ALMA: the cursor arithmetic keeps each node's reads apart. + [Fact] + public async Task A_mixed_almd_and_alma_subscription_projects_each_correctly() + { + var factory = new FakeAbCipTagFactory(); + var drv = new AbCipDriver(Options(AlmdTag("Motor"), AlmaTag(), AlmdTag("Pump")), "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(["Motor", Tag, "Pump"], CancellationToken.None); + await WaitFor(() => factory.Tags.ContainsKey($"{Tag}.HHSeverity") && factory.Tags.ContainsKey("Pump.Severity") + && factory.Tags.ContainsKey("Motor.Severity") && factory.Tags.ContainsKey($"{Tag}.HAcked")); + factory.Tags["Motor.Severity"].Value = 300; + factory.Tags["Pump.Severity"].Value = 700; + factory.Tags[$"{Tag}.HSeverity"].Value = 600; + factory.Tags["Pump.InAlarm"].Value = true; + factory.Tags[$"{Tag}.HInAlarm"].Value = true; + + await WaitFor(() => { lock (events) return events.Count >= 2; }); + await Task.Delay(100); + await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + List evs; lock (events) evs = [.. events]; + evs.Count.ShouldBe(2); + var pump = evs.Single(e => e.ConditionId == "Pump"); + pump.AlarmType.ShouldBe("ALMD"); pump.Severity.ShouldBe(AlarmSeverity.High); + var alma = evs.Single(e => e.ConditionId == Tag); + alma.AlarmType.ShouldBe("ALMA"); alma.OpcUaSeverity.ShouldBe(600); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/AbCipAlmaNativeAlarmEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/AbCipAlmaNativeAlarmEndToEndTests.cs new file mode 100644 index 00000000..0b827959 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/AbCipAlmaNativeAlarmEndToEndTests.cs @@ -0,0 +1,228 @@ +using System.Text.Json; +using Akka.Actor; +using Akka.Cluster.Tools.PublishSubscribe; +using Akka.TestKit; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Gitea #611 item 1 (hw4 review L3) — an AbCip ALMA reaches its Part 9 condition and its /alerts rows END +/// TO END: the REAL (polling a fake controller, ab_server/Z35162 have no ALMA) raises into +/// the REAL , and the test reads what ForwardNativeAlarm + +/// NativeAlarmProjector turned the projection's events into. The row level is where a drop-out's false +/// "Activated" and a surviving limit's false "Acknowledged" show — the driver's own events look fine. +/// +public sealed class AbCipAlmaNativeAlarmEndToEndTests : RuntimeActorTestBase +{ + private static readonly NodeId TestNode = NodeId.Parse("abcip-alma-e2e"); + private static readonly RevisionHash Rev = RevisionHash.Parse(new string('c', 64)); + private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5); + private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(600); + + private const string DriverId = "drv-abcip"; + private const string Device = "ab://10.0.0.5/1,0"; + private const string TagPath = "HighPress"; + private const string RawPath = "Plant/AbCip/plc/hp"; + + private static readonly string[] Bits = + ["HHInAlarm", "HInAlarm", "LInAlarm", "LLInAlarm", "HHAcked", "HAcked", "LAcked", "LLAcked"]; + private static readonly string[] Sevs = ["HHSeverity", "HSeverity", "LSeverity", "LLSeverity"]; + + /// + /// raise (H) → escalate (HH enters: Retrigger) → HH drops out (no row, node text moves to H) → the PLC acks + /// everything (ONE "Acknowledged") → HH excursion unacked then gone (still unacked, no false ack) → clear. + /// + [Fact] + public async Task An_ALMA_walks_raise_escalate_dropout_ack_clear_with_the_right_rows() + { + var db = NewInMemoryDbFactory(); + var alerts = SubscribeAlerts(); + var (host, publish) = SpawnHostAndApply(db, Seed(db, BuildArtifact(), Rev)); + var plc = new FakePlc(); + foreach (var b in Bits) plc.Set(b, false); + plc.Set("HHSeverity", 900); plc.Set("HSeverity", 600); plc.Set("LSeverity", 300); plc.Set("LLSeverity", 800); + + var drv = new AbCipDriver( + new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = [new RawTagEntry(RawPath, TagConfig(), false, Device)], + EnableAlarmProjection = true, + AlarmPollInterval = TimeSpan.FromMilliseconds(20), + }, + DriverId, plc); + try + { + await drv.InitializeAsync("{}", CancellationToken.None); + drv.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e)); +#pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping + await drv.SubscribeAlarmsAsync([RawPath], CancellationToken.None); +#pragma warning restore OTOPCUA0001 + + // Raise: H. + plc.Set("HInAlarm", true); + var raised = NextUpdate(publish); + raised.AlarmNodeId.ShouldBe(RawPath); + raised.State.Active.ShouldBeTrue(); + raised.State.Acknowledged.ShouldBeFalse(); + raised.State.Message.ShouldContain("H alarm"); + Row(alerts).TransitionKind.ShouldBe("Activated"); + + // Escalate: HH enters while H stands — a new unacknowledged occurrence. + plc.Set("HHInAlarm", true); + var esc = NextUpdate(publish); + esc.State.Active.ShouldBeTrue(); + esc.State.Acknowledged.ShouldBeFalse(); + esc.State.Message.ShouldContain("HH alarm"); + Row(alerts).TransitionKind.ShouldBe("Activated"); + + // HH drops out, H stands: text/severity move to H, but nothing new happened — NO row. + plc.Set("HHInAlarm", false); + var drop = NextUpdate(publish); + drop.State.Active.ShouldBeTrue(); + drop.State.Acknowledged.ShouldBeFalse(); + drop.State.Message.ShouldContain("H alarm"); + drop.State.Message.ShouldNotContain("HH"); + drop.State.Severity.ShouldBe((ushort)600); + alerts.ExpectNoMsg(Quiet); + + // The PLC acknowledges every declared limit — ONE "Acknowledged" row. + foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked" }) plc.Set(a, true); + NextUpdate(publish).State.Acknowledged.ShouldBeTrue(); + Row(alerts).TransitionKind.ShouldBe("Acknowledged"); + + // An HH excursion nobody acknowledges (HHAcked drops, HH enters then leaves): the surviving, acked H + // must not turn into a device "Acknowledged" for it. + plc.Set("HHAcked", false); + plc.Set("HHInAlarm", true); + NextUpdate(publish).State.Acknowledged.ShouldBeFalse(); + Row(alerts).TransitionKind.ShouldBe("Activated"); + plc.Set("HHInAlarm", false); + var back = NextUpdate(publish); + back.State.Acknowledged.ShouldBeFalse("the unacknowledged HH excursion keeps the condition unacknowledged"); + alerts.ExpectNoMsg(Quiet); + + // Positive control: the clear MUST write its row (the absence checks above would pass on a dead host). + plc.Set("HInAlarm", false); + var cleared = NextUpdate(publish); + cleared.State.Active.ShouldBeFalse(); + Row(alerts).TransitionKind.ShouldBe("Cleared"); + } + finally + { + await drv.ShutdownAsync(CancellationToken.None); + drv.Dispose(); + } + } + + private AlarmTransitionEvent Row(TestProbe alerts) => alerts.ExpectMsg(Wait); + + private static OpcUaPublishActor.AlarmStateUpdate NextUpdate(TestProbe publish) => + publish.FishForMessage(_ => true, Wait); + + private TestProbe SubscribeAlerts() + { + var alerts = CreateTestProbe(); + DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref); + alerts.ExpectMsg(Wait); + return alerts; + } + + private static string TagConfig() + { + var members = Bits.Select(n => (object)new { name = n, dataType = "Bool" }) + .Concat(Sevs.Select(n => (object)new { name = n, dataType = "DInt" })).ToArray(); + return JsonSerializer.Serialize(new + { + tagPath = TagPath, + dataType = "Structure", + members, + alarm = new { alarmType = "AlarmCondition", severity = 800 }, + }); + } + + private static byte[] BuildArtifact() => + JsonSerializer.SerializeToUtf8Bytes(new + { + RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = new[] + { + // Enabled=false: the host must not spawn a real wire driver — the test drives its own instance. + new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "AbCip", DriverType = DriverTypeNames.AbCip, DriverConfig = "{}", ClusterId = "c1", Enabled = false }, + }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = DriverId, Name = "plc", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = new[] + { + new { TagId = "tag-alarm", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "hp", + DataType = "Boolean", AccessLevel = 0, TagConfig = TagConfig() }, + }, + }); + + private static DeploymentId Seed(IDbContextFactory db, byte[] blob, RevisionHash rev) + { + var id = DeploymentId.NewId(); + using var ctx = db.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed, + CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = blob, + }); + ctx.SaveChanges(); + return id; + } + + private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(IDbContextFactory db, DeploymentId deploymentId) + { + var coordinator = CreateTestProbe(); + var publish = this.CreateCleanOutcomePublishProbe(); + var actor = Sys.ActorOf(DriverHostActor.Props( + db, TestNode, coordinator.Ref, + localRoles: new HashSet { "driver" }, + dependencyMux: CreateTestProbe().Ref, + opcUaPublishActor: publish.Ref, + virtualTagEvaluator: NullVirtualTagEvaluator.Instance, + virtualTagHostOverride: CreateTestProbe().Ref)); + actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId())); + coordinator.ExpectMsg(Wait).Outcome.ShouldBe(ApplyAckOutcome.Applied); + publish.ExpectMsg(Wait); + return (actor, publish); + } + + /// A fake controller: every member runtime answers from one shared value table, keyed by member name. + private sealed class FakePlc : IAbCipTagFactory + { + private readonly System.Collections.Concurrent.ConcurrentDictionary _values = new(); + public void Set(string member, object value) => _values[member] = value; + public IAbCipTagRuntime Create(AbCipTagCreateParams p) => new Runtime(this, p.TagName[(p.TagName.LastIndexOf('.') + 1)..]); + + private sealed class Runtime(FakePlc plc, string member) : IAbCipTagRuntime + { + public Task InitializeAsync(CancellationToken ct) => Task.CompletedTask; + public Task ReadAsync(CancellationToken ct) => Task.CompletedTask; + public Task WriteAsync(CancellationToken ct) => Task.CompletedTask; + public int GetStatus() => 0; + public object? DecodeValue(AbCipDataType type, int? bitIndex) => plc._values.GetValueOrDefault(member); + public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => null; + public object? DecodeArray(AbCipDataType type, int count) => null; + public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) { } + public void Dispose() { } + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs index 49f79b03..55cd4903 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs @@ -456,6 +456,28 @@ public class NativeAlarmProjectorTests rows.ShouldBe([kind]); } + /// hw4 (AbCip ALMA) — an Unspecified + IsRestatement update on an ACTIVE condition moves the text/severity, + /// keeps active/acked, and writes NO row; an unflagged Unspecified keeps its historical "Activated" row. + [Fact] + public void A_flagged_Unspecified_update_moves_text_and_severity_without_a_row() + { + var sut = new NativeAlarmProjector(); + sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.Critical, "HH alarm"), out IReadOnlyList _); + sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge), out IReadOnlyList _); + + var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified, AlarmSeverity.High, "H alarm", restated: true), out IReadOnlyList rows); + + rows.ShouldBeEmpty(); + snap.Active.ShouldBeTrue(); + snap.Acknowledged.ShouldBeTrue(); + snap.Message.ShouldBe("H alarm"); + snap.Severity.ShouldBe((ushort)700); + + // Control: the same update WITHOUT the flag still writes its row (unchanged behaviour for other sources). + sut.Project("n1", Evt(AlarmTransitionKind.Unspecified, AlarmSeverity.High, "H alarm"), out IReadOnlyList legacy); + legacy.ShouldBe([AlarmTransitionKind.Unspecified]); + } + private static AlarmEventArgs Evt( AlarmTransitionKind kind, AlarmSeverity sev = AlarmSeverity.High, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj index 8874ee79..554d3e15 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj @@ -34,6 +34,7 @@ + From 5d7c53bb1913d9f2bce60271d7f72626061e025c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 04:30:56 -0400 Subject: [PATCH 3/3] fix(abcip): ALMA review round 2 - IsRestatement contract docs, ALMD no-InAlarm warning, un-ack retrigger Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- CLAUDE.md | 5 ++- docs/AlarmTracking.md | 8 ++-- docs/drivers/AbCip.md | 5 ++- .../IAlarmSource.cs | 8 +++- .../AbCipAlarmProjection.cs | 23 +++++++++++ .../Drivers/NativeAlarmProjector.cs | 9 ++++- .../AbCipAlarmProjectionTests.cs | 38 +++++++++++++++++++ .../AbCipAlmaProjectionTests.cs | 19 ++++++++++ 8 files changed, 105 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cda3152a..7afe1318 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1139,10 +1139,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa condition (no prior told), Galaxy for an unacked active snapshot entry. **Those two also flag `AlarmEventArgs.IsRestatement` (#656)** — history-only: a flagged Retrigger on an Active+unacked condition with the same message AND severity, or a flagged Clear on an inactive one, writes no `/alerts` row (every Galaxy reconnect used - to duplicate them). Active+acked is the atomic **`AlarmTransitionKind.ActiveAcknowledged`** (Galaxy snapshot + to duplicate them); a flagged `Unspecified` on an already-active condition (AbCip ALMA governing-limit drop-out) + moves the node's text/severity, keeps active/acked and writes no row. Active+acked is the atomic **`AlarmTransitionKind.ActiveAcknowledged`** (Galaxy snapshot `ActiveAcked`, OpcUaClient inactive→acked-active) — never Acknowledge (kept a cold host's `Active=false`: the alarm was invisible) nor Raise+Acknowledge (an unacked intermediate). `NativeAlarmProjector` returns the rows to write; unflagged - sources (FOCAS/MTConnect/AbCip, live Galaxy) are unchanged. E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`. + sources (FOCAS/MTConnect, live Galaxy) are unchanged. E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`. - **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync` first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip alarm projection; FOCAS's is PAUSED, its told record kept, #647) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 4f45d865..2fdff8c6 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -69,10 +69,12 @@ same condition is wired as an **event notifier of every referencing equipment fo `Unspecified` kind projects no state change. - **Snapshot / first-sight re-statements (#656).** A source that RE-STATES upstream's current state — Galaxy's reconnect snapshot entries, OpcUaClient's first event per condition after a start or deploy respawn — flags - `AlarmEventArgs.IsRestatement` (optional, default `false`; FOCAS, MTConnect, AbCip and live Galaxy transitions never - set it). The flag governs **history only, never node state**: the projector still projects, but a flagged `Retrigger` + `AlarmEventArgs.IsRestatement` (optional, default `false`; FOCAS, MTConnect and live Galaxy transitions never + set it; AbCip ALMA sets it for the drop-out update below). The flag governs **history only, never node state**: the projector still projects, but a flagged `Retrigger` on a condition already Active+unacked with the **same message AND severity**, or a flagged `Clear` on an - already-inactive condition, writes no `/alerts` row. Before this every gateway reconnect wrote a duplicate + already-inactive condition, writes no `/alerts` row; so does a flagged `Unspecified` update on an already-active + condition, which moves the node's message/severity (one condition event, active/acked kept) — AbCip ALMA's + governing limit dropping out (HH→H), and OpcUaClient's first sight of an inactive condition after a respawn. Before this every gateway reconnect wrote a duplicate "Activated" (and "Cleared") row per alarm while clients saw no event. A **live** `Retrigger` is a genuine re-occurrence and keeps its row; a changed severity (an MTConnect Warning→Fault that keeps its text) does too. Residual: a Galaxy alarm that genuinely re-occurred during an outage and is still Active+unacked with identical diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 8a7852cc..cee4499a 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -271,6 +271,8 @@ routes native transitions on) and `Kind` = `Raise` / `Clear`. Before Gitea #641 **ALMD's active bit is `InAlarm`** (Rockwell's ALMD reference). Until hw4 the projection polled `InFaulted`, which is the *input-health* flag (Status.1, set when the program raises `InFault`), so a live ALMD would have raised on input faults and never on the alarm itself. Declare `InAlarm` (and `Severity`) on an ALMD tag. +**Migration:** a tag that declared `InFaulted` but not `InAlarm` now never raises (the `InAlarm` read is +`BadNodeIdUnknown`); the projection logs a once-per-tag Warning at subscribe — add `InAlarm` to the tag's members. ### ALMA (analog alarms) @@ -294,7 +296,8 @@ layout — leave grouping off for ALMA tags (BOOL members are read member by mem instruction's `AckRequired` is false); active→none `Clear`; a limit **newly** entering while active `Retrigger` (a new unacknowledged occurrence) — or `ActiveAcknowledged` if nothing is pending ack, never Retrigger-then-Acknowledge; every declared `*Acked` bit going true while the condition was unacknowledged - `Acknowledge`; a limit dropping out (the governing limit or its severity changes, no new occurrence) an + `Acknowledge`; a Good `*Acked` read going false on a fully-acked condition with the same limits active + `Retrigger` (an occurrence the poll missed); a limit dropping out (the governing limit or its severity changes, no new occurrence) an `Unspecified` event flagged `IsRestatement`, which `NativeAlarmProjector` applies as a text/severity move with **no `/alerts` row**. A non-governing limit flickering emits nothing. - **"Acked" is judged over every declared limit, active or not.** In Logix an occurrence that returned to normal diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs index b95f4167..f63011dc 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -107,8 +107,12 @@ public sealed record AlarmAcknowledgeRequest( /// watched happen: a reconnect snapshot entry (Galaxy) or the first event for a condition after a driver start or /// deploy respawn (OpcUaClient). It governs history only, never node state: the host still projects the /// state, but writes no alerts row when the re-statement changes nothing the condition shows (a Retrigger on -/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition). -/// false (the default, every source that does not opt in) keeps every transition's row. +/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition) — or +/// when a flagged update moves the message/severity of an +/// already-active condition (node updated, one condition event, active/acked kept, no row). AbCip ALMA sets the +/// flag for the last shape (the governing limit changed, no new occurrence); OpcUaClient's first sight of an +/// inactive condition after a respawn can reach it too. false (the default, every source that does not +/// opt in) keeps every transition's row. /// public sealed record AlarmEventArgs( IAlarmSubscriptionHandle SubscriptionHandle, 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 bff47d58..fb574ab4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs @@ -38,6 +38,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable private readonly Dictionary _subs = new(); private readonly Lock _subsLock = new(); private long _nextId; + private readonly HashSet _warnedNoInAlarm = new(StringComparer.Ordinal); /// Initializes a new instance of the class. /// The AB CIP driver instance. @@ -285,6 +286,8 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable } } + private bool WarnOnce(string id) { lock (_warnedNoInAlarm) return _warnedNoInAlarm.Add(id); } + private List BuildPlans(IReadOnlyList nodeIds) { var plans = new List(nodeIds.Count); @@ -293,6 +296,16 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable AlmaPlan? alma = null; if (_driver.TryGetTagDefinition(id, out var def) && AbCipAlarmDetector.IsAlma(def)) alma = AlmaPlan.From(def); + else if (def is { DataType: AbCipDataType.Structure, Members: { } members } + && !members.Any(m => string.Equals(m.Name, "InAlarm", StringComparison.OrdinalIgnoreCase)) + && WarnOnce(id)) + { + // ALMD's active bit is InAlarm; a tag that declared members without it (e.g. a pre-hw4 config that + // declared InFaulted) reads BadNodeIdUnknown every tick and would stay silent forever. + _logger.LogWarning( + "AbCip alarm ref {Ref} declares members but no 'InAlarm'; the ALMD projection polls InAlarm + Severity, " + + "so this alarm will never raise. Add InAlarm (InFaulted is the input-health flag, not the alarm).", id); + } plans.Add(new NodePlan(alma)); } return plans; @@ -307,6 +320,8 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable /// a limit NEWLY entering: Retrigger (a new unacknowledged occurrence) — or ActiveAcknowledged when /// nothing is pending ack (AckRequired false), never Retrigger-then-Acknowledge (#656). /// no new limit, every declared limit now acked while the condition was unacked: Acknowledge. + /// no new limit, the condition was fully acked and a declared *Acked read (Good) is now false: + /// Retrigger — an occurrence the poll missed. /// no new limit, the governing limit or its severity changed (a drop-out): an /// Unspecified + IsRestatement state update — the projector keeps active/acked and writes /// NO alerts row; anything else here would render a false "Activated". @@ -386,6 +401,14 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable } _driver.AlarmAnalogRecord.Set(nodeId, mask, nowAcked, topId, topSev); + if (ackKnown && prior.Acked && !allAcked) + { + // Every limit was acked and now one is not, with the same limits active: an occurrence the poll missed (a + // limit left and re-entered inside one interval) or one a Bad *Acked read hid on the tick it entered. + // Either way the PLC says unacknowledged, so tell it — a new unacknowledged occurrence. + _driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Retrigger, message, topSev)); + return; + } if (nowAcked && !prior.Acked) _driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Acknowledge, message, topSev)); else if (topId != prior.Top || topSev != prior.TopSeverity) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs index f710ce84..c21bc591 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs @@ -52,8 +52,10 @@ public sealed class NativeAlarmProjector /// Gitea #656 — a event (a source re-stating upstream's /// current state: a reconnect snapshot, first sight after a respawn) writes no row when it changes nothing /// the condition shows: a Retrigger on an Active+unacked condition with the same message AND severity, or - /// a Clear on an already-inactive condition. Unflagged sources (FOCAS, MTConnect, AbCip, live Galaxy - /// transitions) are unaffected — a live Retrigger is a genuine re-occurrence and keeps its row. + /// a Clear on an already-inactive condition; and a flagged Unspecified update of an already-active + /// condition moves its message/severity (node + one condition event) with active/acked kept and no row + /// (AbCip ALMA's governing-limit change). Unflagged sources (FOCAS, MTConnect, live Galaxy transitions) are + /// unaffected — a live Retrigger is a genuine re-occurrence and keeps its row. /// /// /// The materialised condition node's id (the projection's state key). @@ -110,6 +112,9 @@ public sealed class NativeAlarmProjector break; // A message/severity-only update of an ALREADY-ACTIVE condition that the source flags as a re-statement // (AbCip ALMA: the governing limit changed but no new occurrence started, e.g. HH dropped back to H). + // OpcUaClient can reach it too: first sight after a respawn of an upstream-inactive condition is told + // Unspecified+flagged; with the host still Active the node stays Active exactly as before (a pre-existing + // gap) and the false "Activated" row it used to write is now gone. // The node's text/severity move; active/acked are kept; NO row (an "Activated" row would be false). case AlarmTransitionKind.Unspecified when e.IsRestatement && prev.Active: (active, acked, historyKinds) = (true, prev.Acked, []); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs index 1b2f94fc..11979e9d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs @@ -196,6 +196,44 @@ public sealed class AbCipAlarmProjectionTests await drv.ShutdownAsync(CancellationToken.None); } + /// hw4 F2 — an ALMD that declared members but no InAlarm (a pre-hw4 InFaulted config) would read + /// BadNodeIdUnknown forever; it logs ONE Warning per tag instead of staying silent. + [Fact] + public async Task An_almd_without_a_declared_InAlarm_warns_once() + { + var legacy = new AbCipTagDefinition("Legacy", Device, "Legacy", AbCipDataType.Structure, Members: + [new AbCipStructureMember("InFaulted", AbCipDataType.DInt), new AbCipStructureMember("Acked", AbCipDataType.DInt), + new AbCipStructureMember("Severity", AbCipDataType.DInt)]); + var log = new CapturingLogger(); + var opts = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From(legacy), + EnableAlarmProjection = true, + AlarmPollInterval = TimeSpan.FromMilliseconds(20), + }; + var drv = new AbCipDriver(opts, "drv-1", new FakeAbCipTagFactory(), logger: log); + await drv.InitializeAsync("{}", CancellationToken.None); + var h1 = await drv.SubscribeAlarmsAsync(["Legacy"], CancellationToken.None); + var h2 = await drv.SubscribeAlarmsAsync(["Legacy"], CancellationToken.None); + await drv.UnsubscribeAlarmsAsync(h1, CancellationToken.None); + await drv.UnsubscribeAlarmsAsync(h2, CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + log.Warnings.Count(m => m.Contains("Legacy") && m.Contains("InAlarm")).ShouldBe(1); + } + + private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger + { + public List Warnings { get; } = []; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, + TState state, Exception? exception, Func formatter) + { + if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning) lock (Warnings) Warnings.Add(formatter(state, exception)); + } + } + /// Verifies that alarm clear event fires on 1-to-0 transition. [Fact] public async Task Clear_Event_Fires_On_1_to_0_Transition() 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 index 59814aa7..c6012a88 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs @@ -254,6 +254,25 @@ public sealed class AbCipAlmaProjectionTests "only the genuine PLC ack — the unacked HH excursion must keep the condition unacknowledged"); } + /// F3 — fully acked, then a declared *Acked goes false with the SAME limits active (a limit that left and re-entered + /// between polls): the PLC says unacknowledged, so it is told as a Retrigger. + [Fact] + public async Task An_ack_falling_with_an_unchanged_active_set_retriggers() + { + var r = await StartAsync(x => + { + x.Set("HInAlarm", true); + foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true); + }); + await Next(r, x => x.Kind == AlarmTransitionKind.ActiveAcknowledged); + r.Set("HAcked", false); + var e = await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger); + await Settled(r); + await r.StopAsync(); + e.Message.ShouldBe($"ALMA {Tag} H alarm"); + Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Retrigger).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()