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. Internal (rather than /// private) so sibling test files in this project can reuse it without duplicating the /// fake. /// internal 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]; /// Gets or sets a value indicating whether connect operations should fail. public bool ForceConnectFail { get; set; } /// Initiates a connection to the Modbus server. /// Cancellation token. public Task ConnectAsync(CancellationToken ct) => ForceConnectFail ? Task.FromException(new InvalidOperationException("connect refused")) : Task.CompletedTask; /// Sends a Modbus PDU and receives the response. /// Modbus unit ID. /// Protocol data unit bytes to send. /// Cancellation token. 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)), 0x0F => Task.FromResult(WriteMultipleCoils(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] }; } private byte[] WriteMultipleCoils(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++) Coils[addr + i] = ((pdu[6 + (i / 8)] >> (i % 8)) & 0x01) == 1; return new byte[] { 0x0F, pdu[1], pdu[2], pdu[3], pdu[4] }; } /// Disposes the transport asynchronously. 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); } /// Verifies that Initialize connects and populates the tag map. [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); } /// Verifies that reading Int16 holding registers returns big-endian values correctly. [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); } /// Verifies that reading Float32 values spans two registers in big-endian format. [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); } /// Verifies that reading coils returns boolean values. [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); } /// Verifies that reading unknown tags returns BadNodeIdUnknown status instead of throwing. [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); } /// Verifies that writing UInt16 holding registers round-trips correctly. [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); } /// Verifies that writing Float32 values uses function code 16 (WriteMultipleRegisters). [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); } /// Verifies that writing to input registers returns BadNotWritable status. [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); } /// Verifies that Discover streams one folder per driver with a variable per tag. [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); } /// Verifies that Discover propagates WriteIdempotent from tag to attribute info. [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 --- /// Records discovered address space structure for testing. private sealed class RecordingBuilder : IAddressSpaceBuilder { /// Gets the list of discovered folders. public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); /// Gets the list of discovered variables. public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); /// Records a folder in the address space. /// Folder browse name. /// Folder display name. public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } /// Records a variable in the address space. /// Variable browse name. /// Variable display name. /// Driver attribute information. public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } /// Adds a property (no-op for recording). /// Property name (unused). /// Property data type (unused). /// Property value (unused). public void AddProperty(string _, DriverDataType __, object? ___) { } /// Handle to a discovered variable. private sealed class Handle(string fullRef) : IVariableHandle { /// Gets the full reference name. public string FullReference => fullRef; /// Marks this variable as an alarm condition. /// Alarm condition information. public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } /// No-op alarm condition sink for testing. private sealed class NullSink : IAlarmConditionSink { /// Handles alarm transitions (no-op). /// Alarm event arguments. public void OnTransition(AlarmEventArgs args) { } } } }