fix(arrays): Modbus row honours the FC16/FC03 protocol ceilings, the RMW read cap and the address-space end

Review r0 of Integration-W3:
- I1: the effective FC16 cap is min(configured maxRegistersPerWrite, 123). The
  driver trusts a larger configured cap and sends an over-ceiling FC16 (past 127
  registers its byte-count byte wraps), which every conformant device refuses, so
  such an array is never advertised writable.
- I2: a BitInRegister[] write reads its span first in one unchunked FC03, so it is
  also bounded by min(configured maxRegistersPerRead, 125) (absent/0 = 125).
- m2: every span (registers and coils) must end at or before address 65535.
- m1: the String-node wording says it converts only when the text parses, which
  the server cannot promise (DateTime never converts).
- m4: the parity device now enforces FC01/02 2000, FC03/04 125 (or a smaller
  device read limit), FC15 1968, FC16 123, FC23 121/125, the FC15/FC16 byte-count
  field (exception 03) and the address-space end (exception 02).

Tests: parity rows for each ceiling/cap/span boundary against the real write
path, and Core.Abstractions row tests. The configured-cap test that asserted a
cap of 200 lifts the ceiling now asserts the opposite.

Refs #741

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:32:57 -04:00
parent 8699614d2f
commit cf62007678
7 changed files with 212 additions and 54 deletions
+10 -4
View File
@@ -301,16 +301,22 @@ only for what `ModbusDriver.WriteAsync` encodes end to end
not chunk). Any `dataType` — a bit region is coerced to Bool.
- **HoldingRegisters:** one FC16 whose register count fits
`maxRegistersPerWrite` (default 123; the smallest value on the driver or the
tag's device wins). Registers per element: 1 / 2 / 4 for the numeric types,
tag's device wins), and never more than FC16's protocol ceiling of 123 — a
larger configured cap does not lift it, because a conformant device refuses
the longer FC16. Registers per element: 1 / 2 / 4 for the numeric types,
`(stringLength+1)/2` for `String` (each element nul-padded to that width), 1
for `BitInRegister` (the span is read with one FC03, the addressed bit of each
register is set or cleared, and the span is written back with one FC16 —
the other 15 bits are preserved; `bitIndex` 0–15, the span must end at or
before register 65535). FC23 (`useFC23ForMultiRegisterWrites`) is opt-in,
the other 15 bits are preserved; `bitIndex` 0–15). Because that FC03 is one
unchunked read of the whole span, a BitInRegister array must also fit
`maxRegistersPerRead` (default and protocol ceiling 125).
- **Every span** — registers or coils — must end at or before address 65535
(a conformant device answers exception 02 past it). FC23 (`useFC23ForMultiRegisterWrites`) is opt-in,
capped at 121 registers and falls back to FC16 above that, so it never
changes which arrays are writable.
- **Never writable:** DiscreteInputs / InputRegisters, Bool in a register
region, a `String` with no `stringLength`, a `bitIndex` above 15, a missing
or zero `arrayLength`, and a `String` / `DateTime` node over a numeric or bit
element (such an element never converts). Elements otherwise convert, so a
element (a DateTime never converts; a String converts only when its text
happens to parse, which the server cannot promise). Elements otherwise convert, so a
ScadaBridge `int[]` to an `Int16` node is writable.