Merge commit 'd1f24cf6' into remed/lane-K

This commit is contained in:
Joseph Doherty
2026-10-22 17:13:56 -04:00
105 changed files with 9559 additions and 831 deletions
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`
+16 -4
View File
@@ -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
View File
@@ -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:
+18
View File
@@ -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.
+14
View File
@@ -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
+52 -4
View File
@@ -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>&lt;file&gt;&lt;number&gt;:&lt;word&gt;</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 &gt;= 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 &gt;= 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&lt;int&gt;</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 &gt; 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;
}
@@ -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 &gt; 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
@@ -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,
@@ -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);
}
@@ -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; }
}
}
@@ -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&lt;int&gt;</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.
@@ -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()
@@ -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&lt;int&gt;</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"));
}
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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()
{
@@ -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();
}
}
@@ -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]
@@ -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)]));
}
@@ -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.
@@ -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);
@@ -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);
}
}
@@ -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