feat(abcip): #611 discovered UDT member writes + whole-UDT refusal by decision
Item 2 — controller-discovered UDT members are writable on both transports once browse-committed and authored ReadWrite (writability is the operator's: the commit lands AccessLevel Read with no writable key). The write names the member (one CIP symbolic segment per member), its own type, its own bytes. BIT members are supported: Z-35162 (read-only probe) exposes a UDT BIT member as a 1-byte 0xC1 BOOL, so the controller applies the bit and the hidden host's other bits are never in the request. - AbCipDriver: pre-write width check on BOTH transports (libplctag patched part of its read buffer and sent it all): BadTypeMismatch, Bool over a wider value BadNotSupported; nothing sent, handle kept, health untouched, warn once. - A runtime NotSupportedException is a refusal: no longer latches Degraded. Item 3 — whole-UDT writes refused BadNotSupported before any runtime exists, by decision (a structure write overwrites hidden hosts + padding), documented. ab_server patch 0003: serve member paths from dotted flat tags (UdtSeg.*), and accept libplctag's pad byte on 1-byte writes (stock ab_server refused every libplctag BOOL/SINT write with 0x03). New AbCipUdtMemberWriteTests (both transports), AbCipDiscoveredMemberWriteTests, native request-byte theory, and a browse-commit mapper pin. Docs: AbCip.md Member writes / Whole-UDT writes / Known gaps, fixture docs, CLAUDE.md AB section. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+52
-3
@@ -242,6 +242,9 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
(2026-10-08) and nothing else. Results:
|
||||
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
|
||||
- **libplctag cannot be deleted** until a bench controller allows write verification.
|
||||
- Discovered UDT **member writes** (#611) are likewise `ab_server`-only on both transports; the
|
||||
BIT-member argument rests on a read-only hardware observation (the member reads as a 1-byte `0xC1`
|
||||
BOOL). See [Member writes](#member-writes) for the candidate gate tag.
|
||||
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. Production
|
||||
presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a
|
||||
bench controller exists.
|
||||
@@ -374,8 +377,54 @@ Reading the UDT container tag itself (without a member suffix) returns
|
||||
|
||||
### Member writes
|
||||
|
||||
UDT member **writes** discovered via controller browse are **deferred** in this release.
|
||||
Pre-declared `Members` with `Writable: true` retain full read/write support as before.
|
||||
**Discovered UDT members are writable, on both transports (Gitea #611).** There is no separate
|
||||
"discovered member" write path to enable — under v3 a browsed member leaf becomes an ordinary raw tag
|
||||
when it is browse-committed in `/raw`, and that tag writes like any other atomic tag:
|
||||
|
||||
- **Writability is the operator's, never the browse's.** Browse-commit (`RawBrowseCommitMapper`) writes
|
||||
`tagPath` = the member path (`Seg.RampTime`, nested `Seg.Sub.Code`, program-scoped
|
||||
`Program:Main.Seg.ID`) and `dataType` = the template's atomic type, at **AccessLevel `Read`** and with no
|
||||
`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`).
|
||||
- **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`
|
||||
and end to end on ab_server by `AbCipUdtMemberWriteTests` (both transports, value read back through a fresh
|
||||
driver).
|
||||
- **BIT members** (a UDT `BIT`, which Logix packs into a hidden `ZZZZZZZZZZ…` SINT host) **are supported.**
|
||||
A controller exposes the member as a standalone `BOOL`: on Z-35162, `…_LastSegment.Enable` reads type
|
||||
`0xC1`, **one byte**, while the hidden host at the same offset holds that bit (read-only probe, 2026-10-09).
|
||||
So a `BOOL` write to `Seg.Enable` is a symbolic write of one BOOL byte to the member, and the controller —
|
||||
not the driver — applies the bit; the host's other bits are never in the request. No driver-side
|
||||
read-modify-write is involved (that is reserved for `Tag.N` bit selectors on an integer, below), and the
|
||||
hidden host itself is never browsed, so it cannot be authored by browse-commit.
|
||||
- **Type checking, before anything is sent, on both transports.** The driver compares the authored type
|
||||
with the bytes the controller holds at the path (the runtime's last read) and refuses a mismatch without
|
||||
writing: a narrower/wider atomic type is `BadTypeMismatch` (libplctag would otherwise patch part of its read
|
||||
buffer and send the whole buffer back — e.g. an `Int` over a DINT member wrote four bytes with a stale high
|
||||
word); a `Bool` over a wider value (a packed BOOL-array DWORD, a mistyped integer) is `BadNotSupported`,
|
||||
because writing it would rewrite every neighbouring bit. Native additionally refuses a same-width
|
||||
mismatch (REAL vs DINT) from the CIP type code — see [Native transport behaviour](#native-transport-behaviour).
|
||||
A refusal keeps the handle, warns once per tag and **does not touch driver health**.
|
||||
- **Not verified on a real controller.** Production presses are read-only, so member writes are proven on
|
||||
`ab_server` only (Docker patch 0003 serves member paths). A candidate for a future *approved* write gate:
|
||||
Z-35162 controller tag `_EmptyTemperatureSegment` (`UDT_TemperatureProfile_Segment`), which no logic
|
||||
references — its members (`ID`, `Enable`, `RampTime`, …) are all zero and resolve as described above.
|
||||
|
||||
Pre-declared `Members` with `Writable: true` keep working exactly as before (same write path).
|
||||
|
||||
### Whole-UDT writes — refused, by decision
|
||||
|
||||
A write to a UDT **container** tag (`dataType: Structure`) is refused `BadNotSupported` before any runtime is
|
||||
created or anything is sent, with no health change and one Warning per tag. This is a **decision, not a
|
||||
deferral** (#611): a whole-structure write overwrites every member of the controller's instance — hidden BOOL
|
||||
hosts and alignment padding included — with whatever bytes the client supplied, and the member writes above
|
||||
cover the use case without that blast radius. A runtime-level `NotSupportedException` (the libplctag and
|
||||
native encoders both refuse `Structure`) is likewise a refusal: `BadNotSupported`, handle kept, health untouched
|
||||
(it used to latch `Degraded`).
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -388,7 +437,7 @@ UDT-typed tags found in the walk.
|
||||
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
|
||||
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
|
||||
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
|
||||
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)). The width-mismatch refusal is pinned by `AbCipTypeMismatchTests.A_width_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once`: `BadTypeMismatch`, handle kept, no `Degraded`, one Warning per tag. *Benign edge (#585):* a driver left `Degraded` by an earlier outage whose only remaining tags are mis-typed gets one extra comms-loss-watchdog reconnect ~90 s later before settling, because a type refusal deliberately writes no health and so never clears the stale `Degraded`.
|
||||
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)). The width-mismatch refusal is pinned by `AbCipTypeMismatchTests.A_width_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once`: `BadTypeMismatch`, handle kept, no `Degraded`, one Warning per tag. The same width check runs **before every scalar write, on both transports** (#611): a mismatch is refused `BadTypeMismatch` (a `Bool` over a wider value `BadNotSupported`) with nothing sent — see [Member writes](#member-writes). *Benign edge (#585):* a driver left `Degraded` by an earlier outage whose only remaining tags are mis-typed gets one extra comms-loss-watchdog reconnect ~90 s later before settling, because a type refusal deliberately writes no health and so never clears the stale `Degraded`.
|
||||
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
|
||||
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
|
||||
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
|
||||
|
||||
@@ -43,16 +43,20 @@ libplctag passed on all seven profiles; on `micro800` libplctag failed on the em
|
||||
smoke test passes on both transports. Evidence:
|
||||
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
|
||||
|
||||
- Write-capable tests (the Logix Emulate ALMD test) require `AB_SERVER_ALLOW_WRITES=1` and refuse while
|
||||
`AB_DIFF_ENDPOINT` is set.
|
||||
- Write-capable tests (the Logix Emulate ALMD test, and `AbCipUdtMemberWriteTests` — #611 discovered-member
|
||||
writes, both transports, against the `UdtSeg.*` member tags of the `controllogix` profile) require
|
||||
`AB_SERVER_ALLOW_WRITES=1` and refuse while `AB_DIFF_ENDPOINT` is set.
|
||||
- **Differential harness** (`AbCip.IntegrationTests/Differential`, `LiveIntegration`) compares libplctag and
|
||||
native on browse, template and read parity against one endpoint. It is read-only by construction, aborts on
|
||||
the first connection failure, never retries, and caps the native phase at one Forward Open. Env:
|
||||
`AB_DIFF_ENDPOINT`, `AB_DIFF_PATH`, `AB_DIFF_FAMILY`, `AB_DIFF_TAGS`, `AB_DIFF_MAX_TAGS` (at most 500),
|
||||
`AB_DIFF_MAX_TEMPLATES`, `AB_DIFF_PACE_MS` (default 20), `AB_DIFF_COUNT_OPENS`. Always run with
|
||||
`--filter "FullyQualifiedName~Differential"` and `AB_SERVER_ENDPOINT=127.0.0.1:1`.
|
||||
- ab_server serves no UDTs and refuses 1-byte writes, so template parity is vacuous there and BOOL/SINT
|
||||
parity is weak evidence. Native writes remain simulator-only.
|
||||
- ab_server serves no UDTs, so template parity is vacuous there. Docker patch 0003 serves a UDT **member
|
||||
path** from a flat tag named by that dotted path (`UdtSeg.ID`, `UdtSeg.Sub.Code`, `UdtSeg.Points[2]`) — the
|
||||
wire of a member read/write, not a structure — and accepts the pad byte libplctag adds to a 1-byte write,
|
||||
which stock ab_server refused (`0x03`) for every libplctag BOOL/SINT write. Native writes remain
|
||||
simulator-only.
|
||||
- **Golden-frame capture:** `scripts/ab-capture/capture.sh` (capture client in `scripts/ab-capture/client`);
|
||||
fixtures live in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/` with a README.
|
||||
|
||||
@@ -76,7 +80,9 @@ ControlLogix profile `Notes`: *"ab_server lacks full UDT emulation."*
|
||||
|
||||
Unverified against `ab_server`:
|
||||
|
||||
- PR 6 structured read/write (`AbCipStructureMember` fan-out)
|
||||
- PR 6 structured read/write (`AbCipStructureMember` fan-out) — except the **member-path wire** of a
|
||||
write, which patch 0003 serves (`AbCipUdtMemberWriteTests`, #611); offsets and BIT-member semantics are
|
||||
not emulated
|
||||
- #179 Template Object shape reader (`CipTemplateObjectDecoder` + `FetchUdtShapeAsync`)
|
||||
- #194 whole-UDT read optimization (`AbCipUdtReadPlanner` +
|
||||
`AbCipUdtMemberLayout` + the `ReadGroupAsync` path in `AbCipDriver`)
|
||||
|
||||
Reference in New Issue
Block a user