Phase 3 PR 49 -- Per-device FC03/FC16 register caps with auto-chunking. Adds MaxRegistersPerRead (default 125, spec max) + MaxRegistersPerWrite (default 123, spec max) to ModbusDriverOptions. Reads that exceed the cap automatically split into consecutive FC03 requests: the driver dispatches chunks of [cap] regs at incrementing addresses, copies each response into an assembled byte[] buffer, and hands the full payload to DecodeRegister. From the caller's view a 240-char string read against a cap-100 device is still one Read() call returning one string -- the chunking is invisible, the wire shows N requests of cap-sized quantity plus one tail chunk. Writes are NOT auto-chunked. Splitting an FC16 across two transactions would lose atomicity -- mid-split crash leaves half the value written, which is strictly worse than rejecting upfront. Instead, writes exceeding MaxRegistersPerWrite throw InvalidOperationException with a message naming the tag + cap + the caller's escape hatch (shorten StringLength or split into multiple tags). The driver catches the exception internally and surfaces it to IWritable as BadInternalError so the caller pattern stays symmetric with other failure modes. Per-family cap cheat-sheet (documented in xml-doc on the option): Modbus-TCP spec = 125 read / 123 write, AutomationDirect DL205/DL260 = 128 read / 100 write (128 exceeds spec byte-count capacity so in practice 125 is the working ceiling), Mitsubishi Q/FX3U = 64 / 64, Omron CJ/CS = 125 / 123. Not all PLCs reject over-cap requests cleanly -- some drop the connection silently -- so having the cap enforced client-side prevents the hard-to-diagnose 'driver just stopped' failure mode. Unit tests: Read_within_cap_issues_single_FC03_request (control: no unnecessary chunking), Read_above_cap_splits_into_two_FC03_requests (120 regs / cap 100 -> 100+20, asserts exact per-chunk (Address,Quantity) and end-to-end payload continuity starting with register[100] high byte = 'A'), Read_cap_honors_Mitsubishi_lower_cap_of_64 (100 regs / cap 64 -> 64+36), Write_exceeding_cap_throws_instead_of_splitting (110 regs / cap 100 -> status != 0 AND Fc16Requests.Count == 0 to prove nothing was sent), Write_within_cap_proceeds_normally (control: cap honored on short writes too). Tests use a new RecordingTransport that captures the (Address, Quantity) tuple of every FC03/FC16 request so the chunk layout is directly assertable -- the existing FakeTransport does not expose request history. 103/103 Modbus.Tests pass; 6/6 DL205 integration tests still pass against the live pymodbus dl205 profile with MODBUS_SIM_PROFILE=dl205.

This commit is contained in:
Joseph Doherty
2026-04-18 21:58:49 -04:00
parent 463c5a4320
commit a3f2f95344
3 changed files with 226 additions and 6 deletions

View File

@@ -25,6 +25,26 @@ public sealed class ModbusDriverOptions
/// <see cref="IHostConnectivityProbe"/>.
/// </summary>
public ModbusProbeOptions Probe { get; init; } = new();
/// <summary>
/// Maximum registers per FC03 (Read Holding Registers) / FC04 (Read Input Registers)
/// transaction. Modbus-TCP spec allows 125; many device families impose lower caps:
/// AutomationDirect DL205/DL260 cap at <c>128</c>, Mitsubishi Q/FX3U cap at <c>64</c>,
/// Omron CJ/CS cap at <c>125</c>. Set to the lowest cap across the devices this driver
/// instance talks to; the driver auto-chunks larger reads into consecutive requests.
/// Default <c>125</c> — the spec maximum, safe against any conforming server. Setting
/// to <c>0</c> disables the cap (discouraged — the spec upper bound still applies).
/// </summary>
public ushort MaxRegistersPerRead { get; init; } = 125;
/// <summary>
/// Maximum registers per FC16 (Write Multiple Registers) transaction. Spec maximum is
/// <c>123</c>; DL205/DL260 cap at <c>100</c>. Matching caller-vs-device semantics:
/// exceeding the cap currently throws (writes aren't auto-chunked because a partial
/// write across two FC16 calls is no longer atomic — caller must explicitly opt in
/// by shortening the tag's <c>StringLength</c> or splitting it into multiple tags).
/// </summary>
public ushort MaxRegistersPerWrite { get; init; } = 123;
}
public sealed class ModbusProbeOptions