using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; [Trait("Category", "Unit")] public sealed class ModbusBitRmwTests { /// Fake transport capturing each PDU so tests can assert on the read + write sequence. private sealed class RmwTransport : IModbusTransport { public readonly ushort[] HoldingRegisters = new ushort[256]; public readonly List Pdus = new(); /// Connects asynchronously (no-op for fake). /// Cancellation token (unused). /// A completed task. public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; /// Sends a Modbus PDU and returns a response. /// The Modbus unit ID (unused). /// The protocol data unit to send. /// Cancellation token (unused). /// A task containing the response PDU. public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { Pdus.Add(pdu); if (pdu[0] == 0x03) { // FC03 Read Holding Registers. var addr = (ushort)((pdu[1] << 8) | pdu[2]); var qty = (ushort)((pdu[3] << 8) | pdu[4]); var resp = new byte[2 + qty * 2]; resp[0] = 0x03; resp[1] = (byte)(qty * 2); for (var i = 0; i < qty; i++) { resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8); resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF); } return Task.FromResult(resp); } if (pdu[0] == 0x06) { // FC06 Write Single Register. var addr = (ushort)((pdu[1] << 8) | pdu[2]); var v = (ushort)((pdu[3] << 8) | pdu[4]); HoldingRegisters[addr] = v; return Task.FromResult(new byte[] { 0x06, pdu[1], pdu[2], pdu[3], pdu[4] }); } return Task.FromException(new NotSupportedException($"FC 0x{pdu[0]:X2} not supported by fake")); } /// Disposes asynchronously (no-op for fake). /// A completed task. public ValueTask DisposeAsync() => ValueTask.CompletedTask; } private static (ModbusDriver drv, RmwTransport fake) NewDriver(params ModbusTagDefinition[] tags) { var fake = new RmwTransport(); var opts = new ModbusDriverOptions { Host = "fake", Tags = tags, Probe = new ModbusProbeOptions { Enabled = false }, }; return (new ModbusDriver(opts, "modbus-1", _ => fake), fake); } /// Verifies that setting a bit reads the current register, ORs the bit, and writes back. [Fact] public async Task Bit_set_reads_current_register_ORs_bit_writes_back() { var (drv, fake) = NewDriver( new ModbusTagDefinition("Flag3", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 3)); await drv.InitializeAsync("{}", CancellationToken.None); fake.HoldingRegisters[10] = 0b0000_0001; // bit 0 already set var results = await drv.WriteAsync([new WriteRequest("Flag3", true)], CancellationToken.None); results.Single().StatusCode.ShouldBe(0u); fake.HoldingRegisters[10].ShouldBe((ushort)0b0000_1001); // bit 3 now set, bit 0 preserved // Two PDUs: FC03 read then FC06 write. fake.Pdus.Count.ShouldBe(2); fake.Pdus[0][0].ShouldBe((byte)0x03); fake.Pdus[1][0].ShouldBe((byte)0x06); } /// Verifies that clearing a bit reads the current register, ANDs the bit off, and writes back. [Fact] public async Task Bit_clear_reads_current_register_ANDs_bit_off_writes_back() { var (drv, fake) = NewDriver( new ModbusTagDefinition("Flag3", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 3)); await drv.InitializeAsync("{}", CancellationToken.None); fake.HoldingRegisters[10] = 0xFFFF; // all bits set await drv.WriteAsync([new WriteRequest("Flag3", false)], CancellationToken.None); fake.HoldingRegisters[10].ShouldBe((ushort)0b1111_1111_1111_0111); // bit 3 cleared, rest preserved } /// Verifies that concurrent bit writes to the same register preserve all updates via serialization. [Fact] public async Task Concurrent_bit_writes_to_same_register_preserve_all_updates() { // Serialization test — 8 writers target different bits in register 20. Without the RMW // lock, concurrent reads interleave + last-to-commit wins so some bits get lost. var tags = Enumerable.Range(0, 8) .Select(b => new ModbusTagDefinition($"Bit{b}", ModbusRegion.HoldingRegisters, 20, ModbusDataType.BitInRegister, BitIndex: (byte)b)) .ToArray(); var (drv, fake) = NewDriver(tags); await drv.InitializeAsync("{}", CancellationToken.None); fake.HoldingRegisters[20] = 0; await Task.WhenAll(Enumerable.Range(0, 8).Select(b => drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None))); fake.HoldingRegisters[20].ShouldBe((ushort)0xFF); // all 8 bits set } /// Verifies that bit writes to different registers proceed in parallel without contention. [Fact] public async Task Bit_write_on_different_registers_proceeds_in_parallel_without_contention() { var tags = Enumerable.Range(0, 4) .Select(i => new ModbusTagDefinition($"Bit{i}", ModbusRegion.HoldingRegisters, (ushort)(50 + i), ModbusDataType.BitInRegister, BitIndex: 0)) .ToArray(); var (drv, fake) = NewDriver(tags); await drv.InitializeAsync("{}", CancellationToken.None); await Task.WhenAll(Enumerable.Range(0, 4).Select(i => drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None))); for (var i = 0; i < 4; i++) fake.HoldingRegisters[50 + i].ShouldBe((ushort)0x01); } /// Verifies that bit writes preserve other bits in the same register. [Fact] public async Task Bit_write_preserves_other_bits_in_the_same_register() { var (drv, fake) = NewDriver( new ModbusTagDefinition("BitA", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 5), new ModbusTagDefinition("BitB", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 10)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("BitA", true)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("BitB", true)], CancellationToken.None); fake.HoldingRegisters[30].ShouldBe((ushort)((1 << 5) | (1 << 10))); } // ---- Driver.Modbus-013: RMW read-response not validated before indexing ---- /// /// Transport that returns a too-short PDU for FC03 reads — simulates a buggy device /// returning a malformed response during a BitInRegister read-modify-write. Pre-fix, /// WriteBitInRegisterAsync indexed readResp[2]/[3] without checking resp.Length, causing /// IndexOutOfRangeException. Post-fix it throws InvalidDataException which WriteAsync's /// catch-all maps to BadInternalError. /// private sealed class TruncatedRmwTransport : IModbusTransport { /// Connects asynchronously (no-op for fake). /// Cancellation token (unused). public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; /// Returns a truncated FC03 response to trigger the validation path. /// The Modbus unit ID (unused). /// The PDU to process. /// Cancellation token (unused). public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { if (pdu[0] == 0x03) // Return only [fc][byte-count=2] — missing the actual register data bytes. return Task.FromResult(new byte[] { 0x03, 0x02 }); // FC06 write echoes the PDU return Task.FromResult(pdu); } /// Disposes asynchronously (no-op for fake). public ValueTask DisposeAsync() => ValueTask.CompletedTask; } /// /// Driver.Modbus-013/014: a truncated FC03 read response during a BitInRegister RMW must /// surface as BadCommunicationError, not BadInternalError (a structural/driver-code /// problem) — and not as an IndexOutOfRangeException that escapes WriteAsync uncaught. /// [Fact] public async Task BitInRegister_write_with_truncated_FC03_response_returns_BadCommunicationError() { var truncated = new TruncatedRmwTransport(); var opts = new ModbusDriverOptions { Host = "fake", Tags = [new ModbusTagDefinition("Bit5", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 5)], Probe = new ModbusProbeOptions { Enabled = false }, }; var drv = new ModbusDriver(opts, "modbus-rmw-trunc", _ => truncated); await drv.InitializeAsync("{}", CancellationToken.None); // WriteAsync must complete (not throw) and return BadCommunicationError — the same // status the ReadAsync path returns for a truncated response. BadInternalError (0x80020000) // is wrong because the failure is a communication/protocol error, not a driver bug. const uint BadCommunicationError = 0x80050000u; var results = await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None); results.Single().StatusCode.ShouldBe(BadCommunicationError, "A truncated FC03 RMW response is a communication error, not an internal driver error"); } }