Adds a coalescing read planner that merges nearby tags into single FC03/FC04
PDUs, opt-in via ModbusDriverOptions.MaxReadGap. Default 0 = no coalescing
(every tag gets its own PDU — preserves pre-#143 wire output).
Worked example with MaxReadGap=10:
T1 @ HR 100 (Int16, 1 reg)
T2 @ HR 102 (Int16, 1 reg, gap 1 → joins block)
T3 @ HR 110 (Float32, 2 regs, gap 7 → joins block)
T4 @ HR 200 (Int16, 1 reg, gap 89 → splits, separate read)
→ 2 PDUs total: FC03 start=100 quantity=12 + FC03 start=200 quantity=1.
Planner:
- Eligible tags: known + register region (HR/IR) + scalar + not String /
BitInRegister / array + not CoalesceProhibited.
- Groups by (UnitId, Region) — never coalesces across slaves or regions.
- Sorts by start address; merges when (next.start - last.end - 1) ≤ MaxReadGap
AND the resulting span ≤ MaxRegistersPerRead. Otherwise opens a new block.
- Single-tag blocks are deferred to the per-tag path so WriteOnChange cache
semantics stay correct without duplication.
- Per-block failure marks every member tag Bad and degrades health — same
semantics the per-tag path has, but at the block granularity.
Per-tag escape hatch ModbusTagDefinition.CoalesceProhibited (bool, default
false) — when true, the tag is read in isolation regardless of MaxReadGap.
For PLCs with protected register holes between adjacent tags.
Tests (7 new ModbusCoalescingTests):
- MaxReadGap=0 keeps the per-tag behavior (2 reads for 2 tags).
- MaxReadGap=2 merges 3 tags within 5 registers into 1 read of qty=5.
- MaxReadGap=10 splits T1+T2 from T3 when the gap exceeds the threshold.
- CoalesceProhibited tag reads alone even when neighbours are eligible.
- Coalescing never crosses UnitId boundaries (multi-slave gateway safety).
- MaxRegistersPerRead caps a would-be block; planner falls back to separate
reads when the merged span would exceed the cap.
- Per-tag values surface independently after coalescing (slice-math sanity).
Existing 220 unit tests still green; total 224 pass with the new file (tests
are additive, no regressions).
Follow-up: auto-split-on-protected-hole isn't shipped — a coalesced read
that hits an Illegal Data Address right now marks every member Bad until
the operator sets CoalesceProhibited on the offending tag. Tracked
implicitly by #138's e2e drill against a pymodbus profile with a protected
hole mid-block.