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
@@ -27,10 +27,14 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// 1968 coils, with ANY <c>dataType</c> — the resolver coerces a bit region to Bool and never refuses it.
/// <c>region: HoldingRegisters</c> (FC16), never Bool: a fixed-width numeric <c>dataType</c>,
/// <c>String</c> (<c>(stringLength+1)/2</c> registers per element, <c>stringLength &gt;= 1</c>) or
/// <c>BitInRegister</c> (one register per element, read-modify-written; <c>bitIndex</c> 0–15, the span
/// ending at or before register 65535), whose <c>arrayLength × registers-per-element</c> fits the
/// driver's <c>maxRegistersPerWrite</c> (default 123 — the driver refuses rather than chunks a longer
/// FC16). FC23 is opt-in, capped lower and falls back to FC16, so it never changes the answer. Input
/// <c>BitInRegister</c> (one register per element; <c>bitIndex</c> 0–15), whose
/// <c>arrayLength × registers-per-element</c> fits the driver's <c>maxRegistersPerWrite</c> (default 123 —
/// the driver refuses rather than chunks a longer FC16) and never more than FC16's protocol ceiling of 123,
/// however high the cap is configured (a conformant device refuses a longer FC16, so that write always
/// fails). A BitInRegister array is read-modify-written — one unchunked FC03 of the span, then one FC16 —
/// so it is also bounded by <c>maxRegistersPerRead</c> (default and protocol ceiling 125). Every span
/// (registers or coils) ends at or before address 65535. FC23 is opt-in, capped lower and falls back to
/// FC16, so it never changes the answer. Input
/// regions are read-only. A string-only tag (an <c>addressString</c> with no keys) is resolved by the
/// driver with ITS family through a parser this assembly cannot reference, so it cannot be proven
/// writable here and stays read-only.</item>
@@ -72,9 +76,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// 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>); Modbus <c>Convert</c>s each element to
/// the register / coil type the same way. 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 (Modbus — over a numeric or bit element; a Modbus String element takes any node type — AbCip,
/// AbLegacy, TwinCAT — TwinCAT keeps
/// out only the node types whose elements the server cannot promise to convert: a <c>DateTime</c> node over a
/// numeric element (it never converts) or a <c>String</c> node over one (it converts only when the text happens
/// to parse, <c>"1"</c> / <c>"true"</c>) — on Modbus over a numeric or bit element, and a Modbus String element
/// takes any node type; likewise on AbCip, AbLegacy and 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
@@ -85,13 +90,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
public static class DriverArrayWriteSupport
{
/// <summary>The Modbus driver's FC16 write cap when <c>maxRegistersPerWrite</c> is absent or <c>0</c>
/// (<c>ModbusDriver</c> resolves it the same way).</summary>
/// (<c>ModbusDriver</c> resolves it the same way). A configured value only ever LOWERS it — see
/// <see cref="ModbusProtocolMaxRegistersPerWrite"/>.</summary>
public const int ModbusDefaultMaxRegistersPerWrite = 123;
/// <summary>Modbus FC15's protocol ceiling on coils per request (0x07B0). The driver sends one FC15 for the
/// whole array without chunking, so a longer coil array is refused by every conformant device.</summary>
public const int ModbusMaxCoilsPerWrite = 1968;
/// <summary>Modbus FC16's protocol ceiling on registers per request (0x7B). A configured
/// <c>maxRegistersPerWrite</c> above it never lifts it: a conformant device refuses a longer FC16.</summary>
public const int ModbusProtocolMaxRegistersPerWrite = 123;
/// <summary>Modbus FC03's protocol ceiling on registers per request (0x7D) — the bound on a BitInRegister[]
/// write's read-modify-write read, together with a configured <c>maxRegistersPerRead</c>.</summary>
public const int ModbusProtocolMaxRegistersPerRead = 125;
/// <summary>The last Modbus address (registers and coils alike): a span ending past it is refused by every
/// conformant device (exception 02).</summary>
private const long ModbusLastAddress = ushort.MaxValue;
// Element type → registers per element (ModbusDriver.RegisterCount).
private static readonly Dictionary<string, int> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
@@ -213,11 +231,14 @@ public static class DriverArrayWriteSupport
// `stringByteOrder` must be a member name (a non-string token is read as absent ⇒ the default).
// - Coils: the resolver COERCES any type to Bool (never refuses); one FC15 of at most 1968 coils.
// - HoldingRegisters: never Bool; fixed-width numeric, String ((stringLength+1)/2 registers, stringLength >= 1) or
// BitInRegister (one register each, bitIndex 0–15, span ending at or before 65535); arrayLength × registers per
// element within maxRegistersPerWrite (the driver refuses rather than chunks; FC23 is opt-in and capped lower,
// falling back to FC16, so it never changes the answer).
// - Elements CONVERT, so the node type is not exact-matched; a String / DateTime node over a numeric or bit element
// never converts and is left out. A String element Convert.ToString()s any node type.
// BitInRegister (one register each, bitIndex 0–15); arrayLength × registers per element within
// maxRegistersPerWrite clamped to FC16's 123 (the driver refuses rather than chunks; FC23 is opt-in and capped
// lower, falling back to FC16, so it never changes the answer). BitInRegister[] also READS its span first in one
// unchunked FC03, so it is bounded by maxRegistersPerRead (clamped to FC03's 125) too.
// - Every span ends at or before address 65535 (a conformant device refuses one past it with exception 02).
// - Elements CONVERT, so the node type is not exact-matched. A DateTime node over a numeric or bit element never
// converts; a String node over one converts only when the text happens to parse ("1", "true"), which the server
// cannot promise — both are left out. A String element Convert.ToString()s any node type.
private static bool ModbusSupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
{
if (!TryParseObject(tagConfig, out var root)
@@ -233,7 +254,8 @@ public static class DriverArrayWriteSupport
region ??= "HoldingRegisters";
if (region.Equals("Coils", StringComparison.OrdinalIgnoreCase))
return count <= ModbusMaxCoilsPerWrite && dataType is not ("String" or "DateTime");
return count <= ModbusMaxCoilsPerWrite && address + (long)count - 1 <= ModbusLastAddress
&& dataType is not ("String" or "DateTime");
if (!region.Equals("HoldingRegisters", StringComparison.OrdinalIgnoreCase)) return false; // input regions are read-only
native ??= "Int16";
@@ -250,9 +272,9 @@ public static class DriverArrayWriteSupport
if (dataType is "String" or "DateTime") return false;
if (native.Equals("BitInRegister", StringComparison.OrdinalIgnoreCase))
{
// The factory narrows to byte ((byte)ReadInt) and refuses a bit above 15; the array RMW refuses a span
// past the last register.
if ((byte)ReadInt(root, "bitIndex") > 15 || (long)address + count - 1 > ushort.MaxValue) return false;
// The factory narrows to byte ((byte)ReadInt) and refuses a bit above 15. The RMW reads the whole span
// in ONE unchunked FC03 before writing it, so the read cap bounds it too.
if ((byte)ReadInt(root, "bitIndex") > 15 || count > ModbusMaxRegistersPerRead(driverConfigs)) return false;
registersPerElement = 1;
}
else if (!ModbusRegisterArrayTypes.TryGetValue(native, out registersPerElement))
@@ -260,7 +282,8 @@ public static class DriverArrayWriteSupport
return false; // Bool in a register region: the factory refuses the pair
}
}
return (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
var registers = (long)count * registersPerElement;
return registers <= ModbusMaxRegistersPerWrite(driverConfigs) && address + registers - 1 <= ModbusLastAddress;
}
// TagConfigJson.TryReadEnumStrict over the Modbus enum's member names: absent or a non-string token ⇒ null (the
@@ -428,9 +451,24 @@ public static class DriverArrayWriteSupport
public static bool RefusesArrayValueOnScalarTag(string? driverType) =>
driverType is DriverTypeNames.Modbus or DriverTypeNames.S7;
// The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver
// default (123). The key is matched case-insensitively, like the driver's DTO binding.
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
// The effective Modbus FC16 write cap: the smallest maxRegistersPerWrite configured in any config (absent /
// non-positive ⇒ the driver default, 123), CLAMPED to FC16's protocol ceiling of 123. The driver trusts a larger
// configured cap and sends the over-ceiling FC16 (past 127 registers its one-byte byte-count field even wraps), which
// every conformant device refuses — so such a write always fails and is never advertised.
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs) =>
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerWrite") ?? ModbusDefaultMaxRegistersPerWrite,
ModbusProtocolMaxRegistersPerWrite);
// The effective Modbus FC03 read cap: the smallest maxRegistersPerRead configured (absent / non-positive ⇒ the driver
// default, 125), clamped to FC03's protocol ceiling of 125. Only the BitInRegister[] write reads (its RMW issues one
// unchunked FC03 of the whole span), so only that shape is bounded by it.
private static int ModbusMaxRegistersPerRead(string?[]? driverConfigs) =>
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerRead") ?? ModbusProtocolMaxRegistersPerRead,
ModbusProtocolMaxRegistersPerRead);
// The smallest positive Int32 value of `key` (matched case-insensitively, like the driver's DTO binding) at the root
// of any config; null when none carries one.
private static int? SmallestPositive(string?[]? driverConfigs, string key)
{
int? cap = null;
foreach (var config in driverConfigs ?? [])
@@ -438,7 +476,7 @@ public static class DriverArrayWriteSupport
if (!TryParseObject(config, out var root)) continue;
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase)
if (string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.Number
&& prop.Value.TryGetInt32(out var configured) && configured > 0)
{
@@ -446,7 +484,7 @@ public static class DriverArrayWriteSupport
}
}
}
return cap ?? ModbusDefaultMaxRegistersPerWrite;
return cap;
}
// The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the