Merge hw4-udtwrite (#592/#611)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	docs/drivers/AbCip.md
This commit is contained in:
Joseph Doherty
2026-10-09 04:32:03 -04:00
12 changed files with 970 additions and 25 deletions
+55 -3
View File
@@ -245,6 +245,9 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- **libplctag cannot be deleted yet.** Scalar native writes are proven on a CompactLogix (2026-10-09); what remains is #567
(fragmented wide-struct writes), array/STRING/UDT-member writes on hardware, and AbLegacy native (simulator-only, no PCCC hardware).
- 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.
@@ -428,8 +431,57 @@ 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 is never written, because writing it would rewrite every neighbouring
bit. On **native** the runtime judges it from the CIP type, exactly as its read does: a packed BOOL-array
DWORD (`0xD3`) is `BadNotSupported`, a BOOL authored over a mistyped integer member is `BadTypeMismatch`.
**libplctag** exposes only the size, cannot tell the two apart, and refuses both `BadNotSupported`. 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 as before — same write path, now width-checked
(a mistyped declared member is refused where libplctag used to write it).
### 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
@@ -442,7 +494,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.