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:
@@ -0,0 +1,155 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U2: block-read coalescing is ON by default (gap 8), and it covers coils, discrete inputs,
|
||||
/// <c>BitInRegister</c> and <c>String</c> tags — 16 bit tags on one status word used to cost 16 reads of
|
||||
/// the same register, and 64 adjacent coils 64 FC01s.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusCoalescingDefaultsTests
|
||||
{
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartAsync(ModbusDriverOptions opts)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var d = new ModbusDriver(opts, "m1", _ => t);
|
||||
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (d, t);
|
||||
}
|
||||
|
||||
private static ModbusDriverOptions Opts(IEnumerable<ModbusTagDefinition> tags, bool disable = false) => new()
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), DisableReadCoalescing = disable,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Default_options_coalesce_with_a_gap_of_8()
|
||||
{
|
||||
new ModbusDriverOptions().MaxReadGap.ShouldBe((ushort)8);
|
||||
new ModbusDriverOptions().DisableReadCoalescing.ShouldBeFalse();
|
||||
ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h"}""").MaxReadGap.ShouldBe((ushort)8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_configured_holding_registers_are_read_in_one_PDU()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 10).Select(i =>
|
||||
new ModbusTagDefinition($"HR{i}", ModbusRegion.HoldingRegisters, (ushort)(100 + i * 3), ModbusDataType.Int16)).ToArray();
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
for (var i = 0; i < 10; i++) t.Bank.HoldingRegisters[100 + i * 3] = (ushort)(i + 1);
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(0x03).Count.ShouldBe(1);
|
||||
r.Select(s => (short)s.Value!).ShouldBe(Enumerable.Range(1, 10).Select(i => (short)i));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableReadCoalescing_reads_every_tag_alone()
|
||||
{
|
||||
// Positive control for the default-on test: the off switch really is per-tag.
|
||||
var tags = new[]
|
||||
{
|
||||
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16),
|
||||
};
|
||||
var (drv, t) = await StartAsync(Opts(tags, disable: true));
|
||||
await using var _ = drv;
|
||||
await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
|
||||
t.Requests(0x03).Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ModbusRegion.Coils, (byte)0x01)]
|
||||
[InlineData(ModbusRegion.DiscreteInputs, (byte)0x02)]
|
||||
public async Task Adjacent_bit_tags_fold_into_one_bit_read(ModbusRegion region, byte fc)
|
||||
{
|
||||
var tags = Enumerable.Range(0, 64).Select(i =>
|
||||
new ModbusTagDefinition($"B{i}", region, (ushort)(10 + i), ModbusDataType.Bool)).ToArray();
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
var bank = region == ModbusRegion.Coils ? t.Bank.Coils : t.Bank.DiscreteInputs;
|
||||
for (var i = 0; i < 64; i++) bank[10 + i] = i % 3 == 0;
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(fc).ShouldBe([(fc, (ushort)10, (ushort)64)]);
|
||||
for (var i = 0; i < 64; i++) r[i].Value.ShouldBe(i % 3 == 0, $"bit {i}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_tags_on_one_status_word_fold_into_one_register_read()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 16).Select(b =>
|
||||
new ModbusTagDefinition($"S{b}", ModbusRegion.HoldingRegisters, 40, ModbusDataType.BitInRegister, BitIndex: (byte)b)).ToArray();
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[40] = 0b1010_0000_0000_0101;
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(0x03).ShouldBe([((byte)0x03, (ushort)40, (ushort)1)]);
|
||||
for (var b = 0; b < 16; b++) r[b].Value.ShouldBe(((0b1010_0000_0000_0101 >> b) & 1) == 1, $"bit {b}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_string_tag_coalesces_with_its_neighbours()
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
new ModbusTagDefinition("Num", ModbusRegion.HoldingRegisters, 50, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("Str", ModbusRegion.HoldingRegisters, 51, ModbusDataType.String, StringLength: 4),
|
||||
new ModbusTagDefinition("After", ModbusRegion.HoldingRegisters, 53, ModbusDataType.UInt16),
|
||||
};
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[50] = 7;
|
||||
t.Bank.HoldingRegisters[51] = ('A' << 8) | 'B';
|
||||
t.Bank.HoldingRegisters[52] = ('C' << 8) | 'D';
|
||||
t.Bank.HoldingRegisters[53] = 9;
|
||||
|
||||
var r = await drv.ReadAsync(["Num", "Str", "After"], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(0x03).ShouldBe([((byte)0x03, (ushort)50, (ushort)4)]);
|
||||
r[0].Value.ShouldBe((short)7);
|
||||
r[1].Value.ShouldBe("ABCD");
|
||||
r[2].Value.ShouldBe((ushort)9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_coil_range_is_prohibited_and_reprobed_with_FC01()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 4).Select(i =>
|
||||
new ModbusTagDefinition($"C{i}", ModbusRegion.Coils, (ushort)(20 + i), ModbusDataType.Bool)).ToArray();
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
AutoProhibitReprobeInterval = TimeSpan.FromHours(1),
|
||||
};
|
||||
var (drv, t) = await StartAsync(opts);
|
||||
await using var _ = drv;
|
||||
var refuseMerged = true;
|
||||
t.Handler = (_, pdu, _) =>
|
||||
{
|
||||
var qty = (pdu[3] << 8) | pdu[4];
|
||||
return pdu[0] == 0x01 && qty > 1 && refuseMerged
|
||||
? Task.FromException<byte[]>(new ModbusException(0x01, 0x02, "protected"))
|
||||
: null;
|
||||
};
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
r.ShouldAllBe(s => s.StatusCode == 0u, "the per-tag fallback still reads every coil");
|
||||
drv.AutoProhibitedRangeCount.ShouldBe(1);
|
||||
|
||||
refuseMerged = false;
|
||||
while (t.Sent.TryDequeue(out var _1)) { }
|
||||
await drv.RunReprobeOnceForTestAsync(TestContext.Current.CancellationToken);
|
||||
t.Sent.ShouldAllBe(s => s.Pdu[0] == 0x01, "a coil range is re-probed with FC01, never a register read");
|
||||
drv.AutoProhibitedRangeCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,9 @@ public sealed class ModbusCoalescingTests
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that MaxReadGap=0 defaults to per-tag reads without coalescing.</summary>
|
||||
/// <summary>MaxReadGap=0 coalesces only adjacent tags: a one-register hole splits the read.</summary>
|
||||
[Fact]
|
||||
public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads()
|
||||
public async Task MaxReadGap_Zero_Does_Not_Bridge_A_Gap()
|
||||
{
|
||||
var fake = new CountingTransport();
|
||||
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
|
||||
@@ -56,7 +56,7 @@ public sealed class ModbusCoalescingTests
|
||||
|
||||
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
|
||||
|
||||
// With coalescing off, expect 2 separate FC03 reads.
|
||||
// A one-register gap with MaxReadGap=0: expect 2 separate FC03 reads.
|
||||
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
|
||||
fc03Reads.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ internal sealed class ScriptedTransport : IModbusTransport
|
||||
|
||||
public int TotalSends => _sendsByUnit.Values.Sum();
|
||||
|
||||
/// <summary>Every PDU sent, in order.</summary>
|
||||
public ConcurrentQueue<(byte Unit, byte[] Pdu)> Sent { get; } = new();
|
||||
|
||||
/// <summary>The (function code, start, quantity) of every sent request with that function code.</summary>
|
||||
public List<(byte Fc, ushort Start, ushort Qty)> Requests(byte fc) =>
|
||||
[.. Sent.Where(s => s.Pdu[0] == fc).Select(s => (s.Pdu[0], (ushort)((s.Pdu[1] << 8) | s.Pdu[2]), (ushort)((s.Pdu[3] << 8) | s.Pdu[4])))];
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
ConnectEntered.TrySetResult();
|
||||
@@ -39,6 +46,7 @@ internal sealed class ScriptedTransport : IModbusTransport
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
_sendsByUnit.AddOrUpdate(unitId, 1, (_, n) => n + 1);
|
||||
Sent.Enqueue((unitId, pdu));
|
||||
var scripted = Handler?.Invoke(unitId, pdu, ct);
|
||||
return scripted ?? Bank.SendAsync(unitId, pdu, ct);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user