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.
Lifts the previous "one driver = one slave" assumption so a single Modbus
driver instance can front N RTU slaves behind one Ethernet gateway (Anybus,
ProSoft, Lantronix style). Each tag carries an optional UnitId that drives
the MBAP unit-id byte per-PDU, and the IPerCallHostResolver contract surfaces
per-slave host strings so per-PLC circuit breakers fire per-slave (matches
the AB CIP template documented in docs/v2/multi-host-dispatch.md).
Changes:
- ModbusTagDefinition gains optional UnitId (byte?). Null = use driver-level
ModbusDriverOptions.UnitId (preserves single-slave deployments verbatim).
- ResolveUnitId(tag) helper computed once per ReadOneAsync / WriteOneAsync
call; passed through ReadRegisterBlockAsync / ReadBitBlockAsync /
ReadRegisterBlockChunkedAsync / ReadBitBlockChunkedAsync explicitly. The
probe loop continues using driver-level UnitId (the probe is a
connection-health check, not slave-specific).
- ModbusDriver implements IPerCallHostResolver. ResolveHost(fullReference)
returns "host:port/unitN" — distinct strings per slave so the resilience
pipeline keys breakers on the right granularity. Unknown references fall
back to the bare HostName (single-slave behaviour).
- BitInRegister RMW path also threads the per-tag UnitId through both the
read and write halves so a multi-slave deployment stays correct under bit-
level writes.
- Factory DTO + JSON binding extended with the per-tag UnitId field.
Tests (4 new ModbusMultiUnitTests):
- Per-tag UnitId routes to the correct slave in the MBAP header (driver-level
UnitId=99 must NOT appear when both tags override).
- Tag without override falls back to driver-level UnitId.
- IPerCallHostResolver returns distinct "host:port/unitN" strings per slave.
- Unknown reference returns the bare HostName fallback.
Existing 220 unit tests + 107 addressing tests still green. Per-PLC breaker
isolation under simulated dead slaves is verifiable via the existing AB CIP
test infra; live coverage lands as an integration test in the #138 docs/e2e
refresh.
Two driver-side filters that ≥5 of 6 surveyed vendors expose:
1. Per-tag Deadband (double?, on ModbusTagDefinition) — when set, the
PollGroupEngine onChange callback suppresses publishes whose distance
from the last-published value is below the threshold. Reduces wire
traffic to OPC UA clients on noisy analog signals (flow meters,
temperatures). Numeric scalar types only — Bool / BitInRegister / String
/ array tags publish unconditionally.
2. WriteOnChangeOnly (bool, on ModbusDriverOptions) — when true, the driver
short-circuits writes whose value matches the most recent successful
write to that tag. Saves PLC bandwidth on clients that re-publish the
same setpoint every scan. Cache invalidates on any read that returns a
different value, so HMI-side changes don't get masked.
Both default off so existing deployments see no behaviour change.
Implementation:
- ShouldPublish guard wraps the existing OnDataChange invocation. First sample
always passes through (no baseline); subsequent samples compare via
Convert.ToDouble for the cross-numeric-type math.
- IsRedundantWrite check at the top of WriteAsync; on success the cache is
populated. Object.Equals handles boxed-numeric equality; arrays are
excluded (reference-equality would never match anyway).
- ReadAsync invalidates the WriteOnChangeOnly cache when the new value
differs from the cached last-written value.
Tests (5 new ModbusSubscribeOptionsTests):
- Deadband suppresses sub-threshold changes (100 → 102 → 106 → 107 with
deadband=5 publishes 100 and 106 only).
- Deadband=null still publishes every change.
- WriteOnChangeOnly suppresses 3 identical 42 writes (only first hits wire).
- WriteOnChangeOnly default false hits the wire every time.
- Read-divergence cache invalidation: external panel write to 99, our
client's re-write of 42 must NOT be suppressed.
220/220 unit tests green; existing ProtocolOptions tests hardened against
probe-loop noise by disabling the probe in their fixtures.
Adds ModbusDriverOptions knobs that ≥4 of 6 surveyed vendors expose:
1. MaxCoilsPerRead (ushort, default 2000) — separate from MaxRegistersPerRead
because coil packing (1 bit per coil) and register packing (16 bits each)
have different spec ceilings. Coil-array reads above the cap auto-chunk
the same way register reads have always done. New ReadBitBlockChunkedAsync
re-assembles per-chunk LSB-first bitmaps into one logical bitmap.
2. UseFC15ForSingleCoilWrites (default false) — forces FC15 (Write Multiple
Coils with quantity=1) for single-coil writes instead of the default FC05
(Write Single Coil). Safety / audit PLCs that only accept the multi-write
codes need this.
3. UseFC16ForSingleRegisterWrites (default false) — same idea for FC16 vs
FC06 on single holding-register writes.
4. DisableFC23 (default false) — placeholder no-op for the future block-read
coalescing (#143) work that may opt into FC23 (Read/Write Multiple
Registers). Lets deployments pre-disable FC23 for PLCs that won't accept
it, before we ship the optimisation that emits it.
Defaults preserve the historical wire output bit-for-bit (FC05/FC06 for
singles, no chunking under 2000 coils, no FC23). Factory DTO + JSON-binding
extended with parallel fields.
6 new ModbusProtocolOptionsTests covering: defaults, FC05→FC15 forcing,
FC06→FC16 forcing, MaxCoilsPerRead chunking math (2500 coils / 2000 cap →
2 reads of 2000 + 500). Existing 209 unit tests still green.
Promotes the previously hardcoded transport-layer settings to ModbusDriverOptions
so users can tune them through DriverConfig JSON without recompiling.
Three new option groups:
1. KeepAlive (ModbusKeepAliveOptions): Enabled / Time / Interval / RetryCount.
Defaults preserve the historical PR 53 wire output exactly (Enabled=true,
Time=30s, Interval=10s, RetryCount=3). Set Enabled=false for PLCs that
reject SO_KEEPALIVE.
2. IdleDisconnectTimeout (TimeSpan?): when set, the transport tracks last-PDU-
success and proactively closes + reconnects on the next request after the
threshold. Defends against silent NAT / firewall socket reaping. Default
null = disabled (no behaviour change).
3. Reconnect (ModbusReconnectOptions): InitialDelay / MaxDelay /
BackoffMultiplier for the post-drop reconnect loop. Defaults
(InitialDelay=0, MaxDelay=30s, Multiplier=2.0) preserve the historical
immediate-retry behaviour for the first attempt and add geometric backoff
only if the reconnect itself fails. Capped at 10 attempts before propagating.
ModbusTcpTransport ctor extended with optional keepAlive / idleDisconnect /
reconnect parameters; existing 4-arg call sites continue to compile. Factory
DTO gains parallel KeepAlive / IdleDisconnectMs / Reconnect fields with
default-aware binding.
5 new ModbusConnectionOptionsTests covering the default-preservation contract
(every default field matches pre-#139) and the JSON-binding round-trip for
each knob group. Existing 204 unit tests still green.
Adds the full Wonderware/Kepware/Ignition-style address suffix grammar so
users paste tag spreadsheets without per-tag manual translation:
<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]
Examples that now parse end-to-end:
40001 HoldingRegisters[0], Int16
400001 same, 6-digit form
40001.5 bit 5 of HR[0]
40001:F Float32 (HR[0..1])
40001:F:CDAB word-swapped Float32
40001:STR20 20-char ASCII string
HR1:DI Int32 via mnemonic region
C100 Coils[99] (mnemonic)
40001:F:5 Float32[5] array (3-field shorthand)
40001:I:CDAB:10 Int16[10] word-swapped (4-field strict)
Driver-side plumbing:
- ModbusAddressParser + ParsedModbusAddress in the shared Addressing
assembly. 91 parser tests (every grammar variant + malformed shapes).
- ModbusDataType / ModbusByteOrder moved to shared (with the same namespace
so callers compile unchanged). ModbusByteOrder gains ByteSwap (BADC) and
FullReverse (DCBA) alongside the existing BigEndian (ABCD) and WordSwap
(CDAB).
- NormalizeWordOrder extended to honor all four orders for both 4-byte and
8-byte values. Old WordSwap behavior preserved bit-for-bit.
- ModbusTagDefinition gains optional ArrayCount.
- ReadOneAsync / WriteOneAsync handle array fan-out: one FC03/04 read covers
N consecutive register-typed elements, decoded into a typed array (short[],
float[], etc.). Coil arrays use FC01 reads + FC15 writes (FakeTransport
in tests gains FC15 support to match).
- DriverAttributeInfo IsArray / ArrayDim flow from ArrayCount so the OPC UA
address space surfaces ValueRank=1 + ArrayDimensions to clients.
- ModbusDriverFactoryExtensions gains AddressString DTO field. When
present, the parser drives Region/Address/DataType/ByteOrder/Bit/
StringLength/ArrayCount; structured fields (Writable, WriteIdempotent,
StringByteOrder) still come from the DTO. Existing structured tag rows
keep working unchanged.
Tests: 91 parser unit tests (Driver.Modbus.Addressing.Tests, all green) +
204 driver tests including new ModbusByteOrderTests (BADC/DCBA roundtrips
across Int32/Float32/Float64) and ModbusArrayTests (Int16[5], Float32[3]
CDAB, Coil[10], length-mismatch error, IsArray/ArrayDim discovery).
Solution-wide build clean.
Caveat: grammar names (type codes, byte-order mnemonics, the :count
shorthand) were synthesized from training-era vendor docs. Verify against
current Kepware Modbus Ethernet Driver Help and Ignition Modbus Addressing
manuals before freezing for production deployments — naming may need a
back-compat layer if vendor wording has shifted.
Per-tag opt-in for write-retry per docs/v2/plan.md decisions #44, #45, #143.
Default is false — writes never auto-retry unless the driver author has marked
the tag as safe to replay.
Core.Abstractions:
- DriverAttributeInfo gains `bool WriteIdempotent = false` at the end of the
positional record (back-compatible; every existing call site uses the default).
Driver.Modbus:
- ModbusTagDefinition gains `bool WriteIdempotent = false`. Safe candidates
documented in the param XML: holding-register set-points, configuration
registers. Unsafe: edge-triggered coils, counter-increment addresses.
- ModbusDriver.DiscoverAsync propagates t.WriteIdempotent into
DriverAttributeInfo.WriteIdempotent.
Driver.S7:
- S7TagDefinition gains `bool WriteIdempotent = false`. Safe candidates:
DB word/dword set-points, configuration DBs. Unsafe: M/Q bits that drive
edge-triggered program routines.
- S7Driver.DiscoverAsync propagates the flag.
Stream A.5 integration tests (FlakeyDriverIntegrationTests, 4 new) exercise
the invoker + flaky-driver contract the plan enumerates:
- Read with 5 transient failures succeeds on the 6th attempt (RetryCount=10).
- Non-idempotent write with RetryCount=5 configured still fails on the first
failure — no replay (decision #44 guard at the ExecuteWriteAsync surface).
- Idempotent write with 2 transient failures succeeds on the 3rd attempt.
- Two hosts on the same driver have independent breakers — dead-host trips
its breaker but live-host's first call still succeeds.
Propagation tests:
- ModbusDriverTests: SetPoint WriteIdempotent=true flows into
DriverAttributeInfo; PulseCoil default=false.
- S7DiscoveryAndSubscribeTests: same pattern for DBx SetPoint vs M-bit.
Full solution dotnet test: 947 passing (baseline 906, +41 net across Stream A
so far). Pre-existing Client.CLI Subscribe flake unchanged.
Stream A's remaining work (wiring CapabilityInvoker into DriverNodeManager's
OnReadValue / OnWriteValue / History / Subscribe dispatch paths) is the
server-side integration piece + needs DI wiring for the pipeline builder —
lands in the next PR on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>