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) { }
}
}
}