feat(modbus): block-read coalescing on by default; coils/DI/BitInRegister/String fold

U2: MaxReadGap defaults to 8 and 0 now means adjacent-only rather than
off; a new DisableReadCoalescing switch (factory DTO + AdminUI form) turns it
off entirely. The planner also merges Coils/DiscreteInputs (FC01/FC02, gap in
bits), String scalars and BitInRegister tags - sixteen bit tags on one status
word are one FC03 instead of sixteen. Auto-prohibition and its re-probe /
bisection use the region's own function code (a coil range was re-probed
with FC04).

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:24:38 -04:00
parent f827b3a134
commit 2ef3cd0df2
7 changed files with 269 additions and 56 deletions
@@ -107,14 +107,25 @@ public sealed class ModbusDriverOptions
public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null;
/// <summary>
/// Block-read coalescing budget. When non-zero, the read planner combines tags
/// in the same (UnitId, Region) group whose addresses are at most this many registers
/// apart into a single FC03/FC04/FC01/FC02 read. The sliced response is then dispatched
/// back to per-tag values. Default <c>0</c> = no coalescing — every tag gets its own
/// PDU (preserves the previous behaviour). Typical opt-in values are 5..32 — large enough
/// to bridge a few unused registers, small enough to avoid trampling protected holes.
/// Block-read coalescing budget. The read planner combines tags in the same (UnitId, Region)
/// group whose addresses are at most this many registers (Coils / DiscreteInputs: bits) apart
/// into a single FC03/FC04/FC01/FC02 read, then slices the response back into per-tag values.
/// <c>0</c> coalesces only adjacent or overlapping tags (several <c>BitInRegister</c> tags on one
/// status word still fold into one read). Default <c>8</c>. A range the device refuses is
/// auto-prohibited and read per tag, so a protected hole costs one extra round trip, not data. To
/// turn coalescing off entirely set <see cref="DisableReadCoalescing"/>, or exempt one tag with
/// its <c>coalesceProhibited</c> key.
/// </summary>
public ushort MaxReadGap { get; init; } = 0;
public ushort MaxReadGap { get; init; } = DefaultMaxReadGap;
/// <summary>The default <see cref="MaxReadGap"/> (shared by the factory DTO and the AdminUI form).</summary>
public const ushort DefaultMaxReadGap = 8;
/// <summary>
/// When <c>true</c>, every tag is read with its own PDU (no block coalescing at all) — for a device
/// that mis-answers merged reads in a way the auto-prohibition cannot catch. Default <c>false</c>.
/// </summary>
public bool DisableReadCoalescing { get; init; } = false;
/// <summary>
/// PLC family hint that drives the parser's family-native branch. When set to a
@@ -727,7 +727,7 @@ public sealed class ModbusDriver
var results = new DataValueSnapshot[fullReferences.Count];
var batch = new BatchOutcome();
var coalesced = _options.MaxReadGap > 0
var coalesced = !_options.DisableReadCoalescing
? await ReadCoalescedAsync(transport, table, fullReferences, results, now, batch, cancellationToken).ConfigureAwait(false)
: new HashSet<int>();
@@ -1083,11 +1083,10 @@ public sealed class ModbusDriver
private async Task StraightReprobeAsync(IModbusTransport transport,
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
{
var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
var qty = (ushort)(key.End - key.Start + 1);
try
{
_ = await ReadRegisterBlockAsync(transport, key.Unit, fc, key.Start, qty, ct).ConfigureAwait(false);
_ = await ReadRangeAsync(transport, key.Unit, key.Region, key.Start, qty, ct).ConfigureAwait(false);
lock (_autoProhibitedLock) _autoProhibited.Remove(key);
LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
}
@@ -1107,13 +1106,12 @@ public sealed class ModbusDriver
private async Task BisectAndReprobeAsync(IModbusTransport transport,
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
{
var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
var mid = (ushort)((key.Start + key.End) / 2);
var leftEnd = mid;
var rightStart = (ushort)(mid + 1);
var leftFailed = await ProbeFailsAsync(transport, fc, key.Unit, key.Start, leftEnd, ct).ConfigureAwait(false);
var rightFailed = await ProbeFailsAsync(transport, fc, key.Unit, rightStart, key.End, ct).ConfigureAwait(false);
var leftFailed = await ProbeFailsAsync(transport, key.Region, key.Unit, key.Start, leftEnd, ct).ConfigureAwait(false);
var rightFailed = await ProbeFailsAsync(transport, key.Region, key.Unit, rightStart, key.End, ct).ConfigureAwait(false);
lock (_autoProhibitedLock)
{
@@ -1149,13 +1147,13 @@ public sealed class ModbusDriver
}
}
private async Task<bool> ProbeFailsAsync(IModbusTransport transport, byte fc, byte unit,
private async Task<bool> ProbeFailsAsync(IModbusTransport transport, ModbusRegion region, byte unit,
ushort start, ushort end, CancellationToken ct)
{
var qty = (ushort)(end - start + 1);
try
{
_ = await ReadRegisterBlockAsync(transport, unit, fc, start, qty, ct).ConfigureAwait(false);
_ = await ReadRangeAsync(transport, unit, region, start, qty, ct).ConfigureAwait(false);
return false;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
@@ -1163,12 +1161,15 @@ public sealed class ModbusDriver
}
/// <summary>
/// Block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts
/// by start address, and merges adjacent / near-adjacent (gap ≤ MaxReadGap) into single
/// FC03/FC04 reads. Per-block: emit one Modbus PDU, slice the response back into per-tag
/// values, populate <paramref name="results"/> and the WriteOnChangeOnly cache. Returns
/// the set of <paramref name="fullReferences"/> indices the planner handled — the
/// caller falls back to the per-tag path for the rest (arrays, coils, prohibited, unknown).
/// Block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts by start address,
/// and merges adjacent / near-adjacent tags (gap ≤ <see cref="ModbusDriverOptions.MaxReadGap"/>, 0 =
/// adjacent or overlapping only) into one read per block: FC03/FC04 for the register regions (scalar
/// numeric, <c>String</c> and <c>BitInRegister</c> tags — several bit tags on ONE status word fold into
/// a single read of it), FC01/FC02 for Coils / DiscreteInputs (gap counted in bits). Per block: emit
/// one PDU, slice the response back into per-tag values, populate <paramref name="results"/> and the
/// WriteOnChangeOnly cache. Returns the set of <paramref name="fullReferences"/> indices the planner
/// handled — the caller falls back to the per-tag path for the rest (arrays, lone tags, prohibited,
/// unknown, refused ranges).
/// </summary>
private async Task<HashSet<int>> ReadCoalescedAsync(
IModbusTransport transport,
@@ -1179,9 +1180,8 @@ public sealed class ModbusDriver
BatchOutcome batch,
CancellationToken ct)
{
// Eligible: known tag, register region (HoldingRegisters or InputRegisters), scalar
// (no array, no string), not CoalesceProhibited, not BitInRegister.
var eligible = new List<(int Index, string Ref, ModbusTagDefinition Tag)>();
// Eligible: known scalar tag, not CoalesceProhibited, with a region/type pair the codec can slice.
var eligible = new List<(int Index, ModbusTagDefinition Tag)>();
for (var i = 0; i < fullReferences.Count; i++)
{
if (!table.TryGetValue(fullReferences[i], out var tag)) continue;
@@ -1190,9 +1190,7 @@ public sealed class ModbusDriver
// throw for a tag that slipped through — one bad tag would otherwise fail the whole batch.
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(tag.Region, tag.DataType)) continue;
if (tag.ArrayCount.HasValue) continue;
if (tag.Region is not (ModbusRegion.HoldingRegisters or ModbusRegion.InputRegisters)) continue;
if (tag.DataType is ModbusDataType.String or ModbusDataType.BitInRegister) continue;
eligible.Add((i, fullReferences[i], tag));
eligible.Add((i, tag));
}
if (eligible.Count == 0) return new HashSet<int>();
@@ -1201,42 +1199,43 @@ public sealed class ModbusDriver
// Group by (UnitId, Region) — coalescing across slaves or regions is unsafe.
foreach (var group in eligible.GroupBy(e => (Unit: ResolveUnitId(e.Tag), e.Tag.Region)))
{
var fc = group.Key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
var isBits = IsBitRegion(group.Key.Region);
var cap = isBits
? (_options.MaxCoilsPerRead == 0 ? 2000 : _options.MaxCoilsPerRead)
: (_options.MaxRegistersPerRead == 0 ? 125 : _options.MaxRegistersPerRead);
var sorted = group.OrderBy(e => e.Tag.Address).ToList();
// Build merged blocks. A "block" is (start, lastEnd, members[]) where lastEnd is
// the inclusive end address of the last tag's register span. A new tag joins the
// block if its start ≤ lastEnd + 1 + MaxReadGap AND the resulting span ≤ cap.
var blocks = new List<(ushort Start, ushort End, List<(int Index, ModbusTagDefinition Tag)> Members)>();
foreach (var (idx, _, tag) in sorted)
// Build merged blocks. A "block" is (start, lastEnd, members[]) where lastEnd is the inclusive
// end of the widest member span seen so far. A new tag joins the block if its start ≤ lastEnd +
// 1 + MaxReadGap AND the resulting span ≤ cap AND the span crosses no auto-prohibited range.
var blocks = new List<(int Start, int End, List<(int Index, ModbusTagDefinition Tag)> Members)>();
foreach (var (idx, tag) in sorted)
{
var tagStart = tag.Address;
var tagEnd = (ushort)(tag.Address + RegisterCount(tag) - 1);
var tagStart = (int)tag.Address;
var tagEnd = tagStart + (isBits ? 1 : RegisterCount(tag)) - 1;
if (tagEnd > ushort.MaxValue || tagEnd - tagStart + 1 > cap) continue; // per-tag path chunks / refuses it
if (blocks.Count > 0)
{
var last = blocks[^1];
var gap = tagStart - last.End - 1;
var newEnd = Math.Max(tagEnd, last.End);
var newSpan = newEnd - last.Start + 1;
var crossesProhibition = RangeIsAutoProhibited(group.Key.Unit, group.Key.Region, last.Start, (ushort)newEnd);
var crossesProhibition = RangeIsAutoProhibited(group.Key.Unit, group.Key.Region, (ushort)last.Start, (ushort)newEnd);
if (gap <= _options.MaxReadGap && newSpan <= cap && !crossesProhibition)
{
last.Members.Add((idx, tag));
blocks[^1] = (last.Start, (ushort)newEnd, last.Members);
blocks[^1] = (last.Start, newEnd, last.Members);
continue;
}
}
blocks.Add((tagStart, tagEnd, new List<(int, ModbusTagDefinition)> { (idx, tag) }));
}
// Issue one PDU per block. On a Modbus-level exception (illegal data address /
// protected register), record the range as auto-prohibited, leave the
// member indices UNhandled, and let the per-tag fallback in ReadAsync read each
// surviving address individually. On transport-level failure (timeout / socket
// drop) mark members Bad and short-circuit the per-tag fallback (hitting the
// dead socket again won't help). Bisection narrows the prohibition over
// subsequent re-probe ticks.
// Issue one PDU per block. On a device REFUSAL (illegal data address / protected register),
// record the range as auto-prohibited, leave the member indices UNhandled, and let the per-tag
// fallback in ReadAsync read each surviving address individually. On a transport-level failure
// (timeout / socket drop) mark members Bad and short-circuit the per-tag fallback (hitting the
// dead socket again won't help). Bisection narrows the prohibition over subsequent re-probe ticks.
foreach (var block in blocks)
{
if (block.Members.Count == 1)
@@ -1246,6 +1245,7 @@ public sealed class ModbusDriver
continue;
}
var start = (ushort)block.Start;
var qty = (ushort)(block.End - block.Start + 1);
if (batch.IsFailedFast(group.Key.Unit))
{
@@ -1259,7 +1259,7 @@ public sealed class ModbusDriver
byte[] data;
try
{
data = await ReadRegisterBlockAsync(transport, group.Key.Unit, fc, block.Start, qty, ct).ConfigureAwait(false);
data = await ReadRangeAsync(transport, group.Key.Unit, group.Key.Region, start, qty, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -1271,7 +1271,7 @@ public sealed class ModbusDriver
// as auto-prohibited and leave the members UNhandled so the per-tag fallback reads each
// surviving address individually. The device answered, so health is not degraded.
batch.Answered = true;
RecordAutoProhibition(group.Key.Unit, group.Key.Region, block.Start, block.End);
RecordAutoProhibition(group.Key.Unit, group.Key.Region, start, (ushort)block.End);
continue;
}
catch (Exception ex)
@@ -1292,12 +1292,21 @@ public sealed class ModbusDriver
batch.Answered = true;
foreach (var (idx, tag) in block.Members)
{
var sliceOffsetBytes = (tag.Address - block.Start) * 2;
var sliceLenBytes = RegisterCount(tag) * 2;
handled.Add(idx);
try
{
var value = Decode(() => DecodeRegister(data.AsSpan(sliceOffsetBytes, sliceLenBytes), tag), tag);
object value;
if (isBits)
{
var bit = tag.Address - block.Start;
value = ((data[bit / 8] >> (bit % 8)) & 0x01) == 1;
}
else
{
var sliceOffsetBytes = (tag.Address - block.Start) * 2;
var sliceLenBytes = RegisterCount(tag) * 2;
value = Decode(() => DecodeRegister(data.AsSpan(sliceOffsetBytes, sliceLenBytes), tag), tag);
}
results[idx] = new DataValueSnapshot(value, 0u, timestamp, timestamp);
InvalidateWriteCacheIfDiverged(fullReferences[idx], value);
}
@@ -1313,6 +1322,24 @@ public sealed class ModbusDriver
return handled;
}
private static bool IsBitRegion(ModbusRegion region) => region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs;
private static byte ReadFunctionCode(ModbusRegion region) => region switch
{
ModbusRegion.Coils => 0x01,
ModbusRegion.DiscreteInputs => 0x02,
ModbusRegion.HoldingRegisters => 0x03,
_ => 0x04,
};
/// <summary>Reads one contiguous range of the region with the matching function code — registers as
/// big-endian bytes, bits as an LSB-first bitmap.</summary>
private Task<byte[]> ReadRangeAsync(
IModbusTransport transport, byte unit, ModbusRegion region, ushort start, ushort qty, CancellationToken ct)
=> IsBitRegion(region)
? ReadBitBlockAsync(transport, unit, ReadFunctionCode(region), start, qty, ct)
: ReadRegisterBlockAsync(transport, unit, ReadFunctionCode(region), start, qty, ct);
private void InvalidateWriteCacheIfDiverged(string fullRef, object value)
{
if (!_options.WriteOnChangeOnly) return;
@@ -86,7 +86,8 @@ public static class ModbusDriverFactoryExtensions
UseFC16ForSingleRegisterWrites = dto.UseFC16ForSingleRegisterWrites ?? false,
DisableFC23 = dto.DisableFC23 ?? false,
WriteOnChangeOnly = dto.WriteOnChangeOnly ?? false,
MaxReadGap = dto.MaxReadGap ?? 0,
MaxReadGap = dto.MaxReadGap ?? ModbusDriverOptions.DefaultMaxReadGap,
DisableReadCoalescing = dto.DisableReadCoalescing ?? false,
Family = dto.Family is null ? ModbusFamily.Generic
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
@@ -171,6 +172,8 @@ public static class ModbusDriverFactoryExtensions
public bool? WriteOnChangeOnly { get; init; }
/// <summary>Gets or sets the maximum gap between register addresses for coalescing.</summary>
public ushort? MaxReadGap { get; init; }
/// <summary>Gets or sets a value indicating whether block-read coalescing is turned off entirely.</summary>
public bool? DisableReadCoalescing { get; init; }
/// <summary>Gets or sets the Modbus family type.</summary>
public string? Family { get; init; }
/// <summary>Gets or sets the Melsec subfamily.</summary>
@@ -80,7 +80,13 @@
<div class="col-md-3">
<label class="form-label">Max read gap (coalescing)</label>
<InputNumber @bind-Value="_form.MaxReadGap" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">0 = no coalescing. Typical 5–32.</div>
<div class="form-text">Default 8. 0 = adjacent tags only. Refused ranges fall back per tag.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.DisableReadCoalescing" @bind-Value:after="EmitAsync" class="form-check-input" id="disableCoalescing" />
<label class="form-check-label" for="disableCoalescing">Disable read coalescing</label>
</div>
</div>
</div>
</div>
@@ -311,7 +317,8 @@
public int MaxRegistersPerRead { get; set; } = 125;
public int MaxRegistersPerWrite { get; set; } = 123;
public int MaxCoilsPerRead { get; set; } = 2000;
public int MaxReadGap { get; set; } = 0;
public int MaxReadGap { get; set; } = ModbusDriverOptions.DefaultMaxReadGap;
public bool DisableReadCoalescing { get; set; } = false;
// Write options
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
@@ -357,6 +364,7 @@
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
MaxCoilsPerRead = o.MaxCoilsPerRead,
MaxReadGap = o.MaxReadGap,
DisableReadCoalescing = o.DisableReadCoalescing,
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
DisableFC23 = o.DisableFC23,
@@ -399,6 +407,7 @@
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
DisableReadCoalescing = DisableReadCoalescing,
Family = Family,
MelsecSubFamily = MelsecSubFamily,
Transport = Transport,