using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// #741 (Integration-W3 fix round): every multi-write PDU the driver builds is EXACTLY its header plus the bytes its /// byte-count field declares — FC15 and FC16 are 6 + byteCount, FC23 is 10 + writeByteCount. The /// builders used to allocate one byte more than they filled, so every FC15 / FC16 / FC23 (and the BitInRegister[] /// read-modify-write's FC16) carried a trailing zero byte past the declared data — which a strict device or gateway /// that checks the frame length against the byte count refuses. /// [Trait("Category", "Unit")] public sealed class ModbusWritePduFramingTests { private const string Ref = "arr"; /// Records every PDU; answers reads with zeros and multi-writes with the standard echo. private sealed class CapturingTransport : IModbusTransport { public readonly List Sent = []; public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { Sent.Add(pdu); var qty = (pdu[3] << 8) | pdu[4]; return Task.FromResult(pdu[0] switch { 0x01 or 0x02 => [pdu[0], (byte)((qty + 7) / 8), .. new byte[(qty + 7) / 8]], 0x03 or 0x04 or 0x17 => [pdu[0], (byte)(qty * 2), .. new byte[qty * 2]], 0x0F or 0x10 => new[] { pdu[0], pdu[1], pdu[2], pdu[3], pdu[4] }, _ => pdu, }); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } private static async Task WriteAndCaptureAsync(string tagConfig, object value, byte functionCode, bool fc23 = false) { var transport = new CapturingTransport(); var drv = new ModbusDriver( new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry(Ref, tagConfig, false)], UseFC23ForMultiRegisterWrites = fc23, }, "modbus-framing", _ => transport); await drv.InitializeAsync("{}", CancellationToken.None); (await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None)).Single().StatusCode.ShouldBe(0u); return transport.Sent.Single(p => p[0] == functionCode); } [Theory] [InlineData(1)] [InlineData(8)] [InlineData(9)] public async Task FC15_pdu_is_exactly_header_plus_byte_count(int coils) { var pdu = await WriteAndCaptureAsync( $$"""{"region":"Coils","address":0,"isArray":true,"arrayLength":{{coils}}}""", Enumerable.Repeat(true, coils).ToArray(), 0x0F); pdu[5].ShouldBe((byte)((coils + 7) / 8)); pdu.Length.ShouldBe(6 + pdu[5]); } [Theory] [InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Int16","isArray":true,"arrayLength":3}""", 6)] [InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Float64","isArray":true,"arrayLength":2}""", 16)] [InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Int32"}""", 4)] // scalar multi-register public async Task FC16_pdu_is_exactly_header_plus_byte_count(string tagConfig, int byteCount) { object value = tagConfig.Contains("isArray") ? new[] { 1, 2, 3 }.Take(tagConfig.Contains("Float64") ? 2 : 3).ToArray() : 7; var pdu = await WriteAndCaptureAsync(tagConfig, value, 0x10); pdu[5].ShouldBe((byte)byteCount); pdu.Length.ShouldBe(6 + pdu[5]); } [Fact] public async Task BitInRegister_array_RMW_FC16_pdu_is_exactly_header_plus_byte_count() { var pdu = await WriteAndCaptureAsync( """{"region":"HoldingRegisters","address":0,"dataType":"BitInRegister","bitIndex":2,"isArray":true,"arrayLength":4}""", new[] { true, false, true, false }, 0x10); pdu[5].ShouldBe((byte)8); pdu.Length.ShouldBe(6 + pdu[5]); } [Fact] public async Task FC23_pdu_is_exactly_header_plus_write_byte_count() { var pdu = await WriteAndCaptureAsync( """{"region":"HoldingRegisters","address":0,"dataType":"Int16","isArray":true,"arrayLength":3}""", new[] { 1, 2, 3 }, 0x17, fc23: true); pdu[9].ShouldBe((byte)6); pdu.Length.ShouldBe(10 + pdu[9]); } }