using System.Buffers.Binary; 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 ModbusDriverTests { /// /// In-memory Modbus TCP server impl that speaks the function codes the driver uses. /// Maintains a register/coil bank so Read/Write round-trips work. /// private sealed class FakeTransport : IModbusTransport { public readonly ushort[] HoldingRegisters = new ushort[256]; public readonly ushort[] InputRegisters = new ushort[256]; public readonly bool[] Coils = new bool[256]; public readonly bool[] DiscreteInputs = new bool[256]; public bool ForceConnectFail { get; set; } public Task ConnectAsync(CancellationToken ct) => ForceConnectFail ? Task.FromException(new InvalidOperationException("connect refused")) : Task.CompletedTask; public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { var fc = pdu[0]; return fc switch { 0x01 => Task.FromResult(ReadBits(pdu, Coils)), 0x02 => Task.FromResult(ReadBits(pdu, DiscreteInputs)), 0x03 => Task.FromResult(ReadRegs(pdu, HoldingRegisters)), 0x04 => Task.FromResult(ReadRegs(pdu, InputRegisters)), 0x05 => Task.FromResult(WriteCoil(pdu)), 0x06 => Task.FromResult(WriteSingleReg(pdu)), 0x10 => Task.FromResult(WriteMultipleRegs(pdu)), _ => Task.FromException(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")), }; } private byte[] ReadBits(byte[] pdu, bool[] bank) { var addr = (ushort)((pdu[1] << 8) | pdu[2]); 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; for (var i = 0; i < qty; i++) if (bank[addr + i]) resp[2 + (i / 8)] |= (byte)(1 << (i % 8)); return resp; } private byte[] ReadRegs(byte[] pdu, ushort[] bank) { var addr = (ushort)((pdu[1] << 8) | pdu[2]); 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; for (var i = 0; i < qty; i++) { resp[2 + i * 2] = (byte)(bank[addr + i] >> 8); resp[3 + i * 2] = (byte)(bank[addr + i] & 0xFF); } return resp; } private byte[] WriteCoil(byte[] pdu) { var addr = (ushort)((pdu[1] << 8) | pdu[2]); Coils[addr] = pdu[3] == 0xFF; return pdu; // Modbus echoes the request on write success } private byte[] WriteSingleReg(byte[] pdu) { var addr = (ushort)((pdu[1] << 8) | pdu[2]); HoldingRegisters[addr] = (ushort)((pdu[3] << 8) | pdu[4]); return pdu; } private byte[] WriteMultipleRegs(byte[] pdu) { var addr = (ushort)((pdu[1] << 8) | pdu[2]); var qty = (ushort)((pdu[3] << 8) | pdu[4]); for (var i = 0; i < qty; i++) HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]); return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] }; } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } private static (ModbusDriver driver, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags) { var fake = new FakeTransport(); var opts = new ModbusDriverOptions { Host = "fake", Tags = tags }; var drv = new ModbusDriver(opts, "modbus-1", _ => fake); return (drv, fake); } [Fact] public async Task Initialize_connects_and_populates_tag_map() { var (drv, _) = NewDriver( new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16), new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool)); await drv.InitializeAsync("{}", CancellationToken.None); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Read_Int16_holding_register_returns_BigEndian_value() { var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 10, ModbusDataType.Int16)); await drv.InitializeAsync("{}", CancellationToken.None); fake.HoldingRegisters[10] = 12345; var r = await drv.ReadAsync(["Level"], CancellationToken.None); r[0].Value.ShouldBe((short)12345); r[0].StatusCode.ShouldBe(0u); } [Fact] public async Task Read_Float32_spans_two_registers_BigEndian() { var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32)); await drv.InitializeAsync("{}", CancellationToken.None); // IEEE 754 single for 25.5f is 0x41CC0000 — [41 CC][00 00] big-endian across two regs. var bytes = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(bytes, 25.5f); fake.HoldingRegisters[4] = (ushort)((bytes[0] << 8) | bytes[1]); fake.HoldingRegisters[5] = (ushort)((bytes[2] << 8) | bytes[3]); var r = await drv.ReadAsync(["Temp"], CancellationToken.None); r[0].Value.ShouldBe(25.5f); } [Fact] public async Task Read_Coil_returns_boolean() { var (drv, fake) = NewDriver(new ModbusTagDefinition("Run", ModbusRegion.Coils, 3, ModbusDataType.Bool)); await drv.InitializeAsync("{}", CancellationToken.None); fake.Coils[3] = true; var r = await drv.ReadAsync(["Run"], CancellationToken.None); r[0].Value.ShouldBe(true); } [Fact] public async Task Unknown_tag_returns_BadNodeIdUnknown_not_an_exception() { var (drv, _) = NewDriver(); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["DoesNotExist"], CancellationToken.None); r[0].StatusCode.ShouldBe(0x80340000u); } [Fact] public async Task Write_UInt16_holding_register_roundtrips() { var (drv, fake) = NewDriver(new ModbusTagDefinition("Setpoint", ModbusRegion.HoldingRegisters, 20, ModbusDataType.UInt16)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync([new WriteRequest("Setpoint", (ushort)42000)], CancellationToken.None); results[0].StatusCode.ShouldBe(0u); fake.HoldingRegisters[20].ShouldBe((ushort)42000); } [Fact] public async Task Write_Float32_uses_FC16_WriteMultipleRegisters() { var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Temp", 25.5f)], CancellationToken.None); // Decode back through the fake bank to check the two-register shape. var raw = new byte[4]; raw[0] = (byte)(fake.HoldingRegisters[4] >> 8); raw[1] = (byte)(fake.HoldingRegisters[4] & 0xFF); raw[2] = (byte)(fake.HoldingRegisters[5] >> 8); raw[3] = (byte)(fake.HoldingRegisters[5] & 0xFF); BinaryPrimitives.ReadSingleBigEndian(raw).ShouldBe(25.5f); } [Fact] public async Task Write_to_InputRegister_returns_BadNotWritable() { var (drv, _) = NewDriver(new ModbusTagDefinition("Ro", ModbusRegion.InputRegisters, 0, ModbusDataType.UInt16, Writable: false)); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.WriteAsync([new WriteRequest("Ro", (ushort)7)], CancellationToken.None); r[0].StatusCode.ShouldBe(0x803B0000u); } [Fact] public async Task Discover_streams_one_folder_per_driver_with_a_variable_per_tag() { var (drv, _) = NewDriver( new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16), new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32), new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool)); await drv.InitializeAsync("{}", CancellationToken.None); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.Count.ShouldBe(1); builder.Folders[0].BrowseName.ShouldBe("Modbus"); builder.Variables.Count.ShouldBe(3); builder.Variables.ShouldContain(v => v.BrowseName == "Level" && v.Info.DriverDataType == DriverDataType.Int32); builder.Variables.ShouldContain(v => v.BrowseName == "Temp" && v.Info.DriverDataType == DriverDataType.Float32); builder.Variables.ShouldContain(v => v.BrowseName == "Run" && v.Info.DriverDataType == DriverDataType.Boolean); } [Fact] public async Task Discover_propagates_WriteIdempotent_from_tag_to_attribute_info() { var (drv, _) = NewDriver( new ModbusTagDefinition("SetPoint", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32, WriteIdempotent: true), new ModbusTagDefinition("PulseCoil", ModbusRegion.Coils, 0, ModbusDataType.Bool)); await drv.InitializeAsync("{}", CancellationToken.None); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, CancellationToken.None); var setPoint = builder.Variables.Single(v => v.BrowseName == "SetPoint"); var pulse = builder.Variables.Single(v => v.BrowseName == "PulseCoil"); setPoint.Info.WriteIdempotent.ShouldBeTrue(); pulse.Info.WriteIdempotent.ShouldBeFalse("default is opt-in per decision #44"); } // --- helpers --- private sealed class RecordingBuilder : IAddressSpaceBuilder { public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } public void AddProperty(string _, DriverDataType __, object? ___) { } private sealed class Handle(string fullRef) : IVariableHandle { public string FullReference => fullRef; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } }