Merge master (75b7effb) into remed/lane-G1

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:52:43 -04:00
55 changed files with 4043 additions and 318 deletions
+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