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
@@ -74,11 +74,50 @@ public sealed class DriverArrayWriteSupportTests
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":100}", "{\"maxRegistersPerWrite\":9}")
.ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":0}", null, "not json").ShouldBeTrue();
// A larger configured cap lifts the default: 124 registers fit under 200.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
// A configured cap above the FC16 protocol ceiling (123) never lifts it: a conformant device refuses 124
// registers (and past 127 the driver's one-byte byte-count field wraps), so the write always fails.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 123), "{\"maxRegistersPerWrite\":200}")
.ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
.ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("BitInRegister", 124), "{\"maxRegistersPerWrite\":200}")
.ShouldBeFalse();
}
/// <summary>A BitInRegister[] write is a read-modify-write: one unchunked FC03 of the whole span first, so its
/// length is also bounded by <c>maxRegistersPerRead</c> (smallest positive value across the configs; absent / 0 =>
/// 125, and never above FC03's protocol ceiling of 125). The other register shapes never read on write.</summary>
[Fact]
public void Modbus_bit_in_register_arrays_respect_the_read_cap()
{
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 64), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":64}").ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":100}", "{\"MaxRegistersPerRead\":64}")
.ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 123), "{\"maxRegistersPerRead\":0}").ShouldBeTrue();
// A numeric array of the same length is not read on write, so the read cap does not apply to it.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 65), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
// With the write cap lifted, the read cap is the bound.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 100),
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 101),
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeFalse();
}
/// <summary>Every shape's span must end at or before address 65535 - a conformant device refuses a write past it
/// (exception 02).</summary>
[Theory]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":6}", true)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":7}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65532,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", true)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65533,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65534,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", true)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", false)]
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":536}", true)]
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":537}", false)]
public void Modbus_array_spans_end_within_the_address_space(string tagConfig, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
/// <summary>FC15 carries at most 1968 coils and the driver sends one request, so a longer coil array is not
/// writable.</summary>
[Fact]
@@ -104,7 +143,8 @@ public sealed class DriverArrayWriteSupportTests
$"{{\"region\":\"HoldingRegisters\",\"address\":0,{typeKeys},\"isArray\":true,\"arrayLength\":{length}}}").ShouldBe(expected);
/// <summary>Elements CONVERT (ScadaBridge's <c>int[]</c> to an Int16 node), so the Modbus row keys on no exact node
/// DataType; it leaves out only a String or DateTime node over a numeric / bit element, which never converts. A
/// DataType; it leaves out only a String or DateTime node over a numeric / bit element (a DateTime never converts; a String converts only when its text happens to
/// parse, which the server cannot promise). A
/// String element takes any node type (each element is <c>Convert.ToString</c>'d).</summary>
[Theory]
[InlineData("Int16", "Int16", true)]