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:
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user