Per-tag opt-in for write-retry per docs/v2/plan.md decisions #44, #45, #143. Default is false — writes never auto-retry unless the driver author has marked the tag as safe to replay. Core.Abstractions: - DriverAttributeInfo gains `bool WriteIdempotent = false` at the end of the positional record (back-compatible; every existing call site uses the default). Driver.Modbus: - ModbusTagDefinition gains `bool WriteIdempotent = false`. Safe candidates documented in the param XML: holding-register set-points, configuration registers. Unsafe: edge-triggered coils, counter-increment addresses. - ModbusDriver.DiscoverAsync propagates t.WriteIdempotent into DriverAttributeInfo.WriteIdempotent. Driver.S7: - S7TagDefinition gains `bool WriteIdempotent = false`. Safe candidates: DB word/dword set-points, configuration DBs. Unsafe: M/Q bits that drive edge-triggered program routines. - S7Driver.DiscoverAsync propagates the flag. Stream A.5 integration tests (FlakeyDriverIntegrationTests, 4 new) exercise the invoker + flaky-driver contract the plan enumerates: - Read with 5 transient failures succeeds on the 6th attempt (RetryCount=10). - Non-idempotent write with RetryCount=5 configured still fails on the first failure — no replay (decision #44 guard at the ExecuteWriteAsync surface). - Idempotent write with 2 transient failures succeeds on the 3rd attempt. - Two hosts on the same driver have independent breakers — dead-host trips its breaker but live-host's first call still succeeds. Propagation tests: - ModbusDriverTests: SetPoint WriteIdempotent=true flows into DriverAttributeInfo; PulseCoil default=false. - S7DiscoveryAndSubscribeTests: same pattern for DBx SetPoint vs M-bit. Full solution dotnet test: 947 passing (baseline 906, +41 net across Stream A so far). Pre-existing Client.CLI Subscribe flake unchanged. Stream A's remaining work (wiring CapabilityInvoker into DriverNodeManager's OnReadValue / OnWriteValue / History / Subscribe dispatch paths) is the server-side integration piece + needs DI wiring for the pipeline builder — lands in the next PR on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
262 lines
11 KiB
C#
262 lines
11 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<byte[]> 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<byte[]>(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) { }
|
|
}
|
|
}
|
|
}
|