diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs index 7bf2159..a77c3ac 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -175,21 +175,17 @@ public sealed class ModbusDriver switch (tag.Region) { case ModbusRegion.Coils: - { - // Single FC01 read covers either one coil (scalar) or N consecutive coils (array). - var qty = (ushort)arrayCount; - var pdu = new byte[] { 0x01, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), - (byte)(qty >> 8), (byte)(qty & 0xFF) }; - var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); - return DecodeBitArray(resp.AsSpan(2, resp[1]), arrayCount, tag.ArrayCount.HasValue); - } case ModbusRegion.DiscreteInputs: { - var qty = (ushort)arrayCount; - var pdu = new byte[] { 0x02, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), - (byte)(qty >> 8), (byte)(qty & 0xFF) }; - var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); - return DecodeBitArray(resp.AsSpan(2, resp[1]), arrayCount, tag.ArrayCount.HasValue); + // FC01 (Coils) / FC02 (DiscreteInputs). Auto-chunk when array count exceeds the + // coil cap — Modbus spec says ≤ 2000 bits per request; some devices cap lower + // (we trust the caller-provided MaxCoilsPerRead). + var fc = tag.Region == ModbusRegion.Coils ? (byte)0x01 : (byte)0x02; + var cap = _options.MaxCoilsPerRead == 0 ? (ushort)2000 : _options.MaxCoilsPerRead; + var bitmap = arrayCount <= cap + ? await ReadBitBlockAsync(transport, fc, tag.Address, (ushort)arrayCount, ct).ConfigureAwait(false) + : await ReadBitBlockChunkedAsync(transport, fc, tag.Address, arrayCount, cap, ct).ConfigureAwait(false); + return DecodeBitArray(bitmap, arrayCount, tag.ArrayCount.HasValue); } case ModbusRegion.HoldingRegisters: case ModbusRegion.InputRegisters: @@ -318,6 +314,44 @@ public sealed class ModbusDriver return data; } + private async Task ReadBitBlockAsync( + IModbusTransport transport, byte fc, ushort address, ushort qty, CancellationToken ct) + { + var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF), + (byte)(qty >> 8), (byte)(qty & 0xFF) }; + var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); + var bitmap = new byte[resp[1]]; + Buffer.BlockCopy(resp, 2, bitmap, 0, resp[1]); + return bitmap; + } + + /// + /// Auto-chunk coil-array reads above MaxCoilsPerRead. Reassembles per-chunk bitmaps into + /// one logical bitmap byte array sized for the full ; the + /// downstream walks bits LSB-first the same way it would + /// for a single-chunk response. + /// + private async Task ReadBitBlockChunkedAsync( + IModbusTransport transport, byte fc, ushort address, int totalBits, ushort cap, CancellationToken ct) + { + var assembled = new byte[(totalBits + 7) / 8]; + var done = 0; + while (done < totalBits) + { + var chunk = (ushort)Math.Min(cap, totalBits - done); + var chunkBitmap = await ReadBitBlockAsync(transport, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false); + // Re-pack per-chunk LSB-first bits into the assembled bitmap at the right offset. + for (var i = 0; i < chunk; i++) + { + if (((chunkBitmap[i / 8] >> (i % 8)) & 0x01) == 0) continue; + var dest = done + i; + assembled[dest / 8] |= (byte)(1 << (dest % 8)); + } + done += chunk; + } + return assembled; + } + private async Task ReadRegisterBlockChunkedAsync( IModbusTransport transport, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct) { @@ -395,7 +429,7 @@ public sealed class ModbusDriver { case ModbusRegion.Coils: { - if (!tag.ArrayCount.HasValue) + if (!tag.ArrayCount.HasValue && !_options.UseFC15ForSingleCoilWrites) { var on = Convert.ToBoolean(value); var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), @@ -403,8 +437,13 @@ public sealed class ModbusDriver await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); return; } + // FC15 path: either an explicit array, or UseFC15ForSingleCoilWrites=true forced + // it for a scalar (synthesise a 1-element bool[] from the scalar value). + var arrayLen = tag.ArrayCount ?? 1; + if (!tag.ArrayCount.HasValue) + value = new[] { Convert.ToBoolean(value) }; // FC15 — Write Multiple Coils. Pack the bool[] into LSB-first bitmap. - var values = ToBoolArray(value, tag.ArrayCount.Value, tag.Name); + var values = ToBoolArray(value, arrayLen, tag.Name); var byteCount = (values.Length + 7) / 8; var bitmap = new byte[byteCount]; for (var i = 0; i < values.Length; i++) @@ -425,10 +464,12 @@ public sealed class ModbusDriver ? EncodeRegisterArray(value, tag) : EncodeRegister(value, tag); - if (bytes.Length == 2 && !tag.ArrayCount.HasValue) + if (bytes.Length == 2 && !tag.ArrayCount.HasValue && !_options.UseFC16ForSingleRegisterWrites) { - // FC06 fast-path for single-register scalar writes only. Arrays always use FC16 - // even when the array is one element wide, because the encoder shape may need it. + // FC06 fast-path for single-register scalar writes only. Arrays always use + // FC16 even when the array is one element wide, because the encoder shape + // may need it. UseFC16ForSingleRegisterWrites=true forces FC16 even here for + // PLCs that only accept the multi-write codes. var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), bytes[0], bytes[1] }; await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs index 9a61139..5c1f3c1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs @@ -41,6 +41,10 @@ public static class ModbusDriverFactoryExtensions Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000), MaxRegistersPerRead = dto.MaxRegistersPerRead ?? 125, MaxRegistersPerWrite = dto.MaxRegistersPerWrite ?? 123, + MaxCoilsPerRead = dto.MaxCoilsPerRead ?? 2000, + UseFC15ForSingleCoilWrites = dto.UseFC15ForSingleCoilWrites ?? false, + UseFC16ForSingleRegisterWrites = dto.UseFC16ForSingleRegisterWrites ?? false, + DisableFC23 = dto.DisableFC23 ?? false, AutoReconnect = dto.AutoReconnect ?? true, Tags = dto.Tags is { Count: > 0 } ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))] @@ -147,6 +151,10 @@ public static class ModbusDriverFactoryExtensions public int? TimeoutMs { get; init; } public ushort? MaxRegistersPerRead { get; init; } public ushort? MaxRegistersPerWrite { get; init; } + public ushort? MaxCoilsPerRead { get; init; } + public bool? UseFC15ForSingleCoilWrites { get; init; } + public bool? UseFC16ForSingleRegisterWrites { get; init; } + public bool? DisableFC23 { get; init; } public bool? AutoReconnect { get; init; } public List? Tags { get; init; } public ModbusProbeDto? Probe { get; init; } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs index 8bafe36..5052a79 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs @@ -46,6 +46,39 @@ public sealed class ModbusDriverOptions /// public ushort MaxRegistersPerWrite { get; init; } = 123; + /// + /// Maximum coils per FC01 (Read Coils) / FC02 (Read Discrete Inputs) transaction. Modbus + /// spec allows up to 2000 bits per request — separate from + /// because the underlying packing is different + /// (1 bit per coil vs 16 bits per register). Default 2000; setting to 0 + /// disables the cap. The driver auto-chunks coil-array reads above the cap. + /// + public ushort MaxCoilsPerRead { get; init; } = 2000; + + /// + /// When true, single-element coil writes use FC15 (Write Multiple Coils) with + /// quantity=1 instead of the default FC05 (Write Single Coil). Safety / audit + /// PLCs that only accept the multi-write function codes need this. Default false + /// preserves the existing FC05 path. + /// + public bool UseFC15ForSingleCoilWrites { get; init; } = false; + + /// + /// When true, single-element holding-register writes use FC16 (Write Multiple + /// Registers) with quantity=1 instead of the default FC06 (Write Single + /// Register). Same use-case as . Default + /// false. + /// + public bool UseFC16ForSingleRegisterWrites { get; init; } = false; + + /// + /// Reserved kill-switch for FC23 (Read/Write Multiple Registers). The driver does not + /// currently emit FC23, so this option is a no-op today but exists so future block-read + /// coalescing work that opts into FC23 can be disabled per-deployment without a code + /// change. Default false (FC23 not used either way today). + /// + public bool DisableFC23 { get; init; } = false; + /// /// When true (default) the built-in detects /// mid-transaction socket failures (, diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusProtocolOptionsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusProtocolOptionsTests.cs new file mode 100644 index 0000000..b1aac03 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusProtocolOptionsTests.cs @@ -0,0 +1,135 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +/// +/// #140 protocol-behavior knobs: MaxCoilsPerRead, UseFC15ForSingleCoilWrites, +/// UseFC16ForSingleRegisterWrites, DisableFC23. Coverage focuses on default behaviour +/// (no change from pre-#140) and the wire-FC selection when the knobs are flipped. +/// +[Trait("Category", "Unit")] +public sealed class ModbusProtocolOptionsTests +{ + private sealed class CapturingTransport : IModbusTransport + { + public readonly List Sent = new(); + public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; + public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) + { + Sent.Add(pdu); + // Return a minimal valid response per FC. We zero-fill the data slot but size it + // correctly so the driver's bitmap walker doesn't IndexOutOfRange on chunked reads. + switch (pdu[0]) + { + case 0x05: case 0x06: case 0x0F: case 0x10: + return Task.FromResult(pdu); // echo + case 0x01: case 0x02: + { + var qty = (ushort)((pdu[3] << 8) | pdu[4]); + var byteCount = (byte)((qty + 7) / 8); + var resp = new byte[2 + byteCount]; + resp[0] = pdu[0]; resp[1] = byteCount; + return Task.FromResult(resp); + } + case 0x03: case 0x04: + { + var qty = (ushort)((pdu[3] << 8) | pdu[4]); + var byteCount = (byte)(qty * 2); + var resp = new byte[2 + byteCount]; + resp[0] = pdu[0]; resp[1] = byteCount; + return Task.FromResult(resp); + } + default: + return Task.FromResult(new byte[] { pdu[0], 0, 0 }); + } + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + [Fact] + public void Defaults_Match_Historical_Behaviour() + { + var opts = new ModbusDriverOptions(); + opts.MaxCoilsPerRead.ShouldBe((ushort)2000); + opts.UseFC15ForSingleCoilWrites.ShouldBeFalse(); + opts.UseFC16ForSingleRegisterWrites.ShouldBeFalse(); + opts.DisableFC23.ShouldBeFalse(); + } + + [Fact] + public async Task Single_Coil_Write_Uses_FC05_By_Default() + { + var fake = new CapturingTransport(); + var tag = new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool); + var drv = new ModbusDriver(new ModbusDriverOptions { Host = "f", Tags = [tag] }, "m1", _ => fake); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None); + + fake.Sent.Last()[0].ShouldBe((byte)0x05); // FC05 Write Single Coil + } + + [Fact] + public async Task Single_Coil_Write_Uses_FC15_When_Forced() + { + var fake = new CapturingTransport(); + var tag = new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool); + var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], UseFC15ForSingleCoilWrites = true }; + var drv = new ModbusDriver(opts, "m1", _ => fake); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None); + + fake.Sent.Last()[0].ShouldBe((byte)0x0F); // FC15 Write Multiple Coils + } + + [Fact] + public async Task Single_Register_Write_Uses_FC06_By_Default() + { + var fake = new CapturingTransport(); + var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16); + var drv = new ModbusDriver(new ModbusDriverOptions { Host = "f", Tags = [tag] }, "m1", _ => fake); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); + + fake.Sent.Last()[0].ShouldBe((byte)0x06); // FC06 Write Single Register + } + + [Fact] + public async Task Single_Register_Write_Uses_FC16_When_Forced() + { + var fake = new CapturingTransport(); + var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16); + var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], UseFC16ForSingleRegisterWrites = true }; + var drv = new ModbusDriver(opts, "m1", _ => fake); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); + + fake.Sent.Last()[0].ShouldBe((byte)0x10); // FC16 Write Multiple Registers + } + + [Fact] + public async Task Coil_Array_Read_Auto_Chunks_At_MaxCoilsPerRead() + { + var fake = new CapturingTransport(); + // 2500 coils with cap 2000 → 2 reads (2000 + 500). + var tag = new ModbusTagDefinition("Big", ModbusRegion.Coils, 0, ModbusDataType.Bool, ArrayCount: 2500); + var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], MaxCoilsPerRead = 2000 }; + var drv = new ModbusDriver(opts, "m1", _ => fake); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.ReadAsync(["Big"], CancellationToken.None); + + // Ignore the probe FC03 from InitializeAsync; count only FC01 reads. + var fc01s = fake.Sent.Where(p => p[0] == 0x01).ToList(); + fc01s.Count.ShouldBe(2); + // First chunk asks for 2000. + ((ushort)((fc01s[0][3] << 8) | fc01s[0][4])).ShouldBe((ushort)2000); + // Second chunk asks for 500. + ((ushort)((fc01s[1][3] << 8) | fc01s[1][4])).ShouldBe((ushort)500); + } +}