Merge commit 'd1f24cf6' into remed/lane-K
This commit is contained in:
@@ -151,7 +151,9 @@ Two things to know before relying on any of this:
|
||||
array node is writable only when the driver can write the array (`DriverArrayWriteSupport`: Modbus bounded
|
||||
by `maxRegistersPerWrite`/coil cap, S7 non-Bool/non-wide types, OpcUaClient, Galaxy signed types **with a
|
||||
declared `arrayLength`** — an undeclared length stays read-only because a short whole-array write would
|
||||
resize the Galaxy attribute); `arrayLength >= 1` is required, and the node's `Writable && (!IsArray ||
|
||||
resize the Galaxy attribute; AbCip atomic non-BOOL types, AbLegacy Int/Long/Float on the matching data file
|
||||
(no `/bit`/`.sub`/indirect/`S:`/`I:`/`O:`, not native PLC-5, ≤ 256), TwinCAT every codec type but a BOOL
|
||||
bit-in-word path — #737/#738/#739; those three CONVERT elements, so they never key on an exact node type); `arrayLength >= 1` is required, and the node's `Writable && (!IsArray ||
|
||||
ArrayWritable)` is projected onto the raw node and every UNS mirror.
|
||||
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
|
||||
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
|
||||
|
||||
+16
-5
@@ -327,12 +327,23 @@ carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS
|
||||
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
|
||||
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides (shape included) |
|
||||
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) — **only with a declared `arrayLength`**: an undeclared length materialises an unbounded node, and a short whole-array write would *resize* the Galaxy attribute (mxaccessgw contract), so such a node stays read-only |
|
||||
| AB CIP, AB Legacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
|
||||
| **AB CIP** | Yes (#737) | `SInt`/`USInt`/`Int`/`UInt`/`DInt`/`UDInt`/`Real`/`Dt`/`LInt`/`ULInt`/`LReal` (`AbCipArrayCodec`, both transports). Not `Bool` (bit-packed DWORDs), `String` or `Structure`, nor a `safetyTag` |
|
||||
| **AB Legacy** | Yes (#738) | `Int`/`AnalogInt` on an N/B/A file, `Long` on an L file, `Float` on an F file — the encode width is the data file's, so a type that does not match its file stays read-only — on a plain `<file><number>:<word>` address, ≤ 256 elements. Not Bit / String / Timer / Counter / Control arrays, `/bit` or `.sub` addresses, indirect addresses, the `S:`/`I:`/`O:` files, nor any PLC-5 on the native transport |
|
||||
| **TwinCAT** | Yes (#739) | Every element type `TwinCATArrayCodec` lays out (numeric, `Bool`, `String`/`WString`, temporal); multi-dimensional PLC arrays are written row-major from the flat value. Not a BOOL bit-in-word path (`MAIN.Word.3`) |
|
||||
| FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
|
||||
|
||||
Modbus, S7 and Galaxy additionally need a **declared length** — `isArray: true` *and* an integer
|
||||
`arrayLength >= 1`. The Modbus and S7 tag factories read anything else as a **scalar** tag (whose
|
||||
scalar encoder cannot take an array), even though the composer still materialises an array node;
|
||||
that node stays read-only.
|
||||
Every "Yes" driver but OpcUaClient additionally needs a **declared length** — `isArray: true` *and* an
|
||||
integer `arrayLength >= 1`. The tag factories read anything else as a **scalar** tag (whose scalar
|
||||
encoder cannot take an array), even though the composer still materialises an array node; that node
|
||||
stays read-only.
|
||||
|
||||
**Elements convert; they are not exact-matched.** ScadaBridge writes CLR `int[]` / `List<int>` to Int16
|
||||
nodes: the node manager converts a numeric array to the node's element type, and the AB CIP, AB Legacy
|
||||
and TwinCAT encoders convert each element again to the controller's type (an element that does not fit
|
||||
is `BadOutOfRange`). Those rows therefore do not key on an exact node `DataType`; they leave out only
|
||||
node types whose elements never convert — a `DateTime` or `String` node over a numeric element, and a
|
||||
`Boolean` node on AB Legacy. An array tag deployed read-only before its driver gained an encoder turns
|
||||
writable on the next deploy (a surgical `ArrayWritable` update, no rebuild).
|
||||
|
||||
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
|
||||
test project (`*ArrayWriteSupportParityTests`), so a driver gaining or losing an array encoder
|
||||
|
||||
+16
-5
@@ -252,7 +252,7 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
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
|
||||
writes (#737; see [1-D array support](#1-d-array-support)), 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, UDT-member and STRING native writes are proven on a CompactLogix
|
||||
@@ -262,7 +262,7 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
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. 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 a STRING is a single Write Tag — and a whole DINT[32] array write, #737, is 128 B). 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
|
||||
@@ -361,7 +361,17 @@ integration fixture is down during normal dev (Docker host fixture currently off
|
||||
|
||||
**Live-verify** — integration-fixture-gated (requires `ab_server` Docker fixture on `10.100.0.35:44818`).
|
||||
|
||||
**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization. Safety-partition array tags (`SafetyTag: true`) are excluded (GuardLogix write constraints extend to array writes).
|
||||
**Array writes (#737, review 07 U10)** — a whole-array write of exactly `arrayLength` elements, encoded by
|
||||
`AbCipArrayCodec` little-endian at the element width on both transports. Element values **convert**
|
||||
(ScadaBridge writes `int[]` to INT arrays); a value that does not fit is `BadOutOfRange`, one that cannot convert
|
||||
`BadTypeMismatch`, a different length `BadOutOfRange`, all before anything is sent. The controller buffer must be
|
||||
width × count (else `BadTypeMismatch`, checked before sending). Writable element types: `SInt`/`USInt`/`Int`/`UInt`/
|
||||
`DInt`/`UDInt`/`Real`/`Dt`/`LInt`/`ULInt`/`LReal`. **BOOL arrays** (bit-packed DWORDs), **STRING** and **structure**
|
||||
arrays are refused `BadNotSupported`, so their nodes stay read-only (`DriverArrayWriteSupport`); so does a
|
||||
safety-partition array tag (`safetyTag: true`, `BadNotWritable`). An array deployed read-only before #737 turns
|
||||
writable on the next deploy (a surgical `ArrayWritable` update). Proven on `ab_server` only until the hardware gate runs.
|
||||
|
||||
**Deferrals** — multi-dimensional arrays, per-element historization, whole-array writes on hardware.
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
and the UI authoring flow.
|
||||
@@ -454,8 +464,9 @@ when it is browse-committed in `/raw`, and that tag writes like any other atomic
|
||||
`writable` key. The node is writable only once the operator sets the raw tag to `ReadWrite` (the
|
||||
node-manager gate); an authored `"writable": false` still refuses at the driver (`BadNotWritable`). The
|
||||
browse's `SecurityClass` (members inherit the parent symbol's read-only bit) is not consulted by the
|
||||
commit. An element of a member array is authored by hand as `Seg.Points[2]`; a whole member array is
|
||||
not writable (AbCip has no array encoder, `DriverArrayWriteSupport`).
|
||||
commit. An element of a member array is authored by hand as `Seg.Points[2]`; a whole member array of an
|
||||
atomic type is writable as an array tag (`isArray` + `arrayLength`, #737), a BOOL / STRING / structure member
|
||||
array is not (`DriverArrayWriteSupport`).
|
||||
- **The request names the member.** One CIP symbolic segment per member (plus an element segment), the
|
||||
member's own CIP type, the member's own bytes — never the parent UDT, never a hidden host. Pinned at the
|
||||
request-byte level by `NativeAbCipTagRuntimeTests.A_member_path_write_names_the_member_and_sends_only_its_own_bytes`
|
||||
|
||||
@@ -304,8 +304,18 @@ N values of the declared `AbLegacyDataType`. Maximum element count is 256 (PCCC
|
||||
limit).
|
||||
|
||||
This closes the previously-noted limitation ("single-element addressing today") for the
|
||||
array read path. The opt-in native transport also writes multi-element arrays (chunked); on libplctag
|
||||
array *writes* remain a follow-up.
|
||||
array read path.
|
||||
|
||||
**Array writes (#738, review 07 U10 / r0 I3)** — on both transports, a whole-array write encodes exactly
|
||||
`arrayLength` elements at the **data file's** element width (N / B / A words, L longs, F floats). Element values
|
||||
**convert** (ScadaBridge writes `int[]` to Int16 nodes); a length mismatch or an element that overflows is
|
||||
`BadOutOfRange`, a non-numeric value `BadTypeMismatch`, before any runtime exists. Writable: `Int` / `AnalogInt` on
|
||||
an N / B / A file, `Long` on an L file, `Float` on an F file, on a plain `<file><number>:<word>` address of at most
|
||||
256 elements. These nodes stay **read-only** (`DriverArrayWriteSupport`): Bit arrays (bit-packed words), String /
|
||||
Timer / Counter / Control arrays (rejected at `FromTagConfig`), `/bit` and `.sub` addresses, a type that does not
|
||||
match its file (`BadTypeMismatch`), any PLC-5 on the native transport (writes refused before the wire), the
|
||||
`S:` / `I:` / `O:` files (libplctag refuses their translated names), and — deliberately — indirect addresses, whose
|
||||
element moves with the index word. An array deployed read-only before #738 turns writable on the next deploy.
|
||||
|
||||
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` covers
|
||||
the multi-element read path via the fake tag runtime. The AB Legacy integration fixture is
|
||||
@@ -313,8 +323,10 @@ down during normal dev.
|
||||
|
||||
**Live-verify** — integration-fixture-gated (requires the AB Legacy Docker fixture).
|
||||
|
||||
**Deferrals** — array *writes* on libplctag (the opt-in native transport writes them), multi-dimensional arrays, per-element historization.
|
||||
`arrayLength > 256` is rejected at init with an explicit error (PCCC payload cap).
|
||||
**Deferrals** — multi-dimensional arrays, per-element historization, array writes on hardware
|
||||
(`AbLegacyArrayWriteIntegrationTests` is green on `ab_server` and ready for a real controller via
|
||||
`AB_LEGACY_ARRAY_WRITE_BASE`).
|
||||
`arrayLength > 256` is clamped to 256 by the tag factory (PCCC payload cap), so such a node stays read-only.
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
and the UI authoring flow.
|
||||
|
||||
+23
-6
@@ -117,7 +117,7 @@ driver are pure organisational grouping in the RawPath.
|
||||
"HostId": "", // Host Application identity — spBv1.0/STATE/{HostId}
|
||||
"ActAsPrimaryHost": false, // claim the primary host id (Primary-gated; see below)
|
||||
"RequestRebirthOnGap": true, // a seq gap asks the edge node to re-announce (NCMD)
|
||||
"BirthObservationWindowSeconds": 15 // how long discovery collects births before calling it stable
|
||||
"BirthObservationWindowSeconds": 15 // how long the browse picker collects births before calling them stable
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -128,7 +128,7 @@ driver are pure organisational grouping in the RawPath.
|
||||
| `HostId` | `""` | Sparkplug Host Application identity — the `spBv1.0/STATE/{HostId}` topic. Optional for observation; **required** to claim. Set without `ActAsPrimaryHost`, the driver only observes |
|
||||
| `ActAsPrimaryHost` | `false` | Claim the Sparkplug Primary Host Application identity. **Primary-gated** — only the node holding the redundancy Primary role claims it. See [Primary host](#primary-host-actasprimaryhost) |
|
||||
| `RequestRebirthOnGap` | `true` | On a detected sequence-number gap, publish a rebirth-request NCMD instead of running on stale metric state |
|
||||
| `BirthObservationWindowSeconds` | `15` | Discovery's birth-collection window. Must be ≥ 1 |
|
||||
| `BirthObservationWindowSeconds` | `15` | The Sparkplug browse picker's birth-collection window, clamped to 1–120 s (R06:M-4). The open budget grows by it; the open lingers while NBIRTH/DBIRTH traffic is still arriving (returning after a 1 s quiet spell — an idle plant costs 1 s); and an operator **Request rebirth** waits, bounded by it and capped at 15 s (below the AdminUI's 20 s per-call timeout), for the births it asked for to arrive and go quiet for 500 ms, so the tree re-rendered afterwards carries them. A caller deadline during that wait still reports the publish. The runtime driver does not read it (its `DiscoverAsync` has no production caller — `SupportsOnlineDiscovery` is false) |
|
||||
|
||||
`MqttDriverForm` authors all five in Sparkplug mode. The sub-object is **merged, never replaced** (a
|
||||
key a newer driver adds inside it survives an older AdminUI), and in `Plain` mode it is not touched at
|
||||
@@ -249,7 +249,10 @@ One subscription (`spBv1.0/{GroupId}/#`) feeds a per-edge-node state machine
|
||||
[Structured metrics](#structured-metrics-template--dataset--propertyset).
|
||||
- **Unsupported datatypes are skipped with a warning, never guessed**: `PropertySetList`, `Unknown`,
|
||||
extension values, and any `Template`/`DataSet` that cannot be flattened honestly. `Int8`→`Int16` and
|
||||
`UInt8`→`UInt16` widen; `*Array` variants map to the element type with the array bit set.
|
||||
`UInt8`→`UInt16` widen; `*Array` variants map to the element type with the array bit set — but
|
||||
**array metrics are not read in v1** (warned once, never fed), so the browse picker offers them, and any
|
||||
metric whose type is unsupported, **without an address**: the browse-commit refuses them rather than
|
||||
committing a tag that waits for initial data forever (R06:M-5).
|
||||
- **Ingest state is reset at every session/authoring seam** — a reconnect or a `ReinitializeAsync`
|
||||
must not carry a previous session's alias table into a new one.
|
||||
|
||||
@@ -408,9 +411,18 @@ value. `MqttConnection` therefore raises `MqttConnectRejectedException`:
|
||||
code. (`ServerMoved` is permanent, `UseAnotherServer` is temporary — per the MQTT 5 spec.)
|
||||
|
||||
A reconnect that completes without re-subscribing would be a healthy-looking connection that receives
|
||||
nothing forever, so `OnReconnectedAsync` **throws** when zero filters are granted, tearing the session
|
||||
down rather than swallowing it. A SUBACK failure degrades just the affected RawPaths to
|
||||
`BadCommunicationError`.
|
||||
nothing forever, so `OnReconnectedAsync` **throws** when none of the previously live filters is granted
|
||||
(or the SUBSCRIBE gets no verdict), tearing the session down rather than swallowing it. A SUBACK failure
|
||||
degrades just the affected RawPaths to `BadCommunicationError`.
|
||||
|
||||
**A failed SUBSCRIBE is retried, never forgotten (R06:M-1).** Every topic whose SUBSCRIBE was refused or got
|
||||
no verdict joins a *pending* set that the next `SubscribeAsync` and every reconnect re-request; a grant moves
|
||||
it to the live set, and a redeploy that drops its tags drops it. Pending topics do not count toward the
|
||||
reconnect's total-failure verdict, so one ACL-denied topic cannot reconnect-loop the driver. A plain-mode
|
||||
`SubscribeAsync` whose SUBSCRIBE got **no verdict at all** (transport throw, SUBSCRIBE deadline) degrades its
|
||||
refs and then **throws**, so `DriverInstanceActor` holds no handle and its health-tick reconcile re-subscribes;
|
||||
a SUBACK *refusal* still returns a handle (the broker answered). A subscribe issued with no transport attached
|
||||
records its topics as pending, never as established (M-11).
|
||||
|
||||
There are **two** kinds of reconnect and only one of them fires that callback. A drop the connection's
|
||||
own supervisor recovers keeps the same `MqttConnection`, so `Reconnected` → `OnReconnectedAsync`
|
||||
@@ -479,6 +491,11 @@ with their real type; a birth type the encoder cannot represent (any `*Array`, D
|
||||
matters and is deliberately not configurable: a retained command would be replayed by the broker to the device on *every* future
|
||||
subscribe — including the device's own reconnect — so one stale write would be re-applied forever.
|
||||
|
||||
**A batch publishes concurrently, one topic at a time (R06:M-8).** Writes to *different* topics in one
|
||||
`WriteAsync` go out together, at most 8 in flight, so a stalled broker costs a batch about one publish deadline
|
||||
rather than one per request; writes to the *same* topic stay sequential in request order, so the last one wins.
|
||||
A PUBACKed (QoS 1) publish advances `LastSuccessfulRead` like an inbound message; a QoS 0 Sparkplug command does not, because its completion only means the bytes left the local socket.
|
||||
|
||||
**Plain QoS 1 is at-least-once.** Know the consequences before pointing a Plain `writeTopic` at a non-idempotent
|
||||
device topic:
|
||||
|
||||
|
||||
@@ -166,6 +166,24 @@ emits a `SqlQueryPlan` per distinct group key. Grouping is deterministic (groups
|
||||
order of their first member, members keep their input order, parameters keep first-appearance order),
|
||||
so an unchanged configuration produces byte-identical SQL.
|
||||
|
||||
**Every key-value and where-pair query is row-capped (R06:S-1).** A key-value plan reads at most twice as
|
||||
many rows as it has distinct keys and asks for one more (`TOP (2n+1)` / `LIMIT 2n+1` / `FETCH FIRST 2n+1 ROWS
|
||||
ONLY`); a where-pair wide-row plan asks for two. The reader stops there even if a provider ignores the limit.
|
||||
|
||||
- **A duplicated key is refused per key.** When a key comes back more than once, only the tags on THAT key
|
||||
publish `BadConfigurationError`; every other key in the group reads normally, whatever else is present or
|
||||
absent. (Before this, a duplicate was last-row-wins Good.)
|
||||
- **A gross excess fails the group.** Receiving the extra row past the cap means a history/events table or a
|
||||
non-unique where column, which cannot be attributed because the rest is unread, so every member of the group
|
||||
publishes `BadConfigurationError`. For a where-pair any second row is this case.
|
||||
- Each case logs a per-plan rate-limited warning naming the table and the selector (or an example key).
|
||||
`topByTimestamp` keeps its single-row limit.
|
||||
- **Upgrade note:** a deployment whose key-value source carries stray duplicate rows, or whose where column is
|
||||
not unique, will see those tags go Bad where they used to read an arbitrary row at Good.
|
||||
|
||||
Every such refusal, like the other refusals (absent row, NULL cell, uncoercible cell), is the database
|
||||
answering: it advances `LastSuccessfulRead` and restores `Healthy` after a comms fault.
|
||||
|
||||
⚠️ **Group keys are case-sensitive on identifiers.** Two tags authored `dbo.T` and `DBO.T` plan as
|
||||
**two** groups — one extra round-trip, not a correctness problem, but worth knowing if a group count
|
||||
looks higher than expected.
|
||||
|
||||
@@ -362,6 +362,20 @@ element-typed CLR array (`bool[]`, `sbyte[]`, `byte[]`, `short[]`, `ushort[]`, `
|
||||
down (found live, below). A bit outside the parent's width, or a bit of a non-integer parent,
|
||||
is refused with `BadTypeMismatch`.
|
||||
|
||||
### Array writes (#739, review 07 U9)
|
||||
|
||||
An array node is **writable** (`DriverArrayWriteSupport`) for every element type the codec lays out —
|
||||
numeric, `Bool` (one byte per element, as TwinCAT stores a BOOL array), `STRING`/`WSTRING` and the temporal
|
||||
types. The write takes exactly `arrayLength` elements and **converts** each one (`ConvertForWrite`; ScadaBridge
|
||||
writes `int[]` to an Int16 node); a length mismatch or an out-of-range element is `BadOutOfRange` and a scalar
|
||||
value `BadTypeMismatch`, before any wire traffic. `WriteArrayAsync` then plans the layout from the resolved PLC
|
||||
array with the same `CheckArray` the read uses and encodes with `TwinCATArrayCodec.Encode` (the inverse of the
|
||||
decode); a shorter `arrayLength` writes the leading sub-range, and a multi-dimensional PLC array is written
|
||||
row-major from the flat 1-D value. A BOOL **bit-in-word** path (`MAIN.Word.3`) stays read-only as an array: it is a
|
||||
scalar bit write. Under a `DateTime` node only `DATE`/`DT`/`WSTRING` elements are writable, and under a `String`
|
||||
node only `STRING`/`WSTRING` (other conversions would depend on the text). An array deployed read-only before #739
|
||||
turns writable on the next deploy. Unit- and XAR-gate-tested; real-hardware proof is pending.
|
||||
|
||||
### Notification size cap
|
||||
|
||||
ADS documents no notification size limit, and a live TwinCAT 2.11 target registered and
|
||||
|
||||
@@ -149,7 +149,11 @@ Beyond per-tag addressing, `ModbusDriverOptions` exposes (#139–#143):
|
||||
- `UseFC15ForSingleCoilWrites` — force FC15 (write multiple coils
|
||||
qty=1) for single-coil writes. Safety/audit PLCs may require this.
|
||||
- `UseFC16ForSingleRegisterWrites` — same for FC16 vs FC06.
|
||||
- `DisableFC23` — kill switch for FC23 (currently unused; reserved).
|
||||
- `UseFC23ForMultiRegisterWrites` (default `false`) — opt-in: a 2–121-register
|
||||
holding write is sent as one FC23 (write + read-back). A unit answering
|
||||
exception 01 moves to FC16 until the next re-init. Without the key the
|
||||
wire stays FC16 (#741).
|
||||
- `DisableFC23` — hard kill switch: never send FC23, even when opted in.
|
||||
|
||||
### Subscribe (#141)
|
||||
- Per-tag `Deadband` — suppress sub-threshold publishes on numeric tags.
|
||||
@@ -165,9 +169,13 @@ Beyond per-tag addressing, `ModbusDriverOptions` exposes (#139–#143):
|
||||
this tag in isolation regardless of `MaxReadGap`).
|
||||
|
||||
### Block-read coalescing (#143)
|
||||
- `MaxReadGap` (default 0 = off) — gap budget the planner is willing to
|
||||
bridge between adjacent register tags. With `MaxReadGap=10`, three tags
|
||||
at HR 100/102/110 collapse into one FC03 of quantity 11.
|
||||
- `MaxReadGap` — gap budget the planner is willing to bridge between
|
||||
adjacent tags of one (unit, region). With `MaxReadGap=10`, three tags at
|
||||
HR 100/102/110 collapse into one FC03 of quantity 11. An explicit `0` =
|
||||
coalescing off (its historical meaning); the default `8` applies only when
|
||||
the key is absent (#741). Coils / DiscreteInputs (gap in bits), `String`
|
||||
scalars and same-word `BitInRegister` tags coalesce too.
|
||||
- `DisableReadCoalescing` — turn coalescing off regardless of the gap.
|
||||
|
||||
### Coalescing auto-recovery (#148 / #150 / #151 / #152)
|
||||
- A coalesced read that fails with a Modbus exception (write-only or
|
||||
@@ -231,3 +239,43 @@ the current Kepware "Modbus Ethernet Driver Help" PDF and Ignition's
|
||||
"Modbus Addressing" user-manual page — if a critical tool's mnemonics
|
||||
have shifted, add aliases in `ModbusAddressParser.TryParseType` rather
|
||||
than asking users to rewrite spreadsheets.
|
||||
|
||||
|
||||
## Address strings, families and region/type rules (#741)
|
||||
|
||||
- **One authority: `ModbusTagAddressResolution` (Driver.Modbus.Contracts).** The
|
||||
driver's tag reader, the deploy gate (`Inspect`), the AdminUI editor model
|
||||
(load / save / `Validate` / the hint text) and the CSV import/export all ask
|
||||
it which part of a blob is the address; none restates the rule. Its
|
||||
agreement is pinned by one table-driven test that runs every persisted shape
|
||||
through every consumer (`ModbusAddressAuthorityAgreementTests`).
|
||||
- **Whenever a tag carries both `region` and `address`, those keys are its
|
||||
address** — a classic tag, an editor-pinned tag, and a pre-#741 row that
|
||||
carried an `addressString` beside its keys (the old driver never read the
|
||||
string) all load from the keys, exactly as before, and a family change never
|
||||
remaps them. Only a **string-only** blob (an `addressString` with NO
|
||||
`region`/`address` — CSV / API authoring) is resolved through
|
||||
`ModbusAddressParser` with the **driver's** `Family` / `MelsecSubFamily`; a
|
||||
family that cannot parse it refuses the tag (BadNodeIdUnknown). It is never
|
||||
served as HR[0].
|
||||
- `"addressResolved": true` is editor display / CSV state only: it never makes
|
||||
keys authoritative, and a marker on a blob WITHOUT keys is ignored (the blob
|
||||
is string-only) and dropped on the next editor save.
|
||||
- The AdminUI never writes default keys it did not resolve: the editor model
|
||||
stamps `region`/`address`/type only when the loaded blob had the keys, or when
|
||||
a field is set (hand edit, CSV address cell, Resolve). A string-only,
|
||||
non-string-`addressString` or address-less blob keeps its keys unwritten, and
|
||||
its CSV export leaves the address cells blank.
|
||||
- The editor's "Resolve with driver family" persists the resolved
|
||||
`region`/`address`/type fields plus `"addressResolved": true` (the pinned
|
||||
state). Editing a resolved field by hand in the editor drops the string and
|
||||
the marker. A typed CSV address cell keeps them only while the string still
|
||||
describes the cells under some family (so an unchanged export → import of a
|
||||
pinned row is lossless); a contradicting cell drops them, and the import
|
||||
review grid reports that column as an override.
|
||||
- Coils / DiscreteInputs carry only Bool. A bit-region tag persisted with a
|
||||
register `dataType` (the pre-#741 editor saved `Int16`) is coerced to Bool,
|
||||
never refused. A register region with `Bool` is refused (use
|
||||
`BitInRegister`).
|
||||
- Exception 01/02/03 are device refusals: per-tag Bad, one Warning per tag,
|
||||
no driver Degraded; reads and writes share one comms-fault health slot.
|
||||
|
||||
@@ -35,12 +35,39 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// DateTime — and only with a declared <c>arrayLength</c>: an undeclared length materialises an
|
||||
/// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the
|
||||
/// mxaccessgw <c>gateway.md</c> contract), which is never what a client meant.</item>
|
||||
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
|
||||
/// write encoders only; array nodes stay read-only.</item>
|
||||
/// <item><b>AbCip</b> — <c>dataType</c> SInt / USInt / Int / UInt / DInt / UDInt / Real / Dt / LInt / ULInt /
|
||||
/// LReal (<c>AbCipArrayCodec</c>, both transports), and not a <c>safetyTag</c>. BOOL arrays (bit-packed
|
||||
/// DWORDs), STRING and structure arrays have no array encoder.</item>
|
||||
/// <item><b>AbLegacy</b> — <c>dataType</c> Int / AnalogInt on an N / B / A file, Long on an L file, Float
|
||||
/// on an F file (the encode width is the DATA FILE's; a type that does not match its file is refused),
|
||||
/// on a plain <c><file><number>:<word></c> address of at most 256 elements (the PCCC
|
||||
/// file ceiling the factory clamps to). Refused and so left out: Bit arrays (bit-packed words), String /
|
||||
/// Timer / Counter / Control arrays (structured; the factory rejects them), <c>/bit</c> and <c>.sub</c>
|
||||
/// addresses, a PLC-5 device on the native transport (every PLC-5 write is refused before the wire), and
|
||||
/// the <c>S:</c> / <c>I:</c> / <c>O:</c> files (libplctag refuses their translated names). Indirect
|
||||
/// addresses (<c>N7:[N7:0]</c>) are kept read-only deliberately: the element moves with the index word
|
||||
/// and the index read is not atomic with the write.</item>
|
||||
/// <item><b>TwinCAT</b> — every element type <c>TwinCATArrayCodec</c> lays out (all but Structure, which
|
||||
/// the factory refuses), STRING / WSTRING included, except a BOOL bit-in-word path
|
||||
/// (<c>MAIN.Word.3</c>), which is a scalar bit write. A multi-dimensional PLC array is written row-major
|
||||
/// from the flat 1-D value. The PLC-side shape (element width, declared length ≤ the PLC's) is checked
|
||||
/// against the resolved symbol at write time, which no compose-time table can know.</item>
|
||||
/// <item>Everything else (FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar write encoders only; array
|
||||
/// nodes stay read-only.</item>
|
||||
/// </list>
|
||||
/// <para>Modbus, S7 and Galaxy require a declared length — <c>isArray: true</c> AND an integer
|
||||
/// <c>arrayLength >= 1</c>. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose
|
||||
/// <para>Modbus, S7, Galaxy, AbCip, AbLegacy and TwinCAT require a declared length — <c>isArray: true</c> AND
|
||||
/// an integer <c>arrayLength >= 1</c>. Their tag factories treat anything else as a SCALAR tag (whose
|
||||
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</para>
|
||||
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
|
||||
/// <c>int[]</c> / <c>List<int></c> to Int16 nodes; the node manager converts a numeric array to the
|
||||
/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then <c>Convert</c> each element to the
|
||||
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>). So those rows do not key on an
|
||||
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
|
||||
/// <c>DateTime</c> or <c>String</c> node over a numeric element (AbCip, AbLegacy, TwinCAT — TwinCAT keeps
|
||||
/// DATE / DT and WSTRING under a DateTime node, and STRING / WSTRING under a String node; a DateTime's text
|
||||
/// form is culture-dependent, so a STRING under a DateTime node is left out), and a <c>Boolean</c> node on
|
||||
/// AbLegacy, whose encoder refuses bool elements. An unknown DataType name (served as BaseDataType) hands the
|
||||
/// driver whatever the client sent and is not refused.</para>
|
||||
/// <para>Multi-dimensional arrays (<c>ValueRank > 1</c>) are out of scope altogether — every array node
|
||||
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
|
||||
/// </summary>
|
||||
@@ -72,6 +99,34 @@ public static class DriverArrayWriteSupport
|
||||
"Boolean", "Int32", "Int64", "Float", "Double", "String", "DateTime",
|
||||
};
|
||||
|
||||
// AbCipArrayCodec.ElementWidth: the atomic element types with a fixed wire width.
|
||||
private static readonly HashSet<string> AbCipArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "Real", "Dt", "LInt", "ULInt", "LReal",
|
||||
};
|
||||
|
||||
/// <summary>The PCCC data-file element count ceiling (<c>AbLegacyArray.MaxElements</c>): the AbLegacy tag
|
||||
/// factory CLAMPS a longer <c>arrayLength</c> to it, so a node of the authored (longer) length never matches.</summary>
|
||||
public const int AbLegacyMaxArrayElements = 256;
|
||||
|
||||
// AbLegacyTagDefinitionFactory.ArrayFileElementType for the files the table admits, keyed by file letter, to the
|
||||
// authored element types that match that file's width (ArrayFileMismatch). I / O / S are deliberately absent:
|
||||
// libplctag refuses a write to their translated names, and the table does not widen output-image / status-file
|
||||
// writes on the native transport alone.
|
||||
private static readonly Dictionary<string, string[]> AbLegacyFileElementTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["N"] = ["Int", "AnalogInt"], ["B"] = ["Int", "AnalogInt"], ["A"] = ["Int", "AnalogInt"],
|
||||
["L"] = ["Long"],
|
||||
["F"] = ["Float"],
|
||||
};
|
||||
|
||||
// TwinCATArrayCodec.Encode: every TwinCATDataType but Structure (which the factory refuses outright).
|
||||
private static readonly HashSet<string> TwinCATArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Bool", "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "LInt", "ULInt", "Real", "LReal",
|
||||
"String", "WString", "Time", "Date", "DateTime", "TimeOfDay",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether a driver of <paramref name="driverType"/> can write a 1-D array tag of the given shape.
|
||||
/// </summary>
|
||||
@@ -119,11 +174,150 @@ public static class DriverArrayWriteSupport
|
||||
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||
return S7ArrayTypes.Contains(native);
|
||||
}
|
||||
case DriverTypeNames.AbCip:
|
||||
return AbCipSupports(dataType, tagConfig);
|
||||
case DriverTypeNames.AbLegacy:
|
||||
return AbLegacySupports(dataType, tagConfig, driverConfigs);
|
||||
case DriverTypeNames.TwinCAT:
|
||||
return TwinCATSupports(dataType, tagConfig);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// AbCipTagDefinitionFactory + AbCipDriver.WriteAsync: a non-blank string tagPath, an atomic element type the codec
|
||||
// has a width for (BOOL arrays are bit-packed DWORDs, STRING / structure elements are structures), a declared
|
||||
// length, and not a safety tag (refused BadNotWritable). Each element is CONVERTED, so any numeric / Boolean node
|
||||
// type works; DateTime and (non-numeric) String elements never convert to a Logix integer or REAL.
|
||||
private static bool AbCipSupports(string? dataType, string? tagConfig)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "tagPath")
|
||||
|| ReadArrayLength(root) is null
|
||||
|| (root.TryGetProperty("safetyTag", out var safety) && safety.ValueKind == JsonValueKind.True))
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
|
||||
return AbCipArrayTypes.Contains(native) && dataType is not ("String" or "DateTime");
|
||||
}
|
||||
|
||||
// AbLegacyTagDefinitionFactory + AbLegacyDriver.WriteAsync: a flat Int / AnalogInt / Long / Float array on a plain
|
||||
// `<file><number>:<word>` address whose data file's element width matches (ArrayFileMismatch); no /bit, no .sub,
|
||||
// no indirect [..] address; at most 256 elements (the factory clamps a longer length, so the node never matches);
|
||||
// and not a PLC-5 on the native transport (refused before the wire). Elements CONVERT from any numeric node type;
|
||||
// bool, string and DateTime elements are refused.
|
||||
private static bool AbLegacySupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root)
|
||||
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.String
|
||||
|| ReadArrayLength(root) is not { } count || count > AbLegacyMaxArrayElements
|
||||
|| dataType is "Boolean" or "String" or "DateTime")
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "Int";
|
||||
if (!TryParsePlainPcccAddress(addressEl.GetString(), out var fileLetter)
|
||||
|| !AbLegacyFileElementTypes.TryGetValue(fileLetter, out var matching)
|
||||
|| !matching.Contains(native, StringComparer.OrdinalIgnoreCase))
|
||||
return false;
|
||||
return !AbLegacyIsNativePlc5(driverConfigs);
|
||||
}
|
||||
|
||||
// `<letters><digits>:<digits>` and nothing else (trimmed) — what AbLegacyAddress.TryParse yields with no bit
|
||||
// index, no sub-element and no indirection, for the file letters that carry a file number.
|
||||
private static bool TryParsePlainPcccAddress(string? address, out string fileLetter)
|
||||
{
|
||||
fileLetter = "";
|
||||
if (string.IsNullOrWhiteSpace(address)) return false;
|
||||
var src = address.Trim();
|
||||
var colon = src.IndexOf(':');
|
||||
if (colon <= 0) return false;
|
||||
var letterEnd = 0;
|
||||
while (letterEnd < colon && char.IsAsciiLetter(src[letterEnd])) letterEnd++;
|
||||
if (letterEnd == 0 || letterEnd == colon) return false; // no letter, or no file number
|
||||
var fileNumber = src[letterEnd..colon];
|
||||
var word = src[(colon + 1)..];
|
||||
if (!fileNumber.All(char.IsAsciiDigit) || !int.TryParse(fileNumber, out _)
|
||||
|| word.Length == 0 || !word.All(char.IsAsciiDigit) || !int.TryParse(word, out _))
|
||||
return false;
|
||||
fileLetter = src[..letterEnd];
|
||||
return true;
|
||||
}
|
||||
|
||||
// Conservative: ANY config naming the native transport AND ANY config naming a PLC-5 (a root `plcFamily`, as a
|
||||
// DeviceConfig carries it, or one in a `devices` array, as a DriverConfig carries it). Keys and values are matched
|
||||
// case-insensitively, like the driver's DTO binding and enum parse. An absent transport is libplctag for every
|
||||
// family (AbLegacyTransportResolver), which writes PLC-5.
|
||||
private static bool AbLegacyIsNativePlc5(string?[]? driverConfigs)
|
||||
{
|
||||
var native = false;
|
||||
var plc5 = false;
|
||||
foreach (var config in driverConfigs ?? [])
|
||||
{
|
||||
if (!TryParseObject(config, out var root)) continue;
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (IsNamed(prop, "transport") && IsStringValue(prop.Value, "Native")) native = true;
|
||||
else if (IsNamed(prop, "plcFamily") && IsStringValue(prop.Value, "Plc5")) plc5 = true;
|
||||
else if (IsNamed(prop, "devices") && prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var device in prop.Value.EnumerateArray())
|
||||
{
|
||||
if (device.ValueKind != JsonValueKind.Object) continue;
|
||||
foreach (var dp in device.EnumerateObject())
|
||||
if (IsNamed(dp, "plcFamily") && IsStringValue(dp.Value, "Plc5")) plc5 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return native && plc5;
|
||||
}
|
||||
|
||||
// TwinCATTagDefinitionFactory + TwinCATDriver.WriteAsync: a non-blank string symbolPath, a declared length, any
|
||||
// element type the codec lays out (not Structure, which the factory refuses), and not a BOOL bit-in-word path
|
||||
// (`MAIN.Word.3`: a scalar bit write, which an array value cannot feed). Elements CONVERT. A String node only feeds
|
||||
// a STRING / WSTRING element (anything else would depend on the text happening to parse). A DateTime node only
|
||||
// feeds a DATE / DT element or a WSTRING: its text form is CULTURE-dependent and may carry characters a STRING's
|
||||
// strict Windows-1252 encoding refuses (ICU's en-US "12:00:00 PM").
|
||||
private static bool TwinCATSupports(string? dataType, string? tagConfig)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "symbolPath")
|
||||
|| ReadArrayLength(root) is null)
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
|
||||
if (!TwinCATArrayTypes.Contains(native)) return false;
|
||||
var isWString = native.Equals("WString", StringComparison.OrdinalIgnoreCase);
|
||||
var isString = isWString || native.Equals("String", StringComparison.OrdinalIgnoreCase);
|
||||
var isDate = native.Equals("Date", StringComparison.OrdinalIgnoreCase) || native.Equals("DateTime", StringComparison.OrdinalIgnoreCase);
|
||||
if (dataType == "String" && !isString) return false;
|
||||
if (dataType == "DateTime" && !isWString && !isDate) return false;
|
||||
return !(native.Equals("Bool", StringComparison.OrdinalIgnoreCase)
|
||||
&& HasTwinCATBitSuffix(root.GetProperty("symbolPath").GetString()!));
|
||||
}
|
||||
|
||||
// TwinCATSymbolPath.TryParse's bit-index rule: a last top-level `.` segment of 0..31 with no `[`, behind at least
|
||||
// one other segment (a TwinCAT 2 global's leading dot belongs to its root segment).
|
||||
private static bool HasTwinCATBitSuffix(string symbolPath)
|
||||
{
|
||||
var src = symbolPath.Trim();
|
||||
if (src.StartsWith('.')) src = src[1..];
|
||||
var depth = 0;
|
||||
var lastDot = -1;
|
||||
for (var i = 0; i < src.Length; i++)
|
||||
{
|
||||
if (src[i] == '[') depth++;
|
||||
else if (src[i] == ']') depth--;
|
||||
else if (src[i] == '.' && depth == 0) lastDot = i;
|
||||
}
|
||||
if (lastDot < 0) return false;
|
||||
var last = src[(lastDot + 1)..];
|
||||
return int.TryParse(last, out var bit) && bit is >= 0 and <= 31 && !last.Contains('[');
|
||||
}
|
||||
|
||||
private static bool HasNonBlankString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(el.GetString());
|
||||
|
||||
private static bool IsNamed(JsonProperty prop, string name) => string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsStringValue(JsonElement el, string value) =>
|
||||
el.ValueKind == JsonValueKind.String && string.Equals(el.GetString()?.Trim(), value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a driver of <paramref name="driverType"/> is known to REFUSE an
|
||||
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
/// <summary>
|
||||
/// DDL for the Part 9 scripted-alarm condition state: one row per alarm identity holding the
|
||||
/// operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the ack/confirm audit
|
||||
/// trail and comment history.
|
||||
/// DDL for the Part 9 scripted-alarm condition state: one fixed-size row per alarm identity
|
||||
/// holding the operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the
|
||||
/// ack/confirm audit fields, and a separate append-only table holding each alarm's comment trail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -34,14 +36,58 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
/// the last transition. Timestamps are round-trip ("O") TEXT so lexicographic order is
|
||||
/// chronological; nullable timestamps and audit fields are stored as NULL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The comment trail is NOT on the condition row.</b> It used to be serialized whole into
|
||||
/// <c>comments_json</c>, so every transition rewrote — and replicated — the alarm's entire
|
||||
/// history: O(n) per transition, O(n²) over the alarm's life (review R05:4). It now lives in
|
||||
/// <see cref="CommentsTable"/>, one row per entry keyed by <c>(scripted_alarm_id, seq)</c>,
|
||||
/// where <c>seq</c> is the entry's ordinal in the alarm's trail. A transition inserts only the
|
||||
/// entries it appended, so the condition row stays a fixed size and a transition's write is
|
||||
/// O(1) however long the history is. <c>comments_json</c> is kept, always written as
|
||||
/// <c>"[]"</c>, so the condition table's shape — and with it the pair's replication digest
|
||||
/// for that table — does not change and an old-shape row stays readable;
|
||||
/// <see cref="MigrateInlineComments"/> copies any inline trail across (the alarm's next
|
||||
/// save clears the column).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the ordinal, and not a content hash, is the key.</b> Both halves of a redundant pair
|
||||
/// run the engine and both apply every operator command, each stamping the entry with its own
|
||||
/// clock. A content-derived id would keep TWO rows for every logical entry once the pair
|
||||
/// converged. Keyed by ordinal, the two nodes' copies of entry <i>n</i> share one primary key
|
||||
/// and LWW collapses them to one row — the convergence the whole-row LWW gave before, now per
|
||||
/// entry. Entries go in with <c>INSERT OR IGNORE</c> (<see cref="InsertCommentSql"/>), so an
|
||||
/// entry already held — written locally, or arrived from the peer — is never rewritten. A
|
||||
/// node that MISSED a peer command keeps its own colliding entry by appending it after the
|
||||
/// stored trail, with a Warning (see the store's divergence handling).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class AlarmConditionStateSchema
|
||||
{
|
||||
/// <summary>Table holding one condition-state row per scripted-alarm identity.</summary>
|
||||
public const string StateTable = "alarm_condition_state";
|
||||
|
||||
/// <summary>Append-only table holding one row per comment-trail entry, keyed by alarm id + ordinal.</summary>
|
||||
public const string CommentsTable = "alarm_condition_comment";
|
||||
|
||||
/// <summary>What <c>comments_json</c> holds once the trail lives in <see cref="CommentsTable"/>.</summary>
|
||||
public const string EmptyInlineComments = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Creates the state table if it does not already exist. Idempotent.
|
||||
/// The one append statement for <see cref="CommentsTable"/>, shared by the migration and the
|
||||
/// store. <c>OR IGNORE</c> is what makes the trail append-only: an ordinal already held is
|
||||
/// never rewritten. Parameters: <c>@Id @Seq @Ts @User @Kind @Text</c>.
|
||||
/// </summary>
|
||||
public const string InsertCommentSql =
|
||||
"""
|
||||
INSERT OR IGNORE INTO alarm_condition_comment
|
||||
(scripted_alarm_id, seq, timestamp_utc, user_name, kind, text)
|
||||
VALUES (@Id, @Seq, @Ts, @User, @Kind, @Text)
|
||||
""";
|
||||
|
||||
private static readonly JsonSerializerOptions InlineJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the state table and the comment table if they do not already exist. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="connection">
|
||||
/// An already-open connection. <c>ILocalDb.CreateConnection()</c> hands out open,
|
||||
@@ -55,10 +101,11 @@ public static class AlarmConditionStateSchema
|
||||
|
||||
// The four *_state columns are always written to a mapped, non-null string (Enabled/Disabled,
|
||||
// Acknowledged/Unacknowledged, Confirmed/Unconfirmed, Unshelved/OneShotShelved/TimedShelved),
|
||||
// so they are NOT NULL. comments_json is never null either — an empty trail is the literal
|
||||
// "[]". The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm
|
||||
// has no such history yet. updated_at_utc carries LastTransitionUtc (there is no dedicated
|
||||
// transition column).
|
||||
// so they are NOT NULL. comments_json is never null either — it is the literal "[]" since the
|
||||
// trail moved to alarm_condition_comment (an old-shape row may still carry an inline trail
|
||||
// until the alarm's next save; MigrateInlineComments copies it without touching this row).
|
||||
// The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm has no such history yet. updated_at_utc
|
||||
// carries LastTransitionUtc (there is no dedicated transition column).
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS alarm_condition_state (
|
||||
scripted_alarm_id TEXT NOT NULL PRIMARY KEY,
|
||||
@@ -76,7 +123,161 @@ public static class AlarmConditionStateSchema
|
||||
comments_json TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alarm_condition_comment (
|
||||
scripted_alarm_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
timestamp_utc TEXT NOT NULL,
|
||||
user_name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
PRIMARY KEY (scripted_alarm_id, seq)
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>What one <see cref="MigrateInlineComments"/> run did.</summary>
|
||||
/// <param name="RowsMoved">Condition rows for which at least one inline entry was copied across.</param>
|
||||
/// <param name="UnparseableAlarmIds">
|
||||
/// Alarms whose inline trail is not valid JSON and was left where it is — the caller logs these,
|
||||
/// so an unmigratable history is visible rather than skipped silently on every boot.
|
||||
/// </param>
|
||||
public sealed record InlineCommentMigrationResult(int RowsMoved, IReadOnlyList<string> UnparseableAlarmIds);
|
||||
|
||||
/// <summary>
|
||||
/// Copies every condition row's inline <c>comments_json</c> trail into
|
||||
/// <see cref="CommentsTable"/>. Idempotent; it never writes the condition row itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Run it AFTER the tables are registered for replication</b> — it writes rows, and a
|
||||
/// row written before the capture triggers exist never reaches the peer (the same
|
||||
/// DDL → register → writes rule <c>AlarmSfLegacyMigrator</c> follows).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Copy, don't clear.</b> Resetting <c>comments_json</c> here would be a full-row UPDATE
|
||||
/// that re-stamps the condition row with a fresh HLC — during a pair's rolling upgrade
|
||||
/// that stale-but-newly-stamped copy would beat a newer peer state (an ack made on the
|
||||
/// already-upgraded node) under LWW (review m1). The column is left as it is: the store's
|
||||
/// load merges an inline trail with the table by ordinal (the table wins), and the
|
||||
/// engine's next save of that alarm writes <c>"[]"</c> with the alarm's real state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each entry lands at its ordinal in the inline array via <see cref="InsertCommentSql"/>,
|
||||
/// so a re-run — or a row whose trail was already (partly) copied — neither duplicates nor
|
||||
/// reorders anything and writes nothing for an ordinal already held. Each row copies in its
|
||||
/// own transaction, so a crash leaves it fully copied or untouched. A row whose inline JSON
|
||||
/// does not parse is left exactly as it is (the pre-split store could not load it either)
|
||||
/// and reported in <see cref="InlineCommentMigrationResult.UnparseableAlarmIds"/> rather
|
||||
/// than failing host start.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="connection">An already-open connection from <c>ILocalDb.CreateConnection()</c>.</param>
|
||||
/// <returns>What was copied, and which rows could not be read.</returns>
|
||||
public static InlineCommentMigrationResult MigrateInlineComments(SqliteConnection connection)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
|
||||
var pending = new List<(string AlarmId, string Json)>();
|
||||
using (var select = connection.CreateCommand())
|
||||
{
|
||||
select.CommandText =
|
||||
"SELECT scripted_alarm_id, comments_json FROM alarm_condition_state WHERE comments_json <> @Empty";
|
||||
select.Parameters.AddWithValue("@Empty", EmptyInlineComments);
|
||||
using var reader = select.ExecuteReader();
|
||||
while (reader.Read())
|
||||
pending.Add((reader.GetString(0), reader.GetString(1)));
|
||||
}
|
||||
|
||||
var migrated = 0;
|
||||
var unparseable = new List<string>();
|
||||
foreach (var (alarmId, json) in pending)
|
||||
{
|
||||
IReadOnlyList<InlineComment> entries;
|
||||
try
|
||||
{
|
||||
entries = ParseInlineComments(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
unparseable.Add(alarmId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var inserted = 0;
|
||||
using var tx = connection.BeginTransaction();
|
||||
for (var seq = 0; seq < entries.Count; seq++)
|
||||
{
|
||||
using var insert = connection.CreateCommand();
|
||||
insert.Transaction = tx;
|
||||
insert.CommandText = InsertCommentSql;
|
||||
insert.Parameters.AddWithValue("@Id", alarmId);
|
||||
insert.Parameters.AddWithValue("@Seq", seq);
|
||||
insert.Parameters.AddWithValue("@Ts", FormatTimestamp(entries[seq].TimestampUtc));
|
||||
insert.Parameters.AddWithValue("@User", entries[seq].User);
|
||||
insert.Parameters.AddWithValue("@Kind", entries[seq].Kind);
|
||||
insert.Parameters.AddWithValue("@Text", entries[seq].Text);
|
||||
inserted += insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
if (inserted > 0)
|
||||
migrated++;
|
||||
}
|
||||
|
||||
return new InlineCommentMigrationResult(migrated, unparseable);
|
||||
}
|
||||
|
||||
/// <summary>Formats a comment timestamp for <see cref="CommentsTable"/> (round-trip "O" text).</summary>
|
||||
/// <param name="value">The timestamp.</param>
|
||||
/// <returns>The round-trip text form.</returns>
|
||||
public static string FormatTimestamp(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>Parses a <see cref="CommentsTable"/> timestamp, preserving its <see cref="DateTimeKind"/>.</summary>
|
||||
/// <param name="value">The round-trip text form.</param>
|
||||
/// <returns>The timestamp.</returns>
|
||||
public static DateTime ParseTimestamp(string value) =>
|
||||
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a legacy inline <c>comments_json</c> trail exactly as the pre-split store did
|
||||
/// (<see cref="JsonSerializerDefaults.Web"/>; null strings become empty).
|
||||
/// </summary>
|
||||
/// <param name="json">The column value.</param>
|
||||
/// <returns>The entries in trail order; empty for a blank or <c>"[]"</c> column.</returns>
|
||||
/// <exception cref="JsonException">The column is not a valid trail.</exception>
|
||||
public static IReadOnlyList<InlineComment> ParseInlineComments(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
var dtos = JsonSerializer.Deserialize<List<InlineCommentDto>>(json, InlineJsonOptions);
|
||||
if (dtos is null || dtos.Count == 0) return [];
|
||||
return dtos
|
||||
.Select(d => new InlineComment(
|
||||
d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>One comment-trail entry as stored — the persistence-side mirror of the engine's <c>AlarmComment</c>.</summary>
|
||||
/// <param name="TimestampUtc">When the action happened.</param>
|
||||
/// <param name="User">Identity of the actor.</param>
|
||||
/// <param name="Kind">Classification (Acknowledge, Confirm, AddComment, …).</param>
|
||||
/// <param name="Text">Operator-supplied or engine-generated text.</param>
|
||||
public sealed record InlineComment(DateTime TimestampUtc, string User, string Kind, string Text);
|
||||
|
||||
/// <summary>The pre-split on-disk shape of one entry in <c>comments_json</c>.</summary>
|
||||
private sealed class InlineCommentDto
|
||||
{
|
||||
/// <summary>When the comment was recorded.</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
|
||||
/// <summary>Identity of the actor.</summary>
|
||||
public string? User { get; set; }
|
||||
|
||||
/// <summary>Classification of the entry.</summary>
|
||||
public string? Kind { get; set; }
|
||||
|
||||
/// <summary>Entry text.</summary>
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ public sealed class ReadCommand : ModbusCommandBase
|
||||
ValidateEndpoint();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
// The driver's tag factory refuses an incompatible region/type pair (the tag would read
|
||||
// BadNodeIdUnknown) — say why up front instead.
|
||||
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(Region, DataType))
|
||||
throw new CliFx.Exceptions.CommandException(
|
||||
Region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
|
||||
? $"Region '{Region}' only carries Bool values (--type Bool); got '{DataType}'."
|
||||
: $"Region '{Region}' carries register types; use --type BitInRegister for a bit of a register, not '{DataType}'.");
|
||||
|
||||
var tagName = SynthesiseTagName(Region, Address, DataType);
|
||||
var tag = new ModbusTagDefinition(
|
||||
Name: tagName,
|
||||
|
||||
@@ -74,6 +74,9 @@ public sealed class WriteCommand : ModbusCommandBase
|
||||
throw new CliFx.Exceptions.CommandException(
|
||||
$"Region 'Coils' only supports boolean values (--type Bool). " +
|
||||
$"Type '{DataType}' cannot represent a single-bit coil write.");
|
||||
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(Region, DataType))
|
||||
throw new CliFx.Exceptions.CommandException(
|
||||
$"Region '{Region}' carries register types; use --type BitInRegister for a bit of a register, not '{DataType}'.");
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
|
||||
var tag = new ModbusTagDefinition(
|
||||
|
||||
@@ -89,13 +89,24 @@ public sealed class ModbusDriverOptions
|
||||
public bool UseFC16ForSingleRegisterWrites { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// <b>Reserved / no-op</b> kill-switch for FC23 (Read/Write Multiple Registers). The
|
||||
/// driver does not currently emit FC23 — toggling this option has no observable effect
|
||||
/// today. The slot exists so a future block-read-coalescing enhancement that opts into
|
||||
/// FC23 can be disabled per-deployment without a code change. Default <c>false</c>.
|
||||
/// Hard kill-switch for FC23 (Read/Write Multiple Registers): when <c>true</c> the driver never
|
||||
/// emits FC23, even with <see cref="UseFC23ForMultiRegisterWrites"/> set. Default <c>false</c>.
|
||||
/// FC23 is OPT-IN — a config without <see cref="UseFC23ForMultiRegisterWrites"/> keeps the FC16 wire
|
||||
/// behaviour, so the persisted <c>"disableFC23": false</c> every form-saved config carries changes
|
||||
/// nothing on the wire.
|
||||
/// </summary>
|
||||
public bool DisableFC23 { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Opt-in: send a multi-register holding write (2–121 registers: 32/64-bit values, arrays, strings)
|
||||
/// as ONE FC23 that writes the block and reads the same block back, so the device confirms what it
|
||||
/// now holds. A unit that answers FC23 with exception 01 (MELSEC QJ71MT91, S7 Modbus servers) moves
|
||||
/// to FC16 for that write and every later one until the next initialize; one that fails FC23 at the
|
||||
/// transport moves too (that one write is reported as a comms failure). Ignored when
|
||||
/// <see cref="DisableFC23"/> is set. Default <c>false</c> = FC16, the historical behaviour.
|
||||
/// </summary>
|
||||
public bool UseFC23ForMultiRegisterWrites { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Interval for the background re-probe loop that retries auto-prohibited
|
||||
/// coalesced ranges. When non-null, every <c>AutoProhibitReprobeInterval</c>
|
||||
@@ -107,20 +118,32 @@ public sealed class ModbusDriverOptions
|
||||
public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Block-read coalescing budget. When non-zero, the read planner combines tags
|
||||
/// in the same (UnitId, Region) group whose addresses are at most this many registers
|
||||
/// apart into a single FC03/FC04/FC01/FC02 read. The sliced response is then dispatched
|
||||
/// back to per-tag values. Default <c>0</c> = no coalescing — every tag gets its own
|
||||
/// PDU (preserves the previous behaviour). Typical opt-in values are 5..32 — large enough
|
||||
/// to bridge a few unused registers, small enough to avoid trampling protected holes.
|
||||
/// Block-read coalescing budget. When non-zero, the read planner combines tags in the same
|
||||
/// (UnitId, Region) group whose addresses are at most this many registers (Coils / DiscreteInputs:
|
||||
/// bits) apart into a single FC03/FC04/FC01/FC02 read, then slices the response back into per-tag
|
||||
/// values (several <c>BitInRegister</c> tags on one status word fold into one read). <c>0</c> = no
|
||||
/// coalescing — an explicitly authored 0 keeps that historical meaning. Default <c>8</c> applies
|
||||
/// only when the key is ABSENT. A range the device refuses is auto-prohibited and read per tag.
|
||||
/// <see cref="DisableReadCoalescing"/> also turns it off; <c>coalesceProhibited</c> exempts one tag.
|
||||
/// </summary>
|
||||
public ushort MaxReadGap { get; init; } = 0;
|
||||
public ushort MaxReadGap { get; init; } = DefaultMaxReadGap;
|
||||
|
||||
/// <summary>The default <see cref="MaxReadGap"/> (shared by the factory DTO and the AdminUI form).</summary>
|
||||
public const ushort DefaultMaxReadGap = 8;
|
||||
|
||||
/// <summary>
|
||||
/// PLC family hint that drives the parser's family-native branch. When set to a
|
||||
/// non-Generic value, address strings using that family's native syntax (DL205 V2000 /
|
||||
/// MELSEC D100) parse to the right region + offset directly. Defaults to
|
||||
/// <see cref="ModbusFamily.Generic"/> = Modicon-only behaviour preserved by default.
|
||||
/// When <c>true</c>, every tag is read with its own PDU (no block coalescing at all) — for a device
|
||||
/// that mis-answers merged reads in a way the auto-prohibition cannot catch. Default <c>false</c>.
|
||||
/// </summary>
|
||||
public bool DisableReadCoalescing { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// PLC family hint that drives the parser's family-native branch. A STRING-ONLY tag (an
|
||||
/// <c>addressString</c> with no region/address keys — see <see cref="ModbusTagAddressResolution"/>) is
|
||||
/// resolved with this family at every initialize, so with a non-Generic
|
||||
/// value that family's native syntax (DL205 V2000 octal / MELSEC D100) parses to the right
|
||||
/// region + offset. Defaults to <see cref="ModbusFamily.Generic"/> (Modicon / mnemonic grammar).
|
||||
/// Tags authored by region + numeric address are unaffected.
|
||||
/// </summary>
|
||||
public ModbusFamily Family { get; init; } = ModbusFamily.Generic;
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Which part of a Modbus <c>TagConfig</c> blob names the tag's address — the output of
|
||||
/// <see cref="ModbusTagAddressResolution.Classify(JsonElement)"/>.
|
||||
/// </summary>
|
||||
public enum ModbusAddressShape
|
||||
{
|
||||
/// <summary>
|
||||
/// Both <c>region</c> and <c>address</c> are present: those keys are the address, whatever
|
||||
/// <c>addressString</c> / <c>addressResolved</c> sit beside them (classic, editor-pinned, or a pre-#741 row
|
||||
/// whose string the old driver never read).
|
||||
/// </summary>
|
||||
Keys,
|
||||
|
||||
/// <summary>
|
||||
/// The keys are not both present and <c>addressString</c> is a non-blank string: the string is the address,
|
||||
/// resolved with the DRIVER's family. A family that cannot parse it refuses the tag.
|
||||
/// </summary>
|
||||
StringOnly,
|
||||
|
||||
/// <summary>The keys are not both present and <c>addressString</c> is present but not a string: refused.</summary>
|
||||
InvalidAddressString,
|
||||
|
||||
/// <summary>Neither the keys nor a usable <c>addressString</c>: refused (the tag names no address).</summary>
|
||||
NoAddress,
|
||||
}
|
||||
|
||||
/// <summary>The address part of a Modbus tag, as the driver will poll it.</summary>
|
||||
/// <param name="Shape">Which part of the blob named it (<see cref="ModbusAddressShape.Keys"/> or <see cref="ModbusAddressShape.StringOnly"/>).</param>
|
||||
/// <param name="Region">The register region.</param>
|
||||
/// <param name="Address">The zero-based offset.</param>
|
||||
/// <param name="DataType">The data type (already coerced to <c>Bool</c> for a bit region).</param>
|
||||
/// <param name="ByteOrder">The word/byte order.</param>
|
||||
/// <param name="BitIndex">The bit index (BitInRegister).</param>
|
||||
/// <param name="StringLength">The string length (String).</param>
|
||||
/// <param name="ArrayCount">The element count, or <c>null</c> for a scalar.</param>
|
||||
public sealed record ModbusResolvedAddress(
|
||||
ModbusAddressShape Shape,
|
||||
ModbusRegion Region,
|
||||
ushort Address,
|
||||
ModbusDataType DataType,
|
||||
ModbusByteOrder ByteOrder,
|
||||
byte BitIndex,
|
||||
ushort StringLength,
|
||||
int? ArrayCount);
|
||||
|
||||
/// <summary>The outcome of <see cref="ModbusTagAddressResolution.Resolve(JsonElement, ModbusFamily, MelsecFamily)"/>.</summary>
|
||||
/// <param name="Shape">The blob's address shape (always set, also on a refusal).</param>
|
||||
/// <param name="Address">The resolved address, or <c>null</c> when the tag is refused.</param>
|
||||
/// <param name="Refusal">Why the tag is refused, or <c>null</c> when it resolved.</param>
|
||||
public readonly record struct ModbusAddressResolution(ModbusAddressShape Shape, ModbusResolvedAddress? Address, string? Refusal)
|
||||
{
|
||||
/// <summary>Whether the driver serves the tag at <see cref="Address"/>.</summary>
|
||||
public bool IsResolved => Address is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The one authority for which address a Modbus <c>TagConfig</c> names</b> (#741, review r3 N3). The driver's
|
||||
/// tag reader, the deploy gate, the AdminUI editor model (load / save / Validate / display) and the CSV
|
||||
/// import/export all ask this class; none of them restates the precedence.
|
||||
/// <para>The rule:</para>
|
||||
/// <list type="number">
|
||||
/// <item>When <c>region</c> AND <c>address</c> are both present they are the address
|
||||
/// (<see cref="ModbusAddressShape.Keys"/>). No pre-#741 shape changes the address it resolves to: the old
|
||||
/// driver read the keys only and ignored any <c>addressString</c>.</item>
|
||||
/// <item>Otherwise a non-blank string <c>addressString</c> is the address
|
||||
/// (<see cref="ModbusAddressShape.StringOnly"/>), parsed by <see cref="ModbusAddressParser"/> with the
|
||||
/// DRIVER's family. If that family cannot parse it the tag is REFUSED — never served as HR[0].</item>
|
||||
/// <item>Anything else is refused.</item>
|
||||
/// </list>
|
||||
/// <c>addressResolved</c> is editor display / CSV state only and never changes the shape.
|
||||
/// </summary>
|
||||
public static class ModbusTagAddressResolution
|
||||
{
|
||||
/// <summary>The TagConfig key carrying the parser-grammar address (<c>40001:F:CDAB</c>, <c>V2000</c>, <c>D100</c>).</summary>
|
||||
public const string AddressStringKey = "addressString";
|
||||
|
||||
/// <summary>
|
||||
/// The key the editor writes beside the keys it resolved from an <c>addressString</c>
|
||||
/// (<c>"addressResolved": true</c>). Display / CSV state only: it never decides the address.
|
||||
/// </summary>
|
||||
public const string AddressResolvedKey = "addressResolved";
|
||||
|
||||
/// <summary>Every (family, MELSEC sub-family) a driver can be configured with — the family-unaware gates' search space.</summary>
|
||||
public static IReadOnlyList<(ModbusFamily Family, MelsecFamily Sub)> AllFamilies { get; } =
|
||||
[
|
||||
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.F_iQF),
|
||||
];
|
||||
|
||||
/// <summary>Classifies an object-rooted TagConfig by which part of it names the address.</summary>
|
||||
/// <param name="root">The TagConfig root (a non-object is <see cref="ModbusAddressShape.NoAddress"/>).</param>
|
||||
/// <returns>The address shape.</returns>
|
||||
public static ModbusAddressShape Classify(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object) return ModbusAddressShape.NoAddress;
|
||||
if (root.TryGetProperty("region", out _) && root.TryGetProperty("address", out _)) return ModbusAddressShape.Keys;
|
||||
if (!root.TryGetProperty(AddressStringKey, out var s) || s.ValueKind == JsonValueKind.Null) return ModbusAddressShape.NoAddress;
|
||||
if (s.ValueKind != JsonValueKind.String) return ModbusAddressShape.InvalidAddressString;
|
||||
return string.IsNullOrWhiteSpace(s.GetString()) ? ModbusAddressShape.NoAddress : ModbusAddressShape.StringOnly;
|
||||
}
|
||||
|
||||
/// <summary>Classifies a TagConfig JSON string (blank / malformed / non-object ⇒ <see cref="ModbusAddressShape.NoAddress"/>).</summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <returns>The address shape.</returns>
|
||||
public static ModbusAddressShape Classify(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return ModbusAddressShape.NoAddress;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
return Classify(doc.RootElement);
|
||||
}
|
||||
catch (JsonException) { return ModbusAddressShape.NoAddress; }
|
||||
}
|
||||
|
||||
/// <summary>Resolves a TagConfig JSON string's address under one family.</summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution.</returns>
|
||||
public static ModbusAddressResolution Resolve(string? tagConfig, ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig))
|
||||
return Refused(ModbusAddressShape.NoAddress, "Modbus TagConfig is blank — the tag names no address.");
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
return Resolve(doc.RootElement, family, melsecSubFamily);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Refused(ModbusAddressShape.NoAddress, "Modbus TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the address of an object-rooted TagConfig under one family — the rule in the class remarks.
|
||||
/// Key reads are STRICT (a present-but-invalid <c>region</c> / <c>dataType</c> / <c>byteOrder</c>, or an
|
||||
/// <c>address</c> that is not an integer 0–65535, refuses the tag); a bit region's data type is coerced to
|
||||
/// <c>Bool</c> (pre-#741 editor blobs persisted <c>Int16</c> there and the old driver ignored it).
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root.</param>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution.</returns>
|
||||
public static ModbusAddressResolution Resolve(JsonElement root, ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
{
|
||||
var shape = Classify(root);
|
||||
try
|
||||
{
|
||||
switch (shape)
|
||||
{
|
||||
case ModbusAddressShape.Keys:
|
||||
return ResolveKeys(root);
|
||||
case ModbusAddressShape.StringOnly:
|
||||
{
|
||||
var text = root.GetProperty(AddressStringKey).GetString()!.Trim();
|
||||
if (!ModbusAddressParser.TryParse(text, family, melsecSubFamily, out var parsed, out var error) || parsed is null)
|
||||
return Refused(shape, $"Modbus 'addressString' '{text}' does not parse under the {Describe(family, melsecSubFamily)} family ({error}) — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return Resolved(shape, parsed.Region, parsed.Offset, parsed.DataType, parsed.ByteOrder,
|
||||
parsed.Bit ?? 0, parsed.StringLength, parsed.ArrayCount);
|
||||
}
|
||||
case ModbusAddressShape.InvalidAddressString:
|
||||
return Refused(shape, "Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
|
||||
default:
|
||||
return Refused(shape, "Modbus TagConfig names no address: it needs 'region' + 'address' or an 'addressString' — the tag will not resolve (BadNodeIdUnknown).");
|
||||
}
|
||||
}
|
||||
catch (FormatException) { return Refused(shape, "Modbus TagConfig address could not be read — the tag will not resolve (BadNodeIdUnknown)."); }
|
||||
catch (InvalidOperationException) { return Refused(shape, "Modbus TagConfig address could not be read — the tag will not resolve (BadNodeIdUnknown)."); }
|
||||
}
|
||||
|
||||
/// <summary>Whether the driver serves the blob's address under at least one family (the family-unaware gates' accept rule).</summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <returns><see langword="true"/> when some family resolves it.</returns>
|
||||
public static bool ResolvesUnderAnyFamily(string? tagConfig)
|
||||
=> AllFamilies.Any(f => Resolve(tagConfig, f.Family, f.Sub).IsResolved);
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="addressString"/> parses, under SOME family, to exactly the given address fields —
|
||||
/// i.e. the string still describes the keys beside it. Used by the CSV import to keep an exported pinned
|
||||
/// row's string through an unchanged round trip while dropping one a typed cell contradicts. It never
|
||||
/// decides the address (the keys do, by the rule above).
|
||||
/// </summary>
|
||||
/// <param name="addressString">The address string.</param>
|
||||
/// <param name="region">The keys' region.</param>
|
||||
/// <param name="address">The keys' offset.</param>
|
||||
/// <param name="dataType">The keys' data type.</param>
|
||||
/// <param name="byteOrder">The keys' byte order.</param>
|
||||
/// <param name="bitIndex">The keys' bit index.</param>
|
||||
/// <param name="stringLength">The keys' string length.</param>
|
||||
/// <returns><see langword="true"/> when some family parses the string to those fields.</returns>
|
||||
public static bool AddressStringDescribes(string? addressString, ModbusRegion region, int address, ModbusDataType dataType,
|
||||
ModbusByteOrder byteOrder, int bitIndex, int stringLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(addressString)) return false;
|
||||
foreach (var (family, sub) in AllFamilies)
|
||||
{
|
||||
if (!ModbusAddressParser.TryParse(addressString.Trim(), family, sub, out var p, out _) || p is null) continue;
|
||||
var parsedType = p.Region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs ? ModbusDataType.Bool : p.DataType;
|
||||
if (p.Region == region && p.Offset == address && parsedType == dataType && p.ByteOrder == byteOrder
|
||||
&& (p.Bit ?? 0) == bitIndex && p.StringLength == stringLength)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ModbusAddressResolution ResolveKeys(JsonElement root)
|
||||
{
|
||||
var addr = root.GetProperty("address");
|
||||
if (addr.ValueKind != JsonValueKind.Number || !addr.TryGetInt32(out var address) || address < 0 || address > ushort.MaxValue)
|
||||
return Refused(ModbusAddressShape.Keys,
|
||||
$"value '{addr.GetRawText()}' for 'address' is not a Modbus register address (an integer 0–{ushort.MaxValue}); the tag will not resolve (BadNodeIdUnknown).");
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out ModbusRegion region))
|
||||
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusRegion>(root, "region") ?? "invalid 'region'");
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusTagDefinitionFactory.DefaultDataType(region), out ModbusDataType dataType))
|
||||
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusDataType>(root, "dataType") ?? "invalid 'dataType'");
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out ModbusByteOrder byteOrder))
|
||||
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(root, "byteOrder") ?? "invalid 'byteOrder'");
|
||||
// isArray / arrayLength: an array iff isArray:true AND arrayLength >= 1 (a 1-element array is valid).
|
||||
var isArray = root.TryGetProperty("isArray", out var ia) && ia.ValueKind == JsonValueKind.True;
|
||||
var arrayLength = ReadInt(root, "arrayLength");
|
||||
return Resolved(ModbusAddressShape.Keys, region, address, dataType, byteOrder,
|
||||
(byte)ReadInt(root, "bitIndex"), (ushort)ReadInt(root, "stringLength"), isArray && arrayLength >= 1 ? arrayLength : null);
|
||||
}
|
||||
|
||||
private static ModbusAddressResolution Resolved(ModbusAddressShape shape, ModbusRegion region, int address, ModbusDataType dataType,
|
||||
ModbusByteOrder byteOrder, int bitIndex, int stringLength, int? arrayCount)
|
||||
{
|
||||
// A bit region carries only Bool: coerce, never refuse (pre-#741 coil blobs persisted Int16).
|
||||
if (region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs) dataType = ModbusDataType.Bool;
|
||||
return new ModbusAddressResolution(shape,
|
||||
new ModbusResolvedAddress(shape, region, (ushort)address, dataType, byteOrder, (byte)bitIndex, (ushort)stringLength, arrayCount),
|
||||
null);
|
||||
}
|
||||
|
||||
private static ModbusAddressResolution Refused(ModbusAddressShape shape, string reason) => new(shape, null, reason);
|
||||
|
||||
private static string Describe(ModbusFamily family, MelsecFamily sub)
|
||||
=> family == ModbusFamily.MELSEC ? $"MELSEC {sub}" : family.ToString();
|
||||
|
||||
private static int ReadInt(JsonElement o, string name)
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0;
|
||||
}
|
||||
+112
-26
@@ -32,6 +32,25 @@ public static class ModbusTagDefinitionFactory
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid Modbus address object.</returns>
|
||||
public static bool FromTagConfig(string tagConfig, string rawPath, out ModbusTagDefinition def)
|
||||
=> FromTagConfig(tagConfig, rawPath, ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out def);
|
||||
|
||||
/// <summary>
|
||||
/// Family-aware overload — the call <c>ModbusDriver</c> makes with its own Family / MelsecSubFamily. WHICH
|
||||
/// address the blob names is decided by <see cref="ModbusTagAddressResolution"/> (the one authority shared
|
||||
/// with the deploy gate, the AdminUI editor model and the CSV import/export): <c>region</c> + <c>address</c>
|
||||
/// when both are present, else a string-only <c>addressString</c> resolved with the DRIVER's
|
||||
/// <paramref name="family"/> / <paramref name="melsecSubFamily"/> (refused when that family cannot parse it).
|
||||
/// This method adds only the non-address reads and guards (region/type compatibility, String length,
|
||||
/// BitInRegister index, writable, unitId, deadband, coalesceProhibited).
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||
/// <param name="family">The driver's PLC family (from its <c>DriverConfig</c>).</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family; consulted only for MELSEC.</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid Modbus address object.</returns>
|
||||
public static bool FromTagConfig(string tagConfig, string rawPath, ModbusFamily family, MelsecFamily melsecSubFamily,
|
||||
out ModbusTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
@@ -39,22 +58,24 @@ public static class ModbusTagDefinitionFactory
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("region", out _)
|
||||
|| !root.TryGetProperty("address", out var addr))
|
||||
return false;
|
||||
if (addr.ValueKind != JsonValueKind.Number
|
||||
|| !addr.TryGetInt32(out var address)
|
||||
|| address < 0 || address > ushort.MaxValue)
|
||||
return false;
|
||||
// Strict enum reads: a present-but-invalid (typo'd) enum rejects the tag
|
||||
// (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-width Good.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out var region)) return false;
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return false;
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out var byteOrder)) return false;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
// The address — region, offset, type, order, bit, length, count — comes from the ONE authority
|
||||
// (ModbusTagAddressResolution: keys when both are present, else the addressString with this family).
|
||||
var resolution = ModbusTagAddressResolution.Resolve(root, family, melsecSubFamily);
|
||||
if (resolution.Address is not { } a) return false;
|
||||
var region = a.Region;
|
||||
var address = a.Address;
|
||||
var dataType = a.DataType;
|
||||
var byteOrder = a.ByteOrder;
|
||||
var bitIndex = a.BitIndex;
|
||||
var stringLength = a.StringLength;
|
||||
var arrayCount = a.ArrayCount;
|
||||
// Region/type compatibility. A bit region's dataType was already COERCED to Bool by the resolver
|
||||
// (the pre-#741 editor persisted Int16 for coil tags and the old driver ignored it). A register
|
||||
// region with Bool is refused: that pair never read (it threw out of the planner).
|
||||
if (!IsRegionTypeCompatible(region, dataType)) return false;
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "stringByteOrder", ModbusStringByteOrder.HighByteFirst, out var stringByteOrder)) return false;
|
||||
var bitIndex = (byte)ReadInt(root, "bitIndex");
|
||||
var stringLength = (ushort)ReadInt(root, "stringLength");
|
||||
// Guard: String tags require StringLength >= 1. RegisterCount = (StringLength+1)/2,
|
||||
// so StringLength=0 → 0 registers → spec-illegal FC03/FC04 with quantity=0 → PLC
|
||||
// returns exception 03. Reject here so the driver surfaces BadNodeIdUnknown rather
|
||||
@@ -63,14 +84,6 @@ public static class ModbusTagDefinitionFactory
|
||||
// Guard: BitInRegister tags require BitIndex 0–15. Shift by ≥ 16 on an int overflows
|
||||
// a ushort mask silently — reads always return false, writes have no effect.
|
||||
if (dataType == ModbusDataType.BitInRegister && bitIndex > 15) return false;
|
||||
// isArray / arrayLength — optional keys authored by the typed Modbus tag editor.
|
||||
// Canonical rule: a tag is an array iff isArray:true AND arrayLength >= 1.
|
||||
// isArray:false (with any arrayLength) is scalar — the foundation materialises a
|
||||
// scalar OPC UA node, so returning an array value would cause a shape mismatch.
|
||||
// A 1-element array (isArray:true, arrayLength:1) IS valid and reads as short[1].
|
||||
var isArray = ReadBool(root, "isArray");
|
||||
var arrayLength = ReadInt(root, "arrayLength");
|
||||
int? arrayCount = (isArray && arrayLength >= 1) ? arrayLength : null;
|
||||
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
|
||||
// authorable. Node-level authz remains the effective write gate.
|
||||
var writable = TagConfigJson.ReadWritable(root);
|
||||
@@ -82,7 +95,7 @@ public static class ModbusTagDefinitionFactory
|
||||
var deadband = ReadOptionalDouble(root, "deadband");
|
||||
var coalesceProhibited = ReadBool(root, "coalesceProhibited");
|
||||
def = new ModbusTagDefinition(
|
||||
Name: rawPath, Region: region, Address: (ushort)address, DataType: dataType,
|
||||
Name: rawPath, Region: region, Address: address, DataType: dataType,
|
||||
Writable: writable, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
|
||||
StringByteOrder: stringByteOrder, WriteIdempotent: false,
|
||||
ArrayCount: arrayCount, Deadband: deadband, UnitId: unitId,
|
||||
@@ -94,6 +107,49 @@ public static class ModbusTagDefinitionFactory
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Family-unaware acceptance, for the gates that do not know the owning driver's family (the deploy
|
||||
/// gate's <see cref="Inspect"/>, CSV import, the AdminUI parse consult): accepts a blob SOME family
|
||||
/// resolves, so the gate is never stricter than the driver it guards. Blobs without an
|
||||
/// <c>addressString</c> behave exactly like <see cref="FromTagConfig(string,string,out ModbusTagDefinition)"/>.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath.</param>
|
||||
/// <param name="def">The definition under the first family that resolves it.</param>
|
||||
/// <returns><see langword="true"/> when any family's driver would serve the tag.</returns>
|
||||
public static bool FromTagConfigAnyFamily(string tagConfig, string rawPath, out ModbusTagDefinition def)
|
||||
{
|
||||
foreach (var (family, sub) in ModbusTagAddressResolution.AllFamilies)
|
||||
if (FromTagConfig(tagConfig, rawPath, family, sub, out def)) return true;
|
||||
def = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The key the tag editor writes beside the resolved <c>region</c> / <c>address</c> (<c>"addressResolved": true</c>).
|
||||
/// Display / CSV state only — see <see cref="ModbusTagAddressResolution.AddressResolvedKey"/>.
|
||||
/// </summary>
|
||||
public const string AddressResolvedKey = ModbusTagAddressResolution.AddressResolvedKey;
|
||||
|
||||
/// <summary>The data type an authored tag gets when its <c>dataType</c> key is absent.</summary>
|
||||
/// <param name="region">The tag's region.</param>
|
||||
/// <returns><c>Bool</c> for Coils / DiscreteInputs, <c>Int16</c> for the register regions.</returns>
|
||||
public static ModbusDataType DefaultDataType(ModbusRegion region)
|
||||
=> region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs ? ModbusDataType.Bool : ModbusDataType.Int16;
|
||||
|
||||
/// <summary>
|
||||
/// Region/type compatibility, the one rule every authoring path and the driver share: Coils and
|
||||
/// DiscreteInputs carry only <c>Bool</c>; HoldingRegisters and InputRegisters carry every register
|
||||
/// type (including <c>BitInRegister</c>) but never <c>Bool</c>.
|
||||
/// </summary>
|
||||
/// <param name="region">The tag's region.</param>
|
||||
/// <param name="dataType">The tag's data type.</param>
|
||||
/// <returns><see langword="true"/> when the driver can read the pair.</returns>
|
||||
public static bool IsRegionTypeCompatible(ModbusRegion region, ModbusDataType dataType)
|
||||
=> region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
|
||||
? dataType == ModbusDataType.Bool
|
||||
: dataType != ModbusDataType.Bool;
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
|
||||
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
|
||||
@@ -169,7 +225,7 @@ public static class ModbusTagDefinitionFactory
|
||||
|
||||
// The accept gate: whatever the driver reads draws nothing. See the remarks. The RAW reference is
|
||||
// handed to the mapper — the driver reads the stored blob, not a trimmed copy of it.
|
||||
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
|
||||
if (FromTagConfigAnyFamily(reference, InspectionRawPath, out _)) return warnings;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -210,6 +266,26 @@ public static class ModbusTagDefinitionFactory
|
||||
/// <param name="warnings">The list to append warnings to.</param>
|
||||
private static void DescribeRejection(JsonElement root, List<string> warnings)
|
||||
{
|
||||
// Which part of the blob names the address is the resolver's call, never restated here.
|
||||
switch (ModbusTagAddressResolution.Classify(root))
|
||||
{
|
||||
case ModbusAddressShape.InvalidAddressString:
|
||||
warnings.Add("Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return;
|
||||
case ModbusAddressShape.StringOnly:
|
||||
{
|
||||
// String-only blob: the addressString is what the driver resolves (with its own family).
|
||||
var addressText = root.GetProperty(ModbusTagAddressResolution.AddressStringKey).GetString()!.Trim();
|
||||
if (!ModbusAddressParser.TryParse(addressText, out _, out var error))
|
||||
warnings.Add($"Modbus 'addressString' '{addressText}' does not parse under any PLC family ({error}) — "
|
||||
+ "the tag will not resolve (BadNodeIdUnknown).");
|
||||
else
|
||||
warnings.Add($"Modbus 'addressString' '{addressText}' resolves to an unusable tag (check its type against "
|
||||
+ "the region) — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("region", out _))
|
||||
{
|
||||
warnings.Add(
|
||||
@@ -247,7 +323,17 @@ public static class ModbusTagDefinitionFactory
|
||||
// Type-dependent guards. The byte/ushort NARROWING is part of the rule, not an accident of the
|
||||
// mapper: it reads `(byte)bitIndex` / `(ushort)stringLength`, so 256 is bit 0 and is ACCEPTED.
|
||||
// Describing the un-narrowed value here would call a blob invalid that the driver serves.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return;
|
||||
var region = TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out var r)
|
||||
? r : ModbusRegion.HoldingRegisters;
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DefaultDataType(region), out var dataType)) return;
|
||||
|
||||
// Only a register region with Bool is refused (a bit region with any type is coerced to Bool).
|
||||
if (root.TryGetProperty("region", out _) && region is not (ModbusRegion.Coils or ModbusRegion.DiscreteInputs)
|
||||
&& !IsRegionTypeCompatible(region, dataType))
|
||||
{
|
||||
warnings.Add($"Modbus Bool tags only live in Coils / DiscreteInputs; a {region} tag must use a register type "
|
||||
+ "(use BitInRegister for a bit of a register) — the tag is rejected (BadNodeIdUnknown).");
|
||||
}
|
||||
|
||||
if (dataType == ModbusDataType.String && (ushort)ReadInt(root, "stringLength") < 1)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// The device answered with well-framed data the tag's codec cannot represent (a non-BCD nibble in a
|
||||
/// BCD register, for example). The device is reachable, so this is a per-tag
|
||||
/// <c>BadDecodingError</c> with a once-per-tag Warning — never a comms fault, never a health write.
|
||||
/// </summary>
|
||||
public sealed class ModbusDecodeException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusDecodeException"/> class.</summary>
|
||||
/// <param name="message">A human-readable description.</param>
|
||||
/// <param name="inner">The codec's own exception.</param>
|
||||
public ModbusDecodeException(string message, Exception? inner = null)
|
||||
: base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,22 +78,24 @@ public static class ModbusDriverFactoryExtensions
|
||||
Host = dto.Host!,
|
||||
Port = dto.Port ?? 502,
|
||||
UnitId = dto.UnitId ?? 1,
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
Timeout = Span(dto.TimeoutMs, dto.Timeout) ?? TimeSpan.FromSeconds(2),
|
||||
MaxRegistersPerRead = dto.MaxRegistersPerRead ?? 125,
|
||||
MaxRegistersPerWrite = dto.MaxRegistersPerWrite ?? 123,
|
||||
MaxCoilsPerRead = dto.MaxCoilsPerRead ?? 2000,
|
||||
UseFC15ForSingleCoilWrites = dto.UseFC15ForSingleCoilWrites ?? false,
|
||||
UseFC16ForSingleRegisterWrites = dto.UseFC16ForSingleRegisterWrites ?? false,
|
||||
DisableFC23 = dto.DisableFC23 ?? false,
|
||||
UseFC23ForMultiRegisterWrites = dto.UseFC23ForMultiRegisterWrites ?? false,
|
||||
WriteOnChangeOnly = dto.WriteOnChangeOnly ?? false,
|
||||
MaxReadGap = dto.MaxReadGap ?? 0,
|
||||
MaxReadGap = dto.MaxReadGap ?? ModbusDriverOptions.DefaultMaxReadGap,
|
||||
DisableReadCoalescing = dto.DisableReadCoalescing ?? false,
|
||||
Family = dto.Family is null ? ModbusFamily.Generic
|
||||
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
|
||||
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
|
||||
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
|
||||
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
|
||||
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport"),
|
||||
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
|
||||
AutoProhibitReprobeInterval = Span(dto.AutoProhibitReprobeMs, dto.AutoProhibitReprobeInterval),
|
||||
AutoReconnect = dto.AutoReconnect ?? true,
|
||||
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
|
||||
// tags. The driver maps each RawTagEntry's TagConfig blob through ModbusTagDefinitionFactory
|
||||
@@ -103,22 +105,22 @@ public static class ModbusDriverFactoryExtensions
|
||||
Probe = new ModbusProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
Interval = Span(dto.Probe?.IntervalMs, dto.Probe?.Interval) ?? TimeSpan.FromSeconds(5),
|
||||
Timeout = Span(dto.Probe?.TimeoutMs, dto.Probe?.Timeout) ?? TimeSpan.FromSeconds(2),
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? 0,
|
||||
},
|
||||
KeepAlive = dto.KeepAlive is null ? new ModbusKeepAliveOptions() : new ModbusKeepAliveOptions
|
||||
{
|
||||
Enabled = dto.KeepAlive.Enabled ?? true,
|
||||
Time = TimeSpan.FromMilliseconds(dto.KeepAlive.TimeMs ?? 30_000),
|
||||
Interval = TimeSpan.FromMilliseconds(dto.KeepAlive.IntervalMs ?? 10_000),
|
||||
Time = Span(dto.KeepAlive.TimeMs, dto.KeepAlive.Time) ?? TimeSpan.FromSeconds(30),
|
||||
Interval = Span(dto.KeepAlive.IntervalMs, dto.KeepAlive.Interval) ?? TimeSpan.FromSeconds(10),
|
||||
RetryCount = dto.KeepAlive.RetryCount ?? 3,
|
||||
},
|
||||
IdleDisconnectTimeout = dto.IdleDisconnectMs is { } ms ? TimeSpan.FromMilliseconds(ms) : null,
|
||||
IdleDisconnectTimeout = Span(dto.IdleDisconnectMs, dto.IdleDisconnectTimeout),
|
||||
Reconnect = dto.Reconnect is null ? new ModbusReconnectOptions() : new ModbusReconnectOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.FromMilliseconds(dto.Reconnect.InitialDelayMs ?? 0),
|
||||
MaxDelay = TimeSpan.FromMilliseconds(dto.Reconnect.MaxDelayMs ?? 30_000),
|
||||
InitialDelay = Span(dto.Reconnect.InitialDelayMs, dto.Reconnect.InitialDelay) ?? TimeSpan.Zero,
|
||||
MaxDelay = Span(dto.Reconnect.MaxDelayMs, dto.Reconnect.MaxDelay) ?? TimeSpan.FromSeconds(30),
|
||||
BackoffMultiplier = dto.Reconnect.BackoffMultiplier ?? 2.0,
|
||||
},
|
||||
};
|
||||
@@ -126,6 +128,23 @@ public static class ModbusDriverFactoryExtensions
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One timing knob, accepted in BOTH shapes the config can carry: the <c>*Ms</c> integer keys (CLI,
|
||||
/// seed data, hand-authored JSON) and the TimeSpan-string keys the AdminUI driver form emits — it
|
||||
/// serialises <see cref="ModbusDriverOptions"/> itself, so it writes <c>"timeout":"00:00:07"</c>, not
|
||||
/// <c>timeoutMs</c>. Before this every timing field on that form was silently ignored. The
|
||||
/// millisecond key wins when both are present; an unparseable TimeSpan string is ignored (the
|
||||
/// previous behaviour for these keys), never a reason to fail the driver.
|
||||
/// </summary>
|
||||
private static TimeSpan? Span(int? milliseconds, JsonElement? timeSpan)
|
||||
{
|
||||
if (milliseconds is { } ms) return TimeSpan.FromMilliseconds(ms);
|
||||
if (timeSpan is { ValueKind: JsonValueKind.String } e
|
||||
&& TimeSpan.TryParse(e.GetString(), System.Globalization.CultureInfo.InvariantCulture, out var parsed))
|
||||
return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
@@ -155,6 +174,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
public byte? UnitId { get; init; }
|
||||
/// <summary>Gets or sets the timeout in milliseconds.</summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
/// <summary>Gets or sets the timeout as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Timeout { get; init; }
|
||||
/// <summary>Gets or sets the maximum number of registers per read operation.</summary>
|
||||
public ushort? MaxRegistersPerRead { get; init; }
|
||||
/// <summary>Gets or sets the maximum number of registers per write operation.</summary>
|
||||
@@ -167,10 +188,14 @@ public static class ModbusDriverFactoryExtensions
|
||||
public bool? UseFC16ForSingleRegisterWrites { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether to disable function code 23.</summary>
|
||||
public bool? DisableFC23 { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether multi-register writes opt in to FC23 write + read-back.</summary>
|
||||
public bool? UseFC23ForMultiRegisterWrites { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether to write only on change.</summary>
|
||||
public bool? WriteOnChangeOnly { get; init; }
|
||||
/// <summary>Gets or sets the maximum gap between register addresses for coalescing.</summary>
|
||||
public ushort? MaxReadGap { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether block-read coalescing is turned off entirely.</summary>
|
||||
public bool? DisableReadCoalescing { get; init; }
|
||||
/// <summary>Gets or sets the Modbus family type.</summary>
|
||||
public string? Family { get; init; }
|
||||
/// <summary>Gets or sets the Melsec subfamily.</summary>
|
||||
@@ -179,6 +204,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
public string? Transport { get; init; }
|
||||
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
|
||||
public int? AutoProhibitReprobeMs { get; init; }
|
||||
/// <summary>Gets or sets the re-probe interval as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? AutoProhibitReprobeInterval { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
|
||||
public bool? AutoReconnect { get; init; }
|
||||
/// <summary>Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the
|
||||
@@ -191,6 +218,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
public ModbusKeepAliveDto? KeepAlive { get; init; }
|
||||
/// <summary>Gets or sets the idle disconnect timeout in milliseconds.</summary>
|
||||
public int? IdleDisconnectMs { get; init; }
|
||||
/// <summary>Gets or sets the idle disconnect timeout as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? IdleDisconnectTimeout { get; init; }
|
||||
/// <summary>Gets or sets the reconnect configuration.</summary>
|
||||
public ModbusReconnectDto? Reconnect { get; init; }
|
||||
}
|
||||
@@ -203,6 +232,10 @@ public static class ModbusDriverFactoryExtensions
|
||||
public int? TimeMs { get; init; }
|
||||
/// <summary>Gets or sets the keep-alive interval in milliseconds.</summary>
|
||||
public int? IntervalMs { get; init; }
|
||||
/// <summary>Gets or sets the keep-alive time as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Time { get; init; }
|
||||
/// <summary>Gets or sets the keep-alive interval as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Interval { get; init; }
|
||||
/// <summary>Gets or sets the retry count.</summary>
|
||||
public int? RetryCount { get; init; }
|
||||
}
|
||||
@@ -213,6 +246,10 @@ public static class ModbusDriverFactoryExtensions
|
||||
public int? InitialDelayMs { get; init; }
|
||||
/// <summary>Gets or sets the maximum reconnect delay in milliseconds.</summary>
|
||||
public int? MaxDelayMs { get; init; }
|
||||
/// <summary>Gets or sets the initial reconnect delay as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? InitialDelay { get; init; }
|
||||
/// <summary>Gets or sets the maximum reconnect delay as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? MaxDelay { get; init; }
|
||||
/// <summary>Gets or sets the backoff multiplier for exponential reconnect delays.</summary>
|
||||
public double? BackoffMultiplier { get; init; }
|
||||
}
|
||||
@@ -225,6 +262,10 @@ public static class ModbusDriverFactoryExtensions
|
||||
public int? IntervalMs { get; init; }
|
||||
/// <summary>Gets or sets the probe timeout in milliseconds.</summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
/// <summary>Gets or sets the probe interval as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Interval { get; init; }
|
||||
/// <summary>Gets or sets the probe timeout as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Timeout { get; init; }
|
||||
/// <summary>Gets or sets the probe register address.</summary>
|
||||
public ushort? ProbeAddress { get; init; }
|
||||
}
|
||||
|
||||
@@ -93,9 +93,10 @@ public static class ModbusRtuFraming
|
||||
// Exception frame: excCode(1) + CRC(2).
|
||||
trailing = 1 + 2;
|
||||
}
|
||||
else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
|
||||
else if (fc is 0x01 or 0x02 or 0x03 or 0x04 or 0x17)
|
||||
{
|
||||
// Read response: byteCount(1) then byteCount data bytes + CRC(2).
|
||||
// Read response (and FC23 Read/Write Multiple, whose reply is a read): byteCount(1) then
|
||||
// byteCount data bytes + CRC(2).
|
||||
var bc = new byte[1];
|
||||
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
|
||||
var byteCount = bc[0];
|
||||
@@ -105,9 +106,9 @@ public static class ModbusRtuFraming
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
|
||||
// variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
|
||||
// surface as a desync — revisit the FC-shape table if the driver starts emitting one.
|
||||
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). FC23 is
|
||||
// variable-length and handled above; any NEW variable-length response FC must be added there
|
||||
// or it is mis-sized here and surfaces as a desync.
|
||||
trailing = 4 + 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
private readonly Stream? _testStream;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private bool _disposed;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpTransport"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus gateway.</param>
|
||||
@@ -90,8 +90,8 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
|
||||
if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
|
||||
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
|
||||
if (_life is null && _testStream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
// Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
|
||||
// at a time — serialise every send/receive under the gate.
|
||||
@@ -101,11 +101,14 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
// Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
|
||||
// the configured threshold, tear it down + reconnect before this PDU lands. Defends
|
||||
// against silent NAT / firewall reaping.
|
||||
if (_life is not null && _life.ShouldReconnectForIdle())
|
||||
{
|
||||
if (_life is not null && _life.Stream is not null && _life.ShouldReconnectForIdle())
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Self-heal (real socket only): a socket torn down by an earlier failure is re-dialled here,
|
||||
// once, throttled by the backoff window — same contract as ModbusTcpTransport.
|
||||
var freshlyConnected = _life is not null && _life.Stream is null;
|
||||
if (freshlyConnected)
|
||||
await _life!.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -113,14 +116,16 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
_life?.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
catch (Exception ex) when (!freshlyConnected && _life is not null && _life.AutoReconnect
|
||||
&& !ct.IsCancellationRequested
|
||||
&& ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||
// configured), resend. Single retry — a second failure propagates so health/status
|
||||
// reflect reality. Never reached in the ForTest seam (a raw injected stream has no
|
||||
// lifecycle to reconnect).
|
||||
// Mid-transaction drop: tear down the dead socket, make ONE throttled reconnect attempt
|
||||
// (never a sleep inside the gate), resend. Single retry — a second failure propagates so
|
||||
// health/status reflect reality. Never reached in the ForTest seam (a raw injected stream
|
||||
// has no lifecycle to reconnect).
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
@@ -182,9 +187,8 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
|
||||
_gate.Dispose();
|
||||
// _gate is deliberately NOT disposed: a SendAsync still in flight at teardown releases it in its finally.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and
|
||||
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, geometric-backoff
|
||||
/// reconnect, idle-disconnect tracking, and teardown. It exposes the current
|
||||
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, throttled
|
||||
/// reconnect (one attempt per geometric-backoff window), idle-disconnect tracking, and teardown. It exposes the current
|
||||
/// <see cref="NetworkStream"/> so the composing transport can drive its own framing on top —
|
||||
/// the lifecycle knows nothing about MBAP / RTU wire layout.
|
||||
/// </summary>
|
||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
/// Extracted verbatim from <see cref="ModbusTcpTransport"/> so the connect / reconnect /
|
||||
/// keep-alive / idle behaviour is written once and composed by both transports. The
|
||||
/// constructor is connection-free — all socket I/O happens in <see cref="ConnectAsync"/> /
|
||||
/// <see cref="ConnectWithBackoffAsync"/>.
|
||||
/// <see cref="EnsureConnectedAsync"/> (one throttled attempt per call — never a sleep loop).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
|
||||
@@ -37,6 +37,9 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
private NetworkStream? _stream;
|
||||
private DateTime _lastSuccessUtc = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Monotonic millisecond clock for the reconnect throttle (test seam).</summary>
|
||||
internal Func<long> NowMs { get; set; } = () => Environment.TickCount64;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusSocketLifecycle"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||
/// <param name="port">The TCP port of the Modbus server.</param>
|
||||
@@ -71,63 +74,116 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
/// <returns>A task representing the asynchronous connection operation.</returns>
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(_client, _keepAlive);
|
||||
// A previous socket (failed, torn down or idle-reaped) is released FIRST: overwriting _client
|
||||
// used to strand the old TcpClient for the finalizer — up to nine per backoff loop.
|
||||
ThrowIfDisposed();
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
|
||||
// DNS resolution runs under the same per-op deadline as the TCP connect: an unresolvable or
|
||||
// slow resolver must not hold the transport gate for the OS resolver's own (much longer) timeout.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
TcpClient? client = null;
|
||||
try
|
||||
{
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, cts.Token).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(client, _keepAlive);
|
||||
await client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
ThrowIfDisposed(); // disposed while dialling — the finally releases the fresh client
|
||||
_stream = client.GetStream();
|
||||
_client = client;
|
||||
client = null; // ownership transferred
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
// A DisposeAsync that ran between the check above and the publish has already torn down;
|
||||
// tear the fresh socket down ourselves so it is never stranded for the finalizer.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
throw new ObjectDisposedException(nameof(ModbusSocketLifecycle));
|
||||
}
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// The per-op deadline, not the caller: normalise to a socket-level failure so every
|
||||
// classifier treats a connect timeout as a comms fault.
|
||||
throw new ModbusTransportUnavailableException(
|
||||
$"Modbus connect to {_host}:{_port} timed out after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A failed attempt disposes its own client — never leave it for the finalizer.
|
||||
client?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect throttle state. Guarded by the composing transport's single-flight gate (every caller of
|
||||
// EnsureConnectedAsync holds it), so no extra lock is needed.
|
||||
private long _connectNotBeforeMs;
|
||||
private TimeSpan _failureDelay = TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Connect attempt with the configured geometric backoff. The first attempt fires after
|
||||
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
|
||||
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
|
||||
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
|
||||
/// Ensures a live socket for the next transaction with AT MOST ONE connect attempt, throttled by the
|
||||
/// configured geometric backoff: after a failed attempt, calls inside the backoff window fail fast
|
||||
/// with <see cref="ModbusTransportUnavailableException"/> and never dial. The window starts at
|
||||
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (floored at 1 s) and grows by
|
||||
/// <c>BackoffMultiplier</c> per consecutive failure up to <c>MaxDelay</c>; a successful connect
|
||||
/// resets it. Never sleeps — the composing transport calls it inside its gate, and a sleep there
|
||||
/// used to queue every read, write and probe behind one dead socket for up to ~81 s.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token to observe for cancellation.</param>
|
||||
/// <returns>A task representing the asynchronous reconnect operation.</returns>
|
||||
public async Task ConnectWithBackoffAsync(CancellationToken ct)
|
||||
/// <returns>A task that completes when a socket is live.</returns>
|
||||
/// <exception cref="ModbusTransportUnavailableException">Auto-reconnect is off, or the call is inside the backoff window.</exception>
|
||||
public async Task EnsureConnectedAsync(CancellationToken ct)
|
||||
{
|
||||
var delay = _reconnect.InitialDelay;
|
||||
var attempt = 0;
|
||||
while (true)
|
||||
if (_stream is not null) return;
|
||||
if (!_autoReconnect)
|
||||
throw new ModbusTransportUnavailableException(
|
||||
$"Modbus transport to {_host}:{_port} is not connected and auto-reconnect is disabled");
|
||||
|
||||
var now = NowMs();
|
||||
if (now < _connectNotBeforeMs)
|
||||
throw new ModbusTransportUnavailableException(
|
||||
$"Modbus reconnect to {_host}:{_port} throttled for another {_connectNotBeforeMs - now}ms after a failed connect");
|
||||
|
||||
try
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
|
||||
{
|
||||
attempt++;
|
||||
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
|
||||
// pathological multipliers / long deployments.
|
||||
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
|
||||
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
|
||||
if (attempt >= 10)
|
||||
{
|
||||
// Bail after 10 attempts to surface persistent failure to the caller. With
|
||||
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
|
||||
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
_failureDelay = TimeSpan.Zero;
|
||||
_connectNotBeforeMs = 0;
|
||||
}
|
||||
catch (Exception) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_failureDelay = NextDelay(_failureDelay);
|
||||
_connectNotBeforeMs = NowMs() + (long)_failureDelay.TotalMilliseconds;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimum reconnect window after a failed connect — a dead device is dialled at most once per second.</summary>
|
||||
private static readonly TimeSpan MinReconnectWindow = TimeSpan.FromSeconds(1);
|
||||
|
||||
private TimeSpan NextDelay(TimeSpan previous)
|
||||
{
|
||||
if (previous <= TimeSpan.Zero)
|
||||
{
|
||||
var first = _reconnect.InitialDelay > MinReconnectWindow ? _reconnect.InitialDelay : MinReconnectWindow;
|
||||
return first < _reconnect.MaxDelay ? first : _reconnect.MaxDelay > TimeSpan.Zero ? _reconnect.MaxDelay : first;
|
||||
}
|
||||
// Geometric growth, capped. Math.Min on ticks so a pathological multiplier cannot overflow.
|
||||
var multiplier = _reconnect.BackoffMultiplier >= 1.0 ? _reconnect.BackoffMultiplier : 1.0;
|
||||
var nextTicks = previous.Ticks * multiplier;
|
||||
var cap = _reconnect.MaxDelay > TimeSpan.Zero ? _reconnect.MaxDelay.Ticks : long.MaxValue;
|
||||
return TimeSpan.FromTicks(nextTicks >= cap ? cap : (long)nextTicks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -211,11 +267,16 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
// Latch first: a SendAsync still in flight (the driver tearing down under it) must not reconnect
|
||||
// a fresh socket after this point — ConnectAsync checks the latch both before and after dialling.
|
||||
Volatile.Write(ref _disposed, 1);
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private int _disposed;
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusSocketLifecycle));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private ushort _nextTx;
|
||||
private bool _disposed;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||
@@ -56,14 +56,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
|
||||
}
|
||||
|
||||
/// <summary>The socket lifecycle this transport composes (test seam for its clock).</summary>
|
||||
internal ModbusSocketLifecycle Lifecycle => _life;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
@@ -72,11 +74,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
|
||||
// NAT / firewall reaping where the socket looks alive locally but the upstream side
|
||||
// dropped it minutes ago.
|
||||
if (_life.ShouldReconnectForIdle())
|
||||
{
|
||||
if (_life.Stream is not null && _life.ShouldReconnectForIdle())
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Self-heal: a socket torn down by an earlier failure (two lost replies in a row, a refused
|
||||
// reconnect) is re-dialled HERE, once, throttled by the backoff window. This used to throw
|
||||
// "Transport not connected" before the gate, so the transport never recovered on its own and
|
||||
// every call failed fast until the 90 s comms-loss watchdog re-initialised the whole driver.
|
||||
var freshlyConnected = _life.Stream is null;
|
||||
if (freshlyConnected)
|
||||
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -84,13 +91,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
catch (Exception ex) when (!freshlyConnected && _life.AutoReconnect
|
||||
&& !ct.IsCancellationRequested
|
||||
&& ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||
// configured), resend. Single retry — if it fails again, let it propagate so
|
||||
// health/status reflect reality.
|
||||
// Mid-transaction drop on a socket that had been live: tear down the dead socket, make ONE
|
||||
// throttled reconnect attempt (never a sleep inside the gate), resend. Single retry — if it
|
||||
// fails again, let it propagate so health/status reflect reality. A socket this call just
|
||||
// dialled is not retried: it proved nothing about a stale connection.
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
@@ -162,10 +172,22 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
var respPdu = new byte[respLen - 1];
|
||||
await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Length check before indexing: a PDU must carry at least its function code, and an exception
|
||||
// PDU its exception code too. Indexing past the end used to throw IndexOutOfRangeException —
|
||||
// not an IOException, so no teardown/retry, and a malformed reply read as a driver bug.
|
||||
if (respPdu.Length < 1)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
"Modbus response carried no PDU (MBAP length 1)");
|
||||
|
||||
// Exception PDU: function code has high bit set. This is a well-formed protocol-level
|
||||
// error — the socket is still coherent, so it MUST propagate without teardown.
|
||||
if ((respPdu[0] & 0x80) != 0)
|
||||
{
|
||||
if (respPdu.Length < 2)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
$"Modbus exception response fc=0x{respPdu[0]:X2} carried no exception code");
|
||||
var fc = (byte)(respPdu[0] & 0x7F);
|
||||
var ex = respPdu[1];
|
||||
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
|
||||
@@ -206,9 +228,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
await _life.DisposeAsync().ConfigureAwait(false);
|
||||
_gate.Dispose();
|
||||
// _gate is deliberately NOT disposed: a SendAsync still in flight at teardown releases it in its finally.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a Modbus transport has no live socket and cannot get one for this call: the one
|
||||
/// in-gate connect attempt failed, the reconnect is inside its backoff window, or auto-reconnect is
|
||||
/// disabled. It says the ENDPOINT is unreachable, so the driver fails the rest of the batch fast
|
||||
/// instead of paying the same failure once per tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derives from <see cref="IOException"/> so every existing socket-level classifier
|
||||
/// (<c>ModbusSocketLifecycle.IsSocketLevelFailure</c>, the driver's comms-fault mapping) treats it as
|
||||
/// a communication failure without extra plumbing.
|
||||
/// </remarks>
|
||||
public sealed class ModbusTransportUnavailableException : IOException
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusTransportUnavailableException"/> class.</summary>
|
||||
/// <param name="message">A human-readable description.</param>
|
||||
/// <param name="inner">The originating exception, if any.</param>
|
||||
public ModbusTransportUnavailableException(string message, Exception? inner = null)
|
||||
: base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// The driver refused a write BEFORE anything reached the wire (wrong array length, a payload over
|
||||
/// <c>MaxRegistersPerWrite</c>, an unsupported shape). Carries the OPC UA status the write returns;
|
||||
/// it never touches driver health — nothing was sent.
|
||||
/// </summary>
|
||||
public sealed class ModbusWriteRefusedException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusWriteRefusedException"/> class.</summary>
|
||||
/// <param name="statusCode">The OPC UA status the write returns.</param>
|
||||
/// <param name="message">A human-readable description.</param>
|
||||
public ModbusWriteRefusedException(uint statusCode, string message)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
/// <summary>Gets the OPC UA status the write returns.</summary>
|
||||
public uint StatusCode { get; }
|
||||
}
|
||||
@@ -149,6 +149,16 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// </summary>
|
||||
private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on the rebirth request's wait, below the AdminUI's 20 s <c>BrowserSessionService.PerCallTimeout</c>
|
||||
/// (review minor 4): a configured window above it could never take effect there, and a rebirth aimed at an
|
||||
/// offline edge node must not hold the operator's button for the whole window.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan MaxRebirthWait = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>The quiet spell that ends a rebirth request's wait once its births have started arriving.</summary>
|
||||
private static readonly TimeSpan RebirthQuietInterval = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
/// <summary>Bound on the courtesy DISCONNECT issued at dispose — teardown never hangs on a dead broker.</summary>
|
||||
private static readonly TimeSpan DisconnectTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
@@ -174,6 +184,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
private readonly ConcurrentQueue<string> _publishedTopics = new();
|
||||
|
||||
private int _disposed;
|
||||
private long _birthCount;
|
||||
private long _lastUsedTicksUtc = DateTime.UtcNow.Ticks;
|
||||
private int _nodeCount;
|
||||
private int _oversized;
|
||||
@@ -195,9 +206,16 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// <param name="client">The connected MQTT client; disconnected and disposed by <see cref="DisposeAsync"/>.</param>
|
||||
/// <param name="logger">Optional logger; never receives credentials or payload bodies.</param>
|
||||
/// <param name="nodeCap">Cap on recorded nodes; defaults to <see cref="DefaultNodeCap"/>.</param>
|
||||
internal MqttBrowseSession(MqttMode mode, IMqttClient? client, ILogger? logger, int nodeCap = DefaultNodeCap)
|
||||
/// <param name="birthWindow">
|
||||
/// The Sparkplug birth-observation window: how long <see cref="RequestRebirthAsync"/> waits for the
|
||||
/// births it asked for. <see cref="TimeSpan.Zero"/> (the test default) returns as soon as the NCMDs
|
||||
/// are published.
|
||||
/// </param>
|
||||
internal MqttBrowseSession(
|
||||
MqttMode mode, IMqttClient? client, ILogger? logger, int nodeCap = DefaultNodeCap, TimeSpan birthWindow = default)
|
||||
{
|
||||
Mode = mode;
|
||||
BirthWindow = birthWindow;
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
_nodeCap = nodeCap;
|
||||
@@ -211,6 +229,59 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// </summary>
|
||||
internal int PublishCountForTest => Volatile.Read(ref _publishCount);
|
||||
|
||||
/// <summary>The Sparkplug birth-observation window this session was opened with (R06:M-4).</summary>
|
||||
internal TimeSpan BirthWindow { get; }
|
||||
|
||||
/// <summary>How long a rebirth request waits for its births: the window, capped at <see cref="MaxRebirthWait"/>.</summary>
|
||||
internal TimeSpan RebirthWait => BirthWindow < MaxRebirthWait ? BirthWindow : MaxRebirthWait;
|
||||
|
||||
/// <summary>How many NBIRTH/DBIRTH messages this session has observed — the settle loop's signal.</summary>
|
||||
internal long BirthCount => Interlocked.Read(ref _birthCount);
|
||||
|
||||
/// <summary>
|
||||
/// Waits until birth traffic has gone quiet for one <paramref name="quiet"/> interval, bounded by
|
||||
/// <paramref name="window"/> — the <c>UntilStable</c> cadence of the birth-observation window.
|
||||
/// </summary>
|
||||
/// <param name="birthCount">Reads the running birth count.</param>
|
||||
/// <param name="baseline">The count before the wait began.</param>
|
||||
/// <param name="awaitFirstBirth">
|
||||
/// When true, quiet only counts once at least one birth has arrived past <paramref name="baseline"/>
|
||||
/// — the shape a rebirth request needs, since its answer takes a broker round-trip to arrive.
|
||||
/// </param>
|
||||
/// <param name="quiet">The quiet spell that ends the wait.</param>
|
||||
/// <param name="window">The hard ceiling; non-positive returns at once.</param>
|
||||
/// <param name="cancellationToken">Cancels the wait.</param>
|
||||
/// <returns>A task that completes when the births have settled or the window has elapsed.</returns>
|
||||
internal static async Task SettleBirthsAsync(
|
||||
Func<long> birthCount,
|
||||
long baseline,
|
||||
bool awaitFirstBirth,
|
||||
TimeSpan quiet,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (window <= TimeSpan.Zero) return;
|
||||
|
||||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||||
var last = baseline;
|
||||
var sawBirth = !awaitFirstBirth;
|
||||
while (clock.Elapsed < window)
|
||||
{
|
||||
var step = window - clock.Elapsed;
|
||||
await Task.Delay(step < quiet ? step : quiet, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = birthCount();
|
||||
if (now != last)
|
||||
{
|
||||
last = now;
|
||||
sawBirth = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sawBirth) return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The topics this session published, in order — the discriminating view of the above.</summary>
|
||||
internal IReadOnlyList<string> PublishedTopicsForTest => [.. _publishedTopics];
|
||||
|
||||
@@ -380,16 +451,25 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
var mapped = metric.DataType?.ToDriverDataType();
|
||||
var sparkplugName = metric.DataType?.ToString() ?? nameof(SparkplugDataType.Unknown);
|
||||
|
||||
var isArray = metric.DataType?.IsSparkplugArray() ?? false;
|
||||
|
||||
var descriptor = new StringBuilder(metric.BirthType.ToString()).Append(" · ").Append(sparkplugName);
|
||||
if (metric.Alias is { } alias) descriptor.Append(" · alias ").Append(alias);
|
||||
if (mapped is null) descriptor.Append(" · unsupported in v1 — not bindable");
|
||||
else if (isArray) descriptor.Append(" · array metrics are not read in v1 — not bindable");
|
||||
|
||||
// Unbindable = the ingestor refuses it: an unmapped type, or ANY array variant (SparkplugIngestor
|
||||
// gates on the array bit and never feeds the tag). Such a leaf states NO address, so the
|
||||
// browse-commit refuses it at commit instead of writing a tag that sits at
|
||||
// BadWaitingForInitialData forever (R06:M-5). Labelling it alone was not enough — it still bound.
|
||||
var bindable = mapped is not null && !isArray;
|
||||
|
||||
return new AttributeInfo(
|
||||
metric.Name,
|
||||
mapped?.ToString() ?? sparkplugName,
|
||||
metric.DataType?.IsSparkplugArray() ?? false,
|
||||
isArray,
|
||||
descriptor.ToString(),
|
||||
AddressFields: BuildMetricAddressFields(metric.Name, scope));
|
||||
AddressFields: bindable ? BuildMetricAddressFields(metric.Name, scope) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -557,6 +637,8 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
if (!SparkplugTopic.TryParse(topic, out var parsed)) return;
|
||||
if (parsed.Type is not (SparkplugMessageType.NBIRTH or SparkplugMessageType.DBIRTH)) return;
|
||||
|
||||
Interlocked.Increment(ref _birthCount);
|
||||
|
||||
// The bound has to be on the whole message: unlike a plain payload, a birth must be parsed
|
||||
// entire to yield its metric list, and the parse runs on the shared dispatcher thread.
|
||||
if (payload.Length > MaxBirthPayloadBytes)
|
||||
@@ -898,6 +980,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scope);
|
||||
|
||||
var targets = ResolveRebirthTargets(scope);
|
||||
var baseline = BirthCount;
|
||||
|
||||
foreach (var (groupId, edgeNodeId) in targets)
|
||||
{
|
||||
@@ -908,6 +991,20 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// The birth-observation window (R06:M-4): wait — bounded — for the births just asked for to arrive
|
||||
// and settle, so the tree the picker re-renders after this call already carries them. A courtesy,
|
||||
// never a failure: the NCMDs are out, so a caller deadline here still reports the publish.
|
||||
try
|
||||
{
|
||||
await SettleBirthsAsync(
|
||||
() => BirthCount, baseline, awaitFirstBirth: true, RebirthQuietInterval, RebirthWait, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Published; the caller simply stopped waiting for the answer.
|
||||
}
|
||||
|
||||
Touch();
|
||||
return targets.Count;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,15 @@ public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
/// <summary>Ceiling on the open budget — the picker must never hang on an unreachable broker.</summary>
|
||||
internal const int MaxOpenBudgetSeconds = 30;
|
||||
|
||||
/// <summary>Ceiling on the Sparkplug birth-observation window — an authored hour must not park a picker.</summary>
|
||||
internal const int MaxBirthWindowSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// How long the open waits for a quiet spell in birth traffic before handing the session back. An
|
||||
/// idle plant (births are never retained) costs exactly this, once.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan BirthQuietInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>Marks the transient browse identity in the broker's client-id/session logs.</summary>
|
||||
internal const string BrowseClientIdPrefix = "-browse-";
|
||||
|
||||
@@ -77,7 +86,8 @@ public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
openCts.CancelAfter(OpenBudget(opts));
|
||||
|
||||
var client = new MqttClientFactory().CreateMqttClient();
|
||||
var session = new MqttBrowseSession(opts.Mode, client, _logger);
|
||||
var birthWindow = opts.Mode == MqttMode.SparkplugB ? BirthWindow(opts) : TimeSpan.Zero;
|
||||
var session = new MqttBrowseSession(opts.Mode, client, _logger, birthWindow: birthWindow);
|
||||
try
|
||||
{
|
||||
// Runs on MQTTnet's dispatcher: record and return, nothing else.
|
||||
@@ -100,6 +110,29 @@ public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
.Build();
|
||||
await client.SubscribeAsync(subscribeOptions, openCts.Token).ConfigureAwait(false);
|
||||
|
||||
if (opts.Mode == MqttMode.SparkplugB)
|
||||
{
|
||||
// The birth-observation window (R06:M-4): births already in flight — an edge node mid-rebirth,
|
||||
// a primary-host flap — are collected until the traffic goes quiet, bounded by the window, so
|
||||
// the first RootAsync is not a snapshot taken in the middle of a burst. Passive: nothing is
|
||||
// published to provoke a birth. An idle plant costs one quiet interval.
|
||||
try
|
||||
{
|
||||
await MqttBrowseSession.SettleBirthsAsync(
|
||||
() => session.BirthCount,
|
||||
baseline: session.BirthCount,
|
||||
awaitFirstBirth: false,
|
||||
BirthQuietInterval,
|
||||
birthWindow,
|
||||
openCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The open budget ran out mid-settle: what has been observed is still a usable session.
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI MQTT browse session opened against {Host}:{Port} in {Mode} mode observing "
|
||||
+ "'{Filter}' (read-only).",
|
||||
@@ -179,12 +212,30 @@ public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
options with { CleanSession = true };
|
||||
|
||||
/// <summary>
|
||||
/// Whole-open bound (connect + subscribe), clamped to
|
||||
/// <see cref="MinOpenBudgetSeconds"/>–<see cref="MaxOpenBudgetSeconds"/> around the config's
|
||||
/// own connect deadline.
|
||||
/// Whole-open bound: connect + subscribe, clamped to
|
||||
/// <see cref="MinOpenBudgetSeconds"/>–<see cref="MaxOpenBudgetSeconds"/> around the config's own
|
||||
/// connect deadline — plus, in Sparkplug mode, the <see cref="BirthWindow"/> the open may spend
|
||||
/// collecting births.
|
||||
/// </summary>
|
||||
/// <param name="options">The browse configuration.</param>
|
||||
/// <returns>The clamped open budget.</returns>
|
||||
internal static TimeSpan OpenBudget(MqttDriverOptions options) =>
|
||||
TimeSpan.FromSeconds(Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds));
|
||||
/// <returns>The open budget.</returns>
|
||||
internal static TimeSpan OpenBudget(MqttDriverOptions options)
|
||||
{
|
||||
var connect = TimeSpan.FromSeconds(
|
||||
Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds));
|
||||
return options.Mode == MqttMode.SparkplugB ? connect + BirthWindow(options) : connect;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authored <c>sparkplug.birthObservationWindowSeconds</c>, clamped to 1–<see cref="MaxBirthWindowSeconds"/>
|
||||
/// s: how long the browse surface collects NBIRTH/DBIRTH traffic before calling the observed metric set
|
||||
/// stable (on open, and after an operator rebirth request).
|
||||
/// </summary>
|
||||
/// <param name="options">The browse configuration.</param>
|
||||
/// <returns>The clamped window.</returns>
|
||||
internal static TimeSpan BirthWindow(MqttDriverOptions options) =>
|
||||
TimeSpan.FromSeconds(Math.Clamp(
|
||||
options.Sparkplug?.BirthObservationWindowSeconds ?? new MqttSparkplugOptions().BirthObservationWindowSeconds,
|
||||
1,
|
||||
MaxBirthWindowSeconds));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,30 @@ public sealed class LastValueCache
|
||||
_values[rawPath] = snapshot;
|
||||
}
|
||||
|
||||
/// <summary>How many RawPaths currently hold an entry. Diagnostics and the bounded-growth tests.</summary>
|
||||
internal int Count => _values.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Drops every entry whose RawPath <paramref name="keep"/> rejects — called by each ingest path's
|
||||
/// <c>Register</c> with "is this still an authored tag", so the cache is bounded by the CURRENT
|
||||
/// authored set rather than by the union of every RawPath (and every non-authored reference ever
|
||||
/// subscribed) across the driver's lifetime (R06:M-2).
|
||||
/// </summary>
|
||||
/// <param name="keep">Returns <see langword="true"/> for a RawPath whose entry must survive.</param>
|
||||
/// <returns>How many entries were dropped.</returns>
|
||||
public int RetainOnly(Func<string, bool> keep)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keep);
|
||||
var dropped = 0;
|
||||
foreach (var rawPath in _values.Keys)
|
||||
{
|
||||
if (!keep(rawPath) && _values.TryRemove(rawPath, out _))
|
||||
dropped++;
|
||||
}
|
||||
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the last observed value for <paramref name="rawPath"/>. Never throws: a
|
||||
/// <c>null</c>/empty <paramref name="rawPath"/> or a RawPath never observed both return a
|
||||
|
||||
@@ -104,6 +104,12 @@ public sealed class MqttDriver
|
||||
/// </summary>
|
||||
private const string DiscoveryFolderName = "Mqtt";
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on PUBLISHes one write batch keeps in flight (R06:M-8). Bounded so a large batch cannot
|
||||
/// flood a broker's inflight window; different topics share it, one topic's writes stay sequential.
|
||||
/// </summary>
|
||||
internal const int MaxConcurrentPublishes = 8;
|
||||
|
||||
/// <summary>How often the host-connectivity poll samples <see cref="MqttConnection.State"/>.</summary>
|
||||
private static readonly TimeSpan HostProbeInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
@@ -208,6 +214,18 @@ public sealed class MqttDriver
|
||||
private SparkplugPrimaryHost? _primaryHost;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>UTC ticks of the last successful write PUBLISH; 0 = none. Answer evidence for health.</summary>
|
||||
private long _lastPublishUtcTicks;
|
||||
|
||||
/// <summary>Guards the generation bump against the generation-checked health write.</summary>
|
||||
private readonly object _healthLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bumped by every lifecycle operation (initialize inside the gate; shutdown and dispose before
|
||||
/// waiting for it). The stamp that keeps a superseded operation from publishing state.
|
||||
/// </summary>
|
||||
private long _lifecycleGeneration;
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
private int _disposed;
|
||||
@@ -289,6 +307,9 @@ public sealed class MqttDriver
|
||||
/// </summary>
|
||||
internal Func<CancellationToken, Task>? BeforeConnectHookForTests { get; set; }
|
||||
|
||||
/// <summary>Test seam: observes every <see cref="MqttConnection"/> an initialize builds. Null in production.</summary>
|
||||
internal Action<MqttConnection>? ConnectionCreatedForTests { get; set; }
|
||||
|
||||
// ---- IDriver: lifecycle ----
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -387,6 +408,11 @@ public sealed class MqttDriver
|
||||
// watchdog's reconnect) is measured against, reporting a driver that was delivering a moment ago
|
||||
// as one that has never delivered.
|
||||
var lastMessage = GetHealth().LastSuccessfulRead;
|
||||
|
||||
// Taken BEFORE the gate wait: a wedged initialize holding the gate is superseded by this call, so
|
||||
// when it finally completes it disposes the session it built instead of publishing it behind the
|
||||
// ungated teardown below (R06:M-10), and its late health write is refused.
|
||||
var generation = BeginLifecycleOperation();
|
||||
if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds), cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
@@ -408,8 +434,10 @@ public sealed class MqttDriver
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, lastMessage, null));
|
||||
TransitionHost(HostState.Unknown);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, lastMessage, null)))
|
||||
{
|
||||
TransitionHost(HostState.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -427,7 +455,7 @@ public sealed class MqttDriver
|
||||
return stored;
|
||||
}
|
||||
|
||||
var lastMessage = connection.LastMessageUtc ?? stored.LastSuccessfulRead;
|
||||
var lastMessage = Latest(connection.LastMessageUtc ?? stored.LastSuccessfulRead, LastPublishUtc);
|
||||
return connection.State switch
|
||||
{
|
||||
MqttConnectionState.Connected => new DriverHealth(DriverState.Healthy, lastMessage, stored.LastError),
|
||||
@@ -689,9 +717,12 @@ public sealed class MqttDriver
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
|
||||
var results = new WriteResult[writes.Count];
|
||||
var results = new WriteResult?[writes.Count];
|
||||
var connection = _connection;
|
||||
|
||||
// Pass 1 — everything that needs no broker: resolve, encode, refuse. What survives is grouped by
|
||||
// destination topic in REQUEST order.
|
||||
var byTopic = new Dictionary<string, List<(int Index, MqttEncodedWrite Encoded, string Reference)>>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
@@ -719,22 +750,72 @@ public sealed class MqttDriver
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byTopic.TryGetValue(encoded.Topic, out var queue))
|
||||
{
|
||||
byTopic[encoded.Topic] = queue = [];
|
||||
}
|
||||
|
||||
queue.Add((i, encoded, w.FullReference));
|
||||
}
|
||||
|
||||
// Pass 2 — publish (R06:M-8). Different topics go out concurrently, at most MaxConcurrentPublishes
|
||||
// in flight, so a stalled broker costs a batch ~one publish deadline instead of one per request.
|
||||
// Writes to the SAME topic stay strictly sequential in request order: the device applies them in
|
||||
// arrival order, and the last one must win.
|
||||
if (byTopic.Count > 0)
|
||||
{
|
||||
using var slots = new SemaphoreSlim(MaxConcurrentPublishes, MaxConcurrentPublishes);
|
||||
await Task.WhenAll(byTopic.Values.Select(queue => PublishQueueAsync(connection!, queue, slots, results, cancellationToken)))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Every slot is filled — a caller cancellation leaves the unattempted ones BadCommunicationError
|
||||
// rather than throwing, so the batch shape holds.
|
||||
var filled = new WriteResult[writes.Count];
|
||||
for (var i = 0; i < filled.Length; i++)
|
||||
{
|
||||
filled[i] = results[i] ?? new WriteResult(StatusBadCommunicationError);
|
||||
}
|
||||
|
||||
return filled;
|
||||
}
|
||||
|
||||
/// <summary>Publishes one topic's writes in request order, each under a shared concurrency slot.</summary>
|
||||
private async Task PublishQueueAsync(
|
||||
MqttConnection connection,
|
||||
List<(int Index, MqttEncodedWrite Encoded, string Reference)> queue,
|
||||
SemaphoreSlim slots,
|
||||
WriteResult?[] results,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var (index, encoded, reference) in queue)
|
||||
{
|
||||
try
|
||||
{
|
||||
await slots.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return; // caller-cancelled: the remaining slots are filled by the caller
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await connection
|
||||
.PublishAsync(encoded.Topic, encoded.Payload, encoded.Qos, retain: false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
results[index] = new WriteResult(0u);
|
||||
|
||||
// A PUBACK is the broker answering — evidence the session works, so it feeds
|
||||
// LastSuccessfulRead alongside inbound messages. A completed QoS 0 publish is NOT: it only
|
||||
// means the bytes reached the local socket buffer, and frequent Sparkplug commands would
|
||||
// otherwise keep the watchdog's clock fresh over a half-open session (review minor 2).
|
||||
if (encoded.Qos >= 1) Interlocked.Exchange(ref _lastPublishUtcTicks, DateTime.UtcNow.Ticks);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Caller-cancelled: fill the remainder rather than throwing, so the batch shape holds.
|
||||
for (var j = i; j < writes.Count; j++)
|
||||
{
|
||||
results[j] = new WriteResult(StatusBadCommunicationError);
|
||||
}
|
||||
|
||||
return results;
|
||||
results[index] = new WriteResult(StatusBadCommunicationError);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -742,13 +823,15 @@ public sealed class MqttDriver
|
||||
ex,
|
||||
"MQTT driver '{DriverId}': write to '{Reference}' could not be published on '{Topic}'.",
|
||||
_driverInstanceId,
|
||||
w.FullReference,
|
||||
reference,
|
||||
encoded.Topic);
|
||||
results[i] = new WriteResult(StatusBadCommunicationError);
|
||||
results[index] = new WriteResult(StatusBadCommunicationError);
|
||||
}
|
||||
finally
|
||||
{
|
||||
slots.Release();
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Maps an encode refusal to the status an OPC UA client sees.</summary>
|
||||
@@ -911,6 +994,9 @@ public sealed class MqttDriver
|
||||
return;
|
||||
}
|
||||
|
||||
// Supersedes any in-flight lifecycle operation, exactly as ShutdownAsync does.
|
||||
BeginLifecycleOperation();
|
||||
|
||||
if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds))
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
@@ -940,7 +1026,12 @@ public sealed class MqttDriver
|
||||
/// <summary>The gate-held body of <see cref="InitializeAsync"/>.</summary>
|
||||
private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
|
||||
// Taken INSIDE the gate (the caller holds it), so a second initialize queued behind this one does
|
||||
// not supersede it; only a ShutdownAsync / DisposeAsync — which take theirs before waiting — can.
|
||||
var generation = BeginLifecycleOperation();
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
|
||||
MqttConnection? built = null;
|
||||
var published = false;
|
||||
try
|
||||
{
|
||||
// Tear down whatever this driver already holds BEFORE building a new session. Both fields
|
||||
@@ -965,6 +1056,8 @@ public sealed class MqttDriver
|
||||
RegisterAuthoredTags(_options);
|
||||
|
||||
var connection = new MqttConnection(_options, _driverInstanceId, _logger);
|
||||
built = connection;
|
||||
ConnectionCreatedForTests?.Invoke(connection);
|
||||
|
||||
// MUST precede the connect: this wires message delivery AND the reconnect re-subscribe.
|
||||
// The handler is passed through unwrapped on purpose — its throw-on-total-failure is what
|
||||
@@ -992,10 +1085,13 @@ public sealed class MqttDriver
|
||||
// disposing what we just built, never leaking it.
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw new ObjectDisposedException(nameof(MqttDriver));
|
||||
}
|
||||
|
||||
// The same race against a ShutdownAsync whose bounded gate wait expired: it has already torn
|
||||
// down ungated, so publishing now would leave a live session no later shutdown can see.
|
||||
ThrowIfSuperseded(generation);
|
||||
|
||||
// Sparkplug subscribes ONCE, here, rather than on the OPC UA server's first subscribe: the
|
||||
// driver must see birth certificates whether or not anything is subscribed yet, because a
|
||||
// birth is where every alias and datatype comes from. This also issues the late-join
|
||||
@@ -1008,28 +1104,47 @@ public sealed class MqttDriver
|
||||
// MqttConnection's own remarks describe having already fixed once.
|
||||
if (_sparkplug is { } sparkplug)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sparkplug.EstablishAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
await sparkplug.EstablishAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ThrowIfSuperseded(generation);
|
||||
_connection = connection;
|
||||
published = true;
|
||||
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null)))
|
||||
{
|
||||
TransitionHost(HostState.Running);
|
||||
}
|
||||
|
||||
WriteHealth(new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null));
|
||||
TransitionHost(HostState.Running);
|
||||
StartHostProbe();
|
||||
await StartPrimaryHostAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Re-checked after publishing: a shutdown whose gate wait expired between the check above and
|
||||
// here captured whatever it found and left these fields for us. Intended consequence (review
|
||||
// minor 8): a perfectly healthy init that is just completing when a ShutdownAsync arrives also
|
||||
// releases itself and throws OperationCanceledException — the queued shutdown would tear it down
|
||||
// anyway, and the actor serialises init/shutdown, so only an abandoned init can meet this. We still hold the gate, so no
|
||||
// newer initialize can have published anything — everything in them is ours to release.
|
||||
if (!IsCurrentLifecycle(generation))
|
||||
{
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
ThrowIfSuperseded(generation);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message));
|
||||
TransitionHost(HostState.Faulted);
|
||||
// R06:M-3: a connection built but never published is disposed here — a refused connect, a
|
||||
// failed Sparkplug establish, a supersede. The published one belongs to TeardownAsync.
|
||||
if (!published && built is not null)
|
||||
{
|
||||
await built.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message)))
|
||||
{
|
||||
TransitionHost(HostState.Faulted);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -1380,24 +1495,28 @@ public sealed class MqttDriver
|
||||
/// <summary>Shared teardown for <see cref="ShutdownAsync"/>, <see cref="DisposeAsync"/> and the session rebuild.</summary>
|
||||
private async Task TeardownAsync()
|
||||
{
|
||||
// Every field is CAPTURED before anything is disposed. A shutdown that timed out on the gate runs
|
||||
// this ungated; capturing up front means it only ever releases what existed when it started, and
|
||||
// never reaches into fields a later initialize publishes while the slow disposals below run.
|
||||
var primaryHost = Interlocked.Exchange(ref _primaryHost, null);
|
||||
var probe = Interlocked.Exchange(ref _hostProbeCts, null);
|
||||
var connection = Interlocked.Exchange(ref _connection, null);
|
||||
|
||||
// Stand the host claim down FIRST, while the broker is still reachable and before the ingest
|
||||
// session goes. Its explicit retained OFFLINE is what makes the transition immediate for edge
|
||||
// nodes; leaving it to the Will costs them up to 1.5 keep-alive periods of believing a host
|
||||
// that has already stopped is still online.
|
||||
var primaryHost = Interlocked.Exchange(ref _primaryHost, null);
|
||||
if (primaryHost is not null)
|
||||
{
|
||||
await primaryHost.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var probe = Interlocked.Exchange(ref _hostProbeCts, null);
|
||||
if (probe is not null)
|
||||
{
|
||||
await probe.CancelAsync().ConfigureAwait(false);
|
||||
probe.Dispose();
|
||||
}
|
||||
|
||||
var connection = Interlocked.Exchange(ref _connection, null);
|
||||
if (connection is not null)
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
@@ -1476,9 +1595,66 @@ public sealed class MqttDriver
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, previous, next));
|
||||
}
|
||||
|
||||
/// <summary>The last successful write publish, or null.</summary>
|
||||
private DateTime? LastPublishUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _lastPublishUtcTicks);
|
||||
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The later of two optional instants.</summary>
|
||||
private static DateTime? Latest(DateTime? a, DateTime? b)
|
||||
=> a is null ? b : b is null ? a : (a > b ? a : b);
|
||||
|
||||
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
|
||||
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
||||
|
||||
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
|
||||
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
||||
/// <summary>
|
||||
/// Starts a lifecycle operation and returns its generation. Every later operation supersedes it:
|
||||
/// its health / host-state writes are refused and an initialize stops before publishing a session.
|
||||
/// </summary>
|
||||
private long BeginLifecycleOperation()
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
return ++_lifecycleGeneration;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="generation"/> is still the newest lifecycle operation.</summary>
|
||||
private bool IsCurrentLifecycle(long generation) => Interlocked.Read(ref _lifecycleGeneration) == generation;
|
||||
|
||||
/// <summary>Throws when a newer lifecycle operation has superseded <paramref name="generation"/>.</summary>
|
||||
private void ThrowIfSuperseded(long generation)
|
||||
{
|
||||
if (!IsCurrentLifecycle(generation))
|
||||
{
|
||||
throw new OperationCanceledException(
|
||||
$"MQTT driver '{_driverInstanceId}': this initialize was superseded by a later shutdown/dispose; "
|
||||
+ "the session it built has been released.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a health snapshot only while <paramref name="generation"/> is the newest lifecycle
|
||||
/// operation — the check and the write are atomic against <see cref="BeginLifecycleOperation"/>, so a
|
||||
/// superseded initialize or shutdown can never overwrite a newer one's state.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> when the write was applied.</returns>
|
||||
private bool WriteHealthIfCurrent(long generation, DriverHealth value)
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
if (_lifecycleGeneration != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _health, value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
/// accepted by the parser (warned at deploy, see <c>MqttTagDefinitionFactory.Inspect</c>), so
|
||||
/// matching uses MQTTnet's own <see cref="MqttTopicFilterComparer"/> rather than string
|
||||
/// equality — the same comparer a broker applies. Concrete topics stay a single dictionary
|
||||
/// lookup; the linear wildcard scan runs only when a wildcard tag actually exists.
|
||||
/// lookup; wildcard filters live in a level trie (<c>TopicFilterTrie</c>) whose candidates the comparer
|
||||
/// confirms, so a thousand wildcard tags cost a walk over the topic's levels, not a thousand compares.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sparkplug seam.</b> P1 ingests plain topics only. Sparkplug B decodes a
|
||||
@@ -74,6 +75,13 @@ public sealed class MqttSubscriptionManager
|
||||
/// </summary>
|
||||
public const int DefaultQos = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Throw-on-invalid UTF-8, shared (R06:M-7): it is stateless and thread-safe, and allocating one per
|
||||
/// Raw/Scalar delivery was per-message garbage on the dispatcher thread. Same choice
|
||||
/// <c>MqttBrowseSession</c> makes.
|
||||
/// </summary>
|
||||
private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
// OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
|
||||
private const uint StatusGood = 0x00000000u;
|
||||
|
||||
@@ -113,6 +121,14 @@ public sealed class MqttSubscriptionManager
|
||||
/// <summary>Topics already SUBSCRIBEd on the live session — the dedupe set and the re-subscribe set.</summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _subscribedTopics = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Topics a subscribe asked for whose SUBSCRIBE failed (refused, or no verdict at all). Retried by
|
||||
/// every later <see cref="SubscribeAsync"/> and by <see cref="OnReconnectedAsync"/>; moved to
|
||||
/// <see cref="_subscribedTopics"/> when granted. Without it a failed topic left every retry set and
|
||||
/// stayed dark behind a Healthy connection until a redeploy.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _pendingTopics = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>RawPaths whose extraction failure has already been logged loudly once.</summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _warnedRefs = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -212,8 +228,8 @@ public sealed class MqttSubscriptionManager
|
||||
/// The manager does not own the connection's lifetime and never detaches — the driver disposes
|
||||
/// both together. Call once per connection, and <b>before</b> the first
|
||||
/// <see cref="SubscribeAsync"/>: a subscribe issued with no transport attached records its
|
||||
/// intent (and warns) but sends nothing to a broker, and would only be established if the
|
||||
/// connection later dropped and recovered.
|
||||
/// topics as <i>pending</i> (and warns) but sends nothing to a broker; the next subscribe or
|
||||
/// reconnect on an attached session retries them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Binding to a session forgets the previous session's established filters</b> — the same
|
||||
@@ -317,6 +333,11 @@ public sealed class MqttSubscriptionManager
|
||||
_authored = table;
|
||||
_warnedRefs.Clear();
|
||||
|
||||
// Bound the shared last-value cache by the CURRENT authored set (R06:M-2): a dropped tag's value
|
||||
// and every non-authored reference's BadNodeIdUnknown marker would otherwise live forever. A
|
||||
// still-authored tag keeps its value, so a redeploy never blanks what has been observed.
|
||||
Values.RetainOnly(table.ByRawPath.ContainsKey);
|
||||
|
||||
// Drop state belonging to tags this deploy removed. Without this, a topic whose last tag was
|
||||
// deleted stays in _subscribedTopics and is dutifully re-SUBSCRIBEd on every future reconnect,
|
||||
// forever — and its RawPaths keep a handle mapping that can never fire again.
|
||||
@@ -328,6 +349,14 @@ public sealed class MqttSubscriptionManager
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var topic in _pendingTopics.Keys)
|
||||
{
|
||||
if (!table.ByFilter.ContainsKey(topic))
|
||||
{
|
||||
_pendingTopics.TryRemove(topic, out _);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var rawPath in _handleByRawPath.Keys)
|
||||
{
|
||||
if (!table.ByRawPath.ContainsKey(rawPath))
|
||||
@@ -356,12 +385,13 @@ public sealed class MqttSubscriptionManager
|
||||
/// opening SUBSCRIBE itself; <see cref="OnReconnectedAsync"/> only re-establishes it later.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Never throws, never hangs.</b> A reference that is not an authored tag is recorded as
|
||||
/// <c>BadNodeIdUnknown</c>; a SUBACK rejection, transport failure or subscribe deadline
|
||||
/// degrades exactly the affected references to <c>BadCommunicationError</c>. Either way a
|
||||
/// handle is returned — the OPC UA server's subscribe call must not fail because one topic
|
||||
/// is ACL-denied. Recovery for a transient failure comes from the reconnect path;
|
||||
/// a persistent broker-side refusal is visible as a Bad status on the affected nodes.
|
||||
/// <b>Never hangs; throws only when the broker gave no verdict.</b> A reference that is not an
|
||||
/// authored tag is recorded as <c>BadNodeIdUnknown</c>. A SUBACK rejection degrades exactly the
|
||||
/// affected references to <c>BadCommunicationError</c> and still returns a handle — the OPC UA
|
||||
/// server's subscribe must not fail because one topic is ACL-denied. A transport failure or
|
||||
/// subscribe deadline degrades the same way and then <b>throws</b>, so the host holds no handle
|
||||
/// and its reconcile re-subscribes. Every failed topic also joins a pending set that the next
|
||||
/// subscribe and every reconnect retry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="fullReferences">The RawPath references to subscribe.</param>
|
||||
@@ -414,12 +444,56 @@ public sealed class MqttSubscriptionManager
|
||||
|
||||
// One SUBSCRIBE per distinct topic, and only for topics not already live: re-SUBSCRIBEing an
|
||||
// established filter is legal but pointless, and it re-triggers the retained seed.
|
||||
var pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic)).ToList();
|
||||
await EstablishAsync(BuildFilters(pending, _defaultQos), cancellationToken).ConfigureAwait(false);
|
||||
// Topics an EARLIER subscribe failed to establish ride along: a redeploy's bulk re-subscribe is then a
|
||||
// retry of a refusal the broker may since have lifted, instead of a no-op that leaves them dark.
|
||||
var authored = _authored;
|
||||
var pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic))
|
||||
.Concat(PendingDefinitions(authored))
|
||||
.ToList();
|
||||
var outcome = await EstablishAsync(BuildFilters(pending, _defaultQos), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (outcome.TransportFailure is { } failure)
|
||||
{
|
||||
// The refs are already Bad and pending either way. But a cancel of the CALLER's token (a pipeline
|
||||
// timeout, host shutdown) is a cancellation, not a broker fault: surfacing it as
|
||||
// InvalidOperationException made the resilience breaker count it as a failure (review minor 3).
|
||||
var callerCancelled = cancellationToken.IsCancellationRequested;
|
||||
// No verdict at all (transport throw / SUBSCRIBE deadline): THROW, so the caller holds no handle
|
||||
// and DriverInstanceActor's health-tick reconcile re-subscribes. Returning a handle here was the
|
||||
// silent-death case — the actor thought the subscription live and never retried. The refs already
|
||||
// carry BadCommunicationError (raised under this handle above), so the throw costs no visibility.
|
||||
_refsByHandle.TryRemove(handle, out _);
|
||||
foreach (var def in defs)
|
||||
{
|
||||
_handleByRawPath.TryRemove(new KeyValuePair<string, ISubscriptionHandle>(def.Name, handle));
|
||||
}
|
||||
|
||||
if (callerCancelled)
|
||||
{
|
||||
throw new OperationCanceledException(
|
||||
$"MQTT driver '{_driverId}': SUBSCRIBE for {pending.Count} tag(s) was cancelled by the caller; "
|
||||
+ "the topics stay pending and will be retried.",
|
||||
failure,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"MQTT driver '{_driverId}': SUBSCRIBE for {pending.Count} tag(s) got no broker verdict "
|
||||
+ $"({failure.GetType().Name}); the subscription will be retried.",
|
||||
failure);
|
||||
}
|
||||
|
||||
// A SUBACK refusal (an ACL denial, say) does NOT throw: the broker answered, the refused topics are
|
||||
// Bad + pending, and failing the whole subscribe would take every other topic in it dark too.
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>The authored definitions behind every still-authored pending topic.</summary>
|
||||
private IEnumerable<MqttTagDefinition> PendingDefinitions(AuthoredTable authored)
|
||||
=> _pendingTopics.Keys
|
||||
.Select(topic => authored.ByFilter.TryGetValue(topic, out var defs) ? defs : [])
|
||||
.SelectMany(defs => defs);
|
||||
|
||||
/// <summary>
|
||||
/// Stops attributing notifications to <paramref name="handle"/>. References another live
|
||||
/// subscription also covers keep publishing.
|
||||
@@ -498,8 +572,13 @@ public sealed class MqttSubscriptionManager
|
||||
.SelectMany(defs => defs)
|
||||
.ToList();
|
||||
|
||||
var filters = BuildFilters(live, _defaultQos);
|
||||
if (filters.Count == 0)
|
||||
// Pending topics (a failed earlier SUBSCRIBE) are retried here too — a reconnect re-subscribing
|
||||
// only the granted set is how a failed topic stayed dark forever. They do NOT count toward the
|
||||
// total-failure verdict below: re-refusing a topic that was never live loses nothing, and throwing
|
||||
// for it would turn one ACL-denied topic into a reconnect loop for the whole driver.
|
||||
var pendingDefs = PendingDefinitions(authored).ToList();
|
||||
var filters = BuildFilters(live.Concat(pendingDefs), _defaultQos);
|
||||
if (live.Count == 0 && pendingDefs.Count == 0)
|
||||
{
|
||||
// Silence here is how the wildcard defect hid: no exception, no log, no SUBSCRIBE. Live
|
||||
// topics resolving to zero filters re-establishes nothing, which is the total-failure case
|
||||
@@ -519,13 +598,17 @@ public sealed class MqttSubscriptionManager
|
||||
return;
|
||||
}
|
||||
|
||||
var granted = await EstablishAsync(filters, cancellationToken).ConfigureAwait(false);
|
||||
if (granted == 0)
|
||||
var outcome = await EstablishAsync(filters, cancellationToken).ConfigureAwait(false);
|
||||
var liveTopics = live.Select(d => d.Topic).Distinct(StringComparer.Ordinal).ToList();
|
||||
var liveLost = outcome.TransportFailure is not null
|
||||
|| (subscribed.Count > 0 && (liveTopics.Count == 0 || !liveTopics.Any(outcome.Granted.Contains)));
|
||||
if (liveLost)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MQTT driver '{_driverId}': no subscription could be re-established after reconnect "
|
||||
+ $"({filters.Count} filter(s) requested); tearing the session down rather than serving a "
|
||||
+ "connection that receives nothing.");
|
||||
+ "connection that receives nothing.",
|
||||
outcome.TransportFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,13 +665,14 @@ public sealed class MqttSubscriptionManager
|
||||
}
|
||||
|
||||
// A published topic name may not itself contain a wildcard, so a wildcard-authored
|
||||
// definition can never also be an exact-index hit: no tag is delivered twice.
|
||||
foreach (var def in authored.Wildcards)
|
||||
// definition can never also be an exact-index hit: no tag is delivered twice. The trie walks
|
||||
// the topic's levels (R06:M-6 — this used to be one comparer call per wildcard tag, per
|
||||
// message, on the shared dispatcher thread) and every candidate it yields is confirmed by
|
||||
// MQTTnet's own comparer inside the visitor, so the semantics are the broker's.
|
||||
if (authored.WildcardTrie.Count > 0)
|
||||
{
|
||||
if (MqttTopicFilterComparer.Compare(topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch)
|
||||
{
|
||||
Deliver(def, payload, retained, now, oversized, ref json);
|
||||
}
|
||||
var visitor = new WildcardDelivery(this, topic, payload, retained, now, oversized, ref json);
|
||||
authored.WildcardTrie.Match(topic, ref visitor);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -633,35 +717,35 @@ public sealed class MqttSubscriptionManager
|
||||
/// </summary>
|
||||
/// <param name="filters">The filters to establish; an empty list is a no-op.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the round-trip.</param>
|
||||
/// <returns>How many filters the broker granted.</returns>
|
||||
private async Task<int> EstablishAsync(
|
||||
/// <returns>The granted topics, plus the transport failure when the broker gave no verdict at all.</returns>
|
||||
private async Task<EstablishOutcome> EstablishAsync(
|
||||
IReadOnlyList<MqttTopicSubscription> filters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (filters.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
return EstablishOutcome.Nothing;
|
||||
}
|
||||
|
||||
var transport = _transport;
|
||||
if (transport is null)
|
||||
{
|
||||
// No transport: the intent is recorded so the reconnect path establishes it, but NOTHING
|
||||
// reaches a broker now. Loud, because outside tests this means the composition order is
|
||||
// wrong — AttachTo must run before the OPC UA server's first subscribe, or these topics
|
||||
// stay unestablished until the connection happens to drop and recover.
|
||||
// No transport: NOTHING reaches a broker, so nothing is recorded as subscribed — recording it
|
||||
// was a silent "subscribed, receives nothing" if the AttachTo-before-subscribe ordering ever
|
||||
// changed. The topics wait in the pending set, which the next subscribe and every reconnect
|
||||
// retry. Loud, because outside tests this means the composition order is wrong.
|
||||
foreach (var filter in filters)
|
||||
{
|
||||
_subscribedTopics[filter.Topic] = 0;
|
||||
_pendingTopics[filter.Topic] = 0;
|
||||
}
|
||||
|
||||
_logger?.LogWarning(
|
||||
"MQTT driver '{DriverId}': {FilterCount} subscription(s) recorded with no connection attached; "
|
||||
+ "nothing was sent to a broker. Call AttachTo before subscribing.",
|
||||
"MQTT driver '{DriverId}': {FilterCount} subscription(s) requested with no connection attached; "
|
||||
+ "nothing was sent to a broker and they stay pending. Call AttachTo before subscribing.",
|
||||
_driverId,
|
||||
filters.Count);
|
||||
|
||||
return filters.Count;
|
||||
return EstablishOutcome.Nothing;
|
||||
}
|
||||
|
||||
IReadOnlyList<MqttSubscribeOutcome> outcomes;
|
||||
@@ -681,16 +765,17 @@ public sealed class MqttSubscriptionManager
|
||||
DegradeTopic(filter.Topic, ex.GetType().Name);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return new EstablishOutcome(new HashSet<string>(StringComparer.Ordinal), ex);
|
||||
}
|
||||
|
||||
var granted = 0;
|
||||
var granted = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
if (outcome.Granted)
|
||||
{
|
||||
_subscribedTopics[outcome.Topic] = 0;
|
||||
granted++;
|
||||
_pendingTopics.TryRemove(outcome.Topic, out _);
|
||||
granted.Add(outcome.Topic);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -703,7 +788,16 @@ public sealed class MqttSubscriptionManager
|
||||
}
|
||||
}
|
||||
|
||||
return granted;
|
||||
return new EstablishOutcome(granted, null);
|
||||
}
|
||||
|
||||
/// <summary>One SUBSCRIBE round-trip's verdict.</summary>
|
||||
/// <param name="Granted">The filters the broker granted.</param>
|
||||
/// <param name="TransportFailure">Set when the call produced no verdict at all (throw / deadline).</param>
|
||||
private sealed record EstablishOutcome(IReadOnlySet<string> Granted, Exception? TransportFailure)
|
||||
{
|
||||
public static readonly EstablishOutcome Nothing =
|
||||
new(new HashSet<string>(StringComparer.Ordinal), null);
|
||||
}
|
||||
|
||||
/// <summary>Marks every authored reference on <paramref name="topic"/> as unreachable.</summary>
|
||||
@@ -722,6 +816,7 @@ public sealed class MqttSubscriptionManager
|
||||
|
||||
if (!_authored.ByFilter.TryGetValue(topic, out var defs))
|
||||
{
|
||||
_pendingTopics.TryRemove(topic, out _);
|
||||
_logger?.LogWarning(
|
||||
"MQTT driver '{DriverId}': cannot degrade tags for unestablished filter '{Topic}' ({Reason}) — "
|
||||
+ "no authored tag resolves to it.",
|
||||
@@ -731,6 +826,9 @@ public sealed class MqttSubscriptionManager
|
||||
return;
|
||||
}
|
||||
|
||||
// Still authored, so it must be retried — see _pendingTopics.
|
||||
_pendingTopics[topic] = 0;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var snapshot = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
foreach (var def in defs)
|
||||
@@ -1371,8 +1469,7 @@ public sealed class MqttSubscriptionManager
|
||||
{
|
||||
try
|
||||
{
|
||||
text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)
|
||||
.GetString(payload);
|
||||
text = Utf8Strict.GetString(payload);
|
||||
return true;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
@@ -1400,6 +1497,47 @@ public sealed class MqttSubscriptionManager
|
||||
return $"mqtt:{_mode}:{id}:[{named}{overflow}]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivers each wildcard-trie candidate whose filter the MQTTnet comparer confirms. A <c>ref struct</c>
|
||||
/// so the payload span and the fan-out's shared JSON parse ride along without a closure or a list.
|
||||
/// </summary>
|
||||
private ref struct WildcardDelivery : ITopicFilterVisitor<MqttTagDefinition>
|
||||
{
|
||||
private readonly MqttSubscriptionManager _manager;
|
||||
private readonly string _topic;
|
||||
private readonly ReadOnlySpan<byte> _payload;
|
||||
private readonly bool _retained;
|
||||
private readonly DateTime _now;
|
||||
private readonly bool _oversized;
|
||||
private ref PayloadJson _json;
|
||||
|
||||
public WildcardDelivery(
|
||||
MqttSubscriptionManager manager,
|
||||
string topic,
|
||||
ReadOnlySpan<byte> payload,
|
||||
bool retained,
|
||||
DateTime now,
|
||||
bool oversized,
|
||||
ref PayloadJson json)
|
||||
{
|
||||
_manager = manager;
|
||||
_topic = topic;
|
||||
_payload = payload;
|
||||
_retained = retained;
|
||||
_now = now;
|
||||
_oversized = oversized;
|
||||
_json = ref json;
|
||||
}
|
||||
|
||||
public void Visit(MqttTagDefinition def)
|
||||
{
|
||||
if (MqttTopicFilterComparer.Compare(_topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch)
|
||||
{
|
||||
_manager.Deliver(def, _payload, _retained, _now, _oversized, ref _json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opaque subscription identity; the diagnostic id names the mode and its topics.</summary>
|
||||
/// <param name="DiagnosticId">The human-readable identity surfaced for diagnostics.</param>
|
||||
private sealed record MqttSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
|
||||
@@ -1411,7 +1549,8 @@ public sealed class MqttSubscriptionManager
|
||||
/// <remarks>
|
||||
/// <b>Two topic indexes, and the difference is load-bearing.</b> Delivery matches an
|
||||
/// <i>incoming published topic</i>, which never carries a wildcard, so it uses
|
||||
/// <see cref="ByExactTopic"/> (concrete only) plus a comparer scan of <see cref="Wildcards"/>.
|
||||
/// <see cref="ByExactTopic"/> (concrete only) plus a <see cref="WildcardTrie"/> walk whose candidates the
|
||||
/// comparer confirms.
|
||||
/// Every path that instead reconstructs state from a <i>subscribed filter string</i> —
|
||||
/// reconnect re-subscribe and per-topic degradation — must use <see cref="ByFilter"/>, which is
|
||||
/// keyed by the tag's authored topic <b>including wildcard patterns</b>, because that is what
|
||||
@@ -1425,12 +1564,12 @@ public sealed class MqttSubscriptionManager
|
||||
/// included. Keyed the same way <c>_subscribedTopics</c> and SUBACK outcomes are.
|
||||
/// </param>
|
||||
/// <param name="ByExactTopic">Concrete topic → its definitions; the delivery fast path.</param>
|
||||
/// <param name="Wildcards">Definitions whose authored topic carries an MQTT wildcard.</param>
|
||||
/// <param name="WildcardTrie">Definitions whose authored topic carries an MQTT wildcard, by filter level.</param>
|
||||
private sealed record AuthoredTable(
|
||||
FrozenDictionary<string, MqttTagDefinition> ByRawPath,
|
||||
FrozenDictionary<string, MqttTagDefinition[]> ByFilter,
|
||||
FrozenDictionary<string, MqttTagDefinition[]> ByExactTopic,
|
||||
MqttTagDefinition[] Wildcards)
|
||||
TopicFilterTrie<MqttTagDefinition> WildcardTrie)
|
||||
{
|
||||
public static readonly AuthoredTable Empty =
|
||||
Build(new Dictionary<string, MqttTagDefinition>(StringComparer.Ordinal));
|
||||
@@ -1480,7 +1619,7 @@ public sealed class MqttSubscriptionManager
|
||||
byRawPath.ToFrozenDictionary(StringComparer.Ordinal),
|
||||
byFilter.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal),
|
||||
concrete.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal),
|
||||
[.. wildcards]);
|
||||
TopicFilterTrie<MqttTagDefinition>.Build(wildcards.Select(d => (d.Topic, d))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +416,11 @@ public sealed class SparkplugIngestor
|
||||
_authored = table;
|
||||
_warned.Clear();
|
||||
|
||||
// Bound the shared last-value cache by the CURRENT authored set (R06:M-2): a dropped tag's value
|
||||
// and every non-authored reference's BadNodeIdUnknown marker would otherwise live forever. A
|
||||
// still-authored tag keeps its value, so a redeploy never blanks what has been observed.
|
||||
Values.RetainOnly(table.ByRawPath.ContainsKey);
|
||||
|
||||
// Drop state belonging to edge nodes this deploy removed. Without this a tracker (and its
|
||||
// rebirth debounce slot) for a node nobody reads survives every future redeploy.
|
||||
foreach (var key in _trackers.Keys)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>Receives each candidate a <see cref="TopicFilterTrie{T}"/> match produces.</summary>
|
||||
/// <typeparam name="T">The value type stored against each filter.</typeparam>
|
||||
internal interface ITopicFilterVisitor<in T>
|
||||
{
|
||||
/// <summary>Called once per value whose filter may match the topic.</summary>
|
||||
/// <param name="value">The candidate.</param>
|
||||
void Visit(T value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An immutable trie over MQTT topic FILTERS (<c>+</c> / <c>#</c> wildcards), so matching one published
|
||||
/// topic costs a walk over its levels instead of a comparer call per wildcard-authored tag (R06:M-6:
|
||||
/// the linear scan ran on MQTTnet's shared dispatcher thread for every inbound message).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A candidate generator, not the authority.</b> The trie implements the plain MQTT 3.1.1 §4.7
|
||||
/// level rules — <c>+</c> is exactly one level (an empty one included), <c>#</c> is the parent level
|
||||
/// and everything under it — and deliberately nothing subtler (the <c>$</c>-topic exclusion, shared
|
||||
/// subscriptions). It can therefore only ever return a SUPERSET of the true matches, and every
|
||||
/// caller confirms each candidate with MQTTnet's own <c>MqttTopicFilterComparer</c>, the comparer a
|
||||
/// broker applies. Semantics stay byte-identical to the scan it replaces; only the work changes.
|
||||
/// A differential test pins both halves against that comparer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Allocation-free per match.</b> Levels are walked as spans and looked up through the
|
||||
/// dictionary's span alternate lookup; candidates are pushed to a caller-supplied visitor (a
|
||||
/// <c>ref struct</c> in production) rather than collected into a list.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="T">The value type stored against each filter.</typeparam>
|
||||
internal sealed class TopicFilterTrie<T>
|
||||
{
|
||||
private readonly Node _root;
|
||||
|
||||
private TopicFilterTrie(Node root, int count)
|
||||
{
|
||||
_root = root;
|
||||
Count = count;
|
||||
}
|
||||
|
||||
/// <summary>An empty trie.</summary>
|
||||
public static TopicFilterTrie<T> Empty { get; } = Build([]);
|
||||
|
||||
/// <summary>How many values were inserted.</summary>
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>Builds a trie from <c>(filter, value)</c> pairs.</summary>
|
||||
/// <param name="entries">The filters and their values.</param>
|
||||
/// <returns>The immutable trie.</returns>
|
||||
public static TopicFilterTrie<T> Build(IEnumerable<(string Filter, T Value)> entries)
|
||||
{
|
||||
var root = new Builder();
|
||||
var count = 0;
|
||||
foreach (var (filter, value) in entries)
|
||||
{
|
||||
var node = root;
|
||||
var levels = filter.Split('/');
|
||||
var terminated = false;
|
||||
for (var i = 0; i < levels.Length; i++)
|
||||
{
|
||||
var level = levels[i];
|
||||
if (level == "#")
|
||||
{
|
||||
// '#' must be the last level; anything after it is a malformed filter the comparer
|
||||
// will refuse anyway, so treating '#' as terminal here can only over-generate.
|
||||
node.Hash.Add(value);
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
node = level == "+"
|
||||
? node.Plus ??= new Builder()
|
||||
: node.Children.TryGetValue(level, out var child) ? child : node.Children[level] = new Builder();
|
||||
}
|
||||
|
||||
if (!terminated) node.Exact.Add(value);
|
||||
count++;
|
||||
}
|
||||
|
||||
return new TopicFilterTrie<T>(root.Freeze(), count);
|
||||
}
|
||||
|
||||
/// <summary>Pushes every value whose filter may match <paramref name="topic"/> to <paramref name="visitor"/>.</summary>
|
||||
/// <typeparam name="TVisitor">The visitor; a <c>ref struct</c> is allowed.</typeparam>
|
||||
/// <param name="topic">A concrete published topic (never a filter).</param>
|
||||
/// <param name="visitor">Receives each candidate — a superset of the true matches; confirm each one.</param>
|
||||
public void Match<TVisitor>(string topic, ref TVisitor visitor)
|
||||
where TVisitor : ITopicFilterVisitor<T>, allows ref struct
|
||||
{
|
||||
if (Count == 0 || topic is null) return;
|
||||
Walk(_root, topic.AsSpan(), exhausted: false, ref visitor);
|
||||
}
|
||||
|
||||
private static void Walk<TVisitor>(Node node, ReadOnlySpan<char> rest, bool exhausted, ref TVisitor visitor)
|
||||
where TVisitor : ITopicFilterVisitor<T>, allows ref struct
|
||||
{
|
||||
// '#' here covers this level's parent-match AND every deeper level.
|
||||
foreach (var value in node.Hash) visitor.Visit(value);
|
||||
|
||||
if (exhausted)
|
||||
{
|
||||
foreach (var value in node.Exact) visitor.Visit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
var slash = rest.IndexOf('/');
|
||||
var level = slash < 0 ? rest : rest[..slash];
|
||||
var next = slash < 0 ? ReadOnlySpan<char>.Empty : rest[(slash + 1)..];
|
||||
var nextExhausted = slash < 0;
|
||||
|
||||
if (node.Children.Count > 0
|
||||
&& node.Children.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(level, out var child))
|
||||
{
|
||||
Walk(child, next, nextExhausted, ref visitor);
|
||||
}
|
||||
|
||||
if (node.Plus is { } plus) Walk(plus, next, nextExhausted, ref visitor);
|
||||
}
|
||||
|
||||
private sealed class Node(FrozenDictionary<string, Node> children, Node? plus, T[] exact, T[] hash)
|
||||
{
|
||||
public FrozenDictionary<string, Node> Children { get; } = children;
|
||||
|
||||
public Node? Plus { get; } = plus;
|
||||
|
||||
public T[] Exact { get; } = exact;
|
||||
|
||||
public T[] Hash { get; } = hash;
|
||||
}
|
||||
|
||||
private sealed class Builder
|
||||
{
|
||||
public Dictionary<string, Builder> Children { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public Builder? Plus { get; set; }
|
||||
|
||||
public List<T> Exact { get; } = [];
|
||||
|
||||
public List<T> Hash { get; } = [];
|
||||
|
||||
public Node Freeze() => new(
|
||||
Children.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.Freeze(), StringComparer.Ordinal),
|
||||
Plus?.Freeze(),
|
||||
[.. Exact],
|
||||
[.. Hash]);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// <summary>Column alias carrying the SQL type family name.</summary>
|
||||
private const string DataTypeColumn = "DATA_TYPE";
|
||||
|
||||
/// <summary>
|
||||
/// Column alias carrying <c>YES</c>/<c>NO</c> nullability. Every shipped dialect projects it (Oracle
|
||||
/// normalises its <c>Y</c>/<c>N</c>); it used to be read by nothing (R06:S-5).
|
||||
/// </summary>
|
||||
private const string IsNullableColumn = "IS_NULLABLE";
|
||||
|
||||
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
|
||||
private const string ViewTableType = "VIEW";
|
||||
|
||||
@@ -158,7 +164,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Computed from the columns ALREADY READ for this call — the catalog is not queried a second time.
|
||||
var timestampColumn = PreferredTimestampColumn(columns);
|
||||
var timestampColumn = PreferredTimestampColumn([.. columns.Select(c => (c.Name, c.DataType))]);
|
||||
|
||||
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
|
||||
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
|
||||
@@ -191,7 +197,18 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
Name: column.Name,
|
||||
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
|
||||
IsArray: false,
|
||||
SecurityClass: ReadOnlySecurityClass,
|
||||
// ⚠ SecurityClass is (ab)used as the picker's descriptor line ("{type} · {SecurityClass}"),
|
||||
// exactly as the MQTT browser does. Every consumer today is display-only; a future one that
|
||||
// parses it as SecurityClassification must not read the Sql value (review m-6 — a dedicated
|
||||
// descriptor field on AttributeInfo is the Commons follow-up). Display only. Nullability is
|
||||
// what tells an operator a cell can come back NULL, which publishes Uncertain (or Bad under
|
||||
// nullIsBad), before they commit the tag.
|
||||
SecurityClass: column.IsNullable switch
|
||||
{
|
||||
true => ReadOnlySecurityClass + " · nullable",
|
||||
false => ReadOnlySecurityClass + " · NOT NULL",
|
||||
null => ReadOnlySecurityClass,
|
||||
},
|
||||
IsAlarm: false,
|
||||
AddressFields: addressFields),
|
||||
];
|
||||
@@ -356,7 +373,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
|
||||
/// relation, and neither is a reason to fail an operator's click.
|
||||
/// </summary>
|
||||
private Task<List<(string Name, string DataType)>> ReadColumnsAsync(
|
||||
private Task<List<(string Name, string DataType, bool? IsNullable)>> ReadColumnsAsync(
|
||||
string schema, string table, CancellationToken ct) =>
|
||||
QueryAsync(
|
||||
_dialect.ListColumnsSql,
|
||||
@@ -367,9 +384,30 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
},
|
||||
static reader => (
|
||||
Name: ReadString(reader, ColumnNameColumn),
|
||||
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
|
||||
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty,
|
||||
IsNullable: ReadNullability(reader)),
|
||||
ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>IS_NULLABLE</c> as a tri-state: <see langword="null"/> when a (custom) dialect does not
|
||||
/// project it or the value is neither <c>YES</c> nor <c>NO</c> — "unknown" is said by omission, never
|
||||
/// guessed.
|
||||
/// </summary>
|
||||
private static bool? ReadNullability(DbDataReader reader)
|
||||
{
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
if (!string.Equals(reader.GetName(i), IsNullableColumn, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (reader.IsDBNull(i)) return null;
|
||||
var text = reader.GetValue(i)?.ToString()?.Trim();
|
||||
if (string.Equals(text, "YES", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(text, "NO", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one catalog query under the gate and projects every row. The disposed check happens
|
||||
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed.
|
||||
|
||||
@@ -177,6 +177,31 @@ public interface ISqlDialect
|
||||
/// </summary>
|
||||
string SingleRowLimitSuffix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="SingleRowLimitPrefix"/> position's fragment for an <paramref name="rows"/>-row limit —
|
||||
/// T-SQL's <c>"TOP (n) "</c>; empty for a dialect that limits at the end. Same trailing-space rule.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Added for the key-value / where-pair row cap (R06:S-1): those plans used to carry no limit, so an
|
||||
/// ambiguous selector against a history table materialised every matching row on every poll. The
|
||||
/// planner asks for <c>N + 1</c> rows — one more than the plan can use — so ambiguity stays observable
|
||||
/// (the reader sees the extra row and publishes a configuration error) without unbounded reads.
|
||||
/// </remarks>
|
||||
/// <param name="rows">The row limit; at least 1.</param>
|
||||
/// <returns>The fragment, possibly empty.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
|
||||
string RowLimitPrefix(int rows);
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="SingleRowLimitSuffix"/> position's fragment for an <paramref name="rows"/>-row limit —
|
||||
/// <c>" LIMIT n"</c> / <c>" FETCH FIRST n ROWS ONLY"</c>; empty for a dialect that limits after
|
||||
/// <c>SELECT</c>. Same leading-space rule. See <see cref="RowLimitPrefix"/>.
|
||||
/// </summary>
|
||||
/// <param name="rows">The row limit; at least 1.</param>
|
||||
/// <returns>The fragment, possibly empty.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
|
||||
string RowLimitSuffix(int rows);
|
||||
|
||||
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
|
||||
string ListSchemasSql { get; }
|
||||
|
||||
|
||||
@@ -124,6 +124,21 @@ public sealed class OracleDialect : ISqlDialect
|
||||
// statement shape, which this seam deliberately leaves with the planner.
|
||||
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// The same 12c+ row-limiting clause as SingleRowLimitSuffix; valid after a WHERE with no ORDER BY.
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " FETCH FIRST " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + " ROWS ONLY";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// Schemas visible to the connecting principal, from ALL_USERS (Oracle equates a schema with a
|
||||
// user), aliased to the TABLE_SCHEMA column name the catalog readers project by.
|
||||
|
||||
@@ -86,6 +86,20 @@ public sealed class PostgresDialect : ISqlDialect
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// The schemas holding relations this connection can actually see, system schemas excluded.
|
||||
//
|
||||
// Sourced from information_schema.tables, not information_schema.schemata - a deliberate
|
||||
|
||||
@@ -87,6 +87,22 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitSuffix => string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
// Parenthesised: TOP (n) is the documented modern form, and TOP n without parentheses is the
|
||||
// backwards-compatibility spelling.
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return "TOP (" + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + ") ";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// manufacture an exception to force backoff.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriver
|
||||
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
@@ -156,6 +156,16 @@ public sealed class SqlDriver
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Guards the lifecycle-generation bump against the generation-checked lifecycle health write.</summary>
|
||||
private readonly object _healthLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bumped by every <see cref="InitializeAsync"/> and <see cref="ShutdownAsync"/>. A lifecycle write from
|
||||
/// a superseded call, and a poll that STARTED under an older generation (a stale poll outliving the
|
||||
/// engine's 5 s teardown wait, R06:S-9), can no longer overwrite the newer state.
|
||||
/// </summary>
|
||||
private long _lifecycleGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// The exception instance <see cref="ReadAsync"/> has already turned into a health verdict, held only
|
||||
/// until <see cref="HandlePollError"/> — which the poll engine calls with that same object — has
|
||||
@@ -173,6 +183,9 @@ public sealed class SqlDriver
|
||||
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>Occurs once per poll cycle that produced at least one change, carrying every change of that cycle.</summary>
|
||||
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
|
||||
|
||||
/// <summary>Occurs when the database endpoint transitions between reachable and unreachable.</summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
@@ -230,6 +243,9 @@ public sealed class SqlDriver
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
|
||||
onError: HandlePollError,
|
||||
backoffCap: PollBackoffCap,
|
||||
// R02:P1 — one batch per poll burst, so DriverInstanceActor forwards one message per cycle instead
|
||||
// of one per tag (it subscribes exactly one of the two events).
|
||||
onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
|
||||
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
|
||||
// closure IS the production consumer of its overrun signal.
|
||||
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
|
||||
@@ -261,6 +277,10 @@ public sealed class SqlDriver
|
||||
_factory = _explicitFactory ?? dialect.Factory;
|
||||
_connectionString = connectionString;
|
||||
Endpoint = DescribeEndpoint(connectionString);
|
||||
|
||||
// The reader being replaced owns a SemaphoreSlim (R06:S-3). Disposing it is safe even with a stale
|
||||
// poll still inside it: the gate goes only when the last in-flight group releases it.
|
||||
var previous = _reader;
|
||||
_reader = new SqlPollReader(
|
||||
_factory,
|
||||
connectionString,
|
||||
@@ -271,6 +291,7 @@ public sealed class SqlDriver
|
||||
nullIsBad: options.NullIsBad,
|
||||
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
|
||||
logger: _logger);
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
|
||||
@@ -347,20 +368,39 @@ public sealed class SqlDriver
|
||||
// identifier.
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
var generation = BeginLifecycle();
|
||||
// LastSuccessfulRead is PRESERVED across the lifecycle states — it is the watchdog's evidence clock.
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
|
||||
|
||||
// adopt a changed config BEFORE the tag table is built, so the table and the connection it
|
||||
// will be polled over come from the same revision. ApplyBinding takes the options, dialect and
|
||||
// connection string as one unit; adopting options alone would poll a NEW tag set through the OLD
|
||||
// database, which is why this driver was excluded from the first pass. A null rebinder (direct
|
||||
// construction) or an empty/placeholder document keeps the constructor-supplied trio.
|
||||
if (_rebind is not null && HasConfigBody(driverConfigJson))
|
||||
try
|
||||
{
|
||||
var binding = _rebind(driverConfigJson);
|
||||
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
|
||||
}
|
||||
if (_rebind is not null && HasConfigBody(driverConfigJson))
|
||||
{
|
||||
var binding = _rebind(driverConfigJson);
|
||||
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
|
||||
}
|
||||
|
||||
BuildTagTable();
|
||||
BuildTagTable();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Review m-4: an unresolvable connectionStringRef or a malformed config threw here AFTER Initializing
|
||||
// was written, and no poll may end Initializing — so the driver latched it with no LastError. Fault
|
||||
// it like the liveness stage does: exception TYPE only on the operator surface (the factory's own
|
||||
// messages are already sanitized, but this method cannot rely on that), full exception to the log.
|
||||
var message =
|
||||
$"Sql driver '{_driverInstanceId}' could not adopt its configuration: {ex.GetType().Name}. " +
|
||||
"Check the connectionStringRef and the driver config JSON.";
|
||||
_logger.LogError(ex, "Sql driver {DriverInstanceId} configuration could not be adopted.", _driverInstanceId);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -369,7 +409,7 @@ public sealed class SqlDriver
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The caller tore the initialization down; not a database verdict.
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, ReadHealth().LastSuccessfulRead, null));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -385,8 +425,8 @@ public sealed class SqlDriver
|
||||
$"Check the database is reachable and the connection string is valid.";
|
||||
_logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
|
||||
_driverInstanceId, Endpoint);
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw new InvalidOperationException(message, ex);
|
||||
}
|
||||
|
||||
@@ -399,7 +439,7 @@ public sealed class SqlDriver
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, ReadHealth().LastSuccessfulRead, null));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -415,13 +455,13 @@ public sealed class SqlDriver
|
||||
_logger.LogError(ex,
|
||||
"Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.",
|
||||
_driverInstanceId, Endpoint);
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw new InvalidOperationException(message, ex);
|
||||
}
|
||||
|
||||
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
TransitionHostTo(HostState.Running);
|
||||
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)))
|
||||
TransitionHostTo(HostState.Running);
|
||||
_logger.LogInformation(
|
||||
"Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
|
||||
_driverInstanceId, Endpoint, Tags.Count);
|
||||
@@ -437,9 +477,10 @@ public sealed class SqlDriver
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var generation = BeginLifecycle();
|
||||
var lastRead = ReadHealth().LastSuccessfulRead;
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
|
||||
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, lastRead, null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -512,11 +553,37 @@ public sealed class SqlDriver
|
||||
// unreachable" and degrade a perfectly healthy driver.
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
// The generation this read started under: if a Shutdown/Initialize lands while it is in flight, its
|
||||
// verdict describes a superseded configuration and must not touch the newer health (R06:S-9).
|
||||
var generation = Interlocked.Read(ref _lifecycleGeneration);
|
||||
return await ReadThroughAsync(_reader, fullReferences, generation, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ReadAsync"/>'s body against a specific reader — the one the caller captured. Internal so a
|
||||
/// test can drive the "reader replaced between capture and use" race deterministically.
|
||||
/// </summary>
|
||||
internal async Task<IReadOnlyList<DataValueSnapshot>> ReadThroughAsync(
|
||||
SqlPollReader reader, IReadOnlyList<string> fullReferences, long generation, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
|
||||
ObservePollOutcome(snapshots);
|
||||
return snapshots;
|
||||
var result = await reader.ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false);
|
||||
ObservePollOutcome(result.Snapshots, result.AnsweredRefusalGroups, generation);
|
||||
return result.Snapshots;
|
||||
}
|
||||
catch (SqlPollReaderDisposedException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// A rebind swapped the reader under this poll (review m-1: a redeploy re-initialises without
|
||||
// teardown, so the engine keeps polling). Retry ONCE on the current reader; if there is no newer
|
||||
// one (the driver itself was disposed), hand the engine an exception it is told not to classify —
|
||||
// it used to Degrade the driver as "poll failed: OperationCanceledException".
|
||||
var current = Volatile.Read(ref _reader);
|
||||
if (!ReferenceEquals(current, reader))
|
||||
return await ReadThroughAsync(current, fullReferences, generation, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Volatile.Write(ref _classifiedByRead, ex);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -530,8 +597,12 @@ public sealed class SqlDriver
|
||||
_driverInstanceId, Endpoint);
|
||||
// Type name only — never ex.Message (see InitializeAsync for why); the full exception is already
|
||||
// on the log sink via the LogWarning exception parameter above.
|
||||
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
if (IsCurrentLifecycle(generation))
|
||||
{
|
||||
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
}
|
||||
|
||||
// Mark THIS exception object as already classified before it goes back to the poll engine, which
|
||||
// routes it to HandlePollError next. The mark is what makes the deferral there exact — see that
|
||||
// method for why inferring it from the exception's type was not.
|
||||
@@ -603,9 +674,21 @@ public sealed class SqlDriver
|
||||
/// cadence rather than backed off from.</para>
|
||||
/// </summary>
|
||||
/// <param name="snapshots">The snapshots the reader returned for one poll.</param>
|
||||
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots)
|
||||
/// <param name="answeredRefusals">
|
||||
/// Groups the database answered but the driver refused (an over-cap result set, R06:S-1). Their
|
||||
/// members carry <see cref="SqlStatusCodes.BadConfigurationError"/>, which alone is not answer evidence
|
||||
/// (the planner mints it before any I/O), so the count is what makes them count as the database
|
||||
/// answering.
|
||||
/// </param>
|
||||
/// <param name="generation">
|
||||
/// The lifecycle generation the poll started under; a superseded one is ignored outright. Null (tests)
|
||||
/// means "current".
|
||||
/// </param>
|
||||
internal void ObservePollOutcome(
|
||||
IReadOnlyList<DataValueSnapshot> snapshots, int answeredRefusals = 0, long? generation = null)
|
||||
{
|
||||
if (snapshots.Count == 0) return;
|
||||
if (generation is { } started && !IsCurrentLifecycle(started)) return;
|
||||
|
||||
var good = 0;
|
||||
var unreachable = 0;
|
||||
@@ -620,6 +703,8 @@ public sealed class SqlDriver
|
||||
if (IsAnswerEvidence(snapshot.StatusCode)) answered++;
|
||||
}
|
||||
|
||||
answered += answeredRefusals;
|
||||
|
||||
if (unreachable > 0)
|
||||
{
|
||||
Degrade(
|
||||
@@ -655,17 +740,12 @@ public sealed class SqlDriver
|
||||
|
||||
if (answered == 0) return; // authoring-class Bad only — not a statement about the database.
|
||||
|
||||
if (good == 0)
|
||||
{
|
||||
// The database answered, just not with a value: an absent row, a NULL cell, a cell that would
|
||||
// not coerce. Those are data/authoring facts, so the health STATE is deliberately left exactly
|
||||
// as it was — but they are still proof the transport works, and the evidence clock the
|
||||
// comms-loss watchdog reads must say so.
|
||||
TouchLastSuccessfulRead();
|
||||
TransitionHostTo(HostState.Running);
|
||||
return;
|
||||
}
|
||||
|
||||
// Good values, OR a database that answered with refusals only — an absent row, a NULL cell, an
|
||||
// uncoercible cell, an over-cap result set. A refusal is the database answering: it advances the
|
||||
// evidence clock AND restores Healthy after a comms fault, exactly as a Good read does. Leaving the
|
||||
// state as it was latched a recovered database Degraded behind every poll that read only refusals.
|
||||
//
|
||||
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
|
||||
// ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
|
||||
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
|
||||
@@ -792,7 +872,11 @@ public sealed class SqlDriver
|
||||
/// </summary>
|
||||
/// <param name="project">Builds the snapshot to publish from the current one.</param>
|
||||
private void UpdateHealthUnlessFaulted(Func<DriverHealth, DriverHealth> project)
|
||||
=> UpdateHealth(current => current.State == DriverState.Faulted ? null : project(current));
|
||||
// Initializing too: only the initialize's own final write may end it — a poll landing mid-init
|
||||
// (a re-init without shutdown keeps the engine running) used to flip Initializing → Healthy.
|
||||
=> UpdateHealth(current => current.State is DriverState.Faulted or DriverState.Initializing
|
||||
? null
|
||||
: project(current));
|
||||
|
||||
/// <summary>
|
||||
/// The one read-modify-write of the health field, as a compare-and-swap retry loop.
|
||||
@@ -823,13 +907,31 @@ public sealed class SqlDriver
|
||||
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
|
||||
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
||||
|
||||
/// <summary>Starts a lifecycle operation and returns its generation; every later one supersedes it.</summary>
|
||||
private long BeginLifecycle()
|
||||
{
|
||||
lock (_healthLock) return ++_lifecycleGeneration;
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="generation"/> is still the newest lifecycle operation.</summary>
|
||||
private bool IsCurrentLifecycle(long generation) => Interlocked.Read(ref _lifecycleGeneration) == generation;
|
||||
|
||||
/// <summary>The current lifecycle generation. Test observation of the S-9 guard.</summary>
|
||||
internal long LifecycleGenerationForTests => Interlocked.Read(ref _lifecycleGeneration);
|
||||
|
||||
/// <summary>
|
||||
/// Unconditional publish of a new health snapshot, for the lifecycle transitions that are
|
||||
/// last-writer-wins by definition (Initialize / Shutdown). Everything driven by a poll goes through
|
||||
/// <see cref="UpdateHealth"/> instead, because it has to read the current snapshot first.
|
||||
/// A lifecycle write that applies only while <paramref name="generation"/> is current — atomic against
|
||||
/// <see cref="BeginLifecycle"/>, so a superseded initialize/shutdown never overwrites a newer one.
|
||||
/// </summary>
|
||||
/// <param name="value">The health snapshot to publish.</param>
|
||||
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
||||
private bool WriteHealthIfCurrent(long generation, DriverHealth value)
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
if (_lifecycleGeneration != generation) return false;
|
||||
Volatile.Write(ref _health, value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
|
||||
private void TransitionHostTo(HostState newState)
|
||||
@@ -867,10 +969,15 @@ public sealed class SqlDriver
|
||||
table[entry.RawPath] = definition;
|
||||
else
|
||||
{
|
||||
blobSkips.Add(new SkippedTag(entry.RawPath, "TagConfig did not map to a definition"));
|
||||
// The reason is the DEPLOY GATE's own explanation (R06:S-4) — SqlTagDefinitionFactory.Inspect
|
||||
// asks this same parser and then says why — so /hosts names e.g. the deferred Query model
|
||||
// instead of a generic "did not map" for a tag the gate already warned about.
|
||||
var reason = SqlTagDefinitionFactory.Inspect(entry.TagConfig).FirstOrDefault()
|
||||
?? "TagConfig did not map to a definition";
|
||||
blobSkips.Add(new SkippedTag(entry.RawPath, reason));
|
||||
_logger.LogWarning(
|
||||
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath} Reason={Reason}",
|
||||
_driverInstanceId, entry.RawPath, reason);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1150,7 +1257,20 @@ public sealed class SqlDriver
|
||||
/// <c>await using</c> does not leak the poll loops.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
|
||||
// Only on the FINAL dispose: a Shutdown → Initialize cycle on the same instance keeps polling
|
||||
// through this reader when the init does not rebind (no rebinder / placeholder config).
|
||||
_reader.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Re-runs <see cref="ApplyBinding"/> on the current binding — a real reader swap, for the m-1 race test.</summary>
|
||||
internal void RebindForTests() => ApplyBinding(_options, _dialect, _connectionString);
|
||||
|
||||
/// <summary>The live poll reader. Test observation of the S-3 lifecycle only.</summary>
|
||||
internal SqlPollReader ReaderForTests => _reader;
|
||||
|
||||
/// <summary>
|
||||
/// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe
|
||||
|
||||
@@ -194,8 +194,14 @@ public static class SqlDriverFactoryExtensions
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// The parser's message AND the exception carrying it are withheld (R06:S-8), matching
|
||||
// SqlDriverProbe and SqlDriverBrowser: System.Text.Json quotes the offending token, and a pasted
|
||||
// connection-string literal is exactly the malformed text that lands here. This message becomes
|
||||
// InitializeFailed -> health LastError -> /hosts. Line/byte position carry no content, so they stay.
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
|
||||
$"Sql driver config for '{driverInstanceId}' is not valid JSON (line "
|
||||
+ $"{(ex.LineNumber is { } line ? line + 1 : (object)"?")}, byte {ex.BytePositionInLine?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "?"}). The parser's message is withheld because the malformed text may "
|
||||
+ "carry a connection string.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,15 +230,23 @@ public static class SqlGroupPlanner
|
||||
values.Add(keyValue);
|
||||
}
|
||||
|
||||
// Row cap (R06:S-1): one row per distinct key is all this plan can use. It reads up to TWICE that, so a
|
||||
// handful of stray duplicates stays inside the cap and is attributed PER KEY by the reader (only the
|
||||
// duplicated key's tags go BadConfigurationError) — and asks for one row more than that, so only a
|
||||
// gross excess (a history/events table, unattributable by construction because the rest is unread)
|
||||
// fails the whole group. Memory stays O(keys) either way.
|
||||
var rowCap = values.Count * 2;
|
||||
var sql = new StringBuilder("SELECT ")
|
||||
.Append(dialect.RowLimitPrefix(rowCap + 1))
|
||||
.Append(QuoteList(selected, dialect))
|
||||
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
|
||||
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
|
||||
.Append(" IN (").AppendJoin(", ", markers).Append(')')
|
||||
.Append(dialect.RowLimitSuffix(rowCap + 1))
|
||||
.ToString();
|
||||
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
|
||||
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected, rowCap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -270,14 +278,18 @@ public static class SqlGroupPlanner
|
||||
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
|
||||
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
|
||||
{
|
||||
// Capped at TWO rows (R06:S-1): the selector must identify one row, and the second is the proof it
|
||||
// does not. A TOP 1 alone would hide that ambiguity (see SqlPollReader.SelectWideRow); no cap at all
|
||||
// materialised every matching row of an events table on every poll.
|
||||
var marker = dialect.ParameterMarker(RowSelectorBaseName);
|
||||
var sql = string.Concat(
|
||||
"SELECT ", columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
|
||||
"SELECT ", dialect.RowLimitPrefix(2), columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker,
|
||||
dialect.RowLimitSuffix(2));
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.WideRow, groupKey, sql,
|
||||
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
|
||||
[first.RowSelectorValue], members, selected);
|
||||
[first.RowSelectorValue], members, selected, RowCap: 1);
|
||||
}
|
||||
|
||||
if (!Blank(first.RowSelectorTopByTimestamp))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -36,7 +38,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// no subscription state and no connection between polls, so the driver shell can wrap it in
|
||||
/// <see cref="PollGroupEngine"/> and own those concerns.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlPollReader
|
||||
public sealed class SqlPollReader : IDisposable
|
||||
{
|
||||
/// <summary>Minimum spacing between "your source violates the query contract" warnings, per reader.</summary>
|
||||
private static readonly TimeSpan ContractWarningInterval = TimeSpan.FromMinutes(1);
|
||||
@@ -52,13 +54,41 @@ public sealed class SqlPollReader
|
||||
|
||||
/// <summary>
|
||||
/// Caps concurrently executing group queries — and therefore concurrently held connections
|
||||
/// (design §8.4). Never disposed: a <see cref="SemaphoreSlim"/> only needs disposal once its
|
||||
/// <c>AvailableWaitHandle</c> has been touched, and not disposing it also removes the hazard of a
|
||||
/// timed-out-but-still-running group releasing into a disposed semaphore.
|
||||
/// (design §8.4). Disposed by <see cref="Dispose"/> only once every group that ever acquired it has
|
||||
/// released it (<see cref="_refs"/>), so a timed-out-but-still-running group can never release into a
|
||||
/// disposed semaphore (R06:S-3 — it used to be never disposed, one per reinitialize).
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _gate;
|
||||
|
||||
private long _lastContractWarningTicks;
|
||||
/// <summary>
|
||||
/// Owner reference (1) plus one per in-flight group. <see cref="Dispose"/> drops the owner's; whoever
|
||||
/// takes it to zero disposes <see cref="_gate"/>. Zero is terminal — no group can start after it.
|
||||
/// </summary>
|
||||
private int _refs = 1;
|
||||
|
||||
private int _disposed;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Per-key last-warned instant for the per-poll warnings (R06:S-6): a frozen group's BadTimeout and a
|
||||
/// failing group's query error used to log every poll, per group, indefinitely — 20 groups at 1 s was
|
||||
/// 20 lines a second carrying SQL text. Keyed by the plan's SQL text, so bounded by the plan count.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, long> _lastWarnedTicks = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Tags whose uncoercible cell has been warned about — once per tag per reader (the refusal rule).</summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _coerceWarned = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The resolved definitions, slot map and plans for each reference list a caller polls (R06:S-7). A
|
||||
/// poll loop passes the SAME list instance every cycle, so it is planned once instead of every poll.
|
||||
/// Weakly keyed: an unsubscribed list (and its entry) is collected with it. Invalidation is structural —
|
||||
/// each entry remembers the definition INSTANCES it was built from, and any reference resolving to a
|
||||
/// different instance (a catalog-gate swap) rebuilds it.
|
||||
/// </summary>
|
||||
private readonly ConditionalWeakTable<IReadOnlyList<string>, PollShape> _shapes = new();
|
||||
|
||||
private int _plansBuilt;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="SqlPollReader"/> class.</summary>
|
||||
/// <param name="factory">
|
||||
@@ -154,26 +184,87 @@ public sealed class SqlPollReader
|
||||
/// <exception cref="DbException">The database could not be reached (opening a connection failed).</exception>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
=> (await ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false)).Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ReadAsync"/> plus what the snapshots alone cannot say: how many groups the database
|
||||
/// ANSWERED with more rows than the plan can use (R06:S-1). Those groups publish
|
||||
/// <see cref="SqlStatusCodes.BadConfigurationError"/> — the same code an un-plannable definition gets
|
||||
/// before any I/O — so the driver needs this count to treat them as the answer evidence they are.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The RawPaths to read.</param>
|
||||
/// <param name="cancellationToken">The caller's token; see <see cref="ReadAsync"/>.</param>
|
||||
/// <returns>The snapshots and the answered-refusal count.</returns>
|
||||
public async Task<SqlPollResult> ReadDetailedAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
if (results.Length == 0) return results;
|
||||
if (results.Length == 0) return new SqlPollResult(results, 0);
|
||||
|
||||
var readAt = DateTime.UtcNow;
|
||||
var shape = GetShape(fullReferences);
|
||||
|
||||
// Resolve first. A miss is this tag's own problem (BadNodeIdUnknown) and must not reach the planner,
|
||||
// whose members are the sole thing the slice-back walks.
|
||||
// A miss is this tag's own problem (BadNodeIdUnknown) and never reaches the planner, whose members
|
||||
// are the sole thing the slice-back walks.
|
||||
for (var i = 0; i < shape.Resolved.Length; i++)
|
||||
{
|
||||
if (shape.Resolved[i] is null)
|
||||
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
|
||||
}
|
||||
|
||||
var answeredRefusals = shape.Definitions.Count > 0
|
||||
? await ReadGroupsAsync(shape, results, readAt, cancellationToken).ConfigureAwait(false)
|
||||
: 0;
|
||||
|
||||
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
|
||||
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
|
||||
|
||||
return new SqlPollResult(results, answeredRefusals);
|
||||
}
|
||||
|
||||
/// <summary>How many times a reference list has been (re)planned. Test observation of the S-7 cache.</summary>
|
||||
internal int PlansBuiltForTests => Volatile.Read(ref _plansBuilt);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached shape for <paramref name="fullReferences"/>, rebuilding it when any reference now
|
||||
/// resolves to a different definition instance than the one it was built from.
|
||||
/// </summary>
|
||||
private PollShape GetShape(IReadOnlyList<string> fullReferences)
|
||||
{
|
||||
if (_shapes.TryGetValue(fullReferences, out var cached) && cached.Resolved.Length == fullReferences.Count)
|
||||
{
|
||||
var current = true;
|
||||
for (var i = 0; i < fullReferences.Count && current; i++)
|
||||
{
|
||||
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
|
||||
current = ReferenceEquals(definition, cached.Resolved[i]);
|
||||
}
|
||||
|
||||
if (current) return cached;
|
||||
}
|
||||
|
||||
var shape = BuildShape(fullReferences);
|
||||
_shapes.AddOrUpdate(fullReferences, shape);
|
||||
return shape;
|
||||
}
|
||||
|
||||
/// <summary>Resolves, slots and plans one reference list.</summary>
|
||||
private PollShape BuildShape(IReadOnlyList<string> fullReferences)
|
||||
{
|
||||
Interlocked.Increment(ref _plansBuilt);
|
||||
|
||||
var resolved = new SqlTagDefinition?[fullReferences.Count];
|
||||
var definitions = new List<SqlTagDefinition>(fullReferences.Count);
|
||||
var slots = new Dictionary<SqlTagDefinition, List<int>>();
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
|
||||
if (definition is null)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
|
||||
continue;
|
||||
}
|
||||
resolved[i] = definition;
|
||||
if (definition is null) continue;
|
||||
|
||||
definitions.Add(definition);
|
||||
if (!slots.TryGetValue(definition, out var positions))
|
||||
@@ -181,45 +272,54 @@ public sealed class SqlPollReader
|
||||
positions.Add(i);
|
||||
}
|
||||
|
||||
if (definitions.Count > 0)
|
||||
await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false);
|
||||
if (definitions.Count == 0) return new PollShape(resolved, definitions, slots, [], PlanningFailed: false);
|
||||
|
||||
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
|
||||
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Plans the resolved definitions, runs every group concurrently, and slices the results back.</summary>
|
||||
private async Task ReadGroupsAsync(
|
||||
List<SqlTagDefinition> definitions,
|
||||
Dictionary<SqlTagDefinition, List<int>> slots,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime readAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<SqlQueryPlan> plans;
|
||||
try
|
||||
{
|
||||
plans = SqlGroupPlanner.Plan(definitions, _dialect);
|
||||
return new PollShape(resolved, definitions, slots, SqlGroupPlanner.Plan(definitions, _dialect), PlanningFailed: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
// A definition with a broken invariant — SqlEquipmentTagParser rejects all of these up front, so
|
||||
// reaching here means a definition was built past the parser. It is an authoring fault, not a
|
||||
// database fault: Bad-code the planned tags rather than throwing and triggering connection backoff.
|
||||
// Logged once per (re)plan, which the cache makes once per shape rather than once per poll.
|
||||
_logger.LogError(ex, "Sql poll planning failed for {Count} tag(s); publishing BadConfigurationError.",
|
||||
definitions.Count);
|
||||
foreach (var definition in definitions)
|
||||
return new PollShape(resolved, definitions, slots, [], PlanningFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One reference list's resolved definitions, slot map and plans — immutable once built.</summary>
|
||||
private sealed record PollShape(
|
||||
SqlTagDefinition?[] Resolved,
|
||||
List<SqlTagDefinition> Definitions,
|
||||
Dictionary<SqlTagDefinition, List<int>> Slots,
|
||||
IReadOnlyList<SqlQueryPlan> Plans,
|
||||
bool PlanningFailed);
|
||||
|
||||
/// <summary>
|
||||
/// Runs every planned group concurrently and slices the results back. Returns how many groups came
|
||||
/// back with an answered refusal (over the row cap, or a duplicated key).
|
||||
/// </summary>
|
||||
private async Task<int> ReadGroupsAsync(
|
||||
PollShape shape,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime readAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var slots = shape.Slots;
|
||||
var plans = shape.Plans;
|
||||
if (shape.PlanningFailed)
|
||||
{
|
||||
foreach (var definition in shape.Definitions)
|
||||
{
|
||||
if (!slots.TryGetValue(definition, out var positions)) continue;
|
||||
var snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
|
||||
foreach (var position in positions) results[position] = snapshot;
|
||||
}
|
||||
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group
|
||||
@@ -233,8 +333,18 @@ public sealed class SqlPollReader
|
||||
// leaves a sibling group running against a connection nobody is watching.
|
||||
var outcomes = await Task.WhenAll(groups).ConfigureAwait(false);
|
||||
|
||||
// Surfaced AFTER WhenAll, as one typed exception: a group throwing it would turn WhenAll into a plain
|
||||
// TaskCanceledException and the caller could no longer tell "superseded reader" from a caller cancel.
|
||||
if (outcomes.Any(o => ReferenceEquals(o, GroupResult.ReaderDisposed))) throw new SqlPollReaderDisposedException();
|
||||
|
||||
var answeredRefusals = 0;
|
||||
for (var g = 0; g < plans.Count; g++)
|
||||
Slice(plans[g], outcomes[g], slots, results, readAt);
|
||||
{
|
||||
var refusedSome = Slice(plans[g], outcomes[g], slots, results, readAt);
|
||||
if (refusedSome || ReferenceEquals(outcomes[g], GroupResult.RowCapExceeded)) answeredRefusals++;
|
||||
}
|
||||
|
||||
return answeredRefusals;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -273,6 +383,22 @@ public sealed class SqlPollReader
|
||||
/// inside the deadline, so a poll behind a wedged group still returns on time — as BadTimeout.</para>
|
||||
/// </summary>
|
||||
private async Task<GroupResult> RunGroupAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
// A reader replaced by a reinitialize (and disposed) can still be holding a stale poll: it runs no
|
||||
// new group. Cancellation is the honest answer — the poll that asked has been superseded.
|
||||
if (!TryAddRef()) return GroupResult.ReaderDisposed;
|
||||
|
||||
try
|
||||
{
|
||||
return await RunGroupCoreAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseRef();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GroupResult> RunGroupCoreAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
|
||||
@@ -288,6 +414,9 @@ public sealed class SqlPollReader
|
||||
if (budget <= TimeSpan.Zero) return GroupResult.TimedOut;
|
||||
deadline.CancelAfter(budget);
|
||||
|
||||
// The work holds its OWN reference: it can outlive this method (a timed-out group is abandoned
|
||||
// still running) and its finally releases the slot into _gate, which must therefore still exist.
|
||||
AddRefForWork();
|
||||
work = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
@@ -299,6 +428,7 @@ public sealed class SqlPollReader
|
||||
{
|
||||
_gate.Release();
|
||||
deadline.Dispose();
|
||||
ReleaseRef();
|
||||
}
|
||||
},
|
||||
CancellationToken.None);
|
||||
@@ -334,6 +464,41 @@ public sealed class SqlPollReader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the concurrency gate once no group still holds it. Idempotent; safe while groups are in
|
||||
/// flight (the last one out disposes it). A disposed reader starts no new group.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0) ReleaseRef();
|
||||
}
|
||||
|
||||
/// <summary>True once <see cref="_gate"/> has actually been disposed. Test observation only.</summary>
|
||||
internal bool GateDisposedForTests => Volatile.Read(ref _refs) == 0;
|
||||
|
||||
/// <summary>Takes a reference unless the reader is already fully released.</summary>
|
||||
private bool TryAddRef()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
// A disposed reader starts no new group even while older ones are still in flight (review m-2):
|
||||
// the gate would survive until they finish, but a replaced reader must not take new work.
|
||||
if (Volatile.Read(ref _disposed) != 0) return false;
|
||||
var current = Volatile.Read(ref _refs);
|
||||
if (current == 0) return false;
|
||||
if (Interlocked.CompareExchange(ref _refs, current + 1, current) == current) return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A further reference for work started under an already-held one (so never from zero).</summary>
|
||||
private void AddRefForWork() => Interlocked.Increment(ref _refs);
|
||||
|
||||
/// <summary>Drops a reference; the one that reaches zero disposes the gate.</summary>
|
||||
private void ReleaseRef()
|
||||
{
|
||||
if (Interlocked.Decrement(ref _refs) == 0) _gate.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>How much of this group's <c>operationTimeout</c> budget is left; never negative.</summary>
|
||||
private TimeSpan Remaining(Stopwatch clock)
|
||||
{
|
||||
@@ -346,11 +511,14 @@ public sealed class SqlPollReader
|
||||
{
|
||||
// The table is named for the same reason the contract warnings name it: under a partial outage this
|
||||
// line repeats every poll, and "which source is frozen" is the only question the operator has.
|
||||
_logger.LogWarning(
|
||||
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
|
||||
"timeout; publishing BadTimeout.",
|
||||
plan.Model, plan.Members[0].Table, plan.Members.Count,
|
||||
(int)_operationTimeout.TotalMilliseconds);
|
||||
if (ShouldWarn("timeout:" + plan.SqlText))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
|
||||
"timeout; publishing BadTimeout. Repeats for this group are suppressed for {Interval} s.",
|
||||
plan.Model, plan.Members[0].Table, plan.Members.Count,
|
||||
(int)_operationTimeout.TotalMilliseconds, (int)ContractWarningInterval.TotalSeconds);
|
||||
}
|
||||
|
||||
AbandonedTask.Detach(work);
|
||||
return GroupResult.TimedOut;
|
||||
@@ -412,9 +580,13 @@ public sealed class SqlPollReader
|
||||
{
|
||||
// The connection opened, so the server is up — this query is what failed (an identifier that
|
||||
// is not in the catalog, a lock, a permission). That is this group's problem alone.
|
||||
_logger.LogWarning(
|
||||
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}",
|
||||
plan.Model, plan.Members.Count, plan.SqlText);
|
||||
if (ShouldWarn("failed:" + plan.SqlText))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}. Repeats for this group are " +
|
||||
"suppressed for {Interval} s.",
|
||||
plan.Model, plan.Members.Count, plan.SqlText, (int)ContractWarningInterval.TotalSeconds);
|
||||
}
|
||||
return GroupResult.Failed;
|
||||
}
|
||||
}
|
||||
@@ -442,8 +614,11 @@ public sealed class SqlPollReader
|
||||
columnIndex[selected[c]] = c;
|
||||
}
|
||||
|
||||
// The client-side half of the row cap (R06:S-1): the statement already asks for RowCap + 1 rows, but a
|
||||
// provider that cannot spell a limit must not turn that into an unbounded read either.
|
||||
var limit = plan.RowCap is { } cap ? cap + 1 : int.MaxValue;
|
||||
var rows = new List<object?[]>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
while (rows.Count < limit && await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var row = new object?[selected.Count];
|
||||
for (var c = 0; c < selected.Count; c++)
|
||||
@@ -451,6 +626,27 @@ public sealed class SqlPollReader
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
if (plan.RowCap is { } rowCap && rows.Count > rowCap)
|
||||
{
|
||||
// Per plan, not reader-wide: with two ambiguous groups the reader-wide limiter could name only the
|
||||
// first table forever, and with health still Healthy this line is the operator's only pointer.
|
||||
if (ShouldWarn("rowcap:" + plan.SqlText))
|
||||
{
|
||||
var first = plan.Members[0];
|
||||
var selector = plan.Model == SqlTagModel.KeyValue
|
||||
? string.Concat(first.KeyColumn, " IN (", (rowCap / 2).ToString(CultureInfo.InvariantCulture), " key(s))")
|
||||
: string.Concat(first.RowSelectorColumn, " = ", first.RowSelectorValue);
|
||||
_logger.LogWarning(
|
||||
"Sql poll source '{Table}' returned more rows than the {Model} selector '{Selector}' can attribute " +
|
||||
"(at most {Cap}) in one poll, so the selector is ambiguous — a history/events table, or a " +
|
||||
"where column that is not unique. Its {Members} tag(s) publish BadConfigurationError and " +
|
||||
"nothing past row {Limit} is read. Point the tag at a current-values table or a latest-row view.",
|
||||
first.Table, plan.Model, selector, rowCap, plan.Members.Count, rowCap + 1);
|
||||
}
|
||||
|
||||
return GroupResult.RowCapExceeded;
|
||||
}
|
||||
|
||||
return GroupResult.Ok(rows, columnIndex, typeNames);
|
||||
}
|
||||
|
||||
@@ -470,8 +666,11 @@ public sealed class SqlPollReader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps one group's outcome onto every slot its members occupy in the caller's list.</summary>
|
||||
private void Slice(
|
||||
/// <summary>
|
||||
/// Maps one group's outcome onto every slot its members occupy in the caller's list. Returns whether any
|
||||
/// member was refused on an ANSWERED result (a duplicated key) — answer evidence the driver must count.
|
||||
/// </summary>
|
||||
private bool Slice(
|
||||
SqlQueryPlan plan,
|
||||
GroupResult outcome,
|
||||
Dictionary<SqlTagDefinition, List<int>> slots,
|
||||
@@ -481,9 +680,11 @@ public sealed class SqlPollReader
|
||||
// KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the
|
||||
// where-column is not even in the result set (design §5.3). Both selections happen ONCE per group —
|
||||
// they are group-wide facts, and doing them per member would report a contract violation N times.
|
||||
HashSet<string>? duplicatedKeys = null;
|
||||
var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue
|
||||
? IndexByKey(plan, outcome)
|
||||
? IndexByKey(plan, outcome, out duplicatedKeys)
|
||||
: null;
|
||||
var refusedSome = false;
|
||||
var wideRow = outcome.Succeeded && plan.Model == SqlTagModel.WideRow
|
||||
? SelectWideRow(plan, outcome)
|
||||
: null;
|
||||
@@ -493,26 +694,45 @@ public sealed class SqlPollReader
|
||||
// Members may repeat (two tags on one keyValue, or the same ref twice) — every occurrence is fed.
|
||||
if (!slots.TryGetValue(member, out var positions)) continue;
|
||||
|
||||
var snapshot = outcome.Succeeded
|
||||
? MapMember(plan, member, outcome, byKey, wideRow, readAt)
|
||||
: new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
|
||||
DataValueSnapshot snapshot;
|
||||
if (!outcome.Succeeded)
|
||||
{
|
||||
snapshot = new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
|
||||
}
|
||||
else if (duplicatedKeys is not null && member.KeyValue is { } key && duplicatedKeys.Contains(key))
|
||||
{
|
||||
// More than one row for THIS tag's key: its selector is ambiguous — the same rule a where-pair
|
||||
// follows — so it is a configuration error for this key's tags only. Every other key in the
|
||||
// group is unaffected, whatever else is present or absent (R06:S-1 review I-1: it used to be
|
||||
// last-row-wins Good, or the whole group Bad, depending on an unrelated key's absence).
|
||||
snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
|
||||
refusedSome = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
snapshot = MapMember(plan, member, outcome, byKey, wideRow, readAt);
|
||||
}
|
||||
|
||||
foreach (var position in positions) results[position] = snapshot;
|
||||
}
|
||||
|
||||
return refusedSome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the key → row index a key-value plan slices through. Later rows win, so a source that
|
||||
/// breaks the one-row-per-key contract (design §3.6) still yields a deterministic value; the
|
||||
/// violation is reported through a rate-limited warning rather than silently.
|
||||
/// Builds the key → row index a key-value plan slices through, and the set of keys that came back more
|
||||
/// than once — a source breaking the one-row-per-key contract (design §3.6). Those keys' tags publish
|
||||
/// <see cref="SqlStatusCodes.BadConfigurationError"/>; the violation is also reported through a
|
||||
/// per-plan rate-limited warning.
|
||||
/// </summary>
|
||||
private Dictionary<string, object?[]> IndexByKey(SqlQueryPlan plan, GroupResult outcome)
|
||||
private Dictionary<string, object?[]> IndexByKey(
|
||||
SqlQueryPlan plan, GroupResult outcome, out HashSet<string>? duplicatedKeys)
|
||||
{
|
||||
duplicatedKeys = null;
|
||||
var index = new Dictionary<string, object?[]>(StringComparer.Ordinal);
|
||||
if (plan.KeyColumn is null || !outcome.ColumnIndex.TryGetValue(plan.KeyColumn, out var keyAt))
|
||||
return index;
|
||||
|
||||
var duplicated = 0;
|
||||
foreach (var row in outcome.Rows)
|
||||
{
|
||||
// A NULL key cell indexes nothing: Convert.ToString would fold it to "", which would then be
|
||||
@@ -523,20 +743,16 @@ public sealed class SqlPollReader
|
||||
// (an INTEGER station id, say) — the provider coerces on the way in, so match on the way out.
|
||||
var key = Convert.ToString(keyCell, CultureInfo.InvariantCulture);
|
||||
if (key is null) continue;
|
||||
if (!index.TryAdd(key, row))
|
||||
{
|
||||
index[key] = row;
|
||||
duplicated++;
|
||||
}
|
||||
if (!index.TryAdd(key, row)) (duplicatedKeys ??= new HashSet<string>(StringComparer.Ordinal)).Add(key);
|
||||
}
|
||||
|
||||
if (duplicated > 0 && ShouldWarnAboutContract())
|
||||
if (duplicatedKeys is not null && ShouldWarn("dupkey:" + plan.SqlText))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll; the " +
|
||||
"last row wins. The key-value model requires one row per key — point the tag at a " +
|
||||
"current-values table or an operator-authored latest-per-key view.",
|
||||
plan.Members[0].Table, duplicated);
|
||||
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll (e.g. " +
|
||||
"'{Example}'); those keys' tags publish BadConfigurationError. The key-value model requires one " +
|
||||
"row per key — point the tag at a current-values table or an operator-authored latest-per-key view.",
|
||||
plan.Members[0].Table, duplicatedKeys.Count, duplicatedKeys.First());
|
||||
}
|
||||
|
||||
return index;
|
||||
@@ -549,15 +765,14 @@ public sealed class SqlPollReader
|
||||
/// emits no <c>ORDER BY</c>, so across polls the "last" row is whatever the server returned last,
|
||||
/// i.e. storage order. That is why more than one row is a reported contract violation and not merely
|
||||
/// a tie to break — the model's premise (design §5.3) is that the selector identifies one row.</para>
|
||||
/// <para><b>Why the where-pair form is deliberately NOT given the dialect's single-row limit.</b> A
|
||||
/// <c>TOP 1</c> with no <c>ORDER BY</c> is not deterministic either — it returns whichever row the
|
||||
/// plan happens to produce first, so it would trade one arbitrary row for a differently arbitrary
|
||||
/// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous
|
||||
/// (<c>Rows.Count > 1</c>), converting a loud misconfiguration into a permanently silent one —
|
||||
/// precisely the failure mode this class's summary calls its top risk. The <c>topByTimestamp</c>
|
||||
/// form keeps its row limit because there the <c>ORDER BY</c> at least fixes <em>which</em> rows can
|
||||
/// be first. The accepted cost is that an ambiguous selector materialises every matching row for one
|
||||
/// poll; the warning is what gets it fixed.</para>
|
||||
/// <para><b>Why the where-pair form is capped at TWO rows, not one (R06:S-1).</b> A <c>TOP 1</c> with no
|
||||
/// <c>ORDER BY</c> is not deterministic — it returns whichever row the plan produces first — and it would
|
||||
/// destroy the only evidence that the selector is ambiguous, converting a loud misconfiguration into a
|
||||
/// permanently silent one. No limit at all materialised every matching row of an events table on every
|
||||
/// poll. Two rows keeps the evidence and bounds the read: a second row makes the group publish
|
||||
/// <c>BadConfigurationError</c> in <c>MaterialiseAsync</c>, so a where-pair group never reaches this
|
||||
/// method with more than one row. The multi-row warning below is now reached only by a plan with no
|
||||
/// cap.</para>
|
||||
/// <para><b>⚠ The <c>topByTimestamp</c> answer is only defined when the ordering column is unique</b>
|
||||
/// <c>ORDER BY ts DESC</c> plus a one-row limit over a column with ties returns an
|
||||
/// arbitrary row among the tied ones, and — unlike the where-pair form — this method can never
|
||||
@@ -572,7 +787,7 @@ public sealed class SqlPollReader
|
||||
private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome)
|
||||
{
|
||||
if (outcome.Rows.Count == 0) return null;
|
||||
if (outcome.Rows.Count > 1 && ShouldWarnAboutContract())
|
||||
if (outcome.Rows.Count > 1 && ShouldWarn("multirow:" + plan.SqlText))
|
||||
{
|
||||
var first = plan.Members[0];
|
||||
var selector = string.IsNullOrWhiteSpace(first.RowSelectorColumn)
|
||||
@@ -642,9 +857,14 @@ public sealed class SqlPollReader
|
||||
var target = member.DeclaredType ?? _dialect.MapColumnType(outcome.TypeNames[valueAt]);
|
||||
if (!TryCoerce(cell, target, out var value))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type.",
|
||||
member.Name, cell.GetType().Name, target);
|
||||
// A per-tag refusal: warned ONCE per tag (per reader), never per poll (R06:S-6).
|
||||
if (_coerceWarned.TryAdd(member.Name, 0))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type; it publishes " +
|
||||
"BadTypeMismatch (warned once per tag).",
|
||||
member.Name, cell.GetType().Name, target);
|
||||
}
|
||||
return new DataValueSnapshot(null, SqlStatusCodes.BadTypeMismatch, sourceTimestamp, readAt);
|
||||
}
|
||||
|
||||
@@ -711,13 +931,21 @@ public sealed class SqlPollReader
|
||||
Convert.ToDateTime(cell, CultureInfo.InvariantCulture), DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
/// <summary>Rate-limits the source-contract warning so a misconfigured table cannot flood the log.</summary>
|
||||
private bool ShouldWarnAboutContract()
|
||||
/// <summary>Rate-limits one per-poll warning by key; true at most once per <see cref="ContractWarningInterval"/>.</summary>
|
||||
private bool ShouldWarn(string key)
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
var last = Interlocked.Read(ref _lastContractWarningTicks);
|
||||
if (last != 0 && Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
|
||||
return Interlocked.CompareExchange(ref _lastContractWarningTicks, now, last) == last;
|
||||
while (true)
|
||||
{
|
||||
if (!_lastWarnedTicks.TryGetValue(key, out var last))
|
||||
{
|
||||
if (_lastWarnedTicks.TryAdd(key, now)) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
|
||||
if (_lastWarnedTicks.TryUpdate(key, now, last)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One group's outcome: either a materialised result set, or the status its members inherit.</summary>
|
||||
@@ -747,6 +975,17 @@ public sealed class SqlPollReader
|
||||
public static GroupResult Failed { get; } =
|
||||
new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>The reader was disposed before this group could start; surfaced as a typed exception.</summary>
|
||||
public static GroupResult ReaderDisposed { get; } =
|
||||
new(SqlStatusCodes.BadInternalError, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>
|
||||
/// The database answered with more rows than the plan can use — an ambiguous selector (R06:S-1).
|
||||
/// A configuration fact, not a database fault; the driver counts it as answer evidence.
|
||||
/// </summary>
|
||||
public static GroupResult RowCapExceeded { get; } =
|
||||
new(SqlStatusCodes.BadConfigurationError, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>The status every member inherits when <see cref="Succeeded"/> is false.</summary>
|
||||
public uint FailureStatus { get; }
|
||||
|
||||
@@ -775,6 +1014,21 @@ public sealed class SqlPollReader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A poll reached a <see cref="SqlPollReader"/> after it was disposed — a rebind replaced it, or the driver
|
||||
/// was disposed. Not a database verdict: the driver retries on the current reader, and never degrades for it.
|
||||
/// </summary>
|
||||
public sealed class SqlPollReaderDisposedException()
|
||||
: OperationCanceledException("The Sql poll reader was disposed (replaced by a rebind, or the driver was disposed).");
|
||||
|
||||
/// <summary>One poll's snapshots plus the answer evidence they cannot carry themselves.</summary>
|
||||
/// <param name="Snapshots">One snapshot per requested reference, at the same index.</param>
|
||||
/// <param name="AnsweredRefusalGroups">
|
||||
/// Groups the database ANSWERED but whose result the reader refused in whole or part — an over-cap result
|
||||
/// set, or a duplicated key. Those members carry <see cref="SqlStatusCodes.BadConfigurationError"/>.
|
||||
/// </param>
|
||||
public sealed record SqlPollResult(IReadOnlyList<DataValueSnapshot> Snapshots, int AnsweredRefusalGroups);
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read
|
||||
/// them back with.
|
||||
|
||||
@@ -55,6 +55,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
|
||||
/// reader's map from result-set ordinal to source column.
|
||||
/// </param>
|
||||
/// <param name="RowCap">
|
||||
/// The most rows the reader will attribute before refusing the whole group — twice the distinct key count
|
||||
/// (key-value: headroom so stray duplicates are attributed per key) or 1 (a where-pair wide row: one
|
||||
/// selector, so any second row is the group's ambiguity); <see langword="null"/> when the statement
|
||||
/// already limits itself (topByTimestamp). <see cref="SqlText"/> asks for <c>RowCap + 1</c>, and the
|
||||
/// reader stops there: receiving the extra row means a history/events table or a non-unique where column,
|
||||
/// and the group publishes <c>BadConfigurationError</c> instead of materialising every matching row on
|
||||
/// every poll (R06:S-1).
|
||||
/// </param>
|
||||
public sealed record SqlQueryPlan(
|
||||
SqlTagModel Model,
|
||||
object GroupKey,
|
||||
@@ -63,7 +72,8 @@ public sealed record SqlQueryPlan(
|
||||
IReadOnlyList<string> ParameterBindNames,
|
||||
IReadOnlyList<object?> Parameters,
|
||||
IReadOnlyList<SqlTagDefinition> Members,
|
||||
IReadOnlyList<string> SelectedColumns)
|
||||
IReadOnlyList<string> SelectedColumns,
|
||||
int? RowCap = null)
|
||||
{
|
||||
// A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the
|
||||
// planner's mutable working lists — an IReadOnlyList<T> holding a List<T> is downcastable, and a
|
||||
|
||||
+21
-3
@@ -80,7 +80,13 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max read gap (coalescing)</label>
|
||||
<InputNumber @bind-Value="_form.MaxReadGap" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = no coalescing. Typical 5–32.</div>
|
||||
<div class="form-text">0 = no coalescing. Default 8. Refused ranges fall back per tag.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.DisableReadCoalescing" @bind-Value:after="EmitAsync" class="form-check-input" id="disableCoalescing" />
|
||||
<label class="form-check-label" for="disableCoalescing">Disable read coalescing</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,7 +112,13 @@
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.DisableFC23" @bind-Value:after="EmitAsync" class="form-check-input" id="disableFC23" />
|
||||
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
|
||||
<label class="form-check-label" for="disableFC23">Disable FC23 (never send FC23, even when opted in below)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.UseFC23ForMultiRegisterWrites" @bind-Value:after="EmitAsync" class="form-check-input" id="useFC23" />
|
||||
<label class="form-check-label" for="useFC23">Use FC23 write + read-back for multi-register writes (opt-in; default FC16)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@@ -311,12 +323,14 @@
|
||||
public int MaxRegistersPerRead { get; set; } = 125;
|
||||
public int MaxRegistersPerWrite { get; set; } = 123;
|
||||
public int MaxCoilsPerRead { get; set; } = 2000;
|
||||
public int MaxReadGap { get; set; } = 0;
|
||||
public int MaxReadGap { get; set; } = ModbusDriverOptions.DefaultMaxReadGap;
|
||||
public bool DisableReadCoalescing { get; set; } = false;
|
||||
|
||||
// Write options
|
||||
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
|
||||
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
|
||||
public bool DisableFC23 { get; set; } = false;
|
||||
public bool UseFC23ForMultiRegisterWrites { get; set; } = false;
|
||||
public bool WriteOnChangeOnly { get; set; } = false;
|
||||
|
||||
// Keep-alive
|
||||
@@ -357,9 +371,11 @@
|
||||
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
|
||||
MaxCoilsPerRead = o.MaxCoilsPerRead,
|
||||
MaxReadGap = o.MaxReadGap,
|
||||
DisableReadCoalescing = o.DisableReadCoalescing,
|
||||
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = o.DisableFC23,
|
||||
UseFC23ForMultiRegisterWrites = o.UseFC23ForMultiRegisterWrites,
|
||||
WriteOnChangeOnly = o.WriteOnChangeOnly,
|
||||
KeepAliveEnabled = o.KeepAlive.Enabled,
|
||||
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
|
||||
@@ -396,9 +412,11 @@
|
||||
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = DisableFC23,
|
||||
UseFC23ForMultiRegisterWrites = UseFC23ForMultiRegisterWrites,
|
||||
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
|
||||
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
|
||||
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
||||
DisableReadCoalescing = DisableReadCoalescing,
|
||||
Family = Family,
|
||||
MelsecSubFamily = MelsecSubFamily,
|
||||
Transport = Transport,
|
||||
|
||||
+81
-8
@@ -4,24 +4,60 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-8"><label class="form-label">Address (text, optional)</label>
|
||||
<input type="text" class="form-control form-control-sm" placeholder="e.g. 40001:F:CDAB, V2000 (DL205), D100 (MELSEC)"
|
||||
value="@_addressText" @onchange="@(e => _addressText = e.Value?.ToString())" /></div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ResolveAddressAsync">Resolve with driver family</button>
|
||||
</div>
|
||||
@if (_addressError is not null)
|
||||
{
|
||||
<div class="col-12"><div class="form-text text-danger">@_addressError</div></div>
|
||||
}
|
||||
else if (_m.IsStringOnly)
|
||||
{
|
||||
var served = _m.ResolveWith(_family, _melsecSub);
|
||||
<div class="col-12"><div class="form-text text-warning">String-only tag: the driver resolves <code>@_m.AddressString</code> with its own family (here @_family)
|
||||
@if (served.Address is { } a)
|
||||
{
|
||||
<span> → <b>@($"{a.Region}[{a.Address}] {a.DataType}")</b>.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span> — that family cannot parse it, so the driver <b>refuses</b> the tag.</span>
|
||||
}
|
||||
The fields below are NOT its address — press Resolve to pin them, or edit a field to replace the string.</div></div>
|
||||
}
|
||||
else if (_m.HasLegacyAddressString)
|
||||
{
|
||||
<div class="col-12"><div class="form-text">This tag carries a legacy address string <code>@_m.AddressString</code> that the driver does not use: the fields below are its address. Press Resolve to re-pin it from the string with the driver family.</div></div>
|
||||
}
|
||||
else if (_m.IsPinned)
|
||||
{
|
||||
<div class="col-12"><div class="form-text">Resolved from <code>@_m.AddressString</code> and pinned: the fields below are what the driver polls, so a later family change does not remap the tag. Editing a field drops the string.</div></div>
|
||||
}
|
||||
else if (!_m.HasKeyAddress)
|
||||
{
|
||||
<div class="col-12"><div class="form-text text-warning">This tag names no address yet: set a field below or resolve an address string. The driver refuses it until then.</div></div>
|
||||
}
|
||||
<div class="col-md-4"><label class="form-label">Region</label>
|
||||
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => Update(() => _m.Region = ParseEnum(e.Value, ModbusRegion.HoldingRegisters)))">
|
||||
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => UpdateAddress(() => _m.SetRegion(ParseEnum(e.Value, ModbusRegion.HoldingRegisters))))">
|
||||
@foreach (var v in Enum.GetValues<ModbusRegion>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-2"><label class="form-label">Address</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => Update(() => _m.Address = ParseInt(e.Value)))" /></div>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => UpdateAddress(() => _m.Address = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-md-3"><label class="form-label">Data type</label>
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, ModbusDataType.Int16)))">
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => UpdateAddress(() => _m.DataType = ParseEnum(e.Value, ModbusDataType.Int16)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><label class="form-label">Byte order</label>
|
||||
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => Update(() => _m.ByteOrder = ParseEnum(e.Value, ModbusByteOrder.BigEndian)))">
|
||||
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => UpdateAddress(() => _m.ByteOrder = ParseEnum(e.Value, ModbusByteOrder.BigEndian)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusByteOrder>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-2"><label class="form-label">Bit index</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => Update(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => UpdateAddress(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-md-2"><label class="form-label">String len</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => UpdateAddress(() => _m.StringLength = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-12 mt-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
|
||||
</div>
|
||||
@@ -66,6 +102,15 @@
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = ModbusTagConfigModel.FromJson(ConfigJson);
|
||||
// Reset the address box only when a DIFFERENT config arrives (another tag in a reused instance) — not
|
||||
// when the parent echoes back what this editor just emitted, which would wipe text the operator typed
|
||||
// but has not resolved yet.
|
||||
if (ConfigJson != _lastEmitted)
|
||||
{
|
||||
_addressText = _m.AddressString;
|
||||
_addressError = null;
|
||||
}
|
||||
(_family, _melsecSub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
|
||||
}
|
||||
|
||||
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
|
||||
@@ -74,10 +119,38 @@
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private string? _lastEmitted;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
await EmitAsync();
|
||||
}
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
_lastEmitted = _m.ToJson();
|
||||
await ConfigJsonChanged.InvokeAsync(_lastEmitted);
|
||||
}
|
||||
|
||||
private string? _addressText;
|
||||
private string? _addressError;
|
||||
private ModbusFamily _family = ModbusFamily.Generic;
|
||||
private MelsecFamily _melsecSub = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// A hand edit of any resolved field makes those fields authoritative: drop the address string, which the
|
||||
// driver would otherwise re-resolve over them.
|
||||
private Task UpdateAddress(Action apply) => Update(() => { _m.ClearAddressString(); _addressError = null; apply(); });
|
||||
|
||||
// Resolve the typed address with the OWNING driver's family (DL205 octal V-memory, MELSEC D/X/Y) and persist
|
||||
// both the address string and the resolved region/offset/type fields.
|
||||
private async Task ResolveAddressAsync()
|
||||
{
|
||||
var (family, sub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
|
||||
(_family, _melsecSub) = (family, sub);
|
||||
var error = _m.ResolveAddress(_addressText, family, sub);
|
||||
_addressError = error;
|
||||
if (error is null) await EmitAsync();
|
||||
}
|
||||
|
||||
// Parse the builder string (e.g. "4x00001-1") back into Region + Address fields.
|
||||
@@ -94,7 +167,7 @@
|
||||
var offsetStr = dashIdx >= 0 ? rest[..dashIdx] : rest;
|
||||
if (int.TryParse(offsetStr, out var offset))
|
||||
{
|
||||
await Update(() => { _m.Region = region; _m.Address = offset; });
|
||||
await UpdateAddress(() => { _m.Region = region; _m.Address = offset; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,24 @@ public static class RawTagCsvMapper
|
||||
var json = map?.ApplyTypedColumns(baseJson.Length == 0 ? "{}" : baseJson, row)
|
||||
?? (baseJson.Length == 0 ? "{}" : baseJson);
|
||||
|
||||
// A typed column that handed base keys to the model (CsvTypedColumn.ClearsKeys — Modbus's address
|
||||
// cells and the addressString they contradict) and thereby DROPPED one is reported like any other
|
||||
// override, so the operator sees the string go rather than losing it silently (review r3 m1).
|
||||
if (map is not null)
|
||||
{
|
||||
var mergedKeys = ReadRootKeys(json, out _);
|
||||
foreach (var col in map.Columns)
|
||||
{
|
||||
if (typedUsed.Contains(col.Header)
|
||||
&& col.ClearsKeys is { Count: > 0 } handed
|
||||
&& handed.Any(k => baseKeys.Contains(k) && !mergedKeys.Contains(k))
|
||||
&& !overrodeFallback.Contains(col.Header))
|
||||
{
|
||||
overrodeFallback.Add(col.Header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Historize flags.
|
||||
var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
|
||||
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"));
|
||||
|
||||
@@ -28,11 +28,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// and <c>fullName</c>/<c>dataItemId</c>/<c>address</c>) — see <see cref="OwnedJsonKeys"/> for why the CSV
|
||||
/// path has to know the whole set and not just the one it writes.
|
||||
/// </param>
|
||||
/// <param name="ClearsKeys">
|
||||
/// Root keys a NON-BLANK cell in this column hands to the MODEL: they are not restored from the base cell
|
||||
/// (the model's serialisation decides them), and they are not owned for export. Modbus's address columns use
|
||||
/// it for <c>addressString</c> / <c>addressResolved</c> — a typed address cell is an authored key address, and
|
||||
/// the model keeps the string (and marker) only while it still describes the cells, dropping one a cell
|
||||
/// contradicts. The import review grid reports a column that dropped such a key.
|
||||
/// </param>
|
||||
public sealed record CsvTypedColumn(
|
||||
string Header,
|
||||
string JsonKey,
|
||||
string? ModelProperty,
|
||||
IReadOnlyList<string>? ReadKeys = null)
|
||||
IReadOnlyList<string>? ReadKeys = null,
|
||||
IReadOnlyList<string>? ClearsKeys = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Every root JSON key this column speaks for, in the <b>runtime's</b> read precedence — i.e.
|
||||
@@ -161,22 +169,28 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
private readonly Func<string?, TModel> _fromJson;
|
||||
private readonly Func<TModel, string> _toJson;
|
||||
private readonly IReadOnlyList<CsvModelColumn<TModel>> _columns;
|
||||
private readonly Action<TModel, IReadOnlyList<CsvTypedColumn>>? _afterTypedColumns;
|
||||
|
||||
/// <summary>Builds a model-backed map.</summary>
|
||||
/// <param name="driverType">The driver-type string served.</param>
|
||||
/// <param name="fromJson">The model's <c>FromJson</c> loader (preserves unknown keys).</param>
|
||||
/// <param name="toJson">The model's <c>ToJson</c> serialiser.</param>
|
||||
/// <param name="columns">The typed columns (metadata + read/write pairs).</param>
|
||||
/// <param name="afterTypedColumns">Optional model hook run once after a row's non-blank typed cells were applied
|
||||
/// (given the columns that fired), before serialisation. Modbus uses it to reconcile the address string with
|
||||
/// the authored address cells.</param>
|
||||
public ModelBackedCsvDriverTagMap(
|
||||
string driverType,
|
||||
Func<string?, TModel> fromJson,
|
||||
Func<TModel, string> toJson,
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns)
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns,
|
||||
Action<TModel, IReadOnlyList<CsvTypedColumn>>? afterTypedColumns = null)
|
||||
{
|
||||
DriverType = driverType;
|
||||
_fromJson = fromJson;
|
||||
_toJson = toJson;
|
||||
_columns = columns;
|
||||
_afterTypedColumns = afterTypedColumns;
|
||||
Columns = columns.Select(c => c.Meta).ToArray();
|
||||
}
|
||||
|
||||
@@ -223,9 +237,12 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
col.Set(model, raw.Trim());
|
||||
fired.Add(col.Meta);
|
||||
foreach (var key in col.Meta.OwnedJsonKeys) { overridden.Add(key); }
|
||||
foreach (var key in col.Meta.ClearsKeys ?? []) { overridden.Add(key); }
|
||||
}
|
||||
}
|
||||
|
||||
if (fired.Count > 0) { _afterTypedColumns?.Invoke(model, fired); }
|
||||
|
||||
var merged = TagConfigJson.ParseOrNew(_toJson(model));
|
||||
foreach (var (key, node) in baseObj)
|
||||
{
|
||||
@@ -459,24 +476,35 @@ public static class CsvColumnMap
|
||||
{
|
||||
var maps = new CsvDriverTagMap[]
|
||||
{
|
||||
// A Modbus tag whose blob has no region + address keys (string-only, or naming no usable address — the
|
||||
// one authority, ModbusTagAddressResolution, decides) exports BLANK address cells: its model fields are
|
||||
// load-time defaults, and exporting them as HoldingRegisters / 0 invited a re-import that authored an
|
||||
// HR[0] address. The driver resolves the string (or refuses the tag).
|
||||
new ModelBackedCsvDriverTagMap<ModbusTagConfigModel>(
|
||||
DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
// The six address columns: a non-blank cell is an AUTHORED key address. The string and its
|
||||
// marker survive only while they still describe the cells (ReconcileAddressStringWithKeys,
|
||||
// below) — so an unchanged export → import is lossless and a contradicting cell drops them.
|
||||
ModbusAddrCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => !m.HasKeyAddress ? null : m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusAddrCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => !m.HasKeyAddress ? null : m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusAddrCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => !m.HasKeyAddress ? null : m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusAddrCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => !m.HasKeyAddress ? null : m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusAddrCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => !m.HasKeyAddress ? null : m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusAddrCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
m => !m.HasKeyAddress ? null : m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
ModbusCol("Writable", "writable", nameof(ModbusTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
},
|
||||
(m, fired) =>
|
||||
{
|
||||
if (fired.Any(c => c.ClearsKeys is { Count: > 0 })) { m.ReconcileAddressStringWithKeys(); }
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<S7TagConfigModel>(
|
||||
@@ -617,6 +645,11 @@ public static class CsvColumnMap
|
||||
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<ModbusTagConfigModel> ModbusAddrCol(
|
||||
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p, ClearsKeys: ["addressString", ModbusTagDefinitionFactory.AddressResolvedKey]),
|
||||
get, set);
|
||||
|
||||
private static CsvModelColumn<S7TagConfigModel> S7Col(
|
||||
string h, string k, string p, Func<S7TagConfigModel, string?> get, Action<S7TagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
@@ -7,29 +7,188 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class ModbusTagConfigModel
|
||||
{
|
||||
// Every address field is backed so that SETTING one is an authored address (see _authorsKeys): a hand edit,
|
||||
// a CSV address cell, ResolveAddress or an object initialiser. FromJson assigns the backing fields directly.
|
||||
private ModbusRegion _region = ModbusRegion.HoldingRegisters;
|
||||
private int _address;
|
||||
private ModbusDataType _dataType = ModbusDataType.Int16;
|
||||
private ModbusByteOrder _byteOrder = ModbusByteOrder.BigEndian;
|
||||
private int _bitIndex;
|
||||
private int _stringLength;
|
||||
|
||||
/// <summary>Register region (HoldingRegisters/InputRegisters/Coils/DiscreteInputs).</summary>
|
||||
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
|
||||
public ModbusRegion Region { get => _region; set { _region = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Starting register/coil address.</summary>
|
||||
public int Address { get; set; }
|
||||
public int Address { get => _address; set { _address = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Wire value type the register block decodes to.</summary>
|
||||
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
|
||||
public ModbusDataType DataType { get => _dataType; set { _dataType = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Word/byte ordering for multi-register values.</summary>
|
||||
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
|
||||
public ModbusByteOrder ByteOrder { get => _byteOrder; set { _byteOrder = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Bit index (0-15) for BitInRegister tags.</summary>
|
||||
public int BitIndex { get; set; }
|
||||
public int BitIndex { get => _bitIndex; set { _bitIndex = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>String length in characters for String tags.</summary>
|
||||
public int StringLength { get; set; }
|
||||
public int StringLength { get => _stringLength; set { _stringLength = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Optional writable flag. <c>null</c> ⇒ the key is omitted (driver default true);
|
||||
/// an explicit <c>false</c> makes the equipment tag read-only. Preserved across load→save so an absent
|
||||
/// key stays absent.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The address in the parser grammar (<c>40001:F:CDAB</c>, DL205 <c>V2000</c>, MELSEC <c>D100</c>) when the
|
||||
/// tag carries one; <c>null</c> otherwise. Whether it IS the address is not this model's call — it is
|
||||
/// <see cref="AddressShape"/>, asked of <see cref="ModbusTagAddressResolution"/>: the string is the address
|
||||
/// only when the saved blob has no <c>region</c> + <c>address</c> keys (<see cref="IsStringOnly"/>).
|
||||
/// </summary>
|
||||
public string? AddressString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The editor's <c>"addressResolved": true</c> marker: the keys were resolved FROM <see cref="AddressString"/>
|
||||
/// by <see cref="ResolveAddress"/>. Display / CSV state only — it never makes keys authoritative (keys win
|
||||
/// by presence) and a marker on a blob without keys is ignored (review r3 N3).
|
||||
/// </summary>
|
||||
public bool AddressResolved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Which part of the blob <see cref="ToJson"/> would save names the address — the one authority's verdict
|
||||
/// (<see cref="ModbusTagAddressResolution.Classify(string)"/>) on this model's own output, so the editor,
|
||||
/// the CSV export and the driver can never disagree about it.
|
||||
/// </summary>
|
||||
public ModbusAddressShape AddressShape => ModbusTagAddressResolution.Classify(ToJson());
|
||||
|
||||
/// <summary>
|
||||
/// A string-only tag (CSV / API authored, no <c>region</c> + <c>address</c> keys): the driver resolves
|
||||
/// <see cref="AddressString"/> with its own family, and the region / offset / type fields are NOT the address
|
||||
/// — <see cref="ToJson"/> never stamps them, so no round trip can turn the tag into HoldingRegisters[0].
|
||||
/// </summary>
|
||||
public bool IsStringOnly => AddressShape == ModbusAddressShape.StringOnly;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the fields are the tag's address: the saved blob carries <c>region</c> + <c>address</c>. False for
|
||||
/// a string-only tag and for a blob that names no usable address (the fields are then load-time defaults
|
||||
/// and must not be presented — or exported — as an address).
|
||||
/// </summary>
|
||||
public bool HasKeyAddress => AddressShape == ModbusAddressShape.Keys;
|
||||
|
||||
/// <summary>The string was resolved into the keys and pinned (marker + keys).</summary>
|
||||
public bool IsPinned => AddressString is not null && AddressResolved && HasKeyAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Keys beside an unmarked <see cref="AddressString"/> — a pre-#741 row (the old driver never read the
|
||||
/// string). The keys are the address; the string is informational only.
|
||||
/// </summary>
|
||||
public bool HasLegacyAddressString => AddressString is not null && !AddressResolved && HasKeyAddress;
|
||||
|
||||
/// <summary>What the driver serves this tag as under a family — the one authority, on the blob this model saves.</summary>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution (refused when the family cannot parse a string-only tag).</returns>
|
||||
public ModbusAddressResolution ResolveWith(ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
=> ModbusTagAddressResolution.Resolve(ToJson(), family, melsecSubFamily);
|
||||
|
||||
// Whether ToJson writes the six address fields: the loaded blob carried the keys (or named no address at all
|
||||
// AND the operator then authored a field), or an address field was set since. Never true for a string-only or
|
||||
// invalid-string blob that nobody edited: the editor must not write default keys it did not resolve.
|
||||
private bool _authorsKeys = true;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an address string through <see cref="ModbusAddressParser"/> with the owning driver's
|
||||
/// family and copies the result into the region / offset / type / bit / string-length / byte-order /
|
||||
/// array fields — the "persist the resolved region/offset" half of the family wiring, so the blob is
|
||||
/// readable on its own and the editor shows what the driver will poll.
|
||||
/// </summary>
|
||||
/// <param name="text">The address text the operator typed.</param>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns><c>null</c> on success, else the parser's error.</returns>
|
||||
public string? ResolveAddress(string? text, ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
{
|
||||
if (!ModbusAddressParser.TryParse(text, family, melsecSubFamily, out var parsed, out var error) || parsed is null)
|
||||
return error ?? "Address did not parse.";
|
||||
AddressString = text!.Trim();
|
||||
AddressResolved = true;
|
||||
_authorsKeys = true;
|
||||
Region = parsed.Region;
|
||||
Address = parsed.Offset;
|
||||
DataType = parsed.DataType;
|
||||
ByteOrder = parsed.ByteOrder;
|
||||
BitIndex = parsed.Bit ?? 0;
|
||||
StringLength = parsed.StringLength;
|
||||
if (parsed.ArrayCount is { } count)
|
||||
{
|
||||
TagConfigJson.Set(_bag, "isArray", true);
|
||||
TagConfigJson.Set(_bag, "arrayLength", count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_bag.Remove("isArray");
|
||||
_bag.Remove("arrayLength");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Drops the address string so the hand-edited region / address fields are what the driver reads.</summary>
|
||||
public void ClearAddressString()
|
||||
{
|
||||
AddressString = null;
|
||||
AddressResolved = false;
|
||||
_authorsKeys = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a CSV import's typed address cells were applied (they are an authored key address): keeps the
|
||||
/// <see cref="AddressString"/> (and its marker) only when it still describes those fields under some family
|
||||
/// — so an unchanged export → import of a pinned row is lossless (review r3 m1) — and drops it when a cell
|
||||
/// contradicts it. Either way the keys are the address; this only decides the metadata.
|
||||
/// </summary>
|
||||
public void ReconcileAddressStringWithKeys()
|
||||
{
|
||||
if (AddressString is null) return;
|
||||
if (!ModbusTagAddressResolution.AddressStringDescribes(AddressString, Region, Address, DataType, ByteOrder, BitIndex, StringLength))
|
||||
ClearAddressString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the owning driver's <c>family</c> / <c>melsecSubFamily</c> from its DriverConfig JSON (keys
|
||||
/// matched case-insensitively, like the driver's DTO binding); Generic / Q_L_iQR when absent or unknown.
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver's DriverConfig JSON.</param>
|
||||
/// <returns>The family pair the driver will resolve address strings with.</returns>
|
||||
public static (ModbusFamily Family, MelsecFamily MelsecSubFamily) ReadFamily(string? driverConfigJson)
|
||||
{
|
||||
var family = ModbusFamily.Generic;
|
||||
var sub = MelsecFamily.Q_L_iQR;
|
||||
var o = TagConfigJson.ParseOrNew(driverConfigJson);
|
||||
foreach (var (key, node) in o)
|
||||
{
|
||||
var text = node is JsonValue v && v.TryGetValue<string>(out var str) ? str : null;
|
||||
if (text is null) continue;
|
||||
if (string.Equals(key, "family", StringComparison.OrdinalIgnoreCase)
|
||||
&& Enum.TryParse<ModbusFamily>(text, ignoreCase: true, out var f)) family = f;
|
||||
else if (string.Equals(key, "melsecSubFamily", StringComparison.OrdinalIgnoreCase)
|
||||
&& Enum.TryParse<MelsecFamily>(text, ignoreCase: true, out var m)) sub = m;
|
||||
}
|
||||
return (family, sub);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the region and keeps the data type compatible with it (the driver refuses a bit region
|
||||
/// with a non-Bool type, and a register region with Bool): switching to Coils / DiscreteInputs selects
|
||||
/// Bool, switching from them back to a register region selects Int16.
|
||||
/// </summary>
|
||||
/// <param name="region">The newly selected region.</param>
|
||||
public void SetRegion(ModbusRegion region)
|
||||
{
|
||||
Region = region;
|
||||
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(region, DataType))
|
||||
DataType = ModbusTagDefinitionFactory.DefaultDataType(region);
|
||||
}
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The blob this model was LOADED FROM, or null when it was built by object initialiser rather than
|
||||
@@ -44,16 +203,29 @@ public sealed class ModbusTagConfigModel
|
||||
public static ModbusTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
var shape = ModbusTagAddressResolution.Classify(TagConfigJson.Serialize(o));
|
||||
var region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters);
|
||||
return new ModbusTagConfigModel
|
||||
{
|
||||
_source = json ?? "",
|
||||
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
|
||||
Address = TagConfigJson.GetInt(o, "address"),
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
|
||||
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
StringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
_region = region,
|
||||
_address = TagConfigJson.GetInt(o, "address"),
|
||||
// Absent dataType defaults per region, exactly as the driver's factory does (Bool for a bit region),
|
||||
// and a bit region shows Bool whatever was persisted — the driver coerces a legacy coil/DI
|
||||
// "dataType":"Int16" (what the pre-#741 editor saved) to Bool, so the editor shows what is polled.
|
||||
_dataType = region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
|
||||
? ModbusDataType.Bool
|
||||
: TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
|
||||
_byteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
_bitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
_stringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
AddressString = string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, ModbusTagAddressResolution.AddressStringKey))
|
||||
? null : TagConfigJson.GetString(o, ModbusTagAddressResolution.AddressStringKey),
|
||||
AddressResolved = TagConfigJson.GetBoolNullable(o, ModbusTagAddressResolution.AddressResolvedKey) == true,
|
||||
// The one authority decides whether the loaded fields are an address: only a blob with the keys is.
|
||||
// A string-only / invalid-string / address-less blob keeps its keys unwritten until a field is authored.
|
||||
_authorsKeys = shape == ModbusAddressShape.Keys,
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -63,14 +235,28 @@ public sealed class ModbusTagConfigModel
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "region", Region);
|
||||
TagConfigJson.Set(_bag, "address", Address);
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(_bag, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(_bag, "stringLength", StringLength);
|
||||
TagConfigJson.Set(_bag, "writable", Writable);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
var o = (JsonObject)_bag.DeepClone();
|
||||
// Only an authored key address is stamped (see _authorsKeys). A string-only tag's fields are load-time
|
||||
// defaults, not its address: stamping them would sit HoldingRegisters/0 beside the string, and by the key
|
||||
// rule that would WIN (review r3 N3).
|
||||
if (_authorsKeys)
|
||||
{
|
||||
TagConfigJson.Set(o, "region", Region);
|
||||
TagConfigJson.Set(o, "address", Address);
|
||||
TagConfigJson.Set(o, "dataType", DataType);
|
||||
TagConfigJson.Set(o, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(o, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(o, "stringLength", StringLength);
|
||||
}
|
||||
TagConfigJson.Set(o, "writable", Writable);
|
||||
// A non-string addressString the model could not load is left as persisted (the driver refuses it, and
|
||||
// dropping it would change the deploy gate's explanation) unless the address was authored since.
|
||||
if (AddressString is not null || _authorsKeys)
|
||||
TagConfigJson.Set(o, ModbusTagAddressResolution.AddressStringKey, AddressString);
|
||||
// The marker only means something beside keys; a marker on a keyless blob is a stale lie and is dropped.
|
||||
TagConfigJson.Set(o, ModbusTagAddressResolution.AddressResolvedKey,
|
||||
AddressString is not null && AddressResolved && _authorsKeys ? true : null);
|
||||
return TagConfigJson.Serialize(o);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
@@ -103,13 +289,15 @@ public sealed class ModbusTagConfigModel
|
||||
/// </remarks>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
=> TagConfigJson.DescribeInvalidEnum<ModbusRegion>(_bag, "region")
|
||||
?? TagConfigJson.DescribeInvalidEnum<ModbusDataType>(_bag, "dataType")
|
||||
?? TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(_bag, "byteOrder")
|
||||
// A string-only tag's region / dataType / byteOrder keys are not read by the driver (the string is the
|
||||
// address), so judging them would make authoring stricter than the runtime (review r2 n6).
|
||||
=> (IsStringOnly ? null : TagConfigJson.DescribeInvalidEnum<ModbusRegion>(_bag, "region"))
|
||||
?? (IsStringOnly ? null : TagConfigJson.DescribeInvalidEnum<ModbusDataType>(_bag, "dataType"))
|
||||
?? (IsStringOnly ? null : TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(_bag, "byteOrder"))
|
||||
?? TagConfigJson.DescribeInvalidEnum<ModbusStringByteOrder>(_bag, "stringByteOrder")
|
||||
?? TagConfigParseGate.Describe(
|
||||
_source,
|
||||
j => ModbusTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
|
||||
j => ModbusTagDefinitionFactory.FromTagConfigAnyFamily(j, TagConfigParseGate.AuthoringRawPath, out _),
|
||||
ModbusTagDefinitionFactory.Inspect,
|
||||
"Modbus");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache,
|
||||
/// alarm store-and-forward, and scripted-alarm condition-state tables and — when this node
|
||||
/// alarm store-and-forward, and scripted-alarm condition-state + comment tables and — when this node
|
||||
/// actually replicates — opts them into change capture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
public static class LocalDbSetup
|
||||
{
|
||||
/// <summary>
|
||||
/// The four tables that participate in replication — registered when this node is configured
|
||||
/// The five tables that participate in replication — registered when this node is configured
|
||||
/// to replicate, and deregistered (stale capture triggers dropped) when it is not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -36,6 +36,7 @@ public static class LocalDbSetup
|
||||
DeploymentCacheSchema.PointerTable,
|
||||
AlarmSfSchema.EventsTable,
|
||||
AlarmConditionStateSchema.StateTable,
|
||||
AlarmConditionStateSchema.CommentsTable,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -47,8 +48,9 @@ public static class LocalDbSetup
|
||||
/// <c>RegisterReplicated</c> is what installs the three AFTER triggers that capture
|
||||
/// changes into the oplog. Any row written before that call is never captured, so it
|
||||
/// never reaches the peer — silently, and permanently, because nothing ever revisits
|
||||
/// history. <see cref="AlarmSfLegacyMigrator"/> therefore runs <b>last</b>, after every
|
||||
/// registration — it writes rows, and they must be captured like any other write.
|
||||
/// history. <see cref="AlarmConditionStateSchema.MigrateInlineComments"/> and
|
||||
/// <see cref="AlarmSfLegacyMigrator"/> therefore run <b>last</b>, after every
|
||||
/// registration — they write rows, and those must be captured like any other write.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Registration is CONDITIONAL on <see cref="ReplicationIsConfigured"/>.</b> It used
|
||||
@@ -68,7 +70,7 @@ public static class LocalDbSetup
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure part-way through either loop fails host start, and that is deliberate.</b>
|
||||
/// Neither branch catches, so a throw on the third of four tables leaves two flipped and
|
||||
/// Neither branch catches, so a throw on the third of five tables leaves two flipped and
|
||||
/// propagates out of the <c>AddZbLocalDb</c> singleton factory — a node that came up
|
||||
/// anyway would be capturing (or not) on an arbitrary subset, which is silently partial
|
||||
/// replication with nothing in the log to say so. Both library calls are idempotent, so
|
||||
@@ -116,7 +118,36 @@ public static class LocalDbSetup
|
||||
else
|
||||
RemoveStaleCapture(db, logger ?? Serilog.Log.Logger);
|
||||
|
||||
// LAST, and only here. This is the one call in OnReady that writes rows.
|
||||
// LAST, and only here. These are the only calls in OnReady that write rows.
|
||||
// Copies any pre-split inline comment trail (alarm_condition_state.comments_json) into the
|
||||
// append-only alarm_condition_comment table (review R05:4). Idempotent: an ordinal already
|
||||
// held is never rewritten, and the condition row itself is never touched (the alarm's next
|
||||
// save clears the column). An unreadable trail is reported, not skipped silently.
|
||||
AlarmConditionStateSchema.InlineCommentMigrationResult commentMigration;
|
||||
using (var connection = db.CreateConnection())
|
||||
{
|
||||
commentMigration = AlarmConditionStateSchema.MigrateInlineComments(connection);
|
||||
}
|
||||
|
||||
var migrationLog = logger ?? Serilog.Log.Logger;
|
||||
if (commentMigration.RowsMoved > 0)
|
||||
{
|
||||
migrationLog.Information(
|
||||
"LocalDb: copied the inline comment trail of {AlarmCount} scripted alarm(s) into {Table}.",
|
||||
commentMigration.RowsMoved,
|
||||
AlarmConditionStateSchema.CommentsTable);
|
||||
}
|
||||
|
||||
foreach (var alarmId in commentMigration.UnparseableAlarmIds)
|
||||
{
|
||||
migrationLog.Warning(
|
||||
"LocalDb: scripted alarm {AlarmId} has an unreadable inline comment trail "
|
||||
+ "(alarm_condition_state.comments_json is not valid JSON); its history was NOT moved to {Table} "
|
||||
+ "and the alarm's state will not load until the row is repaired.",
|
||||
alarmId,
|
||||
AlarmConditionStateSchema.CommentsTable);
|
||||
}
|
||||
|
||||
AlarmSfLegacyMigrator.Migrate(db, configuration);
|
||||
}
|
||||
|
||||
|
||||
+296
-98
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
@@ -11,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Node-local <see cref="IAlarmStateStore"/> backed by the replicated
|
||||
/// <see cref="AlarmConditionStateSchema.StateTable"/> table in the node's consolidated LocalDb.
|
||||
/// Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
|
||||
/// Shelving + the ack/confirm audit trail + operator comments.
|
||||
/// <see cref="AlarmConditionStateSchema.StateTable"/> and
|
||||
/// <see cref="AlarmConditionStateSchema.CommentsTable"/> tables in the node's consolidated
|
||||
/// LocalDb. Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
|
||||
/// Shelving + the ack/confirm audit fields on the condition row, and the append-only comment
|
||||
/// trail one row per entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -24,44 +24,75 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
/// state then mirrors to its redundant pair peer instead of depending on central SQL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The round-trip is byte-identical to the EF store</b> — the enum↔string mappers and the
|
||||
/// comment serialization below are duplicated verbatim from it (it is deleted in a later
|
||||
/// task; the ported round-trip tests prove parity). The persistence rules carry over
|
||||
/// unchanged:
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>ActiveState is NOT persisted</b> — there is no column; on <see cref="LoadAsync"/> it is
|
||||
/// restored as <see cref="AlarmActiveState.Inactive"/> and the engine re-derives it from the
|
||||
/// live predicate. <b>LastTransitionUtc ↔ updated_at_utc</b>: no dedicated transition column,
|
||||
/// so the last transition rides the row-write timestamp. <b>LastActiveUtc / LastClearedUtc</b>
|
||||
/// have no columns and default to <c>null</c> on load. <see cref="AlarmConditionState.Comments"/>
|
||||
/// serializes to/from <c>comments_json</c>; an empty list round-trips as <c>"[]"</c>.
|
||||
/// have no columns and default to <c>null</c> on load.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Save is a single unconditional PK upsert.</b> No application-level last-write-wins or
|
||||
/// HLC comparison is layered on top — the LocalDb replication HLC handles convergence, and an
|
||||
/// unconditional upsert keyed by the primary key is HLC-safe (the same discipline the
|
||||
/// alarm-sf sink and the deployment pointer follow).
|
||||
/// <b>The comment trail is written incrementally (review R05:4).</b> Each save writes only
|
||||
/// the entries the table does not yet hold, so a transition's write — and its replication
|
||||
/// payload — is constant however long the history is; the condition row's
|
||||
/// <c>comments_json</c> is always written as <c>"[]"</c>. What is "already stored" is read
|
||||
/// from the table inside the save's transaction (a bounded read of the trail's tail) rather
|
||||
/// than cached, so the store holds no per-alarm state. The pair peer's copy of the same
|
||||
/// command is recognised and not duplicated; a local entry that collides with a peer entry
|
||||
/// this node never applied is appended after it with a Warning, never dropped — see
|
||||
/// <c>AppendNewCommentsAsync</c> and, for why the ordinal is the key,
|
||||
/// <see cref="AlarmConditionStateSchema"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Remove vs. a lagging peer.</b> <see cref="RemoveAsync"/> deletes the whole trail; if the
|
||||
/// pair peer has not yet applied the same deploy, its next save finds no stored trail and
|
||||
/// re-inserts every entry, which replicates back. The next deploy removes them again — the
|
||||
/// same resurrection class the single-row store had, with n+1 rows instead of one (review m3).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Old-shape rows load exactly as before.</b> A row still carrying an inline
|
||||
/// <c>comments_json</c> trail (written by the pre-split store and not yet moved by
|
||||
/// <see cref="AlarmConditionStateSchema.MigrateInlineComments"/>) is read through the same
|
||||
/// parser the old store used, merged by ordinal with whatever the comment table holds (the
|
||||
/// table wins an ordinal both hold), and moved to the table on that alarm's next save.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Save is one transaction:</b> the comment append and an unconditional PK upsert of the
|
||||
/// condition row. No application-level last-write-wins or HLC comparison is layered on top —
|
||||
/// the LocalDb replication HLC handles convergence.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
// Fixed column order shared by every read — the reader map below indexes by these ordinals.
|
||||
private const string SelectColumns =
|
||||
"scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state, " +
|
||||
"shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc, " +
|
||||
"last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc";
|
||||
|
||||
private const string SelectCommentColumns = "scripted_alarm_id, seq, timestamp_utc, user_name, kind, text";
|
||||
|
||||
/// <summary>
|
||||
/// How many trailing in-memory entries each save reconciles against the stored trail by
|
||||
/// content. The engine appends at most one entry per save, so this comfortably covers a few
|
||||
/// missed commands while keeping each save's read bounded.
|
||||
/// </summary>
|
||||
private const int MatchWindow = 16;
|
||||
|
||||
/// <summary>
|
||||
/// How far apart two nodes' timestamps for the SAME command may be and still count as one
|
||||
/// entry: command fan-out latency plus inter-node clock skew. Beyond it both copies are kept
|
||||
/// (the safe direction — a duplicate, never a loss).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan SameCommandSkew = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly ILocalDb _db;
|
||||
private readonly ILogger<LocalDbAlarmConditionStateStore> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LocalDbAlarmConditionStateStore"/> class.</summary>
|
||||
/// <param name="db">
|
||||
/// The node's local database. Its <see cref="AlarmConditionStateSchema.StateTable"/> table
|
||||
/// must already exist and be registered for replication — the host does both in
|
||||
/// <c>LocalDbSetup.OnReady</c>, before any consumer can resolve this store.
|
||||
/// The node's local database. Its condition-state and comment tables must already exist and
|
||||
/// be registered for replication — the host does both in <c>LocalDbSetup.OnReady</c>, before
|
||||
/// any consumer can resolve this store.
|
||||
/// </param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public LocalDbAlarmConditionStateStore(ILocalDb db, ILogger<LocalDbAlarmConditionStateStore> logger)
|
||||
@@ -79,7 +110,15 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
MapRow,
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
return rows.Count > 0 ? rows[0] : null;
|
||||
if (rows.Count == 0) return null;
|
||||
|
||||
var comments = await _db.QueryAsync(
|
||||
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment WHERE scripted_alarm_id = @Id ORDER BY seq",
|
||||
MapComment,
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return WithComments(rows[0], comments);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -90,7 +129,19 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
MapRow,
|
||||
parameters: null,
|
||||
ct).ConfigureAwait(false);
|
||||
return rows;
|
||||
|
||||
var comments = await _db.QueryAsync(
|
||||
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment ORDER BY scripted_alarm_id, seq",
|
||||
MapComment,
|
||||
parameters: null,
|
||||
ct).ConfigureAwait(false);
|
||||
var byAlarm = comments
|
||||
.GroupBy(c => c.AlarmId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<StoredComment>)g.ToList(), StringComparer.Ordinal);
|
||||
|
||||
return rows
|
||||
.Select(r => WithComments(r, byAlarm.TryGetValue(r.State.AlarmId, out var c) ? c : []))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -98,10 +149,15 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
await using var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await AppendNewCommentsAsync(tx, state, ct).ConfigureAwait(false);
|
||||
|
||||
// A single unconditional upsert keyed on the alarm identity. Convergence across the pair is
|
||||
// last-writer-wins over the primary key, handled by the LocalDb replication HLC — no
|
||||
// app-level LWW here (see the class remarks).
|
||||
await _db.ExecuteAsync(
|
||||
// app-level LWW here (see the class remarks). comments_json is always "[]": the trail lives
|
||||
// in alarm_condition_comment, which is what keeps this row a fixed size.
|
||||
await tx.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
@@ -140,60 +196,243 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
LastConfirmUser = state.LastConfirmUser,
|
||||
LastConfirmComment = state.LastConfirmComment,
|
||||
LastConfirmUtc = FormatNullable(state.LastConfirmUtc),
|
||||
CommentsJson = SerializeComments(state.Comments),
|
||||
CommentsJson = AlarmConditionStateSchema.EmptyInlineComments,
|
||||
// No dedicated transition column — persist LastTransitionUtc into updated_at_utc.
|
||||
UpdatedAtUtc = Format(state.LastTransitionUtc),
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogTrace("Persisted alarm-condition state for {AlarmId}", state.AlarmId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the trail entries <paramref name="state"/> holds that the table does not, writing
|
||||
/// a bounded number of rows however long the trail is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Normal case.</b> The in-memory trail and the stored trail agree position by
|
||||
/// position; the only new entries are past <c>MAX(seq)</c> and land at their own ordinal.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Divergence (review I-1).</b> If this node missed a command its peer applied, the
|
||||
/// peer's entry arrives in this table at an ordinal this node's in-memory trail does not
|
||||
/// know about, and this node's NEXT entry has an in-memory index at or below
|
||||
/// <c>MAX(seq)</c>. Positional "already stored" would silently drop it — the survivor's
|
||||
/// newest operator action, in the failover case. So the tail of the in-memory trail
|
||||
/// (the last <see cref="MatchWindow"/> entries) is matched against the stored rows from
|
||||
/// that position on, by content: same Kind, User and Text, timestamps within
|
||||
/// <see cref="SameCommandSkew"/> (each node stamps the same command with its own clock).
|
||||
/// A matched stored row is consumed, so repeated identical entries are counted, not
|
||||
/// merged. An entry with no match is new: at its own ordinal when that is past the stored
|
||||
/// trail, otherwise appended after it (<c>MAX(seq)+1</c>) with a Warning — kept, never
|
||||
/// dropped, and never silent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why this cannot double-count.</b> Both pair nodes apply every command; the second
|
||||
/// node to save finds the first's copy among the stored rows (same Kind/User/Text, close
|
||||
/// timestamp) and consumes it instead of appending. A node's own earlier divergence-append
|
||||
/// is found the same way, so a re-save writes nothing. What it cannot tell apart: a peer
|
||||
/// entry this node missed and a byte-identical entry this node later records within the
|
||||
/// skew window — that pair is kept once. A clock skew between the nodes beyond the window
|
||||
/// errs the other way (both copies kept), never towards loss. True union semantics need an
|
||||
/// originator-assigned entry id on the alarm command (follow-up F3).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>The in-memory index is the durable ordinal.</b> Trimming
|
||||
/// <see cref="AlarmConditionState.Comments"/> in the engine without carrying a persisted
|
||||
/// base offset would make every later save look "already stored" — do not bound that list
|
||||
/// without changing this method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task AppendNewCommentsAsync(ILocalDbTransaction tx, AlarmConditionState state, CancellationToken ct)
|
||||
{
|
||||
var comments = state.Comments;
|
||||
var count = comments.Count;
|
||||
|
||||
// An indexed MAX over the (scripted_alarm_id, seq) primary key — a read, so no replication cost.
|
||||
var max = (await tx.QueryAsync(
|
||||
"SELECT COALESCE(MAX(seq), -1) FROM alarm_condition_comment WHERE scripted_alarm_id = @Id",
|
||||
static r => r.GetInt64(0),
|
||||
new { Id = state.AlarmId },
|
||||
ct).ConfigureAwait(false))[0];
|
||||
|
||||
var windowStart = Math.Max(0, count - MatchWindow);
|
||||
|
||||
// Entries below the window that lie past the stored trail (a first save of a long trail, an
|
||||
// old-shape row's lazy move): plain appends at their own ordinal — nothing is stored there.
|
||||
for (var seq = (int)Math.Max(max + 1, 0); seq < windowStart; seq++)
|
||||
{
|
||||
await InsertCommentAsync(tx, state.AlarmId, seq, comments[seq], ct).ConfigureAwait(false);
|
||||
max = seq;
|
||||
}
|
||||
|
||||
if (windowStart >= count) return;
|
||||
|
||||
// The stored rows the window can match: ordinal >= windowStart. Bounded by the window plus the
|
||||
// number of commands this node missed.
|
||||
var stored = max >= windowStart
|
||||
? await tx.QueryAsync(
|
||||
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment WHERE scripted_alarm_id = @Id AND seq >= @Lo ORDER BY seq",
|
||||
MapComment,
|
||||
new { Id = state.AlarmId, Lo = windowStart },
|
||||
ct).ConfigureAwait(false)
|
||||
: [];
|
||||
var consumed = new bool[stored.Count];
|
||||
|
||||
for (var i = windowStart; i < count; i++)
|
||||
{
|
||||
var entry = comments[i];
|
||||
var match = -1;
|
||||
for (var j = 0; j < stored.Count; j++)
|
||||
{
|
||||
if (!consumed[j] && IsSameEntry(stored[j].Comment, entry))
|
||||
{
|
||||
match = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match >= 0)
|
||||
{
|
||||
consumed[match] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i > max)
|
||||
{
|
||||
await InsertCommentAsync(tx, state.AlarmId, i, entry, ct).ConfigureAwait(false);
|
||||
max = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = max + 1;
|
||||
await InsertCommentAsync(tx, state.AlarmId, target, entry, ct).ConfigureAwait(false);
|
||||
max = target;
|
||||
_logger.LogWarning(
|
||||
"Alarm {AlarmId}: comment trail diverged from the pair peer — the {Kind} entry by {User} at in-memory "
|
||||
+ "position {Position} collides with a stored entry this node never applied (a missed command). "
|
||||
+ "Kept: appended at ordinal {Ordinal}.",
|
||||
state.AlarmId, entry.Kind, entry.User, i, target);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSameEntry(AlarmComment stored, AlarmComment local) =>
|
||||
string.Equals(stored.Kind, local.Kind, StringComparison.Ordinal)
|
||||
&& string.Equals(stored.User, local.User, StringComparison.Ordinal)
|
||||
&& string.Equals(stored.Text, local.Text, StringComparison.Ordinal)
|
||||
&& (stored.TimestampUtc - local.TimestampUtc).Duration() <= SameCommandSkew;
|
||||
|
||||
private static Task<int> InsertCommentAsync(
|
||||
ILocalDbTransaction tx, string alarmId, long seq, AlarmComment c, CancellationToken ct) =>
|
||||
tx.ExecuteAsync(
|
||||
AlarmConditionStateSchema.InsertCommentSql,
|
||||
new
|
||||
{
|
||||
Id = alarmId,
|
||||
Seq = seq,
|
||||
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for a Utc round-trip; the engine
|
||||
// always creates comments with Utc kind.
|
||||
Ts = AlarmConditionStateSchema.FormatTimestamp(c.TimestampUtc),
|
||||
User = c.User,
|
||||
Kind = c.Kind,
|
||||
Text = c.Text,
|
||||
},
|
||||
ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(alarmId);
|
||||
await _db.ExecuteAsync(
|
||||
|
||||
// The trail goes with the condition, as it did when it was a column of the row.
|
||||
await using var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM alarm_condition_comment WHERE scripted_alarm_id = @Id",
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM alarm_condition_state WHERE scripted_alarm_id = @Id",
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AlarmConditionState MapRow(SqliteDataReader r) => new(
|
||||
/// <summary>A condition row as read, before its trail is attached.</summary>
|
||||
private sealed record ConditionRow(AlarmConditionState State, string InlineCommentsJson);
|
||||
|
||||
/// <summary>One <c>alarm_condition_comment</c> row.</summary>
|
||||
private sealed record StoredComment(string AlarmId, long Seq, AlarmComment Comment);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the trail: the comment table's entries by ordinal, plus — for an old-shape row not
|
||||
/// yet migrated — any inline entry at an ordinal the table does not hold.
|
||||
/// </summary>
|
||||
private static AlarmConditionState WithComments(ConditionRow row, IReadOnlyList<StoredComment> stored)
|
||||
{
|
||||
var inline = AlarmConditionStateSchema.ParseInlineComments(row.InlineCommentsJson);
|
||||
if (inline.Count == 0)
|
||||
return row.State with { Comments = stored.Select(s => s.Comment).ToImmutableList() };
|
||||
|
||||
var merged = new SortedDictionary<long, AlarmComment>();
|
||||
foreach (var s in stored)
|
||||
merged[s.Seq] = s.Comment;
|
||||
for (var i = 0; i < inline.Count; i++)
|
||||
{
|
||||
var c = inline[i];
|
||||
merged.TryAdd(i, new AlarmComment(c.TimestampUtc, c.User, c.Kind, c.Text));
|
||||
}
|
||||
|
||||
return row.State with { Comments = merged.Values.ToImmutableList() };
|
||||
}
|
||||
|
||||
private static StoredComment MapComment(SqliteDataReader r) => new(
|
||||
AlarmId: r.GetString(0),
|
||||
Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
|
||||
? AlarmEnabledState.Disabled
|
||||
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
|
||||
// Active is not persisted — the engine re-derives it from the predicate at startup.
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
|
||||
? AlarmAckedState.Acknowledged
|
||||
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
|
||||
Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
|
||||
? AlarmConfirmedState.Confirmed
|
||||
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
|
||||
Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
|
||||
// No transition column — updated_at_utc carries the last transition timestamp.
|
||||
LastTransitionUtc: Parse(r.GetString(13)),
|
||||
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: ParseNullable(r, 8),
|
||||
LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
|
||||
LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
|
||||
LastConfirmUtc: ParseNullable(r, 11),
|
||||
LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
|
||||
LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
|
||||
Comments: DeserializeComments(r.GetString(12)));
|
||||
Seq: r.GetInt64(1),
|
||||
Comment: new AlarmComment(
|
||||
AlarmConditionStateSchema.ParseTimestamp(r.GetString(2)),
|
||||
r.GetString(3),
|
||||
r.GetString(4),
|
||||
r.GetString(5)));
|
||||
|
||||
private static ConditionRow MapRow(SqliteDataReader r) => new(
|
||||
new AlarmConditionState(
|
||||
AlarmId: r.GetString(0),
|
||||
Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
|
||||
? AlarmEnabledState.Disabled
|
||||
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
|
||||
// Active is not persisted — the engine re-derives it from the predicate at startup.
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
|
||||
? AlarmAckedState.Acknowledged
|
||||
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
|
||||
Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
|
||||
? AlarmConfirmedState.Confirmed
|
||||
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
|
||||
Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
|
||||
// No transition column — updated_at_utc carries the last transition timestamp.
|
||||
LastTransitionUtc: Parse(r.GetString(13)),
|
||||
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: ParseNullable(r, 8),
|
||||
LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
|
||||
LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
|
||||
LastConfirmUtc: ParseNullable(r, 11),
|
||||
LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
|
||||
LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
|
||||
Comments: ImmutableList<AlarmComment>.Empty),
|
||||
InlineCommentsJson: r.GetString(12));
|
||||
|
||||
// Timestamps are stored as round-trip ("O") TEXT so lexicographic order stays chronological and
|
||||
// the DateTimeKind.Utc round-trips exactly.
|
||||
private static string Format(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
|
||||
private static string Format(DateTime value) => AlarmConditionStateSchema.FormatTimestamp(value);
|
||||
|
||||
private static string? FormatNullable(DateTime? value) => value?.ToString("O", CultureInfo.InvariantCulture);
|
||||
private static string? FormatNullable(DateTime? value) => value is { } v ? Format(v) : null;
|
||||
|
||||
private static DateTime Parse(string value) =>
|
||||
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
private static DateTime Parse(string value) => AlarmConditionStateSchema.ParseTimestamp(value);
|
||||
|
||||
private static DateTime? ParseNullable(SqliteDataReader r, int ordinal) =>
|
||||
r.IsDBNull(ordinal) ? null : Parse(r.GetString(ordinal));
|
||||
@@ -220,45 +459,4 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
"TimedShelved" => ShelvingKind.Timed,
|
||||
_ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default)
|
||||
};
|
||||
|
||||
private static string SerializeComments(ImmutableList<AlarmComment> comments)
|
||||
{
|
||||
if (comments.IsEmpty) return "[]";
|
||||
var dtos = comments.Select(c => new CommentDto
|
||||
{
|
||||
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip;
|
||||
// the engine always creates AlarmComment instances with Utc kind.
|
||||
TimestampUtc = c.TimestampUtc,
|
||||
User = c.User,
|
||||
Kind = c.Kind,
|
||||
Text = c.Text,
|
||||
});
|
||||
return JsonSerializer.Serialize(dtos, JsonOptions);
|
||||
}
|
||||
|
||||
private static ImmutableList<AlarmComment> DeserializeComments(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
|
||||
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
|
||||
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
|
||||
return dtos
|
||||
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
|
||||
.ToImmutableList();
|
||||
}
|
||||
|
||||
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>comments_json</c>.</summary>
|
||||
private sealed class CommentDto
|
||||
{
|
||||
/// <summary>When the comment was recorded (UTC).</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
|
||||
/// <summary>Identity of the actor that wrote the comment.</summary>
|
||||
public string? User { get; set; }
|
||||
|
||||
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
|
||||
public string? Kind { get; set; }
|
||||
|
||||
/// <summary>Operator-supplied or engine-generated comment text.</summary>
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+107
-1
@@ -138,11 +138,117 @@ public sealed class DriverArrayWriteSupportTests
|
||||
public void OpcUaClient_forwards_every_array() =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.OpcUaClient, "UInt16", "{\"nodeId\":\"ns=2;s=x\"}").ShouldBeTrue();
|
||||
|
||||
// ---- AbCip / AbLegacy / TwinCAT (review 07 U10 / U9: their array encoders landed in #737 / #738 / #739) ----
|
||||
|
||||
/// <summary>AbCip: the atomic element types <c>AbCipArrayCodec</c> encodes. BOOL (bit-packed DWORDs), STRING and
|
||||
/// structure arrays stay read-only, as do a safety tag, an undeclared length and a node type no element converts
|
||||
/// from.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"int\"" + Arr2 + "}", "Int16", true)] // case-insensitive, like the factory
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"LReal\"" + Arr2 + "}", "Double", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Dt\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInnt\"" + Arr2 + "}", "Int32", false)] // typo ⇒ the factory refuses the tag
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"safetyTag\":true" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"isArray\":true}", "Int32", false)] // no length ⇒ scalar
|
||||
[InlineData("{\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", false)] // no tagPath ⇒ not an AbCip tag
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "DateTime", false)] // DateTime elements never convert
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "String", false)] // text converts only when numeric
|
||||
public void AbCip_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>AbLegacy: Int / AnalogInt / Long / Float on a plain address of the MATCHING data file. Every shape the
|
||||
/// write path refuses stays read-only.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"N7:0\"" + Arr2 + "}", "Int16", true)] // dataType ⇒ Int
|
||||
[InlineData("{\"address\":\" n7:10 \",\"dataType\":\"analogint\"" + Arr2 + "}", "Int16", true)] // trimmed, case-insensitive
|
||||
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"A9:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"L9:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Float\"" + Arr2 + "}", "Float", true)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":256}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":257}", "Int16", false)] // the driver clamps to 256
|
||||
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Bit\"" + Arr2 + "}", "Boolean", false)]
|
||||
[InlineData("{\"address\":\"ST9:0\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"address\":\"T4:0\",\"dataType\":\"TimerElement\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0/3\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"T4:0.ACC\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:[N7:0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // type / file mismatch
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"address\":\"S:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // libplctag refuses a translated name
|
||||
[InlineData("{\"address\":\"O:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Innt\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Boolean", false)] // bool elements are refused
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
||||
public void AbLegacy_rows_follow_the_element_type_and_data_file(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>The native transport refuses every PLC-5 write before the wire, so a native PLC-5 device keeps its
|
||||
/// arrays read-only, wherever the transport and the family are configured. libplctag PLC-5 is the control.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"transport\":\"Native\",\"devices\":[{\"hostAddress\":\"ab://h/1,0\",\"plcFamily\":\"Plc5\"}]}", "{}", false)]
|
||||
[InlineData("{\"Transport\":\"native\"}", "{\"PlcFamily\":\"plc5\"}", false)]
|
||||
[InlineData("{\"transport\":\"Native\"}", "{\"plcFamily\":\"Slc500\"}", true)]
|
||||
[InlineData("{\"transport\":\"Libplctag\"}", "{\"plcFamily\":\"Plc5\"}", true)]
|
||||
[InlineData("{}", "{\"plcFamily\":\"Plc5\"}", true)] // absent ⇒ libplctag
|
||||
public void AbLegacy_native_PLC5_stays_read_only(string driverConfig, string deviceConfig, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16",
|
||||
"{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", driverConfig, deviceConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>TwinCAT: every element type <c>TwinCATArrayCodec</c> encodes (the factory refuses Structure tags). A
|
||||
/// BOOL bit-in-word path writes a scalar bit and is refused for an array value.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "String", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "Double", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"DateTime\"" + Arr2 + "}", "DateTime", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Grid[0,0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)] // bit-in-word
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
||||
[InlineData("{\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "DateTime", false)] // a duration, not a date
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "DateTime", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "DateTime", false)] // culture-dependent text vs strict Windows-1252
|
||||
public void TwinCAT_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>ScadaBridge writes CLR <c>int[]</c> / <c>List<int></c> to Int16 nodes, and an unknown DataType
|
||||
/// name (served as BaseDataType) hands the driver whatever the client sent. The element CONVERTS in all three
|
||||
/// encoders, so neither case may make the row read-only.</summary>
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.AbCip, "{\"tagPath\":\"Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
[InlineData(DriverTypeNames.AbLegacy, "{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
[InlineData(DriverTypeNames.TwinCAT, "{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
public void Converting_drivers_do_not_key_on_an_exact_node_type(string driverType, string tagConfig)
|
||||
{
|
||||
foreach (var dataType in new[] { "Int16", "Int32", "Int64", "UInt16", "Double", "Float32", "Reference" })
|
||||
DriverArrayWriteSupport.Supports(driverType, dataType, tagConfig).ShouldBeTrue($"{driverType} {dataType}");
|
||||
DriverArrayWriteSupport.Supports(driverType, null, tagConfig).ShouldBeTrue($"{driverType} <null>");
|
||||
}
|
||||
|
||||
/// <summary>Every driver type NOT in the table — and an unknown or absent one — stays read-only.</summary>
|
||||
[Fact]
|
||||
public void Every_other_driver_type_is_not_array_writable()
|
||||
{
|
||||
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
|
||||
var listed = new[]
|
||||
{
|
||||
DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy,
|
||||
DriverTypeNames.AbCip, DriverTypeNames.AbLegacy, DriverTypeNames.TwinCAT,
|
||||
};
|
||||
var others = DriverTypeNames.All.Except(listed).ToList();
|
||||
others.ShouldNotBeEmpty();
|
||||
// A blob every listed driver would accept, so only the driver type can make the answer false.
|
||||
|
||||
+18
@@ -34,6 +34,24 @@ public sealed class AlarmConditionStateSchemaTests
|
||||
((long)cmd.ExecuteScalar()!).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Apply also creates the append-only comment table, keyed by alarm id + ordinal.</summary>
|
||||
[Fact]
|
||||
public void Apply_creates_the_comment_table_with_a_composite_key()
|
||||
{
|
||||
using var connection = OpenInMemory();
|
||||
|
||||
AlarmConditionStateSchema.Apply(connection);
|
||||
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT name FROM pragma_table_info(@Name) WHERE pk > 0 ORDER BY pk";
|
||||
cmd.Parameters.AddWithValue("@Name", AlarmConditionStateSchema.CommentsTable);
|
||||
var pk = new List<string>();
|
||||
using (var reader = cmd.ExecuteReader())
|
||||
while (reader.Read()) pk.Add(reader.GetString(0));
|
||||
|
||||
pk.ShouldBe(["scripted_alarm_id", "seq"]);
|
||||
}
|
||||
|
||||
/// <summary>Applying twice on the same connection does not throw (CREATE TABLE IF NOT EXISTS).</summary>
|
||||
[Fact]
|
||||
public void Apply_is_idempotent()
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbCip row against the REAL write path (#737, review 07 U10), the
|
||||
/// same way the Modbus, S7 and Galaxy parity tests do: the server advertises an array node writable exactly when
|
||||
/// <see cref="AbCipDriver.WriteAsync"/> encodes a whole-array value for it (through the production
|
||||
/// <see cref="AbCipArrayCodec"/>, which the fake runtime's <c>EncodeArray</c> calls), so a driver change that adds
|
||||
/// or drops an array element type fails here until the table follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The value written is the CLR array the server hands the driver for the node's <c>DataType</c>: the node manager
|
||||
/// converts a numeric client array (ScadaBridge's <c>int[]</c>) to the node's element type first, and the codec
|
||||
/// then converts each element to the controller's type. The fake controller holds exactly
|
||||
/// <c>width × count</c> bytes, so only the element type and the value can make the write fail.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayWriteSupportParityTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static Array ClrArray(string dataType, int n) => dataType switch
|
||||
{
|
||||
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
|
||||
"SByte" => Enumerable.Repeat((sbyte)1, n).ToArray(),
|
||||
"Byte" => Enumerable.Repeat((byte)1, n).ToArray(),
|
||||
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
|
||||
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
|
||||
"Int32" => Enumerable.Repeat(1, n).ToArray(),
|
||||
"UInt32" => Enumerable.Repeat(1u, n).ToArray(),
|
||||
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
|
||||
"UInt64" => Enumerable.Repeat(1UL, n).ToArray(),
|
||||
"Float" => Enumerable.Repeat(1f, n).ToArray(),
|
||||
"Double" => Enumerable.Repeat(1d, n).ToArray(),
|
||||
// Non-numeric text: a String node over a numeric element converts only when the text happens to be a
|
||||
// number, which is not a write the server can promise — the table keeps it read-only.
|
||||
"String" => Enumerable.Repeat("a", n).ToArray(),
|
||||
"DateTime" => Enumerable.Repeat(DateTime.UtcNow, n).ToArray(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(dataType)),
|
||||
};
|
||||
|
||||
private static readonly string[] NodeTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float", "Double", "String", "DateTime"];
|
||||
|
||||
private static async Task<bool> DriverEncodesAsync(string tagConfig, AbCipDataType elementType, string nodeType, int count)
|
||||
{
|
||||
// The controller holds exactly what an encodable element array of this length occupies.
|
||||
var size = (AbCipArrayCodec.ElementWidth(elementType) ?? 1) * count;
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
|
||||
using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Device)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-parity", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, count))], CancellationToken.None);
|
||||
|
||||
return r[0].StatusCode == AbCipStatusMapper.Good
|
||||
&& factory.Tags.TryGetValue("Arr", out var tag)
|
||||
&& tag.EncodedArrayCount == count && tag.WriteCount == 1;
|
||||
}
|
||||
|
||||
public static TheoryData<AbCipDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<AbCipDataType, string>();
|
||||
foreach (var t in Enum.GetValues<AbCipDataType>())
|
||||
foreach (var n in NodeTypes)
|
||||
data.Add(t, n);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every element type × every node DataType: the table answers exactly what the write path does.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(TypeMatrix))]
|
||||
public async Task Table_agrees_with_the_write_path_for_every_element_and_node_type(AbCipDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"tagPath":"Arr","dataType":"{{dataType}}","isArray":true,"arrayLength":3}""";
|
||||
|
||||
var driver = await DriverEncodesAsync(json, dataType, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: a safety tag (never writable), an undeclared length (a
|
||||
/// scalar to the factory, whose scalar encoder cannot take an array), a 1-element array, an element-offset path,
|
||||
/// and their positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":3}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":1}""", 1)]
|
||||
[InlineData("""{"tagPath":"Arr","isArray":true,"arrayLength":3}""", 3)] // dataType ⇒ DInt
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","safetyTag":true,"isArray":true,"arrayLength":3}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":0}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":false,"arrayLength":3}""", 3)]
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(string tagConfig, int nodeLength)
|
||||
{
|
||||
var driver = await DriverEncodesAsync(tagConfig, AbCipDataType.DInt, "Int32", nodeLength);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, "Int32", tagConfig).ShouldBe(driver, tagConfig);
|
||||
}
|
||||
|
||||
/// <summary>The ScadaBridge wire shape reaches the codec: a CLR <c>int[]</c> (and <c>List<int></c>) to an
|
||||
/// INT array on an Int16 node the table advertises writable encodes. Positive control for the conversion rule.</summary>
|
||||
[Fact]
|
||||
public async Task A_ScadaBridge_int_list_to_an_INT_array_encodes()
|
||||
{
|
||||
const string json = """{"tagPath":"Arr","dataType":"Int","isArray":true,"arrayLength":3}""";
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, "Int16", json).ShouldBeTrue();
|
||||
|
||||
foreach (object value in new object[] { new[] { 1, -2, 300 }, new List<int> { 1, -2, 300 } })
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 6 } };
|
||||
using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = [new RawTagEntry("Arr", json, WriteIdempotent: false, DeviceName: Device)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-parity-sb", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", value)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Arr"].EncodedArrayBytes.ShouldBe(Convert.FromHexString("0100FEFF2C01"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
-18
@@ -1,24 +1,48 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbLegacy row against the REAL array encoder
|
||||
/// (<see cref="AbLegacyArrayWriteEncoder"/>), the same way the Modbus and S7 parity tests do: for every element
|
||||
/// type the server must advertise an array node writable exactly when the driver encodes it.
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbLegacy row against the REAL write path, the same way the Modbus,
|
||||
/// S7 and Galaxy parity tests do: the server must advertise an array node writable exactly when
|
||||
/// <see cref="AbLegacyDriver.WriteAsync"/> encodes a whole-array value for it, so a driver change that adds or drops
|
||||
/// an array shape fails here until the table follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but
|
||||
/// <see cref="DriverArrayWriteSupport"/> (Core.Abstractions, not owned by the AbLegacy lane) still lists AbLegacy as
|
||||
/// scalar-only, so every AbLegacy array node stays read-only and the encoder is unreachable from OPC UA. When F1
|
||||
/// adds the row, delete the Skip; this test then holds the table and the encoder together.
|
||||
/// The driver is driven end to end — <c>FromTagConfig</c>, the transport's own <c>WriteRefusal</c>, the data-file
|
||||
/// width check, the element conversion — against a fake runtime that records <c>EncodeArray</c>. The value written
|
||||
/// is the CLR array the server hands the driver for the node's <c>DataType</c> (the node manager converts a numeric
|
||||
/// client array to the node's element type first), sized to the node's declared length.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
{
|
||||
public static TheoryData<AbLegacyDataType> AllTypes() => new(Enum.GetValues<AbLegacyDataType>());
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
// Tag.DataType name → the CLR array the server delivers for a 1-D write of that type.
|
||||
private static Array ClrArray(string dataType, int n) => dataType switch
|
||||
{
|
||||
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
|
||||
"SByte" => Enumerable.Repeat((sbyte)1, n).ToArray(),
|
||||
"Byte" => Enumerable.Repeat((byte)1, n).ToArray(),
|
||||
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
|
||||
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
|
||||
"Int32" => Enumerable.Repeat(1, n).ToArray(),
|
||||
"UInt32" => Enumerable.Repeat(1u, n).ToArray(),
|
||||
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
|
||||
"UInt64" => Enumerable.Repeat(1UL, n).ToArray(),
|
||||
"Float" => Enumerable.Repeat(1f, n).ToArray(),
|
||||
"Double" => Enumerable.Repeat(1d, n).ToArray(),
|
||||
"String" => Enumerable.Repeat("a", n).ToArray(),
|
||||
"DateTime" => Enumerable.Repeat(DateTime.UtcNow, n).ToArray(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(dataType)),
|
||||
};
|
||||
|
||||
private static readonly string[] NodeTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float", "Double", "String", "DateTime"];
|
||||
|
||||
private static string AddressFor(AbLegacyDataType t) => t switch
|
||||
{
|
||||
@@ -32,24 +56,131 @@ public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
_ => "N7:0",
|
||||
};
|
||||
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[MemberData(nameof(AllTypes))]
|
||||
public void Table_agrees_with_the_array_encoder(AbLegacyDataType dataType)
|
||||
private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public Array? Encoded { get; private set; }
|
||||
|
||||
public override void EncodeArray(AbLegacyDataType type, Array values) => Encoded = values;
|
||||
}
|
||||
|
||||
// The fake runtime, but the PRODUCTION transport's pre-wire write refusal (libplctag: translated names; native:
|
||||
// PLC-5 and status-bit sub-elements).
|
||||
private sealed class ParityFactory(IAbLegacyTagFactory transport) : IAbLegacyTagFactory
|
||||
{
|
||||
public List<RecordingTag> Created { get; } = [];
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
|
||||
{
|
||||
var tag = new RecordingTag(p);
|
||||
lock (Created) Created.Add(tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
public string? WriteRefusal(AbLegacyTagCreateParams p) => transport.WriteRefusal(p);
|
||||
}
|
||||
|
||||
// Whether the driver encodes a whole-array write of the node's declared length for this tag.
|
||||
private static async Task<bool> DriverEncodesAsync(
|
||||
string tagConfig, string nodeType, int nodeLength, AbLegacyPlcFamily family, bool native)
|
||||
{
|
||||
var factory = new ParityFactory(native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());
|
||||
using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host, family)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Host)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-parity", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, nodeLength))], CancellationToken.None);
|
||||
|
||||
bool encoded;
|
||||
lock (factory.Created) encoded = factory.Created.Any(t => t.Encoded is { } a && a.Length == nodeLength && t.WriteCount == 1);
|
||||
return r[0].StatusCode == AbLegacyStatusMapper.Good && encoded;
|
||||
}
|
||||
|
||||
public static TheoryData<AbLegacyDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<AbLegacyDataType, string>();
|
||||
foreach (var t in Enum.GetValues<AbLegacyDataType>())
|
||||
foreach (var n in NodeTypes)
|
||||
data.Add(t, n);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every element type × every node DataType, on the matching data file: the table answers exactly what
|
||||
/// the write path does.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(TypeMatrix))]
|
||||
public async Task Table_agrees_with_the_write_path_for_every_element_and_node_type(AbLegacyDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"address":"{{AddressFor(dataType)}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
|
||||
var encoderAccepts = AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _)
|
||||
&& AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good;
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString());
|
||||
var driver = await DriverEncodesAsync(json, nodeType, 2, AbLegacyPlcFamily.Slc500, native: false);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: the <c>/bit</c>, <c>.sub</c>, type-versus-file-width and
|
||||
/// over-long cases the write path refuses, a native PLC-5 device (refused before the wire), and their positive
|
||||
/// controls. Each row carries the address, element type, length, family and transport.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Slc500, true)]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.MicroLogix, true)]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Plc5, false)] // libplctag writes PLC-5
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Plc5, true)] // native refuses every PLC-5 write
|
||||
[InlineData("B3:0", "Int", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("A9:0", "AnalogInt", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:10", "AnalogInt", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("L9:0", "Long", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("F8:0", "Float", 4, AbLegacyPlcFamily.Slc500, true)]
|
||||
[InlineData("N7:0", "Int", 256, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Int", 300, AbLegacyPlcFamily.Slc500, false)] // clamped to 256 by the factory: a 300-element write is refused
|
||||
[InlineData("N7:0/3", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0/3", "Bit", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("T4:0.ACC", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("F8:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Long", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("L9:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Float", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("S:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)] // libplctag refuses a translated name
|
||||
[InlineData("O:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(
|
||||
string address, string dataType, int length, AbLegacyPlcFamily family, bool native)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":{{length}}}""";
|
||||
var driverConfig = native ? """{"transport":"Native"}""" : "{}";
|
||||
var deviceConfig = $$"""{"hostAddress":"{{Host}}","plcFamily":"{{family}}"}""";
|
||||
|
||||
var driver = await DriverEncodesAsync(json, "Int16", length, family, native);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json, driverConfig, deviceConfig)
|
||||
.ShouldBe(driver, $"{address} {dataType}[{length}] {family} native={native}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes the table keeps read-only although the write path may encode them — the deliberately conservative
|
||||
/// direction, pinned so it is a decision and not drift: an INDIRECT address (its element moves with the index
|
||||
/// word, and the index read is not atomic with the write), and the file-number-less <c>S:</c> / <c>I:</c> /
|
||||
/// <c>O:</c> files on the native transport (libplctag refuses them; the table does not read the transport
|
||||
/// closely enough to widen output-image and status-file writes on native alone).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"S:0","dataType":"Int","isArray":true,"arrayLength":2}""", """{"transport":"Native"}""")]
|
||||
[InlineData("""{"address":"O:0","dataType":"Int","isArray":true,"arrayLength":2}""", """{"transport":"Native"}""")]
|
||||
public void Table_keeps_the_conservative_shapes_read_only(string tagConfig, string driverConfig) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
|
||||
|
||||
/// <summary>
|
||||
/// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a
|
||||
/// <c>/bit</c> or <c>.sub</c> address, an indirect address, a type that does not match its data file (I3), and
|
||||
/// a native PLC-5 device. The F1 row must answer false for every one — keyed on <c>dataType</c> alone it would
|
||||
/// advertise them writable. Skipped with the parity theory until F1 lands.
|
||||
/// a native PLC-5 device. Keyed on <c>dataType</c> alone the row would advertise them writable.
|
||||
/// </summary>
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[Theory]
|
||||
[InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
@@ -60,7 +191,7 @@ public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
|
||||
|
||||
/// <summary>What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file).</summary>
|
||||
/// <summary>What the encoder accepts: Int, AnalogInt, Long, Float (on a matching file).</summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.Int, true)]
|
||||
[InlineData(AbLegacyDataType.AnalogInt, true)]
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Comms-loss → recovery against the real pymodbus <c>standard</c> fixture, with a loopback
|
||||
/// <see cref="TcpRelay"/> in front of it so the device can disappear and come back without touching the
|
||||
/// shared container. Covers the review 07 High findings P3 (transport self-heal) and H2 (deadband
|
||||
/// recovery publish).
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "Standard")]
|
||||
public sealed class CommsRecoveryTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
private const uint StatusGood = 0u;
|
||||
|
||||
private void SkipUnlessStandardLive()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
|
||||
if (profile is not null && !string.Equals(profile, "standard", StringComparison.OrdinalIgnoreCase))
|
||||
Assert.Skip($"MODBUS_SIM_PROFILE={profile} — these tests need the standard profile (HR[0..31] = address).");
|
||||
}
|
||||
|
||||
private ModbusDriverOptions Options(TcpRelay relay, params ModbusTagDefinition[] tags) => new()
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = relay.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
RawTags = ModbusRawTags.Entries(tags),
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
private static async Task<T> WaitFor<T>(Func<Task<T>> probe, Func<T, bool> done, TimeSpan budget)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
T last;
|
||||
do
|
||||
{
|
||||
last = await probe();
|
||||
if (done(last)) return last;
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
} while (sw.Elapsed < budget);
|
||||
return last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P3: after the device drops (two failed calls in a row, socket torn down, reconnect refused) the
|
||||
/// transport must reconnect ON ITS OWN once the device is back — before this fix every later call
|
||||
/// failed fast with "Transport not connected" until the 90 s comms-loss watchdog re-initialised the
|
||||
/// driver.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Driver_reads_again_after_the_device_drops_and_returns_without_reinit()
|
||||
{
|
||||
SkipUnlessStandardLive();
|
||||
await using var relay = new TcpRelay(sim.Host, sim.Port);
|
||||
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16, Writable: false);
|
||||
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-p3");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var good = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
|
||||
good[0].StatusCode.ShouldBe(StatusGood);
|
||||
good[0].Value.ShouldBe((ushort)5);
|
||||
|
||||
relay.Cut();
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var bad = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
|
||||
bad[0].StatusCode.ShouldNotBe(StatusGood);
|
||||
}
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
relay.Restore();
|
||||
var recovered = await WaitFor(
|
||||
() => driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken),
|
||||
r => r[0].StatusCode == StatusGood,
|
||||
TimeSpan.FromSeconds(20));
|
||||
recovered[0].StatusCode.ShouldBe(StatusGood, "the transport must reconnect by itself once the device is back");
|
||||
recovered[0].Value.ShouldBe((ushort)5);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// U1 (FC23 opt-in, DisableFC23 kill-switch): a multi-register write on the opted-in FC23 write + read-back path
|
||||
/// round-trips against the real server — whether it implements FC23 or answers Illegal Function and
|
||||
/// is moved to FC16 — and so does the default FC16 path. Uses the standard profile's scratch HR[200..209].
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, 200)]
|
||||
[InlineData(false, 202)]
|
||||
public async Task Multi_register_write_round_trips_with_and_without_FC23(bool useFc23, int address)
|
||||
{
|
||||
SkipUnlessStandardLive();
|
||||
var tag = new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, (ushort)address, ModbusDataType.Float32);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host, Port = sim.Port, UnitId = 1, Timeout = TimeSpan.FromSeconds(2), UseFC23ForMultiRegisterWrites = useFc23,
|
||||
RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, "modbus-u1");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var value = useFc23 ? 6.5f : 12.25f;
|
||||
for (var i = 0; i < 2; i++) // second write exercises the latched path when the server lacks FC23
|
||||
{
|
||||
var w = await driver.WriteAsync([new WriteRequest("F", value + i)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(StatusGood);
|
||||
var r = await driver.ReadAsync(["F"], TestContext.Current.CancellationToken);
|
||||
r[0].Value.ShouldBe(value + i);
|
||||
}
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// H2: a deadbanded tag whose device returns holding the SAME value must publish the Good recovery.
|
||||
/// HR[5] is a constant 5 in the standard profile, so the recovery value is identical by construction.
|
||||
/// Positive control: the Bad publish during the outage is observed first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Deadbanded_subscription_publishes_the_Good_recovery_with_an_identical_value()
|
||||
{
|
||||
SkipUnlessStandardLive();
|
||||
await using var relay = new TcpRelay(sim.Host, sim.Port);
|
||||
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16,
|
||||
Writable: false, Deadband: 10.0);
|
||||
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-h2");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var published = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
|
||||
driver.OnDataChange += (_, e) => published.Enqueue(e.Snapshot);
|
||||
var handle = await driver.SubscribeAsync(["HR5"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
try
|
||||
{
|
||||
(await WaitFor(() => Task.FromResult(published.ToArray()),
|
||||
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(10)))
|
||||
.ShouldContain(s => s.StatusCode == StatusGood, "initial Good publish");
|
||||
|
||||
relay.Cut();
|
||||
(await WaitFor(() => Task.FromResult(published.ToArray()),
|
||||
p => p.Any(s => s.StatusCode != StatusGood), TimeSpan.FromSeconds(20)))
|
||||
.ShouldContain(s => s.StatusCode != StatusGood, "the outage must publish Bad (positive control)");
|
||||
|
||||
var badCount = published.Count;
|
||||
relay.Restore();
|
||||
var after = await WaitFor(() => Task.FromResult(published.Skip(badCount).ToArray()),
|
||||
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(30));
|
||||
var recovery = after.FirstOrDefault(s => s.StatusCode == StatusGood);
|
||||
recovery.ShouldNotBeNull("the Good recovery with the same value must not be swallowed by the deadband");
|
||||
recovery.Value.ShouldBe((ushort)5);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Loopback TCP relay in front of the real Modbus fixture, so a test can make the device "disappear"
|
||||
/// (<see cref="Cut"/>: every relayed connection is closed and new connects are refused) and "come back"
|
||||
/// (<see cref="Restore"/>: listen again on the SAME port) without touching the shared container.
|
||||
/// </summary>
|
||||
internal sealed class TcpRelay : IAsyncDisposable
|
||||
{
|
||||
private readonly string _upstreamHost;
|
||||
private readonly int _upstreamPort;
|
||||
private readonly object _lock = new();
|
||||
private readonly List<TcpClient> _live = [];
|
||||
private TcpListener? _listener;
|
||||
private CancellationTokenSource _stop = new();
|
||||
private int _accepted;
|
||||
|
||||
public TcpRelay(string upstreamHost, int upstreamPort)
|
||||
{
|
||||
_upstreamHost = upstreamHost;
|
||||
_upstreamPort = upstreamPort;
|
||||
var probe = new TcpListener(IPAddress.Loopback, 0);
|
||||
probe.Start();
|
||||
Port = ((IPEndPoint)probe.LocalEndpoint).Port;
|
||||
probe.Stop();
|
||||
Restore();
|
||||
}
|
||||
|
||||
/// <summary>Gets the loopback port clients dial.</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>Gets the number of client connections the relay has accepted.</summary>
|
||||
public int AcceptedConnections => Volatile.Read(ref _accepted);
|
||||
|
||||
/// <summary>Starts (or resumes) relaying on <see cref="Port"/>.</summary>
|
||||
public void Restore()
|
||||
{
|
||||
_stop = new CancellationTokenSource();
|
||||
var listener = new TcpListener(IPAddress.Loopback, Port);
|
||||
listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
listener.Start();
|
||||
_listener = listener;
|
||||
var token = _stop.Token;
|
||||
_ = Task.Run(() => AcceptLoopAsync(listener, token));
|
||||
}
|
||||
|
||||
/// <summary>Closes every relayed connection and refuses new ones until <see cref="Restore"/>.</summary>
|
||||
public void Cut()
|
||||
{
|
||||
try { _stop.Cancel(); } catch { }
|
||||
try { _listener?.Stop(); } catch { }
|
||||
_listener = null;
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var c in _live) { try { c.Close(); } catch { } }
|
||||
_live.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
TcpClient downstream;
|
||||
try { downstream = await listener.AcceptTcpClientAsync(token); }
|
||||
catch { return; }
|
||||
Interlocked.Increment(ref _accepted);
|
||||
_ = Task.Run(() => RelayAsync(downstream, token));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RelayAsync(TcpClient downstream, CancellationToken token)
|
||||
{
|
||||
var upstream = new TcpClient(AddressFamily.InterNetwork);
|
||||
lock (_lock) { _live.Add(downstream); _live.Add(upstream); }
|
||||
try
|
||||
{
|
||||
var addr = (await Dns.GetHostAddressesAsync(_upstreamHost, token))
|
||||
.First(a => a.AddressFamily == AddressFamily.InterNetwork);
|
||||
await upstream.ConnectAsync(addr, _upstreamPort, token);
|
||||
var a = downstream.GetStream();
|
||||
var b = upstream.GetStream();
|
||||
await Task.WhenAny(a.CopyToAsync(b, token), b.CopyToAsync(a, token));
|
||||
}
|
||||
catch { /* cut or upstream gone */ }
|
||||
finally
|
||||
{
|
||||
try { downstream.Close(); } catch { }
|
||||
try { upstream.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Cut();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
+31
@@ -25,10 +25,41 @@ public sealed class ModbusArrayWriteSupportParityTests
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes where the driver has moved ahead of the server-side table (review 07 P6 / U3, Modbus lane) and
|
||||
/// <see cref="DriverArrayWriteSupport"/> — owned outside the Modbus lane — has not followed yet
|
||||
/// (follow-up F1): the tag factory now REFUSES a Coils tag with a non-Bool type (so the driver serves no
|
||||
/// such tag), and the driver now encodes String[] and BitInRegister[] holding-register writes.
|
||||
/// <see cref="Pending_F1_divergences_still_exist"/> fails the moment the table follows, so this list
|
||||
/// is removed with it rather than left to rot.
|
||||
/// </summary>
|
||||
private static bool PendingF1(ModbusRegion region, ModbusDataType dataType) =>
|
||||
(region == ModbusRegion.Coils && dataType != ModbusDataType.Bool)
|
||||
|| (region == ModbusRegion.HoldingRegisters && dataType is ModbusDataType.String or ModbusDataType.BitInRegister);
|
||||
|
||||
|
||||
public static TheoryData<ModbusRegion, ModbusDataType> PendingShapes()
|
||||
{
|
||||
var data = new TheoryData<ModbusRegion, ModbusDataType>();
|
||||
foreach (var (region, dt) in Shapes().Where(s => PendingF1(s.Region, s.DataType))) data.Add(region, dt);
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(PendingShapes))]
|
||||
public void Pending_F1_divergences_still_exist(ModbusRegion region, ModbusDataType dataType)
|
||||
{
|
||||
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
|
||||
var driverAccepts = region == ModbusRegion.HoldingRegisters; // String[] / BitInRegister[] encode; mistyped Coils are refused
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldNotBe(driverAccepts,
|
||||
"F1 landed — DriverArrayWriteSupport now agrees with the driver for this shape; delete it from PendingF1");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllShapes))]
|
||||
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType)
|
||||
{
|
||||
if (PendingF1(region, dataType)) return; // covered by Pending_F1_divergences_still_exist
|
||||
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
|
||||
var fake = new ModbusDriverTests.FakeTransport();
|
||||
var drv = new ModbusDriver(
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U2: block-read coalescing is ON by default (gap 8), and it covers coils, discrete inputs,
|
||||
/// <c>BitInRegister</c> and <c>String</c> tags — 16 bit tags on one status word used to cost 16 reads of
|
||||
/// the same register, and 64 adjacent coils 64 FC01s.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusCoalescingDefaultsTests
|
||||
{
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartAsync(ModbusDriverOptions opts)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var d = new ModbusDriver(opts, "m1", _ => t);
|
||||
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (d, t);
|
||||
}
|
||||
|
||||
private static ModbusDriverOptions Opts(IEnumerable<ModbusTagDefinition> tags, bool disable = false) => new()
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), DisableReadCoalescing = disable,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Default_options_coalesce_with_a_gap_of_8()
|
||||
{
|
||||
new ModbusDriverOptions().MaxReadGap.ShouldBe((ushort)8);
|
||||
new ModbusDriverOptions().DisableReadCoalescing.ShouldBeFalse();
|
||||
ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h"}""").MaxReadGap.ShouldBe((ushort)8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_configured_holding_registers_are_read_in_one_PDU()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 10).Select(i =>
|
||||
new ModbusTagDefinition($"HR{i}", ModbusRegion.HoldingRegisters, (ushort)(100 + i * 3), ModbusDataType.Int16)).ToArray();
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
for (var i = 0; i < 10; i++) t.Bank.HoldingRegisters[100 + i * 3] = (ushort)(i + 1);
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(0x03).Count.ShouldBe(1);
|
||||
r.Select(s => (short)s.Value!).ShouldBe(Enumerable.Range(1, 10).Select(i => (short)i));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (coordinator ruling 2): an explicitly authored maxReadGap 0 — what the pre-#741 form
|
||||
/// persisted on every driver — keeps its old meaning, NO coalescing; the gap-8 default applies only to an
|
||||
/// absent key (positive control).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"host":"h","maxReadGap":0,"disableFC23":false}""", 2)]
|
||||
[InlineData("""{"host":"h"}""", 1)]
|
||||
public async Task A_persisted_maxReadGap_0_keeps_coalescing_off(string legacyConfig, int expectedReads)
|
||||
{
|
||||
var parsed = ModbusDriverFactoryExtensions.ParseOptions("m1", legacyConfig);
|
||||
var tags = new[]
|
||||
{
|
||||
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16),
|
||||
};
|
||||
var (drv, t) = await StartAsync(new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", MaxReadGap = parsed.MaxReadGap, DisableReadCoalescing = parsed.DisableReadCoalescing,
|
||||
RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
});
|
||||
await using var _ = drv;
|
||||
await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
|
||||
t.Requests(0x03).Count.ShouldBe(expectedReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableReadCoalescing_reads_every_tag_alone()
|
||||
{
|
||||
// Positive control for the default-on test: the off switch really is per-tag.
|
||||
var tags = new[]
|
||||
{
|
||||
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16),
|
||||
};
|
||||
var (drv, t) = await StartAsync(Opts(tags, disable: true));
|
||||
await using var _ = drv;
|
||||
await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
|
||||
t.Requests(0x03).Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ModbusRegion.Coils, (byte)0x01)]
|
||||
[InlineData(ModbusRegion.DiscreteInputs, (byte)0x02)]
|
||||
public async Task Adjacent_bit_tags_fold_into_one_bit_read(ModbusRegion region, byte fc)
|
||||
{
|
||||
var tags = Enumerable.Range(0, 64).Select(i =>
|
||||
new ModbusTagDefinition($"B{i}", region, (ushort)(10 + i), ModbusDataType.Bool)).ToArray();
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
var bank = region == ModbusRegion.Coils ? t.Bank.Coils : t.Bank.DiscreteInputs;
|
||||
for (var i = 0; i < 64; i++) bank[10 + i] = i % 3 == 0;
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(fc).ShouldBe([(fc, (ushort)10, (ushort)64)]);
|
||||
for (var i = 0; i < 64; i++) r[i].Value.ShouldBe(i % 3 == 0, $"bit {i}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_tags_on_one_status_word_fold_into_one_register_read()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 16).Select(b =>
|
||||
new ModbusTagDefinition($"S{b}", ModbusRegion.HoldingRegisters, 40, ModbusDataType.BitInRegister, BitIndex: (byte)b)).ToArray();
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[40] = 0b1010_0000_0000_0101;
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(0x03).ShouldBe([((byte)0x03, (ushort)40, (ushort)1)]);
|
||||
for (var b = 0; b < 16; b++) r[b].Value.ShouldBe(((0b1010_0000_0000_0101 >> b) & 1) == 1, $"bit {b}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_string_tag_coalesces_with_its_neighbours()
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
new ModbusTagDefinition("Num", ModbusRegion.HoldingRegisters, 50, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("Str", ModbusRegion.HoldingRegisters, 51, ModbusDataType.String, StringLength: 4),
|
||||
new ModbusTagDefinition("After", ModbusRegion.HoldingRegisters, 53, ModbusDataType.UInt16),
|
||||
};
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[50] = 7;
|
||||
t.Bank.HoldingRegisters[51] = ('A' << 8) | 'B';
|
||||
t.Bank.HoldingRegisters[52] = ('C' << 8) | 'D';
|
||||
t.Bank.HoldingRegisters[53] = 9;
|
||||
|
||||
var r = await drv.ReadAsync(["Num", "Str", "After"], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(0x03).ShouldBe([((byte)0x03, (ushort)50, (ushort)4)]);
|
||||
r[0].Value.ShouldBe((short)7);
|
||||
r[1].Value.ShouldBe("ABCD");
|
||||
r[2].Value.ShouldBe((ushort)9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_coil_range_is_prohibited_and_reprobed_with_FC01()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 4).Select(i =>
|
||||
new ModbusTagDefinition($"C{i}", ModbusRegion.Coils, (ushort)(20 + i), ModbusDataType.Bool)).ToArray();
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
AutoProhibitReprobeInterval = TimeSpan.FromHours(1),
|
||||
};
|
||||
var (drv, t) = await StartAsync(opts);
|
||||
await using var _ = drv;
|
||||
var refuseMerged = true;
|
||||
t.Handler = (_, pdu, _) =>
|
||||
{
|
||||
var qty = (pdu[3] << 8) | pdu[4];
|
||||
return pdu[0] == 0x01 && qty > 1 && refuseMerged
|
||||
? Task.FromException<byte[]>(new ModbusException(0x01, 0x02, "protected"))
|
||||
: null;
|
||||
};
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
r.ShouldAllBe(s => s.StatusCode == 0u, "the per-tag fallback still reads every coil");
|
||||
drv.AutoProhibitedRangeCount.ShouldBe(1);
|
||||
|
||||
refuseMerged = false;
|
||||
while (t.Sent.TryDequeue(out var _1)) { }
|
||||
await drv.RunReprobeOnceForTestAsync(TestContext.Current.CancellationToken);
|
||||
t.Sent.ShouldAllBe(s => s.Pdu[0] == 0x01, "a coil range is re-probed with FC01, never a register read");
|
||||
drv.AutoProhibitedRangeCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,9 @@ public sealed class ModbusCoalescingTests
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that MaxReadGap=0 defaults to per-tag reads without coalescing.</summary>
|
||||
/// <summary>MaxReadGap=0 means no coalescing (its historical meaning, kept for persisted configs).</summary>
|
||||
[Fact]
|
||||
public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads()
|
||||
public async Task MaxReadGap_Zero_Means_No_Coalescing()
|
||||
{
|
||||
var fake = new CountingTransport();
|
||||
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
|
||||
@@ -56,7 +56,7 @@ public sealed class ModbusCoalescingTests
|
||||
|
||||
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
|
||||
|
||||
// With coalescing off, expect 2 separate FC03 reads.
|
||||
// MaxReadGap=0 is coalescing OFF: expect 2 separate FC03 reads.
|
||||
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
|
||||
fc03Reads.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H2: the deadband baseline is keyed on (value, status). A Good-after-Bad recovery carrying
|
||||
/// the IDENTICAL value must publish — the value-only dedup left the node Bad until the value drifted.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusDeadbandRecoveryTests
|
||||
{
|
||||
private const uint Good = 0u;
|
||||
private const uint Uncertain = 0x40000000u;
|
||||
private const uint BadComm = 0x80050000u;
|
||||
|
||||
private static async Task<ModbusDriver> NewDriverAsync()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, Deadband: 5.0);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
var drv = new ModbusDriver(opts, "m1", _ => new ModbusDriverTests.FakeTransport());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return drv;
|
||||
}
|
||||
|
||||
private static DataValueSnapshot Snap(object? v, uint status) => new(v, status, DateTime.UtcNow, DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task Good_after_Bad_with_the_same_value_publishes()
|
||||
{
|
||||
await using var drv = await NewDriverAsync();
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue(); // baseline
|
||||
drv.ShouldPublish("Temp", Snap(null, BadComm)).ShouldBeTrue(); // comms drop
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue("the recovery publish must not be swallowed");
|
||||
// Positive control: the deadband is still active after the recovery re-seeded the baseline.
|
||||
drv.ShouldPublish("Temp", Snap((short)11, Good)).ShouldBeFalse();
|
||||
drv.ShouldPublish("Temp", Snap((short)16, Good)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_status_change_with_the_same_value_publishes()
|
||||
{
|
||||
await using var drv = await NewDriverAsync();
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue();
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Uncertain)).ShouldBeTrue("a quality change must publish");
|
||||
drv.ShouldPublish("Temp", Snap((short)12, Uncertain)).ShouldBeFalse("within deadband at the same quality");
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue("back to Good must publish");
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ public sealed class ModbusDriverTests
|
||||
0x06 => Task.FromResult(WriteSingleReg(pdu)),
|
||||
0x0F => Task.FromResult(WriteMultipleCoils(pdu)),
|
||||
0x10 => Task.FromResult(WriteMultipleRegs(pdu)),
|
||||
0x17 => Task.FromResult(ReadWriteMultipleRegs(pdu)),
|
||||
_ => Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")),
|
||||
};
|
||||
}
|
||||
@@ -102,6 +103,26 @@ public sealed class ModbusDriverTests
|
||||
return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] };
|
||||
}
|
||||
|
||||
// FC23 Read/Write Multiple Registers: write first, then read back the read range.
|
||||
private byte[] ReadWriteMultipleRegs(byte[] pdu)
|
||||
{
|
||||
var readAddr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var readQty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var writeAddr = (ushort)((pdu[5] << 8) | pdu[6]);
|
||||
var writeQty = (ushort)((pdu[7] << 8) | pdu[8]);
|
||||
for (var i = 0; i < writeQty; i++)
|
||||
HoldingRegisters[writeAddr + i] = (ushort)((pdu[10 + i * 2] << 8) | pdu[11 + i * 2]);
|
||||
var resp = new byte[2 + readQty * 2];
|
||||
resp[0] = 0x17;
|
||||
resp[1] = (byte)(readQty * 2);
|
||||
for (var i = 0; i < readQty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[readAddr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[readAddr + i] & 0xFF);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private byte[] WriteMultipleCoils(byte[] pdu)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U1: <c>Family</c>, <c>MelsecSubFamily</c> and <c>DisableFC23</c> were authored in the
|
||||
/// AdminUI and consumed by nothing. A tag's <c>addressString</c> now resolves through
|
||||
/// <see cref="ModbusAddressParser"/> with the driver's family; <c>DisableFC23</c> gates the FC23
|
||||
/// write-and-read-back path for multi-register writes. Also pins that the AdminUI form's own
|
||||
/// (TimeSpan-shaped) config keys reach the driver.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusFamilyWiringTests
|
||||
{
|
||||
// ---------------- addressString through the family-aware parser ----------------
|
||||
|
||||
[Fact]
|
||||
public void A_MELSEC_D_register_resolves_only_under_the_MELSEC_family()
|
||||
{
|
||||
const string json = """{"addressString":"D100"}""";
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)100);
|
||||
// Positive control: the same text means nothing to a Generic (Modicon) driver.
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_DL205_V_memory_address_resolves_octal_under_DL205()
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig("""{"addressString":"V2000"}""", "T", ModbusFamily.DL205, MelsecFamily.Q_L_iQR, out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)0x400); // V2000 octal = 1024
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_string_only_blob_carries_type_order_and_count_and_keeps_the_other_keys()
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(
|
||||
"""{"addressString":"40011:F:CDAB:4","writable":false,"unitId":3}""",
|
||||
"T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)10);
|
||||
def.DataType.ShouldBe(ModbusDataType.Float32);
|
||||
def.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
|
||||
def.ArrayCount.ShouldBe(4);
|
||||
def.Writable.ShouldBeFalse();
|
||||
def.UnitId.ShouldBe((byte)3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (review m2): persisted region/address are authoritative over an addressString, so a
|
||||
/// later Family / MelsecSubFamily change can never silently remap a live tag the editor resolved. (Since
|
||||
/// fix round 3 the keys win by PRESENCE; the marker in this blob is display state only.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Persisted_resolved_keys_win_over_the_address_string_so_a_family_change_cannot_remap()
|
||||
{
|
||||
// The editor resolved D100 under MELSEC, persisted HR 100 and MARKED it; the driver is later switched.
|
||||
const string json = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""";
|
||||
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)100, $"family {family}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 3 (review r2 N2): the persisted-shape matrix. Every shape that carries region + address loads
|
||||
/// from them under every family — including the pre-#741 "keys + addressString, no marker" row, which the
|
||||
/// old driver served from its keys and must keep serving so. Only a string-only blob (no keys) resolves
|
||||
/// the string.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("keys only", """{"region":"InputRegisters","address":5}""", ModbusRegion.InputRegisters, 5)]
|
||||
[InlineData("pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", ModbusRegion.InputRegisters, 5)]
|
||||
[InlineData("pre-lane stamped HR/0 + string", """{"region":"HoldingRegisters","address":0,"dataType":"Int16","addressString":"D100"}""", ModbusRegion.HoldingRegisters, 0)]
|
||||
[InlineData("string only", """{"addressString":"40011:F"}""", ModbusRegion.HoldingRegisters, 10)]
|
||||
[InlineData("string + marker + keys (editor-pinned)", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100}""", ModbusRegion.HoldingRegisters, 100)]
|
||||
[InlineData("string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", ModbusRegion.HoldingRegisters, 10)]
|
||||
public void Every_persisted_shape_resolves_to_its_address_under_every_family(
|
||||
string shape, string json, ModbusRegion region, int address)
|
||||
{
|
||||
foreach (var (family, sub) in new[]
|
||||
{
|
||||
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR), (ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR), (ModbusFamily.MELSEC, MelsecFamily.F_iQF),
|
||||
})
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def).ShouldBeTrue($"{shape} / {family}");
|
||||
def.Region.ShouldBe(region, $"{shape} / {family}");
|
||||
def.Address.ShouldBe((ushort)address, $"{shape} / {family}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_string_only_blob_no_family_parses_is_refused_not_served_as_HR0()
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig("""{"addressString":"D100"}""", "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _)
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unparseable_address_string_rejects_the_tag_and_the_deploy_gate_says_why()
|
||||
{
|
||||
const string json = """{"addressString":"ZZ9"}""";
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out _).ShouldBeFalse();
|
||||
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("addressString"));
|
||||
// The family-unaware gate accepts what SOME family resolves (it must never be stricter than the driver).
|
||||
ModbusTagDefinitionFactory.Inspect("""{"addressString":"D100"}""").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_driver_resolves_address_strings_with_its_configured_family()
|
||||
{
|
||||
var fake = new ScriptedTransport();
|
||||
fake.Bank.HoldingRegisters[100] = 1234;
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", Family = ModbusFamily.MELSEC, Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = [new RawTagEntry("D100", """{"addressString":"D100"}""", false)],
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var r = await drv.ReadAsync(["D100"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
r[0].Value.ShouldBe((short)1234);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_and_MelsecSubFamily_parse_from_the_driver_config()
|
||||
{
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1",
|
||||
"""{"host":"h","family":"MELSEC","melsecSubFamily":"F_iQF","disableFC23":true,"useFC23ForMultiRegisterWrites":true}""");
|
||||
o.Family.ShouldBe(ModbusFamily.MELSEC);
|
||||
o.MelsecSubFamily.ShouldBe(MelsecFamily.F_iQF);
|
||||
o.DisableFC23.ShouldBeTrue();
|
||||
o.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------- FC23 is OPT-IN; DisableFC23 is the kill-switch ----------------
|
||||
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartWriteAsync(string driverConfigJson)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var opts = ModbusDriverFactoryExtensions.ParseOptions("m1", driverConfigJson) is var parsed
|
||||
? new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", DisableFC23 = parsed.DisableFC23,
|
||||
UseFC23ForMultiRegisterWrites = parsed.UseFC23ForMultiRegisterWrites,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Float32)]),
|
||||
}
|
||||
: null!;
|
||||
var drv = new ModbusDriver(opts, "m1", _ => t);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (drv, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (coordinator ruling 1): a config without the opt-in key keeps the FC16 wire — including the
|
||||
/// legacy shape every form-saved config carries ("disableFC23": false).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"host":"h"}""")]
|
||||
[InlineData("""{"host":"h","disableFC23":false,"maxReadGap":0,"useFC15ForSingleCoilWrites":false}""")]
|
||||
public async Task Without_the_opt_in_multi_register_writes_stay_on_FC16(string legacyConfig)
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync(legacyConfig);
|
||||
await using var _ = drv;
|
||||
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x10]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opted_in_multi_register_writes_use_FC23_write_and_read_back()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
|
||||
await using var _ = drv;
|
||||
var w = await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17]);
|
||||
(await drv.ReadAsync(["F"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(1.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableFC23_overrides_the_opt_in()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true,"disableFC23":true}""");
|
||||
await using var _ = drv;
|
||||
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x10]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_opted_in_device_without_FC23_falls_back_to_FC16_once_and_stays_on_it()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x17
|
||||
? Task.FromException<byte[]>(new ModbusException(0x17, 0x01, "illegal function"))
|
||||
: null;
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
(await drv.WriteAsync([new WriteRequest("F", 2.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17, (byte)0x10, (byte)0x10]);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
// ---------------- the AdminUI form's own keys reach the driver ----------------
|
||||
|
||||
[Fact]
|
||||
public void The_AdminUI_form_shape_of_the_timing_keys_is_honoured()
|
||||
{
|
||||
// The form serialises ModbusDriverOptions itself (camelCase, TimeSpan strings), not the *Ms DTO keys.
|
||||
var formShape = JsonSerializer.Serialize(new ModbusDriverOptions
|
||||
{
|
||||
Host = "h",
|
||||
Timeout = TimeSpan.FromSeconds(7),
|
||||
IdleDisconnectTimeout = TimeSpan.FromSeconds(90),
|
||||
AutoProhibitReprobeInterval = TimeSpan.FromSeconds(45),
|
||||
Probe = new ModbusProbeOptions { Interval = TimeSpan.FromSeconds(11), Timeout = TimeSpan.FromSeconds(3) },
|
||||
KeepAlive = new ModbusKeepAliveOptions { Time = TimeSpan.FromSeconds(60), Interval = TimeSpan.FromSeconds(20) },
|
||||
Reconnect = new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(2), MaxDelay = TimeSpan.FromSeconds(40) },
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
|
||||
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", formShape);
|
||||
|
||||
o.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
|
||||
o.IdleDisconnectTimeout.ShouldBe(TimeSpan.FromSeconds(90));
|
||||
o.AutoProhibitReprobeInterval.ShouldBe(TimeSpan.FromSeconds(45));
|
||||
o.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(11));
|
||||
o.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
o.KeepAlive.Time.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
o.KeepAlive.Interval.ShouldBe(TimeSpan.FromSeconds(20));
|
||||
o.Reconnect.InitialDelay.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
o.Reconnect.MaxDelay.ShouldBe(TimeSpan.FromSeconds(40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_millisecond_keys_still_win_when_both_are_present()
|
||||
{
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h","timeoutMs":1500,"timeout":"00:00:07"}""");
|
||||
o.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The driver health contract (review 07 H4 + H7, and the lifecycle/fail-fast rules every driver lane
|
||||
/// must meet): a device REFUSAL is per-tag Bad with no Degraded and proves the device answered; a comms
|
||||
/// fault degrades and preserves LastSuccessfulRead; health is one write per batch; a comms timeout on
|
||||
/// one unit fails the rest of that unit's batch fast; writes map transport failures to
|
||||
/// BadCommunicationError and value problems to BadOutOfRange / BadTypeMismatch.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusHealthContractTests
|
||||
{
|
||||
private const uint Good = 0u;
|
||||
private const uint BadOutOfRange = 0x803C0000u;
|
||||
private const uint BadDeviceFailure = 0x808B0000u;
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
private static ModbusDriverOptions Opts(params ModbusTagDefinition[] tags) => new()
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
private static ModbusTagDefinition Hr(string name, ushort addr, byte? unit = null, ModbusDataType type = ModbusDataType.Int16) =>
|
||||
new(name, ModbusRegion.HoldingRegisters, addr, type, UnitId: unit);
|
||||
|
||||
private static Task<byte[]> Refuse(byte code, byte[] pdu) =>
|
||||
Task.FromException<byte[]>(new ModbusException(pdu[0], code, $"exception {code}"));
|
||||
|
||||
private static ushort Addr(byte[] pdu) => (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
|
||||
private static async Task<(ModbusDriver Driver, ScriptedTransport Transport)> StartAsync(
|
||||
ModbusDriverOptions opts, ILogger<ModbusDriver>? logger = null)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var d = new ModbusDriver(opts, "m1", _ => t, logger);
|
||||
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (d, t);
|
||||
}
|
||||
|
||||
// ---------------- H4: refusals ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task All_tags_refused_keeps_Healthy_and_advances_LastSuccessfulRead()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10), Hr("B", 11)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => Refuse(0x02, pdu);
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
|
||||
|
||||
r.ShouldAllBe(s => s.StatusCode == BadOutOfRange);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy, "a refusal means the device answered — no Degraded, no watchdog fodder");
|
||||
h.LastSuccessfulRead.ShouldNotBeNull();
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_after_a_comms_fault_restores_Healthy()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, _, _) => Task.FromException<byte[]>(new IOException("socket dropped"));
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
var degraded = drv.GetHealth();
|
||||
degraded.State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
t.Handler = (_, pdu, _) => Refuse(0x02, pdu);
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x04)]
|
||||
[InlineData(0x0B)]
|
||||
public async Task Non_refusal_exception_codes_still_degrade(byte code)
|
||||
{
|
||||
// Positive control for the refusal tests: a server-failure / gateway code is NOT a refusal.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => Refuse(code, pdu);
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Mixed_refused_and_good_batch_is_Healthy_regardless_of_tag_order(bool refusedFirst)
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("Refused", 10), Hr("Fine", 50)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Refuse(0x02, pdu) : null;
|
||||
string[] refs = refusedFirst ? ["Refused", "Fine"] : ["Fine", "Refused"];
|
||||
var r = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
|
||||
r.Count(s => s.StatusCode == Good).ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_tag_warns_once_not_once_per_poll()
|
||||
{
|
||||
var logger = new ListLogger();
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)), logger);
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => Refuse(0x02, pdu);
|
||||
for (var i = 0; i < 3; i++) await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
logger.Warnings.Count(m => m.Contains("refused") && m.Contains("A")).ShouldBe(1);
|
||||
}
|
||||
|
||||
// ---------------- fail fast per unit / endpoint ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_comms_timeout_on_one_unit_fails_the_rest_of_that_units_batch_fast()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("U2a", 10, 2), Hr("U2b", 11, 2), Hr("U2c", 12, 2), Hr("U1", 13, 1)));
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[13] = 42;
|
||||
t.Handler = (unit, _, _) => unit == 2
|
||||
? Task.FromException<byte[]>(new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout, "timeout"))
|
||||
: null;
|
||||
|
||||
var r = await drv.ReadAsync(["U2a", "U2b", "U2c", "U1"], TestContext.Current.CancellationToken);
|
||||
|
||||
t.SendsTo(2).ShouldBe(1, "one timeout per unit per batch, not one per tag");
|
||||
r.Take(3).ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
||||
// Positive control: the healthy unit in the same batch is still read.
|
||||
r[3].StatusCode.ShouldBe(Good);
|
||||
r[3].Value.ShouldBe((short)42);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unreachable_endpoint_fails_the_whole_batch_fast()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10, 1), Hr("B", 11, 2), Hr("C", 12, 3)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, _, _) => Task.FromException<byte[]>(new ModbusTransportUnavailableException("throttled"));
|
||||
var r = await drv.ReadAsync(["A", "B", "C"], TestContext.Current.CancellationToken);
|
||||
t.TotalSends.ShouldBe(1);
|
||||
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
||||
}
|
||||
|
||||
// ---------------- H7: write status mapping + write-side health ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task Write_transport_failure_is_BadCommunicationError_and_a_good_read_clears_it()
|
||||
{
|
||||
// Fix round 1 (review Important #3): ONE health writer for reads and writes. A single write blip used
|
||||
// to latch Degraded until the next good WRITE — forever, in a read-mostly plant.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
(await drv.ReadAsync(["A"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a good read proves the shared transport answered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_good_write_clears_a_read_side_comms_fault()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("A", (short)2)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_malformed_reply_does_not_fail_the_rest_of_the_unit_fast()
|
||||
{
|
||||
// Review m3: a byte-count mismatch is a reply that ARRIVED — only a timeout / desync / IO fails the unit fast.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("Bad", 10), Hr("Fine", 50)) is var o
|
||||
? new ModbusDriverOptions { Host = o.Host, RawTags = o.RawTags, Probe = o.Probe, DisableReadCoalescing = true }
|
||||
: null!);
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[50] = 9;
|
||||
t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Task.FromResult(new byte[] { 0x03, 0x04, 0, 0 }) : null;
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Fine"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
r[1].Value.ShouldBe((short)9, "the sibling on the same unit is still read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_leaves_health_Unknown()
|
||||
{
|
||||
// Review m1: Dispose bumps the generation (dropping a concurrent shutdown's write), so it must write its own.
|
||||
var (drv, _) = await StartAsync(Opts(Hr("A", 10)));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.DisposeAsync();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_refusal_counts_as_answered_and_clears_a_write_side_fault()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Refuse(0x03, pdu) : null;
|
||||
var w = await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(BadOutOfRange);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_value_problems_map_to_OutOfRange_and_TypeMismatch_without_touching_health()
|
||||
{
|
||||
var arr = new ModbusTagDefinition("Arr", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Int16, ArrayCount: 3);
|
||||
var (drv, t) = await StartAsync(Opts(Hr("Bcd", 10, type: ModbusDataType.Bcd16), Hr("I16", 11), arr));
|
||||
await using var _ = drv;
|
||||
|
||||
var w = await drv.WriteAsync([
|
||||
new WriteRequest("Bcd", 12345), // > 4 BCD digits
|
||||
new WriteRequest("I16", 70000), // > Int16
|
||||
new WriteRequest("I16", "not a number"),
|
||||
new WriteRequest("Arr", new short[] { 1, 2 }), // length mismatch
|
||||
new WriteRequest("Arr", new int[] { 1, 2, 3 }), // CLR list of another element type converts (ScadaBridge shape)
|
||||
], TestContext.Current.CancellationToken);
|
||||
|
||||
w[0].StatusCode.ShouldBe(BadOutOfRange);
|
||||
w[1].StatusCode.ShouldBe(BadOutOfRange);
|
||||
w[2].StatusCode.ShouldBe(BadTypeMismatch);
|
||||
w[3].StatusCode.ShouldBe(BadOutOfRange);
|
||||
w[4].StatusCode.ShouldBe(Good);
|
||||
t.Bank.HoldingRegisters[22].ShouldBe((ushort)3);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_read_invalidates_the_WriteOnChangeOnly_cache()
|
||||
{
|
||||
// Value-only suppression is the write-side twin of the #527 dedup trap: after a comms fault the
|
||||
// device's register is unknown (it may have rebooted), so re-asserting the same setpoint must reach it.
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries([Hr("A", 10)]), WriteOnChangeOnly = true,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
var (drv, t) = await StartAsync(opts);
|
||||
await using var _ = drv;
|
||||
int Fc06() => t.Sent.Count(s => s.Pdu[0] == 0x06);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
|
||||
await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
|
||||
Fc06().ShouldBe(1, "positive control: an identical re-write is suppressed while nothing failed");
|
||||
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
t.Handler = null;
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
|
||||
Fc06().ShouldBe(2, "after a failed read the device value is unknown; the write must go to the wire");
|
||||
}
|
||||
|
||||
// ---------------- lifecycle generation ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_superseded_initialize_never_publishes_over_the_shutdown_that_overtook_it()
|
||||
{
|
||||
var t = new ScriptedTransport { ConnectGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) };
|
||||
var drv = new ModbusDriver(Opts(Hr("A", 10)), "m1", _ => t) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
|
||||
var init = drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await t.ConnectEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
// The init is wedged in its connect; shutdown waits out the gate timeout and tears down anyway.
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
t.ConnectGate.SetResult(); // the abandoned connect finally completes
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => init);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the superseded init must not publish Healthy");
|
||||
t.DisposeCount.ShouldBe(1, "the superseded init releases the transport it built");
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => drv.ReadAsync(["A"], TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_that_straddles_a_reinitialize_does_not_write_health_about_the_new_transport()
|
||||
{
|
||||
var first = new ScriptedTransport();
|
||||
var second = new ScriptedTransport();
|
||||
var release = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
first.Handler = (_, _, _) => { entered.TrySetResult(); return release.Task; };
|
||||
var calls = 0;
|
||||
var drv = new ModbusDriver(Opts(Hr("A", 10)), "m1", _ => Interlocked.Increment(ref calls) == 1 ? first : second);
|
||||
await using var _ = drv;
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var read = drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // re-init onto `second`
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
release.SetException(new IOException("old socket died")); // the straddling read now fails
|
||||
(await read)[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a read on the superseded transport must not degrade the new one");
|
||||
|
||||
// Positive control: a failure on the CURRENT transport still degrades.
|
||||
second.Handler = (_, _, _) => Task.FromException<byte[]>(new IOException("new socket died"));
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_a_new_tag_set_drops_tags_the_new_config_no_longer_has()
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var drv = new ModbusDriver(Opts(Hr("Old", 10)), "m1", _ => t);
|
||||
await using var _ = drv;
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
(await drv.ReadAsync(["Old"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
|
||||
var config = JsonSerializer.Serialize(new
|
||||
{
|
||||
host = "f",
|
||||
probe = new { enabled = false },
|
||||
rawTags = ModbusRawTags.Entries([Hr("New", 11)]),
|
||||
});
|
||||
await drv.InitializeAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(["Old", "New"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(BadNodeIdUnknown, "a re-init publishes a fresh table, it does not merge into the old one");
|
||||
r[1].StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
// ---------------- complete teardown ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_joins_the_probe_loop_so_no_host_transition_fires_after_it_returns()
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var probeBlocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var probes = 0;
|
||||
t.Handler = async (_, pdu, ct) =>
|
||||
{
|
||||
if (Interlocked.Increment(ref probes) == 1) return await t.Bank.SendAsync(1, pdu, ct); // first probe: Running
|
||||
probeBlocked.TrySetResult();
|
||||
try { await Task.Delay(Timeout.Infinite, ct); }
|
||||
catch (OperationCanceledException) { }
|
||||
throw new ObjectDisposedException("transport"); // a teardown-time failure, NOT an OCE
|
||||
};
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries([Hr("A", 10)]),
|
||||
Probe = new ModbusProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(30) },
|
||||
};
|
||||
var drv = new ModbusDriver(opts, "m1", _ => t);
|
||||
var running = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var afterShutdown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var shutdownReturned = false;
|
||||
drv.OnHostStatusChanged += (_, e) =>
|
||||
{
|
||||
if (e.NewState == HostState.Running) running.TrySetResult();
|
||||
if (Volatile.Read(ref shutdownReturned)) afterShutdown.TrySetResult();
|
||||
};
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await running.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // positive control
|
||||
await probeBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
Volatile.Write(ref shutdownReturned, true);
|
||||
|
||||
await Should.ThrowAsync<TimeoutException>(
|
||||
() => afterShutdown.Task.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private sealed class ListLogger : ILogger<ModbusDriver>
|
||||
{
|
||||
public System.Collections.Concurrent.ConcurrentQueue<string> Messages { get; } = new();
|
||||
public IEnumerable<string> Warnings => Messages.Where(m => m.StartsWith("Warning:", StringComparison.Ordinal));
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
=> Messages.Enqueue($"{logLevel}: {formatter(state, exception)}");
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ public sealed class ModbusLoggerInjectionTests
|
||||
|
||||
// Scan 1 — coalesced read fails. Expect exactly one warning.
|
||||
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
|
||||
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
|
||||
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("auto-prohibited")).ToList();
|
||||
warnings.Count.ShouldBe(1);
|
||||
warnings[0].Message.ShouldContain("drv-logged");
|
||||
warnings[0].Message.ShouldContain("Start=100");
|
||||
@@ -102,7 +102,13 @@ public sealed class ModbusLoggerInjectionTests
|
||||
// the prohibition and skips the merge; even if it didn't, the de-dupe in
|
||||
// RecordAutoProhibition would suppress).
|
||||
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1, "re-fire of same range stays silent");
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("auto-prohibited"))
|
||||
.ShouldBe(1, "re-fire of same range stays silent");
|
||||
// The protected register's per-tag fallback is a device refusal: one Warning for that tag across
|
||||
// both scans (once-per-tag), and none for its readable neighbours.
|
||||
var refusals = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("refused")).ToList();
|
||||
refusals.Count.ShouldBe(1);
|
||||
refusals[0].Message.ShouldContain("T102");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 P6: region/type compatibility is enforced at the tag factory (and so at the deploy gate
|
||||
/// and the AdminUI parse consult). A <c>HoldingRegisters</c> + <c>Bool</c> tag used to throw out of the
|
||||
/// coalescing planner and kill the whole subscription's poll cycle; <c>Coils</c> + <c>Int16</c> silently
|
||||
/// returned a bool into an Int32 node.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusRegionTypeValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"Bool"}""")]
|
||||
[InlineData("""{"region":"InputRegisters","address":1,"dataType":"Bool"}""")]
|
||||
public void A_register_region_with_Bool_is_rejected(string json)
|
||||
{
|
||||
// That pair never read (RegisterCount threw), so refusing it breaks nothing that worked.
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out _).ShouldBeFalse();
|
||||
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("register"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (review Important #2): the pre-#741 editor defaulted dataType to Int16 and persisted it for
|
||||
/// coil / DI tags, and the old driver ignored it for bit regions — those tags worked and must keep loading.
|
||||
/// A bit region with any type is coerced to Bool and draws no deploy warning (a warning can be promoted to
|
||||
/// a blocking error by TagConfigValidationMode.Error, which would refuse an existing config).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""")]
|
||||
[InlineData("""{"region":"DiscreteInputs","address":1,"dataType":"Float32"}""")]
|
||||
public void A_legacy_bit_region_tag_with_a_register_type_still_loads_as_Bool(string legacyJson)
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(legacyJson, "T", out var def).ShouldBeTrue();
|
||||
def.DataType.ShouldBe(ModbusDataType.Bool);
|
||||
ModbusTagDefinitionFactory.Inspect(legacyJson).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_legacy_Int16_coil_tag_reads_and_writes_the_coil()
|
||||
{
|
||||
var fake = new ModbusDriverTests.FakeTransport();
|
||||
fake.Coils[3] = true;
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = [new RawTagEntry("Run", """{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""", false)],
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
(await drv.ReadAsync(["Run"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(true);
|
||||
(await drv.WriteAsync([new WriteRequest("Run", false)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
fake.Coils[3].ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"region":"Coils","address":1,"dataType":"Bool"}""", ModbusDataType.Bool)]
|
||||
[InlineData("""{"region":"Coils","address":1}""", ModbusDataType.Bool)]
|
||||
[InlineData("""{"region":"DiscreteInputs","address":1}""", ModbusDataType.Bool)]
|
||||
[InlineData("""{"region":"InputRegisters","address":1,"dataType":"BitInRegister","bitIndex":3}""", ModbusDataType.BitInRegister)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":1}""", ModbusDataType.Int16)]
|
||||
public void Compatible_region_and_type_is_accepted(string json, ModbusDataType expected)
|
||||
{
|
||||
// Positive control for the rejection theory, including the absent-dataType default per region.
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
|
||||
def.DataType.ShouldBe(expected);
|
||||
ModbusTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_mis_typed_tag_no_longer_kills_the_coalesced_poll_of_its_neighbours()
|
||||
{
|
||||
var fake = new ModbusDriverTests.FakeTransport();
|
||||
fake.HoldingRegisters[100] = 7;
|
||||
fake.HoldingRegisters[102] = 9;
|
||||
var good1 = new ModbusTagDefinition("G1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
|
||||
var good2 = new ModbusTagDefinition("G2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f",
|
||||
MaxReadGap = 5,
|
||||
RawTags =
|
||||
[
|
||||
ModbusRawTags.Entry(good1),
|
||||
new RawTagEntry("Bad", """{"region":"HoldingRegisters","address":101,"dataType":"Bool"}""", false),
|
||||
ModbusRawTags.Entry(good2),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(["G1", "Bad", "G2"], TestContext.Current.CancellationToken);
|
||||
r[0].Value.ShouldBe((short)7);
|
||||
r[1].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown — skipped at init, reported
|
||||
r[2].Value.ShouldBe((short)9);
|
||||
drv.GetSkippedTags()!.Sample.ShouldContain(s => s.RawPath == "Bad");
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ public sealed class ModbusRtuFramingTests
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
|
||||
}
|
||||
|
||||
/// <summary>FC23 (Read/Write Multiple) replies like a read: byte count + data. It used to be sized as a
|
||||
/// fixed 4-byte write echo and desync.</summary>
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC23_returns_bare_pdu()
|
||||
{
|
||||
var body = new byte[] { 0x01, 0x17, 0x04, 0x00, 0x0A, 0x00, 0x0B };
|
||||
await using var s = Canned(ModbusCrc.Append(body));
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x17, 0x04, 0x00, 0x0A, 0x00, 0x0B });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_exception_frame_throws_ModbusException()
|
||||
{
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U3: <c>String[]</c> and <c>BitInRegister[]</c> tags read fine; their writes used to fail
|
||||
/// <c>BadInternalError</c> (no element width for String, <c>Convert.ToBoolean(bool[])</c> for the bit
|
||||
/// array). Both now encode <c>count</c> elements at the tag's element width; a length mismatch is
|
||||
/// <c>BadOutOfRange</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusStringAndBitArrayWriteTests
|
||||
{
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartAsync(params ModbusTagDefinition[] tags)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var d = new ModbusDriver(new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
}, "m1", _ => t);
|
||||
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (d, t);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task String_array_write_encodes_each_element_at_its_register_width()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 10, ModbusDataType.String,
|
||||
StringLength: 3, ArrayCount: 2); // 2 registers per element
|
||||
var (drv, t) = await StartAsync(tag);
|
||||
await using var _ = drv;
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("S", new[] { "AB", "XYZ" })], TestContext.Current.CancellationToken);
|
||||
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
t.Bank.HoldingRegisters[10].ShouldBe((ushort)(('A' << 8) | 'B'));
|
||||
t.Bank.HoldingRegisters[11].ShouldBe((ushort)0);
|
||||
t.Bank.HoldingRegisters[12].ShouldBe((ushort)(('X' << 8) | 'Y'));
|
||||
t.Bank.HoldingRegisters[13].ShouldBe((ushort)('Z' << 8));
|
||||
// Round trip through the read path.
|
||||
(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(new[] { "AB", "XYZ" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BitInRegister_array_write_sets_only_the_addressed_bit_of_each_register()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister,
|
||||
BitIndex: 4, ArrayCount: 3);
|
||||
var (drv, t) = await StartAsync(tag);
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[30] = 0xFFFF;
|
||||
t.Bank.HoldingRegisters[31] = 0x0001;
|
||||
t.Bank.HoldingRegisters[32] = 0x8000;
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("B", new object[] { false, true, 1 })], TestContext.Current.CancellationToken);
|
||||
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
t.Bank.HoldingRegisters[30].ShouldBe((ushort)0xFFEF, "bit 4 cleared, neighbours kept");
|
||||
t.Bank.HoldingRegisters[31].ShouldBe((ushort)0x0011);
|
||||
t.Bank.HoldingRegisters[32].ShouldBe((ushort)0x8010);
|
||||
(await drv.ReadAsync(["B"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(new[] { false, true, true });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("S")]
|
||||
[InlineData("B")]
|
||||
public async Task A_length_mismatch_is_BadOutOfRange_and_sends_nothing(string tagName)
|
||||
{
|
||||
var (drv, t) = await StartAsync(
|
||||
new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 10, ModbusDataType.String, StringLength: 2, ArrayCount: 2),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 1, ArrayCount: 3));
|
||||
await using var _ = drv;
|
||||
object value = tagName == "S" ? new[] { "only one" } : new[] { true };
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest(tagName, value)], TestContext.Current.CancellationToken);
|
||||
|
||||
w[0].StatusCode.ShouldBe(0x803C0000u);
|
||||
t.Sent.Where(s => s.Pdu[0] is 0x06 or 0x10).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #741 fix round 4 (review r3 N3): <see cref="ModbusTagAddressResolution"/> is the one authority for which part
|
||||
/// of a Modbus TagConfig names the address. The cross-consumer agreement matrix lives in AdminUI.Tests
|
||||
/// (<c>ModbusAddressAuthorityAgreementTests</c>); these pin the resolver's own classification and refusals.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusTagAddressResolutionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("""{"region":"InputRegisters","address":5}""", ModbusAddressShape.Keys)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":0,"addressString":"D100"}""", ModbusAddressShape.Keys)]
|
||||
[InlineData("""{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100}""", ModbusAddressShape.Keys)]
|
||||
[InlineData("""{"addressString":"40011:F"}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":"40011:F","addressResolved":true}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":"40011:F","region":"InputRegisters"}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":"ZZ9"}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":5}""", ModbusAddressShape.InvalidAddressString)]
|
||||
[InlineData("""{"addressString":" "}""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""{"addressString":null}""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""{}""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""[1]""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""not json""", ModbusAddressShape.NoAddress)]
|
||||
public void Classify_decides_shape_by_key_presence_never_by_the_marker(string json, ModbusAddressShape shape)
|
||||
=> ModbusTagAddressResolution.Classify(json).ShouldBe(shape);
|
||||
|
||||
[Fact]
|
||||
public void A_string_only_blob_is_refused_with_a_reason_when_the_family_cannot_parse_it()
|
||||
{
|
||||
var r = ModbusTagAddressResolution.Resolve("""{"addressString":"D100"}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
|
||||
r.IsResolved.ShouldBeFalse();
|
||||
r.Shape.ShouldBe(ModbusAddressShape.StringOnly);
|
||||
r.Refusal.ShouldNotBeNull().ShouldContain("D100");
|
||||
|
||||
var melsec = ModbusTagAddressResolution.Resolve("""{"addressString":"D100"}""", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR);
|
||||
melsec.Address.ShouldNotBeNull().Address.ShouldBe((ushort)100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_are_read_strictly_and_a_bit_region_is_coerced_to_Bool()
|
||||
{
|
||||
ModbusTagAddressResolution.Resolve("""{"region":"Holding","address":1}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR)
|
||||
.IsResolved.ShouldBeFalse();
|
||||
ModbusTagAddressResolution.Resolve("""{"region":"HoldingRegisters","address":70000}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR)
|
||||
.IsResolved.ShouldBeFalse();
|
||||
var coil = ModbusTagAddressResolution.Resolve("""{"region":"Coils","address":3,"dataType":"Int16"}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
|
||||
coil.Address.ShouldNotBeNull().DataType.ShouldBe(ModbusDataType.Bool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressStringDescribes_matches_only_the_fields_some_family_parses_it_to()
|
||||
{
|
||||
ModbusTagAddressResolution.AddressStringDescribes("D100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16,
|
||||
ModbusByteOrder.BigEndian, 0, 0).ShouldBeTrue();
|
||||
ModbusTagAddressResolution.AddressStringDescribes("D100", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16,
|
||||
ModbusByteOrder.BigEndian, 0, 0).ShouldBeFalse();
|
||||
ModbusTagAddressResolution.AddressStringDescribes("ZZ9", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16,
|
||||
ModbusByteOrder.BigEndian, 0, 0).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Transport self-heal (review 07 P3), single in-gate connect attempt + failed-client disposal (L3)
|
||||
/// and the response-PDU length check (H9).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusTransportSelfHealTests
|
||||
{
|
||||
private static readonly byte[] ReadHr0 = [0x03, 0x00, 0x00, 0x00, 0x01];
|
||||
|
||||
/// <summary>
|
||||
/// P3: two lost replies in a row leave the socket torn down. The NEXT call must reconnect on its own
|
||||
/// instead of failing fast with "Transport not connected" until the 90 s comms-loss watchdog.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task After_two_consecutive_timeouts_the_next_call_reconnects()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer
|
||||
{
|
||||
Script = i => i < 2 ? ScriptedModbusTcpServer.Action.Stall : ScriptedModbusTcpServer.Action.Answer,
|
||||
};
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromMilliseconds(500));
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// First call: stall → teardown → reconnect → resend → stall again → propagates.
|
||||
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
|
||||
// Second call: the socket is gone; the transport must dial again and succeed.
|
||||
var resp = await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken);
|
||||
resp[0].ShouldBe((byte)0x03);
|
||||
server.AcceptedConnections.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P3 throttle: after a FAILED connect, calls inside the backoff window fail fast with
|
||||
/// <see cref="ModbusTransportUnavailableException"/> and make no connect attempt; once the window has
|
||||
/// passed the next call dials again. Positive control: the device is back before the throttled call,
|
||||
/// so an un-throttled transport would have reconnected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Failed_connect_is_throttled_then_retried_after_the_window()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer();
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
|
||||
var now = 1_000_000L;
|
||||
transport.Lifecycle.NowMs = () => now;
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
(await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03);
|
||||
|
||||
server.Stop();
|
||||
// Dead socket → EOF → teardown → ONE reconnect attempt → refused.
|
||||
var first = await Should.ThrowAsync<Exception>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
ModbusSocketLifecycle.IsSocketLevelFailure(first).ShouldBeTrue();
|
||||
|
||||
server.Start();
|
||||
var acceptedBefore = server.AcceptedConnections;
|
||||
await Should.ThrowAsync<ModbusTransportUnavailableException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
server.AcceptedConnections.ShouldBe(acceptedBefore, "a throttled call must not dial");
|
||||
|
||||
now += 60_000; // past any window (MaxDelay default 30 s)
|
||||
(await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03);
|
||||
server.AcceptedConnections.ShouldBe(acceptedBefore + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L3: the reconnect never sleeps inside the transport gate. With a 30 s InitialDelay the old
|
||||
/// <c>ConnectWithBackoffAsync</c> held the gate for 30 s before even dialling; now one attempt is made
|
||||
/// and the call returns at once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reconnect_after_drop_does_not_sleep_inside_the_gate()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer();
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2),
|
||||
reconnect: new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(30) });
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
server.Stop();
|
||||
|
||||
var call = transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken);
|
||||
var ex = await Should.ThrowAsync<Exception>(() => call.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
|
||||
ex.ShouldNotBeOfType<TimeoutException>("the send must not wait out the 30 s backoff inside the gate");
|
||||
}
|
||||
|
||||
/// <summary>H9: an MBAP frame carrying no PDU is a truncated-frame desync, not an IndexOutOfRangeException.</summary>
|
||||
[Fact]
|
||||
public async Task Empty_response_pdu_is_a_truncated_frame_desync()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer
|
||||
{
|
||||
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, []),
|
||||
};
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
|
||||
}
|
||||
|
||||
/// <summary>H9: an exception frame missing its exception code is a truncated-frame desync.</summary>
|
||||
[Fact]
|
||||
public async Task Exception_frame_without_code_is_a_truncated_frame_desync()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer
|
||||
{
|
||||
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83]),
|
||||
};
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the two H9 tests: a well-formed exception frame still surfaces as a
|
||||
/// <see cref="ModbusException"/> carrying its code.</summary>
|
||||
[Fact]
|
||||
public async Task Well_formed_exception_frame_still_surfaces_as_ModbusException()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer
|
||||
{
|
||||
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83, 0x02]),
|
||||
};
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<ModbusException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
ex.ExceptionCode.ShouldBe((byte)0x02);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Loopback Modbus-TCP stub whose reply to each request is scripted by the test, and whose listener can
|
||||
/// be stopped (connection refused) and restarted on the SAME port. Used by the transport self-heal tests
|
||||
/// that need a device which disappears and comes back.
|
||||
/// </summary>
|
||||
internal sealed class ScriptedModbusTcpServer : IAsyncDisposable
|
||||
{
|
||||
/// <summary>What the server does with one request.</summary>
|
||||
public enum Action
|
||||
{
|
||||
/// <summary>Answer with an FC03-shaped response of zeroed registers.</summary>
|
||||
Answer,
|
||||
/// <summary>Read the request and never answer it (the client's per-op timeout fires).</summary>
|
||||
Stall,
|
||||
/// <summary>Close the socket without answering.</summary>
|
||||
Drop,
|
||||
}
|
||||
|
||||
private TcpListener? _listener;
|
||||
private CancellationTokenSource _stop = new();
|
||||
private int _requests;
|
||||
private int _accepted;
|
||||
|
||||
/// <summary>Optional scripted reply. Receives the 0-based request index and the request PDU; returns
|
||||
/// the raw bytes to write AFTER the MBAP TxId (i.e. the full ADU is built by the test), or null to fall
|
||||
/// through to <see cref="Script"/>.</summary>
|
||||
public Func<int, byte[], byte[], byte[]?>? RawReply { get; set; }
|
||||
|
||||
/// <summary>Per-request action; defaults to <see cref="Action.Answer"/>.</summary>
|
||||
public Func<int, Action> Script { get; set; } = _ => Action.Answer;
|
||||
|
||||
/// <summary>Gets the port the server listens on (stable across <see cref="Stop"/>/<see cref="Start"/>).</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>Gets the number of accepted TCP connections.</summary>
|
||||
public int AcceptedConnections => Volatile.Read(ref _accepted);
|
||||
|
||||
/// <summary>Gets the number of requests received.</summary>
|
||||
public int Requests => Volatile.Read(ref _requests);
|
||||
|
||||
public ScriptedModbusTcpServer()
|
||||
{
|
||||
var probe = new TcpListener(IPAddress.Loopback, 0);
|
||||
probe.Start();
|
||||
Port = ((IPEndPoint)probe.LocalEndpoint).Port;
|
||||
probe.Stop();
|
||||
Start();
|
||||
}
|
||||
|
||||
/// <summary>(Re)starts listening on <see cref="Port"/>.</summary>
|
||||
public void Start()
|
||||
{
|
||||
_stop = new CancellationTokenSource();
|
||||
_listener = new TcpListener(IPAddress.Loopback, Port);
|
||||
_listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
_listener.Start();
|
||||
var listener = _listener;
|
||||
var token = _stop.Token;
|
||||
_ = Task.Run(() => AcceptLoopAsync(listener, token));
|
||||
}
|
||||
|
||||
/// <summary>Stops listening and closes every live connection — new connects are refused.</summary>
|
||||
public void Stop()
|
||||
{
|
||||
try { _stop.Cancel(); } catch { }
|
||||
try { _listener?.Stop(); } catch { }
|
||||
_listener = null;
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try { client = await listener.AcceptTcpClientAsync(token); }
|
||||
catch { return; }
|
||||
Interlocked.Increment(ref _accepted);
|
||||
_ = Task.Run(() => ServeAsync(client, token));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(TcpClient client, CancellationToken token)
|
||||
{
|
||||
using var _ = client;
|
||||
using var reg = token.Register(() => { try { client.Close(); } catch { } });
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var header = new byte[7];
|
||||
if (!await ReadExactly(stream, header, token)) return;
|
||||
var len = (ushort)((header[4] << 8) | header[5]);
|
||||
var pdu = new byte[len - 1];
|
||||
if (!await ReadExactly(stream, pdu, token)) return;
|
||||
var idx = Interlocked.Increment(ref _requests) - 1;
|
||||
|
||||
var raw = RawReply?.Invoke(idx, header, pdu);
|
||||
if (raw is not null)
|
||||
{
|
||||
await stream.WriteAsync(raw, token);
|
||||
await stream.FlushAsync(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (Script(idx))
|
||||
{
|
||||
case Action.Stall:
|
||||
continue;
|
||||
case Action.Drop:
|
||||
client.Client.Shutdown(SocketShutdown.Both);
|
||||
return;
|
||||
}
|
||||
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var respPdu = new byte[2 + qty * 2];
|
||||
respPdu[0] = pdu[0];
|
||||
respPdu[1] = (byte)(qty * 2);
|
||||
await stream.WriteAsync(Adu(header, respPdu), token);
|
||||
await stream.FlushAsync(token);
|
||||
}
|
||||
}
|
||||
catch { /* connection torn down */ }
|
||||
}
|
||||
|
||||
/// <summary>Builds a reply ADU echoing the request's TxId/unit around <paramref name="respPdu"/>.</summary>
|
||||
public static byte[] Adu(byte[] requestHeader, byte[] respPdu)
|
||||
{
|
||||
var respLen = (ushort)(1 + respPdu.Length);
|
||||
var adu = new byte[7 + respPdu.Length];
|
||||
adu[0] = requestHeader[0]; adu[1] = requestHeader[1];
|
||||
adu[4] = (byte)(respLen >> 8); adu[5] = (byte)(respLen & 0xFF);
|
||||
adu[6] = requestHeader[6];
|
||||
Buffer.BlockCopy(respPdu, 0, adu, 7, respPdu.Length);
|
||||
return adu;
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactly(Stream s, byte[] buf, CancellationToken token)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await s.ReadAsync(buf.AsMemory(read), token);
|
||||
if (n == 0) return false;
|
||||
read += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Stop();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory transport whose every send is answered by a test-supplied handler; falls back to the
|
||||
/// register/coil bank fake (<see cref="ModbusDriverTests.FakeTransport"/>) when the handler returns null.
|
||||
/// Counts sends per unit and dispose calls; the connect can be blocked on a gate.
|
||||
/// </summary>
|
||||
internal sealed class ScriptedTransport : IModbusTransport
|
||||
{
|
||||
private readonly ConcurrentDictionary<byte, int> _sendsByUnit = new();
|
||||
private int _disposeCount;
|
||||
|
||||
/// <summary>The bank answering un-scripted sends.</summary>
|
||||
public ModbusDriverTests.FakeTransport Bank { get; } = new();
|
||||
|
||||
/// <summary>Per-send handler; return null to fall through to <see cref="Bank"/>.</summary>
|
||||
public Func<byte, byte[], CancellationToken, Task<byte[]>?>? Handler { get; set; }
|
||||
|
||||
/// <summary>When set, <see cref="ConnectAsync"/> waits for it before completing.</summary>
|
||||
public TaskCompletionSource? ConnectGate { get; set; }
|
||||
|
||||
/// <summary>Signalled when <see cref="ConnectAsync"/> is entered.</summary>
|
||||
public TaskCompletionSource ConnectEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int DisposeCount => Volatile.Read(ref _disposeCount);
|
||||
|
||||
public int SendsTo(byte unit) => _sendsByUnit.TryGetValue(unit, out var n) ? n : 0;
|
||||
|
||||
public int TotalSends => _sendsByUnit.Values.Sum();
|
||||
|
||||
/// <summary>Every PDU sent, in order.</summary>
|
||||
public ConcurrentQueue<(byte Unit, byte[] Pdu)> Sent { get; } = new();
|
||||
|
||||
/// <summary>The (function code, start, quantity) of every sent request with that function code.</summary>
|
||||
public List<(byte Fc, ushort Start, ushort Qty)> Requests(byte fc) =>
|
||||
[.. Sent.Where(s => s.Pdu[0] == fc).Select(s => (s.Pdu[0], (ushort)((s.Pdu[1] << 8) | s.Pdu[2]), (ushort)((s.Pdu[3] << 8) | s.Pdu[4])))];
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
ConnectEntered.TrySetResult();
|
||||
if (ConnectGate is { } gate) await gate.Task.WaitAsync(ct);
|
||||
}
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
_sendsByUnit.AddOrUpdate(unitId, 1, (_, n) => n + 1);
|
||||
Sent.Enqueue((unitId, pdu));
|
||||
var scripted = Handler?.Invoke(unitId, pdu, ct);
|
||||
return scripted ?? Bank.SendAsync(unitId, pdu, ct);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Interlocked.Increment(ref _disposeCount);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT with
|
||||
/// a CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for MQTTnet to report
|
||||
/// a CONNACK, answer PINGREQ with PINGRESP, SUBSCRIBE with a SUBACK (unless told not to), and record
|
||||
/// PUBLISHes (PUBACKing QoS 1 only when told to). It never forwards a PUBLISH to a subscriber. That is enough for MQTTnet to report
|
||||
/// a real connection — which is what makes a real drop (<see cref="DropAll"/>) drive a real
|
||||
/// <c>DisconnectedAsync</c> and a real reconnect, rather than a test seam standing in for one.
|
||||
/// </summary>
|
||||
@@ -59,6 +60,144 @@ internal sealed class MiniBroker : IDisposable
|
||||
/// </summary>
|
||||
public byte ConnAckReturnCode { get; set; } = ReturnCodeAccepted;
|
||||
|
||||
/// <summary>
|
||||
/// When true (the default) a SUBSCRIBE is answered with a SUBACK granting each filter's requested
|
||||
/// QoS. False is a broker that accepts SUBSCRIBE and never answers — the subscribe-deadline shape.
|
||||
/// </summary>
|
||||
public bool AnswerSubscribe { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the SUBACK for one SUBSCRIBE packet read whole into <paramref name="buffer"/>, or null when
|
||||
/// the packet is not parseable from one read (the fake handles one packet per read, like CONNECT).
|
||||
/// </summary>
|
||||
private static byte[]? TryBuildSubAck(byte[] buffer, int read)
|
||||
{
|
||||
// Fixed header: type byte, then the variable-length "remaining length".
|
||||
var at = 1;
|
||||
var remaining = 0;
|
||||
var multiplier = 1;
|
||||
byte digit;
|
||||
do
|
||||
{
|
||||
if (at >= read) return null;
|
||||
digit = buffer[at++];
|
||||
remaining += (digit & 0x7F) * multiplier;
|
||||
multiplier *= 128;
|
||||
}
|
||||
while ((digit & 0x80) != 0);
|
||||
|
||||
var end = at + remaining;
|
||||
if (end > read || remaining < 2) return null;
|
||||
|
||||
var packetIdHigh = buffer[at];
|
||||
var packetIdLow = buffer[at + 1];
|
||||
at += 2;
|
||||
|
||||
var grants = new List<byte>();
|
||||
while (at + 2 <= end)
|
||||
{
|
||||
var length = (buffer[at] << 8) | buffer[at + 1];
|
||||
at += 2 + length;
|
||||
if (at >= end) return null;
|
||||
grants.Add((byte)(buffer[at] & 0x03));
|
||||
at++;
|
||||
}
|
||||
|
||||
var ack = new List<byte> { 0x90, (byte)(2 + grants.Count), packetIdHigh, packetIdLow };
|
||||
ack.AddRange(grants);
|
||||
return [.. ack];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, a QoS 1 PUBLISH is answered with a PUBACK. False (the default) is a broker that takes
|
||||
/// the PUBLISH and never acknowledges it — the stalled-broker shape a write deadline exists for.
|
||||
/// </summary>
|
||||
public bool AckPublish { get; set; }
|
||||
|
||||
/// <summary>Every PUBLISH received, in arrival order, as <c>(topic, payload)</c>.</summary>
|
||||
public System.Collections.Concurrent.ConcurrentQueue<(string Topic, byte[] Payload)> Published { get; } = new();
|
||||
|
||||
/// <summary>Handles one complete packet; returns false when the connection must close.</summary>
|
||||
private async Task<bool> HandlePacketAsync(NetworkStream stream, byte[] packet)
|
||||
{
|
||||
var packetType = packet[0] & 0xF0;
|
||||
if (packetType == 0x10)
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { 0x20, 0x02, 0x00, ConnAckReturnCode }, _cts.Token);
|
||||
|
||||
// A real broker closes the socket straight after refusing, which is what drives
|
||||
// MQTTnet's DisconnectedAsync. Mirroring it keeps the fake honest.
|
||||
return ConnAckReturnCode == ReturnCodeAccepted;
|
||||
}
|
||||
|
||||
if (packetType == 0xC0)
|
||||
{
|
||||
await stream.WriteAsync(PingResp, _cts.Token);
|
||||
}
|
||||
else if (packetType == 0x80 && AnswerSubscribe && TryBuildSubAck(packet, packet.Length) is { } subAck)
|
||||
{
|
||||
await stream.WriteAsync(subAck, _cts.Token);
|
||||
}
|
||||
else if (packetType == 0x30)
|
||||
{
|
||||
var qos = (packet[0] >> 1) & 0x03;
|
||||
var at = HeaderLength(packet);
|
||||
var topicLength = (packet[at] << 8) | packet[at + 1];
|
||||
var topic = System.Text.Encoding.UTF8.GetString(packet, at + 2, topicLength);
|
||||
at += 2 + topicLength;
|
||||
byte idHigh = 0, idLow = 0;
|
||||
if (qos > 0)
|
||||
{
|
||||
idHigh = packet[at];
|
||||
idLow = packet[at + 1];
|
||||
at += 2;
|
||||
}
|
||||
|
||||
Published.Enqueue((topic, packet[at..]));
|
||||
if (qos == 1 && AckPublish)
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { 0x40, 0x02, idHigh, idLow }, _cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Length of a packet's fixed header (type byte + variable-length remaining length).</summary>
|
||||
private static int HeaderLength(byte[] packet)
|
||||
{
|
||||
var at = 1;
|
||||
while ((packet[at] & 0x80) != 0) at++;
|
||||
return at + 1;
|
||||
}
|
||||
|
||||
/// <summary>Removes one complete packet from the front of <paramref name="pending"/>, if one is there.</summary>
|
||||
private static bool TryTakePacket(List<byte> pending, out byte[] packet)
|
||||
{
|
||||
packet = [];
|
||||
if (pending.Count < 2) return false;
|
||||
|
||||
var at = 1;
|
||||
var remaining = 0;
|
||||
var multiplier = 1;
|
||||
byte digit;
|
||||
do
|
||||
{
|
||||
if (at >= pending.Count) return false;
|
||||
digit = pending[at++];
|
||||
remaining += (digit & 0x7F) * multiplier;
|
||||
multiplier *= 128;
|
||||
}
|
||||
while ((digit & 0x80) != 0);
|
||||
|
||||
var total = at + remaining;
|
||||
if (pending.Count < total) return false;
|
||||
|
||||
packet = pending.GetRange(0, total).ToArray();
|
||||
pending.RemoveRange(0, total);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Total TCP connections accepted since construction — counts reconnect attempts.</summary>
|
||||
public int AcceptedCount => Volatile.Read(ref _acceptedCount);
|
||||
|
||||
@@ -130,7 +269,8 @@ internal sealed class MiniBroker : IDisposable
|
||||
|
||||
private async Task ServeAsync(TcpClient client)
|
||||
{
|
||||
var buffer = new byte[1024];
|
||||
var buffer = new byte[4096];
|
||||
var pending = new List<byte>();
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
@@ -147,21 +287,22 @@ internal sealed class MiniBroker : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
var packetType = buffer[0] & 0xF0;
|
||||
if (packetType == 0x10)
|
||||
// Frame properly: several packets can share one TCP read (concurrent publishes do), and
|
||||
// one packet can span two.
|
||||
pending.AddRange(buffer.AsSpan(0, read).ToArray());
|
||||
var close = false;
|
||||
while (TryTakePacket(pending, out var packet))
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { 0x20, 0x02, 0x00, ConnAckReturnCode }, _cts.Token);
|
||||
|
||||
// A real broker closes the socket straight after refusing, which is what drives
|
||||
// MQTTnet's DisconnectedAsync. Mirroring it keeps the fake honest.
|
||||
if (ConnAckReturnCode != ReturnCodeAccepted)
|
||||
if (!await HandlePacketAsync(stream, packet))
|
||||
{
|
||||
close = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (packetType == 0xC0)
|
||||
|
||||
if (close)
|
||||
{
|
||||
await stream.WriteAsync(PingResp, _cts.Token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
|
||||
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-4.</b> <c>sparkplug.birthObservationWindowSeconds</c> was authored, range-checked and
|
||||
/// persisted by the AdminUI and consumed by nothing. It now bounds how long the Sparkplug browse surface
|
||||
/// collects NBIRTH/DBIRTH traffic before calling the observed set stable: the open budget grows by it,
|
||||
/// the open lingers while births are still arriving, and an operator rebirth request waits (bounded) for
|
||||
/// the births it asked for, so the tree the picker re-renders already carries them.
|
||||
/// </summary>
|
||||
public sealed class MqttBirthObservationWindowTests
|
||||
{
|
||||
private static MqttDriverOptions Sparkplug(int windowSeconds, int connectTimeoutSeconds = 10) => new()
|
||||
{
|
||||
Mode = MqttMode.SparkplugB,
|
||||
ConnectTimeoutSeconds = connectTimeoutSeconds,
|
||||
Sparkplug = new MqttSparkplugOptions { GroupId = "Plant1", BirthObservationWindowSeconds = windowSeconds },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OpenBudget_Sparkplug_AddsTheBirthObservationWindow()
|
||||
{
|
||||
MqttDriverBrowser.OpenBudget(Sparkplug(windowSeconds: 15)).ShouldBe(TimeSpan.FromSeconds(10 + 15));
|
||||
MqttDriverBrowser.OpenBudget(Sparkplug(windowSeconds: 40)).ShouldBe(TimeSpan.FromSeconds(10 + 40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenBudget_Plain_IgnoresTheSparkplugWindow()
|
||||
{
|
||||
var plain = Sparkplug(windowSeconds: 40) with { Mode = MqttMode.Plain };
|
||||
MqttDriverBrowser.OpenBudget(plain).ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(-5, 1)]
|
||||
[InlineData(15, 15)]
|
||||
[InlineData(100_000, 120)]
|
||||
public void BirthWindow_IsClamped(int configured, int expectedSeconds)
|
||||
=> MqttDriverBrowser.BirthWindow(Sparkplug(configured)).ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 4: the rebirth wait is capped below the AdminUI's 20 s per-call timeout, so a long
|
||||
/// configured window cannot hold the button past it (and a short one still governs).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(120, 15)]
|
||||
[InlineData(15, 15)]
|
||||
[InlineData(5, 5)]
|
||||
public async Task RebirthWait_IsTheWindowCappedAt15Seconds(int windowSeconds, int expectedSeconds)
|
||||
{
|
||||
await using var s = new MqttBrowseSession(
|
||||
MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromSeconds(windowSeconds));
|
||||
s.RebirthWait.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
||||
}
|
||||
|
||||
/// <summary>An idle plant costs one quiet interval, not the whole window.</summary>
|
||||
[Fact]
|
||||
public async Task Settle_WithNoBirths_ReturnsAfterOneQuietInterval()
|
||||
{
|
||||
var reads = 0;
|
||||
await MqttBrowseSession.SettleBirthsAsync(
|
||||
() => { reads++; return 0; },
|
||||
baseline: 0,
|
||||
awaitFirstBirth: false,
|
||||
quiet: TimeSpan.FromMilliseconds(20),
|
||||
window: TimeSpan.FromMinutes(5),
|
||||
TestContext.Current.CancellationToken)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
reads.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// While births keep arriving the settle keeps collecting; it returns on the first quiet interval
|
||||
/// after the last one. Counted in reads, not wall-clock, so it cannot flake.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Settle_KeepsCollectingWhileBirthsArrive_ThenReturnsOnQuiet()
|
||||
{
|
||||
var reads = 0;
|
||||
long births = 0;
|
||||
await MqttBrowseSession.SettleBirthsAsync(
|
||||
() =>
|
||||
{
|
||||
reads++;
|
||||
if (reads <= 5) births++; // five intervals with fresh births, then silence
|
||||
return births;
|
||||
},
|
||||
baseline: 0,
|
||||
awaitFirstBirth: false,
|
||||
quiet: TimeSpan.FromMilliseconds(20),
|
||||
window: TimeSpan.FromMinutes(5),
|
||||
TestContext.Current.CancellationToken)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
reads.ShouldBe(6);
|
||||
}
|
||||
|
||||
/// <summary>The window is a hard ceiling: a birth flood that never goes quiet still ends.</summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task Settle_NeverQuiet_EndsAtTheWindow()
|
||||
{
|
||||
long births = 0;
|
||||
await MqttBrowseSession.SettleBirthsAsync(
|
||||
() => ++births,
|
||||
baseline: 0,
|
||||
awaitFirstBirth: false,
|
||||
quiet: TimeSpan.FromMilliseconds(20),
|
||||
window: TimeSpan.FromSeconds(1),
|
||||
TestContext.Current.CancellationToken)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
births.ShouldBeGreaterThan(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A rebirth request waits for the birth it asked for. The fixed version cannot complete
|
||||
/// synchronously (it awaits the settle), the unfixed one completes before the call returns — so the
|
||||
/// <c>IsCompleted</c> check is deterministic, not a timing race. The birth then releases it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RequestRebirth_WaitsForTheRequestedBirths_ThenReturns()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(
|
||||
MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromMinutes(5));
|
||||
|
||||
var request = s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
||||
request.IsCompleted.ShouldBeFalse("the request must wait for the births it asked for");
|
||||
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Uptime", SparkplugDataType.Int64);
|
||||
|
||||
(await request.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBe(1);
|
||||
s.PublishCountForTest.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The wait is a courtesy, never a failure: the NCMD was already published, so a caller deadline
|
||||
/// during the settle returns the published count instead of reporting an error for a request that
|
||||
/// succeeded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RequestRebirth_CallerCancelsDuringTheSettle_StillReportsThePublish()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(
|
||||
MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromMinutes(5));
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
|
||||
var request = s.RequestRebirthAsync("Plant1/EdgeA", cts.Token);
|
||||
request.IsCompleted.ShouldBeFalse();
|
||||
await cts.CancelAsync();
|
||||
|
||||
(await request.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -779,6 +779,13 @@ public sealed class MqttBrowseSessionTests
|
||||
"Plant1/EdgeA::Profile", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Int32)); // element type
|
||||
a.IsArray.ShouldBeTrue();
|
||||
|
||||
// R06:M-5: the ingestor refuses array metrics ("not supported in v1"), so the browser must not
|
||||
// offer one as bindable — no stated address means the browse-commit refuses it in words, instead
|
||||
// of committing a tag that sits at BadWaitingForInitialData forever. The scalar control is
|
||||
// SparkplugAttributes_StateTheBindingTuple_SoTheCommitNeverParsesTheNodeId.
|
||||
a.AddressFields.ShouldBeNull();
|
||||
a.SecurityClass.ShouldContain("not bindable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -796,6 +803,7 @@ public sealed class MqttBrowseSessionTests
|
||||
Enum.TryParse<Core.Abstractions.DriverDataType>(a.DriverDataType, ignoreCase: true, out _)
|
||||
.ShouldBeFalse(); // not a guessed type — unmappable, and visibly so
|
||||
a.SecurityClass.ShouldContain("unsupported");
|
||||
a.AddressFields.ShouldBeNull(); // R06:M-5 — labelled AND unbindable, not just labelled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+264
-4
@@ -13,9 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
/// (<see cref="MqttDriver.ShutdownAsync"/> / <see cref="MqttDriver.DisposeAsync"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Plain mode deliberately: it is the only mode whose initialize needs no SUBSCRIBE, and
|
||||
/// <see cref="MiniBroker"/> answers CONNECT and PINGREQ and nothing else — so a Sparkplug driver
|
||||
/// would park on its group SUBSCRIBE until the deadline and prove nothing about connections.
|
||||
/// Plain mode deliberately: it is the only mode whose initialize needs no SUBSCRIBE.
|
||||
/// <see cref="MiniBroker"/> answers CONNECT, SUBSCRIBE (unless told not to) and PINGREQ, and never
|
||||
/// PUBLISHes, so ingest is driven through <c>Subscriptions.HandleMessage</c>.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverSessionLifecycleTests
|
||||
@@ -122,7 +122,7 @@ public sealed class MqttDriverSessionLifecycleTests
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ingest is fed through <c>Subscriptions.HandleMessage</c>, the method MQTTnet's dispatcher
|
||||
/// calls, because <see cref="MiniBroker"/> answers CONNECT and PINGREQ and nothing else: it
|
||||
/// calls, because <see cref="MiniBroker"/> answers CONNECT, SUBSCRIBE and PINGREQ only: it
|
||||
/// can drop a session (which is what this test needs from it) but cannot PUBLISH.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -174,6 +174,266 @@ public sealed class MqttDriverSessionLifecycleTests
|
||||
recovered.Value.ShouldBe(21.5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-1, end to end.</b> A SUBSCRIBE the broker never answers used to come back from
|
||||
/// <see cref="MqttDriver.SubscribeAsync"/> as a live handle while the topic left every retry set, so
|
||||
/// <c>DriverInstanceActor</c> never re-subscribed and the tag stayed dark behind a Healthy connection.
|
||||
/// It must throw (the actor's reconcile then retries), and the retry against a broker that answers
|
||||
/// must establish and deliver — the positive control.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task ASubscribeTheBrokerNeverAnswers_Throws_AndTheRetryEstablishes()
|
||||
{
|
||||
const string rawPath = "Plant/Mqtt/dev1/Temp";
|
||||
const string topic = "factory/oven/temp";
|
||||
|
||||
using var broker = new MiniBroker { AnswerSubscribe = false };
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
RawTags =
|
||||
[
|
||||
new RawTagEntry(
|
||||
rawPath,
|
||||
$$"""{"topic":"{{topic}}","payloadFormat":"Scalar","dataType":"Float64"}""",
|
||||
WriteIdempotent: false),
|
||||
],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
driver.SubscribeAsync([rawPath], TimeSpan.Zero, TestContext.Current.CancellationToken));
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy, "the connection itself is fine — that was the trap");
|
||||
|
||||
broker.AnswerSubscribe = true;
|
||||
var handle = await driver.SubscribeAsync([rawPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
||||
|
||||
var raised = new List<DataChangeEventArgs>();
|
||||
driver.OnDataChange += (_, e) => { lock (raised) raised.Add(e); };
|
||||
driver.Subscriptions.HandleMessage(topic, "7"u8.ToArray(), retained: false);
|
||||
lock (raised)
|
||||
{
|
||||
raised.ShouldHaveSingleItem().SubscriptionHandle.ShouldBe(handle);
|
||||
raised[0].Snapshot.StatusCode.ShouldBe(0u);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-3.</b> A refused initial connect used to rethrow without disposing the
|
||||
/// <see cref="MqttConnection"/> (and its <c>IMqttClient</c>) it had just built. Control: a
|
||||
/// successful initialize leaves its connection live, so the seam is not reporting "disposed" for
|
||||
/// everything.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_RefusedConnect_DisposesTheConnectionItBuilt()
|
||||
{
|
||||
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
var built = new List<MqttConnection>();
|
||||
driver.ConnectionCreatedForTests = c => built.Add(c);
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
|
||||
built.ShouldHaveSingleItem().State.ShouldBe(MqttConnectionState.Disposed);
|
||||
|
||||
broker.ConnAckReturnCode = MiniBroker.ReturnCodeAccepted;
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
built.Count.ShouldBe(2);
|
||||
built[1].State.ShouldBe(MqttConnectionState.Connected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-10 + the lifecycle-generation rule.</b> A shutdown that times out on the lifecycle gate
|
||||
/// tears down ungated; the wedged initialize that held the gate must then NOT publish its freshly
|
||||
/// connected session behind that teardown, and its late health write must not overwrite the
|
||||
/// shutdown's. The positive controls: the wedged init really did reach the broker (AcceptedCount),
|
||||
/// and a later initialize connects and reports Healthy.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task AWedgedInit_CompletingAfterATimedOutShutdown_PublishesNothing()
|
||||
{
|
||||
using var broker = new MiniBroker();
|
||||
var options = BrokerOptions(broker.Port) with { ConnectTimeoutSeconds = 1 };
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
driver.BeforeConnectHookForTests = async _ =>
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.Task;
|
||||
};
|
||||
|
||||
var initialize = Task.Run(() => driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
// Times out on the gate (1 s) and tears down ungated.
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
driver.BeforeConnectHookForTests = null;
|
||||
release.SetResult();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => initialize);
|
||||
|
||||
broker.AcceptedCount.ShouldBe(1, "the wedged initialize did connect — the control for the absence below");
|
||||
await WaitForAsync(() => broker.LiveConnections == 0, "the superseded session to be torn down");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown, "a superseded init must not overwrite newer health");
|
||||
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Unknown);
|
||||
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => broker.LiveConnections == 1, "the fresh session");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
private static RawTagEntry WritableTag(string rawPath, string writeTopic) => new(
|
||||
rawPath,
|
||||
$$"""{"topic":"{{writeTopic}}/state","payloadFormat":"Scalar","dataType":"Float64","writeTopic":"{{writeTopic}}"}""",
|
||||
WriteIdempotent: false);
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-8.</b> A write batch used to publish strictly serially, one awaited QoS 1 round-trip per
|
||||
/// request — against a stalled broker, N writes × the publish deadline. Different topics now publish
|
||||
/// concurrently: all four PUBLISHes reach a broker that never PUBACKs well inside ONE 30 s deadline
|
||||
/// (serial code would have sent exactly one). Cancelling the batch then fills every slot, so the
|
||||
/// batch shape holds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AWriteBatch_PublishesDifferentTopicsConcurrently()
|
||||
{
|
||||
using var broker = new MiniBroker(); // never PUBACKs
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
ConnectTimeoutSeconds = 30,
|
||||
RawTags = [.. Enumerable.Range(0, 4).Select(i => WritableTag($"Plant/Mqtt/dev1/Sp{i}", $"cmd/sp{i}"))],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var write = driver.WriteAsync(
|
||||
[.. Enumerable.Range(0, 4).Select(i => new WriteRequest($"Plant/Mqtt/dev1/Sp{i}", (double)i))],
|
||||
cts.Token);
|
||||
|
||||
await WaitForAsync(() => broker.Published.Count == 4, "all four PUBLISHes to be in flight at once");
|
||||
write.IsCompleted.ShouldBeFalse("no PUBACK has arrived, so nothing can have completed");
|
||||
|
||||
await cts.CancelAsync();
|
||||
var results = await write.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
results.Count.ShouldBe(4);
|
||||
results.ShouldAllBe(r => r.StatusCode == 0x80050000u); // BadCommunicationError, every slot filled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes to the SAME topic in one batch stay in request order — last write wins at the device —
|
||||
/// while the batch as a whole is concurrent. The broker PUBACKs here, so every slot is Good — and
|
||||
/// those acks advance <c>LastSuccessfulRead</c> (writes feed health).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AWriteBatch_KeepsSameTopicWritesInRequestOrder()
|
||||
{
|
||||
using var broker = new MiniBroker { AckPublish = true };
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
RawTags = [WritableTag("Plant/Mqtt/dev1/Sp", "cmd/sp"), WritableTag("Plant/Mqtt/dev1/Other", "cmd/other")],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBeNull("nothing has answered yet");
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[
|
||||
new WriteRequest("Plant/Mqtt/dev1/Sp", 1.0),
|
||||
new WriteRequest("Plant/Mqtt/dev1/Other", 9.0),
|
||||
new WriteRequest("Plant/Mqtt/dev1/Sp", 2.0),
|
||||
new WriteRequest("Plant/Mqtt/dev1/Sp", 3.0),
|
||||
],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results.ShouldAllBe(r => r.StatusCode == 0u);
|
||||
broker.Published.Where(p => p.Topic == "cmd/sp")
|
||||
.Select(p => System.Text.Encoding.UTF8.GetString(p.Payload))
|
||||
.ShouldBe(["1", "2", "3"]);
|
||||
|
||||
// A PUBACK is the broker answering, so writes feed the evidence clock the comms-loss watchdog reads.
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 6: the bound itself. Ten topics against a broker that never PUBACKs: exactly
|
||||
/// <see cref="MqttDriver.MaxConcurrentPublishes"/> PUBLISHes go out and the count then holds — a
|
||||
/// regression to unbounded concurrency sends all ten. The stability check re-reads the count across
|
||||
/// several condition polls rather than sleeping once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AWriteBatch_NeverHasMoreThanTheBoundInFlight()
|
||||
{
|
||||
using var broker = new MiniBroker(); // never PUBACKs
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
ConnectTimeoutSeconds = 30,
|
||||
RawTags = [.. Enumerable.Range(0, 10).Select(i => WritableTag($"Plant/Mqtt/dev1/Sp{i}", $"cmd/sp{i}"))],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var write = driver.WriteAsync(
|
||||
[.. Enumerable.Range(0, 10).Select(i => new WriteRequest($"Plant/Mqtt/dev1/Sp{i}", (double)i))],
|
||||
cts.Token);
|
||||
|
||||
await WaitForAsync(() => broker.Published.Count >= MqttDriver.MaxConcurrentPublishes, "the bound to fill");
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
broker.Published.Count.ShouldBe(MqttDriver.MaxConcurrentPublishes, "a slot freed with no PUBACK received");
|
||||
}
|
||||
|
||||
await cts.CancelAsync();
|
||||
var results = await write.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
results.Count.ShouldBe(10);
|
||||
results.ShouldAllBe(r => r.StatusCode == 0x80050000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 2: a QoS 0 publish (a Sparkplug command) completing only means the bytes reached the
|
||||
/// local socket buffer; the broker has not answered, so it must not advance <c>LastSuccessfulRead</c>.
|
||||
/// The positive control is <see cref="AWriteBatch_KeepsSameTopicWritesInRequestOrder"/>, where a QoS 1
|
||||
/// PUBACK does advance it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ASparkplugQos0Command_DoesNotAdvanceLastSuccessfulRead()
|
||||
{
|
||||
using var broker = new MiniBroker();
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
Mode = MqttMode.SparkplugB,
|
||||
Sparkplug = new MqttSparkplugOptions { GroupId = "G", RequestRebirthOnGap = false },
|
||||
RawTags =
|
||||
[
|
||||
new RawTagEntry(
|
||||
"Plant/Mqtt/spb/Setpoint",
|
||||
"""{"groupId":"G","edgeNodeId":"E","metricName":"Setpoint","dataType":"Float64"}""",
|
||||
WriteIdempotent: false),
|
||||
],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
var before = driver.GetHealth().LastSuccessfulRead;
|
||||
var publishedBefore = broker.Published.Count;
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[new WriteRequest("Plant/Mqtt/spb/Setpoint", 4.5)], TestContext.Current.CancellationToken);
|
||||
|
||||
results.ShouldHaveSingleItem().StatusCode.ShouldBe(0u);
|
||||
await WaitForAsync(
|
||||
() => broker.Published.Skip(publishedBefore).Any(p => p.Topic == "spBv1.0/G/NCMD/E"),
|
||||
"the NCMD to reach the broker — the control that the write really went out");
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, string what)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
|
||||
@@ -538,17 +538,224 @@ public sealed class MqttSubscriptionManagerTests
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A SUBSCRIBE that never got a verdict (transport throw / deadline) degrades its refs AND throws,
|
||||
/// so <c>DriverInstanceActor</c> holds no handle and its health-tick reconcile re-subscribes. It
|
||||
/// used to return a live-looking handle with the topic dropped from every retry set — dark behind a
|
||||
/// Healthy connection until a redeploy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_TransportThrows_DegradesRefsToBad_DoesNotThrow()
|
||||
public async Task SubscribeAsync_TransportThrows_DegradesRefsToBad_AndThrows()
|
||||
{
|
||||
var transport = new FakeTransport { Throw = new InvalidOperationException("broker gone") };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
var raised = new List<DataChangeEventArgs>();
|
||||
mgr.OnDataChange += (_, e) => raised.Add(e);
|
||||
|
||||
await Should.NotThrowAsync(() =>
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
// The Bad verdict still reached the subscriber before the throw.
|
||||
raised.ShouldContain(e => e.FullReference == OvenTempPath && e.Snapshot.StatusCode == BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 3: a cancel of the CALLER's token (a pipeline timeout, host shutdown) is a cancellation,
|
||||
/// not a broker fault — it must surface as OperationCanceledException, or the resilience breaker counts
|
||||
/// it as a failure. The refs are still Bad and pending, so the next reconnect retries them (the positive
|
||||
/// control). The uncancelled no-verdict case throws InvalidOperationException
|
||||
/// (<see cref="SubscribeAsync_TransportThrows_DegradesRefsToBad_AndThrows"/>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_CallerCancelled_ThrowsOperationCanceled_AndTheTopicStaysPending()
|
||||
{
|
||||
var transport = new FakeTransport { Throw = new OperationCanceledException() };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
var ex = await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), cts.Token));
|
||||
ex.ShouldNotBeOfType<InvalidOperationException>();
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
|
||||
transport.Throw = null;
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
transport.Calls.Last().Select(f => f.Topic).ShouldBe([OvenTopic]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reconcile path: once the actor re-subscribes after the throw, the topic IS re-SUBSCRIBEd and
|
||||
/// delivery resumes on the new handle — the positive control for the throw above.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_AfterATransportThrow_TheRetrySubscribesAndDelivers()
|
||||
{
|
||||
var transport = new FakeTransport { Throw = new InvalidOperationException("broker gone") };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
transport.Throw = null;
|
||||
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
var raised = new List<DataChangeEventArgs>();
|
||||
mgr.OnDataChange += (_, e) => raised.Add(e);
|
||||
mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false);
|
||||
|
||||
transport.Calls.Count.ShouldBe(2);
|
||||
transport.Calls[1].Select(f => f.Topic).ShouldBe([OvenTopic]);
|
||||
raised.ShouldHaveSingleItem().SubscriptionHandle.ShouldBe(handle);
|
||||
raised[0].Snapshot.Value.ShouldBe(21.5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A topic whose first SUBSCRIBE was refused stays in a pending set the reconnect re-subscribe
|
||||
/// covers. The reconnect used to re-SUBSCRIBE only the granted set, so it was never retried.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_RetriesATopicWhoseFirstSubscribeWasRefused()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = _ => true };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Reject = _ => false;
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(2);
|
||||
transport.Calls[1].Select(f => f.Topic).ShouldBe([OvenTopic]);
|
||||
|
||||
var raised = new List<DataChangeEventArgs>();
|
||||
mgr.OnDataChange += (_, e) => raised.Add(e);
|
||||
mgr.HandleMessage(OvenTopic, "3"u8.ToArray(), retained: false);
|
||||
raised.ShouldHaveSingleItem().SubscriptionHandle.ShouldBe(handle);
|
||||
raised[0].Snapshot.StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A persistently ACL-denied topic must not turn every reconnect into a teardown loop: a pending
|
||||
/// topic refused again is not "re-established nothing" — nothing that was live was lost. Both a
|
||||
/// mixed set (the granted topic still re-subscribes) and a pending-only set are covered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_PendingTopicsRefusedAgain_DoesNotThrow()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = t => t == "f/oven/press" };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
transport.Calls[1].Select(f => f.Topic).OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ShouldBe(["f/oven/press", OvenTopic]);
|
||||
|
||||
var lone = new FakeTransport { Reject = _ => true };
|
||||
var mgr2 = NewManager(lone);
|
||||
mgr2.Register([Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr2.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.NotThrowAsync(() => mgr2.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
lone.Calls.Count.ShouldBe(2); // it WAS retried
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A later subscribe on the same session (a redeploy's bulk re-subscribe) also retries the pending
|
||||
/// set, so a broker ACL fixed while connected recovers without waiting for a reconnect.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_RetriesPendingTopicsFromAnEarlierRefusal()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = t => t == "f/oven/press" };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Reject = _ => false;
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls[1].Select(f => f.Topic).OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ShouldBe(["f/oven/press", OvenTopic]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-2.</b> Register prunes the cache to the authored set — an unauthored reference's
|
||||
/// BadNodeIdUnknown marker and a dropped tag's value go. Control: a still-authored tag keeps its
|
||||
/// value across the re-register (a redeploy must not blank observed values).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Register_PrunesCacheEntriesForReferencesNoLongerAuthored()
|
||||
{
|
||||
var mgr = NewManager(new FakeTransport());
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath, "Plant/Mqtt/broker1/Nope"], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
||||
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
|
||||
mgr.HandleMessage("f/oven/press", "2"u8.ToArray(), retained: false);
|
||||
mgr.Values.Read("Plant/Mqtt/broker1/Nope").StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
mgr.Values.Read(OvenTempPath).Value.ShouldBe(1.0);
|
||||
mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(0x80320000u);
|
||||
mgr.Values.Read("Plant/Mqtt/broker1/Nope").StatusCode.ShouldBe(0x80320000u);
|
||||
mgr.Values.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-11.</b> A subscribe issued before any transport is attached reaches no broker, so it must
|
||||
/// not be recorded as established. It used to be — "subscribed, receives nothing" — and the first
|
||||
/// reconnect then had nothing to re-establish. Now it stays pending, and the first reconnect on the
|
||||
/// attached session attempts it: the Bad verdict from the unconnected session is the proof.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_WithNoTransport_StaysPending_AndTheFirstReconnectAttemptsIt()
|
||||
{
|
||||
var mgr = NewManager(transport: null);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(0x80320000u); // BadWaitingForInitialData
|
||||
|
||||
await using var attached = new MqttConnection(
|
||||
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }, "mqtt-1");
|
||||
mgr.AttachTo(attached);
|
||||
|
||||
// The unconnected session gives no verdict, so the reconnect throws — and it ATTEMPTED the topic.
|
||||
await Should.ThrowAsync<Exception>(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register drops a pending topic whose tags the redeploy removed — the same housekeeping the
|
||||
/// granted set gets — so a deleted tag's denied topic is not retried forever.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Register_DroppingATag_RemovesItsTopicFromThePendingSet()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = _ => true };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(1); // nothing left to retry
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
@@ -939,7 +1146,10 @@ public sealed class MqttSubscriptionManagerTests
|
||||
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }, "mqtt-1");
|
||||
mgr.AttachTo(rebound);
|
||||
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
// The unconnected session gives no verdict, so the subscribe now THROWS (M-1) — the throw is a
|
||||
// second proof the SUBSCRIBE was attempted against the new session.
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(
|
||||
BadCommunicationError,
|
||||
|
||||
@@ -852,6 +852,28 @@ public sealed class SparkplugIngestorTests
|
||||
ing.TryResolve(TempPath, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-2.</b> Register prunes the shared cache to the authored set: an unauthored reference's
|
||||
/// BadNodeIdUnknown marker and a dropped tag's value go, so the cache cannot grow with every RawPath
|
||||
/// and stray reference ever seen. Control: a still-authored tag's value survives the re-register.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Register_PrunesCacheEntriesForReferencesNoLongerAuthored()
|
||||
{
|
||||
var ing = NewIngestor();
|
||||
await ing.SubscribeAsync(["Plant/Mqtt/spb/NotAThing"], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
||||
var kept = new DataValueSnapshot(1.0f, 0u, null, DateTime.UtcNow);
|
||||
ing.Values.Update(TempPath, kept);
|
||||
ing.Values.Update(PressPath, kept);
|
||||
|
||||
ing.Register([Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32"))]);
|
||||
|
||||
ing.Values.Read(TempPath).ShouldBe(kept);
|
||||
ing.Values.Read(PressPath).StatusCode.ShouldBe(0x80320000u); // dropped tag: no stale value
|
||||
ing.Values.Read("Plant/Mqtt/spb/NotAThing").StatusCode.ShouldBe(0x80320000u);
|
||||
ing.Values.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_UnauthoredReference_ReportsBadNodeIdUnknown()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using MQTTnet;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-6.</b> The wildcard trie replaced a linear <c>MqttTopicFilterComparer</c> scan on the
|
||||
/// dispatcher thread. Its contract is "a superset of the true matches, confirmed by the comparer", so it
|
||||
/// is pinned differentially against that comparer — the oracle a broker uses — over a seeded random
|
||||
/// space that deliberately includes empty levels, <c>$</c>-topics and wildcard-only filters.
|
||||
/// </summary>
|
||||
public sealed class TopicFilterTrieTests
|
||||
{
|
||||
private static readonly string[] Levels = ["a", "b", "c", "", "$SYS", "x y"];
|
||||
|
||||
private sealed class Collect : ITopicFilterVisitor<int>
|
||||
{
|
||||
public HashSet<int> Seen { get; } = [];
|
||||
|
||||
public void Visit(int value) => Seen.Add(value).ShouldBeTrue("a filter must be visited at most once per match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_IsASupersetOfTheComparer_AndConfirmingYieldsExactlyTheComparersAnswer()
|
||||
{
|
||||
var random = new Random(744);
|
||||
for (var round = 0; round < 200; round++)
|
||||
{
|
||||
var filters = Enumerable.Range(0, random.Next(1, 25)).Select(_ => RandomFilter(random)).ToList();
|
||||
var trie = TopicFilterTrie<int>.Build(filters.Select((f, i) => (f, i)));
|
||||
|
||||
for (var t = 0; t < 50; t++)
|
||||
{
|
||||
var topic = RandomTopic(random);
|
||||
var expected = filters
|
||||
.Select((f, i) => (f, i))
|
||||
.Where(p => MqttTopicFilterComparer.Compare(topic, p.f) == MqttTopicFilterCompareResult.IsMatch)
|
||||
.Select(p => p.i)
|
||||
.ToHashSet();
|
||||
|
||||
var collect = new Collect();
|
||||
trie.Match(topic, ref collect);
|
||||
|
||||
collect.Seen.IsSupersetOf(expected).ShouldBeTrue($"topic '{topic}' missed a match; filters: {string.Join(" | ", filters)}");
|
||||
collect.Seen
|
||||
.Where(i => MqttTopicFilterComparer.Compare(topic, filters[i]) == MqttTopicFilterCompareResult.IsMatch)
|
||||
.ToHashSet()
|
||||
.SetEquals(expected)
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/#", "a", true)] // '#' includes the parent level
|
||||
[InlineData("a/#", "a/b/c", true)]
|
||||
[InlineData("a/+", "a/", true)] // '+' matches an empty level
|
||||
[InlineData("a/+", "a", false)]
|
||||
[InlineData("+/+", "a/b", true)]
|
||||
[InlineData("#", "anything/at/all", true)]
|
||||
[InlineData("a/b", "a/b", true)]
|
||||
[InlineData("a/b", "a/c", false)]
|
||||
public void Match_FindsTheCanonicalCases(string filter, string topic, bool expected)
|
||||
{
|
||||
var trie = TopicFilterTrie<int>.Build([(filter, 1)]);
|
||||
var collect = new Collect();
|
||||
trie.Match(topic, ref collect);
|
||||
collect.Seen.Contains(1).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 7: partial-level wildcards and <c>$share/…</c> filters are outside the trie's level rules.
|
||||
/// Whatever it yields, confirmation by the broker's comparer must produce no match — pinned so a later
|
||||
/// "smarter" trie cannot start delivering them.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("a+", "ab")]
|
||||
[InlineData("a/b#", "a/bc")]
|
||||
[InlineData("a/b#", "a/b")]
|
||||
[InlineData("$share/g/a/b", "a/b")]
|
||||
[InlineData("$share/g/#", "a/b")]
|
||||
public void Match_partialLevelWildcardsAndSharedFilters_neverConfirm(string filter, string topic)
|
||||
{
|
||||
var trie = TopicFilterTrie<int>.Build([(filter, 1)]);
|
||||
var collect = new Collect();
|
||||
trie.Match(topic, ref collect);
|
||||
|
||||
collect.Seen.Where(_ => MqttTopicFilterComparer.Compare(topic, filter) == MqttTopicFilterCompareResult.IsMatch)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_MatchesNothing()
|
||||
{
|
||||
var collect = new Collect();
|
||||
TopicFilterTrie<int>.Empty.Match("a/b", ref collect);
|
||||
collect.Seen.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static string RandomFilter(Random random)
|
||||
{
|
||||
var count = random.Next(1, 5);
|
||||
var parts = new List<string>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var roll = random.Next(10);
|
||||
if (roll < 2 && i == count - 1) { parts.Add("#"); break; }
|
||||
parts.Add(roll < 5 ? "+" : Levels[random.Next(Levels.Length)]);
|
||||
}
|
||||
|
||||
return string.Join('/', parts);
|
||||
}
|
||||
|
||||
private static string RandomTopic(Random random) =>
|
||||
string.Join('/', Enumerable.Range(0, random.Next(1, 6)).Select(_ => Levels[random.Next(Levels.Length)]));
|
||||
}
|
||||
+4
@@ -38,6 +38,10 @@ public sealed class OracleFlavouredSqliteDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitSuffix => _inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => _inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => _inner.RowLimitSuffix(rows);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql => _inner.ListSchemasSql;
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@ public sealed class SqlBrowseSessionTests
|
||||
attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn);
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
|
||||
attribute.IsArray.ShouldBeFalse();
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly");
|
||||
// R06:S-5: every dialect projects IS_NULLABLE and the picker now says so. The column is declared NULL.
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly · nullable");
|
||||
attribute.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -92,7 +93,10 @@ public sealed class SqlBrowseSessionTests
|
||||
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn);
|
||||
var attributes = await session.AttributesAsync(nodeId, CancellationToken.None);
|
||||
|
||||
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
|
||||
var attribute = attributes.ShouldHaveSingleItem();
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.String));
|
||||
// The NOT NULL control for the nullable case above — the descriptor tracks the catalog, not a constant.
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly · NOT NULL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -58,6 +58,127 @@ public sealed class SqlServerReadTests
|
||||
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
|
||||
}
|
||||
|
||||
// ---- R06:S-1: the row cap, on real T-SQL ----
|
||||
|
||||
/// <summary>
|
||||
/// A where-pair selector pointed at an events table (40 rows for one station) used to pull every
|
||||
/// matching row on every poll. The plan now emits <c>SELECT TOP (2) …</c> — proven valid T-SQL here —
|
||||
/// and the second row makes the tag publish <c>BadConfigurationError</c> while the driver stays
|
||||
/// Healthy (the server answered). The positive control: a station with exactly one row reads Good
|
||||
/// through the same capped statement.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realSqlServer_anAmbiguousSelectorIsRowCappedToAConfigurationError()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
const string table = "RowCapEvents";
|
||||
|
||||
await using (var arrange = await _sql.OpenInspectionConnectionAsync())
|
||||
{
|
||||
await using var command = arrange.CreateCommand();
|
||||
command.CommandText = string.Create(CultureInfo.InvariantCulture, $"""
|
||||
IF OBJECT_ID(N'{SqlPollServerFixture.Schema}.{table}') IS NOT NULL DROP TABLE {SqlPollServerFixture.Schema}.{table};
|
||||
CREATE TABLE {SqlPollServerFixture.Schema}.{table} (
|
||||
{SqlPollServerFixture.WideRowSelectorColumn} int NOT NULL,
|
||||
oven_temp float NULL,
|
||||
{SqlPollServerFixture.TimestampColumn} datetime2 NOT NULL);
|
||||
INSERT INTO {SqlPollServerFixture.Schema}.{table} VALUES (1, 21.5, '2026-07-24T10:00:00');
|
||||
DECLARE @i int = 0;
|
||||
WHILE @i < 40
|
||||
BEGIN
|
||||
INSERT INTO {SqlPollServerFixture.Schema}.{table} VALUES (7, @i, DATEADD(second, @i, '2026-07-24T10:00:00'));
|
||||
SET @i += 1;
|
||||
END
|
||||
""");
|
||||
await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var driver = await StartAsync(
|
||||
WideRowWhere("Sql/Events/Ambiguous", "oven_temp", "7", table),
|
||||
WideRowWhere("Sql/Events/Clean", "oven_temp", "1", table));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
["Sql/Events/Ambiguous", "Sql/Events/Clean"], TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshots[1].Value).ShouldBe(21.5);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var cleanup = await _sql.OpenInspectionConnectionAsync();
|
||||
await using var drop = cleanup.CreateCommand();
|
||||
drop.CommandText = $"DROP TABLE IF EXISTS {SqlPollServerFixture.Schema}.{table}";
|
||||
await drop.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review I-1, live: one stray duplicate row in a key-value table fails ONLY that key's tag; the other
|
||||
/// key in the same group (one capped <c>SELECT TOP (5) … IN (@k0, @k1)</c>) reads Good — the positive
|
||||
/// control — and the driver stays Healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realSqlServer_aDuplicatedKeyFailsOnlyItsOwnTag()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
const string table = "RowCapCurrent";
|
||||
var qualified = $"{SqlPollServerFixture.Schema}.{table}";
|
||||
|
||||
await using (var arrange = await _sql.OpenInspectionConnectionAsync())
|
||||
{
|
||||
await using var command = arrange.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
IF OBJECT_ID(N'{qualified}') IS NOT NULL DROP TABLE {qualified};
|
||||
CREATE TABLE {qualified} (
|
||||
{SqlPollServerFixture.KeyColumn} nvarchar(64) NOT NULL,
|
||||
{SqlPollServerFixture.ValueColumn} float NULL,
|
||||
{SqlPollServerFixture.TimestampColumn} datetime2 NOT NULL);
|
||||
INSERT INTO {qualified} VALUES
|
||||
(N'Pump7', 1.0, '2026-07-24T10:00:00'),
|
||||
(N'Pump7', 2.0, '2026-07-24T10:00:01'),
|
||||
(N'Pump8', 8.5, '2026-07-24T10:00:00');
|
||||
""";
|
||||
await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var driver = await StartAsync(
|
||||
KeyValueOn("Sql/Current/Pump7", "Pump7", table),
|
||||
KeyValueOn("Sql/Current/Pump8", "Pump8", table));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
["Sql/Current/Pump7", "Sql/Current/Pump8"], TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshots[1].Value).ShouldBe(8.5);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var cleanup = await _sql.OpenInspectionConnectionAsync();
|
||||
await using var drop = cleanup.CreateCommand();
|
||||
drop.CommandText = $"DROP TABLE IF EXISTS {qualified}";
|
||||
await drop.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static RawTagEntry KeyValueOn(string rawPath, string key, string table) => Tag(rawPath, new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = SqlPollServerFixture.Qualified(table),
|
||||
["keyColumn"] = SqlPollServerFixture.KeyColumn,
|
||||
["keyValue"] = key,
|
||||
["valueColumn"] = SqlPollServerFixture.ValueColumn,
|
||||
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
|
||||
});
|
||||
|
||||
// ---- key-value model ----
|
||||
|
||||
/// <summary>The plan's headline case: a seeded key-value row round-trips as a Good snapshot.</summary>
|
||||
|
||||
@@ -77,6 +77,10 @@ public sealed class ColonMarkerDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql => Colonize(Inner.ListSchemasSql);
|
||||
|
||||
|
||||
@@ -210,6 +210,15 @@ public class OracleDialectTests
|
||||
public void LivenessSql_selectsFromDual()
|
||||
=> new OracleDialect().LivenessSql.ShouldBe("SELECT 1 FROM DUAL");
|
||||
|
||||
[Fact]
|
||||
public void RowLimit_isFetchFirstN_inTheSuffixPosition()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
d.RowLimitPrefix(3).ShouldBe("");
|
||||
d.RowLimitSuffix(3).ShouldBe(" FETCH FIRST 3 ROWS ONLY");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitSuffix(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isFetchFirst_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
|
||||
@@ -226,6 +226,15 @@ public class PostgresDialectTests
|
||||
d.ListColumnsSql.ShouldContain("\"IS_NULLABLE\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowLimit_isLimitN_inTheSuffixPosition()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.RowLimitPrefix(3).ShouldBe("");
|
||||
d.RowLimitSuffix(3).ShouldBe(" LIMIT 3");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitSuffix(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
|
||||
@@ -113,6 +113,10 @@ public sealed class SqlCatalogDefaultSchemaTests
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
public string ListSchemasSql => twoSchemas
|
||||
? Inner.ListSchemasSql + " UNION ALL SELECT 'other' AS TABLE_SCHEMA"
|
||||
: Inner.ListSchemasSql;
|
||||
|
||||
@@ -374,6 +374,10 @@ public sealed class SqlCatalogGateDriverTests
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
public string ListSchemasSql { get; } = listSchemasSql;
|
||||
|
||||
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
|
||||
|
||||
@@ -25,6 +25,28 @@ public sealed class SqlDriverFactoryTests
|
||||
{
|
||||
// ---- the plan's headline legs ----
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-8.</b> A malformed config used to quote System.Text.Json's message — which echoes the
|
||||
/// offending token — into the exception that becomes <c>InitializeFailed</c> → health
|
||||
/// <c>LastError</c> → <c>/hosts</c>. A pasted connection string is exactly the malformed text that
|
||||
/// reaches here, so the parser text (and the inner exception carrying it) is withheld, as the probe and
|
||||
/// browser already do. The message still names the instance and says what is wrong.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_malformedJson_withholdsTheParserText()
|
||||
{
|
||||
const string secret = "hunter2-do-not-log";
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":"SqlServer","connectionStringRef": Password={{secret}} }""", null));
|
||||
|
||||
ex.Message.ShouldContain("d1");
|
||||
ex.Message.ShouldContain("not valid JSON");
|
||||
ex.Message.ShouldNotContain(secret);
|
||||
ex.Message.ShouldNotContain("Password");
|
||||
ex.InnerException.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_missingConnectionStringRef_throwsActionable()
|
||||
=> Should.Throw<InvalidOperationException>(() =>
|
||||
|
||||
@@ -272,14 +272,15 @@ public sealed class SqlDriverTests
|
||||
// ---- the comms-loss watchdog's evidence clock (Driver.Sql-006) ----
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_whenTheDatabaseAnswersWithNoRow_advancesTheEvidenceClock_withoutChangingHealth()
|
||||
public async Task ReadAsync_whenTheDatabaseAnswersWithNoRow_advancesTheEvidenceClock_andRestoresHealthy()
|
||||
{
|
||||
// Driver.Sql-006. LastSuccessfulRead is what DriverInstanceActor's comms-loss watchdog reads: a
|
||||
// non-Healthy driver whose clock has been frozen for 90 s is force-reconnected (Gitea #527), on the
|
||||
// premise that the clock is the honest evidence the transport works. BadNoData IS that evidence —
|
||||
// the query ran and the row is absent — so a driver whose key set was deleted must not be
|
||||
// reconnected every 90 s against a database answering every poll on time. The health STATE stays
|
||||
// exactly as it was: an absent row is a data fact, not a connectivity one.
|
||||
// reconnected every 90 s against a database answering every poll on time. And since the database
|
||||
// answered, the driver is Healthy again (lane rule: a refusal means the device answered — it used to
|
||||
// leave the Degraded latched behind every refusal-only poll).
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Missing", SqlitePollFixture.AbsentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
@@ -295,7 +296,7 @@ public sealed class SqlDriverTests
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
||||
var health = driver.GetHealth();
|
||||
health.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before); // RED pre-fix: frozen
|
||||
health.State.ShouldBe(DriverState.Degraded); // classification untouched
|
||||
health.State.ShouldBe(DriverState.Healthy); // the database answered
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -356,6 +357,191 @@ public sealed class SqlDriverTests
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-1.</b> An over-cap result set publishes BadConfigurationError — the code the planner also
|
||||
/// mints before any I/O, which on its own is NOT answer evidence (theory above). The reader's
|
||||
/// answered-refusal count is what makes it count: the database answered, so the clock advances and a
|
||||
/// comms-Degraded driver is Healthy again. Control: the same snapshot with no count leaves both alone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ObservePollOutcome_aRowCapRefusal_isAnswerEvidence_andRestoresHealthy()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
var refused = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, DateTime.UtcNow);
|
||||
|
||||
driver.ObservePollOutcome([new DataValueSnapshot(null, SqlStatusCodes.BadTimeout, null, DateTime.UtcNow)]);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
await Task.Delay(20, TestContext.Current.CancellationToken);
|
||||
|
||||
driver.ObservePollOutcome([refused]); // no count: planner-minted, no evidence
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
|
||||
driver.ObservePollOutcome([refused], answeredRefusals: 1); // the database answered, over-cap
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-9 / lifecycle generations.</b> A poll that started before a Shutdown → Initialize cycle
|
||||
/// (it can outlive the engine's 5 s teardown wait) must not write its verdict onto the NEW
|
||||
/// configuration's health. Control: the same BadTimeout verdict under the current generation degrades.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ObservePollOutcome_fromASupersededGeneration_isIgnored()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
var stale = driver.LifecycleGenerationForTests;
|
||||
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
var timedOut = new DataValueSnapshot(null, SqlStatusCodes.BadTimeout, null, DateTime.UtcNow);
|
||||
|
||||
driver.ObservePollOutcome([timedOut], generation: stale);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy, "a superseded poll must not degrade the new init");
|
||||
|
||||
driver.ObservePollOutcome([timedOut], generation: driver.LifecycleGenerationForTests);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only the initialize's own final write ends <c>Initializing</c>: a Good poll landing mid-init used to
|
||||
/// flip it to Healthy before liveness had even answered. Control: once the init completes it is Healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task APollLandingMidInitialize_cannotEndInitializing()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var factory = new GatedFactory();
|
||||
await using var driver = NewDriver(fixture, fixture.ConnectionString, CapturingLogger.Null, factory,
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
|
||||
factory.Arm();
|
||||
var init = driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
(await factory.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBeTrue();
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Initializing);
|
||||
|
||||
driver.ObservePollOutcome([new DataValueSnapshot(42.0, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)]);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Initializing);
|
||||
|
||||
factory.Release();
|
||||
await init.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A SQLite factory whose next connection creation blocks until released — parks an initialize.</summary>
|
||||
private sealed class GatedFactory : DbProviderFactory
|
||||
{
|
||||
private readonly ManualResetEventSlim _release = new(initialState: true);
|
||||
private volatile bool _armed;
|
||||
|
||||
public TaskCompletionSource<bool> Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void Arm()
|
||||
{
|
||||
_release.Reset();
|
||||
_armed = true;
|
||||
}
|
||||
|
||||
public void Release() => _release.Set();
|
||||
|
||||
public override DbConnection CreateConnection()
|
||||
{
|
||||
if (_armed)
|
||||
{
|
||||
_armed = false;
|
||||
Entered.TrySetResult(true);
|
||||
_release.Wait(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
return Microsoft.Data.Sqlite.SqliteFactory.Instance.CreateConnection()!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-4.</b> A skipped tag's ledger reason (what /hosts shows) is the deploy gate's own explanation
|
||||
/// — for a <c>Query</c> tag, that the model is deferred — instead of a generic "did not map". Control: a
|
||||
/// well-formed tag is not skipped at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ADeferredQueryTag_isSkipped_withTheGatesReason()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture,
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey),
|
||||
new RawTagEntry("Q", """{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}""", WriteIdempotent: false));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var report = ((ISkippedTagReporter)driver).GetSkippedTags().ShouldNotBeNull();
|
||||
var skipped = report.Sample.ShouldHaveSingleItem();
|
||||
skipped.RawPath.ShouldBe("Q");
|
||||
skipped.Reason.ShouldContain("Query");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Review m-1.</b> A redeploy re-initialises without teardown, so a poll can capture the reader just
|
||||
/// before a rebind disposes it. That poll used to throw an OperationCanceledException the engine then
|
||||
/// classified as a poll failure (Degrade). It now retries once on the current reader — and reads Good,
|
||||
/// the positive control — with health untouched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task APollOnAReplacedReader_retriesOnTheCurrentOne()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
var captured = driver.ReaderForTests;
|
||||
|
||||
driver.RebindForTests();
|
||||
captured.GateDisposedForTests.ShouldBeTrue();
|
||||
|
||||
var snapshots = await driver.ReadThroughAsync(
|
||||
captured, ["Speed"], driver.LifecycleGenerationForTests, CancellationToken.None);
|
||||
|
||||
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no newer reader (the driver's own reader was disposed) the disposed-reader exception still
|
||||
/// propagates, but the engine's error callback must not degrade for it. Control: any other poll error
|
||||
/// still degrades.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ADisposedReaderError_isNotAPollFailure()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
driver.ReaderForTests.Dispose();
|
||||
|
||||
// Awaited directly (as the poll engine does): an OperationCanceledException-derived throw ends the task
|
||||
// Canceled, and only a direct await rethrows the original instance — which HandlePollError matches on.
|
||||
Exception? caught = null;
|
||||
try
|
||||
{
|
||||
await driver.ReadThroughAsync(
|
||||
driver.ReaderForTests, ["Speed"], driver.LifecycleGenerationForTests, CancellationToken.None);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
caught = e;
|
||||
}
|
||||
|
||||
caught.ShouldBeOfType<SqlPollReaderDisposedException>();
|
||||
driver.HandlePollError(caught!);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
driver.HandlePollError(new InvalidOperationException("contract violation"));
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
// ---- Driver.Sql-010: the health field is read-modify-written from several poll loops ----
|
||||
|
||||
[Fact]
|
||||
@@ -445,6 +631,31 @@ public sealed class SqlDriverTests
|
||||
Volatile.Read(ref changes).ShouldBe(afterUnsubscribe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R02:P1.</b> The driver runs <c>PollGroupEngine</c>, so it raises one batch per poll burst:
|
||||
/// <c>DriverInstanceActor</c> then forwards one message per cycle instead of one per tag. Both tags of
|
||||
/// the first (force-raised) poll arrive in the SAME batch, on the subscription's handle.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_raisesOneBatchPerPollBurst()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture,
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey), KvEntry("Pressure", SqlitePollFixture.SecondPresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var batchable = driver.ShouldBeAssignableTo<IBatchSubscribable>();
|
||||
var first = new TaskCompletionSource<DataChangeBatchEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
batchable!.OnDataChangeBatch += (_, args) => first.TrySetResult(args);
|
||||
|
||||
var handle = await driver.SubscribeAsync(["Speed", "Pressure"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
|
||||
var batch = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
batch.SubscriptionHandle.ShouldBe(handle);
|
||||
batch.Items.Select(i => i.FullReference).OrderBy(r => r, StringComparer.Ordinal).ShouldBe(["Pressure", "Speed"]);
|
||||
batch.Items.Single(i => i.FullReference == "Speed").Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
}
|
||||
|
||||
// ---- disposal ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -232,6 +232,21 @@ public class SqlEquipmentTagParserTests
|
||||
SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-4.</b> The deploy gate (<c>EquipmentTagConfigInspector</c>) reaches this through the parser:
|
||||
/// a <c>Query</c> tag draws a warning naming the deferral (a rejection under TagConfigValidation=Error).
|
||||
/// Control: a served key-value blob draws nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Inspect_theDeferredQueryModel_isNamedAtTheDeployGate()
|
||||
{
|
||||
SqlTagDefinitionFactory.Inspect("""{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}""")
|
||||
.ShouldHaveSingleItem().ShouldContain("Query");
|
||||
SqlTagDefinitionFactory.Inspect(
|
||||
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"driver":"Sql","model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
|
||||
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
|
||||
|
||||
@@ -150,7 +150,7 @@ public sealed class SqlGroupPlannerChunkingTests
|
||||
plan.ParameterNames.ShouldBe(
|
||||
Enumerable.Range(0, plan.Parameters.Count)
|
||||
.Select(i => "@k" + i.ToString(CultureInfo.InvariantCulture)));
|
||||
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")");
|
||||
plan.SqlText.ShouldContain("IN (" + string.Join(", ", plan.ParameterNames) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,10 @@ public sealed class SqlGroupPlannerChunkingTests
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
public string ListSchemasSql => Inner.ListSchemasSql;
|
||||
|
||||
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
|
||||
|
||||
@@ -29,6 +29,39 @@ public class SqlGroupPlannerTests
|
||||
RowSelectorColumn: selectorColumn, RowSelectorValue: selectorValue,
|
||||
RowSelectorTopByTimestamp: topByTimestamp);
|
||||
|
||||
// ---- R06:S-1: the row cap ----
|
||||
|
||||
/// <summary>
|
||||
/// A key-value plan reads up to twice its distinct keys (so stray duplicates are attributed per key) and
|
||||
/// asks for one row more; a where-pair wide-row plan asks for two. Enough to SEE an ambiguous selector,
|
||||
/// never enough to materialise a history table every poll.
|
||||
/// The topByTimestamp form keeps its single-row limit and needs no cap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Plans_carryARowCap_andAskForOneRowMore()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[
|
||||
KvTag("A", "dbo.TagValues", "tag_name", "k0", "num_value", null),
|
||||
KvTag("B", "dbo.TagValues", "tag_name", "k1", "num_value", null),
|
||||
KvTag("C", "dbo.TagValues", "tag_name", "k1", "num_value", null), // same key: one row serves both
|
||||
WideTag("W", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
|
||||
WideTag("T", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
|
||||
], new SqlServerDialect()).ToList();
|
||||
|
||||
var kv = plans.Single(p => p.Model == SqlTagModel.KeyValue);
|
||||
kv.RowCap.ShouldBe(4); // 2 distinct keys, with 2x headroom for per-key attribution
|
||||
kv.SqlText.ShouldStartWith("SELECT TOP (5) ");
|
||||
|
||||
var wherePair = plans.Single(p => p.Parameters.Count == 1 && p.Model == SqlTagModel.WideRow);
|
||||
wherePair.RowCap.ShouldBe(1);
|
||||
wherePair.SqlText.ShouldStartWith("SELECT TOP (2) ");
|
||||
|
||||
var top = plans.Single(p => p.Parameters.Count == 0);
|
||||
top.RowCap.ShouldBeNull();
|
||||
top.SqlText.ShouldStartWith("SELECT TOP 1 ");
|
||||
}
|
||||
|
||||
// ---- the plan's golden case ----
|
||||
|
||||
[Fact]
|
||||
@@ -58,7 +91,7 @@ public class SqlGroupPlannerTests
|
||||
new SqlServerDialect());
|
||||
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (3) [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { "@k0" });
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "tag_name", "num_value", "sample_ts" });
|
||||
plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
|
||||
@@ -75,7 +108,7 @@ public class SqlGroupPlannerTests
|
||||
new SqlServerDialect());
|
||||
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (3) [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
plans[0].TimestampColumn.ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -181,7 +214,7 @@ public class SqlGroupPlannerTests
|
||||
|
||||
var plan = plans[0];
|
||||
plan.ParameterNames.Count.ShouldBe(plan.Parameters.Count);
|
||||
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")");
|
||||
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")"); // T-SQL caps at the front
|
||||
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
|
||||
}
|
||||
|
||||
@@ -201,7 +234,7 @@ public class SqlGroupPlannerTests
|
||||
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||
], new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1)");
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1) LIMIT 5");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" });
|
||||
}
|
||||
@@ -213,7 +246,7 @@ public class SqlGroupPlannerTests
|
||||
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
|
||||
new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("= :w");
|
||||
plans[0].SqlText.ShouldEndWith("= :w LIMIT 2");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
|
||||
}
|
||||
@@ -277,7 +310,7 @@ public class SqlGroupPlannerTests
|
||||
|
||||
plans.Count.ShouldBe(1);
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
"SELECT TOP (2) [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { "@w" });
|
||||
plans[0].Parameters.ShouldBe(new object[] { "7" }); // the whereValue is bound, never inlined
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure" });
|
||||
@@ -313,7 +346,7 @@ public class SqlGroupPlannerTests
|
||||
plans.Count.ShouldBe(1);
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure", "sample_ts" });
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
"SELECT TOP (2) [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -424,7 +457,7 @@ public class SqlGroupPlannerTests
|
||||
// A column name is an identifier — it cannot be parameterized, so the guarantee is that it is
|
||||
// bracket-quoted with ] doubled, leaving one (nonexistent) identifier rather than executable SQL.
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (3) [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
|
||||
}
|
||||
|
||||
// ---- contract edges ----
|
||||
|
||||
@@ -187,7 +187,7 @@ public sealed class SqlPollReaderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_warnsInsteadOfSilentlyPickingOne()
|
||||
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_publishesAConfigurationError_andWarns()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
// A second row for the SAME station. The where-pair form emits no ORDER BY and no row limit, so
|
||||
@@ -201,8 +201,11 @@ public sealed class SqlPollReaderTests
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
|
||||
|
||||
// Still a value — the reader degrades to "last row wins", exactly as the key-value model does.
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
// R06:S-1: the where-pair plan now asks for TWO rows (one more than it can use). Getting the second is
|
||||
// proof the selector is ambiguous, so the group publishes a configuration error rather than a
|
||||
// storage-order-dependent "last row" value — and nothing past that second row is ever read.
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[0].Value.ShouldBeNull();
|
||||
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Level.ShouldBe(LogLevel.Warning);
|
||||
@@ -269,8 +272,48 @@ public sealed class SqlPollReaderTests
|
||||
snapshots[0].SourceTimestampUtc!.Value.ShouldBe(snapshots[0].ServerTimestampUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-1 review I-1.</b> One stray duplicate row is attributed to ITS key: that key's tags publish
|
||||
/// <see cref="SqlStatusCodes.BadConfigurationError"/> and every other key in the group reads normally.
|
||||
/// It used to be last-row-wins Good, or the whole group Bad, depending on whether an UNRELATED key was
|
||||
/// absent. Both variants are pinned: every other key present, and another key absent. The other keys'
|
||||
/// Good / BadNoData are the positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task ReadAsync_aDuplicatedKey_failsOnlyThatKeysTags_whateverElseIsAbsent(bool withAnAbsentKey)
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
fixture.Execute(
|
||||
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
|
||||
var tags = new List<SqlTagDefinition>
|
||||
{
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey),
|
||||
KvTag("Speed2", SqlitePollFixture.PresentKey), // a second tag on the duplicated key
|
||||
KvTag("Pressure", SqlitePollFixture.SecondPresentKey),
|
||||
};
|
||||
if (withAnAbsentKey) tags.Add(KvTag("Gone", SqlitePollFixture.AbsentKey));
|
||||
var reader = Reader(fixture, logger, [.. tags]);
|
||||
|
||||
var snapshots = await reader.ReadAsync([.. tags.Select(t => t.Name)], CancellationToken.None);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
snapshots[2].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||
if (withAnAbsentKey) snapshots[3].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
||||
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Message.ShouldContain(SqlitePollFixture.PresentKey);
|
||||
}
|
||||
|
||||
/// <summary>The driver counts a duplicated-key refusal as the database answering, like an over-cap one.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_whenASourceViolatesOneRowPerKey_takesTheLastRowDeterministically()
|
||||
public async Task ReadDetailedAsync_aDuplicatedKey_isReportedAsAnAnsweredRefusal()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
fixture.Execute(
|
||||
@@ -278,12 +321,86 @@ public sealed class SqlPollReaderTests
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
|
||||
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
|
||||
var clean = Reader(fixture, KvTag("Pressure", SqlitePollFixture.SecondPresentKey));
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
||||
(await reader.ReadDetailedAsync(["Speed"], CancellationToken.None)).AnsweredRefusalGroups.ShouldBe(1);
|
||||
(await clean.ReadDetailedAsync(["Pressure"], CancellationToken.None)).AnsweredRefusalGroups.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Design §3.6: the contract is one row per key; when a source breaks it the reader is at least
|
||||
// deterministic — last occurrence in reader order — rather than silently arbitrary.
|
||||
snapshots[0].Value.ShouldBe(99.0);
|
||||
/// <summary>
|
||||
/// <b>R06:S-1.</b> A key-value tag pointed at a history table (many rows per key) used to pull every
|
||||
/// matching row into memory on every poll. The plan now asks for one row more than it has keys; a full
|
||||
/// result set proves the selector is ambiguous, so every member publishes a configuration error and a
|
||||
/// (rate-limited) warning names the table. The positive control: the same tag against a clean source
|
||||
/// reads Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_keyValueRowCapExceeded_publishesAConfigurationError_forEveryMember()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
var clean = Reader(fixture, logger, KvTag("Speed", SqlitePollFixture.PresentKey));
|
||||
(await clean.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
logger.Entries.ShouldBeEmpty();
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
fixture.Execute(string.Create(CultureInfo.InvariantCulture,
|
||||
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', {i}, '2026-07-24T11:00:00Z')"));
|
||||
}
|
||||
|
||||
var reader = Reader(fixture, logger, KvTag("Speed", SqlitePollFixture.PresentKey), KvTag("Speed2", SqlitePollFixture.PresentKey));
|
||||
var snapshots = await reader.ReadAsync(["Speed", "Speed2"], CancellationToken.None);
|
||||
|
||||
snapshots.ShouldAllBe(x => x.StatusCode == SqlStatusCodes.BadConfigurationError);
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Level.ShouldBe(LogLevel.Warning);
|
||||
warning.Message.ShouldContain(SqlitePollFixture.KeyValueTable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client-side half of the cap: a dialect whose limit fragments are empty (a provider that cannot
|
||||
/// spell one) must still stop reading at cap + 1 and report the same configuration error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_aDialectThatEmitsNoLimit_isStillCappedClientSide()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
AddSecondRowForPresentStation(fixture);
|
||||
var reader = new SqlPollReader(fixture.Factory, fixture.ConnectionString, new NoLimitDialect(),
|
||||
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
||||
maxConcurrentGroups: 4, nullIsBad: false,
|
||||
resolve: Resolver(WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)));
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
}
|
||||
|
||||
/// <summary>SQLite with the row-limit fragments blanked — the "provider cannot spell a limit" shape.</summary>
|
||||
private sealed class NoLimitDialect : ISqlDialect
|
||||
{
|
||||
private readonly SqliteDialect _inner = new();
|
||||
public SqlProvider Provider => _inner.Provider;
|
||||
public System.Data.Common.DbProviderFactory Factory => _inner.Factory;
|
||||
public int MaxBindParameters => _inner.MaxBindParameters;
|
||||
public string LivenessSql => _inner.LivenessSql;
|
||||
public string SingleRowLimitPrefix => _inner.SingleRowLimitPrefix;
|
||||
public string SingleRowLimitSuffix => _inner.SingleRowLimitSuffix;
|
||||
public string RowLimitPrefix(int rows) => string.Empty;
|
||||
public string RowLimitSuffix(int rows) => string.Empty;
|
||||
public string ListSchemasSql => _inner.ListSchemasSql;
|
||||
public string DefaultSchemaSql => _inner.DefaultSchemaSql;
|
||||
public string ListTablesSql => _inner.ListTablesSql;
|
||||
public string ListColumnsSql => _inner.ListColumnsSql;
|
||||
public string QuoteIdentifier(string ident) => _inner.QuoteIdentifier(ident);
|
||||
public string ParameterMarker(string baseName) => _inner.ParameterMarker(baseName);
|
||||
public string ParameterBindName(string baseName) => _inner.ParameterBindName(baseName);
|
||||
public void ConfigureCommand(System.Data.Common.DbCommand command) => _inner.ConfigureCommand(command);
|
||||
public void ConfigureParameter(System.Data.Common.DbParameter parameter, object? value) => ((ISqlDialect)_inner).ConfigureParameter(parameter, value);
|
||||
public DriverDataType MapColumnType(string sqlDataType) => _inner.MapColumnType(sqlDataType);
|
||||
}
|
||||
|
||||
// ---- the frozen-peer contract ----
|
||||
@@ -522,6 +639,180 @@ public sealed class SqlPollReaderTests
|
||||
dialect.Factory, " ", dialect, CommandTimeout, OperationTimeout, 4, false, _ => null));
|
||||
}
|
||||
|
||||
// ---- R06:S-7: plans are cached per subscription reference list ----
|
||||
|
||||
/// <summary>
|
||||
/// A poll loop hands the reader the SAME reference list every cycle; re-planning it (and rebuilding the
|
||||
/// slot dictionary keyed by a 12-field record) every poll was pure per-poll garbage. Planned once,
|
||||
/// and the cached plan still reads correct values every time — the positive control.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_theSameReferenceList_isPlannedOnce()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var reader = Reader(fixture,
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey), KvTag("Pressure", SqlitePollFixture.SecondPresentKey));
|
||||
IReadOnlyList<string> refs = ["Speed", "Pressure"];
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var snapshots = await reader.ReadAsync(refs, CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||
}
|
||||
|
||||
reader.PlansBuiltForTests.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cache is invalidated by the definitions themselves: a catalog-gate swap (or any re-resolve)
|
||||
/// hands back different definition instances, and the next poll re-plans against them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_whenAReferenceResolvesToADifferentDefinition_rePlans()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var table = new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal)
|
||||
{
|
||||
["Speed"] = KvTag("Speed", SqlitePollFixture.PresentKey),
|
||||
};
|
||||
var reader = new SqlPollReader(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
|
||||
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
||||
maxConcurrentGroups: 4, nullIsBad: false, resolve: r => table.GetValueOrDefault(r));
|
||||
IReadOnlyList<string> refs = ["Speed"];
|
||||
|
||||
(await reader.ReadAsync(refs, CancellationToken.None))[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
table["Speed"] = KvTag("Speed", SqlitePollFixture.SecondPresentKey); // re-pointed by a new resolve
|
||||
|
||||
(await reader.ReadAsync(refs, CancellationToken.None))[0].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||
reader.PlansBuiltForTests.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---- R06:S-6: per-poll warnings are rate-limited ----
|
||||
|
||||
/// <summary>
|
||||
/// A dropped table polled every second used to log a warning — with the full SQL text — on every
|
||||
/// poll, per group, forever. One per minute per plan now; every poll still publishes the Bad status.
|
||||
/// The first poll's warning is the positive control.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_aPersistentlyFailingGroup_warnsOncePerInterval()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
var reader = Reader(fixture, logger,
|
||||
new SqlTagDefinition("Gone", SqlTagModel.KeyValue, "NoSuchTable",
|
||||
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: "k", ValueColumn: SqlitePollFixture.ValueColumn));
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
(await reader.ReadAsync(["Gone"], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
|
||||
}
|
||||
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A cell that will not coerce is a per-tag refusal: one warning per tag, not one per poll.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_anUncoercibleCell_warnsOncePerTag()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
var reader = Reader(fixture, logger,
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey, declaredType: DriverDataType.DateTime));
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(SqlStatusCodes.BadTypeMismatch);
|
||||
}
|
||||
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A frozen group's BadTimeout warning is rate-limited too; both polls still time out.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_aRepeatedlyTimedOutGroup_warnsOncePerInterval()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
var reader = new SqlPollReader(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
|
||||
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
|
||||
maxConcurrentGroups: 4, nullIsBad: false,
|
||||
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)), logger: logger);
|
||||
|
||||
var locker = fixture.OpenNewConnection();
|
||||
Execute(locker, "BEGIN EXCLUSIVE");
|
||||
try
|
||||
{
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Execute(locker, "ROLLBACK");
|
||||
locker.Dispose();
|
||||
}
|
||||
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("operation timeout"))
|
||||
.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ---- R06:S-3: the concurrency gate is released ----
|
||||
|
||||
/// <summary>An idle reader releases its gate on Dispose, and a disposed reader starts no new group.</summary>
|
||||
[Fact]
|
||||
public async Task Dispose_releasesTheGate_andADisposedReaderRunsNoNewGroup()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
|
||||
reader.Dispose();
|
||||
reader.Dispose(); // idempotent
|
||||
|
||||
reader.GateDisposedForTests.ShouldBeTrue();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => reader.ReadAsync(["Speed"], CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposing while a timed-out group is still wedged inside the provider must NOT dispose the gate
|
||||
/// under it — the zombie releases its slot into the gate when it finally finishes, which is exactly
|
||||
/// the hazard that kept the gate undisposed before. The gate goes once the zombie is out.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_withAWedgedGroupInFlight_waitsForItBeforeReleasingTheGate()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var factory = new TrackingFactory();
|
||||
var reader = new SqlPollReader(
|
||||
factory, fixture.ConnectionString, new SqliteDialect(),
|
||||
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
|
||||
maxConcurrentGroups: 1, nullIsBad: false,
|
||||
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
|
||||
|
||||
var locker = fixture.OpenNewConnection();
|
||||
Execute(locker, "BEGIN EXCLUSIVE");
|
||||
try
|
||||
{
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
|
||||
factory.Live.ShouldBe(1); // the zombie is still inside the provider — the control
|
||||
|
||||
reader.Dispose();
|
||||
reader.GateDisposedForTests.ShouldBeFalse("a group still holds the gate");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Execute(locker, "ROLLBACK");
|
||||
locker.Dispose();
|
||||
}
|
||||
|
||||
(await WaitUntilAsync(() => reader.GateDisposedForTests, TimeSpan.FromSeconds(30)))
|
||||
.ShouldBeTrue("the last group out must release the gate");
|
||||
factory.Live.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_rejectsANullReferenceList()
|
||||
=> await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
|
||||
@@ -81,6 +81,11 @@ public sealed class SqlReinitConfigAdoptionTests
|
||||
"old-server",
|
||||
customMessage:
|
||||
"a reinitialize that could not resolve its new connection must leave the previous binding whole");
|
||||
|
||||
// Review m-4: the failure is a fault with a reason, never a latched Initializing with no LastError.
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(Core.Abstractions.DriverState.Faulted);
|
||||
health.LastError.ShouldNotBeNull().ShouldContain("could not adopt its configuration");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle
|
||||
@@ -101,6 +106,43 @@ public sealed class SqlReinitConfigAdoptionTests
|
||||
driver.Endpoint.ShouldContain("old-server");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-3.</b> Every rebind builds a new <see cref="SqlPollReader"/>; the replaced one (and its
|
||||
/// <c>SemaphoreSlim</c>) used to be dropped undisposed, one per reinitialize. The control: the reader
|
||||
/// that replaced it is live.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_rebind_disposes_the_reader_it_replaces()
|
||||
{
|
||||
using var _ = new ScopedEnvironmentVariable(
|
||||
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
|
||||
"Server=old-server;Database=OldDb;Integrated Security=true");
|
||||
using var __ = new ScopedEnvironmentVariable(
|
||||
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefB,
|
||||
"Server=new-server;Database=NewDb;Integrated Security=true");
|
||||
|
||||
var driver = SqlDriverFactoryExtensions.CreateInstance(
|
||||
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
|
||||
var original = driver.ReaderForTests;
|
||||
|
||||
try
|
||||
{
|
||||
await driver.ReinitializeAsync(
|
||||
$$"""{"provider":"SqlServer","connectionStringRef":"{{RefB}}"}""", CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Liveness failure against a non-existent server is expected; the rebind ran first.
|
||||
}
|
||||
|
||||
driver.ReaderForTests.ShouldNotBeSameAs(original);
|
||||
original.GateDisposedForTests.ShouldBeTrue();
|
||||
driver.ReaderForTests.GateDisposedForTests.ShouldBeFalse();
|
||||
|
||||
await driver.DisposeAsync();
|
||||
driver.ReaderForTests.GateDisposedForTests.ShouldBeTrue("the final dispose releases the live reader too");
|
||||
}
|
||||
|
||||
/// <summary>Sets an environment variable for the duration of a test and restores it after. The resolver
|
||||
/// reads process-global state, so a leaked variable would race every sibling test.</summary>
|
||||
private sealed class ScopedEnvironmentVariable : IDisposable
|
||||
|
||||
@@ -13,6 +13,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
/// </summary>
|
||||
public class SqlServerDialectTests
|
||||
{
|
||||
[Fact]
|
||||
public void RowLimit_isTopN_inThePrefixPosition()
|
||||
{
|
||||
var d = new SqlServerDialect();
|
||||
d.RowLimitPrefix(3).ShouldBe("TOP (3) ");
|
||||
d.RowLimitSuffix(3).ShouldBe("");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitPrefix(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
|
||||
{
|
||||
|
||||
@@ -87,6 +87,20 @@ public sealed class SqliteDialect : ISqlDialect
|
||||
/// </summary>
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQLite has no schema namespace, so the schema level is the single literal
|
||||
/// <see cref="MainSchema"/> — enough to give the browser a real root to expand.
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s TwinCAT row against the REAL write path (#739, review 07 U9), the
|
||||
/// same way the Modbus, S7 and Galaxy parity tests do: the server advertises an array node writable exactly when
|
||||
/// <see cref="TwinCATDriver.WriteAsync"/> converts a whole-array value (<c>TwinCATArrayCodec.ToWriteElements</c>)
|
||||
/// and hands it to <c>WriteArrayAsync</c>, AND the production <see cref="TwinCATArrayCodec.Encode"/> lays the
|
||||
/// converted elements out. A driver change that adds or drops an array element type fails here until the table
|
||||
/// follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The value written is the CLR array the server hands the driver for the node's <c>DataType</c> (the node manager
|
||||
/// converts a numeric client array to the node's element type first). Every array node is materialised 1-D; a
|
||||
/// multi-dimensional PLC array is written row-major from that flat value, so nothing here considers more than one
|
||||
/// rank. The PLC-side shape check (<c>CheckArray</c>: declared length ≤ the PLC's element count, element width)
|
||||
/// runs against the resolved symbol at write time and is outside what a compose-time table can know.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATArrayWriteSupportParityTests
|
||||
{
|
||||
private const string Host = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
private static Array ClrArray(string dataType, int n) => dataType switch
|
||||
{
|
||||
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
|
||||
"SByte" => Enumerable.Repeat((sbyte)1, n).ToArray(),
|
||||
"Byte" => Enumerable.Repeat((byte)1, n).ToArray(),
|
||||
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
|
||||
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
|
||||
"Int32" => Enumerable.Repeat(1, n).ToArray(),
|
||||
"UInt32" => Enumerable.Repeat(1u, n).ToArray(),
|
||||
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
|
||||
"UInt64" => Enumerable.Repeat(1UL, n).ToArray(),
|
||||
"Float" => Enumerable.Repeat(1f, n).ToArray(),
|
||||
"Double" => Enumerable.Repeat(1d, n).ToArray(),
|
||||
// Non-numeric text: a String node over a non-string element converts only when the text happens to parse,
|
||||
// which is not a write the server can promise — the table keeps it read-only.
|
||||
"String" => Enumerable.Repeat("a", n).ToArray(),
|
||||
"DateTime" => Enumerable.Repeat(new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc), n).ToArray(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(dataType)),
|
||||
};
|
||||
|
||||
private static readonly string[] NodeTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float", "Double", "String", "DateTime"];
|
||||
|
||||
// A PLC element width the codec can lay each type out at (STRING(80) / WSTRING(80) for the string types).
|
||||
private static int StrideOf(TwinCATDataType t) => t switch
|
||||
{
|
||||
TwinCATDataType.String => 81,
|
||||
TwinCATDataType.WString => 162,
|
||||
_ => TwinCATTypeGuard.DeclaredByteSize(t),
|
||||
};
|
||||
|
||||
private static async Task<bool> DriverEncodesAsync(string tagConfig, string nodeType, int count)
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
using var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Host)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Host)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "twincat-parity", new FakeTwinCATClientFactory { Customise = () => fake });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, count))], CancellationToken.None);
|
||||
if (r[0].StatusCode != TwinCATStatusMapper.Good || fake.ArrayWriteLog.Count != 1) return false;
|
||||
|
||||
var (_, type, elements) = fake.ArrayWriteLog[0];
|
||||
if (elements.Count != count) return false;
|
||||
try
|
||||
{
|
||||
TwinCATArrayCodec.Encode(type, elements, new TwinCATArrayLayout(count, StrideOf(type)), utf8: false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is NotSupportedException or InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static TheoryData<TwinCATDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<TwinCATDataType, string>();
|
||||
foreach (var t in Enum.GetValues<TwinCATDataType>())
|
||||
foreach (var n in NodeTypes)
|
||||
data.Add(t, n);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every element type × every node DataType: the table answers exactly what the write path does.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(TypeMatrix))]
|
||||
public async Task Table_agrees_with_the_write_path_for_every_element_and_node_type(TwinCATDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"symbolPath":"MAIN.Arr","dataType":"{{dataType}}","isArray":true,"arrayLength":3}""";
|
||||
|
||||
if (dataType == TwinCATDataType.String && nodeType == "DateTime")
|
||||
{
|
||||
// The one CULTURE-dependent pair: a DateTime element reaches a STRING as Convert.ToString(DateTime), and
|
||||
// whether its strict Windows-1252 encode accepts that text depends on the host culture (ICU's en-US puts a
|
||||
// U+202F before "PM"; NLS a plain space). The table keeps it read-only — the conservative direction.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, json).ShouldBeFalse();
|
||||
return;
|
||||
}
|
||||
|
||||
var driver = await DriverEncodesAsync(json, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: a BOOL bit-in-word path (a scalar bit write, which an array
|
||||
/// value cannot feed), an undeclared length (a scalar tag), an element-offset path into a multi-dimensional
|
||||
/// array, a TwinCAT 2 global, and their positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"symbolPath":"MAIN.Flags","dataType":"Bool","isArray":true,"arrayLength":3}""", "Boolean")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Word.3","dataType":"Bool","isArray":true,"arrayLength":3}""", "Boolean")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Word.3","dataType":"Int","isArray":true,"arrayLength":3}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Grid[1,0]","dataType":"Int","isArray":true,"arrayLength":3}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":".GlobalArr","dataType":"Real","isArray":true,"arrayLength":3}""", "Float")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","dataType":"Int","isArray":true}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","dataType":"Int","isArray":true,"arrayLength":0}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","isArray":true,"arrayLength":3}""", "Int32")] // dataType ⇒ DInt
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(string tagConfig, string nodeType)
|
||||
{
|
||||
var driver = await DriverEncodesAsync(tagConfig, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, tagConfig).ShouldBe(driver, tagConfig);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ public sealed class ModbusDriverPageFormSerializationTests
|
||||
UseFC15ForSingleCoilWrites = true,
|
||||
UseFC16ForSingleRegisterWrites = true,
|
||||
DisableFC23 = true,
|
||||
UseFC23ForMultiRegisterWrites = true,
|
||||
DisableReadCoalescing = true,
|
||||
WriteOnChangeOnly = true,
|
||||
AutoReconnect = false,
|
||||
Family = ModbusFamily.DL205,
|
||||
@@ -72,6 +74,8 @@ public sealed class ModbusDriverPageFormSerializationTests
|
||||
back.UseFC15ForSingleCoilWrites.ShouldBeTrue();
|
||||
back.UseFC16ForSingleRegisterWrites.ShouldBeTrue();
|
||||
back.DisableFC23.ShouldBeTrue();
|
||||
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
||||
back.DisableReadCoalescing.ShouldBeTrue();
|
||||
back.WriteOnChangeOnly.ShouldBeTrue();
|
||||
back.AutoReconnect.ShouldBeFalse();
|
||||
back.Family.ShouldBe(ModbusFamily.DL205);
|
||||
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Review r3 N3 (#741, fix round 4): <b>one authority for a Modbus tag's address</b>. Every persisted
|
||||
/// <c>TagConfig</c> shape goes through EVERY consumer that decides, displays or rewrites the address. They
|
||||
/// must all agree with the driver, under every family:
|
||||
/// <list type="bullet">
|
||||
/// <item>the driver's tag reader (<c>ModbusTagDefinitionFactory.FromTagConfig</c> with the driver's
|
||||
/// family, the exact call <c>ModbusDriver</c> makes at initialize);</item>
|
||||
/// <item>the deploy gate (<c>EquipmentTagConfigInspector.Inspect</c>);</item>
|
||||
/// <item>the editor model's load (what its fields claim), save (incl. a Writable toggle) and
|
||||
/// <c>Validate</c>;</item>
|
||||
/// <item>the CSV export → import round trip through the real <c>RawTagCsvMapper</c> (the export residual,
|
||||
/// not the stored blob, is the import base).</item>
|
||||
/// </list>
|
||||
/// The expected verdict per family is pinned in the table, so the authority itself cannot drift either.
|
||||
/// Three rounds converged on the rule one consumer at a time; this matrix is what makes a new divergence
|
||||
/// fail in one place.
|
||||
/// </summary>
|
||||
public sealed class ModbusAddressAuthorityAgreementTests
|
||||
{
|
||||
private static readonly (ModbusFamily Family, MelsecFamily Sub, string Label)[] Families =
|
||||
[
|
||||
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR, "Generic"),
|
||||
(ModbusFamily.DL205, MelsecFamily.Q_L_iQR, "DL205"),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, "MELSEC-Q"),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.F_iQF, "MELSEC-F"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Shape name, persisted blob, and the expected driver verdict per family in the order of
|
||||
/// <see cref="Families"/> (<c>IR5</c> = InputRegisters[5], <c>-</c> = refused).
|
||||
/// </summary>
|
||||
public static TheoryData<string, string, string> Shapes => new()
|
||||
{
|
||||
{ "keys only", """{"region":"InputRegisters","address":5}""", "IR5|IR5|IR5|IR5" },
|
||||
{ "pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", "IR5|IR5|IR5|IR5" },
|
||||
{ "pre-lane stamped HR/0 + string",
|
||||
"""{"region":"HoldingRegisters","address":0,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"addressString":"D100"}""",
|
||||
"HR0|HR0|HR0|HR0" },
|
||||
{ "string only", """{"addressString":"40011:F"}""", "HR10|HR10|HR10|HR10" },
|
||||
{ "string only, family-native", """{"addressString":"D100"}""", "-|-|HR100|HR100" },
|
||||
{ "string + marker + keys", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""", "HR100|HR100|HR100|HR100" },
|
||||
{ "string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", "HR10|HR10|HR10|HR10" },
|
||||
{ "string + marker, no keys, family-native", """{"addressString":"D100","addressResolved":true}""", "-|-|HR100|HR100" },
|
||||
{ "malformed string", """{"addressString":"ZZ9"}""", "-|-|-|-" },
|
||||
{ "malformed string + marker", """{"addressString":"ZZ9","addressResolved":true}""", "-|-|-|-" },
|
||||
{ "malformed string + keys", """{"region":"InputRegisters","address":5,"addressString":"ZZ9"}""", "IR5|IR5|IR5|IR5" },
|
||||
{ "non-string addressString", """{"addressString":5}""", "-|-|-|-" },
|
||||
{ "string + one key", """{"addressString":"40011:F","region":"InputRegisters"}""", "HR10|HR10|HR10|HR10" },
|
||||
{ "no address", """{"writable":true}""", "-|-|-|-" },
|
||||
{ "bit region, legacy Int16", """{"region":"Coils","address":3,"dataType":"Int16"}""", "CO3|CO3|CO3|CO3" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Shapes))]
|
||||
public void Every_consumer_agrees_with_the_driver_on_every_persisted_shape(string shape, string blob, string expected)
|
||||
{
|
||||
var want = expected.Split('|');
|
||||
var anyServed = want.Any(v => v != "-");
|
||||
|
||||
// 1. The driver (the authority) — pinned, so the authority cannot drift unnoticed either.
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
Verdict(blob, i).ShouldBe(want[i], $"{shape}: driver / {Families[i].Label}");
|
||||
}
|
||||
|
||||
// 2. The deploy gate: clean exactly when some family's driver serves the tag.
|
||||
(EquipmentTagConfigInspector.Inspect(DriverTypeNames.Modbus, blob).Count == 0)
|
||||
.ShouldBe(anyServed, $"{shape}: deploy gate accepts ⇔ some driver serves it");
|
||||
|
||||
// 3. Editor load: when the model presents its fields as the address, they ARE the driver's address.
|
||||
var loaded = ModbusTagConfigModel.FromJson(blob);
|
||||
if (!loaded.IsStringOnly)
|
||||
{
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
if (want[i] == "-") continue;
|
||||
FieldsVerdict(loaded).ShouldBe(want[i], $"{shape}: editor fields / {Families[i].Label}");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Editor save, untouched and after a Writable toggle (the round trip that remapped N1 / N3).
|
||||
var untouched = ModbusTagConfigModel.FromJson(blob).ToJson();
|
||||
var toggled = ModbusTagConfigModel.FromJson(blob);
|
||||
toggled.Writable = false;
|
||||
var toggledJson = toggled.ToJson();
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
Verdict(untouched, i).ShouldBe(want[i], $"{shape}: editor save / {Families[i].Label}");
|
||||
Verdict(toggledJson, i).ShouldBe(want[i], $"{shape}: editor save after Writable toggle / {Families[i].Label}");
|
||||
}
|
||||
|
||||
// 5. Validate: accepts exactly what some family's driver serves.
|
||||
(ModbusTagConfigModel.FromJson(blob).Validate() is null).ShouldBe(anyServed, $"{shape}: Validate");
|
||||
|
||||
// 6. CSV export → import through the production mapper (the residual is the import base).
|
||||
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
|
||||
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
|
||||
var parsed = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv);
|
||||
parsed.FatalError.ShouldBeNull();
|
||||
var row = parsed.Rows.ShouldHaveSingleItem();
|
||||
if (!row.Ok)
|
||||
{
|
||||
anyServed.ShouldBeFalse($"{shape}: CSV import refused a tag the driver serves ({row.Error})");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
Verdict(row.ImportRow!.Tag.TagConfig, i).ShouldBe(want[i], $"{shape}: CSV export → import / {Families[i].Label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string Verdict(string json, int familyIndex)
|
||||
{
|
||||
var (family, sub, _) = Families[familyIndex];
|
||||
return ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def)
|
||||
? Short(def.Region) + def.Address
|
||||
: "-";
|
||||
}
|
||||
|
||||
private static string FieldsVerdict(ModbusTagConfigModel m) => Short(m.Region) + m.Address;
|
||||
|
||||
private static string Short(ModbusRegion r) => r switch
|
||||
{
|
||||
ModbusRegion.HoldingRegisters => "HR",
|
||||
ModbusRegion.InputRegisters => "IR",
|
||||
ModbusRegion.Coils => "CO",
|
||||
ModbusRegion.DiscreteInputs => "DI",
|
||||
_ => r.ToString(),
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ CSV metadata (review r3 m1 / m3)
|
||||
|
||||
private static RawCsvReviewRow RoundTrip(string blob, Func<string, string>? editCsv = null)
|
||||
{
|
||||
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
|
||||
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
|
||||
if (editCsv is not null) csv = editCsv(csv);
|
||||
return RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv).Rows.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <summary>m1: an UNCHANGED export → import of a pinned row keeps its address string and marker.</summary>
|
||||
[Fact]
|
||||
public void An_unchanged_csv_round_trip_of_a_pinned_row_keeps_its_address_string_and_marker()
|
||||
{
|
||||
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
|
||||
var row = RoundTrip(pinned);
|
||||
row.Ok.ShouldBeTrue(row.Error);
|
||||
var json = row.ImportRow!.Tag.TagConfig;
|
||||
json.ShouldContain("\"addressString\":\"D100\"");
|
||||
json.ShouldContain("\"addressResolved\":true");
|
||||
row.TypedOverrodeFallback.ShouldBeEmpty("nothing was overridden");
|
||||
ModbusTagConfigModel.FromJson(json).IsPinned.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>m1 positive control: an address cell that CONTRADICTS the string drops it, and the grid says so.</summary>
|
||||
[Fact]
|
||||
public void A_csv_address_cell_that_contradicts_the_string_drops_it_and_the_review_grid_reports_it()
|
||||
{
|
||||
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
|
||||
// Edit the exported Address cell 100 → 101.
|
||||
var row = RoundTrip(pinned, csv => csv.Replace(",HoldingRegisters,100,", ",HoldingRegisters,101,", StringComparison.Ordinal));
|
||||
row.Ok.ShouldBeTrue(row.Error);
|
||||
var json = row.ImportRow!.Tag.TagConfig;
|
||||
json.ShouldNotContain("addressString");
|
||||
json.ShouldNotContain("addressResolved");
|
||||
row.TypedOverrodeFallback.ShouldNotBeEmpty("the dropped string is reported, not silent");
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)101);
|
||||
}
|
||||
|
||||
/// <summary>m3: the n5 case on an UNMARKED string-only row — typed address cells are applied and drop the string.</summary>
|
||||
[Fact]
|
||||
public void A_typed_csv_address_cell_on_an_unmarked_string_only_row_is_applied_and_drops_the_string()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var merged = map.ApplyTypedColumns("""{"addressString":"40011:F"}""",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal) { ["Region"] = "InputRegisters", ["Address"] = "7" });
|
||||
|
||||
merged.ShouldNotContain("addressString");
|
||||
foreach (var (family, sub, label) in Families)
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", family, sub, out var def).ShouldBeTrue(label);
|
||||
def.Region.ShouldBe(ModbusRegion.InputRegisters, label);
|
||||
def.Address.ShouldBe((ushort)7, label);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>N3, by name: the marker without keys is string-only for the editor, its save and its CSV export.</summary>
|
||||
[Fact]
|
||||
public void A_marker_without_keys_is_string_only_and_never_saved_as_HR0()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F","addressResolved":true}""");
|
||||
m.IsStringOnly.ShouldBeTrue();
|
||||
m.IsPinned.ShouldBeFalse("a marker without keys pins nothing");
|
||||
m.Writable = false;
|
||||
var saved = m.ToJson();
|
||||
saved.ShouldNotContain("\"address\":");
|
||||
saved.ShouldNotContain("\"region\":");
|
||||
CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.ReadTypedColumns(saved).ShouldNotContainKey("Address");
|
||||
}
|
||||
|
||||
/// <summary>The editor never writes default keys it did not resolve: a Writable toggle on an address-less blob
|
||||
/// does not author HoldingRegisters[0]; setting a field does.</summary>
|
||||
[Fact]
|
||||
public void An_address_less_blob_gains_keys_only_when_a_field_is_authored()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.Writable = false;
|
||||
m.ToJson().ShouldNotContain("\"region\":");
|
||||
m.HasKeyAddress.ShouldBeFalse();
|
||||
|
||||
m.Address = 0; // an explicit authoring of HR[0] is honoured
|
||||
m.HasKeyAddress.ShouldBeTrue();
|
||||
ModbusTagDefinitionFactory.FromTagConfig(m.ToJson(), "T", out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)0);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,23 @@ public sealed class ModbusDriverFormModelTests
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Review r1 n2: the lane's new form keys survive FromOptions → ToOptions, and their defaults keep
|
||||
/// the historical wire (FC16, coalescing on at gap 8 only when the key is absent).</summary>
|
||||
[Fact]
|
||||
public void Form_round_trips_the_FC23_opt_in_and_coalescing_keys()
|
||||
{
|
||||
var form = new ModbusDriverForm.FormModel { UseFC23ForMultiRegisterWrites = true, DisableFC23 = true, DisableReadCoalescing = true, MaxReadGap = 0 };
|
||||
var back = ModbusDriverForm.FormModel.FromOptions(form.ToOptions());
|
||||
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
||||
back.DisableFC23.ShouldBeTrue();
|
||||
back.DisableReadCoalescing.ShouldBeTrue();
|
||||
back.MaxReadGap.ShouldBe(0);
|
||||
|
||||
var defaults = new ModbusDriverForm.FormModel().ToOptions();
|
||||
defaults.UseFC23ForMultiRegisterWrites.ShouldBeFalse();
|
||||
defaults.MaxReadGap.ShouldBe((ushort)8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_channel_fields_and_enum()
|
||||
{
|
||||
|
||||
@@ -121,4 +121,190 @@ public sealed class ModbusTagConfigModelTests
|
||||
[InlineData("""{"region":3,"address":1,"dataType":true}""")]
|
||||
public void Validate_accepts_every_enum_value_the_driver_accepts(string json)
|
||||
=> ModbusTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||
|
||||
/// <summary>Review 07 P6: switching region keeps the data type one the driver accepts for it.</summary>
|
||||
[Theory]
|
||||
[InlineData(ModbusDataType.Int16, ModbusRegion.Coils, ModbusDataType.Bool)]
|
||||
[InlineData(ModbusDataType.Float32, ModbusRegion.DiscreteInputs, ModbusDataType.Bool)]
|
||||
[InlineData(ModbusDataType.Bool, ModbusRegion.HoldingRegisters, ModbusDataType.Int16)]
|
||||
[InlineData(ModbusDataType.Float32, ModbusRegion.InputRegisters, ModbusDataType.Float32)]
|
||||
public void SetRegion_keeps_the_type_compatible(ModbusDataType from, ModbusRegion region, ModbusDataType expected)
|
||||
{
|
||||
var m = new ModbusTagConfigModel { DataType = from };
|
||||
m.SetRegion(region);
|
||||
m.Region.ShouldBe(region);
|
||||
m.DataType.ShouldBe(expected);
|
||||
ModbusTagConfigModel.FromJson(m.ToJson()).Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A register region with Bool (CSV / API) is refused at authoring, matching the driver.</summary>
|
||||
[Fact]
|
||||
public void Validate_refuses_a_register_region_with_Bool()
|
||||
=> ModbusTagConfigModel.FromJson("""{"region":"HoldingRegisters","address":1,"dataType":"Bool"}""").Validate().ShouldNotBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1: the exact blob the pre-#741 editor persisted for a coil (dataType defaulted to Int16)
|
||||
/// still validates, loads as Bool (what the driver polls), and re-saves as Bool.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_legacy_Int16_coil_blob_validates_and_loads_as_Bool()
|
||||
{
|
||||
const string legacy = """{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
|
||||
var m = ModbusTagConfigModel.FromJson(legacy);
|
||||
m.Validate().ShouldBeNull();
|
||||
m.DataType.ShouldBe(ModbusDataType.Bool);
|
||||
m.ToJson().ShouldContain("\"dataType\":\"Bool\"");
|
||||
}
|
||||
|
||||
/// <summary>Review 07 U1: the editor resolves an address string with the DRIVER's family and persists
|
||||
/// both the string and the resolved fields; the driver re-resolves the string with its family.</summary>
|
||||
[Fact]
|
||||
public void ResolveAddress_uses_the_driver_family_and_persists_string_and_resolved_fields()
|
||||
{
|
||||
var (family, sub) = ModbusTagConfigModel.ReadFamily("""{"Family":"MELSEC","melsecSubFamily":"Q_L_iQR"}""");
|
||||
family.ShouldBe(ModbusFamily.MELSEC);
|
||||
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.ResolveAddress("D100", family, sub).ShouldBeNull();
|
||||
m.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
m.Address.ShouldBe(100);
|
||||
|
||||
var json = m.ToJson();
|
||||
json.ShouldContain("\"addressString\":\"D100\"");
|
||||
json.ShouldContain("\"address\":100");
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)100);
|
||||
ModbusTagConfigModel.FromJson(json).Validate().ShouldBeNull("the family-unaware authoring gate accepts what the MELSEC driver serves");
|
||||
|
||||
// Positive control: under Generic the same text is an error, and nothing is persisted.
|
||||
var g = ModbusTagConfigModel.FromJson("{}");
|
||||
g.ResolveAddress("D100", ModbusFamily.Generic, MelsecFamily.Q_L_iQR).ShouldNotBeNull();
|
||||
g.AddressString.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Fix round 2 (review r1 N1): a string-only tag through CSV import must still resolve its string
|
||||
/// with the driver's family — never the HoldingRegisters/0 defaults the model loads it with.</summary>
|
||||
[Fact]
|
||||
public void Csv_import_of_a_string_only_tag_keeps_resolving_the_string()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var merged = map.ApplyTypedColumns("""{"addressString":"D100"}""",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal) { ["Writable"] = "false" });
|
||||
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var melsec).ShouldBeTrue();
|
||||
melsec.Address.ShouldBe((ushort)100, "not HR[0]");
|
||||
melsec.Writable.ShouldBeFalse("the typed column still applied");
|
||||
// Under another family the string is resolved by THAT family (here it does not parse), never HR[0].
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
|
||||
// Export shows blank address cells for it, so an export → re-import cannot author an HR[0] address.
|
||||
map.ReadTypedColumns(merged).ShouldNotContainKey("Region");
|
||||
map.ReadTypedColumns(merged).ShouldNotContainKey("Address");
|
||||
}
|
||||
|
||||
/// <summary>Fix round 2 (review r1 N1): an editor save (e.g. the Writable toggle) of a string-only tag.</summary>
|
||||
[Fact]
|
||||
public void Editor_save_of_a_string_only_tag_keeps_resolving_the_string()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F"}""");
|
||||
m.IsStringOnly.ShouldBeTrue();
|
||||
m.Writable = false;
|
||||
var json = m.ToJson();
|
||||
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)10);
|
||||
def.DataType.ShouldBe(ModbusDataType.Float32);
|
||||
def.Writable.ShouldBeFalse();
|
||||
json.ShouldNotContain("\"address\":");
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the two above: Resolve pins the fields (marker), and a family change then
|
||||
/// cannot remap the tag.</summary>
|
||||
[Fact]
|
||||
public void A_resolved_tag_is_pinned_against_a_later_family_change()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.ResolveAddress("D100", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR).ShouldBeNull();
|
||||
var json = m.ToJson();
|
||||
json.ShouldContain("\"addressResolved\":true");
|
||||
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)100, $"family {family}");
|
||||
}
|
||||
}
|
||||
|
||||
private const string PreLaneRow = """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""";
|
||||
|
||||
/// <summary>Fix round 3 (review r2 N2): a pre-#741 row (keys + string, no marker) keeps resolving from its
|
||||
/// keys through a model save and a CSV export → import, under every family.</summary>
|
||||
[Fact]
|
||||
public void A_pre_lane_keys_plus_string_row_keeps_its_keys_through_save_and_csv_round_trip()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson(PreLaneRow);
|
||||
m.IsStringOnly.ShouldBeFalse();
|
||||
m.HasLegacyAddressString.ShouldBeTrue();
|
||||
m.Writable = false;
|
||||
var saved = m.ToJson();
|
||||
|
||||
// The production export → import path (review r3 m2): the import base is the export RESIDUAL, not the row.
|
||||
var csv = ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawTagCsvMapper.Export(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus,
|
||||
[new ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawCsvExportTag(null, "t", "Int16",
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite, false, null, PreLaneRow)]);
|
||||
csv.ShouldContain(",InputRegisters,5,");
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawTagCsvMapper.Parse(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus, csv)
|
||||
.Rows.ShouldHaveSingleItem();
|
||||
row.Ok.ShouldBeTrue(row.Error);
|
||||
var reimported = row.ImportRow!.Tag.TagConfig;
|
||||
// The legacy string contradicts the keys under every family, so the import drops it — reported, not silent.
|
||||
reimported.ShouldNotContain("addressString");
|
||||
row.TypedOverrodeFallback.ShouldNotBeEmpty();
|
||||
|
||||
foreach (var json in new[] { saved, reimported })
|
||||
foreach (var family in new[] { ModbusFamily.Generic, ModbusFamily.DL205, ModbusFamily.MELSEC })
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.InputRegisters, family.ToString());
|
||||
def.Address.ShouldBe((ushort)5, family.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Review r2 n5: a typed CSV address cell on a keyless row carrying a (stale) marker is an authored
|
||||
/// address — it is applied and drops the string it contradicts. The unmarked string-only case is
|
||||
/// <c>ModbusAddressAuthorityAgreementTests.A_typed_csv_address_cell_on_an_unmarked_string_only_row_…</c> (r3 m3).</summary>
|
||||
[Fact]
|
||||
public void A_typed_csv_address_cell_on_a_marker_bearing_keyless_row_is_applied_and_drops_the_string()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var merged = map.ApplyTypedColumns("""{"addressString":"40011:F","addressResolved":true}""",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal) { ["Region"] = "InputRegisters", ["Address"] = "7" });
|
||||
|
||||
merged.ShouldNotContain("addressString");
|
||||
merged.ShouldNotContain("addressResolved");
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.InputRegisters);
|
||||
def.Address.ShouldBe((ushort)7);
|
||||
}
|
||||
|
||||
/// <summary>Review r2 n6: authoring is never stricter than the driver — a string-only tag's ignored dataType /
|
||||
/// byteOrder keys are not judged (positive control: the same junk on a classic tag is refused).</summary>
|
||||
[Fact]
|
||||
public void Validate_does_not_judge_keys_the_driver_ignores_on_a_string_only_tag()
|
||||
{
|
||||
ModbusTagConfigModel.FromJson("""{"addressString":"40011:F","dataType":"Intt16","byteOrder":"Nope"}""").Validate().ShouldBeNull();
|
||||
ModbusTagConfigModel.FromJson("""{"region":"HoldingRegisters","address":1,"dataType":"Intt16"}""").Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hand_edit_after_resolving_drops_the_address_string()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.ResolveAddress("40011:F", ModbusFamily.Generic, MelsecFamily.Q_L_iQR).ShouldBeNull();
|
||||
m.ClearAddressString();
|
||||
m.Address = 50;
|
||||
var json = m.ToJson();
|
||||
json.ShouldNotContain("addressString");
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)50);
|
||||
}
|
||||
}
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb;
|
||||
|
||||
/// <summary>
|
||||
/// Review R05:4 — the scripted-alarm comment trail moved off the condition row into its own
|
||||
/// append-only <c>alarm_condition_comment</c> table. This proves the move kept the property that
|
||||
/// mattered: the trail still reaches the redundant pair peer, over a real loopback h2c transport
|
||||
/// through the real fail-closed interceptor, with both nodes initialised by the production
|
||||
/// <c>LocalDbSetup.OnReady</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// State is written and read through the production <see cref="LocalDbAlarmConditionStateStore"/>
|
||||
/// on each node, so the ordinal keying, the incremental append and the load-side merge under
|
||||
/// test are the ones production runs.
|
||||
/// </remarks>
|
||||
[Collection("LocalDbPairConvergence")]
|
||||
public sealed class AlarmConditionCommentConvergenceTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
|
||||
|
||||
private static LocalDbAlarmConditionStateStore Store(ILocalDb db) =>
|
||||
new(db, NullLogger<LocalDbAlarmConditionStateStore>.Instance);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static async Task<int> CommentCountAsync(ILocalDb db, string alarmId) =>
|
||||
(await Store(db).LoadAsync(alarmId, Ct))?.Comments.Count ?? -1;
|
||||
|
||||
[Fact]
|
||||
public async Task CommentsWrittenOnA_ArriveOnB_AndBsAppendArrivesBackOnA()
|
||||
{
|
||||
await using var pair = new LocalDbPairHarness();
|
||||
await pair.StartAsync();
|
||||
|
||||
var storeA = Store(pair.A);
|
||||
var state = AlarmConditionState.Fresh("eq/alarm-1", T0) with
|
||||
{
|
||||
Active = AlarmActiveState.Active,
|
||||
Acked = AlarmAckedState.Unacknowledged,
|
||||
};
|
||||
state = Part9StateMachine.ApplyAddComment(state, "jane", "first look", T0.AddMinutes(1)).State;
|
||||
state = Part9StateMachine.ApplyAcknowledge(state, "bob", "on it", T0.AddMinutes(2)).State;
|
||||
await storeA.SaveAsync(state, Ct);
|
||||
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.B, "eq/alarm-1") == 2,
|
||||
"node B to hold node A's two comment-trail entries");
|
||||
|
||||
var onB = await Store(pair.B).LoadAsync("eq/alarm-1", Ct);
|
||||
onB.ShouldNotBeNull();
|
||||
onB.Comments.ShouldBe(state.Comments);
|
||||
onB.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
|
||||
// The reverse direction, as an incremental append: B adds one entry to the trail it loaded,
|
||||
// and only that entry has to travel for A to see the full trail.
|
||||
var appended = Part9StateMachine.ApplyAddComment(onB, "amy", "from B", T0.AddMinutes(3)).State;
|
||||
await Store(pair.B).SaveAsync(appended, Ct);
|
||||
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/alarm-1") == 3,
|
||||
"node A to receive the entry node B appended");
|
||||
|
||||
(await storeA.LoadAsync("eq/alarm-1", Ct))!.Comments.ShouldBe(appended.Comments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failover_AfterAMissedCommand_BothTheMissedEntryAndTheSurvivorsNextEntry_ReachBothPeers()
|
||||
{
|
||||
// Review I-1, end to end over the real pair:
|
||||
// 1. a shared trail on both nodes;
|
||||
// 2. an AddComment reaches only node A (B's command delivery failed) — A's entry replicates
|
||||
// into B's table, but B's engine never applied it, so B's IN-MEMORY trail is one short;
|
||||
// 3. A goes away (link down) and the operator acks on the survivor B — B's in-memory Ack
|
||||
// collides with A's entry at the same ordinal;
|
||||
// 4. the pair rejoins.
|
||||
// Both entries must survive on both peers. Before the fix B's Ack was silently dropped.
|
||||
await using var pair = new LocalDbPairHarness();
|
||||
await pair.StartAsync();
|
||||
|
||||
var shared = AlarmConditionState.Fresh("eq/alarm-fo", T0) with
|
||||
{
|
||||
Active = AlarmActiveState.Active,
|
||||
Acked = AlarmAckedState.Unacknowledged,
|
||||
};
|
||||
for (var i = 0; i < 3; i++)
|
||||
shared = Part9StateMachine.ApplyAddComment(shared, "jane", $"note {i}", T0.AddMinutes(i)).State;
|
||||
await Store(pair.A).SaveAsync(shared, Ct);
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.B, "eq/alarm-fo") == 3,
|
||||
"node B to hold the shared trail");
|
||||
|
||||
var onlyOnA = Part9StateMachine.ApplyAddComment(shared, "jane", "seen on A only", T0.AddHours(1)).State;
|
||||
await Store(pair.A).SaveAsync(onlyOnA, Ct);
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.B, "eq/alarm-fo") == 4,
|
||||
"node A's entry to replicate into node B's table");
|
||||
|
||||
await pair.StopPassiveAsync();
|
||||
|
||||
// B's engine still holds the 3-entry trail it applied; the operator acks there.
|
||||
var ackOnB = Part9StateMachine.ApplyAcknowledge(shared, "bob", "acked on B", T0.AddHours(2)).State;
|
||||
await Store(pair.B).SaveAsync(ackOnB, Ct);
|
||||
|
||||
await pair.RestartPairAsync();
|
||||
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/alarm-fo") == 5
|
||||
&& await CommentCountAsync(pair.B, "eq/alarm-fo") == 5,
|
||||
"both nodes to hold the shared trail + A's missed-by-B entry + B's ack");
|
||||
|
||||
var onA = (await Store(pair.A).LoadAsync("eq/alarm-fo", Ct))!;
|
||||
var onB = (await Store(pair.B).LoadAsync("eq/alarm-fo", Ct))!;
|
||||
onA.Comments.ShouldBe(onB.Comments);
|
||||
onA.Comments[3].ShouldBe(onlyOnA.Comments[3]);
|
||||
onA.Comments[4].ShouldBe(ackOnB.Comments[3]);
|
||||
|
||||
// Held, not merely reached: nothing late duplicates either entry.
|
||||
(await LocalDbPairHarness.HeldWithinAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/alarm-fo") != 5
|
||||
|| await CommentCountAsync(pair.B, "eq/alarm-fo") != 5,
|
||||
TimeSpan.FromSeconds(2)))
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothNodesRecordingTheSameEntry_ConvergeToOneEntry_NotTwo()
|
||||
{
|
||||
// Both pair nodes run the engine and both apply every operator command, each stamping the
|
||||
// entry with its own clock. Ordinal keying is what collapses the two copies to one entry;
|
||||
// a content-derived key would leave the trail doubled on both nodes.
|
||||
await using var pair = new LocalDbPairHarness();
|
||||
await pair.StartAsync();
|
||||
|
||||
var fresh = AlarmConditionState.Fresh("eq/alarm-2", T0);
|
||||
var onA = Part9StateMachine.ApplyAddComment(fresh, "jane", "checked", T0.AddSeconds(1)).State;
|
||||
var onB = Part9StateMachine.ApplyAddComment(fresh, "jane", "checked", T0.AddSeconds(1).AddMilliseconds(7)).State;
|
||||
await Store(pair.A).SaveAsync(onA, Ct);
|
||||
await Store(pair.B).SaveAsync(onB, Ct);
|
||||
|
||||
// Positive control: a DISTINCT second alarm written only on B. Without it "one entry on
|
||||
// both" would also hold for two databases that never replicated at all.
|
||||
var onlyOnB = Part9StateMachine.ApplyAddComment(
|
||||
AlarmConditionState.Fresh("eq/only-on-b", T0), "amy", "b only", T0.AddSeconds(2)).State;
|
||||
await Store(pair.B).SaveAsync(onlyOnB, Ct);
|
||||
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/only-on-b") == 1,
|
||||
"node A to receive node B's distinct alarm");
|
||||
|
||||
// LWW picks ONE node's copy and both nodes end up holding that same copy.
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () =>
|
||||
{
|
||||
var a = await Store(pair.A).LoadAsync("eq/alarm-2", Ct);
|
||||
var b = await Store(pair.B).LoadAsync("eq/alarm-2", Ct);
|
||||
return a is not null && b is not null
|
||||
&& a.Comments.Count == 1 && b.Comments.Count == 1
|
||||
&& a.Comments[0] == b.Comments[0];
|
||||
},
|
||||
"both nodes to agree on a single copy of the shared entry");
|
||||
|
||||
// Held, not merely reached: nothing late may double the trail on either node.
|
||||
(await LocalDbPairHarness.HeldWithinAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/alarm-2") != 1
|
||||
|| await CommentCountAsync(pair.B, "eq/alarm-2") != 1,
|
||||
TimeSpan.FromSeconds(2)))
|
||||
.ShouldBeFalse("the shared entry must stay one entry on both nodes, never two");
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public void OnReady_WhenReplicationIsConfigured_RegistersExactlyTheFourReplicatedTables()
|
||||
public void OnReady_WhenReplicationIsConfigured_RegistersExactlyTheFiveReplicatedTables()
|
||||
{
|
||||
// Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call
|
||||
// (that table then never replicates). "No more" catches an accidental registration —
|
||||
@@ -142,11 +142,11 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
var db = Boot(InitiatorReplication);
|
||||
|
||||
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnReady_WhenOnlyTheSyncApiKeyIsSet_StillRegistersTheFourTables()
|
||||
public void OnReady_WhenOnlyTheSyncApiKeyIsSet_StillRegistersTheFiveTables()
|
||||
{
|
||||
// The passive half of a pair carries the key and nothing else. If the predicate were
|
||||
// "PeerAddress is set", this node would install no capture triggers and its OWN writes
|
||||
@@ -155,7 +155,7 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
var db = Boot(PassiveReplication);
|
||||
|
||||
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -216,6 +216,56 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
db.ReplicatedTables["alarm_condition_state"].PkColumns.ShouldBe(["scripted_alarm_id"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmConditionComment_PkIsTheAlarmIdPlusTheTrailOrdinal()
|
||||
{
|
||||
// One row per trail entry. Keyed by ordinal (not content) so the pair's two copies of the
|
||||
// same logical entry — each stamped by its own node's clock — collapse to one row under LWW.
|
||||
var db = Boot(InitiatorReplication);
|
||||
|
||||
db.ReplicatedTables["alarm_condition_comment"].PkColumns.ShouldBe(["scripted_alarm_id", "seq"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnReady_MovesAnOldShapeInlineCommentTrail_AfterRegistration_SoTheMoveReplicates()
|
||||
{
|
||||
// A file written by the pre-split store: the trail is inline on the condition row.
|
||||
var first = Boot(InitiatorReplication);
|
||||
await first.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
comments_json, updated_at_utc)
|
||||
VALUES ('eq/alarm-legacy', 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved',
|
||||
'[{"timestampUtc":"2026-06-10T10:00:00Z","user":"jane","kind":"Acknowledge","text":"a"},{"timestampUtc":"2026-06-10T10:05:00Z","user":"bob","kind":"Confirm","text":"b"}]',
|
||||
'2026-06-10T10:05:00.0000000Z')
|
||||
""",
|
||||
parameters: null,
|
||||
TestContext.Current.CancellationToken);
|
||||
// Positive control: before the restart the trail is inline and the comment table is empty.
|
||||
(await ScalarAsync(first, "SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(0);
|
||||
|
||||
// The upgrade restart.
|
||||
var db = Boot(InitiatorReplication);
|
||||
|
||||
(await ScalarAsync(db, "SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'eq/alarm-legacy'"))
|
||||
.ShouldBe(2);
|
||||
// The condition row itself is NOT rewritten (review m1): a migration UPDATE would re-stamp it
|
||||
// with a fresh HLC and could beat a newer peer state under LWW. Only the seeding INSERT is in
|
||||
// the oplog for it; the alarm's next save clears the inline column.
|
||||
(await ScalarAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_condition_state'"))
|
||||
.ShouldBe(1);
|
||||
// Captured: the migration ran after RegisterReplicated, so the moved entries reach the peer.
|
||||
(await ScalarAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_condition_comment'"))
|
||||
.ShouldBe(2);
|
||||
|
||||
// Idempotent: a further boot writes nothing more.
|
||||
var third = Boot(InitiatorReplication);
|
||||
(await ScalarAsync(third, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_condition_comment'"))
|
||||
.ShouldBe(2);
|
||||
(await ScalarAsync(third, "SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmSfEventRows_EnterTheOplog()
|
||||
{
|
||||
@@ -296,7 +346,7 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
var replicated = Boot(InitiatorReplication);
|
||||
await InsertPointerAsync(replicated, "SITE-A");
|
||||
(await ScalarAsync(replicated, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger'"))
|
||||
.ShouldBe(12);
|
||||
.ShouldBe(15);
|
||||
|
||||
var unreplicated = Boot();
|
||||
|
||||
@@ -350,6 +400,45 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
sink.Events.Count(e => e.Level == LogEventLevel.Information).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnReady_WarnsOncePerAlarmWhoseInlineCommentTrailIsUnreadable()
|
||||
{
|
||||
// Review m2: an unmigratable history must be visible, not skipped silently on every boot.
|
||||
var first = Boot();
|
||||
await first.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
comments_json, updated_at_utc)
|
||||
VALUES (@Id, 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved', @Json, '2026-06-10T10:05:00.0000000Z')
|
||||
""",
|
||||
new { Id = "eq/alarm-bad", Json = "{not json" },
|
||||
TestContext.Current.CancellationToken);
|
||||
await first.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
comments_json, updated_at_utc)
|
||||
VALUES (@Id, 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved', @Json, '2026-06-10T10:05:00.0000000Z')
|
||||
""",
|
||||
new
|
||||
{
|
||||
Id = "eq/alarm-good",
|
||||
Json = """[{"timestampUtc":"2026-06-10T10:00:00Z","user":"jane","kind":"AddComment","text":"a"}]""",
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var sink = new CollectingSink();
|
||||
Boot(replication: null, logger: BuildLogger(sink));
|
||||
|
||||
var warnings = sink.Events.Where(e => e.Level == LogEventLevel.Warning).ToList();
|
||||
warnings.Count.ShouldBe(1);
|
||||
warnings[0].RenderMessage().ShouldContain("eq/alarm-bad");
|
||||
// Positive control: the readable row DID move, and said so.
|
||||
sink.Events.Count(e => e.Level == LogEventLevel.Information && e.RenderMessage().Contains("copied"))
|
||||
.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisablingReplication_SaysNothingWhenThereWasNothingToClean()
|
||||
{
|
||||
@@ -365,13 +454,13 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
[Fact]
|
||||
public void AFailureMidwayThroughRegistration_FailsBootLoudly_AndTheRetriedBootConverges()
|
||||
{
|
||||
// OnReady flips four tables in a loop with no try/catch, so a failure on the third leaves
|
||||
// two registered and two not. That is the RIGHT behaviour and this pins both halves of why:
|
||||
// OnReady flips five tables in a loop with no try/catch, so a failure on the third leaves
|
||||
// two registered and three not. That is the RIGHT behaviour and this pins both halves of why:
|
||||
// the throw propagates out of the AddZbLocalDb singleton factory and fails host start
|
||||
// (a node half-capturing is a node whose replication is silently partial), and because both
|
||||
// library APIs are idempotent the retried boot completes the flip rather than compounding it.
|
||||
//
|
||||
// Swallowing the throw would leave the node up, serving, and replicating two of four tables
|
||||
// Swallowing the throw would leave the node up, serving, and replicating two of five tables
|
||||
// with nothing in the log to say so.
|
||||
var real = Boot(InitiatorReplication);
|
||||
|
||||
@@ -387,11 +476,11 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
// pinning some unrelated throw, and the "partial flip" it claims to model never happened.
|
||||
faulty.Calls.ShouldBe(3);
|
||||
|
||||
// The retry: same file, no wrapper. Idempotent re-registration converges on all four.
|
||||
// The retry: same file, no wrapper. Idempotent re-registration converges on all five.
|
||||
LocalDbSetup.OnReady(real, configuration);
|
||||
|
||||
real.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user