using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// Review 07 U2: block-read coalescing is ON by default (gap 8), and it covers coils, discrete inputs, /// BitInRegister and String tags — 16 bit tags on one status word used to cost 16 reads of /// the same register, and 64 adjacent coils 64 FC01s. /// [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 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)); } /// /// Fix round 1 (coordinator ruling 2): an explicitly authored maxReadGap 0 — what the pre-#741 form /// persisted on every driver — keeps its old meaning, NO coalescing; the gap-8 default applies only to an /// absent key (positive control). /// [Theory] [InlineData("""{"host":"h","maxReadGap":0,"disableFC23":false}""", 2)] [InlineData("""{"host":"h"}""", 1)] public async Task A_persisted_maxReadGap_0_keeps_coalescing_off(string legacyConfig, int expectedReads) { var parsed = ModbusDriverFactoryExtensions.ParseOptions("m1", legacyConfig); var tags = new[] { new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16), new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16), }; var (drv, t) = await StartAsync(new ModbusDriverOptions { Host = "f", MaxReadGap = parsed.MaxReadGap, DisableReadCoalescing = parsed.DisableReadCoalescing, RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false }, }); await using var _ = drv; await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken); t.Requests(0x03).Count.ShouldBe(expectedReads); } [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(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); } }