chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -0,0 +1,139 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class DirectLogicAddressTests
{
[Theory]
[InlineData("V0", (ushort)0x0000)]
[InlineData("V1", (ushort)0x0001)]
[InlineData("V7", (ushort)0x0007)]
[InlineData("V10", (ushort)0x0008)]
[InlineData("V2000", (ushort)0x0400)] // canonical DL205/DL260 user-memory start
[InlineData("V7777", (ushort)0x0FFF)]
[InlineData("V10000", (ushort)0x1000)]
[InlineData("V17777", (ushort)0x1FFF)]
public void UserVMemoryToPdu_converts_octal_V_prefix(string v, ushort expected)
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
[Theory]
[InlineData("0", (ushort)0)]
[InlineData("2000", (ushort)0x0400)]
[InlineData("v2000", (ushort)0x0400)] // lowercase v
[InlineData(" V2000 ", (ushort)0x0400)] // surrounding whitespace
public void UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded(string v, ushort expected)
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
[Theory]
[InlineData("V8")] // 8 is not a valid octal digit
[InlineData("V19")]
[InlineData("V2009")]
public void UserVMemoryToPdu_rejects_non_octal_digits(string v)
{
Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v))
.Message.ShouldContain("octal");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("V")]
public void UserVMemoryToPdu_rejects_empty_input(string? v)
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v!));
[Fact]
public void UserVMemoryToPdu_overflow_rejected()
{
// 200000 octal = 0x10000 — one past ushort range.
Should.Throw<OverflowException>(() => DirectLogicAddress.UserVMemoryToPdu("V200000"));
}
[Fact]
public void SystemVMemoryBasePdu_is_0x2100_for_V40400()
{
// V40400 on DL260 / H2-ECOM100 absolute mode → PDU 0x2100 (decimal 8448), NOT 0x4100
// which a naive octal-to-decimal of 40400 octal would give (= 16640).
DirectLogicAddress.SystemVMemoryBasePdu.ShouldBe((ushort)0x2100);
DirectLogicAddress.SystemVMemoryToPdu(0).ShouldBe((ushort)0x2100);
}
[Fact]
public void SystemVMemoryToPdu_offsets_within_bank()
{
DirectLogicAddress.SystemVMemoryToPdu(1).ShouldBe((ushort)0x2101);
DirectLogicAddress.SystemVMemoryToPdu(0x100).ShouldBe((ushort)0x2200);
}
[Fact]
public void SystemVMemoryToPdu_rejects_overflow()
{
// ushort wrap: 0xFFFF - 0x2100 = 0xDEFF; anything above should throw.
Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF));
Should.Throw<OverflowException>(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00));
}
// --- Bit memory: Y-output, C-relay, X-input, SP-special ---
[Theory]
[InlineData("Y0", (ushort)2048)]
[InlineData("Y1", (ushort)2049)]
[InlineData("Y7", (ushort)2055)]
[InlineData("Y10", (ushort)2056)] // octal 10 = decimal 8
[InlineData("Y17", (ushort)2063)] // octal 17 = decimal 15
[InlineData("Y777", (ushort)2559)] // top of DL260 Y range per doc table
public void YOutputToCoil_adds_octal_offset_to_2048(string y, ushort expected)
=> DirectLogicAddress.YOutputToCoil(y).ShouldBe(expected);
[Theory]
[InlineData("C0", (ushort)3072)]
[InlineData("C1", (ushort)3073)]
[InlineData("C10", (ushort)3080)]
[InlineData("C1777", (ushort)4095)] // top of DL260 C range
public void CRelayToCoil_adds_octal_offset_to_3072(string c, ushort expected)
=> DirectLogicAddress.CRelayToCoil(c).ShouldBe(expected);
[Theory]
[InlineData("X0", (ushort)0)]
[InlineData("X17", (ushort)15)]
[InlineData("X777", (ushort)511)] // top of DL260 X range
public void XInputToDiscrete_adds_octal_offset_to_0(string x, ushort expected)
=> DirectLogicAddress.XInputToDiscrete(x).ShouldBe(expected);
[Theory]
[InlineData("SP0", (ushort)1024)]
[InlineData("SP7", (ushort)1031)]
[InlineData("sp0", (ushort)1024)] // lowercase prefix
[InlineData("SP777", (ushort)1535)]
public void SpecialToDiscrete_adds_octal_offset_to_1024(string sp, ushort expected)
=> DirectLogicAddress.SpecialToDiscrete(sp).ShouldBe(expected);
[Theory]
[InlineData("Y8")]
[InlineData("C9")]
[InlineData("X18")]
public void Bit_address_rejects_non_octal_digits(string bad)
=> Should.Throw<ArgumentException>(() =>
{
if (bad[0] == 'Y') DirectLogicAddress.YOutputToCoil(bad);
else if (bad[0] == 'C') DirectLogicAddress.CRelayToCoil(bad);
else DirectLogicAddress.XInputToDiscrete(bad);
});
[Theory]
[InlineData("Y")]
[InlineData("C")]
[InlineData("")]
public void Bit_address_rejects_empty(string bad)
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.YOutputToCoil(bad));
[Fact]
public void YOutputToCoil_accepts_lowercase_prefix()
=> DirectLogicAddress.YOutputToCoil("y0").ShouldBe((ushort)2048);
[Fact]
public void CRelayToCoil_accepts_bare_octal_without_C_prefix()
=> DirectLogicAddress.CRelayToCoil("0").ShouldBe((ushort)3072);
}

View File

@@ -0,0 +1,116 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class MelsecAddressTests
{
// --- X / Y hex vs octal family trap ---
[Theory]
[InlineData("X0", (ushort)0)]
[InlineData("X9", (ushort)9)]
[InlineData("XA", (ushort)10)] // hex
[InlineData("XF", (ushort)15)]
[InlineData("X10", (ushort)16)] // hex 0x10 = decimal 16
[InlineData("X20", (ushort)32)] // hex 0x20 = decimal 32 — the classic MELSEC-Q trap
[InlineData("X1FF", (ushort)511)]
[InlineData("x10", (ushort)16)] // lowercase prefix
public void XInputToDiscrete_QLiQR_parses_hex(string x, ushort expected)
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.Q_L_iQR).ShouldBe(expected);
[Theory]
[InlineData("X0", (ushort)0)]
[InlineData("X7", (ushort)7)]
[InlineData("X10", (ushort)8)] // octal 10 = decimal 8
[InlineData("X20", (ushort)16)] // octal 20 = decimal 16 — SAME string, DIFFERENT value on FX
[InlineData("X777", (ushort)511)]
public void XInputToDiscrete_FiQF_parses_octal(string x, ushort expected)
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.F_iQF).ShouldBe(expected);
[Theory]
[InlineData("Y0", (ushort)0)]
[InlineData("Y1F", (ushort)31)]
public void YOutputToCoil_QLiQR_parses_hex(string y, ushort expected)
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.Q_L_iQR).ShouldBe(expected);
[Theory]
[InlineData("Y0", (ushort)0)]
[InlineData("Y17", (ushort)15)]
public void YOutputToCoil_FiQF_parses_octal(string y, ushort expected)
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.F_iQF).ShouldBe(expected);
[Fact]
public void Same_address_string_decodes_differently_between_families()
{
// This is the headline quirk: "X20" in GX Works means one thing on Q-series and
// another on FX-series. The driver's family selector is the only defence.
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32);
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16);
}
[Theory]
[InlineData("X8")] // 8 is non-octal
[InlineData("X12G")] // G is non-hex
public void XInputToDiscrete_FiQF_rejects_non_octal(string bad)
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.F_iQF));
[Theory]
[InlineData("X12G")]
public void XInputToDiscrete_QLiQR_rejects_non_hex(string bad)
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.Q_L_iQR));
[Fact]
public void XInputToDiscrete_honors_bank_base_from_assignment_block()
{
// Real-world QJ71MT91 assignment blocks commonly place X at DI 8192+ when other
// ranges take the low Modbus addresses. Helper must add the base cleanly.
MelsecAddress.XInputToDiscrete("X10", MelsecFamily.Q_L_iQR, xBankBase: 8192).ShouldBe((ushort)(8192 + 16));
}
// --- M-relay (decimal, both families) ---
[Theory]
[InlineData("M0", (ushort)0)]
[InlineData("M10", (ushort)10)] // M addresses are DECIMAL, not hex or octal
[InlineData("M511", (ushort)511)]
[InlineData("m99", (ushort)99)] // lowercase
public void MRelayToCoil_parses_decimal(string m, ushort expected)
=> MelsecAddress.MRelayToCoil(m).ShouldBe(expected);
[Fact]
public void MRelayToCoil_honors_bank_base()
=> MelsecAddress.MRelayToCoil("M0", mBankBase: 512).ShouldBe((ushort)512);
[Fact]
public void MRelayToCoil_rejects_non_numeric()
=> Should.Throw<ArgumentException>(() => MelsecAddress.MRelayToCoil("M1F"));
// --- D-register (decimal, both families) ---
[Theory]
[InlineData("D0", (ushort)0)]
[InlineData("D100", (ushort)100)]
[InlineData("d1023", (ushort)1023)]
public void DRegisterToHolding_parses_decimal(string d, ushort expected)
=> MelsecAddress.DRegisterToHolding(d).ShouldBe(expected);
[Fact]
public void DRegisterToHolding_honors_bank_base()
=> MelsecAddress.DRegisterToHolding("D10", dBankBase: 4096).ShouldBe((ushort)4106);
[Fact]
public void DRegisterToHolding_rejects_empty()
=> Should.Throw<ArgumentException>(() => MelsecAddress.DRegisterToHolding("D"));
// --- overflow ---
[Fact]
public void XInputToDiscrete_overflow_throws()
{
// 0xFFFF + base 1 = 0x10000 — past ushort.
Should.Throw<OverflowException>(() =>
MelsecAddress.XInputToDiscrete("XFFFF", MelsecFamily.Q_L_iQR, xBankBase: 1));
}
}

View File

@@ -0,0 +1,172 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Round-trip coverage for #137 array support — read N consecutive registers (or coils)
/// and surface them as a typed OPC UA array. Builds on the FakeTransport in
/// <see cref="ModbusDriverTests"/>; tests are co-located with the rest of the in-memory
/// driver coverage so they all share the same harness.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusArrayTests
{
private static (ModbusDriver driver, ModbusDriverTests.FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
{
var fake = new ModbusDriverTests.FakeTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = tags };
var drv = new ModbusDriver(opts, "modbus-array", _ => fake);
return (drv, fake);
}
[Fact]
public async Task Read_Int16_Array_Returns_Typed_Array()
{
var tag = new ModbusTagDefinition("Levels", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, ArrayCount: 5);
var (drv, fake) = NewDriver(tag);
for (var i = 0; i < 5; i++) fake.HoldingRegisters[i] = (ushort)(100 + i);
await drv.InitializeAsync("{}", CancellationToken.None);
var values = await drv.ReadAsync(["Levels"], CancellationToken.None);
var arr = values[0].Value.ShouldBeOfType<short[]>();
arr.ShouldBe(new short[] { 100, 101, 102, 103, 104 });
}
[Fact]
public async Task Read_Float32_Array_Returns_Typed_Array_With_WordSwap()
{
var tag = new ModbusTagDefinition("Temps", ModbusRegion.HoldingRegisters, 10, ModbusDataType.Float32,
ArrayCount: 3, ByteOrder: ModbusByteOrder.WordSwap);
var (drv, fake) = NewDriver(tag);
// Pre-encode 3 floats into the fake bank using the matching CDAB layout.
// Float 1.5f = 0x3FC00000; word-swap → low word in high reg pair: reg0=0x0000, reg1=0x3FC0.
// Loop encodes 1.5, 2.5, 3.5.
var src = new[] { 1.5f, 2.5f, 3.5f };
for (var i = 0; i < src.Length; i++)
{
var bytes = BitConverter.GetBytes(src[i]);
// BitConverter is little-endian on x86; rearrange to big-endian register pair.
// CDAB means: reg(addr+0) holds bytes[1..0] (low word), reg(addr+1) holds bytes[3..2] (high word).
fake.HoldingRegisters[10 + i * 2 + 0] = (ushort)((bytes[1] << 8) | bytes[0]);
fake.HoldingRegisters[10 + i * 2 + 1] = (ushort)((bytes[3] << 8) | bytes[2]);
}
await drv.InitializeAsync("{}", CancellationToken.None);
var values = await drv.ReadAsync(["Temps"], CancellationToken.None);
var arr = values[0].Value.ShouldBeOfType<float[]>();
arr.ShouldBe(src);
}
[Fact]
public async Task Read_Coil_Array_Returns_Bool_Array()
{
var tag = new ModbusTagDefinition("Flags", ModbusRegion.Coils, 0, ModbusDataType.Bool, ArrayCount: 10);
var (drv, fake) = NewDriver(tag);
// alternating pattern: T F T F T F T F T F
for (var i = 0; i < 10; i++) fake.Coils[i] = i % 2 == 0;
await drv.InitializeAsync("{}", CancellationToken.None);
var values = await drv.ReadAsync(["Flags"], CancellationToken.None);
var arr = values[0].Value.ShouldBeOfType<bool[]>();
arr.ShouldBe(new[] { true, false, true, false, true, false, true, false, true, false });
}
[Fact]
public async Task Write_Int16_Array_Lands_Contiguous_In_Bank()
{
var tag = new ModbusTagDefinition("Setpoints", ModbusRegion.HoldingRegisters, 50, ModbusDataType.Int16, ArrayCount: 4);
var (drv, fake) = NewDriver(tag);
await drv.InitializeAsync("{}", CancellationToken.None);
var write = new[] { (short)10, (short)20, (short)30, (short)40 };
var results = await drv.WriteAsync(
new[] { new WriteRequest("Setpoints", write) },
CancellationToken.None);
results[0].StatusCode.ShouldBe(0u);
for (var i = 0; i < 4; i++)
fake.HoldingRegisters[50 + i].ShouldBe((ushort)write[i]);
}
[Fact]
public async Task Write_Coil_Array_Packs_LSB_First()
{
var tag = new ModbusTagDefinition("Outputs", ModbusRegion.Coils, 0, ModbusDataType.Bool, ArrayCount: 10);
var (drv, fake) = NewDriver(tag);
await drv.InitializeAsync("{}", CancellationToken.None);
var pattern = new[] { true, true, false, true, false, false, true, false, true, true };
var results = await drv.WriteAsync(
new[] { new WriteRequest("Outputs", pattern) },
CancellationToken.None);
results[0].StatusCode.ShouldBe(0u);
for (var i = 0; i < pattern.Length; i++)
fake.Coils[i].ShouldBe(pattern[i]);
}
[Fact]
public async Task Write_Array_Mismatch_Length_Surfaces_Error()
{
var tag = new ModbusTagDefinition("Setpoints", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, ArrayCount: 4);
var (drv, _) = NewDriver(tag);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
new[] { new WriteRequest("Setpoints", new short[] { 1, 2, 3 }) }, // 3 != 4
CancellationToken.None);
results[0].StatusCode.ShouldNotBe(0u);
}
[Fact]
public async Task Discovery_Surfaces_IsArray_And_ArrayDim()
{
var tag = new ModbusTagDefinition("Vector", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32, ArrayCount: 8);
var (drv, _) = NewDriver(tag);
await drv.InitializeAsync("{}", CancellationToken.None);
var captured = new List<DriverAttributeInfo>();
await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None);
captured.Count.ShouldBe(1);
captured[0].IsArray.ShouldBeTrue();
captured[0].ArrayDim.ShouldBe(8u);
}
[Fact]
public async Task Scalar_Tag_Discovery_Stays_NonArray()
{
// Regression guard: scalar tags must keep IsArray=false / ArrayDim=null.
var tag = new ModbusTagDefinition("Single", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var (drv, _) = NewDriver(tag);
await drv.InitializeAsync("{}", CancellationToken.None);
var captured = new List<DriverAttributeInfo>();
await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None);
captured[0].IsArray.ShouldBeFalse();
captured[0].ArrayDim.ShouldBeNull();
}
private sealed class RecordingBuilder(List<DriverAttributeInfo> captured) : IAddressSpaceBuilder
{
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
captured.Add(attributeInfo);
return new StubHandle(browseName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class StubHandle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
=> throw new NotSupportedException("RecordingBuilder doesn't model alarms");
}
}
}

View File

@@ -0,0 +1,141 @@
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
{
/// <summary>Fake transport capturing each PDU so tests can assert on the read + write sequence.</summary>
private sealed class RmwTransport : IModbusTransport
{
public readonly ushort[] HoldingRegisters = new ushort[256];
public readonly List<byte[]> Pdus = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> 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<byte[]>(new NotSupportedException($"FC 0x{pdu[0]:X2} not supported by fake"));
}
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);
}
[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);
}
[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
}
[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
}
[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);
}
[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)));
}
}

View File

@@ -0,0 +1,73 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Coverage for the new ByteSwap (BADC) and FullReverse (DCBA) byte orders added by #137.
/// The existing BigEndian (ABCD) and WordSwap (CDAB) cases live in <see cref="ModbusDataTypeTests"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusByteOrderTests
{
[Fact]
public void Int32_ByteSwap_decodes_BADC_layout()
{
// Value 0x12345678. PLC stores bytes within each register swapped:
// register[0] = 0x3412, register[1] = 0x7856 → wire [0x34, 0x12, 0x78, 0x56].
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
ByteOrder: ModbusByteOrder.ByteSwap);
var bytes = new byte[] { 0x34, 0x12, 0x78, 0x56 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
}
[Fact]
public void Int32_FullReverse_decodes_DCBA_layout()
{
// Value 0x12345678 stored fully little-endian:
// wire [0x78, 0x56, 0x34, 0x12].
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
ByteOrder: ModbusByteOrder.FullReverse);
var bytes = new byte[] { 0x78, 0x56, 0x34, 0x12 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
}
[Theory]
[InlineData(ModbusByteOrder.BigEndian)]
[InlineData(ModbusByteOrder.WordSwap)]
[InlineData(ModbusByteOrder.ByteSwap)]
[InlineData(ModbusByteOrder.FullReverse)]
public void Float32_All_ByteOrders_Roundtrip(ModbusByteOrder order)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32, ByteOrder: order);
var wire = ModbusDriver.EncodeRegister(3.14159f, tag);
wire.Length.ShouldBe(4);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(3.14159f);
}
[Theory]
[InlineData(ModbusByteOrder.BigEndian)]
[InlineData(ModbusByteOrder.WordSwap)]
[InlineData(ModbusByteOrder.ByteSwap)]
[InlineData(ModbusByteOrder.FullReverse)]
public void Float64_All_ByteOrders_Roundtrip(ModbusByteOrder order)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float64, ByteOrder: order);
var wire = ModbusDriver.EncodeRegister(2.718281828459045d, tag);
wire.Length.ShouldBe(8);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(2.718281828459045d);
}
[Theory]
[InlineData(ModbusByteOrder.BigEndian)]
[InlineData(ModbusByteOrder.WordSwap)]
[InlineData(ModbusByteOrder.ByteSwap)]
[InlineData(ModbusByteOrder.FullReverse)]
public void Int32_All_ByteOrders_Roundtrip(ModbusByteOrder order)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32, ByteOrder: order);
var wire = ModbusDriver.EncodeRegister(unchecked((int)0xDEADBEEF), tag);
wire.Length.ShouldBe(4);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(unchecked((int)0xDEADBEEF));
}
}

View File

@@ -0,0 +1,165 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class ModbusCapTests
{
/// <summary>
/// Records every PDU sent so tests can assert request-count and per-request quantity —
/// the only observable behaviour of the auto-chunking path.
/// </summary>
private sealed class RecordingTransport : IModbusTransport
{
public readonly ushort[] HoldingRegisters = new ushort[1024];
public readonly List<(ushort Address, ushort Quantity)> Fc03Requests = new();
public readonly List<(ushort Address, ushort Quantity)> Fc16Requests = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var fc = pdu[0];
if (fc == 0x03)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
Fc03Requests.Add((addr, qty));
var byteCount = (byte)(qty * 2);
var resp = new byte[2 + byteCount];
resp[0] = 0x03;
resp[1] = byteCount;
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 (fc == 0x10)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
Fc16Requests.Add((addr, qty));
for (var i = 0; i < qty; i++)
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
}
return Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} unsupported"));
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Read_within_cap_issues_single_FC03_request()
{
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 40); // 20 regs — fits in default cap (125).
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["S"], TestContext.Current.CancellationToken);
transport.Fc03Requests.Count.ShouldBe(1);
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)20);
}
[Fact]
public async Task Read_above_cap_splits_into_two_FC03_requests()
{
// 240-char string = 120 regs. Cap = 100 (a typical sub-spec device cap). Expect 100 + 20.
var tag = new ModbusTagDefinition("LongString", ModbusRegion.HoldingRegisters, 100, ModbusDataType.String,
StringLength: 240);
var transport = new RecordingTransport();
// Seed cells so the re-assembled payload is stable — confirms chunks are stitched in order.
for (ushort i = 100; i < 100 + 120; i++)
transport.HoldingRegisters[i] = (ushort)((('A' + (i - 100) % 26) << 8) | ('A' + (i - 100) % 26));
var opts = new ModbusDriverOptions
{
Host = "fake",
Tags = [tag],
MaxRegistersPerRead = 100,
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.ReadAsync(["LongString"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
transport.Fc03Requests.Count.ShouldBe(2, "120 regs / cap 100 → 2 requests");
transport.Fc03Requests[0].ShouldBe(((ushort)100, (ushort)100));
transport.Fc03Requests[1].ShouldBe(((ushort)200, (ushort)20));
// Payload continuity: re-assembled string starts where register 100 does and keeps going.
var s = (string)results[0].Value!;
s.Length.ShouldBeGreaterThan(0);
s[0].ShouldBe('A'); // register[100] high byte
}
[Fact]
public async Task Read_cap_honors_Mitsubishi_lower_cap_of_64()
{
// 200-char string = 100 regs. Mitsubishi Q cap = 64. Expect: 64, 36.
var tag = new ModbusTagDefinition("MitString", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 200);
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerRead = 64, Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["MitString"], TestContext.Current.CancellationToken);
transport.Fc03Requests.Count.ShouldBe(2);
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)64);
transport.Fc03Requests[1].Quantity.ShouldBe((ushort)36);
}
[Fact]
public async Task Write_exceeding_cap_throws_instead_of_splitting()
{
// Partial FC16 across two transactions is not atomic. Forcing an explicit exception so the
// caller knows their tag definition is incompatible with the device cap rather than silently
// writing half a string and crashing between chunks.
var tag = new ModbusTagDefinition("LongStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 220); // 110 regs.
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[new WriteRequest("LongStringWrite", new string('A', 220))],
TestContext.Current.CancellationToken);
// Driver catches the internal exception and surfaces BadInternalError — the Fc16Requests
// list must still be empty because nothing was sent.
results[0].StatusCode.ShouldNotBe(0u);
transport.Fc16Requests.Count.ShouldBe(0);
}
[Fact]
public async Task Write_within_cap_proceeds_normally()
{
var tag = new ModbusTagDefinition("ShortStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 40); // 20 regs.
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[new WriteRequest("ShortStringWrite", "HELLO")],
TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
transport.Fc16Requests.Count.ShouldBe(1);
transport.Fc16Requests[0].Quantity.ShouldBe((ushort)20);
}
}

View File

@@ -0,0 +1,220 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #148 — block-coalescing auto-recovery from protected register holes. When a coalesced
/// FC03 fails with a Modbus exception, the planner records the failed range and stops
/// re-coalescing across it on subsequent scans. Healthy tags around the protected hole
/// keep working without operator intervention.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCoalescingAutoRecoveryTests
{
/// <summary>
/// Programmable transport that returns IllegalDataAddress (Modbus exception code 0x02)
/// when a read covers a configured "protected" register address. Otherwise responds
/// normally with zero-filled data of the requested size.
/// </summary>
private sealed class ProtectedHoleTransport : IModbusTransport
{
public ushort ProtectedAddress { get; set; } = ushort.MaxValue;
public readonly List<(byte Fc, ushort Address, ushort Quantity)> Reads = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 or 0x04) Reads.Add((pdu[0], addr, qty));
// If the protected address falls within the request span, return a Modbus exception
// PDU. The driver's transport layer detects exceptions by the high bit on the FC.
if (pdu[0] is 0x03 or 0x04 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
return Task.FromException<byte[]>(new ModbusException(pdu[0], 0x02, "IllegalDataAddress"));
switch (pdu[0])
{
case 0x03: case 0x04:
{
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task First_Failure_Falls_Back_To_PerTag_Same_Scan()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
// Three tags: 100, 102 (protected), 104. With MaxReadGap=5, the coalesced block is
// 100..104 — covers the protected register, so FC03 quantity=5 fails. Pre-#148 marked
// ALL three Bad. Post-#148, the failure auto-falls back to per-tag in the same scan
// so 100 and 104 still surface Good values.
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
var values = await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
// T100 + T104 should fall through per-tag and succeed; T102 is the protected register
// and surfaces the exception status code at single-tag granularity.
values[0].StatusCode.ShouldBe(0u, "T100 should succeed via per-tag fallback");
values[2].StatusCode.ShouldBe(0u, "T104 should succeed via per-tag fallback");
values[1].StatusCode.ShouldNotBe(0u, "T102 is the protected address — single-tag read still surfaces the exception");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Second_Scan_Skips_Coalesced_Read_Of_Prohibited_Range()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// Scan 1: planner forms 100..104 block, fails, records the prohibition.
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
var scan1Reads = fake.Reads.Count;
// Scan 2: planner sees the prohibition, doesn't form the 100..104 block, falls back to
// per-tag for everyone. Total scan-2 PDUs: 3 (one per tag) — vs 1 failed coalesced
// read + 3 per-tag fallbacks if we re-tried the merge.
fake.Reads.Clear();
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
var coalescedAttemptedAgain = fake.Reads.Any(r => r.Address == 100 && r.Quantity > 1);
coalescedAttemptedAgain.ShouldBeFalse("planner must NOT re-attempt the prohibited block");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Reprobe_Clears_Prohibition_When_Range_Becomes_Healthy()
{
// #151 — when AutoProhibitReprobeInterval is set, the background loop retries each
// prohibition periodically. We exercise that via the test-only RunReprobeOnceForTestAsync
// helper rather than waiting for the timer (which would slow the suite).
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// Scan 1: coalesced read fails, prohibition recorded.
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Operator unlocks the protected register at the PLC (firmware update etc.). The
// re-probe should now succeed and clear the prohibition.
fake.ProtectedAddress = ushort.MaxValue;
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(0, "re-probe must clear the prohibition once the range is healthy");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Reprobe_Leaves_Prohibition_When_Range_Is_Still_Bad()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Re-probe with the protected register still bad — prohibition stays.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1, "re-probe failure must keep the prohibition in place");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot()
{
// #152 — diagnostic accessor returns the live prohibition map as a snapshot of public
// ModbusAutoProhibition records. Consumers (Admin UI, dashboards) project this list
// into whatever shape they need.
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", UnitId = 7, Tags = [t100, t102, t104], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// Pre-failure: nothing prohibited.
drv.GetAutoProhibitedRanges().ShouldBeEmpty();
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
var snapshot = drv.GetAutoProhibitedRanges();
snapshot.Count.ShouldBe(1);
snapshot[0].UnitId.ShouldBe((byte)7);
snapshot[0].Region.ShouldBe(ModbusRegion.HoldingRegisters);
snapshot[0].StartAddress.ShouldBe((ushort)100);
snapshot[0].EndAddress.ShouldBe((ushort)104);
snapshot[0].BisectionPending.ShouldBeTrue("multi-register prohibition starts split-pending");
snapshot[0].LastProbedUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Tags_Outside_Prohibited_Range_Still_Coalesce()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
// Tags split across the protected boundary: cluster 100..104 (will fail) and cluster
// 200..204 (well clear of the protected register). The 200-cluster should keep
// coalescing on subsequent scans even after the 100-cluster is prohibited.
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var t200 = new ModbusTagDefinition("T200", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16);
var t202 = new ModbusTagDefinition("T202", ModbusRegion.HoldingRegisters, 202, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104, t200, t202], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T100", "T102", "T104", "T200", "T202"], CancellationToken.None);
fake.Reads.Clear();
await drv.ReadAsync(["T100", "T102", "T104", "T200", "T202"], CancellationToken.None);
// The 200..202 block should still coalesce — its range doesn't overlap the
// 100..104 prohibition.
var coalesced200Block = fake.Reads.Any(r => r.Address == 200 && r.Quantity == 3);
coalesced200Block.ShouldBeTrue("the 200..202 block must keep coalescing — it's outside the prohibited range");
await drv.ShutdownAsync(CancellationToken.None);
}
}

View File

@@ -0,0 +1,171 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #150 — bisection-style range narrowing for coalescing prohibitions. After a coalesced
/// read fails, the re-probe loop bisects the prohibited range over multiple ticks until
/// it pinpoints the actual protected register(s). Healthy halves get cleared as the
/// bisection narrows.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCoalescingBisectionTests
{
/// <summary>
/// Programmable transport like the one in ModbusCoalescingAutoRecoveryTests but local
/// to keep this test file standalone — having the protection model live next to the
/// bisection assertions makes the test intent easier to read.
/// </summary>
private sealed class ProtectedHoleTransport : IModbusTransport
{
public ushort ProtectedAddress { get; set; } = ushort.MaxValue;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 or 0x04 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
return Task.FromException<byte[]>(new ModbusException(pdu[0], 0x02, "IllegalDataAddress"));
switch (pdu[0])
{
case 0x03: case 0x04:
{
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Bisection_Narrows_Multi_Register_Prohibition_Per_Reprobe()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 105 };
// 11 tags 100..110 with MaxReadGap=10 → coalesce into one block 100..110. The protected
// register is in the middle (105). After the first failure the planner records the
// full 100..110 range as split-pending. Each subsequent re-probe bisects until the
// prohibition is pinned at register 105.
var tags = Enumerable.Range(100, 11)
.Select(i => new ModbusTagDefinition($"T{i}", ModbusRegion.HoldingRegisters, (ushort)i, ModbusDataType.Int16))
.ToArray();
var opts = new ModbusDriverOptions { Host = "f", Tags = tags, MaxReadGap = 10,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(tags.Select(t => t.Name).ToArray(), CancellationToken.None);
// Initial prohibition: full 100..110 range, split-pending.
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Re-probe pass 1: bisect 100..110 → mid=105 → left=100..105 (fails because 105 is
// protected), right=106..110 (succeeds). Result: prohibition collapses to 100..105.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1, "after pass 1 the prohibition narrows but doesn't disappear");
// Re-probe pass 2: bisect 100..105 → mid=102 → left=100..102 (succeeds), right=103..105 (fails).
// Result: prohibition collapses to 103..105.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
// Re-probe pass 3: bisect 103..105 → mid=104 → left=103..104 (succeeds), right=105..105 (fails).
// Result: prohibition collapses to 105..105 (single register, no longer split-pending).
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1, "single-register prohibition stays after bisection terminates");
// Re-probe pass 4: 105..105 is single-register; straight-retry path. Still fails;
// prohibition stays.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Bisection_Clears_When_Both_Halves_Are_Healthy()
{
// Transient failure scenario: range failed once, but by the next re-probe the PLC has
// unlocked it. Bisection of (100..110) returns success on both halves → entry removed
// entirely.
var fake = new ProtectedHoleTransport { ProtectedAddress = 105 };
var tags = Enumerable.Range(100, 11)
.Select(i => new ModbusTagDefinition($"T{i}", ModbusRegion.HoldingRegisters, (ushort)i, ModbusDataType.Int16))
.ToArray();
var opts = new ModbusDriverOptions { Host = "f", Tags = tags, MaxReadGap = 10,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(tags.Select(t => t.Name).ToArray(), CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Operator unlocks the protected register before the re-probe runs.
fake.ProtectedAddress = ushort.MaxValue;
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(0, "both bisected halves succeed → parent prohibition cleared entirely");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Bisection_Splits_Into_Two_When_Both_Halves_Still_Fail()
{
// Two protected registers in the same coalesced range: 102 and 108. After bisection,
// both halves of the original (100..110) range still contain a protected address
// (left=100..105 contains 102, right=106..110 contains 108). The prohibition replaces
// the parent with TWO smaller split-pending entries.
var fake = new ProtectedHoleTransport();
// Build a more elaborate transport that protects two addresses.
var twoHole = new TwoHoleTransport { ProtectedAddresses = { 102, 108 } };
var tags = Enumerable.Range(100, 11)
.Select(i => new ModbusTagDefinition($"T{i}", ModbusRegion.HoldingRegisters, (ushort)i, ModbusDataType.Int16))
.ToArray();
var opts = new ModbusDriverOptions { Host = "f", Tags = tags, MaxReadGap = 10,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => twoHole);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(tags.Select(t => t.Name).ToArray(), CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Re-probe: bisect 100..110 at mid=105 → left=100..105 (contains 102, fails),
// right=106..110 (contains 108, fails). Result: TWO entries in place of the parent.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(2, "both halves still fail → prohibition splits into two");
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class TwoHoleTransport : IModbusTransport
{
public readonly HashSet<ushort> ProtectedAddresses = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 or 0x04)
for (var i = 0; i < qty; i++)
if (ProtectedAddresses.Contains((ushort)(addr + i)))
return Task.FromException<byte[]>(new ModbusException(pdu[0], 0x02, "IllegalDataAddress"));
switch (pdu[0])
{
case 0x03: case 0x04:
{
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,176 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #143 block-read coalescing: with MaxReadGap > 0 the driver merges nearby tags into a
/// single FC03/FC04 read. Coverage focuses on the planner output (PDU count + quantity)
/// rather than wire bytes — those are tested by ModbusDriverTests.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCoalescingTests
{
private sealed class CountingTransport : IModbusTransport
{
public readonly List<(byte Unit, byte Fc, ushort Address, ushort Quantity)> Reads = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 or 0x04) Reads.Add((unitId, pdu[0], addr, qty));
switch (pdu[0])
{
case 0x03: case 0x04:
{
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads()
{
var fake = new CountingTransport();
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 0,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
// With coalescing off, expect 2 separate FC03 reads.
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(2);
}
[Fact]
public async Task MaxReadGap_Bridges_Two_Adjacent_Tags_Into_One_Read()
{
var fake = new CountingTransport();
// Three tags within 5 registers: T1@100, T2@102, T3@104. Gaps: 1, 1. MaxReadGap=2 → 1 block.
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2, t3], MaxReadGap = 2,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(1);
fc03Reads[0].Address.ShouldBe((ushort)100);
fc03Reads[0].Quantity.ShouldBe((ushort)5); // 100..104
}
[Fact]
public async Task MaxReadGap_Splits_When_Gap_Exceeds_Threshold()
{
var fake = new CountingTransport();
// T1@100, T2@102 (gap 1, joins block), T3@200 (gap 97 → exceeds gap=10 → second block).
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2, t3], MaxReadGap = 10,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(2); // T1+T2 coalesced; T3 alone
}
[Fact]
public async Task CoalesceProhibited_Tag_Reads_Alone()
{
var fake = new CountingTransport();
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16, CoalesceProhibited: true);
var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2, t3], MaxReadGap = 10,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
// T2 read alone (CoalesceProhibited). T1 and T3 coalesce (gap = 3 within MaxReadGap=10).
// Expect 2 reads total.
fc03Reads.Count.ShouldBe(2);
}
[Fact]
public async Task Coalescing_Does_Not_Cross_UnitId_Boundaries()
{
var fake = new CountingTransport();
// Same Region + adjacent addresses but different UnitIds → must NOT coalesce.
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16, UnitId: 1);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16, UnitId: 2);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 100,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(2);
fc03Reads.Select(r => r.Unit).Distinct().Count().ShouldBe(2);
}
[Fact]
public async Task Coalescing_Splits_Block_That_Exceeds_MaxRegistersPerRead()
{
var fake = new CountingTransport();
// T1@0, T2@200 with MaxReadGap=300 would naturally form one block of 201 registers,
// but MaxRegistersPerRead=125 caps it. The planner should NOT coalesce because the
// resulting span exceeds the cap — it falls back to two separate reads.
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 300,
MaxRegistersPerRead = 125, Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(2);
}
[Fact]
public async Task Coalesced_Read_Surfaces_Each_Tag_Value_Independently()
{
// Sanity check: after coalescing the per-tag values must still be correct (no
// index-shift bugs in the slice math).
var fake = new CountingTransport();
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
var values = await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
values.Count.ShouldBe(2);
values[0].StatusCode.ShouldBe(0u);
values[1].StatusCode.ShouldBe(0u);
// The fake returns zeros for our values; the assertion is on quality + that the slice
// didn't mis-index (a bug there would surface as IndexOutOfRange / wrong type).
}
}

View File

@@ -0,0 +1,101 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #139 connection-layer config knobs: keep-alive, idle-disconnect, reconnect backoff.
/// Coverage focuses on default behaviour (matches pre-#139 wire output exactly) and the
/// DTO-binding path so users can drive these from JSON without editing C#.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusConnectionOptionsTests
{
[Fact]
public void Defaults_Match_Historical_Behaviour()
{
var opts = new ModbusDriverOptions();
opts.KeepAlive.Enabled.ShouldBeTrue();
opts.KeepAlive.Time.ShouldBe(TimeSpan.FromSeconds(30));
opts.KeepAlive.Interval.ShouldBe(TimeSpan.FromSeconds(10));
opts.KeepAlive.RetryCount.ShouldBe(3);
opts.IdleDisconnectTimeout.ShouldBeNull();
opts.Reconnect.InitialDelay.ShouldBe(TimeSpan.Zero);
opts.Reconnect.MaxDelay.ShouldBe(TimeSpan.FromSeconds(30));
opts.Reconnect.BackoffMultiplier.ShouldBe(2.0);
}
[Fact]
public void Factory_Reads_KeepAlive_Knobs_From_Json()
{
const string json = """
{
"host": "10.0.0.10",
"tags": [],
"keepAlive": { "enabled": false, "timeMs": 60000, "intervalMs": 5000, "retryCount": 5 }
}
""";
var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json);
// Reach into options via reflection — the factory's options field is internal.
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.KeepAlive.Enabled.ShouldBeFalse();
opts.KeepAlive.Time.ShouldBe(TimeSpan.FromMinutes(1));
opts.KeepAlive.Interval.ShouldBe(TimeSpan.FromSeconds(5));
opts.KeepAlive.RetryCount.ShouldBe(5);
}
[Fact]
public void Factory_Reads_IdleDisconnect_From_Json()
{
const string json = """{ "host": "10.0.0.10", "tags": [], "idleDisconnectMs": 120000 }""";
var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.IdleDisconnectTimeout.ShouldBe(TimeSpan.FromMinutes(2));
}
[Fact]
public void Factory_Reads_Reconnect_Backoff_From_Json()
{
const string json = """
{
"host": "10.0.0.10",
"tags": [],
"reconnect": { "initialDelayMs": 500, "maxDelayMs": 60000, "backoffMultiplier": 1.5 }
}
""";
var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Reconnect.InitialDelay.ShouldBe(TimeSpan.FromMilliseconds(500));
opts.Reconnect.MaxDelay.ShouldBe(TimeSpan.FromMinutes(1));
opts.Reconnect.BackoffMultiplier.ShouldBe(1.5);
}
[Fact]
public void Factory_With_Empty_Json_Uses_All_Defaults()
{
const string json = """{ "host": "10.0.0.10", "tags": [] }""";
var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
// Every connection-layer field must match the historical defaults so existing config
// rows stay bit-for-bit identical after #139.
opts.KeepAlive.Enabled.ShouldBeTrue();
opts.IdleDisconnectTimeout.ShouldBeNull();
opts.Reconnect.InitialDelay.ShouldBe(TimeSpan.Zero);
opts.AutoReconnect.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,318 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class ModbusDataTypeTests
{
/// <summary>
/// Register-count lookup is per-tag now (strings need StringLength; Int64/Float64 need 4).
/// </summary>
[Theory]
[InlineData(ModbusDataType.BitInRegister, 1)]
[InlineData(ModbusDataType.Int16, 1)]
[InlineData(ModbusDataType.UInt16, 1)]
[InlineData(ModbusDataType.Int32, 2)]
[InlineData(ModbusDataType.UInt32, 2)]
[InlineData(ModbusDataType.Float32, 2)]
[InlineData(ModbusDataType.Int64, 4)]
[InlineData(ModbusDataType.UInt64, 4)]
[InlineData(ModbusDataType.Float64, 4)]
public void RegisterCount_returns_correct_register_count_per_type(ModbusDataType t, int expected)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, t);
ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expected);
}
[Theory]
[InlineData(0, 1)] // 0 chars → still 1 byte / 1 register (pathological but well-defined: length 0 is 0 bytes)
[InlineData(1, 1)]
[InlineData(2, 1)]
[InlineData(3, 2)]
[InlineData(10, 5)]
public void RegisterCount_for_String_rounds_up_to_register_pair(ushort chars, int expectedRegs)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String, StringLength: chars);
// 0-char is encoded as 0 regs; the test case expects 1 for lengths 1-2, 2 for 3-4, etc.
if (chars == 0) ModbusDriver.RegisterCount(tag).ShouldBe((ushort)0);
else ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expectedRegs);
}
// --- Int32 / UInt32 / Float32 with byte-order variants ---
[Fact]
public void Int32_BigEndian_decodes_ABCD_layout()
{
// Value 0x12345678 → bytes [0x12, 0x34, 0x56, 0x78] as PLC wrote them.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
ByteOrder: ModbusByteOrder.BigEndian);
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
}
[Fact]
public void Int32_WordSwap_decodes_CDAB_layout()
{
// Siemens/AB PLC stored 0x12345678 as register[0] = 0x5678, register[1] = 0x1234.
// Wire bytes are [0x56, 0x78, 0x12, 0x34]; with ByteOrder=WordSwap we get 0x12345678 back.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
ByteOrder: ModbusByteOrder.WordSwap);
var bytes = new byte[] { 0x56, 0x78, 0x12, 0x34 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
}
[Fact]
public void Float32_WordSwap_encode_decode_roundtrips()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32,
ByteOrder: ModbusByteOrder.WordSwap);
var wire = ModbusDriver.EncodeRegister(25.5f, tag);
wire.Length.ShouldBe(4);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(25.5f);
}
// --- Int64 / UInt64 / Float64 ---
[Fact]
public void Int64_BigEndian_roundtrips()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int64);
var wire = ModbusDriver.EncodeRegister(0x0123456789ABCDEFL, tag);
wire.Length.ShouldBe(8);
BinaryPrimitives.ReadInt64BigEndian(wire).ShouldBe(0x0123456789ABCDEFL);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(0x0123456789ABCDEFL);
}
[Fact]
public void UInt64_WordSwap_reverses_four_words()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.UInt64,
ByteOrder: ModbusByteOrder.WordSwap);
var value = 0xAABBCCDDEEFF0011UL;
var wireBE = new byte[8];
BinaryPrimitives.WriteUInt64BigEndian(wireBE, value);
// Word-swap layout: [word3, word2, word1, word0] where each word keeps its bytes big-endian.
var wireWS = new byte[] { wireBE[6], wireBE[7], wireBE[4], wireBE[5], wireBE[2], wireBE[3], wireBE[0], wireBE[1] };
ModbusDriver.DecodeRegister(wireWS, tag).ShouldBe(value);
var roundtrip = ModbusDriver.EncodeRegister(value, tag);
roundtrip.ShouldBe(wireWS);
}
[Fact]
public void Float64_roundtrips_under_word_swap()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float64,
ByteOrder: ModbusByteOrder.WordSwap);
var wire = ModbusDriver.EncodeRegister(3.14159265358979d, tag);
wire.Length.ShouldBe(8);
((double)ModbusDriver.DecodeRegister(wire, tag)!).ShouldBe(3.14159265358979d, tolerance: 1e-12);
}
// --- BitInRegister ---
[Theory]
[InlineData(0b0000_0000_0000_0001, 0, true)]
[InlineData(0b0000_0000_0000_0001, 1, false)]
[InlineData(0b1000_0000_0000_0000, 15, true)]
[InlineData(0b0100_0000_0100_0000, 6, true)]
[InlineData(0b0100_0000_0100_0000, 14, true)]
[InlineData(0b0100_0000_0100_0000, 7, false)]
public void BitInRegister_extracts_bit_at_index(ushort raw, byte bitIndex, bool expected)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
BitIndex: bitIndex);
var bytes = new byte[] { (byte)(raw >> 8), (byte)(raw & 0xFF) };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(expected);
}
[Fact]
public void BitInRegister_EncodeRegister_still_rejects_direct_calls()
{
// BitInRegister writes now go through WriteBitInRegisterAsync's RMW path (task #181).
// EncodeRegister should never be reached for this type — if it is, throwing keeps an
// unintended caller loud rather than silently clobbering the register.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
BitIndex: 5);
Should.Throw<InvalidOperationException>(() => ModbusDriver.EncodeRegister(true, tag))
.Message.ShouldContain("WriteBitInRegisterAsync");
}
// --- String ---
[Fact]
public void String_decodes_ASCII_packed_two_chars_per_register()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 6);
// "HELLO!" = 0x48 0x45 0x4C 0x4C 0x4F 0x21 across 3 registers.
var bytes = "HELLO!"u8.ToArray();
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("HELLO!");
}
[Fact]
public void String_decode_truncates_at_first_nul()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 10);
var bytes = new byte[] { 0x48, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("Hi");
}
[Fact]
public void String_encode_nul_pads_remaining_bytes()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 8);
var wire = ModbusDriver.EncodeRegister("Hi", tag);
wire.Length.ShouldBe(8);
wire[0].ShouldBe((byte)'H');
wire[1].ShouldBe((byte)'i');
for (var i = 2; i < 8; i++) wire[i].ShouldBe((byte)0);
}
// --- DL205 low-byte-first strings (AutomationDirect DirectLOGIC quirk) ---
[Fact]
public void String_LowByteFirst_decodes_DL205_packed_Hello()
{
// HR[1040] = 0x6548 (wire BE bytes [0x65, 0x48]) decodes first char from low byte = 'H',
// second from high byte = 'e'. HR[1041] = 0x6C6C → 'l','l'. HR[1042] = 0x006F → 'o', nul.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 5, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
var wire = new byte[] { 0x65, 0x48, 0x6C, 0x6C, 0x00, 0x6F };
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
}
[Fact]
public void String_LowByteFirst_decode_truncates_at_first_nul()
{
// Low-byte-first with only 2 real chars in register 0 (lo='H', hi='i') and the rest nul.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 6, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
var wire = new byte[] { 0x69, 0x48, 0x00, 0x00, 0x00, 0x00 };
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hi");
}
[Fact]
public void String_LowByteFirst_encode_round_trips_with_decode()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 5, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
var wire = ModbusDriver.EncodeRegister("Hello", tag);
// Expect exactly the DL205-documented byte sequence.
wire.ShouldBe(new byte[] { 0x65, 0x48, 0x6C, 0x6C, 0x00, 0x6F });
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
}
[Fact]
public void String_HighByteFirst_and_LowByteFirst_differ_on_same_wire()
{
// Same wire buffer, different byte order → first char switches 'H' vs 'e'.
var wire = new byte[] { 0x48, 0x65 };
var hi = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 2, StringByteOrder: ModbusStringByteOrder.HighByteFirst);
var lo = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 2, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
ModbusDriver.DecodeRegister(wire, hi).ShouldBe("He");
ModbusDriver.DecodeRegister(wire, lo).ShouldBe("eH");
}
// --- BCD (binary-coded decimal, DL205/DL260 default numeric encoding) ---
[Theory]
[InlineData(0x0000u, 0u)]
[InlineData(0x0001u, 1u)]
[InlineData(0x0009u, 9u)]
[InlineData(0x0010u, 10u)]
[InlineData(0x1234u, 1234u)]
[InlineData(0x9999u, 9999u)]
public void DecodeBcd_16_bit_decodes_expected_decimal(uint raw, uint expected)
=> ModbusDriver.DecodeBcd(raw, nibbles: 4).ShouldBe(expected);
[Fact]
public void DecodeBcd_rejects_nibbles_above_nine()
{
Should.Throw<InvalidDataException>(() => ModbusDriver.DecodeBcd(0x00A5u, nibbles: 4))
.Message.ShouldContain("Non-BCD nibble");
}
[Theory]
[InlineData(0u, 0x0000u)]
[InlineData(5u, 0x0005u)]
[InlineData(42u, 0x0042u)]
[InlineData(1234u, 0x1234u)]
[InlineData(9999u, 0x9999u)]
public void EncodeBcd_16_bit_encodes_expected_nibbles(uint value, uint expected)
=> ModbusDriver.EncodeBcd(value, nibbles: 4).ShouldBe(expected);
[Fact]
public void Bcd16_decodes_DL205_register_1234_as_decimal_1234()
{
// HR[1072] = 0x1234 on the DL205 profile represents decimal 1234. A plain Int16 decode
// would return 0x04D2 = 4660 — proof the BCD path is different.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34 }, tag).ShouldBe(1234);
var int16Tag = tag with { DataType = ModbusDataType.Int16 };
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34 }, int16Tag).ShouldBe((short)0x1234);
}
[Fact]
public void Bcd16_encode_round_trips_with_decode()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
var wire = ModbusDriver.EncodeRegister(4321, tag);
wire.ShouldBe(new byte[] { 0x43, 0x21 });
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(4321);
}
[Fact]
public void Bcd16_encode_rejects_out_of_range_values()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
Should.Throw<OverflowException>(() => ModbusDriver.EncodeRegister(10000, tag))
.Message.ShouldContain("4 decimal digits");
}
[Fact]
public void Bcd32_decodes_8_digits_big_endian()
{
// 0x12345678 as BCD = decimal 12_345_678.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34, 0x56, 0x78 }, tag).ShouldBe(12_345_678);
}
[Fact]
public void Bcd32_word_swap_handles_CDAB_layout()
{
// PLC stored 12_345_678 with word swap: low-word 0x5678 first, high-word 0x1234 second.
// Wire bytes [0x56, 0x78, 0x12, 0x34] + WordSwap → decode to decimal 12_345_678.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32,
ByteOrder: ModbusByteOrder.WordSwap);
ModbusDriver.DecodeRegister(new byte[] { 0x56, 0x78, 0x12, 0x34 }, tag).ShouldBe(12_345_678);
}
[Fact]
public void Bcd32_encode_round_trips_with_decode()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
var wire = ModbusDriver.EncodeRegister(87_654_321u, tag);
wire.ShouldBe(new byte[] { 0x87, 0x65, 0x43, 0x21 });
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(87_654_321);
}
[Fact]
public void Bcd_RegisterCount_matches_underlying_width()
{
var b16 = new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
var b32 = new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
ModbusDriver.RegisterCount(b16).ShouldBe((ushort)1);
ModbusDriver.RegisterCount(b32).ShouldBe((ushort)2);
}
}

View File

@@ -0,0 +1,273 @@
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. Internal (rather than
/// private) so sibling test files in this project can reuse it without duplicating the
/// fake.
/// </summary>
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];
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)),
0x0F => Task.FromResult(WriteMultipleCoils(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] };
}
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] };
}
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) { }
}
}
}

View File

@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Unit tests for the Modbus-exception-code → OPC UA StatusCode mapping added in PR 52.
/// Before PR 52 every server exception + every transport failure collapsed to
/// BadInternalError (0x80020000), which made field diagnosis "is this a bad tag or a bad
/// driver?" impossible. These tests lock in the translation table documented on
/// <see cref="ModbusDriver.MapModbusExceptionToStatus"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusExceptionMapperTests
{
[Theory]
[InlineData((byte)0x01, 0x803D0000u)] // Illegal Function → BadNotSupported
[InlineData((byte)0x02, 0x803C0000u)] // Illegal Data Address → BadOutOfRange
[InlineData((byte)0x03, 0x803C0000u)] // Illegal Data Value → BadOutOfRange
[InlineData((byte)0x04, 0x80550000u)] // Server Failure → BadDeviceFailure
[InlineData((byte)0x05, 0x80550000u)] // Acknowledge (long op) → BadDeviceFailure
[InlineData((byte)0x06, 0x80550000u)] // Server Busy → BadDeviceFailure
[InlineData((byte)0x0A, 0x80050000u)] // Gateway path unavailable → BadCommunicationError
[InlineData((byte)0x0B, 0x80050000u)] // Gateway target failed to respond → BadCommunicationError
[InlineData((byte)0xFF, 0x80020000u)] // Unknown code → BadInternalError fallback
public void MapModbusExceptionToStatus_returns_informative_status(byte code, uint expected)
=> ModbusDriver.MapModbusExceptionToStatus(code).ShouldBe(expected);
private sealed class ExceptionRaisingTransport(byte exceptionCode) : IModbusTransport
{
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromException<byte[]>(new ModbusException(pdu[0], exceptionCode, $"fc={pdu[0]} code={exceptionCode}"));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Read_surface_exception_02_as_BadOutOfRange_not_BadInternalError()
{
var transport = new ExceptionRaisingTransport(exceptionCode: 0x02);
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x803C0000u, "FC03 at an unmapped register must bubble out as BadOutOfRange so operators can spot a bad tag config");
}
[Fact]
public async Task Write_surface_exception_04_as_BadDeviceFailure()
{
var transport = new ExceptionRaisingTransport(exceptionCode: 0x04);
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var writes = await drv.WriteAsync(
[new WriteRequest("T", (short)42)],
TestContext.Current.CancellationToken);
writes[0].StatusCode.ShouldBe(0x80550000u, "FC06 returning exception 04 (CPU in PROGRAM mode) maps to BadDeviceFailure");
}
private sealed class NonModbusFailureTransport : IModbusTransport
{
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromException<byte[]>(new EndOfStreamException("socket closed mid-response"));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Read_non_modbus_failure_maps_to_BadCommunicationError_not_BadInternalError()
{
// Socket drop / timeout / malformed frame → transport-layer failure. Should surface
// distinctly from tag-level faults so operators know to check the network, not the config.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => new NonModbusFailureTransport());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x80050000u);
}
}

View File

@@ -0,0 +1,99 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #153 — confirm ModbusDriver emits structured warnings on first-fire of an
/// auto-prohibition and informational logs on re-probe clearance. The logger plumbing
/// extends through ModbusDriverFactoryExtensions.Register so production server-bootstrap
/// paths get the logger automatically; here we exercise the constructor injection
/// directly.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusLoggerInjectionTests
{
private sealed class CapturingLogger : ILogger<ModbusDriver>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } }
}
private sealed class ProtectedHoleTransport : IModbusTransport
{
public ushort ProtectedAddress { get; set; } = 102;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
return Task.FromException<byte[]>(new ModbusException(0x03, 0x02, "IllegalDataAddress"));
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task First_Failure_Emits_Single_Warning_Subsequent_Refire_Stays_Quiet()
{
var fake = new ProtectedHoleTransport();
var logger = new CapturingLogger();
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "drv-logged", _ => fake, logger: logger);
await drv.InitializeAsync("{}", CancellationToken.None);
// Scan 1 — coalesced read fails. Expect exactly one warning.
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
warnings.Count.ShouldBe(1);
warnings[0].Message.ShouldContain("drv-logged");
warnings[0].Message.ShouldContain("Start=100");
warnings[0].Message.ShouldContain("End=104");
// Scan 2 — same coalesced range still fails. Re-fire is suppressed (planner sees
// the prohibition and skips the merge; even if it didn't, the de-dupe in
// RecordAutoProhibition would suppress).
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1, "re-fire of same range stays silent");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Reprobe_Clearing_Prohibition_Emits_Information_Log()
{
var fake = new ProtectedHoleTransport();
var logger = new CapturingLogger();
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
AutoProhibitReprobeInterval = TimeSpan.FromHours(1), // long interval — we drive it manually
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "drv-logged", _ => fake, logger: logger);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
// Operator unlocks the protected register; re-probe should clear + log.
fake.ProtectedAddress = ushort.MaxValue;
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
var infoLogs = logger.Entries.Where(e => e.Level == LogLevel.Information && e.Message.Contains("cleared")).ToList();
infoLogs.Count.ShouldBeGreaterThanOrEqualTo(1, "re-probe success must emit a 'cleared' info log");
await drv.ShutdownAsync(CancellationToken.None);
}
}

View File

@@ -0,0 +1,101 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #142 multi-unit-ID gateway support: per-tag UnitId override + IPerCallHostResolver +
/// wire-level routing of UnitId in the MBAP header per-PDU.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusMultiUnitTests
{
private sealed class UnitCapturingTransport : IModbusTransport
{
public readonly List<byte> SeenUnitIds = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
SeenUnitIds.Add(unitId);
switch (pdu[0])
{
case 0x03: case 0x04:
{
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task PerTag_UnitId_Routes_To_Correct_Slave_In_MBAP()
{
var fake = new UnitCapturingTransport();
var tagSlave1 = new ModbusTagDefinition("S1Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, UnitId: 1);
var tagSlave5 = new ModbusTagDefinition("S5Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, UnitId: 5);
var opts = new ModbusDriverOptions { Host = "f", UnitId = 99, Tags = [tagSlave1, tagSlave5],
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["S1Temp", "S5Temp"], CancellationToken.None);
// Two reads: one for slave 1, one for slave 5. Driver-level UnitId=99 must NOT appear.
fake.SeenUnitIds.ShouldContain((byte)1);
fake.SeenUnitIds.ShouldContain((byte)5);
fake.SeenUnitIds.ShouldNotContain((byte)99);
}
[Fact]
public async Task Tag_Without_UnitId_Falls_Back_To_DriverLevel()
{
var fake = new UnitCapturingTransport();
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16); // no UnitId override
var opts = new ModbusDriverOptions { Host = "f", UnitId = 7, Tags = [tag],
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T"], CancellationToken.None);
fake.SeenUnitIds.ShouldContain((byte)7);
}
[Fact]
public async Task IPerCallHostResolver_Returns_Per_Slave_Host_String()
{
var fake = new UnitCapturingTransport();
var t1 = new ModbusTagDefinition("S1Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, UnitId: 1);
var t5 = new ModbusTagDefinition("S5Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, UnitId: 5);
var opts = new ModbusDriverOptions { Host = "10.1.2.3", Port = 502, Tags = [t1, t5],
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// The pipeline keys breakers on these strings; distinct slave IDs must produce distinct
// host strings so per-PLC isolation works.
var resolver = (IPerCallHostResolver)drv;
resolver.ResolveHost("S1Temp").ShouldBe("10.1.2.3:502/unit1");
resolver.ResolveHost("S5Temp").ShouldBe("10.1.2.3:502/unit5");
resolver.ResolveHost("S1Temp").ShouldNotBe(resolver.ResolveHost("S5Temp"));
}
[Fact]
public async Task IPerCallHostResolver_Unknown_Tag_Falls_Back_To_HostName()
{
var fake = new UnitCapturingTransport();
var opts = new ModbusDriverOptions { Host = "10.1.2.3", Port = 502, Tags = [],
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
var resolver = (IPerCallHostResolver)drv;
resolver.ResolveHost("never-defined").ShouldBe("10.1.2.3:502");
}
}

View File

@@ -0,0 +1,208 @@
using System.Collections.Concurrent;
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 ModbusProbeTests
{
/// <summary>
/// Transport fake the probe tests flip between "responding" and "unreachable" to
/// exercise the state machine. Calls to SendAsync with FC=0x03 count as probe traffic
/// (the driver's probe loop issues exactly that shape).
/// </summary>
private sealed class FlappyTransport : IModbusTransport
{
public volatile bool Reachable = true;
public int ProbeCount;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] == 0x03) Interlocked.Increment(ref ProbeCount);
if (!Reachable)
return Task.FromException<byte[]>(new IOException("transport unreachable"));
// Happy path — return a valid FC03 response for 1 register at addr.
if (pdu[0] == 0x03)
{
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var resp = new byte[2 + qty * 2];
resp[0] = 0x03;
resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
return Task.FromException<byte[]>(new NotSupportedException());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static (ModbusDriver drv, FlappyTransport fake) NewDriver(ModbusProbeOptions probe)
{
var fake = new FlappyTransport();
var opts = new ModbusDriverOptions { Host = "fake", Port = 502, Probe = probe };
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
}
[Fact]
public async Task Initial_state_is_Unknown_before_first_probe_tick()
{
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
await drv.InitializeAsync("{}", CancellationToken.None);
var statuses = drv.GetHostStatuses();
statuses.Count.ShouldBe(1);
statuses[0].State.ShouldBe(HostState.Unknown);
statuses[0].HostName.ShouldBe("fake:502");
}
[Fact]
public async Task First_successful_probe_transitions_to_Running()
{
var (drv, fake) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(150),
Timeout = TimeSpan.FromSeconds(1),
});
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for the first probe to complete.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (fake.ProbeCount == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
// Then wait for the event to actually arrive.
deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1);
while (transitions.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
transitions.Count.ShouldBeGreaterThanOrEqualTo(1);
transitions.TryDequeue(out var t).ShouldBeTrue();
t!.OldState.ShouldBe(HostState.Unknown);
t.NewState.ShouldBe(HostState.Running);
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Transport_failure_transitions_to_Stopped()
{
var (drv, fake) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(150),
Timeout = TimeSpan.FromSeconds(1),
});
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
fake.Reachable = false;
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
transitions.Select(t => t.NewState).ShouldContain(HostState.Stopped);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Recovery_transitions_Stopped_back_to_Running()
{
var (drv, fake) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(150),
Timeout = TimeSpan.FromSeconds(1),
});
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
fake.Reachable = false;
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
fake.Reachable = true;
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
// We expect at minimum: Unknown→Running, Running→Stopped, Stopped→Running.
transitions.Count.ShouldBeGreaterThanOrEqualTo(3);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Repeated_successful_probes_do_not_generate_duplicate_Running_events()
{
var (drv, _) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromSeconds(1),
});
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
await Task.Delay(500); // several more probe ticks, all successful — state shouldn't thrash
transitions.Count.ShouldBe(1); // only the initial Unknown→Running
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Disabled_probe_stays_Unknown_and_fires_no_events()
{
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(300);
transitions.Count.ShouldBe(0);
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Unknown);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Shutdown_stops_the_probe_loop()
{
var (drv, fake) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromSeconds(1),
});
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
var before = fake.ProbeCount;
await drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(400);
// A handful of in-flight ticks may complete after shutdown in a narrow race; the
// contract is that the loop stops scheduling new ones. Tolerate ≤1 extra.
(fake.ProbeCount - before).ShouldBeLessThanOrEqualTo(1);
}
private static async Task WaitForStateAsync(ModbusDriver drv, HostState expected, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (drv.GetHostStatuses()[0].State == expected) return;
await Task.Delay(25);
}
}
}

View File

@@ -0,0 +1,135 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #140 protocol-behavior knobs: MaxCoilsPerRead, UseFC15ForSingleCoilWrites,
/// UseFC16ForSingleRegisterWrites, DisableFC23. Coverage focuses on default behaviour
/// (no change from pre-#140) and the wire-FC selection when the knobs are flipped.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusProtocolOptionsTests
{
private sealed class CapturingTransport : IModbusTransport
{
public readonly List<byte[]> Sent = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
Sent.Add(pdu);
// Return a minimal valid response per FC. We zero-fill the data slot but size it
// correctly so the driver's bitmap walker doesn't IndexOutOfRange on chunked reads.
switch (pdu[0])
{
case 0x05: case 0x06: case 0x0F: case 0x10:
return Task.FromResult(pdu); // echo
case 0x01: case 0x02:
{
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;
return Task.FromResult(resp);
}
case 0x03: case 0x04:
{
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;
return Task.FromResult(resp);
}
default:
return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public void Defaults_Match_Historical_Behaviour()
{
var opts = new ModbusDriverOptions();
opts.MaxCoilsPerRead.ShouldBe((ushort)2000);
opts.UseFC15ForSingleCoilWrites.ShouldBeFalse();
opts.UseFC16ForSingleRegisterWrites.ShouldBeFalse();
opts.DisableFC23.ShouldBeFalse();
}
[Fact]
public async Task Single_Coil_Write_Uses_FC05_By_Default()
{
var fake = new CapturingTransport();
var tag = new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool);
var drv = new ModbusDriver(new ModbusDriverOptions { Host = "f", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } }, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None);
fake.Sent.Last()[0].ShouldBe((byte)0x05); // FC05 Write Single Coil
}
[Fact]
public async Task Single_Coil_Write_Uses_FC15_When_Forced()
{
var fake = new CapturingTransport();
var tag = new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool);
var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], UseFC15ForSingleCoilWrites = true, Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None);
fake.Sent.Last()[0].ShouldBe((byte)0x0F); // FC15 Write Multiple Coils
}
[Fact]
public async Task Single_Register_Write_Uses_FC06_By_Default()
{
var fake = new CapturingTransport();
var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var drv = new ModbusDriver(new ModbusDriverOptions { Host = "f", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } }, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None);
fake.Sent.Last()[0].ShouldBe((byte)0x06); // FC06 Write Single Register
}
[Fact]
public async Task Single_Register_Write_Uses_FC16_When_Forced()
{
var fake = new CapturingTransport();
var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], UseFC16ForSingleRegisterWrites = true, Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None);
fake.Sent.Last()[0].ShouldBe((byte)0x10); // FC16 Write Multiple Registers
}
[Fact]
public async Task Coil_Array_Read_Auto_Chunks_At_MaxCoilsPerRead()
{
var fake = new CapturingTransport();
// 2500 coils with cap 2000 → 2 reads (2000 + 500).
var tag = new ModbusTagDefinition("Big", ModbusRegion.Coils, 0, ModbusDataType.Bool, ArrayCount: 2500);
var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], MaxCoilsPerRead = 2000, Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Big"], CancellationToken.None);
// Ignore the probe FC03 from InitializeAsync; count only FC01 reads.
var fc01s = fake.Sent.Where(p => p[0] == 0x01).ToList();
fc01s.Count.ShouldBe(2);
// First chunk asks for 2000.
((ushort)((fc01s[0][3] << 8) | fc01s[0][4])).ShouldBe((ushort)2000);
// Second chunk asks for 500.
((ushort)((fc01s[1][3] << 8) | fc01s[1][4])).ShouldBe((ushort)500);
}
}

View File

@@ -0,0 +1,172 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #141 subscribe-side knobs: per-tag Deadband, driver-wide WriteOnChangeOnly.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusSubscribeOptionsTests
{
/// <summary>
/// Programmable transport: caller seeds a bank-of-registers value, each FC03 returns
/// the current value. Lets tests step the underlying register through a sequence and
/// observe how the deadband filter responds.
/// </summary>
private sealed class ProgrammableTransport : IModbusTransport
{
public ushort CurrentValue;
public int WritesSent;
public int FC06Count;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
switch (pdu[0])
{
case 0x03:
{
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)(CurrentValue >> 8);
resp[3 + i * 2] = (byte)(CurrentValue & 0xFF);
}
return Task.FromResult(resp);
}
case 0x06:
WritesSent++; FC06Count++;
CurrentValue = (ushort)((pdu[3] << 8) | pdu[4]);
return Task.FromResult(pdu);
default:
return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Deadband_Suppresses_SubThreshold_Changes()
{
var fake = new ProgrammableTransport();
var tag = new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, Deadband: 5.0);
var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
var publishes = new List<short>();
drv.OnDataChange += (_, e) => publishes.Add((short)e.Snapshot.Value!);
// First publish always passes (no baseline). Then step the value:
// 100 → 102 (delta 2 < 5, suppressed) → 106 (delta 6 ≥ 5, published) → 107 (delta 1, suppressed).
var sub = await drv.SubscribeAsync(["Temp"], TimeSpan.FromMilliseconds(50), CancellationToken.None);
try
{
fake.CurrentValue = 100;
await Task.Delay(150);
fake.CurrentValue = 102;
await Task.Delay(150);
fake.CurrentValue = 106;
await Task.Delay(150);
fake.CurrentValue = 107;
await Task.Delay(150);
}
finally
{
await drv.UnsubscribeAsync(sub, CancellationToken.None);
}
// Expect at most 2 distinct values surfaced (100 baseline + 106). The 102 and 107 should
// be suppressed by the deadband. Ordering can be flaky on slow CI so we assert the set,
// not the exact sequence.
publishes.ShouldContain((short)100);
publishes.ShouldContain((short)106);
publishes.ShouldNotContain((short)102);
publishes.ShouldNotContain((short)107);
}
[Fact]
public async Task Deadband_Null_Publishes_Every_Change()
{
var fake = new ProgrammableTransport();
var tag = new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16); // no deadband
var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
var publishes = new List<short>();
drv.OnDataChange += (_, e) => publishes.Add((short)e.Snapshot.Value!);
var sub = await drv.SubscribeAsync(["Temp"], TimeSpan.FromMilliseconds(50), CancellationToken.None);
try
{
fake.CurrentValue = 100; await Task.Delay(150);
fake.CurrentValue = 101; await Task.Delay(150); // tiny change still publishes
}
finally { await drv.UnsubscribeAsync(sub, CancellationToken.None); }
publishes.ShouldContain((short)100);
publishes.ShouldContain((short)101);
}
[Fact]
public async Task WriteOnChangeOnly_Suppresses_Identical_Repeated_Writes()
{
var fake = new ProgrammableTransport();
var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], WriteOnChangeOnly = true,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); // suppressed
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); // suppressed
await drv.WriteAsync([new WriteRequest("Sp", (short)43)], CancellationToken.None); // distinct
fake.WritesSent.ShouldBe(2, "two distinct values written; identical-value repeats suppressed");
}
[Fact]
public async Task WriteOnChangeOnly_Default_False_Always_Writes()
{
var fake = new ProgrammableTransport();
var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [tag],
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None);
fake.WritesSent.ShouldBe(3, "default false → every write goes to the wire");
}
[Fact]
public async Task WriteOnChangeOnly_Cache_Invalidated_By_Read_Divergence()
{
var fake = new ProgrammableTransport();
var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], WriteOnChangeOnly = true,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None);
fake.FC06Count.ShouldBe(1);
// External change at the PLC (panel writes 99). Read sees 99 → invalidates the cache.
fake.CurrentValue = 99;
var read = await drv.ReadAsync(["Sp"], CancellationToken.None);
read[0].Value.ShouldBe((short)99);
// Now writing 42 again should NOT be suppressed because the cache was invalidated.
await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None);
fake.FC06Count.ShouldBe(2, "post-divergence write not suppressed");
}
}

View File

@@ -0,0 +1,180 @@
using System.Collections.Concurrent;
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 ModbusSubscriptionTests
{
/// <summary>
/// Lightweight fake transport the subscription tests drive through — only the FC03
/// (Read Holding Registers) path is used. Mutating <see cref="HoldingRegisters"/>
/// between polls is how each test simulates a PLC value change.
/// </summary>
private sealed class FakeTransport : IModbusTransport
{
public readonly ushort[] HoldingRegisters = new ushort[256];
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] != 0x03) return Task.FromException<byte[]>(new NotSupportedException("FC not supported"));
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);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static (ModbusDriver drv, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
{
var fake = new FakeTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = tags };
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
}
[Fact]
public async Task Initial_poll_raises_OnDataChange_for_every_subscribed_tag()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
fake.HoldingRegisters[1] = 200;
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level", "Temp"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
events.Select(e => e.FullReference).ShouldContain("Level");
events.Select(e => e.FullReference).ShouldContain("Temp");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
[Fact]
public async Task Unchanged_values_do_not_raise_after_initial_poll()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await Task.Delay(500); // ~5 poll cycles at 100ms, value stable the whole time
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBe(1); // only the initial-data push, no change events after
}
[Fact]
public async Task Value_change_between_polls_raises_OnDataChange()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
fake.HoldingRegisters[0] = 200; // simulate PLC update
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBeGreaterThanOrEqualTo(2);
events.Last().Snapshot.Value.ShouldBe((short)200);
}
[Fact]
public async Task Unsubscribe_stops_the_polling_loop()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
var countAfterUnsub = events.Count;
fake.HoldingRegisters[0] = 999; // would trigger a change if still polling
await Task.Delay(400);
events.Count.ShouldBe(countAfterUnsub);
}
[Fact]
public async Task SubscribeAsync_floors_intervals_below_100ms()
{
var (drv, _) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
// 10ms requested — implementation floors to 100ms. We verify indirectly: over 300ms, a
// 10ms interval would produce many more events than a 100ms interval would on a stable
// value. Since the value is unchanged, we only expect the initial-data push (1 event).
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(10), CancellationToken.None);
await Task.Delay(300);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBe(1);
}
[Fact]
public async Task Multiple_subscriptions_fire_independently()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
var eventsA = new ConcurrentQueue<DataChangeEventArgs>();
var eventsB = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) =>
{
if (e.FullReference == "A") eventsA.Enqueue(e);
else if (e.FullReference == "B") eventsB.Enqueue(e);
};
var ha = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
var hb = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForCountAsync(eventsA, 1, TimeSpan.FromSeconds(1));
await WaitForCountAsync(eventsB, 1, TimeSpan.FromSeconds(1));
await drv.UnsubscribeAsync(ha, CancellationToken.None);
var aCount = eventsA.Count;
fake.HoldingRegisters[1] = 77; // only B should pick this up
await WaitForCountAsync(eventsB, 2, TimeSpan.FromSeconds(2));
eventsA.Count.ShouldBe(aCount); // unchanged since unsubscribe
eventsB.Count.ShouldBeGreaterThanOrEqualTo(2);
await drv.UnsubscribeAsync(hb, CancellationToken.None);
}
private static async Task WaitForCountAsync<T>(ConcurrentQueue<T> q, int target, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (q.Count < target && DateTime.UtcNow < deadline)
await Task.Delay(25);
}
}

View File

@@ -0,0 +1,146 @@
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Exercises <see cref="ModbusTcpTransport"/> against a real TCP listener that can close
/// its socket mid-session on demand. Verifies the PR 53 reconnect-on-drop behavior: after
/// the "first" socket is forcibly torn down, the next SendAsync must re-establish the
/// connection and complete the PDU without bubbling an error to the caller.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusTcpReconnectTests
{
/// <summary>
/// Minimal in-process Modbus-TCP stub. Accepts one TCP connection at a time, reads an
/// MBAP + PDU, replies with a canned FC03 response echoing the request quantity of
/// zeroed bytes, then optionally closes the socket to simulate a NAT/firewall drop.
/// </summary>
private sealed class FlakeyModbusServer : IAsyncDisposable
{
private readonly TcpListener _listener;
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
public int DropAfterNTransactions { get; set; } = int.MaxValue;
private readonly CancellationTokenSource _stop = new();
private int _txCount;
public FlakeyModbusServer()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
_ = Task.Run(AcceptLoopAsync);
}
private async Task AcceptLoopAsync()
{
while (!_stop.IsCancellationRequested)
{
TcpClient? client = null;
try { client = await _listener.AcceptTcpClientAsync(_stop.Token); }
catch { return; }
_ = Task.Run(() => ServeAsync(client!));
}
}
private async Task ServeAsync(TcpClient client)
{
try
{
using var _ = client;
var stream = client.GetStream();
while (!_stop.IsCancellationRequested && client.Connected)
{
var header = new byte[7];
if (!await ReadExactly(stream, header)) return;
var len = (ushort)((header[4] << 8) | header[5]);
var pdu = new byte[len - 1];
if (!await ReadExactly(stream, pdu)) return;
var fc = pdu[0];
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var respPdu = new byte[2 + qty * 2];
respPdu[0] = fc;
respPdu[1] = (byte)(qty * 2);
// data bytes stay 0
var respLen = (ushort)(1 + respPdu.Length);
var adu = new byte[7 + respPdu.Length];
adu[0] = header[0]; adu[1] = header[1];
adu[4] = (byte)(respLen >> 8); adu[5] = (byte)(respLen & 0xFF);
adu[6] = header[6];
Buffer.BlockCopy(respPdu, 0, adu, 7, respPdu.Length);
await stream.WriteAsync(adu);
await stream.FlushAsync();
_txCount++;
if (_txCount >= DropAfterNTransactions)
{
// Simulate NAT/firewall silent close: slam the socket without a
// protocol-level goodbye, which is what DL260 + an intermediate
// middlebox would look like from the client's perspective.
client.Client.Shutdown(SocketShutdown.Both);
client.Close();
return;
}
}
}
catch { /* best-effort */ }
}
private static async Task<bool> ReadExactly(NetworkStream s, byte[] buf)
{
var read = 0;
while (read < buf.Length)
{
var n = await s.ReadAsync(buf.AsMemory(read));
if (n == 0) return false;
read += n;
}
return true;
}
public async ValueTask DisposeAsync()
{
_stop.Cancel();
_listener.Stop();
await Task.CompletedTask;
}
}
[Fact]
public async Task Transport_recovers_from_mid_session_drop_and_retries_successfully()
{
await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 };
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: true);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
// First transaction succeeds; server then closes the socket.
var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 };
var first = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
first[0].ShouldBe((byte)0x03);
// Second transaction: the connection is dead, but auto-reconnect must transparently
// spin up a new socket, resend, and produce a valid response. Before PR 53 this would
// surface as EndOfStreamException / IOException to the caller.
var second = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
second[0].ShouldBe((byte)0x03);
}
[Fact]
public async Task Transport_without_AutoReconnect_propagates_drop_to_caller()
{
await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 };
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 };
_ = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
await Should.ThrowAsync<Exception>(async () =>
await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken));
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>