Merge hw4-alma (#592/#611)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -125,7 +127,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.
|
||||
|
||||
+56
-6
@@ -19,7 +19,7 @@ coverage map, see [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md).
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD alarm projection. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD/ALMA alarm projection. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/` | Project-less shared source (`LibplctagOrphanRelease.cs`, #578/#580) compiled into both `Driver.AbCip` and `Driver.AbLegacy`. The `AbStatus` → OPC UA table is shared in `Driver.Ab.Transport` (`AbOpcUaStatusMap`). |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. |
|
||||
|
||||
@@ -44,7 +44,7 @@ AB Legacy surface.
|
||||
| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | CIP has no push model — subscriptions become polling groups. |
|
||||
| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | One probe loop per device reading `Probe.ProbeTagPath`; no path configured ⇒ a warning is logged and the device stays `Unknown`. |
|
||||
| `IPerCallHostResolver` | `ResolveHost` | Routes each call to the tag's `DeviceHostAddress`, the breaker key for the resilience pipeline so one dead PLC trips only its own breaker. |
|
||||
| `IAlarmSource` | `AbCipAlarmProjection` (ALMD) | Opt-in via `EnableAlarmProjection`; off by default the subscribe path is a no-op so capability negotiation still works. |
|
||||
| `IAlarmSource` | `AbCipAlarmProjection` (ALMD + ALMA) | Opt-in via `EnableAlarmProjection`; off by default the subscribe path is a no-op so capability negotiation still works. |
|
||||
|
||||
## Addressing Model
|
||||
|
||||
@@ -80,7 +80,7 @@ the driver's `DriverConfig` JSON. Key fields:
|
||||
- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). They also write durations as the integer-millisecond keys the factory binds (`timeoutMs`, `alarmPollIntervalMs`, `probe.intervalMs`, `probe.timeoutMs`); a legacy TimeSpan-string key (`timeout`: "00:00:05") is loaded and migrated on save.
|
||||
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
|
||||
- **Discovery** — `EnableControllerBrowse` (`@tags` walk), `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path) and `browsePageDelayMs` (native browse pacing, see [Native browse](#native-browse-paging-pacing-and-template-leases)).
|
||||
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
|
||||
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD/ALMA projection.
|
||||
|
||||
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
|
||||
reproduced in [docs/v2/driver-specs.md §3](../v2/driver-specs.md).
|
||||
@@ -262,9 +262,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 `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
|
||||
the projection semantics don't exactly mirror Rockwell FT A&E.
|
||||
|
||||
@@ -272,6 +272,56 @@ 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.
|
||||
**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)
|
||||
|
||||
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 <ref> HH alarm`,
|
||||
`H`, `L`, `LL`, `ROC+`, `ROC-`) come from the **most severe active limit** (ties: HH, LL, H, L, ROC+, ROC-).
|
||||
- **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 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
|
||||
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 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
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <b>history only, never node state</b>: the host still projects the
|
||||
/// state, but writes no <c>alerts</c> 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).
|
||||
/// <c>false</c> (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 <see cref="AlarmTransitionKind.Unspecified"/> 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. <c>false</c> (the default, every source that does not
|
||||
/// opt in) keeps every transition's row.
|
||||
/// </param>
|
||||
public sealed record AlarmEventArgs(
|
||||
IAlarmSubscriptionHandle SubscriptionHandle,
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class AbCipDriverOptions
|
||||
/// <summary>
|
||||
/// When <c>true</c>, declared ALMD tags are surfaced as alarm conditions
|
||||
/// via <c>IAlarmSource</c>; the driver polls each subscribed
|
||||
/// alarm's <c>InFaulted</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
|
||||
/// alarm's <c>InAlarm</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
|
||||
/// state transitions. Default <c>false</c> — operators explicitly opt in because
|
||||
/// projection semantics don't exactly mirror Rockwell FT Alarm & Events; shops
|
||||
/// running FT Live should keep this off + take alarms through the native route.
|
||||
|
||||
@@ -5,18 +5,18 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
|
||||
/// polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members at a
|
||||
/// Projects AB Logix ALMD (digital) and ALMA (analog, <c>ALARM_ANALOG</c>) alarm instructions onto the OPC UA
|
||||
/// alarm surface by polling the ALMD UDT's <c>InAlarm</c> / <c>Severity</c> members (or the
|
||||
/// ALMA limit/ack/severity members) at a
|
||||
/// configurable interval + translating state transitions into <c>OnAlarmEvent</c>
|
||||
/// callbacks on the owning <see cref="AbCipDriver"/>. Feature-flagged off by default via
|
||||
/// <see cref="AbCipDriverOptions.EnableAlarmProjection"/>; callers that leave the flag off
|
||||
/// get a no-op subscribe path so capability negotiation still works.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>ALMD-only in this pass. ALMA (analog alarm) projection is a follow-up because
|
||||
/// its threshold + limit semantics need more design — ALMD's "is the alarm active + has
|
||||
/// the operator acked" shape maps cleanly onto the driver-agnostic
|
||||
/// <see cref="IAlarmSource"/> contract without concessions.</para>
|
||||
/// <para>A subscribed ref is treated as ALMA when its declared tag definition carries any of the six per-limit
|
||||
/// <c>*InAlarm</c> members (<see cref="AbCipAlarmDetector.IsAlma"/>); every other ref is ALMD (the historic
|
||||
/// default, including refs with no declared members). See <see cref="AbCipAlmaLimit"/> for the ALMA mapping.</para>
|
||||
///
|
||||
/// <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the
|
||||
/// whole-UDT optimization for free when the ALMD is declared with its standard members.
|
||||
@@ -38,6 +38,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
private readonly HashSet<string> _warnedNoInAlarm = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AbCipAlarmProjection"/> class.</summary>
|
||||
/// <param name="driver">The AB CIP driver instance.</param>
|
||||
@@ -61,6 +62,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
var handle = new AbCipAlarmSubscriptionHandle(id);
|
||||
var cts = new CancellationTokenSource();
|
||||
var sub = new Subscription(handle, [..sourceNodeIds], cts);
|
||||
sub.Plans = BuildPlans(sub.SourceNodeIds);
|
||||
|
||||
// Collapse to a SINGLE active poll loop. The owning DriverInstanceActor re-subscribes
|
||||
// alarms on every Connected entry (its DetachAlarmSource nulls the cached handle on
|
||||
@@ -75,6 +77,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// server carries the condition across a rebuild, and a Raise would un-acknowledge it. A ref that left and
|
||||
// comes back was materialised fresh, so it starts from "not faulted" and is raised again.
|
||||
_driver.AlarmFaultRecord.RetainOnly(sub.SourceNodeIds);
|
||||
_driver.AlarmAnalogRecord.RetainOnly(sub.SourceNodeIds);
|
||||
|
||||
List<Subscription> stale;
|
||||
lock (_subsLock)
|
||||
@@ -146,15 +149,26 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// Write Acked=1 per request. IWritable isn't on AbCipAlarmProjection so route through
|
||||
// the driver's public interface — delegating instead of re-implementing the write path
|
||||
// keeps the bit-in-DINT + idempotency + per-call-host-resolve knobs intact.
|
||||
// ALMA has no top-level Acked status bit (the per-limit *Acked bits are instruction OUTPUTS); the designed
|
||||
// operator-ack input is OperAckAll, so an ALMA ref gets that instead.
|
||||
var requests = acknowledgements
|
||||
.Select(a => new WriteRequest($"{a.SourceNodeId}.Acked", true))
|
||||
.Select(a => new WriteRequest(
|
||||
_driver.TryGetTagDefinition(a.SourceNodeId, out var def) && AbCipAlarmDetector.IsAlma(def)
|
||||
? $"{a.SourceNodeId}.OperAckAll"
|
||||
: $"{a.SourceNodeId}.Acked", true))
|
||||
.ToArray();
|
||||
// Best-effort — the driver's WriteAsync returns per-item status; individual ack
|
||||
// failures don't poison the batch. Swallow the return so a single faulted ack
|
||||
// doesn't bubble out of the caller's batch expectation.
|
||||
#pragma warning disable OTOPCUA0001 // Driver-INTERNAL self-call: the projection is owned by AbCipDriver; CapabilityInvoker wraps the driver from the dispatch layer OUTWARD, so a driver's own internal ack path must NOT re-wrap. Intentional, not a dispatch gap.
|
||||
_ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
|
||||
var written = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
// An ALMA ref whose OperAckAll member is not declared (or an ALMA-looking user UDT) fails here as
|
||||
// BadNodeIdUnknown; surface it rather than letting the ack vanish.
|
||||
for (var i = 0; i < written.Count && i < requests.Length; i++)
|
||||
if (written[i].StatusCode != AbCipStatusMapper.Good)
|
||||
_logger.LogWarning("AbCip alarm acknowledge write to {Reference} failed with status 0x{Status:X8}",
|
||||
requests[i].FullReference, written[i].StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Releases all resources associated with this alarm projection.</summary>
|
||||
@@ -172,7 +186,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Poll-tick body — reads <c>InFaulted</c> + <c>Severity</c> for every source node id
|
||||
/// Poll-tick body — reads <c>InAlarm</c> + <c>Severity</c> for every source node id
|
||||
/// in the subscription, diffs each against last-seen state, fires raise/clear events.
|
||||
/// Extracted so tests can drive one tick without standing up the Task.Run loop.
|
||||
/// </summary>
|
||||
@@ -180,19 +194,28 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
/// <param name="results">The data values read from the subscription source nodes.</param>
|
||||
internal void Tick(Subscription sub, IReadOnlyList<DataValueSnapshot> results)
|
||||
{
|
||||
// results index layout: for each sourceNode, [InFaulted, Severity] in order.
|
||||
// results index layout: per sourceNode, ALMD = [InAlarm, Severity]; ALMA = the plan's member list.
|
||||
var cursor = 0;
|
||||
for (var i = 0; i < sub.SourceNodeIds.Count; i++)
|
||||
{
|
||||
var nodeId = sub.SourceNodeIds[i];
|
||||
var inFaultedDv = results[i * 2];
|
||||
var severityDv = results[i * 2 + 1];
|
||||
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
|
||||
var plan = sub.Plans[i];
|
||||
if (plan.Alma is not null)
|
||||
{
|
||||
TickAlma(sub, nodeId, plan.Alma, results, cursor);
|
||||
cursor += plan.Alma.RefCount;
|
||||
continue;
|
||||
}
|
||||
var inAlarmDv = results[cursor];
|
||||
var severityDv = results[cursor + 1];
|
||||
cursor += 2;
|
||||
if (inAlarmDv.StatusCode != AbCipStatusMapper.Good) continue;
|
||||
|
||||
var nowFaulted = ToBool(inFaultedDv.Value);
|
||||
var nowFaulted = ToBool(inAlarmDv.Value);
|
||||
// severityDv.StatusCode is not checked here. When the Severity read is Bad (value null),
|
||||
// ToInt(null) returns 0 and MapSeverity buckets it as Low. This is acceptable because
|
||||
// InFaulted and Severity are members of the same ALMD UDT read in one batch, so a Good
|
||||
// InFaulted almost always implies a Good Severity. The "unknown severity → Low" fallback
|
||||
// InAlarm and Severity are members of the same ALMD UDT read in one batch, so a Good
|
||||
// InAlarm almost always implies a Good Severity. The "unknown severity → Low" fallback
|
||||
// is intentional and matches the documented behaviour.
|
||||
var severity = ToInt(severityDv.Value);
|
||||
|
||||
@@ -228,9 +251,14 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
var refs = new List<string>(sub.SourceNodeIds.Count * 2);
|
||||
foreach (var nodeId in sub.SourceNodeIds)
|
||||
for (var i = 0; i < sub.SourceNodeIds.Count; i++)
|
||||
{
|
||||
refs.Add($"{nodeId}.InFaulted");
|
||||
var nodeId = sub.SourceNodeIds[i];
|
||||
if (sub.Plans[i].Alma is { } alma) { refs.AddRange(alma.Refs(nodeId)); continue; }
|
||||
// InAlarm is ALMD's alarm-active output (Rockwell ALMD reference). InFaulted (Status.1) is the INPUT
|
||||
// health flag — it is not the alarm. Until hw4 this polled InFaulted, so a live ALMD would have raised on
|
||||
// input faults and never on the alarm condition.
|
||||
refs.Add($"{nodeId}.InAlarm");
|
||||
refs.Add($"{nodeId}.Severity");
|
||||
}
|
||||
|
||||
@@ -258,6 +286,135 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private bool WarnOnce(string id) { lock (_warnedNoInAlarm) return _warnedNoInAlarm.Add(id); }
|
||||
|
||||
private List<NodePlan> BuildPlans(IReadOnlyList<string> nodeIds)
|
||||
{
|
||||
var plans = new List<NodePlan>(nodeIds.Count);
|
||||
foreach (var id in nodeIds)
|
||||
{
|
||||
AlmaPlan? alma = null;
|
||||
if (_driver.TryGetTagDefinition(id, out var def) && AbCipAlarmDetector.IsAlma(def))
|
||||
alma = AlmaPlan.From(def);
|
||||
else if (def is { DataType: AbCipDataType.Structure, Members: { } members }
|
||||
&& !members.Any(m => string.Equals(m.Name, "InAlarm", StringComparison.OrdinalIgnoreCase))
|
||||
&& WarnOnce(id))
|
||||
{
|
||||
// ALMD's active bit is InAlarm; a tag that declared members without it (e.g. a pre-hw4 config that
|
||||
// declared InFaulted) reads BadNodeIdUnknown every tick and would stay silent forever.
|
||||
_logger.LogWarning(
|
||||
"AbCip alarm ref {Ref} declares members but no 'InAlarm'; the ALMD projection polls InAlarm + Severity, " +
|
||||
"so this alarm will never raise. Add InAlarm (InFaulted is the input-health flag, not the alarm).", id);
|
||||
}
|
||||
plans.Add(new NodePlan(alma));
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ALMA tick for one node. Active = any polled limit bit set; the condition's severity/message come from the
|
||||
/// MOST SEVERE active limit (ties: HH, LL, H, L, ROC+, ROC-). What is told to the host:
|
||||
/// <list type="bullet">
|
||||
/// <item>none->active: Raise, or the atomic ActiveAcknowledged when every declared limit is acked.</item>
|
||||
/// <item>active->none: Clear.</item>
|
||||
/// <item>a limit NEWLY entering: Retrigger (a new unacknowledged occurrence) — or ActiveAcknowledged when
|
||||
/// nothing is pending ack (AckRequired false), never Retrigger-then-Acknowledge (#656).</item>
|
||||
/// <item>no new limit, every declared limit now acked while the condition was unacked: Acknowledge.</item>
|
||||
/// <item>no new limit, the condition was fully acked and a declared <c>*Acked</c> read (Good) is now false:
|
||||
/// Retrigger — an occurrence the poll missed.</item>
|
||||
/// <item>no new limit, the governing limit or its severity changed (a drop-out): an
|
||||
/// <c>Unspecified</c> + <c>IsRestatement</c> state update — the projector keeps active/acked and writes
|
||||
/// NO alerts row; anything else here would render a false "Activated".</item>
|
||||
/// </list>
|
||||
/// "Acked" is judged over EVERY declared limit's <c>*Acked</c> bit, active or not: in Logix an occurrence that
|
||||
/// went back to normal unacknowledged keeps its bit false, so it must keep the condition unacknowledged
|
||||
/// rather than being silently acked by a surviving, already-acked limit. A Bad ack/severity/limit read judges
|
||||
/// nothing it could corrupt (the tick is skipped, or the ack state carried forward).
|
||||
/// </summary>
|
||||
private void TickAlma(Subscription sub, string nodeId, AlmaPlan plan, IReadOnlyList<DataValueSnapshot> results, int at)
|
||||
{
|
||||
var mask = 0;
|
||||
AbCipAlmaLimit? top = null;
|
||||
var topSev = int.MinValue;
|
||||
for (var k = 0; k < plan.Limits.Count; k++)
|
||||
{
|
||||
var active = results[at + plan.InAlarmIndex(k)];
|
||||
// A Bad limit bit leaves the whole node unjudged this tick (matches ALMD skipping a Bad InAlarm).
|
||||
if (active.StatusCode != AbCipStatusMapper.Good) return;
|
||||
if (!ToBool(active.Value)) continue;
|
||||
var lim = plan.Limits[k];
|
||||
mask |= 1 << (int)lim;
|
||||
var sev = 1;
|
||||
if (plan.SeverityIndex(k) is { } si)
|
||||
{
|
||||
var sevDv = results[at + si];
|
||||
// A Bad severity must not change the told message/severity: skip the tick.
|
||||
if (sevDv.StatusCode != AbCipStatusMapper.Good) return;
|
||||
sev = Math.Clamp(ToInt(sevDv.Value), 1, 1000);
|
||||
}
|
||||
if (sev > topSev) { topSev = sev; top = lim; }
|
||||
}
|
||||
|
||||
// Ack state over every declared *Acked bit. Unknown (none declared, or any read Bad) ⇒ carry the told value.
|
||||
var ackKnown = plan.HasAcked;
|
||||
var allAcked = true;
|
||||
for (var k = 0; k < plan.Limits.Count && ackKnown; k++)
|
||||
{
|
||||
if (plan.AckedIndex(k) is not { } ai) continue;
|
||||
var dv = results[at + ai];
|
||||
if (dv.StatusCode != AbCipStatusMapper.Good) ackKnown = false;
|
||||
else if (!ToBool(dv.Value)) allAcked = false;
|
||||
}
|
||||
|
||||
var prior = _driver.AlarmAnalogRecord.Get(nodeId);
|
||||
var nowAcked = mask != 0 && (ackKnown ? allAcked : prior.Acked);
|
||||
var topId = top is { } t0 ? (int)t0 : -1;
|
||||
if (mask == 0 && prior.Mask == 0) { _driver.AlarmAnalogRecord.Set(nodeId, 0, false, -1, 0); return; }
|
||||
|
||||
AlarmEventArgs Build(AlarmTransitionKind kind, string message, int sev, bool restatement = false) => new(
|
||||
sub.Handle, nodeId, ConditionId: nodeId, AlarmType: "ALMA", Message: message,
|
||||
Severity: MapSeverity(sev), SourceTimestampUtc: DateTime.UtcNow, Kind: kind, OpcUaSeverity: sev,
|
||||
IsRestatement: restatement);
|
||||
|
||||
if (mask == 0)
|
||||
{
|
||||
_driver.AlarmAnalogRecord.Set(nodeId, 0, false, -1, 0);
|
||||
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Clear, $"ALMA {nodeId} cleared", 1));
|
||||
return;
|
||||
}
|
||||
|
||||
var message = $"ALMA {nodeId} {top!.Value.Label()} alarm";
|
||||
if (prior.Mask == 0)
|
||||
{
|
||||
_driver.AlarmAnalogRecord.Set(nodeId, mask, nowAcked, topId, topSev);
|
||||
_driver.InvokeAlarmEvent(Build(
|
||||
nowAcked ? AlarmTransitionKind.ActiveAcknowledged : AlarmTransitionKind.Raise, message, topSev));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((mask & ~prior.Mask) != 0)
|
||||
{
|
||||
_driver.AlarmAnalogRecord.Set(nodeId, mask, nowAcked, topId, topSev);
|
||||
_driver.InvokeAlarmEvent(Build(
|
||||
nowAcked ? AlarmTransitionKind.ActiveAcknowledged : AlarmTransitionKind.Retrigger, message, topSev));
|
||||
return;
|
||||
}
|
||||
|
||||
_driver.AlarmAnalogRecord.Set(nodeId, mask, nowAcked, topId, topSev);
|
||||
if (ackKnown && prior.Acked && !allAcked)
|
||||
{
|
||||
// Every limit was acked and now one is not, with the same limits active: an occurrence the poll missed (a
|
||||
// limit left and re-entered inside one interval) or one a Bad *Acked read hid on the tick it entered.
|
||||
// Either way the PLC says unacknowledged, so tell it — a new unacknowledged occurrence.
|
||||
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Retrigger, message, topSev));
|
||||
return;
|
||||
}
|
||||
if (nowAcked && !prior.Acked)
|
||||
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Acknowledge, message, topSev));
|
||||
else if (topId != prior.Top || topSev != prior.TopSeverity)
|
||||
_driver.InvokeAlarmEvent(Build(AlarmTransitionKind.Unspecified, message, topSev, restatement: true));
|
||||
}
|
||||
|
||||
/// <summary>Maps a raw severity value to an <see cref="AlarmSeverity"/> enum value.</summary>
|
||||
/// <param name="raw">The raw severity value from the alarm data.</param>
|
||||
/// <returns>The corresponding alarm severity level.</returns>
|
||||
@@ -286,6 +443,55 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
internal sealed record NodePlan(AlmaPlan? Alma);
|
||||
|
||||
/// <summary>The members one ALMA node polls, in the order they are read: all <c>*InAlarm</c> bits, then the
|
||||
/// <c>*Acked</c> bits, then the <c>*Severity</c> values — only for the limits the tag declares.</summary>
|
||||
internal sealed class AlmaPlan
|
||||
{
|
||||
private readonly List<string> _inAlarm = [];
|
||||
private readonly List<string?> _acked = [];
|
||||
private readonly List<string?> _severity = [];
|
||||
private readonly List<int?> _ackIdx = [];
|
||||
private readonly List<int?> _sevIdx = [];
|
||||
|
||||
public List<AbCipAlmaLimit> Limits { get; } = [];
|
||||
|
||||
public int RefCount { get; private set; }
|
||||
|
||||
/// <summary>True when the tag declares at least one <c>*Acked</c> member.</summary>
|
||||
public bool HasAcked => _acked.Any(a => a is not null);
|
||||
|
||||
public static AlmaPlan From(AbCipTagDefinition def)
|
||||
{
|
||||
var names = def.Members!.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var plan = new AlmaPlan();
|
||||
foreach (var lim in Enum.GetValues<AbCipAlmaLimit>())
|
||||
{
|
||||
if (!names.Contains(lim.InAlarmMember())) continue;
|
||||
plan.Limits.Add(lim);
|
||||
plan._inAlarm.Add(lim.InAlarmMember());
|
||||
plan._acked.Add(names.Contains(lim.AckedMember()) ? lim.AckedMember() : null);
|
||||
plan._severity.Add(names.Contains(lim.SeverityMember()) ? lim.SeverityMember() : null);
|
||||
}
|
||||
var n = plan._inAlarm.Count;
|
||||
var next = n;
|
||||
foreach (var a in plan._acked) plan._ackIdx.Add(a is null ? null : next++);
|
||||
foreach (var v in plan._severity) plan._sevIdx.Add(v is null ? null : next++);
|
||||
plan.RefCount = next;
|
||||
return plan;
|
||||
}
|
||||
|
||||
public IEnumerable<string> Refs(string nodeId) =>
|
||||
_inAlarm.Concat(_acked.Where(a => a is not null).Select(a => a!))
|
||||
.Concat(_severity.Where(v => v is not null).Select(v => v!))
|
||||
.Select(m => $"{nodeId}.{m}");
|
||||
|
||||
public int InAlarmIndex(int k) => k;
|
||||
public int? AckedIndex(int k) => _ackIdx[k];
|
||||
public int? SeverityIndex(int k) => _sevIdx[k];
|
||||
}
|
||||
|
||||
internal sealed class Subscription
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="Subscription"/> class.</summary>
|
||||
@@ -308,11 +514,14 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
|
||||
/// <summary>Gets or sets the polling loop task.</summary>
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
|
||||
/// <summary>Per-source-node read plan (ALMD or ALMA), index-aligned with <see cref="SourceNodeIds"/>.</summary>
|
||||
public List<NodePlan> Plans { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the last <c>InFaulted</c> the ALMD projection told the server, per alarm RawPath. Owned by
|
||||
/// Gitea #641 review — the last <c>InAlarm</c> the ALMD projection told the server, per alarm RawPath. Owned by
|
||||
/// the <see cref="AbCipDriver"/> rather than a subscription or a projection, so it survives a re-subscribe and a
|
||||
/// re-initialise (both of which happen on every reconnect): the server's condition nodes outlive them, and a
|
||||
/// re-raise there would un-acknowledge. Thread-safe — a retiring poll loop may overlap the new one for a tick.
|
||||
@@ -349,6 +558,66 @@ internal sealed class AbCipAlarmFaultRecord
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ALMA (<c>ALARM_ANALOG</c>) alarm conditions. Declaration order is the severity tie-break order (the first
|
||||
/// limit wins an equal-severity tie). Member names per Rockwell's Studio 5000 "Analog Alarm (ALMA)" reference:
|
||||
/// <c>HHInAlarm</c>/<c>HInAlarm</c>/<c>LInAlarm</c>/<c>LLInAlarm</c>/<c>ROCPosInAlarm</c>/<c>ROCNegInAlarm</c>,
|
||||
/// <c>HHAcked</c>… <c>ROCNegAcked</c>, and <c>HHSeverity</c>… <c>ROCNegSeverity</c> (DINT, 1..1000).
|
||||
/// </summary>
|
||||
internal enum AbCipAlmaLimit { HH, LL, H, L, ROCPos, ROCNeg }
|
||||
|
||||
internal static class AbCipAlmaLimitExtensions
|
||||
{
|
||||
private static string Prefix(AbCipAlmaLimit l) => l.ToString();
|
||||
|
||||
public static string InAlarmMember(this AbCipAlmaLimit l) => Prefix(l) + "InAlarm";
|
||||
public static string AckedMember(this AbCipAlmaLimit l) => Prefix(l) + "Acked";
|
||||
public static string SeverityMember(this AbCipAlmaLimit l) => Prefix(l) + "Severity";
|
||||
|
||||
/// <summary>Operator-facing name used in the alarm message.</summary>
|
||||
public static string Label(this AbCipAlmaLimit l) => l switch
|
||||
{
|
||||
AbCipAlmaLimit.ROCPos => "ROC+",
|
||||
AbCipAlmaLimit.ROCNeg => "ROC-",
|
||||
_ => l.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ALMA projection's last-told state per alarm RawPath: the bitmask of active limits and whether every
|
||||
/// active limit was acked. Driver-owned for the same reason as <see cref="AbCipAlarmFaultRecord"/> — it must
|
||||
/// survive a re-subscribe so a still-active alarm is not re-raised (which would un-acknowledge it).
|
||||
/// </summary>
|
||||
internal sealed class AbCipAlarmAnalogRecord
|
||||
{
|
||||
internal readonly record struct State(int Mask, bool Acked, int Top, int TopSeverity);
|
||||
|
||||
private readonly Dictionary<string, State> _last = new(StringComparer.Ordinal);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
/// <summary>The state last told for <paramref name="nodeId"/> (all-zero when none).</summary>
|
||||
public State Get(string nodeId)
|
||||
{
|
||||
lock (_lock) return _last.GetValueOrDefault(nodeId, new State(0, false, -1, 0));
|
||||
}
|
||||
|
||||
/// <summary>Records the state just told.</summary>
|
||||
public void Set(string nodeId, int mask, bool acked, int top, int topSeverity)
|
||||
{
|
||||
lock (_lock) _last[nodeId] = new State(mask, acked, top, topSeverity);
|
||||
}
|
||||
|
||||
/// <summary>Forgets every ref not in <paramref name="nodeIds"/>.</summary>
|
||||
public void RetainOnly(IReadOnlyList<string> nodeIds)
|
||||
{
|
||||
var keep = new HashSet<string>(nodeIds, StringComparer.Ordinal);
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var k in _last.Keys.Where(k => !keep.Contains(k)).ToList()) _last.Remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handle returned by <see cref="AbCipAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
@@ -365,9 +634,8 @@ public static class AbCipAlarmDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c> when <paramref name="tag"/> is a Structure whose declared members match
|
||||
/// the ALMD signature (<c>InFaulted</c> + <c>Acked</c> present). ALMA detection
|
||||
/// (analog alarms with <c>HHLimit</c>/<c>HLimit</c>/<c>LLimit</c>/<c>LLLimit</c>)
|
||||
/// ships as a follow-up.
|
||||
/// the ALMD signature (<c>InAlarm</c> + <c>Acked</c> present). ALMA is detected by
|
||||
/// <see cref="IsAlma"/>.
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag definition to check for ALMD signature.</param>
|
||||
/// <returns>True if the tag has the ALMD alarm signature; false otherwise.</returns>
|
||||
@@ -375,6 +643,24 @@ public static class AbCipAlarmDetector
|
||||
{
|
||||
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
|
||||
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return names.Contains("InFaulted") && names.Contains("Acked");
|
||||
return names.Contains("InAlarm") && names.Contains("Acked");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when <paramref name="tag"/> is a Structure whose declared members include at least one ALMA
|
||||
/// per-limit status bit (<c>HHInAlarm</c>/<c>HInAlarm</c>/<c>LInAlarm</c>/<c>LLInAlarm</c>/
|
||||
/// <c>ROCPosInAlarm</c>/<c>ROCNegInAlarm</c>) and no plain <c>Acked</c> (an ALMD member). Checked BEFORE <see cref="IsAlmd"/> by the projection — an
|
||||
/// ALMA also exposes <c>InFaulted</c>, and the limit bits are what ALMD does not have.
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag definition to check.</param>
|
||||
/// <returns>True for an ALMA signature.</returns>
|
||||
public static bool IsAlma(AbCipTagDefinition tag)
|
||||
{
|
||||
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
|
||||
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
// ALMA-specific: a per-limit *InAlarm bit, and NOT ALMD's plain Acked (ALMA has only per-limit *Acked). A user
|
||||
// UDT that is ALMD-shaped (plain InAlarm/Acked) stays ALMD even if it also names an "HInAlarm".
|
||||
return !names.Contains("Acked")
|
||||
&& Enum.GetValues<AbCipAlmaLimit>().Any(l => names.Contains(l.InAlarmMember()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +119,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly ILogger<AbCipDriver> _logger;
|
||||
private AbCipAlarmProjection _alarmProjection;
|
||||
|
||||
/// <summary>Gitea #641 review — the ALMD projection's last-told InFaulted per alarm ref; outlives every
|
||||
/// <summary>Gitea #641 review — the ALMD projection's last-told InAlarm per alarm ref; outlives every
|
||||
/// projection and subscription (see <see cref="AbCipAlarmFaultRecord"/>).</summary>
|
||||
internal AbCipAlarmFaultRecord AlarmFaultRecord { get; } = new();
|
||||
|
||||
/// <summary>The ALMA projection's last-told (active-limit mask, acked) per alarm ref; driver-owned for the
|
||||
/// same reason as <see cref="AlarmFaultRecord"/>.</summary>
|
||||
internal AbCipAlarmAnalogRecord AlarmAnalogRecord { get; } = new();
|
||||
|
||||
/// <summary>Resolves an authored tag definition by RawPath (the alarm projection's ALMA/ALMD discriminator).</summary>
|
||||
internal bool TryGetTagDefinition(string rawPath, out AbCipTagDefinition definition) =>
|
||||
_tagsByRawPath.TryGetValue(rawPath, out definition!);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Occurs when a subscribed tag's value changes.</summary>
|
||||
|
||||
@@ -52,8 +52,10 @@ public sealed class NativeAlarmProjector
|
||||
/// <item>Gitea #656 — a <see cref="AlarmEventArgs.IsRestatement"/> 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.</item>
|
||||
/// 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.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
||||
@@ -108,6 +110,15 @@ 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).
|
||||
// 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, []);
|
||||
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.
|
||||
|
||||
+5
-5
@@ -8,16 +8,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||
/// <summary>
|
||||
/// Golden-box-tier ALMD alarm projection tests against Logix Emulate.
|
||||
/// Promotes the feature-flagged ALMD projection (task #177) from unit-only coverage
|
||||
/// (<c>AbCipAlarmProjectionTests</c> with faked InFaulted sequences) to end-to-end
|
||||
/// (<c>AbCipAlarmProjectionTests</c> with faked InAlarm sequences) to end-to-end
|
||||
/// wire-level coverage — Emulate runs the real ALMD instruction, with real
|
||||
/// rising-edge semantics on <c>InFaulted</c> + <c>Ack</c>, so the driver's poll-based
|
||||
/// rising-edge semantics on <c>InAlarm</c> + <c>Ack</c>, so the driver's poll-based
|
||||
/// projection gets validated against the actual behaviour shops running FT View see.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c>):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Controller-scope ALMD tag <c>HighTempAlarm</c> — a standard ALMD instruction
|
||||
/// with default member set (<c>In</c>, <c>InFaulted</c>, <c>Acked</c>,
|
||||
/// with default member set (<c>In</c>, <c>InAlarm</c>, <c>Acked</c>,
|
||||
/// <c>Severity</c>, <c>Cfg_ProgTime</c>, …).</item>
|
||||
/// <item>A periodic task that drives <c>HighTempAlarm.In</c> 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);
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
|
||||
/// <summary>Verifies that ALMD structure signature is correctly detected as an alarm.</summary>
|
||||
@@ -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,73 @@ public sealed class AbCipAlarmProjectionTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// hw4 — ALMD's alarm-active output is <c>InAlarm</c> (Rockwell ALMD reference); <c>InFaulted</c> (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.
|
||||
/// </summary>
|
||||
[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<AlarmEventArgs>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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<AbCipDriver>
|
||||
{
|
||||
public List<string> Warnings { get; } = [];
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning) lock (Warnings) Warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm clear event fires on 1-to-0 transition.</summary>
|
||||
[Fact]
|
||||
public async Task Clear_Event_Fires_On_1_to_0_Transition()
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #611 item 1 — ALMA (<c>ALARM_ANALOG</c>) projection. Driven through fake tag runtimes only:
|
||||
/// ab_server has no alarm instructions, and Z35162 has no ALMA/ALMD, so there is no wire-level gate.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipAlmaProjectionTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
private const string Tag = "HighPress";
|
||||
|
||||
private static readonly string[] Bits =
|
||||
[
|
||||
"HHInAlarm", "HInAlarm", "LInAlarm", "LLInAlarm", "ROCPosInAlarm",
|
||||
"HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked", "OperAckAll",
|
||||
];
|
||||
private static readonly string[] Sevs = ["HHSeverity", "HSeverity", "LSeverity", "LLSeverity", "ROCPosSeverity"];
|
||||
|
||||
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 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(tags),
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
|
||||
private sealed record Rig(AbCipDriver Drv, FakeAbCipTagFactory Factory, List<AlarmEventArgs> Events, IAlarmSubscriptionHandle Handle)
|
||||
{
|
||||
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);
|
||||
await Drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts a quiet ALMA (severities set, nothing in alarm, nothing acked) and applies <paramref name="seed"/>.</summary>
|
||||
private static async Task<Rig> StartAsync(Action<Rig>? seed = null)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(Options(AlmaTag()), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
var handle = await drv.SubscribeAlarmsAsync([Tag], CancellationToken.None);
|
||||
var rig = new Rig(drv, factory, events, handle);
|
||||
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;
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> cond)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline) { if (cond()) return; await Task.Delay(5); }
|
||||
throw new TimeoutException("condition never met");
|
||||
}
|
||||
|
||||
private static async Task<AlarmEventArgs> Next(Rig r, Func<AlarmEventArgs, bool> match)
|
||||
{
|
||||
await WaitFor(() => { lock (r.Events) return r.Events.Any(match); });
|
||||
lock (r.Events) return r.Events.First(match);
|
||||
}
|
||||
|
||||
/// <summary>Processed-count wait: six more polls of the shared member runtime.</summary>
|
||||
private static async Task Settled(Rig r)
|
||||
{
|
||||
var target = r.Probe.ReadCount + 6;
|
||||
await WaitFor(() => r.Probe.ReadCount >= target);
|
||||
}
|
||||
|
||||
private static List<AlarmEventArgs> Snapshot(Rig r) { lock (r.Events) return [.. r.Events]; }
|
||||
|
||||
/// <summary>Detector: ALMA by per-limit bits and no plain Acked; ALMD-shaped / plain UDTs are not ALMA.</summary>
|
||||
[Fact]
|
||||
public void Detector_Flags_Alma_And_Not_Almd_Or_User_Udts()
|
||||
{
|
||||
AbCipAlarmDetector.IsAlma(AlmaTag()).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();
|
||||
}
|
||||
|
||||
/// <summary>A limit entering alarm raises one ALMA condition keyed on the RawPath, naming the limit, with the limit's severity.</summary>
|
||||
[Fact]
|
||||
public async Task A_limit_in_alarm_raises_with_the_limits_name_and_severity()
|
||||
{
|
||||
var r = await StartAsync();
|
||||
await Settled(r);
|
||||
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");
|
||||
e.Message.ShouldBe($"ALMA {Tag} H alarm");
|
||||
e.OpcUaSeverity.ShouldBe(600);
|
||||
e.Severity.ShouldBe(AlarmSeverity.High);
|
||||
}
|
||||
|
||||
/// <summary>With several limits active the most severe one names the condition and sets its severity.</summary>
|
||||
[Fact]
|
||||
public async Task The_most_severe_active_limit_wins()
|
||||
{
|
||||
var r = await StartAsync(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");
|
||||
e.OpcUaSeverity.ShouldBe(900);
|
||||
}
|
||||
|
||||
/// <summary>ROC limits are named with their sign.</summary>
|
||||
[Fact]
|
||||
public async Task A_rate_of_change_limit_is_named_with_its_sign()
|
||||
{
|
||||
var r = await StartAsync(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");
|
||||
}
|
||||
|
||||
/// <summary>All limits returning to normal clears the one condition.</summary>
|
||||
[Fact]
|
||||
public async Task All_limits_clearing_fires_one_clear()
|
||||
{
|
||||
var r = await StartAsync(x => x.Set("LLInAlarm", true));
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
r.Set("LLInAlarm", false);
|
||||
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Clear);
|
||||
await Settled(r);
|
||||
await r.StopAsync();
|
||||
e.ConditionId.ShouldBe(Tag);
|
||||
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Clear).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A newly entered (unacked) limit while active is a Retrigger, not a second Raise.</summary>
|
||||
[Fact]
|
||||
public async Task A_newly_entered_limit_while_active_retriggers()
|
||||
{
|
||||
var r = await StartAsync(x => x.Set("HInAlarm", true));
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
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);
|
||||
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>L1 — a limit entering ALREADY acked (AckRequired false) is atomic ActiveAcknowledged, never Retrigger+Acknowledge.</summary>
|
||||
[Fact]
|
||||
public async Task A_new_limit_entering_already_acked_is_active_acknowledged_not_retrigger_then_ack()
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>M1 — a limit dropping out while another stays active moves the text/severity as a no-row state update.</summary>
|
||||
[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);
|
||||
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);
|
||||
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Unspecified).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>M1 — a non-governing limit flickering (ROC under HH) changes neither text nor severity: no event at all.</summary>
|
||||
[Fact]
|
||||
public async Task A_non_governing_limit_flicker_emits_nothing()
|
||||
{
|
||||
var r = await StartAsync(x => { x.Set("HHInAlarm", true); x.Set("ROCPosInAlarm", true); });
|
||||
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
||||
r.Set("ROCPosInAlarm", false);
|
||||
await Settled(r);
|
||||
await r.StopAsync();
|
||||
Snapshot(r).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>The PLC acknowledging every declared limit surfaces as one Acknowledge.</summary>
|
||||
[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();
|
||||
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>An alarm already active AND acked when first seen is told atomically, never Raise-then-Acknowledge.</summary>
|
||||
[Fact]
|
||||
public async Task An_already_acked_active_alarm_is_active_acknowledged()
|
||||
{
|
||||
var r = await StartAsync(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);
|
||||
Snapshot(r).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A re-subscribe must not re-raise a still-active ALMA (it would un-acknowledge it).</summary>
|
||||
[Fact]
|
||||
public async Task A_resubscribe_does_not_re_raise_a_still_active_alma()
|
||||
{
|
||||
var r = await StartAsync(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();
|
||||
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A Bad limit read judges nothing that tick: no event, and no false Clear for an active alarm.</summary>
|
||||
[Fact]
|
||||
public async Task A_bad_read_does_not_clear_an_active_alarm()
|
||||
{
|
||||
var r = await StartAsync(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 as "all clear").
|
||||
var recreations = 0;
|
||||
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
|
||||
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound;
|
||||
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
|
||||
await r.StopAsync();
|
||||
Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>L2 — a Bad <c>*Acked</c> read is "unknown", not "un-acked": it must neither un-ack nor re-ack.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>L2 — a Bad severity read must not change the told message/severity.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>A client acknowledge writes the ALMA's OperAckAll input — never the ALMD-only <c>Acked</c> member.</summary>
|
||||
[Fact]
|
||||
public async Task Acknowledge_writes_OperAckAll_not_Acked()
|
||||
{
|
||||
var r = await StartAsync();
|
||||
await r.Drv.AcknowledgeAsync([new AlarmAcknowledgeRequest(Tag, Tag, null)], CancellationToken.None);
|
||||
await r.StopAsync();
|
||||
r.Factory.Tags.ShouldContainKey($"{Tag}.OperAckAll");
|
||||
r.Factory.Tags[$"{Tag}.OperAckAll"].WriteCount.ShouldBe(1);
|
||||
r.Factory.Tags.ShouldNotContainKey($"{Tag}.Acked");
|
||||
}
|
||||
|
||||
/// <summary>L4 — one subscription mixing an ALMD and an ALMA: the cursor arithmetic keeps each node's reads apart.</summary>
|
||||
[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<AlarmEventArgs>();
|
||||
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<AlarmEventArgs> 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);
|
||||
}
|
||||
}
|
||||
+228
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #611 item 1 (hw4 review L3) — an AbCip ALMA reaches its Part 9 condition and its <c>/alerts</c> rows END
|
||||
/// TO END: the REAL <see cref="AbCipDriver"/> (polling a fake controller, ab_server/Z35162 have no ALMA) raises into
|
||||
/// the REAL <see cref="DriverHostActor"/>, and the test reads what <c>ForwardNativeAlarm</c> +
|
||||
/// <c>NativeAlarmProjector</c> 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.
|
||||
/// </summary>
|
||||
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"];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<AlarmTransitionEvent>(Wait);
|
||||
|
||||
private static OpcUaPublishActor.AlarmStateUpdate NextUpdate(TestProbe publish) =>
|
||||
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, Wait);
|
||||
|
||||
private TestProbe SubscribeAlerts()
|
||||
{
|
||||
var alerts = CreateTestProbe();
|
||||
DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref);
|
||||
alerts.ExpectMsg<SubscribeAck>(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<object>(),
|
||||
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<OtOpcUaConfigDbContext> 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<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
dependencyMux: CreateTestProbe().Ref,
|
||||
opcUaPublishActor: publish.Ref,
|
||||
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||
virtualTagHostOverride: CreateTestProbe().Ref));
|
||||
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
|
||||
coordinator.ExpectMsg<ApplyAck>(Wait).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Wait);
|
||||
return (actor, publish);
|
||||
}
|
||||
|
||||
/// <summary>A fake controller: every member runtime answers from one shared value table, keyed by member name.</summary>
|
||||
private sealed class FakePlc : IAbCipTagFactory
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, object?> _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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -456,6 +456,28 @@ public class NativeAlarmProjectorTests
|
||||
rows.ShouldBe([kind]);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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<AlarmTransitionKind> _);
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge), out IReadOnlyList<AlarmTransitionKind> _);
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified, AlarmSeverity.High, "H alarm", restated: true), out IReadOnlyList<AlarmTransitionKind> 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<AlarmTransitionKind> legacy);
|
||||
legacy.ShouldBe([AlarmTransitionKind.Unspecified]);
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(
|
||||
AlarmTransitionKind kind,
|
||||
AlarmSeverity sev = AlarmSeverity.High,
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<!-- Gitea #647: the real FOCAS driver, so the native-alarm end-to-end test proves a CNC alarm reaches the
|
||||
host's Part 9 condition (ForwardNativeAlarm → NativeAlarmProjector), not merely that it was raised. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<!-- Gitea #656: the real Galaxy (over a scripted gateway alarm stream) and OpcUaClient drivers, so the
|
||||
re-statement end-to-end tests prove what reaches the host's /alerts rows and Part 9 conditions. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
|
||||
Reference in New Issue
Block a user