Compare commits
9 Commits
fecfdd224d
...
ea220363f3
| Author | SHA1 | Date | |
|---|---|---|---|
| ea220363f3 | |||
| b394413e30 | |||
| ede40b6c6d | |||
| 24eed8addd | |||
| a7c27e978f | |||
| c36224d956 | |||
| 1f983d610f | |||
| 48a80b5962 | |||
| c0fbe74693 |
@@ -1087,7 +1087,7 @@ re-emit a concrete default. The defaults differ per driver (final review, 2026-1
|
||||
because the driver factory AND the admin-node Test Connect probe both read it. ⚠️ So **the upgrade restart
|
||||
alone moves no-key AbCip drivers to Native**; to stay on libplctag, set the node key — NOT a per-driver key
|
||||
through the old AdminUI, whose typed form drops unknown keys (#576). Evidence: AbCip reads proven on a real
|
||||
CompactLogix (Z-35162); scalar native writes (DINT/BOOL/REAL) proven on it once (2026-10-09, user-approved one-off, restored) — and native UDT member writes (DINT/BIT/REAL, #611 gate, same day); array/STRING, libplctag member and fragmented writes are `ab_server`-only.
|
||||
CompactLogix (Z-35162); scalar native writes (DINT/BOOL/REAL) proven on it once (2026-10-09, user-approved one-off, restored) — and native UDT member writes (DINT/BIT/REAL, #611 gate, same day) and a native STRING write (hw5, same day, restored byte-exact); and a native array-element write (`EM01_PowerUnit._SEQ_EP[31]`, user-approved, an element no alias targets, restored; neighbours unchanged); libplctag member and fragmented writes are `ab_server`-only. #566: a real Logix drops TCP on a connection idled past its timeout (no 0x0107/0x0203); the faulted session reopens on the next request.
|
||||
- **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On
|
||||
a real MicroLogix 1400 (Z-33323, idle, 2026-10-09) native read S/N/F/B/T/C/R/ST/O/I incl. `S:0`, T/C/R
|
||||
sub-elements and status bits, and ST byte order, all correct, and wrote N/F8/B-bit (restored). libplctag (the default)
|
||||
@@ -1151,7 +1151,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
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
|
||||
invisible) nor Raise+Acknowledge (an unacked intermediate). Its mirror **`InactiveUnacknowledged`** (#657, OpcUaClient)
|
||||
replaces Raise/Retrigger+Clear (an Active flicker; rows only for what changed). OpcUaClient's first sight of an inactive
|
||||
condition is a flagged InactiveUnacknowledged, or flagged Clear+Acknowledge for inactive+acked (a flagged Acknowledge on an
|
||||
acked condition writes no row) — never `Unspecified`, which wrote "Activated" for an inactive alarm and left a
|
||||
retained-Active condition Active. `NativeAlarmProjector` returns the rows to write; unflagged
|
||||
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;
|
||||
|
||||
+15
-4
@@ -71,10 +71,10 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
||||
reconnect snapshot entries, OpcUaClient's first event per condition after a start or deploy respawn — flags
|
||||
`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; 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
|
||||
on a condition already Active+unacked with the **same message AND severity**, a flagged `Clear` on an
|
||||
already-inactive condition, or a flagged `Acknowledge` on an already-acknowledged one (#657), 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). 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
|
||||
@@ -88,6 +88,17 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
||||
`Active = false` on a cold host or an inactive prior, so an alarm active in AVEVA projected **inactive** and was
|
||||
invisible to clients. OpcUaClient emits it for inactive → (active, acked) instead of `Raise` + `Acknowledge`,
|
||||
which showed clients an Active+**unacked** intermediate event.
|
||||
- **`AlarmTransitionKind.InactiveUnacknowledged` (#657)** — its mirror: "inactive AND unacknowledged" (went active and
|
||||
cleared unacked) in ONE projection, never `Raise`/`Retrigger` + `Clear` (which showed clients the condition Active
|
||||
again for an instant). Rows only for what changed, flag or not: "Activated" (raise time when known) + "Cleared" when
|
||||
the condition was acknowledged (an un-ack needs a new occurrence), "Cleared" alone when it was Active+unacked, none
|
||||
when it already showed inactive+unacked. OpcUaClient emits it for every move into inactive+unacked except from
|
||||
Active+unacked (a plain `Clear`). Its **first sight** of an inactive condition (after a start or deploy respawn, when
|
||||
the host may still hold ANY state) is `InactiveUnacknowledged`, or `Clear` + `Acknowledge` for inactive+acked — both
|
||||
flagged — so a host already showing that state writes nothing, and a host that retained the condition Active gets a
|
||||
real "Cleared". Pre-#657 a respawn wrote a duplicate "Activated" + "Cleared" pair per inactive+unacked condition, and
|
||||
an inactive+acked first sight went out as `Unspecified` (an "Activated" row for an inactive alarm; a retained-Active
|
||||
condition was never cleared). Residual (pre-existing, not a regression): a cold host treats an unseen condition as acked, so after a node restart each upstream inactive+unacked condition still writes "Activated" + "Cleared" once. E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`.
|
||||
|
||||
### Native condition state survives a full rebuild (Gitea #641)
|
||||
|
||||
|
||||
+24
-11
@@ -165,8 +165,8 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
the connection size. A read whose size cannot be bounded (structure / unknown authored type) is sent
|
||||
alone; writes and status-only replies count their 4-byte header. Pinned by
|
||||
`RequestSchedulerTests.Mixed_scalar_string_and_array_replies_are_packed_within_the_reply_budget`.
|
||||
⚠️ RESIDUAL: mixed STRING/array packing has not been exercised against a real controller (the real-press
|
||||
gate predates the budget); the budget is unit-tested against the scheduler and fake channel only.
|
||||
Exercised on a real CompactLogix (Z-35162, hw4, 2026-10-09): a STRING, an array element, a DINT[32] and two
|
||||
scalars rode one Multiple Service Packet in 5/5 attempts, values equal to single reads.
|
||||
- **Fragmented-read integrity (#567):** `Read Tag Fragmented` replies are compared against the first reply;
|
||||
a changed type code *or* structure handle mid-read fails the read with `BadReply` rather than stitching
|
||||
two types into one value.
|
||||
@@ -179,9 +179,16 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
and the next request reopens through the throttle. The request that got the reply fails
|
||||
`BadCommunicationError` (transport `BadConnection`); a keep-alive that gets it recycles the connection
|
||||
before any request fails. Every other status — tag-level errors, other `0x01` extended codes — is left to
|
||||
the request. ⚠️ RESIDUAL: which of the two codes a real Logix controller returns for a forgotten
|
||||
connection (and whether it answers at all rather than dropping TCP) is unobserved; the check is correct
|
||||
whichever it does — a silent target still faults the session through the exchange timeout.
|
||||
the request. **Observed on a real CompactLogix (Z-35162, hw5, 2026-10-09): neither code.** A connection left
|
||||
idle 60 s, past its 16 s timeout (keep-alive disabled for the test), was closed at the TCP level somewhere in
|
||||
that 16–60 s window — the exact moment, and whether the controller sent RST or FIN, were not captured (no
|
||||
packet capture; EPIPE on the first write only says the close was complete by then); the next request
|
||||
failed on its write in 9 ms (`Broken pipe` → `BadConnection` → `BadCommunicationError`, no CIP or
|
||||
encapsulation status) and the request after it reopened with a fresh Forward Open. That path needs no CIP
|
||||
check — a faulted session always reopens on the next send — and is pinned offline by
|
||||
`CipConnectionTests.A_target_that_resets_an_idle_session_fails_the_next_request_fast_and_the_one_after_reopens`.
|
||||
The 0x0107/0x0203 recycle stays for targets that keep TCP up. In production the keep-alive (every 8 s) stops
|
||||
a held connection idling out, so this shape follows a controller restart / re-download or a network gap.
|
||||
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
|
||||
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
|
||||
node (its connection carries the probe timeout). A later opener with different options is ignored and
|
||||
@@ -241,16 +248,22 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
and templates on Z-35162; a user-approved one-off on 2026-10-09 added **scalar native writes on a real CompactLogix**
|
||||
(DINT `test2`, BOOL `Program:UN01_Press.xxtest`, REAL `Program:ES03_EM03_Zone3._ff_test`: write, read back, restore, all
|
||||
Good), and the #611 gate added **native UDT member writes** (DINT, BIT and REAL members of `_EmptyTemperatureSegment`).
|
||||
Still ab_server-only: array and STRING writes, libplctag member writes, and fragmented writes. Mixed STRING/array/scalar
|
||||
hw5 (2026-10-09) added a **native STRING write** (`Program:ES03_TemperatureControl._StrTmp`, through the driver:
|
||||
write, read back, restored byte-exact). A user-approved **native array-element write** followed: `Program:EM01_PowerUnit._SEQ_EP[31]`
|
||||
0→4242→0 (the array's only alias targets `[1]`, no routine indexes or block-copies it; all 32 elements snapshotted
|
||||
twice before, after the write and after the restore — only `[31]` changed). Still ab_server-only: whole-array
|
||||
writes (the driver has none), libplctag member writes, and fragmented writes. #566 is answered (the controller drops TCP; see "A target-closed CIP connection"). Mixed STRING/array/scalar
|
||||
Multiple Service Packet reads (#582) are proven on the press (one 5-read MSP, values equal to single reads). Results:
|
||||
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
|
||||
- **libplctag cannot be deleted yet.** Scalar native writes are proven on a CompactLogix (2026-10-09); what remains is #567
|
||||
(fragmented wide-struct writes), array/STRING writes on hardware, and AbLegacy native (simulator-only, no PCCC hardware).
|
||||
- **libplctag cannot be deleted yet.** Scalar, UDT-member and STRING native writes are proven on a CompactLogix
|
||||
(2026-10-09); what remains is #567 (fragmented wide-struct writes), whole-array writes on hardware, and AbLegacy
|
||||
native beyond the MicroLogix 1400 evidence.
|
||||
- UDT **member writes** (#611) are proven on a real CompactLogix on the **native** transport only (2026-10-09); the
|
||||
libplctag member-write path is `ab_server`-only. See [Member writes](#member-writes).
|
||||
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. Production
|
||||
presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a
|
||||
bench controller exists.
|
||||
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. A write fragments only
|
||||
above `MaxRequestBytes` (4000 B on a Large Forward Open), so it needs a ~4 KB value; Z-35162 has no safe one (a DINT[32]
|
||||
or a STRING is a single Write Tag, and AbCip has no whole-array write anyway). It stays open until a bench controller
|
||||
or an approved large scratch tag exists.
|
||||
- Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages
|
||||
are paced, and a browse's template reads share one lease. Observed on Z-35162 (2026-10-09, 41
|
||||
pages / 3162 tags): every page strictly ascending, so out-of-order listing has never been seen on a real controller — the
|
||||
|
||||
@@ -100,7 +100,7 @@ physical inputs and may reject the write.
|
||||
re-parses carry identical numbers. This is not a double conversion: libplctag's `pccc.c`
|
||||
(`parse_pccc_elem_num` / `parse_pccc_bit_num`) parses decimal numerals only (a FIXME there says octal is
|
||||
unsupported), so the driver must convert. Pinned by `Plc5_io_indices_are_octal` (`I:017/17` -> `I:15/15`).
|
||||
The connectivity-probe address is normalised the same way.
|
||||
The connectivity-probe address is normalised the same way — by both the driver's probe loop and the Test Connect probe, through one helper (`AbLegacyProbeAddress.Normalise`, #660); an unparseable Test Connect probe address fails cleanly ("not a valid AbLegacy address") instead of reaching the transport. Normalisation yields the canonical name; the libplctag `S:0` → `S2:0` translation (#659) happens downstream in the runtime, so the two compose.
|
||||
- ⚠️ **Behaviour change / migration.** Before this change a PLC-5 `I:`/`O:` address was passed to libplctag as
|
||||
written, i.e. read as **decimal**. An address copied from RSLogix in octal (e.g. `I:017`) therefore read the
|
||||
wrong word, and an authored decimal such as `I:1/12` now means bit 10 (octal 12). A previously "working"
|
||||
|
||||
@@ -101,9 +101,12 @@ initialise (the AdminUI form drops them on the next save). Pinned by
|
||||
> `Raise`/`Acknowledge`/`Clear`/`Retrigger` kinds the host projector consumes (without a kind no upstream alarm could ever
|
||||
> show active); the kinds are emitted from the state **last told to the host** per RawPath, so an identical re-delivery
|
||||
> emits nothing (no re-raise, which would un-ack). An inactive → active+acked move is ONE `ActiveAcknowledged` (#656; it
|
||||
> used to be `Raise` + `Acknowledge`, a client-visible unacked intermediate). The first event per condition after a
|
||||
> start or deploy respawn is flagged `IsRestatement`, so the host writes no duplicate "Activated" row when its
|
||||
> condition already shows that state (see `docs/AlarmTracking.md`). Condition **branch** events (non-null `BranchId`) are ignored.
|
||||
> used to be `Raise` + `Acknowledge`, a client-visible unacked intermediate); a move into inactive+unacked is ONE
|
||||
> `InactiveUnacknowledged` (#657; it used to be `Raise`/`Retrigger` + `Clear`, an Active flicker). The first event per
|
||||
> condition after a start or deploy respawn is flagged `IsRestatement` and states the upstream condition so it lands
|
||||
> from whatever the host retained: an inactive+acked one goes out as `Clear` + `Acknowledge` (#657; it used to be an
|
||||
> `Unspecified` the host wrote as "Activated", and a retained-Active host condition stayed Active). So the host writes no
|
||||
> duplicate row when its condition already shows that state (see `docs/AlarmTracking.md`). Condition **branch** events (non-null `BranchId`) are ignored.
|
||||
> **`ConditionRefresh`** is called after the event item is created, after every in-place reconnect, and on a
|
||||
> `RefreshRequired` event, so a condition already active upstream is reported at subscribe; `RefreshStart`/`RefreshEnd`
|
||||
> markers are dropped. A server without it (`BadNotSupported`/`BadMethodInvalid`/…) is logged once and tolerated.
|
||||
|
||||
@@ -702,3 +702,98 @@ ST14:10 String Native value="Z-33323" status=0x00000000 (
|
||||
Every address reads identically on both transports. `S:4` is the free-running clock, so its value differs read to read on each transport (both Good). `I:0` = -29376 = 0x8D40, and bits `/6` and `/15` read true on both while `/0`, `/1`, `/7` read false — the bit is extracted from the word libplctag read, not defaulted. ab_server has no S/O/I files, so this real-controller read is the only wire evidence; the translation is unit-pinned against a model of the `pccc.c` parser (`LibplctagLegacyNameTests`).
|
||||
|
||||
Caveats: `R6:0.DN` and `R6:0.EN` read `false` on both transports, so this read does not discriminate the control-word bit number (a wrong bit would very likely also read false) — the R bit mapping rests on the native table and the `pccc.c` parser model, not on this wire evidence. Writes: on libplctag every address the translation changes (`S:`/`O:`/`I:`, R/UA status bits) is refused `BadNotSupported` (#659 review), so this fix adds no libplctag write path.
|
||||
|
||||
## hw5 — #566 idle-connection close and a native STRING write on Z-35162 — 2026-10-09 (user-approved)
|
||||
|
||||
Press: Z-35162, CompactLogix 5069-L320ERS2 rev 34.11, `ab://10.209.70.160/1,0`, family CompactLogix, Mac VPN. The user
|
||||
approved exactly two things: (A) one read-only idle-connection session for #566 and (B) native array and STRING writes on
|
||||
logic-unreferenced tags. Harness: `AbCip.IntegrationTests/HardwareGate/` (its own namespace, outside the read-only
|
||||
`Differential` guard), both tests env-gated. No packet capture (`tcpdump` needs sudo); the diagnostics tap
|
||||
(`ConnectionDiagnostics.ExchangeObserver`, now also fed the Forward Open reply so the granted RPIs are visible) and the
|
||||
transport's exception text are the evidence.
|
||||
|
||||
### A. #566 — what a real Logix does with a class-3 connection idle past its timeout: it drops TCP, no CIP status
|
||||
|
||||
`AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_EVID_IDLE=1 AB_EVID_IDLE_SECONDS=60` →
|
||||
`IdleConnectionEvidenceTests` (raw: [hw5-idle566.txt](2026-10-07-native-ab-transport-t21/hw5-idle566.txt)). One private
|
||||
pool, keep-alive pushed out to 1 h so nothing touched the connection, open-attempt budget 2, three Read Tag requests of
|
||||
`test2` (DINT) and nothing else. 10:04:49Z–10:05:54Z UTC.
|
||||
|
||||
| t (s) | Event |
|
||||
|---|---|
|
||||
| 0.26 | Large Forward Open (0x5B) granted: O→T API = T→O API = 2 000 000 µs (the requested RPI, unchanged). Multiplier 1 (×8) ⇒ connection timeout **16 s** |
|
||||
| 0.32 | read 1: general status 0x00 |
|
||||
| 0.32 – 60.33 | idle, no traffic (3.75 × the timeout) |
|
||||
| 60.33 | read 2: **failed in 9 ms on the write**: `IOException: Broken pipe` ← `SocketException(Shutdown)` → `AbTransportException(BadConnection)` → OPC UA `BadCommunicationError` (0x80050000). No CIP reply, no encapsulation status, `ConnectionsClosedByTarget` 0 |
|
||||
| 62.47 | read 3: fresh Large Forward Open granted (new O→T id), read general 0x00 — **recovered on the next request**, open attempts 2/2 |
|
||||
| 62.56 | lease released: Forward Close sent/acked 1/1 |
|
||||
|
||||
- **Answer to #566: neither 0x0107 nor 0x0203.** The controller closed the TCP session; the next request's write hit a dead
|
||||
socket. EPIPE on the *first* write after the idle (not a successful write followed by EOF) means the close was already
|
||||
complete on the Mac's side, which is consistent with an RST (or FIN + RST) from the controller; without a capture the
|
||||
segment type and the exact moment within the 60 s window are not observed — only that it happened after the 16 s
|
||||
timeout and before 60 s.
|
||||
- **The transport handles it correctly as it stands, so no production code changed.** The failed write faults the session
|
||||
(`EncapsulationClient.Fault`, `IsConnected` false), the failing request alone fails `BadConnection` (fast, not an
|
||||
exchange timeout), the driver maps that to `BadCommunicationError` and evicts the runtime / reports `Degraded` for that
|
||||
poll, and the next send reopens through the Forward Open throttle. The 0x0107/0x0203 recycle (#566) stays: it is
|
||||
correct for a target that keeps TCP up, and harmless here (not exercised).
|
||||
- **In production this path needs more than idleness.** The keep-alive fires at half the expiry (8 s), so a connection the
|
||||
driver holds never idles 16 s; the shape above arises after a controller restart / re-download or a network gap.
|
||||
- **Pinned offline:** `CipConnectionTests.A_target_that_resets_an_idle_session_fails_the_next_request_fast_and_the_one_after_reopens`
|
||||
replays the shape (a `FakeEipServer.ResetAllConnections` RST with no request in flight): `BadConnection` in < 4 s,
|
||||
`ConnectionsClosedByTarget` 0, one Forward Open before / two after. Proven able to fail: with `EnsureOpenAsync` made to
|
||||
reuse a non-connected session it goes red (the reopen never happens).
|
||||
|
||||
### B. Native writes: STRING PASS; array NOT RUN (no tag qualifies)
|
||||
|
||||
**STRING — `Program:ES03_TemperatureControl._StrTmp` (PASS, restored byte-exact).** Census: refs 0, extrefs 0, not in the
|
||||
Galaxy map, program not HMI/Wonderware; raw grep of `SN3244.L5X` finds the name only at its declaration (line 69426).
|
||||
`AB_HW_WRITE_GATEWAY=ab://10.209.70.160/1,0 AB_HW_WRITE_APPROVED=1 AB_HW_WRITE_STRING_TAG=Program:ES03_TemperatureControl._StrTmp`
|
||||
→ `StringWriteGateTests`, through the production `AbCipDriver` on the native transport, 10:10:54Z–10:11:29Z UTC (raw:
|
||||
[hw5-string-write.txt](2026-10-07-native-ab-transport-t21/hw5-string-write.txt)). A read-only dry run (`AB_HW_WRITE_DRYRUN=1`)
|
||||
first confirmed the preflight. The raw bytes come from the tap on each Read Tag Fragmented reply.
|
||||
|
||||
| Step | Status | Value | Raw (type, handle, LEN, DATA…) |
|
||||
|---|---|---|---|
|
||||
| snapshot 1 | Good | `ES03_TemperatureControl:Fault` | `A002 CE0F 1D000000 4553…6C74 00…` (92 B) |
|
||||
| snapshot 2 (+30 s) | Good | same | identical to snapshot 1 |
|
||||
| preflight | — | — | built-in STRING (0x02A0 / handle 0x0FCE / 88 B), LEN 29, every byte past LEN zero |
|
||||
| write `OTOPCUA-TEST` | Good (0x00000000) | — | — |
|
||||
| read back | Good | `OTOPCUA-TEST` | `A002 CE0F 0C000000 4F544F504355412D54455354 00…` |
|
||||
| restore original | Good | — | — |
|
||||
| read back | Good | `ES03_TemperatureControl:Fault` | **byte-equal to snapshot 1** |
|
||||
| final snapshot (+2 s) | Good | same | byte-equal to snapshot 1 |
|
||||
|
||||
The original matches the L5X export's value. The write is one unfragmented Write Tag (88 B of data plus the path is far
|
||||
below the 4000-byte request limit of the Large Forward Open).
|
||||
|
||||
**Array — not run; no candidate passes the brief's own rule.** The only logic-unreferenced, Read/Write atomic arrays in the
|
||||
census are `Program:EM01_PowerUnit._SEQ_EP` and `Program:EM02_Clamp._SEQ_EP` (DINT[32]), and the raw grep shows they are
|
||||
**not** unreferenced: aliases `_SEQ_EP01_AutoCycle → _SEQ_EP[1]` (EM01) and `_SEQ_EP01_Jog / _SEQ_EP02_Auto /
|
||||
_SEQ_EP03_Stopping → _SEQ_EP[1..3]` (EM02) are the step registers of the power-unit auto cycle and the clamp jog / auto /
|
||||
stopping sequencers, used by dozens of rungs (`MOV(…,_SEQ_EP02_Auto)`, `EQU(_SEQ_EP01_AutoCycle,300)`…). The census counts
|
||||
references by tag name, so references through an alias were invisible to it. A whole-array write would rewrite live
|
||||
sequencer state, and even a single unaliased element fails the "name appears only at its declaration" check. The other
|
||||
arrays are referenced (`_DisableStates`, `_ModeTransitions`, `_ModeNames`, `_VacuumCalibration_*`, `Stored`,
|
||||
`_monitorID`), `ExternalAccess="Read Only"` (`_SEQ_UP`, `Inp_CycleTime`) or framework lookup tables (`Inf_LookupFwkB`). No
|
||||
array write was made. An element write needs a tag the user picks (or an unaliased element they approve explicitly).
|
||||
|
||||
**#567 (fragmented wide-struct write) — still cannot be done here, and a DINT[32] would not have done it.** AbCip has no
|
||||
whole-array write at the driver layer (array tags are read-only, `DriverArrayWriteSupport`); and at the transport layer a
|
||||
128-byte array (or this 88-byte STRING) is a single Write Tag: `LogixTagClient.WriteAsync` fragments only when the request
|
||||
exceeds `MaxRequestBytes` (4000 B on the Large Forward Open this controller grants). A fragmented write needs a value of
|
||||
roughly 4 KB.
|
||||
|
||||
### Native array-element write — 2026-10-09 (user-approved, Z-35162)
|
||||
|
||||
`Program:EM01_PowerUnit._SEQ_EP` (DINT[32]). L5X check: its only alias is `_SEQ_EP01_AutoCycle -> _SEQ_EP[1]`; no routine references `_SEQ_EP` directly (no index, COP or FLL); no cross-program reference. AbCip CLI (native) from master `98c00904`, one element per read, two snapshots 30 s apart before writing. `[1]` = -1 is the idle auto-cycle step.
|
||||
|
||||
```
|
||||
SNAP1: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
SNAP2: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
WRITE [31]=4242: Write Program:EM01_PowerUnit._SEQ_EP[31]: 0x00000000 (Good)
|
||||
AFTER: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4242
|
||||
RESTORE [31]=0: Write Program:EM01_PowerUnit._SEQ_EP[31]: 0x00000000 (Good)
|
||||
FINAL: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
```
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# hw5 #566 idle-connection gate, Z-35162, 2026-10-09 10:04:49Z-10:05:54Z UTC
|
||||
# AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_EVID_IDLE=1 AB_EVID_IDLE_SECONDS=60 -> HardwareGate/IdleConnectionEvidenceTests
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate.IdleConnectionEvidenceTests.An_idle_connection_past_its_timeout_reports_what_the_controller_does_and_recovers [1 m 2 s]
|
||||
[ 0.00 s] Target 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 50, pace 20 ms; tag 'test2'; requested RPI 2000000 us both ways, timeout multiplier 1 (x8) => expiry 16.0 s; keep-alive pushed to 01:00:00; idle 60 s
|
||||
[ 0.26 s] Forward Open 0x5B granted: O->T id 0xFFFE58C1, O->T API 2000000 us, T->O API 2000000 us; raw DB000000C158FEFF067DB1E37B4F3713143E526D80841E0080841E000000
|
||||
[ 0.32 s] exchange: request service 0x4C (12 B) -> reply 10 B: CC000000C40000000000
|
||||
[ 0.32 s] read 1 (opens): reply in 314 ms, general 0x00
|
||||
[ 0.32 s] Forward Opens 1; now idle for 60 s with no traffic
|
||||
[ 60.33 s] read 2 (after idle): THREW after 9 ms: AbTransportException status=BadConnection (OPC UA 0x80050000) message='Connection lost: Unable to write data to the transport connection: Broken pipe.' inner=IOException('Unable to write data to the transport connection: Broken pipe.') <- SocketException(Shutdown)
|
||||
[ 60.33 s] After read 2: Forward Opens 1 (delta 0), closed-by-target 0, success=False
|
||||
[ 62.47 s] Forward Open 0x5B granted: O->T id 0xFFFE58C2, O->T API 2000000 us, T->O API 2000000 us; raw DB000000C258FEFF489E51887C4F3713143E526D80841E0080841E000000
|
||||
[ 62.51 s] exchange: request service 0x4C (12 B) -> reply 10 B: CC000000C40000000000
|
||||
[ 62.51 s] read 3 (recovery): reply in 178 ms, general 0x00
|
||||
[ 62.51 s] After read 3: Forward Opens 2, closed-by-target 0, open attempts used 2/2, success=True
|
||||
[ 62.56 s] Lease released: Forward Closes sent/acked 1/1
|
||||
Total time: 1.0757 Minutes
|
||||
@@ -0,0 +1,15 @@
|
||||
# hw5 native STRING write gate, Z-35162, 2026-10-09 10:10:54Z-10:11:29Z UTC (user-approved; restored)
|
||||
# AB_HW_WRITE_GATEWAY=ab://10.209.70.160/1,0 AB_HW_WRITE_APPROVED=1 AB_HW_WRITE_STRING_TAG=Program:ES03_TemperatureControl._StrTmp -> HardwareGate/StringWriteGateTests
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate.StringWriteGateTests.A_native_STRING_write_reads_back_and_restores_the_exact_original [32 s]
|
||||
Gateway ab://10.209.70.160/1,0; tag 'Program:ES03_TemperatureControl._StrTmp'; transport Native; snapshot gap 30 s
|
||||
snapshot 1: status 0x00000000, value 'ES03_TemperatureControl:Fault', raw (92 B) A002CE0F1D000000455330335F54656D7065726174757265436F6E74726F6C3A4661756C7400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
snapshot 2: status 0x00000000, value 'ES03_TemperatureControl:Fault', raw (92 B) A002CE0F1D000000455330335F54656D7065726174757265436F6E74726F6C3A4661756C7400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
Preflight OK: type/handle A002CE0F, LEN 29, tail zero; original 'ES03_TemperatureControl:Fault'
|
||||
write test value: 'OTOPCUA-TEST' (ASCII 4F544F504355412D54455354) -> status 0x00000000
|
||||
read back test value: status 0x00000000, value 'OTOPCUA-TEST', raw (92 B) A002CE0F0C0000004F544F504355412D54455354000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
restore original: 'ES03_TemperatureControl:Fault' (ASCII 455330335F54656D7065726174757265436F6E74726F6C3A4661756C74) -> status 0x00000000
|
||||
read back restored: status 0x00000000, value 'ES03_TemperatureControl:Fault', raw (92 B) A002CE0F1D000000455330335F54656D7065726174757265436F6E74726F6C3A4661756C7400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
Test-value raw LEN 12; restored raw equals original: True
|
||||
final snapshot: status 0x00000000, value 'ES03_TemperatureControl:Fault', raw (92 B) A002CE0F1D000000455330335F54656D7065726174757265436F6E74726F6C3A4661756C7400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
RESULT: write, read-back, exact restore — PASS
|
||||
Total time: 34.8142 Seconds
|
||||
@@ -107,11 +107,13 @@ 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) — or
|
||||
/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition; an
|
||||
/// Acknowledge on an already-acknowledged 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
|
||||
/// flag for the last shape (the governing limit changed, no new occurrence). OpcUaClient (Gitea #657) re-states a
|
||||
/// first-seen inactive+acked condition as a flagged Clear then a flagged Acknowledge, so a host already showing
|
||||
/// that writes nothing and a host retaining it Active gets a real "Cleared". <c>false</c> (the default, every source that does not
|
||||
/// opt in) keeps every transition's row.
|
||||
/// </param>
|
||||
public sealed record AlarmEventArgs(
|
||||
@@ -147,6 +149,11 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
|
||||
/// transition-kind enums (e.g. Galaxy's <c>GalaxyAlarmTransitionKind</c>).
|
||||
/// <see cref="ActiveAcknowledged"/> (Gitea #656) means "the condition is active AND acknowledged", applied by the
|
||||
/// host as ONE projection — never Raise-then-Acknowledge, which would show clients an Active+unacked intermediate.
|
||||
/// <see cref="InactiveUnacknowledged"/> (Gitea #657) is its mirror: "the condition is inactive AND unacknowledged"
|
||||
/// (it went active and cleared unacknowledged), applied as ONE projection — never Raise-then-Clear, which showed
|
||||
/// clients the condition Active again for an instant. The host writes rows only for what actually changed:
|
||||
/// "Activated" + "Cleared" when the condition was acknowledged (a new occurrence must have happened), "Cleared"
|
||||
/// alone when it was Active+unacked, none when it already showed inactive+unacked.
|
||||
/// Members are append-only: their numeric values must not move.
|
||||
/// </summary>
|
||||
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged }
|
||||
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged, InactiveUnacknowledged }
|
||||
|
||||
@@ -221,7 +221,9 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
_o.RpiMicros, size, _route);
|
||||
var frame = ConnectionManagerServices.ForwardOpen(p);
|
||||
_diagnostics.CountForwardOpen();
|
||||
return CipReply.Parse(await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false));
|
||||
var raw = await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false);
|
||||
_diagnostics.NotifyExchange(frame, raw); // evidence tap: the granted RPIs / connection ids (#566 gate)
|
||||
return CipReply.Parse(raw);
|
||||
}
|
||||
|
||||
private ushort NextConnectionSerial()
|
||||
|
||||
@@ -92,8 +92,8 @@ internal sealed class ConnectionDiagnostics
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence tap for the gate harness (#582 / #572): sees the CIP request and the raw CIP reply of every
|
||||
/// completed exchange. Null in production; set only by the read-only differential harness. A throwing
|
||||
/// Evidence tap for the gate harness (#582 / #572 / #566): sees the CIP request and the raw CIP reply of every
|
||||
/// completed exchange, Forward Opens included (their replies carry the granted RPIs). Null in production; set only by the read-only differential harness. A throwing
|
||||
/// observer is swallowed so it can never affect a request.
|
||||
/// </summary>
|
||||
internal Action<ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? ExchangeObserver { get; set; }
|
||||
|
||||
@@ -693,7 +693,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
// Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through
|
||||
// so the transport reports the failure as it always did.
|
||||
TagName: AbLegacyAddress.TryParse(_options.Probe.ProbeAddress, state.Options.PlcFamily)?.ToLibplctagName()
|
||||
TagName: AbLegacyProbeAddress.Normalise(_options.Probe.ProbeAddress, state.Options.PlcFamily)
|
||||
?? _options.Probe.ProbeAddress!,
|
||||
Timeout: _options.Probe.Timeout);
|
||||
|
||||
|
||||
@@ -98,12 +98,18 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
?? FirstProbeableTagAddress(opts, firstDevice)
|
||||
?? "S:0";
|
||||
|
||||
// Normalise through the same helper as the driver's probe loop (#660): PLC-5 octal I/O -> decimal.
|
||||
// An unparseable address is a clean failure here — the factory would otherwise throw outside the try.
|
||||
var normalisedName = AbLegacyProbeAddress.Normalise(tagName, firstDevice?.PlcFamily ?? AbLegacyPlcFamily.Slc500);
|
||||
if (normalisedName is null)
|
||||
return new(false, $"Probe address '{tagName}' is not a valid AbLegacy address for this PLC family.", null);
|
||||
|
||||
var p = new AbLegacyTagCreateParams(
|
||||
Gateway: parsed.Gateway,
|
||||
Port: parsed.Port,
|
||||
CipPath: parsed.CipPath,
|
||||
LibplctagPlcAttribute: profile.LibplctagPlcAttribute,
|
||||
TagName: tagName,
|
||||
TagName: normalisedName,
|
||||
Timeout: timeout);
|
||||
|
||||
var factory = _tagFactory ?? (AbLegacyTransportResolver.Resolve(opts) == AbLegacyTransport.Native
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// The single authority that turns an operator-authored probe address into the name handed to the tag
|
||||
/// factory: family-aware parse (PLC-5 <c>I:</c>/<c>O:</c> octal indices become decimal) then
|
||||
/// <see cref="AbLegacyAddress.ToLibplctagName"/> — the same normalisation tag paths get. Shared by the
|
||||
/// driver's probe loop and the Test Connect probe (#660).
|
||||
/// </summary>
|
||||
internal static class AbLegacyProbeAddress
|
||||
{
|
||||
/// <summary>Returns the normalised name, or null when <paramref name="address"/> does not parse for the family.</summary>
|
||||
public static string? Normalise(string? address, AbLegacyPlcFamily family) =>
|
||||
AbLegacyAddress.TryParse(address, family)?.ToLibplctagName();
|
||||
}
|
||||
@@ -1887,9 +1887,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
|
||||
/// <summary>
|
||||
/// #619 re-review: open (or re-open) the host's alarm subscription over <paramref name="sourceNodeIds"/>.
|
||||
/// A CHANGED set means the host rebuilt its address space (an alarm-bearing delta always does), recreating
|
||||
/// every condition node inactive+acked — so every last-told state is forgotten and the post-subscribe
|
||||
/// ConditionRefresh re-reports from that default. The SAME set (a reinit / reconnect re-subscribe) keeps
|
||||
/// A CHANGED set means the host applied an alarm-bearing delta, so every last-told state is forgotten and the
|
||||
/// post-subscribe ConditionRefresh re-reports as first sight. Since #641 a rebuild KEEPS native condition state,
|
||||
/// so the host may hold ANY state here — which is why first sight is flagged (#656/#657) rather than assuming
|
||||
/// an inactive+acked default. The SAME set (a reinit / reconnect re-subscribe) keeps
|
||||
/// them, so the refresh copy of an already-reported state emits nothing. Ack targets of refs no longer
|
||||
/// subscribed are pruned either way.
|
||||
/// </summary>
|
||||
@@ -2069,7 +2070,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <summary>
|
||||
/// #619 review — the <see cref="AlarmTransitionKind"/> sequence that moves the host's
|
||||
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked — or, on an already-active condition, a re-statement keeping the ack (#647) — Acknowledge ⇒ acked,
|
||||
/// ActiveAcknowledged ⇒ active+acked in one step (#656), Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
|
||||
/// ActiveAcknowledged ⇒ active+acked in one step (#656), InactiveUnacknowledged ⇒ inactive+unacked in one step (#657), Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
|
||||
/// upstream event can need two (an alarm that went active and was acknowledged between two notifications).
|
||||
/// No state change ⇒ empty (the caller decides whether a message/severity change warrants an Unspecified).
|
||||
/// </summary>
|
||||
@@ -2086,6 +2087,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
internal static IReadOnlyList<AlarmTransitionKind> AlarmTransitions(
|
||||
(bool Active, bool Acked) prior, (bool Active, bool Acked) target, bool priorKnown = true)
|
||||
{
|
||||
// Gitea #657: on first sight the host may hold ANY state (it retains a still-deployed condition across a
|
||||
// respawn), so an inactive target is stated in kinds that land on it from every host state: inactive+unacked
|
||||
// as ONE InactiveUnacknowledged; inactive+acked as Clear then Acknowledge (flagged by the caller, so a host
|
||||
// already showing it writes no row, and a host retaining it Active gets a real "Cleared"). Pre-fix an
|
||||
// inactive+acked first sight equalled the default prior and went out as Unspecified — an "Activated" row for
|
||||
// an inactive alarm, and a retained-Active host condition was never cleared.
|
||||
if (!priorKnown && !target.Active)
|
||||
return target.Acked
|
||||
? [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
|
||||
: [AlarmTransitionKind.InactiveUnacknowledged];
|
||||
if (prior == target) return [];
|
||||
var kinds = Transitions(prior, target);
|
||||
return priorKnown || target.Acked
|
||||
@@ -2109,11 +2120,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
(false, true) => prior.Active
|
||||
? prior.Acked ? [AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
|
||||
: [AlarmTransitionKind.Acknowledge],
|
||||
// Inactive+UNacked (went active and cleared unacknowledged — e.g. first seen by a ConditionRefresh):
|
||||
// the only kinds that un-ack are Raise/Retrigger, so pass through active, then Clear.
|
||||
(false, false) => prior.Active
|
||||
? prior.Acked ? [AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear]
|
||||
: [AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
|
||||
// Inactive+UNacked (went active and cleared unacknowledged — e.g. first seen by a ConditionRefresh): ONE
|
||||
// atomic InactiveUnacknowledged (Gitea #657) — Raise/Retrigger-then-Clear showed clients the condition
|
||||
// Active again for an instant. From Active+unacked a plain Clear already lands there.
|
||||
(false, false) => prior is (true, false)
|
||||
? [AlarmTransitionKind.Clear]
|
||||
: [AlarmTransitionKind.InactiveUnacknowledged],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1885,7 +1885,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// decides WHICH rows the transition warrants: none for a re-statement (Gitea #647 review: a Raise on an
|
||||
// already-active condition from a respawned driver; #656: a flagged snapshot/first-sight Retrigger or
|
||||
// Clear that changes nothing the condition shows), and up to two for an ActiveAcknowledged (#656:
|
||||
// "Activated" only if it was not already active, "Acknowledged" only if it was not already acked).
|
||||
// "Activated" only if it was not already active, "Acknowledged" only if it was not already acked) or an
|
||||
// InactiveUnacknowledged (#657: "Activated" + "Cleared" only if it was acked, "Cleared" if it was active).
|
||||
if (!serviceAlertsAsPrimary) continue;
|
||||
|
||||
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
||||
@@ -1911,8 +1912,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
{
|
||||
// #656: an ActiveAcknowledged expands to "Activated" + "Acknowledged". The operator comment/user belong to
|
||||
// the acknowledgement, and the activation happened at the original raise time when the source knows it.
|
||||
var expandedActivation = args.Kind == AlarmTransitionKind.ActiveAcknowledged
|
||||
&& historyKind != AlarmTransitionKind.Acknowledge;
|
||||
// #657: an InactiveUnacknowledged's "Activated" row is likewise the occurrence, at its raise time when known.
|
||||
var expandedActivation = (args.Kind == AlarmTransitionKind.ActiveAcknowledged
|
||||
&& historyKind != AlarmTransitionKind.Acknowledge)
|
||||
|| (args.Kind == AlarmTransitionKind.InactiveUnacknowledged && historyKind == AlarmTransitionKind.Raise);
|
||||
var comment = expandedActivation ? null : args.OperatorComment;
|
||||
var timestamp = expandedActivation ? args.OriginalRaiseTimestampUtc ?? args.SourceTimestampUtc : args.SourceTimestampUtc;
|
||||
var alertEvent = new AlarmTransitionEvent(
|
||||
@@ -1957,6 +1960,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
AlarmTransitionKind.Acknowledge => "Acknowledged",
|
||||
// #656: never reached in practice — the projector expands ActiveAcknowledged into Raise + Acknowledge rows.
|
||||
AlarmTransitionKind.ActiveAcknowledged => "Acknowledged",
|
||||
// #657: likewise never reached — expanded into Raise + Clear (or Clear alone) rows.
|
||||
AlarmTransitionKind.InactiveUnacknowledged => "Cleared",
|
||||
_ => "Activated",
|
||||
};
|
||||
|
||||
|
||||
@@ -49,10 +49,13 @@ public sealed class NativeAlarmProjector
|
||||
/// <item>Gitea #656 — <see cref="AlarmTransitionKind.ActiveAcknowledged"/> projects Active+acked in ONE
|
||||
/// update (never an Active+unacked intermediate a client could see) and writes "Activated" only when the
|
||||
/// condition was not already showing that alarm active, then "Acknowledged" only when it was not acked.</item>
|
||||
/// <item>Gitea #657 — <see cref="AlarmTransitionKind.InactiveUnacknowledged"/> projects inactive+unacked in ONE
|
||||
/// update and writes "Activated" + "Cleared" only when the condition was acknowledged (an un-ack needs a new
|
||||
/// occurrence), "Cleared" alone when it was Active+unacked, nothing when it already showed exactly that.</item>
|
||||
/// <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; and a flagged Unspecified update of an already-active
|
||||
/// the condition shows: a Retrigger on an Active+unacked condition with the same message AND severity,
|
||||
/// a Clear on an already-inactive condition, or an Acknowledge on an already-acknowledged one (#657); 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>
|
||||
@@ -99,6 +102,21 @@ public sealed class NativeAlarmProjector
|
||||
? [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]
|
||||
: !prev.Acked ? [AlarmTransitionKind.Acknowledge] : [];
|
||||
break;
|
||||
// #657: the mirror of ActiveAcknowledged — inactive AND unacked in ONE update (never Raise-then-Clear, which
|
||||
// showed clients the condition Active again for an instant). Rows only for what changed: an acknowledged
|
||||
// condition can only have become unacked through a new occurrence ("Activated" + "Cleared"); an
|
||||
// Active+unacked one just cleared; one already inactive+unacked changed nothing.
|
||||
case AlarmTransitionKind.InactiveUnacknowledged:
|
||||
(active, acked) = (false, false);
|
||||
historyKinds = prev.Acked
|
||||
? [AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]
|
||||
: prev.Active ? [AlarmTransitionKind.Clear] : [];
|
||||
break;
|
||||
// #657: a source re-stating an acknowledgement the condition already shows (OpcUaClient's first sight of
|
||||
// an inactive+acked condition is a flagged Clear + Acknowledge) changes nothing.
|
||||
case AlarmTransitionKind.Acknowledge when e.IsRestatement && prev.Acked:
|
||||
(active, acked, historyKinds) = (prev.Active, true, []);
|
||||
break;
|
||||
case AlarmTransitionKind.Acknowledge:
|
||||
(active, acked, historyKinds) = (prev.Active, true, [AlarmTransitionKind.Acknowledge]);
|
||||
break;
|
||||
@@ -112,9 +130,8 @@ public sealed class NativeAlarmProjector
|
||||
break;
|
||||
// A message/severity-only update of an ALREADY-ACTIVE condition that the source flags as a re-statement
|
||||
// (AbCip ALMA: the governing limit changed but no new occurrence started, e.g. HH dropped back to H).
|
||||
// OpcUaClient can reach it too: first sight after a respawn of an upstream-inactive condition is told
|
||||
// Unspecified+flagged; with the host still Active the node stays Active exactly as before (a pre-existing
|
||||
// gap) and the false "Activated" row it used to write is now gone.
|
||||
// OpcUaClient no longer reaches it (#657: its first sight of an inactive condition is a flagged Clear +
|
||||
// Acknowledge, or an InactiveUnacknowledged — never a flagged Unspecified).
|
||||
// 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, []);
|
||||
|
||||
@@ -565,6 +565,36 @@ public sealed class CipConnectionTests
|
||||
target.ForwardOpens.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_target_that_resets_an_idle_session_fails_the_next_request_fast_and_the_one_after_reopens()
|
||||
{
|
||||
// The shape a real CompactLogix produced (Z-35162, hw5 #566 gate): a session idle past its connection
|
||||
// timeout was reset with NO CIP status, so the next write hit a dead socket ("Broken pipe"). It must surface
|
||||
// at once as BadConnection (not wait out the exchange timeout), must not count as a CIP-level close, and the
|
||||
// request after it must reopen with a fresh Forward Open.
|
||||
await using var target = new FakeCipTarget();
|
||||
target.ConnectedHandler = cip => Task.FromResult<byte[]?>(Ok(cip));
|
||||
var time = new ManualTimeProvider();
|
||||
await using var conn = Create(target, Options(timeoutSeconds: 5), time);
|
||||
await conn.SendAsync(ReadTag, TestCt);
|
||||
|
||||
target.Server.ResetAllConnections();
|
||||
await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "the reset to land");
|
||||
var started = System.Diagnostics.Stopwatch.StartNew();
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection);
|
||||
started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(4), "a dead socket is not a timeout");
|
||||
conn.Diagnostics.ConnectionsClosedByTarget.ShouldBe(0); // no CIP status was involved
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
|
||||
var reopen = conn.SendAsync(ReadTag, TestCt);
|
||||
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // through the throttle
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
CipReply.Parse(await reopen.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
||||
target.ForwardOpens.ShouldBe(2);
|
||||
target.Server.Connections.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Other_error_statuses_describe_the_request_and_leave_the_connection_alone()
|
||||
{
|
||||
|
||||
@@ -73,6 +73,24 @@ internal sealed class FakeEipServer : IAsyncDisposable
|
||||
/// <summary>Close the connection instead of answering the next non-session request.</summary>
|
||||
public bool DropOnNextRequest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reset every accepted connection (SO_LINGER 0 ⇒ RST) without any request in flight — what a real Logix
|
||||
/// (Z-35162, Gitea #566 hw5 gate) did to a class-3 session left idle past its connection timeout: no CIP
|
||||
/// status, the next request's write just fails on a dead socket.
|
||||
/// </summary>
|
||||
public void ResetAllConnections()
|
||||
{
|
||||
foreach (var c in _clients)
|
||||
{
|
||||
try
|
||||
{
|
||||
c.Client.LingerState = new LingerOption(true, 0);
|
||||
c.Client.Close();
|
||||
}
|
||||
catch (ObjectDisposedException) { /* already gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Queue one-shot responders, consumed one per non-session request.</summary>
|
||||
public void Script(params Func<byte[], byte[]?>[] steps)
|
||||
{
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate;
|
||||
|
||||
/// <summary>
|
||||
/// Offline guard for the hardware-gate folder — deliberately NOT env-gated, so it runs in every build. The folder sits
|
||||
/// outside the read-only <c>Differential</c> guard because one test in it writes; this pins how far that goes:
|
||||
/// only <see cref="StringWriteGateTests"/> may name a write path, its allow-list is exactly the two approved
|
||||
/// (Z-35162, tag) pairs, and its explicit opt-in and differential-run refusal are present.
|
||||
/// </summary>
|
||||
public sealed class HardwareGateGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public void Only_the_string_write_gate_names_a_write_path()
|
||||
{
|
||||
// Built by concatenation so this file's own text never matches itself.
|
||||
string[] forbidden =
|
||||
[
|
||||
"Write" + "Async", "Encode" + "Value", "WriteBit" + "InDInt", "Write" + "Tag", "Write" + "Request", "AbCip" + "Driver",
|
||||
];
|
||||
var sources = Directory.GetFiles(Path.GetDirectoryName(ThisFile())!, "*.cs");
|
||||
sources.ShouldContain(f => Path.GetFileName(f) == nameof(StringWriteGateTests) + ".cs");
|
||||
sources.ShouldContain(f => Path.GetFileName(f) == nameof(IdleConnectionEvidenceTests) + ".cs");
|
||||
|
||||
var hits = (from f in sources
|
||||
where Path.GetFileName(f) != nameof(StringWriteGateTests) + ".cs"
|
||||
let text = File.ReadAllText(f)
|
||||
from token in forbidden
|
||||
where text.Contains(token, StringComparison.Ordinal)
|
||||
select $"{Path.GetFileName(f)} names {token}").ToList();
|
||||
hits.ShouldBeEmpty();
|
||||
|
||||
// Positive control: the scan does see the one file that is allowed to write.
|
||||
var writer = File.ReadAllText(sources.Single(f => Path.GetFileName(f) == nameof(StringWriteGateTests) + ".cs"));
|
||||
writer.ShouldContain(forbidden[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_write_allow_list_is_exactly_the_two_approved_pairs_on_Z35162()
|
||||
{
|
||||
StringWriteGateTests.Approved.ShouldBe(
|
||||
[
|
||||
("10.209.70.160", "Program:ES03_TemperatureControl._StrTmp"),
|
||||
("10.209.70.160", "Program:ES03_TemperatureControl._Prefix"),
|
||||
]);
|
||||
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.160/1,0", "Program:ES03_TemperatureControl._StrTmp").ShouldBeTrue();
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.160:44818/1,0", "Program:ES03_TemperatureControl._Prefix").ShouldBeTrue();
|
||||
// Same tag on any other controller, another tag on Z-35162, or a malformed gateway: refused.
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.161/1,0", "Program:ES03_TemperatureControl._StrTmp").ShouldBeFalse();
|
||||
StringWriteGateTests.IsApproved("ab://10.100.0.35/1,0", "Program:ES03_TemperatureControl._StrTmp").ShouldBeFalse();
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.160/1,0", "test2").ShouldBeFalse();
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.160/1,0", "Program:UN01_Press._Prefix").ShouldBeFalse();
|
||||
StringWriteGateTests.IsApproved("10.209.70.160", "Program:ES03_TemperatureControl._StrTmp").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_string_write_gate_keeps_its_opt_in_and_refuses_during_a_differential_run()
|
||||
{
|
||||
var text = File.ReadAllText(Path.Combine(Path.GetDirectoryName(ThisFile())!, nameof(StringWriteGateTests) + ".cs"));
|
||||
text.ShouldContain("""GetEnvironmentVariable("AB_HW_WRITE_APPROVED") != "1") Assert.Skip(""");
|
||||
text.ShouldContain("GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is { Length: > 0 })");
|
||||
text.ShouldContain("if (!IsApproved(gateway, tagPath))");
|
||||
IntegrationWriteGate.DifferentialEndpointVar.ShouldBe("AB_DIFF_ENDPOINT");
|
||||
}
|
||||
|
||||
private static string ThisFile([CallerFilePath] string path = "") => path;
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #566 hardware evidence (hw5): what a real Logix does with a class-3 connection that has been left idle
|
||||
/// past its connection timeout. READ-ONLY: three Read Tag requests of one tag on one private pool, nothing else.
|
||||
/// <list type="bullet">
|
||||
/// <item>Opt-in: <c>AB_DIFF_ENDPOINT</c> (the target, see <see cref="DifferentialTarget"/>) AND
|
||||
/// <c>AB_EVID_IDLE=1</c>.</item>
|
||||
/// <item><c>AB_EVID_IDLE_TAG</c> — the tag read, default <c>test2</c> (a DINT).</item>
|
||||
/// <item><c>AB_EVID_IDLE_SECONDS</c> — idle time, default 60, in [1, 300]. It must exceed the expiry the
|
||||
/// controller grants (logged from the Forward Open reply), or the read just succeeds.</item>
|
||||
/// </list>
|
||||
/// The keep-alive is pushed out to one hour on this private pool so nothing touches the connection while it is
|
||||
/// idle (in production it fires every half expiry, which is why this case only arises after a controller
|
||||
/// restart / re-download or a network gap). The open-attempt budget is 2: the first open and one reconnect.
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public sealed class IdleConnectionEvidenceTests
|
||||
{
|
||||
private const string DefaultTag = "test2";
|
||||
|
||||
[Fact]
|
||||
public async Task An_idle_connection_past_its_timeout_reports_what_the_controller_does_and_recovers()
|
||||
{
|
||||
var target = DifferentialTarget.FromEnvironment();
|
||||
if (target is null) Assert.Skip(DifferentialTarget.SkipReason);
|
||||
if (Environment.GetEnvironmentVariable("AB_EVID_IDLE") != "1") Assert.Skip("AB_EVID_IDLE=1 not set.");
|
||||
var tag = Environment.GetEnvironmentVariable("AB_EVID_IDLE_TAG") is { Length: > 0 } t ? t.Trim() : DefaultTag;
|
||||
var idleSeconds = 60;
|
||||
if (Environment.GetEnvironmentVariable("AB_EVID_IDLE_SECONDS") is { Length: > 0 } rawIdle
|
||||
&& (!int.TryParse(rawIdle, NumberStyles.None, CultureInfo.InvariantCulture, out idleSeconds) || idleSeconds is < 1 or > 300))
|
||||
throw new InvalidOperationException($"AB_EVID_IDLE_SECONDS='{rawIdle}' must be an integer in [1, 300].");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(idleSeconds + 120));
|
||||
var ct = cts.Token;
|
||||
var pool = new DeviceSessionPool();
|
||||
var diagnostics = pool.Diagnostics;
|
||||
var p = target.Params(tag);
|
||||
var key = NativeAbCipDevice.Key(p);
|
||||
var options = NativeAbCipDevice.Options(p) with { KeepAliveInterval = TimeSpan.FromHours(1) };
|
||||
var clock = Stopwatch.StartNew();
|
||||
var log = new List<string>();
|
||||
void Note(string line)
|
||||
{
|
||||
var stamped = $"[{clock.Elapsed.TotalSeconds,7:F2} s] {line}";
|
||||
log.Add(stamped);
|
||||
TestContext.Current.TestOutputHelper?.WriteLine(stamped);
|
||||
}
|
||||
|
||||
diagnostics.ExchangeObserver = (req, rep) => Note(DescribeExchange(req.Span, rep.Span));
|
||||
using var budget = diagnostics.LimitOpenAttempts(key, 2);
|
||||
var request = LogixServices.ReadTag(CipPath.EncodeSymbolic(tag), 1);
|
||||
Note($"Target {target}; tag '{tag}'; requested RPI {options.RpiMicros} us both ways, timeout multiplier " +
|
||||
$"{options.TimeoutMultiplier} (x{4 << options.TimeoutMultiplier}) => expiry {options.ConnectionExpiry.TotalSeconds:F1} s; " +
|
||||
$"keep-alive pushed to {options.KeepAliveInterval}; idle {idleSeconds} s");
|
||||
|
||||
var lease = pool.Acquire(key, options);
|
||||
try
|
||||
{
|
||||
var first = await ReadAsync("read 1 (opens)", lease.Channel, request, Note, ct);
|
||||
first.ShouldBeTrue("the first read must succeed, or there is no connection to idle");
|
||||
Note($"Forward Opens {diagnostics.ForwardOpens}; now idle for {idleSeconds} s with no traffic");
|
||||
await Task.Delay(TimeSpan.FromSeconds(idleSeconds), ct);
|
||||
|
||||
var opensBefore = diagnostics.ForwardOpens;
|
||||
var second = await ReadAsync("read 2 (after idle)", lease.Channel, request, Note, ct);
|
||||
Note($"After read 2: Forward Opens {diagnostics.ForwardOpens} (delta {diagnostics.ForwardOpens - opensBefore}), " +
|
||||
$"closed-by-target {diagnostics.ConnectionsClosedByTarget}, success={second}");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), ct); // past the 1 s Forward Open interval
|
||||
var third = await ReadAsync("read 3 (recovery)", lease.Channel, request, Note, ct);
|
||||
Note($"After read 3: Forward Opens {diagnostics.ForwardOpens}, closed-by-target " +
|
||||
$"{diagnostics.ConnectionsClosedByTarget}, open attempts used {budget.Attempts}/{budget.Budget}, success={third}");
|
||||
third.ShouldBeTrue("the transport must recover on the request after the failed one");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await lease.DisposeAsync();
|
||||
diagnostics.ExchangeObserver = null;
|
||||
Note($"Lease released: Forward Closes sent/acked {diagnostics.ForwardClosesSent}/{diagnostics.ForwardClosesAcked}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadAsync(string label, ICipChannel channel, byte[] request, Action<string> note,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var reply = CipReply.Parse(await channel.SendAsync(request, ct));
|
||||
note($"{label}: reply in {sw.ElapsedMilliseconds} ms, general 0x{reply.GeneralStatus:X2}" +
|
||||
$"{(reply.ExtendedStatus.Length > 0 ? $" extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}" : "")}");
|
||||
return reply.GeneralStatus == 0;
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
note($"{label}: THREW after {sw.ElapsedMilliseconds} ms: AbTransportException status={ex.Status} " +
|
||||
$"(OPC UA 0x{AbOpcUaStatusMap.MapStatus(ex.Status):X8}) message='{ex.Message}' inner={Inner(ex)}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Inner(Exception ex)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
for (var e = ex.InnerException; e is not null; e = e.InnerException)
|
||||
parts.Add(e is SocketException se ? $"SocketException({se.SocketErrorCode})" : $"{e.GetType().Name}('{e.Message}')");
|
||||
return parts.Count == 0 ? "none" : string.Join(" <- ", parts);
|
||||
}
|
||||
|
||||
private static string DescribeExchange(ReadOnlySpan<byte> request, ReadOnlySpan<byte> reply)
|
||||
{
|
||||
var service = request.Length > 0 ? request[0] : (byte)0;
|
||||
var hex = Convert.ToHexString(reply[..Math.Min(reply.Length, 48)]);
|
||||
if (service is 0x54 or 0x5B && reply.Length >= 4 && reply[2] == 0)
|
||||
{
|
||||
// Forward Open reply data: O->T id, T->O id, serial, vendor, originator serial, O->T API, T->O API.
|
||||
var d = reply[(4 + 2 * reply[3])..];
|
||||
if (d.Length >= 24)
|
||||
return $"Forward Open 0x{service:X2} granted: O->T id 0x{BinaryPrimitives.ReadUInt32LittleEndian(d):X8}, " +
|
||||
$"O->T API {BinaryPrimitives.ReadUInt32LittleEndian(d[16..])} us, T->O API {BinaryPrimitives.ReadUInt32LittleEndian(d[20..])} us; raw {hex}";
|
||||
}
|
||||
return $"exchange: request service 0x{service:X2} ({request.Length} B) -> reply {reply.Length} B: {hex}";
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #592 (hw5) — ONE user-approved native STRING write on a real controller, through the production driver
|
||||
/// (<see cref="AbCipDriver"/>, native transport): snapshot twice ≥ 30 s apart (raw bytes, from the exchange tap),
|
||||
/// write a distinctive value, read it back, restore the exact original, read back, final raw snapshot equals the
|
||||
/// original. Fails closed before ANY write when the tag is not a built-in 88-byte STRING, moved between the two
|
||||
/// snapshots, or holds non-zero bytes past its LEN (the driver's encoder zero-fills, so such a value could not be
|
||||
/// restored byte-exact).
|
||||
/// <list type="bullet">
|
||||
/// <item><c>AB_HW_WRITE_GATEWAY</c> — <c>ab://host/1,0</c>; absent ⇒ skip.</item>
|
||||
/// <item><c>AB_HW_WRITE_STRING_TAG</c> — with the gateway's host, must be a pair on <see cref="Approved"/>.</item>
|
||||
/// <item><c>AB_HW_WRITE_APPROVED=1</c> — the explicit opt-in.</item>
|
||||
/// <item><c>AB_HW_WRITE_GAP_SECONDS</c> — snapshot gap, default 30, at least 30.</item>
|
||||
/// <item><c>AB_HW_WRITE_DRYRUN=1</c> — stop after the read-only preflight (no write).</item>
|
||||
/// </list>
|
||||
/// Refused (skipped) while <c>AB_DIFF_ENDPOINT</c> is set (a read-only differential run). Never set by any other
|
||||
/// run; <see cref="IntegrationWriteGate"/> (the simulator opt-in) is deliberately not
|
||||
/// what enables this.
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public sealed class StringWriteGateTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The only (controller, tag) pairs this gate may write: Z-35162's logic-, alias-, trend- and Galaxy-unreferenced
|
||||
/// STRINGs (L5X census + raw grep). Pinned to the host, so the same tag name on another controller is refused.
|
||||
/// <see cref="HardwareGateGuardTests"/> pins this list.
|
||||
/// </summary>
|
||||
internal static readonly (string Host, string Tag)[] Approved =
|
||||
[
|
||||
("10.209.70.160", "Program:ES03_TemperatureControl._StrTmp"),
|
||||
("10.209.70.160", "Program:ES03_TemperatureControl._Prefix"),
|
||||
];
|
||||
|
||||
/// <summary>The host of an <c>ab://host[:port]/path</c> gateway, or null when it is not one.</summary>
|
||||
internal static string? GatewayHost(string gateway)
|
||||
{
|
||||
if (!gateway.StartsWith("ab://", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var authority = gateway[5..].Split('/', 2)[0];
|
||||
var host = authority.Split(':', 2)[0];
|
||||
return host.Length == 0 ? null : host;
|
||||
}
|
||||
|
||||
/// <summary>True only for an exact (host, tag) pair on <see cref="Approved"/>.</summary>
|
||||
internal static bool IsApproved(string gateway, string tag) =>
|
||||
GatewayHost(gateway) is { } host
|
||||
&& Approved.Any(a => string.Equals(a.Host, host, StringComparison.Ordinal) && string.Equals(a.Tag, tag, StringComparison.Ordinal));
|
||||
|
||||
private const string TestValue = "OTOPCUA-TEST";
|
||||
private const int BuiltInStringBytes = 88;
|
||||
private const int StringCapacity = 82;
|
||||
|
||||
[Fact]
|
||||
public async Task A_native_STRING_write_reads_back_and_restores_the_exact_original()
|
||||
{
|
||||
var gateway = Environment.GetEnvironmentVariable("AB_HW_WRITE_GATEWAY")?.Trim();
|
||||
if (string.IsNullOrEmpty(gateway)) Assert.Skip("AB_HW_WRITE_GATEWAY not set.");
|
||||
if (Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is { Length: > 0 })
|
||||
Assert.Skip($"Write gate refused: {IntegrationWriteGate.DifferentialEndpointVar} is set, so a read-only differential run is in progress.");
|
||||
if (Environment.GetEnvironmentVariable("AB_HW_WRITE_APPROVED") != "1") Assert.Skip("AB_HW_WRITE_APPROVED=1 not set.");
|
||||
var tagPath = Environment.GetEnvironmentVariable("AB_HW_WRITE_STRING_TAG")?.Trim() ?? "";
|
||||
if (!IsApproved(gateway, tagPath))
|
||||
throw new InvalidOperationException($"('{gateway}', '{tagPath}') is not an approved (controller, tag) pair.");
|
||||
var gap = 30;
|
||||
if (Environment.GetEnvironmentVariable("AB_HW_WRITE_GAP_SECONDS") is { Length: > 0 } rawGap
|
||||
&& (!int.TryParse(rawGap, out gap) || gap < 30 || gap > 300))
|
||||
throw new InvalidOperationException($"AB_HW_WRITE_GAP_SECONDS='{rawGap}' must be in [30, 300].");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(gap + 120));
|
||||
var ct = cts.Token;
|
||||
const string Ref = "gate-string";
|
||||
var diagnostics = DeviceSessionPool.Shared.Diagnostics;
|
||||
var replies = new List<byte[]>();
|
||||
diagnostics.ExchangeObserver = (req, rep) =>
|
||||
{
|
||||
// Read Tag Fragmented (0x52) replies only: service 0xD2, status 0, then type 0x02A0 + handle + data.
|
||||
if (req.Length > 0 && req.Span[0] == 0x52) lock (replies) replies.Add(rep.ToArray());
|
||||
};
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(gateway, AbCipPlcFamily.CompactLogix, DeviceName: "hw5-gate")],
|
||||
RawTags =
|
||||
[
|
||||
new RawTagEntry(Ref, $$"""{"tagPath":"{{tagPath}}","dataType":"String","writable":true}""",
|
||||
WriteIdempotent: false, DeviceName: gateway),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Transport = AbCipTransport.Native,
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = false,
|
||||
EnableAlarmProjection = false,
|
||||
}, "hw5-string-gate");
|
||||
try
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
Log($"Gateway {gateway}; tag '{tagPath}'; transport Native; snapshot gap {gap} s");
|
||||
|
||||
var (v1, raw1) = await SnapshotAsync(drv, Ref, replies, "snapshot 1", ct);
|
||||
await Task.Delay(TimeSpan.FromSeconds(gap), ct);
|
||||
var (v2, raw2) = await SnapshotAsync(drv, Ref, replies, "snapshot 2", ct);
|
||||
|
||||
// Preflight — fail closed before any write.
|
||||
raw1.ShouldBe(raw2, "the tag changed between the two snapshots: something else writes it; NOT writing");
|
||||
raw1.Length.ShouldBe(4 + BuiltInStringBytes, "expected a built-in STRING (type 0x02A0 + handle + 88 bytes)");
|
||||
var data = raw1.AsSpan(4);
|
||||
var len = BitConverter.ToInt32(data[..4]);
|
||||
len.ShouldBeInRange(0, StringCapacity);
|
||||
data[(4 + len)..].ToArray().ShouldAllBe(b => b == 0,
|
||||
"bytes past LEN are not zero, so the driver's zero-filling encoder could not restore them exactly; NOT writing");
|
||||
Log($"Preflight OK: type/handle {Convert.ToHexString(raw1.AsSpan(0, 4))}, LEN {len}, tail zero; original '{v1}'");
|
||||
if (Environment.GetEnvironmentVariable("AB_HW_WRITE_DRYRUN") == "1")
|
||||
{
|
||||
Log("AB_HW_WRITE_DRYRUN=1: stopping after the read-only preflight; nothing written.");
|
||||
return;
|
||||
}
|
||||
|
||||
var restored = false;
|
||||
byte[] rawTest;
|
||||
try
|
||||
{
|
||||
await WriteAsync(drv, Ref, TestValue, "write test value", ct);
|
||||
(var vTest, rawTest) = await SnapshotAsync(drv, Ref, replies, "read back test value", ct);
|
||||
vTest.ShouldBe(TestValue);
|
||||
await WriteAsync(drv, Ref, v1, "restore original", ct);
|
||||
restored = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Whatever failed after the first write, the original goes back (own token: never skip the restore).
|
||||
if (!restored) await WriteAsync(drv, Ref, v1, "restore original (after a failure)", CancellationToken.None);
|
||||
}
|
||||
var (vRestored, rawRestored) = await SnapshotAsync(drv, Ref, replies, "read back restored", ct);
|
||||
vRestored.ShouldBe(v1);
|
||||
rawRestored.ShouldBe(raw1, "restored bytes must equal the original byte for byte");
|
||||
Log($"Test-value raw LEN {BitConverter.ToInt32(rawTest.AsSpan(4, 4))}; restored raw equals original: {rawRestored.SequenceEqual(raw1)}");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
||||
var (vFinal, rawFinal) = await SnapshotAsync(drv, Ref, replies, "final snapshot", ct);
|
||||
rawFinal.ShouldBe(raw1);
|
||||
vFinal.ShouldBe(v1);
|
||||
Log("RESULT: write, read-back, exact restore — PASS");
|
||||
}
|
||||
finally
|
||||
{
|
||||
diagnostics.ExchangeObserver = null;
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(string Value, byte[] Raw)> SnapshotAsync(
|
||||
AbCipDriver drv, string reference, List<byte[]> replies, string label, CancellationToken ct)
|
||||
{
|
||||
lock (replies) replies.Clear();
|
||||
var snap = (await drv.ReadAsync([reference], ct)).Single();
|
||||
byte[] reply;
|
||||
lock (replies)
|
||||
{
|
||||
// The first read of a fresh runtime is two wire reads (the create/existence read, then the read itself).
|
||||
replies.Count.ShouldBeInRange(1, 2, $"{label}: Read Tag Fragmented replies on the wire");
|
||||
replies.ShouldAllBe(r => r.SequenceEqual(replies[0]), $"{label}: the wire reads of one call disagree");
|
||||
reply = replies[^1];
|
||||
}
|
||||
reply[2].ShouldBe((byte)0, $"{label}: CIP general status");
|
||||
var raw = reply.AsSpan(4).ToArray(); // type (2) + handle (2) + data
|
||||
Log($"{label}: status 0x{snap.StatusCode:X8}, value '{snap.Value}', raw ({raw.Length} B) {Convert.ToHexString(raw)}");
|
||||
snap.StatusCode.ShouldBe(0u, label);
|
||||
return ((string)snap.Value!, raw);
|
||||
}
|
||||
|
||||
private static async Task WriteAsync(AbCipDriver drv, string reference, string value, string label, CancellationToken ct)
|
||||
{
|
||||
var result = (await drv.WriteAsync([new WriteRequest(reference, value)], ct)).Single();
|
||||
Log($"{label}: '{value}' (ASCII {Convert.ToHexString(Encoding.ASCII.GetBytes(value))}) -> status 0x{result.StatusCode:X8}");
|
||||
result.StatusCode.ShouldBe(0u, label);
|
||||
}
|
||||
|
||||
private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
@@ -147,4 +148,56 @@ public sealed class AbLegacyDriverProbeTests
|
||||
$"Expected 'timed out' or 'Connect failed' but got: {result.Message}");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- #660: Test Connect normalises the probe address exactly like the driver's probe loop ----
|
||||
|
||||
private static async Task<(DriverProbeResult Result, string? SeenName)> ProbeWithFakeAsync(string family, string? probeAddress)
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var probe = new AbLegacyDriverProbe(factory);
|
||||
var addr = probeAddress is null ? "" : $",\"probe\":{{\"probeAddress\":\"{probeAddress}\"}}";
|
||||
var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\",\"plcFamily\":\"{family}\"}}]{addr}}}";
|
||||
var result = await probe.ProbeAsync(json, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||
return (result, factory.Tags.Keys.SingleOrDefault());
|
||||
}
|
||||
finally { listener.Stop(); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Plc5", "I:017", "I:15")]
|
||||
[InlineData("Plc5", "O:010/07", "O:8/7")]
|
||||
[InlineData("LogixPccc", "I:017", "I:17")]
|
||||
[InlineData("Slc500", "I:017", "I:17")]
|
||||
[InlineData("MicroLogix", "I:017", "I:17")]
|
||||
[InlineData("Slc500", "N7:0", "N7:0")]
|
||||
[InlineData("Slc500", null, "S:0")]
|
||||
public async Task Probe_address_reaches_the_factory_family_normalised(string family, string? configured, string expected)
|
||||
{
|
||||
var (result, seen) = await ProbeWithFakeAsync(family, configured);
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
seen.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Normalised_default_probe_then_composes_with_the_libplctag_translation()
|
||||
{
|
||||
var (_, seen) = await ProbeWithFakeAsync("Plc5", "I:017");
|
||||
LibplctagLegacyName.Translate(seen!).ShouldBe("I1:15");
|
||||
var (_, status) = await ProbeWithFakeAsync("Slc500", null);
|
||||
LibplctagLegacyName.Translate(status!).ShouldBe("S2:0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unparseable_probe_address_is_a_clean_failure_not_an_exception()
|
||||
{
|
||||
var (result, seen) = await ProbeWithFakeAsync("Slc500", "not-an-address");
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message!.ShouldContain("not-an-address");
|
||||
seen.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,10 @@ public sealed class OpcUaClientAlarmTests
|
||||
[InlineData(true, false, false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, true, true, false, new[] { AlarmTransitionKind.Retrigger })]
|
||||
[InlineData(true, false, true, false, new AlarmTransitionKind[0])]
|
||||
// Gitea #657: inactive+unacked is ONE atomic InactiveUnacknowledged (never an Active flicker), except from
|
||||
// Active+unacked, where a plain Clear already lands there.
|
||||
[InlineData(false, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
|
||||
[InlineData(true, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
|
||||
public void AlarmTransitions_move_prior_to_target(
|
||||
bool priorActive, bool priorAcked, bool active, bool acked, AlarmTransitionKind[] expected)
|
||||
{
|
||||
@@ -242,13 +246,34 @@ public sealed class OpcUaClientAlarmTests
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
|
||||
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
|
||||
[InlineData(false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
|
||||
[InlineData(true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
|
||||
[InlineData(false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
|
||||
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
|
||||
{
|
||||
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #657 — on first sight the HOST may hold any state (it retains a still-deployed condition across a
|
||||
/// respawn), so the first-sight kinds must land on the upstream's state from EVERY host state. Pre-fix an
|
||||
/// inactive+acked target emitted nothing state-bearing, leaving a retained-Active host condition Active.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void First_sight_kinds_reach_the_target_from_every_host_state()
|
||||
{
|
||||
bool[] b = [false, true];
|
||||
foreach (var ha in b) foreach (var hk in b) foreach (var ta in b) foreach (var tk in b)
|
||||
{
|
||||
var kinds = OpcUaClientDriver.AlarmTransitions((false, true), (ta, tk), priorKnown: false);
|
||||
var s = (ha, hk);
|
||||
foreach (var k in kinds) s = Simulate(s, k);
|
||||
s.ShouldBe((ta, tk), $"host ({ha},{hk}) -> upstream ({ta},{tk}) via [{string.Join(",", kinds)}]");
|
||||
kinds.ShouldNotContain(AlarmTransitionKind.Unspecified);
|
||||
kinds.ShouldNotContain(AlarmTransitionKind.Raise, "a first sight must not keep a stale host ack");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
|
||||
|
||||
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
|
||||
@@ -256,6 +281,7 @@ public sealed class OpcUaClientAlarmTests
|
||||
{
|
||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||
AlarmTransitionKind.ActiveAcknowledged => (true, true),
|
||||
AlarmTransitionKind.InactiveUnacknowledged => (false, false),
|
||||
AlarmTransitionKind.Acknowledge => (s.Active, true),
|
||||
AlarmTransitionKind.Clear => (false, s.Acked),
|
||||
_ => s,
|
||||
@@ -414,6 +440,32 @@ public sealed class OpcUaClientAlarmTests
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #657 — first sight of an INACTIVE upstream condition: inactive+unacked is ONE flagged
|
||||
/// InactiveUnacknowledged (pre-fix a Retrigger then a Clear — clients saw it Active again); inactive+acked is
|
||||
/// a flagged Clear then a flagged Acknowledge (pre-fix an Unspecified the host wrote as "Activated").
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void First_sight_of_an_inactive_condition_is_restated_atomically()
|
||||
{
|
||||
using var unacked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657a");
|
||||
var seenA = Record(unacked);
|
||||
unacked.RouteAlarmEvent(ConditionFields(false, false), TripIndex, new FakeAlarmHandle());
|
||||
seenA.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([(AlarmTransitionKind.InactiveUnacknowledged, true)]);
|
||||
|
||||
using var acked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657b");
|
||||
var seenB = Record(acked);
|
||||
acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle());
|
||||
seenB.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([
|
||||
(AlarmTransitionKind.Clear, true),
|
||||
(AlarmTransitionKind.Acknowledge, true),
|
||||
]);
|
||||
|
||||
// Positive control: once known, the identical refresh copy emits nothing.
|
||||
acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle());
|
||||
seenB.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.</summary>
|
||||
[Fact]
|
||||
public void The_exact_upstream_severity_is_carried()
|
||||
|
||||
@@ -478,6 +478,58 @@ public class NativeAlarmProjectorTests
|
||||
legacy.ShouldBe([AlarmTransitionKind.Unspecified]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #657 — InactiveUnacknowledged projects inactive+unacked in ONE update from every prior, and writes rows
|
||||
/// only for what changed: "Activated"+"Cleared" from an acknowledged prior (an un-ack needs a new occurrence),
|
||||
/// "Cleared" from Active+unacked, nothing when the condition already shows inactive+unacked. The flag does not
|
||||
/// change it — the rows already describe only real changes.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear })]
|
||||
[InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear })]
|
||||
[InlineData(true, false, new[] { AlarmTransitionKind.Clear })]
|
||||
[InlineData(false, false, new AlarmTransitionKind[0])]
|
||||
public void InactiveUnacknowledged_projects_inactive_unacked_with_rows_only_for_what_changed(
|
||||
bool priorActive, bool priorAcked, AlarmTransitionKind[] expected)
|
||||
{
|
||||
foreach (var restated in new[] { false, true })
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
if (priorActive) sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
if (priorActive && priorAcked) sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
|
||||
if (!priorActive && !priorAcked) sut.Project("n1", Evt(AlarmTransitionKind.InactiveUnacknowledged));
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.InactiveUnacknowledged, restated: restated),
|
||||
out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
snap.Active.ShouldBeFalse();
|
||||
snap.Acknowledged.ShouldBeFalse();
|
||||
rows.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #657 — a flagged Acknowledge on an already-acknowledged condition (OpcUaClient's first-sight Clear +
|
||||
/// Acknowledge of an inactive+acked condition) writes no row; the unflagged control and a flagged Acknowledge
|
||||
/// of an UNACKED condition both write one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_restated_Acknowledge_on_an_acked_condition_writes_no_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("cold", Evt(AlarmTransitionKind.Acknowledge, restated: true), out IReadOnlyList<AlarmTransitionKind> cold);
|
||||
cold.ShouldBeEmpty();
|
||||
|
||||
sut.Project("cold", Evt(AlarmTransitionKind.Acknowledge), out IReadOnlyList<AlarmTransitionKind> unflagged);
|
||||
unflagged.ShouldBe([AlarmTransitionKind.Acknowledge]);
|
||||
|
||||
sut.Project("n2", Evt(AlarmTransitionKind.Raise));
|
||||
var snap = sut.Project("n2", Evt(AlarmTransitionKind.Acknowledge, restated: true), out IReadOnlyList<AlarmTransitionKind> real);
|
||||
real.ShouldBe([AlarmTransitionKind.Acknowledge]);
|
||||
snap.Acknowledged.ShouldBeTrue();
|
||||
snap.Active.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(
|
||||
AlarmTransitionKind kind,
|
||||
AlarmSeverity sev = AlarmSeverity.High,
|
||||
|
||||
+165
@@ -330,6 +330,171 @@ public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #657 item 1 — the respawned instance's ConditionRefresh re-reads an upstream condition that went active and
|
||||
/// cleared UNACKNOWLEDGED, which the host already shows inactive+unacked. Pre-fix the first sight went out as
|
||||
/// Retrigger then Clear: two condition updates (clients saw it Active again) and a duplicate "Activated" +
|
||||
/// "Cleared" pair. Now it is ONE InactiveUnacknowledged: one update, already inactive, and no row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUaClient_respawn_restating_an_inactive_unacked_alarm_does_not_flicker_or_write_rows()
|
||||
{
|
||||
var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
|
||||
var handle = new Handle();
|
||||
|
||||
using (var a = NewOpcUaDriver(host, "a"))
|
||||
{
|
||||
a.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish).State.Active.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
a.RouteAlarmEvent(ConditionFields(active: false, acked: false), SourceIndex, handle);
|
||||
var cleared = NextAlarmUpdate(publish);
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
cleared.State.Acknowledged.ShouldBeFalse();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
}
|
||||
|
||||
Respawn(host, publish, coordinator, db, blob);
|
||||
|
||||
using var b = NewOpcUaDriver(host, "b");
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: false), SourceIndex, handle); // ConditionRefresh copy
|
||||
var restated = NextAlarmUpdate(publish);
|
||||
restated.State.Active.ShouldBeFalse("no Active flicker may reach clients");
|
||||
restated.State.Acknowledged.ShouldBeFalse("upstream says unacknowledged");
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
|
||||
// Positive control: the upstream operator acknowledges — the NEXT update is that ack (no leftover second
|
||||
// update of the re-statement), and it writes its row.
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
|
||||
var acked = NextAlarmUpdate(publish);
|
||||
acked.State.Active.ShouldBeFalse();
|
||||
acked.State.Acknowledged.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #657 item 1, retained-Active variant — the host retained the condition Active+unacked across the respawn and
|
||||
/// upstream has since cleared it (still unacked). That is ONE real "Cleared" — one update straight to
|
||||
/// inactive+unacked (pre-fix the first update re-stated it Active, then a second cleared it) and no
|
||||
/// "Activated" row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUaClient_respawn_clearing_a_retained_active_unacked_alarm_is_one_update_and_one_Cleared_row()
|
||||
{
|
||||
var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
|
||||
var handle = new Handle();
|
||||
|
||||
using (var a = NewOpcUaDriver(host, "a"))
|
||||
{
|
||||
a.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish).State.Active.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
}
|
||||
|
||||
Respawn(host, publish, coordinator, db, blob);
|
||||
|
||||
using var b = NewOpcUaDriver(host, "b");
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: false), SourceIndex, handle);
|
||||
var cleared = NextAlarmUpdate(publish);
|
||||
cleared.State.Active.ShouldBeFalse("the first update must already be the clear");
|
||||
cleared.State.Acknowledged.ShouldBeFalse();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
publish.ReceiveWhile<object>(Quiet, m => m).OfType<OpcUaPublishActor.AlarmStateUpdate>()
|
||||
.ShouldBeEmpty("one update, not a re-statement followed by the clear");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #657 item 2 (#658) — upstream cleared AND acknowledged a condition while the driver was down, and the host
|
||||
/// retained it Active+acked across the respawn. Pre-fix the first sight was a flagged Unspecified: the host
|
||||
/// kept it ACTIVE (nothing cleared it) and wrote nothing. Now it is a flagged Clear + Acknowledge: the
|
||||
/// condition goes inactive+acked with exactly one real "Cleared" row (the ack changed nothing — no row).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUaClient_respawn_seeing_inactive_acked_clears_a_retained_active_condition()
|
||||
{
|
||||
var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
|
||||
var handle = new Handle();
|
||||
|
||||
using (var a = NewOpcUaDriver(host, "a"))
|
||||
{
|
||||
a.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish).State.Active.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
|
||||
}
|
||||
|
||||
Respawn(host, publish, coordinator, db, blob);
|
||||
|
||||
using var b = NewOpcUaDriver(host, "b");
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
|
||||
var cleared = NextAlarmUpdate(publish);
|
||||
cleared.State.Active.ShouldBeFalse("a retained Active condition must be cleared");
|
||||
cleared.State.Acknowledged.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
var reAcked = NextAlarmUpdate(publish);
|
||||
reAcked.State.Active.ShouldBeFalse();
|
||||
reAcked.State.Acknowledged.ShouldBeTrue();
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #657 item 2 (#658) — a host that already shows the condition inactive+acked re-reads it on a respawn. Pre-fix
|
||||
/// the Unspecified first sight wrote an "Activated" row for an inactive alarm. Now: no row, the node stays
|
||||
/// inactive+acked; a later genuine raise still writes its row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUaClient_respawn_restating_an_inactive_acked_alarm_writes_no_Activated_row()
|
||||
{
|
||||
var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
|
||||
var handle = new Handle();
|
||||
|
||||
using (var a = NewOpcUaDriver(host, "a"))
|
||||
{
|
||||
a.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish);
|
||||
a.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish).State.Active.ShouldBeFalse();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
}
|
||||
|
||||
Respawn(host, publish, coordinator, db, blob);
|
||||
|
||||
using var b = NewOpcUaDriver(host, "b");
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
|
||||
var restated = NextAlarmUpdate(publish);
|
||||
restated.State.Active.ShouldBeFalse();
|
||||
restated.State.Acknowledged.ShouldBeTrue();
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
|
||||
// Positive control: a genuine re-activation MUST write its row.
|
||||
b.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
|
||||
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(u => u.State.Active, Wait);
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
}
|
||||
|
||||
private (IActorRef Host, TestProbe Publish, TestProbe Coordinator, TestProbe Alerts,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> Db, byte[] Blob) StartOpcUaHost()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var blob = BuildOpcUaArtifact();
|
||||
var alerts = SubscribeAlerts();
|
||||
var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev));
|
||||
return (host, publish, coordinator, alerts, db, blob);
|
||||
}
|
||||
|
||||
/// <summary>A config-changing deploy: the host keeps the still-deployed condition's state, the driver respawns.</summary>
|
||||
private static void Respawn(
|
||||
IActorRef host, TestProbe publish, TestProbe coordinator, IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob)
|
||||
{
|
||||
var revB = RevisionHash.Parse(new string('b', 64));
|
||||
host.Tell(new DispatchDeployment(Seed(db, blob, revB), revB, CorrelationId.NewId()));
|
||||
coordinator.FishForMessage<ApplyAck>(x => x.Outcome == ApplyAckOutcome.Applied, Wait);
|
||||
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, Wait);
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> SourceIndex = new() { [UpstreamSource] = OpcRawPath };
|
||||
|
||||
private static OpcUaClientDriver NewOpcUaDriver(IActorRef host, string suffix)
|
||||
|
||||
Reference in New Issue
Block a user