From 83d8de303c9e75bb01e5607aec0def249c2e80f1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 04:22:45 -0400 Subject: [PATCH] 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 @@ +